Skip to content

Commit a06a929

Browse files
jsonGroupArray Improvements (pointfreeco#60)
Added: * An isDistinct option * An overload on optional tables that automatically filters out nil values and unwraps them * Documentation Co-authored-by: Stephen Celis <[email protected]>
1 parent 3cd8948 commit a06a929

File tree

4 files changed

+180
-22
lines changed

4 files changed

+180
-22
lines changed

Sources/StructuredQueriesCore/Documentation.docc/Articles/QueryCookbook.md

Lines changed: 58 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ table. For example, querying for all reminders lists along with an array of the
349349
list. We'd like to be able to query for this data and decode it into a collection of values
350350
from the following data type:
351351

352-
```struct
352+
```swift
353353
struct Row {
354354
let remindersList: RemindersList
355355
let reminders: [Reminder]
@@ -395,7 +395,7 @@ Another way to do this is to use the `@Selection` macro described above
395395
(<doc:QueryCookbook#Custom-selections>), along with a ``Swift/Decodable/JSONRepresentation`` of the
396396
collection of reminders you want to load for each list:
397397

398-
```struct
398+
```swift
399399
@Selection
400400
struct Row {
401401
let remindersList: RemindersList
@@ -414,7 +414,7 @@ columns of [primary keyed tables](<doc:PrimaryKeyedTables>):
414414

415415
```swift
416416
RemindersList
417-
.join(Reminder.all) { $0.id.eq($1.remindersListID) }
417+
.leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) }
418418
.select {
419419
Row.Columns(
420420
remindersList: $0,
@@ -423,8 +423,63 @@ RemindersList
423423
}
424424
```
425425

426+
> Note: There are 2 important things to note about this query:
427+
>
428+
> * Since not every reminders list will have a reminder associated with it, we are using a
429+
> ``Select/leftJoin(_:on:)``. That will make sure to select all lists no matter what.
430+
> * The left join introduces _optional_ reminders, but we are using a special overload of
431+
> `jsonGroupArray` on optionals that automatically filters out `nil` reminders and unwraps them.
432+
426433
This allows you to fetch all of the data in a single SQLite query and decode the data into a
427434
collection of `Row` values. There is an extra cost associated with decoding the JSON object,
428435
but that cost may be smaller than executing multiple SQLite requests and transforming the data
429436
into `Row` manually, not to mention the additional code you need to write and maintain to process
430437
the data.
438+
439+
It is even possible to load multiple associations at once. For example, suppose that there is a
440+
`Milestone` table that is associated with a `RemindersList`:
441+
442+
```swift
443+
@Table
444+
struct Milestone: Identifiable, Codable {
445+
let id: Int
446+
var title = ""
447+
var remindersListID: RemindersList.ID
448+
}
449+
```
450+
451+
And suppose you would like to fetch all `RemindersList`s along with the collection of all milestones
452+
and reminders associated with the list:
453+
454+
```struct
455+
@Selection
456+
struct Row {
457+
let remindersList: RemindersList
458+
@Column(as: [Milestone].JSONRepresentation.self)
459+
let milestones: [Milestone]
460+
@Column(as: [Reminder].JSONRepresentation.self)
461+
let reminders: [Reminder]
462+
}
463+
```
464+
465+
It is possible to do this using two left joins and two `jsonGroupArray`s:
466+
467+
```swift
468+
RemindersList
469+
.leftJoin(Milestone.all) { $0.id.eq($1.remindersListID) }
470+
.leftJoin(Reminder.all) { $0.id.eq($2.remindersListID) }
471+
.select {
472+
Row.Columns(
473+
remindersList: $0,
474+
milestones: $1.jsonGroupArray(isDistinct: true),
475+
reminders: $2.jsonGroupArray(isDistinct: true)
476+
)
477+
}
478+
```
479+
480+
> Note: Because we are now joining two independent tables to `RemindersList`, we will get duplicate
481+
> entries for all pairs of reminders with milestones. To remove those duplicates we use the
482+
> `isDistinct` option for `jsonGroupArray`.
483+
484+
This will now load all reminders lists with all of their reminders and milestones in one single
485+
SQL query.

Sources/StructuredQueriesCore/SQLite/JSONFunctions.swift

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,81 @@ extension PrimaryKeyedTableDefinition where QueryValue: Codable & Sendable {
110110
filter: filter?.queryFragment
111111
)
112112
}
113+
}
114+
115+
extension PrimaryKeyedTableDefinition {
116+
/// A JSON array representation of the aggregation of a table's columns.
117+
///
118+
/// Constructs a JSON array of JSON objects with a field for each column of the table. This can be
119+
/// useful for loading many associated values in a single query. For example, to query for every
120+
/// reminders list, along with the array of reminders it is associated with, one can define a
121+
/// custom `@Selection` for that data and query as follows:
122+
///
123+
/// @Row {
124+
/// @Column {
125+
/// ```swift
126+
/// @Selection struct Row {
127+
/// let remindersList: RemindersList
128+
/// @Column(as: JSONRepresentation<[Reminder]>.self)
129+
/// let reminders: [Reminder]
130+
/// }
131+
/// RemindersList
132+
/// .leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) }
133+
/// .select {
134+
/// Row.Columns(
135+
/// remindersList: $0,
136+
/// reminders: $1.jsonGroupArray()
137+
/// )
138+
/// }
139+
/// ```
140+
/// }
141+
/// @Column {
142+
/// ```sql
143+
/// SELECT
144+
/// "remindersLists".…,
145+
/// iif(
146+
/// "reminders"."id" IS NULL,
147+
/// NULL,
148+
/// json_object(
149+
/// 'id', json_quote("id"),
150+
/// 'title', json_quote("title"),
151+
/// 'priority', json_quote("priority")
152+
/// )
153+
/// )
154+
/// FROM "remindersLists"
155+
/// JOIN "reminders"
156+
/// ON ("remindersLists"."id" = "reminders"."remindersListID")
157+
/// ```
158+
/// }
159+
/// }
160+
///
161+
/// - Parameters:
162+
/// - isDistinct: An boolean to enable the `DISTINCT` clause to apply to the aggregation.
163+
/// - order: An `ORDER BY` clause to apply to the aggregation.
164+
/// - filter: A `FILTER` clause to apply to the aggregation.
165+
/// - Returns: A JSON array aggregate of this table.
166+
public func jsonGroupArray<Wrapped: Codable & Sendable>(
167+
isDistinct: Bool = false,
168+
order: (some QueryExpression)? = Bool?.none,
169+
filter: (some QueryExpression<Bool>)? = Bool?.none
170+
) -> some QueryExpression<[Wrapped].JSONRepresentation>
171+
where QueryValue == Wrapped?
172+
{
173+
let filterQueryFragment = if let filter {
174+
self.primaryKey.isNot(nil).and(filter).queryFragment
175+
} else {
176+
self.primaryKey.isNot(nil).queryFragment
177+
}
178+
return AggregateFunction(
179+
"json_group_array",
180+
isDistinct: isDistinct,
181+
[jsonObject.queryFragment],
182+
order: order?.queryFragment,
183+
filter: filterQueryFragment
184+
)
185+
}
113186

114-
private var jsonObject: some QueryExpression<QueryValue> {
187+
fileprivate var jsonObject: some QueryExpression<QueryValue> {
115188
func open<TableColumn: TableColumnExpression>(_ column: TableColumn) -> QueryFragment {
116189
typealias Value = TableColumn.QueryValue._Optionalized.Wrapped
117190

@@ -143,8 +216,8 @@ extension PrimaryKeyedTableDefinition where QueryValue: Codable & Sendable {
143216
} else if Value.self == Date.JulianDayRepresentation.self {
144217
return "\(quote: column.name, delimiter: .text), datetime(\(column), 'julianday')"
145218
} else if let codableType = TableColumn.QueryValue.QueryOutput.self
146-
as? any (Codable & Sendable).Type,
147-
isJSONRepresentation(codableType)
219+
as? any (Codable & Sendable).Type,
220+
isJSONRepresentation(codableType)
148221
{
149222
return "\(quote: column.name, delimiter: .text), json(\(column))"
150223
} else {

Tests/StructuredQueriesTests/JSONFunctionsTests.swift

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -136,21 +136,21 @@ extension SnapshotTests {
136136
ReminderRow.Columns(
137137
assignedUser: user,
138138
reminder: reminder,
139-
tags: #sql("\(tag.jsonGroupArray())")
139+
tags: tag.jsonGroupArray()
140140
)
141141
}
142142
.limit(2)
143143
) {
144144
"""
145-
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"
145+
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"
146146
FROM "reminders"
147147
LEFT JOIN "remindersTags" ON ("reminders"."id" = "remindersTags"."reminderID")
148148
LEFT JOIN "tags" ON ("remindersTags"."tagID" = "tags"."id")
149149
LEFT JOIN "users" ON ("reminders"."assignedUserID" = "users"."id")
150150
GROUP BY "reminders"."id"
151151
LIMIT 2
152152
"""
153-
} results: {
153+
}results: {
154154
"""
155155
┌──────────────────────────────────────────────┐
156156
│ ReminderRow( │
@@ -214,24 +214,27 @@ extension SnapshotTests {
214214
assertQuery(
215215
RemindersList
216216
.group(by: \.id)
217-
.leftJoin(Reminder.incomplete) { $0.id.eq($1.remindersListID) }
218-
.select { remindersList, reminder in
217+
.leftJoin(Milestone.all) { $0.id.eq($1.remindersListID) }
218+
.leftJoin(Reminder.incomplete) { $0.id.eq($2.remindersListID) }
219+
.select {
219220
RemindersListRow.Columns(
220-
remindersList: remindersList,
221-
reminders: #sql("\(reminder.jsonGroupArray())")
221+
remindersList: $0,
222+
milestones: $1.jsonGroupArray(isDistinct: true),
223+
reminders: $2.jsonGroupArray(isDistinct: true)
222224
)
223225
}
224226
.limit(1)
225227
) {
226228
"""
227-
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"
229+
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"
228230
FROM "remindersLists"
231+
LEFT JOIN "milestones" ON ("remindersLists"."id" = "milestones"."remindersListID")
229232
LEFT JOIN "reminders" ON ("remindersLists"."id" = "reminders"."remindersListID")
230233
WHERE NOT ("reminders"."isCompleted")
231234
GROUP BY "remindersLists"."id"
232235
LIMIT 1
233236
"""
234-
} results: {
237+
}results: {
235238
"""
236239
┌────────────────────────────────────────────────┐
237240
│ RemindersListRow( │
@@ -240,6 +243,23 @@ extension SnapshotTests {
240243
│ color: 4889071, │
241244
│ title: "Personal"
242245
│ ), │
246+
│ milestones: [ │
247+
│ [0]: Milestone( │
248+
│ id: 1, │
249+
│ remindersListID: 1, │
250+
│ title: "Phase 1"
251+
│ ), │
252+
│ [1]: Milestone( │
253+
│ id: 2, │
254+
│ remindersListID: 1, │
255+
│ title: "Phase 2"
256+
│ ), │
257+
│ [2]: Milestone( │
258+
│ id: 3, │
259+
│ remindersListID: 1, │
260+
│ title: "Phase 3"
261+
│ ) │
262+
│ ], │
243263
│ reminders: [ │
244264
│ [0]: Reminder( │
245265
│ id: 1, │
@@ -295,7 +315,7 @@ extension SnapshotTests {
295315
}
296316

297317
@Selection
298-
private struct ReminderRow {
318+
private struct ReminderRow: Codable {
299319
let assignedUser: User?
300320
let reminder: Reminder
301321
@Column(as: [Tag].JSONRepresentation.self)
@@ -305,6 +325,8 @@ private struct ReminderRow {
305325
@Selection
306326
private struct RemindersListRow {
307327
let remindersList: RemindersList
328+
@Column(as: [Milestone].JSONRepresentation.self)
329+
let milestones: [Milestone]
308330
@Column(as: [Reminder].JSONRepresentation.self)
309331
let reminders: [Reminder]
310332
}

Tests/StructuredQueriesTests/Support/Schema.swift

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ struct ReminderTag: Equatable {
6565
let tagID: Int
6666
}
6767

68+
@Table struct Milestone: Codable, Equatable {
69+
let id: Int
70+
var remindersListID: RemindersList.ID
71+
var title = ""
72+
}
73+
6874
extension Database {
6975
static func `default`() throws -> Database {
7076
let db = try Database()
@@ -135,12 +141,11 @@ extension Database {
135141
)
136142
try execute(
137143
"""
138-
CREATE INDEX "index_remindersTags_on_reminderID" ON "remindersTags"("reminderID")
139-
"""
140-
)
141-
try execute(
142-
"""
143-
CREATE INDEX "index_remindersTags_on_tagID" ON "remindersTags"("tagID")
144+
CREATE TABLE "milestones" (
145+
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
146+
"remindersListID" INTEGER NOT NULL REFERENCES "remindersLists"("id") ON DELETE CASCADE,
147+
"title" TEXT NOT NULL DEFAULT ''
148+
)
144149
"""
145150
)
146151
}
@@ -244,6 +249,9 @@ extension Database {
244249
ReminderTag(reminderID: 2, tagID: 4)
245250
ReminderTag(reminderID: 4, tagID: 1)
246251
ReminderTag(reminderID: 4, tagID: 2)
252+
Milestone.Draft(remindersListID: 1, title: "Phase 1")
253+
Milestone.Draft(remindersListID: 1, title: "Phase 2")
254+
Milestone.Draft(remindersListID: 1, title: "Phase 3")
247255
}
248256
.forEach(execute)
249257
}

0 commit comments

Comments
 (0)