SwiftFTS is a Swift wrapper around SQLite FTS5 for fast and simple full-text search on iOS/macOS.
- 🚀 Modern async/await API
- 📦 Rich metadata support for indexed items
- ⚡️ Fast SQLite FTS5 engine (ranking, prefix queries, phrase matching)
- 🔒 Thread-safe
FTSDatabaseQueue - 🎯 Type-safe search results with generics
- 🛠 Query builder for complex searches (AND, OR, phrases)
- 📄 Pagination, custom ranking, and snippet support
- 🔄 Update and remove operations
- 🎨 Custom result transformation with factory closures
- ✅ 100% test coverage
- 📦 No external dependencies
Add the following to your Package.swift dependencies:
dependencies: [ .package(url: "https://github.com/cbess/SwiftFTS.git", revision: "<commit id>") ]
And include SwiftFTS in your target dependencies:
targets: [ .target( name: "YourTarget", dependencies: [ .product(name: "SwiftFTS", package: "SwiftFTS") ] ) ]
import SwiftFTS // create an in-memory database let dbQueue = try FTSDatabaseQueue.makeInMemory() let indexer = try SearchIndexer(databaseQueue: dbQueue) let engine = SearchEngine(databaseQueue: dbQueue) // using built-in FTSItem and FTSItemMetadata let item = FTSItem(id: "one", text: "Hello, world!", type: 1, metadata: FTSItemMetadata()) try await indexer.addItems([item]) // find it let results: [any FullTextSearchable<FTSItemMetadata?>] = try await engine.search(query: "woRld") #expect(results.count == 1) #expect(results.first?.indexItemType == 1)
import SwiftFTS // Create an in-memory database let dbQueue = try FTSDatabaseQueue.makeInMemory() // Or create a file-based database // let dbQueue = try FTSDatabaseQueue(path: "path/to/db.sqlite") // Initialize indexer and search engine let indexer = try SearchIndexer(databaseQueue: dbQueue) let engine = SearchEngine(databaseQueue: dbQueue) // Add documents, using custom types struct Article: FullTextSearchable { let id: String let text: String var indexItemID: String { id } var indexText: String { text } var indexItemType: FTSItemType { FTSItemTypeUnspecified } var indexMetadata: String? { nil } } let article = Article(id: "one", text: "Soli Deo gloria") try await indexer.addItems([article]) // Search let results: [any FullTextSearchable<String?>] = try await engine.search(query: "gloria") print(results.first?.indexText ?? "")
Adopt the FullTextSearchable protocol to make your types searchable (or inherit from FTSItem):
struct MyDocument: FullTextSearchable { // Define your metadata structure struct Metadata: Codable, Sendable { let author: String let year: Int let category: String } let id: String let content: String let type: FTSItemType let metadata: Metadata? let priority: Int // Conform to FullTextSearchable var indexItemID: String { id } var indexText: String { content } var indexMetadata: Metadata? { metadata } // optional var indexItemType: FTSItemType { type } // optional, used to change result sorting var indexPriority: Int { priority } }
Optional metadata: Your metadata can be optional (Metadata?) if not all documents have metadata.
// Create database queue (thread-safe) let dbQueue = try FTSDatabaseQueue.makeInMemory() // Or: let dbQueue = try FTSDatabaseQueue(path: "/path/to/database.sqlite") // Create indexer for adding/updating/removing documents let indexer = try SearchIndexer(databaseQueue: dbQueue) // Create search engine for querying let engine = SearchEngine(databaseQueue: dbQueue)
let doc1 = Document( id: "1", content: "Swift is a powerful programming language.", type: 1, metadata: Document.Metadata(author: "Apple", year: 2014, category: "Programming") ) let doc2 = Document( id: "2", content: "Objective-C was the primary language for iOS.", type: 1, metadata: Document.Metadata(author: "NeXT", year: 1984, category: "Legacy") ) // Add multiple documents at once try await indexer.addItems([doc1, doc2])
let updatedDoc = Document( id: "1", content: "Swift is a powerful and modern programming language.", type: 1, metadata: Document.Metadata(author: "Apple", year: 2024, category: "Programming") ) try await indexer.updateItem(updatedDoc)
// Remove a single item try await indexer.removeItem(id: "1") // Remove multiple items try await indexer.removeItems(ids: ["1", "2", "3"])
// Get total count of indexed items let count = try await indexer.count() // Get total count of indexed items of a specific item type let countType3 = try await indexer.count(type: 3) // Optimize the database (reclaim space after deletions) try await indexer.optimize() // Rebuild the entire FTS index try await indexer.reindex()
// Simple search (case-insensitive) let results: [any FullTextSearchable<Document.Metadata>] = try await engine.search(query: "Swift") for result in results { print("ID: \(result.indexItemID)") print("Text: \(result.indexText)") print("Author: \(result.indexMetadata?.author ?? "Unknown")") }
// Search only documents of a specific type let results: [any FullTextSearchable<Document.Metadata>] = try await engine.search( query: "programming", itemType: 1 )
// Get first page (10 results) let page1: [any FullTextSearchable<Document.Metadata>] = try await engine.search( query: "Swift", offset: 0, limit: 10 ) // Get second page let page2: [any FullTextSearchable<Document.Metadata>] = try await engine.search( query: "Swift", offset: 10, limit: 10 )
Generate highlighted snippets showing the search term in context:
// Create snippet configuration let params = FTSSnippetParameters( startMatch: "«", // Marker before match endMatch: "»", // Marker after match ellipsis: "...", // Truncation indicator tokenCount: 15 // Words of context around match ) let engine = SearchEngine(databaseQueue: dbQueue, snippetParams: params) // Use custom results to get snippets struct SearchResult: Sendable { let id: String let snippet: String? } let results: [SearchResult] = try await engine.search(query: "programming") { ftsItem in SearchResult(id: ftsItem.id, snippet: ftsItem.snippet) } // Example snippet output: "...Swift is a powerful «programming» language..."
Use FTSQueryBuilder for complex queries:
// Find documents matching ANY of the terms let query = FTSQueryBuilder.orQuery("Swift", "Objective-C", "Python") let results: [any FullTextSearchable<Document.Metadata>] = try await engine.search(query: query)
// Find documents matching ALL of the terms let query = FTSQueryBuilder.andQuery("Swift", "programming", "language") let results: [any FullTextSearchable<Document.Metadata>] = try await engine.search(query: query)
// Exact phrase matching let query = FTSQueryBuilder.phraseQuery("powerful programming language") let results: [any FullTextSearchable<Document.Metadata>] = try await engine.search(query: query)
let isValid = FTSQueryBuilder.isValid(userInput) if isValid { let results: [any FullTextSearchable<Document.Metadata>] = try await engine.search(query: userInput) }
Use factory closures to transform search results into your custom types:
// Transform FTSItem results into your Document type let documents: [Document] = try await engine.search(query: "Swift", factory: { ftsItem in Document( id: ftsItem.id, content: ftsItem.text, type: ftsItem.type, metadata: try ftsItem.metadata() ) }) // Custom transformation with additional logic struct EnrichedDocument { let id: String let text: String let isRecent: Bool let author: String? } let enriched: [EnrichedDocument] = try await engine.search(query: "Swift", factory: { ftsItem in let metadata: Document.Metadata? = try ftsItem.metadata() return EnrichedDocument( id: ftsItem.id, text: ftsItem.text, isRecent: (metadata?.year ?? 0) > 2020, author: metadata?.author ) })
Use FTSItemType to categorize your documents:
let article = Document(id: "1", content: "...", type: 1, metadata: ...) // Articles let tutorial = Document(id: "2", content: "...", type: 2, metadata: ...) // Tutorials let reference = Document(id: "3", content: "...", type: 3, metadata: ...) // Reference docs // Search only tutorials let results: [any FullTextSearchable<Document.Metadata>] = try await engine.search( query: "Swift", itemType: 2 )
Documents can have optional metadata:
struct Article: FullTextSearchable { let id: String let text: String let metadata: ArticleMetadata? // Optional var indexItemID: String { id } var indexText: String { text } var indexItemType: FTSItemType { FTSItemTypeUnspecified } var indexMetadata: ArticleMetadata? { metadata } } // Some documents with metadata, some without let withMeta = Article(id: "1", text: "...", metadata: ArticleMetadata(...)) let withoutMeta = Article(id: "2", text: "...", metadata: nil) try await indexer.addItems([withMeta, withoutMeta])
Implement optional hooks to respond to indexing events:
struct Document: FullTextSearchable { // ... properties ... var canIndex: Bool { // Return false to skip indexing this document !content.isEmpty } func willIndex() { // Called before indexing print("About to index: \(id)") } func didIndex() { // Called after successful indexing print("Successfully indexed: \(id)") } }
SwiftFTS automatically handles large batches efficiently:
// Add thousands of documents efficiently var docs: [Document] = [] for i in 1...10000 { docs.append(Document(id: "\(i)", content: "Document \(i)", type: 1, metadata: nil)) } try await indexer.addItems(docs) // Remove items - automatically batched in chunks for optimal performance let idsToRemove = (1...5000).map { "\(0ドル)" } try await indexer.removeItems(ids: idsToRemove)
// Close the database when done dbQueue.close() // Optimize after many deletions to reclaim space try await indexer.optimize() // Rebuild the entire index if needed try await indexer.reindex()
You can register a custom rank function to fully customize how search results are sorted:
import SQLite3 struct Article: FullTextSearchable { let id: String let text: String let priority: Int // Custom priority value var indexItemID: String { id } var indexText: String { text } var indexItemType: FTSItemType { FTSItemTypeUnspecified } var indexMetadata: String? { nil } var indexPriority: Int { priority } } // Create SwiftFTS instance let swiftFTS = try SwiftFTS.makeInMemory() // Define a custom rank function that prioritizes by the priority field // Lower scores appear first in results let customRank: @convention(c) (OpaquePointer?, Int32, UnsafeMutablePointer<OpaquePointer?>?) -> Void = { context, argc, argv in guard let argv else { return } // Extract priority value (argv[1]) // argv[1] is always priority let priority = sqlite3_value_int(argv[1]) // argv[2] is always the fts item type let itemType = sqlite3_value_int(argv[2]) // Return negative priority so higher priority values rank first sqlite3_result_double(context, Double(-priority) - Double(itemType)) } // Register the custom rank function try swiftFTS.registerRankFunction(name: "customRank", block: customRank) // Index articles with different priorities let article1 = Article(id: "1", text: "Swift programming tutorial", priority: 10) let article2 = Article(id: "2", text: "Swift best practices", priority: 50) let article3 = Article(id: "3", text: "Introduction to Swift", priority: 30) try await swiftFTS.indexer.addItems([article1, article2, article3]) // Search - results will be ordered by custom rank function first let results: [any FullTextSearchable<String?>] = try await swiftFTS.searchEngine.search(query: "Swift") // article2 appears first (priority 50), then article3 (30), then article1 (10)
import SwiftFTS // 1. Define your document type struct BlogPost: FullTextSearchable { struct Meta: Codable, Sendable { let author: String let publishedDate: Date let tags: [String] } let id: String let title: String let body: String let meta: Meta var indexItemID: String { id } var indexText: String { "\(title)\(body)" } var indexMetadata: Meta { meta } } // 2. Setup let dbQueue = try FTSDatabaseQueue.makeInMemory() let indexer = try SearchIndexer(databaseQueue: dbQueue) let engine = SearchEngine(databaseQueue: dbQueue) // 3. Index some posts let post1 = BlogPost( id: "swift-intro", title: "Introduction to Swift", body: "Swift is a powerful and intuitive programming language...", meta: BlogPost.Meta(author: "John", publishedDate: Date(), tags: ["swift", "ios"]) ) let post2 = BlogPost( id: "swiftui-basics", title: "SwiftUI Basics", body: "SwiftUI is a declarative framework for building user interfaces...", meta: BlogPost.Meta(author: "Jane", publishedDate: Date(), tags: ["swiftui", "ios"]) ) try await indexer.addItems([post1, post2]) // 4. Search let swiftPosts: [BlogPost] = try await engine.search(query: "Swift", factory: { item in BlogPost( id: item.id, title: "", // You might want to store this separately body: item.text, meta: try item.metadata() ) }) print("Found \(swiftPosts.count) posts about Swift") // 5. Complex search let query = FTSQueryBuilder.andQuery("Swift", "programming") let results: [BlogPost] = try await engine.search(query: query, factory: { item in BlogPost(id: item.id, title: "", body: item.text, meta: try item.metadata()) }) // 6. Cleanup dbQueue.close()
- Use batched operations (
addItems,removeItems) instead of individual operations for better performance - Call
optimize()periodically after large deletions to reclaim disk space - Use type filters when searching specific categories to improve search speed
- Implement
canIndexto skip empty or invalid documents - Use pagination for large result sets to improve memory usage
- Consider file-based databases for persistent storage across app launches
platforms: [ .iOS(.v16), .macOS(.v13), .macCatalyst(.v16), .tvOS(.v16), .visionOS(.v2) ]
- Swift 5.9+
- Xcode 15.0+
SwiftFTS includes comprehensive tests with 100% code coverage. Run tests with:
swift testSee LICENSE file for details.