diff --git a/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md b/Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md index 396c2369..64cc6b64 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,7 +414,7 @@ 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, @@ -423,8 +423,63 @@ RemindersList } ``` +> 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. +> * 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, 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 milestones: [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(Milestone.all) { $0.id.eq($1.remindersListID) } + .leftJoin(Reminder.all) { $0.id.eq($2.remindersListID) } + .select { + Row.Columns( + remindersList: $0, + milestones: $1.jsonGroupArray(isDistinct: true), + reminders: $2.jsonGroupArray(isDistinct: true) + ) + } +``` + +> 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/Sources/StructuredQueriesCore/SQLite/JSONFunctions.swift b/Sources/StructuredQueriesCore/SQLite/JSONFunctions.swift index 268d0503..b87d3d55 100644 --- a/Sources/StructuredQueriesCore/SQLite/JSONFunctions.swift +++ b/Sources/StructuredQueriesCore/SQLite/JSONFunctions.swift @@ -110,8 +110,81 @@ extension PrimaryKeyedTableDefinition where QueryValue: Codable & Sendable { filter: filter?.queryFragment ) } +} + +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, + 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 +216,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 ddbf57ae..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,24 +214,27 @@ extension SnapshotTests { assertQuery( RemindersList .group(by: \.id) - .leftJoin(Reminder.incomplete) { $0.id.eq($1.remindersListID) } - .select { remindersList, reminder in + .leftJoin(Milestone.all) { $0.id.eq($1.remindersListID) } + .leftJoin(Reminder.incomplete) { $0.id.eq($2.remindersListID) } + .select { RemindersListRow.Columns( - remindersList: remindersList, - reminders: #sql("\(reminder.jsonGroupArray())") + remindersList: $0, + milestones: $1.jsonGroupArray(isDistinct: true), + reminders: $2.jsonGroupArray(isDistinct: true) ) } .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 "milestones" ON ("remindersLists"."id" = "milestones"."remindersListID") LEFT JOIN "reminders" ON ("remindersLists"."id" = "reminders"."remindersListID") WHERE NOT ("reminders"."isCompleted") GROUP BY "remindersLists"."id" LIMIT 1 """ - } results: { + }results: { """ ┌────────────────────────────────────────────────┐ │ RemindersListRow( │ @@ -240,6 +243,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, │ @@ -295,7 +315,7 @@ extension SnapshotTests { } @Selection -private struct ReminderRow { +private struct ReminderRow: Codable { let assignedUser: User? let reminder: Reminder @Column(as: [Tag].JSONRepresentation.self) @@ -305,6 +325,8 @@ 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] } diff --git a/Tests/StructuredQueriesTests/Support/Schema.swift b/Tests/StructuredQueriesTests/Support/Schema.swift index 162ce25f..0fc6ef23 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() @@ -135,12 +141,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 '' + ) """ ) } @@ -244,6 +249,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) }