EP 339 · Modern Search · Sep 22, 2025 ·Members

Video #339: Modern Search: Syntax & Tokenization

smart_display

Loading stream…

Video #339: Modern Search: Syntax & Tokenization

Episode: Video #339 Date: Sep 22, 2025 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep339-modern-search-syntax-tokenization

Episode thumbnail

Description

We round out modern search by diving into FTS5’s query syntax language. We’ll learn how it works, how to escape terms sent directly by the user, and we’ll introduce SwiftUI search tokens that can refine a query by term proximity and tags.

Video

Cloudflare Stream video ID: 037b2ac205bcc066f3f3c91240b496aa Local file: video_339_modern-search-syntax-tokenization.mp4 *(download with --video 339)*

References

Transcript

0:05

The search functionality of our reminders app is really starting to shine. Not only are we using SQLite’s FTS5 module to provide advanced searching capabilities, including ranking, term highlighting and term snippets, but we are even able to leverage all of this work to make our queries more efficient across the board. Even for queries that have nothing to do with searching.

0:23

We’re getting close to the end of this series, and we’ve already covered so many things that we didn’t plan on when we started. But that’s just because of how incredible and powerful the FTS5 module is. And there’s still one more thing we want to cover. Brandon

0:35

It turns out that the text we feed to the

NEAR 1:01

Let’s explore these powers and see how we can leverage them to build an advanced search interface into our reminders app. FTS query syntax

NEAR 0:70

Let’s go back to the fantastic documentation for full-text search and in the table of contents we will see an entire section on “ Full-text Query Syntax ”.

NEAR 1:19

This describes a very tiny query language that can be used within the text that is supplied to the

MATCH 1:27

Among the things we can do with the tiny query language is use boolean operators, such as

NOT 1:44

And we can give this a spin in the SQLite console. For example, what if we wanted to search for all reminders that are about doctors or accountants: sqlite> select * from reminderTexts ..> where reminderTexts MATCH 'doctor OR accountant'; ┌────────────┬────────────────────┬──────────────────────────┬──────┐ │ reminderID │ title │ notes │ tags │ ├────────────┼────────────────────┼──────────────────────────┼──────┤ │ 3 │ Doctor appointment │ Ask about diet │ │ ├────────────┼────────────────────┼──────────────────────────┼──────┤ │ 9 │ Call accountant │ Status of tax return │ │ │ │ │ Expenses for next year │ │ │ │ │ Changing payroll company │ │ └────────────┴────────────────────┴──────────────────────────┴──────┘

NOT 2:14

That found two reminders.

NOT 2:29

Or what if we wanted to find all reminders that are “fun” but not on the “weekend”: sqlite> select * from reminderTexts ..> where reminderTexts MATCH 'fun NOT weekend'; ┌────────────┬─────────────────────┬───────┬──────┐ │ reminderID │ title │ notes │ tags │ ├────────────┼─────────────────────┼───────┼──────┤ │ 5 │ Buy concert tickets │ │ #fun │ └────────────┴─────────────────────┴───────┴──────┘

NOT 2:40

Looks like we’ve gotta buy some concert tickets sometime this week.

NOT 2:44

We can also make this query more correct. Right now “fun” is being searched in all columns of the reminderTexts virtual table, but we only want to search the tags column for that term. We can do that by prepending the search term with the name of a column and then a colon: sqlite> select * from reminderTexts ..> where reminderTexts MATCH 'tags:fun NOT tags:weekend'; ┌────────────┬─────────────────────┬───────┬──────┐ │ reminderID │ title │ notes │ tags │ ├────────────┼─────────────────────┼───────┼──────┤ │ 5 │ Buy concert tickets │ │ #fun │ └────────────┴─────────────────────┴───────┴──────┘

NOT 3:33

This returns the same results, but it is more correct because it will not match reminders that contain the word “fun” in its title or notes. It’s only searching the tags.

NOT 3:41

You can also do the opposite, where you search all columns except for a given column. Like if we wanted to search for “week” but did not want tags to factor in: sqlite> select * from reminderTexts ..> where reminderTexts MATCH '-tags:week'; ┌────────────┬───────────────────────┬───────────────────────────────────┬────────────────────┐ │ reminderID │ title │ notes │ tags │ ├────────────┼───────────────────────┼───────────────────────────────────┼────────────────────┤ │ 1 │ Groceries │ Check weekly specials │ #weekend │ │ │ │ Chips │ │ │ │ │ Dips │ │ │ │ │ Yogurt │ │ │ │ │ Granola │ │ │ │ │ Tomatoes │ │ │ │ │ Milk │ │ │ │ │ Eggs │ │ │ │ │ Apples │ │ │ │ │ Oatmeal │ │ │ │ │ Spinach │ │ ├────────────┼───────────────────────┼───────────────────────────────────┼────────────────────┤ │ 2 │ Haircut next week │ Ask if I can reschedule next week │ #weekend #easy-win │ ├────────────┼───────────────────────┼───────────────────────────────────┼────────────────────┤ │ 4 │ Take a walk this week │ │ #weekend #fun │ ├────────────┼───────────────────────┼───────────────────────────────────┼────────────────────┤ │ 10 │ Send weekly emails │ │ │ └────────────┴───────────────────────┴───────────────────────────────────┴────────────────────┘

NOT 4:26

There’s another powerful operator that is supported called

NEAR 4:52

And that does indeed fine the “Groceries” reminder.

NEAR 4:56

But, if we searched for “milk spinach” then it no longer finds “Groceries” because those words are not near each other: sqlite> select * from reminderTexts where reminderTexts MATCH 'NEAR(milk spinach)';

NEAR 5:19

So that is a basic overview of the tiny query language that is built into full-text searches. And technically our app supports this mini query language because we are just passing along exactly what the user enters to the

MATCH 5:43

We can see this by running the app and searching for “Doctor OR accountant”, and we see that it finds the two reminders. We can also search for “Fun NOT weekend” and it indeed finds “Buy concert tickets”. Or if we search for “-tags:week” we get “Send weekly emails” only, and none of the reminders that are tagged with “weekend”. And finally, if we search “NEAR(milk eggs)” we do get the “Groceries” reminder, but if we search “NEAR(milk spinach)” we do not.

MATCH 6:44

And so it’s kinda awesome that our app technically already supports a little mini query language for building advanced searches. However, we would never want to actually expose this query language to the user in this manner. Our users shouldn’t need to know how to use boolean operators, or be familiar with the schema of our FTS virtual table so that they can search certain columns.

MATCH 7:13

And if the user accidentally types in a syntax error, SQLite will produce an error, which is currently being reported as a runtime warning: SearchRemindersFeature.swift:29 Caught error: SQLite error 1: fts5: syntax error near “AND” - while executing SELECT count(DISTINCT "reminders"."id") FROM "reminders" JOIN "reminderTexts" ON ("reminders"."id" = "reminderTexts"."reminderID") WHERE ("reminderTexts" MATCH ?) AND ("reminders"."status" <> ?)

MATCH 7:39

What we want to do is quote each individual word in the search query in order to kind of “sanitize” it from being interpreted as query syntax by full-texts search. We can do this with a little helper on string for quoting: extension String { fileprivate func quoted() -> String { split(separator: " ").map { """ "\($0)" """ } .joined(separator: " ") } } And then we can apply it when constructing our search query: .where { reminder, reminderText in reminderText.match(searchText.quoted()) }

MATCH 8:69

This will completely quarantine the user’s search text from any of the query language syntax that FTS provides.

MATCH 9:18

With that done we can now search for “Doctor OR accountant” in the app and it now does not return any results. And if we check the logs for what query was executed we will see that each individual word in the search has been quoted: WHERE ("reminderTexts" MATCH '"Doctor" "OR" "accountant"')

MATCH 9:43

This makes it so that FTS5 does not treat

NEAR 9:65

We have now introduced FTS5’s special query language and shown that it is quite powerful. It allows us to perform boolean operators on search results, we can search certain columns for certain terms, and we can even search for multiple words that must be grouped together in the document. And we even saw that technically our app already supported these advanced search operators because the user could just type the query language directly into the search field. Stephen

NEAR 10:30

But then you ripped away that functionality by quoting each term of the search!

NEAR 10:36

But that’s OK, that is the correct thing to do. We do not want our users constructing cryptic searches to get access to this functionality. Instead, we should build first class tools into the app that allows the user to construct advanced searches through the UI. For example, we could allow our users to specify that they want to search for tags by first typing a “#” symbol and then us presenting some UI for them to choose a tag to filter by. And further, if the user enters a few search terms and then hits tab on the keyboard, we can clump those terms into a

NEAR 10:70

We can do all of this quite easily thanks to SQLite’s powerful FTS5 features, and thanks to some of the functionality built into the SwiftUI searchable view modifier.

NEAR 11:19

Let’s take a look.

NEAR 11:22

Let’s start by uncovering what tools SwiftUI gives us to layer on more advanced functionality to search. If we bring up autocomplete on the searchable view modifier we will find there are quite a few overloads. By our count there are actually 27 overloads of this function.

NEAR 11:34

If we further type “token” after “searchable” we will narrow down the overloads to a subset, but still quite a few. These methods have various argument names with the word “token” in it, such as tokens , editableTokens and suggestedTokens . These methods allow you to render special tokens directly in the search bar that indicate some kind of special search is being performed. We can bring up the Apple docs for this API, in particular the “ Performing a search operation ” article, to see what those tokens look like.

NEAR 11:56

The docs are unfortunately quite lacking on this API, and there isn’t much sample code out there, either from Apple or the greater Swift community, and so it can be difficult to get started with these APIs.

NEAR 11:66

We will be using the version of searchable that allows us to specify the tokens to display in the search bar, as well as a trailing closure that determines the view to use for each token: .searchable( text: $searchRemindersModel.searchText, tokens: <#Binding<Collection>#>, token: <#(Identifiable) -> View#> )

NEAR 12:20

The second argument is another binding to a collection of values that represent the tokens that will be displayed in the search bar. The values need to be identifiable, so let’s quickly define a type that wraps a string to represent a token: struct Token: Identifiable { let id = UUID() var value = "" }

NEAR 12:54

And just to get something on the screen, let’s hardcode a constant binding that has an array with a single token: .searchable( text: $searchRemindersModel.searchText, tokens: .constant([Token(value: "Test")]), token: <#(Identifiable) -> View#> )

NEAR 12:69

The final argument for this method is a trailing closure that is handed a token that is to be displayed in the UI, and it is our job to return a view that represents that token. So for now we can just return a simple text view: .searchable( text: $searchRemindersModel.searchText, tokens: .constant([Token(value: "Test")]) ) { token in Text(token.value) }

NEAR 13:28

OK, with that little bit done we can already see in our preview that a special little “Test” token shows up in the search field. And when we type into the field it appears next to the token. Further, if we tap on the token it is selected, and if it wasn’t for the fact that our binding is a constant one we would even be able to tap delete to remove the token.

NEAR 13:60

So this is a fun bit of functionality given to us by the searchable view modifier, but how can we utilize it to offer more advanced search functionality in our application? Well, what if we made it so that the user can type a few words into the search field, and then type “tab” to tokenize their phrase, and then the search we perform will look for documents that have those words appear near each other. So, if you type “pizza party”, hit tab, then the results won’t find documents that have “pizza” in the title and “party” in the notes. We will require that somewhere in the reminder’s data the words “pizza party” show up relatively close to each other.

NEAR 14:31

To do this we need to perform some domain modeling in our observable model that encapsulates all of the behavior for search. Let’s move the Token type to be a nested type in the SearchRemindersModel , and let’s add some state for the tokens: var searchTokens: [Token] = []

NEAR 14:42

Now that we have this state we can derive a binding to it for the searchable modifier: .searchable( text: $searchRemindersModel.searchText, tokens: $searchRemindersModel.searchTokens ) { token in Text(token.value) }

NEAR 14:55

Now anytime the tokens changes it will automatically update the view, and importantly, the view now has the ability to delete items from the tokens.

NEAR 14:62

Now we have to figure out under what circumstances we should add tokens to this array. We already have a didSet defined on the searchText that allows us to see when the value changes so that we can execute a new search query: var searchText = "" { didSet { if oldValue != searchText { updateQuery() } } }

NEAR 15:13

Before executing the query we check if the last character entered was a tab character, and if so, we tokenize the expression they have entered so far, add it to our collection of tokens, and then clear out the search text: if searchText.hasSuffix("\t") { searchTokens.append( Token(value: String(searchText.dropLast())) ) searchText = "" }

NEAR 15:65

And amazingly that little bit of work is all it takes to start tokenizing search phrases. If we run the app in the simulator, type “Pizza party”, and then type tab, we will see a token created directly in the search UI.

NEAR 16:16

However, we aren’t currently using this token information at all in order to customize our search query. All of our searching logic is packed into this one single where clause: .where { reminder, reminderText in reminderText.match(searchText.quoted()) }

NEAR 16:27

This quotes each individual term in the search text, and then matches it in the FTS virtual table index.

NEAR 16:33

We would like to adapt this to additionally search for each token in the collection using the

NEAR 16:47

Then, for each token in the searchTokens array we will perform an additional FTS match against a

NEAR 16:65

But to get access to searchTokens in this type we must pass it in, just as we are with searchText : struct SearchRequest: FetchKeyRequest { … let searchText: String let searchTokens: [SearchRemindersModel.Token] … }

NEAR 17:16

And this also forces the Token type to be Hashable , which is easy enough to do: struct Token: Hashable, Identifiable { let id = UUID() var value = "" }

NEAR 17:30

Now we can loop over each token to individually match it with a “NEAR” query: .where { _, reminderText in for token in searchTokens { reminderText.match("NEAR(\(token.value))") } } And then finally to get things compiling we have to pass along the search tokens when constructing a SearchRequest : try await $searchResults.load( SearchRequest( searchText: searchText, searchTokens: searchTokens ), animation: .default )

NEAR 17:42

This should be all that is necessary to beef up our search query with tokenized phrases, so let’s give it a spin. I’ll run the simulator, search for “Pizza party”, hit tab, and well… sadly we get a SQL error: Caught error: SQLite error 1: fts5: syntax error near “” - while executing SELECT count(DISTINCT "reminders"."id") FROM "reminders" JOIN "reminderTexts" ON ("reminders"."id" = "reminderTexts"."reminderID") WHERE ("reminderTexts" MATCH ?) AND ("reminderTexts" MATCH ?) AND ("reminders"."status" <> ?)

NEAR 17:63

This is actually an existing problem we’ve had in our query, and our use of tokens is just making it more obvious. If we delete all of the text in the search field we will get a similar error.

NEAR 18:14

The problem is that the FTS5

MATCH 18:24

So we should take special care to not perform a

MATCH 18:40

We should probably also do the same for the

NEAR 18:51

But we also probably not even allow creating empty tokens, and so we can add that check to the didSet of our searchText : if searchText.count > 1, searchText.hasSuffix("\t") { … }

NEAR 18:65

OK, with that we are able to perform searches now without errors. And we can see the SQL generated has a

NEAR 19:18

But strangely the act of tokenizing our phrase seems to have gotten rid of all the search results. This is happening because in the list view, which is the view responsible for displaying the RemindersSearchView , checks to see if results should be show based on the contents of the searchText : if model.searchRemindersModel.searchText.isEmpty {

NEAR 19:39

But now we have a more complicated way of providing search information. We have both searchText and searchTokens . So let’s cook up a dedicated helper that looks at both of those pieces of information to determine if we are currently searching: var isSearching: Bool { !searchText.isEmpty || !searchTokens.isEmpty } And then use that to determine when we should show search results: if !model.searchRemindersModel.isSearching {

NEAR 20:26

Finally we can now make use of our new search tokenizer. However, if we search for “Pizza party” and then tab, we of course don’t get any results because we don’t have any reminders for pizza party.

NEAR 20:50

But, if we search for “Milk eggs” and tokenize, then we will see our “Groceries” reminder appear because the notes of that reminder does have “Milk” and “Eggs” quite close to each other, and so that does seem relevant when searching for the whole phrase “Milk eggs”.

NEAR 20:58

However, let’s search for “special eggs”. Before tokenizing the “Groceries” reminder does show up because, indeed, the notes contains the word “Special” from the line “Check weekly specials” and then later in the notes it has “Eggs”. But those words are not close at all and not really related to each other. So, let’s tab in order to tokenize the phrase, and now we see the “Groceries” reminder goes away. That reminder is no longer deemed relevant because although it does contain the word “Special” and “Eggs”, they are not close enough together. Tags tokenization

NEAR 20:70

Well, this is pretty incredible! Thanks to the searchable view modifier in SwiftUI and some more powerful features of SQLite full-text search, we have now provided a new, advanced searching tool to our users. One can search their reminders by typing in some words, and any reminder that has those words in the title, notes or tags will be displayed. But further, if one tabs while focused in the search bar, the current search text will be tokenized in the UI, and then from that point those words will match a reminder only if they are clumped together somewhere in the data of the reminder. And without SQLite’s FTS5 module, I really don’t even know where I would begin to build out such advanced searching functionality. Brandon

NEAR 21:45

But let’s amp things up even more. Let’s provide another way to tokenize certain kinds of search terms. Let’s make it so that we detect if the user types a “#” symbol at the beginning of their search term, and then display a list of tags that are used for organizing reminders. Tapping one of those tags or typing tab should then tokenize that bit of text and search only the tags of reminders.

NEAR 22:13

This sounds quite difficult to do, but I think you will be pleasantly surprised at how quickly we will be able to add this functionality.

NEAR 22:18

Let’s dig in!

NEAR 22:21

Right now our Token type is designed to just handle one type of token, which is a search phrase that is searched for using the

NEAR 22:29

We need to beef up this type so that it can represent two types of tokens: search phrases and tags. And we will do this by modeling a Kind enum for the two types of searches, and holding onto a value of Kind in the Token : struct Token: Hashable, Identifiable { enum Kind { case near, tag } let id = UUID() var value = "" var kind: Kind }

NEAR 22:51

We will be able to use this kind value to customize how we build our search query.

NEAR 22:56

This causes one compiler error where we are constructing a token, which we can fix by specifying that it is now a near token: searchTokens.append( Token(kind: .near, value: String(searchText.dropLast())) )

NEAR 22:70

But this isn’t quite right. We should do extra logic so that when tab is entered when searching for tags, we add a tag token. We can do this by further checking if the first character of the search is a “#” symbol, and if so we will add a tag token, and be sure to drop the first and last characters: if searchText.hasPrefix("#") { searchTokens.append( Token( kind: .tag, value: String( searchText.dropFirst().dropLast() ) ) ) } else { searchTokens.append( Token( kind: .near, value: String(searchText.dropLast()) ) ) }

NEAR 23:66

With this information we can now slightly customize how tag tokens are rendered versus near tokens. We will prefix the token value with a “#” symbol to make it clear that that token is related to reminder tags: .searchable( text: $searchRemindersModel.searchText, tokens: $searchRemindersModel.searchTokens ) { token in switch token.kind { case .near: Text(token.value) case .tag: Text("#\(token.value)") } }

NEAR 24:41

And with that things kinda work. I mean we can search for “#fun”, hit tab, and it is tokenized and the “fun” reminders show. But also I could have searched for “#groceries” and the “Groceries” reminder displays even though there is no “groceries” tag. This is happening because we aren’t doing the extra work necessary to search just for tags from the tag tokens. And further we aren’t live displaying the tags available to us as we type the “#” symbol with other characters. So there’s a bit more work to do here.

NEAR 25:26

First, let’s update our search query to handle the two kinds of tokens we have modeled. Right now we are searching for all tokens using the

NEAR 25:33

Inside this for loop we can switch over the kind of token: switch token.kind { case .near: case .tag: }

NEAR 25:47

And then for each kind of token decide what we want to do. We already know what to do for near tokens: case .near: reminderText.match("NEAR(\(token.value))")

NEAR 25:50

But what do we do for tag tokens?

NEAR 25:53

Earlier in this episode we saw that it’s possible to use FTS5’s special query syntax to search just a single field of the ReminderText index: sqlite> select * from reminderTexts where reminderTexts MATCH 'tags:fun'; ┌────────────┬─────────────────────┬───────┬──────┐ │ reminderID │ title │ notes │ tags │ ├────────────┼─────────────────────┼───────┼──────┤ │ 5 │ Buy concert tickets │ │ #fun │ └────────────┴─────────────────────┴───────┴──────┘

NEAR 25:69

We have access to this special query syntax in our StructuredQueries library with the following: case .tag: reminderText.tags.match(token.value) That is all it takes to search the ReminderText index in just the “tags” field.

NEAR 26:48

Now when we run the app and search for “#groceries” as a token, no results are returned. But if we search for “#fun” we do get some reminders.

NEAR 28:19

To complete this feature we now want to make it so that as you type after the “#” symbol we live search the tags to make it easier for you to add a tag. We will start by adding some state to our SearchRemindersModel that represents the tags that match what is currently entered. But we will add this state to our SearchRequest.Value , which is currently responsible for searching reminders, and will soon also be responsible for search tags: struct SearchRequest: FetchKeyRequest { struct Value { var completedCount = 0 var rows: [Row] = [] var tags: [Tag] = [] } … }

NEAR 29:15

Then we can update the fetch method in here to perform a search for tags when the first character of searchText is the “#” symbol: guard !searchText.hasPrefix("#") else { return Value(tags: []) }

NEAR 29:64

And now we can query the Tag table for all tags whose prefix is the search text, minus the leading “#” symbol: return try Value( tags: Tag.where { $0.title.hasPrefix(searchText.dropFirst()) } .fetchAll(db) )

NEAR 30:54

That’s the basics of this query, but we will actually beef this up into something more interesting in a moment.

NEAR 30:61

Next let’s get the basics of the view into place. At the very top of the search results we can check if there are any searched tags to display. If there are, we can put them in a horizontally scrolling view: var body: some View { if !model.searchResults.tags.isEmpty { Section { ScrollView(.horizontal) { HStack { ForEach(model.searchResults.tags) { tag in Button("#\(tag.title)") { } } } } .scrollIndicators(.hidden) } } … }

NEAR 32:11

When the tag button is tapped we can invoke a method in the model: Button("#\(tag.title)") { model.tagButtonTapped(tag) }

NEAR 32:30

And that method can be responsible for appending a new search token and clearing out the search text: func tagButtonTapped(_ tag: Tag) { searchTokens.append( Token(kind: .tag, value: tag.title) ) searchText = "" }

NEAR 32:51

And now things are working a little nicer. We can type “#” into the search bar, enter a few characters, we will see the list of tags filter down a bit, and then we can tap one of those buttons to search just the tags of reminders. For example, searching “#weekend” and “Milk eggs” finds the “Groceries” reminder because it is tagged with “weekend” and it has “milk” and “eggs” near each other in the document.

NEAR 33:24

That is basically all there is to adding this feature. Pretty incredible how easy it was, right?

NEAR 33:51

But let’s take the time to add one additional feature that will give our users a nicer experience. It is probably the case that the tag we are looking to apply is one of the ones that we most use. So, what if we sorted the tags displayed in the horizontal list so that they are sorted by the number of times they are used across all reminders. We will put the most used tags first.

NEAR 34:16

This sounds like a difficult thing to do, but SQLite makes it incredibly easy. We can beef up the current query we run for fetching tags: return try Value( tags: Tag.where { $0.title.hasPrefix(searchText.dropFirst()) } .fetchAll(db) )

NEAR 34:30

…by having it join into the reminders table, and aggregating counts for each tag.

NEAR 34:43

To do this we need to join the ReminderTag join table: return try Value( tags: Tag .where { $0.title.hasPrefix(searchText.dropFirst()) } .leftJoin(ReminderTag.all) { $0.id.eq($1.tagID) } .fetchAll(db) )

NEAR 34:65

Next, and here is where things get magical, we can sort these results by the count of reminders: return try Value( tags: Tag .where { $0.title.hasPrefix(searchText.dropFirst()) } .leftJoin(ReminderTag.all) { $0.id.eq($1.tagID) } .order { $1.reminderID.count().desc() } .fetchAll(db) )

NEAR 35:25

And further, if two tags have the same number of reminders associated with them we can break the tie by further sorting on their titles: return try Value( tags: Tag .where { $0.title.hasPrefix(searchText.dropFirst()) } .leftJoin(ReminderTag.all) { $0.id.eq($1.tagID) } .order { ($1.count().desc(), $0.title) } .fetchAll(db) )

NEAR 35:39

And at the end of this query we only want the Tag information. We don’t need the data inside the join table, so we can perform a select to discard that information: return try Value( tags: Tag .where { $0.title.hasPrefix(searchText.dropFirst()) } .leftJoin(ReminderTag.all) { $0.id.eq($1.tagID) } .order { ($1.count().desc(), $0.title) } .select { tag, _ in tag } .fetchAll(db) )

NEAR 35:52

And finally, because we are wanting to aggregate the number of reminders per tag, we need to group this query by the tag’s ID: return try Value( tags: Tag .group(by: \.id) .where { $0.title.hasPrefix(searchText.dropFirst()) } .leftJoin(ReminderTag.all) { $0.id.eq($1.tagID) } .order { ($1.count().desc(), $0.title) } .select { tag, _ in tag } .fetchAll(db) )

NEAR 35:70

And truly that is all there is to it. If we run the app, type “#” into the search field, we will see that “#easy-win” is our most commonly used tag. If we tap on the tag to tokenize it, we will see that we have four reminders associated with that tag.

NEAR 37:40

It’s just really incredible how easy it was to add such a complex query. It was almost an afterthought. SQLite really does empower us to build all of kinds of powerful and nuanced features for our users without us having to worry about loading all the data into memory and munging it together. Conclusion

NEAR 37:56

Well, we are finally at the end of our multi-part series on “Modern Search” using SQLite and the FTS5 module. We have learned how to create type-safe and schema-safe virtual tables in our application. We used triggers to monitor changes in our tables in order to populate the full-text search index. And we used some advanced functions in FTS5 to sift through our reminders, rank results based on search terms, highlight matches in the documents, and extract snippets from the documents to display to the user. Stephen

NEAR 38:35

And really there is even more we could discuss on the topic of full-text search, but we will stop here. We still have more planned for our “Modern Persistence” super series, and just last week we officially released our new library, SQLiteData 1.0 , which brings powerful new CloudKit synchronization and data sharing tools to SQLite.

NEAR 38:52

Until next time! References SQLite FTS5 Extension FTS5 is an SQLite virtual table module that provides full-text search functionality to database applications. https://www.sqlite.org/fts5.html 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 0339-fts-pt6 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 .