diff --git a/eng/Dependencies.props b/eng/Dependencies.props
index dd9f4f86ca..fa5b1bbbf2 100644
--- a/eng/Dependencies.props
+++ b/eng/Dependencies.props
@@ -134,6 +134,8 @@ and are generated based on the last package release.
+
+
diff --git a/eng/SharedFramework.External.props b/eng/SharedFramework.External.props
index 3d3f44d186..83705b2145 100644
--- a/eng/SharedFramework.External.props
+++ b/eng/SharedFramework.External.props
@@ -90,6 +90,7 @@
-->
<_CompilationOnlyReference Include="System.Buffers" />
+ <_CompilationOnlyReference Include="System.ComponentModel.Annotations" />
4.5.04.4.0
+ 4.3.04.3.24.5.2
diff --git a/src/Components/Blazor/Build/src/targets/BuiltInBclLinkerDescriptor.xml b/src/Components/Blazor/Build/src/targets/BuiltInBclLinkerDescriptor.xml
index ba6c7faa11..4b442b1bb8 100644
--- a/src/Components/Blazor/Build/src/targets/BuiltInBclLinkerDescriptor.xml
+++ b/src/Components/Blazor/Build/src/targets/BuiltInBclLinkerDescriptor.xml
@@ -9,4 +9,9 @@
to implement timers. Fixes https://github.com/aspnet/Blazor/issues/239 -->
+
+
+
+
+
diff --git a/src/Components/Blazor/Build/test/RuntimeDependenciesResolverTest.cs b/src/Components/Blazor/Build/test/RuntimeDependenciesResolverTest.cs
index c7435cacdf..8f00691a28 100644
--- a/src/Components/Blazor/Build/test/RuntimeDependenciesResolverTest.cs
+++ b/src/Components/Blazor/Build/test/RuntimeDependenciesResolverTest.cs
@@ -84,6 +84,8 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test
"System.Collections.dll",
"System.ComponentModel.Composition.dll",
"System.ComponentModel.dll",
+ "System.ComponentModel.Annotations.dll",
+ "System.ComponentModel.DataAnnotations.dll",
"System.Core.dll",
"System.Data.dll",
"System.Diagnostics.Debug.dll",
diff --git a/src/Components/Components/src/Forms/DataAnnotationsValidator.cs b/src/Components/Components/src/Forms/DataAnnotationsValidator.cs
new file mode 100644
index 0000000000..1cf02723bb
--- /dev/null
+++ b/src/Components/Components/src/Forms/DataAnnotationsValidator.cs
@@ -0,0 +1,28 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+ ///
+ /// Adds Data Annotations validation support to an .
+ ///
+ public class DataAnnotationsValidator : ComponentBase
+ {
+ [CascadingParameter] EditContext CurrentEditContext { get; set; }
+
+ ///
+ protected override void OnInit()
+ {
+ if (CurrentEditContext == null)
+ {
+ throw new InvalidOperationException($"{nameof(DataAnnotationsValidator)} requires a cascading " +
+ $"parameter of type {nameof(EditContext)}. For example, you can use {nameof(DataAnnotationsValidator)} " +
+ $"inside an {nameof(EditForm)}.");
+ }
+
+ CurrentEditContext.AddDataAnnotationsValidation();
+ }
+ }
+}
diff --git a/src/Components/Components/src/Forms/EditContext.cs b/src/Components/Components/src/Forms/EditContext.cs
new file mode 100644
index 0000000000..fd09a241f4
--- /dev/null
+++ b/src/Components/Components/src/Forms/EditContext.cs
@@ -0,0 +1,191 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+ ///
+ /// Holds metadata related to a data editing process, such as flags to indicate which
+ /// fields have been modified and the current set of validation messages.
+ ///
+ public sealed class EditContext
+ {
+ // Note that EditContext tracks state for any FieldIdentifier you give to it, plus
+ // the underlying storage is sparse. As such, none of the APIs have a "field not found"
+ // error state. If you give us an unrecognized FieldIdentifier, that just means we
+ // didn't yet track any state for it, so we behave as if it's in the default state
+ // (valid and unmodified).
+ private readonly Dictionary _fieldStates = new Dictionary();
+
+ ///
+ /// Constructs an instance of .
+ ///
+ /// The model object for the . This object should hold the data being edited, for example as a set of properties.
+ public EditContext(object model)
+ {
+ // The only reason we disallow null is because you'd almost always want one, and if you
+ // really don't, you can pass an empty object then ignore it. Ensuring it's nonnull
+ // simplifies things for all consumers of EditContext.
+ Model = model ?? throw new ArgumentNullException(nameof(model));
+ }
+
+ ///
+ /// An event that is raised when a field value changes.
+ ///
+ public event EventHandler OnFieldChanged;
+
+ ///
+ /// An event that is raised when validation is requested.
+ ///
+ public event EventHandler OnValidationRequested;
+
+ ///
+ /// An event that is raised when validation state has changed.
+ ///
+ public event EventHandler OnValidationStateChanged;
+
+ ///
+ /// Supplies a corresponding to a specified field name
+ /// on this 's .
+ ///
+ /// The name of the editable field.
+ /// A corresponding to a specified field name on this 's .
+ public FieldIdentifier Field(string fieldName)
+ => new FieldIdentifier(Model, fieldName);
+
+ ///
+ /// Gets the model object for this .
+ ///
+ public object Model { get; }
+
+ ///
+ /// Signals that the value for the specified field has changed.
+ ///
+ /// Identifies the field whose value has been changed.
+ public void NotifyFieldChanged(in FieldIdentifier fieldIdentifier)
+ {
+ GetFieldState(fieldIdentifier, ensureExists: true).IsModified = true;
+ OnFieldChanged?.Invoke(this, new FieldChangedEventArgs(fieldIdentifier));
+ }
+
+ ///
+ /// Signals that some aspect of validation state has changed.
+ ///
+ public void NotifyValidationStateChanged()
+ {
+ OnValidationStateChanged?.Invoke(this, ValidationStateChangedEventArgs.Empty);
+ }
+
+ ///
+ /// Clears any modification flag that may be tracked for the specified field.
+ ///
+ /// Identifies the field whose modification flag (if any) should be cleared.
+ public void MarkAsUnmodified(in FieldIdentifier fieldIdentifier)
+ {
+ if (_fieldStates.TryGetValue(fieldIdentifier, out var state))
+ {
+ state.IsModified = false;
+ }
+ }
+
+ ///
+ /// Clears all modification flags within this .
+ ///
+ public void MarkAsUnmodified()
+ {
+ foreach (var state in _fieldStates)
+ {
+ state.Value.IsModified = false;
+ }
+ }
+
+ ///
+ /// Determines whether any of the fields in this have been modified.
+ ///
+ /// True if any of the fields in this have been modified; otherwise false.
+ public bool IsModified()
+ {
+ // If necessary, we could consider caching the overall "is modified" state and only recomputing
+ // when there's a call to NotifyFieldModified/NotifyFieldUnmodified
+ foreach (var state in _fieldStates)
+ {
+ if (state.Value.IsModified)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Gets the current validation messages across all fields.
+ ///
+ /// This method does not perform validation itself. It only returns messages determined by previous validation actions.
+ ///
+ /// The current validation messages.
+ public IEnumerable GetValidationMessages()
+ {
+ // Since we're only enumerating the fields for which we have a non-null state, the cost of this grows
+ // based on how many fields have been modified or have associated validation messages
+ foreach (var state in _fieldStates)
+ {
+ foreach (var message in state.Value.GetValidationMessages())
+ {
+ yield return message;
+ }
+ }
+ }
+
+ ///
+ /// Gets the current validation messages for the specified field.
+ ///
+ /// This method does not perform validation itself. It only returns messages determined by previous validation actions.
+ ///
+ /// Identifies the field whose current validation messages should be returned.
+ /// The current validation messages for the specified field.
+ public IEnumerable GetValidationMessages(FieldIdentifier fieldIdentifier)
+ {
+ if (_fieldStates.TryGetValue(fieldIdentifier, out var state))
+ {
+ foreach (var message in state.GetValidationMessages())
+ {
+ yield return message;
+ }
+ }
+ }
+
+ ///
+ /// Determines whether the specified fields in this has been modified.
+ ///
+ /// True if the field has been modified; otherwise false.
+ public bool IsModified(in FieldIdentifier fieldIdentifier)
+ => _fieldStates.TryGetValue(fieldIdentifier, out var state)
+ ? state.IsModified
+ : false;
+
+ ///
+ /// Validates this .
+ ///
+ /// True if there are no validation messages after validation; otherwise false.
+ public bool Validate()
+ {
+ OnValidationRequested?.Invoke(this, ValidationRequestedEventArgs.Empty);
+ return !GetValidationMessages().Any();
+ }
+
+ internal FieldState GetFieldState(in FieldIdentifier fieldIdentifier, bool ensureExists)
+ {
+ if (!_fieldStates.TryGetValue(fieldIdentifier, out var state) && ensureExists)
+ {
+ state = new FieldState(fieldIdentifier);
+ _fieldStates.Add(fieldIdentifier, state);
+ }
+
+ return state;
+ }
+ }
+}
diff --git a/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs b/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs
new file mode 100644
index 0000000000..6542114a8d
--- /dev/null
+++ b/src/Components/Components/src/Forms/EditContextDataAnnotationsExtensions.cs
@@ -0,0 +1,101 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.Linq;
+using System.Reflection;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+ ///
+ /// Extension methods to add DataAnnotations validation to an .
+ ///
+ public static class EditContextDataAnnotationsExtensions
+ {
+ private static ConcurrentDictionary<(Type ModelType, string FieldName), PropertyInfo> _propertyInfoCache
+ = new ConcurrentDictionary<(Type, string), PropertyInfo>();
+
+ ///
+ /// Adds DataAnnotations validation support to the .
+ ///
+ /// The .
+ public static EditContext AddDataAnnotationsValidation(this EditContext editContext)
+ {
+ if (editContext == null)
+ {
+ throw new ArgumentNullException(nameof(editContext));
+ }
+
+ var messages = new ValidationMessageStore(editContext);
+
+ // Perform object-level validation on request
+ editContext.OnValidationRequested +=
+ (sender, eventArgs) => ValidateModel((EditContext)sender, messages);
+
+ // Perform per-field validation on each field edit
+ editContext.OnFieldChanged +=
+ (sender, eventArgs) => ValidateField(editContext, messages, eventArgs.FieldIdentifier);
+
+ return editContext;
+ }
+
+ private static void ValidateModel(EditContext editContext, ValidationMessageStore messages)
+ {
+ var validationContext = new ValidationContext(editContext.Model);
+ var validationResults = new List();
+ Validator.TryValidateObject(editContext.Model, validationContext, validationResults, true);
+
+ // Transfer results to the ValidationMessageStore
+ messages.Clear();
+ foreach (var validationResult in validationResults)
+ {
+ foreach (var memberName in validationResult.MemberNames)
+ {
+ messages.Add(editContext.Field(memberName), validationResult.ErrorMessage);
+ }
+ }
+
+ editContext.NotifyValidationStateChanged();
+ }
+
+ private static void ValidateField(EditContext editContext, ValidationMessageStore messages, in FieldIdentifier fieldIdentifier)
+ {
+ if (TryGetValidatableProperty(fieldIdentifier, out var propertyInfo))
+ {
+ var propertyValue = propertyInfo.GetValue(fieldIdentifier.Model);
+ var validationContext = new ValidationContext(fieldIdentifier.Model)
+ {
+ MemberName = propertyInfo.Name
+ };
+ var results = new List();
+
+ Validator.TryValidateProperty(propertyValue, validationContext, results);
+ messages.Clear(fieldIdentifier);
+ messages.AddRange(fieldIdentifier, results.Select(result => result.ErrorMessage));
+
+ // We have to notify even if there were no messages before and are still no messages now,
+ // because the "state" that changed might be the completion of some async validation task
+ editContext.NotifyValidationStateChanged();
+ }
+ }
+
+ private static bool TryGetValidatableProperty(in FieldIdentifier fieldIdentifier, out PropertyInfo propertyInfo)
+ {
+ var cacheKey = (ModelType: fieldIdentifier.Model.GetType(), fieldIdentifier.FieldName);
+ if (!_propertyInfoCache.TryGetValue(cacheKey, out propertyInfo))
+ {
+ // DataAnnotations only validates public properties, so that's all we'll look for
+ // If we can't find it, cache 'null' so we don't have to try again next time
+ propertyInfo = cacheKey.ModelType.GetProperty(cacheKey.FieldName);
+
+ // No need to lock, because it doesn't matter if we write the same value twice
+ _propertyInfoCache[cacheKey] = propertyInfo;
+ }
+
+ return propertyInfo != null;
+ }
+ }
+}
diff --git a/src/Components/Components/src/Forms/EditContextExpressionExtensions.cs b/src/Components/Components/src/Forms/EditContextExpressionExtensions.cs
new file mode 100644
index 0000000000..3d856e241f
--- /dev/null
+++ b/src/Components/Components/src/Forms/EditContextExpressionExtensions.cs
@@ -0,0 +1,35 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq.Expressions;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+ ///
+ /// Provides extension methods to simplify using with expressions.
+ ///
+ public static class EditContextExpressionExtensions
+ {
+ ///
+ /// Gets the current validation messages for the specified field.
+ ///
+ /// This method does not perform validation itself. It only returns messages determined by previous validation actions.
+ ///
+ /// The .
+ /// Identifies the field whose current validation messages should be returned.
+ /// The current validation messages for the specified field.
+ public static IEnumerable GetValidationMessages(this EditContext editContext, Expression> accessor)
+ => editContext.GetValidationMessages(FieldIdentifier.Create(accessor));
+
+ ///
+ /// Determines whether the specified fields in this has been modified.
+ ///
+ /// The .
+ /// Identifies the field whose current validation messages should be returned.
+ /// True if the field has been modified; otherwise false.
+ public static bool IsModified(this EditContext editContext, Expression> accessor)
+ => editContext.IsModified(FieldIdentifier.Create(accessor));
+ }
+}
diff --git a/src/Components/Components/src/Forms/EditContextFieldClassExtensions.cs b/src/Components/Components/src/Forms/EditContextFieldClassExtensions.cs
new file mode 100644
index 0000000000..bd92563535
--- /dev/null
+++ b/src/Components/Components/src/Forms/EditContextFieldClassExtensions.cs
@@ -0,0 +1,46 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Linq;
+using System.Linq.Expressions;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+ ///
+ /// Provides extension methods to describe the state of
+ /// fields as CSS class names.
+ ///
+ public static class EditContextFieldClassExtensions
+ {
+ ///
+ /// Gets a string that indicates the status of the specified field. This will include
+ /// some combination of "modified", "valid", or "invalid", depending on the status of the field.
+ ///
+ /// The .
+ /// An identifier for the field.
+ /// A string that indicates the status of the field.
+ public static string FieldClass(this EditContext editContext, Expression> accessor)
+ => FieldClass(editContext, FieldIdentifier.Create(accessor));
+
+ ///
+ /// Gets a string that indicates the status of the specified field. This will include
+ /// some combination of "modified", "valid", or "invalid", depending on the status of the field.
+ ///
+ /// The .
+ /// An identifier for the field.
+ /// A string that indicates the status of the field.
+ public static string FieldClass(this EditContext editContext, in FieldIdentifier fieldIdentifier)
+ {
+ var isValid = !editContext.GetValidationMessages(fieldIdentifier).Any();
+ if (editContext.IsModified(fieldIdentifier))
+ {
+ return isValid ? "modified valid" : "modified invalid";
+ }
+ else
+ {
+ return isValid ? "valid" : "invalid";
+ }
+ }
+ }
+}
diff --git a/src/Components/Components/src/Forms/EditForm.cs b/src/Components/Components/src/Forms/EditForm.cs
new file mode 100644
index 0000000000..9ad279a519
--- /dev/null
+++ b/src/Components/Components/src/Forms/EditForm.cs
@@ -0,0 +1,139 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Components.RenderTree;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+ ///
+ /// Renders a form element that cascades an to descendants.
+ ///
+ public class EditForm : ComponentBase
+ {
+ private readonly Func _handleSubmitDelegate; // Cache to avoid per-render allocations
+
+ private EditContext _fixedEditContext;
+
+ ///
+ /// Constructs an instance of .
+ ///
+ public EditForm()
+ {
+ _handleSubmitDelegate = HandleSubmitAsync;
+ }
+
+ ///
+ /// Supplies the edit context explicitly. If using this parameter, do not
+ /// also supply , since the model value will be taken
+ /// from the property.
+ ///
+ [Parameter] EditContext EditContext { get; set; }
+
+ ///
+ /// Specifies the top-level model object for the form. An edit context will
+ /// be constructed for this model. If using this parameter, do not also supply
+ /// a value for .
+ ///
+ [Parameter] object Model { get; set; }
+
+ ///
+ /// Specifies the content to be rendered inside this .
+ ///
+ [Parameter] RenderFragment ChildContent { get; set; }
+
+ ///
+ /// A callback that will be invoked when the form is submitted.
+ ///
+ /// If using this parameter, you are responsible for triggering any validation
+ /// manually, e.g., by calling .
+ ///
+ [Parameter] EventCallback OnSubmit { get; set; }
+
+ ///
+ /// A callback that will be invoked when the form is submitted and the
+ /// is determined to be valid.
+ ///
+ [Parameter] EventCallback OnValidSubmit { get; set; }
+
+ ///
+ /// A callback that will be invoked when the form is submitted and the
+ /// is determined to be invalid.
+ ///
+ [Parameter] EventCallback OnInvalidSubmit { get; set; }
+
+ ///
+ protected override void OnParametersSet()
+ {
+ if ((EditContext == null) == (Model == null))
+ {
+ throw new InvalidOperationException($"{nameof(EditForm)} requires a {nameof(Model)} " +
+ $"parameter, or an {nameof(EditContext)} parameter, but not both.");
+ }
+
+ // If you're using OnSubmit, it becomes your responsibility to trigger validation manually
+ // (e.g., so you can display a "pending" state in the UI). In that case you don't want the
+ // system to trigger a second validation implicitly, so don't combine it with the simplified
+ // OnValidSubmit/OnInvalidSubmit handlers.
+ if (OnSubmit.HasDelegate && (OnValidSubmit.HasDelegate || OnInvalidSubmit.HasDelegate))
+ {
+ throw new InvalidOperationException($"When supplying an {nameof(OnSubmit)} parameter to " +
+ $"{nameof(EditForm)}, do not also supply {nameof(OnValidSubmit)} or {nameof(OnInvalidSubmit)}.");
+ }
+
+ // Update _fixedEditContext if we don't have one yet, or if they are supplying a
+ // potentially new EditContext, or if they are supplying a different Model
+ if (_fixedEditContext == null || EditContext != null || Model != _fixedEditContext.Model)
+ {
+ _fixedEditContext = EditContext ?? new EditContext(Model);
+ }
+ }
+
+ ///
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ base.BuildRenderTree(builder);
+
+ // If _fixedEditContext changes, tear down and recreate all descendants.
+ // This is so we can safely use the IsFixed optimization on CascadingValue,
+ // optimizing for the common case where _fixedEditContext never changes.
+ builder.OpenRegion(_fixedEditContext.GetHashCode());
+
+ builder.OpenElement(0, "form");
+ builder.AddAttribute(1, "onsubmit", _handleSubmitDelegate);
+ builder.OpenComponent>(2);
+ builder.AddAttribute(3, "IsFixed", true);
+ builder.AddAttribute(4, "Value", _fixedEditContext);
+ builder.AddAttribute(5, RenderTreeBuilder.ChildContent, ChildContent?.Invoke(_fixedEditContext));
+ builder.CloseComponent();
+ builder.CloseElement();
+
+ builder.CloseRegion();
+ }
+
+ private async Task HandleSubmitAsync()
+ {
+ if (OnSubmit.HasDelegate)
+ {
+ // When using OnSubmit, the developer takes control of the validation lifecycle
+ await OnSubmit.InvokeAsync(_fixedEditContext);
+ }
+ else
+ {
+ // Otherwise, the system implicitly runs validation on form submission
+ var isValid = _fixedEditContext.Validate(); // This will likely become ValidateAsync later
+
+ if (isValid && OnValidSubmit.HasDelegate)
+ {
+ await OnValidSubmit.InvokeAsync(_fixedEditContext);
+ }
+
+ if (!isValid && OnInvalidSubmit.HasDelegate)
+ {
+ await OnInvalidSubmit.InvokeAsync(_fixedEditContext);
+ }
+ }
+ }
+ }
+}
diff --git a/src/Components/Components/src/Forms/FieldChangedEventArgs.cs b/src/Components/Components/src/Forms/FieldChangedEventArgs.cs
new file mode 100644
index 0000000000..9bf18dd486
--- /dev/null
+++ b/src/Components/Components/src/Forms/FieldChangedEventArgs.cs
@@ -0,0 +1,21 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+ ///
+ /// Provides information about the event.
+ ///
+ public sealed class FieldChangedEventArgs
+ {
+ ///
+ /// Identifies the field whose value has changed.
+ ///
+ public FieldIdentifier FieldIdentifier { get; }
+
+ internal FieldChangedEventArgs(in FieldIdentifier fieldIdentifier)
+ {
+ FieldIdentifier = fieldIdentifier;
+ }
+ }
+}
diff --git a/src/Components/Components/src/Forms/FieldIdentifier.cs b/src/Components/Components/src/Forms/FieldIdentifier.cs
new file mode 100644
index 0000000000..a113cccbe7
--- /dev/null
+++ b/src/Components/Components/src/Forms/FieldIdentifier.cs
@@ -0,0 +1,113 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Linq.Expressions;
+
+namespace Microsoft.AspNetCore.Components.Forms
+{
+ ///
+ /// Uniquely identifies a single field that can be edited. This may correspond to a property on a
+ /// model object, or can be any other named value.
+ ///
+ public readonly struct FieldIdentifier
+ {
+ ///
+ /// Initializes a new instance of the structure.
+ ///
+ /// An expression that identifies an object member.
+ public static FieldIdentifier Create(Expression> accessor)
+ {
+ if (accessor == null)
+ {
+ throw new ArgumentNullException(nameof(accessor));
+ }
+
+ ParseAccessor(accessor, out var model, out var fieldName);
+ return new FieldIdentifier(model, fieldName);
+ }
+
+ ///
+ /// Initializes a new instance of the structure.
+ ///
+ /// The object that owns the field.
+ /// The name of the editable field.
+ public FieldIdentifier(object model, string fieldName)
+ {
+ if (model == null)
+ {
+ throw new ArgumentNullException(nameof(model));
+ }
+
+ if (model.GetType().IsValueType)
+ {
+ throw new ArgumentException("The model must be a reference-typed object.", nameof(model));
+ }
+
+ Model = model;
+
+ // Note that we do allow an empty string. This is used by some validation systems
+ // as a place to store object-level (not per-property) messages.
+ FieldName = fieldName ?? throw new ArgumentNullException(nameof(fieldName));
+ }
+
+ ///
+ /// Gets the object that owns the editable field.
+ ///
+ public object Model { get; }
+
+ ///
+ /// Gets the name of the editable field.
+ ///
+ public string FieldName { get; }
+
+ ///
+ public override int GetHashCode()
+ => (Model, FieldName).GetHashCode();
+
+ ///
+ public override bool Equals(object obj)
+ => obj is FieldIdentifier otherIdentifier
+ && otherIdentifier.Model == Model
+ && string.Equals(otherIdentifier.FieldName, FieldName, StringComparison.Ordinal);
+
+ private static void ParseAccessor(Expression> accessor, out object model, out string fieldName)
+ {
+ var accessorBody = accessor.Body;
+
+ // Unwrap casts to object
+ if (accessorBody is UnaryExpression unaryExpression
+ && unaryExpression.NodeType == ExpressionType.Convert
+ && unaryExpression.Type == typeof(object))
+ {
+ accessorBody = unaryExpression.Operand;
+ }
+
+ if (!(accessorBody is MemberExpression memberExpression))
+ {
+ throw new ArgumentException($"The provided expression contains a {accessorBody.GetType().Name} which is not supported. {nameof(FieldIdentifier)} only supports simple member accessors (fields, properties) of an object.");
+ }
+
+ // Identify the field name. We don't mind whether it's a property or field, or even something else.
+ fieldName = memberExpression.Member.Name;
+
+ // Get a reference to the model object
+ // i.e., given an value like "(something).MemberName", determine the runtime value of "(something)",
+ switch (memberExpression.Expression)
+ {
+ case ConstantExpression constantExpression:
+ model = constantExpression.Value;
+ break;
+ default:
+ // It would be great to cache this somehow, but it's unclear there's a reasonable way to do
+ // so, given that it embeds captured values such as "this". We could consider special-casing
+ // for "() => something.Member" and building a cache keyed by "something.GetType()" with values
+ // of type Func