From 110bdaa608a755af22d13c4526be621ee62aa3e0 Mon Sep 17 00:00:00 2001 From: Timm Preetz Date: Thu, 31 Oct 2024 16:53:24 +0100 Subject: [PATCH 1/2] 2.0: Clean up naming --- CHANGELOG.md | 8 + example/example.md | 4 +- .../async_value_group_and_detail.dart | 4 +- .../lib/src/examples/simple_synchronous.dart | 10 +- example/pubspec.lock | 2 +- lib/src/index_entity_store.dart | 117 ++----- pubspec.yaml | 2 +- test/indexed_entity_store_test.dart | 301 ++++++++++-------- 8 files changed, 216 insertions(+), 232 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b8e6f8..b0c8a1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## 2.0.0 + +* Update method names + * Most notably `insert` is now `write` to transmit the ambivalence between "insert" and "update" + * `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` is now switched to take the entity by default, and offers `deleteByKey` to delete by primary key if you know it + ## 1.4.3 * Add `insertMany` method to handle batch inserts/updates diff --git a/example/example.md b/example/example.md index 1f4edd2..e9e9f15 100644 --- a/example/example.md +++ b/example/example.md @@ -39,11 +39,11 @@ final db = IndexedEntityDabase.open('/tmp/appdata.sqlite3'); // in practice put final todos = db.entityStore(todoConnector); // While using the String columns here is not super nice, this works without code gen and will throw if using a non-indexed column -final openTodos = todos.query((cols) => cols['done'].equals(false)); +final openTodos = todos.query(where: (cols) => cols['done'].equals(false)); print(openTodos.value); // prints an empty list on first run as no TODOs are yet added to the database -todos.insert( +todos.write( Todo(id: 1, text: 'Publish new version', done: false), ); diff --git a/example/lib/src/examples/async_value_group_and_detail.dart b/example/lib/src/examples/async_value_group_and_detail.dart index fa35924..6fa4320 100644 --- a/example/lib/src/examples/async_value_group_and_detail.dart +++ b/example/lib/src/examples/async_value_group_and_detail.dart @@ -169,7 +169,7 @@ class AsyncProductRepository { DisposableValueListenable> getProductDetail( int productId, ) { - final productQuery = _detailStore.get(productId); + final productQuery = _detailStore.read(productId); if (productQuery.value != null && // If data is older than 30s, run the fetch below (but show the most recent value while it's loading) @@ -194,7 +194,7 @@ class AsyncProductRepository { _productApi.getProductDetail(productId).then((product) { // When we receive the product successfully, we put it into the database, // from which point on the product query will deliver it to the outside - _detailStore.insert(product); + _detailStore.write(product); return product; }).asAsyncValue(), diff --git a/example/lib/src/examples/simple_synchronous.dart b/example/lib/src/examples/simple_synchronous.dart index de1d01c..92fef41 100644 --- a/example/lib/src/examples/simple_synchronous.dart +++ b/example/lib/src/examples/simple_synchronous.dart @@ -75,16 +75,16 @@ class SimpleSynchronousRepository { }) : _store = store; void init() { - _store.insert(Todo(id: 1, text: 'Brew Coffe', done: false)); - _store.insert(Todo(id: 2, text: 'Get milk', done: false)); - _store.insert(Todo(id: 3, text: 'Read newspaper', done: false)); + _store.write(Todo(id: 1, text: 'Brew Coffe', done: false)); + _store.write(Todo(id: 2, text: 'Get milk', done: false)); + _store.write(Todo(id: 3, text: 'Read newspaper', done: false)); } QueryResult> getOpenTodos() { - return _store.query((cols) => cols['done'].equals(false)); + return _store.query(where: (cols) => cols['done'].equals(false)); } void updateTodo(Todo todo) { - _store.insert(todo); + _store.write(todo); } } diff --git a/example/pubspec.lock b/example/pubspec.lock index 437d704..3df9038 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -105,7 +105,7 @@ packages: path: ".." relative: true source: path - version: "1.4.3" + version: "2.0.0-dev1" leak_tracker: dependency: transitive description: diff --git a/lib/src/index_entity_store.dart b/lib/src/index_entity_store.dart index 5d0cde5..bde6526 100644 --- a/lib/src/index_entity_store.dart +++ b/lib/src/index_entity_store.dart @@ -52,7 +52,7 @@ class IndexedEntityStore { } /// Returns a subscription to a single entity by its primary key - QueryResult get(K key) { + QueryResult read(K key) { final QueryResultMapping mapping = ( () => _getOnce(key), QueryResult._( @@ -75,7 +75,7 @@ class IndexedEntityStore { } /// Returns a single entity by its primary key - T? getOnce(K key) { + T? readOnce(K key) { return _getOnce(key).result; } @@ -94,68 +94,16 @@ class IndexedEntityStore { return (dbValues: [dbValue], result: _connector.deserialize(dbValue)); } - /// Returns a subscription to all entities in this store - QueryResult> getAll({ - OrderByClause? orderBy, - }) { - final QueryResultMapping> mapping = ( - () => _getAllOnce(orderBy: orderBy), - QueryResult._( - initialValue: _getAllOnce(orderBy: orderBy), - onDispose: (r) { - _entityResults.removeWhere((m) => m.$2 == r); - }, - ) - ); - - _entityResults.add(mapping); - - return mapping.$2; - } - - /// Returns a list of all entities in this store - List getAllOnce({ - OrderByClause? orderBy, - }) { - return _getAllOnce(orderBy: orderBy).result; - } - - MappedDBResult> _getAllOnce({ - OrderByClause? orderBy, - }) { - final res = _database.select( - [ - 'SELECT `entity`.`value` FROM `entity`', - if (orderBy != null) - ' JOIN `index` ON `index`.`entity` = `entity`.`key` ', - ' WHERE `entity`.`type` = ? ', - if (orderBy != null) - ' AND `index`.`field` = ? ORDER BY `index`.`value` ${orderBy.$2 == SortOrder.asc ? 'ASC' : 'DESC'}', - ].join(), - [ - _entityKey, - if (orderBy != null) orderBy.$1, - ], - ); - - final values = res.map((e) => e['value']).toList(); - - return ( - dbValues: values, - result: values.map((v) => _connector.deserialize(v)).toList() - ); - } - /// Returns the single entity (or null) for the given query /// /// Throws an exception if the query returns 2 or more values. /// If the caller expects more than 1 value but is only interested in one, /// they can use [query] with a limit instead. - QueryResult single(QueryBuilder q) { + QueryResult querySingle(QueryBuilder where) { final QueryResultMapping mapping = ( - () => _singleOnce(q), + () => _querySingleOnce(where), QueryResult._( - initialValue: _singleOnce(q), + initialValue: _querySingleOnce(where), onDispose: (r) { _entityResults.removeWhere((m) => m.$2 == r); }, @@ -172,16 +120,16 @@ class IndexedEntityStore { /// Throws an exception if the query returns 2 or more values. /// If the caller expects more than 1 value but is only interested in one, /// they can use [query] with a limit instead. - T? singleOnce(QueryBuilder q) { - return _singleOnce(q).result; + T? querySingleOnce(QueryBuilder where) { + return _querySingleOnce(where).result; } - MappedDBResult _singleOnce(QueryBuilder q) { - final result = _queryOnce(q, limit: 2); + MappedDBResult _querySingleOnce(QueryBuilder where) { + final result = _queryOnce(where: where, limit: 2); if (result.result.length > 1) { throw Exception( - 'singleOnce expected to find one element, but found at least 2 matching the query $q', + 'singleOnce expected to find one element, but found at least 2 matching the query $where', ); } @@ -189,15 +137,15 @@ class IndexedEntityStore { } /// Returns a subscription to entities matching the given query - QueryResult> query( - QueryBuilder q, { + QueryResult> query({ + QueryBuilder? where, OrderByClause? orderBy, int? limit, }) { final QueryResultMapping> mapping = ( - () => _queryOnce(q, limit: limit, orderBy: orderBy), + () => _queryOnce(where: where, limit: limit, orderBy: orderBy), QueryResult._( - initialValue: _queryOnce(q, limit: limit, orderBy: orderBy), + initialValue: _queryOnce(where: where, limit: limit, orderBy: orderBy), onDispose: (r) { _entityResults.removeWhere((m) => m.$2 == r); }, @@ -210,33 +158,34 @@ class IndexedEntityStore { } /// Returns a list of entities matching the given query - List queryOnce( - QueryBuilder q, { + List queryOnce({ + QueryBuilder? where, OrderByClause? orderBy, int? limit, }) { - return _queryOnce(q, orderBy: orderBy, limit: limit).result; + return _queryOnce(where: where, orderBy: orderBy, limit: limit).result; } - MappedDBResult> _queryOnce( - QueryBuilder q, { + MappedDBResult> _queryOnce({ + QueryBuilder? where, OrderByClause? orderBy, int? limit, }) { - final (w, s) = q(_indexColumns)._entityKeysQuery(); + final whereClause = where?.call(_indexColumns)._entityKeysQuery(); final query = [ 'SELECT `entity`.`value` FROM `entity` ', if (orderBy != null) ' JOIN `index` ON `index`.`entity` = `entity`.`key` ', - ' WHERE `entity`.`type` = ? AND `entity`.`key` IN ( $w ) ', + ' WHERE `entity`.`type` = ? ', + if (whereClause != null) ' AND `entity`.`key` IN ( ${whereClause.$1} ) ', if (orderBy != null) 'AND `index`.`field` = ? ORDER BY `index`.`value` ${orderBy.$2 == SortOrder.asc ? 'ASC' : 'DESC'}', if (limit != null) ' LIMIT ?' ].join(); final values = [ _entityKey, - ...s, + ...?whereClause?.$2, if (orderBy != null) orderBy.$1, if (limit != null) limit, ]; @@ -260,7 +209,7 @@ class IndexedEntityStore { /// /// In case an entity with the same primary already exists in the database, it will be updated. // TODO(tp): We might want to rename this to `upsert` going forward to make it clear that this will overwrite and not error when the entry already exits (alternatively maybe `persist`, `write`, or `set`). - void insert(T e) { + void write(T e) { _database.execute('BEGIN'); assert(_database.autocommit == false); @@ -278,7 +227,7 @@ class IndexedEntityStore { /// Insert or update many entities in a single batch /// /// Notification for changes will only fire after all changes have been written (meaning queries will get a single update after all writes are finished) - void insertMany(Iterable entities) { + void writeMany(Iterable entities) { _database.execute('BEGIN'); assert(_database.autocommit == false); @@ -331,18 +280,18 @@ class IndexedEntityStore { } /// Deletes a single entity by its primary key - void delete(K key) { - deleteMany({key}); + void deleteByKey(K key) { + deleteManyByKey({key}); } /// Deletes a single entity - void deleteEntity(T entity) { - delete(_connector.getPrimaryKey(entity)); + void delete(T entity) { + deleteByKey(_connector.getPrimaryKey(entity)); } /// Deletes many entities - void deleteEntities(Iterable entities) { - deleteMany( + void deleteMany(Iterable entities) { + deleteManyByKey( { for (final e in entities) _connector.getPrimaryKey(e), }, @@ -350,7 +299,7 @@ class IndexedEntityStore { } /// Deletes many entities by their primary key - void deleteMany(Set keys) { + void deleteManyByKey(Set keys) { for (final key in keys) { _database.execute( 'DELETE FROM `entity` WHERE `type` = ? AND `key` = ?', @@ -383,7 +332,7 @@ class IndexedEntityStore { _database.execute('BEGIN'); - final entities = getAllOnce(); + final entities = queryOnce(); for (final e in entities) { _updateIndexInternal(e); diff --git a/pubspec.yaml b/pubspec.yaml index bae9216..8d95a30 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: 1.4.3 +version: 2.0.0-dev1 repository: https://github.com/LunaONE/indexed_entity_store environment: diff --git a/test/indexed_entity_store_test.dart b/test/indexed_entity_store_test.dart index b7dab2a..8cff594 100644 --- a/test/indexed_entity_store_test.dart +++ b/test/indexed_entity_store_test.dart @@ -17,68 +17,84 @@ void main() { final fooStore = db.entityStore(fooConnector); - expect(fooStore.getAllOnce(), isEmpty); + expect(fooStore.queryOnce(), isEmpty); - fooStore.insert( + fooStore.write( _FooEntity(id: 99, valueA: 'a', valueB: 2, valueC: true), ); - expect(fooStore.getOnce(99), isA<_FooEntity>()); - expect(fooStore.getAllOnce(), hasLength(1)); + expect(fooStore.readOnce(99), isA<_FooEntity>()); + expect(fooStore.queryOnce(), hasLength(1)); - expect(fooStore.queryOnce((cols) => cols['a'].equals('a')), hasLength(1)); - expect(fooStore.queryOnce((cols) => cols['a'].equals('b')), hasLength(0)); + expect(fooStore.queryOnce(where: (cols) => cols['a'].equals('a')), + hasLength(1)); + expect(fooStore.queryOnce(where: (cols) => cols['a'].equals('b')), + hasLength(0)); - expect(fooStore.queryOnce((cols) => cols['b'].equals(2)), hasLength(1)); - expect(fooStore.queryOnce((cols) => cols['b'].equals(4)), hasLength(0)); + expect( + fooStore.queryOnce(where: (cols) => cols['b'].equals(2)), hasLength(1)); + expect( + fooStore.queryOnce(where: (cols) => cols['b'].equals(4)), hasLength(0)); expect( - fooStore.queryOnce((cols) => cols['a'].equals('a') & cols['b'].equals(2)), + fooStore.queryOnce( + where: (cols) => cols['a'].equals('a') & cols['b'].equals(2)), hasLength(1), ); expect( - fooStore.queryOnce((cols) => cols['a'].equals('b') & cols['b'].equals(2)), + fooStore.queryOnce( + where: (cols) => cols['a'].equals('b') & cols['b'].equals(2)), isEmpty, ); expect( - fooStore.queryOnce((cols) => cols['a'].equals('a') & cols['b'].equals(3)), + fooStore.queryOnce( + where: (cols) => cols['a'].equals('a') & cols['b'].equals(3)), isEmpty, ); expect( - fooStore.queryOnce((cols) => cols['a'].equals('b') & cols['b'].equals(3)), + fooStore.queryOnce( + where: (cols) => cols['a'].equals('b') & cols['b'].equals(3)), isEmpty, ); expect( - fooStore.queryOnce((cols) => cols['a'].equals('a') | cols['b'].equals(3)), + fooStore.queryOnce( + where: (cols) => cols['a'].equals('a') | cols['b'].equals(3)), hasLength(1), ); expect( - fooStore.queryOnce((cols) => cols['a'].equals('b') | cols['b'].equals(2)), + fooStore.queryOnce( + where: (cols) => cols['a'].equals('b') | cols['b'].equals(2)), hasLength(1), ); expect( - fooStore.queryOnce((cols) => cols['a'].equals('b') | cols['b'].equals(3)), + fooStore.queryOnce( + where: (cols) => cols['a'].equals('b') | cols['b'].equals(3)), isEmpty, ); expect( - () => fooStore.queryOnce((cols) => cols['does_not_exist'].equals('b')), + () => fooStore.queryOnce( + where: (cols) => cols['does_not_exist'].equals('b')), throwsException, ); // add a second entity with the same values, but different key - fooStore.insert( + fooStore.write( _FooEntity(id: 101, valueA: 'a', valueB: 2, valueC: true), ); - expect(fooStore.getAllOnce(), hasLength(2)); - expect(fooStore.queryOnce((cols) => cols['a'].equals('a')), hasLength(2)); + expect(fooStore.queryOnce(), hasLength(2)); + expect(fooStore.queryOnce(where: (cols) => cols['a'].equals('a')), + hasLength(2)); // delete initial - fooStore.deleteMany({99}); - expect(fooStore.getAllOnce(), hasLength(1)); - expect(fooStore.queryOnce((cols) => cols['a'].equals('a')), hasLength(1)); + fooStore.deleteManyByKey({99}); + expect(fooStore.queryOnce(), hasLength(1)); + expect( + fooStore.queryOnce(where: (cols) => cols['a'].equals('a')), + hasLength(1), + ); db.dispose(); @@ -97,34 +113,34 @@ void main() { final fooStore = db.entityStore(fooConnector); - expect(fooStore.getAllOnce(), isEmpty); + expect(fooStore.queryOnce(), isEmpty); - fooStore.insert( + fooStore.write( _FooEntity(id: 99, valueA: 'a', valueB: 2, valueC: true), ); expect( - fooStore.queryOnce((cols) => cols['a'].equals('a')), + fooStore.queryOnce(where: (cols) => cols['a'].equals('a')), hasLength(1), ); expect( - fooStore.queryOnce((cols) => cols['a'].equals('b')), + fooStore.queryOnce(where: (cols) => cols['a'].equals('b')), hasLength(0), ); - fooStore.insert( + fooStore.write( _FooEntity(id: 99, valueA: 'A', valueB: 2, valueC: true), ); expect( - fooStore.queryOnce((cols) => cols['a'].equals('a')), + fooStore.queryOnce(where: (cols) => cols['a'].equals('a')), hasLength(0), ); expect( - fooStore.queryOnce((cols) => cols['a'].equals('A')), + fooStore.queryOnce(where: (cols) => cols['a'].equals('A')), hasLength(1), ); expect( - fooStore.queryOnce((cols) => cols['a'].equals('b')), + fooStore.queryOnce(where: (cols) => cols['a'].equals('b')), hasLength(0), ); @@ -137,14 +153,14 @@ void main() { final fooStore = db.entityStore(fooConnectorWithIndexOnC); - expect(fooStore.getAllOnce(), hasLength(1)); + expect(fooStore.queryOnce(), hasLength(1)); // old index is not longer supported expect( - () => fooStore.queryOnce((cols) => cols['a'].equals('A')), + () => fooStore.queryOnce(where: (cols) => cols['a'].equals('A')), throwsException, ); expect( - fooStore.queryOnce((cols) => cols['c'].equals(true)), + fooStore.queryOnce(where: (cols) => cols['c'].equals(true)), hasLength(1), ); } @@ -163,27 +179,29 @@ void main() { final fooStore = db.entityStore(fooConnector); - final allFoos = fooStore.getAll(); + final allFoos = fooStore.query(); expect(allFoos.value, isEmpty); expect(fooStore.subscriptionCount, 1); const int singleId = -2263796707128; - final fooById1 = fooStore.get(singleId); + final fooById1 = fooStore.read(singleId); expect(fooById1.value, isNull); expect(fooStore.subscriptionCount, 2); - final fooByQueryValueA = fooStore.query((cols) => cols['a'].equals('a')); + final fooByQueryValueA = + fooStore.query(where: (cols) => cols['a'].equals('a')); expect(fooByQueryValueA.value, isEmpty); - final fooById99 = fooStore.get(99); + final fooById99 = fooStore.read(99); expect(fooById99.value, isNull); - final fooByQueryValueNotExists = - fooStore.query((cols) => cols['a'].equals('does_not_exist')); + final fooByQueryValueNotExists = fooStore.query( + where: (cols) => cols['a'].equals('does_not_exist'), + ); expect(fooByQueryValueNotExists.value, isEmpty); // insert new entity matching the open queries - fooStore.insert( + fooStore.write( _FooEntity(id: singleId, valueA: 'a', valueB: 2, valueC: true), ); @@ -200,7 +218,7 @@ void main() { valueB: 2, valueC: true, ); - fooStore.insert(entity2); + fooStore.write(entity2); expect(allFoos.value, hasLength(2)); expect(fooById1.value, isA<_FooEntity>()); @@ -209,7 +227,7 @@ void main() { expect(fooByQueryValueNotExists.value, isEmpty); // delete ID 1 - fooStore.delete(singleId); + fooStore.deleteByKey(singleId); expect(allFoos.value, hasLength(1)); expect(fooById1.value, isNull); expect(fooByQueryValueA.value, isEmpty); @@ -217,7 +235,7 @@ void main() { expect(fooByQueryValueNotExists.value, isEmpty); /// Does not exist, does not make a difference - fooStore.delete(9999); + fooStore.deleteByKey(9999); // Dispose all expect(fooStore.subscriptionCount, 5); @@ -233,7 +251,7 @@ void main() { expect(fooStore.subscriptionCount, 0); // No more subscriptions, so this has no effect - fooStore.deleteEntity(entity2); + fooStore.delete(entity2); }, ); @@ -260,7 +278,7 @@ void main() { final valueStore = db.entityStore(valueWrappingConnector); - final valueWithId1Subscription = valueStore.get(1); + final valueWithId1Subscription = valueStore.read(1); final valuesWithId1 = [valueWithId1Subscription.value]; valueWithId1Subscription.addListener(() { // Add new values as they are exposed @@ -268,7 +286,7 @@ void main() { }); final shortValuesSubscription = valueStore.query( - (cols) => cols['length'].lessThan(5), + where: (cols) => cols['length'].lessThan(5), ); final shortValues = [shortValuesSubscription.value]; shortValuesSubscription.addListener(() { @@ -287,7 +305,7 @@ void main() { /// Add first entry { - valueStore.insert(_ValueWrapper(1, 'one')); + valueStore.write(_ValueWrapper(1, 'one')); // both subscriptions got updated expect( @@ -308,7 +326,7 @@ void main() { /// Add second entry, matching only the query { - valueStore.insert(_ValueWrapper(2, 'two')); + valueStore.write(_ValueWrapper(2, 'two')); // both subscriptions got updated expect( @@ -333,7 +351,7 @@ void main() { /// Re-insert first entry again, which should not cause an update, as the value has not changed { - valueStore.insert(_ValueWrapper(1, 'one')); + valueStore.write(_ValueWrapper(1, 'one')); // both subscriptions got updated expect( @@ -358,7 +376,7 @@ void main() { /// Insert another entry which does not match any query, and thus should not cause an update { - valueStore.insert(_ValueWrapper(3, 'three')); + valueStore.write(_ValueWrapper(3, 'three')); // both subscriptions got updated expect( @@ -383,7 +401,7 @@ void main() { /// Insert and update to entity 1, which should cause both to update { - valueStore.insert(_ValueWrapper(1, 'eins')); + valueStore.write(_ValueWrapper(1, 'eins')); // both subscriptions got updated expect( @@ -452,12 +470,12 @@ void main() { final store = db.entityStore(indexedEntityConnector); - expect(store.getAllOnce(), isEmpty); + expect(store.queryOnce(), isEmpty); - store.insert( + store.write( _AllSupportedIndexTypes.defaultIfNull(string: 'default'), ); - store.insert( + store.write( _AllSupportedIndexTypes.defaultIfNull( string: 'all_set', stringOpt: 'string_2', @@ -474,97 +492,99 @@ void main() { ), ); - expect(store.getAllOnce(), hasLength(2)); + expect(store.queryOnce(), hasLength(2)); // Valid queries with values expect( - store.queryOnce((cols) => cols['string'].equals('all_set')), + store.queryOnce(where: (cols) => cols['string'].equals('all_set')), hasLength(1), ); expect( - store.queryOnce((cols) => cols['stringOpt'].equals('string_2')), + store.queryOnce(where: (cols) => cols['stringOpt'].equals('string_2')), hasLength(1), ); expect( - store.queryOnce((cols) => cols['number'].equals(1)), + store.queryOnce(where: (cols) => cols['number'].equals(1)), hasLength(1), ); expect( - store.queryOnce((cols) => cols['numberOpt'].equals(2)), + store.queryOnce(where: (cols) => cols['numberOpt'].equals(2)), hasLength(1), ); expect( - store.queryOnce((cols) => cols['integer'].equals(3)), + store.queryOnce(where: (cols) => cols['integer'].equals(3)), hasLength(1), ); expect( - store.queryOnce((cols) => cols['integerOpt'].equals(4)), + store.queryOnce(where: (cols) => cols['integerOpt'].equals(4)), hasLength(1), ); expect( - store.queryOnce((cols) => cols['stringOpt'].equals('string_2')), + store.queryOnce(where: (cols) => cols['stringOpt'].equals('string_2')), hasLength(1), ); expect( - store.queryOnce((cols) => cols['float'].equals(5.678)), + store.queryOnce(where: (cols) => cols['float'].equals(5.678)), hasLength(1), ); expect( - store.queryOnce((cols) => cols['floatOpt'].equals(6.789)), + store.queryOnce(where: (cols) => cols['floatOpt'].equals(6.789)), hasLength(1), ); expect( - store.queryOnce((cols) => cols['boolean'].equals(false)), + store.queryOnce(where: (cols) => cols['boolean'].equals(false)), hasLength(2), // as this also finds the default one ); expect( - store.queryOnce((cols) => cols['booleanOpt'].equals(true)), + store.queryOnce(where: (cols) => cols['booleanOpt'].equals(true)), hasLength(1), ); expect( - store.queryOnce((cols) => cols['dateTime'].equals(DateTime.utc(2000))), + store.queryOnce( + where: (cols) => cols['dateTime'].equals(DateTime.utc(2000)), + ), hasLength(1), ); expect( store.queryOnce( - (cols) => cols['dateTimeOpt'].equals(DateTime.utc(2100)), + where: (cols) => cols['dateTimeOpt'].equals(DateTime.utc(2100)), ), hasLength(1), ); // Valid queries with `null` expect( - store.queryOnce((cols) => cols['stringOpt'].equals(null)), + store.queryOnce(where: (cols) => cols['stringOpt'].equals(null)), hasLength(1), ); expect( - store.queryOnce((cols) => cols['numberOpt'].equals(null)), + store.queryOnce(where: (cols) => cols['numberOpt'].equals(null)), hasLength(1), ); expect( - store.queryOnce((cols) => cols['integerOpt'].equals(null)), + store.queryOnce(where: (cols) => cols['integerOpt'].equals(null)), hasLength(1), ); expect( - store.queryOnce((cols) => cols['stringOpt'].equals(null)), + store.queryOnce(where: (cols) => cols['stringOpt'].equals(null)), hasLength(1), ); expect( - store.queryOnce((cols) => cols['floatOpt'].equals(null)), + store.queryOnce(where: (cols) => cols['floatOpt'].equals(null)), hasLength(1), ); expect( - store.queryOnce((cols) => cols['booleanOpt'].equals(null)), + store.queryOnce(where: (cols) => cols['booleanOpt'].equals(null)), hasLength(1), ); expect( - store.queryOnce((cols) => cols['dateTimeOpt'].equals(null)), + store.queryOnce(where: (cols) => cols['dateTimeOpt'].equals(null)), hasLength(1), ); // type mismatches expect( - () => store.queryOnce((cols) => cols['string'].equals(null)), + () => store.queryOnce(where: (cols) => cols['string'].equals(null)), throwsA( isA().having( (e) => e.toString(), @@ -576,7 +596,7 @@ void main() { ), ); expect( - () => store.queryOnce((cols) => cols['boolean'].equals(1.0)), + () => store.queryOnce(where: (cols) => cols['boolean'].equals(1.0)), throwsA( isA().having( (e) => e.toString(), @@ -588,7 +608,7 @@ void main() { ), ); expect( - () => store.queryOnce((cols) => cols['dateTime'].equals('')), + () => store.queryOnce(where: (cols) => cols['dateTime'].equals('')), throwsA( isA().having( (e) => e.toString(), @@ -633,11 +653,11 @@ void main() { final store = db.entityStore(indexedEntityConnector); - expect(store.getAllOnce(), isEmpty); + expect(store.queryOnce(), isEmpty); final now = DateTime.now(); - store.insert( + store.write( _AllSupportedIndexTypes.defaultIfNull( string: 'default', dateTime: now, @@ -646,39 +666,41 @@ void main() { ); expect( - store.queryOnce((cols) => cols['dateTime'].equals(now)), + store.queryOnce(where: (cols) => cols['dateTime'].equals(now)), hasLength(1), ); expect( - store.queryOnce((cols) => cols['dateTime'].equals(now.toUtc())), + store.queryOnce(where: (cols) => cols['dateTime'].equals(now.toUtc())), hasLength(1), ); expect( - store.queryOnce((cols) => cols['dateTimeOpt'].equals(null)), + store.queryOnce(where: (cols) => cols['dateTimeOpt'].equals(null)), hasLength(1), ); // DateTime: less than, greater than expect( - store.queryOnce((cols) => cols['dateTime'].lessThan(now)), + store.queryOnce(where: (cols) => cols['dateTime'].lessThan(now)), isEmpty, ); expect( - store.queryOnce((cols) => cols['dateTime'].lessThanOrEqual(now)), + store.queryOnce(where: (cols) => cols['dateTime'].lessThanOrEqual(now)), hasLength(1), ); expect( - store.queryOnce((cols) => cols['dateTime'].greaterThan(now)), + store.queryOnce(where: (cols) => cols['dateTime'].greaterThan(now)), isEmpty, ); expect( - store.queryOnce((cols) => cols['dateTime'].greaterThanOrEqual(now)), + store.queryOnce( + where: (cols) => cols['dateTime'].greaterThanOrEqual(now), + ), hasLength(1), ); expect( store.queryOnce( - (cols) => cols['dateTime'].greaterThan( + where: (cols) => cols['dateTime'].greaterThan( now.subtract(const Duration(seconds: 1)), ), ), @@ -687,38 +709,42 @@ void main() { // Null field: Should not be found for less than, equal, or greater than expect( - store.queryOnce((cols) => cols['dateTimeOpt'].equals(now)), + store.queryOnce(where: (cols) => cols['dateTimeOpt'].equals(now)), isEmpty, ); expect( - store.queryOnce((cols) => cols['dateTimeOpt'].lessThan(now)), + store.queryOnce(where: (cols) => cols['dateTimeOpt'].lessThan(now)), isEmpty, ); expect( - store.queryOnce((cols) => cols['dateTimeOpt'].lessThanOrEqual(now)), + store.queryOnce( + where: (cols) => cols['dateTimeOpt'].lessThanOrEqual(now)), isEmpty, ); expect( - store.queryOnce((cols) => cols['dateTimeOpt'].greaterThan(now)), + store.queryOnce(where: (cols) => cols['dateTimeOpt'].greaterThan(now)), isEmpty, ); expect( - store.queryOnce((cols) => cols['dateTimeOpt'].greaterThanOrEqual(now)), + store.queryOnce( + where: (cols) => cols['dateTimeOpt'].greaterThanOrEqual(now)), isEmpty, ); /// Numeric - expect(store.queryOnce((cols) => cols['float'].equals(1000)), hasLength(1)); + expect(store.queryOnce(where: (cols) => cols['float'].equals(1000)), + hasLength(1)); expect( - store.queryOnce((cols) => cols['float'].greaterThan(1000)), + store.queryOnce(where: (cols) => cols['float'].greaterThan(1000)), isEmpty, ); expect( - store.queryOnce((cols) => cols['float'].greaterThan(999.6)), + store.queryOnce(where: (cols) => cols['float'].greaterThan(999.6)), hasLength(1), ); expect( - store.queryOnce((cols) => cols['float'].greaterThanOrEqual(1000.0)), + store.queryOnce( + where: (cols) => cols['float'].greaterThanOrEqual(1000.0)), hasLength(1), ); }); @@ -740,31 +766,31 @@ void main() { final store = db.entityStore(indexedEntityConnector); - expect(store.getAllOnce(), isEmpty); + expect(store.queryOnce(), isEmpty); for (var i = 0; i < 10; i++) { - store.insert(i); + store.write(i); } - expect(store.getAllOnce(), hasLength(10)); + expect(store.queryOnce(), hasLength(10)); expect( - store.queryOnce((cols) => cols['value'].greaterThan(-1)), + store.queryOnce(where: (cols) => cols['value'].greaterThan(-1)), hasLength(10), ); expect( - store.queryOnce((cols) => cols['value'].greaterThan(-1), limit: 0), + store.queryOnce(where: (cols) => cols['value'].greaterThan(-1), limit: 0), isEmpty, ); expect( - store.queryOnce((cols) => cols['value'].greaterThan(-1), limit: 1), + store.queryOnce(where: (cols) => cols['value'].greaterThan(-1), limit: 1), hasLength(1), ); expect( - store.queryOnce((cols) => cols['value'].greaterThan(-1), limit: 5), + store.queryOnce(where: (cols) => cols['value'].greaterThan(-1), limit: 5), hasLength(5), ); expect( - store.queryOnce((cols) => cols['value'].greaterThan(5), limit: 5), + store.queryOnce(where: (cols) => cols['value'].greaterThan(5), limit: 5), equals({6, 7, 8, 9}), ); }); @@ -795,24 +821,24 @@ void main() { final store = db.entityStore(indexedEntityConnector); - expect(store.getAllOnce(), isEmpty); + expect(store.queryOnce(), isEmpty); for (final n in randomNumbers) { - store.insert(n); + store.write(n); } - expect(store.getAllOnce(), hasLength(10)); + expect(store.queryOnce(), hasLength(10)); expect( store.queryOnce( - (cols) => cols['value'].greaterThan(-1), + where: (cols) => cols['value'].greaterThan(-1), orderBy: ('value', SortOrder.asc), ), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], ); expect( store.queryOnce( - (cols) => cols['value'].greaterThan(-1), + where: (cols) => cols['value'].greaterThan(-1), orderBy: ('value', SortOrder.desc), ), [9, 8, 7, 6, 5, 4, 3, 2, 1, 0], @@ -820,7 +846,7 @@ void main() { expect( store.queryOnce( - (cols) => cols['value'].greaterThan(-1), + where: (cols) => cols['value'].greaterThan(-1), orderBy: ('value', SortOrder.asc), limit: 3, ), @@ -828,7 +854,7 @@ void main() { ); expect( store.queryOnce( - (cols) => cols['value'].greaterThan(-1), + where: (cols) => cols['value'].greaterThan(-1), orderBy: ('value', SortOrder.desc), limit: 3, ), @@ -837,7 +863,7 @@ void main() { expect( store.queryOnce( - (cols) => cols['value'].greaterThan(-1), + where: (cols) => cols['value'].greaterThan(-1), orderBy: ('name', SortOrder.asc), limit: 3, ), @@ -849,7 +875,7 @@ void main() { ); expect( store.queryOnce( - (cols) => cols['value'].greaterThan(-1), + where: (cols) => cols['value'].greaterThan(-1), orderBy: ('name', SortOrder.desc), limit: 3, ), @@ -861,7 +887,7 @@ void main() { ); expect( store.queryOnce( - (cols) => cols['isEven'].equals(true), + where: (cols) => cols['isEven'].equals(true), orderBy: ('name', SortOrder.desc), limit: 3, ), @@ -873,7 +899,7 @@ void main() { ); expect( store.queryOnce( - (cols) => cols['isEven'].equals(true), + where: (cols) => cols['isEven'].equals(true), orderBy: ('name', SortOrder.asc), limit: 3, ), @@ -885,13 +911,13 @@ void main() { ); expect( - store.getAllOnce( + store.queryOnce( orderBy: ('value', SortOrder.asc), ), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9], ); expect( - store.getAllOnce( + store.queryOnce( orderBy: ('value', SortOrder.desc), ), [9, 8, 7, 6, 5, 4, 3, 2, 1, 0], @@ -916,27 +942,27 @@ void main() { final store = db.entityStore(indexedEntityConnector); - expect(store.getAllOnce(), isEmpty); + expect(store.queryOnce(), isEmpty); for (final n in [1, 2, 3]) { - store.insert(n); + store.write(n); } expect( - store.singleOnce((cols) => cols['value'].equals(10)), + store.querySingleOnce((cols) => cols['value'].equals(10)), isNull, ); expect( - store.singleOnce((cols) => cols['value'].equals(3)), + store.querySingleOnce((cols) => cols['value'].equals(3)), 3, ); expect( - store.singleOnce((cols) => cols['isEven'].equals(true)), + store.querySingleOnce((cols) => cols['isEven'].equals(true)), 2, ); expect( // query with 2 matches - () => store.singleOnce((cols) => cols['isEven'].equals(false)), + () => store.querySingleOnce((cols) => cols['isEven'].equals(false)), throwsA( isA().having( (e) => e.toString(), @@ -954,19 +980,20 @@ void main() { final fooStore = db.entityStore(fooConnector); - expect(fooStore.getAllOnce(), isEmpty); + expect(fooStore.queryOnce(), isEmpty); - fooStore.insert( + fooStore.write( _FooEntity(id: 1, valueA: 'a', valueB: 1, valueC: true), ); - fooStore.insert( + fooStore.write( _FooEntity(id: 2, valueA: 'b', valueB: 2, valueC: true), ); - expect(fooStore.getAllOnce(), hasLength(2)); + expect(fooStore.queryOnce(), hasLength(2)); - final singleSubscription = fooStore.get(1); - final listSubscription = fooStore.query((cols) => cols['b'].lessThan(5)); + final singleSubscription = fooStore.read(1); + final listSubscription = + fooStore.query(where: (cols) => cols['b'].lessThan(5)); expect(singleSubscription.value, isA<_FooEntity>()); expect(listSubscription.value, hasLength(2)); @@ -975,7 +1002,7 @@ void main() { expect(singleSubscription.value, isNull); expect(listSubscription.value, isEmpty); - expect(fooStore.getAllOnce(), isEmpty); + expect(fooStore.queryOnce(), isEmpty); }); test( @@ -988,10 +1015,10 @@ void main() { final fooStore = db.entityStore(fooConnector); - expect(fooStore.getAllOnce(), isEmpty); + expect(fooStore.queryOnce(), isEmpty); // Insert one row, so statements are prepared - fooStore.insert( + fooStore.write( _FooEntity(id: 0, valueA: 'a', valueB: 1, valueC: true), ); @@ -1002,7 +1029,7 @@ void main() { final sw2 = Stopwatch()..start(); for (var i = 1; i <= batchSize; i++) { - fooStore.insert( + fooStore.write( _FooEntity(id: i, valueA: 'a', valueB: 1, valueC: true), ); } @@ -1016,7 +1043,7 @@ void main() { { final sw2 = Stopwatch()..start(); - fooStore.insertMany( + fooStore.writeMany( [ for (var i = batchSize + 1; i <= batchSize * 2; i++) _FooEntity(id: i, valueA: 'a', valueB: 1, valueC: true), @@ -1032,7 +1059,7 @@ void main() { { final sw2 = Stopwatch()..start(); - fooStore.insertMany( + fooStore.writeMany( [ for (var i = batchSize + 1; i <= batchSize * 2; i++) _FooEntity(id: i, valueA: 'aaaaaa', valueB: 111111, valueC: true), @@ -1044,7 +1071,7 @@ void main() { ); } - expect(fooStore.getAllOnce(), hasLength(batchSize * 2 + 1)); + expect(fooStore.queryOnce(), hasLength(batchSize * 2 + 1)); }, skip: !Platform.isMacOS, // only run locally for now ); From b86bcc300c8fb85e7c37efe4c2c67cdcb6e7720e Mon Sep 17 00:00:00 2001 From: Timm Preetz Date: Sun, 3 Nov 2024 16:23:47 +0100 Subject: [PATCH 2/2] 2.0: Hot reload support --- CHANGELOG.md | 3 + example/lib/main.dart | 2 + example/lib/src/examples/hot_reload.dart | 274 ++++++++++++++++++ example/lib/src/stores/product_connector.dart | 33 ++- example/lib/src/stores/todo_connector.dart | 37 ++- lib/src/index_entity_store.dart | 103 +++++-- lib/src/indexed_entity_connector.dart | 53 +--- lib/src/indexed_entity_database.dart | 26 +- lib/src/query_result.dart | 32 +- 9 files changed, 460 insertions(+), 103 deletions(-) create mode 100644 example/lib/src/examples/hot_reload.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index b0c8a1a..3d55f50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## 2.0.0 +* 🔥 Hot-reload: The connector interface has been adapted to allow for hot reload (in addition to hot restart) + * This means that instead of a configuration object instead, one now need to implement a class, such that updates to the implementation are accessible on hot reload (which was not possible with the configuration object, which was treated as "state" and thus not refreshed) + * While some patterns on top of the configuration objects (e.g. passing factories) could've allowed for hot-reload, requiring an implementation of the connector class ensures that it works all the time. This requires a one-time migration of all existing code though. * Update method names * Most notably `insert` is now `write` to transmit the ambivalence between "insert" and "update" * `get` is now `read` to be symmetric to write (but instead of entities it takes keys, of course) diff --git a/example/lib/main.dart b/example/lib/main.dart index 8c0f765..243dcd3 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/cupertino.dart'; import 'package:indexed_entity_store_example/src/examples/async_value_group_and_detail.dart'; +import 'package:indexed_entity_store_example/src/examples/hot_reload.dart'; import 'package:indexed_entity_store_example/src/examples/simple_synchronous.dart'; import 'package:path_provider/path_provider.dart'; @@ -39,6 +40,7 @@ class _ExampleSelectorState extends State { Widget? _example; static Map examples = { + 'Hot-reload example': const HotReloadExample(), 'Simple synchronous data repository': const SimpleSynchronousExample(), 'AsyncValue-based product list & detail view': const AsyncValueGroupDetailExample(), diff --git a/example/lib/src/examples/hot_reload.dart b/example/lib/src/examples/hot_reload.dart new file mode 100644 index 0000000..df7e57c --- /dev/null +++ b/example/lib/src/examples/hot_reload.dart @@ -0,0 +1,274 @@ +import 'dart:convert'; + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:indexed_entity_store/indexed_entity_store.dart'; +import 'package:indexed_entity_store_example/src/stores/database_helper.dart'; + +enum HotReloadCase { + intial, + + /// Switching to this case will cause an error, as there is a persistent query on the age + /// + /// Since this error is not something that happens at normal runtime, but just during development when + /// using index names which are no longer value, it's exposed through the `QueryResult.value` (which + /// will `throw` when access), such that we don't have to wrap the `value` into a success/error wrapper + /// all the time (because under ordinary circumstances we'll always have a successful value). + withoutAgeIndex, + + /// When this is active, a new name index is created, and only if active is a query for this index used + /// + /// Thus there will be no error, but instead the new query starts working right away, showcasing how the + /// code gracefully handles updates while coding. + withNameIndex, +} + +const _currentCase = HotReloadCase.withNameIndex; + +/// Showcases how the store can be updated during development, supporting hot-reload +class HotReloadExample extends StatefulWidget { + const HotReloadExample({ + super.key, + }); + + @override + State createState() => _HotReloadExampleState(); +} + +class _HotReloadExampleState extends State { + final database = getNewDatabase(); // todo merge again + + late final PersonStore store = database.entityStore(PersonConnector()); + + late final QueryResult> everyone = store.query(); + + late final QueryResult> adults = + store.query(where: (cols) => cols['age'].greaterThanOrEqual(18)); + + QueryResult>? roberts; + + @override + initState() { + super.initState(); + + // ignore: invalid_use_of_protected_member + // WidgetsBinding.instance.registerSignalServiceExtension( + // name: FoundationServiceExtensions.reassemble.name, + // callback: () async { + // print('reassemble'); + // }, + // ); + + store.writeMany([ + Person(id: 1, name: 'Sam', age: 5), + Person(id: 2, name: 'Bob', age: 3), + Person(id: 3, name: 'Robert', age: 20), + Person(id: 4, name: 'Max', age: 21), + ]); + } + + @override + void reassemble() { + super.reassemble(); + + debugPrint('Reassemble'); + database.handleHotReload(); + // WidgetsBinding.instance.addPostFrameCallback((_) { + // }); + + if (_currentCase == HotReloadCase.withNameIndex) { + roberts = store.query( + where: (cols) => + cols['name'].equals('Robert') | cols['name'].equals('Bob')); + } else { + roberts?.dispose(); + roberts = null; + } + + // static final _hotReload = ChangeNotifier(); + // static Listenable get hotReload => _hotReload; + + // WidgetsFlutterBinding.ensureInitialized().addObserver(observer); + // WidgetsFlutterBinding.ensureInitialized().buildOwner.; + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Change the `_currentCase` value in the code and see how the app adapts to the new configuration after hot-reload', + ), + Expanded( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _GroupWidget(name: 'Everyone', persons: everyone), + _GroupWidget(name: 'Adults', persons: adults), + if (roberts != null) + _GroupWidget(name: 'Roberts', persons: roberts!), + ], + ), + ), + ), + ], + ); + } + + @override + void dispose() { + // In practice, whoever create the database, store, and repository would have to dispose it + + everyone.dispose(); + adults.dispose(); + roberts?.dispose(); + + super.dispose(); + } +} + +class _GroupWidget extends StatelessWidget { + const _GroupWidget({ + // ignore: unused_element + super.key, + required this.name, + required this.persons, + }); + + final String name; + + final QueryResult> persons; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: persons, + builder: (context, persons, _) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 20), + Text( + ' $name', + style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold), + ), + for (final person in persons) + CupertinoListTile( + title: Text('${person.name} (${person.age})'), + ), + ], + ); + }, + ); + } +} + +// class SimpleSynchronousRepository { +// final TodoStore _store; + +// SimpleSynchronousRepository({ +// required TodoStore store, +// }) : _store = store; + +// void init() { +// _store.write(Todo(id: 1, text: 'Brew Coffe', done: false)); +// _store.write(Todo(id: 2, text: 'Get milk', done: false)); +// _store.write(Todo(id: 3, text: 'Read newspaper', done: false)); +// } + +// QueryResult> getOpenTodos() { +// return _store.query(where: (cols) => cols['done'].equals(false)); +// } + +// void updateTodo(Todo todo) { +// _store.write(todo); +// } +// } + +typedef PersonStore = IndexedEntityStore; + +// Instantiate the store on top of the DB? +// with (super.database) +// would that make query writing better by e.g. using `store.ageCol.equals` ? + +class PersonConnector + implements + IndexedEntityConnector< + Person, + // key type + int, + // DB value type + String> { + @override + final entityKey = 'person'; + + @override + void getIndices(IndexCollector index) { + if (_currentCase != HotReloadCase.withoutAgeIndex) { + index((p) => p.age, as: 'age'); + } + + if (_currentCase == HotReloadCase.withNameIndex) { + index((p) => p.name, as: 'name'); + } + } + + @override + int getPrimaryKey(Person e) => e.id; + + @override + String serialize(Person e) => jsonEncode(e.toJSON()); + + @override + Person deserialize(String s) => Person.fromJSON( + jsonDecode(s) as Map, + ); +} + +class Person { + Person({ + required this.id, + required this.name, + required this.age, + }); + + final int id; + + final String name; + + final int age; + + // These would very likely be created by [json_serializable](https://pub.dev/packages/json_serializable) + // or [freezed](https://pub.dev/packages/freezed) already for your models + Map toJSON() { + return { + 'id': id, + 'name': name, + 'age': age, + }; + } + + static Person fromJSON(Map json) { + return Person( + id: json['id'], + name: json['name'], + age: json['age'], + ); + } +} + +class Foo extends WidgetsBindingObserver {} + +class HotReloadTracker { + static final _instance = HotReloadTracker._internal(); + factory HotReloadTracker() => _instance; + + HotReloadTracker._internal() { + if (kDebugMode) { + print("HotReloadTracker initialized - possible hot reload"); + } + } +} diff --git a/example/lib/src/stores/product_connector.dart b/example/lib/src/stores/product_connector.dart index 9f35610..1d3fe74 100644 --- a/example/lib/src/stores/product_connector.dart +++ b/example/lib/src/stores/product_connector.dart @@ -6,16 +6,27 @@ import 'package:indexed_entity_store_example/src/stores/entities/product.dart'; typedef ProductDetailStore = IndexedEntityStore; -final productDetailConnector = IndexedEntityConnector( - entityKey: 'product', - getPrimaryKey: (t) => t.id, - getIndices: (index) {}, - serialize: (t) => jsonEncode(t.toJSON()), - deserialize: (s) => ProductDetail.fromJSON( - jsonDecode(s) as Map, - ), -); +class ProductDetailConnector + implements + IndexedEntityConnector { + @override + final entityKey = 'product'; + + @override + void getIndices(IndexCollector index) {} + + @override + int getPrimaryKey(ProductDetail e) => e.id; + + @override + String serialize(ProductDetail e) => jsonEncode(e.toJSON()); + + @override + ProductDetail deserialize(String s) => ProductDetail.fromJSON( + jsonDecode(s) as Map, + ); +} /// Creates a new ProductDetail store, backed by a new, temporary database /// @@ -23,5 +34,5 @@ final productDetailConnector = IndexedEntityConnector; -final todoConnector = - IndexedEntityConnector( - entityKey: 'todo', - getPrimaryKey: (t) => t.id, - getIndices: (index) { +class TodoConnector extends IndexedEntityConnector { + @override + final entityKey = 'todo'; + + @override + getIndices(index) { index((t) => t.done, as: 'done'); - }, - serialize: (t) => jsonEncode(t.toJSON()), - deserialize: (s) => Todo.fromJSON( - jsonDecode(s) as Map, - ), -); + } + + @override + getPrimaryKey(e) { + return e.id; + } + + @override + serialize(e) { + return jsonEncode(e.toJSON()); + } + + @override + deserialize(s) { + return Todo.fromJSON(jsonDecode(s) as Map); + } +} /// Creates a new Todo store, backed by a new, temporary database /// @@ -25,5 +38,5 @@ final todoConnector = /// and more importantly the same instance would be used instead of a new one created /// each time as done here for the showcase. TodoStore getTodoStore() { - return getNewDatabase().entityStore(todoConnector); + return getNewDatabase().entityStore(TodoConnector()); } diff --git a/lib/src/index_entity_store.dart b/lib/src/index_entity_store.dart index bde6526..ce4483c 100644 --- a/lib/src/index_entity_store.dart +++ b/lib/src/index_entity_store.dart @@ -8,7 +8,10 @@ part 'index_columns.dart'; part 'query.dart'; part 'query_result.dart'; -typedef QueryResultMapping = (MappedDBResult Function(), QueryResult); +typedef QueryResultMapping = ( + _SuccessDBResult Function(), + QueryResult +); enum SortOrder { asc, @@ -20,24 +23,30 @@ class IndexedEntityStore { this._database, this._connector, ) { - { - final collector = IndexCollector._(_connector.entityKey); + init(); + } - _connector.getIndices(collector); + void init() { + debugPrint('$runtimeType.init'); - _indexColumns = IndexColumns._({ - for (final col in collector._indices) col._field: col, - }); - } + final collector = IndexCollector._(_connector.entityKey); + + _connector.getIndices(collector); + + _indexColumns = IndexColumns._({ + for (final col in collector._indices) col._field: col, + }); _ensureIndexIsUpToDate(); + + _updateAllQueries(); } final Database _database; final IndexedEntityConnector _connector; - late final IndexColumns _indexColumns; + late IndexColumns _indexColumns; String get _entityKey => _connector.entityKey; @@ -79,19 +88,22 @@ class IndexedEntityStore { return _getOnce(key).result; } - MappedDBResult _getOnce(K key) { + _SuccessDBResult _getOnce(K key) { final res = _database.select( 'SELECT value FROM `entity` WHERE `type` = ? AND `key` = ?', [_entityKey, key], ); if (res.isEmpty) { - return (dbValues: [null], result: null); + return _SuccessDBResult(dbValues: [null], result: null); } final dbValue = res.single['value']; - return (dbValues: [dbValue], result: _connector.deserialize(dbValue)); + return _SuccessDBResult( + dbValues: [dbValue], + result: _connector.deserialize(dbValue), + ); } /// Returns the single entity (or null) for the given query @@ -124,7 +136,7 @@ class IndexedEntityStore { return _querySingleOnce(where).result; } - MappedDBResult _querySingleOnce(QueryBuilder where) { + _SuccessDBResult _querySingleOnce(QueryBuilder where) { final result = _queryOnce(where: where, limit: 2); if (result.result.length > 1) { @@ -133,7 +145,8 @@ class IndexedEntityStore { ); } - return (dbValues: result.dbValues, result: result.result.firstOrNull); + return _SuccessDBResult( + dbValues: result.dbValues, result: result.result.firstOrNull); } /// Returns a subscription to entities matching the given query @@ -166,7 +179,7 @@ class IndexedEntityStore { return _queryOnce(where: where, orderBy: orderBy, limit: limit).result; } - MappedDBResult> _queryOnce({ + _SuccessDBResult> _queryOnce({ QueryBuilder? where, OrderByClause? orderBy, int? limit, @@ -194,7 +207,7 @@ class IndexedEntityStore { final dbValues = res.map((e) => e['value']).toList(); - return ( + return _SuccessDBResult( dbValues: dbValues, result: dbValues.map((v) => _connector.deserialize(v)).toList(), ); @@ -332,6 +345,11 @@ class IndexedEntityStore { _database.execute('BEGIN'); + _database.execute( + 'DELETE FROM `index` WHERE `type` = ?', + [_entityKey], + ); + final entities = queryOnce(); for (final e in entities) { @@ -350,11 +368,12 @@ class IndexedEntityStore { if (singleEntitySubscriptions != null) { for (final mapping in singleEntitySubscriptions) { final newValue = mapping.$1(); + final currentValue = mapping.$2._value.value; - if (newValue.dbValues.length == - mapping.$2._value.value.dbValues.length && - newValue.dbValues.indexed.every( - (e) => mapping.$2._value.value.dbValues[e.$1] == e.$2)) { + if (currentValue is _SuccessDBResult && + newValue.dbValues.length == currentValue.dbValues.length && + newValue.dbValues.indexed + .every((e) => currentValue.dbValues[e.$1] == e.$2)) { continue; // values already match } @@ -365,16 +384,56 @@ class IndexedEntityStore { for (final mapping in _entityResults) { final newValue = mapping.$1(); + final currentValue = mapping.$2._value.value; - if (newValue.dbValues.length == mapping.$2._value.value.dbValues.length && + if (currentValue is _SuccessDBResult && + newValue.dbValues.length == currentValue.dbValues.length && newValue.dbValues.indexed - .every((e) => mapping.$2._value.value.dbValues[e.$1] == e.$2)) { + .every((e) => currentValue.dbValues[e.$1] == e.$2)) { continue; // values already match } mapping.$2._value.value = newValue; } } + + void _updateAllQueries() { + for (final mapping in _singleEntityResults.values.expand((e) => e)) { + try { + final newValue = mapping.$1(); + final currentValue = mapping.$2._value.value; + + if (currentValue is _SuccessDBResult && + newValue.dbValues.length == currentValue.dbValues.length && + newValue.dbValues.indexed + .every((e) => currentValue.dbValues[e.$1] == e.$2)) { + continue; // values already match + } + + mapping.$2._value.value = newValue; + } catch (e) { + mapping.$2._value.value = mapping.$2._value.value._error(e); + } + } + + for (final mapping in _entityResults) { + try { + final newValue = mapping.$1(); + final currentValue = mapping.$2._value.value; + + if (currentValue is _SuccessDBResult && + newValue.dbValues.length == currentValue.dbValues.length && + newValue.dbValues.indexed + .every((e) => currentValue.dbValues[e.$1] == e.$2)) { + continue; // values already match + } + + mapping.$2._value.value = newValue; + } catch (e) { + mapping.$2._value.value = mapping.$2._value.value._error(e); + } + } + } } // NOTE(tp): This is implemented as a `class` with `call` such that we can diff --git a/lib/src/indexed_entity_connector.dart b/lib/src/indexed_entity_connector.dart index 1fd9604..e3885a5 100644 --- a/lib/src/indexed_entity_connector.dart +++ b/lib/src/indexed_entity_connector.dart @@ -1,65 +1,14 @@ import 'package:indexed_entity_store/indexed_entity_store.dart'; abstract class IndexedEntityConnector { - factory IndexedEntityConnector({ - required String entityKey, - required K Function(T) getPrimaryKey, - required void Function(IndexCollector index) getIndices, - required S Function(T) serialize, - required T Function(S) deserialize, - }) { - return _IndexedEntityConnector( - entityKey, - getPrimaryKey, - getIndices, - serialize, - deserialize, - ); - } - + S /* storage format, string or bytes */ > { String get entityKey; K getPrimaryKey(T e); void getIndices(IndexCollector index); - /// String or bytes S serialize(T e); T deserialize(S s); } - -class _IndexedEntityConnector - implements IndexedEntityConnector { - _IndexedEntityConnector( - this.entityKey, - this._getPrimaryKey, - this._getIndices, - this._serialize, - this._deserialize, - ); - - @override - final String entityKey; - - final K Function(T) _getPrimaryKey; - - final void Function(IndexCollector index) _getIndices; - - final S Function(T) _serialize; - - final T Function(S) _deserialize; - - @override - K getPrimaryKey(T e) => _getPrimaryKey(e); - - @override - S serialize(T e) => _serialize(e); - - @override - T deserialize(S s) => _deserialize(s); - - @override - void getIndices(IndexCollector index) => _getIndices(index); -} diff --git a/lib/src/indexed_entity_database.dart b/lib/src/indexed_entity_database.dart index b3247c3..f58aff4 100644 --- a/lib/src/indexed_entity_database.dart +++ b/lib/src/indexed_entity_database.dart @@ -72,18 +72,40 @@ class IndexedEntityDabase { return IndexedEntityDabase._(path); } + final _stores = {}; + IndexedEntityStore entityStore( IndexedEntityConnector connector, ) { - // TODO(tp): Throw if another connected for `type` is already connect (taking reloads into account) + if (_stores.containsKey(connector.entityKey)) { + throw Exception( + 'A store for "${connector.entityKey}" has already been created', + ); + } - return IndexedEntityStore( + final store = IndexedEntityStore( _database, connector, ); + + _stores[connector.entityKey] = store; + + return store; } + /// Closes the underlying database dispose() { _database.dispose(); + + _stores.clear(); + } + + /// Updates the database in response to potential code changes after a hot reload + /// Either have this method called in a high-level `State.reassemble` (before the database is used), + /// or use the provided `IndexedEntityStoreHotReloadWrapper` widget. + void handleHotReload() { + for (final store in _stores.values) { + store.init(); + } } } diff --git a/lib/src/query_result.dart b/lib/src/query_result.dart index 1070f15..62a68c9 100644 --- a/lib/src/query_result.dart +++ b/lib/src/query_result.dart @@ -1,15 +1,36 @@ part of 'index_entity_store.dart'; -typedef MappedDBResult = ({List dbValues, T result}); +sealed class _MappedDBResult { + /// Returns an error for the same generic type + _ErrorDBResult _error(Object e) { + return _ErrorDBResult(e); + } +} + +class _SuccessDBResult extends _MappedDBResult { + _SuccessDBResult({ + required this.dbValues, + required this.result, + }); + + final List dbValues; + final T result; +} + +class _ErrorDBResult extends _MappedDBResult { + _ErrorDBResult(this.error); + + final Object error; +} class QueryResult implements DisposableValueListenable { QueryResult._({ - required MappedDBResult initialValue, + required _MappedDBResult initialValue, void Function(QueryResult self)? onDispose, }) : _value = ValueNotifier(initialValue), _onDispose = onDispose; - final ValueNotifier> _value; + final ValueNotifier<_MappedDBResult> _value; final void Function(QueryResult self)? _onDispose; @@ -24,7 +45,10 @@ class QueryResult implements DisposableValueListenable { } @override - T get value => _value.value.result; + T get value => switch (_value.value) { + _SuccessDBResult(:final result) => result, + _ErrorDBResult(:final error) => throw error, + }; @override @mustCallSuper