Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -395,7 +395,7 @@ Another way to do this is to use the `@Selection` macro described above
(<doc:QueryCookbook#Custom-selections>), 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
Expand All @@ -414,7 +414,7 @@ columns of [primary keyed tables](<doc:PrimaryKeyedTables>):

```swift
RemindersList
.join(Reminder.all) { $0.id.eq($1.remindersListID) }
.leftJoin(Reminder.all) { $0.id.eq($1.remindersListID) }
.select {
Row.Columns(
remindersList: $0,
Expand All @@ -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.
79 changes: 76 additions & 3 deletions Sources/StructuredQueriesCore/SQLite/JSONFunctions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Wrapped: Codable & Sendable>(
isDistinct: Bool = false,
order: (some QueryExpression)? = Bool?.none,
filter: (some QueryExpression<Bool>)? = 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<QueryValue> {
fileprivate var jsonObject: some QueryExpression<QueryValue> {
func open<TableColumn: TableColumnExpression>(_ column: TableColumn) -> QueryFragment {
typealias Value = TableColumn.QueryValue._Optionalized.Wrapped

Expand Down Expand Up @@ -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 {
Expand Down
42 changes: 32 additions & 10 deletions Tests/StructuredQueriesTests/JSONFunctionsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,21 +136,21 @@ 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")
LEFT JOIN "users" ON ("reminders"."assignedUserID" = "users"."id")
GROUP BY "reminders"."id"
LIMIT 2
"""
} results: {
}results: {
"""
┌──────────────────────────────────────────────┐
│ ReminderRow( │
Expand Down Expand Up @@ -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( │
Expand All @@ -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, │
Expand Down Expand Up @@ -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)
Expand All @@ -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]
}
20 changes: 14 additions & 6 deletions Tests/StructuredQueriesTests/Support/Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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 ''
)
"""
)
}
Expand Down Expand Up @@ -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)
}
Expand Down