Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for expressions in keypath building #3518

Merged
merged 13 commits into from
Mar 6, 2024
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
Loading