Skip to content

Commit 0ae47cf

Browse files
Type-safe CREATE TEMPORARY TRIGGER builder (#82)
* Temporary triggers * wip * touch triggers * fix * wip * wip * wip * wip * wip * Remove trailing comma (#75) * Remove trailing comma while we support Swift 6.0 * compile for swift 6.0 * Don't require decodable fields in `GROUP BY` (#79) This PR allows the following to work without qualifying the expression type: ```diff Reminder.group { - #sql("date(\($0.dueDate))", as: Date?.self) + #sql("date(\($0.dueDate))") } ``` * Add `QueryExpression<Optional>.map,flatMap` (#80) * Add `QueryExpression<Optional>.map,flatMap` This PR adds helpers that make it a little easier to work with optional query expressions in a builder. For example, if you want to execute a `LIKE` operator on an optional string, you currently have to resort to one of the following workarounds: ```swift .where { ($0.title ?? "").like("%foo%") } // or: .where { #sql("\($0.title) LIKE '%foo%') } ``` This PR introduces `map` and `flatMap` operations on optional `QueryExpression`s that unwraps the expression, giving you additional flexibility in how you express your builder code: ```swift .where { $0.title.map { $0.like("%foo%") } ?? false } ``` While this is more code than the above options, some may prefer its readability, and should we merge the other optional helpers from #61, it could be further shortened: ```swift .where { $0.title.map { $0.like("%foo%") } } ``` * tests * wip * wip * wip * wip * wip * find update remove later * Revert "find update remove later" This reverts commit a3de95c. * wip * wip * wip * more overloads * wip * wip * wip * Support multiple statements in triggers * Reuse query fragment builder * wip * wip * wip * wip --------- Co-authored-by: Brandon Williams <[email protected]> Co-authored-by: Brandon Williams <[email protected]>
1 parent e6b2d2e commit 0ae47cf

File tree

18 files changed

+1793
-912
lines changed

18 files changed

+1793
-912
lines changed

Sources/StructuredQueries/Macros.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public macro Column(
4646
type: "ColumnMacro"
4747
)
4848

49-
/// Tells Structured Queries not to consider the annotated property a column of the table
49+
/// Tells StructuredQueries not to consider the annotated property a column of the table.
5050
///
5151
/// Like SwiftData's `@Transient` macro, but for SQL.
5252
@attached(peer)
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
# Triggers
2+
3+
Learn how to build trigger statements that can monitor the database for events and react.
4+
5+
## Overview
6+
7+
[Triggers](https://sqlite.org/lang_createtrigger.html) are operations that execute in your database
8+
when some specific database event occurs. StructuredQueries comes with tools to create _temporary_
9+
triggers in a type-safe and schema-safe fashion.
10+
11+
### Trigger basics
12+
13+
One of the most common use cases for a trigger is refreshing an "updatedAt" timestamp on a row when
14+
it is updated in the database. One can create such a trigger SQL statement using the
15+
``Table/createTemporaryTrigger(_:ifNotExists:after:fileID:line:column:)`` static method:
16+
17+
@Row {
18+
@Column {
19+
```swift
20+
Reminder.createTemporaryTrigger(
21+
after: .update { _, _ in
22+
Reminder.update {
23+
$0.updatedAt = #sql("datetime('subsec')")
24+
}
25+
}
26+
)
27+
```
28+
}
29+
@Column {
30+
```sql
31+
CREATE TEMPORARY TRIGGER "after_update_on_reminders@…"
32+
AFTER UPDATE ON "reminders"
33+
FOR EACH ROW
34+
BEGIN
35+
UPDATE "reminders"
36+
SET "updatedAt" = datetime('subsec');
37+
END
38+
```
39+
}
40+
}
41+
42+
This will make it so that anytime a reminder is updated in the database its `updatedAt` will be
43+
refreshed with the current time immediately.
44+
45+
This pattern of updating a timestamp when a row changes is so common that the library comes with
46+
a specialized tool just for that kind of trigger,
47+
``Table/createTemporaryTrigger(_:ifNotExists:afterUpdateTouch:fileID:line:column:)``:
48+
49+
@Row {
50+
@Column {
51+
```swift
52+
Reminder.createTemporaryTrigger(
53+
afterUpdateTouch: {
54+
$0.updatedAt = datetime('subsec')
55+
}
56+
)
57+
```
58+
}
59+
@Column {
60+
```sql
61+
CREATE TEMPORARY TRIGGER "after_update_on_reminders@…"
62+
AFTER UPDATE ON "reminders"
63+
FOR EACH ROW
64+
BEGIN
65+
UPDATE "reminders"
66+
SET "updatedAt" = datetime('subsec');
67+
END
68+
```
69+
}
70+
}
71+
72+
And further, the pattern of specifically updating a _timestamp_ column is so common that the library
73+
comes with another specialized too just for that kind of trigger,
74+
``Table/createTemporaryTrigger(_:ifNotExists:afterUpdateTouch:fileID:line:column:)``:
75+
76+
77+
@Row {
78+
@Column {
79+
```swift
80+
Reminder.createTemporaryTrigger(
81+
afterUpdateTouch: \.updatedAt
82+
)
83+
```
84+
}
85+
@Column {
86+
```sql
87+
CREATE TEMPORARY TRIGGER "after_update_on_reminders@…"
88+
AFTER UPDATE ON "reminders"
89+
FOR EACH ROW
90+
BEGIN
91+
UPDATE "reminders"
92+
SET "updatedAt" = datetime('subsec');
93+
END
94+
```
95+
}
96+
}
97+
98+
### More types of triggers
99+
100+
There are 3 kinds of triggers depending on the event being listened for in the database: inserts,
101+
updates, and deletes. For each of these kinds of triggers one can perform 4 kinds of actions: a
102+
select, insert, update, or delete. Each action can be performed either before or after the event
103+
being listened for executes. All 24 combinations of these kinds of triggers are supported by the
104+
library.
105+
106+
> Tip: SQLite generally
107+
> [recommends against](https://sqlite.org/lang_createtrigger.html#cautions_on_the_use_of_before_triggers)
108+
> using `BEFORE` triggers, as it can lead to undefined behavior.
109+
110+
Here are a few examples to show you the possibilities with triggers:
111+
112+
#### Non-empty tables
113+
114+
One can use triggers to enforce that a table is never fully emptied out. For example, suppose you
115+
want to make sure that the `remindersLists` table always has at least one row. Then one can use an
116+
`AFTER DELETE` trigger with an `INSERT` action to insert a stub reminders list when it detects the
117+
last list was deleted:
118+
119+
@Row {
120+
@Column {
121+
```swift
122+
RemindersList.createTemporaryTrigger(
123+
after: .delete { _ in
124+
RemindersList.insert {
125+
RemindersList.Draft(title: "Personal")
126+
}
127+
} when: { _ in
128+
!RemindersList.exists()
129+
}
130+
)
131+
```
132+
}
133+
@Column {
134+
```sql
135+
CREATE TEMPORARY TRIGGER "after_delete_on_remindersLists@…"
136+
AFTER DELETE ON "remindersLists"
137+
FOR EACH ROW WHEN NOT (EXISTS (SELECT * FROM "remindersLists"))
138+
BEGIN
139+
INSERT INTO "remindersLists"
140+
("id", "color", "title")
141+
VALUES
142+
(NULL, 0xffaaff00, 'Personal');
143+
END
144+
```
145+
}
146+
}
147+
148+
#### Invoke Swift code from triggers
149+
150+
One can use triggers with a `SELECT` action to invoke Swift code when an event occurs in your
151+
database. For example, suppose you want to execute a Swift function a new reminder is inserted
152+
into the database. First you must register the function with SQLite and that depends on what
153+
SQLite driver you are using ([here][grdb-add-function] is how to do it in GRDB).
154+
155+
Suppose we registered a function called `didInsertReminder`, and further suppose it takes one
156+
argument of the ID of the newly inserted reminder. Then one can invoke this function whenever a
157+
reminder is inserted into the database with the following trigger:
158+
159+
[grdb-add-function]: https://swiftpackageindex.com/groue/grdb.swift/v7.5.0/documentation/grdb/database/add(function:)
160+
161+
@Row {
162+
@Column {
163+
```swift
164+
Reminders.createTemporaryTrigger(
165+
after: .insert { new in
166+
#sql("SELECT didInsertReminder(\(new.id))")
167+
}
168+
)
169+
```
170+
}
171+
@Column {
172+
```sql
173+
CREATE TEMPORARY TRIGGER "after_insert_on_reminders@…"
174+
AFTER INSERT ON "reminders"
175+
FOR EACH ROW
176+
BEGIN
177+
SELECT didInsertReminder("new"."id")
178+
END
179+
```
180+
}
181+
}
182+
183+
184+
## Topics
185+
186+
### Creating temporary triggers
187+
188+
- ``Table/createTemporaryTrigger(_:ifNotExists:after:fileID:line:column:)``
189+
- ``Table/createTemporaryTrigger(_:ifNotExists:before:fileID:line:column:)``
190+
191+
### Touching records
192+
193+
- ``Table/createTemporaryTrigger(_:ifNotExists:afterInsertTouch:fileID:line:column:)``
194+
- ``Table/createTemporaryTrigger(_:ifNotExists:afterUpdateTouch:fileID:line:column:)``

Sources/StructuredQueriesCore/Documentation.docc/StructuredQueriesCore.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ reading to learn more about building SQL with StructuredQueries.
124124
- <doc:UpdateStatements>
125125
- <doc:DeleteStatements>
126126
- <doc:WhereClauses>
127+
- <doc:Triggers>
127128
- <doc:CommonTableExpressions>
128129
- <doc:StatementTypes>
129130

Sources/StructuredQueriesCore/QueryFragmentBuilder.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,18 @@ extension QueryFragmentBuilder<()> {
4242
Array(repeat each expression)
4343
}
4444
}
45+
46+
extension QueryFragmentBuilder<any Statement> {
47+
public static func buildExpression(
48+
_ expression: some Statement
49+
) -> [QueryFragment] {
50+
[expression.query]
51+
}
52+
53+
public static func buildBlock(
54+
_ first: [QueryFragment],
55+
_ rest: [QueryFragment]...
56+
) -> [QueryFragment] {
57+
first + rest.flatMap(\.self)
58+
}
59+
}

0 commit comments

Comments
 (0)