feat(01-03): create Repository pattern with typed errors
- Add User domain model (summer_users table mapping) - Add RepositoryError ADT (NotFound, Conflict, ValidationError, DatabaseError) - Implement UserRepository trait with CRUD operations - Add UserRepositoryLive using Quill with compile-time SQL validation - Handle SQL exceptions with refineOrDie for typed error channel
This commit is contained in:
26
summercms/src/model/User.scala
Normal file
26
summercms/src/model/User.scala
Normal file
@@ -0,0 +1,26 @@
|
||||
package model
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
/** User domain model
|
||||
*
|
||||
* Represents a registered user in the system. Maps to the summer_users table.
|
||||
*
|
||||
* @param id
|
||||
* Auto-generated primary key
|
||||
* @param email
|
||||
* Unique email address, used for login
|
||||
* @param passwordHash
|
||||
* Bcrypt-hashed password (never store plain text)
|
||||
* @param createdAt
|
||||
* Timestamp when user was created
|
||||
* @param updatedAt
|
||||
* Timestamp of last update
|
||||
*/
|
||||
case class User(
|
||||
id: Long,
|
||||
email: String,
|
||||
passwordHash: String,
|
||||
createdAt: Instant,
|
||||
updatedAt: Instant
|
||||
)
|
||||
39
summercms/src/repository/RepositoryError.scala
Normal file
39
summercms/src/repository/RepositoryError.scala
Normal file
@@ -0,0 +1,39 @@
|
||||
package repository
|
||||
|
||||
/** Typed error ADT for repository operations
|
||||
*
|
||||
* Provides compile-time checked errors for all data access operations. Each
|
||||
* error type includes context for proper error handling and user-friendly
|
||||
* messages.
|
||||
*
|
||||
* Usage:
|
||||
* {{{
|
||||
* def findUser(id: Long): IO[RepositoryError, User] = ???
|
||||
*
|
||||
* // Handle specific error types
|
||||
* result.catchSome {
|
||||
* case RepositoryError.NotFound(entity, id) =>
|
||||
* ZIO.fail(HttpError.NotFound(s"$entity $id not found"))
|
||||
* }
|
||||
* }}}
|
||||
*/
|
||||
sealed trait RepositoryError
|
||||
|
||||
object RepositoryError:
|
||||
|
||||
/** Entity with given ID was not found */
|
||||
case class NotFound(entity: String, id: Long) extends RepositoryError
|
||||
|
||||
/** Entity with given field value was not found */
|
||||
case class NotFoundByField(entity: String, field: String, value: String)
|
||||
extends RepositoryError
|
||||
|
||||
/** Operation would violate uniqueness constraint */
|
||||
case class Conflict(entity: String, message: String) extends RepositoryError
|
||||
|
||||
/** Input validation failed */
|
||||
case class ValidationError(errors: List[String]) extends RepositoryError
|
||||
|
||||
/** Database-level error occurred */
|
||||
case class DatabaseError(message: String, cause: Option[Throwable] = None)
|
||||
extends RepositoryError
|
||||
172
summercms/src/repository/UserRepository.scala
Normal file
172
summercms/src/repository/UserRepository.scala
Normal file
@@ -0,0 +1,172 @@
|
||||
package repository
|
||||
|
||||
import io.getquill.*
|
||||
import io.getquill.jdbczio.Quill
|
||||
import model.User
|
||||
import zio.*
|
||||
|
||||
import java.time.Instant
|
||||
|
||||
/** User repository trait defining CRUD operations
|
||||
*
|
||||
* All methods return typed IO with RepositoryError for proper error handling.
|
||||
* Implementations use Quill for compile-time SQL validation.
|
||||
*
|
||||
* Usage:
|
||||
* {{{
|
||||
* ZIO.serviceWithZIO[UserRepository](_.findById(1L))
|
||||
* .provide(UserRepository.live, QuillContext.quillLive)
|
||||
* }}}
|
||||
*/
|
||||
trait UserRepository:
|
||||
/** Find user by ID
|
||||
*
|
||||
* @param id
|
||||
* User ID
|
||||
* @return
|
||||
* Some(user) if found, None if not found
|
||||
*/
|
||||
def findById(id: Long): IO[RepositoryError, Option[User]]
|
||||
|
||||
/** Find user by email address
|
||||
*
|
||||
* @param email
|
||||
* Email to search for (case-sensitive)
|
||||
* @return
|
||||
* Some(user) if found, None if not found
|
||||
*/
|
||||
def findByEmail(email: String): IO[RepositoryError, Option[User]]
|
||||
|
||||
/** Create new user
|
||||
*
|
||||
* @param email
|
||||
* Unique email address
|
||||
* @param passwordHash
|
||||
* Pre-hashed password (use bcrypt)
|
||||
* @return
|
||||
* Created user with generated ID
|
||||
* @throws RepositoryError.Conflict
|
||||
* if email already exists
|
||||
*/
|
||||
def create(email: String, passwordHash: String): IO[RepositoryError, User]
|
||||
|
||||
/** Update existing user
|
||||
*
|
||||
* @param user
|
||||
* User with updated fields (ID must exist)
|
||||
* @return
|
||||
* Updated user
|
||||
* @throws RepositoryError.NotFound
|
||||
* if user doesn't exist
|
||||
*/
|
||||
def update(user: User): IO[RepositoryError, User]
|
||||
|
||||
/** Delete user by ID
|
||||
*
|
||||
* @param id
|
||||
* User ID to delete
|
||||
* @return
|
||||
* Unit on success
|
||||
* @throws RepositoryError.NotFound
|
||||
* if user doesn't exist
|
||||
*/
|
||||
def delete(id: Long): IO[RepositoryError, Unit]
|
||||
|
||||
object UserRepository:
|
||||
/** Live implementation layer requiring Quill.Postgres
|
||||
*
|
||||
* Provides UserRepository backed by PostgreSQL via Quill.
|
||||
*/
|
||||
val live: ZLayer[Quill.Postgres[SnakeCase], Nothing, UserRepository] =
|
||||
ZLayer.fromFunction(UserRepositoryLive(_))
|
||||
|
||||
// Accessor methods for ZIO service pattern
|
||||
def findById(id: Long): ZIO[UserRepository, RepositoryError, Option[User]] =
|
||||
ZIO.serviceWithZIO(_.findById(id))
|
||||
|
||||
def findByEmail(
|
||||
email: String
|
||||
): ZIO[UserRepository, RepositoryError, Option[User]] =
|
||||
ZIO.serviceWithZIO(_.findByEmail(email))
|
||||
|
||||
def create(
|
||||
email: String,
|
||||
passwordHash: String
|
||||
): ZIO[UserRepository, RepositoryError, User] =
|
||||
ZIO.serviceWithZIO(_.create(email, passwordHash))
|
||||
|
||||
def update(user: User): ZIO[UserRepository, RepositoryError, User] =
|
||||
ZIO.serviceWithZIO(_.update(user))
|
||||
|
||||
def delete(id: Long): ZIO[UserRepository, RepositoryError, Unit] =
|
||||
ZIO.serviceWithZIO(_.delete(id))
|
||||
|
||||
/** Live UserRepository implementation using Quill
|
||||
*
|
||||
* Provides compile-time validated SQL queries against PostgreSQL. Handles SQL
|
||||
* exceptions and converts them to typed RepositoryError values.
|
||||
*
|
||||
* Table mapping: User -> summer_users (snake_case columns)
|
||||
*
|
||||
* @param quill
|
||||
* Quill PostgreSQL context with SnakeCase naming
|
||||
*/
|
||||
class UserRepositoryLive(quill: Quill.Postgres[SnakeCase])
|
||||
extends UserRepository:
|
||||
import quill.*
|
||||
|
||||
/** Table schema mapping to summer_users table */
|
||||
inline def users = quote(querySchema[User]("summer_users"))
|
||||
|
||||
override def findById(id: Long): IO[RepositoryError, Option[User]] =
|
||||
run(users.filter(_.id == lift(id)))
|
||||
.map(_.headOption)
|
||||
.refineOrDie { case e: java.sql.SQLException =>
|
||||
RepositoryError.DatabaseError(e.getMessage, Some(e))
|
||||
}
|
||||
|
||||
override def findByEmail(email: String): IO[RepositoryError, Option[User]] =
|
||||
run(users.filter(_.email == lift(email)))
|
||||
.map(_.headOption)
|
||||
.refineOrDie { case e: java.sql.SQLException =>
|
||||
RepositoryError.DatabaseError(e.getMessage, Some(e))
|
||||
}
|
||||
|
||||
override def create(
|
||||
email: String,
|
||||
passwordHash: String
|
||||
): IO[RepositoryError, User] =
|
||||
val now = Instant.now()
|
||||
val newUser = User(0, email, passwordHash, now, now)
|
||||
run(users.insertValue(lift(newUser)).returningGenerated(_.id))
|
||||
.map(generatedId => newUser.copy(id = generatedId))
|
||||
.refineOrDie {
|
||||
case e: java.sql.SQLException if e.getSQLState == "23505" =>
|
||||
RepositoryError.Conflict("User", s"Email $email already exists")
|
||||
case e: java.sql.SQLException =>
|
||||
RepositoryError.DatabaseError(e.getMessage, Some(e))
|
||||
}
|
||||
|
||||
override def update(user: User): IO[RepositoryError, User] =
|
||||
val updatedUser = user.copy(updatedAt = Instant.now())
|
||||
run(
|
||||
users
|
||||
.filter(_.id == lift(user.id))
|
||||
.updateValue(lift(updatedUser))
|
||||
).refineOrDie { case e: java.sql.SQLException =>
|
||||
RepositoryError.DatabaseError(e.getMessage, Some(e))
|
||||
}.flatMap { rowsUpdated =>
|
||||
if rowsUpdated == 0 then
|
||||
ZIO.fail(RepositoryError.NotFound("User", user.id))
|
||||
else ZIO.succeed(updatedUser)
|
||||
}
|
||||
|
||||
override def delete(id: Long): IO[RepositoryError, Unit] =
|
||||
run(users.filter(_.id == lift(id)).delete)
|
||||
.refineOrDie { case e: java.sql.SQLException =>
|
||||
RepositoryError.DatabaseError(e.getMessage, Some(e))
|
||||
}
|
||||
.flatMap { rowsDeleted =>
|
||||
if rowsDeleted == 0 then ZIO.fail(RepositoryError.NotFound("User", id))
|
||||
else ZIO.unit
|
||||
}
|
||||
Reference in New Issue
Block a user