Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/instructions/dynamicdata-cache.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,8 @@ Filters Update changes based on reference equality or a custom predicate. If fil
| `WhenValueChanged(expr)` | Like above but emits just the property value (no sender). |
| `WhenAnyPropertyChanged()` | Emits the item when **any** property changes (no specific property). |

Property paths used by `WhenPropertyChanged` and `WhenValueChanged` evaluate numeric conversions before subsequent property access.

---

## Writing a New Cache Operator
Expand Down
2 changes: 2 additions & 0 deletions .github/instructions/dynamicdata-list.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,8 @@ myObservable.ToObservableChangeSet(expireAfter: item => TimeSpan.FromMinutes(5))

### Property Observation

Property paths used by `WhenPropertyChanged` and `WhenValueChanged` evaluate numeric conversions before subsequent property access.

```csharp
// Observe a property on all items (requires INotifyPropertyChanged)
list.Connect()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Copyright (c) 2011-2025 Roland Pheasant. All rights reserved.
// Roland Pheasant licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.

using System.ComponentModel;

using DynamicData.Binding;
using DynamicData.Tests.Domain;
using DynamicData.Tests.Utilities;

using FluentAssertions;

using Xunit;

namespace DynamicData.Tests.Binding;

public sealed partial class WhenPropertyChangedBehaviorFixture
{
/// <summary>Verifies that a numeric conversion is evaluated before reading a property of the converted value.</summary>
[Fact]
public void NumericConversionBeforePropertyAccess_InitialValue_IsObserved()
{
// Arrange
// An odd numerator produces an exactly representable, non-integral amount.
var amount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 4d;
var model = new ObservablePrice { Amount = amount };
var expectedScale = ((decimal)amount).Scale;

// Act
using var subscription = model.WhenValueChanged(static price => ((decimal)price.Amount).Scale)
.RecordValues(out var results);

// Assert
results.Error.Should().BeNull(because: "the next property belongs to the converted decimal, not the source double");
results.RecordedValues.Should().Equal(new[] { expectedScale }, because: "the initial value must match the supplied expression");
results.HasCompleted.Should().BeFalse(because: "further amount changes remain observable");
}

/// <summary>Verifies that property changes remain observable through a value-changing numeric conversion.</summary>
/// <param name="notifyOnInitialValue">Whether subscribing requests an initial value notification.</param>
[Theory]
[InlineData(false)]
[InlineData(true)]
public void NumericConversionBeforePropertyAccess_PropertyChanges_AreObserved(bool notifyOnInitialValue)
{
// Arrange
// Odd quarters and eighths have distinct decimal scales without floating-point rounding.
var initialAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 4d;
var changedAmount = (_randomizer.Int(1, ushort.MaxValue) | 1) / 8d;
var model = new ObservablePrice { Amount = initialAmount };
var initialScale = ((decimal)initialAmount).Scale;
var changedScale = ((decimal)changedAmount).Scale;
var expectedScales = notifyOnInitialValue ? new[] { initialScale, changedScale } : new[] { changedScale };
using var subscription = model.WhenValueChanged(static price => ((decimal)price.Amount).Scale, notifyOnInitialValue)
.RecordValues(out var results);

// Act
model.Amount = changedAmount;

// Assert
results.Error.Should().BeNull(because: "conversion must be applied on both initial and subsequent chain reads");
results.RecordedValues.Should().Equal(expectedScales, because: "notifications must match the expression and initial-value option");
results.HasCompleted.Should().BeFalse(because: "amount changes do not complete the observation");
}

/// <summary>Verifies that a numeric conversion at the end of a property path preserves initial and changed values.</summary>
[Fact]
public void NumericConversionAtLeaf_PropertyChanges_AreObserved()
{
// Arrange
var initialAmount = _randomizer.Double();
var changedAmount = initialAmount + _randomizer.Double(1, 2);
var model = new ObservablePrice { Amount = initialAmount };
var expectedAmounts = new[] { (decimal)initialAmount, (decimal)changedAmount };
using var subscription = model.WhenValueChanged(static price => (decimal)price.Amount)
.RecordValues(out var results);

// Act
model.Amount = changedAmount;

// Assert
results.Error.Should().BeNull(because: "a conversion must also remain supported as the final expression step");
results.RecordedValues.Should().Equal(expectedAmounts, because: "each observed value must be converted to the requested type");
results.HasCompleted.Should().BeFalse(because: "further amount changes remain observable");
}

/// <summary>Verifies that a reference cast preserves observation of properties on the runtime model.</summary>
[Fact]
public void ReferenceCastBeforePropertyAccess_PropertyChanges_AreObserved()
{
// Arrange
var person = Fakers.Person.Clone().WithSeed(_randomizer).Generate();
INotifyPropertyChanged model = person;
var initialAge = person.Age;
var changedAge = initialAge + _randomizer.Int(1, byte.MaxValue);
using var subscription = model.WhenValueChanged(static source => ((Person)source).Age)
.RecordValues(out var results);

// Act
person.Age = changedAge;

// Assert
results.Error.Should().BeNull(because: "supported reference casts must preserve property observation");
results.RecordedValues.Should().Equal(new[] { initialAge, changedAge }, because: "the runtime model remains the notification source");
results.HasCompleted.Should().BeFalse(because: "further age changes remain observable");
}

/// <summary>Verifies that an interface cast preserves observation after replacing an intermediate object.</summary>
[Fact]
public void InterfaceCastBeforePropertyAccess_ReplacementChildChanges_AreObserved()
{
// Arrange
var initialAge = _randomizer.Int(1, byte.MaxValue);
var replacementAge = initialAge + _randomizer.Int(1, byte.MaxValue);
var changedAge = replacementAge + _randomizer.Int(1, byte.MaxValue);
var parent = new ParentModel { Child = new ChildModel { Age = initialAge } };
var replacement = new ChildModel { Age = replacementAge };
using var subscription = parent.WhenValueChanged(static source => ((IHasAge)source.Child!).Age)
.RecordValues(out var results);
parent.Child = replacement;

// Act
replacement.Age = changedAge;

// Assert
results.Error.Should().BeNull(because: "supported interface casts must preserve nested property observation");
results.RecordedValues.Should().Equal(new[] { initialAge, replacementAge, changedAge },
because: "the interface property must follow the replacement child and its subsequent changes");
results.HasCompleted.Should().BeFalse(because: "further child changes remain observable");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,35 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;

using Bogus;

using DynamicData.Binding;
using DynamicData.Tests.Utilities;
using FluentAssertions;

using Xunit;
using Xunit.Abstractions;

namespace DynamicData.Tests.Binding;

/// <summary>
/// Single-threaded contract tests for <see cref="NotifyPropertyChangedEx.WhenPropertyChanged{TObject, TProperty}"/>:
/// handler attachment ordering, no-dedup semantics, deep-chain re-walks on swaps.
/// handler attachment ordering, expression conversions, no-dedup semantics, deep-chain re-walks on swaps.
/// </summary>
public sealed class WhenPropertyChangedBehaviorFixture
public sealed partial class WhenPropertyChangedBehaviorFixture
{
private readonly Randomizer _randomizer;

/// <summary>Initializes deterministic inputs for property-observation contracts.</summary>
/// <param name="output">Receives the seed used to generate test inputs.</param>
public WhenPropertyChangedBehaviorFixture(ITestOutputHelper output)
{
const int seed = 0x35C1_709B;
_randomizer = new Randomizer(seed);
output.WriteLine($"{nameof(WhenPropertyChangedBehaviorFixture)} seed: 0x{seed:X8}");
}

[Fact]
public void Shallow_NotifyInitialFalse_SubscribesHandlerBeforeReturning()
{
Expand Down Expand Up @@ -204,6 +219,24 @@ public int Value
}
}

/// <summary>An observable model with a numeric property, used to exercise value-changing conversions.</summary>
private sealed class ObservablePrice : INotifyPropertyChanged
{
private double _amount;

public event PropertyChangedEventHandler? PropertyChanged;

public double Amount
{
get => _amount;
set
{
_amount = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Amount)));
}
}
}

private sealed class ParentModel : INotifyPropertyChanged
{
private ChildModel? _child;
Expand Down
18 changes: 13 additions & 5 deletions src/DynamicData/Binding/ExpressionBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,19 @@ internal static Func<object, IObservable<Unit>> CreatePropertyChangedFactory(thi

return property.GetValue;

// I.E. cast operations. Since we're just dealing with everything as `object`, there's really nothing for us
// to do. If we cast from `object` to the desired type, we'll just immediately cast back to `object` to
// return. The runtime has to resolve and do the correct cast anyway, regardless of what we do here.
case UnaryExpression { NodeType: ExpressionType.Convert }:
return static target => target;
case UnaryExpression { NodeType: ExpressionType.Convert } conversion:
// Built-in reference casts and boxing/unboxing preserve a non-null chain target.
// Numeric and user-defined conversions must produce the target used by the next step.
if (conversion.Method is null && (!conversion.Operand.Type.IsValueType || !conversion.Type.IsValueType))
{
return static target => target;
}

var parameter = Expression.Parameter(typeof(object), "target");
var operand = Expression.Convert(parameter, conversion.Operand.Type);
var converted = conversion.Update(operand);

return Expression.Lambda<Func<object, object?>>(Expression.Convert(converted, typeof(object)), parameter).Compile();

case null:
throw new ArgumentNullException(nameof(source));
Expand Down
9 changes: 9 additions & 0 deletions src/DynamicData/Binding/NotifyPropertyChangedEx.cs
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,11 @@ public static class NotifyPropertyChangedEx
/// For an object like Parent.Child.Sibling, sibling is an object so if Child is null, the value null and obtainable and is returned as null.</param>
/// <returns>A observable which also notifies when the property value changes.</returns>
/// <exception cref="ArgumentNullException">propertyAccessor.</exception>
/// <remarks>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pointless documentation. "Value-changing conversions are evaluated before accessing subsequent properties in the path" is just describing how C# expressions work. Consumers should expect that when they supply an expression to a function, it is evaluated correctly.

/// Property paths can contain reference and numeric conversions. Value-changing conversions are evaluated
/// before accessing subsequent properties in the path.
/// </remarks>
/// <seealso cref="WhenValueChanged{TObject, TProperty}"/>
public static IObservable<PropertyValue<TObject, TProperty>> WhenPropertyChanged<TObject, TProperty>(this TObject source, Expression<Func<TObject, TProperty>> propertyAccessor, bool notifyOnInitialValue = true, Func<TProperty?>? fallbackValue = null)
where TObject : INotifyPropertyChanged
{
Expand All @@ -267,6 +272,10 @@ public static IObservable<PropertyValue<TObject, TProperty>> WhenPropertyChanged
/// For example when observing Parent.Child.Age, if Child is null the value is unobtainable as Age is a struct and cannot be set to Null.
/// For an object like Parent.Child.Sibling, sibling is an object so if Child is null, the value null and obtainable and is returned as null.</param>
/// <returns>An observable which emits the results.</returns>
/// <remarks>

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

/// Supports the property-path conversions described by <see cref="WhenPropertyChanged{TObject, TProperty}"/>.
/// </remarks>
/// <seealso cref="WhenPropertyChanged{TObject, TProperty}"/>
public static IObservable<TProperty?> WhenValueChanged<TObject, TProperty>(this TObject source, Expression<Func<TObject, TProperty>> propertyAccessor, bool notifyOnInitialValue = true, Func<TProperty>? fallbackValue = null)
where TObject : INotifyPropertyChanged
{
Expand Down
Loading