Null conditional operator in bindings (#18270)
* Update ncrunch config. * Fix test naming. * Add BindingExpressionGrammar tests. We only had tests for the error states, and that was named incorrectly. * Parse null-conditionals in bindings. * Initial impl of null conditionals in binding path. Fixes #17029. * Ensure that nothing is logged. * Make "?." work when binding to methods. * Don't add a new public API. And add a comment reminding us to make this class internal for 12.0. * Use existing method.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
<ProjectConfiguration>
|
||||
<Settings>
|
||||
<IgnoreThisComponentCompletely>True</IgnoreThisComponentCompletely>
|
||||
</Settings>
|
||||
</ProjectConfiguration>
|
||||
@@ -0,0 +1,5 @@
|
||||
<ProjectConfiguration>
|
||||
<Settings>
|
||||
<IgnoreThisComponentCompletely>True</IgnoreThisComponentCompletely>
|
||||
</Settings>
|
||||
</ProjectConfiguration>
|
||||
@@ -14,12 +14,14 @@ internal sealed class PropertyAccessorNode : ExpressionNode, IPropertyAccessorNo
|
||||
{
|
||||
private readonly Action<object?> _onValueChanged;
|
||||
private readonly IPropertyAccessorPlugin _plugin;
|
||||
private readonly bool _acceptsNull;
|
||||
private IPropertyAccessor? _accessor;
|
||||
private bool _enableDataValidation;
|
||||
|
||||
public PropertyAccessorNode(string propertyName, IPropertyAccessorPlugin plugin)
|
||||
public PropertyAccessorNode(string propertyName, IPropertyAccessorPlugin plugin, bool acceptsNull)
|
||||
{
|
||||
_plugin = plugin;
|
||||
_acceptsNull = acceptsNull;
|
||||
_onValueChanged = OnValueChanged;
|
||||
PropertyName = propertyName;
|
||||
}
|
||||
@@ -50,8 +52,14 @@ internal sealed class PropertyAccessorNode : ExpressionNode, IPropertyAccessorNo
|
||||
[UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access otherwise can break functionality when trimming application code", Justification = "<Pending>")]
|
||||
protected override void OnSourceChanged(object? source, Exception? dataValidationError)
|
||||
{
|
||||
if (!ValidateNonNullSource(source))
|
||||
if (source is null)
|
||||
{
|
||||
if (_acceptsNull)
|
||||
SetValue(null);
|
||||
else
|
||||
ValidateNonNullSource(source);
|
||||
return;
|
||||
}
|
||||
|
||||
var reference = new WeakReference<object?>(source);
|
||||
|
||||
|
||||
+10
-2
@@ -14,12 +14,14 @@ namespace Avalonia.Data.Core.ExpressionNodes.Reflection;
|
||||
[RequiresUnreferencedCode(TrimmingMessages.ExpressionNodeRequiresUnreferencedCodeMessage)]
|
||||
internal sealed class DynamicPluginPropertyAccessorNode : ExpressionNode, IPropertyAccessorNode, ISettableNode
|
||||
{
|
||||
private readonly bool _acceptsNull;
|
||||
private readonly Action<object?> _onValueChanged;
|
||||
private IPropertyAccessor? _accessor;
|
||||
private bool _enableDataValidation;
|
||||
|
||||
public DynamicPluginPropertyAccessorNode(string propertyName)
|
||||
public DynamicPluginPropertyAccessorNode(string propertyName, bool acceptsNull)
|
||||
{
|
||||
_acceptsNull = acceptsNull;
|
||||
_onValueChanged = OnValueChanged;
|
||||
PropertyName = propertyName;
|
||||
}
|
||||
@@ -44,8 +46,14 @@ internal sealed class DynamicPluginPropertyAccessorNode : ExpressionNode, IPrope
|
||||
|
||||
protected override void OnSourceChanged(object? source, Exception? dataValidationError)
|
||||
{
|
||||
if (!ValidateNonNullSource(source))
|
||||
if (source is null)
|
||||
{
|
||||
if (_acceptsNull)
|
||||
SetValue(null);
|
||||
else
|
||||
ValidateNonNullSource(source);
|
||||
return;
|
||||
}
|
||||
|
||||
var reference = new WeakReference<object?>(source);
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ internal class BindingExpressionVisitor<TIn> : ExpressionVisitor
|
||||
switch (node.Member.MemberType)
|
||||
{
|
||||
case MemberTypes.Property:
|
||||
return Add(node.Expression, node, new DynamicPluginPropertyAccessorNode(node.Member.Name));
|
||||
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}.");
|
||||
}
|
||||
@@ -99,7 +99,7 @@ internal class BindingExpressionVisitor<TIn> : ExpressionVisitor
|
||||
}
|
||||
else if (method == CreateDelegateMethod)
|
||||
{
|
||||
var accessor = new DynamicPluginPropertyAccessorNode(GetValue<MethodInfo>(node.Object!).Name);
|
||||
var accessor = new DynamicPluginPropertyAccessorNode(GetValue<MethodInfo>(node.Object!).Name, acceptsNull: false);
|
||||
return Add(node.Arguments[1], node, accessor);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
|
||||
namespace Avalonia.Utilities
|
||||
{
|
||||
// TODO12: This should not be public
|
||||
#if !BUILDTASK
|
||||
public
|
||||
#endif
|
||||
@@ -46,6 +47,20 @@ namespace Avalonia.Utilities
|
||||
}
|
||||
}
|
||||
|
||||
internal bool TakeIf(string s)
|
||||
{
|
||||
var p = TryPeek(s.Length);
|
||||
|
||||
if (p.SequenceEqual(s.AsSpan()))
|
||||
{
|
||||
_s = _s.Slice(s.Length);
|
||||
Position += s.Length;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool TakeIf(Func<char, bool> condition)
|
||||
{
|
||||
if (condition(Peek))
|
||||
|
||||
+42
-8
@@ -207,11 +207,11 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
|
||||
}
|
||||
else if (GetAllDefinedProperties(targetType).FirstOrDefault(p => p.Name == propName.PropertyName) is IXamlProperty clrProperty)
|
||||
{
|
||||
nodes.Add(new XamlIlClrPropertyPathElementNode(clrProperty));
|
||||
nodes.Add(new XamlIlClrPropertyPathElementNode(clrProperty, propName.AcceptsNull));
|
||||
}
|
||||
else if (GetAllDefinedMethods(targetType).FirstOrDefault(m => m.Name == propName.PropertyName) is IXamlMethod method)
|
||||
{
|
||||
nodes.Add(new XamlIlClrMethodPathElementNode(method, context.Configuration.WellKnownTypes.Delegate));
|
||||
nodes.Add(new XamlIlClrMethodPathElementNode(method, context.Configuration.WellKnownTypes.Delegate, propName.AcceptsNull));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -683,10 +683,12 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
|
||||
class XamlIlClrPropertyPathElementNode : IXamlIlBindingPathElementNode
|
||||
{
|
||||
private readonly IXamlProperty _property;
|
||||
private readonly bool _acceptsNull;
|
||||
|
||||
public XamlIlClrPropertyPathElementNode(IXamlProperty property)
|
||||
public XamlIlClrPropertyPathElementNode(IXamlProperty property, bool acceptsNull)
|
||||
{
|
||||
_property = property;
|
||||
_acceptsNull = acceptsNull;
|
||||
}
|
||||
|
||||
public void Emit(XamlIlEmitContext context, IXamlILEmitter codeGen)
|
||||
@@ -697,9 +699,23 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
|
||||
context.Configuration.GetExtra<XamlIlPropertyInfoAccessorFactoryEmitter>()
|
||||
.EmitLoadInpcPropertyAccessorFactory(context, codeGen);
|
||||
|
||||
// By default use the 2-argument overload of CompiledBindingPathBuilder.Property,
|
||||
// unless a "?." null conditional operator appears in the path, in which case use
|
||||
// the 3-parameter version with the `acceptsNull` parameter. This ensures we don't
|
||||
// get a missing method exception if we run against an old version of Avalonia.
|
||||
var methodArgumentCount = 2;
|
||||
|
||||
if (_acceptsNull)
|
||||
{
|
||||
methodArgumentCount = 3;
|
||||
codeGen.Ldc_I4(1);
|
||||
}
|
||||
|
||||
codeGen
|
||||
.EmitCall(context.GetAvaloniaTypes()
|
||||
.CompiledBindingPathBuilder.GetMethod(m => m.Name == "Property"));
|
||||
.CompiledBindingPathBuilder.GetMethod(m =>
|
||||
m.Name == "Property" &&
|
||||
m.Parameters.Count == methodArgumentCount));
|
||||
}
|
||||
|
||||
public IXamlType Type => _property.PropertyType;
|
||||
@@ -707,11 +723,13 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
|
||||
|
||||
class XamlIlClrMethodPathElementNode : IXamlIlBindingPathElementNode
|
||||
{
|
||||
private readonly bool _acceptsNull;
|
||||
|
||||
public XamlIlClrMethodPathElementNode(IXamlMethod method, IXamlType systemDelegateType)
|
||||
public XamlIlClrMethodPathElementNode(IXamlMethod method, IXamlType systemDelegateType, bool acceptsNull)
|
||||
{
|
||||
Method = method;
|
||||
Type = systemDelegateType;
|
||||
_acceptsNull = acceptsNull;
|
||||
}
|
||||
public IXamlMethod Method { get; }
|
||||
|
||||
@@ -754,9 +772,25 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
|
||||
|
||||
codeGen
|
||||
.Ldtoken(Method)
|
||||
.Ldtoken(specificDelegateType)
|
||||
.EmitCall(context.GetAvaloniaTypes()
|
||||
.CompiledBindingPathBuilder.GetMethod(m => m.Name == "Method"));
|
||||
.Ldtoken(specificDelegateType);
|
||||
|
||||
// By default use the 2-argument overload of CompiledBindingPathBuilder.Method,
|
||||
// unless a "?." null conditional operator appears in the path, in which case use
|
||||
// the 3-parameter version with the `acceptsNull` parameter. This ensures we don't
|
||||
// get a missing method exception if we run against an old version of Avalonia.
|
||||
var methodArgumentCount = 2;
|
||||
|
||||
if (_acceptsNull)
|
||||
{
|
||||
methodArgumentCount = 3;
|
||||
codeGen.Ldc_I4(1);
|
||||
}
|
||||
|
||||
codeGen.EmitCall(context.GetAvaloniaTypes()
|
||||
.CompiledBindingPathBuilder.GetMethod(m =>
|
||||
m.Name == "Method" &&
|
||||
m.Parameters.Count == methodArgumentCount));
|
||||
|
||||
|
||||
newDelegateTypeBuilder?.CreateType();
|
||||
}
|
||||
|
||||
+40
-6
@@ -35,7 +35,7 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings
|
||||
node = null;
|
||||
break;
|
||||
case PropertyElement prop:
|
||||
node = new PropertyAccessorNode(prop.Property.Name, new PropertyInfoAccessorPlugin(prop.Property, prop.AccessorFactory));
|
||||
node = new PropertyAccessorNode(prop.Property.Name, new PropertyInfoAccessorPlugin(prop.Property, prop.AccessorFactory), prop.AcceptsNull);
|
||||
break;
|
||||
case MethodAsCommandElement methodAsCommand:
|
||||
node = new MethodCommandNode(
|
||||
@@ -45,7 +45,10 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings
|
||||
methodAsCommand.DependsOnProperties);
|
||||
break;
|
||||
case MethodAsDelegateElement methodAsDelegate:
|
||||
node = new PropertyAccessorNode(methodAsDelegate.Method.Name, new MethodAccessorPlugin(methodAsDelegate.Method, methodAsDelegate.DelegateType));
|
||||
node = new PropertyAccessorNode(
|
||||
methodAsDelegate.Method.Name,
|
||||
new MethodAccessorPlugin(methodAsDelegate.Method, methodAsDelegate.DelegateType),
|
||||
methodAsDelegate.AcceptsNull);
|
||||
break;
|
||||
case ArrayElementPathElement arr:
|
||||
node = new ArrayIndexerNode(arr.Indices);
|
||||
@@ -132,15 +135,33 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings
|
||||
}
|
||||
else
|
||||
{
|
||||
_elements.Add(new PropertyElement(info, accessorFactory, _elements.Count == 0));
|
||||
return Property(info, accessorFactory, acceptsNull: false);
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public CompiledBindingPathBuilder Property(
|
||||
IPropertyInfo info,
|
||||
Func<WeakReference<object?>, IPropertyInfo, IPropertyAccessor> accessorFactory,
|
||||
bool acceptsNull)
|
||||
{
|
||||
_elements.Add(new PropertyElement(info, accessorFactory, _elements.Count == 0, acceptsNull));
|
||||
return this;
|
||||
}
|
||||
|
||||
public CompiledBindingPathBuilder Method(RuntimeMethodHandle handle, RuntimeTypeHandle delegateType)
|
||||
{
|
||||
_elements.Add(new MethodAsDelegateElement(handle, delegateType));
|
||||
Method(handle, delegateType, acceptsNull: false);
|
||||
return this;
|
||||
}
|
||||
|
||||
public CompiledBindingPathBuilder Method(
|
||||
RuntimeMethodHandle handle,
|
||||
RuntimeTypeHandle delegateType,
|
||||
bool acceptsNull)
|
||||
{
|
||||
_elements.Add(new MethodAsDelegateElement(handle, delegateType, acceptsNull));
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -228,34 +249,47 @@ namespace Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings
|
||||
{
|
||||
private readonly bool _isFirstElement;
|
||||
|
||||
public PropertyElement(IPropertyInfo property, Func<WeakReference<object?>, IPropertyInfo, IPropertyAccessor> accessorFactory, bool isFirstElement)
|
||||
public PropertyElement(
|
||||
IPropertyInfo property,
|
||||
Func<WeakReference<object?>, IPropertyInfo, IPropertyAccessor> accessorFactory,
|
||||
bool isFirstElement,
|
||||
bool acceptsNull)
|
||||
{
|
||||
Property = property;
|
||||
AccessorFactory = accessorFactory;
|
||||
_isFirstElement = isFirstElement;
|
||||
AcceptsNull = acceptsNull;
|
||||
}
|
||||
|
||||
public IPropertyInfo Property { get; }
|
||||
|
||||
public Func<WeakReference<object?>, IPropertyInfo, IPropertyAccessor> AccessorFactory { get; }
|
||||
|
||||
public bool AcceptsNull { get; }
|
||||
|
||||
public override string ToString()
|
||||
=> _isFirstElement ? Property.Name : $".{Property.Name}";
|
||||
}
|
||||
|
||||
internal class MethodAsDelegateElement : ICompiledBindingPathElement
|
||||
{
|
||||
public MethodAsDelegateElement(RuntimeMethodHandle method, RuntimeTypeHandle delegateType)
|
||||
public MethodAsDelegateElement(
|
||||
RuntimeMethodHandle method,
|
||||
RuntimeTypeHandle delegateType,
|
||||
bool acceptsNull)
|
||||
{
|
||||
Method = MethodBase.GetMethodFromHandle(method) as MethodInfo
|
||||
?? throw new ArgumentException("Invalid method handle", nameof(method));
|
||||
DelegateType = Type.GetTypeFromHandle(delegateType)
|
||||
?? throw new ArgumentException("Unexpected null returned from Type.GetTypeFromHandle in MethodAsDelegateElement");
|
||||
AcceptsNull = acceptsNull;
|
||||
}
|
||||
|
||||
public MethodInfo Method { get; }
|
||||
|
||||
public Type DelegateType { get; }
|
||||
|
||||
public bool AcceptsNull { get; }
|
||||
}
|
||||
|
||||
internal class MethodAsCommandElement : ICompiledBindingPathElement
|
||||
|
||||
@@ -52,11 +52,19 @@ namespace Avalonia.Markup.Parsers
|
||||
break;
|
||||
|
||||
case State.BeforeMember:
|
||||
state = ParseBeforeMember(ref r, nodes);
|
||||
state = ParseBeforeMember(ref r, nodes, false);
|
||||
break;
|
||||
|
||||
case State.BeforeMemberNullable:
|
||||
state = ParseBeforeMember(ref r, nodes, true);
|
||||
break;
|
||||
|
||||
case State.AttachedProperty:
|
||||
state = ParseAttachedProperty(ref r, nodes);
|
||||
state = ParseAttachedProperty(ref r, nodes, false);
|
||||
break;
|
||||
|
||||
case State.AttachedPropertyNullable:
|
||||
state = ParseAttachedProperty(ref r, nodes, true);
|
||||
break;
|
||||
|
||||
case State.Indexer:
|
||||
@@ -84,7 +92,7 @@ namespace Avalonia.Markup.Parsers
|
||||
throw new ExpressionParseException(r.Position, "Expected end of expression.");
|
||||
}
|
||||
|
||||
if (state == State.BeforeMember)
|
||||
if (state is State.BeforeMember or State.BeforeMemberNullable)
|
||||
{
|
||||
throw new ExpressionParseException(r.Position, "Unexpected end of expression.");
|
||||
}
|
||||
@@ -142,9 +150,9 @@ namespace Avalonia.Markup.Parsers
|
||||
|
||||
private static State ParseAfterMember(ref CharacterReader r, IList<INode> nodes)
|
||||
{
|
||||
if (ParseMemberAccessor(ref r))
|
||||
if (ParseMemberAccessor(ref r, out var acceptsNull))
|
||||
{
|
||||
return State.BeforeMember;
|
||||
return acceptsNull ? State.BeforeMemberNullable : State.BeforeMember;
|
||||
}
|
||||
else if (ParseStreamOperator(ref r))
|
||||
{
|
||||
@@ -163,7 +171,7 @@ namespace Avalonia.Markup.Parsers
|
||||
return State.End;
|
||||
}
|
||||
|
||||
private static State ParseBeforeMember(ref CharacterReader r, IList<INode> nodes)
|
||||
private static State ParseBeforeMember(ref CharacterReader r, IList<INode> nodes, bool acceptsNull)
|
||||
{
|
||||
if (ParseOpenBrace(ref r))
|
||||
{
|
||||
@@ -172,7 +180,7 @@ namespace Avalonia.Markup.Parsers
|
||||
return State.TypeCast;
|
||||
}
|
||||
|
||||
return State.AttachedProperty;
|
||||
return acceptsNull ? State.AttachedPropertyNullable : State.AttachedProperty;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -180,7 +188,11 @@ namespace Avalonia.Markup.Parsers
|
||||
|
||||
if (!identifier.IsEmpty)
|
||||
{
|
||||
nodes.Add(new PropertyNameNode { PropertyName = identifier.ToString() });
|
||||
nodes.Add(new PropertyNameNode
|
||||
{
|
||||
AcceptsNull = acceptsNull,
|
||||
PropertyName = identifier.ToString()
|
||||
});
|
||||
return State.AfterMember;
|
||||
}
|
||||
|
||||
@@ -192,7 +204,7 @@ namespace Avalonia.Markup.Parsers
|
||||
#if NET7SDK
|
||||
scoped
|
||||
#endif
|
||||
ref CharacterReader r, List<INode> nodes)
|
||||
ref CharacterReader r, List<INode> nodes, bool acceptsNull)
|
||||
{
|
||||
var (ns, owner) = ParseTypeName(ref r);
|
||||
|
||||
@@ -221,6 +233,7 @@ namespace Avalonia.Markup.Parsers
|
||||
|
||||
nodes.Add(new AttachedPropertyNameNode
|
||||
{
|
||||
AcceptsNull = acceptsNull,
|
||||
Namespace = ns,
|
||||
TypeName = owner,
|
||||
PropertyName = name.ToString()
|
||||
@@ -256,9 +269,9 @@ namespace Avalonia.Markup.Parsers
|
||||
throw new ExpressionParseException(r.Position, "Expected ')'.");
|
||||
}
|
||||
|
||||
result = ParseBeforeMember(ref r, nodes);
|
||||
result = ParseBeforeMember(ref r, nodes, false);
|
||||
if (result == State.AttachedProperty)
|
||||
result = ParseAttachedProperty(ref r, nodes);
|
||||
result = ParseAttachedProperty(ref r, nodes, false);
|
||||
|
||||
if (r.Peek == '[')
|
||||
{
|
||||
@@ -372,9 +385,28 @@ namespace Avalonia.Markup.Parsers
|
||||
return !r.End && r.TakeIf('!');
|
||||
}
|
||||
|
||||
private static bool ParseMemberAccessor(ref CharacterReader r)
|
||||
private static bool ParseMemberAccessor(ref CharacterReader r, out bool acceptsNull)
|
||||
{
|
||||
return !r.End && r.TakeIf('.');
|
||||
if (r.End)
|
||||
{
|
||||
acceptsNull = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (r.TakeIf('.'))
|
||||
{
|
||||
acceptsNull = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (r.TakeIf("?."))
|
||||
{
|
||||
acceptsNull = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
acceptsNull = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool ParseOpenBrace(ref CharacterReader r)
|
||||
@@ -424,7 +456,9 @@ namespace Avalonia.Markup.Parsers
|
||||
ElementName,
|
||||
AfterMember,
|
||||
BeforeMember,
|
||||
BeforeMemberNullable,
|
||||
AttachedProperty,
|
||||
AttachedPropertyNullable,
|
||||
Indexer,
|
||||
TypeCast,
|
||||
End,
|
||||
@@ -456,11 +490,13 @@ namespace Avalonia.Markup.Parsers
|
||||
|
||||
public class PropertyNameNode : INode
|
||||
{
|
||||
public bool AcceptsNull { get; set; }
|
||||
public string PropertyName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class AttachedPropertyNameNode : INode
|
||||
{
|
||||
public bool AcceptsNull { get; set; }
|
||||
public string Namespace { get; set; } = string.Empty;
|
||||
public string TypeName { get; set; } = string.Empty;
|
||||
public string PropertyName { get; set; } = string.Empty;
|
||||
|
||||
@@ -52,7 +52,7 @@ namespace Avalonia.Markup.Parsers
|
||||
++negated;
|
||||
break;
|
||||
case BindingExpressionGrammar.PropertyNameNode propName:
|
||||
node = new DynamicPluginPropertyAccessorNode(propName.PropertyName);
|
||||
node = new DynamicPluginPropertyAccessorNode(propName.PropertyName, propName.AcceptsNull);
|
||||
break;
|
||||
case BindingExpressionGrammar.SelfNode:
|
||||
node = null;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Data;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Avalonia.Base.UnitTests.Data.Core;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="TextBox"/> which stores the latest binding error state.
|
||||
/// </summary>
|
||||
public class ErrorCollectingTextBox : TextBox
|
||||
{
|
||||
public Exception? Error { get; private set; }
|
||||
public BindingValueType ErrorState { get; private set; }
|
||||
|
||||
protected override void UpdateDataValidation(AvaloniaProperty property, BindingValueType state, Exception? error)
|
||||
{
|
||||
if (property == TextProperty)
|
||||
{
|
||||
Error = error;
|
||||
ErrorState = state;
|
||||
}
|
||||
|
||||
base.UpdateDataValidation(property, state, error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Data;
|
||||
using Avalonia.Logging;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.UnitTests;
|
||||
using Xunit;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace Avalonia.Base.UnitTests.Data.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for null-conditional operator in binding paths.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ideally these would be part of the <see cref="BindingExpressionTests"/> suite but that uses
|
||||
/// C# expression trees as an abstraction to represent both reflection and compiled binding paths.
|
||||
/// This is a problem because expression trees don't support the C# null-conditional operator
|
||||
/// and I have no desire to refactor all of those tests right now.
|
||||
/// </remarks>
|
||||
public class NullConditionalBindingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Should_Report_Error_Without_Null_Conditional_Operator(bool compileBindings)
|
||||
{
|
||||
// Testing the baseline: should report a null error without null conditionals.
|
||||
using var app = Start();
|
||||
using var log = TestLogger.Create();
|
||||
|
||||
var xaml = $$$"""
|
||||
<Window xmlns='https://github.com/avaloniaui'
|
||||
xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
|
||||
xmlns:local='using:Avalonia.Base.UnitTests.Data.Core'
|
||||
x:DataType='local:NullConditionalBindingTests+First'
|
||||
x:CompileBindings='{{{compileBindings}}}'>
|
||||
<local:ErrorCollectingTextBox Text='{Binding Second.Third.Final}'/>
|
||||
</Window>
|
||||
""";
|
||||
var data = new First(new Second(null));
|
||||
var window = CreateTarget(xaml, data);
|
||||
var textBox = Assert.IsType<ErrorCollectingTextBox>(window.Content);
|
||||
var error = Assert.IsType<BindingChainException>(textBox.Error);
|
||||
var message = Assert.Single(log.Messages);
|
||||
|
||||
Assert.Null(textBox.Text);
|
||||
Assert.Equal("Second.Third.Final", error.Expression);
|
||||
Assert.Equal("Third", error.ExpressionErrorPoint);
|
||||
Assert.Equal(BindingValueType.BindingError, textBox.ErrorState);
|
||||
Assert.Equal("An error occurred binding {Property} to {Expression} at {ExpressionErrorPoint}: {Message}", message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Should_Not_Report_Error_With_Null_Conditional_Operator(bool compileBindings)
|
||||
{
|
||||
using var app = Start();
|
||||
using var log = TestLogger.Create();
|
||||
var xaml = $$$"""
|
||||
<Window xmlns='https://github.com/avaloniaui'
|
||||
xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
|
||||
xmlns:local='using:Avalonia.Base.UnitTests.Data.Core'
|
||||
x:DataType='local:NullConditionalBindingTests+First'
|
||||
x:CompileBindings='{{{compileBindings}}}'>
|
||||
<local:ErrorCollectingTextBox Text='{Binding Second.Third?.Final}'/>
|
||||
</Window>
|
||||
""";
|
||||
var data = new First(new Second(null));
|
||||
var window = CreateTarget(xaml, data);
|
||||
var textBox = Assert.IsType<ErrorCollectingTextBox>(window.Content);
|
||||
|
||||
Assert.Null(textBox.Text);
|
||||
Assert.Null(textBox.Error);
|
||||
Assert.Equal(BindingValueType.Value, textBox.ErrorState);
|
||||
Assert.Empty(log.Messages);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Should_Not_Report_Error_With_Null_Conditional_Operator_Before_Method(bool compileBindings)
|
||||
{
|
||||
using var app = Start();
|
||||
using var log = TestLogger.Create();
|
||||
var xaml = $$$"""
|
||||
<Window xmlns='https://github.com/avaloniaui'
|
||||
xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
|
||||
xmlns:local='using:Avalonia.Base.UnitTests.Data.Core'
|
||||
x:DataType='local:NullConditionalBindingTests+First'
|
||||
x:CompileBindings='{{{compileBindings}}}'>
|
||||
<local:ErrorCollectingTextBox Text='{Binding Second.Third?.Greeting}'/>
|
||||
</Window>
|
||||
""";
|
||||
var data = new First(new Second(null));
|
||||
var window = CreateTarget(xaml, data);
|
||||
var textBox = Assert.IsType<ErrorCollectingTextBox>(window.Content);
|
||||
|
||||
Assert.Null(textBox.Text);
|
||||
Assert.Null(textBox.Error);
|
||||
Assert.Equal(BindingValueType.Value, textBox.ErrorState);
|
||||
Assert.Empty(log.Messages);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void Should_Use_TargetNullValue_With_Null_Conditional_Operator(bool compileBindings)
|
||||
{
|
||||
using var app = Start();
|
||||
using var log = TestLogger.Create();
|
||||
var xaml = $$$"""
|
||||
<Window xmlns='https://github.com/avaloniaui'
|
||||
xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
|
||||
xmlns:local='using:Avalonia.Base.UnitTests.Data.Core'
|
||||
x:DataType='local:NullConditionalBindingTests+First'
|
||||
x:CompileBindings='{{{compileBindings}}}'>
|
||||
<local:ErrorCollectingTextBox Text='{Binding Second.Third?.Final, TargetNullValue=ItsNull}'/>
|
||||
</Window>
|
||||
""";
|
||||
var data = new First(new Second(null));
|
||||
var window = CreateTarget(xaml, data);
|
||||
var textBox = Assert.IsType<ErrorCollectingTextBox>(window.Content);
|
||||
|
||||
Assert.Equal("ItsNull", textBox.Text);
|
||||
Assert.Null(textBox.Error);
|
||||
Assert.Equal(BindingValueType.Value, textBox.ErrorState);
|
||||
Assert.Empty(log.Messages);
|
||||
}
|
||||
|
||||
private Window CreateTarget(string xaml, object? data)
|
||||
{
|
||||
var result = (Window)AvaloniaRuntimeXamlLoader.Load(xaml);
|
||||
result.DataContext = data;
|
||||
result.Show();
|
||||
return result;
|
||||
}
|
||||
|
||||
private static IDisposable Start()
|
||||
{
|
||||
return UnitTestApplication.Start(TestServices.StyledWindow);
|
||||
}
|
||||
|
||||
public record First(Second? Second);
|
||||
public record Second(Third? Third);
|
||||
public record Third(string Final)
|
||||
{
|
||||
public string Greeting() => "Hello!";
|
||||
}
|
||||
|
||||
private class TestLogger : ILogSink, IDisposable
|
||||
{
|
||||
private TestLogger() { }
|
||||
|
||||
public IList<string> Messages { get; } = [];
|
||||
|
||||
public static TestLogger Create()
|
||||
{
|
||||
var result = new TestLogger();
|
||||
Logger.Sink = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
public void Dispose() => Logger.Sink = null;
|
||||
|
||||
public bool IsEnabled(LogEventLevel level, string area)
|
||||
{
|
||||
return level >= LogEventLevel.Warning && area == LogArea.Binding;
|
||||
}
|
||||
|
||||
public void Log(LogEventLevel level, string area, object? source, string messageTemplate)
|
||||
{
|
||||
Messages.Add(messageTemplate);
|
||||
}
|
||||
|
||||
public void Log(LogEventLevel level, string area, object? source, string messageTemplate, params object?[] propertyValues)
|
||||
{
|
||||
Messages.Add(messageTemplate);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
using System.Collections.Generic;
|
||||
using Avalonia.Markup.Parsers;
|
||||
using Avalonia.Utilities;
|
||||
using Xunit;
|
||||
|
||||
namespace Avalonia.Markup.UnitTests.Parsers
|
||||
{
|
||||
public partial class BindingExpressionGrammarTests
|
||||
{
|
||||
[Fact]
|
||||
public void Should_Parse_Single_Property()
|
||||
{
|
||||
var result = Parse("Foo");
|
||||
var node = Assert.Single(result);
|
||||
|
||||
AssertIsProperty(node, "Foo");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Parse_Underscored_Property()
|
||||
{
|
||||
var result = Parse("_Foo");
|
||||
var node = Assert.Single(result);
|
||||
|
||||
AssertIsProperty(node, "_Foo");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Parse_Property_With_Digits()
|
||||
{
|
||||
var result = Parse("F0o");
|
||||
var node = Assert.Single(result);
|
||||
|
||||
AssertIsProperty(node, "F0o");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Parse_Dot()
|
||||
{
|
||||
var result = Parse(".");
|
||||
var node = Assert.Single(result);
|
||||
|
||||
Assert.IsType<BindingExpressionGrammar.EmptyExpressionNode>(node);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Parse_Single_Attached_Property()
|
||||
{
|
||||
var result = Parse("(Foo.Bar)");
|
||||
var node = Assert.Single(result);
|
||||
|
||||
AssertIsAttachedProperty(node, "Foo", "Bar");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Parse_Property_Chain()
|
||||
{
|
||||
var result = Parse("Foo.Bar.Baz");
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
AssertIsProperty(result[0], "Foo");
|
||||
AssertIsProperty(result[1], "Bar");
|
||||
AssertIsProperty(result[2], "Baz");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Parse_Property_Chain_With_Attached_Property_1()
|
||||
{
|
||||
var result = Parse("(Foo.Bar).Baz");
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
AssertIsAttachedProperty(result[0], "Foo", "Bar");
|
||||
AssertIsProperty(result[1], "Baz");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Parse_Property_Chain_With_Attached_Property_2()
|
||||
{
|
||||
var result = Parse("Foo.(Bar.Baz)");
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
AssertIsProperty(result[0], "Foo");
|
||||
AssertIsAttachedProperty(result[1], "Bar", "Baz");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Parse_Property_Chain_With_Attached_Property_3()
|
||||
{
|
||||
var result = Parse("Foo.(Bar.Baz).Last");
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
AssertIsProperty(result[0], "Foo");
|
||||
AssertIsAttachedProperty(result[1], "Bar", "Baz");
|
||||
AssertIsProperty(result[2], "Last");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Parse_Null_Conditional_In_Property_Chain_1()
|
||||
{
|
||||
var result = Parse("Foo?.Bar.Baz");
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
AssertIsProperty(result[0], "Foo");
|
||||
AssertIsProperty(result[1], "Bar", acceptsNull: true);
|
||||
AssertIsProperty(result[2], "Baz");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Parse_Null_Conditional_In_Property_Chain_2()
|
||||
{
|
||||
var result = Parse("Foo.Bar?.Baz");
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
AssertIsProperty(result[0], "Foo");
|
||||
AssertIsProperty(result[1], "Bar");
|
||||
AssertIsProperty(result[2], "Baz", acceptsNull: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Parse_Null_Conditional_In_Property_Chain_3()
|
||||
{
|
||||
var result = Parse("Foo?.(Bar.Baz)");
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
AssertIsProperty(result[0], "Foo");
|
||||
AssertIsAttachedProperty(result[1], "Bar", "Baz", acceptsNull: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Parse_Negated_Property_Chain()
|
||||
{
|
||||
var result = Parse("!Foo.Bar.Baz");
|
||||
|
||||
Assert.Equal(4, result.Count);
|
||||
Assert.IsType<BindingExpressionGrammar.NotNode>(result[0]);
|
||||
AssertIsProperty(result[1], "Foo");
|
||||
AssertIsProperty(result[2], "Bar");
|
||||
AssertIsProperty(result[3], "Baz");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Parse_Double_Negated_Property_Chain()
|
||||
{
|
||||
var result = Parse("!!Foo.Bar.Baz");
|
||||
|
||||
Assert.Equal(5, result.Count);
|
||||
Assert.IsType<BindingExpressionGrammar.NotNode>(result[0]);
|
||||
Assert.IsType<BindingExpressionGrammar.NotNode>(result[1]);
|
||||
AssertIsProperty(result[2], "Foo");
|
||||
AssertIsProperty(result[3], "Bar");
|
||||
AssertIsProperty(result[4], "Baz");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Parse_Indexed_Property()
|
||||
{
|
||||
var result = Parse("Foo[15]");
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
AssertIsProperty(result[0], "Foo");
|
||||
AssertIsIndexer(result[1], "15");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Parse_Indexed_Property_StringIndex()
|
||||
{
|
||||
var result = Parse("Foo[Key]");
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
AssertIsProperty(result[0], "Foo");
|
||||
AssertIsIndexer(result[1], "Key");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Parse_Multiple_Indexed_Property()
|
||||
{
|
||||
var result = Parse("Foo[15,6]");
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
AssertIsProperty(result[0], "Foo");
|
||||
AssertIsIndexer(result[1], "15", "6");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Parse_Multiple_Indexed_Property_With_Space()
|
||||
{
|
||||
var result = Parse("Foo[5, 16]");
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
AssertIsProperty(result[0], "Foo");
|
||||
AssertIsIndexer(result[1], "5", "16");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Parse_Consecutive_Indexers()
|
||||
{
|
||||
var result = Parse("Foo[15][16]");
|
||||
|
||||
Assert.Equal(3, result.Count);
|
||||
AssertIsProperty(result[0], "Foo");
|
||||
AssertIsIndexer(result[1], "15");
|
||||
AssertIsIndexer(result[2], "16");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Parse_Indexed_Property_In_Chain()
|
||||
{
|
||||
var result = Parse("Foo.Bar[5, 6].Baz");
|
||||
|
||||
Assert.Equal(4, result.Count);
|
||||
AssertIsProperty(result[0], "Foo");
|
||||
AssertIsProperty(result[1], "Bar");
|
||||
AssertIsIndexer(result[2], "5", "6");
|
||||
AssertIsProperty(result[3], "Baz");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Should_Parse_Stream_Node()
|
||||
{
|
||||
var result = Parse("Foo^");
|
||||
|
||||
Assert.Equal(2, result.Count);
|
||||
Assert.IsType<BindingExpressionGrammar.StreamNode>(result[1]);
|
||||
}
|
||||
|
||||
private static void AssertIsProperty(
|
||||
BindingExpressionGrammar.INode node,
|
||||
string name,
|
||||
bool acceptsNull = false)
|
||||
{
|
||||
var p = Assert.IsType<BindingExpressionGrammar.PropertyNameNode>(node);
|
||||
Assert.Equal(name, p.PropertyName);
|
||||
Assert.Equal(acceptsNull, p.AcceptsNull);
|
||||
}
|
||||
|
||||
private static void AssertIsAttachedProperty(
|
||||
BindingExpressionGrammar.INode node,
|
||||
string typeName,
|
||||
string name,
|
||||
bool acceptsNull = false)
|
||||
{
|
||||
var p = Assert.IsType<BindingExpressionGrammar.AttachedPropertyNameNode>(node);
|
||||
Assert.Equal(typeName, p.TypeName);
|
||||
Assert.Equal(name, p.PropertyName);
|
||||
Assert.Equal(acceptsNull, p.AcceptsNull);
|
||||
}
|
||||
|
||||
private static void AssertIsIndexer(BindingExpressionGrammar.INode node, params string[] args)
|
||||
{
|
||||
var e = Assert.IsType<BindingExpressionGrammar.IndexerNode>(node);
|
||||
Assert.Equal(e.Arguments, args);
|
||||
}
|
||||
|
||||
private static List<BindingExpressionGrammar.INode> Parse(string s)
|
||||
{
|
||||
var r = new CharacterReader(s);
|
||||
return BindingExpressionGrammar.Parse(ref r).Nodes;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
using System.Collections.Generic;
|
||||
using Avalonia.Data.Core;
|
||||
using Avalonia.Markup.Parsers;
|
||||
using Avalonia.Utilities;
|
||||
using Xunit;
|
||||
|
||||
namespace Avalonia.Markup.UnitTests.Parsers
|
||||
{
|
||||
public class ExpressionObserverBuilderTests_Errors
|
||||
public partial class BindingExpressionGrammarTests
|
||||
{
|
||||
[Fact]
|
||||
public void Identifier_Cannot_Start_With_Digit()
|
||||
@@ -22,12 +19,40 @@ namespace Avalonia.Markup.UnitTests.Parsers
|
||||
() => Parse("Foo.%Bar"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Identifier_Cannot_Start_With_QuestionMark()
|
||||
{
|
||||
Assert.Throws<ExpressionParseException>(
|
||||
() => Parse("?Foo"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Identifier_Cannot_Start_With_NullConditional()
|
||||
{
|
||||
Assert.Throws<ExpressionParseException>(
|
||||
() => Parse("?.Foo"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expression_Cannot_End_With_Period()
|
||||
{
|
||||
Assert.Throws<ExpressionParseException>(
|
||||
() => Parse("Foo.Bar."));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expression_Cannot_End_With_QuestionMark()
|
||||
{
|
||||
Assert.Throws<ExpressionParseException>(
|
||||
() => Parse("Foo.Bar?"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expression_Cannot_End_With_Null_Conditional()
|
||||
{
|
||||
Assert.Throws<ExpressionParseException>(
|
||||
() => Parse("Foo.Bar?."));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Expression_Cannot_Start_With_Period_Then_Token()
|
||||
@@ -77,11 +102,5 @@ namespace Avalonia.Markup.UnitTests.Parsers
|
||||
Assert.Throws<ExpressionParseException>(
|
||||
() => Parse("Foo.Bar[3,4]A"));
|
||||
}
|
||||
|
||||
private static List<BindingExpressionGrammar.INode> Parse(string s)
|
||||
{
|
||||
var r = new CharacterReader(s);
|
||||
return BindingExpressionGrammar.Parse(ref r).Nodes;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ using Xunit;
|
||||
|
||||
namespace Avalonia.Markup.UnitTests.Parsers
|
||||
{
|
||||
public class ExpressionObserverBuilderTests
|
||||
public class ExpressionNodeFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Should_Build_Single_Property()
|
||||
Reference in New Issue
Block a user