EP 332 · Persistence Callbacks · Jul 21, 2025 ·Members

Video #332: Persistence Callbacks: Validation Triggers

smart_display

Loading stream…

Video #332: Persistence Callbacks: Validation Triggers

Episode: Video #332 Date: Jul 21, 2025 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep332-persistence-callbacks-validation-triggers

Episode thumbnail

Description

We add a new tag editing feature to our rewrite of Apple’s Reminders app to show how we can use database triggers to validate them, and prevent invalid state from ever entering our user’s data.

Video

Cloudflare Stream video ID: 551e130d919c8704b5263cd3d650469e Local file: video_332_persistence-callbacks-validation-triggers.mp4 *(download with --video 332)*

References

Transcript

0:05

That was surprisingly easy! We have yet another trigger in our database, this time for making sure that newly created reminders lists are inserted in the right spot. We hope this is giving all of our viewers some inspiration for figuring out where little bits of logic can be extracted out of your app code and put into the database layer, where it can truly be enforced 100% of the time. Brandon

0:24

Let’s play around with another trigger, and this one will be a little different. Let’s say that we only want users to be able to associate at most 5 tags with a reminder. This kind of validation logic is easy enough to implement in SwiftUI. When the user taps the “Save” button we can simply check how many tags are assigned, and if it’s more than 5 we refuse to actually save the data and display an alert.

0:45

But that is a weak enforcement of this rule. Nothing is stopping other parts of the code base from sneaking in some extra tags, breaking the rule that at most 5 tags should be associated with a reminder. A better way to enforce this would be to install a trigger that makes sure one is never allowed to assign more than 5 tags to a single reminder.

1:09

And it’s surprising just how easy it is to implement this with triggers. But, first, we don’t even currently have a way for users to associate tags with reminders in our app. We never built out that part of the UI. So let’s do that, and then write a trigger. Tags feature

1:27

Let’s first add the functionality to our app that allows us to actually edit the tags for a reminder. Right now everything related to tags in the reminder form is still stubbed out: Section { Button { <#Tags action#> } label: { HStack { Image(systemName: "number.square.fill") .font(.title) .foregroundStyle(.gray) Text("Tags") .foregroundStyle(Color(.label)) Spacer() <#"#weekend #fun"#> .lineLimit(1) .truncationMode(.tail) .font(.callout) .foregroundStyle(.gray) Image(systemName: "chevron.right") } } } .popover(isPresented: <#false#>) { NavigationStack { Text("Tags") } }

1:48

First, to display the reminders actual tags in the button we need to fetch them from the database. That state is not accessible to us directly on the Reminder type, and instead is held in a many-to-many join table that joins reminders to tags.

2:19

Now, it may seem like it’s appropriate to use the @FetchAll tool to get the reminder’s tags since that is what we have reached for every time we needed to query the database: struct ReminderFormView: View { … @FetchAll var selectedTags: [Tag] … }

2:30

But this isn’t correct this time. This time we want to load the initial tags that are associated with the reminders, but then after that we want the user to be able to edit these tags by deselecting existing tags or selecting new ones.

2:48

That means we want to hold onto this state as a bit of scratch, local state: struct ReminderFormView: View { … @State var selectedTags: [Tag] = [] … } Now we are free to make changes to these tags without affecting the reminder’s actual tags, and then once the user taps “Save” we can go and actually update the database with these tags.

3:10

Now we do need to populate this state with the reminder’s current tags, and an appropriate place to do that is in a task modifier in the view, which gives us the ability to run some async code when the view appears: .task { }

3:32

Inside here we will execute a database request: await withErrorReporting { try await database.read { db } }

3:42

In here we want to select all tags belonging to the reminder we are currently viewing, which is determined by the reminder property we are holding onto in the view.

3:48

The query is pretty straightforward to write, with only a few twists and turns. We can start by selecting from the Tag s table, and we might as well order by tag titles while we are here: Tag .order(by: \.title)

3:58

Next, to determine which of these tags belong to our reminder we need to join to the ReminderTag table, which is a many-to-many join table: Tag .order(by: \.title) .join(ReminderTag.all) { $0.id.eq($1.tagID) }

4:29

Now we need to whittle down this collection of all associations between tags and reminders to just the ones belonging to our reminder: Tag .order(by: \.title) .join(ReminderTag.all) { $0.id.eq($1.tagID) } .where { $1.reminderID.eq(reminder.id) }

4:53

However this is not compiling for a few reasons, and in fact we have a few errors we should deal with: Value of optional type ‘Int?’ must be unwrapped to a value of type ’Int’ Which is happening here: .where { $1.reminderID.eq(reminderID) }

5:02

…because eq requires that the types of the left-hand side and right-hand side be the same, just as is the case with Swift code. But reminderID is optional because the reminder we hold in the view is a draft, which has an optional ID: @State var reminder: Reminder.Draft

5:37

If you want to check for equality on nullable values then you can use the SQL

IS 6:02

The next error is that we are trying to throw directly in task , which is not a throwing context. We don’t think there are any actionable things to do with errors here, so we can wrap things in withErrorReporting to direct errors to runtime warnings, instead. await withErrorReporting { try await database.read { db in … } }

IS 6:27

And finally, reminder is main actor bound since it is held in a SwiftUI view, which is always main actor: Main actor-isolated property ‘reminder’ can not be referenced from a nonisolated context

IS 6:36

This is a problem because the trailing closure of read does not have any isolation. But that’s OK. We don’t really need to access the reminder inside that closure. We can just capture the ID upfront: try await database.read { [reminderID = reminder.id] db … }

IS 6:59

OK, we are getting closer. We have now selected all tags belonging to our reminder, but because we joined to the ReminderTag s table, we technically have all of that data coming along for the ride. We need to further make it explicit that of all the columns made available to us, we only want the ones belonging to the Tag s table: Tag .order(by: \.title) .join(ReminderTag.all) { $0.id.eq($1.tagID) } .where { $1.reminderID.is(reminderID) } .select { tag, _ in tag }

IS 7:32

And now this is a query that we can execute in order to get all of the currently selected tags: selectedTags = try await database.write { [reminderID = reminder.id] db in try Tag .order(by: \.title) .join(ReminderTag.all) { $0.id.eq($1.tagID) } .where { $1.reminderID.is(reminderID) } .select { tag, _ in tag } .fetchAll(db) }

IS 7:50

Now that we have the tags associated with the reminder, we can display them in the form. I am going to paste in a helper that will transform the array of selected tags into a Text view with all the tags prefixed with a “#” symbol and concatenated together: private var tagsDetail: Text { guard let tag = selectedTags.first else { return Text("") } return selectedTags.dropFirst().reduce(Text("#\(tag.title)")) { result, tag in result + Text(" #\(tag.title) ") } }

IS 8:18

And now we can use this in the view to replace a placeholder: tagsDetail .lineLimit(1) .truncationMode(.tail) .font(.callout) .foregroundStyle(.gray)

IS 8:25

OK, we are now showing currently selected tags in the UI, but we still don’t have a way of editing these tags, or saving the selected tags. Let’s start with the first part.

IS 8:43

We currently have a placeholder for showing a popover for tags: .popover(isPresented: <#false#>) { … }

IS 8:49

So, let’s add some local state to the view to control whether or not the popover is presented: @State var isTagsPickerPresented = false

IS 8:55

And then we can drive the popover from that state: .popover(isPresented: $isTagsPickerPresented) { … }

IS 8:59

And we can flip this state to true when the button is tapped: Button { isTagsPickerPresented = true } label: { … }

IS 9:03

Now we need a view to present in this popover. We just want a simple view that lists out all of the tags, and allows the user to select and deselect any number of tags they want. And further, this view will communicate back to the parent which tags are selected via a binding.

IS 9:18

We aren’t going to take the time to build this view from scratch because there aren’t many important lessons to learn from doing so. Let’s create a new file…

IS 9:32

And I am going to paste in the following: import SharingGRDB import SwiftUI struct TagsView: View { @FetchAll(Tag.order(by: \.title)) var tags @Binding var selectedTags: [Tag] @Environment(\.dismiss) var dismiss var body: some View { Form { let selectedTagIDs = Set(selectedTags.map(\.id)) ForEach(tags) { tag in TagView( isSelected: selectedTagIDs.contains(tag.id), selectedTags: $selectedTags, tag: tag ) } } .toolbar { ToolbarItem { Button("Done") { dismiss() } } } .navigationTitle(Text("Tags")) } } private struct TagView: View { let isSelected: Bool @Binding var selectedTags: [Tag] let tag: Tag var body: some View { Button { if isSelected { selectedTags.removeAll(where: { $0.id == tag.id }) } else { selectedTags.append(tag) } } label: { HStack { if isSelected { Image.init(systemName: "checkmark") } Text(tag.title) } } .tint(isSelected ? .accentColor : .primary) } } #Preview { @Previewable @State var tags: [Tag] = [] let _ = try! prepareDependencies { $0.defaultDatabase = try appDatabase() } TagsView(selectedTags: $tags) }

IS 9:43

We can run the preview to see that it gives us a simple list of tags, and we can tap on the rows to select or deselect them.

IS 9:51

Now we can update our popover to display the TagsView , and we will pass along a binding to the selectedTags state so that the view can make any changes it wants to the array: .popover(isPresented: $isTagsPickerPresented) { NavigationStack { TagsView(selectedTags: $selectedTags) } }

IS 10:04

We can now give this a spin in the preview to see it works. We can present the tags popover, select a few, and then when we hit “Done” the popover is dismissed and we see our selected tags displayed in the view.

IS 10:17

But we currently aren’t saving the selected tags when dismissing the reminder form. To do that we can going to need to employ some fancy SQL. Current the action closure of the “Save” button is quite simple. We just perform an upsert so that we can insert the reminder if its brand new or we can update it if it’s pre-existing: try database.write { db in try Reminder.upsert { reminder }.execute(db) }

IS 10:37

After we perform that upsert we need to delete any deselected tags from the ReminderTag s join table, and also insert any newly selected tags. To figure out what tags were removed and what tags were added, we need to fetch the reminder’s most current set of tags, and really we only need their tag IDs: let currentReminderTagIDs = try ReminderTag .where { $0.reminderID.is(reminder.id) } .select(\.tagID) .fetchAll(db)

IS 11:22

We can take these IDs and subtract the selected tag IDs to figure out which tags we need to delete. And conversely, we can take the selected tag IDs and subtract the current tag IDs to figure out which ones we need to insert. And the Set type in Swift makes this quite easy to do: let selectedTagIDs = Set(selectedTags.map(\.id)) let tagIDsToDelete = Set(currentReminderTagIDs) .subtracting(selectedTagIDs) let tagIDsToInsert = selectedTagIDs .subtracting(currentReminderTagIDs)

IS 12:28

Now we just need to perform the deletions and inserts. We can start by deleting every ReminderTag association that belongs to our current reminder, and whose tagID is in the tagIDsToDelete collection: try ReminderTag .where { $0.reminderID.is(reminder.id) && $0.tagID.in(tagIDsToDelete) } .delete() .execute(db)

IS 12:58

Next we want to insert all new ReminderTag associations, one for each tag ID in the tagIDsToInsert set. The only problem is the reminder’s ID: try ReminderTag.insert { tagIDsToInsert.map { ReminderTag(reminderID: reminder.id, tagID: $0) } } .execute(db)

IS 13:24

This does not compile because technically reminder.id is optional since reminder is a draft, but ReminderTag requires that its reminderID be non-optional. However, we just upserted this reminder, and so shouldn’t its ID be definitely assigned at this point?

IS 13:48

And that is true, but that does not update the local reminder we have. It’s just a value type and so it cannot update itself magically. But, we can leverage a wonderful feature of SQL to get the freshest ID for the reminder that was just inserted or updated. It’s called returning , and allows you to return some columns from the row just upserted: let reminderID = try Reminder.upsert { reminder } .returning(\.id) .fetchOne(db)

IS 14:35

This value is optional because technically inserts and updates are allowed to affect zero rows, but in our case that’s not really possible. This may be a good use case to actually force unwrap this because we do expect it to always return an ID, but we can also play it safe by guarding to unwrap it: guard let reminderID else { return }

IS 15:02

Now we can finish our insertion: try ReminderTag.insert { tagIDsToInsert .map { ReminderTag(reminderID: reminderID, tagID: $0) } } .execute(db)

IS 15:11

That’s all it takes to implement the tagging feature. We can now edit a reminders tags by selecting new ones or deselecting existing ones. Validating tags with triggers

IS 16:09

We have now implemented the tagging feature in our reminders app. We can associate any number of tags to any reminder we want. But we haven’t yet implemented the bit of behavior we described a moment ago where we would like to validate that at most 5 tags can be associated with any particular reminder. Stephen

IS 16:25

But we finally are in a position to write this validation logic. While we can implement this logic in the application code, it would be better if we performed it in the database since it is the true arbiter of our data. If this logic only existed in app code then it would of course be possible to get around this rule by writing to the database directly. But by baking the rule directly into the database, it will be impossible to violate the rule.

IS 16:46

The way we will enforce this rule we will create a trigger on the ReminderTag s table so that whenever a row is about to be inserted, we will see if that makes that reminder have too many tags. And we will show how it’s possible to raise an error in that case so that the query fails and the database transaction is rolled back.

IS 17:06

So, let’s hop over to the Schema.swift file where all of our other triggers are, and let’s create a new temporary trigger, but this time it will be a

BEFORE 17:17

Running this logic before the insert happens means we can check if there are already 5 associated tags to the reminder. We could also do this in an

BEFORE 17:31

The event we want to listen for in the trigger is inserts, so we can fill that in. And we’ll want the when trailing closure so that we can determine when to raise an error: try ReminderTag.createTemporaryTrigger( before: .insert { new in } when: { new in } ) .execute(db)

BEFORE 17:50

The first trailing closure is the action we will execute when the trigger is invoked. We want to essentially raise an error so that it bubbles up to an error in our Swift code. SQLite has a function that does this called

RAISE 18:40

Now we have to implement the when closure in order to determine when it is appropriate to execute the SELECT RAISE statement.

RAISE 18:43

For this we want to count the number of reminder tags associated with a reminder, and if it’s already greater than or equal to 5 then we know we can’t allow for the new insert to happen: before: .insert { new in #sql( """ SELECT RAISE(ABORT, 'Reminders can have a maximum of 5 tags.') """ ) } when: { new in ReminderTag .where { $0.reminderID.eq(new.reminderID) } .count() >= 5 }

RAISE 19:08

And now that we have created the temporary trigger SQL statement, we just have to execute it in order to install it in our database connection: try ReminderTag.createTemporaryTrigger( before: .insert { new in #sql( """ SELECT RAISE(ABORT, 'Reminders can have a maximum of 5 tags.') """ ) } when: { new in ReminderTag .where { $0.reminderID.eq(new.reminderID) } .count() >= 5 } ) .execute(db)

RAISE 19:14

We can now give this for a spin. But first, we don’t even have enough tags in our database to assign more than 5 to a reminder, so let’s seed a few more: Tag(id: 1, title: "weekend") Tag(id: 2, title: "fun") Tag(id: 3, title: "easy-win") Tag(id: 4, title: "exercise") Tag(id: 5, title: "social") Tag(id: 6, title: "point-free")

RAISE 19:33

Now when we run the app we can navigate to a reminder, assign all 6 reminders, tap “Done”, and then “Save”, and well, we will see that the form dismisses, the changes were definitely not saved, and we do have a purple runtime warning letting us know that something went wrong: Caught error: SQLite error 19: Reminders can have a maximum of 5 tags. - while executing INSERT INTO "reminderTags" ("reminderID", "tagID") VALUES (?, ?), (?, ?), (?, ?), (?, ?), (?, ?), (?, ?)

RAISE 19:57

So it’s nice to see that our trigger worked, and it did prevent us from associating 6 tags with our reminder, but this is not how we want to handle this error.

RAISE 20:05

We previously installed a withErrorReporting around this database transaction because we felt that any error it produced would be programmer error, not user error. And so there would be no point it showing it to the user, and instead we should fix the bug ourselves if we ever saw an error being reported.

RAISE 20:18

But now we do actually produce some validation errors that we want to communicate to the user. So, we will ditch the withErrorReporting for a standard do / catch so that we can get access to the error, and we will move the dismiss() inside the do so that we only dismiss the view if the database transaction completed successfully: do { … dismiss() } catch }

RAISE 20:39

Next we will catch the on error that we know can be thrown by the database by first trying to cast it as a DatabaseError , which is a type provided by GRDB: } catch let error as DatabaseError { } catch { }

RAISE 20:52

Further, we only want to catch only a very specific type of error. This error type has a result code that can be used to determine what exactly went wrong in SQLite, and we can look at the SQLite documentation to see what is available to us:

RAISE 0:00

The error we are interested in arises from a constraint failure, and so we can make sure the result code is that: } catch let error as DatabaseError where error.resultCode == .SQLITE_CONSTRAINT { } catch { }

RAISE 21:32

And further, the kind of constraint error comes from a trigger, which we can filter by looking at the extendedResultCode : } catch let error as DatabaseError where error.resultCode == .SQLITE_CONSTRAINT && error.extendedResultCode == .SQLITE_CONSTRAINT_TRIGGER { } catch { }

RAISE 21:51

And any other error emitted will just be reported to Xcode since we think those errors would most likely be due to programmer error: } catch let error as DatabaseError where error.resultCode == .SQLITE_CONSTRAINT && error.extendedResultCode == .SQLITE_CONSTRAINT_TRIGGER { } catch { reportIssue(error) dismiss() }

RAISE 22:01

Now we just need to mutate some state in this catch let scope that will signal to the view that it needs to show an alert with an error message.

RAISE 22:07

In vanilla SwiftUI the way one does this is using a binding to a boolean to control whether or not the alert is presented, as well as a piece of optional state that is unwrapped so that the message can be dependent on that state: @State var isSaveErrorPresented = false @State var saveErrorMessage: String?

RAISE 22:34

Then we can populate that state in the catch let block: } catch let error as DatabaseError where error.resultCode.rawValue == .SQLITE_CONSTRAINT && error.extendedResultCode.rawValue == .SQLITE_CONSTRAINT_TRIGGER { saveErrorMessage = error.message isSaveErrorPresented = true } catch { reportIssue(error) }

RAISE 22:50

And finally we can use the alert(_:isPresented:presenting:) view modifier in SwiftUI to present an alert from this state: .alert( "Save error", isPresented: $isSaveErrorPresented, presenting: saveErrorMessage ) { _ in } message: { message in Text(message) }

RAISE 23:19

That’s all it takes. We can take this for a spin in the simulator to see that when we try to assign too many tags to a reminder we get a helpful error letting us know what went wrong. And this error was emitted from the trigger we set up in the database. This means that no matter how the database is changed, our validation logic will always execute.

RAISE 23:58

And a cool thing to mention about this validation is that it is part of a broader transaction. Remember, we run an upsert query on the reminder first and only after this query do we run queries related to selected tags. But because the error raised by the trigger happens in the same transaction, even the successful upsert query is rolled back. And we can see this by making edits to the reminder itself, as well as assign too many tags to the reminder: when we try to save, we get the alert, and if we drill out to the reminders list we will see that none of those edits were applied. Next time: Calling back to Swift

RAISE 25:10

We now have the ability to bake validation rules directly into our database. Every single time we assign or remove a tag from a reminder, our validation logic will run, and if the configuration of tags is invalid an error will be emitted that can be easily intercepted and displayed from Swift. It’s just really incredible to see how easy it is to install little validation rules like this at a global level without worrying about auditing every single place a reminder is edited to make sure we are enforcing this rule. And of course, like everything we do in this modern persistence series, this validation logic is 100% unit testable since our tests use the actual database connection, but we won’t explore that right now. Brandon

RAISE 25:45

Let’s move onto our final topic for SQLite triggers. So far we have explored some of the more typical use cases for triggers. We’ve performed an update when a database event happens, such as refreshing updated at timestamps on a record. We’ve performed an insert when a database event happens, such as making sure that we always have at least one reminders list in the database. And we just explored raising errors after certain database events when we detect an invalid configuration of the data in our database.

RAISE 26:13

The final form of trigger we are going to explore is calling out to actual Swift code when a database event happens. This can be great for performing little bits of extra logic that are not purely just database transformations, such as inserts, updates or deletes. We will begin by exploring this by adding a very simple user experience improvement to our app.

RAISE 26:37

Let’s check it out! References SQLiteData Brandon Williams & Stephen Celis A fast, lightweight replacement for SwiftData, powered by SQL. https://github.com/pointfreeco/sqlite-data StructuredQueries A library for building SQL in a safe, expressive, and composable manner. https://github.com/pointfreeco/swift-structured-queries Downloads Sample code 0332-callbacks-pt3 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .