Skip to content
/ SwiftFTS Public

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

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

14 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

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

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages