EP 324 · Modern Persistence · May 12, 2025 ·Members

Video #324: Modern Persistence: Reminders Lists, Part 1

smart_display

Loading stream…

Video #324: Modern Persistence: Reminders Lists, Part 1

Episode: Video #324 Date: May 12, 2025 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep324-modern-persistence-reminders-lists-part-1

Episode thumbnail

Description

We tackle the first screen in our Reminders app rewrite: the reminders lists view. We will take the @FetchAll property wrapper for a spin, which is like SwiftData’s @Query macro, but unlike @Query it can be used from both the view and observable models. And we will even get some end-to-end, snapshot test coverage of our feature in place.

Video

Cloudflare Stream video ID: a568e53ac7c693cf10c00578597635ce Local file: video_324_modern-persistence-reminders-lists-part-1.mp4 *(download with --video 324)*

References

Transcript

0:05

We have now finished a big, foundational step to creating a modern app with its persistence based on SQLite. It may seem a little weird that we took any entire episode to just set up some tables and a database, but we have already learned a bunch of valuable lessons:

0:19

We have seen that we like to first-and-foremost design our database tables with our Swift data types in mind. This is because they are the interface to our data that we are going to be dealing with 99% of the time, and it allows us to take full advantage of everything Swift has to offer, such as enums for the finite enumeration of options. Brandon

0:37

Then we saw how to create a connection to a database, and how sometimes we may want to do that to a live file stored on disk, but other times, such as in tests and previews, we may want to do that in-memory. That way multiple tests and previews do not trample on each other by writing to the same file on disk. Stephen

0:57

And finally, we saw how to create the tables that represent our Swift data types, including how to set up a foreign key relationship that allows each reminder to belong to exactly one reminders list, and another relationship for allowing any number of tags to belong to any number of reminders.

1:12

These are all good concepts to be familiar with when building modern persistence into your app, we are going to leverage these concepts more and more as we progress through the app. Brandon

1:21

But we still haven’t actually gotten anything to display on the screen! And now it is time. We are going to start building out the views that can display the data in our database. We will show that our libraries come with a suite of tools that make it incredibly easy to query for complex subsets of data, and immediately display them in views. And further, any changes made to the database will cause the view to automatically update. And best of all? These tools are not relegated to only the view layer. They of course work in the view, but they also work in so many more places.

1:58

Let’s get to it! Fetching reminders lists

2:00

Let’s start by adding a new file that will hold everything for the reminders lists feature…

2:11

And we will paste in a stub of a view for this new feature: import SwiftUI struct RemindersListsView: View { var body: some View { List { Section { <#Top-level stats#> } Section { <#Reminders lists#> } header: { Text("My lists") .font(.largeTitle) .bold() .foregroundStyle(.black) .textCase(nil) } Section { <#Tags#> } header: { Text("Tags") .font(.largeTitle) .bold() .foregroundStyle(.black) .textCase(nil) } } .searchable(text: <#Search text#>) .toolbar { ToolbarItem(placement: .bottomBar) { HStack { Button { <#New reminder action#> } label: { HStack { Image(systemName: "plus.circle.fill") Text("New Reminder") } .bold() .font(.title3) } Spacer() Button { <#Add list action#> } label: { Text("Add List") .font(.title3) } } } } } } #Preview { NavigationStack { RemindersListsView() } }

2:28

And we’ve gone ahead and filled in a couple of things we are going to want:

2:37

We have a section at the top to house all of the stats that can be computed across all of the reminders, such as number of reminders due today, scheduled, flagged, completed, and so on.

2:50

Then a section to hold all the lists,

2:57

And then a section to hold all of the tags, if any.

3:08

We’ve have added a search field to the whole list using the searchable view modifier.

3:19

And further we’ve added a toolbar at the bottom to house buttons to create new reminders and lists.

3:25

If we run this in the preview we will see the beginnings of what our app looks like.

3:27

The next thing we will concentrate on is getting a view into place to represent a row in the list of reminders lists. We will just paste this in because there isn’t anything too interesting in the simple view hierarchy: import SwiftUI struct RemindersListRow: View { let incompleteRemindersCount: Int let remindersList: RemindersList var body: some View { HStack { Image(systemName: "list.bullet.circle.fill") .font(.title) .foregroundStyle(Color(hex: remindersList.color)) Text(remindersList.title) Spacer() Text("\(incompleteRemindersCount)") } } } extension Color { init(hex: Int) { self.init( red: Double((hex >> 24) & 0xFF) / 255.0, green: Double((hex >> 16) & 0xFF) / 255.0, blue: Double((hex >> 8) & 0xFF) / 255.0, opacity: Double(hex & 0xFF) / 0xFF ) } } #Preview { NavigationStack { List { RemindersListRow( incompleteRemindersCount: 10, remindersList: RemindersList( id: 1, title: "Personal" ) ) } } }

4:02

This view holds onto an integer representing how many reminders are in the list, as well as a value from the RemindersList table. And then it just displays the properties of that list in an HStack , and we’ve also got a little helper for converting the integer that is stored in the database for the color into a SwiftUI Color . The preview shows us exactly what it looks like.

5:04

But with that row view in place we can add a few rows to the lists section in the RemindersListsView just to see what things look like: Section { RemindersListRow( incompleteRemindersCount: 5, remindersList: RemindersList( id: 1, color: 0x4a99ef_ff, title: "Personal" ) ) RemindersListRow( incompleteRemindersCount: 10, remindersList: RemindersList( id: 2, color: 0xef7e4a_ff, title: "Family" ) ) RemindersListRow( incompleteRemindersCount: 3, remindersList: RemindersList( id: 3, color: 0x7ee04a_ff, title: "Business" ) ) } header: { Text("My lists") .font(.largeTitle) .bold() .foregroundStyle(.black) .textCase(nil) }

5:21

And we’re starting to make some progress.

5:22

However, we are just hard coding this data into the view. Ideally we would be able to fetch this data from our SQLite database. And this is where we get to start using some very modern, very powerful tools that we have built.

5:40

Let’s import SharingGRDB : import SharingGRDB

5:44

And this comes with a tool that is called @FetchAll : @FetchAll …that can be used directly in SwiftUI views, and can be used in @Observable models, and can even be used in UIKit. And it represents the results from a database query.

6:03

Under the hood it is powered by a much more general set of tools called Sharing that is a separate library of its own. It provides a general framework for loading, saving and observing changes in an external storage system, such as the file system or user defaults. It is even possible for others to build their own strategies with the tools. Some in the community have even released libraries built on top of Sharing to add support for things like Firebase and Supabase .

6:31

And this is exactly what SharingGRDB does. It builds upon Sharing to provide seamless access to a SQLite database via the @FetchAll property wrapper.

6:47

And then if we want to fetch all reminders lists we can simply specify the data type: @FetchAll var remindersLists: [ReminderList]

6:59

It may seem too good to be true, but that is all it takes. That will give us immediate access to all reminders lists in our view, and any changes will cause the view to re-render.

7:16

We can also customize the query by passing it to the @FetchAll property wrapper: @FetchAll(<#SelectStatement#>) var remindersLists: [ReminderList]

7:40

For example, we could get a little fancy and sort the reminders by their title: @FetchAll(RemindersList.order(by: \.title)) var remindersLists: [ReminderList]

7:51

And when providing a query, the type annotation can even be omitted: @FetchAll(RemindersList.order(by: \.title)) var remindersLists

8:11

We can now use this collection of lists to display inside the lists section instead of the hard coded rows. We don’t yet have a count of the reminders for each list, and so let’s just hard code that to 0 for now: ForEach(remindersLists) { remindersList in RemindersListRow( remindersCount: 0, remindersList: remindersList ) }

8:39

And that should be enough to get something on the screen, but currently the preview shows a blank list, and there are two reasons for that. First of all, by default our database is not going to have any data in it. There’s no lists, no reminders, and no tags.

9:00

Eventually we will build all the functionality in the app that lets us create news lists, reminders and tags, but until then it’s a bummer that we can’t see any data when running our app in previews or in the simulator.

9:13

Luckily there is a simple solution. We can seed our database with some data when we create and migrate the database. We can even do this as a migration: migrator.registerMigration("Seed database") { db in }

9:39

Inside this migration we will create a few records for our tables so that our previews and simulators start out with some baseline data so we don’t have to worry about creating the data from scratch each time.

9:54

But because we only want to seed the database with sample data when testing in the simulator or on device we should make sure to only ever run this in DEBUG builds: #if DEBUG migrator.registerMigration("Seed database") { db in } #endif

10:06

And then SharingGRDB even comes with a tool that can help with seeding. There’s a seed method defined on the db variable that takes a trailing closure: migrator.registerMigration("Seed database") { db in try db.seed { } }

10:26

And this trailing closure is a builder context where we can list out any number of table values we want, and the seed method will collect those values and insert them into the database.

10:31

So, let’s create 3 reminders lists: try db.seed { RemindersList(id: 1, color: 0x4a99ef_ff, title: "Personal") RemindersList(id: 2, color: 0xef7e4a_ff, title: "Family") RemindersList(id: 3, color: 0x7ee04a_ff, title: "Business") }

10:46

That is all it takes. Now when this migration runs we will seed the database with these 3 lists. And so do our previews work now?

11:06

Well, unfortunately they are still blank. The reason this is happening is because we have to set up our database for the preview. The @FetchAll property wrapper relies on a defaultDatabase dependency under the hood, and so we have to override that dependency with our appDatabase .

11:37

We can do this by simply invoking the prepareDependencies function at the top of the preview: #Preview { let _ = prepareDependencies { $0.defaultDatabase = try appDatabase() } NavigationStack { RemindersListsView() } }

12:16

And just like that we finally are getting something on screen that is powered by our our database. These 3 rows are appearing only because the data exists in a SQLite database, one that is held in memory for the preview, and the view is querying for the data and updating the view when the data is fetched and decoded.

12:39

While the preview console is blank, this is just because OSLog doesn’t support previews. But we can check the preview context and print in previews instead: #if DEBUG db.trace(options: .profile) { if context == .preview { print($0.expandedDescription) } else { logger.debug("\($0.expandedDescription)") } } #endif

13:13

Now can look in the console to see all of the queries that were executed, including our migrations and seeds, as well as the query that shows the displayed rows: 0.000s SELECT "remindersLists"."id", "remindersLists"."color", "remindersLists"."title" FROM "remindersLists" ORDER BY "remindersLists"."title"

13:41

And so this really does prove that we are fetching data from the database to display in the UI. Deleting reminders lists

14:25

Things are now getting pretty exciting. We have seen just how easy it is to query for data from our database and display it in the view. And honestly, the steps are that much different from SwiftData. We use a property wrapper, @FetchAll , instead of the @Query macro to specify the query. And we use the prepareDependencies tool to set up the database for the preview instead of injecting a model context into the environment. But really, all of those steps are very similar. Stephen

14:52

Let’s now see what it looks like to make a write to the database that causes the data to change, which should cause our view to re-render. We will begin with deleting lists, because that can be done with a simple swipe gesture.

15:04

Let’s take a look.

15:06

To add a simple swipe-to-delete action to our list of lists, we will use the onDelete view modifier that is available to ForEach views: ForEach(remindersLists) { remindersList in … } .onDelete { indexSet in }

15:22

That makes it possible to swipe on a row to expose a delete button, and when that delete button is tapped the trailing closure will be invoked with the index set of rows that should be deleted.

15:30

And so we can loop over the indices in the index set, subscript into the remindersLists state, and then somehow we need to delete that list: .onDelete { indexSet in for index in indexSet { // Delete this reminder: remindersLists[index] } }

15:42

But, the question is how do we delete it?

15:44

Well, we can’t just reach into remindersLists directly and mutate it as if it was a simple array: remindersLists.remove(at: index) Cannot use mutating member on immutable value: ‘remindersLists’ is a get-only property

15:53

And that is because remindersLists is read-only since it is held in a @FetchAll , which provides no write access to the underlying data. And the reason we need this state to be read-only is because it can be powered by any kind of SQL query, and so there is no reasonable way to make mutations to it directly and somehow have that magically translate into updates in the database. Even SwiftData does not work this way. The @Query macro handles only fetching data that is read-only, and then any changes to the database must be made through the model context.

16:26

The equivalent of that in our SharingGRDB is to get access to the default database dependency, which can be added directly to the view: struct RemindersListsView: View { @Dependency(\.defaultDatabase) var database … }

16:39

And then in onDelete closure we can open up a write transaction using a write method that comes from GRDB: .onDelete { indexSet in try database.write { db in } } Then inside here we can loop over each index in the set: .onDelete { indexSet in try database.write { db in for index in indexSet { } } }

16:50

And for each index we can construct a SQL

DELETE 17:13

This essentially creates a SQL statement like so: RemindersList.delete(remindersLists[index]) // DELETE FROM "remindersLists" WHERE "remindersLists"."id" = ? It binds the ID from the list we pass to delete . Then we can execute this query by invoking the execute method: try RemindersList.delete(remindersLists[index]) .execute(db)

DELETE 17:24

But onDelete does not have a throwing context, and so we need to catch and handle the error. .onDelete { indexSet in do { … } catch {} }

DELETE 17:28

I’m not entirely certain what to do with this error right now. I feel like such errors are incredibly rare, especially since we have a type-safe and schema-safe query builder. Perhaps the most common kind of error that can happen here is if we had a foreign key constraint fail, such as deleting a list that had reminders associated to it. But we decided to cascade deletions of lists to the reminders associated with it, and so even that cannot cause an error in our application.

DELETE 17:51

So, we won’t actually handle the error at all right now, but it definitely doesn’t seem correct to silently swallow it. Instead we can surface the error in a little more visible manner, but still unobtrusively, by using the withErrorReporting tool that comes with our IssueReporting library, which is automatically included with SharingGRDB: .onDelete { indexSet in withErrorReporting { … } }

DELETE 18:13

Any error thrown in this trailing closure will be caught and emitted as a purple runtime warning. In general, we feel that if a block of code can only throw errors that are due to programmer error, such as incorrect SQL, then it is OK to wrap it in withErrorReporting to provide some visibility to us. But we don’t need to notify the user that we constructed an invalid SQL string. Ideally we catch such errors in development.

DELETE 18:36

OK, that is all it should take to implement the delete functionality. We can give it a spin in the preview to see that it indeed works…

DELETE 18:53

And we can open up the logs to see that when we deleted the reminder the following SQL statement was executed: 0.000s DELETE FROM "remindersLists" WHERE ("remindersLists"."id" = 3)

DELETE 18:59

We deleted the list with ID 3. And further, right after that happened the @FetchAll property wrapper detected that a change was made to the remindersLists table and so decided to reload all the data for the view: 0.000s SELECT "remindersLists"."id", "remindersLists"."color", "remindersLists"."title" FROM "remindersLists" ORDER BY "remindersLists"."title"

DELETE 19:24

And so that is pretty amazing. This shows that we are free to make any kind of write we want to the database, and the state in our view will always be up-to-date, and the view will always refresh to show the newest data.

DELETE 19:35

And now that we have the basics of delete in place we can show off what it would look like if we ever had an error with our delete statement. Rather than using our query builder suppose we executed a

DELETE 20:11

This compiles, but when we run in a preview and try deleting a row we see the following printed to the console: ModernPersistence/RemindersListFeature.swift:14: Caught error: SQLite error 1: near “remindersLists”: syntax error - while executing DELETE remindersLists where id = ?

DELETE 20:36

This shows that we will produce very visible, yet still unobtrusive, warnings when we encounter any SQL problems in our app. But let’s go back to the code that works…

DELETE 20:50

And there is one more small improvement we can make to this code. In certain situations it is possible to delete multiple lists at once, and that is why we are dealing with an IndexSet rather than just a plain index. And if we do end up deleting multiple lists, we are going to execute a delete statement for each one: try database.write { db in for index in indexSet { try RemindersList.delete(remindersLists[index]) .execute(db) } }

DELETE 21:07

So, deleting N lists means executing N queries. And really that isn’t too big of a deal because SQLite is great at handling many queries.

DELETE 21:14

But, we can technically delete all of the reminders in just a single query, and it is fun to see, so let’s give it a shot. We can start by getting the ID of all the lists we want to delete: let ids = indexSet.map { remindersLists[$0].id }

DELETE 21:39

And then construct a where clause that selects all lists with those IDs: RemindersList .where { $0.id.in(ids) } This query essentially represents the following in SQL: SELECT … FROM reminders WHERE id IN (…) …and then an integer is bound for each value in the ids array.

DELETE 21:58

So we now have a where clause to single out the reminders we want to delete, and we can follow that up with the delete statement: RemindersList .where { $0.id.in(ids) } .delete() This transforms the query to now be a delete statement whose SQL looks like: DELETE FROM reminders WHERE id IN (…)

DELETE 22:02

And so now we can execute this one single query rather than executing a query for each list we are deleting: try database.write { db in let ids = indexSet.map { remindersLists[$0].id } try RemindersList .where { $0.id.in(ids) } .delete() .execute(db) } Testing

DELETE 22:34

This is looking really nice. We now see that we can introduce the defaultDatabase dependency to our feature, and use it to write to the database, such as deleting a reminders list. And the moment that happens, the @FetchAll detects the change and re-renders the view.

DELETE 22:50

And again, this code is not much different from how one does it in SwiftData. Over in SwiftData we would have introduced a model context to our view via the environment, and then we would have made mutations to it and saved the context. It’s basically the same steps. Brandon

DELETE 23:06

But there is still one thing we personally don’t like about what we have done so far, and that’s how much logic has already crept into our view. As we saw, there is some nuance to how we delete all of the rows specified by the index set, and further, depending on how we construct the query and how complex the query is, there is a slight chance we may get something wrong in the SQL or in how the data is encoded and decoded.

DELETE 23:30

And by cramming all of this logic into the view we limit ourselves to understanding these kinds of edge cases by literally running the app in the preview, simulator or on device. And humans are fallible, especially when we have run a feature over and over, dozens or hundreds of times, where we may lose track of how exactly a feature is supposed to work or not notice subtle problems in the behavior we are witnessing.

DELETE 23:55

And for this reason we like to get a little bit of test coverage on this kind of logic. But in order to test this kind of logic we need to move this code to a place that is instantiable and exercisable in tests, and that is exactly what the @Observable macro was made for.

DELETE 24:09

Let’s take a look.

DELETE 24:11

We can create a new class that will power this feature, and decorate it with the @Observable macro: @Observable class RemindersListsModel { }

DELETE 24:23

And this class is where it will be appropriate to hold onto any data that the view needs to display and that the feature needs to implement its logic, and it will have methods that the view can invoke when the user takes an action.

DELETE 24:35

We can start by moving the state and dependencies that we added to the view over to this class: @Observable class RemindersListsModel { @FetchAll(RemindersList.order(by: \.title)) var remindersLists @Dependency(\.defaultDatabase) var database }

DELETE 24:44

However, macros do not play nicely with property wrappers, such as @FetchAll and @Dependency . Property wrappers must implement their own observation logic on the inside, and that is exactly what @FetchAll does. And @Dependency doesn’t need any observation, and so we can ignore observation on these two properties: @Observable class RemindersListsModel { @ObservationIgnored @FetchAll(RemindersList.order(by: \.title)) var remindersLists @ObservationIgnored @Dependency(\.defaultDatabase) var database }

DELETE 25:10

If in the future we add more mutable state to this class that is not decorated with a property wrapper, then the @Observable macro will take care of observing changes to those fields so that the view can be re-rendered if the view accesses any of the fields.

DELETE 25:22

We will also add a method to this class that handles all of the deletion logic for us. It will be passed the index set from the view: func deleteButtonTapped(indexSet: IndexSet) { }

DELETE 25:31

And then it will perform the exact same logic that view is performing: func deleteButtonTapped(indexSet: IndexSet) { withErrorReporting { try database.write { db in for index in indexSet { try RemindersList.delete(remindersLists[index]) .execute(db) } } } }

DELETE 25:54

So far all we’ve done is move code around a little bit. But with that done a lot of powerful things start to open up for us.

DELETE 26:02

For one thing, the view gets a lot simpler. The view now only needs to hold onto an observable model: struct RemindersListsView: View { let model: RemindersListsModel … }

DELETE 26:08

And to access state for the body of the view we go through the model: ForEach(model.remindersLists) { remindersList in … }

DELETE 26:12

And when deleting reminders we simply invoke a method on the model and pass along the index set: .onDelete { indexSet in model.deleteButtonTapped(indexSet: indexSet) }

DELETE 26:40

And then when constructing the preview we create a model and pass it along: #Preview { let _ = prepareDependencies { $0.defaultDatabase = try! appDatabase() } NavigationStack { RemindersListsView(model: RemindersListsModel()) } }

DELETE 26:46

That’s all it takes and the app works exactly as it did before, but we are now in a position to test our deletion logic.

DELETE 26:59

In fact, let’s do just that. Let’s create a new file called RemindersListsFeatureTests.swift…

DELETE 27:07

And let’s get a stub in place for a test: import Testing @testable import ModernPersistence @Suite struct RemindersListsFeatureTests { func deletion() { } }

DELETE 27:32

The first thing we will do is construct a model since that is the thing that encapsulates the logic and behavior of our feature and the thing that the view interacts with: let model = RemindersListsModel()

DELETE 27:44

And right off the bat we should be able to assert that this model already holds a few lists since our database is seeded with a few lists from the beginning: #expect(model.remindersLists.count == 3)

DELETE 28:08

Right now we are just asserting on the number of lists, but we could assert on more data if we wanted. We could assert on all of the data held in the remindersLists array, or could just assert on a part of it, like their IDs: #expect(model.remindersLists.map(\.id) == [1, 2, 3])

DELETE 28:24

You can choose whatever level of granularity you want, but in either case we would hope this passes when running tests, but sadly it does not: Expectation failed: (model.remindersLists.count → 0) == 3 Expectation failed: (model.remindersLists.map(\.id) → []) == [1, 2, 3]

DELETE 28:44

It doesn’t seem the remindersLists variable holds any data yet. And there is another error that can help us understand what is going on and what we need to do to fix it: Test deletion() recorded an issue at DefaultDatabase.swift:42:42: Issue recorded ↳ A blank, in-memory database is being used. To set the database that is used by ‘SharingGRDB’ in a test, use a tool like the ‘dependency’ trait: @Suite(.dependency(\.defaultDatabase, try DatabaseQueue(/* ... */) struct MyTests { // ... } This is telling us that we are using the default database that comes with SharingGRDB, which is just an empty, in-memory database with no tables whatsoever. We need to override this database for the test with our app database which migrates all of the tables and adds some seed data for our tests. We can do this by using a test trait that comes with our dependencies library, and the tools lives in its own module: import DependenciesTestSupport

DELETE 29:32

And to get access to this we have to explicitly depend on the swift-dependencies package…

DELETE 29:43

And link the DependenciesTestSupport module to our test target…

DELETE 29:51

You may wonder why we need this separate test support library. Unfortunately any code that makes use of the Swift Testing framework cannot be included in libraries or targets that are ultimately compiled for app targets. So we have no choice but to put testing tools in its own library, and then you need to link that library to your test targets.

DELETE 30:12

But with that done we can use the .dependency trait on our @Suite , which takes a key path to the dependency we want to override, as well as the value we want to override it with. In particular we want to override the default database to be our app database: @Suite(.dependency(\.defaultDatabase, try appDatabase())) struct RemindersListsFeatureTests { … }

DELETE 30:29

And now every test in this suite will run in an environment that has the default database overridden with our migrated and seeded database. And best of all, this even plays nicely with parallel tests. Each test gets its own in-memory database even though many tests can be running in parallel.

DELETE 30:47

However, even with that, running the test still fails. Now the problem is that it does take GRDB a tiny fraction of a second to execute the query, and it happens in a background thread in order to not hold up the main thread, and so we have to sleep for a tiny bit of time before asserting: let model = RemindersListsModel() try await Task.sleep(for: .seconds(0.01)) #expect(model.remindersLists.count == 3) #expect(model.remindersLists.map(\.id) == [1, 2, 3])

DELETE 31:20

And now when we run tests the first expectation passes, but the second still fails: Expectation failed: (model.remindersLists.map(\.id) → [3, 2, 1]) == [1, 2, 3]

DELETE 31:31

But this is actually to be expected! Because we decide to order the lists alphabetically and so their IDs are not necessarily in order. It turns out that the first inserted list is sorted to the middle when ordering alphabetically: #expect(model.remindersLists.map(\.id) == [3, 2, 1])

DELETE 32:00

And now this passes!

DELETE 32:03

However, the sleep we introduced is unfortunate. It’s very small at the moment, but also it could fail at any time in the future, especially in resource constrained environments like CI. And also, as we write dozens or hundreds of tests, these sleeps begin to add up, and more and more tests could fail at any moment due to flakiness.

DELETE 32:37

Instead it would be better to execute code that waits for new results, and @FetchAll comes with a tool that allows just that called load() . We can invoke it and it will immediately reload the most recent data from the database: try await model.$remindersLists.load()

DELETE 32:48

And now the test still passes, but we can be confident that the data we are asserting against is the freshest data in the database. We don’t have to guess how much time we have to sleep until the results are delivered to us.

DELETE 32:59

And so already we are getting a little bit of test coverage on the logic of our feature. We are able to make sure that we are indeed ordering the reminders by their title, alphabetically, when the feature first loads.

DELETE 33:14

Next let’s emulate the user deleting the middle list: model.deleteButtonTapped(indexSet: [1])

DELETE 33:23

We will again have to explicitly reload the underlying data because GRDB’s observation happens asynchronously, but after that fetch we can again assert how many lists we think the array holds, as well as assert which IDs remain: try await model.$remindersLists.load() #expect(model.remindersLists.count == 2) #expect(model.remindersLists.map(\.id) == [3, 1])

DELETE 33:47

And now the test passes! And this really is proving that we actually deleted data from the database, and then @FetchAll observed those changes, and then updated its state with a new array that holds only 2 elements.

DELETE 34:02

In fact, if we were to introduce an invalid query, we would see that the test actually fails with the associated error that was emitted as a runtime warning when run in the simulator. Test deletion() recorded an issue at RemindersListFeature.swift:14:24: Caught error: SQLite error 1: near “remindersLists”: syntax error - while executing DELETE remindersLists where id = ? Which would be a good thing! We accidentally left the bad SQL statement in our feature code that we introduced in order to show how SQL errors cause purple runtime warnings in Xcode. But those warnings become failures when running tests, which is great for making sure that our SQL is executing properly.

DELETE 34:34

And as we mentioned early, technically it is possible to delete multiple rows at once. So, let’s beef up this test to delete the first and third row: model.deleteButtonTapped(indexSet: [0, 2]) try await model.$remindersLists.load() #expect(model.remindersLists.count == 1) #expect(model.remindersLists.map(\.id) == [1])

DELETE 34:47

…and this too passes, and now we are getting even more test coverage on the logic of our feature! Snapshot testing

DELETE 35:08

And so already we are getting a decent amount of test coverage in our feature with very little work. We’ve shown when the feature first loads it queries for all of the reminders lists, sorted by their titles. And then, when the user taps on the “Delete” button for a reminder, that reminder is deleted from the database and the state in the feature updates. And the tests run super fast, in a tiny fraction of a second, whereas the equivalent UI test would take many seconds to run and would probably be flakey too.

DELETE 35:34

But we think there are some improvements that can be made here. Stephen

DELETE 35:38

It’s a bit of a bummer that we are asserting on so little of the actual state in our feature. Currently we are only asserting that the number of elements in the array matches what we expect, and we are further asserting on the IDs just to get a little more coverage. But we had a bug in our feature that accidentally updated the titles of the lists, we would be oblivious to that because we are not asserting on any of that state.

DELETE 36:03

Let’s see what it takes to improve the strength of our tests, and this will give us a chance to explore two other tools of ours that help with building modern apps.

DELETE 36:14

One way to strengthen our current test is to assert on literally the entire array of remindersLists all at once: #expect( model.remindersLists == [ RemindersList(id: 2, color: 0xef7e4a_ff, title: "Family"), RemindersList(id: 1, color: 0x4a99ef_ff, title: "Personal"), RemindersList(id: 3, color: 0x7ee04a_ff, title: "Business"), ] )

DELETE 36:29

This does require us to make RemindersList equatable, but that is easy enough to do: @Table struct RemindersList: Equatable, Identifiable { … }

DELETE 36:39

And now our test still passes, but we are asserting on a lot more of the actual state in our features.

DELETE 36:47

However, asserting on large pieces of state in this way can get really messy when a test failure happens. For example, let’s go back to fetch all reminders lists in their default order instead of sorted by title: @ObservationIgnored @FetchAll(RemindersList.all) var remindersLists

DELETE 37:00

Now with such a change we should of course expect that we need to update some tests. But it’s not always feasible to immediately update tests, and so some time may pass until we run our suite to find that something is failing. And unfortunately, we are greeted with the following inscrutable test failure message: Expectation failed: (model.remindersLists → [ModernPersistence.RemindersList(id: 2, color: 15695434, title: "Family"), ModernPersistence.RemindersList(id: 1, color: 4889071, title: "Personal"), ModernPersistence.RemindersList(id: 3, color: 8314954, title: "Work")]) == ([ RemindersList(id: 1, color: 0x4a99ef_ff, title: "Personal"), RemindersList(id: 2, color: 0xef7e4a_ff, title: "Family"), RemindersList(id: 3, color: 0x7ee04a_ff, title: "Work"), ] → [ModernPersistence.RemindersList(id: 1, color: 4889071, title: "Personal"), ModernPersistence.RemindersList(id: 2, color: 15695434, title: "Family"), ModernPersistence.RemindersList(id: 3, color: 8314954, title: "Work")])

DELETE 37:08

It would take quite a bit of time to parse out this message and figure out that the only thing wrong is that the first and second element are reversed.

DELETE 37:20

And it’s this incomprehensible failure message is what led us to release a tool known as expectNoDifference : expectNoDifference( <#Equatable#>, <#Equatable#> ) It takes two equatable values and when they do not equal it prints out a nicely formatted diff message of what exactly differs between the two values.

DELETE 37:43

This function comes with our CustomDump library: import CustomDump

DELETE 37:51

…which comes along for free when depending on SharingGRDB, and so we already have access to it. And so if we convert our existing #expect to be an expectNoDifference instead: expectNoDifference( model.remindersLists, [ RemindersList(id: 2, color: 0xef7e4a_ff, title: "Family"), RemindersList(id: 1, color: 0x4a99ef_ff, title: "Personal"), RemindersList(id: 3, color: 0x7ee04a_ff, title: "Work") ] )

DELETE 37:59

…we get a much nicer test failure message: Issue recorded: Difference: … [ − [0]: RemindersList( − id: 2, − color: 15695434, − title: "Family" − ), [0]: RemindersList(…), + [1]: RemindersList( + id: 2, + color: 15695434, + title: "Family" + ), [2]: RemindersList(…) ] (First: −, Second: +) This clearly shows that the reminder at the 0th index has moved to the 1st index.

DELETE 38:27

And if we update our model to sort by title again: @ObservationIgnored @FetchAll(RemindersList.order(by: \.title)) var remindersLists

DELETE 38:33

…the test now passes.

DELETE 38:36

And we can also update the expectation after deleting the reminder: expectNoDifference( model.remindersLists, [ RemindersList(id: 1, color: 0x4a99ef_ff, title: "Personal"), RemindersList(id: 3, color: 0x7ee04a_ff, title: "Work"), ] )

DELETE 38:50

And so we feel that a modern test suite will get more mileage out of using our expectNoDifference function for comparing large data structures than the #expect macro.

DELETE 38:58

But, we feel that there is perhaps a way to improve this even more. Writing out large assertions like this can be tedious, and they are prone to breaking often when new data is added to our tables or logic is updated in our features.

DELETE 39:11

There is an alternative to writing such large assertions that can be handy, though this style does have its cons too. The tool we are going to show off lives in our popular SnapshotTesting library, and it’s called InlineSnapshotTesting: import InlineSnapshotTesting

DELETE 39:25

To get access to this we will depend on swift-snapshot-testing and add InlineSnapshotTesting as a dependency to our test target…

DELETE 39:39

With that done we are allowed to assert on large, complex values with a single line of code: assertInlineSnapshot(of: model.remindersLists, as: .customDump)

DELETE 40:25

Well, this doesn’t yet actually assert anything. It asserts there is an inline snapshot that matches the value we pass, but no snapshot has yet been recorded. We can run the test suite to see it record the value: let model = RemindersListsModel() try await Task.sleep(for: .seconds(0.01)) assertInlineSnapshot(of: model.remindersLists, as: .customDump) { """ [ [0]: RemindersList( id: 3, color: 8314954, title: "Business" ) [1]: RemindersList( id: 2, color: 15695434, title: "Family" ), [2]: RemindersList( id: 1, color: 4889071, title: "Personal" ), ] """ }

DELETE 40:45

This clearly shows that the model’s remindersLists array holds 3 reminders in the order of “Business”, “Family”, and “Personal”, because they are sorted alphabetically.

DELETE 40:50

If we snapshot the state after the delete button is tapped: model.deleteButtonTapped(indexSet: [0, 2]) try await model.$remindersLists.load() assertInlineSnapshot(of: model.remindersLists, as: .customDump) { """ [ [0]: RemindersList( id: 2, color: 15695434, title: "Family" ), ] """ } …we see that the middle list, “Family”, remains.

DELETE 41:04

And if we change our feature logic so that it fetches lists in a different manner. Like suppose we no longer want to sort alphabetically: @ObservationIgnored @FetchAll(RemindersList.all) var remindersLists

DELETE 41:10

We immediately get a test failure letting us know what exactly went wrong: Issue recorded: Snapshot did not match. Difference: … [ [0]: RemindersList( − id: 3, − color: 8314954, − title: "Business" + id: 1, + color: 4889071, + title: "Personal" ), [1]: RemindersList( id: 1, color: 4889071, title: "Personal" ), [2]: RemindersList( − id: 1, − color: 4889071, − title: "Personal" + id: 3, + color: 8314954, + title: "Business" ) ]

DELETE 41:15

We can clearly see that the first and third reminders have been swapped so that the order is now “Personal”, “Family”, and then “Business”.

DELETE 41:35

And if we decided that this is indeed the correct value for this state, we can record the newest value directly into this test file. To do that we can use the snapshots trait to put the entire suite in the .failed record mode: @Suite( .dependency(\.defaultDatabase, try appDatabase()), .snapshots(record: .failed) ) struct RemindersListsFeatureTests { … } This will re-record any snapshot that fails. And so running our suite again fails because the currently recorded snapshot does not match the current value, but the fresh value was re-recorded: Issue recorded: Snapshot did not match. Difference: … [ [0]: RemindersList( − id: 3, − color: 8314954, − title: "Business" + id: 1, + color: 4889071, + title: "Personal" ), [1]: RemindersList( id: 1, color: 4889071, title: "Personal" ), [2]: RemindersList( − id: 1, − color: 4889071, − title: "Personal" + id: 3, + color: 8314954, + title: "Business" ) ] A new snapshot was automatically recorded.

DELETE 42:09

Notice the message “A new snapshot was automatically recorded.” at the end that lets us know a new snapshot was recorded. And if we run the test suite again it will pass.

DELETE 42:19

And so we feel this provides a wonderful, modern testing experience for complex features with lots of state. We get to snapshot the state, verify that the output is what we expect, and when the state changes in the future it will automatically re-record and then it will be our job to re-verify that the state is correct. Next time: Aggregation

DELETE 42:27

We now have a bit of test coverage on our feature using our powerful inline snapshot testing tools. It’s forcing us to assert on how data is fetched from our database, including the manner it is sorted. We even get test coverage on how our feature deletes some records from the database directly, and then that causes the state in our model to update. And it’s pretty incredible to see how easy it is to write tests on complex data types using these tools, and we will see that it will continue to pay dividends over and over in this series.

DELETE 43:05

OK, we now know how to query for data in our feature to render all of our reminders lists. And we know how to delete a list when the user taps a “Delete” button. And we’ve even got some test coverage on the feature. Brandon

DELETE 43:16

Let’s now move on to a more complex aspect of this app. Right now we are just showing the names of the reminders lists in the UI, but the real reminders app also shows the number of uncompleted reminders for each list. We need to somehow query for not only the list of reminders, sorted by title, as we currently are, but also compute the number of reminders in each list.

DELETE 43:42

And this is the kinda of thing that SQL excels at, and sadly SwiftData is not currently capable of performing. In order to execute this kind of query on Apple platforms you have to dip into CoreData APIs, which are cumbersome and not safe at all to use…next time!

DELETE 0:00

So let’s see what it takes to update our query to be more powerful. References SQLiteData 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 0324-modern-persistence-pt2 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 .