Added the catalogue models and seed to support phase 2 of the POC prototype in the Persistence package.
This commit is contained in:
1 parent
ea89ea892a
commit
09c7b8a1a7
10 files changed
+238
-21
No files matched your search
@@ -8,6 +8,7 @@ The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based da
|
|||||||
| Service | `Service`, which builds the `Fluent` service configured for the chosen driver |
|
| Service | `Service`, which builds the `Fluent` service configured for the chosen driver |
|
||||||
| Migrations | `PrepareDB`, the single registrar declaring every migration, in order |
|
| Migrations | `PrepareDB`, the single registrar declaring every migration, in order |
|
||||||
| Readiness | `Probe`, which reports whether the default database answers a `SELECT 1` within a deadline |
|
| Readiness | `Probe`, which reports whether the default database answers a `SELECT 1` within a deadline |
|
||||||
|
| Accounts | `AccountRepository`, which finds an account by email or creates it, returning a `User` snapshot; `CreateUser` seeds the prototype's demo accounts |
|
||||||
| Catalogue | `CatalogueRepository`, returning `Shop` and `Listing` snapshots narrowed and ordered by a `ListingFilter`; `SeedCatalogue` fills it with the prototype's demo data |
|
| Catalogue | `CatalogueRepository`, returning `Shop` and `Listing` snapshots narrowed and ordered by a `ListingFilter`; `SeedCatalogue` fills it with the prototype's demo data |
|
||||||
|
|
||||||
## Design rules
|
## Design rules
|
||||||
@@ -29,12 +30,12 @@ Sources/
|
|||||||
├── Public/
|
├── Public/
|
||||||
│ ├── Enumerations/ Driver, TLS
|
│ ├── Enumerations/ Driver, TLS
|
||||||
│ ├── Methods/ Service, PrepareDB, Probe
|
│ ├── Methods/ Service, PrepareDB, Probe
|
||||||
│ ├── Models/ Shop, Listing, ListingFilter
|
│ ├── Models/ Shop, Listing, ListingFilter, User
|
||||||
│ ├── Repositories/ CatalogueRepository
|
│ ├── Repositories/ CatalogueRepository, AccountRepository
|
||||||
│ └── Types/ Configuration
|
│ └── Types/ Configuration
|
||||||
└── Internal/
|
└── Internal/
|
||||||
├── Migrations/ CreateShop, CreateListing, SeedCatalogue
|
├── Migrations/ CreateShop, CreateListing, SeedCatalogue, CreateUser
|
||||||
├── Models/ ShopRecord, ListingRecord
|
├── Models/ ShopRecord, ListingRecord, UserRecord
|
||||||
└── Seeds/ Seed
|
└── Seeds/ Seed
|
||||||
Tests/
|
Tests/
|
||||||
├── Cases/ the test suites, mirroring the Sources/ layout
|
├── Cases/ the test suites, mirroring the Sources/ layout
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import FluentKit
|
||||||
|
|
||||||
|
/// Creates and drops the `users` table backing ``UserRecord``, seeded with the prototype's demo accounts.
|
||||||
|
struct CreateUser: AsyncMigration {
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
func prepare(on database: Database) async throws {
|
||||||
|
try await database.schema(UserRecord.schema)
|
||||||
|
.id()
|
||||||
|
.field("email", .string, .required)
|
||||||
|
.field("display_name", .string, .required)
|
||||||
|
.unique(on: "email")
|
||||||
|
.create()
|
||||||
|
|
||||||
|
for account in Seed.accounts {
|
||||||
|
try await UserRecord(email: account.email, displayName: account.displayName).save(on: database)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func revert(on database: Database) async throws {
|
||||||
|
try await database.schema(UserRecord.schema)
|
||||||
|
.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import FluentKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A FluentKit model of a single `users` row.
|
||||||
|
final class UserRecord: Model, @unchecked Sendable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
static let schema = "users"
|
||||||
|
|
||||||
|
@ID(key: .id)
|
||||||
|
var id: UUID?
|
||||||
|
|
||||||
|
@Field(key: "email")
|
||||||
|
var email: String
|
||||||
|
|
||||||
|
@Field(key: "display_name")
|
||||||
|
var displayName: String
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
/// Creates a record with the given values.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - email: the account's normalized email address.
|
||||||
|
/// - displayName: the name other people see.
|
||||||
|
init(
|
||||||
|
email: String,
|
||||||
|
displayName: String
|
||||||
|
) {
|
||||||
|
self.email = email
|
||||||
|
self.displayName = displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Extensions
|
||||||
|
|
||||||
|
extension UserRecord {
|
||||||
|
|
||||||
|
// MARK: Computed
|
||||||
|
|
||||||
|
/// The `Sendable` snapshot of the saved record.
|
||||||
|
var snapshot: User {
|
||||||
|
get throws {
|
||||||
|
.init(
|
||||||
|
id: try requireID(),
|
||||||
|
email: email,
|
||||||
|
displayName: displayName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -5,6 +5,12 @@ enum Seed {
|
|||||||
|
|
||||||
// MARK: Constants
|
// MARK: Constants
|
||||||
|
|
||||||
|
/// The demo accounts: the buyer the stubbed passkey signs in, then a seller.
|
||||||
|
static let accounts: [(email: String, displayName: String)] = [
|
||||||
|
("mara.v@example.com", "Mara Visser"),
|
||||||
|
("joost@kestrelrow.example", "Joost de Wit"),
|
||||||
|
]
|
||||||
|
|
||||||
/// The demo listings, newest first.
|
/// The demo listings, newest first.
|
||||||
static let listings: [Listing] = [
|
static let listings: [Listing] = [
|
||||||
single(
|
single(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public struct PrepareDB: Sendable {
|
|||||||
CreateShop(),
|
CreateShop(),
|
||||||
CreateListing(),
|
CreateListing(),
|
||||||
SeedCatalogue(),
|
SeedCatalogue(),
|
||||||
|
CreateUser(),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A `Sendable` snapshot of an account.
|
||||||
|
public struct User: Sendable, Equatable {
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
/// The account's identifier.
|
||||||
|
public let id: UUID
|
||||||
|
/// The account's normalized email address.
|
||||||
|
public let email: String
|
||||||
|
/// The name other people see.
|
||||||
|
public let displayName: String
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: UUID,
|
||||||
|
email: String,
|
||||||
|
displayName: String
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.email = email
|
||||||
|
self.displayName = displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import FluentKit
|
||||||
|
import HummingbirdFluent
|
||||||
|
|
||||||
|
/// Reads and creates accounts through the default database.
|
||||||
|
public struct AccountRepository: Sendable {
|
||||||
|
|
||||||
|
// MARK: Constants
|
||||||
|
|
||||||
|
/// The email of the demo buyer the prototype's stubbed passkey signs in.
|
||||||
|
public static let demoEmail = Seed.accounts[0].email
|
||||||
|
|
||||||
|
// MARK: Properties
|
||||||
|
|
||||||
|
private let fluent: Fluent
|
||||||
|
|
||||||
|
// MARK: Initializers
|
||||||
|
|
||||||
|
/// Creates a repository backed by the given `Fluent` service.
|
||||||
|
/// - Parameter fluent: the service whose default database the repository operates on.
|
||||||
|
public init(
|
||||||
|
fluent: Fluent
|
||||||
|
) {
|
||||||
|
self.fluent = fluent
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Methods
|
||||||
|
|
||||||
|
/// Fetches the account with the given email, creating it when there is none: one step both signs in and joins.
|
||||||
|
///
|
||||||
|
/// A new account is named after the email's local part until its owner picks a display name.
|
||||||
|
/// - Parameter email: the account's normalized email address.
|
||||||
|
/// - Returns: the account, existing or new.
|
||||||
|
public func account(
|
||||||
|
email: String
|
||||||
|
) async throws -> User {
|
||||||
|
if let record = try await UserRecord.query(on: fluent.db())
|
||||||
|
.filter(\.$email == email)
|
||||||
|
.first()
|
||||||
|
{
|
||||||
|
return try record.snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
let record = UserRecord(
|
||||||
|
email: email,
|
||||||
|
displayName: String(email.prefix { $0 != "@" })
|
||||||
|
)
|
||||||
|
|
||||||
|
try await record.save(on: fluent.db())
|
||||||
|
|
||||||
|
return try record.snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import Persistence
|
||||||
|
|
||||||
|
@Suite(
|
||||||
|
"AccountRepository repository",
|
||||||
|
.tags(.repository)
|
||||||
|
)
|
||||||
|
struct AccountRepositoryTests {
|
||||||
|
|
||||||
|
// MARK: Methods tests
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `finds the seeded demo buyer`() async throws {
|
||||||
|
try await withDatabase { fluent in
|
||||||
|
let user = try await AccountRepository(fluent: fluent).account(email: AccountRepository.demoEmail)
|
||||||
|
|
||||||
|
#expect(user.email == "mara.v@example.com")
|
||||||
|
#expect(user.displayName == "Mara Visser")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func `joins an unknown email once, named after its local part`() async throws {
|
||||||
|
try await withDatabase { fluent in
|
||||||
|
let repository = AccountRepository(fluent: fluent)
|
||||||
|
let joined = try await repository.account(email: "sam@example.com")
|
||||||
|
let returning = try await repository.account(email: "sam@example.com")
|
||||||
|
|
||||||
|
#expect(joined.displayName == "sam")
|
||||||
|
#expect(returning == joined)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
+3
-17
@@ -78,27 +78,13 @@ struct CatalogueRepositoryTests {
|
|||||||
|
|
||||||
private extension CatalogueRepositoryTests {
|
private extension CatalogueRepositoryTests {
|
||||||
|
|
||||||
/// Runs the body against a migrated, seeded in-memory catalogue, shutting the pool down after.
|
/// Runs the body against a migrated, seeded in-memory catalogue.
|
||||||
func withCatalogue(
|
func withCatalogue(
|
||||||
_ body: (CatalogueRepository) async throws -> Void
|
_ body: (CatalogueRepository) async throws -> Void
|
||||||
) async throws {
|
) async throws {
|
||||||
let fluent = try Service(
|
try await withDatabase {
|
||||||
driver: .inMemory,
|
try await body(.init(fluent: $0))
|
||||||
logger: Logger(label: "test")
|
|
||||||
)()
|
|
||||||
|
|
||||||
do {
|
|
||||||
await PrepareDB()(for: fluent)
|
|
||||||
|
|
||||||
try await fluent.migrate()
|
|
||||||
try await body(.init(fluent: fluent))
|
|
||||||
} catch {
|
|
||||||
try? await fluent.shutdown()
|
|
||||||
|
|
||||||
throw error
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try await fluent.shutdown()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import HummingbirdFluent
|
||||||
|
import Logging
|
||||||
|
|
||||||
|
@testable import Persistence
|
||||||
|
|
||||||
|
/// Runs the body against a migrated, seeded in-memory database, shutting the pool down after — even on failure, or the pool asserts on `deinit`.
|
||||||
|
func withDatabase(
|
||||||
|
_ body: (Fluent) async throws -> Void
|
||||||
|
) async throws {
|
||||||
|
let fluent = try Service(
|
||||||
|
driver: .inMemory,
|
||||||
|
logger: Logger(label: "test")
|
||||||
|
)()
|
||||||
|
|
||||||
|
do {
|
||||||
|
await PrepareDB()(for: fluent)
|
||||||
|
|
||||||
|
try await fluent.migrate()
|
||||||
|
try await body(fluent)
|
||||||
|
} catch {
|
||||||
|
try? await fluent.shutdown()
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
try await fluent.shutdown()
|
||||||
|
}
|
||||||
Reference in new issue
Block a user