Skip to content

Commit 8e68b3f

Browse files
authored
Enhanced .WhenValueChanged() to support type casting within the expression. In particular, this allows the use of null as a fallback value for non-nullable value types. (#1059)
Resolves #1057
1 parent d9a994d commit 8e68b3f

File tree

5 files changed

+139
-52
lines changed

5 files changed

+139
-52
lines changed

src/DynamicData.Tests/Binding/NotifyPropertyChangedExFixture.cs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
using System;
2+
using System.Collections.Generic;
3+
using System.ComponentModel;
4+
using System.Linq;
5+
using System.Runtime.CompilerServices;
26

37
using DynamicData.Binding;
48
using DynamicData.Tests.Domain;
9+
using DynamicData.Tests.Utilities;
510

611
using FluentAssertions;
712

@@ -98,4 +103,77 @@ public void SubscribeToValueChangeForAllItemsInList(bool notifyOnInitialValue)
98103
anotherPerson.Age = 13;
99104
lastAgeChange.Should().Be(13);
100105
}
106+
107+
[Fact]
108+
public void CastToNullable()
109+
{
110+
var parent = new TestEntity()
111+
{
112+
Id = 1,
113+
Age = 10
114+
};
115+
116+
using var subscription = parent.WhenValueChanged(
117+
propertyAccessor: static entity => (int?)entity.Child.Age,
118+
notifyOnInitialValue: true,
119+
fallbackValue: static () => null)
120+
.RecordValues(out var results);
121+
122+
results.Error.Should().BeNull("no errors should have occurred");
123+
results.HasCompleted.Should().BeFalse("additional changes could be made");
124+
results.RecordedValues.Should().ContainSingle("an initial value should have been published");
125+
results.RecordedValues[0].Should().Be(null, "the target entity has no child");
126+
127+
var child = new TestEntity()
128+
{
129+
Id = 2,
130+
Age = 5
131+
};
132+
parent.Child = child;
133+
134+
results.Error.Should().BeNull("no errors should have occurred");
135+
results.HasCompleted.Should().BeFalse("additional changes could be made");
136+
results.RecordedValues.Skip(1).Should().ContainSingle("a single change was performed");
137+
results.RecordedValues.Skip(1).First().Should().Be(child.Age, "a child of age 5 was added");
138+
139+
child.Age = 6;
140+
141+
results.Error.Should().BeNull("no errors should have occurred");
142+
results.HasCompleted.Should().BeFalse("additional changes could be made");
143+
results.RecordedValues.Skip(2).Should().ContainSingle("a single change was performed");
144+
results.RecordedValues.Skip(2).First().Should().Be(child.Age, "the child entity's age was changed");
145+
}
146+
147+
public class TestEntity
148+
: INotifyPropertyChanged
149+
{
150+
public long Id { get; init; }
151+
152+
public int Age
153+
{
154+
get;
155+
set => SetPropertyField(ref field, value);
156+
}
157+
158+
public TestEntity? Child
159+
{
160+
get;
161+
set => SetPropertyField(ref field, value);
162+
}
163+
164+
public event PropertyChangedEventHandler? PropertyChanged;
165+
166+
protected void SetPropertyField<T>(
167+
ref T field,
168+
T value,
169+
[CallerMemberName] string? propertyName = null)
170+
{
171+
if (EqualityComparer<T>.Default.Equals(field, value))
172+
return;
173+
174+
field = value;
175+
176+
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
177+
}
178+
}
101179
}

src/DynamicData/Binding/ExpressionBuilder.cs

Lines changed: 52 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -22,49 +22,51 @@ public static IEnumerable<MemberExpression> GetMembers<TObject, TProperty>(this
2222
}
2323
}
2424

25-
internal static Func<object, IObservable<Unit>> CreatePropertyChangedFactory(this MemberExpression source)
25+
internal static Func<object, IObservable<Unit>> CreatePropertyChangedFactory(this Expression source)
2626
{
27-
var property = source.GetProperty();
28-
29-
if (property.DeclaringType is null)
27+
if ((source is not MemberExpression { Member: PropertyInfo property })
28+
|| !typeof(INotifyPropertyChanged).IsAssignableFrom(property.DeclaringType))
3029
{
31-
throw new ArgumentException("The property does not have a valid declaring type.", nameof(source));
30+
return static _ => Observable.Never<Unit>();
3231
}
3332

34-
var notifyPropertyChanged = typeof(INotifyPropertyChanged).GetTypeInfo().IsAssignableFrom(property.DeclaringType.GetTypeInfo());
35-
36-
return t => ((t is null) || !notifyPropertyChanged)
37-
? Observable<Unit>.Never
38-
39-
: Observable.FromEventPattern<PropertyChangedEventHandler, PropertyChangedEventArgs>(handler => ((INotifyPropertyChanged)t).PropertyChanged += handler, handler => ((INotifyPropertyChanged)t).PropertyChanged -= handler).Where(args => args.EventArgs.PropertyName == property.Name).Select(_ => Unit.Default);
33+
return target => Observable.FromEventPattern<PropertyChangedEventHandler, PropertyChangedEventArgs>(
34+
addHandler: handler => ((INotifyPropertyChanged)target).PropertyChanged += handler,
35+
removeHandler: handler => ((INotifyPropertyChanged)target).PropertyChanged -= handler)
36+
.Where(pattern => pattern.EventArgs.PropertyName == property.Name)
37+
.Select(static _ => Unit.Default);
4038
}
4139

42-
internal static Func<object, object> CreateValueAccessor(this MemberExpression source)
40+
internal static Func<object, object?> CreateInvoker(this Expression source)
4341
{
44-
// create an expression which accepts the parent and returns the child
45-
var property = source.GetProperty();
46-
var method = property.GetMethod;
47-
48-
if (method is null)
42+
switch (source)
4943
{
50-
throw new ArgumentException("The property does not have a valid get method.", nameof(method));
51-
}
44+
case MemberExpression memberExpression:
45+
if (memberExpression.Member is not PropertyInfo property)
46+
throw new ArgumentException($"Unable to parse expression: Member type {memberExpression.Member.MemberType} is not supported", nameof(source));
5247

53-
if (source.Expression is null)
54-
{
55-
throw new ArgumentException("The source expression does not have a valid expression.", nameof(source));
56-
}
48+
if (property.GetMethod is null)
49+
throw new ArgumentException($"Unable to parse expression: Property \"{property.Name}\" has no getter", nameof(source));
50+
51+
if (property.GetMethod.IsStatic)
52+
throw new ArgumentException($"Unable to parse expression: Property \"{property.Name}\" is static", nameof(source));
53+
54+
return property.GetValue;
5755

58-
// convert the parameter i.e. the declaring class to an object
59-
var parameter = Expression.Parameter(typeof(object));
60-
var converted = Expression.Convert(parameter, source.Expression.Type);
56+
case UnaryExpression { NodeType: ExpressionType.Convert } convertExpression:
57+
return (convertExpression.Type.IsGenericType
58+
&& (convertExpression.Type.GetGenericTypeDefinition() == typeof(Nullable<>)))
59+
? static target => target
60+
: target => Convert.ChangeType(
61+
value: target,
62+
conversionType: convertExpression.Type);
6163

62-
// call the get value of the property and box it
63-
var propertyCall = Expression.Call(converted, method);
64-
var boxed = Expression.Convert(propertyCall, typeof(object));
65-
var accessorExpr = Expression.Lambda<Func<object, object>>(boxed, parameter);
64+
case null:
65+
throw new ArgumentNullException(nameof(source));
6666

67-
return accessorExpr.Compile();
67+
default:
68+
throw new ArgumentException($"Unable to parse expression: Node type {source.NodeType} not supported", nameof(source));
69+
}
6870
}
6971

7072
internal static MemberInfo GetMember<TObject, TProperty>(this Expression<Func<TObject, TProperty>> expression)
@@ -77,22 +79,29 @@ internal static MemberInfo GetMember<TObject, TProperty>(this Expression<Func<TO
7779
return GetMemberInfo(expression);
7880
}
7981

80-
internal static IEnumerable<MemberExpression> GetMemberChain<TObject, TProperty>(this Expression<Func<TObject, TProperty>> expression)
82+
internal static IEnumerable<Expression> SplitIntoSteps<TObject, TProperty>(this Expression<Func<TObject, TProperty>> expression)
8183
{
82-
var memberExpression = expression.Body as MemberExpression;
83-
while (memberExpression?.Expression is not null)
84+
var currentStep = expression.Body;
85+
while (currentStep is not null)
8486
{
85-
if (memberExpression.Expression.NodeType != ExpressionType.Parameter)
86-
{
87-
var parent = memberExpression.Expression;
88-
yield return memberExpression.Update(Expression.Parameter(parent.Type));
89-
}
90-
else
87+
switch (currentStep)
9188
{
92-
yield return memberExpression;
93-
}
89+
case MemberExpression memberExpression:
90+
yield return memberExpression;
91+
currentStep = memberExpression.Expression;
92+
break;
9493

95-
memberExpression = memberExpression.Expression as MemberExpression;
94+
case ParameterExpression:
95+
yield break;
96+
97+
case UnaryExpression { NodeType: ExpressionType.Convert } unaryExpression:
98+
yield return unaryExpression;
99+
currentStep = unaryExpression.Operand;
100+
break;
101+
102+
default:
103+
throw new ArgumentException($"Unable to parse expression: Node type {currentStep.NodeType} is not supported", nameof(expression));
104+
}
96105
}
97106
}
98107

src/DynamicData/Binding/ObservablePropertyFactory.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,11 @@ public ObservablePropertyFactory(Expression<Func<TObject, TProperty>> expression
5555
// create notifier for all parts of the property path
5656
private static IEnumerable<IObservable<Unit>> GetNotifiers(TObject source, IEnumerable<ObservablePropertyPart> chain)
5757
{
58-
object value = source;
58+
object? value = source;
5959
foreach (var metadata in chain.Reverse())
6060
{
6161
var obs = metadata.Factory(value).Publish().RefCount();
62-
value = metadata.Accessor(value);
62+
value = metadata.Invoker(value);
6363
yield return obs;
6464

6565
if (value is null)
@@ -72,10 +72,10 @@ private static IEnumerable<IObservable<Unit>> GetNotifiers(TObject source, IEnum
7272
// walk the tree and break at a null, or return the value [should reduce this to a null an expression]
7373
private static PropertyValue<TObject, TProperty> GetPropertyValue(TObject source, IEnumerable<ObservablePropertyPart> chain, Func<TObject, TProperty> valueAccessor)
7474
{
75-
object value = source;
75+
object? value = source;
7676
foreach (var metadata in chain.Reverse())
7777
{
78-
value = metadata.Accessor(value);
78+
value = metadata.Invoker(value);
7979
if (value is null)
8080
{
8181
return new PropertyValue<TObject, TProperty>(source);

src/DynamicData/Binding/ObservablePropertyFactoryCache.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,13 @@ public ObservablePropertyFactory<TObject, TProperty> GetFactory<TObject, TProper
2727
key,
2828
_ =>
2929
{
30-
var memberChain = expression.GetMemberChain().ToArray();
31-
if (memberChain.Length == 1)
30+
var steps = expression.SplitIntoSteps().ToArray();
31+
if (steps.Length == 1)
3232
{
3333
return new ObservablePropertyFactory<TObject, TProperty>(expression);
3434
}
3535

36-
var chain = memberChain.Select(m => new ObservablePropertyPart(m)).ToArray();
36+
var chain = steps.Select(m => new ObservablePropertyPart(m)).ToArray();
3737
var accessor = expression.Compile() ?? throw new ArgumentNullException(nameof(expression));
3838

3939
return new ObservablePropertyFactory<TObject, TProperty>(accessor, chain);

src/DynamicData/Binding/ObservablePropertyPart.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
namespace DynamicData.Binding;
1010

1111
[DebuggerDisplay("ObservablePropertyPart<{" + nameof(expression) + "}>")]
12-
internal sealed class ObservablePropertyPart(MemberExpression expression)
12+
internal sealed class ObservablePropertyPart(Expression expression)
1313
{
14-
public Func<object, object> Accessor { get; } = expression.CreateValueAccessor();
14+
public Func<object, object?> Invoker { get; } = expression.CreateInvoker();
1515

1616
public Func<object, IObservable<Unit>> Factory { get; } = expression.CreatePropertyChangedFactory();
1717
}

0 commit comments

Comments
 (0)