#if SQLITE_ENABLE_FTS5 import XCTest import GRDB class FTS5TableBuilderTests: GRDBTestCase { func testWithoutBody() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.create(virtualTable: "documents", using: FTS5()) { t in t.column("content") } assertDidExecute(sql: "CREATE VIRTUAL TABLE \"documents\" USING fts5(content)") try db.execute(sql: "INSERT INTO documents VALUES (?)", arguments: ["abc"]) XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM documents WHERE documents MATCH ?", arguments: ["abc"])!, 1) } } func testOptions() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.create(virtualTable: "documents", ifNotExists: true, using: FTS5()) { t in t.column("content") } assertDidExecute(sql: "CREATE VIRTUAL TABLE IF NOT EXISTS \"documents\" USING fts5(content)") try db.execute(sql: "INSERT INTO documents VALUES (?)", arguments: ["abc"]) XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM documents WHERE documents MATCH ?", arguments: ["abc"])!, 1) } } func testAsciiTokenizer() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.create(virtualTable: "documents", using: FTS5()) { t in t.tokenizer = .ascii() t.column("content") } assertDidExecute(sql: "CREATE VIRTUAL TABLE \"documents\" USING fts5(content, tokenize='''ascii''')") } } func testAsciiTokenizerSeparators() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.create(virtualTable: "documents", using: FTS5()) { t in t.tokenizer = .ascii(separators: ["X"]) t.column("content") } assertDidExecute(sql: "CREATE VIRTUAL TABLE \"documents\" USING fts5(content, tokenize='''ascii'' ''separators'' ''X''')") } } func testAsciiTokenizerTokenCharacters() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.create(virtualTable: "documents", using: FTS5()) { t in t.tokenizer = .ascii(tokenCharacters: Set(".-")) t.column("content") } assertDidExecute(sql: "CREATE VIRTUAL TABLE \"documents\" USING fts5(content, tokenize='''ascii'' ''tokenchars'' ''-.''')") } } func testDefaultPorterTokenizer() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.create(virtualTable: "documents", using: FTS5()) { t in t.tokenizer = .porter() t.column("content") } assertDidExecute(sql: "CREATE VIRTUAL TABLE \"documents\" USING fts5(content, tokenize='''porter''')") } } func testPorterOnAsciiTokenizer() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.create(virtualTable: "documents", using: FTS5()) { t in t.tokenizer = .porter(wrapping: .ascii()) t.column("content") } assertDidExecute(sql: "CREATE VIRTUAL TABLE \"documents\" USING fts5(content, tokenize='''porter'' ''ascii''')") } } func testPorterOnUnicode61Tokenizer() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.create(virtualTable: "documents", using: FTS5()) { t in t.tokenizer = .porter(wrapping: .unicode61()) t.column("content") } assertDidExecute(sql: "CREATE VIRTUAL TABLE \"documents\" USING fts5(content, tokenize='''porter'' ''unicode61''')") } } func testUnicode61Tokenizer() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.create(virtualTable: "documents", using: FTS5()) { t in t.tokenizer = .unicode61() t.column("content") } assertDidExecute(sql: "CREATE VIRTUAL TABLE \"documents\" USING fts5(content, tokenize='''unicode61''')") } } func testUnicode61TokenizerDiacriticsKeep() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.create(virtualTable: "documents", using: FTS5()) { t in t.tokenizer = .unicode61(diacritics: .keep) t.column("content") } assertDidExecute(sql: "CREATE VIRTUAL TABLE \"documents\" USING fts5(content, tokenize='''unicode61'' ''remove_diacritics'' ''0''')") } } #if GRDBCUSTOMSQLITE func testUnicode61TokenizerDiacriticsRemove() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.create(virtualTable: "documents", using: FTS5()) { t in t.tokenizer = .unicode61(diacritics: .remove) t.column("content") } assertDidExecute(sql: "CREATE VIRTUAL TABLE \"documents\" USING fts5(content, tokenize='''unicode61'' ''remove_diacritics'' ''2''')") } } #elseif !GRDBCIPHER func testUnicode61TokenizerDiacriticsRemove() throws { guard #available(iOS 14, macOS 10.16, tvOS 14, watchOS 7, *) else { throw XCTSkip() } let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.create(virtualTable: "documents", using: FTS5()) { t in t.tokenizer = .unicode61(diacritics: .remove) t.column("content") } assertDidExecute(sql: "CREATE VIRTUAL TABLE \"documents\" USING fts5(content, tokenize='''unicode61'' ''remove_diacritics'' ''2''')") } } #endif func testUnicode61TokenizerSeparators() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.create(virtualTable: "documents", using: FTS5()) { t in t.tokenizer = .unicode61(separators: ["X"]) t.column("content") } assertDidExecute(sql: "CREATE VIRTUAL TABLE \"documents\" USING fts5(content, tokenize='''unicode61'' ''separators'' ''X''')") } } func testUnicode61TokenizerTokenCharacters() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.create(virtualTable: "documents", using: FTS5()) { t in t.tokenizer = .unicode61(tokenCharacters: Set(".-")) t.column("content") } assertDidExecute(sql: "CREATE VIRTUAL TABLE \"documents\" USING fts5(content, tokenize='''unicode61'' ''tokenchars'' ''-.''')") } } func testColumns() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.create(virtualTable: "books", using: FTS5()) { t in t.column("author") t.column("title") t.column("body") } assertDidExecute(sql: "CREATE VIRTUAL TABLE \"books\" USING fts5(author, title, body)") try db.execute(sql: "INSERT INTO books VALUES (?, ?, ?)", arguments: ["Melville", "Moby Dick", "Call me Ishmael."]) XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM books WHERE books MATCH ?", arguments: ["Melville"])!, 1) XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM books WHERE books MATCH ?", arguments: ["title:Melville"])!, 0) XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM books WHERE books MATCH ?", arguments: ["author:Melville"])!, 1) } } func testNotIndexedColumns() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.create(virtualTable: "books", using: FTS5()) { t in t.column("author").notIndexed() t.column("title") t.column("body").notIndexed() } assertDidExecute(sql: "CREATE VIRTUAL TABLE \"books\" USING fts5(author UNINDEXED, title, body UNINDEXED)") try db.execute(sql: "INSERT INTO books VALUES (?, ?, ?)", arguments: ["Melville", "Moby Dick", "Call me Ishmael."]) XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM books WHERE books MATCH ?", arguments: ["Dick"])!, 1) XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM books WHERE books MATCH ?", arguments: ["title:Dick"])!, 1) XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM books WHERE books MATCH ?", arguments: ["author:Dick"])!, 0) XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM books WHERE books MATCH ?", arguments: ["Melville"])!, 0) XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM books WHERE books MATCH ?", arguments: ["title:Melville"])!, 0) XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM books WHERE books MATCH ?", arguments: ["author:Melville"])!, 0) } } func testFTS5Options() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.create(virtualTable: "documents", using: FTS5()) { t in t.content = "" t.prefixes = [2, 4] t.columnSize = 0 t.detail = "column" t.column("content") } assertDidExecute(sql: "CREATE VIRTUAL TABLE \"documents\" USING fts5(content, content='', prefix='2 4', columnSize=0, detail=column)") } } func testFTS5Synchronization() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.create(table: "documents") { t in t.primaryKey("id", .integer) t.column("content", .text) } try db.execute(sql: "INSERT INTO documents (content) VALUES (?)", arguments: ["foo"]) try db.create(virtualTable: "ft_documents", using: FTS5()) { t in t.synchronize(withTable: "documents") t.column("content") } // Prepopulated XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM ft_documents WHERE ft_documents MATCH ?", arguments: ["foo"])!, 1) XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM ft_documents WHERE ft_documents MATCH ?", arguments: ["bar"])!, 0) // Synchronized on update try db.execute(sql: "UPDATE documents SET content = ?", arguments: ["bar"]) XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM ft_documents WHERE ft_documents MATCH ?", arguments: ["foo"])!, 0) XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM ft_documents WHERE ft_documents MATCH ?", arguments: ["bar"])!, 1) // Synchronized on insert try db.execute(sql: "INSERT INTO documents (content) VALUES (?)", arguments: ["foo"]) XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM ft_documents WHERE ft_documents MATCH ?", arguments: ["foo"])!, 1) XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM ft_documents WHERE ft_documents MATCH ?", arguments: ["bar"])!, 1) // Synchronized on delete try db.execute(sql: "DELETE FROM documents WHERE content = ?", arguments: ["foo"]) XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM ft_documents WHERE ft_documents MATCH ?", arguments: ["foo"])!, 0) XCTAssertEqual(try Int.fetchOne(db, sql: "SELECT COUNT(*) FROM ft_documents WHERE ft_documents MATCH ?", arguments: ["bar"])!, 1) } } func testFTS5SynchronizationIfNotExists() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.writeWithoutTransaction { db in try db.create(table: "documents") { t in t.primaryKey("id", .integer) t.column("content", .text) } assertDidExecute(sql: "CREATE TABLE \"documents\" (\"id\" INTEGER PRIMARY KEY, \"content\" TEXT)") try db.create(virtualTable: "ft_documents", ifNotExists: true, using: FTS5()) { t in t.synchronize(withTable: "documents") t.column("content") } assertDidExecute(sql: """ CREATE VIRTUAL TABLE IF NOT EXISTS "ft_documents" USING fts5(content, content='documents', content_rowid='id') """) assertDidExecute(sql: """ CREATE TRIGGER IF NOT EXISTS "__ft_documents_ai" AFTER INSERT ON "documents" BEGIN INSERT INTO "ft_documents"("rowid", "content") VALUES (new."id", new."content"); END """) } } func testFTS5SynchronizationCleanup() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.create(table: "documents") { t in t.primaryKey("id", .integer) t.column("content", .text) } try db.execute(sql: "INSERT INTO documents (content) VALUES (?)", arguments: ["foo"]) try db.create(virtualTable: "ft_documents", using: FTS5()) { t in t.synchronize(withTable: "documents") t.column("content") } try db.drop(table: "ft_documents") try db.dropFTS5SynchronizationTriggers(forTable: "ft_documents") // It is possible to modify the content table try db.execute(sql: "UPDATE documents SET content = ?", arguments: ["bar"]) try db.execute(sql: "INSERT INTO documents (content) VALUES (?)", arguments: ["foo"]) try db.execute(sql: "DELETE FROM documents WHERE content = ?", arguments: ["foo"]) // It is possible to recreate the FT table try db.create(virtualTable: "ft_documents", using: FTS5()) { t in t.synchronize(withTable: "documents") t.column("content") } } } func testFTS5SynchronizationCleanupWithLegacySupport() throws { let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in try db.create(table: "documents") { t in t.primaryKey("id", .integer) t.column("content", .text) } try db.execute(sql: "INSERT INTO documents (content) VALUES (?)", arguments: ["foo"]) try db.create(virtualTable: "ft_documents", using: FTS5()) { t in t.synchronize(withTable: "documents") t.column("content") } try db.drop(table: "ft_documents") try db.dropFTS5SynchronizationTriggers(forTable: "documents") // legacy GRDB <= 2.3.1 try db.dropFTS5SynchronizationTriggers(forTable: "ft_documents") // It is possible to modify the content table try db.execute(sql: "UPDATE documents SET content = ?", arguments: ["bar"]) try db.execute(sql: "INSERT INTO documents (content) VALUES (?)", arguments: ["foo"]) try db.execute(sql: "DELETE FROM documents WHERE content = ?", arguments: ["foo"]) // It is possible to recreate the FT table try db.create(virtualTable: "ft_documents", using: FTS5()) { t in t.synchronize(withTable: "documents") t.column("content") } } } // Regression test for func testIssue1390() throws { #if GRDBCUSTOMSQLITE || GRDBCIPHER guard sqlite3_libversion_number() >= 3035000 else { throw XCTSkip("UPSERT is not available") } #else guard #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) else { throw XCTSkip("UPSERT is not available") } #endif let dbQueue = try makeDatabaseQueue() try dbQueue.inDatabase { db in struct Content: Codable, Hashable, PersistableRecord { var id: String var text: String } try db.create(table: "content") { t in t.primaryKey("id", .text) t.column("text", .text) } try db.create(virtualTable: "fts", using: FTS5()) { t in t.tokenizer = .porter() t.synchronize(withTable: "content") t.column("id").notIndexed() t.column("text") } try db.inTransaction { try Content(id: "foobar", text: "baz").upsert(db) return .commit } } } } #endif