diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d53035..be41436 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ * `get` is now `read` to be symmetric to write (but instead of entities it takes keys, of course) * `query` now takes an optional `where` parameter, so instead of `getAll()` you can now just use `query()` to get the same result * `delete` now bundles multiple ways to identify entities to delete, offering a simpler API surface for the overall store +* Support foreign key (`referencing`) and `unique` constraints on indices + * With `referencing` one can ensure that the index's value points to an entityt with the same primary key in another store + * A `unique` index ensures that only one entity in the store uses the same value for that key ## 1.4.7 diff --git a/example/pubspec.lock b/example/pubspec.lock index 04d774d..4a5555d 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -121,7 +121,7 @@ packages: path: ".." relative: true source: path - version: "2.0.0-dev2" + version: "2.0.0-dev3" leak_tracker: dependency: transitive description: diff --git a/lib/src/index_collector.dart b/lib/src/index_collector.dart new file mode 100644 index 0000000..e3be8cc --- /dev/null +++ b/lib/src/index_collector.dart @@ -0,0 +1,37 @@ +part of 'index_entity_store.dart'; + +// NOTE(tp): This is implemented as a `class` with `call` such that we can +// correctly capture the index type `I` and forward that to `IndexColumn` +class IndexCollector { + IndexCollector._(this._entityKey); + + final String _entityKey; + + final _indices = >[]; + + /// Adds a new index defined by the mapping [index] and stores it in [as] + void call( + I Function(T e) index, { + required String as, + + /// If non-`null` this index points to the to the specified entity, like a foreign key contraints on the referenced entity's primary key + /// + /// When inserting an entry in this store then, the referenced entity must already exist in the database. + /// When deleting the referenced entity, the referencing entities in this store must be removed beforehand (they will not get automatically deleted). + /// The index is not allowed to return `null`, but rather must always return a valid primary of the referenced entity. + String? referencing, + + /// If `true`, the value for this index (`as`) must be unique in the entire store + bool unique = false, + }) { + _indices.add( + IndexColumn._( + entity: _entityKey, + field: as, + getIndexValue: index, + referencedEntity: referencing, + unique: unique, + ), + ); + } +} diff --git a/lib/src/index_column.dart b/lib/src/index_column.dart index 23dae75..92cebd8 100644 --- a/lib/src/index_column.dart +++ b/lib/src/index_column.dart @@ -9,9 +9,13 @@ class IndexColumn { required String entity, required String field, required I Function(T e) getIndexValue, + required String? referencedEntity, + required bool unique, }) : _entity = entity, _field = field, - _getIndexValueFunc = getIndexValue { + _getIndexValueFunc = getIndexValue, + _referencedEntity = referencedEntity, + _unique = unique { if (!_typeEqual() && !_typeEqual() && !_typeEqual() && @@ -28,6 +32,18 @@ class IndexColumn { 'Can not create index for field "$field", as type can not be asserted. Type is $I.', ); } + + if (referencedEntity != null && + (_typeEqual() || + _typeEqual() || + _typeEqual() || + _typeEqual() || + _typeEqual() || + _typeEqual())) { + throw Exception( + 'Can not create index for field "$field" referencing "$referencedEntity" where the "value" is nullable. Type is $I.', + ); + } } final String _entity; @@ -36,6 +52,10 @@ class IndexColumn { final I Function(T e) _getIndexValueFunc; + final String? _referencedEntity; + + final bool _unique; + // Usually I, just for `DateTime` we have some special handling to support that out of the box (by converting to int) dynamic _getIndexValue(T e) { final v = _getIndexValueFunc(e); diff --git a/lib/src/index_entity_store.dart b/lib/src/index_entity_store.dart index 8a693be..abab40d 100644 --- a/lib/src/index_entity_store.dart +++ b/lib/src/index_entity_store.dart @@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart'; import 'package:indexed_entity_store/indexed_entity_store.dart'; import 'package:sqlite3/sqlite3.dart'; +part 'index_collector.dart'; part 'index_column.dart'; part 'index_columns.dart'; part 'query.dart'; @@ -268,7 +269,7 @@ class IndexedEntityStore { } late final _insertIndexStatement = _database.prepare( - 'INSERT INTO `index` (`type`, `entity`, `field`, `value`) VALUES (?, ?, ?, ?)', + 'INSERT INTO `index` (`type`, `entity`, `field`, `value`, `referenced_type`, `unique`) VALUES (?, ?, ?, ?, ?, ?)', persistent: true, ); @@ -280,6 +281,8 @@ class IndexedEntityStore { _connector.getPrimaryKey(e), indexColumn._field, indexColumn._getIndexValue(e), + indexColumn._referencedEntity, + indexColumn._unique, ], ); } @@ -366,25 +369,47 @@ class IndexedEntityStore { } void _ensureIndexIsUpToDate() { - final currentlyIndexedFields = _database - .select( - 'SELECT DISTINCT `field` FROM `index` WHERE `type` = ?', - [this._entityKey], - ) - .map((r) => r['field'] as String) - .toSet(); - - final currentEntityIndexedFields = _indexColumns._indexColumns.keys.toSet(); - - final missingFields = - currentEntityIndexedFields.difference(currentlyIndexedFields); - - if (currentEntityIndexedFields.length != currentlyIndexedFields.length || - missingFields.isNotEmpty) { - debugPrint( - 'Need to update index as fields where changed or added', - ); + final List<({String field, bool usesUnique, bool usesReference})> + currentDatabaseIndices = _database + .select( + 'SELECT DISTINCT `field`, `unique` = 1 AS usesUnique, `referenced_type` IS NOT NULL AS usesReference FROM `index` WHERE `type` = ?', + [this._entityKey], + ) + .map( + (row) => ( + field: row['field'] as String, + usesUnique: row['usesUnique'] == 1, + usesReference: row['usesReference'] == 1, + ), + ) + .toList(); + + var needsIndexUpdate = false; + if (currentDatabaseIndices.length != _indexColumns._indexColumns.length) { + needsIndexUpdate = true; + } else { + for (final storeIndex in _indexColumns._indexColumns.values) { + final databaseIndex = currentDatabaseIndices + .where( + (dbIndex) => + dbIndex.field == storeIndex._field && + dbIndex.usesReference == + (storeIndex._referencedEntity != null) && + dbIndex.usesUnique == storeIndex._unique, + ) + .firstOrNull; + + if (databaseIndex == null) { + debugPrint( + 'Index "${storeIndex._field}" (referencing "${storeIndex._referencedEntity}", unique "${storeIndex._unique}") was not found in the database and will now be created.', + ); + + needsIndexUpdate = true; + } + } + } + if (needsIndexUpdate) { try { _database.execute('BEGIN'); @@ -444,26 +469,5 @@ class IndexedEntityStore { } } -// NOTE(tp): This is implemented as a `class` with `call` such that we can -// correctly capture the index type `I` and forward that to `IndexColumn` -class IndexCollector { - IndexCollector._(this._entityKey); - - final String _entityKey; - - final _indices = >[]; - - /// Adds a new index defined by the mapping [index] and stores it in [as] - void call(I Function(T e) index, {required String as}) { - _indices.add( - IndexColumn._( - entity: _entityKey, - field: as, - getIndexValue: index, - ), - ); - } -} - /// Specifies how the result should be sorted typedef OrderByClause = (String column, SortOrder direction); diff --git a/lib/src/indexed_entity_database.dart b/lib/src/indexed_entity_database.dart index 5bf0059..1702400 100644 --- a/lib/src/indexed_entity_database.dart +++ b/lib/src/indexed_entity_database.dart @@ -3,11 +3,20 @@ import 'package:indexed_entity_store/indexed_entity_store.dart'; import 'package:sqlite3/sqlite3.dart'; class IndexedEntityDabase { - factory IndexedEntityDabase.open(String path) { - return IndexedEntityDabase._(path); + factory IndexedEntityDabase.open( + String path, { + @visibleForTesting int targetSchemaVersion = 4, + }) { + return IndexedEntityDabase._( + path, + targetSchemaVersion: targetSchemaVersion, + ); } - IndexedEntityDabase._(String path) : _database = sqlite3.open(path) { + IndexedEntityDabase._( + String path, { + required int targetSchemaVersion, + }) : _database = sqlite3.open(path) { final res = _database.select( "SELECT name FROM sqlite_master WHERE type='table' AND name='entity';", ); @@ -15,18 +24,21 @@ class IndexedEntityDabase { debugPrint('Creating new DB'); _initialDBSetup(); - _v2Migration(); - _v3Migration(); - } else if (_dbVersion == 1) { - debugPrint('Migrating DB to v2'); + } + if (_dbVersion < targetSchemaVersion) { _v2Migration(); + } + + if (_dbVersion < targetSchemaVersion) { _v3Migration(); - } else if (_dbVersion == 2) { - _v3Migration(); } - assert(_dbVersion == 3); + if (_dbVersion < targetSchemaVersion) { + _v4Migration(); + } + + assert(_dbVersion == targetSchemaVersion); // Foreign keys need to be re-enable on every open (session) // https://www.sqlite.org/foreignkeys.html#fk_enable @@ -99,6 +111,51 @@ class IndexedEntityDabase { ); } + void _v4Migration() { + // New `index` table schema supporting unique and and foreign key constraints + + _database.execute('DROP TABLE `index`'); + + _database.execute( + 'CREATE TABLE `index` ( ' + ' `type` TEXT NOT NULL, ' + ' `entity` NOT NULL, ' + ' `field` TEXT NOT NULL, ' + ' `value`, ' + ' `referenced_type` TEXT, ' + ' `unique` BOOLEAN NOT NULL DEFAULT FALSE, ' + ' FOREIGN KEY (`type`, `entity`) REFERENCES `entity` (`type`, `key`) ON DELETE CASCADE, ' + ' FOREIGN KEY (`referenced_type`, `value`) REFERENCES `entity` (`type`, `key`), ' + ' PRIMARY KEY ( `type`, `entity`, `field` )' + ')', + ); + + _database.execute( + 'CREATE INDEX index_field_values ' + 'ON `index` ( `type`, `field`, `value` )', + ); + + // This index is needed to not pay a performance penalty for the new foreign key constraint (between entities) + // Otherwise the `insertMany` update duration would increase 5x (even without making use of the reference, passing `null`). + // With this index, even though it only tracks non-`null` references (which would be rare), overall insert performance stays the same as before. + _database.execute( + 'CREATE INDEX index_field_values_FK ' + 'ON `index` ( `referenced_type`, `value` )' + 'WHERE `referenced_type` IS NOT NULL ', + ); + + _database.execute( + 'CREATE UNIQUE INDEX index_type_entity_field_unique_index ' + 'ON `index` ( `type`, `field`, `value` ) ' + 'WHERE `unique` = 1 ', + ); + + _database.execute( + 'UPDATE `metadata` SET `value` = ? WHERE `key` = ?', + [4, 'version'], + ); + } + IndexedEntityStore entityStore( IndexedEntityConnector connector, ) { diff --git a/pubspec.yaml b/pubspec.yaml index 74ec82c..974b60b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: indexed_entity_store description: A fast, simple, and synchronous entity store for Flutter applications. -version: 2.0.0-dev2 +version: 2.0.0-dev3 repository: https://github.com/LunaONE/indexed_entity_store environment: diff --git a/test/foreign_key_test.dart b/test/foreign_key_test.dart new file mode 100644 index 0000000..b3bee23 --- /dev/null +++ b/test/foreign_key_test.dart @@ -0,0 +1,182 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:indexed_entity_store/indexed_entity_store.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + + test('Foreign key reference tests', () async { + final path = '/tmp/index_entity_store_test_${FlutterTimeline.now}.sqlite3'; + + final db = IndexedEntityDabase.open(path); + + final fooStore = db.entityStore(fooConnector); + final fooAttachmentStore = db.entityStore(fooAttachmentConnector); + + expect(fooStore.queryOnce(), isEmpty); + expect(fooAttachmentStore.queryOnce(), isEmpty); + + // Not valid to insert attachment where "parent" does not exist + expect( + () => fooAttachmentStore + .write(_FooAttachment(id: 1, fooId: 1, value: 'adsf')), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('FOREIGN KEY constraint failed'), + ), + ), + ); + + final foo1 = fooStore.read(1); + final foo1Attachments = fooAttachmentStore.query( + where: (cols) => cols['fooId'].equals(1), + ); + final allFooAttachments = fooAttachmentStore.query(); + + fooStore.write(_FooEntity(id: 1, value: 'initial')); + fooAttachmentStore.write(_FooAttachment(id: 1, fooId: 1, value: 'adsf')); + + expect(foo1.value?.value, 'initial'); + expect(foo1Attachments.value, hasLength(1)); + expect(allFooAttachments.value, hasLength(1)); + + // Update "parent", "attachment" should be kept in place + fooStore.write(_FooEntity(id: 1, value: 'new value')); + + expect(foo1.value?.value, 'new value'); + expect(foo1Attachments.value, hasLength(1)); + expect(allFooAttachments.value, hasLength(1)); + + fooAttachmentStore.write( + _FooAttachment(id: 2, fooId: 1, value: 'attachment 2'), + ); + expect(foo1Attachments.value, hasLength(2)); + expect(allFooAttachments.value, hasLength(2)); + + // deleting the parent is not allowed while children are still referencing it + expect( + () => fooStore.delete(key: 1), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('FOREIGN KEY constraint failed'), + ), + ), + ); + + // Add another parent, and move attachment 1 there + fooStore.write(_FooEntity(id: 2, value: 'another foo')); + fooAttachmentStore.write(_FooAttachment(id: 1, fooId: 2, value: 'adsf')); + expect(foo1Attachments.value, hasLength(1)); + expect(allFooAttachments.value, hasLength(2)); + expect( + fooAttachmentStore.queryOnce(where: (cols) => cols['fooId'].equals(2)), + hasLength(1), + ); + + fooAttachmentStore.delete(keys: [1, 2]); + // now that the referencing attachments are deleted, we can delete the parent as well + fooStore.delete(keys: [1, 2]); + + expect(foo1.value, isNull); + expect(foo1Attachments.value, isEmpty); + expect(allFooAttachments.value, isEmpty); + + foo1.dispose(); + foo1Attachments.dispose(); + allFooAttachments.dispose(); + + db.dispose(); + + // TODO(tp): Test index key migrations for foreign keys (addition to non-empty stor, removal from store, name update) + + File(path).deleteSync(); + }); +} + +class _FooEntity { + _FooEntity({ + required this.id, + required this.value, + }); + + final int id; + + final String value; + + Map toJSON() { + return { + 'id': id, + 'value': value, + }; + } + + static _FooEntity fromJSON(Map json) { + return _FooEntity( + id: json['id'], + value: json['value'], + ); + } +} + +final fooConnector = IndexedEntityConnector<_FooEntity, int, String>( + entityKey: 'foo', + getPrimaryKey: (f) => f.id, + getIndices: (index) { + index((e) => e.value, as: 'value'); + }, + serialize: (f) => jsonEncode(f.toJSON()), + deserialize: (s) => _FooEntity.fromJSON( + jsonDecode(s) as Map, + ), +); + +class _FooAttachment { + _FooAttachment({ + required this.id, + required this.fooId, + required this.value, + }); + + final int id; + + final int fooId; + + final String value; + + Map toJSON() { + return { + 'id': id, + 'fooId': fooId, + 'value': value, + }; + } + + static _FooAttachment fromJSON(Map json) { + return _FooAttachment( + id: json['id'], + fooId: json['fooId'], + value: json['value'], + ); + } +} + +final fooAttachmentConnector = + IndexedEntityConnector<_FooAttachment, int, String>( + entityKey: 'foo_attachment', + getPrimaryKey: (f) => f.id, + getIndices: (index) { + index((e) => e.fooId, as: 'fooId', referencing: 'foo'); + }, + serialize: (f) => jsonEncode(f.toJSON()), + deserialize: (s) => _FooAttachment.fromJSON( + jsonDecode(s) as Map, + ), +); diff --git a/test/indexed_entity_store_test.dart b/test/indexed_entity_store_test.dart index 691ff71..fe7e61a 100644 --- a/test/indexed_entity_store_test.dart +++ b/test/indexed_entity_store_test.dart @@ -170,7 +170,7 @@ void main() { db.dispose(); } - // setup with a new connect, which requires an index update + // setup with a new connector, which requires an index update { final db = IndexedEntityDabase.open(path); @@ -196,6 +196,33 @@ void main() { ); } + // setup with a new connector (with unique index on B), which requires an index update + { + final db = IndexedEntityDabase.open(path); + + final fooStore = + db.entityStore(fooConnectorWithUniqueIndexOnBAndIndexOnC); + + expect(fooStore.queryOnce(), hasLength(1)); + // old index is not longer supported + expect( + () => fooStore.queryOnce(where: (cols) => cols['a'].equals('A')), + throwsException, + ); + expect( + fooStore.queryOnce(where: (cols) => cols['b'].equals(1002)), + hasLength(1), + ); + expect( + fooStore.queryOnce(where: (cols) => cols['b'].equals(1002)), + hasLength(1), + ); + expect( + fooStore.queryOnce(where: (cols) => cols['c'].equals(true)), + hasLength(1), + ); + } + File(path).deleteSync(); }, ); @@ -1282,6 +1309,18 @@ final fooConnectorWithIndexOnBAndC = deserialize: fooConnector.deserialize, ); +final fooConnectorWithUniqueIndexOnBAndIndexOnC = + IndexedEntityConnector<_FooEntity, int, String>( + entityKey: fooConnector.entityKey, + getPrimaryKey: fooConnector.getPrimaryKey, + getIndices: (index) { + index((e) => e.valueB + 1000, as: 'b', unique: true); // updated index B + index((e) => e.valueC, as: 'c'); + }, + serialize: fooConnector.serialize, + deserialize: fooConnector.deserialize, +); + class _AllSupportedIndexTypes { _AllSupportedIndexTypes({ required this.string, diff --git a/test/unique_test.dart b/test/unique_test.dart new file mode 100644 index 0000000..ea4dba6 --- /dev/null +++ b/test/unique_test.dart @@ -0,0 +1,120 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:indexed_entity_store/indexed_entity_store.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + + test('Unique constraint test', () async { + final path = '/tmp/index_entity_store_test_${FlutterTimeline.now}.sqlite3'; + + final db = IndexedEntityDabase.open(path); + + // 2 separate store, each having a `value` and `uniqueValue` index + final aStore = db.entityStore(aConnector); + final bStore = db.entityStore(bConnector); + + final allAs = aStore.query(); + final allBs = bStore.query(); + + // Each separate store may use the same unique value ("u") + aStore.write(_Entity(id: 1, value: 'a', uniqueValue: 'u')); + bStore.write(_Entity(id: 1, value: 'b', uniqueValue: 'u')); + + expect(allAs.value, hasLength(1)); + expect(allBs.value, hasLength(1)); + + // Updating an entry works as expected (by primary key) + aStore.write(_Entity(id: 1, value: 'new_a', uniqueValue: 'u')); + expect(allAs.value.firstOrNull?.value, 'new_a'); + + // Inserting a new entry which would use the same as an existing entry with a unique constraint errs + expect( + () => aStore.write(_Entity(id: 99, value: '', uniqueValue: 'u')), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('UNIQUE constraint failed'), + ), + ), + ); + + // When entry 1 releases the unique value, a new entry 2 can be written claiming it + // Both entries can use the same value for the non-unique index in `value` + aStore.write(_Entity(id: 1, value: 'a', uniqueValue: 'new_u')); + aStore.write(_Entity(id: 2, value: 'a', uniqueValue: 'u')); + expect(allAs.value, hasLength(2)); + + allAs.dispose(); + allBs.dispose(); + db.dispose(); + + File(path).deleteSync(); + }); +} + +class _Entity { + _Entity({ + required this.id, + required this.value, + required this.uniqueValue, + }); + + final int id; + + final String value; + + final String uniqueValue; + + Map toJSON() { + return { + 'id': id, + 'value': value, + 'uniqueValue': uniqueValue, + }; + } + + static _Entity fromJSON(Map json) { + return _Entity( + id: json['id'], + value: json['value'], + uniqueValue: json['uniqueValue'], + ); + } + + @override + String toString() { + return '_Entity($id, value: $value, uniqueValue: $uniqueValue)'; + } +} + +final aConnector = IndexedEntityConnector<_Entity, int, String>( + entityKey: 'a', + getPrimaryKey: (f) => f.id, + getIndices: (index) { + index((e) => e.value, as: 'value'); + index((e) => e.uniqueValue, as: 'uniqueValue', unique: true); + }, + serialize: (f) => jsonEncode(f.toJSON()), + deserialize: (s) => _Entity.fromJSON( + jsonDecode(s) as Map, + ), +); + +final bConnector = IndexedEntityConnector<_Entity, int, String>( + entityKey: 'b', + getPrimaryKey: (f) => f.id, + getIndices: (index) { + index((e) => e.value, as: 'value'); + index((e) => e.uniqueValue, as: 'uniqueValue', unique: true); + }, + serialize: (f) => jsonEncode(f.toJSON()), + deserialize: (s) => _Entity.fromJSON( + jsonDecode(s) as Map, + ), +);