This one query handles selecting only the incomplete reminders if needed, as well as selecting only reminders belonging to a particular list. It orders those reminders so that the incomplete are up top, and then further orders them by either their due date, priority or title. Sure, it’s a mouthful of a query, but it can also be expressed as one single expression and does not need to be split up into a bunch of different pieces for no reason.
It’s just really incredible to see how easy it is to build a complex query with our libraries. And each step of the way we get static access to the schema of our tables that helps prevent typos, type-mismatches, or just constructing non-sensical queries.
However, to really drive home how incredible we think this is, we want to pause our journey towards recreating Apple’s reminders app to take a moment and reflect. We personally think that these tools provide a great alternative to SwiftData. They accomplish most of what SwiftData accomplishes, and the code written often looks very similar to SwiftData, but our tools give us full, unfettered access to the power of SQL, which we are really starting to take advantage of now.
So, let’s take a moment to dabble in some SwiftData. Let’s see what it looks like if we were to try to rebuild this complex query using the tools that SwiftData gives us. I think our viewers are going to be pretty surprised by what we uncover here.
In order to explore how SwiftData approaches large, complex queries, we are going to rebuild some of the basic pieces of our app in SwiftData. I’m going to create a new file called SwiftDataExplorations.swift.
And I am going to just copy and paste our RemindersList
and Reminder
types to this file:
import Foundation
import SwiftData
import SwiftUI
struct RemindersList: Equatable, Identifiable {
let id: Int
var color = 0x4a99ef_ff
var title = ""
}
struct Reminder: Identifiable {
let id: Int
@Column(as: Date.ISO8601Representation?.self)
var dueDate: Date?
var isCompleted = false
var isFlagged = false
var notes = ""
var priority: Priority?
var remindersListID: RemindersList.ID
var title = ""
enum Priority: Int, QueryBindable {
case low = 1
case medium
case high
}
}
…and rename them to something unique:
struct RemindersListModel: Equatable, Identifiable {
…
}
struct ReminderModel: Identifiable {
…
}
In SwiftData you decorate your models with @Model
instead of @Table
:
@Model
struct RemindersListModel: Equatable, Identifiable {
…
}
@Model
struct ReminderModel: Identifiable {
…
}
But right out of the gate we find that @Model
does not work with structs:
'@Model'
cannot be applied to struct type 'RemindersListModel'
(from macro 'Model'
)
It only works with classes.
So guess we need to downgrade our types to be classes instead of structs:
@Model
final class RemindersListModel: Equatable, Identifiable {
…
}
@Model
final class ReminderModel: Identifiable {
…
}
Which is a real bummer since these should just be simple data types that represent the rows of our database. Now we have to contend with the fact that these objects are capable of encapsulating all types of unknowable behavior.
Further, in SwiftData one should not provide their own id
field because that is automatically provided by the framework…
And then further @Model
requires an initializer:
@Model requires an initializer be provided for 'RemindersListModel'
(from macro 'Model'
)
And because these are now classes, Swift no longer synthesizes the initializer for us. That is now our responsibility:
@Model
final class RemindersListModel: Equatable, Identifiable {
…
init(color: Int = 0x4a99ef_ff, title: String = "") {
self.color = color
self.title = title
}
}
@Model
final class ReminderModel: Identifiable {
…
init(
dueDate: Date? = nil,
isCompleted: Bool = false,
isFlagged: Bool = false,
notes: String = "",
priority: Priority? = nil,
remindersListID: RemindersList.ID,
title: String = ""
) {
self.dueDate = dueDate
self.isCompleted = isCompleted
self.isFlagged = isFlagged
self.notes = notes
self.priority = priority
self.remindersListID = remindersListID
self.title = title
}
}
We are close to compiling, but something is still failing. Hidden inside the macro expansion of @Model
we will find this:
Instance method 'setValue(forKey:to:)'
requires that 'ReminderModel.Priority'
conform to 'PersistentModel'
This error message is very misleading though. We cannot make Priority
conform to PersistentModel
because that protocol is constrained only to objects. What we really need to do is conform Priority
to Codable
:
enum Priority: Int, Codable {
case low = 1
case medium
case high
}
We’re not sure why SwiftData requires Codable
in order to serialize this data to store in the database, and not sure why it doesn’t just use Priority
’s integer raw value, but at least everything now compiles. And it’s worth noting that it is compiling even though our RemindersListModel
is Equatable
. Classes do not get an automatic synthesized Equatable
conformance like structs do, and for good reason. But the @Model
macros applies the PersistentModel
protocol to our class, and that protocol requires Equatable
and Hashable
protocols:
public protocol PersistentModel:
AnyObject,
Observable,
Hashable,
Identifiable
{
And SwiftData further provides a default conformance to those protocols using the object identity of the class, which as we have explored in past episodes of Point-Free, is the only sensible conformance for classes. 99% of the time one should not conform a class to Hashable
using the data inside the class.
And before moving on to querying these models, we can leverage some of SwiftData’s relationship tools. In SwiftData one does not express foreign key relationships by storing an ID pointer like this:
@Model
class ReminderModel: Identifiable {
…
var remindersListID: RemindersList.ID
…
}
Instead, you hold onto the entire RemindersListModel
and annotate the field with @Relationship
:
@Model
class ReminderModel: Identifiable {
…
@Relationship
var remindersList: RemindersListModel
…
}
This has the benefit of making it so that we don’t think about foreign keys and joining tables. But also has the downside of obscuring a powerful SQL feature from us that makes it possible to aggregate data in a database.
But in order to apply this relationship we need to describe it on the RemindersListModel
, as well:
@Model
class RemindersListModel: Equatable, Identifiable {
…
@Relationship
var reminders: [ReminderModel]
…
}
So that we can reference this relationship in the ReminderModel
:
@Model
class ReminderModel: Identifiable {
…
@Relationship(inverse: \RemindersListModel.reminders)
var remindersList: RemindersListModel
…
}
And we need to manually update both initializers to account for these relationships.
OK, now that we have our models in place, let’s see what it takes to re-construct our complex query from the detail feature, which remember looks like this:
var remindersQuery: some SelectStatementOf<Reminder> {
Reminder
.where {
if !showCompleted {
!$0.isCompleted
}
}
.where {
switch detailType {
case .remindersList(let remindersList):
$0.remindersListID.eq(remindersList.id)
}
}
.order { $0.isCompleted }
.order {
switch ordering {
case .dueDate:
$0.dueDate.asc(nulls: .last)
case .priority:
($0.priority.desc(), $0.isFlagged.desc())
case .title:
$0.title
}
}
}
We will carve out a little space for us to construct a SwiftData query by defining a function that returns a Query
value:
func remindersQuery() -> Query {
}
The Query
type from SwiftData has two generics, one for the model that we are querying for, and another for the type of collection that will be returned when the query is run. This 2nd generics seems to be more of a future looking tools because right now all of SwiftData’s APIs just use a plain array:
func remindersQuery() -> Query<ReminderModel, [ReminderModel]> {
}
And further, the state that we are using to build our details query, such as the showCompleted
boolean, detail type and ordering, will just become arguments we pass to these function to simulate that this is something that can change dynamically:
func remindersQuery(
showCompleted: Bool,
ordering: Ordering,
detailType: DetailType
) -> Query<ReminderModel, [ReminderModel]> {
}
But actually this DetailType
is not correct because that speaks the language of our struct data types, not these new SwiftData classes. So we will define a new version of that type:
enum DetailTypeModel {
case remindersList(RemindersListModel)
}
And use that in the future signature:
func remindersQuery(
showCompleted: Bool,
ordering: Ordering,
detailType: DetailTypeModel
) -> Query<ReminderModel, [ReminderModel]> {
}
OK, how hard can it be to implement this function?
There is an initializer on Query
that takes a predicate, sort, and animation:
Query(
filter: <#Predicate<_>?#>,
sort: <#[SortDescriptor<_>]#>,
animation: <#Animation#>
)
This is a bit different from our query building tools because here SwiftData has separated the concepts of filtering the rows from the database and sorting the rows. But in SQL those concepts are all included in a single SELECT
statement.
Either way we can quickly stub in some arguments just to get things compiling:
Query(
filter: #Predicate { _ in
true
},
sort: [],
animation: .default
)
But we do need to mark our remindersQuery
as @MainActor
since Query
is main actor:
@MainActor
func remindersQuery(
showCompleted: Bool,
ordering: Ordering,
detailType: DetailType
) -> Query<ReminderModel, [ReminderModel]> {
…
}
Let’s first work on the predicate. In our details predicate we start with this little bit of logic:
.where {
if !showCompleted {
!$0.isCompleted
}
}
That makes it so that we hide completed reminders when the showCompleted
flag is false
, and otherwise we show all reminders.
We are able to use an if
statement in the where
trailing closure because that closure has a simplified builder context. The #Predicate
macro does support something similar, though it is not a result builder. Instead, the macro analyzes the Swift syntax inside to transform it into the equivalent SQL code. We could try to use the same syntax:
filter: #Predicate {
if !showCompleted {
!$0.isCompleted
}
},
If expressions without an else expression are not supported in this predicate (from macro 'Predicate'
)
But unlike our builder, it requires the else
branch to form the predicate, which is simple enough to add:
filter: #Predicate {
if !showCompleted {
!$0.isCompleted
} else {
true
}
},
This looks quite similar to our query builder. The trailing closure of #Predicate
is given an actual, concrete ReminderModel
, which means we can access any of its properties.
The next part of the details query allows us to filter the reminders to only include the ones in a particular list, which is determined by the detailType
enum:
.where {
switch detailType {
case .remindersList(let remindersList):
$0.remindersListID.eq(remindersList.id)
}
}
And this is where things start to get tricky with SwiftData predicates. We cannot inline this logic in the #Predicate
macro. First, the trailing closure is not a result builder context, and so we can’t just stack the logical predicates:
filter: #Predicate {
if !showCompleted {
!$0.isCompleted
} else {
true
}
switch detailType {
}
},
Predicate body may only contain one expression (from macro 'Predicate'
)
And on top of that, switch
statements aren’t even allowed in #Predicate
:
filter: #Predicate { _ in
// if !showCompleted {
// !$0.isCompleted
// } else {
// true
// }
switch detailType {
}
},
Switch expressions are not supported in this predicate (from macro 'Predicate'
)
So what we actually need to do is define a separate predicate just for the detail type, and somehow merge its logic into this existing predicate. So, let’s define a detailTypePredicate
:
let detailTypePredicate: Predicate<ReminderModel>
We will switch
over the detail type to figure out how to define this predicate:
switch detailType {
case .remindersList(let remindersListModel):
}
And in this case we can construct a predicate that makes sure our reminders belong to the specified list:
switch detailType {
case .remindersList(let remindersListModel):
detailTypePredicate = #Predicate {
$0.remindersList == remindersListModel
}
}
However this does not compile, and unfortunately Xcode is not showing us an error. This is because the error is actually need inside the macro expansion of #Predicate
:
Foundation.Predicate({
PredicateExpressions.build_Equal(
lhs: PredicateExpressions.build_KeyPath(
root: PredicateExpressions.build_Arg($0),
keyPath: \.remindersList
),
rhs: PredicateExpressions.build_Arg(remindersListModel)
)
})
Cannot convert value of type 'PredicateExpressions.Equal<PredicateExpressions.KeyPath<PredicateExpressions.Variable<ReminderModel>, RemindersListModel>>, PredicateExpressions.Value<RemindersListModel>>'
to closure result type 'any StandardPredicateExpression<Bool>'
And I can’t explain what exactly the error message is trying to communicate to us, but it turns out that it is not appropriate to compare relationships in this way. Instead we need to drop down to comparing the underlying IDs:
switch detailType {
case .remindersList(let remindersListModel):
detailTypePredicate = #Predicate {
$0.remindersList.id == remindersListModel.id
}
}
But even that isn’t enough. There’s still an error deep inside the macro-generated code:
Foundation.Predicate({
PredicateExpressions.build_Equal(
lhs: PredicateExpressions.build_KeyPath(
root: PredicateExpressions.build_KeyPath(
root: PredicateExpressions.build_Arg($0),
keyPath: \.remindersList
),
keyPath: \.id
),
rhs: PredicateExpressions.build_KeyPath(
root: PredicateExpressions.build_Arg(remindersListModel),
keyPath: \.id
)
)
})
Cannot convert value of type 'PredicateExpressions.Equal<PredicateExpressions.KeyPath<PredicateExpressions.KeyPath<PredicateExpressions.Variable<ReminderModel>, RemindersListModel>, PersistentIdentifier>, PredicateExpressions.KeyPath<PredicateExpressions.Value<RemindersListModel>, PersistentIdentifier>>'
to closure result type 'any StandardPredicateExpression<Bool>'
After a good amount of time debugging we found out that it was not OK for us to capture the remindersList
inside the #Predicate
closure, and instead had to compute the id
outside the closure, and then capture that:
case .remindersList(let remindersListModel):
let remindersListID = remindersListModel.id
detailTypePredicate = #Predicate {
$0.remindersList.id == remindersListID
}
Now we’re back to compiling order, but the question is how do we “merge” this predicate into our existing predicate? Well, it’s not well documented, but you can actually evaluate a predicate within another predicate:
filter: #Predicate {
if !showCompleted {
!$0.isCompleted && detailTypePredicate.evaluate($0)
} else {
detailTypePredicate.evaluate($0)
}
},
And we can repeat this evaluation in each branch of the predicate. So the more complex the predicate gets, the less it looks like our SQL query builder version. It is a bit of a bummer that we have to use multiple statements to build up this query instead of just having it all defined in a single expression like we could do with our query builder. But at least it’s done.
Now let’s move on to the sort descriptors.
In our details query we always sort by isCompleted
no matter what in order to make sure that all completed reminders are at the bottom of the list:
.order { $0.isCompleted }
So we can add a SortDescriptor
to the array:
sort: [
SortDescriptor(<#any KeyPath<Compared, Comparable> & Sendable#>)
],
And we can provide a key path to the field we want to sort by. And thanks to type inference Xcode already knows the model these key paths are coming from, and so we can use autocomplete to choose the field on the model:
SortDescriptor(\.isCompleted)
However, we now run into our next snafu in SwiftData:
No exact matches in call to initializer
This is not a good error message, but the problem here is that SortDescriptor
requires that the field we sort on to be a Comparable
type, and Bool
is not comparable. Now, you may find it surprising that booleans are not comparable, but it’s for good reason. There is no well-established, canonical way to sort booleans. Sure it seems logical at first that false
should be less than true
, but this is highly context sensitive.
For example, for the isCompleted
boolean it helps us to think of false
as less than true because we would like incomplete reminders to be above completed. But in the context of the isFlagged
boolean we would want the opposite. We want flagged reminders to be above the unflagged.
And because of this lack of coherence, Swift has made the choice to not conform Bool
to Comparable
, and we think that is the right choice. You could of course fix this by providing your own @retroactive
conformance, but we highly, highly recommend against that. That is a far reaching change to make to all Swift code compiling with your code and can easily have disastrous effects.
And SwiftData could have provided a Bool
-specific API here, and in fact they do have an Optional
-specific API here, though it has its own caveats.
So, all that theoretical stuff out of the way, what are we to do? Well, if all we need is a key path to a comparable thing, could we define a computed property on Bool
to turn it into an integer:
extension Bool {
var toInt: Int { self ? 1 : 0 }
}
And then use that:
SortDescriptor(\.isCompleted.toInt)
Seems like a good idea, and it compiles. However, this will actually crash at runtime. Because the #Predicate
macro is only analyzing the Swift code inside the trailing closure to turn it into an equivalent SQL statement, it will generate invalid SQL. And SwiftData has internal preconditions that get tripped up during that process.
To see this concrete I will paste the following into our app entry point just to exercise this predicate:
import SwiftData
import SwiftUI
@main
struct ModernPersistenceApp: App {
var sharedModelContainer: ModelContainer = {
let schema = Schema([RemindersListModel.self, ReminderModel.self])
let modelConfiguration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: false
)
return try! ModelContainer(
for: schema,
configurations: [modelConfiguration]
)
}()
var body: some Scene {
WindowGroup {
InnerView()
}
.modelContainer(sharedModelContainer)
}
}
struct InnerView: View {
@Query var reminders: [ReminderModel]
init() {
_reminders = remindersQuery(
showCompleted: true,
detailType: .remindersList(RemindersListModel()),
ordering: .dueDate
)
}
var body: some View {
Form {
ForEach(reminders) { _ in }
}
}
}
Running this app crashes with the following message:
SwiftData/Schema.swift:326: Fatal error: Invalid KeyPath isCompleted.toInt on ReminderModel points to a value type: Bool but has additional descendant: toInt
This is a huge bummer. Perfectly reasonable looking code that compiles leads to a runtime crash.
So, what truly is the fix?
Well, honestly, we don’t think there is actually a nice fix out there. We think that one is forced to turn isCompleted
into something that is comparable, for example an integer:
var isCompleted = 0
Which means updating the initializer, and updating the predicate in a strange way:
filter: #Predicate {
if !showCompleted {
$0.isCompleted == 0 && detailTypePredicate.evaluate($0)
} else {
detailTypePredicate.evaluate($0)
}
},
And now the sort descriptor compiles:
SortDescriptor(\.isCompleted)
And running the app no longer produces a crash.
But in some sense this is an even bigger bummer! We are now forced to represent a simple two value boolean with a type that has over 9 quintillion values!
And you may be tempted to add some tricky code like the following to have isCompleted
technically be an integer while still exposing a boolean interface:
var _isCompleted = 0
var isCompleted: Bool {
get { _isCompleted == 0 ? false : true }
set { _isCompleted = newValue ? 1 : 0 }
}
Seems like a good idea. And we can even update our predicate to use this boolean:
filter: #Predicate {
(showCompleted || !$0.isCompleted)
&& detailTypePredicate.evaluate($0)
},
While allowing the sort descriptor to use the integer value:
SortDescriptor(\._isCompleted)
This all compiles, yet sadly crashes at runtime:
SwiftData/DataUtilities.swift:85: Fatal error: Couldn’t find \ReminderModel.isCompleted
on ReminderModel with fields [SwiftData.Schema.PropertyMetadata(name: "dueDate", keypath: \ReminderModel.dueDate, defaultValue: nil, metadata: nil), SwiftData.Schema.PropertyMetadata(name: "_isCompleted", keypath: \ReminderModel._isCompleted, defaultValue: Optional(0), metadata: nil), SwiftData.Schema.PropertyMetadata(name: "isFlagged", keypath: \ReminderModel.isFlagged, defaultValue: Optional(false), metadata: nil), SwiftData.Schema.PropertyMetadata(name: "notes", keypath: \ReminderModel.notes, defaultValue: Optional(""), metadata: nil), SwiftData.Schema.PropertyMetadata(name: "priority", keypath: \ReminderModel.priority, defaultValue: nil, metadata: nil), SwiftData.Schema.PropertyMetadata(name: "remindersList", keypath: \ReminderModel.remindersList, defaultValue: nil, metadata: Optional(Relationship - name: , options: [], valueType: Any, destination: , inverseName: nil, inverseKeypath: nil)), SwiftData.Schema.PropertyMetadata(name: "title", keypath: \ReminderModel.title, defaultValue: Optional(""), metadata: nil)]
This is happening because in our predicate, isCompleted
is only a computed property, not a stored property. And so it can’t possible generate a valid SQL query to some data that isn’t even stored in the database.
And this in our opinion is a fundamental flaw in #Predicate
. Because the trailing closure is handed an honest RemindersListModel
, you are free to pluck out any property on the model, whether it is stored or computed. You just have to have the discipline to force yourself to only ever reach for stored properties, otherwise you will get runtime crashes.
Whereas our query builder uses a special opaque type to represent the schema of your tables, and that is what you get access to in our trailing closures. This means you can only ever access stored properties on the struct, and never accidentally grab a computed property.
For example, if we felt like being cute by defining an isNotCompleted
property on our Reminder
:
extension Reminder {
var isNotCompleted: Bool { !isCompleted }
}
Then accessing this property in a where
clause is a compiler error:
.where {
if !showCompleted {
$0.isNotCompleted
}
}
Value of type 'Reminder.TableColumns'
has no member 'isNotCompleted'
And this property doesn’t even show up in the autocomplete to confuse you. And this is because the trailing closures are handed a TableColumns
type, which only holds the stored properties of your tables.
So, sadly we don’t think the _isCompleted
trick is even worth employing given its caveats, and so it really does seem we are forced to use an integer for a property that is just a boolean:
var isCompleted = 0
OK, we are down to the last ordering clause in our detail query, which switches over the user specified ordering
state to figure out how we order the reminders:
.order {
switch ordering {
case .dueDate:
$0.dueDate.asc(nulls: .last)
case .priority:
($0.priority.desc(), $0.isFlagged.desc())
case .title:
$0.title
}
}
Again this cannot be done inline with our existing sort
array, so we will split out into a separate variable:
let orderingSorts: [SortDescriptor<ReminderModel>] =
switch ordering {
case .dueDate:
[]
case .priority:
[]
case .title:
[]
}
For dueDate
we can construct a sort descriptor easy enough:
case .dueDate:
[SortDescriptor(\.dueDate)]
But what we cannot do is still SwiftData to put all the reminders with no due date last. That is a standard issue feature of SQL but it is completely hidden from us in SwiftData because we do not have access to the underlying SQL.
We could investigate doing another one of those shadow variables so that we can interpret nil
as being in the distant future:
var _dueDate: Date
var dueDate: Date? {
get { _dueDate == .distantFuture ? nil : _dueDate }
set { _dueDate = newValue ?? .distantFuture }
}
That would allow us put reminders with no due date last. But then what do we do when sometime in the future we want to sort by due date in a descending fashion, while still keeping those with no due dates last? By hard coding distantFuture
we will make all of them appear first. I’m not really sure how to handle that, but even beyond that problem, we of course have the problem that we have to remember to always use the _dueDate
when constructing predicates and sort descriptors, because using just dueDate
will crash.
So, we won’t even try to recapture the due date sorting logic in SwiftData that comes so easily to us in SQL.
Next when sorting by priority we want to first sort by the priority
property in descending order, and then by isFlagged
in descending order:
case .priority:
[
SortDescriptor(\.priority, order: .reverse),
SortDescriptor(\.isFlagged, order: .reverse)
]
But we can’t sort by priority
since it is not comparable. Luckily that is an easy one for us to fix:
enum Priority: Int, Codable, Comparable {
case low = 1
case medium
case high
static func < (lhs: Self, rhs: Self) -> Bool {
lhs.rawValue < rhs.rawValue
}
}
But also isFlagged
sorting isn’t working because, yet again, boolean is not comparable. So I guess we have to weaken the isFlagged
state to be an integer:
@Model
final class ReminderModel: Identifiable {
…
var isFlagged = 0
…
init(
…
isFlagged: Int = 0,
…
) {
…
self.isFlagged = isFlagged
…
}
}
And now it compiles.
And we can handle the last case in a quite straightforward manner:
case .title:
[SortDescriptor(\.title)]
And then we take this array of sort descriptors and append them to the array we already have:
sort: [
SortDescriptor(\.isCompleted)
] + detailTypeSorts,
Now everything compiles. And if we run the app in the simulator it seems that the query is constructed without crashing.
But let’s not celebrate yet. There is a crash lurking in the shadows here. Right now our query is sorting by dueDate
, but let’s flip it to priority
:
_reminders = remindersQuery(
showCompleted: true,
ordering: .priority,
detailType: .remindersList(RemindersListModel())
)
How when we run the app we get a new crash, this time it seems to be happening from the depths of CoreData:
CoreData: error: SQLCore dispatchRequest: exception handling request: <NSSQLFetchRequestContext: 0x600003b08380>
, keypath priority not found in entity ReminderModel with userInfo of (null) *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: 'keypath priority not found in entity ReminderModel'
For some reason it says that the priority
key path is not found.
This message is not great, but the reason this is happening is because SwiftData does not support raw representable enums as well as you might hope. You certainly can store raw representable data in your model as a Codable
type. But you can never query against that data, whether it be in a predicate or a sort descriptor.
We actually have no choice but to again weaken our model by using a plain integer instead of an enum for the priority:
@Model
final class ReminderModel: Identifiable {
…
var priority: Int?
…
init(
…
priority: Int? = nil,
…
) {
…
self.priority = priority
…
}
}
Now everything compiles and runs in the simulator without crashing.
And we have finally mostly reconstructed our complex detail query in SwiftData. And I don’t think we are being biased when we say there is a clear winner. First of all, the SwiftData query needed to be built up in multiple steps with multiple statements, and then pieced together in an awkward manner at the end. Whereas our query builder allowed us to build everything in one single statement, and everything reads clearly from top-to-bottom.
And second, we encountered cryptic compiler errors when trying to use the #Predicate
macro. And we were even able to write perfectly reasonable looking, compiling code that actually crashed at runtime. And in order to avoid these crashes we had to repeatedly weaken our model. We were forced to use integers where booleans and simple enums would have been best. And this imprecisely modeled domain is really going to start to show its teeth when we start integrating it into a SwiftUI view because anytime we want to use a simple toggle or picker we are going to have to contend with the fact that we are dealing with integers rather than something more finitely constrained.
And in contrast, our query builder library allows us to fully embrace all of the powers of Swift without sacrificing correctness when it comes to interacting with SQL. And it gives us compile-time errors when we do something wrong, rather than runtime crashes. We really do feel like we are getting the best of both worlds with our tools.
OK, we now have quite a complex query implemented, but how can we be sure it works correctly? With such a complex query there’s a lot we can get wrong, and it would be nice to get some automated test coverage so that we know when the user flips to show completed, that all of the completed reminders are displayed. And when the sort is changed that the reminders are sorted.
Well, luckily it is very easy to get test coverage on this functionality. We already have a lot of the infrastructure in place, and so we can mostly just start writing tests right away.
Let’s get started. But first, we need to get our tests in building order again…
Let’s create a new file for writing tests for our detail feature…
And I am going to paste in some basic scaffolding for the testing:
import InlineSnapshotTesting
import SharingGRDB
import Testing
@testable import ModernPersistence
@MainActor
@Suite(
.dependency(\.defaultDatabase, try appDatabase()),
.snapshots(record: .failed)
)
struct RemindersDetailTests {
}
This is all inspired by what we have in our RemindersListsFeatureTests
. We know we want to override the defaultDatabase
dependency to provide our fully prepared and migrated database. And we want to set the record mode of snapshots to .failed
so that when a test fails it automatically records the newest snapshot. There are other record modes we offer too, but we find .failed
provides a nice balance of quick iteration and being notified when something doesn’t match.
Now, some of you may think that it’s a bit of a bummer that we have two suites with basically the same preamble to them:
@Suite(
.dependency(\.defaultDatabase, try appDatabase()),
.snapshots(record: .failed)
)
struct RemindersDetailTests {
…
}
…and:
@Suite(
.dependency(\.defaultDatabase, try appDatabase()),
.snapshots(record: .failed)
)
struct RemindersListsFeatureTests {
…
}
What if as time goes on there is more work that we want to do for each of our test suites? Wouldn’t it be nice if we could extract this to a shared helper that can be used in all test suites?
Well, there is! Let’s create a new file called BaseTestSuite.swift, and create a new suite that applies all of these settings:
import InlineSnapshotTesting
import SharingGRDB
import Testing
@testable import ModernPersistence
@Suite(
.dependency(\.defaultDatabase, try appDatabase()),
.snapshots(record: .failed)
)
struct BaseTestSuite {
}
Then any existing suite can inherit these settings by simply be nested inside the BaseTestSuite
type. For example, if we nest the RemindersDetailTests
type inside of BaseTestSuite
by simply placing it inside an extension:
extension BaseTestSuite {
@MainActor
struct RemindersDetailTests {
…
}
}
…then RemindersDetailTests
will automatically get all of the traits applied to BaseTestSuite
. There’s no need to repeat them. Now, we do have to apply the @MainActor
attribute on each suite type because that is not something inherited by types that are nested inside a @MainActor
type, but that is ok.
And we can give RemindersListsFeatureTests
the same treatment in order to clean it up:
extension BaseTestSuite {
@MainActor
struct RemindersListsFeatureTests {
…
}
}
OK, now we are ready to write a test that exercises the complex querying logic in our feature:
@Test func querying() async throws {
let model = RemindersDetailModel(detailType: .remindersList(<#???#>))
}
But, to construct a RemindersDetailModel
we need to have access to a reminders list. We could of course construct one from scratch, but without it being persisted to the database there won’t be any reminders associated with it.
Remember that in each test our database has been fully prepared, migrated and seeded with data. So, we can get a handle to the database connection by adding a @Dependency
to our test suite:
struct RemindersDetailTests {
@Dependency(\.defaultDatabase) var database
…
}
And then right at the top of the test we can perform a query on the database to get some reminders list:
let remindersList = try await database.read { db in
}
We know that there are 3 lists seeded in the database, and I’ll just take the one with ID 2, which is the “Family” list:
let remindersList = try await database.read { db in
try RemindersList.find(2).fetchOne(db)!
}
And now we can construct our model:
let model = RemindersDetailModel(detailType: .remindersList(remindersList))
Right out the gate we want to assert on what data was initially loaded into the model. To do this we will wait until the reminders
data has loaded, and then snapshot the reminders:
try await model.$reminders.load()
assertInlineSnapshot(of: model.reminders, as: .customDump)
Now we can just run the test to have the output recorded:
assertInlineSnapshot(of: model.reminders, as: .customDump) {
"""
[
[0]: Reminder(
id: 6,
dueDate: Date(2009-02-15T23:31:30.000Z),
isCompleted: false,
isFlagged: true,
notes: "",
priority: .high,
remindersListID: 2,
title: "Pick up kids from school"
),
[1]: Reminder(
id: 8,
dueDate: Date(2009-02-17T23:31:30.000Z),
isCompleted: false,
isFlagged: false,
notes: "",
priority: .high,
remindersListID: 2,
title: "Take out trash"
)
]
"""
}
The data held in reminders
is instantly inserted right into our test and we can verify that it is correct. It is indeed the case that “Pick up kids from school” and “Take out trash” are our family reminders, and further we can clearly see that only incomplete reminders are being shown.
And speaking of which, the next thing we could simulate is the user tapping the “Show completed” button in the UI and then assert on how the state changes after that:
await model.toggleShowCompletedButtonTapped()
try await model.$reminders.load()
assertInlineSnapshot(of: model.reminders, as: .customDump)
Running the test a new snapshot is recorded clearly showing that a new reminder has now been displayed to the user, “Get laundry”, which has been completed and is displayed at the end of the list:
assertInlineSnapshot(of: model.reminders, as: .customDump) {
"""
[
[0]: Reminder(
id: 6,
dueDate: Date(2009-02-15T23:31:30.000Z),
isCompleted: false,
isFlagged: true,
notes: "",
priority: .high,
remindersListID: 2,
title: "Pick up kids from school"
),
[1]: Reminder(
id: 8,
dueDate: Date(2009-02-17T23:31:30.000Z),
isCompleted: false,
isFlagged: false,
notes: "",
priority: .high,
remindersListID: 2,
title: "Take out trash"
),
[2]: Reminder(
id: 7,
dueDate: Date(2009-02-11T23:31:30.000Z),
isCompleted: true,
isFlagged: false,
notes: "",
priority: .low,
remindersListID: 2,
title: "Get laundry"
)
]
"""
}
And finally we can simulate the user changing the order to be sorted by priority:
await model.orderingButtonTapped(.priority)
try await model.$reminders.load()
assertInlineSnapshot(of: model.reminders, as: .customDump) {
"""
[
[0]: Reminder(
id: 6,
dueDate: Date(2009-02-15T23:31:30.000Z),
isCompleted: false,
isFlagged: true,
notes: "",
priority: .high,
remindersListID: 2,
title: "Pick up kids from school"
),
[1]: Reminder(
id: 8,
dueDate: Date(2009-02-17T23:31:30.000Z),
isCompleted: false,
isFlagged: false,
notes: "",
priority: .high,
remindersListID: 2,
title: "Take out trash"
),
[2]: Reminder(
id: 7,
dueDate: Date(2009-02-11T23:31:30.000Z),
isCompleted: true,
isFlagged: false,
notes: "",
priority: .low,
remindersListID: 2,
title: "Get laundry"
)
]
"""
}
And with that we see that indeed the reminders are first supported by priority, and then for any two reminders with the same priority they will be sorted by whether or not they are flagged. This is why “Pick up kids from school” is sorted above “Take out trash”. It is flagged, whereas “Take out trash” is not.
And we now have some amazing test coverage on this very complex functionality in our feature. And if we ever got something wrong in our query, like say we ordered by isFlagged
in the wrong direction:
case .priority:
($0.priority.desc(), $0.isFlagged)
…we will instantly get a test failure letting us know that something went wrong:
Snapshot did not match. Difference: …
@@ −1,24 +1,24 @@
[
[0]: Reminder(
− id: 6,
− dueDate: Date(2009-02-15T23:31:30.000Z),
− isCompleted: false,
− isFlagged: true,
− notes: "",
− priority: .high,
− remindersListID: 2,
− title: "Pick up kids from school"
− ),
− [1]: Reminder(
id: 8,
dueDate: Date(2009-02-17T23:31:30.000Z),
isCompleted: false,
isFlagged: false,
notes: "",
priority: .high,
remindersListID: 2,
title: "Take out trash"
+ ),
+ [1]: Reminder(
+ id: 6,
+ dueDate: Date(2009-02-15T23:31:30.000Z),
+ isCompleted: false,
+ isFlagged: true,
+ notes: "",
+ priority: .high,
+ remindersListID: 2,
+ title: "Pick up kids from school"
),
[2]: Reminder(
id: 7,
dueDate: Date(2009-02-11T23:31:30.000Z),
A new snapshot was automatically recorded.
Now we see that “Pick up kids from school” comes after “Take out trash”, even though it is flagged and therefore should be put first.
Or, if we had forgotten to take into account the showCompleted
state in order to select only incomplete reminders:
Reminder
// .where {
// if !showCompleted {
// !$0.isCompleted
// }
// }
.where {
Then we get a test failure showing that the “Get laundry” reminder was showing in a place that it should not have:
Snapshot did not match. Difference: …
@@ −17,6 +17,16 @@
notes: "",
priority: .high,
remindersListID: 2,
title: "Take out trash"
+ ),
+ [2]: Reminder(
+ id: 7,
+ dueDate: Date(2009-02-11T23:31:30.000Z),
+ isCompleted: true,
+ isFlagged: false,
+ notes: "",
+ priority: .low,
+ remindersListID: 2,
+ title: "Get laundry"
)
]
A new snapshot was automatically recorded.
It is pretty amazing how quickly we were able to get a massive amount of test coverage on this feature. And it’s all thanks to our inline snapshot testing tool.
We were able to add an impressive amount of code coverage to our complex query in just a few lines of code. Thanks to all of the infrastructure built into our tools, and the fact that everything was built with testing in the mind from the beginning, we are able to easily load up our model, tweak the state that controls the reminders we are viewing, and then snapshot the state in the model. Just really incredible stuff.
But we still don’t have a way of actually creating reminders, which seems to be pretty important for a reminders app. Let’s start working on this feature, which will have a lot of similarities to what we did when implementing the feature to create reminders lists.
Let’s create a new file to house this feature…
And I will paste it a bunch of scaffolding for a form that can edit the various fields of a reminder:
import IssueReporting
import SharingGRDB
import SwiftUI
struct ReminderFormView: View {
var body: some View {
Form {
TextField("Title", text: <#.constant("Get groceries")#>)
TextEditor(text: <#.constant("* Milk * Eggs * Cheese")#>)
.lineLimit(4)
Section {
Button {
<#Tags action#>
} label: {
HStack {
Image(systemName: "number.square.fill")
.font(.title)
.foregroundStyle(.gray)
Text("Tags")
.foregroundStyle(Color(.label))
Spacer()
<#Text("#weekend #fun")#>
.lineLimit(1)
.truncationMode(.tail)
.font(.callout)
.foregroundStyle(.gray)
Image(systemName: "chevron.right")
}
}
}
.popover(isPresented: <#.constant(false)#>) {
NavigationStack {
Text("Tags")
}
}
Section {
Toggle(isOn: <#.constant(false)#>) {
HStack {
Image(systemName: "calendar.circle.fill")
.font(.title)
.foregroundStyle(.red)
Text("Date")
}
}
if <#Due date#> != nil {
DatePicker(
"",
selection: <#.constant(dueDate)#>,
displayedComponents: [.date, .hourAndMinute]
)
}
}
Section {
Toggle(isOn: <#.constant(false)#>) {
HStack {
Image(systemName: "flag.circle.fill")
.font(.title)
.foregroundStyle(.red)
Text("Flag")
}
}
Picker(selection: <#.constant(Reminder.Priority.medium)#>) {
Text("None").tag(Reminder.Priority?.none)
Divider()
Text("High").tag(Reminder.Priority.high)
Text("Medium").tag(Reminder.Priority.medium)
Text("Low").tag(Reminder.Priority.low)
} label: {
HStack {
Image(systemName: "exclamationmark.circle.fill")
.font(.title)
.foregroundStyle(.red)
Text("Priority")
}
}
Picker(selection: <#.constant(reminder.remindersListID)#>) {
ForEach(<#remindersLists#>) { remindersList in
Text(remindersList.title)
.tag(remindersList.id)
.buttonStyle(.plain)
}
} label: {
HStack {
Image(systemName: "list.bullet.circle.fill")
.font(.title)
.foregroundStyle(<#remindersList.color#>)
Text("List")
}
}
}
}
.toolbar {
ToolbarItem {
Button {
} label: {
Text("Save")
}
}
ToolbarItem(placement: .cancellationAction) {
Button {
} label: {
Text("Cancel")
}
}
}
}
}
struct ReminderFormPreview: PreviewProvider {
static var previews: some View {
let _ = try! prepareDependencies {
$0.defaultDatabase = try appDatabase()
}
NavigationStack {
ReminderFormView()
.navigationTitle("Reminder")
}
}
}
There is a lot here, including a lot of placeholders that we need to eventually fill in, but we can run the preview to see what we are dealing with. We have various text fields, toggles, and pickers for editing the various parts of a reminder.
But nothing is functional right now, so let’s get started. First of all, this view holds onto no state whatsoever. We at least need a reminder to make edits to, and so maybe we just hold onto a reminder like this:
struct ReminderFormView: View {
let reminder: Reminder
…
}
That will allow us to provide the reminder from the outside that we want to make edits to. But that’s not going to work because we can’t edit this state from the view. So, we can upgrade it to be local @State
:
struct ReminderFormView: View {
@State var reminder: Reminder
…
}
Now it can be provided to this view from the outside and we can make local edits to it. But this can still be improved. As we saw with creating and editing lists, it is better to deal with a “draft” version of the value that does not require an ID to be present. That way we can have a single view service both creating a reminder from scratch and editing an existing reminder.
And so we will do that:
struct ReminderFormView: View {
@State var reminder: Reminder.Draft
…
}
And let’s go ahead and fix the one compiler error we have with this change, which is to provide a draft to the preview:
ReminderFormView(
reminder: Reminder.Draft(
dueDate: Date(),
isFlagged: false,
notes: "* Milk\n* Eggs\n* Cheese",
priority: .medium,
remindersListID: 1,
title: "Get grocers"
)
)
Now we can start filling in some of these placeholders. The title and notes can not be driven off of a binding to the reminder’s fields:
TextField("Title", text: $reminder.title)
TextEditor(text: $reminder.notes)
.lineLimit(4)
We’re not yet ready to deal with tags so we can skip over those placeholders.
Next we need a binding of boolean that represents whether or not the due date is nil
. The best way to do this define a custom computed property on optional dates that checks whether or not it is nil
:
extension Date? {
var isNotNil: Bool {
get { self != nil }
set { self = newValue ? Date() : nil }
}
}
And we are also providing a setter so that when true
is written we will just default to the current date. And now thanks to the power of dynamic member lookup we can derive a binding to a boolean from the optional due date:
Toggle(isOn: $reminder.dueDate.isNotNil) {
…
}
Next we can check the reminder’s due date to determine whether or not we show a date picker:
if reminder.dueDate != nil {
…
}
Next we need to derive a binding to a date for the DatePicker
, but we can’t use dueDate
since it is optional, and date pickers require honest, non-optional dates.
Well, we can define a subscript, which also benefits from dynamic member lookup, to coalesce an optional binding into a non-optional given some default value:
extension Optional {
subscript(coalesce coalesce: Wrapped) -> Wrapped {
get { self ?? coalesce }
set { self = coalesce }
}
}
And now we can derive a binding to a non-optional date:
DatePicker(
"",
selection: $reminder.dueDate.toNonOptional,
displayedComponents: [.date, .hourAndMinute]
)
Moving on, we can derive a binding of a boolean for the isFlagged
toggle like so:
Toggle(isOn: $reminder.isFlagged) {
…
}
As well as priority picker:
Picker(selection: $reminder.priority) {
…
}
And now we get to something a little tricky. This requires that we have access to the a remindersList
value in order to get its color, but right now we only have the reminder
state, and it has a property for the remindersListID
it belongs to, but not the full list.
So it sounds like we need to hold onto some additional state that determines which list we want this reminder to belong to:
struct ReminderFormView: View {
@State var reminder: Reminder.Draft
let remindersList: RemindersList
…
}
In this case we do not want it to be mutable because the source of truth of which list this reminder belongs to lies in the remindersListID
property of the reminder. We just want to fetch the list based on that ID so that we have its other properties, such as name and color.
So sounds like we want to drive this state off of a query that selects the list who ID matches the ID from the reminder. And we can use the alternative @FetchOne
property wrapper for fetching just one single record instead of a collection of records:
@FetchOne var remindersList: RemindersList
However, this does require a default for the times that the query does not return anything:
@FetchOne var remindersList = RemindersList(id: <#???#>)
But then we are left with the weird situation of need to choose an ID. I guess we could just use 0:
@FetchOne var remindersList = RemindersList(id: 0)
But it would be better to not even have to think about the ID, and we can do that with the draft:
@FetchOne var remindersList = RemindersList.Draft()
Further, we want this state to always be in sync with the remindersListID
property that set in the reminder. When that property changes, we should re-fetch the list from the database. This sounds like a great job for the task(id:)
view modifier in SwiftUI, which allows you to execute some async work when a value changes:
.task(id: reminder.remindersListID) {
}
And in here we can just execute a query to find the reminders list based on the list ID in the reminder:
.task(id: reminder.remindersListID) {
await withErrorReporting {
try await $selectedRemindersList.load(
RemindersList.Draft.where { $0.id == reminder.remindersListID }
)
}
}
We can now be guaranteed that the remindersList
state in our view is always up-to-date with the list specified by our reminder. And now we can set the foreground style based on this state:
.foregroundStyle(remindersList.color.swiftUIColor)
Further, a little bit below we have a picker that wants to choose which list the reminder belongs to. Well, this can now be driven off of our reminder.remindersListID
value:
Picker(selection: $reminder.remindersListID) {
…
}
Next we need to render every current list inside the picker as an option that can be selected. So it sounds like we need to query for all of the available lists to display in here. And this is just the kind of job that @FetchAll
thrives at:
struct ReminderFormView: View {
@FetchAll var remindersLists: [RemindersList]
…
}
That one single line will instantly load all reminders lists in our database and allow us to use that data in the view:
ForEach(remindersLists) { remindersList in
…
}
With that change we can already see the live collection of reminders lists being populated in the previews. But, they aren’t alphabetized. Let’s get a little fancy by sorting by their name:
@FetchAll(RemindersList.order(by: \.title)) var remindersLists
And now the lists displayed in the popover are alphabetized.
Next we can address the actions for the “Save” and “Cancel” buttons. For the “Save” closure we will do something similar as to what we did for reminders lists. We will “upsert” the reminder draft and dismiss the form:
Button {
withErrorReporting {
try database.write { db in
try Reminder.upsert(reminder)
.execute(db)
}
}
dismiss()
} label: {
Text("Save")
}
But we will need to add the dismiss
environment:
@Environment(\.dismiss) var dismiss
And for “Cancel” we will just dismiss:
Button {
dismiss()
} label: {
Text("Cancel")
}
For the most part this form view is done. We are displaying the properties of the reminder, we allow the user to edit those properties, and then they can save or discard their changes. The only placeholders we have left related to tags, which are aren’t ready for yet, and so we will come back to them later.
We now have a form that is capable of servicing two related, but distinct, tasks in our app. It can allow us to create a fresh reminder from scratch, or it can allow us to update an existing reminder. And it was so easy to do thanks to the fact that our @Table
macro generates a “draft” type under the hood whose id
property is optional. This allows us to use the same state for both creating and editing a reminder, and it’s really wonderful to see.
But we still don’t have a way to actually navigating to this new reminders form, nor do we have a way to navigate to the detail screen from the collection of lists at the root of the app. Let’s see what it takes to set up more navigation in the app.
Now let’s make it so that we can navigate to this new reminder form view from the reminder detail view. To represent the form being presented we will add some optional state to our model:
@MainActor
@Observable
class RemindersDetailModel {
…
var reminderForm: Reminder.Draft?
…
}
When this state becomes non-nil
we can present the form and hand it the draft so that it can make changes to it.
Then we can add a sheet(item:)
view modifier to be driven off of this state:
.sheet(item: $model.reminderForm) { reminder in
NavigationStack {
ReminderFormView(reminder: reminder)
}
}
But, in order to use the draft in sheets we are going to have to make it identifiable, just as we did with RemindersList.Draft
:
extension Reminder.Draft: Identifiable {}
That’s all it takes to display the form from the state in our model, but we still aren’t populating that state. There are two action closures we need to implement to populate this state. First there is the trailing closure of the ReminderRow
which is invoked when any of the details buttons are tapped in the row. We will just call out to a method on the model to handle this logic:
ReminderRow(
…
) {
model.reminderDetailsButtonTapped(reminder: reminder)
}
And there’s a “New reminder” button that we will also just call out to a method on the model:
Button {
model.newReminderButtonTapped()
} label: {
…
}
Now we just need to implement this methods:
func newReminderButtonTapped() {
}
func reminderDetailsButtonTapped(reminder: Reminder) {
}
When the details button is tapped we can just construct a draft from the existing reminder:
func reminderDetailsButtonTapped(reminder: Reminder) {
reminderForm = Reminder.Draft(reminder)
}
And for the “New reminder” button we want to populate the draft state with a fresh draft that has no ID, but we do need to provide a remindersListID
, and that can be done by destructuring the detailType
:
func newReminderButtonTapped() {
switch detailType {
case .remindersList(let remindersList):
reminderForm = Reminder.Draft(remindersListID: remindersList.id)
}
}
And that is all it takes! We can open up a reminder form, make a few edits, hit “Save” and instantly see the changes applied in the detail view. We can even move a reminder to a different list and see it instantly be removed from the details list. That’s because we have an observation set up for our massive query in the detail feature that will notice any change to the “reminders” table, and immediately update the state and re-render the view.
Next we will add navigation to the root collection of reminders lists because there is still no way to navigate from that list to an actual detail screen of reminders. To represent this navigation we will add another piece of optional state to our model:
@MainActor
@Observable
class RemindersListsModel {
…
var remindersDetail: RemindersDetailModel?
…
}
Now, if you have followed Point-Free for any amount of time, you will know that we do not like the sight of multiple optionals for driving navigation:
var remindersDetail: RemindersDetailModel?
var remindersListForm: RemindersList.Draft?
This currently allows for a non-sensical situation where both the detail and the form can be open at the same time, even though that shouldn’t be possible. And for each new optional added for navigation we double the number invalid states that our feature can be in.
So typically we like to model this kind of navigation as an enum and then hold onto a single piece of optional state:
enum Destination {
case remindersDetail(RemindersDetailModel)
case remindersListForm(RemindersList.Draft)
}
var destination: Destination?
But sadly SwiftUI does not come with the tools that let us actually do this. The various navigation view modifiers do not have the powers to destructure this state to focus in on just a single case of the enum. But luckily we have filled in the gaps left by SwiftUI. We have a library called SwiftNavigation that allows one to drive navigation from enums, which helps avoid a whole host of bugs in applications.
But, while those concepts are important, we are primarily focused on modern persistence techniques right now, so we are not going to go down this road for now…
With this new optional state in our feature we can use the navigationDestination(item:)
view modifier in order to drive a drill-down from the state:
.navigationDestination(
item: $model.remindersDetail
) { remindersDetail in
RemindersDetailView(model: remindersDetail)
}
But in order for this to work RemindersDetailModel
must be Hashable
. Since the model is a class, and further a @MainActor
class, there is only one sensible way to implement a Hashable
conformance, and that is using object identity:
extension RemindersDetailModel: Hashable {
nonisolated static func == (
lhs: RemindersDetailModel, rhs: RemindersDetailModel
) -> Bool {
lhs === rhs
}
nonisolated func hash(into hasher: inout Hasher) {
hasher.combine(ObjectIdentifier(self))
}
}
It may seem like a strong statement to make that this is the only sensible conformance to Hashable
, but we do stand by it. We had a whole series of episodes devoted to Equatable
and Hashable
, and during those episodes we demonstrated why conformance classes to those protocols is fraught. We highly recommend you go watch those episodes if you are interested.
Now that we have our view modifier hooked up we just need to populate the state when the reminders list is tapped. We will do this by invoking a method on the model:
Button {
model.remindersListTapped(remindersList: row.remindersList)
} label: {
RemindersListRow(
incompleteRemindersCount: row.incompleteRemindersCount,
remindersList: row.remindersList
)
.foregroundColor(.primary)
}
And then the model can be responsible for populating the state:
func remindersListTapped(remindersList: RemindersList) {
remindersDetail = RemindersDetailModel(
detailType: .remindersList(remindersList)
)
}
And just like that navigation is working in the full app. We are able to drill down to a list, add a few reminders, and when we go back to the root list we will see that the count next to the list has gone up. This shows that everything is hooked up correctly and our tools are correctly observing changes in the SQLite database.
Alright, our remake of Apple’s reminder app is really starting to take shape. We now have a root collection of all the reminders lists in our app. We can create a new list or delete any of our lists if we want. Then we can tap a list to drill down into that list and see all the reminders associated with it. And in that detail we are able to sort and filter the lists, and those settings are even saved on a per list basis. And finally, we are able to create new reminders, update existing reminders, as well as delete reminders.
It’s all looking great, but there are just a few more final touches we want to put on the app before ending this series. There are still some placeholders left throughout our views representing functionality that we haven’t yet figured out. There are some complex computations we haven’t dealt with, such as calculating all of the top-level stats at the root of the app. There are also some complex interactions with tags that aren’t implemented. That will give us our first exposure to many-to-many relationships, which is something that can be quite difficult to get right in SwiftData.
And we will get to all of that in due time, but we are going to start with something a bit easier. Right now we have a placeholder in our view for calculating the “past due” state for our reminders. You might typically think that is something that should be computed in app code, but we want to show that it is actually really great for us to leave that computation to SQLite. And this will give us our first exposure into building little reusable SQL helpers that can be pieced together in any query.
Let’s dig in.