fix(database): cache unavailable FTS5 capability

This commit is contained in:
zarzet
2026-08-28 18:47:54 +07:00
parent 887a8ede12
commit 357190757c
4 changed files with 98 additions and 10 deletions
+6 -5
View File
@@ -74,7 +74,9 @@ class HistoryDatabase {
static final HistoryDatabase instance = HistoryDatabase._init();
static final sqlite.SingleFlightInitializer<Database> _database =
sqlite.SingleFlightInitializer<Database>();
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<void> _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<void> updateFilePath(
+6 -5
View File
@@ -26,7 +26,9 @@ class LibraryDatabase {
static final sqlite.SingleFlightInitializer<Database> _database =
sqlite.SingleFlightInitializer<Database>();
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<void> _ensureHistoryAttached(Database db) async {
if (_historyAttached) return;
@@ -1659,6 +1659,7 @@ class LibraryDatabase {
await db.close();
_database.reset();
_historyAttached = false;
_searchFtsAvailable = null;
}
Future<Map<String, int>> getFileModTimes({String? sourceId}) async {
+56
View File
@@ -6,6 +6,9 @@ import 'package:sqflite/sqflite.dart';
final _log = AppLogger('AppSqlite');
Future<bool>? _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<T extends Object> {
@@ -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<bool> _supportsTrigramFts5(DatabaseExecutor db) {
return _trigramFts5Capability ??= _probeTrigramFts5(db);
}
Future<bool> _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<bool> 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",
+30
View File
@@ -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 ??='));
});
});
}