From 399bcfebb2dade1b2d509d7a090dca1ab00830ae Mon Sep 17 00:00:00 2001 From: rafuck Date: Fri, 4 Mar 2022 18:35:38 +0300 Subject: [PATCH 1/4] Immutable entities with copyWithId setter --- generator/lib/src/code_builder.dart | 1 + generator/lib/src/code_chunks.dart | 10 ++- generator/lib/src/entity_resolver.dart | 12 +++- objectbox/Makefile | 10 +-- objectbox/lib/src/annotations.dart | 27 +++++++- .../lib/src/modelinfo/entity_definition.dart | 2 +- objectbox/lib/src/modelinfo/enums.dart | 5 ++ .../lib/src/modelinfo/modelproperty.dart | 20 +++++- objectbox/lib/src/native/box.dart | 62 +++++++++++++++---- objectbox/test/entity_immutable.dart | 25 ++++++++ objectbox/test/immutable_test.dart | 44 +++++++++++++ objectbox/test/objectbox-model.json | 39 ++++++++++-- 12 files changed, 229 insertions(+), 28 deletions(-) create mode 100644 objectbox/test/entity_immutable.dart create mode 100644 objectbox/test/immutable_test.dart diff --git a/generator/lib/src/code_builder.dart b/generator/lib/src/code_builder.dart index 083ab3d37..cfab67346 100644 --- a/generator/lib/src/code_builder.dart +++ b/generator/lib/src/code_builder.dart @@ -209,6 +209,7 @@ class CodeBuilder extends Builder { propInModel.name = prop.name; propInModel.type = prop.type; propInModel.flags = prop.flags; + propInModel.generatorFlags = prop.generatorFlags; propInModel.dartFieldType = prop.dartFieldType; propInModel.relationTarget = prop.relationTarget; diff --git a/generator/lib/src/code_chunks.dart b/generator/lib/src/code_chunks.dart index 8775a3f5d..2cdd91ffb 100644 --- a/generator/lib/src/code_chunks.dart +++ b/generator/lib/src/code_chunks.dart @@ -125,7 +125,8 @@ class CodeChunks { id: ${createIdUid(property.id)}, name: '${property.name}', type: ${property.type}, - flags: ${property.flags} + flags: ${property.flags}, + generatorFlags: ${property.generatorFlags} $additionalArgs ) '''; @@ -180,8 +181,12 @@ class CodeChunks { } static String setId(ModelEntity entity) { + if (entity.idProperty + .hasGeneratorFlag(OBXPropertyGeneratorFlags.USE_COPY_WITH_ID)) { + return '{return object.copyWithId(id);}'; + } if (!entity.idProperty.fieldIsReadOnly) { - return '{object.${propertyFieldName(entity.idProperty)} = id;}'; + return '{object.${propertyFieldName(entity.idProperty)} = id; return object;}'; } // Note: this is a special case handling read-only IDs with assignable=true. // Such ID must already be set, i.e. it could not have been assigned. @@ -193,6 +198,7 @@ class CodeChunks { "doesn't match the inserted ID (ID \$id). " 'You must assign an ID before calling [box.put()].'); } + return object; }'''; } diff --git a/generator/lib/src/entity_resolver.dart b/generator/lib/src/entity_resolver.dart index 45d8929ea..ccacd5e47 100644 --- a/generator/lib/src/entity_resolver.dart +++ b/generator/lib/src/entity_resolver.dart @@ -116,7 +116,7 @@ class EntityResolver extends Builder { var isToManyRel = false; int? fieldType; - var flags = 0; + var flags = 0, generatorFlags = 0; int? propUid; _idChecker.runIfMatches(f, (annotation) { @@ -124,6 +124,9 @@ class EntityResolver extends Builder { if (annotation.getField('assignable')!.toBoolValue()!) { flags |= OBXPropertyFlags.ID_SELF_ASSIGNABLE; } + if (annotation.getField('useCopyWith')!.toBoolValue()!) { + generatorFlags |= OBXPropertyGeneratorFlags.USE_COPY_WITH_ID; + } }); _propertyChecker.runIfMatches(f, (annotation) { @@ -210,6 +213,7 @@ class EntityResolver extends Builder { final prop = ModelProperty.create( IdUid(0, propUid ?? 0), f.name, fieldType, flags: flags, + generatorFlags: generatorFlags, entity: entity, uidRequest: propUid != null && propUid == 0); @@ -242,7 +246,9 @@ class EntityResolver extends Builder { final idField = element.fields .singleWhere((FieldElement f) => f.name == entity.idProperty.name); if (idField.setter == null) { - if (!entity.idProperty.hasFlag(OBXPropertyFlags.ID_SELF_ASSIGNABLE)) { + if (!entity.idProperty.hasFlag(OBXPropertyFlags.ID_SELF_ASSIGNABLE) && + !entity.idProperty + .hasGeneratorFlag(OBXPropertyGeneratorFlags.USE_COPY_WITH_ID)) { throw InvalidGenerationSourceError( "Entity ${entity.name} has an ID field '${idField.name}' that is " 'not assignable (that usually means it is declared final). ' @@ -308,7 +314,7 @@ class EntityResolver extends Builder { final indexAnnotation = _indexChecker.firstAnnotationOfExact(f); final uniqueAnnotation = _uniqueChecker.firstAnnotationOfExact(f); - if (indexAnnotation == null && uniqueAnnotation == null) return null; + if (indexAnnotation == null && uniqueAnnotation == null) return; // Throw if property type does not support any index. if (fieldType == OBXPropertyType.Float || diff --git a/objectbox/Makefile b/objectbox/Makefile index a5dce9dc8..ec87d95e1 100644 --- a/objectbox/Makefile +++ b/objectbox/Makefile @@ -11,22 +11,22 @@ help: ## Show this help all: depend test valgrind-test integration-test depend: ## Build dependencies - pub get + dart pub get ../install.sh test: ## Test all targets - pub run build_runner build - pub run test + dart run build_runner build + dart run test coverage: ## Calculate test coverage - pub run build_runner build + dart run build_runner build # Note: only flutter test generates `.lcov` - can't use `dart test` flutter test --coverage lcov --remove coverage/lcov.info 'lib/src/native/sync.dart' 'lib/src/native/bindings/objectbox_c.dart' 'lib/src/native/bindings/bindings.dart' 'lib/src/modelinfo/*' -o coverage/lcov.info genhtml coverage/lcov.info -o coverage/html || true valgrind-test: ## Test all targets with valgrind - pub run build_runner build + dart run build_runner build ./tool/valgrind.sh integration-test: ## Execute integration tests diff --git a/objectbox/lib/src/annotations.dart b/objectbox/lib/src/annotations.dart index 09d26cfe5..8712c4cd2 100644 --- a/objectbox/lib/src/annotations.dart +++ b/objectbox/lib/src/annotations.dart @@ -134,8 +134,33 @@ class Id { /// telling which objects are new and which are already saved. final bool assignable; + /// Use copyWith new id in the entity identifier setter. + /// For example, if your entity is immutable object, + /// you can define idSetter as follows + /// + /// @Entity() + /// class ImmutableEntity { + /// @Id(idSetter: _idSetter) + /// final int? id; + /// + /// final int payload; + /// + /// ImmutableEntity copyWith({int? id, int? unique, int? payload}) => + /// ImmutableEntity( + /// id: id, + /// unique: unique ?? this.unique, + /// payload: payload ?? this.payload, + /// ); + /// + /// ImmutableEntity{this.id, required this.unique, required this.payload}); + /// + /// static ImmutableEntity copyWithId(ImmutableEntity entity, int newId) => + /// entity.copyWith(id: newId); + /// } + final bool useCopyWith; + /// Create an Id annotation. - const Id({this.assignable = false}); + const Id({this.assignable = false, this.useCopyWith = false}); } /// Transient annotation marks fields that should not be stored in the database. diff --git a/objectbox/lib/src/modelinfo/entity_definition.dart b/objectbox/lib/src/modelinfo/entity_definition.dart index 4902632c2..a3f4be594 100644 --- a/objectbox/lib/src/modelinfo/entity_definition.dart +++ b/objectbox/lib/src/modelinfo/entity_definition.dart @@ -16,7 +16,7 @@ class EntityDefinition { final int Function(T, fb.Builder) objectToFB; final T Function(Store, ByteData) objectFromFB; final int? Function(T) getId; - final void Function(T, int) setId; + final T Function(T, int) setId; final List Function(T) toOneRelations; final Map Function(T) toManyRelations; diff --git a/objectbox/lib/src/modelinfo/enums.dart b/objectbox/lib/src/modelinfo/enums.dart index 8d57c1fa0..17062eae7 100644 --- a/objectbox/lib/src/modelinfo/enums.dart +++ b/objectbox/lib/src/modelinfo/enums.dart @@ -138,6 +138,11 @@ abstract class OBXPropertyFlags { static const int UNIQUE_ON_CONFLICT_REPLACE = 32768; } +abstract class OBXPropertyGeneratorFlags { + /// Use copyWithId in id setter + static const int USE_COPY_WITH_ID = 65536; +} + abstract class OBXPropertyType { /// < 1 byte static const int Bool = 1; diff --git a/objectbox/lib/src/modelinfo/modelproperty.dart b/objectbox/lib/src/modelinfo/modelproperty.dart index 28f19e8c5..84d5a5d9f 100644 --- a/objectbox/lib/src/modelinfo/modelproperty.dart +++ b/objectbox/lib/src/modelinfo/modelproperty.dart @@ -10,7 +10,7 @@ class ModelProperty { late String _name; - late int _type, _flags; + late int _type, _flags, _generatorFlags; IdUid? _indexId; ModelEntity? entity; String? relationTarget; @@ -51,6 +51,16 @@ class ModelProperty { _flags = value; } + int get generatorFlags => _generatorFlags; + + set generatorFlags(int? value) { + if (value == null || value < 0) { + throw ArgumentError('generator flags must be defined and may not be < 0'); + } + + _generatorFlags = value; + } + String get dartFieldType => _dartFieldType!; set dartFieldType(String value) => _dartFieldType = value; @@ -92,6 +102,7 @@ class ModelProperty { // used in code generator ModelProperty.create(this.id, String? name, int? type, {int flags = 0, + int generatorFlags = 0, String? indexId, this.entity, String? dartFieldType, @@ -101,6 +112,7 @@ class ModelProperty { this.name = name; this.type = type; this.flags = flags; + this.generatorFlags = generatorFlags; this.indexId = indexId == null ? null : IdUid.fromString(indexId); } @@ -110,11 +122,13 @@ class ModelProperty { required String name, required int type, required int flags, + int? generatorFlags, IdUid? indexId, this.relationTarget}) : _name = name, _type = type, _flags = flags, + _generatorFlags = generatorFlags ?? 0, _indexId = indexId, uidRequest = false; @@ -122,6 +136,7 @@ class ModelProperty { : this.create(IdUid.fromString(data['id'] as String?), data['name'] as String?, data['type'] as int?, flags: data['flags'] as int? ?? 0, + generatorFlags: data['generatorFlags'] as int? ?? 0, indexId: data['indexId'] as String?, entity: entity, dartFieldType: data['dartFieldType'] as String?, @@ -134,6 +149,7 @@ class ModelProperty { ret['name'] = name; ret['type'] = type; if (flags != 0) ret['flags'] = flags; + if (generatorFlags != 0) ret['generatorFlags'] = generatorFlags; if (indexId != null) ret['indexId'] = indexId!.toString(); if (relationTarget != null) ret['relationTarget'] = relationTarget; if (!forModelJson && _dartFieldType != null) { @@ -144,6 +160,7 @@ class ModelProperty { } bool hasFlag(int flag) => (flags & flag) == flag; + bool hasGeneratorFlag(int flag) => (generatorFlags & flag) == flag; bool hasIndexFlag() => hasFlag(OBXPropertyFlags.INDEXED) || @@ -167,6 +184,7 @@ class ModelProperty { result += ' type:${obxPropertyTypeToString(type)}'; if (!isSigned) result += ' unsigned'; result += ' flags:$flags'; + result += ' generatorFlags:$generatorFlags'; if (hasIndexFlag()) { result += ' index:' + diff --git a/objectbox/lib/src/native/box.dart b/objectbox/lib/src/native/box.dart index 763121be8..c2d5eaca9 100644 --- a/objectbox/lib/src/native/box.dart +++ b/objectbox/lib/src/native/box.dart @@ -116,7 +116,7 @@ class Box { _builder.resetIfLarge(); // reset before `await` if (id == 0) { // Note: if the newId future completes with an error, ID isn't set. - _entity.setId(object, await newId); + object = _entity.setId(object, await newId); } return newId; }); @@ -148,9 +148,18 @@ class Box { var id = _entity.objectToFB(object, _builder.fbb); final newId = C.async_put_object4(_async!._cAsync, _builder.bufPtr, _builder.fbb.size(), _getOBXPutMode(mode)); - id = _handlePutObjectResult(object, id, newId); + final result = _handlePutObjectResult(object, id, newId); + + // replace object if id setter returns another object + if (object != result.object) { + _builder.fbb.reset(); + _entity.objectToFB(result.object, _builder.fbb); + C.async_put_object4(_async!._cAsync, _builder.bufPtr, _builder.fbb.size(), + _getOBXPutMode(mode)); + } + _builder.resetIfLarge(); - return newId; + return result.id; } int _put(T object, PutMode mode, Transaction? tx) { @@ -164,7 +173,7 @@ class Box { if ((_entity.getId(object) ?? 0) == 0) { final newId = C.box_id_for_put(_cBox, 0); if (newId == 0) throwLatestNativeError(context: 'id-for-put failed'); - _entity.setId(object, newId); + object = _entity.setId(object, newId); } _putToOneRelFields(object, mode, tx); } @@ -173,10 +182,19 @@ class Box { var id = _entity.objectToFB(object, _builder.fbb); final newId = C.box_put_object4( _cBox, _builder.bufPtr, _builder.fbb.size(), _getOBXPutMode(mode)); - id = _handlePutObjectResult(object, id, newId); - if (_hasToManyRelations) _putToManyRelFields(object, mode, tx!); + final result = _handlePutObjectResult(object, id, newId); + + // replace object if id setter returns another object + if (object != result.object) { + _builder.fbb.reset(); + _entity.objectToFB(result.object, _builder.fbb); + C.box_put_object4( + _cBox, _builder.bufPtr, _builder.fbb.size(), _getOBXPutMode(mode)); + } + + if (_hasToManyRelations) _putToManyRelFields(result.object, mode, tx!); _builder.resetIfLarge(); - return id; + return result.id; } /// Puts the given [objects] into this Box in a single transaction. @@ -201,7 +219,21 @@ class Box { final id = _entity.objectToFB(object, _builder.fbb); final newId = C.cursor_put_object4( cursor.ptr, _builder.bufPtr, _builder.fbb.size(), cMode); - putIds[i] = _handlePutObjectResult(object, id, newId); + final result = _handlePutObjectResult(object, id, newId); + putIds[i] = result.id; + + // replace object if id setter returns another object + if (object != result.object) { + objects[i] = result.object; + _builder.fbb.reset(); + _entity.objectToFB(result.object, _builder.fbb); + C.cursor_put_object4( + cursor.ptr, + _builder.bufPtr, + _builder.fbb.size(), + cMode, + ); + } } if (_hasToManyRelations) { @@ -216,10 +248,11 @@ class Box { // Checks if native obx_*_put_object() was successful (result is a valid ID). // Sets the given ID on the object if previous ID was zero (new object). @pragma('vm:prefer-inline') - int _handlePutObjectResult(T object, int prevId, int result) { + _HandlePutResult _handlePutObjectResult(T object, int prevId, int result) { if (result == 0) throwLatestNativeError(context: 'object put failed'); - if (prevId == 0) _entity.setId(object, result); - return result; + T newObject = object; + if (prevId == 0) newObject = _entity.setId(object, result); + return _HandlePutResult(result, newObject); } /// Retrieves the stored object with the ID [id] from this box's database. @@ -510,3 +543,10 @@ class InternalBoxAccess { return result; }); } + +/// Result of identifier setter _handlePutObjectResult +class _HandlePutResult { + final int id; + final T object; + const _HandlePutResult(this.id, this.object); +} diff --git a/objectbox/test/entity_immutable.dart b/objectbox/test/entity_immutable.dart new file mode 100644 index 000000000..839b9014a --- /dev/null +++ b/objectbox/test/entity_immutable.dart @@ -0,0 +1,25 @@ +import 'package:objectbox/objectbox.dart'; + +// Testing a model for immutable entities +@Entity() +class TestEntityImmutable { + @Id(useCopyWith: true) + final int? id; + + @Unique(onConflict: ConflictStrategy.replace) + final int unique; + + final int payload; + + TestEntityImmutable copyWith({int? id, int? unique, int? payload}) => + TestEntityImmutable( + id: id, + unique: unique ?? this.unique, + payload: payload ?? this.payload, + ); + + TestEntityImmutable({this.id, required this.unique, required this.payload}); + + TestEntityImmutable copyWithId(int newId) => + (id != newId) ? copyWith(id: newId) : this; +} diff --git a/objectbox/test/immutable_test.dart b/objectbox/test/immutable_test.dart new file mode 100644 index 000000000..05029fd66 --- /dev/null +++ b/objectbox/test/immutable_test.dart @@ -0,0 +1,44 @@ +import 'package:collection/collection.dart'; +import 'package:test/test.dart'; + +import 'entity_immutable.dart'; +import 'objectbox.g.dart'; +import 'test_env.dart'; + +// We want to have types explicit - verifying the return types of functions. +// ignore_for_file: omit_local_variable_types + +void main() { + late TestEnv env; + late Box box; + + setUp(() { + env = TestEnv('entity_immutable'); + box = env.store.box(); + }); + tearDown(() => env.closeAndDelete()); + + test('Query with no conditions, and order as desc ints', () { + box.putMany([ + TestEntityImmutable(unique: 1, payload: 1), + TestEntityImmutable(unique: 10, payload: 10), + TestEntityImmutable(unique: 2, payload: 2), + TestEntityImmutable(unique: 100, payload: 100), + TestEntityImmutable(unique: 0, payload: 0), + TestEntityImmutable(unique: 50, payload: 0), + ]); + + final query = (box.query() + ..order(TestEntityImmutable_.payload, flags: Order.descending)) + .build(); + var listDesc = query.find(); + + expect(listDesc.map((t) => t.payload).toList(), [100, 10, 2, 1, 0, 0]); + + box.put(TestEntityImmutable(unique: 50, payload: 50)); + + listDesc = query.find(); + expect(listDesc.map((t) => t.payload).toList(), [100, 50, 10, 2, 1, 0]); + query.close(); + }); +} diff --git a/objectbox/test/objectbox-model.json b/objectbox/test/objectbox-model.json index 700838464..e19feecbb 100644 --- a/objectbox/test/objectbox-model.json +++ b/objectbox/test/objectbox-model.json @@ -527,16 +527,44 @@ } ], "relations": [] + }, + { + "id": "12:3755469960880688715", + "lastPropertyId": "3:4328105509779076145", + "name": "TestEntityImmutable", + "properties": [ + { + "id": "1:2702147992811450538", + "name": "id", + "type": 6, + "flags": 1, + "generatorFlags": 65536 + }, + { + "id": "2:8497246254690861169", + "name": "unique", + "type": 6, + "flags": 32808, + "indexId": "22:5208599577901675227" + }, + { + "id": "3:4328105509779076145", + "name": "payload", + "type": 6 + } + ], + "relations": [] } ], - "lastEntityId": "10:8814538095619551454", - "lastIndexId": "20:4846837430056399798", + "lastEntityId": "12:3755469960880688715", + "lastIndexId": "22:5208599577901675227", "lastRelationId": "1:2155747579134420981", "lastSequenceId": "0:0", "modelVersion": 5, "modelVersionParserMinimum": 5, "retiredEntityUids": [ - 2679953000475642792 + 2679953000475642792, + 1095349603547153763 ], "retiredIndexUids": [], "retiredPropertyUids": [ @@ -554,7 +582,10 @@ 263290714597678490, 2191004797635629014, 6174275661850707374, - 2900967122054840440 + 2900967122054840440, + 7226208115703027531, + 570487578217848006, + 2435024852950745282 ], "retiredRelationUids": [], "version": 1 From 3a6d779124ab9242975420a649b7340c392df8d6 Mon Sep 17 00:00:00 2001 From: rafuck Date: Fri, 4 Mar 2022 20:20:51 +0300 Subject: [PATCH 2/4] Update annotation doc --- objectbox/lib/src/annotations.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/objectbox/lib/src/annotations.dart b/objectbox/lib/src/annotations.dart index 8712c4cd2..cd02f2141 100644 --- a/objectbox/lib/src/annotations.dart +++ b/objectbox/lib/src/annotations.dart @@ -136,11 +136,11 @@ class Id { /// Use copyWith new id in the entity identifier setter. /// For example, if your entity is immutable object, - /// you can define idSetter as follows + /// you can define useCopyWith as follows /// /// @Entity() /// class ImmutableEntity { - /// @Id(idSetter: _idSetter) + /// @Id(useCopyWith: true) /// final int? id; /// /// final int payload; @@ -154,8 +154,8 @@ class Id { /// /// ImmutableEntity{this.id, required this.unique, required this.payload}); /// - /// static ImmutableEntity copyWithId(ImmutableEntity entity, int newId) => - /// entity.copyWith(id: newId); + /// ImmutableEntity copyWithId(int newId) => + /// (id != newId) ? entity.copyWith(id: newId) : this; /// } final bool useCopyWith; From c68343127ccb6b0f6ce30d6e54ec0162e8c75cad Mon Sep 17 00:00:00 2001 From: rafuck Date: Wed, 9 Mar 2022 12:53:06 +0300 Subject: [PATCH 3/4] Box setters put* for immutable entities --- objectbox/lib/src/native/box.dart | 139 ++++++++++++++++++--------- objectbox/test/entity_immutable.dart | 6 +- objectbox/test/immutable_test.dart | 35 ++++--- 3 files changed, 121 insertions(+), 59 deletions(-) diff --git a/objectbox/lib/src/native/box.dart b/objectbox/lib/src/native/box.dart index c2d5eaca9..d11e084c3 100644 --- a/objectbox/lib/src/native/box.dart +++ b/objectbox/lib/src/native/box.dart @@ -72,9 +72,25 @@ class Box { int put(T object, {PutMode mode = PutMode.put}) { if (_hasRelations) { return InternalStoreAccess.runInTransaction( - _store, TxMode.write, (Transaction tx) => _put(object, mode, tx)); + _store, TxMode.write, (Transaction tx) => _put(object, mode, tx).id); } else { - return _put(object, mode, null); + return _put(object, mode, null).id; + } + } + + /// Puts the given Immutable Object in the box (aka persisting it). + /// + /// If this is a new object (its ID property is 0), a new object with new ID will be returned. + /// + /// If the object with given was already in the box, it will be overwritten. + /// + /// Performance note: consider [putImmutableMany] to put several objects at once. + T putImmutable(T object, {PutMode mode = PutMode.put}) { + if (_hasRelations) { + return InternalStoreAccess.runInTransaction(_store, TxMode.write, + (Transaction tx) => _put(object, mode, tx).object); + } else { + return _put(object, mode, null).object; } } @@ -94,6 +110,28 @@ class Box { /// See also [putQueued] which doesn't return a [Future] but a pre-allocated /// ID immediately, even though the actual database put operation may fail. Future putAsync(T object, {PutMode mode = PutMode.put}) async => + Future.sync(() async => (await _putAsync(object, mode: mode)).id); + + /// Puts the given immutable object in the box (persisting it) asynchronously. + /// + /// The returned future completes with an stored object. If it is a new + /// object (its ID property is 0), a new object with new ID will be assigned to the object + /// argument, after the returned [Future] completes. + /// + /// In extreme scenarios (e.g. having hundreds of thousands async operations + /// per second), this may fail as internal queues fill up if the disk can't + /// keep up. However, this should not be a concern for typical apps. + /// The returned future may also complete with an error if the put failed + /// for another reason, for example a unique constraint violation. In that + /// case the [object]'s id field remains unchanged (0 if it was a new object). + /// + /// See also [putImmutableQueued] which doesn't return a [Future] but a pre-allocated + /// object immediately, even though the actual database put operation may fail. + Future putImmutableAsync(T object, {PutMode mode = PutMode.put}) async => + Future.sync(() async => (await _putAsync(object, mode: mode)).object); + + Future<_HandlePutResult> _putAsync(T object, + {PutMode mode = PutMode.put}) async => // Wrap with [Future.sync] to avoid mixing sync and async errors. // Note: doesn't seem to decrease performance at all. // https://dart.dev/guides/libraries/futures-error-handling#potential-problem-accidentally-mixing-synchronous-and-asynchronous-errors @@ -111,14 +149,14 @@ class Box { // > This means that within an async function body, all synchronous code // > before the first await keyword executes immediately. _builder.fbb.reset(); - var id = _entity.objectToFB(object, _builder.fbb); + final id = _entity.objectToFB(object, _builder.fbb); final newId = _async!.put(id, _builder, mode); _builder.resetIfLarge(); // reset before `await` if (id == 0) { // Note: if the newId future completes with an error, ID isn't set. object = _entity.setId(object, await newId); } - return newId; + return _HandlePutResult(await newId, object); }); /// Schedules the given object to be put later on, by an asynchronous queue. @@ -137,7 +175,29 @@ class Box { /// actual database put was successful. /// Use [Store.awaitAsyncCompletion] and [Store.awaitAsyncSubmitted] to wait /// until all operations have finished. - int putQueued(T object, {PutMode mode = PutMode.put}) { + int putQueued(T object, {PutMode mode = PutMode.put}) => + _putQueued(object, mode: mode).id; + + /// Schedules the given immutable object to be put later on, by an asynchronous queue. + /// + /// The actual database put operation may fail even if this function returned + /// normally (and even if it returned a new a new object with new ID). For example + /// if the database put failed because of a unique constraint violation. + /// Therefore, you should make sure the data you put is correct and you have + /// a fall back in place even if it eventually failed. + /// + /// In extreme scenarios (e.g. having hundreds of thousands async operations + /// per second), this may fail as internal queues fill up if the disk can't + /// keep up. However, this should not be a concern for typical apps. + /// + /// See also [putImmutableAsync] which returns a [Future] that only completes after an + /// actual database put was successful. + /// Use [Store.awaitAsyncCompletion] and [Store.awaitAsyncSubmitted] to wait + /// until all operations have finished. + T putImmutableQueued(T object, {PutMode mode = PutMode.put}) => + _putQueued(object, mode: mode).object; + + _HandlePutResult _putQueued(T object, {PutMode mode = PutMode.put}) { if (_hasRelations) { throw UnsupportedError('putQueued() is currently not supported on entity ' '${T.toString()} because it has relations.'); @@ -150,19 +210,11 @@ class Box { _builder.fbb.size(), _getOBXPutMode(mode)); final result = _handlePutObjectResult(object, id, newId); - // replace object if id setter returns another object - if (object != result.object) { - _builder.fbb.reset(); - _entity.objectToFB(result.object, _builder.fbb); - C.async_put_object4(_async!._cAsync, _builder.bufPtr, _builder.fbb.size(), - _getOBXPutMode(mode)); - } - _builder.resetIfLarge(); - return result.id; + return result; } - int _put(T object, PutMode mode, Transaction? tx) { + _HandlePutResult _put(T object, PutMode mode, Transaction? tx) { if (_hasRelations) { if (tx == null) { throw StateError( @@ -184,31 +236,35 @@ class Box { _cBox, _builder.bufPtr, _builder.fbb.size(), _getOBXPutMode(mode)); final result = _handlePutObjectResult(object, id, newId); - // replace object if id setter returns another object - if (object != result.object) { - _builder.fbb.reset(); - _entity.objectToFB(result.object, _builder.fbb); - C.box_put_object4( - _cBox, _builder.bufPtr, _builder.fbb.size(), _getOBXPutMode(mode)); - } - if (_hasToManyRelations) _putToManyRelFields(result.object, mode, tx!); _builder.resetIfLarge(); - return result.id; + return result; } /// Puts the given [objects] into this Box in a single transaction. /// /// Returns a list of all IDs of the inserted Objects. - List putMany(List objects, {PutMode mode = PutMode.put}) { + List putMany(List objects, {PutMode mode = PutMode.put}) => + _putMany(objects, mode: mode).map((obj) => obj.id).toList(); + + /// Puts the given List of immutable entities [objects] into this Box in a single transaction. + /// + /// Returns a list of all inserted Objects. + List putImmutableMany(List objects, {PutMode mode = PutMode.put}) => + _putMany(objects, mode: mode).map((obj) => obj.object).toList(); + + List<_HandlePutResult> _putMany(List objects, + {PutMode mode = PutMode.put}) { if (objects.isEmpty) return []; - final putIds = List.filled(objects.length, 0); + final putResults = <_HandlePutResult>[]; InternalStoreAccess.runInTransaction(_store, TxMode.write, (Transaction tx) { if (_hasToOneRelations) { - objects.forEach((object) => _putToOneRelFields(object, mode, tx)); + for (final object in objects) { + _putToOneRelFields(object, mode, tx); + } } final cursor = tx.cursor(_entity); @@ -220,29 +276,18 @@ class Box { final newId = C.cursor_put_object4( cursor.ptr, _builder.bufPtr, _builder.fbb.size(), cMode); final result = _handlePutObjectResult(object, id, newId); - putIds[i] = result.id; - - // replace object if id setter returns another object - if (object != result.object) { - objects[i] = result.object; - _builder.fbb.reset(); - _entity.objectToFB(result.object, _builder.fbb); - C.cursor_put_object4( - cursor.ptr, - _builder.bufPtr, - _builder.fbb.size(), - cMode, - ); - } + putResults.add(result); } if (_hasToManyRelations) { - objects.forEach((object) => _putToManyRelFields(object, mode, tx)); + for (final object in objects) { + _putToManyRelFields(object, mode, tx); + } } _builder.resetIfLarge(); }); - return putIds; + return putResults; } // Checks if native obx_*_put_object() was successful (result is a valid ID). @@ -250,8 +295,8 @@ class Box { @pragma('vm:prefer-inline') _HandlePutResult _handlePutObjectResult(T object, int prevId, int result) { if (result == 0) throwLatestNativeError(context: 'object put failed'); - T newObject = object; - if (prevId == 0) newObject = _entity.setId(object, result); + + final T newObject = (prevId == 0) ? _entity.setId(object, result) : object; return _HandlePutResult(result, newObject); } @@ -385,7 +430,7 @@ class Box { // put new objects if (rel.targetId == 0) { rel.targetId = - InternalToOneAccess.targetBox(rel)._put(rel.target, mode, tx); + InternalToOneAccess.targetBox(rel)._put(rel.target, mode, tx).id; } }); } @@ -477,7 +522,7 @@ class InternalBoxAccess { @pragma('vm:prefer-inline') static int put( Box box, EntityT object, PutMode mode, Transaction? tx) => - box._put(object, mode, tx); + box._put(object, mode, tx).id; /// Put a standalone relation. @pragma('vm:prefer-inline') diff --git a/objectbox/test/entity_immutable.dart b/objectbox/test/entity_immutable.dart index 839b9014a..6216f140b 100644 --- a/objectbox/test/entity_immutable.dart +++ b/objectbox/test/entity_immutable.dart @@ -18,7 +18,11 @@ class TestEntityImmutable { payload: payload ?? this.payload, ); - TestEntityImmutable({this.id, required this.unique, required this.payload}); + const TestEntityImmutable({ + this.id, + required this.unique, + required this.payload, + }); TestEntityImmutable copyWithId(int newId) => (id != newId) ? copyWith(id: newId) : this; diff --git a/objectbox/test/immutable_test.dart b/objectbox/test/immutable_test.dart index 05029fd66..ea5f9e5bf 100644 --- a/objectbox/test/immutable_test.dart +++ b/objectbox/test/immutable_test.dart @@ -9,17 +9,11 @@ import 'test_env.dart'; // ignore_for_file: omit_local_variable_types void main() { - late TestEnv env; - late Box box; + test('Test putImmutable*', () async { + final env = TestEnv('entity_immutable'); + final Box box = env.store.box(); - setUp(() { - env = TestEnv('entity_immutable'); - box = env.store.box(); - }); - tearDown(() => env.closeAndDelete()); - - test('Query with no conditions, and order as desc ints', () { - box.putMany([ + final result = box.putImmutableMany(const [ TestEntityImmutable(unique: 1, payload: 1), TestEntityImmutable(unique: 10, payload: 10), TestEntityImmutable(unique: 2, payload: 2), @@ -28,6 +22,9 @@ void main() { TestEntityImmutable(unique: 50, payload: 0), ]); + expect(result.length, 6); + expect(result.map((e) => e.unique).toList(), [1, 10, 2, 100, 0, 50]); + final query = (box.query() ..order(TestEntityImmutable_.payload, flags: Order.descending)) .build(); @@ -35,10 +32,26 @@ void main() { expect(listDesc.map((t) => t.payload).toList(), [100, 10, 2, 1, 0, 0]); - box.put(TestEntityImmutable(unique: 50, payload: 50)); + final obj = box.putImmutable(const TestEntityImmutable( + unique: 50, + payload: 50, + )); + expect([obj.unique, obj.payload], [50, 50]); listDesc = query.find(); expect(listDesc.map((t) => t.payload).toList(), [100, 50, 10, 2, 1, 0]); query.close(); + + final objAsync = await box.putImmutableAsync(const TestEntityImmutable( + unique: 60, + payload: 60, + )); + + expect( + [objAsync.id, objAsync.unique, objAsync.payload], + [obj.id! + 1, 60, 60], + ); + + env.closeAndDelete(); }); } From 38f31dacd54af754534e2f351745751fe52b31bc Mon Sep 17 00:00:00 2001 From: rafuck Date: Thu, 10 Mar 2022 16:32:17 +0300 Subject: [PATCH 4/4] Immutable relations: toOne, toMany, backLink --- generator/lib/src/code_chunks.dart | 6 +- generator/lib/src/entity_resolver.dart | 5 +- objectbox/lib/objectbox.dart | 4 +- .../lib/src/modelinfo/entity_definition.dart | 4 +- objectbox/lib/src/native/box.dart | 176 +++++-- objectbox/lib/src/relations/info.dart | 9 +- objectbox/lib/src/relations/to_many.dart | 147 +++++- objectbox/lib/src/relations/to_one.dart | 94 +++- objectbox/test/entity_immutable.dart | 113 ++++- objectbox/test/immutable_relations_test.dart | 464 ++++++++++++++++++ objectbox/test/immutable_test.dart | 17 +- objectbox/test/objectbox-model.json | 184 ++++++- 12 files changed, 1146 insertions(+), 77 deletions(-) create mode 100644 objectbox/test/immutable_relations_test.dart diff --git a/generator/lib/src/code_chunks.dart b/generator/lib/src/code_chunks.dart index 2cdd91ffb..0114f0ce9 100644 --- a/generator/lib/src/code_chunks.dart +++ b/generator/lib/src/code_chunks.dart @@ -400,6 +400,8 @@ class CodeChunks { if (entity.properties[index].isRelation) { if (paramDartType.startsWith('ToOne<')) { paramValueCode = 'ToOne(targetId: $paramValueCode)'; + } else if (paramDartType.startsWith('ToOneProxy<')) { + paramValueCode = 'ToOneProxy(targetId: $paramValueCode)'; } else if (paramType == 'optional-named') { log.info('Skipping constructor parameter $paramName on ' "'${entity.name}': the matching field is a relation but the type " @@ -409,6 +411,8 @@ class CodeChunks { } } else if (paramDartType.startsWith('ToMany<')) { paramValueCode = 'ToMany()'; + } else if (paramDartType.startsWith('ToManyProxy<')) { + paramValueCode = 'ToManyProxy()'; } else { // If we can't find a positional param, we can't use the constructor at all. if (paramType == 'positional' || paramType == 'required-named') { @@ -456,7 +460,7 @@ class CodeChunks { if (!p.isRelation) return; if (fieldReaders[index].isNotEmpty) { postLines.add( - 'object.${propertyFieldName(p)}.targetId = ${fieldReaders[index]};'); + 'object.${propertyFieldName(p)}.relation.targetId = ${fieldReaders[index]};'); } postLines.add('object.${propertyFieldName(p)}.attach(store);'); }); diff --git a/generator/lib/src/entity_resolver.dart b/generator/lib/src/entity_resolver.dart index ccacd5e47..60a71accf 100644 --- a/generator/lib/src/entity_resolver.dart +++ b/generator/lib/src/entity_resolver.dart @@ -436,10 +436,11 @@ class EntityResolver extends Builder { bool isRelationField(FieldElement f) => isToOneRelationField(f) || isToManyRelationField(f); - bool isToOneRelationField(FieldElement f) => f.type.element!.name == 'ToOne'; + bool isToOneRelationField(FieldElement f) => + const {'ToOne', 'ToOneProxy'}.contains(f.type.element!.name); bool isToManyRelationField(FieldElement f) => - f.type.element!.name == 'ToMany'; + const {'ToMany', 'ToManyProxy'}.contains(f.type.element!.name); bool isNullable(DartType type) => type.nullabilitySuffix == NullabilitySuffix.star || diff --git a/objectbox/lib/objectbox.dart b/objectbox/lib/objectbox.dart index 0d06cac9c..e13331ca9 100644 --- a/objectbox/lib/objectbox.dart +++ b/objectbox/lib/objectbox.dart @@ -24,8 +24,8 @@ export 'src/query.dart' QueryParamInt, QueryParamBool, QueryParamDouble; -export 'src/relations/to_many.dart' show ToMany; -export 'src/relations/to_one.dart' show ToOne; +export 'src/relations/to_many.dart' show ToMany, ToManyProxy; +export 'src/relations/to_one.dart' show ToOne, ToOneProxy; export 'src/store.dart' show Store, ObservableStore; export 'src/sync.dart' show diff --git a/objectbox/lib/src/modelinfo/entity_definition.dart b/objectbox/lib/src/modelinfo/entity_definition.dart index a3f4be594..cc357bb0e 100644 --- a/objectbox/lib/src/modelinfo/entity_definition.dart +++ b/objectbox/lib/src/modelinfo/entity_definition.dart @@ -17,8 +17,8 @@ class EntityDefinition { final T Function(Store, ByteData) objectFromFB; final int? Function(T) getId; final T Function(T, int) setId; - final List Function(T) toOneRelations; - final Map Function(T) toManyRelations; + final List Function(T) toOneRelations; + final Map Function(T) toManyRelations; const EntityDefinition( {required this.model, diff --git a/objectbox/lib/src/native/box.dart b/objectbox/lib/src/native/box.dart index d11e084c3..32a78dd18 100644 --- a/objectbox/lib/src/native/box.dart +++ b/objectbox/lib/src/native/box.dart @@ -47,6 +47,12 @@ class Box { final _builder = BuilderWithCBuffer(); _AsyncBoxHelper? _async; + // chached put results object -> newObject + final _putCache = _PutCache(); + void _clearPutCache() { + _putCache.clear(); + } + /// Create a box for an Entity. factory Box(Store store) => store.box(); @@ -70,6 +76,8 @@ class Box { /// /// Performance note: consider [putMany] to put several objects at once. int put(T object, {PutMode mode = PutMode.put}) { + _clearPutCache(); + if (_hasRelations) { return InternalStoreAccess.runInTransaction( _store, TxMode.write, (Transaction tx) => _put(object, mode, tx).id); @@ -86,6 +94,8 @@ class Box { /// /// Performance note: consider [putImmutableMany] to put several objects at once. T putImmutable(T object, {PutMode mode = PutMode.put}) { + _clearPutCache(); + if (_hasRelations) { return InternalStoreAccess.runInTransaction(_store, TxMode.write, (Transaction tx) => _put(object, mode, tx).object); @@ -109,8 +119,11 @@ class Box { /// /// See also [putQueued] which doesn't return a [Future] but a pre-allocated /// ID immediately, even though the actual database put operation may fail. - Future putAsync(T object, {PutMode mode = PutMode.put}) async => - Future.sync(() async => (await _putAsync(object, mode: mode)).id); + Future putAsync(T object, {PutMode mode = PutMode.put}) async { + _clearPutCache(); + + return Future.sync(() async => (await _putAsync(object, mode: mode)).id); + } /// Puts the given immutable object in the box (persisting it) asynchronously. /// @@ -127,8 +140,12 @@ class Box { /// /// See also [putImmutableQueued] which doesn't return a [Future] but a pre-allocated /// object immediately, even though the actual database put operation may fail. - Future putImmutableAsync(T object, {PutMode mode = PutMode.put}) async => - Future.sync(() async => (await _putAsync(object, mode: mode)).object); + Future putImmutableAsync(T object, {PutMode mode = PutMode.put}) async { + _clearPutCache(); + + return Future.sync( + () async => (await _putAsync(object, mode: mode)).object); + } Future<_HandlePutResult> _putAsync(T object, {PutMode mode = PutMode.put}) async => @@ -175,8 +192,11 @@ class Box { /// actual database put was successful. /// Use [Store.awaitAsyncCompletion] and [Store.awaitAsyncSubmitted] to wait /// until all operations have finished. - int putQueued(T object, {PutMode mode = PutMode.put}) => - _putQueued(object, mode: mode).id; + int putQueued(T object, {PutMode mode = PutMode.put}) { + _clearPutCache(); + + return _putQueued(object, mode: mode).id; + } /// Schedules the given immutable object to be put later on, by an asynchronous queue. /// @@ -194,8 +214,11 @@ class Box { /// actual database put was successful. /// Use [Store.awaitAsyncCompletion] and [Store.awaitAsyncSubmitted] to wait /// until all operations have finished. - T putImmutableQueued(T object, {PutMode mode = PutMode.put}) => - _putQueued(object, mode: mode).object; + T putImmutableQueued(T object, {PutMode mode = PutMode.put}) { + _clearPutCache(); + + return _putQueued(object, mode: mode).object; + } _HandlePutResult _putQueued(T object, {PutMode mode = PutMode.put}) { if (_hasRelations) { @@ -223,9 +246,19 @@ class Box { if (_hasToOneRelations) { // In this case, there may be relation cycles so get the ID first. if ((_entity.getId(object) ?? 0) == 0) { - final newId = C.box_id_for_put(_cBox, 0); - if (newId == 0) throwLatestNativeError(context: 'id-for-put failed'); - object = _entity.setId(object, newId); + final cached = _putCache.get(object); + if (cached != null) { + object = cached.object; + } else { + final newId = C.box_id_for_put(_cBox, 0); + if (newId == 0) { + throwLatestNativeError(context: 'id-for-put failed'); + } + + final newObject = _entity.setId(object, newId); + _putCache.store(object, _HandlePutResult(newId, newObject)); + object = newObject; + } } _putToOneRelFields(object, mode, tx); } @@ -236,7 +269,9 @@ class Box { _cBox, _builder.bufPtr, _builder.fbb.size(), _getOBXPutMode(mode)); final result = _handlePutObjectResult(object, id, newId); - if (_hasToManyRelations) _putToManyRelFields(result.object, mode, tx!); + if (_hasToManyRelations) { + _putToManyRelFields(result.object, mode, tx!); + } _builder.resetIfLarge(); return result; } @@ -244,17 +279,31 @@ class Box { /// Puts the given [objects] into this Box in a single transaction. /// /// Returns a list of all IDs of the inserted Objects. - List putMany(List objects, {PutMode mode = PutMode.put}) => - _putMany(objects, mode: mode).map((obj) => obj.id).toList(); + List putMany( + List objects, { + PutMode mode = PutMode.put, + }) { + _clearPutCache(); + + return _putMany(objects, mode: mode).map((obj) => obj.id).toList(); + } /// Puts the given List of immutable entities [objects] into this Box in a single transaction. /// /// Returns a list of all inserted Objects. - List putImmutableMany(List objects, {PutMode mode = PutMode.put}) => - _putMany(objects, mode: mode).map((obj) => obj.object).toList(); + List putImmutableMany( + List objects, { + PutMode mode = PutMode.put, + }) { + _clearPutCache(); + + return _putMany(objects, mode: mode).map((obj) => obj.object).toList(); + } - List<_HandlePutResult> _putMany(List objects, - {PutMode mode = PutMode.put}) { + List<_HandlePutResult> _putMany( + List objects, { + PutMode mode = PutMode.put, + }) { if (objects.isEmpty) return []; final putResults = <_HandlePutResult>[]; @@ -269,8 +318,7 @@ class Box { final cursor = tx.cursor(_entity); final cMode = _getOBXPutMode(mode); - for (var i = 0; i < objects.length; i++) { - final object = objects[i]; + for (final object in objects) { _builder.fbb.reset(); final id = _entity.objectToFB(object, _builder.fbb); final newId = C.cursor_put_object4( @@ -280,8 +328,8 @@ class Box { } if (_hasToManyRelations) { - for (final object in objects) { - _putToManyRelFields(object, mode, tx); + for (final result in putResults) { + _putToManyRelFields(result.object, mode, tx); } } _builder.resetIfLarge(); @@ -423,23 +471,41 @@ class Box { } } - void _putToOneRelFields(T object, PutMode mode, Transaction tx) { - _entity.toOneRelations(object).forEach((ToOne rel) { + void _putToOneRelFields( + T object, + PutMode mode, + Transaction tx, + ) { + _entity.toOneRelations(object).forEach((ToOneRelationProvider rel) { if (!rel.hasValue) return; rel.attach(_store); // put new objects if (rel.targetId == 0) { - rel.targetId = - InternalToOneAccess.targetBox(rel)._put(rel.target, mode, tx).id; + late final _HandlePutResult result; + + // check if putCache already contains same objects _putResult (immutability) + final cached = _putCache.getDynamic(rel.target); + if (cached != null) { + result = cached; + } else { + result = InternalToOneAccess.targetBox(rel.relation) + ._put(rel.target, mode, tx); + // cache _put result + _putCache.storeDynamic(rel.target, result); + } + rel.targetId = result.id; + rel.target = result.object; } }); } void _putToManyRelFields(T object, PutMode mode, Transaction tx) { - _entity.toManyRelations(object).forEach((RelInfo info, ToMany rel) { + _entity + .toManyRelations(object) + .forEach((RelInfo info, ToManyRelationProvider rel) { // Always set relation info so ToMany applyToDb can be used after initial put - InternalToManyAccess.setRelInfo(rel, _store, info, this); - if (InternalToManyAccess.hasPendingDbChanges(rel)) { + InternalToManyAccess.setRelInfo(rel.relation, _store, info, this); + if (InternalToManyAccess.hasPendingDbChanges(rel.relation)) { rel.applyToDb(mode: mode, tx: tx); } }); @@ -520,9 +586,17 @@ class InternalBoxAccess { /// Put the object in a given transaction. @pragma('vm:prefer-inline') - static int put( + static _HandlePutResult put( Box box, EntityT object, PutMode mode, Transaction? tx) => - box._put(object, mode, tx).id; + box._put(object, mode, tx); + + /// Put the object in a given transaction. + @pragma('vm:prefer-inline') + static EntityT putClearCache( + Box box, EntityT object, PutMode mode) { + box._clearPutCache(); + return box.putImmutable(object, mode: mode); + } /// Put a standalone relation. @pragma('vm:prefer-inline') @@ -595,3 +669,43 @@ class _HandlePutResult { final T object; const _HandlePutResult(this.id, this.object); } + +// TODO use this type on SDK >= 2.15 +//typedef _PutCache = Map>>; + +class _PutCache { + static final _instance = _PutCache._(); + factory _PutCache() => _instance; + _PutCache._(); + + final Map>> _cache = {}; + + _HandlePutResult? get(T object) => + _cache[T]?[object] as _HandlePutResult?; + + _HandlePutResult? getDynamic(dynamic object) { + final type = object.runtimeType; + return _cache[type]?[object]; + } + + void store(T object, _HandlePutResult result) { + if (object == result.object) { + return; + } + _cache[T] ??= >{}; + _cache[T]![object] = result; + } + + void storeDynamic(dynamic object, _HandlePutResult result) { + if (object == result.object) { + return; + } + final type = object.runtimeType; + _cache[type] ??= >{}; + _cache[type]![object] = result; + } + + void clear() { + _cache.clear(); + } +} diff --git a/objectbox/lib/src/relations/info.dart b/objectbox/lib/src/relations/info.dart index ac090eb8e..baf21831d 100644 --- a/objectbox/lib/src/relations/info.dart +++ b/objectbox/lib/src/relations/info.dart @@ -23,7 +23,7 @@ class RelInfo { final int _id; // only for backlinks: - final ToOne Function(SourceEntityT)? _getToOneSourceField; + final ToOneRelationProvider Function(SourceEntityT)? _getToOneSourceField; const RelInfo._( this._type, this._id, this._objectId, this._getToOneSourceField); @@ -33,8 +33,8 @@ class RelInfo { : this._(RelType.toMany, id, objectId, null); /// Create info for a [ToOne] relation field backlink. - const RelInfo.toOneBacklink( - int id, int objectId, ToOne Function(SourceEntityT) srcFieldAccessor) + const RelInfo.toOneBacklink(int id, int objectId, + ToOneRelationProvider Function(SourceEntityT) srcFieldAccessor) : this._(RelType.toOneBacklink, id, objectId, srcFieldAccessor); /// Create info for a [ToMany] relation field backlink. @@ -51,5 +51,6 @@ class RelInfo { RelType get type => _type; /// Source field associated with this toOne relation backlink. - ToOne toOneSourceField(SourceEntityT object) => _getToOneSourceField!(object); + ToOneRelationProvider toOneSourceField(SourceEntityT object) => + _getToOneSourceField!(object); } diff --git a/objectbox/lib/src/relations/to_many.dart b/objectbox/lib/src/relations/to_many.dart index b0e2e4f1c..95080380c 100644 --- a/objectbox/lib/src/relations/to_many.dart +++ b/objectbox/lib/src/relations/to_many.dart @@ -8,6 +8,23 @@ import '../store.dart'; import '../transaction.dart'; import 'info.dart'; +/// ToManyRelationProvider interface to use ToMany or ToManyProxy in same context +abstract class ToManyRelationProvider { + /// Provider must implements relation getter + ToMany get relation; + + /// Save changes made to this ToMany relation to the database. Alternatively, + /// you can call box.put(object) or box.putImmutable, its relations are automatically saved. + /// + /// If this collection contains new objects (with zero IDs), applyToDb() + /// will put them on-the-fly. For this to work the source object (the object + /// owing this ToMany) must be already stored because its ID is required. + void applyToDb({ + PutMode mode = PutMode.put, + Transaction? tx, + }); +} + /// Manages a to-many relation, an unidirectional link from a "source" entity to /// multiple objects of a "target" entity. /// @@ -42,7 +59,9 @@ import 'info.dart'; /// student.teachers.removeAt(index) /// student.teachers.applyToDb(); // or store.box().put(student); /// ``` -class ToMany extends Object with ListMixin { +class ToMany extends Object + with ListMixin + implements ToManyRelationProvider { bool _attached = false; late final Store _store; @@ -109,7 +128,7 @@ class ToMany extends Object with ListMixin { void add(EntityT element) { ArgumentError.checkNotNull(element, 'element'); _track(element, 1); - if (__items == null) { + if (!_itemsLoaded) { // We don't need to load old data from DB to add new items. _addedBeforeLoad.add(element); } else { @@ -120,7 +139,7 @@ class ToMany extends Object with ListMixin { @override void addAll(Iterable iterable) { iterable.forEach(_track); - if (__items == null) { + if (!_itemsLoaded) { // We don't need to load old data from DB to add new items. _addedBeforeLoad.addAll(iterable); } else { @@ -153,13 +172,28 @@ class ToMany extends Object with ListMixin { /// True if there are any changes not yet saved in DB. bool get _hasPendingDbChanges => _counts.values.any((c) => c != 0); + void _updateObjectInItems(EntityT from, EntityT to) { + if (from == to || !_itemsLoaded) { + return; + } + + final idx = _items.indexOf(from); + if (idx > -1) { + _items[idx] = to; + } + } + /// Save changes made to this ToMany relation to the database. Alternatively, /// you can call box.put(object), its relations are automatically saved. /// /// If this collection contains new objects (with zero IDs), applyToDb() /// will put them on-the-fly. For this to work the source object (the object /// owing this ToMany) must be already stored because its ID is required. - void applyToDb({PutMode mode = PutMode.put, Transaction? tx}) { + @override + void applyToDb({ + PutMode mode = PutMode.put, + Transaction? tx, + }) { if (!_hasPendingDbChanges) return; _verifyAttached(); @@ -184,7 +218,11 @@ class ToMany extends Object with ListMixin { switch (_rel!.type) { case RelType.toMany: if (add) { - if (id == 0) id = InternalBoxAccess.put(_box, object, mode, tx); + if (id == 0) { + final result = InternalBoxAccess.put(_box, object, mode, tx); + _updateObjectInItems(object, result.object); + id = result.id; + } InternalBoxAccess.relPut(_otherBox, _rel!.id, _rel!.objectId, id); } else { if (id == 0) return; @@ -195,11 +233,18 @@ class ToMany extends Object with ListMixin { case RelType.toOneBacklink: final srcField = _rel!.toOneSourceField(object); srcField.targetId = add ? _rel!.objectId : null; - _box.put(object, mode: mode); + + final result = InternalBoxAccess.putClearCache(_box, object, mode); + _updateObjectInItems(object, result); + break; case RelType.toManyBacklink: if (add) { - if (id == 0) id = InternalBoxAccess.put(_box, object, mode, tx); + if (id == 0) { + final result = InternalBoxAccess.put(_box, object, mode, tx); + _updateObjectInItems(object, result.object); + id = result.id; + } InternalBoxAccess.relPut(_box, _rel!.id, id, _rel!.objectId); } else { if (id == 0) return; @@ -238,6 +283,7 @@ class ToMany extends Object with ListMixin { } List get _items => __items ??= _loadItems(); + bool get _itemsLoaded => __items != null; List _loadItems() { if (_rel == null) { @@ -261,6 +307,78 @@ class ToMany extends Object with ListMixin { "Don't call applyToDb() on new objects, use box.put() instead."); } } + + /// [ToManyRelationProvider] interface implementtation + @override + ToMany get relation => this; +} + +/// Proxy ToMany relation between immutable entities +class ToManyProxy implements ToManyRelationProvider { + late ToMany _sharedRelation; + + /// [ToManyRelationProvider] interface implementtation + @override + ToMany get relation => _sharedRelation; + + /// [ToManyRelationProvider] interface implementtation + @override + void applyToDb({PutMode mode = PutMode.put, Transaction? tx}) { + relation.applyToDb(mode: mode, tx: tx); + } + + /// Clone relation link from another proxy + void cloneFrom(ToManyRelationProvider other) { + _sharedRelation = other.relation; + } + + /// [ListMixin] proxy to underlying [ToMany] relation + int get length => relation.length; + + /// [ListMixin] proxy to underlying [ToMany] relation + EntityT operator [](int index) => relation[index]; + + /// [ListMixin] proxy to underlying [ToMany] relation + void operator []=(int index, EntityT element) { + relation[index] = element; + } + + /// [ListMixin] proxy to underlying [ToMany] relation + void add(EntityT element) { + relation.add(element); + } + + /// [ListMixin] proxy to underlying [ToMany] relation + void addAll(Iterable iterable) { + relation.addAll(iterable); + } + + /// [ListMixin] proxy to underlying [ToMany] relation + Iterable map(T Function(EntityT) f) => relation.map(f); + + /// [ListMixin] proxy to underlying [ToMany] relation + bool remove(Object? element) => relation.remove(element); + + /// [ListMixin] proxy to underlying [ToMany] relation + void removeWhere(bool Function(EntityT) test) { + relation.removeWhere(test); + } + + /// [ListMixin] proxy to underlying [ToMany] relation + void clear() { + relation.clear(); + } + + /// Create a ToManyProxy for shared ToMany relationship between immutable entities. + /// + /// Normally, you don't assign items in the constructor but rather use this + /// class as a lazy-loaded/saved list. The option to assign in the constructor + /// is useful to initialize objects from an external source, e.g. from JSON. + /// Setting the items in the constructor bypasses the lazy loading, ignoring + /// any relations that are currently stored in the DB for the source object. + ToManyProxy({List? items}) { + _sharedRelation = ToMany(items: items); + } } /// Internal only. @@ -270,26 +388,27 @@ class InternalToManyAccess { static bool hasPendingDbChanges(ToMany toMany) => toMany._hasPendingDbChanges; /// Set relation info. - static void setRelInfo(ToMany toMany, Store store, RelInfo rel, Box srcBox) => - toMany._setRelInfo(store, rel, srcBox); + static void setRelInfo(ToManyRelationProvider toMany, Store store, + RelInfo rel, Box srcBox) => + toMany.relation._setRelInfo(store, rel, srcBox); } /// Internal only. @internal @visibleForTesting class InternalToManyTestAccess { - final ToMany _rel; + final ToManyRelationProvider _rel; /// Used in tests. - bool get itemsLoaded => _rel.__items != null; + bool get itemsLoaded => _rel.relation._itemsLoaded; /// Used in tests. - List get items => _rel._items; + List get items => _rel.relation._items; /// Used in tests. Set get added { final result = {}; - _rel._counts.forEach((EntityT object, count) { + _rel.relation._counts.forEach((EntityT object, count) { if (count > 0) result.add(object); }); return result; @@ -298,7 +417,7 @@ class InternalToManyTestAccess { /// Used in tests. Set get removed { final result = {}; - _rel._counts.forEach((EntityT object, count) { + _rel.relation._counts.forEach((EntityT object, count) { if (count < 0) result.add(object); }); return result; diff --git a/objectbox/lib/src/relations/to_one.dart b/objectbox/lib/src/relations/to_one.dart index d3755e72a..4f10e01a1 100644 --- a/objectbox/lib/src/relations/to_one.dart +++ b/objectbox/lib/src/relations/to_one.dart @@ -4,6 +4,37 @@ import '../box.dart'; import '../modelinfo/entity_definition.dart'; import '../store.dart'; +/// ToOneRelationProvider interface to use ToOne or ToOneProxy in same context +abstract class ToOneRelationProvider { + /// Provider must implements relation getter + ToOne get relation; + + /// Get target object. If it's the first access, this reads from DB. + EntityT? get target; + + /// Set relation target object. Note: this does not store the change yet, use + /// [Box.put()] on the containing (relation source) object. + set target(EntityT? object); + + /// Get ID of a relation target object. + int get targetId; + + /// Set ID of a relation target object. Note: this does not store the change + /// yet, use [Box.put()] on the containing (relation source) object. + set targetId(int? id); + + /// Whether the relation field has a value stored. Otherwise it's null. + bool get hasValue; + + /// Initialize the relation field, attaching it to the store. + /// + /// [Box.put()] calls this automatically. You only need to call this manually + /// on new objects after you've set [target] and want to read [targetId], + /// which is a very unusual operation because you've just assigned the + /// [target] so you should know it's ID. + void attach(Store store); +} + /// Manages a to-one relation, an unidirectional link from a "source" entity to /// a "target" entity. The target object is referenced by its ID, which is /// persisted in the source object. @@ -45,7 +76,7 @@ import '../store.dart'; /// // ... or ... /// order.customer.targetId = 0 /// ``` -class ToOne { +class ToOne implements ToOneRelationProvider { bool _attached = false; late final Store _store; @@ -76,6 +107,7 @@ class ToOne { } /// Get target object. If it's the first access, this reads from DB. + @override EntityT? get target { if (_value._state == _ToOneState.lazy) { _verifyAttached(); @@ -89,6 +121,7 @@ class ToOne { /// Set relation target object. Note: this does not store the change yet, use /// [Box.put()] on the containing (relation source) object. + @override set target(EntityT? object) { if (object == null) { _value = _ToOneValue.none(); @@ -103,6 +136,7 @@ class ToOne { } /// Get ID of a relation target object. + @override int get targetId { if (_value._state == _ToOneState.unknown) { // If the target was previously set while not attached, the ID is unknown. @@ -120,6 +154,7 @@ class ToOne { /// Set ID of a relation target object. Note: this does not store the change /// yet, use [Box.put()] on the containing (relation source) object. + @override set targetId(int? id) { id ??= 0; if (id == 0) { @@ -137,6 +172,7 @@ class ToOne { } /// Whether the relation field has a value stored. Otherwise it's null. + @override bool get hasValue => _value._state != _ToOneState.none; /// Initialize the relation field, attaching it to the store. @@ -145,6 +181,7 @@ class ToOne { /// on new objects after you've set [target] and want to read [targetId], /// which is a very unusual operation because you've just assigned the /// [target] so you should know it's ID. + @override void attach(Store store) { if (_attached) { if (_store != store) { @@ -170,6 +207,61 @@ class ToOne { _verifyAttached(); return _entity.getId(object) ?? 0; } + + /// [ToOneRelationProvider] interface implementtation + @override + ToOne get relation => this; +} + +/// Proxy ToOne relation between immutable entities +class ToOneProxy implements ToOneRelationProvider { + ToOne _sharedRelation = ToOne(); + + /// [ToOneRelationProvider] interface implementtation + @override + ToOne get relation => _sharedRelation; + + /// Clone relation link from another proxy + void cloneFrom(ToOneRelationProvider other) { + _sharedRelation = other.relation; + } + + /// Get target object. If it's the first access, this reads from DB. + @override + EntityT? get target => relation.target; + + /// Set relation target object. Note: this does not store the change yet, use + /// [Box.put()] on the containing (relation source) object. + @override + set target(EntityT? object) { + relation.target = object; + } + + /// Get ID of a relation target object. + @override + int get targetId => relation.targetId; + + /// Set ID of a relation target object. Note: this does not store the change + /// yet, use [Box.put()] on the containing (relation source) object. + @override + set targetId(int? id) { + relation.targetId = id; + } + + /// Whether the relation field has a value stored. Otherwise it's null. + @override + bool get hasValue => relation.hasValue; + + /// Initialize the relation field, attaching it to the store. + /// + /// [Box.put()] calls this automatically. You only need to call this manually + /// on new objects after you've set [target] and want to read [targetId], + /// which is a very unusual operation because you've just assigned the + /// [target] so you should know it's ID. + @override + void attach(Store store) { + relation.attach(store); + } } enum _ToOneState { none, unstored, unknown, lazy, stored, unresolvable } diff --git a/objectbox/test/entity_immutable.dart b/objectbox/test/entity_immutable.dart index 6216f140b..0a6e3643f 100644 --- a/objectbox/test/entity_immutable.dart +++ b/objectbox/test/entity_immutable.dart @@ -18,7 +18,7 @@ class TestEntityImmutable { payload: payload ?? this.payload, ); - const TestEntityImmutable({ + TestEntityImmutable({ this.id, required this.unique, required this.payload, @@ -27,3 +27,114 @@ class TestEntityImmutable { TestEntityImmutable copyWithId(int newId) => (id != newId) ? copyWith(id: newId) : this; } + +@Entity() +class TestEntityImmutableRel { + @Id(useCopyWith: true) + final int? id; + + final String? tString; + + TestEntityImmutableRel copyWith({int? id, String? tString}) => + TestEntityImmutableRel( + id: id, + tString: tString ?? this.tString, + ) + ..relA.cloneFrom(relA) + ..relB.cloneFrom(relB) + ..relManyA.cloneFrom(relManyA); + + TestEntityImmutableRel({ + this.id, + required this.tString, + }); + + TestEntityImmutableRel copyWithId(int newId) => + (id != newId) ? copyWith(id: newId) : this; + + final relA = ToOneProxy(); + final relB = ToOneProxy(); + + final relManyA = ToManyProxy(); +} + +@Entity() +class RelatedImmutableEntityA { + @Id(useCopyWith: true) + final int? id; + + final int? tInt; + final bool? tBool; + final relB = ToOneProxy(); + + @Backlink('relManyA') + final testEntities = ToManyProxy(); + + RelatedImmutableEntityA({this.id, this.tInt, this.tBool}); + RelatedImmutableEntityA copyWithId(int newId) { + if (newId == id) { + return this; + } + return RelatedImmutableEntityA( + id: newId, + tInt: tInt, + tBool: tBool, + ) + ..relB.cloneFrom(relB) + ..testEntities.cloneFrom(testEntities); + } +} + +@Entity() +class RelatedImmutableEntityB { + @Id(useCopyWith: true) + final int? id; + + final String? tString; + final double? tDouble; + final relA = ToOneProxy(); + final relB = ToOneProxy(); + + @Backlink() + final testEntities = ToManyProxy(); + + RelatedImmutableEntityB({this.id, this.tString, this.tDouble}); + RelatedImmutableEntityB copyWithId(int newId) { + if (newId == id) { + return this; + } + return RelatedImmutableEntityB( + id: newId, + tString: tString, + tDouble: tDouble, + ) + ..relA.cloneFrom(relA) + ..relB.cloneFrom(relB) + ..testEntities.cloneFrom(testEntities); + } +} + +@Entity() +class TreeNodeImmutable { + @Id(useCopyWith: true) + final int id; + + final String path; // just to help debugging, not used anywhere + + final parent = ToOneProxy(); + + @Backlink() + final children = ToManyProxy(); + + TreeNodeImmutable(this.path, {this.id = 0}); + + TreeNodeImmutable copyWithId(int newId) { + if (id == newId) { + return this; + } + + return TreeNodeImmutable(path, id: newId) + ..parent.cloneFrom(parent) + ..children.cloneFrom(children); + } +} diff --git a/objectbox/test/immutable_relations_test.dart b/objectbox/test/immutable_relations_test.dart new file mode 100644 index 000000000..8446716e5 --- /dev/null +++ b/objectbox/test/immutable_relations_test.dart @@ -0,0 +1,464 @@ +import 'package:objectbox/src/relations/to_many.dart'; +import 'package:test/test.dart'; + +import 'entity_immutable.dart'; +import 'objectbox.g.dart'; +import 'test_env.dart'; + +void main() { + late TestEnv env; + + setUp(() { + env = TestEnv('immutable_relations'); + }); + + tearDown(() => env.closeAndDelete()); + + group('ToOne', () { + test('put', () { + final Box box = env.store.box(); + + var src = TestEntityImmutableRel(tString: 'Hello'); + expect(src.relA.hasValue, isFalse); + expect(src.relA.target, isNull); + src.relA.target = RelatedImmutableEntityA(tInt: 42); + expect(src.relA.hasValue, isTrue); + expect(src.relA.target, isNotNull); + expect(src.relA.target!.tInt, 42); + + // Can't access targetId on new objects (not coming from box) unless + // attached manually. + expect( + () => src.relA.targetId, + throwsA(predicate( + (StateError e) => e.toString().contains('call attach(')))); + src.relA.attach(env.store); + expect(src.relA.targetId, isZero); + + src.relA.target!.relB.target = RelatedImmutableEntityB(tString: 'B1'); + + // use the same target on two relations - must insert only once + src.relB.target = src.relA.target!.relB.target; + + src = box.putImmutable(src); + + var read = box.get(1)!; + expect(read.tString, equals(src.tString)); + expect(read.relA.hasValue, isTrue); + expect(read.relA.targetId, 1); + var readRelA = read.relA; + expect(readRelA.target!.tInt, 42); + var readRelARelB = readRelA.target!.relB; + expect(readRelARelB.target!.tString, equals('B1')); + + // it's the same DB object ID but different instances (read twice) + expect(read.relB.targetId, equals(readRelARelB.targetId)); + expect(read.relB.target, isNot(equals(readRelARelB.target))); + + // attach an existing item + var readRelARelBRelA = readRelARelB.target!.relA; + expect(readRelARelBRelA.hasValue, isFalse); + readRelARelBRelA.target = readRelA.target; + expect(readRelARelBRelA.hasValue, isTrue); + expect(readRelARelBRelA.targetId, readRelA.targetId); + env.store + .box() + .putImmutable(readRelARelB.target!); + + read = box.get(1)!; + readRelA = read.relA; + readRelARelB = readRelA.target!.relB; + readRelARelBRelA = readRelARelB.target!.relA; + expect(readRelARelBRelA.targetId, readRelA.targetId); + + // remove a relation, using [targetId] + readRelARelB.targetId = 0; + env.store.box().putImmutable(readRelA.target!); + read = box.get(1)!; + readRelA = read.relA; + readRelARelB = readRelA.target!.relB; + expect(readRelARelB.target, isNull); + expect(readRelARelB.targetId, isZero); + + // remove a relation, using [target] + read.relA.target = null; + box.putImmutable(read); + read = box.get(1)!; + expect(read.relA.target, isNull); + expect(read.relA.targetId, isZero); + }); + + test('putMany & simple ID query', () { + final Box box = env.store.box(); + final src = TestEntityImmutableRel(tString: 'Hello'); + src.relA.target = RelatedImmutableEntityA(tInt: 42); + box.putImmutableMany([src, TestEntityImmutableRel(tString: 'there')]); + expect(src.relA.targetId, 1); + + var query = box.query(TestEntityImmutableRel_.relA.equals(1)).build(); + expect(query.count(), 1); + var read = query.find()[0]; + expect(read.tString, equals('Hello')); + expect(read.relA.targetId, equals(1)); + query.close(); + + query = box.query(TestEntityImmutableRel_.relA.equals(0)).build(); + expect(query.count(), 1); + read = query.find()[0]; + expect(read.tString, equals('there')); + expect(read.relA.targetId, equals(0)); + query.close(); + }); + + test('query link', () { + final box = env.store.box(); + final src1 = TestEntityImmutableRel(tString: 'foo'); + src1.relA.target = RelatedImmutableEntityA(tInt: 5); + final src2 = TestEntityImmutableRel(tString: 'bar'); + src2.relA.target = RelatedImmutableEntityA(tInt: 10); + src2.relA.target!.relB.target = RelatedImmutableEntityB(tString: 'deep'); + box.putImmutableMany([src1, src2]); + + { + final qb = box.query(); + qb.link(TestEntityImmutableRel_.relA, + RelatedImmutableEntityA_.tInt.equals(10)); + final query = qb.build(); + final found = query.find(); + expect(found.length, 1); + expect(found[0].tString, 'bar'); + query.close(); + } + + { + final qb = box.query(); + qb.link(TestEntityImmutableRel_.relA).link( + RelatedImmutableEntityA_.relB, + RelatedImmutableEntityB_.tString.equals('deep')); + final query = qb.build(); + final found = query.find(); + expect(found.length, 1); + expect(found[0].tString, 'bar'); + query.close(); + } + }); + }); + + group('ToMany', () { + TestEntityImmutableRel? src; + setUp(() { + src = TestEntityImmutableRel(tString: 'Hello'); + }); + + test('put', () { + final box = env.store.box(); + expect(src!.relManyA, isNotNull); + // Add three + src!.relManyA.add(RelatedImmutableEntityA(tInt: 1)); + src!.relManyA.addAll([ + RelatedImmutableEntityA(tInt: 2), + src!.relManyA[0], + RelatedImmutableEntityA(tInt: 3) + ]); + box.putImmutable(src!); + + src = box.get(1); + check(src!.relManyA.relation, items: [1, 2, 3], added: [], removed: []); + + // Remove one + src!.relManyA.relation.removeWhere((e) => e.tInt == 2); + check(src!.relManyA.relation, items: [1, 3], added: [], removed: [2]); + box.putImmutable(src!); + + src = box.get(1); + check(src!.relManyA.relation, items: [1, 3], added: [], removed: []); + + // Add existing again, add new one + src!.relManyA.add(src!.relManyA[0]); + src!.relManyA.add(RelatedImmutableEntityA(tInt: 4)); + check(src!.relManyA.relation, + items: [1, 1, 3, 4], added: [1, 4], removed: []); + box.putImmutable(src!); + + src = box.get(1); + check(src!.relManyA.relation, items: [1, 3, 4], added: [], removed: []); + + // Remove one, add one + src!.relManyA.relation.removeWhere((element) => element.tInt == 3); + src!.relManyA.add(RelatedImmutableEntityA(tInt: 5)); + check(src!.relManyA.relation, items: [1, 4, 5], added: [5], removed: [3]); + box.putImmutable(src!); + src = box.get(1); + check(src!.relManyA.relation, items: [1, 4, 5], added: [], removed: []); + + // Remove all + src!.relManyA.clear(); + check(src!.relManyA.relation, items: [], added: [], removed: [1, 4, 5]); + box.putImmutable(src!); + src = box.get(1); + check(src!.relManyA.relation, items: [], added: [], removed: []); + }); + + test('applyToDb', () { + final box = env.store.box(); + final entity = src!; + expect(entity.relManyA, isNotNull); + + // Put with empty ToMany + box.putImmutable(entity); + check(entity.relManyA.relation, items: [], added: [], removed: []); + + // Add one + entity.relManyA.add(RelatedImmutableEntityA(tInt: 1)); + entity.relManyA.applyToDb(); + check(entity.relManyA.relation, items: [1], added: [], removed: []); + + // Remove all + entity.relManyA.clear(); + entity.relManyA.applyToDb(); + check(entity.relManyA.relation, items: [], added: [], removed: []); + }); + + test('applyToDb not attached throws', () { + final entity = src!; + expect(entity.relManyA, isNotNull); + + entity.relManyA.add(RelatedImmutableEntityA(tInt: 1)); + expect( + entity.relManyA.applyToDb, + throwsA(predicate((StateError e) => e.toString().contains( + "ToMany relation field not initialized. Don't call applyToDb() on new objects, use box.put() instead.")))); + }); + + test("don't load old data when just adding", () { + final box = env.store.box(); + expect(src!.relManyA, isNotNull); + src!.relManyA.add(RelatedImmutableEntityA(tInt: 1)); + src!.relManyA.addAll([ + RelatedImmutableEntityA(tInt: 2), + src!.relManyA[0], + RelatedImmutableEntityA(tInt: 3) + ]); + box.putImmutable(src!); + + src = box.get(1); + check(src!.relManyA.relation, items: [1, 2, 3], added: [], removed: []); + expect(InternalToManyTestAccess(src!.relManyA).itemsLoaded, isTrue); + + src = box.get(1); + expect(InternalToManyTestAccess(src!.relManyA).itemsLoaded, isFalse); + final rel = RelatedImmutableEntityA(tInt: 4); + src!.relManyA.add(rel); + src!.relManyA.addAll([RelatedImmutableEntityA(tInt: 5), rel]); + expect(InternalToManyTestAccess(src!.relManyA).itemsLoaded, isFalse); + src = box.putImmutable(src!); + expect(InternalToManyTestAccess(src!.relManyA).itemsLoaded, isFalse); + src = box.get(1); + check(src!.relManyA.relation, + items: [1, 2, 3, 4, 5], added: [], removed: []); + }); + }); + + group('to-one backlink', () { + late Box boxB; + late Box box; + setUp(() { + boxB = env.store.box(); + box = env.store.box(); + box.putImmutable(TestEntityImmutableRel(tString: 'foo') + ..relB.target = RelatedImmutableEntityB(tString: 'foo B')); + box.putImmutable(TestEntityImmutableRel(tString: 'bar') + ..relB.target = RelatedImmutableEntityB(tString: 'bar B')); + box.putImmutable( + TestEntityImmutableRel(tString: 'bar2')..relB.targetId = 2); + + boxB.putImmutable(RelatedImmutableEntityB(tString: 'not referenced')); + }); + + test('put and get', () { + final List b = boxB.getAll(); + expect(b[0]!.id, 1); + expect(b[0]!.tString, 'foo B'); + expect(b[1]!.id, 2); + expect(b[1]!.tString, 'bar B'); + expect(b[2]!.id, 3); + expect(b[2]!.tString, 'not referenced'); + + final strings = (TestEntityImmutableRel? e) => e!.tString; + expect(b[0]!.testEntities.map(strings), sameAsList(['foo'])); + expect(b[1]!.testEntities.map(strings), sameAsList(['bar', 'bar2'])); + expect(b[2]!.testEntities.length, isZero); + + // Update an existing target. + b[1]!.testEntities.add(box.get(1)!); // foo + expect( + b[1]!.testEntities.map(strings), sameAsList(['foo', 'bar', 'bar2'])); + b[1]!.testEntities.removeWhere((e) => e.tString == 'bar'); + expect(b[1]!.testEntities.map(strings), sameAsList(['foo', 'bar2'])); + boxB.putImmutable(b[1]!); + b[1] = boxB.get(b[1]!.id!); + expect(b[1]!.testEntities.map(strings), sameAsList(['foo', 'bar2'])); + + // Insert a new target, already with some "source" entities pointing to it. + var newB = RelatedImmutableEntityB(); + expect(newB.testEntities.length, isZero); + newB.testEntities.add(box.get(1)!); // foo + newB.testEntities + .add(TestEntityImmutableRel(tString: 'newly created from B')); + newB = boxB.putImmutable(newB); + expect(newB.testEntities[0].id, 1); + expect(newB.testEntities[1].id, 4); + + expect(box.get(4)!.tString, equals('newly created from B')); + newB = boxB.get(newB.id!)!; + expect(newB.testEntities.map(strings), + sameAsList(['foo', 'newly created from B'])); + + // The previous put also affects b[1], 'foo' is not related anymore. + b[1] = boxB.get(b[1]!.id!); + expect(b[1]!.testEntities.map(strings), sameAsList(['bar2'])); + }); + + test('put on ToMany side before loading', () { + // Test [ToMany._addedBeforeLoad] field - there was previously an issue + // causing the backlinked item to be shown twice in ToMany. + var b = RelatedImmutableEntityB(); + b.testEntities.add(TestEntityImmutableRel(tString: 'Foo')); + b = boxB.putImmutable(b); + expect(b.testEntities.length, 1); + }); + + test('query', () { + final qb = boxB.query(); + qb.backlink( + TestEntityImmutableRel_.relB, + TestEntityImmutableRel_.tString.startsWith('bar'), + ); + final query = qb.build(); + final b = query.find(); + expect(b.length, 1); + expect(b.first.tString, 'bar B'); + query.close(); + }); + }); + + group('to-many backlink', () { + late Box boxA; + late Box box; + setUp(() { + boxA = env.store.box(); + box = env.store.box(); + box.putImmutable(TestEntityImmutableRel(tString: 'foo') + ..relManyA.add(RelatedImmutableEntityA(tInt: 1))); + box.putImmutable(TestEntityImmutableRel(tString: 'bar') + ..relManyA.add(RelatedImmutableEntityA(tInt: 2))); + box.putImmutable( + TestEntityImmutableRel(tString: 'bar2')..relManyA.add(boxA.get(2)!)); + + boxA.putImmutable(RelatedImmutableEntityA(tInt: 3)); // not referenced + }); + + test('put and get', () { + final a = boxA.getAll(); + expect(a[0].id, 1); + expect(a[0].tInt, 1); + expect(a[1].id, 2); + expect(a[1].tInt, 2); + expect(a[2].id, 3); + expect(a[2].tInt, 3); + + final strings = (TestEntityImmutableRel? e) => e!.tString; + expect(a[0].testEntities.map(strings), sameAsList(['foo'])); + expect(a[1].testEntities.map(strings), sameAsList(['bar', 'bar2'])); + expect(a[2].testEntities.length, isZero); + + // Update an existing target. + a[1].testEntities.add(box.get(1)!); // foo + expect( + a[1].testEntities.map(strings), sameAsList(['foo', 'bar', 'bar2'])); + a[1].testEntities.removeWhere((e) => e.tString == 'bar'); + expect(a[1].testEntities.map(strings), sameAsList(['foo', 'bar2'])); + a[1] = boxA.putImmutable(a[1]); + a[1] = boxA.get(a[1].id!)!; + expect(a[1].testEntities.map(strings), sameAsList(['foo', 'bar2'])); + + // Insert a new target with some "source" entities pointing to it. + var newA = RelatedImmutableEntityA(tInt: 4); + expect(newA.testEntities.length, isZero); + newA.testEntities.add(box.get(1)!); // foo + newA.testEntities + .add(TestEntityImmutableRel(tString: 'newly created from A')); + newA = boxA.putImmutable(newA); + expect(newA.testEntities[0].id, 1); + expect(newA.testEntities[1].id, 4); + + expect(box.get(4)!.tString, equals('newly created from A')); + newA = boxA.get(newA.id!)!; + expect(newA.testEntities.map(strings), + sameAsList(['foo', 'newly created from A'])); + + // The previous put also affects TestEntityImmutableRel(foo) - added target (tInt=4). + expect(box.get(1)!.relManyA.map(toInt), sameAsList([1, 2, 4])); + }); + + test('query', () { + final qb = boxA.query(); + qb.backlinkMany(TestEntityImmutableRel_.relManyA, + TestEntityImmutableRel_.tString.startsWith('bar')); + final query = qb.build(); + final a = query.find(); + expect(a.length, 1); + expect(a.first.tInt, 2); + query.close(); + }); + }); + + test('trees', () { + final box = env.store.box(); + var root = TreeNodeImmutable('R'); + root.children.addAll([TreeNodeImmutable('R.1'), TreeNodeImmutable('R.2')]); + root.children[1].children.add(TreeNodeImmutable('R.2.1')); + root = box.putImmutable(root); + expect(box.count(), 4); + final read = box.get(1)!; + root.expectSameAs(read); + }); + + test('cycles', () { + var a = RelatedImmutableEntityA(); + var b = RelatedImmutableEntityB(); + a.relB.target = b; + b.relA.target = a; + a = env.store.box().putImmutable(a); + b = a.relB.target!; + + final readB = env.store.box().get(b.id!)!; + expect(a.relB.targetId, readB.id!); + expect(readB.relA.target!.id, a.id); + }); +} + +int toInt(dynamic e) => e.tInt as int; + +void check(ToMany rel, + {required List items, + required List added, + required List removed}) { + final relT = InternalToManyTestAccess(rel); + expect(relT.items.map(toInt), unorderedEquals(items)); + expect(relT.added.map(toInt), unorderedEquals(added)); + expect(relT.removed.map(toInt), unorderedEquals(removed)); +} + +extension TreeNodeEquals on TreeNodeImmutable { + void expectSameAs(TreeNodeImmutable other) { + printOnFailure('Comparing tree nodes $path and ${other.path}'); + expect(id, other.id); + expect(path, other.path); + expect(parent.targetId, other.parent.targetId); + expect(children.length, other.children.length); + for (var i = 0; i < children.length; i++) { + children[i].expectSameAs(other.children[i]); + } + } +} diff --git a/objectbox/test/immutable_test.dart b/objectbox/test/immutable_test.dart index ea5f9e5bf..cc709b20b 100644 --- a/objectbox/test/immutable_test.dart +++ b/objectbox/test/immutable_test.dart @@ -9,11 +9,18 @@ import 'test_env.dart'; // ignore_for_file: omit_local_variable_types void main() { + late TestEnv env; + + setUp(() { + env = TestEnv('entity_immutable'); + }); + + tearDown(() => env.closeAndDelete()); + test('Test putImmutable*', () async { - final env = TestEnv('entity_immutable'); final Box box = env.store.box(); - final result = box.putImmutableMany(const [ + final result = box.putImmutableMany([ TestEntityImmutable(unique: 1, payload: 1), TestEntityImmutable(unique: 10, payload: 10), TestEntityImmutable(unique: 2, payload: 2), @@ -32,7 +39,7 @@ void main() { expect(listDesc.map((t) => t.payload).toList(), [100, 10, 2, 1, 0, 0]); - final obj = box.putImmutable(const TestEntityImmutable( + final obj = box.putImmutable(TestEntityImmutable( unique: 50, payload: 50, )); @@ -42,7 +49,7 @@ void main() { expect(listDesc.map((t) => t.payload).toList(), [100, 50, 10, 2, 1, 0]); query.close(); - final objAsync = await box.putImmutableAsync(const TestEntityImmutable( + final objAsync = await box.putImmutableAsync(TestEntityImmutable( unique: 60, payload: 60, )); @@ -51,7 +58,5 @@ void main() { [objAsync.id, objAsync.unique, objAsync.payload], [obj.id! + 1, 60, 60], ); - - env.closeAndDelete(); }); } diff --git a/objectbox/test/objectbox-model.json b/objectbox/test/objectbox-model.json index e19feecbb..66f7d9783 100644 --- a/objectbox/test/objectbox-model.json +++ b/objectbox/test/objectbox-model.json @@ -529,44 +529,193 @@ "relations": [] }, { - "id": "12:3755469960880688715", - "lastPropertyId": "3:4328105509779076145", + "id": "13:9139468792337671521", + "lastPropertyId": "4:508549725588098006", + "name": "RelatedImmutableEntityA", + "properties": [ + { + "id": "1:9004208996269576227", + "name": "id", + "type": 6, + "flags": 1, + "generatorFlags": 65536 + }, + { + "id": "2:7364152450279135164", + "name": "tInt", + "type": 6 + }, + { + "id": "3:4158034180133116793", + "name": "tBool", + "type": 1 + }, + { + "id": "4:508549725588098006", + "name": "relBId", + "type": 11, + "flags": 520, + "indexId": "23:2318472862590943592", + "relationTarget": "RelatedImmutableEntityB" + } + ], + "relations": [] + }, + { + "id": "14:8238373454959304410", + "lastPropertyId": "5:5396480413322509796", + "name": "RelatedImmutableEntityB", + "properties": [ + { + "id": "1:918283110834983017", + "name": "id", + "type": 6, + "flags": 1, + "generatorFlags": 65536 + }, + { + "id": "2:1324383836848691494", + "name": "tString", + "type": 9 + }, + { + "id": "3:562728766229685582", + "name": "tDouble", + "type": 8 + }, + { + "id": "4:1697132547006070068", + "name": "relAId", + "type": 11, + "flags": 520, + "indexId": "24:2623130680987704341", + "relationTarget": "RelatedImmutableEntityA" + }, + { + "id": "5:5396480413322509796", + "name": "relBId", + "type": 11, + "flags": 520, + "indexId": "25:4394222378861345524", + "relationTarget": "RelatedImmutableEntityB" + } + ], + "relations": [] + }, + { + "id": "15:5528690488476214717", + "lastPropertyId": "5:307754380333437636", "name": "TestEntityImmutable", "properties": [ { - "id": "1:2702147992811450538", + "id": "1:8131301181367106436", "name": "id", "type": 6, "flags": 1, "generatorFlags": 65536 }, { - "id": "2:8497246254690861169", + "id": "2:8730435032773480625", "name": "unique", "type": 6, "flags": 32808, - "indexId": "22:5208599577901675227" + "indexId": "26:7126640996430627841" }, { - "id": "3:4328105509779076145", + "id": "3:3953267977096150307", "name": "payload", "type": 6 } ], "relations": [] + }, + { + "id": "16:2470167520576246830", + "lastPropertyId": "6:1279072412814651264", + "name": "TestEntityImmutableRel", + "properties": [ + { + "id": "1:3607484298072611028", + "name": "id", + "type": 6, + "flags": 1, + "generatorFlags": 65536 + }, + { + "id": "4:6800546832061203813", + "name": "relAId", + "type": 11, + "flags": 520, + "indexId": "30:1997912815982545974", + "relationTarget": "RelatedImmutableEntityA" + }, + { + "id": "5:818820819269638981", + "name": "relBId", + "type": 11, + "flags": 520, + "indexId": "31:1753234411987798189", + "relationTarget": "RelatedImmutableEntityB" + }, + { + "id": "6:1279072412814651264", + "name": "tString", + "type": 9 + } + ], + "relations": [ + { + "id": "3:6421012658772418604", + "name": "relManyA", + "targetId": "13:9139468792337671521" + } + ] + }, + { + "id": "17:8111338276125851414", + "lastPropertyId": "3:762764910678605412", + "name": "TreeNodeImmutable", + "properties": [ + { + "id": "1:4132130601196751498", + "name": "id", + "type": 6, + "flags": 1, + "generatorFlags": 65536 + }, + { + "id": "2:8588416836455364942", + "name": "path", + "type": 9 + }, + { + "id": "3:762764910678605412", + "name": "parentId", + "type": 11, + "flags": 520, + "indexId": "32:3315605054377727710", + "relationTarget": "TreeNodeImmutable" + } + ], + "relations": [] } ], - "lastEntityId": "12:3755469960880688715", - "lastIndexId": "22:5208599577901675227", - "lastRelationId": "1:2155747579134420981", + "lastEntityId": "17:8111338276125851414", + "lastIndexId": "32:3315605054377727710", + "lastRelationId": "3:6421012658772418604", "lastSequenceId": "0:0", "modelVersion": 5, "modelVersionParserMinimum": 5, "retiredEntityUids": [ 2679953000475642792, - 1095349603547153763 + 1095349603547153763, + 3755469960880688715 + ], + "retiredIndexUids": [ + 4269312067425216922, + 7596649364047794608, + 50365692400698103 ], - "retiredIndexUids": [], "retiredPropertyUids": [ 5849170199816666167, 6119953496456269132, @@ -585,8 +734,17 @@ 2900967122054840440, 7226208115703027531, 570487578217848006, - 2435024852950745282 + 2435024852950745282, + 2702147992811450538, + 8497246254690861169, + 4328105509779076145, + 5058343472059819265, + 307754380333437636, + 5866639361448284308, + 6119209574201055645 + ], + "retiredRelationUids": [ + 8419318098209983433 ], - "retiredRelationUids": [], "version": 1 } \ No newline at end of file