From 6d7ab4bcba382e7646d7f2fd34d7ada68cc86bc0 Mon Sep 17 00:00:00 2001 From: Javier Cicchelli Date: Mon, 21 Sep 2026 11:16:13 +0200 Subject: [PATCH] Added the catalogue models and seed to support phase 1 of the POC prototype in the Persistence package. --- Packages/Persistence/README.md | 17 +- .../Migrations/CreateExampleRecord.swift | 28 -- .../Internal/Migrations/CreateListing.swift | 46 +++ .../Internal/Migrations/CreateShop.swift | 33 ++ .../Internal/Migrations/SeedCatalogue.swift | 27 ++ .../Internal/Models/ExampleRecord.swift | 45 --- .../Internal/Models/ListingRecord.swift | 176 +++++++++++ .../Sources/Internal/Models/ShopRecord.swift | 107 +++++++ .../Repositories/ExampleRepository.swift | 63 ---- .../Sources/Internal/Seeds/Seed.swift | 283 ++++++++++++++++++ .../Sources/Public/Methods/PrepareDB.swift | 4 +- .../Sources/Public/Models/Listing.swift | 159 ++++++++++ .../Sources/Public/Models/ListingFilter.swift | 197 ++++++++++++ .../Sources/Public/Models/Shop.swift | 65 ++++ .../Repositories/CatalogueRepository.swift | 72 +++++ .../Cases/Public/Methods/ServiceTests.swift | 11 +- .../Public/Models/ListingFilterTests.swift | 91 ++++++ .../CatalogueRepositoryTests.swift | 104 +++++++ .../Utils/Extensions/Tag+Constants.swift | 4 + 19 files changed, 1381 insertions(+), 151 deletions(-) delete mode 100644 Packages/Persistence/Sources/Internal/Migrations/CreateExampleRecord.swift create mode 100644 Packages/Persistence/Sources/Internal/Migrations/CreateListing.swift create mode 100644 Packages/Persistence/Sources/Internal/Migrations/CreateShop.swift create mode 100644 Packages/Persistence/Sources/Internal/Migrations/SeedCatalogue.swift delete mode 100644 Packages/Persistence/Sources/Internal/Models/ExampleRecord.swift create mode 100644 Packages/Persistence/Sources/Internal/Models/ListingRecord.swift create mode 100644 Packages/Persistence/Sources/Internal/Models/ShopRecord.swift delete mode 100644 Packages/Persistence/Sources/Internal/Repositories/ExampleRepository.swift create mode 100644 Packages/Persistence/Sources/Internal/Seeds/Seed.swift create mode 100644 Packages/Persistence/Sources/Public/Models/Listing.swift create mode 100644 Packages/Persistence/Sources/Public/Models/ListingFilter.swift create mode 100644 Packages/Persistence/Sources/Public/Models/Shop.swift create mode 100644 Packages/Persistence/Sources/Public/Repositories/CatalogueRepository.swift create mode 100644 Packages/Persistence/Tests/Cases/Public/Models/ListingFilterTests.swift create mode 100644 Packages/Persistence/Tests/Cases/Public/Repositories/CatalogueRepositoryTests.swift diff --git a/Packages/Persistence/README.md b/Packages/Persistence/README.md index 977375c..7a0a8e9 100644 --- a/Packages/Persistence/README.md +++ b/Packages/Persistence/README.md @@ -8,13 +8,14 @@ The [Fluent](https://github.com/hummingbird-project/hummingbird-fluent)-based da | Service | `Service`, which builds the `Fluent` service configured for the chosen driver | | 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 | -| Scaffolding | The internal `ExampleRecord` and `CreateExampleRecord`, and the public `Example` snapshot and `ExampleRepository` — the model → migration → repository pattern, to be replaced by the first real domain model | +| Catalogue | `CatalogueRepository`, returning `Shop` and `Listing` snapshots narrowed and ordered by a `ListingFilter`; `SeedCatalogue` fills it with the prototype's demo data | ## Design rules - **The package reads no configuration.** The executable maps its `database.*` keys onto a `Driver` and hands it over; connection values arrive as plain data. The Website service's `ConfigReader+Properties` has the mapping. - **One default database.** `Service` registers the selected backend as the *default* database, so repositories resolve it with a plain `fluent.db()` and stay agnostic of which driver is in use. -- **Migrations are declared once, and append-only.** `PrepareDB` registers every migration in the order it must run; alter the schema by adding a migration, never by editing one that has already run. Registering does not apply them: the in-memory backend migrates on startup, while a shared PostgreSQL database is migrated out of band (the executable's migrate-and-exit mode), so booting instances never race. -- **Models never cross a concurrency boundary.** FluentKit models are mutable reference types, so they stay internal to the package and repositories return `Sendable` value-type snapshots (e.g. `Example`) instead. +- **Migrations are declared once, and append-only.** `PrepareDB` registers every migration in the order it must run; alter the schema by adding a migration, never by editing one that has already run. Registering does not apply them: the in-memory backend is migrated at launch, while a shared PostgreSQL database is migrated out of band (the executable's migrate-and-exit mode), so booting instances never race. +- **Models never cross a concurrency boundary.** FluentKit models are mutable reference types, so they stay internal to the package and repositories return `Sendable` value-type snapshots (e.g. `Listing`) instead. +- **Prototype shortcuts.** `CatalogueRepository` filters in memory, the catalogue fields (series, issue, publisher) are folded into the listing, and the demo data ships as a migration. All three go once real data arrives. - **Readiness never throws, and never hangs.** `Probe` runs a schema-independent `SELECT 1`, maps every failure to `false`, and races the query against a deadline (2 seconds by default) — a hanging database yields a prompt "not ready" instead of a stalled endpoint. - **A single connection for the in-memory store.** The SQLite backend is capped at one connection per event loop, so every query reaches the same in-memory database instead of each pooled connection getting a private one. - **Method structs.** `Service`, `PrepareDB`, and `Probe` hold their lifetime-fixed configuration in `init` and take only per-call inputs in `callAsFunction`. @@ -28,19 +29,19 @@ Sources/ ├── Public/ │ ├── Enumerations/ Driver, TLS │ ├── Methods/ Service, PrepareDB, Probe +│ ├── Models/ Shop, Listing, ListingFilter +│ ├── Repositories/ CatalogueRepository │ └── Types/ Configuration └── Internal/ - ├── Migrations/ CreateExampleRecord - ├── Models/ ExampleRecord - └── Repositories/ ExampleRepository (returning the Example snapshot) + ├── Migrations/ CreateShop, CreateListing, SeedCatalogue + ├── Models/ ShopRecord, ListingRecord + └── Seeds/ Seed Tests/ ├── Cases/ the test suites, mirroring the Sources/ layout └── Utils/ the NotSQL* fakes backing the probe's non-SQL-database case, the plaintext-only and silent fake PostgreSQL servers, and the suite Tag constants ``` -> **Exception:** `Example` and `ExampleRepository` are `public` despite sitting under `Internal/`, so they are API a service can call. Move them to `Public/Models` and `Public/Repositories` when the first real domain model replaces them. - ## Testing The suite runs against the in-memory backend by default, so `swift test` needs no database. The PostgreSQL integration test is skipped unless `POSTGRES_TEST_HOST` points at one (with optional `POSTGRES_TEST_PORT`, `POSTGRES_TEST_NAME`, `POSTGRES_TEST_USERNAME`, and `POSTGRES_TEST_PASSWORD`); it reverts its migrations afterwards, leaving a shared database as it was found: ```sh diff --git a/Packages/Persistence/Sources/Internal/Migrations/CreateExampleRecord.swift b/Packages/Persistence/Sources/Internal/Migrations/CreateExampleRecord.swift deleted file mode 100644 index ef06d99..0000000 --- a/Packages/Persistence/Sources/Internal/Migrations/CreateExampleRecord.swift +++ /dev/null @@ -1,28 +0,0 @@ -import FluentKit - -/// Creates and drops the `example_records` table backing ``ExampleRecord``. -/// -/// Reference scaffolding paired with ``ExampleRecord``; replace it with the first real migration once a -/// domain model is defined. Migrations are append-only in production — add a new migration to alter the -/// schema rather than editing one that has already run. -struct CreateExampleRecord: AsyncMigration { - - // MARK: Methods - - /// Creates the `example_records` table with an `id` primary key and a required `name` column. - /// - Parameter database: the database the schema change is applied to. - func prepare(on database: Database) async throws { - try await database.schema(ExampleRecord.schema) - .id() - .field("name", .string, .required) - .create() - } - - /// Drops the `example_records` table, reverting ``prepare(on:)``. - /// - Parameter database: the database the schema change is applied to. - func revert(on database: Database) async throws { - try await database.schema(ExampleRecord.schema) - .delete() - } - -} diff --git a/Packages/Persistence/Sources/Internal/Migrations/CreateListing.swift b/Packages/Persistence/Sources/Internal/Migrations/CreateListing.swift new file mode 100644 index 0000000..a00edba --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Migrations/CreateListing.swift @@ -0,0 +1,46 @@ +import FluentKit + +/// Creates and drops the `listings` table backing ``ListingRecord``. +struct CreateListing: AsyncMigration { + + // MARK: Methods + + func prepare(on database: Database) async throws { + try await database.schema(ListingRecord.schema) + .id() + .field("shop_id", .uuid, .required, .references(ShopRecord.schema, "id", onDelete: .cascade)) + .field("slug", .string, .required) + .field("title", .string, .required) + .field("series_title", .string, .required) + .field("issue_label", .string, .required) + .field("publisher", .string, .required) + .field("year", .int, .required) + .field("format", .string, .required) + .field("printing", .string, .required) + .field("printing_detail", .string, .required) + .field("is_first_print", .bool, .required) + .field("grader", .string) + .field("grade", .double) + .field("condition", .string, .required) + .field("cert_number", .string) + .field("page_quality", .string) + .field("key_notes", .string) + .field("seller_notes", .string, .required) + .field("price_cents", .int, .required) + .field("shipping_cents", .int, .required) + .field("accepts_offers", .bool, .required) + .field("cover_tone", .string, .required) + .field("cover_title", .string, .required) + .field("cover_imprint", .string, .required) + .field("cover_number", .string, .required) + .field("listed_at", .datetime, .required) + .unique(on: "slug") + .create() + } + + func revert(on database: Database) async throws { + try await database.schema(ListingRecord.schema) + .delete() + } + +} diff --git a/Packages/Persistence/Sources/Internal/Migrations/CreateShop.swift b/Packages/Persistence/Sources/Internal/Migrations/CreateShop.swift new file mode 100644 index 0000000..69e0597 --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Migrations/CreateShop.swift @@ -0,0 +1,33 @@ +import FluentKit + +/// Creates and drops the `shops` table backing ``ShopRecord``. +struct CreateShop: AsyncMigration { + + // MARK: Methods + + func prepare(on database: Database) async throws { + try await database.schema(ShopRecord.schema) + .id() + .field("slug", .string, .required) + .field("name", .string, .required) + .field("city", .string, .required) + .field("country", .string, .required) + .field("since_year", .int, .required) + .field("is_verified", .bool, .required) + .field("rating", .double, .required) + .field("rated_sales", .int, .required) + .field("sold_count", .int, .required) + .field("handling_days", .int, .required) + .field("ships_note", .string, .required) + .field("returns_note", .string, .required) + .field("about", .string, .required) + .unique(on: "slug") + .create() + } + + func revert(on database: Database) async throws { + try await database.schema(ShopRecord.schema) + .delete() + } + +} diff --git a/Packages/Persistence/Sources/Internal/Migrations/SeedCatalogue.swift b/Packages/Persistence/Sources/Internal/Migrations/SeedCatalogue.swift new file mode 100644 index 0000000..d787ee0 --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Migrations/SeedCatalogue.swift @@ -0,0 +1,27 @@ +import FluentKit + +/// Fills the catalogue with the prototype's demo shops and listings. +struct SeedCatalogue: AsyncMigration { + + // MARK: Methods + + func prepare(on database: Database) async throws { + for shop in Seed.shops { + let record = ShopRecord(shop) + + try await record.save(on: database) + + let shopID = try record.requireID() + + for listing in Seed.listings where listing.shop.slug == shop.slug { + try await ListingRecord(listing, shopID: shopID).save(on: database) + } + } + } + + func revert(on database: Database) async throws { + try await ListingRecord.query(on: database).delete() + try await ShopRecord.query(on: database).delete() + } + +} diff --git a/Packages/Persistence/Sources/Internal/Models/ExampleRecord.swift b/Packages/Persistence/Sources/Internal/Models/ExampleRecord.swift deleted file mode 100644 index 350a8b1..0000000 --- a/Packages/Persistence/Sources/Internal/Models/ExampleRecord.swift +++ /dev/null @@ -1,45 +0,0 @@ -import FluentKit -import Foundation - -/// A FluentKit model of a single `example_records` row. -/// -/// This is reference scaffolding: it demonstrates the model → migration → repository pattern the rest of -/// the package is built around, and is what the tests exercise. Replace it with the first real domain model -/// (paired with its own migration and repository) once one is defined. -/// -/// FluentKit models are mutable reference types whose property wrappers are not `Sendable`; the model never -/// crosses a concurrency boundary (repositories map it to a `Sendable` snapshot before returning), so the -/// conformance is declared `@unchecked Sendable`. -final class ExampleRecord: Model, @unchecked Sendable { - - // MARK: Properties - - /// The name of the backing table. - static let schema = "example_records" - - /// The row's primary key, assigned on first save. - @ID(key: .id) - var id: UUID? - - /// The row's name column. - @Field(key: "name") - var name: String - - // MARK: Initializers - - /// Creates an empty record, as required by FluentKit to hydrate query results. - init() {} - - /// Creates a record with the given values. - /// - Parameters: - /// - id: the primary key, or `nil` to have one assigned on save. - /// - name: the value of the name column. - init( - id: UUID? = nil, - name: String - ) { - self.id = id - self.name = name - } - -} diff --git a/Packages/Persistence/Sources/Internal/Models/ListingRecord.swift b/Packages/Persistence/Sources/Internal/Models/ListingRecord.swift new file mode 100644 index 0000000..e3f66d5 --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Models/ListingRecord.swift @@ -0,0 +1,176 @@ +import FluentKit +import Foundation + +/// A FluentKit model of a single `listings` row. +/// +/// The catalogue fields (series, issue, publisher) are folded into the listing; they move to tables of their own once the series screen needs them. +final class ListingRecord: Model, @unchecked Sendable { + + // MARK: Properties + + static let schema = "listings" + + @ID(key: .id) + var id: UUID? + + @Parent(key: "shop_id") + var shop: ShopRecord + + @Field(key: "slug") + var slug: String + + @Field(key: "title") + var title: String + + @Field(key: "series_title") + var seriesTitle: String + + @Field(key: "issue_label") + var issueLabel: String + + @Field(key: "publisher") + var publisher: String + + @Field(key: "year") + var year: Int + + @Field(key: "format") + var format: String + + @Field(key: "printing") + var printing: String + + @Field(key: "printing_detail") + var printingDetail: String + + @Field(key: "is_first_print") + var isFirstPrint: Bool + + @OptionalField(key: "grader") + var grader: String? + + @OptionalField(key: "grade") + var grade: Double? + + @Field(key: "condition") + var condition: String + + @OptionalField(key: "cert_number") + var certNumber: String? + + @OptionalField(key: "page_quality") + var pageQuality: String? + + @OptionalField(key: "key_notes") + var keyNotes: String? + + @Field(key: "seller_notes") + var sellerNotes: String + + @Field(key: "price_cents") + var priceCents: Int + + @Field(key: "shipping_cents") + var shippingCents: Int + + @Field(key: "accepts_offers") + var acceptsOffers: Bool + + @Field(key: "cover_tone") + var coverTone: String + + @Field(key: "cover_title") + var coverTitle: String + + @Field(key: "cover_imprint") + var coverImprint: String + + @Field(key: "cover_number") + var coverNumber: String + + @Field(key: "listed_at") + var listedAt: Date + + // MARK: Initializers + + init() {} + + /// Creates a record from a snapshot. + /// - Parameters: + /// - listing: the snapshot the record takes its values from. + /// - shopID: the identifier of the shop the listing belongs to. + init( + _ listing: Listing, + shopID: UUID + ) { + self.$shop.id = shopID + self.slug = listing.slug + self.title = listing.title + self.seriesTitle = listing.seriesTitle + self.issueLabel = listing.issueLabel + self.publisher = listing.publisher + self.year = listing.year + self.format = listing.format + self.printing = listing.printing + self.printingDetail = listing.printingDetail + self.isFirstPrint = listing.isFirstPrint + self.grader = listing.grader + self.grade = listing.grade + self.condition = listing.condition + self.certNumber = listing.certNumber + self.pageQuality = listing.pageQuality + self.keyNotes = listing.keyNotes + self.sellerNotes = listing.sellerNotes + self.priceCents = listing.priceCents + self.shippingCents = listing.shippingCents + self.acceptsOffers = listing.acceptsOffers + self.coverTone = listing.cover.tone + self.coverTitle = listing.cover.title + self.coverImprint = listing.cover.imprint + self.coverNumber = listing.cover.number + self.listedAt = listing.listedAt + } + +} + +// MARK: - Extensions + +extension ListingRecord { + + // MARK: Computed + + /// The `Sendable` snapshot of the record; its shop must be eager-loaded. + var snapshot: Listing { + .init( + slug: slug, + shop: shop.snapshot, + title: title, + seriesTitle: seriesTitle, + issueLabel: issueLabel, + publisher: publisher, + year: year, + format: format, + printing: printing, + printingDetail: printingDetail, + isFirstPrint: isFirstPrint, + grader: grader, + grade: grade, + condition: condition, + certNumber: certNumber, + pageQuality: pageQuality, + keyNotes: keyNotes, + sellerNotes: sellerNotes, + priceCents: priceCents, + shippingCents: shippingCents, + acceptsOffers: acceptsOffers, + cover: .init( + tone: coverTone, + title: coverTitle, + imprint: coverImprint, + number: coverNumber + ), + listedAt: listedAt + ) + } + +} diff --git a/Packages/Persistence/Sources/Internal/Models/ShopRecord.swift b/Packages/Persistence/Sources/Internal/Models/ShopRecord.swift new file mode 100644 index 0000000..d60af04 --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Models/ShopRecord.swift @@ -0,0 +1,107 @@ +import FluentKit +import Foundation + +/// A FluentKit model of a single `shops` row. +final class ShopRecord: Model, @unchecked Sendable { + + // MARK: Properties + + static let schema = "shops" + + @ID(key: .id) + var id: UUID? + + @Field(key: "slug") + var slug: String + + @Field(key: "name") + var name: String + + @Field(key: "city") + var city: String + + @Field(key: "country") + var country: String + + @Field(key: "since_year") + var sinceYear: Int + + @Field(key: "is_verified") + var isVerified: Bool + + @Field(key: "rating") + var rating: Double + + @Field(key: "rated_sales") + var ratedSales: Int + + @Field(key: "sold_count") + var soldCount: Int + + @Field(key: "handling_days") + var handlingDays: Int + + @Field(key: "ships_note") + var shipsNote: String + + @Field(key: "returns_note") + var returnsNote: String + + @Field(key: "about") + var about: String + + @Children(for: \.$shop) + var listings: [ListingRecord] + + // MARK: Initializers + + init() {} + + /// Creates a record from a snapshot. + /// - Parameter shop: the snapshot the record takes its values from. + init( + _ shop: Shop + ) { + self.slug = shop.slug + self.name = shop.name + self.city = shop.city + self.country = shop.country + self.sinceYear = shop.sinceYear + self.isVerified = shop.isVerified + self.rating = shop.rating + self.ratedSales = shop.ratedSales + self.soldCount = shop.soldCount + self.handlingDays = shop.handlingDays + self.shipsNote = shop.shipsNote + self.returnsNote = shop.returnsNote + self.about = shop.about + } + +} + +// MARK: - Extensions + +extension ShopRecord { + + // MARK: Computed + + /// The `Sendable` snapshot of the record. + var snapshot: Shop { + .init( + slug: slug, + name: name, + city: city, + country: country, + sinceYear: sinceYear, + isVerified: isVerified, + rating: rating, + ratedSales: ratedSales, + soldCount: soldCount, + handlingDays: handlingDays, + shipsNote: shipsNote, + returnsNote: returnsNote, + about: about + ) + } + +} diff --git a/Packages/Persistence/Sources/Internal/Repositories/ExampleRepository.swift b/Packages/Persistence/Sources/Internal/Repositories/ExampleRepository.swift deleted file mode 100644 index 081dea1..0000000 --- a/Packages/Persistence/Sources/Internal/Repositories/ExampleRepository.swift +++ /dev/null @@ -1,63 +0,0 @@ -import FluentKit -import Foundation -import HummingbirdFluent - -/// A `Sendable` snapshot of an ``ExampleRecord``, safe to return across concurrency boundaries. -/// -/// Repositories return these value-type snapshots rather than FluentKit models, which are mutable reference -/// types that must not escape the database's execution context. -public struct Example: Sendable, Equatable { - - // MARK: Properties - - /// The record's primary key, or `nil` if it has never been saved. - public let id: UUID? - /// The record's name. - public let name: String - -} - -/// Reads and writes ``ExampleRecord`` rows through the default database. -/// -/// This is the shape every real repository takes: it holds the `Sendable` `Fluent` service, resolves the -/// default database per call, and maps FluentKit models to `Sendable` snapshots before returning — so no -/// model ever escapes across an async boundary. It is reference scaffolding paired with ``ExampleRecord``; -/// replace it with the first real repository once a domain model is defined. -public struct ExampleRepository: Sendable { - - // MARK: Properties - - /// The service providing the default database the repository reads and writes through. - 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 - - /// Inserts a record with the given name. - /// - Parameter name: the name of the record to insert. - /// - Returns: a `Sendable` snapshot of the inserted record, including its assigned identifier. - public func create(name: String) async throws -> Example { - let record = ExampleRecord(name: name) - - try await record.save(on: fluent.db()) - - return Example(id: record.id, name: record.name) - } - - /// Fetches every record, ordered by name. - /// - Returns: a `Sendable` snapshot of each record, sorted by name. - public func all() async throws -> [Example] { - try await ExampleRecord.query(on: fluent.db()) - .sort(\.$name) - .all() - .map { Example(id: $0.id, name: $0.name) } - } - -} diff --git a/Packages/Persistence/Sources/Internal/Seeds/Seed.swift b/Packages/Persistence/Sources/Internal/Seeds/Seed.swift new file mode 100644 index 0000000..01cd584 --- /dev/null +++ b/Packages/Persistence/Sources/Internal/Seeds/Seed.swift @@ -0,0 +1,283 @@ +import Foundation + +/// The prototype's demo catalogue, taken from the design canvas. +enum Seed { + + // MARK: Constants + + /// The demo listings, newest first. + static let listings: [Listing] = [ + single( + "nightward-1-cgc-9-8", kestrelRow, "Nightward", "1", "Meridian Comics", 2019, + price: 240, shipping: 6, grade: 9.8, condition: "NM/M", grader: "CGC", cert: "4312087-002", + pages: "White", key: "First appearance of the Nightward; first issue of the series", + offers: true, tone: "navy", lettering: "NIGHT\nWARD", imprint: "MERIDIAN", + notes: "Bought from the original owner and sent for grading in one batch. Case is clean with no scuffs or scratches; inner well intact. Photos are of this exact copy — the label photo shows the full cert number, which you can check on the grader's site before you buy." + ), + single( + "ferrous-saint-12", thirdPanel, "Ferrous Saint", "12", "Blackline", 1994, + price: 38, shipping: 7, grade: 9.0, condition: "VF/NM", + printing: "newsstand", printingDetail: "First printing, newsstand edition", + tone: "wine", lettering: "FERROUS\nSAINT", imprint: "BLACKLINE", + notes: "Newsstand copy with the barcode box. Light spine stress at the lower staple, otherwise sharp corners and full gloss." + ), + single( + "echo-country-3", kestrelRow, "Echo Country", "3", "Harbour & Vale", 2021, + price: 14, shipping: 5, grade: 9.4, condition: "NM", + printing: "2nd print", printingDetail: "Second printing", isFirstPrint: false, + tone: "forest", lettering: "ECHO\nCOUNTRY", imprint: "HARBOUR & VALE", + notes: "Second printing with the recoloured cover. Read once, bagged and boarded since." + ), + single( + "vermilion-lake-7-cgc-6-5", longbox, "Vermilion Lake", "7", "Meridian Comics", 1987, + price: 95, shipping: 14, grade: 6.5, condition: "FN+", grader: "CGC", cert: "3907714-011", + pages: "Off-white to white", key: "Origin of the Lake Warden", + printing: "direct", printingDetail: "First printing, direct edition", offers: true, + tone: "rust", lettering: "VERMILION\nLAKE", imprint: "MERIDIAN", + notes: "Older slab with light shelf wear on the case. The grader's notes list a small corner crease and light spine stress." + ), + single( + "paper-tigers-1-variant-b", silverfish, "Paper Tigers", "1", "Cutwater Press", 2023, + price: 22, shipping: 5, grade: 9.6, condition: "NM+", + key: "First issue of the series", printing: "variant B", printingDetail: "First printing, variant cover B", + tone: "slate", lettering: "PAPER\nTIGERS", imprint: "CUTWATER", + notes: "Pulled from the box on release day and never read. 1:10 retailer variant." + ), + collected( + "hollow-comet-vol-4", thirdPanel, "Hollow Comet, Vol. 4", "Hollow Comet", "Vol. 4", "Sōkai Books", 2022, + format: "manga", price: 11, shipping: 5, condition: "Like new", printingDetail: "English first printing", + tone: "plum", lettering: "HOLLOW\nCOMET", imprint: "SŌKAI BOOKS", number: "VOL 4", + notes: "Read once. No spine creases, no sun fading; obi strip not included." + ), + collected( + "nightward-the-first-year-hc", kestrelRow, "Nightward: The First Year", "Nightward", "Hardcover, collects #1–12", "Meridian Comics", 2022, + format: "hardcovers", price: 45, shipping: 8, condition: "Sealed", printingDetail: "First printing", + tone: "navy", lettering: "NIGHTWARD\nTHE FIRST\nYEAR", imprint: "MERIDIAN", number: "HC", + notes: "Still in the publisher's shrink-wrap. Ships in a book box with corner protection." + ), + collected( + "solar-divide-vol-1", longbox, "Solar Divide, Vol. 1", "Solar Divide", "Vol. 1", "Sōkai Books", 2020, + format: "manga", price: 28, shipping: 12, condition: "Very good", printingDetail: "English first printing", + tone: "brick", lettering: "SOLAR\nDIVIDE", imprint: "SŌKAI BOOKS", number: "VOL 1", + notes: "Out of print in English. Light shelf wear to the lower edge; pages clean." + ), + collected( + "the-gilded-hour-tpb", silverfish, "The Gilded Hour", "The Gilded Hour", "Trade paperback, complete", "Harbour & Vale", 2019, + format: "trade-paperbacks", price: 19, shipping: 6, condition: "Like new", printing: "3rd printing", + printingDetail: "Third printing", isFirstPrint: false, + tone: "olive", lettering: "THE\nGILDED\nHOUR", imprint: "HARBOUR & VALE", number: "TPB", + notes: "Third printing with the corrected lettering in chapter four. Spine uncreased." + ), + collected( + "ferrous-saint-omnibus-hc", thirdPanel, "Ferrous Saint Omnibus", "Ferrous Saint", "Hardcover omnibus, collects #1–30", "Blackline", 2020, + format: "hardcovers", price: 88, shipping: 10, condition: "Sealed", printingDetail: "First printing", offers: true, + tone: "ink", lettering: "FERROUS\nSAINT\nOMNIBUS", imprint: "BLACKLINE", number: "HC", + notes: "Sealed. Heavy book: ships double-boxed." + ), + single( + "nightward-2-cgc-9-6", kestrelRow, "Nightward", "2", "Meridian Comics", 2019, + price: 85, shipping: 6, grade: 9.6, condition: "NM+", grader: "CGC", cert: "4312087-003", + pages: "White", offers: true, tone: "navy", lettering: "NIGHT\nWARD", imprint: "MERIDIAN", + notes: "Graded in the same batch as our #1. Clean case." + ), + single( + "nightward-1-cgc-9-4", longbox, "Nightward", "1", "Meridian Comics", 2019, + price: 150, shipping: 14, grade: 9.4, condition: "NM", grader: "CGC", cert: "4198220-007", + pages: "White", key: "First appearance of the Nightward; first issue of the series", + offers: true, tone: "navy", lettering: "NIGHT\nWARD", imprint: "MERIDIAN", + notes: "A tidy 9.4 for anyone who wants the key without the 9.8 premium." + ), + single( + "nightward-1-raw-nm", thirdPanel, "Nightward", "1", "Meridian Comics", 2019, + price: 120, shipping: 7, grade: 9.4, condition: "NM", + key: "First appearance of the Nightward; first issue of the series", + offers: true, tone: "navy", lettering: "NIGHT\nWARD", imprint: "MERIDIAN", + notes: "Raw copy, a grading candidate. One faint non-colour-breaking stress line on the spine, visible in photo 3." + ), + single( + "nightward-1-2nd-print", silverfish, "Nightward", "1", "Meridian Comics", 2019, + price: 35, shipping: 5, grade: 8.0, condition: "VF", + key: "First appearance of the Nightward", + printing: "2nd print", printingDetail: "Second printing, red logo", isFirstPrint: false, + tone: "navy", lettering: "NIGHT\nWARD", imprint: "MERIDIAN", + notes: "Second printing with the red logo. Small corner blunt, top right." + ), + single( + "ferrous-saint-1-cbcs-9-0", inkwell, "Ferrous Saint", "1", "Blackline", 1993, + price: 130, shipping: 9, grade: 9.0, condition: "VF/NM", grader: "CBCS", cert: "21-2E4F771-004", + pages: "White", key: "First appearance of the Ferrous Saint", offers: true, + tone: "wine", lettering: "FERROUS\nSAINT", imprint: "BLACKLINE", + notes: "CBCS slab, verified signature series not applicable. Case has one light scuff on the back." + ), + single( + "echo-country-1", inkwell, "Echo Country", "1", "Harbour & Vale", 2021, + price: 24, shipping: 6, grade: 9.4, condition: "NM", key: "First issue of the series", + tone: "forest", lettering: "ECHO\nCOUNTRY", imprint: "HARBOUR & VALE", + notes: "First printing. Flat, glossy, no spine ticks." + ), + single( + "vermilion-lake-12", kestrelRow, "Vermilion Lake", "12", "Meridian Comics", 1988, + price: 18, shipping: 5, grade: 8.5, condition: "VF+", + printing: "direct", printingDetail: "First printing, direct edition", + tone: "rust", lettering: "VERMILION\nLAKE", imprint: "MERIDIAN", + notes: "Two small spine ticks; cover gloss is strong for its age." + ), + single( + "paper-tigers-2", silverfish, "Paper Tigers", "2", "Cutwater Press", 2023, + price: 9, shipping: 5, grade: 9.4, condition: "NM", + tone: "slate", lettering: "PAPER\nTIGERS", imprint: "CUTWATER", + notes: "Cover A. Bagged and boarded from new." + ), + collected( + "hollow-comet-vol-1", inkwell, "Hollow Comet, Vol. 1", "Hollow Comet", "Vol. 1", "Sōkai Books", 2021, + format: "manga", price: 14, shipping: 6, condition: "Very good", printingDetail: "English first printing", + tone: "plum", lettering: "HOLLOW\nCOMET", imprint: "SŌKAI BOOKS", number: "VOL 1", + notes: "First English printing with the misprinted spine number, corrected in later runs." + ), + collected( + "vermilion-lake-tpb-vol-1", inkwell, "Vermilion Lake, Vol. 1", "Vermilion Lake", "Trade paperback, collects #1–6", "Meridian Comics", 2015, + format: "trade-paperbacks", price: 16, shipping: 6, condition: "Good", printing: "2nd printing", + printingDetail: "Second printing", isFirstPrint: false, + tone: "rust", lettering: "VERMILION\nLAKE", imprint: "MERIDIAN", number: "TPB", + notes: "A reading copy: creased spine and a previous owner's name inside the front cover." + ), + ] + + /// The demo shops. + static let shops: [Shop] = [kestrelRow, thirdPanel, longbox, silverfish, inkwell] + +} + +// MARK: - Helpers + +private extension Seed { + + // MARK: Constants + + static let inkwell = Shop( + slug: "inkwell-and-staple", name: "Inkwell & Staple", city: "Berlin", country: "DE", sinceYear: 2024, + isVerified: true, rating: 4.8, ratedSales: 96, soldCount: 140, handlingDays: 3, + shipsNote: "ships within the EU, tracked", returnsNote: "14-day returns", + about: "A two-person shop working through a warehouse find of nineties independents. Everything is graded against the public guide, and anything we are unsure of is graded down." + ) + + static let kestrelRow = Shop( + slug: "kestrel-row-comics", name: "Kestrel Row Comics", city: "Amsterdam", country: "NL", sinceYear: 2023, + isVerified: true, rating: 4.9, ratedSales: 312, soldCount: 1108, handlingDays: 2, + shipsNote: "ships worldwide, tracked", returnsNote: "14-day returns on raw books", + about: "Modern keys and Bronze Age runs, mostly slabbed. Every raw book is photographed front, back and spine under daylight; grades are conservative and defects are listed, not hinted at. Offers welcome on anything over €50." + ) + + static let longbox = Shop( + slug: "longbox-atlantic", name: "Longbox Atlantic", city: "Bristol", country: "GB", sinceYear: 2024, + isVerified: false, rating: 4.6, ratedSales: 58, soldCount: 71, handlingDays: 4, + shipsNote: "ships worldwide from the UK; import VAT may apply", returnsNote: "returns by agreement", + about: "A collector thinning out twenty years of long boxes. Mid-grade keys and out-of-print manga, priced to move." + ) + + static let silverfish = Shop( + slug: "silverfish-comics", name: "Silverfish Comics", city: "Utrecht", country: "NL", sinceYear: 2022, + isVerified: true, rating: 4.7, ratedSales: 204, soldCount: 655, handlingDays: 1, + shipsNote: "ships within the EU, tracked", returnsNote: "14-day returns", + about: "New-release variants and recent indies, shipped the next working day. Pickup from the Utrecht shop is always free." + ) + + static let thirdPanel = Shop( + slug: "third-panel-supply", name: "Third Panel Supply", city: "Antwerp", country: "BE", sinceYear: 2023, + isVerified: true, rating: 4.8, ratedSales: 167, soldCount: 420, handlingDays: 2, + shipsNote: "ships within the EU, tracked", returnsNote: "14-day returns", + about: "Collected editions, manga and the odd newsstand oddity. Sealed means sealed; everything else is described page by page." + ) + + /// The moment the newest demo listing was listed; each later entry in ``listings`` is a few hours older. + static let epoch = Date(timeIntervalSince1970: 1_789_900_000) + + // MARK: Methods + + /// Builds a collected edition — manga, trade paperback, or hardcover — which carries a condition but no grade. + static func collected( + _ slug: String, + _ shop: Shop, + _ title: String, + _ series: String, + _ issueLabel: String, + _ publisher: String, + _ year: Int, + format: String, + price: Int, + shipping: Int, + condition: String, + printing: String = "1st printing", + printingDetail: String, + isFirstPrint: Bool = true, + offers: Bool = false, + tone: String, + lettering: String, + imprint: String, + number: String, + notes: String + ) -> Listing { + .init( + slug: slug, shop: shop, title: title, seriesTitle: series, issueLabel: issueLabel, + publisher: publisher, year: year, format: format, printing: printing, printingDetail: printingDetail, + isFirstPrint: isFirstPrint, grader: nil, grade: nil, condition: condition, certNumber: nil, + pageQuality: nil, keyNotes: nil, sellerNotes: notes, priceCents: price * 100, + shippingCents: shipping * 100, acceptsOffers: offers, + cover: .init(tone: tone, title: lettering, imprint: imprint, number: number), + listedAt: listedAt(slug) + ) + } + + /// The listing date of the entry with the given slug: the ``epoch``, less seven hours per earlier entry. + static func listedAt( + _ slug: String + ) -> Date { + epoch.addingTimeInterval(-25_200 * Double(order.firstIndex(of: slug) ?? order.count)) + } + + /// Builds a single issue, slabbed when a grader is given and raw otherwise. + static func single( + _ slug: String, + _ shop: Shop, + _ series: String, + _ number: String, + _ publisher: String, + _ year: Int, + price: Int, + shipping: Int, + grade: Double, + condition: String, + grader: String? = nil, + cert: String? = nil, + pages: String? = nil, + key: String? = nil, + printing: String = "1st print", + printingDetail: String = "First printing", + isFirstPrint: Bool = true, + offers: Bool = false, + tone: String, + lettering: String, + imprint: String, + notes: String + ) -> Listing { + .init( + slug: slug, shop: shop, title: "\(series) #\(number)", seriesTitle: "\(series) (\(year) series)", + issueLabel: "#\(number)", publisher: publisher, year: year, format: "single-issues", + printing: printing, printingDetail: printingDetail, isFirstPrint: isFirstPrint, grader: grader, + grade: grade, condition: condition, certNumber: cert, pageQuality: pages, keyNotes: key, + sellerNotes: notes, priceCents: price * 100, shippingCents: shipping * 100, acceptsOffers: offers, + cover: .init(tone: tone, title: lettering, imprint: imprint, number: number), + listedAt: listedAt(slug) + ) + } + + /// The listing slugs, newest first; ``listedAt(_:)`` spaces the listing dates by this order. + static let order = [ + "nightward-1-cgc-9-8", "ferrous-saint-12", "echo-country-3", "vermilion-lake-7-cgc-6-5", + "paper-tigers-1-variant-b", "hollow-comet-vol-4", "nightward-the-first-year-hc", "solar-divide-vol-1", + "the-gilded-hour-tpb", "ferrous-saint-omnibus-hc", "nightward-2-cgc-9-6", "nightward-1-cgc-9-4", + "nightward-1-raw-nm", "nightward-1-2nd-print", "ferrous-saint-1-cbcs-9-0", "echo-country-1", + "vermilion-lake-12", "paper-tigers-2", "hollow-comet-vol-1", "vermilion-lake-tpb-vol-1", + ] + +} diff --git a/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift index 932216e..7aa60db 100644 --- a/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift +++ b/Packages/Persistence/Sources/Public/Methods/PrepareDB.swift @@ -22,7 +22,9 @@ public struct PrepareDB: Sendable { for fluent: Fluent ) async { await fluent.migrations.add([ - CreateExampleRecord() + CreateShop(), + CreateListing(), + SeedCatalogue(), ]) } diff --git a/Packages/Persistence/Sources/Public/Models/Listing.swift b/Packages/Persistence/Sources/Public/Models/Listing.swift new file mode 100644 index 0000000..7960e9b --- /dev/null +++ b/Packages/Persistence/Sources/Public/Models/Listing.swift @@ -0,0 +1,159 @@ +import Foundation + +/// A `Sendable` snapshot of one copy for sale, with the shop selling it. +public struct Listing: Sendable, Equatable { + + // MARK: Properties + + /// The listing's URL slug. + public let slug: String + /// The shop selling the copy. + public let shop: Shop + /// The listing's title, e.g. `Nightward #1`. + public let title: String + /// The series the copy belongs to, e.g. `Nightward (2019 series)`. + public let seriesTitle: String + /// The issue or volume, e.g. `#1, cover A`. + public let issueLabel: String + /// The publisher's name. + public let publisher: String + /// The cover year. + public let year: Int + /// The format token, e.g. `single-issues`. + public let format: String + /// The printing in a few words, e.g. `1st print`. + public let printing: String + /// The printing in full, e.g. `First printing, direct edition`. + public let printingDetail: String + /// Whether the copy is a first printing. + public let isFirstPrint: Bool + /// The company that graded and slabbed the copy, or `nil` for a raw copy. + public let grader: String? + /// The numeric grade, or `nil` for formats that are not graded. + public let grade: Double? + /// The condition as the seller states it, e.g. `NM` or `Like new`. + public let condition: String + /// The grader's certificate number, or `nil` for a raw copy. + public let certNumber: String? + /// The page quality, or `nil` when not stated. + public let pageQuality: String? + /// Why the issue is a key, or `nil` when it is not one. + public let keyNotes: String? + /// The seller's notes on the copy. + public let sellerNotes: String + /// The asking price, in euro cents. + public let priceCents: Int + /// The shipping cost to the Netherlands, in euro cents. + public let shippingCents: Int + /// Whether the seller takes offers on the copy. + public let acceptsOffers: Bool + /// The placeholder cover shown until listings carry photos. + public let cover: Cover + /// When the copy was listed. + public let listedAt: Date + + // MARK: Initializers + + public init( + slug: String, + shop: Shop, + title: String, + seriesTitle: String, + issueLabel: String, + publisher: String, + year: Int, + format: String, + printing: String, + printingDetail: String, + isFirstPrint: Bool, + grader: String?, + grade: Double?, + condition: String, + certNumber: String?, + pageQuality: String?, + keyNotes: String?, + sellerNotes: String, + priceCents: Int, + shippingCents: Int, + acceptsOffers: Bool, + cover: Cover, + listedAt: Date + ) { + self.slug = slug + self.shop = shop + self.title = title + self.seriesTitle = seriesTitle + self.issueLabel = issueLabel + self.publisher = publisher + self.year = year + self.format = format + self.printing = printing + self.printingDetail = printingDetail + self.isFirstPrint = isFirstPrint + self.grader = grader + self.grade = grade + self.condition = condition + self.certNumber = certNumber + self.pageQuality = pageQuality + self.keyNotes = keyNotes + self.sellerNotes = sellerNotes + self.priceCents = priceCents + self.shippingCents = shippingCents + self.acceptsOffers = acceptsOffers + self.cover = cover + self.listedAt = listedAt + } + +} + +// MARK: - Extensions + +public extension Listing { + + // MARK: Computed + + /// Whether the issue is a key. + var isKey: Bool { + keyNotes != nil + } + + /// Whether the copy is graded and slabbed by a grading company. + var isSlabbed: Bool { + grader != nil + } + +} + +// MARK: - Structs + +public extension Listing { + /// A placeholder cover: lettering on a toned ground. + struct Cover: Sendable, Equatable { + + // MARK: Properties + + /// The tone token naming the cover's colours, e.g. `navy`. + public let tone: String + /// The title lettering, one line per newline. + public let title: String + /// The publisher's imprint line. + public let imprint: String + /// The issue number or volume marker. + public let number: String + + // MARK: Initializers + + public init( + tone: String, + title: String, + imprint: String, + number: String + ) { + self.tone = tone + self.title = title + self.imprint = imprint + self.number = number + } + + } +} diff --git a/Packages/Persistence/Sources/Public/Models/ListingFilter.swift b/Packages/Persistence/Sources/Public/Models/ListingFilter.swift new file mode 100644 index 0000000..1cca243 --- /dev/null +++ b/Packages/Persistence/Sources/Public/Models/ListingFilter.swift @@ -0,0 +1,197 @@ +/// The criteria a listing search is narrowed and ordered by; the default matches every listing, newest first. +public struct ListingFilter: Sendable, Equatable { + + // MARK: Properties + + /// Words that must each appear in the listing's title, publisher, or shop name. + public var query: String? + /// The format tokens to match; empty matches all. + public var formats: [String] + /// `true` for slabbed copies only, `false` for raw only, `nil` for both. + public var isSlabbed: Bool? + /// The lowest numeric grade to match. + public var gradeMin: Double? + /// The highest numeric grade to match. + public var gradeMax: Double? + /// Whether to match first printings only. + public var firstPrintOnly: Bool + /// Whether to match key issues only. + public var keysOnly: Bool + /// The publishers to match; empty matches all. + public var publishers: [String] + /// The lowest price to match, in euro cents. + public var priceMin: Int? + /// The highest price to match, in euro cents. + public var priceMax: Int? + /// Where matching listings ship from. + public var shipsFrom: Region + /// Whether to match only listings whose seller takes offers. + public var acceptsOffers: Bool + /// Whether to match only listings from verified shops. + public var verifiedShops: Bool + /// The slug of the one shop to match, or `nil` for all shops. + public var shop: String? + /// The order of the results. + public var sort: Sort + + // MARK: Initializers + + public init( + query: String? = nil, + formats: [String] = [], + isSlabbed: Bool? = nil, + gradeMin: Double? = nil, + gradeMax: Double? = nil, + firstPrintOnly: Bool = false, + keysOnly: Bool = false, + publishers: [String] = [], + priceMin: Int? = nil, + priceMax: Int? = nil, + shipsFrom: Region = .anywhere, + acceptsOffers: Bool = false, + verifiedShops: Bool = false, + shop: String? = nil, + sort: Sort = .newest + ) { + self.query = query + self.formats = formats + self.isSlabbed = isSlabbed + self.gradeMin = gradeMin + self.gradeMax = gradeMax + self.firstPrintOnly = firstPrintOnly + self.keysOnly = keysOnly + self.publishers = publishers + self.priceMin = priceMin + self.priceMax = priceMax + self.shipsFrom = shipsFrom + self.acceptsOffers = acceptsOffers + self.verifiedShops = verifiedShops + self.shop = shop + self.sort = sort + } + +} + +// MARK: - Enumerations + +public extension ListingFilter { + /// Where a listing ships from. + enum Region: String, CaseIterable, Sendable { + /// The Netherlands. + case nl + /// Any member state of the European Union. + case eu + /// Any country. + case anywhere + } + + /// The order of a listing search's results. + enum Sort: String, CaseIterable, Sendable { + /// Most recently listed first. + case newest + /// Cheapest first. + case priceAscending = "price-asc" + /// Most expensive first. + case priceDescending = "price-desc" + /// Highest grade first; ungraded listings last. + case gradeDescending = "grade-desc" + } +} + +// MARK: - Extensions + +extension ListingFilter { + + // MARK: Methods + + /// Whether the listing meets every criterion. + /// - Parameter listing: the listing to test. + func matches( + _ listing: Listing + ) -> Bool { + if let shop, listing.shop.slug != shop { + return false + } + + if !formats.isEmpty, !formats.contains(listing.format) { + return false + } + + if let isSlabbed, listing.isSlabbed != isSlabbed { + return false + } + + if gradeMin != nil || gradeMax != nil { + guard + let grade = listing.grade, + grade >= gradeMin ?? 0, + grade <= gradeMax ?? 10 + else { + return false + } + } + + if firstPrintOnly, !listing.isFirstPrint { + return false + } + + if keysOnly, !listing.isKey { + return false + } + + if !publishers.isEmpty, !publishers.contains(listing.publisher) { + return false + } + + if let priceMin, listing.priceCents < priceMin { + return false + } + + if let priceMax, listing.priceCents > priceMax { + return false + } + + if acceptsOffers, !listing.acceptsOffers { + return false + } + + if verifiedShops, !listing.shop.isVerified { + return false + } + + switch shipsFrom { + case .nl where listing.shop.country != "NL": return false + case .eu where !Self.euCountries.contains(listing.shop.country): return false + default: break + } + + let haystack = "\(listing.title) \(listing.publisher) \(listing.shop.name)".lowercased() + + return (query ?? "") + .lowercased() + .split(whereSeparator: \.isWhitespace) + .allSatisfy { haystack.contains($0) } + } + + /// Whether the first listing sorts ahead of the second. + func ordersBefore( + _ lhs: Listing, + _ rhs: Listing + ) -> Bool { + switch sort { + case .newest: lhs.listedAt > rhs.listedAt + case .priceAscending: lhs.priceCents < rhs.priceCents + case .priceDescending: lhs.priceCents > rhs.priceCents + case .gradeDescending: (lhs.grade ?? -1) > (rhs.grade ?? -1) + } + } + + // MARK: Constants + + /// The ISO 3166-1 alpha-2 codes of the European Union's member states. + static let euCountries: Set = [ + "AT", "BE", "BG", "CY", "CZ", "DE", "DK", "EE", "ES", "FI", "FR", "GR", "HR", "HU", + "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO", "SE", "SI", "SK", + ] + +} diff --git a/Packages/Persistence/Sources/Public/Models/Shop.swift b/Packages/Persistence/Sources/Public/Models/Shop.swift new file mode 100644 index 0000000..749529e --- /dev/null +++ b/Packages/Persistence/Sources/Public/Models/Shop.swift @@ -0,0 +1,65 @@ +/// A `Sendable` snapshot of a seller's shop. +public struct Shop: Sendable, Equatable { + + // MARK: Properties + + /// The shop's URL slug. + public let slug: String + /// The shop's name. + public let name: String + /// The city the shop ships from. + public let city: String + /// The ISO 3166-1 alpha-2 code of the country the shop ships from. + public let country: String + /// The year the shop started selling on the site. + public let sinceYear: Int + /// Whether the shop's identity has been verified. + public let isVerified: Bool + /// The shop's average rating, out of five. + public let rating: Double + /// The number of rated sales behind ``rating``. + public let ratedSales: Int + /// The number of items the shop has sold. + public let soldCount: Int + /// The shop's handling time, in business days. + public let handlingDays: Int + /// Where and how the shop ships. + public let shipsNote: String + /// The shop's returns policy, in a few words. + public let returnsNote: String + /// The shop's description of itself. + public let about: String + + // MARK: Initializers + + public init( + slug: String, + name: String, + city: String, + country: String, + sinceYear: Int, + isVerified: Bool, + rating: Double, + ratedSales: Int, + soldCount: Int, + handlingDays: Int, + shipsNote: String, + returnsNote: String, + about: String + ) { + self.slug = slug + self.name = name + self.city = city + self.country = country + self.sinceYear = sinceYear + self.isVerified = isVerified + self.rating = rating + self.ratedSales = ratedSales + self.soldCount = soldCount + self.handlingDays = handlingDays + self.shipsNote = shipsNote + self.returnsNote = returnsNote + self.about = about + } + +} diff --git a/Packages/Persistence/Sources/Public/Repositories/CatalogueRepository.swift b/Packages/Persistence/Sources/Public/Repositories/CatalogueRepository.swift new file mode 100644 index 0000000..bf003ff --- /dev/null +++ b/Packages/Persistence/Sources/Public/Repositories/CatalogueRepository.swift @@ -0,0 +1,72 @@ +import FluentKit +import HummingbirdFluent + +/// Reads the shops and their listings through the default database. +public struct CatalogueRepository: Sendable { + + // 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 listing with the given slug. + /// - Parameter slug: the listing's URL slug. + /// - Returns: the listing, or `nil` when none has the slug. + public func listing( + slug: String + ) async throws -> Listing? { + try await ListingRecord.query(on: fluent.db()) + .filter(\.$slug == slug) + .with(\.$shop) + .first()? + .snapshot + } + + /// Fetches the listings meeting the filter, in its order. + /// + /// The prototype filters in memory, which its few dozen seeded listings allow; the criteria move into the query with real data. + /// - Parameter filter: the criteria the listings are narrowed and ordered by. + /// - Returns: every matching listing. + public func listings( + matching filter: ListingFilter = .init() + ) async throws -> [Listing] { + try await ListingRecord.query(on: fluent.db()) + .with(\.$shop) + .all() + .map(\.snapshot) + .filter(filter.matches) + .sorted(by: filter.ordersBefore) + } + + /// Fetches the name of every publisher with a listing, in alphabetical order. + public func publishers() async throws -> [String] { + try await ListingRecord.query(on: fluent.db()) + .unique() + .sort(\.$publisher) + .all(\.$publisher) + } + + /// Fetches the shop with the given slug. + /// - Parameter slug: the shop's URL slug. + /// - Returns: the shop, or `nil` when none has the slug. + public func shop( + slug: String + ) async throws -> Shop? { + try await ShopRecord.query(on: fluent.db()) + .filter(\.$slug == slug) + .first()? + .snapshot + } + +} diff --git a/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift b/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift index ad6d38e..eb8c794 100644 --- a/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift +++ b/Packages/Persistence/Tests/Cases/Public/Methods/ServiceTests.swift @@ -82,13 +82,13 @@ struct ServiceTests { try await fluent.shutdown() } - @Test("in-memory: migrate, insert, read back") + @Test("in-memory: migrate, seed, read back") func inMemoryRoundTrip() async throws { try await roundTrip(driver: .inMemory) } @Test( - "postgres: migrate, insert, read back", + "postgres: migrate, seed, read back", .enabled(if: postgresDriver != nil) ) func postgresRoundTrip() async throws { @@ -104,7 +104,7 @@ struct ServiceTests { private extension ServiceTests { - /// Migrates, inserts, and reads back a record against the given driver, shutting the pool down after. + /// Migrates, seeds, and reads the catalogue back against the given driver, shutting the pool down after. /// /// The `Fluent` service normally owns pool shutdown via its `run()` in the service group; outside that, /// the test must shut it down explicitly — even on failure — or the pool asserts on `deinit`. @@ -129,10 +129,9 @@ private extension ServiceTests { try await fluent.migrate() - let repository = ExampleRepository(fluent: fluent) - let created = try await repository.create(name: "site") + let repository = CatalogueRepository(fluent: fluent) - #expect(try await repository.all().contains(created)) + #expect(try await !repository.listings().isEmpty) if revertAfter { try await fluent.revert() diff --git a/Packages/Persistence/Tests/Cases/Public/Models/ListingFilterTests.swift b/Packages/Persistence/Tests/Cases/Public/Models/ListingFilterTests.swift new file mode 100644 index 0000000..cfa66e1 --- /dev/null +++ b/Packages/Persistence/Tests/Cases/Public/Models/ListingFilterTests.swift @@ -0,0 +1,91 @@ +import Testing + +@testable import Persistence + +@Suite( + "ListingFilter model", + .tags(.model) +) +struct ListingFilterTests { + + // MARK: Methods tests + + @Test + func `matches every listing by default`() { + #expect(slugs(.init()).count == Seed.listings.count) + } + + @Test + func `matches every word of the query across title, publisher, and shop`() { + #expect(slugs(.init(query: "NIGHTWARD kestrel")) == [ + "nightward-1-cgc-9-8", "nightward-the-first-year-hc", "nightward-2-cgc-9-6", + ]) + #expect(slugs(.init(query: "no such book")).isEmpty) + } + + @Test + func `matches the given formats`() { + #expect(slugs(.init(formats: ["manga"])) == ["hollow-comet-vol-4", "solar-divide-vol-1", "hollow-comet-vol-1"]) + } + + @Test + func `matches slabbed or raw copies`() { + #expect(slugs(.init(isSlabbed: true)).count == 5) + #expect(slugs(.init(formats: ["single-issues"], isSlabbed: false)).count == 8) + } + + @Test + func `leaves ungraded listings out of a grade range`() { + #expect(slugs(.init(gradeMin: 9.6)) == ["nightward-1-cgc-9-8", "paper-tigers-1-variant-b", "nightward-2-cgc-9-6"]) + #expect(slugs(.init(gradeMax: 7)) == ["vermilion-lake-7-cgc-6-5"]) + } + + @Test + func `matches a price range in cents`() { + #expect(slugs(.init(priceMin: 10_000, priceMax: 20_000)) == [ + "nightward-1-cgc-9-4", "nightward-1-raw-nm", "ferrous-saint-1-cbcs-9-0", + ]) + } + + @Test + func `matches where a listing ships from`() { + #expect(slugs(.init(shipsFrom: .nl)).count == 9) + #expect(slugs(.init(shipsFrom: .eu)).count == Seed.listings.count - 3) + } + + @Test + func `matches the remaining flags`() { + #expect(slugs(.init(verifiedShops: true)).count == Seed.listings.count - 3) + #expect(slugs(.init(keysOnly: true)).count == 8) + #expect(slugs(.init(firstPrintOnly: true)).count == Seed.listings.count - 4) + #expect(slugs(.init(acceptsOffers: true)).count == 7) + #expect(slugs(.init(publishers: ["Cutwater Press"])) == ["paper-tigers-1-variant-b", "paper-tigers-2"]) + #expect(slugs(.init(shop: "silverfish-comics")).count == 4) + } + + @Test + func `orders ungraded listings last by grade`() { + let grades = Seed.listings + .sorted(by: ListingFilter(sort: .gradeDescending).ordersBefore) + .map(\.grade) + + #expect(grades.first == 9.8) + #expect(grades.suffix(6).allSatisfy { $0 == nil }) + } + +} + +// MARK: - Helpers + +private extension ListingFilterTests { + + /// The slugs of the demo listings the filter matches, in seed order. + func slugs( + _ filter: ListingFilter + ) -> [String] { + Seed.listings + .filter(filter.matches) + .map(\.slug) + } + +} diff --git a/Packages/Persistence/Tests/Cases/Public/Repositories/CatalogueRepositoryTests.swift b/Packages/Persistence/Tests/Cases/Public/Repositories/CatalogueRepositoryTests.swift new file mode 100644 index 0000000..7d2dbc1 --- /dev/null +++ b/Packages/Persistence/Tests/Cases/Public/Repositories/CatalogueRepositoryTests.swift @@ -0,0 +1,104 @@ +import HummingbirdFluent +import Logging +import Testing + +@testable import Persistence + +@Suite( + "CatalogueRepository repository", + .tags(.repository) +) +struct CatalogueRepositoryTests { + + // MARK: Methods tests + + @Test + func `seeds every demo shop and listing`() async throws { + try await withCatalogue { repository in + let listings = try await repository.listings() + + #expect(listings.count == Seed.listings.count) + + for shop in Seed.shops { + let found = try await repository.shop(slug: shop.slug) + + #expect(found == shop) + } + } + } + + @Test + func `lists the newest listing first by default`() async throws { + try await withCatalogue { repository in + let listings = try await repository.listings() + + #expect(listings.map(\.slug) == Seed.listings.map(\.slug)) + } + } + + @Test + func `finds a listing with its shop by slug`() async throws { + try await withCatalogue { repository in + let found = try await repository.listing(slug: "nightward-1-cgc-9-8") + let missing = try await repository.listing(slug: "missing") + let listing = try #require(found) + + #expect(listing.title == "Nightward #1") + #expect(listing.shop.name == "Kestrel Row Comics") + #expect(missing == nil) + } + } + + @Test + func `lists each publisher once, alphabetically`() async throws { + try await withCatalogue { repository in + let publishers = try await repository.publishers() + + #expect(publishers == [ + "Blackline", "Cutwater Press", "Harbour & Vale", "Meridian Comics", "Sōkai Books", + ]) + } + } + + @Test + func `narrows and orders the listings by the filter`() async throws { + try await withCatalogue { repository in + let listings = try await repository.listings(matching: .init( + query: "nightward 1", + sort: .priceAscending + )) + + #expect(listings.map(\.priceCents) == [3_500, 12_000, 15_000, 24_000]) + } + } + +} + +// MARK: - Helpers + +private extension CatalogueRepositoryTests { + + /// Runs the body against a migrated, seeded in-memory catalogue, shutting the pool down after. + func withCatalogue( + _ body: (CatalogueRepository) 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(.init(fluent: fluent)) + } catch { + try? await fluent.shutdown() + + throw error + } + + try await fluent.shutdown() + } + +} diff --git a/Packages/Persistence/Tests/Utils/Extensions/Tag+Constants.swift b/Packages/Persistence/Tests/Utils/Extensions/Tag+Constants.swift index bec1ff6..1da4d3f 100644 --- a/Packages/Persistence/Tests/Utils/Extensions/Tag+Constants.swift +++ b/Packages/Persistence/Tests/Utils/Extensions/Tag+Constants.swift @@ -5,4 +5,8 @@ extension Tag { @Tag static var enumeration: Tag /// Tests exercising a method of the Persistence package. @Tag static var method: Tag + /// Tests exercising a model of the Persistence package. + @Tag static var model: Tag + /// Tests exercising a repository of the Persistence package. + @Tag static var repository: Tag }