EP 334 · Modern Search · Aug 11, 2025 ·Members

Video #334: Modern Search: The Basics

smart_display

Loading stream…

Video #334: Modern Search: The Basics

Episode: Video #334 Date: Aug 11, 2025 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep334-modern-search-the-basics

Episode thumbnail

Description

Search is a natural feature to add to an app once your user has stored a whole bunch of data. We will tackle the problem from the perspective of modern persistence using SQLite as your data store by adding a simple search feature to our rewrite of Apple’s Reminders app.

Video

Cloudflare Stream video ID: 660d55314be89cd07fc95013b97fc330 Local file: video_334_modern-search-the-basics.mp4 *(download with --video 334)*

References

Transcript

0:05

We have spent the past many weeks diving into topics related to what we like to call “ Modern Persistence .” In those episodes we uncovered powerful techniques for persisting user data, querying data, observes changes to the data in views, reacting to database events with triggers, and we accomplished all of this with an eye on type-safety, schema-safety, and testability.

0:28

We are going to continue the theme of “Modern Persistence” for a little bit longer by exploring something that naturally comes up once your user has stored a whole bunch of data in your app: how can you efficiently search through it? Searching data sets is something that SQL excels at in general, but as your dataset gets large enough, or your search terms get complex enough, you need to start turning to other tools. Stephen

0:53

One of the most popular tools out there for this is known as “full-text search”, which is a technique that has been codified in many storage systems, such as MySQL , Postgres , Elasticsearch , and of course our beloved SQLite . Full-text search allows you to efficiently process many thousands of documents to search for phrases with some fancy bells and whistles. For example, it can rank documents based on how relevant they are to the search term, and it comes with a simple query syntax for searching complex terms, and it can even help you generate fragments of your documents with search terms highlighted in order to display it to your users. Brandon

1:30

We are going to cover most of this, but first let’s explore how we might approach searching our database from first principles. We are going to take the Reminders app that we have been building for multiple weeks, and we are going to add a search feature to it.

1:42

Let’s get started. Stubbing a search feature

1:44

Let’s start by updating the UI with a search field. SwiftUI makes this incredibly easy for us to add because there is a dedicated searchable view modifier that handles a lot of the heavy lifting for us. We can apply this modifier to the List view in our root view, the RemindersListView : .searchable(text: <#Binding<String>#>)

2:04

We need a binding of a string to use this modifier, which means we need to add some state to our feature. We already have an observable model set up for this view, so let’s add the state to that: @MainActor @Observable class RemindersListsModel { … var searchText = "" … }

2:33

And now we can derive a binding to this string from the model: .searchable(text: $model.searchText)

2:43

And with that one small change we already have a search field appearing at the top of the view. We can even focus the search field to see that it takes care of animating away the navigation bar. But of course typing into the search field does nothing. We need to implement extra logic in our feature to react to the search text changing and re-fetching reminders based on that search text.

3:15

In order to show results in the list we need show different views based on whether the search text is empty or not: List { if model.searchText.isEmpty { … } else { Text("Search results") } }

4:02

Now when we run the preview and type something we see the “Search results” text showing. We now just need to figure out how to populate this view with the actual results.

4:27

Now we could of course cram all the searching logic directly into our existing observable model, but that feels a little cramped. Our RemindersListsModel is already quite complex, as it handles computing some complex queries for stats, and handles deleting lists, adding lists, editing lists, and re-ordering lists. There’s no reason to add reminder searching to this since searching is a completely separate set of functionality from these tasks.

5:19

Let’s create a new file to house this feature…

5:27

And I am going to go ahead and paste in a little scaffolding to get us started. First, this feature is going to be complex enough that it will warrant its own observable model: import SharingGRDB import SwiftUI @MainActor @Observable class SearchRemindersModel { @ObservationIgnored @FetchAll var reminders: [Reminder] }

5:40

This will make it possible to encapsulate all of the logic and behavior of this feature into a single class, which will be very easy for us to write tests against. We will be able to construct this model in a test, hit it with some complex queries, and then assert that it produced the correct search results. It can be pretty amazing to see how easy it is to get test coverage on something so complex, but we will take a look at that a bit later.

6:06

And we have gone ahead and added a @FetchAll of reminders to the model, which will just fetch literally all reminders. However, in the future we will dynamically update this property wrapper so that we can execute a new SQL query and find specific reminders matching our search terms.

6:44

Next I’ll paste in a basic view that holds onto this observable model and displays a bunch of ReminderRow s, as well as a preview: struct SearchRemindersView: View { let model: SearchRemindersModel var body: some View { ForEach(model.reminders) { reminder in ReminderRow( color: .blue, isPastDue: false, reminder: reminder, tags: [], onDetailsTapped: {} ) } } } struct SearchRemindersPreviews: PreviewProvider { static var previews: some View { Content() } struct Content: View { @State var searchText = "" @Bindable var model: SearchRemindersModel init() { let _ = try! prepareDependencies { $0.defaultDatabase = try appDatabase() } model = SearchRemindersModel() } var body: some View { NavigationStack { List { if searchText.isEmpty { Text(#"Tap "Search"..."#) } else { SearchRemindersView(model: model) } } .searchable(text: $searchText) } } } }

7:21

And from the preview we can see that this does indeed show all of our reminders.

8:14

Now that we have a new feature built for search, we can integrate it into the reminders lists feature. We do this by having the RemindersListModel hold onto the new SearchRemindersModel we just created: @MainActor @Observable class RemindersListsModel { … let searchRemindersModel = SearchRemindersModel() … }

8:59

And in the else branch of our check in the view we can now display the SearchRemindersView : } else { SearchRemindersView(model: model.searchRemindersModel) }

9:16

Now when we run the preview and type a character we see our new view being displayed, along with a stub of a reminder result.

9:26

There is one small improvement we can make to this before move to actually executing search queries and dynamically displaying data in this view. Right now the RemindersListsModel holds both the search text and the search model as sibling state: var searchText = "" let searchRemindersModel = SearchRemindersModel()

9:44

It would be better for the SearchRemindersModel to hold onto the searchText state because, after all, it will be doing the heavy lifting when it comes to searching. So, let’s remove the state from this model: // var searchText = "" let searchRemindersModel = SearchRemindersModel()

10:05

Move it into the SearchRemindersModel : @MainActor @Observable class SearchRemindersModel { var searchText = "" }

10:33

Update the conditional to use this state: if model.searchRemindersModel.searchText.isEmpty { … } else { … }

10:48

…and update the searchable view modifier to take the searchText from the nested SearchRemindersModel . Now unfortunately we cannot derive a binding through the nested model: .searchable(text: $model.searchRemindersModel.searchText) Cannot assign to property: ‘searchRemindersModel’ is a ‘let’ constant Because it is defined as a let .

11:20

And while we could change it to a var , we don’t really ever want it to be mutable and replaceable with a whole new model. Instead, we can localize the bindable to the inner model earlier in the view: var body: some View { @Bindable var searchRemindersModel = model.searchRemindersModel … }

11:53

Which will let us derive a binding more directly: .searchable(text: $searchRemindersModel.searchText)

12:05

And while this may seem like a strange dance to do, it’s exactly what SwiftUI already requires when you want to derive a binding from an observable object held in the environment: @Environment var observableObject: SomeObservableObject

12:24

The cannot derive bindings directly to this object because the projected $observableObject refers to Environment and not Bindable . If you want to derive a binding you must do the same kind of dance: var body: some View { @Bindable var observableObject = observableObject … }

12:55

Now everything works the way it did before, but the searchText state lives in the model that actually needs the state. Basic SQL searching

13:26

OK, we now have a basic feature in place. We haven’t really done anything too interesting yet, other than use the searchable view modifier and paste in some scaffolding. Stephen

13:36

So, let’s now do something more fun. We will start to add some rudimentary searching to our app by using the most basic tools at our disposal for matching substrings in text columns. This includes pattern matching operators such as

REGEXP 13:58

Let’s give it a shot.

REGEXP 14:01

We want to make it so that each time the searchText state is changed in our model we fire a new SQL query to search our database. So, sounds like we need a didSet on the field in our model: var searchText = "" { didSet { } }

REGEXP 14:11

Inside this callback we can invoke a method on the model that will be responsible for updating the @FetchAll query: var searchText = "" { didSet { updateQuery() } } func updateQuery() { }

REGEXP 14:25

And so now we just need to implement this method.

REGEXP 14:28

First let’s remember what one does to dynamically update a query in a @FetchAll . There is a method called load on the projected value of our @FetchAll : func updateQuery() { $reminders.load(<#SelectStatement#>) }

REGEXP 14:41

…and it takes a new SQL statement to be executed. By loading this query into $reminders we will execute the query and populate the reminders state with its results. One very quick thing we could do is just load the query that fetches all reminders: func updateQuery() { $reminders.load(Reminder.all) }

REGEXP 14:53

However, the load method is async and it suspends for however long it takes the SQL statement to execute in SQLite. Typically that is a tiny fraction of a second, but regardless, we are not in an async context right now.

REGEXP 15:05

We really have no choice but to spin up an unstructured task so that we can get an async context: func updateQuery() { Task { await $reminders.load(Reminder.all) } }

REGEXP 15:13

But this does introduce a tiny possibility of a race condition. If you typed two characters very quickly, there is a slight change the task created for the first character finishes after the task created for the second character, and that could leave you with the wrong search results being displayed.

REGEXP 15:26

So, we need to further keep track of this unstructured task so that we can cancel any inflight work if the updateQuery method is called again: var searchTask: Task<Void, Never>? func updateQuery() { searchTask?.cancel() searchTask = Task { await $reminders.load(Reminder.all) } }

REGEXP 15:48

The load method is also throwing, and it will throw any errors that SQLite or GRDB produces. Currently we think any such errors are most likely to be programmer error and not something the user can do to fix, so we will report those errors to ourselves by using the withErrorReporting tool from our Issue Reporting library: await withErrorReporting { try await $reminders.load(Reminder.all) }

REGEXP 16:12

We’ve already used this tool in a number of places throughout this app in places where database access can cause an error but we think it’s just programmer error, and so best to only report it as a purple runtime warning in Xcode.

REGEXP 16:30

OK, we are making some progress, but of course we still are not actually searching our database for the searchText terms. If we run the app in the simulator and look at the console, we’ll see that a query has been fired for all reminders before we even tap into the search field, due to the property wrapper’s default behavior: @FetchAll var reminders: [Reminder]

REGEXP 16:54

Because our query is dynamic and we know we want to wait to execute it, we can opt out of this behavior with a special “none” query, which is a query that never executes: @FetchAll(Reminder.none) var reminders

REGEXP 17:04

And now when we run the preview we can see that the query does not execute till we tap into the search field: 0.000s SELECT "reminders"."id", "reminders"."createdAt", "reminders"."dueDate", "reminders"."isFlagged", "reminders"."notes", "reminders"."priority", "reminders"."remindersListID", "reminders"."status", "reminders"."title", "reminders"."updatedAt" FROM "reminders"

REGEXP 17:14

In fact, it executes this query twice . The reason it is executed twice is just due to a long-standing bug in SwiftUI, where typing into a TextField causes it to write to its binding twice, which causes the didSet of searchText to execute twice. One thing we could do to protect against this is check if the previous value differs from the new value, and only then update the query: var searchText = "" { didSet { if oldValue != searchText { updateQuery() } } } Now on our first key stroke we do get two queries executed, but that’s just because one query is to load the initial results and the other is for setting up the subscription to the database. But after that each key stroke executes only a single query.

REGEXP 18:15

OK, now let’s see what it takes to actually implement this updateQuery method. The SQL statement we want to provide is one that searches all of the reminders for the terms specified by the user. So, we can at least start with a where clause: $reminders.load( Reminder.where { } )

REGEXP 18:32

And now we have to decide how exactly we will search the database. We have access to searchText , which is the text the user has entered into the search bar. A very, very simplified way to search for that text is to use the

LIKE 19:11

This will match all reminders whose title contains whatever is in the search text field. We even ship a helper in our StructuredQueries library to hide away the percent symbols: $reminders.load( Reminder.where { $0.title.contains(searchText) } )

LIKE 19:21

And we can even jump to the definition of contains to see exactly what it is doing: public func contains(_ other: QueryValue) -> some QueryExpression<Bool> { like("%\(other)%") }

LIKE 19:30

And with that we now have very rudimentary search functionality in our app. We can run the app and search for “Doctor” and see that it finds that exact reminder. However, if we search for “diet”, which is the notes of the “Doctor appointment” reminder, we will see that nothing comes up. And that’s because we aren’t searching the notes of the reminders.

LIKE 19:45

That’s easy enough to add: Reminder.where { $0.title.contains(searchText) || $0.notes.contains(searchText) }

LIKE 19:53

Now when we search “diet” we see the doctor appointment come up.

LIKE 19:57

We can also search for “Take” and see that there is “Take a walk” and “Take out trash”. But if I further search for “Take walk” nothing appears. And that is because we are requiring that our entire search term be a sub-string of the title or notes of a reminder. However, our search string is “Take walk” and the actual reminder title is “Take a walk”.

LIKE 20:20

So, what we’d like to do is split apart the search term on spaces and search for each term individually in the title and notes. Let’s start by splitting the full search term to get each sub-term: searchText.split(separator: " ")

LIKE 20:35

This produces an array of each term, so we can map over that array to transform each term into a little query fragment: searchText.split(separator: " ") .map { term in }

LIKE 20:40

And now that we have a closure inside a closure we should give a name to argument of the where trailing closure, which represents the schema of the reminders table: Reminder.where { reminder in searchText.split(separator: " ") .map { term in } }

LIKE 20:46

Now we can construct a query fragment that checks if the reminder’s title or notes contains the sub-term that is being mapped: Reminder.where { reminder in searchText.split(separator: " ") .map { term in reminder.title.contains(term) || reminder.notes.contains(term) } }

LIKE 20:57

Right now this has constructed an array of some QueryExpression<Bool> s, which we want to collapse down to a single query expression of a boolean. And the manner we are going to do this collapsing is to combine all of the query expressions with the

AND 21:21

That sounds complex, but there is actually a very fun trick we can employ. Behind the scenes of this where trailing closure is a result builder that allows us to use a subset of regular Swift syntax, such as conditionals and switches, and that gets translated into the obvious SQL syntax.

AND 21:35

We are already making use of this tool over in RemindersDetailFeature.swift in order to customize some where clauses based on parameters that the user can tweak: where { if detailType != .completed && !showCompleted { $0.status.neq(Reminder.Status.completed) } } .where { switch detailType { case .remindersList(let remindersList): $0.remindersListID.eq(remindersList.id) case .all: true case .completed: $0.isCompleted case .flagged: $0.isFlagged case .scheduled: $0.isScheduled case .today: $0.isToday } }

AND 21:51

Well, not only does the trailing closure of where support conditionals like this, but it also supports for loops. If you execute a for loop inside this builder context it is automatically translated into SQL that corresponds to joining each expression in the loop with the

AND 22:07

So, instead of mapping on the split search terms, let’s just do a for loop: Reminder.where { reminder in for term in searchText.split(separator: " ") { reminder.title.contains(term) || reminder.notes.contains(term) } }

AND 22:20

We can even shorten things up since we’re using a control flow statement instead of nested closures: Reminder.where { for term in searchText.split(separator: " ") { $0.title.contains(term) || $0.notes.contains(term) } }

AND 22:28

And believe it or not, that’s all it takes. Let’s run the app and search for “Take walk”. Amazingly it does find the “Take a walk” reminder, even though the search term we entered does not match exactly what is in the title. It is indeed splitting on the search term so that we match each sub-term individually.

AND 22:46

And we can take a look at the logs to see this is indeed the case: 0.000s SELECT "reminders"."id", "reminders"."createdAt", "reminders"."dueDate", "reminders"."isFlagged", "reminders"."notes", "reminders"."priority", "reminders"."remindersListID", "reminders"."status", "reminders"."title", "reminders"."updatedAt" FROM "reminders" WHERE (("reminders"."title" LIKE '%Take%') OR ("reminders"."notes" LIKE '%Take%')) AND (("reminders"."title" LIKE '%walk%') OR ("reminders"."notes" LIKE '%walk%')) Notice that it is searching for “LIKE ‘%Take%’” in the title or notes, and then further searching for “LIKE ‘%walk%’” in the title or notes.

AND 22:59

We can even search for “Doctor diet” and that surfaces our “Doctor appointment” reminder. But note that the “Doctor” sub-term matches the title of the reminder, whereas “diet” matches the notes of the reminder. This is pretty incredible. Next time: Rounding out the demo

AND 23:11

We now have rudimentary searching in our app. We simply split the user-entered search term on spaces to get an array of search sub-terms. And then we search for each of those sub-terms in the reminders table by checking if the term matches as a substring in the title or notes of the reminder. Brandon

AND 23:27

Let’s amp things up a bit though. There are few fun bells and whistles that the real Reminders app in iOS implements in its search that we have not yet captured. For example, it shows the number of completed reminders that satisfy the search parameters and give you the option to delete all of those reminders or just hide them. You can also search tags, not just the title and notes of reminders.

AND 23:52

Let’s see what it takes to add some of these more advanced querying options to our search feature…next time! References SQLiteData Brandon Williams & Stephen Celis A fast, lightweight replacement for SwiftData, powered by SQL. https://github.com/pointfreeco/sqlite-data StructuredQueries A library for building SQL in a safe, expressive, and composable manner. https://github.com/pointfreeco/swift-structured-queries Downloads Sample code 0334-fts-pt1 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 .