diff --git a/.ncrunch/Avalonia.HarfBuzz.v3.ncrunchproject b/.ncrunch/Avalonia.HarfBuzz.v3.ncrunchproject new file mode 100644 index 0000000000..0bcc569d05 --- /dev/null +++ b/.ncrunch/Avalonia.HarfBuzz.v3.ncrunchproject @@ -0,0 +1,5 @@ + + + False + + \ No newline at end of file diff --git a/Avalonia.v3.ncrunchsolution b/Avalonia.v3.ncrunchsolution index 4de91979d6..f675b66f7b 100644 --- a/Avalonia.v3.ncrunchsolution +++ b/Avalonia.v3.ncrunchsolution @@ -13,8 +13,9 @@ TargetFrameworks = net10.0 False + True .ncrunch True True - + \ No newline at end of file diff --git a/src/Avalonia.Base/Data/CompiledBinding.cs b/src/Avalonia.Base/Data/CompiledBinding.cs index 952a5cddc4..e243246b4f 100644 --- a/src/Avalonia.Base/Data/CompiledBinding.cs +++ b/src/Avalonia.Base/Data/CompiledBinding.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Linq.Expressions; using Avalonia.Controls; using Avalonia.Data.Converters; using Avalonia.Data.Core; @@ -27,6 +29,95 @@ public class CompiledBinding : BindingBase /// The binding path. public CompiledBinding(CompiledBindingPath path) => Path = path; + /// + /// Creates a from a lambda expression. + /// + /// The input type of the binding expression. + /// The output type of the binding expression. + /// + /// The lambda expression representing the binding path + /// (e.g., vm => vm.PropertyName). + /// + /// The source object for the binding. If null, uses the target's DataContext. + /// + /// + /// Optional value converter to transform values between source and target. + /// + /// + /// The binding mode. Default is which resolves to the + /// property's default binding mode. + /// + /// The binding priority. + /// The culture in which to evaluate the converter. + /// A parameter to pass to the converter. + /// + /// The value to use when the binding is unable to produce a value. + /// + /// The string format for the binding result. + /// The value to use when the binding result is null. + /// + /// The timing of binding source updates for TwoWay/OneWayToSource bindings. + /// + /// + /// The amount of time, in milliseconds, to wait before updating the binding source. + /// + /// + /// A configured instance ready to be applied to a property. + /// + /// + /// Thrown when the expression contains unsupported operations or invalid syntax for binding + /// expressions. + /// + /// + /// This builds a 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: + /// + /// Property access: x => x.Property + /// Nested properties: x => x.Property.Nested + /// Indexers: x => x.Items[0] + /// Type casts: x => ((DerivedType)x).Property + /// Logical NOT: x => !x.BoolProperty + /// Stream bindings: x => x.TaskProperty (Task/Observable) + /// AvaloniaProperty access: x => x[MyProperty] + /// + /// + [RequiresDynamicCode(TrimmingMessages.ExpressionNodeRequiresDynamicCodeMessage)] + [RequiresUnreferencedCode(TrimmingMessages.ExpressionNodeRequiresUnreferencedCodeMessage)] + public static CompiledBinding Create( + Expression> 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.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 + }; + } + /// /// Gets or sets the amount of time, in milliseconds, to wait before updating the binding /// source after the value on the target changes. diff --git a/src/Avalonia.Base/Data/Core/BindingExpression.cs b/src/Avalonia.Base/Data/Core/BindingExpression.cs index 6ad1a99f41..00a90f87fe 100644 --- a/src/Avalonia.Base/Data/Core/BindingExpression.cs +++ b/src/Avalonia.Base/Data/Core/BindingExpression.cs @@ -173,59 +173,6 @@ internal class BindingExpression : UntypedBindingExpressionBase, IDescription, I _nodes[0].SetSource(source, null); } - /// - /// Creates an from an expression tree. - /// - /// The input type of the binding expression. - /// The output type of the binding expression. - /// The source from which the binding value will be read. - /// The expression representing the binding path. - /// The converter to use. - /// The converter culture to use. - /// The converter parameter. - /// Whether data validation should be enabled for the binding. - /// The fallback value. - /// The binding mode. - /// The binding priority. - /// The null target value. - /// Whether to allow reflection for target type conversion. - [RequiresUnreferencedCode(TrimmingMessages.ExpressionNodeRequiresUnreferencedCodeMessage)] -#if NET8_0_OR_GREATER - [RequiresDynamicCode(TrimmingMessages.ExpressionNodeRequiresDynamicCodeMessage)] -#endif - internal static BindingExpression Create( - TIn source, - Expression> expression, - IValueConverter? converter = null, - CultureInfo? converterCulture = null, - object? converterParameter = null, - bool enableDataValidation = false, - Optional fallbackValue = default, - BindingMode mode = BindingMode.OneWay, - BindingPriority priority = BindingPriority.LocalValue, - object? targetNullValue = null, - bool allowReflection = true) - where TIn : class? - { - var nodes = BindingExpressionVisitor.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()); - } - /// /// Called by an belonging to this binding when its /// changes. diff --git a/src/Avalonia.Base/Data/Core/Parsers/BindingExpressionVisitor.cs b/src/Avalonia.Base/Data/Core/Parsers/BindingExpressionVisitor.cs index 1981732145..c354bcc15a 100644 --- a/src/Avalonia.Base/Data/Core/Parsers/BindingExpressionVisitor.cs +++ b/src/Avalonia.Base/Data/Core/Parsers/BindingExpressionVisitor.cs @@ -1,47 +1,59 @@ using System; -using System.Collections; using System.Collections.Generic; +using System.Collections.Specialized; +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Linq.Expressions; using System.Reflection; +using System.Threading.Tasks; using Avalonia.Data.Core.ExpressionNodes; using Avalonia.Data.Core.ExpressionNodes.Reflection; +using Avalonia.Data.Core.Plugins; +using Avalonia.Reactive; +using Avalonia.Utilities; namespace Avalonia.Data.Core.Parsers; -[RequiresUnreferencedCode(TrimmingMessages.ExpressionNodeRequiresUnreferencedCodeMessage)] -#if NET8_0_OR_GREATER +/// +/// Visits and processes a LINQ expression to build a compiled binding path. +/// +/// The input parameter type for the binding expression. +/// +/// This visitor traverses lambda expressions used in compiled bindings and uses +/// to construct a , which +/// can then be converted into instances. It supports property access, +/// indexers, AvaloniaProperty access, stream bindings, type casts, and logical operators. +/// [RequiresDynamicCode(TrimmingMessages.ExpressionNodeRequiresDynamicCodeMessage)] -#endif -internal class BindingExpressionVisitor : ExpressionVisitor +[RequiresUnreferencedCode(TrimmingMessages.ExpressionNodeRequiresUnreferencedCodeMessage)] +internal class BindingExpressionVisitor(LambdaExpression expression) : ExpressionVisitor { - private static readonly PropertyInfo AvaloniaObjectIndexer; - private static readonly MethodInfo CreateDelegateMethod; private const string IndexerGetterName = "get_Item"; private const string MultiDimensionalArrayGetterMethodName = "Get"; - private readonly bool _enableDataValidation; - private readonly LambdaExpression _rootExpression; - private readonly List _nodes = new(); + private readonly LambdaExpression _rootExpression = expression; + private readonly CompiledBindingPathBuilder _builder = new(); private Expression? _head; - public BindingExpressionVisitor(LambdaExpression expression, bool enableDataValidation) - { - _rootExpression = expression; - _enableDataValidation = enableDataValidation; - } - - static BindingExpressionVisitor() - { - AvaloniaObjectIndexer = typeof(AvaloniaObject).GetProperty("Item", new[] { typeof(AvaloniaProperty) })!; - CreateDelegateMethod = typeof(MethodInfo).GetMethod("CreateDelegate", new[] { typeof(Type), typeof(object) })!; - } - - public static List BuildNodes(Expression> expression, bool enableDataValidation) + /// + /// Builds a compiled binding path from a lambda expression. + /// + /// The output type of the binding expression. + /// + /// The lambda expression to parse and convert into a binding path. + /// + /// + /// A representing the binding path. + /// + /// + /// Thrown when the expression contains unsupported operations or invalid syntax for binding + /// expressions. + /// + public static CompiledBindingPath BuildPath(Expression> expression) { - var visitor = new BindingExpressionVisitor(expression, enableDataValidation); + var visitor = new BindingExpressionVisitor(expression); visitor.Visit(expression); - return visitor._nodes; + return visitor._builder.Build(); } protected override Expression VisitBinary(BinaryExpression node) @@ -49,33 +61,64 @@ internal class BindingExpressionVisitor : ExpressionVisitor // 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 })); + return Visit(Expression.MakeIndex(node.Left, null, [node.Right])); throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}."); } protected override Expression VisitIndex(IndexExpression node) { - if (node.Indexer == AvaloniaObjectIndexer) + if (node.Indexer == BindingExpressionVisitorMembers.AvaloniaObjectIndexer) { var property = GetValue(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).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(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).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) { - switch (node.Member.MemberType) + return node.Member.MemberType switch { - case MemberTypes.Property: - return Add(node.Expression, node, new DynamicPluginPropertyAccessorNode(node.Member.Name, acceptsNull: false)); - default: - throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}."); - } + MemberTypes.Property => AddPropertyNode(node), + _ => throw new ExpressionParseException(0, $"Invalid expression type in binding expression: {node.NodeType}."), + }; } protected override Expression VisitMethodCall(MethodCallExpression node) @@ -90,20 +133,43 @@ internal class BindingExpressionVisitor : ExpressionVisitor else if (method.Name == MultiDimensionalArrayGetterMethodName && node.Object is not null) { - var expression = Expression.MakeIndex(node.Object, null, node.Arguments); - return Add(node.Object, node, new ExpressionTreeIndexerNode(expression)); + var indexes = node.Arguments.Select(GetValue).ToArray(); + return Add(node.Object, node, x => x.ArrayElement(indexes, node.Type)); } else if (method.Name.StartsWith(StreamBindingExtensions.StreamBindingName) && method.DeclaringType == typeof(StreamBindingExtensions)) { var instance = node.Method.IsStatic ? node.Arguments[0] : node.Object; - Add(instance, node, new DynamicPluginStreamNode()); - return node; + var instanceType = instance?.Type; + 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(node.Object!).Name, acceptsNull: false); - return Add(node.Arguments[1], node, accessor); + var methodInfo = GetValue(node.Object!); + var delegateType = GetValue(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}'."); @@ -120,20 +186,26 @@ internal class BindingExpressionVisitor : ExpressionVisitor { 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) { - 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 - return _head = base.VisitUnary(node); + var castMethod = typeof(CompiledBindingPathBuilder) + .GetMethod(nameof(CompiledBindingPathBuilder.TypeCast))! + .MakeGenericMethod(node.Type); + return Add(node.Operand, node, x => castMethod.Invoke(x, null)); } } else if (node.NodeType == ExpressionType.TypeAs) { - // Ignore as operator. - return _head = base.VisitUnary(node); + var castMethod = typeof(CompiledBindingPathBuilder) + .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}."); @@ -146,7 +218,7 @@ internal class BindingExpressionVisitor : ExpressionVisitor 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) @@ -156,17 +228,17 @@ internal class BindingExpressionVisitor : ExpressionVisitor 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) { - 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) { - 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) @@ -191,7 +263,7 @@ internal class BindingExpressionVisitor : ExpressionVisitor 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) @@ -209,17 +281,69 @@ internal class BindingExpressionVisitor : ExpressionVisitor 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 build) { var visited = Visit(instance); + if (visited != _head) + { throw new ExpressionParseException( - 0, + 0, $"Unable to parse '{expression}': expected an instance of '{_head}' but got '{visited}'."); - _nodes.Add(node); + } + + build(_builder); 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? CreateGetter(PropertyInfo info) + { + if (info.GetMethod == null) + return null; + var target = Expression.Parameter(typeof(object), "target"); + return Expression.Lambda>( + Expression.Convert(Expression.Call(Expression.Convert(target, info.DeclaringType!), info.GetMethod), + typeof(object)), + target) + .Compile(); + } + + private static Action? 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>( + Expression.Call(Expression.Convert(target, info.DeclaringType!), info.SetMethod, + Expression.Convert(value, info.SetMethod.GetParameters()[0].ParameterType)), + target, value) + .Compile(); + } + private static T GetValue(Expression expr) { if (expr is ConstantExpression constant) @@ -232,4 +356,168 @@ internal class BindingExpressionVisitor : ExpressionVisitor var type = method.DeclaringType; return type?.GetRuntimeProperties().FirstOrDefault(prop => prop.GetMethod == method); } + + // Accessor factory methods + private static IPropertyAccessor CreateInpcPropertyAccessor(WeakReference target, IPropertyInfo property) + => new InpcPropertyAccessor(target, property); + + private static IPropertyAccessor CreateAvaloniaPropertyAccessor(WeakReference target, IPropertyInfo property) + => new AvaloniaPropertyAccessor( + new WeakReference((AvaloniaObject?)(target.TryGetTarget(out var o) ? o : null)), + (AvaloniaProperty)property); + + private static IPropertyAccessor CreateIndexerPropertyAccessor(WeakReference target, IPropertyInfo property, int argument) + => new IndexerAccessor(target, property, argument); + + // Accessor implementations + private class AvaloniaPropertyAccessor : PropertyAccessorBase, IWeakEventSubscriber + { + private readonly WeakReference _reference; + private readonly AvaloniaProperty _property; + + public AvaloniaPropertyAccessor(WeakReference 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 + { + protected readonly WeakReference _reference; + private readonly IPropertyInfo _property; + + public InpcPropertyAccessor(WeakReference 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 + { + private readonly int _index; + + public IndexerAccessor(WeakReference 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 + }; + } + } } diff --git a/src/Avalonia.Base/Data/Core/Parsers/BindingExpressionVisitorMembers.cs b/src/Avalonia.Base/Data/Core/Parsers/BindingExpressionVisitorMembers.cs new file mode 100644 index 0000000000..82163f89e6 --- /dev/null +++ b/src/Avalonia.Base/Data/Core/Parsers/BindingExpressionVisitorMembers.cs @@ -0,0 +1,20 @@ +using System; +using System.Reflection; + +namespace Avalonia.Data.Core.Parsers; + +/// +/// Stores reflection members used by outside of the +/// generic class to avoid duplication for each generic instantiation. +/// +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; +} diff --git a/tests/Avalonia.Base.UnitTests/Data/CompiledBindingTests_Create.cs b/tests/Avalonia.Base.UnitTests/Data/CompiledBindingTests_Create.cs new file mode 100644 index 0000000000..0dcce969df --- /dev/null +++ b/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(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( + 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( + vm => vm.StringProperty, + converter: converter); + + Assert.Same(converter, binding.Converter); + } + + [Fact] + public void Create_Should_Apply_Mode() + { + var binding = CompiledBinding.Create( + 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( + 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( + 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( + 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( + 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(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(); + } + + 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(); + } +} diff --git a/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.cs b/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.cs index 251be3fc27..6302b68b08 100644 --- a/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.cs +++ b/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.cs @@ -95,7 +95,7 @@ public abstract partial class BindingExpressionTests var target = new TargetClass { DataContext = dataContext }; var nodes = new List(); var fallback = fallbackValue.HasValue ? fallbackValue.Value : AvaloniaProperty.UnsetValue; - var path = CompiledBindingPathFromExpressionBuilder.Build(expression, enableDataValidation); + var path = BindingExpressionVisitor.BuildPath(expression); if (relativeSource is not null && relativeSource.Mode is not RelativeSourceMode.Self) throw new NotImplementedException(); diff --git a/tests/Avalonia.Base.UnitTests/Data/Core/CompiledBindingPathFromExpressionBuilder.cs b/tests/Avalonia.Base.UnitTests/Data/Core/CompiledBindingPathFromExpressionBuilder.cs deleted file mode 100644 index d3623fde80..0000000000 --- a/tests/Avalonia.Base.UnitTests/Data/Core/CompiledBindingPathFromExpressionBuilder.cs +++ /dev/null @@ -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(Expression> 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(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).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(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).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).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 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? CreateGetter(PropertyInfo info) - { - if (info.GetMethod == null) - return null; - var target = Expression.Parameter(typeof(object), "target"); - return Expression.Lambda>( - Expression.Convert(Expression.Call(Expression.Convert(target, info.DeclaringType!), info.GetMethod), - typeof(object)), - target) - .Compile(); - } - - private static Action? 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>( - Expression.Call(Expression.Convert(target, info.DeclaringType!), info.SetMethod, - Expression.Convert(value, info.SetMethod.GetParameters()[0].ParameterType)), - target, value) - .Compile(); - } - - private static T GetValue(Expression expr) - { - if (expr is ConstantExpression constant) - return (T)constant.Value!; - return Expression.Lambda>(expr).Compile(preferInterpretation: true)(); - } - - private static PropertyInfo? TryGetPropertyFromMethod(MethodInfo method) - { - var type = method.DeclaringType; - return type?.GetRuntimeProperties().FirstOrDefault(prop => prop.GetMethod == method); - } -} diff --git a/tests/Avalonia.Base.UnitTests/Data/Core/Parsers/BindingExpressionVisitorExtensions.cs b/tests/Avalonia.Base.UnitTests/Data/Core/Parsers/BindingExpressionVisitorExtensions.cs new file mode 100644 index 0000000000..6f3d3474f5 --- /dev/null +++ b/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; + +/// +/// Test extensions for BindingExpressionVisitor tests. +/// +internal static class BindingExpressionVisitorExtensions +{ + /// + /// Builds a list of binding expression nodes from a lambda expression. + /// This is a test helper method - production code should use BuildPath() instead. + /// + public static List BuildNodes(Expression> expression) + { + var path = BindingExpressionVisitor.BuildPath(expression); + var nodes = new List(); + path.BuildExpression(nodes, out var _); + return nodes; + } +} diff --git a/tests/Avalonia.Base.UnitTests/Data/Core/Parsers/BindingExpressionVisitorTests.cs b/tests/Avalonia.Base.UnitTests/Data/Core/Parsers/BindingExpressionVisitorTests.cs new file mode 100644 index 0000000000..ddd0f87c04 --- /dev/null +++ b/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> expr = x => x.StringProperty; + + var nodes = BuildNodes(expr); + + var node = Assert.Single(nodes); + var propertyNode = Assert.IsType(node); + Assert.Equal("StringProperty", propertyNode.PropertyName); + } + + [Fact] + public void BuildNodes_Should_Parse_Property_Chain() + { + Expression> expr = x => x.Child!.StringProperty; + + var nodes = BuildNodes(expr); + + Assert.Equal(2, nodes.Count); + + var firstNode = Assert.IsType(nodes[0]); + Assert.Equal("Child", firstNode.PropertyName); + + var secondNode = Assert.IsType(nodes[1]); + Assert.Equal("StringProperty", secondNode.PropertyName); + } + + [Fact] + public void BuildNodes_Should_Parse_Long_Property_Chain() + { + Expression> expr = x => x.Child!.Child!.StringProperty; + + var nodes = BuildNodes(expr); + + Assert.Equal(3, nodes.Count); + Assert.All(nodes, n => Assert.IsType(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> expr = x => x.IndexedProperty![0]; + + var nodes = BuildNodes(expr); + + Assert.Equal(2, nodes.Count); + + var propertyNode = Assert.IsType(nodes[0]); + Assert.Equal("IndexedProperty", propertyNode.PropertyName); + + Assert.IsType(nodes[1]); // List indexer, not array + } + + [Fact] + public void BuildNodes_Should_Parse_Array_Index() + { + Expression> expr = x => x.ArrayProperty![0]; + + var nodes = BuildNodes(expr); + + Assert.Equal(2, nodes.Count); + + var propertyNode = Assert.IsType(nodes[0]); + Assert.Equal("ArrayProperty", propertyNode.PropertyName); + + Assert.IsType(nodes[1]); + } + + [Fact] + public void BuildNodes_Should_Parse_Multi_Dimensional_Array() + { + Expression> expr = x => x.MultiDimensionalArray![0, 1]; + + var nodes = BuildNodes(expr); + + Assert.Equal(2, nodes.Count); + + var propertyNode = Assert.IsType(nodes[0]); + Assert.Equal("MultiDimensionalArray", propertyNode.PropertyName); + + Assert.IsType(nodes[1]); + } + + [Fact] + public void BuildNodes_Should_Parse_AvaloniaProperty_Access() + { + Expression> expr = x => x[StyledElement.DataContextProperty]; + + var nodes = BuildNodes(expr); + + var node = Assert.Single(nodes); + var avaloniaPropertyNode = Assert.IsType(node); + Assert.Equal("DataContext", avaloniaPropertyNode.PropertyName); // AvaloniaProperty accessed as property + } + + [Fact] + public void BuildNodes_Should_Parse_AvaloniaProperty_Access_In_Chain() + { + Expression> expr = x => x.StyledChild![StyledElement.DataContextProperty]; + + var nodes = BuildNodes(expr); + + Assert.Equal(2, nodes.Count); + + var propertyNode = Assert.IsType(nodes[0]); + Assert.Equal("StyledChild", propertyNode.PropertyName); + + var avaloniaPropertyNode = Assert.IsType(nodes[1]); + Assert.Equal("DataContext", avaloniaPropertyNode.PropertyName); // AvaloniaProperty accessed as property + } + + [Fact] + public void BuildNodes_Should_Parse_Logical_Not() + { + Expression> expr = x => !x.BoolProperty; + + var nodes = BuildNodes(expr); + + Assert.Equal(2, nodes.Count); + + var propertyNode = Assert.IsType(nodes[0]); + Assert.Equal("BoolProperty", propertyNode.PropertyName); + + Assert.IsType(nodes[1]); + } + + [Fact] + public void BuildNodes_Should_Parse_Logical_Not_In_Chain() + { + Expression> expr = x => !x.Child!.BoolProperty; + + var nodes = BuildNodes(expr); + + Assert.Equal(3, nodes.Count); + Assert.IsType(nodes[0]); + Assert.IsType(nodes[1]); + Assert.IsType(nodes[2]); + } + + [Fact] + public void BuildNodes_Should_Parse_Task_StreamBinding() + { + Expression> expr = x => x.TaskProperty!.StreamBinding(); + + var nodes = BuildNodes(expr); + + Assert.Equal(2, nodes.Count); + + var propertyNode = Assert.IsType(nodes[0]); + Assert.Equal("TaskProperty", propertyNode.PropertyName); + + Assert.IsType(nodes[1]); + } + + [Fact] + public void BuildNodes_Should_Parse_Observable_StreamBinding() + { + Expression> expr = x => x.ObservableProperty!.StreamBinding(); + + var nodes = BuildNodes(expr); + + Assert.Equal(2, nodes.Count); + + var propertyNode = Assert.IsType(nodes[0]); + Assert.Equal("ObservableProperty", propertyNode.PropertyName); + + Assert.IsType(nodes[1]); + } + + [Fact] + public void BuildNodes_Should_Parse_Void_Task_StreamBinding() + { + Expression> expr = x => x.VoidTaskProperty!.StreamBinding(); + + var nodes = BuildNodes(expr); + + Assert.Equal(2, nodes.Count); + + var propertyNode = Assert.IsType(nodes[0]); + Assert.Equal("VoidTaskProperty", propertyNode.PropertyName); + + Assert.IsType(nodes[1]); + } + + [Fact] + public void BuildNodes_Should_Create_Node_For_Upcast() + { + // Upcasts (derived to base) create a cast node + Expression> expr = x => (TestClass)x; + + var nodes = BuildNodes(expr); + + var node = Assert.Single(nodes); + Assert.IsType(node); + } + + [Fact] + public void BuildNodes_Should_Create_Node_For_Upcast_In_Property_Chain() + { + // Cast creates a node, then property access creates another + Expression> expr = x => ((TestClass)x).StringProperty; + + var nodes = BuildNodes(expr); + + Assert.Equal(2, nodes.Count); + Assert.IsType(nodes[0]); + var propertyNode = Assert.IsType(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> expr = x => (DerivedTestClass)x; + + var nodes = BuildNodes(expr); + + var node = Assert.Single(nodes); + Assert.IsType(node); + } + + [Fact] + public void BuildNodes_Should_Create_Node_For_Downcast_In_Property_Chain() + { + // Practical example: casting to access derived type properties + Expression> expr = x => ((DerivedTestClass)x.Child!).DerivedProperty; + + var nodes = BuildNodes(expr); + + Assert.Equal(3, nodes.Count); + var childNode = Assert.IsType(nodes[0]); + Assert.Equal("Child", childNode.PropertyName); + Assert.IsType(nodes[1]); + var derivedNode = Assert.IsType(nodes[2]); + Assert.Equal("DerivedProperty", derivedNode.PropertyName); + } + + [Fact] + public void BuildNodes_Should_Throw_For_Value_Type_Cast() + { + // Value type conversions should throw + Expression> expr = x => (long)x.IntProperty; + + var ex = Assert.Throws(() => + 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> expr = x => (string)(object)x.StringProperty!; + + var nodes = BuildNodes(expr); + + Assert.Equal(3, nodes.Count); + var propertyNode = Assert.IsType(nodes[0]); + Assert.Equal("StringProperty", propertyNode.PropertyName); + Assert.IsType(nodes[1]); // cast to object + Assert.IsType(nodes[2]); // cast to string + } + + [Fact] + public void BuildNodes_Should_Create_Node_For_TypeAs_Operator() + { + // TypeAs operator creates a cast node + Expression> expr = x => x.Child as object; + + var nodes = BuildNodes(expr); + + Assert.Equal(2, nodes.Count); + var propertyNode = Assert.IsType(nodes[0]); + Assert.Equal("Child", propertyNode.PropertyName); + Assert.IsType(nodes[1]); + } + + [Fact] + public void BuildNodes_Should_Throw_For_Addition_Operator() + { + Expression> expr = x => x.IntProperty + 1; + + var ex = Assert.Throws(() => + BuildNodes(expr)); + + Assert.Contains("Invalid expression type", ex.Message); + Assert.Contains("Add", ex.Message); + } + + [Fact] + public void BuildNodes_Should_Throw_For_Subtraction_Operator() + { + Expression> expr = x => x.IntProperty - 1; + + var ex = Assert.Throws(() => + BuildNodes(expr)); + + Assert.Contains("Invalid expression type", ex.Message); + Assert.Contains("Subtract", ex.Message); + } + + [Fact] + public void BuildNodes_Should_Throw_For_Multiplication_Operator() + { + Expression> expr = x => x.IntProperty * 2; + + var ex = Assert.Throws(() => + BuildNodes(expr)); + + Assert.Contains("Invalid expression type", ex.Message); + Assert.Contains("Multiply", ex.Message); + } + + [Fact] + public void BuildNodes_Should_Throw_For_Equality_Operator() + { + Expression> expr = x => x.IntProperty == 42; + + var ex = Assert.Throws(() => + BuildNodes(expr)); + + Assert.Contains("Invalid expression type", ex.Message); + Assert.Contains("Equal", ex.Message); + } + + [Fact] + public void BuildNodes_Should_Throw_For_Conditional_Expression() + { + Expression> expr = x => x.BoolProperty ? "true" : "false"; + + var ex = Assert.Throws(() => + 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> expr = x => x.StringProperty!.ToUpper(); + + var ex = Assert.Throws(() => + 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> expr = x => +x.IntProperty; + + var nodes = BuildNodes(expr); + + var node = Assert.Single(nodes); + var propertyNode = Assert.IsType(node); + Assert.Equal("IntProperty", propertyNode.PropertyName); + } + + [Fact] + public void BuildNodes_Should_Throw_For_Unary_Minus_Operator() + { + Expression> expr = x => -x.IntProperty; + + var ex = Assert.Throws(() => + BuildNodes(expr)); + + Assert.Contains("Invalid expression type", ex.Message); + Assert.Contains("Negate", ex.Message); + } + + [Fact] + public void BuildNodes_Should_Parse_Chained_Indexers() + { + Expression> expr = x => x.NestedIndexedProperty![0]![1]; + + var nodes = BuildNodes(expr); + + Assert.Equal(3, nodes.Count); + + var propertyNode = Assert.IsType(nodes[0]); + Assert.Equal("NestedIndexedProperty", propertyNode.PropertyName); + + Assert.IsType(nodes[1]); // List indexer + Assert.IsType(nodes[2]); // List indexer + } + + [Fact] + public void BuildNodes_Should_Parse_Property_After_Indexer() + { + Expression> expr = x => x.IndexedProperty![0]!.StringProperty; + + var nodes = BuildNodes(expr); + + Assert.Equal(3, nodes.Count); + Assert.IsType(nodes[0]); + Assert.IsType(nodes[1]); // List indexer + Assert.IsType(nodes[2]); + } + + [Fact] + public void BuildNodes_Should_Parse_Indexer_With_String_Key() + { + Expression> expr = x => x.DictionaryProperty!["key"]; + + var nodes = BuildNodes(expr); + + Assert.Equal(2, nodes.Count); + + var propertyNode = Assert.IsType(nodes[0]); + Assert.Equal("DictionaryProperty", propertyNode.PropertyName); + + Assert.IsType(nodes[1]); // Dictionary indexer + } + + [Fact] + public void BuildNodes_Should_Parse_Indexer_With_Variable_Key() + { + var key = "test"; + Expression> expr = x => x.DictionaryProperty![key]; + + var nodes = BuildNodes(expr); + + Assert.Equal(2, nodes.Count); + Assert.IsType(nodes[0]); + Assert.IsType(nodes[1]); // Dictionary indexer + } + + [Fact] + public void BuildNodes_Should_Parse_StreamBinding_In_Property_Chain() + { + Expression> expr = x => x.Child!.TaskProperty!.StreamBinding(); + + var nodes = BuildNodes(expr); + + Assert.Equal(3, nodes.Count); + Assert.IsType(nodes[0]); + Assert.IsType(nodes[1]); + Assert.IsType(nodes[2]); + } + + [Fact] + public void BuildNodes_Should_Parse_Logical_Not_After_StreamBinding() + { + Expression> expr = x => !x.BoolTaskProperty!.StreamBinding(); + + var nodes = BuildNodes(expr); + + Assert.Equal(3, nodes.Count); + Assert.IsType(nodes[0]); + Assert.IsType(nodes[1]); + Assert.IsType(nodes[2]); + } + + [Fact] + public void BuildNodes_Should_Handle_Empty_Expression() + { + Expression> expr = x => x; + + var nodes = BuildNodes(expr); + + Assert.Empty(nodes); + } + + [Fact] + public void BuildNodes_Should_Parse_Multiple_Logical_Not_Operators() + { + Expression> expr = x => !!x.BoolProperty; + + var nodes = BuildNodes(expr); + + Assert.Equal(3, nodes.Count); + Assert.IsType(nodes[0]); + Assert.IsType(nodes[1]); + Assert.IsType(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? IndexedProperty { get; set; } + public List>? NestedIndexedProperty { get; set; } + public Dictionary? DictionaryProperty { get; set; } + public Task? TaskProperty { get; set; } + public Task? VoidTaskProperty { get; set; } + public Task? BoolTaskProperty { get; set; } + public IObservable? ObservableProperty { get; set; } + } + + public class DerivedTestClass : TestClass + { + public string? DerivedProperty { get; set; } + } + + private static List BuildNodes(Expression> expression) + => BindingExpressionVisitorExtensions.BuildNodes(expression); + } +} diff --git a/tests/Avalonia.LeakTests/BindingExpressionExtensions.cs b/tests/Avalonia.LeakTests/BindingExpressionExtensions.cs new file mode 100644 index 0000000000..98ec67e31b --- /dev/null +++ b/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; + +/// +/// Test extensions for creating BindingExpression instances from lambda expressions. +/// +internal static class BindingExpressionExtensions +{ + [RequiresUnreferencedCode(TrimmingMessages.ExpressionNodeRequiresUnreferencedCodeMessage)] + [RequiresDynamicCode(TrimmingMessages.ExpressionNodeRequiresDynamicCodeMessage)] + public static BindingExpression CreateBindingExpression( + TIn source, + Expression> expression, + IValueConverter? converter = null, + CultureInfo? converterCulture = null, + object? converterParameter = null, + bool enableDataValidation = false, + Optional fallbackValue = default, + BindingMode mode = BindingMode.OneWay, + BindingPriority priority = BindingPriority.LocalValue, + object? targetNullValue = null, + bool allowReflection = true) + where TIn : class? + { + var path = BindingExpressionVisitor.BuildPath(expression); + var nodes = new List(); + 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()); + } +} diff --git a/tests/Avalonia.LeakTests/BindingExpressionTests.cs b/tests/Avalonia.LeakTests/BindingExpressionTests.cs index 5a6eddc869..c5b38e209f 100644 --- a/tests/Avalonia.LeakTests/BindingExpressionTests.cs +++ b/tests/Avalonia.LeakTests/BindingExpressionTests.cs @@ -1,6 +1,10 @@ -using System; +using System; using System.Collections.Generic; +using System.Globalization; +using System.Linq.Expressions; using Avalonia.Collections; +using Avalonia.Data; +using Avalonia.Data.Converters; using Avalonia.Data.Core; using Avalonia.UnitTests; using Xunit; @@ -16,7 +20,7 @@ namespace Avalonia.LeakTests { var list = new AvaloniaList { "foo", "bar" }; var source = new { Foo = list }; - var target = BindingExpression.Create(source, o => o.Foo); + var target = CreateBindingExpression(source, o => o.Foo); target.ToObservable().Subscribe(_ => { }); return new WeakReference(list); @@ -37,7 +41,7 @@ namespace Avalonia.LeakTests { var list = new AvaloniaList { "foo", "bar" }; 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(_ => { }); return new WeakReference(list); @@ -58,7 +62,7 @@ namespace Avalonia.LeakTests { var indexer = new NonIntegerIndexer(); var source = new { Foo = indexer }; - var target = BindingExpression.Create(source, o => o.Foo); + var target = CreateBindingExpression(source, o => o.Foo); target.ToObservable().Subscribe(_ => { }); return new WeakReference(indexer); @@ -79,7 +83,7 @@ namespace Avalonia.LeakTests { var methodBound = new 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(_ => { }); return new WeakReference(methodBound); } @@ -92,6 +96,34 @@ namespace Avalonia.LeakTests Assert.False(weakSource.IsAlive); } + private static BindingExpression CreateBindingExpression( + TIn source, + Expression> expression, + IValueConverter? converter = null, + CultureInfo? converterCulture = null, + object? converterParameter = null, + bool enableDataValidation = false, + Optional 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 { public void A() { }