Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
bfce09e
Temporary triggers
stephencelis Jun 12, 2025
9d2a53a
wip
stephencelis Jun 12, 2025
bfd7068
touch triggers
mbrandonw Jun 13, 2025
a72716b
fix
mbrandonw Jun 13, 2025
ecac05b
wip
mbrandonw Jun 13, 2025
c79a759
wip
mbrandonw Jun 13, 2025
4b33440
wip
mbrandonw Jun 13, 2025
3be7439
wip
mbrandonw Jun 16, 2025
6f33d62
wip
mbrandonw Jun 16, 2025
42779c0
Remove trailing comma (#75)
mbrandonw Jun 16, 2025
067c919
Don't require decodable fields in `GROUP BY` (#79)
stephencelis Jun 16, 2025
81600ca
Add `QueryExpression<Optional>.map,flatMap` (#80)
stephencelis Jun 16, 2025
f869923
wip
stephencelis Jun 16, 2025
bfee212
Merge remote-tracking branch 'origin/main' into temp-triggers
stephencelis Jun 16, 2025
3173769
wip
stephencelis Jun 16, 2025
b4ce72c
wip
stephencelis Jun 16, 2025
6ade611
wip
stephencelis Jun 16, 2025
7b81eb4
wip
stephencelis Jun 16, 2025
a28fe52
Merge remote-tracking branch 'origin/main' into temp-triggers
mbrandonw Jun 17, 2025
e79b469
Merge remote-tracking branch 'origin/main' into temp-triggers
mbrandonw Jun 17, 2025
a3de95c
find update remove later
mbrandonw Jun 17, 2025
09a677d
Revert "find update remove later"
mbrandonw Jun 17, 2025
e94b3fa
wip
mbrandonw Jun 18, 2025
95c2598
Merge remote-tracking branch 'origin/main' into temp-triggers
mbrandonw Jun 18, 2025
e29eccc
wip
stephencelis Jun 18, 2025
bcac786
wip
stephencelis Jun 18, 2025
1807da7
Merge branch 'main' into temp-triggers
stephencelis Jun 19, 2025
386e8f8
Merge remote-tracking branch 'origin/main' into temp-triggers
stephencelis Jun 19, 2025
2d1bffe
more overloads
mbrandonw Jun 19, 2025
2c31653
wip
mbrandonw Jun 19, 2025
618f8bd
wip
stephencelis Jun 20, 2025
473615e
wip
stephencelis Jun 20, 2025
365ce8f
Merge remote-tracking branch 'origin/main' into temp-triggers
stephencelis Jun 20, 2025
df1068b
Support multiple statements in triggers
mbrandonw Jun 21, 2025
4ddfd26
Reuse query fragment builder
stephencelis Jun 21, 2025
8b0e6fd
wip
stephencelis Jun 21, 2025
c975361
wip
stephencelis Jun 23, 2025
0d79f07
wip
stephencelis Jun 23, 2025
7ab1657
wip
mbrandonw Jun 23, 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
2 changes: 1 addition & 1 deletion Sources/StructuredQueries/Macros.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ public macro Column(
type: "ColumnMacro"
)

/// Tells Structured Queries not to consider the annotated property a column of the table
/// Tells StructuredQueries not to consider the annotated property a column of the table.
///
/// Like SwiftData's `@Transient` macro, but for SQL.
@attached(peer)
Expand Down
194 changes: 194 additions & 0 deletions Sources/StructuredQueriesCore/Documentation.docc/Articles/Triggers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
# Triggers

Learn how to build trigger statements that can monitor the database for events and react.

## Overview

[Triggers](https://sqlite.org/lang_createtrigger.html) are operations that execute in your database
when some specific database event occurs. StructuredQueries comes with tools to create _temporary_
triggers in a type-safe and schema-safe fashion.

### Trigger basics

One of the most common use cases for a trigger is refreshing an "updatedAt" timestamp on a row when
it is updated in the database. One can create such a trigger SQL statement using the
``Table/createTemporaryTrigger(_:ifNotExists:after:fileID:line:column:)`` static method:

@Row {
@Column {
```swift
Reminder.createTemporaryTrigger(
after: .update { _, _ in
Reminder.update {
$0.updatedAt = #sql("datetime('subsec')")
}
}
)
```
}
@Column {
```sql
CREATE TEMPORARY TRIGGER "after_update_on_reminders@…"
AFTER UPDATE ON "reminders"
FOR EACH ROW
BEGIN
UPDATE "reminders"
SET "updatedAt" = datetime('subsec');
END
```
}
}

This will make it so that anytime a reminder is updated in the database its `updatedAt` will be
refreshed with the current time immediately.

This pattern of updating a timestamp when a row changes is so common that the library comes with
a specialized tool just for that kind of trigger,
``Table/createTemporaryTrigger(_:ifNotExists:afterUpdateTouch:fileID:line:column:)``:

@Row {
@Column {
```swift
Reminder.createTemporaryTrigger(
afterUpdateTouch: {
$0.updatedAt = datetime('subsec')
}
)
```
}
@Column {
```sql
CREATE TEMPORARY TRIGGER "after_update_on_reminders@…"
AFTER UPDATE ON "reminders"
FOR EACH ROW
BEGIN
UPDATE "reminders"
SET "updatedAt" = datetime('subsec');
END
```
}
}

And further, the pattern of specifically updating a _timestamp_ column is so common that the library
comes with another specialized too just for that kind of trigger,
``Table/createTemporaryTrigger(_:ifNotExists:afterUpdateTouch:fileID:line:column:)``:


@Row {
@Column {
```swift
Reminder.createTemporaryTrigger(
afterUpdateTouch: \.updatedAt
)
```
}
@Column {
```sql
CREATE TEMPORARY TRIGGER "after_update_on_reminders@…"
AFTER UPDATE ON "reminders"
FOR EACH ROW
BEGIN
UPDATE "reminders"
SET "updatedAt" = datetime('subsec');
END
```
}
}

### More types of triggers

There are 3 kinds of triggers depending on the event being listened for in the database: inserts,
updates, and deletes. For each of these kinds of triggers one can perform 4 kinds of actions: a
select, insert, update, or delete. Each action can be performed either before or after the event
being listened for executes. All 24 combinations of these kinds of triggers are supported by the
library.

> Tip: SQLite generally
> [recommends against](https://sqlite.org/lang_createtrigger.html#cautions_on_the_use_of_before_triggers)
> using `BEFORE` triggers, as it can lead to undefined behavior.

Here are a few examples to show you the possibilities with triggers:

#### Non-empty tables

One can use triggers to enforce that a table is never fully emptied out. For example, suppose you
want to make sure that the `remindersLists` table always has at least one row. Then one can use an
`AFTER DELETE` trigger with an `INSERT` action to insert a stub reminders list when it detects the
last list was deleted:

@Row {
@Column {
```swift
RemindersList.createTemporaryTrigger(
after: .delete { _ in
RemindersList.insert {
RemindersList.Draft(title: "Personal")
}
} when: { _ in
!RemindersList.exists()
}
)
```
}
@Column {
```sql
CREATE TEMPORARY TRIGGER "after_delete_on_remindersLists@…"
AFTER DELETE ON "remindersLists"
FOR EACH ROW WHEN NOT (EXISTS (SELECT * FROM "remindersLists"))
BEGIN
INSERT INTO "remindersLists"
("id", "color", "title")
VALUES
(NULL, 0xffaaff00, 'Personal');
END
```
}
}

#### Invoke Swift code from triggers

One can use triggers with a `SELECT` action to invoke Swift code when an event occurs in your
database. For example, suppose you want to execute a Swift function a new reminder is inserted
into the database. First you must register the function with SQLite and that depends on what
SQLite driver you are using ([here][grdb-add-function] is how to do it in GRDB).

Suppose we registered a function called `didInsertReminder`, and further suppose it takes one
argument of the ID of the newly inserted reminder. Then one can invoke this function whenever a
reminder is inserted into the database with the following trigger:

[grdb-add-function]: https://swiftpackageindex.com/groue/grdb.swift/v7.5.0/documentation/grdb/database/add(function:)

@Row {
@Column {
```swift
Reminders.createTemporaryTrigger(
after: .insert { new in
#sql("SELECT didInsertReminder(\(new.id))")
}
)
```
}
@Column {
```sql
CREATE TEMPORARY TRIGGER "after_insert_on_reminders@…"
AFTER INSERT ON "reminders"
FOR EACH ROW
BEGIN
SELECT didInsertReminder("new"."id")
END
```
}
}


## Topics

### Creating temporary triggers

- ``Table/createTemporaryTrigger(_:ifNotExists:after:fileID:line:column:)``
- ``Table/createTemporaryTrigger(_:ifNotExists:before:fileID:line:column:)``

### Touching records

- ``Table/createTemporaryTrigger(_:ifNotExists:afterInsertTouch:fileID:line:column:)``
- ``Table/createTemporaryTrigger(_:ifNotExists:afterUpdateTouch:fileID:line:column:)``
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ reading to learn more about building SQL with StructuredQueries.
- <doc:UpdateStatements>
- <doc:DeleteStatements>
- <doc:WhereClauses>
- <doc:Triggers>
- <doc:CommonTableExpressions>
- <doc:StatementTypes>

Expand Down
15 changes: 15 additions & 0 deletions Sources/StructuredQueriesCore/QueryFragmentBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,18 @@ extension QueryFragmentBuilder<()> {
Array(repeat each expression)
}
}

extension QueryFragmentBuilder<any Statement> {
public static func buildExpression(
_ expression: some Statement
) -> [QueryFragment] {
[expression.query]
}

public static func buildBlock(
_ first: [QueryFragment],
_ rest: [QueryFragment]...
) -> [QueryFragment] {
first + rest.flatMap(\.self)
}
}
Loading