Browse Source

Add CompiledBinding.Create factory methods from LINQ expressions (#20443)

* Reminder for future me.

* Move compiled bindings to Avalonia base.

* Update suppressions.

* Tweaked `BindingExpressionVisitor`.

And documented public (internal) API.

* Fix ncrunch config.

* Add comprehensive unit tests for BindingExpressionVisitor.

Tests cover all supported features including property access, indexers,
AvaloniaProperty access, logical NOT, stream bindings, and type operators.
Also includes tests for unsupported operations that should throw exceptions.

Discovered bug: IsAssignableFrom check at line 139 is backwards, causing
upcasts to incorrectly throw and downcasts to be incorrectly ignored.
Bug is documented with skipped tests showing expected behavior and passing
tests documenting current broken behavior.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Fix backwards IsAssignableFrom check in BindingExpressionVisitor.

Fixed the inheritance cast check which was inverted, causing:
- Upcasts (derived→base) to incorrectly throw exceptions
- Downcasts (base→derived) to be incorrectly ignored

Changed line 139 from:
  node.Operand.Type.IsAssignableFrom(node.Type)
to:
  node.Type.IsAssignableFrom(node.Operand.Type)

This correctly identifies safe upcasts (which are ignored) vs unsafe
downcasts (which throw exceptions).

Updated tests to remove skip attributes and removed the temporary tests
that documented the broken behavior. All 33 tests now pass.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Allow downcasts and all reference type casts in binding expressions.

Changed the cast handling to allow both upcasts and downcasts for reference
types. This makes casts actually useful in binding expressions while still
rejecting value type conversions that would require actual conversion logic.

The logic now checks:
- Both types must be reference types (not value types)
- One type must be assignable to/from the other (either direction)

This allows practical scenarios like:
- Upcasts: (BaseClass)derived
- Downcasts: (DerivedClass)baseInstance
- Casting through object: (TargetType)(object)source

The binding system will gracefully handle any runtime type mismatches.

Updated tests to verify downcasts are allowed and added test for casting
through object pattern.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Add clarifying test for cast transparency in binding expressions.

Added test showing that casts are transparent in property chains.
For example, ((TestClass)x).Property produces just one node for the
property access - the cast doesn't create additional nodes.

This clarifies that empty nodes for (TestClass)x is correct behavior:
- Empty nodes = bind to source directly
- The cast is just a type annotation, transparent to the binding path

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Fix: Casts should create ReflectionTypeCastNode, not be transparent.

Casts were incorrectly being treated as transparent (not creating nodes),
but CompiledBindingPath uses TypeCastPathElement which creates FuncTransformNode.
For consistency and correctness, BindingExpressionVisitor should also create
cast nodes using ReflectionTypeCastNode.

Changes:
- Convert expressions now create ReflectionTypeCastNode
- TypeAs expressions now create ReflectionTypeCastNode
- Both upcasts and downcasts create nodes (runtime checks handle failures)

Examples:
- x => (TestClass)x → 1 node (cast)
- x => ((TestClass)x).Prop → 2 nodes (cast + property)
- x => x.Child as object → 2 nodes (property + cast)

This matches the behavior of CompiledBindingPathBuilder.TypeCast<T>().

Updated all related tests to verify cast nodes are created.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Use compiled cast functions instead of reflection-based type checks.

Changed from ReflectionTypeCastNode (which uses Type.IsInstanceOfType) to
FuncTransformNode with a compiled cast function. This matches how
CompiledBindingPath handles TypeCastPathElement and provides better
performance by avoiding reflection.

The CreateCastFunc method compiles an expression `(object? obj) => obj as T`
which generates efficient IL similar to the 'is T' pattern used in
TypeCastPathElement<T>, rather than using reflection-based type checks.

Performance improvement:
- Before: Type.IsInstanceOfType() reflection call for each cast
- After: Compiled IL using 'as' operator (same as TypeCastPathElement<T>)

Updated tests to expect FuncTransformNode instead of ReflectionTypeCastNode.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Reuse TypeCastPathElement<T> cast function directly.

Instead of compiling our own cast expression, extract the Cast delegate
directly from TypeCastPathElement<T>. This ensures we use the exact same
code path as CompiledBindingPath, avoiding any potential behavioral
differences and code duplication.

Benefits:
- Code reuse - single implementation of cast logic
- Consistency - same behavior as CompiledBindingPathBuilder.TypeCast<T>()
- No duplicate expression compilation logic

Implementation uses reflection to create the closed generic type and
extract the pre-compiled Cast delegate, which is still more efficient
than reflection-based type checks at runtime.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Revert to lambda compilation approach for cast functions.

Reverted from using TypeCastPathElement<T> back to compiling lambda
expressions directly. The lambda approach is cleaner and more straightforward:

Benefits of lambda compilation:
- No Activator.CreateInstance call (avoids reflection for construction)
- More direct - creates exactly what we need
- No coupling to TypeCastPathElement internal implementation
- Simpler code flow

The compiled lambda generates the same efficient IL code (using 'as' operator)
as TypeCastPathElement<T> does, just without the indirection.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Code cleanup: Fix XML docs and remove unused usings.

- Changed CreateCastFunc XML docs to use <remarks> tag for better formatting
- Removed unused 'using Avalonia.Data;' from tests
- Removed redundant '#nullable enable' from tests

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Refactor BindingExpressionVisitor to use CompiledBindingPathBuilder.

Changes BindingExpressionVisitor to use CompiledBindingPathBuilder instead of
directly creating ExpressionNode instances, unifying the approach with
compile-time XAML bindings. Adds new BuildPath() method that returns
CompiledBindingPath, while maintaining backwards compatibility through the
existing BuildNodes() wrapper method.

Key changes:
- Replace internal List<ExpressionNode> with CompiledBindingPathBuilder
- Refactor all visitor methods to call builder methods
- Add accessor factory methods and implementations for property access
- Support AvaloniaProperty, CLR properties, arrays, indexers, streams, casts
- Update tests to expect PropertyAccessorNode, StreamNode, ArrayIndexerNode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Remove CompiledBindingPathFromExpressionBuilder in favor of BindingExpressionVisitor.

Now that BindingExpressionVisitor has been refactored to use
CompiledBindingPathBuilder and provides a BuildPath() method, the test-only
CompiledBindingPathFromExpressionBuilder class is redundant and can be removed.

Changes:
- Replace CompiledBindingPathFromExpressionBuilder.Build() with
  BindingExpressionVisitor<TIn>.BuildPath() in BindingExpressionTests
- Delete CompiledBindingPathFromExpressionBuilder.cs test file
- All 122 tests in BindingExpressionTests.Compiled continue to pass

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Move test-only methods from production code to test extensions.

Removes BindingExpression.Create and BindingExpressionVisitor.BuildNodes
from production code since they were only used by unit tests.

Changes:
- Remove BindingExpression.Create<TIn, TOut> method
- Remove BindingExpressionVisitor.BuildNodes method
- Add BindingExpressionVisitorExtensions in Base.UnitTests
- Add BindingExpressionExtensions in LeakTests
- Add static helper methods in test classes to reduce noise

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Add public CompiledBinding.Create factory methods from LINQ expressions.

Adds two static factory methods to create CompiledBinding instances from
lambda expressions using BindingExpressionVisitor.BuildPath():

- Create<TIn, TOut>(expression, converter, mode)
  Creates binding without explicit source (uses DataContext)

- Create<TIn, TOut>(source, expression, converter, mode)
  Creates binding with explicit source

This provides a type-safe, ergonomic API for creating compiled bindings
from code without string-based paths.

Usage:
  var binding = CompiledBinding.Create(viewModel, vm => vm.Title);
  textBlock.Bind(TextBlock.TextProperty, binding);

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* Merge CompiledBinding.Create overloads and add all binding property parameters.

Consolidates the two Create method overloads into a single method with source
as an optional parameter. Adds optional parameters for all CompiledBinding
properties (priority, converterCulture, converterParameter, fallbackValue,
stringFormat, targetNullValue, updateSourceTrigger, delay) per PR feedback.

Properties that default to AvaloniaProperty.UnsetValue (source, fallbackValue,
targetNullValue) use null-coalescing to convert null parameter values.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* (Re-)update suppressions.

* Add missing using.

* Fix ncrunch build.

* Remove file I committed by accident.

* PR feedback.

* Store static members outside generic class.

Based on PR feedback.

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
master
Steven Kirk 16 hours ago
committed by GitHub
parent
commit
70f3bef705
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      .ncrunch/Avalonia.HarfBuzz.v3.ncrunchproject
  2. 3
      Avalonia.v3.ncrunchsolution
  3. 91
      src/Avalonia.Base/Data/CompiledBinding.cs
  4. 53
      src/Avalonia.Base/Data/Core/BindingExpression.cs
  5. 402
      src/Avalonia.Base/Data/Core/Parsers/BindingExpressionVisitor.cs
  6. 20
      src/Avalonia.Base/Data/Core/Parsers/BindingExpressionVisitorMembers.cs
  7. 162
      tests/Avalonia.Base.UnitTests/Data/CompiledBindingTests_Create.cs
  8. 2
      tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.cs
  9. 313
      tests/Avalonia.Base.UnitTests/Data/Core/CompiledBindingPathFromExpressionBuilder.cs
  10. 25
      tests/Avalonia.Base.UnitTests/Data/Core/Parsers/BindingExpressionVisitorExtensions.cs
  11. 528
      tests/Avalonia.Base.UnitTests/Data/Core/Parsers/BindingExpressionVisitorTests.cs
  12. 56
      tests/Avalonia.LeakTests/BindingExpressionExtensions.cs
  13. 42
      tests/Avalonia.LeakTests/BindingExpressionTests.cs

5
.ncrunch/Avalonia.HarfBuzz.v3.ncrunchproject

@ -0,0 +1,5 @@
<ProjectConfiguration>
<Settings>
<IgnoreThisComponentCompletely>False</IgnoreThisComponentCompletely>
</Settings>
</ProjectConfiguration>

3
Avalonia.v3.ncrunchsolution

@ -13,8 +13,9 @@
<Value>TargetFrameworks = net10.0</Value> <Value>TargetFrameworks = net10.0</Value>
</CustomBuildProperties> </CustomBuildProperties>
<EnableRDI>False</EnableRDI> <EnableRDI>False</EnableRDI>
<IgnoreThisComponentCompletely>True</IgnoreThisComponentCompletely>
<ProjectConfigStoragePathRelativeToSolutionDir>.ncrunch</ProjectConfigStoragePathRelativeToSolutionDir> <ProjectConfigStoragePathRelativeToSolutionDir>.ncrunch</ProjectConfigStoragePathRelativeToSolutionDir>
<RdiConfigured>True</RdiConfigured> <RdiConfigured>True</RdiConfigured>
<SolutionConfigured>True</SolutionConfigured> <SolutionConfigured>True</SolutionConfigured>
</Settings> </Settings>
</SolutionConfiguration> </SolutionConfiguration>

91
src/Avalonia.Base/Data/CompiledBinding.cs

@ -1,7 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Globalization; using System.Globalization;
using System.Linq.Expressions;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Data.Converters; using Avalonia.Data.Converters;
using Avalonia.Data.Core; using Avalonia.Data.Core;
@ -27,6 +29,95 @@ public class CompiledBinding : BindingBase
/// <param name="path">The binding path.</param> /// <param name="path">The binding path.</param>
public CompiledBinding(CompiledBindingPath path) => Path = path; public CompiledBinding(CompiledBindingPath path) => Path = path;
/// <summary>
/// Creates a <see cref="CompiledBinding"/> from a lambda expression.
/// </summary>
/// <typeparam name="TIn">The input type of the binding expression.</typeparam>
/// <typeparam name="TOut">The output type of the binding expression.</typeparam>
/// <param name="expression">
/// The lambda expression representing the binding path
/// (e.g., <c>vm => vm.PropertyName</c>).
/// </param>
/// <param name="source"
/// >The source object for the binding. If null, uses the target's DataContext.
/// </param>
/// <param name="converter">
/// Optional value converter to transform values between source and target.
/// </param>
/// <param name="mode">
/// The binding mode. Default is <see cref="BindingMode.Default"/> which resolves to the
/// property's default binding mode.
/// </param>
/// <param name="priority">The binding priority.</param>
/// <param name="converterCulture">The culture in which to evaluate the converter.</param>
/// <param name="converterParameter">A parameter to pass to the converter.</param>
/// <param name="fallbackValue">
/// The value to use when the binding is unable to produce a value.
/// </param>
/// <param name="stringFormat">The string format for the binding result.</param>
/// <param name="targetNullValue">The value to use when the binding result is null.</param>
/// <param name="updateSourceTrigger">
/// The timing of binding source updates for TwoWay/OneWayToSource bindings.
/// </param>
/// <param name="delay">
/// The amount of time, in milliseconds, to wait before updating the binding source.
/// </param>
/// <returns>
/// A configured <see cref="CompiledBinding"/> instance ready to be applied to a property.
/// </returns>
/// <exception cref="ExpressionParseException">
/// Thrown when the expression contains unsupported operations or invalid syntax for binding
/// expressions.
/// </exception>
/// <remarks>
/// This builds a <see cref="CompiledBinding"/> with a path described by a lambda expression.
/// The resulting binding avoids reflection for property access, providing better performance
/// than reflection-based bindings.
///
/// Supported expressions include:
/// <list type="bullet">
/// <item>Property access: <c>x => x.Property</c></item>
/// <item>Nested properties: <c>x => x.Property.Nested</c></item>
/// <item>Indexers: <c>x => x.Items[0]</c></item>
/// <item>Type casts: <c>x => ((DerivedType)x).Property</c></item>
/// <item>Logical NOT: <c>x => !x.BoolProperty</c></item>
/// <item>Stream bindings: <c>x => x.TaskProperty</c> (Task/Observable)</item>
/// <item>AvaloniaProperty access: <c>x => x[MyProperty]</c></item>
/// </list>
/// </remarks>
[RequiresDynamicCode(TrimmingMessages.ExpressionNodeRequiresDynamicCodeMessage)]
[RequiresUnreferencedCode(TrimmingMessages.ExpressionNodeRequiresUnreferencedCodeMessage)]
public static CompiledBinding Create<TIn, TOut>(
Expression<Func<TIn, TOut>> expression,
object? source = null,
IValueConverter? converter = null,
BindingMode mode = BindingMode.Default,
BindingPriority priority = BindingPriority.LocalValue,
CultureInfo? converterCulture = null,
object? converterParameter = null,
object? fallbackValue = null,
string? stringFormat = null,
object? targetNullValue = null,
UpdateSourceTrigger updateSourceTrigger = UpdateSourceTrigger.Default,
int delay = 0)
{
var path = BindingExpressionVisitor<TIn>.BuildPath(expression);
return new CompiledBinding(path)
{
Source = source ?? AvaloniaProperty.UnsetValue,
Converter = converter,
ConverterCulture = converterCulture,
ConverterParameter = converterParameter,
FallbackValue = fallbackValue ?? AvaloniaProperty.UnsetValue,
Mode = mode,
Priority = priority,
StringFormat = stringFormat,
TargetNullValue = targetNullValue ?? AvaloniaProperty.UnsetValue,
UpdateSourceTrigger = updateSourceTrigger,
Delay = delay
};
}
/// <summary> /// <summary>
/// Gets or sets the amount of time, in milliseconds, to wait before updating the binding /// Gets or sets the amount of time, in milliseconds, to wait before updating the binding
/// source after the value on the target changes. /// source after the value on the target changes.

53
src/Avalonia.Base/Data/Core/BindingExpression.cs

@ -173,59 +173,6 @@ internal class BindingExpression : UntypedBindingExpressionBase, IDescription, I
_nodes[0].SetSource(source, null); _nodes[0].SetSource(source, null);
} }
/// <summary>
/// Creates an <see cref="BindingExpression"/> from an expression tree.
/// </summary>
/// <typeparam name="TIn">The input type of the binding expression.</typeparam>
/// <typeparam name="TOut">The output type of the binding expression.</typeparam>
/// <param name="source">The source from which the binding value will be read.</param>
/// <param name="expression">The expression representing the binding path.</param>
/// <param name="converter">The converter to use.</param>
/// <param name="converterCulture">The converter culture to use.</param>
/// <param name="converterParameter">The converter parameter.</param>
/// <param name="enableDataValidation">Whether data validation should be enabled for the binding.</param>
/// <param name="fallbackValue">The fallback value.</param>
/// <param name="mode">The binding mode.</param>
/// <param name="priority">The binding priority.</param>
/// <param name="targetNullValue">The null target value.</param>
/// <param name="allowReflection">Whether to allow reflection for target type conversion.</param>
[RequiresUnreferencedCode(TrimmingMessages.ExpressionNodeRequiresUnreferencedCodeMessage)]
#if NET8_0_OR_GREATER
[RequiresDynamicCode(TrimmingMessages.ExpressionNodeRequiresDynamicCodeMessage)]
#endif
internal static BindingExpression Create<TIn, TOut>(
TIn source,
Expression<Func<TIn, TOut>> expression,
IValueConverter? converter = null,
CultureInfo? converterCulture = null,
object? converterParameter = null,
bool enableDataValidation = false,
Optional<object?> fallbackValue = default,
BindingMode mode = BindingMode.OneWay,
BindingPriority priority = BindingPriority.LocalValue,
object? targetNullValue = null,
bool allowReflection = true)
where TIn : class?
{
var nodes = BindingExpressionVisitor<TIn>.BuildNodes(expression, enableDataValidation);
var fallback = fallbackValue.HasValue ? fallbackValue.Value : AvaloniaProperty.UnsetValue;
return new BindingExpression(
source,
nodes,
fallback,
converter: converter,
converterCulture: converterCulture,
converterParameter: converterParameter,
enableDataValidation: enableDataValidation,
mode: mode,
priority: priority,
targetNullValue: targetNullValue,
targetTypeConverter: allowReflection ?
TargetTypeConverter.GetReflectionConverter() :
TargetTypeConverter.GetDefaultConverter());
}
/// <summary> /// <summary>
/// Called by an <see cref="ExpressionNode"/> belonging to this binding when its /// Called by an <see cref="ExpressionNode"/> belonging to this binding when its
/// <see cref="ExpressionNode.Value"/> changes. /// <see cref="ExpressionNode.Value"/> changes.

402
src/Avalonia.Base/Data/Core/Parsers/BindingExpressionVisitor.cs

@ -1,47 +1,59 @@
using System; using System;
using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Reflection; using System.Reflection;
using System.Threading.Tasks;
using Avalonia.Data.Core.ExpressionNodes; using Avalonia.Data.Core.ExpressionNodes;
using Avalonia.Data.Core.ExpressionNodes.Reflection; using Avalonia.Data.Core.ExpressionNodes.Reflection;
using Avalonia.Data.Core.Plugins;
using Avalonia.Reactive;
using Avalonia.Utilities;
namespace Avalonia.Data.Core.Parsers; namespace Avalonia.Data.Core.Parsers;
[RequiresUnreferencedCode(TrimmingMessages.ExpressionNodeRequiresUnreferencedCodeMessage)] /// <summary>
#if NET8_0_OR_GREATER /// Visits and processes a LINQ expression to build a compiled binding path.
/// </summary>
/// <typeparam name="TIn">The input parameter type for the binding expression.</typeparam>
/// <remarks>
/// This visitor traverses lambda expressions used in compiled bindings and uses
/// <see cref="CompiledBindingPathBuilder"/> to construct a <see cref="CompiledBindingPath"/>, which
/// can then be converted into <see cref="ExpressionNode"/> instances. It supports property access,
/// indexers, AvaloniaProperty access, stream bindings, type casts, and logical operators.
/// </remarks>
[RequiresDynamicCode(TrimmingMessages.ExpressionNodeRequiresDynamicCodeMessage)] [RequiresDynamicCode(TrimmingMessages.ExpressionNodeRequiresDynamicCodeMessage)]
#endif [RequiresUnreferencedCode(TrimmingMessages.ExpressionNodeRequiresUnreferencedCodeMessage)]
internal class BindingExpressionVisitor<TIn> : ExpressionVisitor internal class BindingExpressionVisitor<TIn>(LambdaExpression expression) : ExpressionVisitor
{ {
private static readonly PropertyInfo AvaloniaObjectIndexer;
private static readonly MethodInfo CreateDelegateMethod;
private const string IndexerGetterName = "get_Item"; private const string IndexerGetterName = "get_Item";
private const string MultiDimensionalArrayGetterMethodName = "Get"; private const string MultiDimensionalArrayGetterMethodName = "Get";
private readonly bool _enableDataValidation; private readonly LambdaExpression _rootExpression = expression;
private readonly LambdaExpression _rootExpression; private readonly CompiledBindingPathBuilder _builder = new();
private readonly List<ExpressionNode> _nodes = new();
private Expression? _head; private Expression? _head;
public BindingExpressionVisitor(LambdaExpression expression, bool enableDataValidation) /// <summary>
{ /// Builds a compiled binding path from a lambda expression.
_rootExpression = expression; /// </summary>
_enableDataValidation = enableDataValidation; /// <typeparam name="TOut">The output type of the binding expression.</typeparam>
} /// <param name="expression">
/// The lambda expression to parse and convert into a binding path.
static BindingExpressionVisitor() /// </param>
{ /// <returns>
AvaloniaObjectIndexer = typeof(AvaloniaObject).GetProperty("Item", new[] { typeof(AvaloniaProperty) })!; /// A <see cref="CompiledBindingPath"/> representing the binding path.
CreateDelegateMethod = typeof(MethodInfo).GetMethod("CreateDelegate", new[] { typeof(Type), typeof(object) })!; /// </returns>
} /// <exception cref="ExpressionParseException">
/// Thrown when the expression contains unsupported operations or invalid syntax for binding
public static List<ExpressionNode> BuildNodes<TOut>(Expression<Func<TIn, TOut>> expression, bool enableDataValidation) /// expressions.
/// </exception>
public static CompiledBindingPath BuildPath<TOut>(Expression<Func<TIn, TOut>> expression)
{ {
var visitor = new BindingExpressionVisitor<TIn>(expression, enableDataValidation); var visitor = new BindingExpressionVisitor<TIn>(expression);
visitor.Visit(expression); visitor.Visit(expression);
return visitor._nodes; return visitor._builder.Build();
} }
protected override Expression VisitBinary(BinaryExpression node) protected override Expression VisitBinary(BinaryExpression node)
@ -49,33 +61,64 @@ internal class BindingExpressionVisitor<TIn> : ExpressionVisitor
// Indexers require more work since the compiler doesn't generate IndexExpressions: // Indexers require more work since the compiler doesn't generate IndexExpressions:
// they weren't in System.Linq.Expressions v1 and so must be generated manually. // they weren't in System.Linq.Expressions v1 and so must be generated manually.
if (node.NodeType == ExpressionType.ArrayIndex) if (node.NodeType == ExpressionType.ArrayIndex)
return Visit(Expression.MakeIndex(node.Left, null, new[] { node.Right })); return Visit(Expression.MakeIndex(node.Left, null, [node.Right]));
throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}."); throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}.");
} }
protected override Expression VisitIndex(IndexExpression node) protected override Expression VisitIndex(IndexExpression node)
{ {
if (node.Indexer == AvaloniaObjectIndexer) if (node.Indexer == BindingExpressionVisitorMembers.AvaloniaObjectIndexer)
{ {
var property = GetValue<AvaloniaProperty>(node.Arguments[0]); var property = GetValue<AvaloniaProperty>(node.Arguments[0]);
return Add(node.Object, node, new AvaloniaPropertyAccessorNode(property)); return Add(node.Object, node, x => x.Property(property, CreateAvaloniaPropertyAccessor));
} }
else else if (node.Object?.Type.IsArray == true)
{
var indexes = node.Arguments.Select(GetValue<int>).ToArray();
return Add(node.Object, node, x => x.ArrayElement(indexes, node.Type));
}
else if (node.Indexer?.GetMethod is not null &&
node.Arguments.Count == 1 &&
node.Arguments[0].Type == typeof(int))
{ {
return Add(node.Object, node, new ExpressionTreeIndexerNode(node)); var getMethod = node.Indexer.GetMethod;
var setMethod = node.Indexer.SetMethod;
var index = GetValue<int>(node.Arguments[0]);
var info = new ClrPropertyInfo(
CommonPropertyNames.IndexerName,
x => getMethod.Invoke(x, new object[] { index }),
setMethod is not null ? (o, v) => setMethod.Invoke(o, new[] { index, v }) : null,
getMethod.ReturnType);
return Add(node.Object, node, x => x.Property(
info,
(weakRef, propInfo) => CreateIndexerPropertyAccessor(weakRef, propInfo, index)));
} }
else if (node.Indexer?.GetMethod is not null)
{
var getMethod = node.Indexer.GetMethod;
var setMethod = node.Indexer?.SetMethod;
var indexes = node.Arguments.Select(GetValue<object>).ToArray();
var info = new ClrPropertyInfo(
CommonPropertyNames.IndexerName,
x => getMethod.Invoke(x, indexes),
setMethod is not null ? (o, v) => setMethod.Invoke(o, indexes.Append(v).ToArray()) : null,
getMethod.ReturnType);
return Add(node.Object, node, x => x.Property(
info,
CreateInpcPropertyAccessor));
}
throw new ExpressionParseException(0, $"Invalid indexer in binding expression: {node.NodeType}.");
} }
protected override Expression VisitMember(MemberExpression node) protected override Expression VisitMember(MemberExpression node)
{ {
switch (node.Member.MemberType) return node.Member.MemberType switch
{ {
case MemberTypes.Property: MemberTypes.Property => AddPropertyNode(node),
return Add(node.Expression, node, new DynamicPluginPropertyAccessorNode(node.Member.Name, acceptsNull: false)); _ => throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}."),
default: };
throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}.");
}
} }
protected override Expression VisitMethodCall(MethodCallExpression node) protected override Expression VisitMethodCall(MethodCallExpression node)
@ -90,20 +133,43 @@ internal class BindingExpressionVisitor<TIn> : ExpressionVisitor
else if (method.Name == MultiDimensionalArrayGetterMethodName && else if (method.Name == MultiDimensionalArrayGetterMethodName &&
node.Object is not null) node.Object is not null)
{ {
var expression = Expression.MakeIndex(node.Object, null, node.Arguments); var indexes = node.Arguments.Select(GetValue<int>).ToArray();
return Add(node.Object, node, new ExpressionTreeIndexerNode(expression)); return Add(node.Object, node, x => x.ArrayElement(indexes, node.Type));
} }
else if (method.Name.StartsWith(StreamBindingExtensions.StreamBindingName) && else if (method.Name.StartsWith(StreamBindingExtensions.StreamBindingName) &&
method.DeclaringType == typeof(StreamBindingExtensions)) method.DeclaringType == typeof(StreamBindingExtensions))
{ {
var instance = node.Method.IsStatic ? node.Arguments[0] : node.Object; var instance = node.Method.IsStatic ? node.Arguments[0] : node.Object;
Add(instance, node, new DynamicPluginStreamNode()); var instanceType = instance?.Type;
return node; var genericArgs = method.GetGenericArguments();
var genericArg = genericArgs.Length > 0 ? genericArgs[0] : typeof(object);
if (instanceType == typeof(Task) ||
(instanceType?.IsGenericType == true &&
instanceType.GetGenericTypeDefinition() == typeof(Task<>) &&
genericArg.IsAssignableFrom(instanceType.GetGenericArguments()[0])))
{
var builderMethod = typeof(CompiledBindingPathBuilder)
.GetMethod(nameof(CompiledBindingPathBuilder.StreamTask))!
.MakeGenericMethod(genericArg);
return Add(instance, node, x => builderMethod.Invoke(x, null));
}
else if (typeof(IObservable<>).MakeGenericType(genericArg).IsAssignableFrom(instance?.Type))
{
var builderMethod = typeof(CompiledBindingPathBuilder)
.GetMethod(nameof(CompiledBindingPathBuilder.StreamObservable))!
.MakeGenericMethod(genericArg);
return Add(instance, node, x => builderMethod.Invoke(x, null));
}
} }
else if (method == CreateDelegateMethod) else if (method == BindingExpressionVisitorMembers.CreateDelegateMethod)
{ {
var accessor = new DynamicPluginPropertyAccessorNode(GetValue<MethodInfo>(node.Object!).Name, acceptsNull: false); var methodInfo = GetValue<MethodInfo>(node.Object!);
return Add(node.Arguments[1], node, accessor); var delegateType = GetValue<Type>(node.Arguments[0]);
return Add(node.Arguments[1], node, x => x.Method(
methodInfo.MethodHandle,
delegateType.TypeHandle,
acceptsNull: false));
} }
throw new ExpressionParseException(0, $"Invalid method call in binding expression: '{node.Method.DeclaringType}.{node.Method.Name}'."); throw new ExpressionParseException(0, $"Invalid method call in binding expression: '{node.Method.DeclaringType}.{node.Method.Name}'.");
@ -120,20 +186,26 @@ internal class BindingExpressionVisitor<TIn> : ExpressionVisitor
{ {
if (node.NodeType == ExpressionType.Not && node.Type == typeof(bool)) if (node.NodeType == ExpressionType.Not && node.Type == typeof(bool))
{ {
return Add(node.Operand, node, new LogicalNotNode()); return Add(node.Operand, node, x => x.Not());
} }
else if (node.NodeType == ExpressionType.Convert) else if (node.NodeType == ExpressionType.Convert)
{ {
if (node.Operand.Type.IsAssignableFrom(node.Type)) // Allow reference type casts (both upcasts and downcasts) but reject value type conversions
if (!node.Type.IsValueType && !node.Operand.Type.IsValueType &&
(node.Type.IsAssignableFrom(node.Operand.Type) || node.Operand.Type.IsAssignableFrom(node.Type)))
{ {
// Ignore inheritance casts var castMethod = typeof(CompiledBindingPathBuilder)
return _head = base.VisitUnary(node); .GetMethod(nameof(CompiledBindingPathBuilder.TypeCast))!
.MakeGenericMethod(node.Type);
return Add(node.Operand, node, x => castMethod.Invoke(x, null));
} }
} }
else if (node.NodeType == ExpressionType.TypeAs) else if (node.NodeType == ExpressionType.TypeAs)
{ {
// Ignore as operator. var castMethod = typeof(CompiledBindingPathBuilder)
return _head = base.VisitUnary(node); .GetMethod(nameof(CompiledBindingPathBuilder.TypeCast))!
.MakeGenericMethod(node.Type);
return Add(node.Operand, node, x => castMethod.Invoke(x, null));
} }
throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}."); throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}.");
@ -146,7 +218,7 @@ internal class BindingExpressionVisitor<TIn> : ExpressionVisitor
protected override CatchBlock VisitCatchBlock(CatchBlock node) protected override CatchBlock VisitCatchBlock(CatchBlock node)
{ {
throw new ExpressionParseException(0, $"Catch blocks are not allowed in binding expressions."); throw new ExpressionParseException(0, "Catch blocks are not allowed in binding expressions.");
} }
protected override Expression VisitConditional(ConditionalExpression node) protected override Expression VisitConditional(ConditionalExpression node)
@ -156,17 +228,17 @@ internal class BindingExpressionVisitor<TIn> : ExpressionVisitor
protected override Expression VisitDynamic(DynamicExpression node) protected override Expression VisitDynamic(DynamicExpression node)
{ {
throw new ExpressionParseException(0, $"Dynamic expressions are not allowed in binding expressions."); throw new ExpressionParseException(0, "Dynamic expressions are not allowed in binding expressions.");
} }
protected override ElementInit VisitElementInit(ElementInit node) protected override ElementInit VisitElementInit(ElementInit node)
{ {
throw new ExpressionParseException(0, $"Element init expressions are not valid in a binding expression."); throw new ExpressionParseException(0, "Element init expressions are not valid in a binding expression.");
} }
protected override Expression VisitGoto(GotoExpression node) protected override Expression VisitGoto(GotoExpression node)
{ {
throw new ExpressionParseException(0, $"Goto expressions not supported in binding expressions."); throw new ExpressionParseException(0, "Goto expressions are not supported in binding expressions.");
} }
protected override Expression VisitInvocation(InvocationExpression node) protected override Expression VisitInvocation(InvocationExpression node)
@ -191,7 +263,7 @@ internal class BindingExpressionVisitor<TIn> : ExpressionVisitor
protected override MemberAssignment VisitMemberAssignment(MemberAssignment node) protected override MemberAssignment VisitMemberAssignment(MemberAssignment node)
{ {
throw new ExpressionParseException(0, $"Member assignments not supported in binding expressions."); throw new ExpressionParseException(0, "Member assignments not supported in binding expressions.");
} }
protected override Expression VisitSwitch(SwitchExpression node) protected override Expression VisitSwitch(SwitchExpression node)
@ -209,17 +281,69 @@ internal class BindingExpressionVisitor<TIn> : ExpressionVisitor
throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}."); throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}.");
} }
private Expression Add(Expression? instance, Expression expression, ExpressionNode node) private Expression Add(Expression? instance, Expression expression, Action<CompiledBindingPathBuilder> build)
{ {
var visited = Visit(instance); var visited = Visit(instance);
if (visited != _head) if (visited != _head)
{
throw new ExpressionParseException( throw new ExpressionParseException(
0, 0,
$"Unable to parse '{expression}': expected an instance of '{_head}' but got '{visited}'."); $"Unable to parse '{expression}': expected an instance of '{_head}' but got '{visited}'.");
_nodes.Add(node); }
build(_builder);
return _head = expression; return _head = expression;
} }
private Expression AddPropertyNode(MemberExpression node)
{
// Check if it's an AvaloniaProperty accessed via CLR wrapper
if (typeof(AvaloniaObject).IsAssignableFrom(node.Expression?.Type) &&
AvaloniaPropertyRegistry.Instance.FindRegistered(node.Expression.Type, node.Member.Name) is { } avaloniaProperty)
{
return Add(
node.Expression,
node,
x => x.Property(avaloniaProperty, CreateAvaloniaPropertyAccessor));
}
else
{
var property = (PropertyInfo)node.Member;
var info = new ClrPropertyInfo(
property.Name,
CreateGetter(property),
CreateSetter(property),
property.PropertyType);
return Add(node.Expression, node, x => x.Property(info, CreateInpcPropertyAccessor));
}
}
private static Func<object, object>? CreateGetter(PropertyInfo info)
{
if (info.GetMethod == null)
return null;
var target = Expression.Parameter(typeof(object), "target");
return Expression.Lambda<Func<object, object>>(
Expression.Convert(Expression.Call(Expression.Convert(target, info.DeclaringType!), info.GetMethod),
typeof(object)),
target)
.Compile();
}
private static Action<object, object?>? CreateSetter(PropertyInfo info)
{
if (info.SetMethod == null)
return null;
var target = Expression.Parameter(typeof(object), "target");
var value = Expression.Parameter(typeof(object), "value");
return Expression.Lambda<Action<object, object?>>(
Expression.Call(Expression.Convert(target, info.DeclaringType!), info.SetMethod,
Expression.Convert(value, info.SetMethod.GetParameters()[0].ParameterType)),
target, value)
.Compile();
}
private static T GetValue<T>(Expression expr) private static T GetValue<T>(Expression expr)
{ {
if (expr is ConstantExpression constant) if (expr is ConstantExpression constant)
@ -232,4 +356,168 @@ internal class BindingExpressionVisitor<TIn> : ExpressionVisitor
var type = method.DeclaringType; var type = method.DeclaringType;
return type?.GetRuntimeProperties().FirstOrDefault(prop => prop.GetMethod == method); return type?.GetRuntimeProperties().FirstOrDefault(prop => prop.GetMethod == method);
} }
// Accessor factory methods
private static IPropertyAccessor CreateInpcPropertyAccessor(WeakReference<object?> target, IPropertyInfo property)
=> new InpcPropertyAccessor(target, property);
private static IPropertyAccessor CreateAvaloniaPropertyAccessor(WeakReference<object?> target, IPropertyInfo property)
=> new AvaloniaPropertyAccessor(
new WeakReference<AvaloniaObject?>((AvaloniaObject?)(target.TryGetTarget(out var o) ? o : null)),
(AvaloniaProperty)property);
private static IPropertyAccessor CreateIndexerPropertyAccessor(WeakReference<object?> target, IPropertyInfo property, int argument)
=> new IndexerAccessor(target, property, argument);
// Accessor implementations
private class AvaloniaPropertyAccessor : PropertyAccessorBase, IWeakEventSubscriber<AvaloniaPropertyChangedEventArgs>
{
private readonly WeakReference<AvaloniaObject?> _reference;
private readonly AvaloniaProperty _property;
public AvaloniaPropertyAccessor(WeakReference<AvaloniaObject?> reference, AvaloniaProperty property)
{
_reference = reference ?? throw new ArgumentNullException(nameof(reference));
_property = property ?? throw new ArgumentNullException(nameof(property));
}
public override Type PropertyType => _property.PropertyType;
public override object? Value => _reference.TryGetTarget(out var instance) ? instance?.GetValue(_property) : null;
public override bool SetValue(object? value, BindingPriority priority)
{
if (!_property.IsReadOnly && _reference.TryGetTarget(out var instance))
{
instance.SetValue(_property, value, priority);
return true;
}
return false;
}
public void OnEvent(object? sender, WeakEvent ev, AvaloniaPropertyChangedEventArgs e)
{
if (e.Property == _property)
PublishValue(Value);
}
protected override void SubscribeCore()
{
if (_reference.TryGetTarget(out var reference) && reference is not null)
{
PublishValue(reference.GetValue(_property));
WeakEvents.AvaloniaPropertyChanged.Subscribe(reference, this);
}
}
protected override void UnsubscribeCore()
{
if (_reference.TryGetTarget(out var reference) && reference is not null)
WeakEvents.AvaloniaPropertyChanged.Unsubscribe(reference, this);
}
}
private class InpcPropertyAccessor : PropertyAccessorBase, IWeakEventSubscriber<PropertyChangedEventArgs>
{
protected readonly WeakReference<object?> _reference;
private readonly IPropertyInfo _property;
public InpcPropertyAccessor(WeakReference<object?> reference, IPropertyInfo property)
{
_reference = reference ?? throw new ArgumentNullException(nameof(reference));
_property = property ?? throw new ArgumentNullException(nameof(property));
}
public override Type PropertyType => _property.PropertyType;
public override object? Value => _reference.TryGetTarget(out var o) ? _property.Get(o) : null;
public override bool SetValue(object? value, BindingPriority priority)
{
if (_property.CanSet && _reference.TryGetTarget(out var o))
{
_property.Set(o, value);
SendCurrentValue();
return true;
}
return false;
}
public void OnEvent(object? sender, WeakEvent ev, PropertyChangedEventArgs e)
{
if (e.PropertyName == _property.Name || string.IsNullOrEmpty(e.PropertyName))
SendCurrentValue();
}
protected override void SubscribeCore()
{
SendCurrentValue();
if (_reference.TryGetTarget(out var o) && o is INotifyPropertyChanged inpc)
WeakEvents.ThreadSafePropertyChanged.Subscribe(inpc, this);
}
protected override void UnsubscribeCore()
{
if (_reference.TryGetTarget(out var o) && o is INotifyPropertyChanged inpc)
WeakEvents.ThreadSafePropertyChanged.Unsubscribe(inpc, this);
}
protected void SendCurrentValue()
{
try
{
PublishValue(Value);
}
catch (Exception e)
{
PublishValue(new BindingNotification(e, BindingErrorType.Error));
}
}
}
private class IndexerAccessor : InpcPropertyAccessor, IWeakEventSubscriber<NotifyCollectionChangedEventArgs>
{
private readonly int _index;
public IndexerAccessor(WeakReference<object?> target, IPropertyInfo basePropertyInfo, int argument)
: base(target, basePropertyInfo)
{
_index = argument;
}
protected override void SubscribeCore()
{
base.SubscribeCore();
if (_reference.TryGetTarget(out var o) && o is INotifyCollectionChanged incc)
WeakEvents.CollectionChanged.Subscribe(incc, this);
}
protected override void UnsubscribeCore()
{
base.UnsubscribeCore();
if (_reference.TryGetTarget(out var o) && o is INotifyCollectionChanged incc)
WeakEvents.CollectionChanged.Unsubscribe(incc, this);
}
public void OnEvent(object? sender, WeakEvent ev, NotifyCollectionChangedEventArgs args)
{
if (ShouldNotifyListeners(args))
SendCurrentValue();
}
private bool ShouldNotifyListeners(NotifyCollectionChangedEventArgs e)
{
return e.Action switch
{
NotifyCollectionChangedAction.Add => _index >= e.NewStartingIndex,
NotifyCollectionChangedAction.Remove => _index >= e.OldStartingIndex,
NotifyCollectionChangedAction.Replace => _index >= e.NewStartingIndex &&
_index < e.NewStartingIndex + e.NewItems!.Count,
NotifyCollectionChangedAction.Move => (_index >= e.NewStartingIndex &&
_index < e.NewStartingIndex + e.NewItems!.Count) ||
(_index >= e.OldStartingIndex &&
_index < e.OldStartingIndex + e.OldItems!.Count),
NotifyCollectionChangedAction.Reset => true,
_ => false
};
}
}
} }

20
src/Avalonia.Base/Data/Core/Parsers/BindingExpressionVisitorMembers.cs

@ -0,0 +1,20 @@
using System;
using System.Reflection;
namespace Avalonia.Data.Core.Parsers;
/// <summary>
/// Stores reflection members used by <see cref="BindingExpressionVisitor{TIn}"/> outside of the
/// generic class to avoid duplication for each generic instantiation.
/// </summary>
internal static class BindingExpressionVisitorMembers
{
static BindingExpressionVisitorMembers()
{
AvaloniaObjectIndexer = typeof(AvaloniaObject).GetProperty(CommonPropertyNames.IndexerName, [typeof(AvaloniaProperty)])!;
CreateDelegateMethod = typeof(MethodInfo).GetMethod(nameof(MethodInfo.CreateDelegate), [typeof(Type), typeof(object)])!;
}
public static readonly PropertyInfo AvaloniaObjectIndexer;
public static readonly MethodInfo CreateDelegateMethod;
}

162
tests/Avalonia.Base.UnitTests/Data/CompiledBindingTests_Create.cs

@ -0,0 +1,162 @@
using System;
using System.Globalization;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Data.Converters;
using Avalonia.UnitTests;
using Xunit;
namespace Avalonia.Base.UnitTests.Data;
public class CompiledBindingTests_Create
{
[Fact]
public void Create_Should_Create_Binding_With_Simple_Property()
{
var binding = CompiledBinding.Create<TestViewModel, string?>(vm => vm.StringProperty);
Assert.NotNull(binding);
Assert.NotNull(binding.Path);
Assert.Equal("StringProperty", binding.Path.ToString());
Assert.Equal(AvaloniaProperty.UnsetValue, binding.Source);
Assert.Equal(BindingMode.Default, binding.Mode);
}
[Fact]
public void Create_Should_Create_Binding_With_Source()
{
var source = new TestViewModel { StringProperty = "Test" };
var binding = CompiledBinding.Create<TestViewModel, string?>(
vm => vm.StringProperty,
source: source);
Assert.NotNull(binding);
Assert.NotNull(binding.Path);
Assert.Equal("StringProperty", binding.Path.ToString());
Assert.Same(source, binding.Source);
}
[Fact]
public void Create_Should_Apply_Converter()
{
var converter = new TestConverter();
var binding = CompiledBinding.Create<TestViewModel, string?>(
vm => vm.StringProperty,
converter: converter);
Assert.Same(converter, binding.Converter);
}
[Fact]
public void Create_Should_Apply_Mode()
{
var binding = CompiledBinding.Create<TestViewModel, string?>(
vm => vm.StringProperty,
mode: BindingMode.TwoWay);
Assert.Equal(BindingMode.TwoWay, binding.Mode);
}
[Fact]
public void Create_Should_Work_With_Nested_Properties()
{
var binding = CompiledBinding.Create<TestViewModel, string?>(
vm => vm.Child!.StringProperty);
Assert.NotNull(binding);
Assert.NotNull(binding.Path);
Assert.Equal("Child.StringProperty", binding.Path.ToString());
}
[Fact]
public void Create_Should_Work_With_Indexer()
{
var binding = CompiledBinding.Create<TestViewModel, string?>(
vm => vm.Items[0]);
Assert.NotNull(binding);
Assert.NotNull(binding.Path);
Assert.Equal("Items[0]", binding.Path.ToString());
}
[Fact]
public void Binding_Should_Work_When_Applied_To_Control()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var target = new TextBlock();
var viewModel = new TestViewModel { StringProperty = "Hello" };
var binding = CompiledBinding.Create<TestViewModel, string?>(
vm => vm.StringProperty,
source: viewModel);
target.Bind(TextBlock.TextProperty, binding);
Assert.Equal("Hello", target.Text);
}
}
[Fact]
public void Binding_Should_Update_When_Source_Property_Changes()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var target = new TextBlock();
var viewModel = new TestViewModel { StringProperty = "Initial" };
var binding = CompiledBinding.Create<TestViewModel, string?>(
vm => vm.StringProperty,
source: viewModel);
target.Bind(TextBlock.TextProperty, binding);
Assert.Equal("Initial", target.Text);
viewModel.StringProperty = "Updated";
Assert.Equal("Updated", target.Text);
}
}
[Fact]
public void Binding_Should_Use_DataContext_When_No_Source_Specified()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var target = new TextBlock();
var viewModel = new TestViewModel { StringProperty = "FromDataContext" };
var binding = CompiledBinding.Create<TestViewModel, string?>(vm => vm.StringProperty);
target.DataContext = viewModel;
target.Bind(TextBlock.TextProperty, binding);
Assert.Equal("FromDataContext", target.Text);
}
}
private class TestViewModel : NotifyingBase
{
private string? _stringProperty;
private TestViewModel? _child;
public string? StringProperty
{
get => _stringProperty;
set { _stringProperty = value; RaisePropertyChanged(); }
}
public TestViewModel? Child
{
get => _child;
set { _child = value; RaisePropertyChanged(); }
}
public string?[] Items { get; set; } = Array.Empty<string?>();
}
private class TestConverter : IValueConverter
{
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value?.ToString()?.ToUpper();
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value?.ToString()?.ToLower();
}
}

2
tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.cs

@ -95,7 +95,7 @@ public abstract partial class BindingExpressionTests
var target = new TargetClass { DataContext = dataContext }; var target = new TargetClass { DataContext = dataContext };
var nodes = new List<ExpressionNode>(); var nodes = new List<ExpressionNode>();
var fallback = fallbackValue.HasValue ? fallbackValue.Value : AvaloniaProperty.UnsetValue; var fallback = fallbackValue.HasValue ? fallbackValue.Value : AvaloniaProperty.UnsetValue;
var path = CompiledBindingPathFromExpressionBuilder.Build(expression, enableDataValidation); var path = BindingExpressionVisitor<TIn>.BuildPath(expression);
if (relativeSource is not null && relativeSource.Mode is not RelativeSourceMode.Self) if (relativeSource is not null && relativeSource.Mode is not RelativeSourceMode.Self)
throw new NotImplementedException(); throw new NotImplementedException();

313
tests/Avalonia.Base.UnitTests/Data/Core/CompiledBindingPathFromExpressionBuilder.cs

@ -1,313 +0,0 @@
using System;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Threading.Tasks;
using Avalonia.Data;
using Avalonia.Data.Core;
using Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings;
#nullable enable
namespace Avalonia.Base.UnitTests.Data.Core;
internal class CompiledBindingPathFromExpressionBuilder : ExpressionVisitor
{
private static readonly PropertyInfo AvaloniaObjectIndexer;
private static readonly MethodInfo CreateDelegateMethod;
private static readonly string IndexerGetterName = "get_Item";
private const string MultiDimensionalArrayGetterMethodName = "Get";
private readonly bool _enableDataValidation;
private readonly LambdaExpression _rootExpression;
private readonly CompiledBindingPathBuilder _builder = new();
private Expression? _head;
public CompiledBindingPathFromExpressionBuilder(LambdaExpression expression, bool enableDataValidation)
{
_rootExpression = expression;
_enableDataValidation = enableDataValidation;
}
static CompiledBindingPathFromExpressionBuilder()
{
AvaloniaObjectIndexer = typeof(AvaloniaObject).GetProperty("Item", new[] { typeof(AvaloniaProperty) })!;
CreateDelegateMethod = typeof(MethodInfo).GetMethod("CreateDelegate", new[] { typeof(Type), typeof(object) })!;
}
public static CompiledBindingPath Build<TIn, TOut>(Expression<Func<TIn, TOut>> expression, bool enableDataValidation)
{
var visitor = new CompiledBindingPathFromExpressionBuilder(expression, enableDataValidation);
visitor.Visit(expression);
return visitor._builder.Build();
}
protected override Expression VisitBinary(BinaryExpression node)
{
// Indexers require more work since the compiler doesn't generate IndexExpressions:
// they weren't in System.Linq.Expressions v1 and so must be generated manually.
if (node.NodeType == ExpressionType.ArrayIndex)
return Visit(Expression.MakeIndex(node.Left, null, new[] { node.Right }));
throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}.");
}
protected override Expression VisitIndex(IndexExpression node)
{
if (node.Indexer == AvaloniaObjectIndexer)
{
var property = GetValue<AvaloniaProperty>(node.Arguments[0]);
return Add(node.Object, node, x => x.Property(property, PropertyInfoAccessorFactory.CreateAvaloniaPropertyAccessor));
}
else if (node.Object?.Type.IsArray == true)
{
var indexes = node.Arguments.Select(GetValue<int>).ToArray();
return Add(node.Object, node, x => x.ArrayElement(indexes, node.Type));
}
else if (node.Indexer?.GetMethod is not null &&
node.Arguments.Count == 1 &&
node.Arguments[0].Type == typeof(int))
{
var getMethod = node.Indexer.GetMethod;
var setMethod = node.Indexer.SetMethod;
var index = GetValue<int>(node.Arguments[0]);
var info = new ClrPropertyInfo(
CommonPropertyNames.IndexerName,
x => getMethod.Invoke(x, new object[] { index }),
setMethod is not null ? (o, v) => setMethod.Invoke(o, new[] { v }) : null,
getMethod.ReturnType);
return Add(node.Object, node, x => x.Property(
info,
(x, i) => PropertyInfoAccessorFactory.CreateIndexerPropertyAccessor(x, i, index)));
}
else if (node.Indexer?.GetMethod is not null)
{
var getMethod = node.Indexer.GetMethod;
var setMethod = node.Indexer?.SetMethod;
var indexes = node.Arguments.Select(GetValue<object>).ToArray();
var info = new ClrPropertyInfo(
CommonPropertyNames.IndexerName,
x => getMethod.Invoke(x, indexes),
setMethod is not null ? (o, v) => setMethod.Invoke(o, indexes.Append(v).ToArray()) : null,
getMethod.ReturnType);
return Add(node.Object, node, x => x.Property(
info,
PropertyInfoAccessorFactory.CreateInpcPropertyAccessor));
}
throw new ExpressionParseException(0, $"Invalid indexer in binding expression: {node.NodeType}.");
}
protected override Expression VisitMember(MemberExpression node)
{
if (node.Member.MemberType != MemberTypes.Property)
throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}.");
if (typeof(AvaloniaObject).IsAssignableFrom(node.Expression?.Type) &&
AvaloniaPropertyRegistry.Instance.FindRegistered(node.Expression.Type, node.Member.Name) is { } avaloniaProperty)
{
return Add(
node.Expression,
node,
x => x.Property(avaloniaProperty, PropertyInfoAccessorFactory.CreateAvaloniaPropertyAccessor));
}
else
{
var property = (PropertyInfo)node.Member;
var info = new ClrPropertyInfo(
property.Name,
CreateGetter(property),
CreateSetter(property),
property.PropertyType);
return Add(node.Expression, node, x => x.Property(info, PropertyInfoAccessorFactory.CreateInpcPropertyAccessor));
}
}
protected override Expression VisitMethodCall(MethodCallExpression node)
{
var method = node.Method;
if (method.Name == IndexerGetterName && node.Object is not null)
{
var property = TryGetPropertyFromMethod(method);
return Visit(Expression.MakeIndex(node.Object, property, node.Arguments));
}
else if (method.Name == MultiDimensionalArrayGetterMethodName &&
node.Object is not null)
{
var indexes = node.Arguments.Select(GetValue<int>).ToArray();
return Add(node.Object, node, x => x.ArrayElement(indexes, node.Type));
}
else if (method.Name.StartsWith(StreamBindingExtensions.StreamBindingName) &&
method.DeclaringType == typeof(StreamBindingExtensions) &&
method.GetGenericArguments() is [Type genericArg])
{
var instance = node.Method.IsStatic ? node.Arguments[0] : node.Object;
if (typeof(Task<>).MakeGenericType(genericArg).IsAssignableFrom(instance?.Type))
{
var builderMethod = typeof(CompiledBindingPathBuilder)
.GetMethod(nameof(CompiledBindingPathBuilder.StreamTask))!
.MakeGenericMethod(genericArg);
return Add(instance, node, x => builderMethod.Invoke(x, null));
}
else if (typeof(IObservable<>).MakeGenericType(genericArg).IsAssignableFrom(instance?.Type))
{
var builderMethod = typeof(CompiledBindingPathBuilder)
.GetMethod(nameof(CompiledBindingPathBuilder.StreamObservable))!
.MakeGenericMethod(genericArg);
return Add(instance, node, x => builderMethod.Invoke(x, null));
}
}
throw new ExpressionParseException(0, $"Invalid method call in binding expression: '{node.Method.DeclaringType}.{node.Method.Name}'.");
}
protected override Expression VisitParameter(ParameterExpression node)
{
if (node == _rootExpression.Parameters[0] && _head is null)
_head = node;
return base.VisitParameter(node);
}
protected override Expression VisitUnary(UnaryExpression node)
{
if (node.NodeType == ExpressionType.Not && node.Type == typeof(bool))
{
return Add(node.Operand, node, x => x.Not());
}
else if (node.NodeType == ExpressionType.Convert)
{
if (node.Operand.Type.IsAssignableFrom(node.Type))
{
// Ignore inheritance casts
return _head = base.VisitUnary(node);
}
}
else if (node.NodeType == ExpressionType.TypeAs)
{
// Ignore as operator.
return _head = base.VisitUnary(node);
}
throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}.");
}
protected override Expression VisitBlock(BlockExpression node)
{
throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}.");
}
protected override CatchBlock VisitCatchBlock(CatchBlock node)
{
throw new ExpressionParseException(0, $"Catch blocks are not allowed in binding expressions.");
}
protected override Expression VisitConditional(ConditionalExpression node)
{
throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}.");
}
protected override Expression VisitDynamic(DynamicExpression node)
{
throw new ExpressionParseException(0, $"Dynamic expressions are not allowed in binding expressions.");
}
protected override ElementInit VisitElementInit(ElementInit node)
{
throw new ExpressionParseException(0, $"Element init expressions are not valid in a binding expression.");
}
protected override Expression VisitGoto(GotoExpression node)
{
throw new ExpressionParseException(0, $"Goto expressions not supported in binding expressions.");
}
protected override Expression VisitInvocation(InvocationExpression node)
{
throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}.");
}
protected override Expression VisitLabel(LabelExpression node)
{
throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}.");
}
protected override Expression VisitListInit(ListInitExpression node)
{
throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}.");
}
protected override Expression VisitLoop(LoopExpression node)
{
throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}.");
}
protected override MemberAssignment VisitMemberAssignment(MemberAssignment node)
{
throw new ExpressionParseException(0, $"Member assignments not supported in binding expressions.");
}
protected override Expression VisitSwitch(SwitchExpression node)
{
throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}.");
}
protected override Expression VisitTry(TryExpression node)
{
throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}.");
}
protected override Expression VisitTypeBinary(TypeBinaryExpression node)
{
throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}.");
}
private Expression Add(Expression? instance, Expression expression, Action<CompiledBindingPathBuilder> build)
{
var visited = Visit(instance);
if (visited != _head)
throw new ExpressionParseException(
0,
$"Unable to parse '{expression}': expected an instance of '{_head}' but got '{visited}'.");
build(_builder);
return _head = expression;
}
private static Func<object, object>? CreateGetter(PropertyInfo info)
{
if (info.GetMethod == null)
return null;
var target = Expression.Parameter(typeof(object), "target");
return Expression.Lambda<Func<object, object>>(
Expression.Convert(Expression.Call(Expression.Convert(target, info.DeclaringType!), info.GetMethod),
typeof(object)),
target)
.Compile();
}
private static Action<object, object?>? CreateSetter(PropertyInfo info)
{
if (info.SetMethod == null)
return null;
var target = Expression.Parameter(typeof(object), "target");
var value = Expression.Parameter(typeof(object), "value");
return Expression.Lambda<Action<object, object?>>(
Expression.Call(Expression.Convert(target, info.DeclaringType!), info.SetMethod,
Expression.Convert(value, info.SetMethod.GetParameters()[0].ParameterType)),
target, value)
.Compile();
}
private static T GetValue<T>(Expression expr)
{
if (expr is ConstantExpression constant)
return (T)constant.Value!;
return Expression.Lambda<Func<T>>(expr).Compile(preferInterpretation: true)();
}
private static PropertyInfo? TryGetPropertyFromMethod(MethodInfo method)
{
var type = method.DeclaringType;
return type?.GetRuntimeProperties().FirstOrDefault(prop => prop.GetMethod == method);
}
}

25
tests/Avalonia.Base.UnitTests/Data/Core/Parsers/BindingExpressionVisitorExtensions.cs

@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using Avalonia.Data.Core.ExpressionNodes;
using Avalonia.Data.Core.Parsers;
namespace Avalonia.Base.UnitTests.Data.Core.Parsers;
/// <summary>
/// Test extensions for BindingExpressionVisitor tests.
/// </summary>
internal static class BindingExpressionVisitorExtensions
{
/// <summary>
/// Builds a list of binding expression nodes from a lambda expression.
/// This is a test helper method - production code should use BuildPath() instead.
/// </summary>
public static List<ExpressionNode> BuildNodes<TIn, TOut>(Expression<Func<TIn, TOut>> expression)
{
var path = BindingExpressionVisitor<TIn>.BuildPath(expression);
var nodes = new List<ExpressionNode>();
path.BuildExpression(nodes, out var _);
return nodes;
}
}

528
tests/Avalonia.Base.UnitTests/Data/Core/Parsers/BindingExpressionVisitorTests.cs

@ -0,0 +1,528 @@
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Threading.Tasks;
using Avalonia.Data.Core;
using Avalonia.Data.Core.ExpressionNodes;
using Avalonia.Data.Core.Parsers;
using Xunit;
namespace Avalonia.Base.UnitTests.Data.Core.Parsers
{
public class BindingExpressionVisitorTests
{
[Fact]
public void BuildNodes_Should_Parse_Simple_Property()
{
Expression<Func<TestClass, string?>> expr = x => x.StringProperty;
var nodes = BuildNodes(expr);
var node = Assert.Single(nodes);
var propertyNode = Assert.IsType<PropertyAccessorNode>(node);
Assert.Equal("StringProperty", propertyNode.PropertyName);
}
[Fact]
public void BuildNodes_Should_Parse_Property_Chain()
{
Expression<Func<TestClass, string?>> expr = x => x.Child!.StringProperty;
var nodes = BuildNodes(expr);
Assert.Equal(2, nodes.Count);
var firstNode = Assert.IsType<PropertyAccessorNode>(nodes[0]);
Assert.Equal("Child", firstNode.PropertyName);
var secondNode = Assert.IsType<PropertyAccessorNode>(nodes[1]);
Assert.Equal("StringProperty", secondNode.PropertyName);
}
[Fact]
public void BuildNodes_Should_Parse_Long_Property_Chain()
{
Expression<Func<TestClass, string?>> expr = x => x.Child!.Child!.StringProperty;
var nodes = BuildNodes(expr);
Assert.Equal(3, nodes.Count);
Assert.All(nodes, n => Assert.IsType<PropertyAccessorNode>(n));
Assert.Equal("Child", ((PropertyAccessorNode)nodes[0]).PropertyName);
Assert.Equal("Child", ((PropertyAccessorNode)nodes[1]).PropertyName);
Assert.Equal("StringProperty", ((PropertyAccessorNode)nodes[2]).PropertyName);
}
[Fact]
public void BuildNodes_Should_Parse_Indexer()
{
Expression<Func<TestClass, TestClass?>> expr = x => x.IndexedProperty![0];
var nodes = BuildNodes(expr);
Assert.Equal(2, nodes.Count);
var propertyNode = Assert.IsType<PropertyAccessorNode>(nodes[0]);
Assert.Equal("IndexedProperty", propertyNode.PropertyName);
Assert.IsType<PropertyAccessorNode>(nodes[1]); // List indexer, not array
}
[Fact]
public void BuildNodes_Should_Parse_Array_Index()
{
Expression<Func<TestClass, string?>> expr = x => x.ArrayProperty![0];
var nodes = BuildNodes(expr);
Assert.Equal(2, nodes.Count);
var propertyNode = Assert.IsType<PropertyAccessorNode>(nodes[0]);
Assert.Equal("ArrayProperty", propertyNode.PropertyName);
Assert.IsType<ArrayIndexerNode>(nodes[1]);
}
[Fact]
public void BuildNodes_Should_Parse_Multi_Dimensional_Array()
{
Expression<Func<TestClass, string?>> expr = x => x.MultiDimensionalArray![0, 1];
var nodes = BuildNodes(expr);
Assert.Equal(2, nodes.Count);
var propertyNode = Assert.IsType<PropertyAccessorNode>(nodes[0]);
Assert.Equal("MultiDimensionalArray", propertyNode.PropertyName);
Assert.IsType<ArrayIndexerNode>(nodes[1]);
}
[Fact]
public void BuildNodes_Should_Parse_AvaloniaProperty_Access()
{
Expression<Func<StyledElement, object?>> expr = x => x[StyledElement.DataContextProperty];
var nodes = BuildNodes(expr);
var node = Assert.Single(nodes);
var avaloniaPropertyNode = Assert.IsType<PropertyAccessorNode>(node);
Assert.Equal("DataContext", avaloniaPropertyNode.PropertyName); // AvaloniaProperty accessed as property
}
[Fact]
public void BuildNodes_Should_Parse_AvaloniaProperty_Access_In_Chain()
{
Expression<Func<TestClass, object?>> expr = x => x.StyledChild![StyledElement.DataContextProperty];
var nodes = BuildNodes(expr);
Assert.Equal(2, nodes.Count);
var propertyNode = Assert.IsType<PropertyAccessorNode>(nodes[0]);
Assert.Equal("StyledChild", propertyNode.PropertyName);
var avaloniaPropertyNode = Assert.IsType<PropertyAccessorNode>(nodes[1]);
Assert.Equal("DataContext", avaloniaPropertyNode.PropertyName); // AvaloniaProperty accessed as property
}
[Fact]
public void BuildNodes_Should_Parse_Logical_Not()
{
Expression<Func<TestClass, bool>> expr = x => !x.BoolProperty;
var nodes = BuildNodes(expr);
Assert.Equal(2, nodes.Count);
var propertyNode = Assert.IsType<PropertyAccessorNode>(nodes[0]);
Assert.Equal("BoolProperty", propertyNode.PropertyName);
Assert.IsType<LogicalNotNode>(nodes[1]);
}
[Fact]
public void BuildNodes_Should_Parse_Logical_Not_In_Chain()
{
Expression<Func<TestClass, bool>> expr = x => !x.Child!.BoolProperty;
var nodes = BuildNodes(expr);
Assert.Equal(3, nodes.Count);
Assert.IsType<PropertyAccessorNode>(nodes[0]);
Assert.IsType<PropertyAccessorNode>(nodes[1]);
Assert.IsType<LogicalNotNode>(nodes[2]);
}
[Fact]
public void BuildNodes_Should_Parse_Task_StreamBinding()
{
Expression<Func<TestClass, string?>> expr = x => x.TaskProperty!.StreamBinding();
var nodes = BuildNodes(expr);
Assert.Equal(2, nodes.Count);
var propertyNode = Assert.IsType<PropertyAccessorNode>(nodes[0]);
Assert.Equal("TaskProperty", propertyNode.PropertyName);
Assert.IsType<StreamNode>(nodes[1]);
}
[Fact]
public void BuildNodes_Should_Parse_Observable_StreamBinding()
{
Expression<Func<TestClass, int>> expr = x => x.ObservableProperty!.StreamBinding();
var nodes = BuildNodes(expr);
Assert.Equal(2, nodes.Count);
var propertyNode = Assert.IsType<PropertyAccessorNode>(nodes[0]);
Assert.Equal("ObservableProperty", propertyNode.PropertyName);
Assert.IsType<StreamNode>(nodes[1]);
}
[Fact]
public void BuildNodes_Should_Parse_Void_Task_StreamBinding()
{
Expression<Func<TestClass, object>> expr = x => x.VoidTaskProperty!.StreamBinding();
var nodes = BuildNodes(expr);
Assert.Equal(2, nodes.Count);
var propertyNode = Assert.IsType<PropertyAccessorNode>(nodes[0]);
Assert.Equal("VoidTaskProperty", propertyNode.PropertyName);
Assert.IsType<StreamNode>(nodes[1]);
}
[Fact]
public void BuildNodes_Should_Create_Node_For_Upcast()
{
// Upcasts (derived to base) create a cast node
Expression<Func<DerivedTestClass, TestClass>> expr = x => (TestClass)x;
var nodes = BuildNodes(expr);
var node = Assert.Single(nodes);
Assert.IsType<FuncTransformNode>(node);
}
[Fact]
public void BuildNodes_Should_Create_Node_For_Upcast_In_Property_Chain()
{
// Cast creates a node, then property access creates another
Expression<Func<DerivedTestClass, string?>> expr = x => ((TestClass)x).StringProperty;
var nodes = BuildNodes(expr);
Assert.Equal(2, nodes.Count);
Assert.IsType<FuncTransformNode>(nodes[0]);
var propertyNode = Assert.IsType<PropertyAccessorNode>(nodes[1]);
Assert.Equal("StringProperty", propertyNode.PropertyName);
}
[Fact]
public void BuildNodes_Should_Create_Node_For_Downcast()
{
// Downcasts (base to derived) create a cast node - the binding system will handle runtime errors
Expression<Func<TestClass, DerivedTestClass>> expr = x => (DerivedTestClass)x;
var nodes = BuildNodes(expr);
var node = Assert.Single(nodes);
Assert.IsType<FuncTransformNode>(node);
}
[Fact]
public void BuildNodes_Should_Create_Node_For_Downcast_In_Property_Chain()
{
// Practical example: casting to access derived type properties
Expression<Func<TestClass, string?>> expr = x => ((DerivedTestClass)x.Child!).DerivedProperty;
var nodes = BuildNodes(expr);
Assert.Equal(3, nodes.Count);
var childNode = Assert.IsType<PropertyAccessorNode>(nodes[0]);
Assert.Equal("Child", childNode.PropertyName);
Assert.IsType<FuncTransformNode>(nodes[1]);
var derivedNode = Assert.IsType<PropertyAccessorNode>(nodes[2]);
Assert.Equal("DerivedProperty", derivedNode.PropertyName);
}
[Fact]
public void BuildNodes_Should_Throw_For_Value_Type_Cast()
{
// Value type conversions should throw
Expression<Func<TestClass, long>> expr = x => (long)x.IntProperty;
var ex = Assert.Throws<ExpressionParseException>(() =>
BuildNodes(expr));
Assert.Contains("Invalid expression type", ex.Message);
Assert.Contains("Convert", ex.Message);
}
[Fact]
public void BuildNodes_Should_Create_Nodes_For_Casting_Through_Object()
{
// Casting through object creates cast nodes
Expression<Func<TestClass, string>> expr = x => (string)(object)x.StringProperty!;
var nodes = BuildNodes(expr);
Assert.Equal(3, nodes.Count);
var propertyNode = Assert.IsType<PropertyAccessorNode>(nodes[0]);
Assert.Equal("StringProperty", propertyNode.PropertyName);
Assert.IsType<FuncTransformNode>(nodes[1]); // cast to object
Assert.IsType<FuncTransformNode>(nodes[2]); // cast to string
}
[Fact]
public void BuildNodes_Should_Create_Node_For_TypeAs_Operator()
{
// TypeAs operator creates a cast node
Expression<Func<TestClass, object?>> expr = x => x.Child as object;
var nodes = BuildNodes(expr);
Assert.Equal(2, nodes.Count);
var propertyNode = Assert.IsType<PropertyAccessorNode>(nodes[0]);
Assert.Equal("Child", propertyNode.PropertyName);
Assert.IsType<FuncTransformNode>(nodes[1]);
}
[Fact]
public void BuildNodes_Should_Throw_For_Addition_Operator()
{
Expression<Func<TestClass, int>> expr = x => x.IntProperty + 1;
var ex = Assert.Throws<ExpressionParseException>(() =>
BuildNodes(expr));
Assert.Contains("Invalid expression type", ex.Message);
Assert.Contains("Add", ex.Message);
}
[Fact]
public void BuildNodes_Should_Throw_For_Subtraction_Operator()
{
Expression<Func<TestClass, int>> expr = x => x.IntProperty - 1;
var ex = Assert.Throws<ExpressionParseException>(() =>
BuildNodes(expr));
Assert.Contains("Invalid expression type", ex.Message);
Assert.Contains("Subtract", ex.Message);
}
[Fact]
public void BuildNodes_Should_Throw_For_Multiplication_Operator()
{
Expression<Func<TestClass, int>> expr = x => x.IntProperty * 2;
var ex = Assert.Throws<ExpressionParseException>(() =>
BuildNodes(expr));
Assert.Contains("Invalid expression type", ex.Message);
Assert.Contains("Multiply", ex.Message);
}
[Fact]
public void BuildNodes_Should_Throw_For_Equality_Operator()
{
Expression<Func<TestClass, bool>> expr = x => x.IntProperty == 42;
var ex = Assert.Throws<ExpressionParseException>(() =>
BuildNodes(expr));
Assert.Contains("Invalid expression type", ex.Message);
Assert.Contains("Equal", ex.Message);
}
[Fact]
public void BuildNodes_Should_Throw_For_Conditional_Expression()
{
Expression<Func<TestClass, string?>> expr = x => x.BoolProperty ? "true" : "false";
var ex = Assert.Throws<ExpressionParseException>(() =>
BuildNodes(expr));
Assert.Contains("Invalid expression type", ex.Message);
Assert.Contains("Conditional", ex.Message);
}
[Fact]
public void BuildNodes_Should_Throw_For_Method_Call_That_Is_Not_Indexer_Or_StreamBinding()
{
Expression<Func<TestClass, string?>> expr = x => x.StringProperty!.ToUpper();
var ex = Assert.Throws<ExpressionParseException>(() =>
BuildNodes(expr));
Assert.Contains("Invalid method call", ex.Message);
Assert.Contains("ToUpper", ex.Message);
}
[Fact]
public void BuildNodes_Should_Handle_Unary_Plus_Operator()
{
// Unary plus is typically optimized away by the C# compiler and doesn't appear in the
// expression tree, so it doesn't throw an exception.
Expression<Func<TestClass, int>> expr = x => +x.IntProperty;
var nodes = BuildNodes(expr);
var node = Assert.Single(nodes);
var propertyNode = Assert.IsType<PropertyAccessorNode>(node);
Assert.Equal("IntProperty", propertyNode.PropertyName);
}
[Fact]
public void BuildNodes_Should_Throw_For_Unary_Minus_Operator()
{
Expression<Func<TestClass, int>> expr = x => -x.IntProperty;
var ex = Assert.Throws<ExpressionParseException>(() =>
BuildNodes(expr));
Assert.Contains("Invalid expression type", ex.Message);
Assert.Contains("Negate", ex.Message);
}
[Fact]
public void BuildNodes_Should_Parse_Chained_Indexers()
{
Expression<Func<TestClass, string?>> expr = x => x.NestedIndexedProperty![0]![1];
var nodes = BuildNodes(expr);
Assert.Equal(3, nodes.Count);
var propertyNode = Assert.IsType<PropertyAccessorNode>(nodes[0]);
Assert.Equal("NestedIndexedProperty", propertyNode.PropertyName);
Assert.IsType<PropertyAccessorNode>(nodes[1]); // List indexer
Assert.IsType<PropertyAccessorNode>(nodes[2]); // List indexer
}
[Fact]
public void BuildNodes_Should_Parse_Property_After_Indexer()
{
Expression<Func<TestClass, string?>> expr = x => x.IndexedProperty![0]!.StringProperty;
var nodes = BuildNodes(expr);
Assert.Equal(3, nodes.Count);
Assert.IsType<PropertyAccessorNode>(nodes[0]);
Assert.IsType<PropertyAccessorNode>(nodes[1]); // List indexer
Assert.IsType<PropertyAccessorNode>(nodes[2]);
}
[Fact]
public void BuildNodes_Should_Parse_Indexer_With_String_Key()
{
Expression<Func<TestClass, int>> expr = x => x.DictionaryProperty!["key"];
var nodes = BuildNodes(expr);
Assert.Equal(2, nodes.Count);
var propertyNode = Assert.IsType<PropertyAccessorNode>(nodes[0]);
Assert.Equal("DictionaryProperty", propertyNode.PropertyName);
Assert.IsType<PropertyAccessorNode>(nodes[1]); // Dictionary indexer
}
[Fact]
public void BuildNodes_Should_Parse_Indexer_With_Variable_Key()
{
var key = "test";
Expression<Func<TestClass, int>> expr = x => x.DictionaryProperty![key];
var nodes = BuildNodes(expr);
Assert.Equal(2, nodes.Count);
Assert.IsType<PropertyAccessorNode>(nodes[0]);
Assert.IsType<PropertyAccessorNode>(nodes[1]); // Dictionary indexer
}
[Fact]
public void BuildNodes_Should_Parse_StreamBinding_In_Property_Chain()
{
Expression<Func<TestClass, string?>> expr = x => x.Child!.TaskProperty!.StreamBinding();
var nodes = BuildNodes(expr);
Assert.Equal(3, nodes.Count);
Assert.IsType<PropertyAccessorNode>(nodes[0]);
Assert.IsType<PropertyAccessorNode>(nodes[1]);
Assert.IsType<StreamNode>(nodes[2]);
}
[Fact]
public void BuildNodes_Should_Parse_Logical_Not_After_StreamBinding()
{
Expression<Func<TestClass, bool>> expr = x => !x.BoolTaskProperty!.StreamBinding();
var nodes = BuildNodes(expr);
Assert.Equal(3, nodes.Count);
Assert.IsType<PropertyAccessorNode>(nodes[0]);
Assert.IsType<StreamNode>(nodes[1]);
Assert.IsType<LogicalNotNode>(nodes[2]);
}
[Fact]
public void BuildNodes_Should_Handle_Empty_Expression()
{
Expression<Func<TestClass, TestClass>> expr = x => x;
var nodes = BuildNodes(expr);
Assert.Empty(nodes);
}
[Fact]
public void BuildNodes_Should_Parse_Multiple_Logical_Not_Operators()
{
Expression<Func<TestClass, bool>> expr = x => !!x.BoolProperty;
var nodes = BuildNodes(expr);
Assert.Equal(3, nodes.Count);
Assert.IsType<PropertyAccessorNode>(nodes[0]);
Assert.IsType<LogicalNotNode>(nodes[1]);
Assert.IsType<LogicalNotNode>(nodes[2]);
}
public class TestClass
{
public string? StringProperty { get; set; }
public int IntProperty { get; set; }
public bool BoolProperty { get; set; }
public TestClass? Child { get; set; }
public StyledElement? StyledChild { get; set; }
public string?[]? ArrayProperty { get; set; }
public string?[,]? MultiDimensionalArray { get; set; }
public List<TestClass>? IndexedProperty { get; set; }
public List<List<string>>? NestedIndexedProperty { get; set; }
public Dictionary<string, int>? DictionaryProperty { get; set; }
public Task<string?>? TaskProperty { get; set; }
public Task? VoidTaskProperty { get; set; }
public Task<bool>? BoolTaskProperty { get; set; }
public IObservable<int>? ObservableProperty { get; set; }
}
public class DerivedTestClass : TestClass
{
public string? DerivedProperty { get; set; }
}
private static List<ExpressionNode> BuildNodes<TIn, TOut>(Expression<Func<TIn, TOut>> expression)
=> BindingExpressionVisitorExtensions.BuildNodes(expression);
}
}

56
tests/Avalonia.LeakTests/BindingExpressionExtensions.cs

@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq.Expressions;
using Avalonia.Data;
using Avalonia.Data.Converters;
using Avalonia.Data.Core;
using Avalonia.Data.Core.ExpressionNodes;
using Avalonia.Data.Core.Parsers;
using Avalonia.Utilities;
namespace Avalonia.LeakTests;
/// <summary>
/// Test extensions for creating BindingExpression instances from lambda expressions.
/// </summary>
internal static class BindingExpressionExtensions
{
[RequiresUnreferencedCode(TrimmingMessages.ExpressionNodeRequiresUnreferencedCodeMessage)]
[RequiresDynamicCode(TrimmingMessages.ExpressionNodeRequiresDynamicCodeMessage)]
public static BindingExpression CreateBindingExpression<TIn, TOut>(
TIn source,
Expression<Func<TIn, TOut>> expression,
IValueConverter? converter = null,
CultureInfo? converterCulture = null,
object? converterParameter = null,
bool enableDataValidation = false,
Optional<object?> fallbackValue = default,
BindingMode mode = BindingMode.OneWay,
BindingPriority priority = BindingPriority.LocalValue,
object? targetNullValue = null,
bool allowReflection = true)
where TIn : class?
{
var path = BindingExpressionVisitor<TIn>.BuildPath(expression);
var nodes = new List<ExpressionNode>();
path.BuildExpression(nodes, out var _);
var fallback = fallbackValue.HasValue ? fallbackValue.Value : AvaloniaProperty.UnsetValue;
return new BindingExpression(
source,
nodes,
fallback,
converter: converter,
converterCulture: converterCulture,
converterParameter: converterParameter,
enableDataValidation: enableDataValidation,
mode: mode,
priority: priority,
targetNullValue: targetNullValue,
targetTypeConverter: allowReflection ?
TargetTypeConverter.GetReflectionConverter() :
TargetTypeConverter.GetDefaultConverter());
}
}

42
tests/Avalonia.LeakTests/BindingExpressionTests.cs

@ -1,6 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq.Expressions;
using Avalonia.Collections; using Avalonia.Collections;
using Avalonia.Data;
using Avalonia.Data.Converters;
using Avalonia.Data.Core; using Avalonia.Data.Core;
using Avalonia.UnitTests; using Avalonia.UnitTests;
using Xunit; using Xunit;
@ -16,7 +20,7 @@ namespace Avalonia.LeakTests
{ {
var list = new AvaloniaList<string> { "foo", "bar" }; var list = new AvaloniaList<string> { "foo", "bar" };
var source = new { Foo = list }; var source = new { Foo = list };
var target = BindingExpression.Create(source, o => o.Foo); var target = CreateBindingExpression(source, o => o.Foo);
target.ToObservable().Subscribe(_ => { }); target.ToObservable().Subscribe(_ => { });
return new WeakReference(list); return new WeakReference(list);
@ -37,7 +41,7 @@ namespace Avalonia.LeakTests
{ {
var list = new AvaloniaList<string> { "foo", "bar" }; var list = new AvaloniaList<string> { "foo", "bar" };
var source = new { Foo = list }; var source = new { Foo = list };
var target = BindingExpression.Create(source, o => o.Foo, enableDataValidation: true); var target = CreateBindingExpression(source, o => o.Foo, enableDataValidation: true);
target.ToObservable().Subscribe(_ => { }); target.ToObservable().Subscribe(_ => { });
return new WeakReference(list); return new WeakReference(list);
@ -58,7 +62,7 @@ namespace Avalonia.LeakTests
{ {
var indexer = new NonIntegerIndexer(); var indexer = new NonIntegerIndexer();
var source = new { Foo = indexer }; var source = new { Foo = indexer };
var target = BindingExpression.Create(source, o => o.Foo); var target = CreateBindingExpression(source, o => o.Foo);
target.ToObservable().Subscribe(_ => { }); target.ToObservable().Subscribe(_ => { });
return new WeakReference(indexer); return new WeakReference(indexer);
@ -79,7 +83,7 @@ namespace Avalonia.LeakTests
{ {
var methodBound = new MethodBound(); var methodBound = new MethodBound();
var source = new { Foo = methodBound }; var source = new { Foo = methodBound };
var target = BindingExpression.Create(source, o => (Action)o.Foo.A); var target = CreateBindingExpression(source, o => (Action)o.Foo.A);
target.ToObservable().Subscribe(_ => { }); target.ToObservable().Subscribe(_ => { });
return new WeakReference(methodBound); return new WeakReference(methodBound);
} }
@ -92,6 +96,34 @@ namespace Avalonia.LeakTests
Assert.False(weakSource.IsAlive); Assert.False(weakSource.IsAlive);
} }
private static BindingExpression CreateBindingExpression<TIn, TOut>(
TIn source,
Expression<Func<TIn, TOut>> expression,
IValueConverter? converter = null,
CultureInfo? converterCulture = null,
object? converterParameter = null,
bool enableDataValidation = false,
Optional<object?> fallbackValue = default,
BindingMode mode = BindingMode.OneWay,
BindingPriority priority = BindingPriority.LocalValue,
object? targetNullValue = null,
bool allowReflection = true)
where TIn : class?
{
return BindingExpressionExtensions.CreateBindingExpression(
source,
expression,
converter,
converterCulture,
converterParameter,
enableDataValidation,
fallbackValue,
mode,
priority,
targetNullValue,
allowReflection);
}
private class MethodBound private class MethodBound
{ {
public void A() { } public void A() { }

Loading…
Cancel
Save