import 'dart:async'; import 'dart:convert'; import 'package:sqflite_common/sqflite.dart'; import 'package:matrix/src/database/zone_transaction_mixin.dart'; /// Key-Value store abstraction over Sqflite so that the sdk database can use /// a single interface for all platforms. API is inspired by Hive. class BoxCollection with ZoneTransactionMixin { final Database _db; final Set boxNames; final String name; BoxCollection(this._db, this.boxNames, this.name); static Future open( String name, Set boxNames, { Object? sqfliteDatabase, DatabaseFactory? sqfliteFactory, dynamic idbFactory, int version = 1, }) async { if (sqfliteDatabase is! Database) { throw ('You must provide a Database `sqfliteDatabase` for use on native.'); } final batch = sqfliteDatabase.batch(); for (final name in boxNames) { batch.execute( 'CREATE TABLE IF NOT EXISTS $name (k TEXT PRIMARY KEY NOT NULL, v TEXT)', ); batch.execute('CREATE INDEX IF NOT EXISTS k_index ON $name (k)'); } await batch.commit(noResult: true); return BoxCollection(sqfliteDatabase, boxNames, name); } Box openBox(String name) { if (!boxNames.contains(name)) { throw ('Box with name $name is not in the known box names of this collection.'); } return Box(name, this); } Batch? _activeBatch; Future transaction( Future Function() action, { List? boxNames, bool readOnly = false, }) => zoneTransaction(() async { final batch = _db.batch(); _activeBatch = batch; await action(); _activeBatch = null; await batch.commit(noResult: true); }); Future clear() => transaction( () async { for (final name in boxNames) { await _db.delete(name); } }, ); Future close() => zoneTransaction(() => _db.close()); @Deprecated('use collection.deleteDatabase now') static Future delete(String path, [dynamic factory]) => (factory ?? databaseFactory).deleteDatabase(path); Future deleteDatabase(String path, [dynamic factory]) async { await close(); await (factory ?? databaseFactory).deleteDatabase(path); } } class Box { final String name; final BoxCollection boxCollection; final Map _cache = {}; /// _cachedKeys is only used to make sure that if you fetch all keys from a /// box, you do not need to have an expensive read operation twice. There is /// no other usage for this at the moment. So the cache is never partial. /// Once the keys are cached, they need to be updated when changed in put and /// delete* so that the cache does not become outdated. Set? _cachedKeys; bool get _keysCached => _cachedKeys != null; static const Set allowedValueTypes = { List, Map, String, int, double, bool, }; Box(this.name, this.boxCollection) { if (!allowedValueTypes.any((type) => V == type)) { throw Exception( 'Illegal value type for Box: "${V.toString()}". Must be one of $allowedValueTypes', ); } } String? _toString(V? value) { if (value == null) return null; switch (V) { case const (List): case const (Map): return jsonEncode(value); case const (String): case const (int): case const (double): case const (bool): default: return value.toString(); } } V? _fromString(Object? value) { if (value == null) return null; if (value is! String) { throw Exception( 'Wrong database type! Expected String but got one of type ${value.runtimeType}'); } switch (V) { case const (int): return int.parse(value) as V; case const (double): return double.parse(value) as V; case const (bool): return (value == 'true') as V; case const (List): return List.unmodifiable(jsonDecode(value)) as V; case const (Map): return Map.unmodifiable(jsonDecode(value)) as V; case const (String): default: return value as V; } } Future> getAllKeys([Transaction? txn]) async { if (_keysCached) return _cachedKeys!.toList(); final executor = txn ?? boxCollection._db; final result = await executor.query(name, columns: ['k']); final keys = result.map((row) => row['k'] as String).toList(); _cachedKeys = keys.toSet(); return keys; } Future> getAllValues([Transaction? txn]) async { final executor = txn ?? boxCollection._db; final result = await executor.query(name); return Map.fromEntries( result.map( (row) => MapEntry( row['k'] as String, _fromString(row['v']) as V, ), ), ); } Future get(String key, [Transaction? txn]) async { if (_cache.containsKey(key)) return _cache[key]; final executor = txn ?? boxCollection._db; final result = await executor.query( name, columns: ['v'], where: 'k = ?', whereArgs: [key], ); final value = result.isEmpty ? null : _fromString(result.single['v']); _cache[key] = value; return value; } Future> getAll(List keys, [Transaction? txn]) async { if (!keys.any((key) => !_cache.containsKey(key))) { return keys.map((key) => _cache[key]).toList(); } // The SQL operation might fail with more than 1000 keys. We define some // buffer here and half the amount of keys recursively for this situation. const getAllMax = 800; if (keys.length > getAllMax) { final half = keys.length ~/ 2; return [ ...(await getAll(keys.sublist(0, half))), ...(await getAll(keys.sublist(half))), ]; } final executor = txn ?? boxCollection._db; final list = []; final result = await executor.query( name, where: 'k IN (${keys.map((_) => '?').join(',')})', whereArgs: keys, ); final resultMap = Map.fromEntries( result.map((row) => MapEntry(row['k'] as String, _fromString(row['v']))), ); // We want to make sure that they values are returnd in the exact same // order than the given keys. That's why we do this instead of just return // `resultMap.values`. list.addAll(keys.map((key) => resultMap[key])); _cache.addAll(resultMap); return list; } Future put(String key, V val) async { final txn = boxCollection._activeBatch; final params = { 'k': key, 'v': _toString(val), }; if (txn == null) { await boxCollection._db.insert( name, params, conflictAlgorithm: ConflictAlgorithm.replace, ); } else { txn.insert( name, params, conflictAlgorithm: ConflictAlgorithm.replace, ); } _cache[key] = val; _cachedKeys?.add(key); return; } Future delete(String key, [Batch? txn]) async { txn ??= boxCollection._activeBatch; if (txn == null) { await boxCollection._db.delete(name, where: 'k = ?', whereArgs: [key]); } else { txn.delete(name, where: 'k = ?', whereArgs: [key]); } // Set to null instead remove() so that inside of transactions null is // returned. _cache[key] = null; _cachedKeys?.remove(key); return; } Future deleteAll(List keys, [Batch? txn]) async { txn ??= boxCollection._activeBatch; final placeholder = keys.map((_) => '?').join(','); if (txn == null) { await boxCollection._db.delete( name, where: 'k IN ($placeholder)', whereArgs: keys, ); } else { txn.delete( name, where: 'k IN ($placeholder)', whereArgs: keys, ); } for (final key in keys) { _cache[key] = null; _cachedKeys?.removeAll(keys); } return; } Future clear([Batch? txn]) async { txn ??= boxCollection._activeBatch; if (txn == null) { await boxCollection._db.delete(name); } else { txn.delete(name); } _cache.clear(); _cachedKeys = null; return; } }