diff --git a/generator/integration-tests/part-partof/1.dart b/generator/integration-tests/part-partof/1.dart index 34eceb608..6ac321b98 100644 --- a/generator/integration-tests/part-partof/1.dart +++ b/generator/integration-tests/part-partof/1.dart @@ -2,10 +2,10 @@ import 'dart:io'; import 'package:test/test.dart'; -import '../test_env.dart'; import '../common.dart'; -import 'lib/json.dart'; +import '../test_env.dart'; import 'lib/frozen.dart'; +import 'lib/json.dart'; import 'lib/objectbox.g.dart'; void main() { @@ -16,12 +16,36 @@ void main() { expect(File('lib/objectbox-model.json').existsSync(), true); }); - setupTestsFor(JsonEntity(id: 0, str: 'foo', date: DateTime.now())); - setupTestsFor(FrozenEntity(id: 1, str: 'foo', date: DateTime.now())); + group('package:JsonSerializable', () { + setupTestsFor(JsonEntity(id: 0, str: 'foo', date: DateTime.now())); + setupRelTestsFor(JsonBook.fromJson({ + 'author': {'name': 'Charles'}, + 'readers': [ + {'name': 'Emily'}, + {'name': 'Diana'} + ] + })); + }); + + group('package:Freezed', () { + setupTestsFor(FrozenEntity(id: 1, str: 'foo', date: DateTime.now())); + final author = FrozenPerson(id: 1, name: 'Charles'); + final readers = [ + FrozenPerson(id: 2, name: 'Emily'), + FrozenPerson(id: 3, name: 'Diana') + ]; + setupRelTestsFor( + FrozenBook( + id: 1, + author: ToOne(target: author), + readers: ToMany(items: readers)), + (Store store) => + store.box().putMany([author, ...readers])); + }); } void setupTestsFor(EntityT newObject) { - group('${EntityT}', () { + group(EntityT.toString(), () { late TestEnv env; setUp(() => env = TestEnv(getObjectBoxModel())); tearDown(() => env.close()); @@ -32,3 +56,24 @@ void setupTestsFor(EntityT newObject) { }); }); } + +void setupRelTestsFor(BookEntityT book, + [void Function(Store)? init]) { + group(BookEntityT.toString(), () { + late TestEnv env; + setUp(() => env = TestEnv(getObjectBoxModel())); + tearDown(() => env.close()); + + test('relations', () { + if (init != null) init(env.store); + env.box.put(book); + + final bookRead = env.box.get(1)! as dynamic; + expect(bookRead.author.targetId, 1); + expect(bookRead.author.target!.name, 'Charles'); + + expect(bookRead.readers[0]!.name, 'Emily'); + expect(bookRead.readers[1]!.name, 'Diana'); + }); + }); +} diff --git a/generator/integration-tests/part-partof/lib/frozen.dart b/generator/integration-tests/part-partof/lib/frozen.dart index 5523f3b5a..237cd5f6c 100644 --- a/generator/integration-tests/part-partof/lib/frozen.dart +++ b/generator/integration-tests/part-partof/lib/frozen.dart @@ -1,5 +1,5 @@ -import 'package:objectbox/objectbox.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:objectbox/objectbox.dart'; part 'frozen.freezed.dart'; @@ -11,3 +11,20 @@ class FrozenEntity with _$FrozenEntity { required String str, required DateTime date}) = _FrozenEntity; } + +@freezed +class FrozenPerson with _$FrozenPerson { + @Entity(realClass: FrozenPerson) + factory FrozenPerson( + {@Id(assignable: true) required int id, + required String name}) = _FrozenPerson; +} + +@freezed +class FrozenBook with _$FrozenBook { + @Entity(realClass: FrozenBook) + factory FrozenBook( + {@Id(assignable: true) required int id, + required ToOne author, + required ToMany readers}) = _FrozenBook; +} diff --git a/generator/integration-tests/part-partof/lib/json.dart b/generator/integration-tests/part-partof/lib/json.dart index df4cd6ffa..ce3c430bc 100644 --- a/generator/integration-tests/part-partof/lib/json.dart +++ b/generator/integration-tests/part-partof/lib/json.dart @@ -1,5 +1,5 @@ -import 'package:objectbox/objectbox.dart'; import 'package:json_annotation/json_annotation.dart'; +import 'package:objectbox/objectbox.dart'; part 'json.g.dart'; @@ -17,3 +17,64 @@ class JsonEntity { Map toJson() => _$JsonEntityToJson(this); } + +@Entity() +@JsonSerializable() +class JsonPerson { + int? id; + String name; + + JsonPerson({required this.name}); + + factory JsonPerson.fromJson(Map json) => + _$JsonPersonFromJson(json); + + Map toJson() => _$JsonPersonToJson(this); +} + +@Entity() +@JsonSerializable() +class JsonBook { + int? id; + + @_PersonRelToOneConverter() + final ToOne author; + + @_PersonRelToManyConverter() + final ToMany readers; + + JsonBook({required this.author, required this.readers}); + + factory JsonBook.fromJson(Map json) => + _$JsonBookFromJson(json); + + Map toJson() => _$JsonBookToJson(this); +} + +class _PersonRelToOneConverter + implements JsonConverter, Map?> { + const _PersonRelToOneConverter(); + + @override + ToOne fromJson(Map? json) => ToOne( + target: json == null ? null : JsonPerson.fromJson(json)); + + @override + Map? toJson(ToOne rel) => rel.target?.toJson(); +} + +class _PersonRelToManyConverter + implements JsonConverter, List>?> { + const _PersonRelToManyConverter(); + + @override + ToMany fromJson(List>? json) => + ToMany( + items: json == null + ? null + : json.map((e) => JsonPerson.fromJson(e)).toList()); + + @override + List>? toJson(ToMany rel) => + rel.map((JsonPerson obj) => obj.toJson()).toList(); +} diff --git a/generator/integration-tests/test_env.dart b/generator/integration-tests/test_env.dart index 86fd4a709..276b00607 100644 --- a/generator/integration-tests/test_env.dart +++ b/generator/integration-tests/test_env.dart @@ -7,13 +7,12 @@ export 'package:objectbox/internal.dart'; class TestEnv { static final dir = Directory('testdata'); late final Store store; - late final Box box; + late final Box box = store.box(); TestEnv(ModelDefinition defs) { if (dir.existsSync()) dir.deleteSync(recursive: true); store = Store(defs, directory: dir.path); - box = Box(store); } void close() { diff --git a/generator/lib/src/code_chunks.dart b/generator/lib/src/code_chunks.dart index c9d6836f3..6a0465b7c 100644 --- a/generator/lib/src/code_chunks.dart +++ b/generator/lib/src/code_chunks.dart @@ -374,36 +374,36 @@ class CodeChunks { return readField(); }).toList(growable: false); - // add initializers for relations - entity.properties.forEachIndexed((int index, ModelProperty p) { - if (p.isRelation) { - postLines.add( - 'object.${propertyFieldName(p)}.targetId = ${fieldReaders[index]};' - '\n object.${propertyFieldName(p)}.attach(store);'); - } - }); - - postLines.addAll(entity.relations.map((ModelRelation rel) => - 'InternalToManyAccess.setRelInfo(object.${rel.name}, store, ${relInfo(entity, rel)}, store.box<${entity.name}>());')); - - postLines.addAll(entity.backlinks.map((ModelBacklink bl) { - return 'InternalToManyAccess.setRelInfo(object.${bl.name}, store, ${backlinkRelInfo(entity, bl)}, store.box<${entity.name}>());'; - })); - // try to initialize as much as possible using the constructor entity.constructorParams.forEachWhile((String declaration) { // See [EntityResolver.constructorParams()] for the format. - final paramName = declaration.split(' ')[0]; - final paramType = declaration.split(' ')[1]; + final declarationParts = declaration.split(' '); + final paramName = declarationParts[0]; + final paramType = declarationParts[1]; + final paramDartType = declarationParts[2]; final index = fieldIndexes[paramName]; - if (index == null) { + late String paramValueCode; + if (index != null) { + paramValueCode = fieldReaders[index]; + if (entity.properties[index].isRelation) { + if (paramDartType.startsWith('ToOne<')) { + paramValueCode = 'ToOne(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 " + "isn't - don't know how to initialize this parameter."); + return true; + } + } + } else if (paramDartType.startsWith('ToMany<')) { + paramValueCode = 'ToMany()'; + } else { // If we can't find a positional param, we can't use the constructor at all. - if (paramType == 'positional') { - log.warning("Cannot use the default constructor of '${entity.name}': " + if (paramType == 'positional' || paramType == 'required-named') { + throw InvalidGenerationSourceError( + "Cannot use the default constructor of '${entity.name}': " "don't know how to initialize param $paramName - no such property."); - constructorLines.clear(); - return false; } else if (paramType == 'optional') { // OK, close the constructor, the rest will be initialized separately. return false; @@ -414,18 +414,20 @@ class CodeChunks { switch (paramType) { case 'positional': case 'optional': - constructorLines.add(fieldReaders[index]); + constructorLines.add(paramValueCode); break; - case 'named': - constructorLines.add('$paramName: ${fieldReaders[index]}'); + case 'required-named': + case 'optional-named': + constructorLines.add('$paramName: $paramValueCode'); break; default: throw InvalidGenerationSourceError( 'Invalid constructor parameter type - internal error'); } - // Good, we don't need to set this field anymore - fieldReaders[index] = ''; // don't remove - that would mess up indexes + // Good, we don't need to set this field anymore. + // Don't remove - that would mess up indexes. + if (index != null) fieldReaders[index] = ''; return true; }); @@ -438,6 +440,23 @@ class CodeChunks { } }); + // add initializers for relations + entity.properties.forEachIndexed((int index, ModelProperty p) { + if (!p.isRelation) return; + if (fieldReaders[index].isNotEmpty) { + postLines.add( + 'object.${propertyFieldName(p)}.targetId = ${fieldReaders[index]};'); + } + postLines.add('object.${propertyFieldName(p)}.attach(store);'); + }); + + postLines.addAll(entity.relations.map((ModelRelation rel) => + 'InternalToManyAccess.setRelInfo(object.${rel.name}, store, ${relInfo(entity, rel)}, store.box<${entity.name}>());')); + + postLines.addAll(entity.backlinks.map((ModelBacklink bl) { + return 'InternalToManyAccess.setRelInfo(object.${bl.name}, store, ${backlinkRelInfo(entity, bl)}, store.box<${entity.name}>());'; + })); + return '''(Store store, ByteData fbData) { final buffer = fb.BufferContext(fbData); final rootOffset = buffer.derefObject(0); diff --git a/generator/lib/src/entity_resolver.dart b/generator/lib/src/entity_resolver.dart index d32238941..0eeccc8c5 100644 --- a/generator/lib/src/entity_resolver.dart +++ b/generator/lib/src/entity_resolver.dart @@ -420,7 +420,10 @@ class EntityResolver extends Builder { var info = param.name; if (param.isRequiredPositional) info += ' positional'; if (param.isOptionalPositional) info += ' optional'; - if (param.isNamed) info += ' named'; + if (param.isRequiredNamed) + info += ' required-named'; + else if (param.isNamed) info += ' optional-named'; + info += ' ${param.type}'; return info; }).toList(growable: false); } diff --git a/objectbox/lib/src/relations/to_many.dart b/objectbox/lib/src/relations/to_many.dart index 4fc2e4027..28a2d19e9 100644 --- a/objectbox/lib/src/relations/to_many.dart +++ b/objectbox/lib/src/relations/to_many.dart @@ -61,6 +61,20 @@ class ToMany extends Object with ListMixin { final _counts = {}; final _addedBeforeLoad = []; + /// Create a ToMany relationship. + /// + /// 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. + ToMany({List? items}) { + if (items != null) { + __items = items; + items.forEach(_track); + } + } + @override int get length => _items.length; @@ -105,10 +119,7 @@ class ToMany extends Object with ListMixin { @override void addAll(Iterable iterable) { - iterable.forEach((element) { - ArgumentError.checkNotNull(element, 'iterable element'); - _track(element, 1); - }); + iterable.forEach(_track); if (__items == null) { // We don't need to load old data from DB to add new items. _addedBeforeLoad.addAll(iterable); @@ -127,7 +138,7 @@ class ToMany extends Object with ListMixin { /// "add": increment = 1 /// "remove": increment = -1 - void _track(EntityT object, int increment) { + void _track(EntityT object, [int increment = 1]) { if (_counts.containsKey(object)) { _counts[object] = _counts[object]! + increment; } else { diff --git a/objectbox/lib/src/relations/to_one.dart b/objectbox/lib/src/relations/to_one.dart index 5aed8c872..d3755e72a 100644 --- a/objectbox/lib/src/relations/to_one.dart +++ b/objectbox/lib/src/relations/to_one.dart @@ -56,6 +56,25 @@ class ToOne { _ToOneValue _value = _ToOneValue.none(); + /// Create a ToOne relationship. + /// + /// Normally, you don't assign the target in the constructor but rather use + /// the `.target` setter. The option to assign in the constructor is useful + /// to initialize objects from an external source, e.g. from JSON. + ToOne({EntityT? target, int? targetId}) { + if (targetId != null) { + if (target != null) { + // May be a user error... and we can't check if (target.id == targetId). + throw ArgumentError( + 'Provide at most one specification of a ToOne relation target: ' + 'either [target] or [targetId] argument'); + } + this.targetId = targetId; + } else if (target != null) { + this.target = target; + } + } + /// Get target object. If it's the first access, this reads from DB. EntityT? get target { if (_value._state == _ToOneState.lazy) {