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