diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 31e3847c..b11041fe 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -74,7 +74,9 @@ class HistoryDatabase { static final HistoryDatabase instance = HistoryDatabase._init(); static final sqlite.SingleFlightInitializer _database = sqlite.SingleFlightInitializer(); - bool _searchFtsAvailable = false; + // null means this database connection has not attempted FTS setup yet. + // false is a completed capability/setup result and must not be retried. + bool? _searchFtsAvailable; HistoryDatabase._init(); @@ -89,14 +91,12 @@ class HistoryDatabase { // onCreate normally initializes this derived index. Retry once after // opening an existing database in case an earlier setup was // interrupted; unsupported SQLite builds remain on the LIKE fallback. - if (!_searchFtsAvailable) { - _searchFtsAvailable = await _createSearchFts(db); - } + _searchFtsAvailable ??= await _createSearchFts(db); return db; }); } - bool get searchFtsAvailable => _searchFtsAvailable; + bool get searchFtsAvailable => _searchFtsAvailable ?? false; Future _createDB(Database db, int version) async { _log.i('Creating database schema v$version'); @@ -1079,6 +1079,7 @@ class HistoryDatabase { final db = await database; await db.close(); _database.reset(); + _searchFtsAvailable = null; } Future updateFilePath( diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 479272bc..7236b7e1 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -26,7 +26,9 @@ class LibraryDatabase { static final sqlite.SingleFlightInitializer _database = sqlite.SingleFlightInitializer(); bool _historyAttached = false; - bool _searchFtsAvailable = false; + // null means this database connection has not attempted FTS setup yet. + // false is a completed capability/setup result and must not be retried. + bool? _searchFtsAvailable; LibraryDatabase._init(); @@ -41,14 +43,12 @@ class LibraryDatabase { // onCreate normally initializes this derived index. Retry once after // opening an existing database in case an earlier setup was // interrupted; unsupported SQLite builds remain on the LIKE fallback. - if (!_searchFtsAvailable) { - _searchFtsAvailable = await _createSearchFts(db); - } + _searchFtsAvailable ??= await _createSearchFts(db); return db; }); } - bool get searchFtsAvailable => _searchFtsAvailable; + bool get searchFtsAvailable => _searchFtsAvailable ?? false; Future _ensureHistoryAttached(Database db) async { if (_historyAttached) return; @@ -1659,6 +1659,7 @@ class LibraryDatabase { await db.close(); _database.reset(); _historyAttached = false; + _searchFtsAvailable = null; } Future> getFileModTimes({String? sourceId}) async { diff --git a/lib/services/sqlite_helpers.dart b/lib/services/sqlite_helpers.dart index b8366f7e..0d27cdd7 100644 --- a/lib/services/sqlite_helpers.dart +++ b/lib/services/sqlite_helpers.dart @@ -6,6 +6,9 @@ import 'package:sqflite/sqflite.dart'; final _log = AppLogger('AppSqlite'); +Future? _trigramFts5Capability; +const _trigramFts5ProbeTable = 'spotiflac_trigram_fts5_probe'; + /// Caches an asynchronously-created value while also coalescing concurrent /// callers onto the same in-flight initialization. class SingleFlightInitializer { @@ -108,6 +111,57 @@ String? ftsPhraseSearchQuery(String value) { return '"${value.replaceAll('"', '""')}"'; } +/// Whether [error] means the current SQLite runtime cannot provide the +/// FTS5/trigram combination used by the search indexes. +bool isTrigramFts5UnavailableError(Object error) { + final message = error.toString().toLowerCase(); + return message.contains('no such module: fts5') || + message.contains('no such tokenizer: trigram') || + message.contains('unknown tokenizer: trigram'); +} + +/// Probes FTS5 + trigram once for the current process. +/// +/// Android's sqflite implementation uses the platform SQLite runtime, so +/// compile-time modules can vary by device. A real temporary virtual table is +/// more reliable than PRAGMA compile_options because it also verifies that the +/// trigram tokenizer is registered. All app databases use the same sqflite +/// runtime, so subsequent history/library initialization can reuse the result. +Future _supportsTrigramFts5(DatabaseExecutor db) { + return _trigramFts5Capability ??= _probeTrigramFts5(db); +} + +Future _probeTrigramFts5(DatabaseExecutor db) async { + try { + await db.execute(''' + CREATE VIRTUAL TABLE temp.$_trigramFts5ProbeTable USING fts5( + search_text, + tokenize='trigram' + ) + '''); + return true; + } catch (error) { + if (isTrigramFts5UnavailableError(error)) { + _log.i( + 'Trigram FTS5 is unavailable in this SQLite runtime; ' + 'search will use the LIKE fallback', + ); + } else { + _log.w( + 'Could not probe trigram FTS5; search will use the LIKE fallback: ' + '$error', + ); + } + return false; + } finally { + try { + await db.execute('DROP TABLE IF EXISTS temp.$_trigramFts5ProbeTable'); + } catch (error) { + _log.d('Could not remove the temporary FTS5 probe table: $error'); + } + } +} + /// Creates an external-content FTS5 index that preserves substring search /// semantics through SQLite's trigram tokenizer. /// @@ -122,6 +176,8 @@ Future createTrigramFtsIndex( required String contentTable, required String triggerPrefix, }) async { + if (!await _supportsTrigramFts5(db)) return false; + try { final existingIndex = await db.rawQuery( "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ? LIMIT 1", diff --git a/test/sqlite_helpers_test.dart b/test/sqlite_helpers_test.dart index a0f48422..d4b39676 100644 --- a/test/sqlite_helpers_test.dart +++ b/test/sqlite_helpers_test.dart @@ -103,5 +103,35 @@ void main() { expect(ftsPhraseSearchQuery('a\u0000b'), isNull); }, ); + + test('recognizes missing FTS5 and trigram capability errors', () { + expect( + isTrigramFts5UnavailableError( + Exception('DatabaseException(no such module: fts5)'), + ), + isTrue, + ); + expect( + isTrigramFts5UnavailableError( + Exception('DatabaseException(no such tokenizer: trigram)'), + ), + isTrue, + ); + expect( + isTrigramFts5UnavailableError(Exception('database is locked')), + isFalse, + ); + }); + + test('probes the runtime with a temporary trigram FTS5 table', () { + final source = File( + 'lib/services/sqlite_helpers.dart', + ).readAsStringSync(); + + expect(source, contains('CREATE VIRTUAL TABLE temp.')); + expect(source, contains("tokenize='trigram'")); + expect(source, contains('DROP TABLE IF EXISTS temp.')); + expect(source, contains('_trigramFts5Capability ??=')); + }); }); }