Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings
/ SwiftFTS Public

SwiftFTS is a full text search library written in Swift that uses Sqlite3 FTS5 for iOS/macOS apps

License

Notifications You must be signed in to change notification settings

cbess/SwiftFTS

Folders and files

NameName
Last commit message
Last commit date

Latest commit

History

15 Commits

Repository files navigation

SwiftFTS

SwiftFTS is a Swift wrapper around SQLite FTS5 for fast and simple full-text search on iOS/macOS.

Features

  • 🚀 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

Installation

Swift Package Manager

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")
 ]
 )
]

Quick Start

Simple

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)

More advanced

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 ?? "")

Usage

1. Define Your Document Type

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.

2. Setup Database and Components

// 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)

3. Index Your Documents

Adding Items

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])

Updating Items

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)

Removing Items

// Remove a single item
try await indexer.removeItem(id: "1")
// Remove multiple items
try await indexer.removeItems(ids: ["1", "2", "3"])

Other Operations

// 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()

4. Search Your Documents

Basic Search

// 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 with Type Filter

// Search only documents of a specific type
let results: [any FullTextSearchable<Document.Metadata>] = try await engine.search(
 query: "programming",
 itemType: 1
)

Pagination

// 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
)

Search with Snippets

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..."

5. Advanced Query Building

Use FTSQueryBuilder for complex queries:

OR 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)

AND Queries

// 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)

Phrase Queries

// Exact phrase matching
let query = FTSQueryBuilder.phraseQuery("powerful programming language")
let results: [any FullTextSearchable<Document.Metadata>] = try await engine.search(query: query)

Validate Queries

let isValid = FTSQueryBuilder.isValid(userInput)
if isValid {
 let results: [any FullTextSearchable<Document.Metadata>] = try await engine.search(query: userInput)
}

6. Custom Result Transformation

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
 )
})

Advanced Features

Document Type Categories

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
)

Optional Metadata Handling

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])

Lifecycle Hooks

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)")
 }
}

Large Batch Operations

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)

Database Cleanup

// 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()

Custom Rank Function

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)

Complete Basic Example

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()

Performance Tips

  • 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 canIndex to 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

Requirements

platforms: [
 .iOS(.v16),
 .macOS(.v13),
 .macCatalyst(.v16),
 .tvOS(.v16),
 .visionOS(.v2)
]
  • Swift 5.9+
  • Xcode 15.0+

Testing

SwiftFTS includes comprehensive tests with 100% code coverage. Run tests with:

swift test

License

See LICENSE file for details.

About

SwiftFTS is a full text search library written in Swift that uses Sqlite3 FTS5 for iOS/macOS apps

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

AltStyle によって変換されたページ (->オリジナル) /