Skip to content

Commit

Permalink
Added support for expressions in keypath building (#3518)
Browse files Browse the repository at this point in the history
* Added support for expressions in keypath building

* Added more tests

* Fixed docs

* Fixed changelog

* Small fix

* Apply suggestions from code review

Co-authored-by: Nikola Irinchev <irinchev@me.com>

* Corrections according to PR

* Fixed tests and merged main

* Corrections

* Corrected tests

---------

Co-authored-by: Nikola Irinchev <irinchev@me.com>
  • Loading branch information
papafe and nirinchev authored Mar 6, 2024
1 parent 1b8a07e commit 2a67040
Show file tree
Hide file tree
Showing 3 changed files with 112 additions and 1 deletion.
9 changes: 8 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,26 @@
* Add support for passing a key paths collection (`KeyPathsCollection`) when using `IRealmCollection.SubscribeForNotifications`. Passing a `KeyPathsCollection` allows to specify which changes in properties should raise a notification.

A `KeyPathsCollection` can be obtained by:
- building it explicitly by using the method `KeyPathsCollection.Of`;
- building it explicitly by using the methods `KeyPathsCollection.Of` or `KeyPathsCollection.Of<T>`;
- building it implicitly with the conversion from a `List` or array of `KeyPath` or strings;
- getting one of the static values `Full` and `Shallow` for full and shallow notifications respectively.

A `KeyPath` can be obtained by implicit conversion from a string or built from an expression using the `KeyPath.ForExpression<T>` method.

For example:
```csharp
var query = realm.All<Person>();

KeyPath kp1 = "Email";
KeyPath kp2 = KeyPath.ForExpression<Person>(p => p.Name);

KeyPathsCollection kpc;

//Equivalent declarations
kpc = KeyPathsCollection.Of("Email", "Name");
kpc = KeyPathsCollection.Of<Person>(p => p.Email, p => p.Name);
kpc = new List<KeyPath> {"Email", "Name"};
kpc = new List<KeyPath> {kp1, kp2};

query.SubscribeForNotifications(NotificationCallback, kpc);
```
Expand Down
53 changes: 53 additions & 0 deletions Realm/Realm/DatabaseTypes/KeyPathCollection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;

namespace Realms;

Expand Down Expand Up @@ -78,6 +80,19 @@ public static KeyPathsCollection Of(params KeyPath[] paths)
return new KeyPathsCollection(KeyPathsCollectionType.Explicit, paths);
}

/// <summary>
/// Builds a <see cref="KeyPathsCollection"/> from an array of <see cref="Expression"/>.
/// Each of the expressions must represent the path to a <see cref="IRealmObject">realm object</see> property, eventually chained.
/// </summary>
/// <typeparam name="T">The <see cref="IRealmObject">realm object.</see> type.</typeparam>
/// <param name="expressions">The array of <see cref="Expression"/> to use for the <see cref="KeyPathsCollection"/>.</param>
/// <returns>The <see cref="KeyPathsCollection"/> built from the input array of <see cref="Expression"/>.</returns>
public static KeyPathsCollection Of<T>(params Expression<Func<T, object?>>[] expressions)
where T : IRealmObject
{
return Of(expressions.Select(KeyPath.ForExpression).ToArray());
}

/// <summary>
/// Gets a <see cref="KeyPathsCollection"/> value for shallow notifications, that will raise notifications only for changes to the collection itself (for example when an element is added or removed),
/// but not for changes to any of the properties of the elements of the collection.
Expand Down Expand Up @@ -129,6 +144,8 @@ IEnumerator IEnumerable.GetEnumerator()
/// Represents a key path that can be used as a part of a <see cref="KeyPathsCollection"/> when subscribing for notifications.
/// A <see cref="KeyPath"/> can be implicitly built from a string, where the string is the name of a property (e.g "FirstName"), eventually dotted to indicated nested properties.
/// (e.g "Dog.Name"). Wildcards can also be used in key paths to capture all properties at a given level (e.g "*", "Friends.*" or "*.FirstName").
/// A <see cref="KeyPath"/> can also be built using the <see cref="ForExpression{T}(Expression{Func{T, object}})"/> method, that creates the <see cref="KeyPath"/> corresponding
/// to the property path represented by the input expression.
/// </summary>
public readonly struct KeyPath
{
Expand All @@ -139,8 +156,44 @@ private KeyPath(string path)
Path = path;
}

/// <summary>
/// Creates a <see cref="KeyPath"/> from an <see cref="Expression"/> that specifies a property path for a given <see cref="IRealmObject">realm object</see> type.
/// </summary>
/// <typeparam name="T">The type of the <see cref="IRealmObject">realm object.</see>.</typeparam>
/// <param name="expression">The expression specifying the path to the property.</param>
/// <returns>A <see cref="KeyPath"/> representing the full path to the specified property.</returns>
/// <example>
/// <code>
/// var keyPath = KeyPath.For&lt;Person&gt;(p => p.Dog.Name);
/// </code>
/// </example>
public static KeyPath ForExpression<T>(Expression<Func<T, object?>> expression)
where T : IRealmObject
{
if (expression is null)
{
throw new ArgumentException("The input expression cannot be null", nameof(expression));
}

return new(GetFullPath(expression.Body));
}

public static implicit operator KeyPath(string s) => new(s);

private static string GetFullPath(Expression expression)
{
return expression switch
{
// MemberExpression: field or property expression;
// Expression == null for static members;
// Member: PropertyInfo to filter out field access
MemberExpression { Expression: { } innerExpression, Member: PropertyInfo pi } =>
innerExpression is ParameterExpression ? pi.Name : $"{GetFullPath(innerExpression)}.{pi.Name}",
ParameterExpression => string.Empty,
_ => throw new ArgumentException("The input expression is not a path to a property"),
};
}

/// <inheritdoc/>
public override bool Equals(object? obj) => obj is KeyPath path && Path == path.Path;

Expand Down
51 changes: 51 additions & 0 deletions Tests/Realm.Tests/Database/NotificationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1654,6 +1654,28 @@ public void KeyPath_ImplicitOperator_CorrectlyConvertsFromString()
Assert.That(keyPath.Path, Is.EqualTo("test"));
}

[Test]
public void KeyPath_CanBeBuiltFromExpressions()
{
KeyPath keyPath;

keyPath = KeyPath.ForExpression<TestNotificationObject>(t => t.ListSameType);
Assert.That(keyPath.Path, Is.EqualTo("ListSameType"));

keyPath = KeyPath.ForExpression<TestNotificationObject>(t => t.LinkAnotherType!.DictOfDogs);
Assert.That(keyPath.Path, Is.EqualTo("LinkAnotherType.DictOfDogs"));
}

[Test]
public void KeyPath_WithInvalidExpressions_ThrowsException()
{
Assert.That(() => KeyPath.ForExpression<TestNotificationObject>(t => t.Equals(this)),
Throws.Exception.TypeOf<ArgumentException>().With.Message.Contains("The input expression is not a path to a property"));

Assert.That(() => KeyPath.ForExpression<TestNotificationObject>(null!),
Throws.Exception.TypeOf<ArgumentException>().With.Message.Contains("The input expression cannot be null"));
}

[Test]
public void KeyPathsCollection_CanBeBuiltInDifferentWays()
{
Expand Down Expand Up @@ -1710,6 +1732,35 @@ void AssertKeyPathsCollectionCorrectness(KeyPathsCollection k, IEnumerable<strin
}
}

[Test]
public void KeyPathsCollection_CanBeBuiltFromExpressions()
{
var expected = new List<string> { "ListSameType", "LinkAnotherType.DictOfDogs" };

var kpc = KeyPathsCollection.Of<TestNotificationObject>(t => t.ListSameType, t => t.LinkAnotherType!.DictOfDogs);
AssertKeyPathsCollectionCorrectness(kpc, expected);

kpc = KeyPathsCollection.Of(KeyPath.ForExpression<TestNotificationObject>(t => t.ListSameType),
KeyPath.ForExpression<TestNotificationObject>(t => t.LinkAnotherType!.DictOfDogs));
AssertKeyPathsCollectionCorrectness(kpc, expected);

void AssertKeyPathsCollectionCorrectness(KeyPathsCollection k, IEnumerable<string> expectedStrings)
{
Assert.That(k.Type, Is.EqualTo(KeyPathsCollectionType.Explicit));
Assert.That(k.GetStrings(), Is.EqualTo(expectedStrings));
}
}

[Test]
public void KeyPathsCollection_WithInvalidExpressions_ThrowsExceptions()
{
Assert.That(() => KeyPathsCollection.Of<TestNotificationObject>(t => t.ListSameType, null!),
Throws.Exception.TypeOf<ArgumentException>().With.Message.Contains("The input expression cannot be null"));

Assert.That(() => KeyPathsCollection.Of<TestNotificationObject>(t => t.Equals(this)),
Throws.Exception.TypeOf<ArgumentException>().With.Message.Contains("The input expression is not a path to a property"));
}

[Test]
public void SubscribeWithKeypaths_AnyKeypath_RaisesNotificationsForResults()
{
Expand Down

0 comments on commit 2a67040

Please sign in to comment.