Skip to content

Commit

Permalink
Support maps (#1406)
Browse files Browse the repository at this point in the history
* Update generator to support maps

* Generator updates

* Better error message for wrong key type

* Remove the default value lists

* Add tests for non-empty default collection initializers

* Wire up some of the implementation

* Wire up most of the test infrastructure

* Add notification tests

* Fix generator test expects

* Fix tests

* Add changelog, clean up the generator a little

* Revert some unneeded changes

* Fix expectations

---------

Co-authored-by: Nikola Irinchev <[email protected]>
  • Loading branch information
nielsenko and nirinchev committed Jan 3, 2024
1 parent 3e3d0bb commit 0bc8bec
Show file tree
Hide file tree
Showing 41 changed files with 2,624 additions and 193 deletions.
22 changes: 21 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,37 @@
* The `App(AppConfiguration)` constructor should only be used on the main isolate. Ideally, it should be called once as soon as your app launches. If you attempt to use it on a background isolate (as indicated by `Isolate.debugName` being different from `main`), a warning will be logged.
* Added a new method - `App.getById` that allows you to obtain an already constructed app on a background isolate.
(Issue [#1433](https://github.com/realm/realm-dart/issues/1433))
* Added support for fields of type `Map<String, T>` where `T` is any supported Realm type. You can define a model with a map like:
```dart
@RealmModel()
class _LotsOfMaps {
late Map<String, _Person?> persons;
late Map<String, bool> bools;
late Map<String, DateTime> dateTimes;
late Map<String, Decimal128> decimals;
late Map<String, double> doubles;
late Map<String, int> ints;
late Map<String, ObjectId> objectIds;
late Map<String, RealmValue> realmValues;
late Map<String, String> strings;
late Map<String, Uint8List> datas;
late Map<String, Uuid> uuids;
}
```

The map keys may not contain `.` or start with `$`. (Issue [#685](https://github.com/realm/realm-dart/issues/685))

### Fixed
* Fixed warnings being emitted by the realm generator requesting that `xyz.g.dart` be included with `part 'xyz.g.dart';` for `xyz.dart` files that import `realm` but don't have realm models defined. Those should not need generated parts and including the part file would have resulted in an empty file with `// ignore_for_file: type=lint` being generated. (PR [#1443](https://github.com/realm/realm-dart/pull/1443))
* Updated the minimum required CMake version for Flutter on Linux to 3.19. (Issue [#1381](https://github.com/realm/realm-dart/issues/1381))
* Errors in user-provided client reset callbacks, such as `RecoverOrDiscardUnsyncedChangesHandler.onBeforeReset/onAfterDiscard` would not be correctly propagated and the client reset exception would contain a message like `A fatal error occurred during client reset: 'User-provided callback failed'` but no details about the actual error. Now `SyncError` has an `innerError` field which contains the original error thrown in the callback. (PR [#1447](https://github.com/realm/realm-dart/pull/1447))
* Fixed a bug where the generator would not emit errors for invalid default values for collection properties. Default values for collection properties are not supported unless the default value is an empty collection. (PR [#1406](https://github.com/realm/realm-dart/pull/1406))

### Compatibility
* Realm Studio: 13.0.0 or later.

### Internal
* Using Core x.y.z.
* Using Core 13.24.0.

## 1.6.1 (2023-11-30)

Expand Down
20 changes: 19 additions & 1 deletion common/lib/src/realm_types.dart
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import 'dart:math';
import 'dart:typed_data';
import 'package:objectid/objectid.dart';
import 'package:sane_uuid/uuid.dart';
import 'package:collection/collection.dart';

Type _typeOf<T>() => T;

Expand Down Expand Up @@ -98,7 +99,20 @@ enum RealmCollectionType {
list,
set,
_3, // ignore: unused_field, constant_identifier_names
dictionary,
map;

String get plural {
switch (this) {
case RealmCollectionType.list:
return "lists";
case RealmCollectionType.set:
return "sets";
case RealmCollectionType.map:
return "maps";
default:
return "none";
}
}
}

/// A base class of all Realm errors.
Expand Down Expand Up @@ -216,6 +230,10 @@ class RealmValue {
@override
operator ==(Object? other) {
if (other is RealmValue) {
if (value is Uint8List && other.value is Uint8List) {
return ListEquality().equals(value as Uint8List, other.value as Uint8List);
}

return value == other.value;
}

Expand Down
14 changes: 4 additions & 10 deletions generator/lib/src/dart_type_ex.dart
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,7 @@ extension DartTypeEx on DartType {
RealmCollectionType get realmCollectionType {
if (isDartCoreSet) return RealmCollectionType.set;
if (isDartCoreList) return RealmCollectionType.list;
if (isDartCoreMap && (this as ParameterizedType).typeArguments.first == session.typeProvider.stringType) {
return RealmCollectionType.dictionary;
}
if (isDartCoreMap) return RealmCollectionType.map;
return RealmCollectionType.none;
}

Expand All @@ -79,18 +77,14 @@ extension DartTypeEx on DartType {
if (self is ParameterizedType) {
final mapped = self.typeArguments.last.mappedType;
if (self != mapped) {
final provider = session.typeProvider;
if (self.isDartCoreList) {
final mappedList = provider.listType(mapped);
return PseudoType('Realm${mappedList.getDisplayString(withNullability: true)}', nullabilitySuffix: mappedList.nullabilitySuffix);
return PseudoType('RealmList<${mapped.getDisplayString(withNullability: true)}>');
}
if (self.isDartCoreSet) {
final mappedSet = provider.setType(mapped);
return PseudoType('Realm${mappedSet.getDisplayString(withNullability: true)}', nullabilitySuffix: mappedSet.nullabilitySuffix);
return PseudoType('RealmSet<${mapped.getDisplayString(withNullability: true)}>');
}
if (self.isDartCoreMap) {
final mappedMap = provider.mapType(self.typeArguments.first, mapped);
return PseudoType('Realm${mappedMap.getDisplayString(withNullability: true)}', nullabilitySuffix: mappedMap.nullabilitySuffix);
return PseudoType('RealmMap<${mapped.getDisplayString(withNullability: true)}>');
}
}
}
Expand Down
113 changes: 70 additions & 43 deletions generator/lib/src/field_element_ex.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
////////////////////////////////////////////////////////////////////////////////
import 'package:analyzer/dart/ast/ast.dart';
import 'package:analyzer/src/dart/ast/ast.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:analyzer/dart/element/type.dart';
import 'package:build/build.dart';
Expand Down Expand Up @@ -93,17 +94,6 @@ extension FieldElementEx on FieldElement {
final indexed = indexedInfo;
final backlink = backlinkInfo;

// Check for as-of-yet unsupported type
if (type.isDartCoreMap) {
throw RealmInvalidGenerationSourceError(
'Field type not supported yet',
element: this,
primarySpan: typeSpan(span!.file),
primaryLabel: 'not yet supported',
todo: 'Avoid using $modelTypeName for now',
);
}

// Validate primary key
if (primaryKey != null) {
if (indexed != null) {
Expand Down Expand Up @@ -220,7 +210,7 @@ extension FieldElementEx on FieldElement {
} else {
// Validate collections and backlinks
if (type.isRealmCollection || backlink != null) {
final typeDescription = type.isRealmCollection ? (type.isRealmSet ? 'sets' : 'collections') : 'backlinks';
final typeDescription = type.isRealmCollection ? type.realmCollectionType.plural : 'backlinks';
if (type.isNullable) {
throw RealmInvalidGenerationSourceError(
'Realm $typeDescription cannot be nullable',
Expand All @@ -231,40 +221,65 @@ extension FieldElementEx on FieldElement {
);
}
final itemType = type.basicType;
if (itemType.isRealmModel && itemType.isNullable) {
throw RealmInvalidGenerationSourceError('Nullable realm objects are not allowed in $typeDescription',
final objectsShouldBeNullable = type.realmCollectionType == RealmCollectionType.map;
if (itemType.isRealmModel && itemType.isNullable != objectsShouldBeNullable) {
final requestedObjectType = objectsShouldBeNullable ? 'nullable' : 'non-nullable';
final invalidObjectType = objectsShouldBeNullable ? 'non-nullable' : 'nullable';

throw RealmInvalidGenerationSourceError('Realm objects in $typeDescription must be $requestedObjectType',
primarySpan: typeSpan(file),
primaryLabel: 'which has a nullable realm object element type',
primaryLabel: 'which has a $invalidObjectType realm object element type',
element: this,
todo: 'Ensure element type is non-nullable');
todo: 'Ensure element type is $requestedObjectType');
}

if (type.isRealmSet) {
final typeArgument = (type as ParameterizedType).typeArguments.first;
if (realmSetUnsupportedRealmTypes.contains(realmType)) {
throw RealmInvalidGenerationSourceError('$type is not supported',
primarySpan: typeSpan(file),
primaryLabel: 'Set element type is not supported',
element: this,
todo: 'Ensure set element type $typeArgument is a type supported by RealmSet.');
}

if (realmType == RealmPropertyType.mixed && typeArgument.isNullable) {
throw RealmInvalidGenerationSourceError('$type is not supported',
primarySpan: typeSpan(file),
primaryLabel: 'Set of nullable RealmValues is not supported',
element: this,
todo: 'Did you mean to use Set<RealmValue> instead?');
}

final initExpression = initializerExpression;
if (initExpression != null) {
throw RealmInvalidGenerationSourceError('Default values for set are not supported.',
primarySpan: initializerExpressionSpan(file, initExpression),
primaryLabel: 'Remove the default value.',
element: this,
todo: 'Remove the default value for field $displayName.');
}
if (realmType == RealmPropertyType.mixed && itemType.isNullable) {
throw RealmInvalidGenerationSourceError('$type is not supported',
primarySpan: typeSpan(file),
primaryLabel: 'Nullable RealmValues are not supported',
element: this,
todo: 'Ensure the RealmValue type argument is non-nullable. RealmValue can hold null, but must not be nullable itself.');
}

if (itemType.isRealmCollection || itemType.realmType == RealmPropertyType.linkingObjects) {
throw RealmInvalidGenerationSourceError('$type is not supported',
primarySpan: typeSpan(file),
primaryLabel: 'Collections of collections are not supported',
element: this,
todo: 'Ensure the collection element type $itemType is not Iterable.');
}

final initExpression = initializerExpression;
if (initExpression != null && !_isValidCollectionInitializer(initExpression)) {
throw RealmInvalidGenerationSourceError('Non-empty default values for $typeDescription are not supported.',
primarySpan: initializerExpressionSpan(file, initExpression),
primaryLabel: 'Remove the default value.',
element: this,
todo: 'Remove the default value for field $displayName or change it to be an empty collection.');
}

switch (type.realmCollectionType) {
case RealmCollectionType.map:
final keyType = (type as ParameterizedType).typeArguments.first;
if (!keyType.isDartCoreString || keyType.isNullable) {
throw RealmInvalidGenerationSourceError('$type is not supported',
primarySpan: typeSpan(file),
primaryLabel: 'Non-String keys are not supported in maps',
element: this,
todo: 'Change the map key type to be String');
}
break;
case RealmCollectionType.set:
if (itemType.realmObjectType == ObjectType.embeddedObject) {
throw RealmInvalidGenerationSourceError('$type is not supported',
primarySpan: typeSpan(file),
primaryLabel: 'Embedded objects in sets are not supported',
element: this,
todo: 'Change the collection element to be a non-embedded object');
}
break;
default:
break;
}
}

Expand Down Expand Up @@ -341,7 +356,7 @@ extension FieldElementEx on FieldElement {
'RealmValue fields cannot be nullable',
primarySpan: typeSpan(file),
primaryLabel: '$modelTypeName is nullable',
todo: 'Change type to ${modelType.asNonNullable}',
todo: 'Change type to RealmValue. RealmValue can hold null, but must not be nullable itself.',
element: this,
);
}
Expand All @@ -368,4 +383,16 @@ extension FieldElementEx on FieldElement {
);
}
}

bool _isValidCollectionInitializer(Expression initExpression) {
if (initExpression is AstNodeImpl) {
final astNode = initExpression as AstNodeImpl;
final elementsNode = astNode.namedChildEntities.where((e) => e.name == 'elements').singleOrNull;
final nodeValue = elementsNode?.value;
if (nodeValue is NodeList && nodeValue.isEmpty) {
return true;
}
}
return false;
}
}
16 changes: 10 additions & 6 deletions generator/lib/src/realm_field_info.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,6 @@ class RealmFieldInfo {
DartType get type => fieldElement.type;

bool get isFinal => fieldElement.isFinal;
bool get isRealmCollection => type.isRealmCollection;
bool get isDartCoreSet => type.isDartCoreSet;
bool get isLate => fieldElement.isLate;
bool get hasDefaultValue => fieldElement.hasInitializer;
bool get optional => type.basicType.isNullable || realmType == RealmPropertyType.mixed;
Expand All @@ -53,6 +51,11 @@ class RealmFieldInfo {
bool get isMixed => realmType == RealmPropertyType.mixed;
bool get isComputed => isRealmBacklink; // only computed, so far

bool get isRealmCollection => type.isRealmCollection;
bool get isDartCoreList => type.isDartCoreList;
bool get isDartCoreSet => type.isDartCoreSet;
bool get isDartCoreMap => type.isDartCoreMap;

String get name => fieldElement.name;
String get realmName => mapTo ?? name;

Expand All @@ -61,17 +64,18 @@ class RealmFieldInfo {
String get basicNonNullableMappedTypeName => type.basicType.asNonNullable.mappedName;

String get basicRealmTypeName =>
fieldElement.modelType.basicType.asNonNullable.element?.remappedRealmName ?? fieldElement.modelType.asNonNullable.basicMappedName;
fieldElement.modelType.basicType.asNonNullable.element?.remappedRealmName ?? fieldElement.modelType.basicType.asNonNullable.basicMappedName;

String get modelTypeName => fieldElement.modelTypeName;

String get mappedTypeName => fieldElement.mappedTypeName;

String get initializer {
if (type.isDartCoreList) return ' = const []';
if (isMixed && !type.isRealmCollection) return ' = const RealmValue.nullValue()';
if (type.realmCollectionType == RealmCollectionType.list) return ' = const []';
if (type.realmCollectionType == RealmCollectionType.set) return ' = const {}';
if (type.realmCollectionType == RealmCollectionType.map) return ' = const {}';
if (isMixed) return ' = const RealmValue.nullValue()';
if (hasDefaultValue) return ' = ${fieldElement.initializerExpression}';
if (type.isDartCoreSet) return ' = const {}';
return ''; // no initializer
}

Expand Down
19 changes: 15 additions & 4 deletions generator/lib/src/realm_model_info.dart
Original file line number Diff line number Diff line change
Expand Up @@ -43,24 +43,27 @@ class RealmModelInfo {
yield '';
}

// Constructor
yield '$name(';
{
final required = allSettable.where((f) => f.isRequired || f.isPrimaryKey);
yield* required.map((f) => '${f.mappedTypeName} ${f.name},');

final notRequired = allSettable.where((f) => !f.isRequired && !f.isPrimaryKey);
final collections = fields.where((f) => f.isRealmCollection && !f.isDartCoreSet).toList();
final lists = fields.where((f) => f.isDartCoreList).toList();
final sets = fields.where((f) => f.isDartCoreSet).toList();
if (notRequired.isNotEmpty || collections.isNotEmpty || sets.isNotEmpty) {
final maps = fields.where((f) => f.isDartCoreMap).toList();
if (notRequired.isNotEmpty || lists.isNotEmpty || sets.isNotEmpty || maps.isNotEmpty) {
yield '{';
yield* notRequired.map((f) {
if (f.type.isUint8List && f.hasDefaultValue) {
return '${f.mappedTypeName}? ${f.name},';
}
return '${f.mappedTypeName} ${f.name}${f.initializer},';
});
yield* collections.map((c) => 'Iterable<${c.type.basicMappedName}> ${c.name}${c.initializer},');
yield* lists.map((c) => 'Iterable<${c.type.basicMappedName}> ${c.name}${c.initializer},');
yield* sets.map((c) => 'Set<${c.type.basicMappedName}> ${c.name}${c.initializer},');
yield* maps.map((c) => 'Map<String, ${c.type.basicMappedName}> ${c.name}${c.initializer},');
yield '}';
}

Expand All @@ -82,32 +85,40 @@ class RealmModelInfo {
return "RealmObjectBase.set(this, '${f.realmName}', ${f.name});";
});

yield* collections.map((c) {
yield* lists.map((c) {
return "RealmObjectBase.set<${c.mappedTypeName}>(this, '${c.realmName}', ${c.mappedTypeName}(${c.name}));";
});

yield* sets.map((c) {
return "RealmObjectBase.set<${c.mappedTypeName}>(this, '${c.realmName}', ${c.mappedTypeName}(${c.name}));";
});

yield* maps.map((c) {
return "RealmObjectBase.set<${c.mappedTypeName}>(this, '${c.realmName}', ${c.mappedTypeName}(${c.name}));";
});
}
yield '}';
yield '';
yield '$name._();';
yield '';

// Properties
yield* fields.expand((f) => [
...f.toCode(),
'',
]);

// Changes
yield '@override';
yield 'Stream<RealmObjectChanges<$name>> get changes => RealmObjectBase.getChanges<$name>(this);';
yield '';

// Freeze
yield '@override';
yield '$name freeze() => RealmObjectBase.freezeObject<$name>(this);';
yield '';

// Schema
yield 'static SchemaObject get schema => _schema ??= _initSchema();';
yield 'static SchemaObject? _schema;';
yield 'static SchemaObject _initSchema() {';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import 'package:realm_common/realm_common.dart';

//part 'dict_non_empty_initializer.g.dart';

@RealmModel()
class _Bad {
late int x;
final dictWithInitializer = {"a": 5};
}
Loading

0 comments on commit 0bc8bec

Please sign in to comment.