Skip to content

Commit

Permalink
Add support for RealmObject.getBacklinks (#1483)
Browse files Browse the repository at this point in the history
* Add support for RealmObject.getBacklinks

* Update comment
  • Loading branch information
nirinchev authored Jan 25, 2024
1 parent 9d4b542 commit 7e34d0d
Show file tree
Hide file tree
Showing 4 changed files with 231 additions and 4 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
The map keys may not contain `.` or start with `$`. (Issue [#685](https://github.com/realm/realm-dart/issues/685))
* Added a new exception - `MigrationRequiredException` that will be thrown when a local Realm is opened with a schema that differs from the schema on disk and no migration callback is supplied. Additionally, a `helpLink` property has been added to `RealmException` and its subclasses to provide a link to the documentation for the error. (Issue [#1448](https://github.com/realm/realm-dart/issues/1448))
* Downgrade minimum dependencies to Dart 3.0.0 and Flutter 3.10.0. (PR [#1457](https://github.com/realm/realm-dart/pull/1457))
* Added `RealmObject.getBacklinks<SourceType>('sourceProperty')` which is a method allowing you to look up all objects of type `SourceType` which link to the current object via their `sourceProperty` property. (Issue [#1480](https://github.com/realm/realm-dart/issues/1480))

### 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))
Expand Down
57 changes: 55 additions & 2 deletions lib/src/realm_object.dart
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,24 @@ class RealmObjectMetadata {

RealmObjectMetadata(this.schema, this.classKey, this._propertyKeys);

RealmPropertyMetadata operator [](String propertyName) =>
_propertyKeys[propertyName] ?? (throw RealmException("Property $propertyName does not exist on class $_realmObjectTypeName"));
RealmPropertyMetadata operator [](String propertyName) {
var meta = _propertyKeys[propertyName];
if (meta == null) {
// We couldn't find a proeprty by the name supplied by the user - this may be because the _propertyKeys
// map is keyed on the property names as they exist in the database while the user supplied the public
// name (i.e. the name of the property in the model). Try and look up the property by the public name and
// then try to re-fetch the property meta using the database name.
final publicName = schema.properties.firstWhereOrNull((e) => e.name == propertyName)?.mapTo;
if (publicName != null && publicName != propertyName) {
meta = _propertyKeys[publicName];
}
}

return meta ?? (throw RealmException("Property $propertyName does not exist on class $_realmObjectTypeName"));
}
// _propertyKeys[propertyName] ??
// schema.properties.firstWhereOrNull((p) => p.name == propertyName) ??
// (throw RealmException("Property $propertyName does not exist on class $_realmObjectTypeName"));

String? getPropertyName(int propertyKey) {
for (final entry in _propertyKeys.entries) {
Expand Down Expand Up @@ -501,6 +517,43 @@ mixin RealmObjectBase on RealmEntity implements RealmObjectBaseMarker, Finalizab

/// Creates a frozen snapshot of this [RealmObject].
RealmObjectBase freeze() => freezeObject(this);

/// Returns all the objects of type [T] that link to this object via [propertyName].
/// Example:
/// ```dart
/// @RealmModel()
/// class School {
/// late String name;
/// }
///
/// @RealmModel()
/// class Student {
/// School? school;
/// }
///
/// // Find all students in a school
/// final school = realm.all<School>().first;
/// final allStudents = school.getBacklinks<Student>('school');
/// ```
RealmResults<T> getBacklinks<T>(String propertyName) {
if (!isManaged) {
throw RealmStateError("Can't look up backlinks of unmanaged objects.");
}

final sourceMeta = realm.metadata.getByType(T);
final sourceProperty = sourceMeta[propertyName];

if (sourceProperty.objectType == null) {
throw RealmError("Property $T.$propertyName is not a link property - it is a property of type ${sourceProperty.propertyType}");
}

if (sourceProperty.objectType != realm.metadata.getByType(runtimeType).schema.name) {
throw RealmError(
"Property $T.$propertyName is a link property that links to ${sourceProperty.objectType} which is different from the type of the current object, which is $runtimeType.");
}
final handle = realmCore.getBacklinks(this, sourceMeta.classKey, sourceProperty.key);
return RealmResultsInternal.create<T>(handle, realm, sourceMeta);
}
}

/// @nodoc
Expand Down
143 changes: 141 additions & 2 deletions test/backlinks_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
//
////////////////////////////////////////////////////////////////////////////////
import 'package:test/expect.dart' hide throws;
import 'package:test/test.dart' hide test, throws;

import '../lib/realm.dart';
import 'test.dart';
Expand All @@ -29,6 +29,12 @@ class _Source {
@MapTo('et mål') // to throw a curve ball..
_Target? oneTarget;
late List<_Target> manyTargets;

// These are the same as the properties above, but don't have defined backlinks
// on Target
@MapTo('dynamisk mål')
_Target? dynamicTarget;
late List<_Target> dynamicManyTargets;
}

@RealmModel()
Expand All @@ -40,6 +46,8 @@ class _Target {

@Backlink(#manyTargets)
late Iterable<_Source> manyToMany;

_Source? source;
}

Future<void> main([List<String>? args]) async {
Expand Down Expand Up @@ -126,7 +134,9 @@ Future<void> main([List<String>? args]) async {
isA<RealmResultsChanges<Source>>().having((ch) => ch.inserted, 'inserted', [0]),
isA<RealmResultsChanges<Source>>().having((ch) => ch.inserted, 'inserted', [1]),
isA<RealmResultsChanges<Source>>() //
.having((ch) => ch.inserted, 'inserted', [0, 2]) // is this surprising?
// Backlinks don't have a natural order - removing the element at 0, then adding a new one will
// appear like the new one was added at position 0.
.having((ch) => ch.inserted, 'inserted', [0, 2]) //
.having((ch) => ch.deleted, 'deleted', [0]) //
.having((ch) => ch.modified, 'modified', [1]),
]));
Expand Down Expand Up @@ -168,4 +178,133 @@ Future<void> main([List<String>? args]) async {
expect(s.manyTargets.map((t) => t.name), targets.map((t) => t.name));
}
});

group('getBacklinks() tests', () {
(Target theOne, List<Target> targets, Iterable<String> expectedSources) populateData() {
final config = Configuration.local([Target.schema, Source.schema]);
final realm = getRealm(config);

final theOne = Target(name: 'the one');
final targets = List.generate(100, (i) => Target(name: 'T$i'));
final sources = List.generate(100, (i) {
return i % 2 == 0
? Source(name: 'TargetLessSource$i')
: Source(name: 'S$i', manyTargets: targets, oneTarget: theOne, dynamicManyTargets: targets, dynamicTarget: theOne);
});

final expectedSources = sources.where((e) => e.name.startsWith('S')).map((e) => e.name);

realm.write(() {
realm.addAll(sources);
realm.addAll(targets);
realm.add(theOne);
});

return (theOne, targets, expectedSources);
}

test('pointing to a valid property', () {
final (theOne, targets, expectedSources) = populateData();

// getBacklinks should work with both the public and the @MapTo property names
expect(theOne.getBacklinks<Source>('oneTarget').map((s) => s.name), expectedSources);
expect(theOne.getBacklinks<Source>('et mål').map((s) => s.name), expectedSources);

expect(theOne.getBacklinks<Source>('dynamicTarget').map((s) => s.name), expectedSources);
expect(theOne.getBacklinks<Source>('dynamisk mål').map((s) => s.name), expectedSources);

for (final t in targets) {
expect(t.getBacklinks<Source>('manyTargets').map((s) => s.name), expectedSources);
expect(t.getBacklinks<Source>('dynamicManyTargets').map((s) => s.name), expectedSources);
}
});

test('notifications', () {
final config = Configuration.local([Target.schema, Source.schema]);
final realm = getRealm(config);

final target = realm.write(() => realm.add(Target()));

expectLater(
target.getBacklinks<Source>('oneTarget').changes,
emitsInOrder(<Matcher>[
isA<RealmResultsChanges<Source>>().having((ch) => ch.inserted, 'inserted', <int>[]),
isA<RealmResultsChanges<Source>>().having((ch) => ch.inserted, 'inserted', [0]),
isA<RealmResultsChanges<Source>>().having((ch) => ch.inserted, 'inserted', [1]),
isA<RealmResultsChanges<Source>>() //
// Backlinks don't have a natural order - removing the element at 0, then adding a new one will
// appear like the new one was added at position 0.
.having((ch) => ch.inserted, 'inserted', [0, 2]) //
.having((ch) => ch.deleted, 'deleted', [0]) //
.having((ch) => ch.modified, 'modified', [1]),
]));

final first = realm.write(() => realm.add(Source(oneTarget: target)));

final second = realm.write(() => realm.add(Source(oneTarget: target)));

realm.write(() {
realm.add(Source(oneTarget: target));
realm.add(Source(oneTarget: target));
second.name = "changed second";
realm.delete(first);
});
});

test('pointing to a non-existent property throws', () {
final (theOne, _, _) = populateData();

expect(() => theOne.getBacklinks<Source>('foo'),
throwsA(isA<RealmException>().having((p0) => p0.message, 'message', 'Property foo does not exist on class Source')));
});

test('on an unmanaged object throws', () {
final theOne = Target(name: 'the one');
expect(() => theOne.getBacklinks<Source>('oneTarget'),
throwsA(isA<RealmStateError>().having((p0) => p0.message, 'message', "Can't look up backlinks of unmanaged objects.")));
});

test('on a deleted object throws', () {
final (theOne, _, _) = populateData();
theOne.realm.write(() => theOne.realm.delete(theOne));

expect(theOne.isValid, false);
expect(theOne.isManaged, true);

expect(
() => theOne.getBacklinks<Source>('oneTarget'),
throwsA(
isA<RealmException>().having((p0) => p0.message, 'message', contains("Accessing object of type Target which has been invalidated or deleted."))));
});

test('with a dynamic type argument throws', () {
final (theOne, _, _) = populateData();
expect(() => theOne.getBacklinks('oneTarget'),
throwsA(isA<RealmError>().having((p0) => p0.message, 'message', contains("Object type dynamic not configured in the current Realm's schema."))));
});

test('with an invalid type argument throws', () {
final (theOne, _, _) = populateData();
expect(() => theOne.getBacklinks('oneTarget'),
throwsA(isA<RealmError>().having((p0) => p0.message, 'message', contains("Object type dynamic not configured in the current Realm's schema."))));
});

test('pointing to a non-link property throws', () {
final (theOne, _, _) = populateData();

expect(
() => theOne.getBacklinks<Source>('name'),
throwsA(isA<RealmError>()
.having((p0) => p0.message, 'message', 'Property Source.name is not a link property - it is a property of type RealmPropertyType.string')));
});

test('pointing to a link property of incorrect type throws', () {
final (theOne, _, _) = populateData();

expect(
() => theOne.getBacklinks<Target>('source'),
throwsA(isA<RealmError>().having((p0) => p0.message, 'message',
'Property Target.source is a link property that links to Source which is different from the type of the current object, which is Target.')));
});
});
}
34 changes: 34 additions & 0 deletions test/backlinks_test.g.dart

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 7e34d0d

Please sign in to comment.