Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
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 @@ -5,19 +5,19 @@ A library for building SQL in a type-safe, expressive, and composable manner.
## Overview

The core functionality of this library is defined in
[`StructuredQueriesCore`](<doc:/StructuredQueriesCore>), which this module automatically exports.
[`StructuredQueriesCore`](structuredqueriescore), which this module automatically exports.
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We previously made this change because it's supported when turning on an experimental docc feature that combines docs of multiple modules. But then it later turned out that setting didn't play nicely with SPI, but we forgot to revert this change.


This module also contains all of the macros that support the core functionality of the library.

See [`StructuredQueriesCore`](<doc:/StructuredQueriesCore>) for general library usage.
See [`StructuredQueriesCore`](structuredqueriescore) for general library usage.

StructuredQueries also ships SQLite-specific helpers:

- [`StructuredQueriesSQLiteCore`](<doc:/StructuredQueriesSQLiteCore>): Core, SQLite-specific
- [`StructuredQueriesSQLiteCore`](structuredqueriessqlitecore): Core, SQLite-specific
functionality, including full-text search, type-safe temporary triggers, full-text search, and
more.

- [`StructuredQueriesSQLite`](<doc:/StructuredQueriesSQLite>): Everything from
- [`StructuredQueriesSQLite`](structuredqueriessqlitecore): Everything from
`StructuredQueriesSQLiteCore` and macros that support it, like `@DatabaseFunction.`

## Topics
Expand Down
8 changes: 7 additions & 1 deletion Sources/StructuredQueriesMacros/SelectionMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,9 @@ extension SelectionMacro: ExtensionMacro {
columnQueryValueType = "\(raw: base.trimmedDescription)"

case .some(let label) where label.text == "primaryKey":
guard !declaration.hasMacroApplication("Table")
else { continue }
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated to the core of this PR but something I noticed. We can allow @Selection types to have a primary key if it is also a @Table.


var newArguments = arguments
newArguments.remove(at: argumentIndex)
diagnostics.append(
Expand Down Expand Up @@ -257,6 +260,9 @@ extension SelectionMacro: MemberMacro {
columnQueryValueType = "\(raw: base.trimmedDescription)"

case .some(let label) where label.text == "primaryKey":
guard !declaration.hasMacroApplication("Table")
else { continue }

var newArguments = arguments
newArguments.remove(at: argumentIndex)
expansionFailed = true
Expand Down Expand Up @@ -329,7 +335,7 @@ extension SelectionMacro: MemberMacro {
"""
\($0): some \(moduleName).QueryExpression\
\($1.map { "<\($0)>" } ?? "")\
\($2.map { "= #bind(\($0))" } ?? "")
\($2.map { "= BindQueryExpression(\($0))" } ?? "")
"""
}
.joined(separator: ",\n")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,139 @@ Learn how to create views that can be queried.
be queried like a table. StructuredQueries comes with tools to create _temporary_ views in a
type-safe and schema-safe fashion.

### Creating temporary views

To define a view into your database you must first define a Swift data type that holds the data
you want to query for. As a simple example, suppose we want a view into the database that selects
the title of each reminder, along with the title of each list, we can model this as a simple
Swift struct:

```swift
@Table @Selection
private struct ReminderWithList {
let reminderTitle: String
let remindersListTitle: String
}
```

Note that we have applied both the `@Table` macro and `@Selection` macro. This is similar to what
one does with common table expressions, and it allows one to represent a type that for intents and
purposes seems like a regular SQLite table, but it's not actually persisted in the database.

With that type defined we can use the
``StructuredQueriesCore/Table/createTemporaryView(ifNotExists:as:)`` to create a SQL query that
creates a temporary view. You provide a select statement that selects all the data needed for the
view:

```swift
ReminderWithList.createTemporaryView(
as: Reminder
.join(RemindersList.all) { $0.remindersListID.eq($1.id) }
.select {
ReminderWithList.Columns(
reminderTitle: $0.title,
remindersListTitle: $1.title
)
}
)
```

Once that is executed in your database you are free to query from this table as if it is a regular
table:

@Row {
@Column {
```swift
ReminderWithList
.order {
($0.remindersListTitle,
$0.reminderTitle)
}
.limit(3)
```
}
@Column {
```sql
SELECT
"reminderWithLists"."reminderTitle",
"reminderWithLists"."remindersListTitle"
FROM "reminderWithLists"
ORDER BY
"reminderWithLists"."remindersListTitle",
"reminderWithLists"."reminderTitle"
LIMIT 3
```
}
}

The best part of this is that the `JOIN` used in the view is completely hidden from us. For all
intents and purposes, `ReminderWithList` seems like a regular SQL table for which each row holds
just two strings. We can simply query from the table to get that data in whatever way we want.

### Inserting, updating, and delete rows from views

The other querying tools of SQL do not immediately work because they are not real tables. For
example if you try to insert into `ReminderWithList` you will be met with a SQL error:

```swift
ReminderWithList.insert {
ReminderWithList(
reminderTitle: "Morning sync",
remindersListTitle: "Business"
)
}
// 🛑 cannot modify reminderWithLists because it is a view
```

However, it is possible to restore inserts if you can describe how inserting a `(String, String)`
pair into the table ultimately re-routes to inserts into your actual, non-view tables. The logic
for rerouting inserts is highly specific to the situation at hand, and there can be multiple
reasonable ways to do it for a particular view. For example, upon inserting into `ReminderWithList`
we could try first creating a new list with the title, and then use that new list to insert a new
reminder with the title. Or, we could decide that we will not allow creating a new list, and
instead we will just find an existing list with the title, and if we cannot then we fail the query.

In order to demonstrate this technique, we will use the latter rerouting logic: when a
`(String, String)` is inserted into `ReminderWithList` we will only create a new reminder with
the title specified, and we will only find an existing reminders list (if one exists) for the title
specified. And to implement this rerouting logic, one uses a [temporary trigger](<doc:Triggers>) on
the view with an `INSTEAD OF` clause, which allows you to reroute any inserts on the view into some
other table:

```swift
ReminderWithList.createTemporaryTrigger(
insteadOf: .insert { new in
Reminder.insert {
(
$0.title,
$0.remindersListID
)
} values: {
(
new.reminderTitle,
RemindersList
.select(\.id)
.where { $0.title.eq(new.remindersListTitle) }
)
}
}
)
```

After you have installed this trigger into your database you are allowed to insert rows into the
view:

```swift
ReminderWithList.insert {
ReminderWithList(
reminderTitle: "Morning sync",
remindersListTitle: "Business"
)
}
```

Following this pattern you can also restore updates and deletes on the view.

## Topics

### Creating temporary views
Expand Down
186 changes: 184 additions & 2 deletions Tests/StructuredQueriesMacrosTests/SelectionMacroTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -163,8 +163,8 @@ extension SnapshotTests {
public typealias QueryValue = Row
public let selection: [(aliasName: String, expression: StructuredQueriesCore.QueryFragment)]
public init(
title: some StructuredQueriesCore.QueryExpression<Swift.String> = StructuredQueriesCore.BindQueryExpression(""),
notes: some StructuredQueriesCore.QueryExpression<[String].JSONRepresentation> = StructuredQueriesCore.BindQueryExpression([])
title: some StructuredQueriesCore.QueryExpression<Swift.String> = BindQueryExpression(""),
notes: some StructuredQueriesCore.QueryExpression<[String].JSONRepresentation> = BindQueryExpression([])
) {
self.selection = [("title", title.queryFragment), ("notes", notes.queryFragment)]
}
Expand All @@ -188,5 +188,187 @@ extension SnapshotTests {
"""
}
}

@Test func primaryKey() {
assertMacro {
"""
@Selection struct Row {
@Column(primaryKey: true)
let id: Int
var title = ""
}
"""
} diagnostics: {
"""
@Selection struct Row {
@Column(primaryKey: true)
┬─────────
╰─ 🛑 '@Selection' primary keys are not supported
✏️ Remove 'primaryKey: true'
let id: Int
var title = ""
}
"""
} fixes: {
"""
@Selection struct Row {
@Column()
let id: Int
var title = ""
}
"""
} expansion: {
"""
struct Row {
let id: Int
var title = ""

public struct Columns: StructuredQueriesCore._SelectedColumns {
public typealias QueryValue = Row
public let selection: [(aliasName: String, expression: StructuredQueriesCore.QueryFragment)]
public init(
id: some StructuredQueriesCore.QueryExpression<Int>,
title: some StructuredQueriesCore.QueryExpression<Swift.String> = BindQueryExpression("")
) {
self.selection = [("id", id.queryFragment), ("title", title.queryFragment)]
}
}
}

extension Row: StructuredQueriesCore._Selection {
public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws {
let id = try decoder.decode(Int.self)
let title = try decoder.decode(Swift.String.self)
guard let id else {
throw QueryDecodingError.missingRequiredColumn
}
guard let title else {
throw QueryDecodingError.missingRequiredColumn
}
self.id = id
self.title = title
}
}
"""
}
}

@Test func tableSelectionPrimaryKey() {
assertMacro {
"""
@Table @Selection struct Row {
@Column(primaryKey: true)
let id: Int
var title = ""
}
"""
} expansion: {
#"""
struct Row {
let id: Int
var title = ""

public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition, StructuredQueriesCore.PrimaryKeyedTableDefinition {
public typealias QueryValue = Row
public let id = StructuredQueriesCore.TableColumn<QueryValue, Int>("id", keyPath: \QueryValue.id)
public let title = StructuredQueriesCore.TableColumn<QueryValue, Swift.String>("title", keyPath: \QueryValue.title, default: "")
public var primaryKey: StructuredQueriesCore.TableColumn<QueryValue, Int> {
self.id
}
public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] {
[QueryValue.columns.id, QueryValue.columns.title]
}
public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] {
[QueryValue.columns.id, QueryValue.columns.title]
}
public var queryFragment: QueryFragment {
"\(self.id), \(self.title)"
}
}

public struct Draft: StructuredQueriesCore.TableDraft {
public typealias PrimaryTable = Row
let id: Int?
var title = ""
public nonisolated struct TableColumns: StructuredQueriesCore.TableDefinition {
public typealias QueryValue = Draft
public let id = StructuredQueriesCore.TableColumn<QueryValue, Int?>("id", keyPath: \QueryValue.id)
public let title = StructuredQueriesCore.TableColumn<QueryValue, Swift.String>("title", keyPath: \QueryValue.title, default: "")
public static var allColumns: [any StructuredQueriesCore.TableColumnExpression] {
[QueryValue.columns.id, QueryValue.columns.title]
}
public static var writableColumns: [any StructuredQueriesCore.WritableTableColumnExpression] {
[QueryValue.columns.id, QueryValue.columns.title]
}
public var queryFragment: QueryFragment {
"\(self.id), \(self.title)"
}
}
public nonisolated static var columns: TableColumns {
TableColumns()
}

public nonisolated static var tableName: String {
Row.tableName
}

public nonisolated init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws {
self.id = try decoder.decode(Int.self)
self.title = try decoder.decode(Swift.String.self) ?? ""
}

public nonisolated init(_ other: Row) {
self.id = other.id
self.title = other.title
}
public init(
id: Int? = nil,
title: Swift.String = ""
) {
self.id = id
self.title = title
}
}

public struct Columns: StructuredQueriesCore._SelectedColumns {
public typealias QueryValue = Row
public let selection: [(aliasName: String, expression: StructuredQueriesCore.QueryFragment)]
public init(
id: some StructuredQueriesCore.QueryExpression<Int>,
title: some StructuredQueriesCore.QueryExpression<Swift.String> = BindQueryExpression("")
) {
self.selection = [("id", id.queryFragment), ("title", title.queryFragment)]
}
}
}

nonisolated extension Row: StructuredQueriesCore.Table, StructuredQueriesCore.PrimaryKeyedTable, StructuredQueriesCore.PartialSelectStatement {
public typealias QueryValue = Self
public typealias From = Swift.Never
public nonisolated static var columns: TableColumns {
TableColumns()
}
public nonisolated static var tableName: String {
"rows"
}
}

extension Row: StructuredQueriesCore._Selection {
public init(decoder: inout some StructuredQueriesCore.QueryDecoder) throws {
let id = try decoder.decode(Int.self)
let title = try decoder.decode(Swift.String.self)
guard let id else {
throw QueryDecodingError.missingRequiredColumn
}
guard let title else {
throw QueryDecodingError.missingRequiredColumn
}
self.id = id
self.title = title
}
}
"""#
}
}
}
}
Loading
Loading