From 945ce718455b8b1dff43a6e3535899f0a03fc095 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 29 Apr 2025 17:33:10 -0700 Subject: [PATCH 1/3] Add `Seeds` type for preparings seeds for a database This type is mainly to simplify downstream integrations like `SharingGRDB`, which already provides such a tool defined directly on `GRDB.Database`. That tool will be able to simply use this tool, instead, and future integrations can also leverage it. --- .../Articles/InsertStatements.md | 4 + Sources/StructuredQueriesCore/Seeds.swift | 105 ++++++++++++++++++ 2 files changed, 109 insertions(+) create mode 100644 Sources/StructuredQueriesCore/Seeds.swift diff --git a/Sources/StructuredQueriesCore/Documentation.docc/Articles/InsertStatements.md b/Sources/StructuredQueriesCore/Documentation.docc/Articles/InsertStatements.md index f11d93a9..c77568bc 100644 --- a/Sources/StructuredQueriesCore/Documentation.docc/Articles/InsertStatements.md +++ b/Sources/StructuredQueriesCore/Documentation.docc/Articles/InsertStatements.md @@ -447,3 +447,7 @@ clause: ### Statement types - ``Insert`` + +### Seeding a database + +- ``Seeds`` diff --git a/Sources/StructuredQueriesCore/Seeds.swift b/Sources/StructuredQueriesCore/Seeds.swift new file mode 100644 index 00000000..961aa23d --- /dev/null +++ b/Sources/StructuredQueriesCore/Seeds.swift @@ -0,0 +1,105 @@ +/// A type that can prepare statements to seed a database's initial state. +public struct Seeds: Sequence { + let seeds: [any Table] + + /// Prepares a number of batched insert statements to be executed. + /// + /// ```swift + /// Seeds { + /// SyncUp(id: 1, seconds: 60, theme: .appOrange, title: "Design") + /// SyncUp(id: 2, seconds: 60 * 10, theme: .periwinkle, title: "Engineering") + /// SyncUp(id: 3, seconds: 60 * 30, theme: .poppy, title: "Product") + /// + /// for name in ["Blob", "Blob Jr", "Blob Sr", "Blob Esq", "Blob III", "Blob I"] { + /// Attendee.Draft(name: name, syncUpID: 1) + /// } + /// for name in ["Blob", "Blob Jr"] { + /// Attendee.Draft(name: name, syncUpID: 2) + /// } + /// for name in ["Blob Sr", "Blob Jr"] { + /// Attendee.Draft(name: name, syncUpID: 3) + /// } + /// } + /// // INSERT INTO "syncUps" + /// // ("id", "seconds", "theme", "title") + /// // VALUES + /// // (1, 60, 'appOrange', 'Design'), + /// // (2, 600, 'periwinkle', 'Engineering'), + /// // (3, 1800, 'poppy', 'Product'); + /// // INSERT INTO "attendees" + /// // ("id", "name", "syncUpID") + /// // VALUES + /// // (NULL, 'Blob', 1), + /// // (NULL, 'Blob Jr', 1), + /// // (NULL, 'Blob Sr', 1), + /// // (NULL, 'Blob Esq', 1), + /// // (NULL, 'Blob III', 1), + /// // (NULL, 'Blob I', 1), + /// // (NULL, 'Blob', 2), + /// // (NULL, 'Blob Jr', 2), + /// // (NULL, 'Blob Sr', 3), + /// // (NULL, 'Blob Jr', 3); + /// ``` + /// + /// And then you can iterate over each insert statement and execute it given a database + /// connection. For example, using the [SharingGRDB][] driver: + /// + /// ```swift + /// try database.write { db in + /// let seeds = Seeds { + /// // ... + /// } + /// for insert in seeds { + /// try db.execute(insert) + /// } + /// } + /// ``` + /// + /// > Tip: [SharingGRDB][] extends GRDB's `Database` connection with a `seed` method that can + /// > build and insert records in a single step: + /// > + /// > ```swift + /// > try db.seed { + /// > // ... + /// > } + /// > ``` + /// + /// [SharingGRDB]: https://github.com/pointfreeco/sharing-grdb + /// + /// - Parameter build: A result builder closure that prepares statements to insert every built row. + public init(@InsertValuesBuilder _ build: () -> [any Table]) { + self.seeds = build() + } + + public func makeIterator() -> Iterator { + Iterator(seeds: seeds) + } + + public struct Iterator: IteratorProtocol { + var seeds: [any Table] + + public mutating func next() -> SQLQueryExpression? { + guard let first = seeds.first else { return nil } + + let firstType = type(of: first) + + if let firstType = firstType as? any TableDraft.Type { + func insertBatch(_: T.Type) -> SQLQueryExpression { + let batch = Array(seeds.lazy.prefix { $0 is T }.compactMap { $0 as? T }) + defer { seeds.removeFirst(batch.count) } + return SQLQueryExpression(T.PrimaryTable.insert(batch)) + } + + return insertBatch(firstType) + } else { + func insertBatch(_: T.Type) -> SQLQueryExpression { + let batch = Array(seeds.lazy.prefix { $0 is T }.compactMap { $0 as? T }) + defer { seeds.removeFirst(batch.count) } + return SQLQueryExpression(T.insert(batch)) + } + + return insertBatch(firstType) + } + } + } +} From 357e8a72860737133ec811ea481aabfb00d5684a Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 29 Apr 2025 17:52:09 -0700 Subject: [PATCH 2/3] wip --- Sources/StructuredQueriesCore/SQLQueryExpression.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Sources/StructuredQueriesCore/SQLQueryExpression.swift b/Sources/StructuredQueriesCore/SQLQueryExpression.swift index 6c962a29..dc92937c 100644 --- a/Sources/StructuredQueriesCore/SQLQueryExpression.swift +++ b/Sources/StructuredQueriesCore/SQLQueryExpression.swift @@ -35,4 +35,11 @@ public struct SQLQueryExpression: Statement { public init(_ expression: some QueryExpression) { self.queryFragment = expression.queryFragment } + + /// Creates a type erased query expression from a statement. + /// + /// - Parameter statement: A statement. + public init(_ statement: some Statement) { + self.queryFragment = statement.query + } } From c139073e41ea20d607d830db3d5a8654a7967c15 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 29 Apr 2025 18:03:38 -0700 Subject: [PATCH 3/3] Leverage seeds in test suite --- .../Support/Schema.swift | 233 ++++++++---------- 1 file changed, 102 insertions(+), 131 deletions(-) diff --git a/Tests/StructuredQueriesTests/Support/Schema.swift b/Tests/StructuredQueriesTests/Support/Schema.swift index f99405c2..8b4f0a23 100644 --- a/Tests/StructuredQueriesTests/Support/Schema.swift +++ b/Tests/StructuredQueriesTests/Support/Schema.swift @@ -70,7 +70,7 @@ extension Database { static func `default`() throws -> Database { let db = try Database() try db.migrate() - try db.createMockData() + try db.seedDatabase() return db } @@ -145,136 +145,107 @@ extension Database { ) } - func createMockData() throws { - try createDebugUsers() - try createDebugRemindersLists() - try createDebugReminders() - try createDebugTags() - } - - func createDebugUsers() throws { - try execute( - User.insert { - $0.name - } values: { - "Blob" - "Blob Jr" - "Blob Sr" - } - ) - } - - func createDebugRemindersLists() throws { - try execute( - RemindersList.insert { - ($0.color, $0.title) - } values: { - (0x4a99ef, "Personal") - (0xed8935, "Family") - (0xb25dd3, "Business") - } - ) - } - - func createDebugReminders() throws { - let now = Date(timeIntervalSinceReferenceDate: 0) - try execute( - Reminder.insert([ - Reminder.Draft( - assignedUserID: 1, - dueDate: now, - notes: """ - Milk, Eggs, Apples - """, - remindersListID: 1, - title: "Groceries" - ), - Reminder.Draft( - dueDate: now.addingTimeInterval(-60 * 60 * 24 * 2), - isFlagged: true, - remindersListID: 1, - title: "Haircut" - ), - Reminder.Draft( - dueDate: now, - notes: "Ask about diet", - priority: .high, - remindersListID: 1, - title: "Doctor appointment" - ), - Reminder.Draft( - dueDate: now.addingTimeInterval(-60 * 60 * 24 * 190), - isCompleted: true, - remindersListID: 1, - title: "Take a walk" - ), - Reminder.Draft( - remindersListID: 1, - title: "Buy concert tickets" - ), - Reminder.Draft( - dueDate: now.addingTimeInterval(60 * 60 * 24 * 2), - isFlagged: true, - priority: .high, - remindersListID: 2, - title: "Pick up kids from school" - ), - Reminder.Draft( - dueDate: now.addingTimeInterval(-60 * 60 * 24 * 2), - isCompleted: true, - priority: .low, - remindersListID: 2, - title: "Get laundry" - ), - Reminder.Draft( - dueDate: now.addingTimeInterval(60 * 60 * 24 * 4), - isCompleted: false, - priority: .high, - remindersListID: 2, - title: "Take out trash" - ), - Reminder.Draft( - dueDate: now.addingTimeInterval(60 * 60 * 24 * 2), - notes: """ - Status of tax return - Expenses for next year - Changing payroll company - """, - remindersListID: 3, - title: "Call accountant" - ), - Reminder.Draft( - dueDate: now.addingTimeInterval(-60 * 60 * 24 * 2), - isCompleted: true, - priority: .medium, - remindersListID: 3, - title: "Send weekly emails" - ), - ]) - ) - } - - func createDebugTags() throws { - try execute( - Tag.insert(\.title) { - "car" - "kids" - "someday" - "optional" - } - ) - try execute( - ReminderTag.insert { - ($0.reminderID, $0.tagID) - } values: { - (1, 3) - (1, 4) - (2, 3) - (2, 4) - (4, 1) - (4, 2) - } - ) + func seedDatabase() throws { + try Seeds { + User(id: 1, name: "Blob") + User(id: 2, name: "Blob Jr") + User(id: 3, name: "Blob Sr") + RemindersList(id: 1, color: 0x4a99ef, title: "Personal") + RemindersList(id: 2, color: 0xed8935, title: "Family") + RemindersList(id: 3, color: 0xb25dd3, title: "Business") + let now = Date(timeIntervalSinceReferenceDate: 0) + Reminder( + id: 1, + assignedUserID: 1, + dueDate: now, + notes: """ + Milk, Eggs, Apples + """, + remindersListID: 1, + title: "Groceries" + ) + Reminder( + id: 2, + dueDate: now.addingTimeInterval(-60 * 60 * 24 * 2), + isFlagged: true, + remindersListID: 1, + title: "Haircut" + ) + Reminder( + id: 3, + dueDate: now, + notes: "Ask about diet", + priority: .high, + remindersListID: 1, + title: "Doctor appointment" + ) + Reminder( + id: 4, + dueDate: now.addingTimeInterval(-60 * 60 * 24 * 190), + isCompleted: true, + remindersListID: 1, + title: "Take a walk" + ) + Reminder( + id: 5, + remindersListID: 1, + title: "Buy concert tickets" + ) + Reminder( + id: 6, + dueDate: now.addingTimeInterval(60 * 60 * 24 * 2), + isFlagged: true, + priority: .high, + remindersListID: 2, + title: "Pick up kids from school" + ) + Reminder( + id: 7, + dueDate: now.addingTimeInterval(-60 * 60 * 24 * 2), + isCompleted: true, + priority: .low, + remindersListID: 2, + title: "Get laundry" + ) + Reminder( + id: 8, + dueDate: now.addingTimeInterval(60 * 60 * 24 * 4), + isCompleted: false, + priority: .high, + remindersListID: 2, + title: "Take out trash" + ) + Reminder( + id: 9, + dueDate: now.addingTimeInterval(60 * 60 * 24 * 2), + notes: """ + Status of tax return + Expenses for next year + Changing payroll company + """, + remindersListID: 3, + title: "Call accountant" + ) + Reminder( + id: 10, + dueDate: now.addingTimeInterval(-60 * 60 * 24 * 2), + isCompleted: true, + priority: .medium, + remindersListID: 3, + title: "Send weekly emails" + ) + Tag(id: 1, title: "car") + Tag(id: 2, title: "kids") + Tag(id: 3, title: "someday") + Tag(id: 4, title: "optional") + ReminderTag(reminderID: 1, tagID: 3) + ReminderTag(reminderID: 1, tagID: 4) + ReminderTag(reminderID: 2, tagID: 3) + ReminderTag(reminderID: 2, tagID: 4) + ReminderTag(reminderID: 4, tagID: 1) + ReminderTag(reminderID: 4, tagID: 2) + } + .forEach(execute) } }