#if SQLITE_ENABLE_FTS5 import XCTest import GRDB class FTS5TokenizerTests: GRDBTestCase { private func match(_ db: Database, _ content: String, _ query: String) -> Bool { try! db.execute(sql: "INSERT INTO documents VALUES (?)", arguments: [content]) defer { try! db.execute(sql: "DELETE FROM documents") } return try! Int.fetchOne(db, sql: "SELECT COUNT(*) FROM documents WHERE documents MATCH ?", arguments: [query])! > 0 } 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") } // simple match XCTAssertTrue(match(db, "abcDÉF", "abcDÉF")) // English stemming XCTAssertFalse(match(db, "database", "databases")) // diacritics in latin characters XCTAssertFalse(match(db, "eéÉ", "Èèe")) // unicode case XCTAssertFalse(match(db, "jérôme", "JÉRÔME")) } } 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") } XCTAssertTrue(match(db, "abcXdef", "abcXdef")) XCTAssertFalse(match(db, "abcXdef", "defXabc")) // likely a bug in FTS5. FTS3 handles that well. XCTAssertTrue(match(db, "abcXdef", "abc")) XCTAssertTrue(match(db, "abcXdef", "def")) } } 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") } XCTAssertTrue(match(db, "2016-10-04.txt", "\"2016-10-04.txt\"")) XCTAssertFalse(match(db, "2016-10-04.txt", "2016")) XCTAssertFalse(match(db, "2016-10-04.txt", "txt")) } } 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") } // simple match XCTAssertTrue(match(db, "abcDÉF", "abcDÉF")) // English stemming XCTAssertTrue(match(db, "database", "databases")) // diacritics in latin characters XCTAssertTrue(match(db, "eéÉ", "Èèe")) // unicode case XCTAssertTrue(match(db, "jérôme", "JÉRÔME")) } } 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") } // simple match XCTAssertTrue(match(db, "abcDÉF", "abcDÉF")) // English stemming XCTAssertTrue(match(db, "database", "databases")) // diacritics in latin characters XCTAssertFalse(match(db, "eéÉ", "Èèe")) // unicode case XCTAssertFalse(match(db, "jérôme", "JÉRÔME")) } } 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") } // simple match XCTAssertTrue(match(db, "abcDÉF", "abcDÉF")) // English stemming XCTAssertTrue(match(db, "database", "databases")) // diacritics in latin characters XCTAssertTrue(match(db, "eéÉ", "Èèe")) // unicode case XCTAssertTrue(match(db, "jérôme", "JÉRÔME")) } } 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") } // simple match XCTAssertTrue(match(db, "abcDÉF", "abcDÉF")) // English stemming XCTAssertFalse(match(db, "database", "databases")) // diacritics in latin characters XCTAssertTrue(match(db, "eéÉ", "Èèe")) // unicode case XCTAssertTrue(match(db, "jérôme", "JÉRÔME")) } } 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") } // simple match XCTAssertTrue(match(db, "abcDÉF", "abcDÉF")) // English stemming XCTAssertFalse(match(db, "database", "databases")) // diacritics in latin characters XCTAssertFalse(match(db, "eéÉ", "Èèe")) // unicode case XCTAssertTrue(match(db, "jérôme", "JÉRÔME")) } } #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") } // simple match XCTAssertTrue(match(db, "abcDÉF", "abcDÉF")) // English stemming XCTAssertFalse(match(db, "database", "databases")) // diacritics in latin characters XCTAssertTrue(match(db, "eéÉ", "Èèe")) // unicode case XCTAssertTrue(match(db, "jérôme", "JÉRÔME")) } } #endif func testUnicode61TokenizerCategories() throws { // Prevent SQLCipher failures. // Categories are not mentioned in the SQLite release notes. // They were introduced on 2018-07-13 in https://sqlite.org/src/info/80d2b9e635e3100f // Next version is 3.25.0. // So we assume support for categories was introduced in SQLite 3.25.0. guard sqlite3_libversion_number() >= 3025000 else { throw XCTSkip("FTS5 unicode61 tokenizer categories are not available") } // Default categories try makeDatabaseQueue().inDatabase { db in try db.create(virtualTable: "documents", using: FTS5()) { t in t.column("content") } XCTAssertTrue(match(db, "ABC", "abc")) XCTAssertFalse(match(db, "👍", "👍")) XCTAssertTrue(match(db, "👁‍🗨", "🗨")) XCTAssertFalse(match(db, "🔎🐠", "🐠")) XCTAssertFalse(match(db, "🔎🐠", "🔎🐠")) } // Default categories plus symbols try makeDatabaseQueue().inDatabase { db in try db.create(virtualTable: "documents", using: FTS5()) { t in t.tokenizer = .unicode61(categories: "L* N* Co S*") t.column("content") } XCTAssertTrue(match(db, "ABC", "abc")) XCTAssertTrue(match(db, "👍", "👍")) XCTAssertTrue(match(db, "👁‍🗨", "🗨")) XCTAssertFalse(match(db, "🔎🐠", "🐠")) XCTAssertTrue(match(db, "🔎🐠", "🔎🐠")) } // Only lowercase letters try makeDatabaseQueue().inDatabase { db in try db.create(virtualTable: "documents", using: FTS5()) { t in t.tokenizer = .unicode61(categories: "Ll") t.column("content") } XCTAssertTrue(match(db, "ABCdef g H", "ABCdef")) XCTAssertTrue(match(db, "ABCdef g H", "def")) XCTAssertTrue(match(db, "ABCdef g H", "g")) XCTAssertFalse(match(db, "ABCdef g H", "h")) } } 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") } XCTAssertTrue(match(db, "abcXdef", "abcXdef")) XCTAssertFalse(match(db, "abcXdef", "defXabc")) // likely a bug in FTS5. FTS3 handles that well. XCTAssertTrue(match(db, "abcXdef", "abc")) XCTAssertTrue(match(db, "abcXdef", "def")) } } 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") } XCTAssertTrue(match(db, "2016-10-04.txt", "\"2016-10-04.txt\"")) XCTAssertFalse(match(db, "2016-10-04.txt", "2016")) XCTAssertFalse(match(db, "2016-10-04.txt", "txt")) } } func testTokenize() throws { try makeDatabaseQueue().inDatabase { db in let ascii = try db.makeTokenizer(.ascii()) let porter = try db.makeTokenizer(.porter()) let unicode61 = try db.makeTokenizer(.unicode61()) let unicode61WithDiacritics = try db.makeTokenizer(.unicode61(diacritics: .keep)) // Empty query try XCTAssertEqual(ascii.tokenize(query: "").map(\.token), []) try XCTAssertEqual(porter.tokenize(query: "").map(\.token), []) try XCTAssertEqual(unicode61.tokenize(query: "").map(\.token), []) try XCTAssertEqual(unicode61WithDiacritics.tokenize(query: "").map(\.token), []) try XCTAssertEqual(ascii.tokenize(query: "?!").map(\.token), []) try XCTAssertEqual(porter.tokenize(query: "?!").map(\.token), []) try XCTAssertEqual(unicode61.tokenize(query: "?!").map(\.token), []) try XCTAssertEqual(unicode61WithDiacritics.tokenize(query: "?!").map(\.token), []) // Token queries try XCTAssertEqual(ascii.tokenize(query: "Moby").map(\.token), ["moby"]) try XCTAssertEqual(porter.tokenize(query: "Moby").map(\.token), ["mobi"]) try XCTAssertEqual(unicode61.tokenize(query: "Moby").map(\.token), ["moby"]) try XCTAssertEqual(unicode61WithDiacritics.tokenize(query: "Moby").map(\.token), ["moby"]) try XCTAssertEqual(ascii.tokenize(query: "écarlates").map(\.token), ["écarlates"]) try XCTAssertEqual(porter.tokenize(query: "écarlates").map(\.token), ["ecarl"]) try XCTAssertEqual(unicode61.tokenize(query: "écarlates").map(\.token), ["ecarlates"]) try XCTAssertEqual(unicode61WithDiacritics.tokenize(query: "écarlates").map(\.token), ["écarlates"]) try XCTAssertEqual(ascii.tokenize(query: "fooéı👨👨🏿🇫🇷🇨🇮").map(\.token), ["fooéı👨👨🏿🇫🇷🇨🇮"]) try XCTAssertEqual(porter.tokenize(query: "fooéı👨👨🏿🇫🇷🇨🇮").map(\.token), ["fooeı", "🏿"]) try XCTAssertEqual(unicode61.tokenize(query: "fooéı👨👨🏿🇫🇷🇨🇮").map(\.token), ["fooeı", "🏿"]) try XCTAssertEqual(unicode61WithDiacritics.tokenize(query: "fooéı👨👨🏿🇫🇷🇨🇮").map(\.token), ["fooéı", "🏿"]) try XCTAssertEqual(ascii.tokenize(query: "SQLite database").map(\.token), ["sqlite", "database"]) try XCTAssertEqual(porter.tokenize(query: "SQLite database").map(\.token), ["sqlite", "databas"]) try XCTAssertEqual(unicode61.tokenize(query: "SQLite database").map(\.token), ["sqlite", "database"]) try XCTAssertEqual(unicode61WithDiacritics.tokenize(query: "SQLite database").map(\.token), ["sqlite", "database"]) try XCTAssertEqual(ascii.tokenize(query: "Édouard Manet").map(\.token), ["Édouard", "manet"]) try XCTAssertEqual(porter.tokenize(query: "Édouard Manet").map(\.token), ["edouard", "manet"]) try XCTAssertEqual(unicode61.tokenize(query: "Édouard Manet").map(\.token), ["edouard", "manet"]) try XCTAssertEqual(unicode61WithDiacritics.tokenize(query: "Édouard Manet").map(\.token), ["édouard", "manet"]) // Prefix queries try XCTAssertEqual(ascii.tokenize(query: "*").map(\.token), []) try XCTAssertEqual(porter.tokenize(query: "*").map(\.token), []) try XCTAssertEqual(unicode61.tokenize(query: "*").map(\.token), []) try XCTAssertEqual(unicode61WithDiacritics.tokenize(query: "*").map(\.token), []) try XCTAssertEqual(ascii.tokenize(query: "Robin*").map(\.token), ["robin"]) try XCTAssertEqual(porter.tokenize(query: "Robin*").map(\.token), ["robin"]) try XCTAssertEqual(unicode61.tokenize(query: "Robin*").map(\.token), ["robin"]) try XCTAssertEqual(unicode61WithDiacritics.tokenize(query: "Robin*").map(\.token), ["robin"]) // Phrase queries try XCTAssertEqual(ascii.tokenize(query: "\"foulent muscles\"").map(\.token), ["foulent", "muscles"]) try XCTAssertEqual(porter.tokenize(query: "\"foulent muscles\"").map(\.token), ["foulent", "muscl"]) try XCTAssertEqual(unicode61.tokenize(query: "\"foulent muscles\"").map(\.token), ["foulent", "muscles"]) try XCTAssertEqual(unicode61WithDiacritics.tokenize(query: "\"foulent muscles\"").map(\.token), ["foulent", "muscles"]) try XCTAssertEqual(ascii.tokenize(query: "\"Kim Stan* Robin*\"").map(\.token), ["kim", "stan", "robin"]) try XCTAssertEqual(porter.tokenize(query: "\"Kim Stan* Robin*\"").map(\.token), ["kim", "stan", "robin"]) try XCTAssertEqual(unicode61.tokenize(query: "\"Kim Stan* Robin*\"").map(\.token), ["kim", "stan", "robin"]) try XCTAssertEqual(unicode61WithDiacritics.tokenize(query: "\"Kim Stan* Robin*\"").map(\.token), ["kim", "stan", "robin"]) // Logical queries try XCTAssertEqual(ascii.tokenize(query: "years AND months").map(\.token), ["years", "and", "months"]) try XCTAssertEqual(porter.tokenize(query: "years AND months").map(\.token), ["year", "and", "month"]) try XCTAssertEqual(unicode61.tokenize(query: "years AND months").map(\.token), ["years", "and", "months"]) try XCTAssertEqual(unicode61WithDiacritics.tokenize(query: "years AND months").map(\.token), ["years", "and", "months"]) // column queries try XCTAssertEqual(ascii.tokenize(query: "title:brest").map(\.token), ["title", "brest"]) try XCTAssertEqual(porter.tokenize(query: "title:brest").map(\.token), ["titl", "brest"]) try XCTAssertEqual(unicode61.tokenize(query: "title:brest").map(\.token), ["title", "brest"]) try XCTAssertEqual(unicode61WithDiacritics.tokenize(query: "title:brest").map(\.token), ["title", "brest"]) } } func testTokenize_Unicode61TokenizerCategories() throws { // Prevent SQLCipher failures. // Categories are not mentioned in the SQLite release notes. // They were introduced on 2018-07-13 in https://sqlite.org/src/info/80d2b9e635e3100f // Next version is 3.25.0. // So we assume support for categories was introduced in SQLite 3.25.0. guard sqlite3_libversion_number() >= 3025000 else { throw XCTSkip("FTS5 unicode61 tokenizer categories are not available") } try makeDatabaseQueue().inDatabase { db in let unicode61OnlyLowercaseLetters = try db.makeTokenizer(.unicode61(categories: "Ll")) let unicode61WithSymbols = try db.makeTokenizer(.unicode61(categories: "L* N* Co S*")) // Empty query try XCTAssertEqual(unicode61OnlyLowercaseLetters.tokenize(query: "").map(\.token), []) try XCTAssertEqual(unicode61WithSymbols.tokenize(query: "").map(\.token), []) try XCTAssertEqual(unicode61OnlyLowercaseLetters.tokenize(query: "?!").map(\.token), []) try XCTAssertEqual(unicode61WithSymbols.tokenize(query: "?!").map(\.token), []) // Token queries try XCTAssertEqual(unicode61OnlyLowercaseLetters.tokenize(query: "Moby").map(\.token), ["oby"]) try XCTAssertEqual(unicode61WithSymbols.tokenize(query: "Moby").map(\.token), ["moby"]) try XCTAssertEqual(unicode61OnlyLowercaseLetters.tokenize(query: "écarlates").map(\.token), ["ecarlates"]) try XCTAssertEqual(unicode61WithSymbols.tokenize(query: "écarlates").map(\.token), ["ecarlates"]) try XCTAssertEqual(unicode61OnlyLowercaseLetters.tokenize(query: "fooéı👨👨🏿🇫🇷🇨🇮").map(\.token), ["fooeı", "🏿"]) try XCTAssertEqual(unicode61WithSymbols.tokenize(query: "fooéı👨👨🏿🇫🇷🇨🇮").map(\.token), ["fooeı👨👨🏿🇫🇷🇨🇮"]) try XCTAssertEqual(unicode61OnlyLowercaseLetters.tokenize(query: "SQLite database").map(\.token), ["ite", "database"]) try XCTAssertEqual(unicode61WithSymbols.tokenize(query: "SQLite database").map(\.token), ["sqlite", "database"]) try XCTAssertEqual(unicode61OnlyLowercaseLetters.tokenize(query: "Édouard Manet").map(\.token), ["douard", "anet"]) try XCTAssertEqual(unicode61WithSymbols.tokenize(query: "Édouard Manet").map(\.token), ["edouard", "manet"]) // Prefix queries try XCTAssertEqual(unicode61OnlyLowercaseLetters.tokenize(query: "*").map(\.token), []) try XCTAssertEqual(unicode61WithSymbols.tokenize(query: "*").map(\.token), []) try XCTAssertEqual(unicode61OnlyLowercaseLetters.tokenize(query: "Robin*").map(\.token), ["obin"]) try XCTAssertEqual(unicode61WithSymbols.tokenize(query: "Robin*").map(\.token), ["robin"]) // Phrase queries try XCTAssertEqual(unicode61OnlyLowercaseLetters.tokenize(query: "\"foulent muscles\"").map(\.token), ["foulent", "muscles"]) try XCTAssertEqual(unicode61WithSymbols.tokenize(query: "\"foulent muscles\"").map(\.token), ["foulent", "muscles"]) try XCTAssertEqual(unicode61OnlyLowercaseLetters.tokenize(query: "\"Kim Stan* Robin*\"").map(\.token), ["im", "tan", "obin"]) try XCTAssertEqual(unicode61WithSymbols.tokenize(query: "\"Kim Stan* Robin*\"").map(\.token), ["kim", "stan", "robin"]) // Logical queries try XCTAssertEqual(unicode61OnlyLowercaseLetters.tokenize(query: "years AND months").map(\.token), ["years", "months"]) try XCTAssertEqual(unicode61WithSymbols.tokenize(query: "years AND months").map(\.token), ["years", "and", "months"]) // column queries try XCTAssertEqual(unicode61OnlyLowercaseLetters.tokenize(query: "title:brest").map(\.token), ["title", "brest"]) try XCTAssertEqual(unicode61WithSymbols.tokenize(query: "title:brest").map(\.token), ["title", "brest"]) } } } #endif