Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
e2bf301
wip
mbrandonw Apr 24, 2025
b46283d
wip
mbrandonw Apr 24, 2025
fd2754f
wip
mbrandonw Apr 24, 2025
0d9e09c
wip
mbrandonw Apr 24, 2025
5021924
wip
mbrandonw Apr 25, 2025
220e4ac
wip
mbrandonw Apr 25, 2025
9b343b4
wip
mbrandonw Apr 25, 2025
a4379ed
wip
mbrandonw Apr 25, 2025
8d00669
wip
mbrandonw Apr 28, 2025
95b7292
wip
mbrandonw Apr 28, 2025
ef62a7b
wip
mbrandonw Apr 28, 2025
f7e805c
wip
mbrandonw Apr 28, 2025
81a492e
wip
mbrandonw Apr 28, 2025
2a62976
wip
mbrandonw Apr 28, 2025
01a949f
Merge remote-tracking branch 'origin/main' into json-association
mbrandonw Apr 28, 2025
70550bb
wip
mbrandonw Apr 28, 2025
4367c45
wip
mbrandonw Apr 28, 2025
9a4a5b9
wip
mbrandonw Apr 28, 2025
3c35cad
Update QueryCookbook.md
stephencelis Apr 28, 2025
05c9bfa
remove jsonObject
mbrandonw Apr 28, 2025
0495647
wip
mbrandonw Apr 28, 2025
69e3685
json encoder
mbrandonw Apr 28, 2025
02c521f
revert name-to-title rename
mbrandonw Apr 29, 2025
c8076fb
update snaps
mbrandonw Apr 29, 2025
2fb7e7c
Update Sources/StructuredQueriesCore/Documentation.docc/Articles/Quer…
stephencelis Apr 29, 2025
5904af6
rename
mbrandonw Apr 29, 2025
259a501
fix docs
mbrandonw Apr 29, 2025
cd1cb2e
wip
stephencelis Apr 29, 2025
a7caa0d
wip
mbrandonw Apr 29, 2025
21d40a6
cleanup
stephencelis Apr 29, 2025
8056cf4
cleanup
stephencelis Apr 29, 2025
4eddc50
wip
stephencelis Apr 29, 2025
1fbf9c1
Merge branch 'main' into json-association
stephencelis Apr 29, 2025
efc3286
wip
stephencelis Apr 29, 2025
5e0a91d
Update QueryCookbook.md
stephencelis Apr 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ scopes, and decoding into custom data types.
The library comes with a variety of tools that allow you to define helpers for composing together
large and complex queries.

* [Reusable table queries](#Reusable-table-queries)
* [Reusable column queries](#Reusable-column-queries)
* [Default scopes](#Default-scopes)
* [Custom selections](#Custom-selections)
* [Pre-loading associations](#Pre-loading-associations)

### Reusable table queries

One can define query helpers as statics on their tables in order to facilitate using those
Expand Down Expand Up @@ -339,3 +345,88 @@ And a query that selects into this type can be defined like so:
```
}
}

### Pre-loading associations

There are times you may want to load rows from a table along with the data from some associated
table. For example, querying for all reminders lists along with an array of the reminders in each
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
struct Row {
let remindersList: RemindersList
let reminders: [Reminder]
}
```

However, typically this requires one to make multiple SQL queries. First a query to selects all
of the reminders lists:

```swift
let remindersLists = try RemindersLists.all.execute(db)
```

Then you execute another query to fetch all of the reminders

```swift
let reminders = try Reminder
.where { $0.id.in(remindersLists.map(\.id)) }
.execute(db))
```

And then finally you need to transform the `remindersLists` and `reminders` into a single collection
of `Row` values:

```swift
let rows = remindersLists.map { remindersList in
Row(
remindersList: remindersList,
reminders: reminders.filter { reminder in
reminder.remindersListID == remindersList.id
}
)
}
```

This can work, but it's incredibly inefficient, a lot of boilerplate, and prone to mistakes. And
this is doing work that SQL actually excels at. In fact, the condition inside the `filter` looks
suspiciously like a join constraint, which should give us a hint that what we are doing is not
quite right.

A better way to do this is to use the `@Selection` macro described above
(<doc:QueryCookbook#Custom-selections>), along with a ``JSONRepresentation`` of the collection
of reminders you want to load for each list:

```struct
@Selection
struct Row {
let remindersList: RemindersList
@Column(as: JSONRepresentation<[Reminder]>.self)
let reminders: [Reminder]
}
```

> Note: `Reminder` must conform to `Codable` to be able to use ``JSONRepresentation``.

This allows the query to serialize the associated rows into JSON, which are then deserialized into
a `Row` type. To construct such a query you can use the
``PrimaryKeyedTableDefinition/jsonObjects`` property that is defined on the columns of
[primary keyed tables](<doc:PrimaryKeyedTables>):

```swift
RemindersList
.join(Reminder.all) { $0.id.eq($1.remindersListID) }
.select {
Row.Columns(
remindersList: $0,
reminders: $1.jsonObjects
)
}
```

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.
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public struct JSONRepresentation<QueryOutput: Codable & Sendable>: QueryRepresen

public init(decoder: inout some QueryDecoder) throws {
self.init(
queryOutput: try JSONDecoder().decode(
queryOutput: try jsonDecoder.decode(
QueryOutput.self,
from: Data(String(decoder: &decoder).utf8)
)
Expand All @@ -32,7 +32,7 @@ public struct JSONRepresentation<QueryOutput: Codable & Sendable>: QueryRepresen
extension JSONRepresentation: QueryBindable {
public var queryBinding: QueryBinding {
do {
return try .text(String(decoding: JSONEncoder().encode(queryOutput), as: UTF8.self))
return try .text(String(decoding: jsonEncoder.encode(queryOutput), as: UTF8.self))
} catch {
return .invalid(error)
}
Expand All @@ -44,3 +44,19 @@ extension JSONRepresentation: SQLiteType {
String.typeAffinity
}
}

private let jsonDecoder: JSONDecoder = {
var decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom {
try $0.singleValueContainer().decode(String.self).iso8601
}
return decoder
}()

private let jsonEncoder: JSONEncoder = {
var encoder = JSONEncoder()
#if DEBUG
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
#endif
return encoder
}()
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ extension Date.ISO8601Representation: QueryBindable {

extension Date.ISO8601Representation: QueryDecodable {
public init(decoder: inout some QueryDecoder) throws {
try self.init(queryOutput: String(decoder: &decoder).date)
try self.init(queryOutput: String(decoder: &decoder).iso8601)
}
}

Expand Down Expand Up @@ -69,7 +69,7 @@ extension DateFormatter {
}

extension String {
fileprivate var date: Date {
var iso8601: Date {
get throws {
if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) {
do {
Expand Down
77 changes: 77 additions & 0 deletions Sources/StructuredQueriesCore/SQLite/JSONFunctions.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import Foundation
import StructuredQueriesSupport

extension QueryExpression {
/// Wraps this expression with the `json_array_length` function.
///
Expand Down Expand Up @@ -34,3 +37,77 @@ extension QueryExpression where QueryValue: Codable & Sendable {
AggregateFunction("json_group_array", self, order: order, filter: filter)
}
}

extension PrimaryKeyedTableDefinition where QueryValue: Codable & Sendable {
/// A JSON array repsentation 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
/// .join(Reminder.all) { $0.id.eq($1.remindersListID) }
/// .select {
/// Row.Columns(
/// remindersList: $0,
/// reminders: $1.jsonObjects
/// )
/// }
/// ```
/// }
/// @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")
/// ```
/// }
/// }
///
/// > Note: If the primary key of the row is NULL, then the object is omitted from the array.
public var jsonObjects: some QueryExpression<JSONRepresentation<[QueryValue]>> {
SQLQueryExpression(
"json_group_array(\(jsonObject)) filter(where \(self.primaryKey.isNot(nil)))"
)
}

private var jsonObject: some QueryExpression<JSONRepresentation<QueryValue>> {
func open<TableColumn: TableColumnExpression>(_ column: TableColumn) -> QueryFragment {
switch TableColumn.QueryValue.self {
case is Bool.Type:
return
"\(quote: column.name, delimiter: .text), iif(\(column) = 0, json('false'), json('true'))"
case is Date.UnixTimeRepresentation.Type:
return "\(quote: column.name, delimiter: .text), datetime(\(column), 'unixepoch')"
case is Date.JulianDayRepresentation.Type:
return "\(quote: column.name, delimiter: .text), datetime(\(column), 'julianday')"
default:
return "\(quote: column.name, delimiter: .text), json_quote(\(column))"
}
}
let fragment: QueryFragment = Self.allColumns
.map { open($0) }
.joined(separator: ", ")
return SQLQueryExpression("iif(\(self.primaryKey.is(nil)), NULL, json_object(\(fragment)))")
}
}
Loading