Browse Source

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.
pull/18450/head
Steven Kirk 9 months ago
committed by GitHub
parent
commit
fb5121a42f
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      .ncrunch/XEmbedSample.net8.0.v3.ncrunchproject
  2. 5
      .ncrunch/XEmbedSample.netstandard2.0.v3.ncrunchproject
  3. 12
      src/Avalonia.Base/Data/Core/ExpressionNodes/PropertyAccessorNode.cs
  4. 12
      src/Avalonia.Base/Data/Core/ExpressionNodes/Reflection/DynamicPluginPropertyAccessorNode.cs
  5. 4
      src/Avalonia.Base/Data/Core/Parsers/BindingExpressionVisitor.cs
  6. 15
      src/Avalonia.Base/Utilities/CharacterReader.cs
  7. 50
      src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/XamlIlBindingPathHelper.cs
  8. 46
      src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindings/CompiledBindingPath.cs
  9. 62
      src/Markup/Avalonia.Markup/Markup/Parsers/BindingExpressionGrammar.cs
  10. 2
      src/Markup/Avalonia.Markup/Markup/Parsers/ExpressionNodeFactory.cs
  11. 27
      tests/Avalonia.Base.UnitTests/Data/Core/ErrorCollectingTextBox.cs
  12. 184
      tests/Avalonia.Base.UnitTests/Data/Core/NullConditionalBindingTests.cs
  13. 260
      tests/Avalonia.Markup.UnitTests/Parsers/BindingExpressionGrammarTests.cs
  14. 39
      tests/Avalonia.Markup.UnitTests/Parsers/BindingExpressionGrammarTests_Errors.cs
  15. 2
      tests/Avalonia.Markup.UnitTests/Parsers/ExpressionNodeFactoryTests.cs

5
.ncrunch/XEmbedSample.net8.0.v3.ncrunchproject

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

5
.ncrunch/XEmbedSample.netstandard2.0.v3.ncrunchproject

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

12
src/Avalonia.Base/Data/Core/ExpressionNodes/PropertyAccessorNode.cs

@ -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);

12
src/Avalonia.Base/Data/Core/ExpressionNodes/Reflection/DynamicPluginPropertyAccessorNode.cs

@ -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);

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

@ -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);
}

15
src/Avalonia.Base/Utilities/CharacterReader.cs

@ -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))

50
src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/XamlIlBindingPathHelper.cs

@ -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();
}

46
src/Markup/Avalonia.Markup.Xaml/MarkupExtensions/CompiledBindings/CompiledBindingPath.cs

@ -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

62
src/Markup/Avalonia.Markup/Markup/Parsers/BindingExpressionGrammar.cs

@ -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;

2
src/Markup/Avalonia.Markup/Markup/Parsers/ExpressionNodeFactory.cs

@ -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;

27
tests/Avalonia.Base.UnitTests/Data/Core/ErrorCollectingTextBox.cs

@ -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);
}
}

184
tests/Avalonia.Base.UnitTests/Data/Core/NullConditionalBindingTests.cs

@ -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);
}
}
}

260
tests/Avalonia.Markup.UnitTests/Parsers/BindingExpressionGrammarTests.cs

@ -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;
}
}
}

39
tests/Avalonia.Markup.UnitTests/Parsers/BindingExpressionGrammarTests_Errors.cs

@ -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;
}
}
}

2
tests/Avalonia.Markup.UnitTests/Parsers/ExpressionPathParsing.cs → tests/Avalonia.Markup.UnitTests/Parsers/ExpressionNodeFactoryTests.cs

@ -9,7 +9,7 @@ using Xunit;
namespace Avalonia.Markup.UnitTests.Parsers
{
public class ExpressionObserverBuilderTests
public class ExpressionNodeFactoryTests
{
[Fact]
public void Should_Build_Single_Property()
Loading…
Cancel
Save