From f849af39121cff5b7b39d25019e98e4128f683ff Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Fri, 30 May 2025 11:33:28 -0700 Subject: [PATCH 01/10] Add docs about pre-loading multiple associations. --- .../Articles/QueryCookbook.md | 65 +++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md b/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md index 396c2369..f5202422 100644 --- a/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md +++ b/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md @@ -349,7 +349,7 @@ table. For example, querying for all reminders lists along with an array of the list. We'd like to be able to query for this data and decode it into a collection of values from the following data type: -```struct +```swift struct Row { let remindersList: RemindersList let reminders: [Reminder] @@ -395,7 +395,7 @@ Another way to do this is to use the `@Selection` macro described above (), along with a ``Swift/Decodable/JSONRepresentation`` of the collection of reminders you want to load for each list: -```struct +```swift @Selection struct Row { let remindersList: RemindersList @@ -414,17 +414,74 @@ columns of [primary keyed tables](): ```swift RemindersList - .join(Reminder.all) { $0.id.eq($1.remindersListID) } + .leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) } .select { Row.Columns( remindersList: $0, - reminders: $1.jsonGroupArray() + reminders: #sql("\($1.jsonGroupArray(filter: $1.id.isNot(nil)))") ) } ``` +> Note: There are 3 important things to note about this query: +> +> * Since not every reminders list will have a reminder associated with it, we are using a +> ``Select/leftJoin(_:on:)``. That will make sure to select all lists no matter what. +> * We are using `jsonGroupArray` to encode all reminders associated with a list into a JSON object +> and bundle them into an array. And because it's possible to have no reminders in a list, +> we further use the `filter` option to remove any NULL values from the array. +> * And lastly, `$1` represents an optionalized table due to the left join, and hence the +> `$1.jsonGroupArray(…)` expression actually returns an _optional_ array of reminders. But due +> to how `json_group_array` works in SQL we can be guaranteed that it always returns an array, +> and never NULL, and so we are using the `#sql` macro as a quick escape hatch to take +> responsibility for the types in this expression. + This allows you to fetch all of the data in a single SQLite query and decode the data into a collection of `Row` values. There is an extra cost associated with decoding the JSON object, but that cost may be smaller than executing multiple SQLite requests and transforming the data into `Row` manually, not to mention the additional code you need to write and maintain to process the data. + +It is even possible to load multiple associations at once. For example, suppose that there is a +`Milestone` table that is associated with a `RemindersList`: + +```swift +@Table +struct Milestone: Identifiable, Codable { + let id: Int + var title = "" + var remindersListID: RemindersList.ID +} +``` + +And suppose you would like to fetch all `RemindersList`s along with the collection of all milestones +and reminders associated with the list: + +```struct +@Selection +struct Row { + let remindersList: RemindersList + @Column(as: [Milestone].JSONRepresentation.self) + let milestons: [Milestone] + @Column(as: [Reminder].JSONRepresentation.self) + let reminders: [Reminder] +} +``` + +It is possible to do this using two left joins and two `jsonGroupArray`s: + +```swift +RemindersList + .leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) } + .leftJoin(Milestone.all) { $0.id.eq($2.remindersListID) } + .select { + Row.Columns( + remindersList: $0, + reminders: #sql("\($1.jsonGroupArray(filter: $1.id.isNot(nil))"), + reminders: #sql("\($2.jsonGroupArray(filter: $2.id.isNot(nil))") + ) + } +``` + +This will now load all reminders lists with all of their reminders and milestones in one single +SQL query. From 4aa00d5599e341b8d590fd2982b8b8ad7ced5ef0 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Sat, 31 May 2025 06:54:29 -0700 Subject: [PATCH 02/10] wip --- .../Documentation.docc/Articles/QueryCookbook.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md b/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md index f5202422..f01226cc 100644 --- a/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md +++ b/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md @@ -472,12 +472,12 @@ It is possible to do this using two left joins and two `jsonGroupArray`s: ```swift RemindersList - .leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) } - .leftJoin(Milestone.all) { $0.id.eq($2.remindersListID) } + .leftJoin(Milestone.all) { $0.id.eq($1.remindersListID) } + .leftJoin(Reminder.all) { $0.id.eq($12.remindersListID) } .select { Row.Columns( remindersList: $0, - reminders: #sql("\($1.jsonGroupArray(filter: $1.id.isNot(nil))"), + milestons: #sql("\($1.jsonGroupArray(filter: $1.id.isNot(nil))"), reminders: #sql("\($2.jsonGroupArray(filter: $2.id.isNot(nil))") ) } From 53ce867b0f832a9ae27ac58f2a63f418fe112b24 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Sat, 31 May 2025 08:54:28 -0700 Subject: [PATCH 03/10] Update Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md --- .../Documentation.docc/Articles/QueryCookbook.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md b/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md index f01226cc..8d0ba6bc 100644 --- a/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md +++ b/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md @@ -473,7 +473,7 @@ It is possible to do this using two left joins and two `jsonGroupArray`s: ```swift RemindersList .leftJoin(Milestone.all) { $0.id.eq($1.remindersListID) } - .leftJoin(Reminder.all) { $0.id.eq($12.remindersListID) } + .leftJoin(Reminder.all) { $0.id.eq($2.remindersListID) } .select { Row.Columns( remindersList: $0, From 7de0bef4ca8ad250bd121d21827d1845ec38ac9c Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Sat, 31 May 2025 09:05:18 -0700 Subject: [PATCH 04/10] Update QueryCookbook.md --- .../Documentation.docc/Articles/QueryCookbook.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md b/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md index 8d0ba6bc..e1bd96f0 100644 --- a/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md +++ b/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md @@ -429,12 +429,12 @@ RemindersList > ``Select/leftJoin(_:on:)``. That will make sure to select all lists no matter what. > * We are using `jsonGroupArray` to encode all reminders associated with a list into a JSON object > and bundle them into an array. And because it's possible to have no reminders in a list, -> we further use the `filter` option to remove any NULL values from the array. +> we further use the `filter` option to remove any `NULL` values from the array. > * And lastly, `$1` represents an optionalized table due to the left join, and hence the -> `$1.jsonGroupArray(…)` expression actually returns an _optional_ array of reminders. But due -> to how `json_group_array` works in SQL we can be guaranteed that it always returns an array, -> and never NULL, and so we are using the `#sql` macro as a quick escape hatch to take -> responsibility for the types in this expression. +> `$1.jsonGroupArray(…)` expression actually returns an array of _optional_ reminders. But +> because we are filtering out `NULL` values we can be guaranteed that every element of the +> array can be decoded to a reminder, and so we are using the `#sql` macro as a quick escape +> hatch to take responsibility for the types in this expression. This allows you to fetch all of the data in a single SQLite query and decode the data into a collection of `Row` values. There is an extra cost associated with decoding the JSON object, From aae7ff49ee97aa37607e2edb09831a40a777fdf9 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Sat, 31 May 2025 09:06:01 -0700 Subject: [PATCH 05/10] Update QueryCookbook.md --- .../Documentation.docc/Articles/QueryCookbook.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md b/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md index e1bd96f0..be6002d3 100644 --- a/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md +++ b/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md @@ -462,7 +462,7 @@ and reminders associated with the list: struct Row { let remindersList: RemindersList @Column(as: [Milestone].JSONRepresentation.self) - let milestons: [Milestone] + let milestones: [Milestone] @Column(as: [Reminder].JSONRepresentation.self) let reminders: [Reminder] } @@ -477,7 +477,7 @@ RemindersList .select { Row.Columns( remindersList: $0, - milestons: #sql("\($1.jsonGroupArray(filter: $1.id.isNot(nil))"), + milestones: #sql("\($1.jsonGroupArray(filter: $1.id.isNot(nil))"), reminders: #sql("\($2.jsonGroupArray(filter: $2.id.isNot(nil))") ) } From c0eb2518f8cb7ae3f92263bd691a0cad25f88ff4 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 2 Jun 2025 07:49:19 -0700 Subject: [PATCH 06/10] wip --- .../JSONFunctionsTests.swift | 84 +++++++++++++++++-- .../Support/Schema.swift | 20 +++-- 2 files changed, 93 insertions(+), 11 deletions(-) diff --git a/Tests/StructuredQueriesTests/JSONFunctionsTests.swift b/Tests/StructuredQueriesTests/JSONFunctionsTests.swift index fd4b5dd2..466f0168 100644 --- a/Tests/StructuredQueriesTests/JSONFunctionsTests.swift +++ b/Tests/StructuredQueriesTests/JSONFunctionsTests.swift @@ -215,23 +215,30 @@ extension SnapshotTests { RemindersList .group(by: \.id) .leftJoin(Reminder.incomplete) { $0.id.eq($1.remindersListID) } - .select { remindersList, reminder in + .leftJoin(Milestone.all) { $0.id.eq($2.remindersListID) } + .select { remindersList, reminder, milestone in RemindersListRow.Columns( remindersList: remindersList, - reminders: #sql("\(reminder.jsonGroupArray())") + milestones: #sql( + "\(milestone.jsonGroupArray(isDistinct: true, filter: milestone.id.isNot(nil)))" + ), + reminders: #sql( + "\(reminder.jsonGroupArray(isDistinct: true, filter: reminder.id.isNot(nil)))" + ) ) } .limit(1) ) { """ - SELECT "remindersLists"."id", "remindersLists"."color", "remindersLists"."title" AS "remindersList", json_group_array(CASE WHEN ("reminders"."id" IS NOT NULL) THEN json_object('id', json_quote("reminders"."id"), 'assignedUserID', json_quote("reminders"."assignedUserID"), 'dueDate', json_quote("reminders"."dueDate"), 'isCompleted', json(CASE "reminders"."isCompleted" WHEN 0 THEN 'false' WHEN 1 THEN 'true' END), 'isFlagged', json(CASE "reminders"."isFlagged" WHEN 0 THEN 'false' WHEN 1 THEN 'true' END), 'notes', json_quote("reminders"."notes"), 'priority', json_quote("reminders"."priority"), 'remindersListID', json_quote("reminders"."remindersListID"), 'title', json_quote("reminders"."title")) END) AS "reminders" + SELECT "remindersLists"."id", "remindersLists"."color", "remindersLists"."title" AS "remindersList", json_group_array(DISTINCT CASE WHEN ("milestones"."id" IS NOT NULL) THEN json_object('id', json_quote("milestones"."id"), 'remindersListID', json_quote("milestones"."remindersListID"), 'title', json_quote("milestones"."title")) END) FILTER (WHERE ("milestones"."id" IS NOT NULL)) AS "milestones", json_group_array(DISTINCT CASE WHEN ("reminders"."id" IS NOT NULL) THEN json_object('id', json_quote("reminders"."id"), 'assignedUserID', json_quote("reminders"."assignedUserID"), 'dueDate', json_quote("reminders"."dueDate"), 'isCompleted', json(CASE "reminders"."isCompleted" WHEN 0 THEN 'false' WHEN 1 THEN 'true' END), 'isFlagged', json(CASE "reminders"."isFlagged" WHEN 0 THEN 'false' WHEN 1 THEN 'true' END), 'notes', json_quote("reminders"."notes"), 'priority', json_quote("reminders"."priority"), 'remindersListID', json_quote("reminders"."remindersListID"), 'title', json_quote("reminders"."title")) END) FILTER (WHERE ("reminders"."id" IS NOT NULL)) AS "reminders" FROM "remindersLists" LEFT JOIN "reminders" ON ("remindersLists"."id" = "reminders"."remindersListID") + LEFT JOIN "milestones" ON ("remindersLists"."id" = "milestones"."remindersListID") WHERE NOT ("reminders"."isCompleted") GROUP BY "remindersLists"."id" LIMIT 1 """ - } results: { + }results: { """ ┌────────────────────────────────────────────────┐ │ RemindersListRow( │ @@ -240,6 +247,23 @@ extension SnapshotTests { │ color: 4889071, │ │ title: "Personal" │ │ ), │ + │ milestones: [ │ + │ [0]: Milestone( │ + │ id: 1, │ + │ remindersListID: 1, │ + │ title: "Phase 1" │ + │ ), │ + │ [1]: Milestone( │ + │ id: 2, │ + │ remindersListID: 1, │ + │ title: "Phase 2" │ + │ ), │ + │ [2]: Milestone( │ + │ id: 3, │ + │ remindersListID: 1, │ + │ title: "Phase 3" │ + │ ) │ + │ ], │ │ reminders: [ │ │ [0]: Reminder( │ │ id: 1, │ @@ -291,11 +315,52 @@ extension SnapshotTests { """ } } + + // @Test func jsonAssociation_All() throws { + // assertQuery( + // RemindersList + // .group(by: \.id) + // .leftJoin(Reminder.incomplete) { $0.id.eq($1.remindersListID) + // } + // .select { + // remindersList, + // reminder in + // Row.Columns( + // remindersList: remindersList, + // reminderRows: ReminderTag + // .where { $0.reminderID.eq(reminder.id) + // } + // .join(Tag.all) { $0.tagID.eq($1.id) } + // .select { + // _, + // tag in + // ReminderRow.Columns.init( + // assignedUser: #sql(""), + // reminder: #sql("\(reminder)"), + // tags: tag.title.jsonGroupArray() + // ) + // } + // + //// ReminderRow.Columns( + //// assignedUser: #sql(""), + //// reminder: reminder, + //// tags: #sql("") + ////// ReminderTag + ////// .where { $0.reminderID.eq(reminder.id) } + ////// .join(Tag.all) { $0.tagID.eq($1.id) } + ////// .select { _, tag in tag } + //// ) + //// .json + // ) + // } + // .limit(1) + // ) + // } } } @Selection -private struct ReminderRow { +private struct ReminderRow: Codable { let assignedUser: User? let reminder: Reminder @Column(as: [Tag].JSONRepresentation.self) @@ -305,6 +370,15 @@ private struct ReminderRow { @Selection private struct RemindersListRow { let remindersList: RemindersList + @Column(as: [Milestone].JSONRepresentation.self) + let milestones: [Milestone] @Column(as: [Reminder].JSONRepresentation.self) let reminders: [Reminder] } + +@Selection +private struct Row { + let remindersList: RemindersList + @Column(as: [ReminderRow].JSONRepresentation.self) + let reminderRows: [ReminderRow] +} diff --git a/Tests/StructuredQueriesTests/Support/Schema.swift b/Tests/StructuredQueriesTests/Support/Schema.swift index d6766567..fd7aca27 100644 --- a/Tests/StructuredQueriesTests/Support/Schema.swift +++ b/Tests/StructuredQueriesTests/Support/Schema.swift @@ -65,6 +65,12 @@ struct ReminderTag: Equatable { let tagID: Int } +@Table struct Milestone: Codable, Equatable { + let id: Int + var remindersListID: RemindersList.ID + var title = "" +} + extension Database { static func `default`() throws -> Database { let db = try Database() @@ -134,12 +140,11 @@ extension Database { ) try execute( """ - CREATE INDEX "index_remindersTags_on_reminderID" ON "remindersTags"("reminderID") - """ - ) - try execute( - """ - CREATE INDEX "index_remindersTags_on_tagID" ON "remindersTags"("tagID") + CREATE TABLE "milestones" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE, + "title" TEXT NOT NULL DEFAULT '' + ) """ ) } @@ -243,6 +248,9 @@ extension Database { ReminderTag(reminderID: 2, tagID: 4) ReminderTag(reminderID: 4, tagID: 1) ReminderTag(reminderID: 4, tagID: 2) + Milestone.Draft(remindersListID: 1, title: "Phase 1") + Milestone.Draft(remindersListID: 1, title: "Phase 2") + Milestone.Draft(remindersListID: 1, title: "Phase 3") } .forEach(execute) } From 81ff6944283105d645854eb4283646261323b80a Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 2 Jun 2025 07:49:35 -0700 Subject: [PATCH 07/10] wip --- .../JSONFunctionsTests.swift | 48 ------------------- 1 file changed, 48 deletions(-) diff --git a/Tests/StructuredQueriesTests/JSONFunctionsTests.swift b/Tests/StructuredQueriesTests/JSONFunctionsTests.swift index 466f0168..73a6f766 100644 --- a/Tests/StructuredQueriesTests/JSONFunctionsTests.swift +++ b/Tests/StructuredQueriesTests/JSONFunctionsTests.swift @@ -315,47 +315,6 @@ extension SnapshotTests { """ } } - - // @Test func jsonAssociation_All() throws { - // assertQuery( - // RemindersList - // .group(by: \.id) - // .leftJoin(Reminder.incomplete) { $0.id.eq($1.remindersListID) - // } - // .select { - // remindersList, - // reminder in - // Row.Columns( - // remindersList: remindersList, - // reminderRows: ReminderTag - // .where { $0.reminderID.eq(reminder.id) - // } - // .join(Tag.all) { $0.tagID.eq($1.id) } - // .select { - // _, - // tag in - // ReminderRow.Columns.init( - // assignedUser: #sql(""), - // reminder: #sql("\(reminder)"), - // tags: tag.title.jsonGroupArray() - // ) - // } - // - //// ReminderRow.Columns( - //// assignedUser: #sql(""), - //// reminder: reminder, - //// tags: #sql("") - ////// ReminderTag - ////// .where { $0.reminderID.eq(reminder.id) } - ////// .join(Tag.all) { $0.tagID.eq($1.id) } - ////// .select { _, tag in tag } - //// ) - //// .json - // ) - // } - // .limit(1) - // ) - // } } } @@ -375,10 +334,3 @@ private struct RemindersListRow { @Column(as: [Reminder].JSONRepresentation.self) let reminders: [Reminder] } - -@Selection -private struct Row { - let remindersList: RemindersList - @Column(as: [ReminderRow].JSONRepresentation.self) - let reminderRows: [ReminderRow] -} From 00d5cf3c567fa6a69774d2c9b9031de4bca24422 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Mon, 2 Jun 2025 07:53:23 -0700 Subject: [PATCH 08/10] fixes --- .../Documentation.docc/Articles/QueryCookbook.md | 8 ++++++-- Tests/StructuredQueriesTests/JSONFunctionsTests.swift | 4 ++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md b/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md index be6002d3..a2ed425f 100644 --- a/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md +++ b/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md @@ -477,11 +477,15 @@ RemindersList .select { Row.Columns( remindersList: $0, - milestones: #sql("\($1.jsonGroupArray(filter: $1.id.isNot(nil))"), - reminders: #sql("\($2.jsonGroupArray(filter: $2.id.isNot(nil))") + milestones: #sql("\($1.jsonGroupArray(isDistinct: true, filter: $1.id.isNot(nil))"), + reminders: #sql("\($2.jsonGroupArray(isDistinct: true, filter: $2.id.isNot(nil))") ) } ``` +> Note: Because we are now joining two independent tables to `RemindersList`, we will get duplicate +> entries for all pairs of reminders with milestones. To remove those duplicates we use the +> `isDistinct` option for `jsonGroupArray`. + This will now load all reminders lists with all of their reminders and milestones in one single SQL query. diff --git a/Tests/StructuredQueriesTests/JSONFunctionsTests.swift b/Tests/StructuredQueriesTests/JSONFunctionsTests.swift index 73a6f766..a895b892 100644 --- a/Tests/StructuredQueriesTests/JSONFunctionsTests.swift +++ b/Tests/StructuredQueriesTests/JSONFunctionsTests.swift @@ -214,9 +214,9 @@ extension SnapshotTests { assertQuery( RemindersList .group(by: \.id) - .leftJoin(Reminder.incomplete) { $0.id.eq($1.remindersListID) } .leftJoin(Milestone.all) { $0.id.eq($2.remindersListID) } - .select { remindersList, reminder, milestone in + .leftJoin(Reminder.incomplete) { $0.id.eq($1.remindersListID) } + .select { remindersList, milestone, reminder in RemindersListRow.Columns( remindersList: remindersList, milestones: #sql( From 3b9c37e8de86654f1844e28ae47f8ff8864ba9d3 Mon Sep 17 00:00:00 2001 From: Brandon Williams Date: Tue, 17 Jun 2025 10:23:15 -0700 Subject: [PATCH 09/10] wip --- .../SQLite/JSONFunctions.swift | 30 +++++++++++++++++-- .../JSONFunctionsTests.swift | 24 +++++++-------- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/Sources/StructuredQueriesCore/SQLite/JSONFunctions.swift b/Sources/StructuredQueriesCore/SQLite/JSONFunctions.swift index 268d0503..73144881 100644 --- a/Sources/StructuredQueriesCore/SQLite/JSONFunctions.swift +++ b/Sources/StructuredQueriesCore/SQLite/JSONFunctions.swift @@ -110,8 +110,32 @@ extension PrimaryKeyedTableDefinition where QueryValue: Codable & Sendable { filter: filter?.queryFragment ) } +} + + +extension PrimaryKeyedTableDefinition { + public func jsonGroupArray( + isDistinct: Bool = false, + order: (some QueryExpression)? = Bool?.none, + filter: (some QueryExpression)? = Bool?.none + ) -> some QueryExpression<[Wrapped].JSONRepresentation> + where QueryValue == Wrapped? + { + let filterQueryFragment = if let filter { + self.primaryKey.isNot(nil).and(filter).queryFragment + } else { + self.primaryKey.isNot(nil).queryFragment + } + return AggregateFunction( + "json_group_array", + isDistinct: isDistinct, + [jsonObject.queryFragment], + order: order?.queryFragment, + filter: filterQueryFragment + ) + } - private var jsonObject: some QueryExpression { + fileprivate var jsonObject: some QueryExpression { func open(_ column: TableColumn) -> QueryFragment { typealias Value = TableColumn.QueryValue._Optionalized.Wrapped @@ -143,8 +167,8 @@ extension PrimaryKeyedTableDefinition where QueryValue: Codable & Sendable { } else if Value.self == Date.JulianDayRepresentation.self { return "\(quote: column.name, delimiter: .text), datetime(\(column), 'julianday')" } else if let codableType = TableColumn.QueryValue.QueryOutput.self - as? any (Codable & Sendable).Type, - isJSONRepresentation(codableType) + as? any (Codable & Sendable).Type, + isJSONRepresentation(codableType) { return "\(quote: column.name, delimiter: .text), json(\(column))" } else { diff --git a/Tests/StructuredQueriesTests/JSONFunctionsTests.swift b/Tests/StructuredQueriesTests/JSONFunctionsTests.swift index 5e654ff6..611910dd 100644 --- a/Tests/StructuredQueriesTests/JSONFunctionsTests.swift +++ b/Tests/StructuredQueriesTests/JSONFunctionsTests.swift @@ -136,13 +136,13 @@ extension SnapshotTests { ReminderRow.Columns( assignedUser: user, reminder: reminder, - tags: #sql("\(tag.jsonGroupArray())") + tags: tag.jsonGroupArray() ) } .limit(2) ) { """ - SELECT "users"."id", "users"."name" AS "assignedUser", "reminders"."id", "reminders"."assignedUserID", "reminders"."dueDate", "reminders"."isCompleted", "reminders"."isFlagged", "reminders"."notes", "reminders"."priority", "reminders"."remindersListID", "reminders"."title" AS "reminder", json_group_array(CASE WHEN ("tags"."id" IS NOT NULL) THEN json_object('id', json_quote("tags"."id"), 'title', json_quote("tags"."title")) END) AS "tags" + SELECT "users"."id", "users"."name" AS "assignedUser", "reminders"."id", "reminders"."assignedUserID", "reminders"."dueDate", "reminders"."isCompleted", "reminders"."isFlagged", "reminders"."notes", "reminders"."priority", "reminders"."remindersListID", "reminders"."title" AS "reminder", json_group_array(CASE WHEN ("tags"."id" IS NOT NULL) THEN json_object('id', json_quote("tags"."id"), 'title', json_quote("tags"."title")) END) FILTER (WHERE ("tags"."id" IS NOT NULL)) AS "tags" FROM "reminders" LEFT JOIN "remindersTags" ON ("reminders"."id" = "remindersTags"."reminderID") LEFT JOIN "tags" ON ("remindersTags"."tagID" = "tags"."id") @@ -150,7 +150,7 @@ extension SnapshotTests { GROUP BY "reminders"."id" LIMIT 2 """ - } results: { + }results: { """ ┌──────────────────────────────────────────────┐ │ ReminderRow( │ @@ -214,17 +214,13 @@ extension SnapshotTests { assertQuery( RemindersList .group(by: \.id) - .leftJoin(Milestone.all) { $0.id.eq($2.remindersListID) } - .leftJoin(Reminder.incomplete) { $0.id.eq($1.remindersListID) } - .select { remindersList, milestone, reminder in + .leftJoin(Milestone.all) { $0.id.eq($1.remindersListID) } + .leftJoin(Reminder.incomplete) { $0.id.eq($2.remindersListID) } + .select { RemindersListRow.Columns( - remindersList: remindersList, - milestones: #sql( - "\(milestone.jsonGroupArray(isDistinct: true, filter: milestone.id.isNot(nil)))" - ), - reminders: #sql( - "\(reminder.jsonGroupArray(isDistinct: true, filter: reminder.id.isNot(nil)))" - ) + remindersList: $0, + milestones: $1.jsonGroupArray(isDistinct: true), + reminders: $2.jsonGroupArray(isDistinct: true) ) } .limit(1) @@ -232,8 +228,8 @@ extension SnapshotTests { """ SELECT "remindersLists"."id", "remindersLists"."color", "remindersLists"."title" AS "remindersList", json_group_array(DISTINCT CASE WHEN ("milestones"."id" IS NOT NULL) THEN json_object('id', json_quote("milestones"."id"), 'remindersListID', json_quote("milestones"."remindersListID"), 'title', json_quote("milestones"."title")) END) FILTER (WHERE ("milestones"."id" IS NOT NULL)) AS "milestones", json_group_array(DISTINCT CASE WHEN ("reminders"."id" IS NOT NULL) THEN json_object('id', json_quote("reminders"."id"), 'assignedUserID', json_quote("reminders"."assignedUserID"), 'dueDate', json_quote("reminders"."dueDate"), 'isCompleted', json(CASE "reminders"."isCompleted" WHEN 0 THEN 'false' WHEN 1 THEN 'true' END), 'isFlagged', json(CASE "reminders"."isFlagged" WHEN 0 THEN 'false' WHEN 1 THEN 'true' END), 'notes', json_quote("reminders"."notes"), 'priority', json_quote("reminders"."priority"), 'remindersListID', json_quote("reminders"."remindersListID"), 'title', json_quote("reminders"."title")) END) FILTER (WHERE ("reminders"."id" IS NOT NULL)) AS "reminders" FROM "remindersLists" - LEFT JOIN "reminders" ON ("remindersLists"."id" = "reminders"."remindersListID") LEFT JOIN "milestones" ON ("remindersLists"."id" = "milestones"."remindersListID") + LEFT JOIN "reminders" ON ("remindersLists"."id" = "reminders"."remindersListID") WHERE NOT ("reminders"."isCompleted") GROUP BY "remindersLists"."id" LIMIT 1 From 396e23e93acf26ef3d7cf97e8496143329134dd5 Mon Sep 17 00:00:00 2001 From: Stephen Celis Date: Tue, 17 Jun 2025 12:02:17 -0700 Subject: [PATCH 10/10] wip --- .../Articles/QueryCookbook.md | 18 +++---- .../SQLite/JSONFunctions.swift | 51 ++++++++++++++++++- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md b/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md index a2ed425f..64cc6b64 100644 --- a/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md +++ b/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md @@ -418,23 +418,17 @@ RemindersList .select { Row.Columns( remindersList: $0, - reminders: #sql("\($1.jsonGroupArray(filter: $1.id.isNot(nil)))") + reminders: $1.jsonGroupArray() ) } ``` -> Note: There are 3 important things to note about this query: +> Note: There are 2 important things to note about this query: > > * Since not every reminders list will have a reminder associated with it, we are using a > ``Select/leftJoin(_:on:)``. That will make sure to select all lists no matter what. -> * We are using `jsonGroupArray` to encode all reminders associated with a list into a JSON object -> and bundle them into an array. And because it's possible to have no reminders in a list, -> we further use the `filter` option to remove any `NULL` values from the array. -> * And lastly, `$1` represents an optionalized table due to the left join, and hence the -> `$1.jsonGroupArray(…)` expression actually returns an array of _optional_ reminders. But -> because we are filtering out `NULL` values we can be guaranteed that every element of the -> array can be decoded to a reminder, and so we are using the `#sql` macro as a quick escape -> hatch to take responsibility for the types in this expression. +> * The left join introduces _optional_ reminders, but we are using a special overload of +> `jsonGroupArray` on optionals that automatically filters out `nil` reminders and unwraps them. This allows you to fetch all of the data in a single SQLite query and decode the data into a collection of `Row` values. There is an extra cost associated with decoding the JSON object, @@ -477,8 +471,8 @@ RemindersList .select { Row.Columns( remindersList: $0, - milestones: #sql("\($1.jsonGroupArray(isDistinct: true, filter: $1.id.isNot(nil))"), - reminders: #sql("\($2.jsonGroupArray(isDistinct: true, filter: $2.id.isNot(nil))") + milestones: $1.jsonGroupArray(isDistinct: true), + reminders: $2.jsonGroupArray(isDistinct: true) ) } ``` diff --git a/Sources/StructuredQueriesCore/SQLite/JSONFunctions.swift b/Sources/StructuredQueriesCore/SQLite/JSONFunctions.swift index 73144881..b87d3d55 100644 --- a/Sources/StructuredQueriesCore/SQLite/JSONFunctions.swift +++ b/Sources/StructuredQueriesCore/SQLite/JSONFunctions.swift @@ -112,8 +112,57 @@ extension PrimaryKeyedTableDefinition where QueryValue: Codable & Sendable { } } - extension PrimaryKeyedTableDefinition { + /// A JSON array representation of the aggregation of a table's columns. + /// + /// Constructs a JSON array of JSON objects with a field for each column of the table. This can be + /// useful for loading many associated values in a single query. For example, to query for every + /// reminders list, along with the array of reminders it is associated with, one can define a + /// custom `@Selection` for that data and query as follows: + /// + /// @Row { + /// @Column { + /// ```swift + /// @Selection struct Row { + /// let remindersList: RemindersList + /// @Column(as: JSONRepresentation<[Reminder]>.self) + /// let reminders: [Reminder] + /// } + /// RemindersList + /// .leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) } + /// .select { + /// Row.Columns( + /// remindersList: $0, + /// reminders: $1.jsonGroupArray() + /// ) + /// } + /// ``` + /// } + /// @Column { + /// ```sql + /// SELECT + /// "remindersLists".…, + /// iif( + /// "reminders"."id" IS NULL, + /// NULL, + /// json_object( + /// 'id', json_quote("id"), + /// 'title', json_quote("title"), + /// 'priority', json_quote("priority") + /// ) + /// ) + /// FROM "remindersLists" + /// JOIN "reminders" + /// ON ("remindersLists"."id" = "reminders"."remindersListID") + /// ``` + /// } + /// } + /// + /// - Parameters: + /// - isDistinct: An boolean to enable the `DISTINCT` clause to apply to the aggregation. + /// - order: An `ORDER BY` clause to apply to the aggregation. + /// - filter: A `FILTER` clause to apply to the aggregation. + /// - Returns: A JSON array aggregate of this table. public func jsonGroupArray( isDistinct: Bool = false, order: (some QueryExpression)? = Bool?.none,