From c213639a82fadf8bced7c46ccd0880aaefc7be0c Mon Sep 17 00:00:00 2001 From: Sam Xu Date: Mon, 10 Aug 2026 10:11:29 -0700 Subject: [PATCH] Support ResourceConstantNode and refactored CollectionConstantNode from ODL 9.0 Add binder, validator, and translator support for the new ResourceConstantNode (inline JSON object literal) and the refactored CollectionConstantNode (Items of IList) introduced in ODL 9.0. Untyped resource literals bind to Dictionary; typed literals bind to the CLR type. Collections build homogeneous or heterogeneous lists as appropriate. --- .../Microsoft.AspNetCore.OData.xml | 70 ++++- .../PublicAPI.Unshipped.txt | 3 + .../Query/Expressions/QueryBinder.cs | 163 +++++++++++- .../Query/ParameterAliasNodeTranslator.cs | 10 + .../Query/Validator/FilterQueryValidator.cs | 54 ++++ .../Microsoft.AspNetCore.OData.PublicApi.bsl | 3 + .../Query/Expressions/FilterBinderTests.cs | 20 ++ .../Query/Expressions/QueryBinderTests.cs | 240 ++++++++++++++++++ 8 files changed, 550 insertions(+), 13 deletions(-) diff --git a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml index b17c58604..33e369550 100644 --- a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml +++ b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml @@ -1217,13 +1217,6 @@ out: the element type of the collection. True if the type is an enumeration; false otherwise. - - - Determines whether the specified represents an type. - - The to evaluate. - True if the type is an enumeration; false otherwise. - Check whether the given type is a primitive type or known type. @@ -1231,6 +1224,13 @@ The type to validate. True if type is primitive or known type, otherwise False. + + + Determines whether the specified represents an type. + + The to evaluate. + True if the type is an enumeration; false otherwise. + Returns type of T if the type implements IEnumerable of T, otherwise, return null. @@ -10561,6 +10561,32 @@ The query binder context. The LINQ created. + + + Binds a to create a LINQ that + represents the semantics of the . + + The query node to bind. + The query binder context. + The LINQ created. + + A represents an inline JSON object literal (e.g. {"Name":"John"}) + introduced in OData Library (ODL) 9.0. When the node has a valid (non-untyped) expected structured type, + it is materialized into a CLR instance of that type using a member-initialization expression, binding each + property value recursively. When the expected type is missing or untyped (e.g. a literal against an open + or untyped property), the node is materialized into a keyed by + property name. + + + + + Materializes an untyped into a + keyed by property name, binding each property value recursively. + + The query node to bind. + The query binder context. + The LINQ that builds the dictionary. + Creates an from the . @@ -12469,6 +12495,13 @@ The node to be translated. The original node. + + + Translate a ResourceConstantNode. + + The node to be translated. + The original node. + Translate a ConvertNode. @@ -13719,6 +13752,29 @@ The constant node to validate. The validation context. + + + Override this method to restrict the resource (inline JSON object) literal inside the filter query. + + + A represents an inline resource literal (e.g. {"Name":"John"}) + introduced in OData Library (ODL) 9.0. This method recursively validates each of its property value nodes. + + The resource constant node to validate. + The validator context. + + + + Override this method to restrict the collection (inline JSON array) literal inside the filter query. + + + This method recursively validates each item node of a . As of + OData Library (ODL) 9.0 those items may include and nested + instances. + + The collection constant node to validate. + The validator context. + Override this method to restrict the 'cast' inside the filter query. diff --git a/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt b/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt index 433cc0744..80f725dac 100644 --- a/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt +++ b/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt @@ -35,3 +35,6 @@ Microsoft.AspNetCore.OData.Query.Validator.FilterValidatorContext.EnterFunctionC Microsoft.AspNetCore.OData.Query.Validator.FilterValidatorContext.ExitFunctionCall() -> void Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings.MaxFunctionCallDepth.get -> int Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings.MaxFunctionCallDepth.set -> void +virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.BindResourceConstantNode(Microsoft.OData.UriParser.ResourceConstantNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression +virtual Microsoft.AspNetCore.OData.Query.Validator.FilterQueryValidator.ValidateResourceConstantNode(Microsoft.OData.UriParser.ResourceConstantNode resourceConstantNode, Microsoft.AspNetCore.OData.Query.Validator.FilterValidatorContext validatorContext) -> void +virtual Microsoft.AspNetCore.OData.Query.Validator.FilterQueryValidator.ValidateCollectionConstantNode(Microsoft.OData.UriParser.CollectionConstantNode collectionConstantNode, Microsoft.AspNetCore.OData.Query.Validator.FilterValidatorContext validatorContext) -> void diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinder.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinder.cs index 9883a7f8b..bc5828c37 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinder.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinder.cs @@ -137,6 +137,9 @@ public virtual Expression BindSingleValueNode(SingleValueNode node, QueryBinderC case QueryNodeKind.Constant: return BindConstantNode(node as ConstantNode, context); + case QueryNodeKind.ResourceConstant: + return BindResourceConstantNode(node as ResourceConstantNode, context); + case QueryNodeKind.Convert: return BindConvertNode(node as ConvertNode, context); @@ -937,8 +940,54 @@ public virtual Expression BindCollectionConstantNode(CollectionConstantNode node { CheckArgumentNull(node, context); + // ODL 9.0 replaced the (now [Obsolete]) 'Collection' property (IList) with the + // 'Items' property (IList). The 'Items' collection can be heterogeneous: besides + // primitive/enum ConstantNode items, it may contain ResourceConstantNode (a JSON object literal) + // and nested CollectionConstantNode items, in any combination. + // + // When every item is a plain ConstantNode we keep the original behavior and materialize a constant + // List (which is friendly to LINQ providers such as Entity Framework). Otherwise we build the + // list with a ListInit expression, binding each item recursively so that ConstantNode, + // ResourceConstantNode and nested CollectionConstantNode items are all handled. + if (!node.Items.All(item => item is ConstantNode)) + { + // Bind each item first so that ConstantNode, ResourceConstantNode and nested + // CollectionConstantNode items are all materialized into expressions. + List itemExpressions = node.Items.Select(item => Bind(item, context)).ToList(); + + // Prefer the CLR type mapped from the EDM item type. The model cannot map some item types + // (e.g. a nested Collection(...) type or an untyped item type), so fall back to the common CLR + // type of the bound items, using object when the items are heterogeneous. + Type elementClrType = context.Model.GetClrType(node.ItemType, context.AssembliesResolver); + if (elementClrType == null) + { + Type firstType = itemExpressions.FirstOrDefault()?.Type; + bool uniform = firstType != null && itemExpressions.All(e => firstType.IsAssignableFrom(e.Type)); + elementClrType = uniform ? firstType : typeof(object); + } + + Type listType = typeof(List<>).MakeGenericType(elementClrType); + NewExpression newList = Expression.New(listType); + List elementInits = new List(); + MethodInfo addMethod = listType.GetMethod("Add", new[] { elementClrType }); + + foreach (Expression itemExpression in itemExpressions) + { + Expression element = itemExpression; + if (element.Type != elementClrType && (!elementClrType.IsAssignableFrom(element.Type) || element.Type.IsValueType)) + { + element = Expression.Convert(element, elementClrType); + } + + elementInits.Add(Expression.ElementInit(addMethod, element)); + } + + return elementInits.Count == 0 ? (Expression)newList : Expression.ListInit(newList, elementInits); + } + // It's fine if the collection is empty; the returned value will be an empty list. - ConstantNode firstNode = node.Collection.FirstOrDefault(); + IList constantItems = node.Items.OfType().ToList(); + ConstantNode firstNode = constantItems.FirstOrDefault(); object value = null; if (firstNode != null) { @@ -949,12 +998,12 @@ public virtual Expression BindCollectionConstantNode(CollectionConstantNode node Type nullableConstantType = node.ItemType.IsNullable && constantType.IsValueType && Nullable.GetUnderlyingType(constantType) == null ? typeof(Nullable<>).MakeGenericType(constantType) : constantType; - Type listType = typeof(List<>).MakeGenericType(nullableConstantType); - IList castedList = Activator.CreateInstance(listType) as IList; + Type listConstantType = typeof(List<>).MakeGenericType(nullableConstantType); + IList castedList = Activator.CreateInstance(listConstantType) as IList; // Getting a LINQ expression to dynamically cast each item in the Collection during runtime is tricky, // so using a foreach loop and doing an implicit cast from object to the CLR type of ItemType. - foreach (ConstantNode item in node.Collection) + foreach (ConstantNode item in constantItems) { object member; if (item.Value == null) @@ -975,10 +1024,112 @@ public virtual Expression BindCollectionConstantNode(CollectionConstantNode node if (context.QuerySettings.EnableConstantParameterization) { - return LinqParameterContainer.Parameterize(listType, castedList); + return LinqParameterContainer.Parameterize(listConstantType, castedList); + } + + return Expression.Constant(castedList, listConstantType); + } + + /// + /// Binds a to create a LINQ that + /// represents the semantics of the . + /// + /// The query node to bind. + /// The query binder context. + /// The LINQ created. + /// + /// A represents an inline JSON object literal (e.g. {"Name":"John"}) + /// introduced in OData Library (ODL) 9.0. When the node has a valid (non-untyped) expected structured type, + /// it is materialized into a CLR instance of that type using a member-initialization expression, binding each + /// property value recursively. When the expected type is missing or untyped (e.g. a literal against an open + /// or untyped property), the node is materialized into a keyed by + /// property name. + /// + public virtual Expression BindResourceConstantNode(ResourceConstantNode node, QueryBinderContext context) + { + CheckArgumentNull(node, context); + + IEdmStructuredTypeReference structuredTypeReference = node.ExpectedStructuredType; + + // No expected type (or an untyped one): materialize the literal into a Dictionary. + if (structuredTypeReference == null || structuredTypeReference.IsUntyped()) + { + return BindResourceConstantNodeAsDictionary(node, context); + } + + IEdmStructuredType structuredType = structuredTypeReference.StructuredDefinition(); + Type clrType = context.Model.GetClrType(structuredTypeReference, context.AssembliesResolver); + if (clrType == null) + { + throw new ODataException(Error.Format(SRResources.ClrTypeNotInModel, structuredTypeReference.FullName())); + } + + List memberBindings = new List(); + foreach (KeyValuePair property in node.Properties) + { + // Skip control information such as the "@odata.type" annotation. + if (property.Key == null || property.Key.StartsWith("@", StringComparison.Ordinal)) + { + continue; + } + + IEdmProperty edmProperty = structuredType.FindProperty(property.Key); + if (edmProperty == null) + { + continue; + } + + string clrPropertyName = context.Model.GetClrPropertyName(edmProperty); + PropertyInfo propertyInfo = clrType.GetProperty(clrPropertyName); + if (propertyInfo == null || !propertyInfo.CanWrite) + { + continue; + } + + Expression valueExpression = Bind(property.Value, context); + if (!propertyInfo.PropertyType.IsAssignableFrom(valueExpression.Type)) + { + valueExpression = Expression.Convert(valueExpression, propertyInfo.PropertyType); + } + + memberBindings.Add(Expression.Bind(propertyInfo, valueExpression)); + } + + return Expression.MemberInit(Expression.New(clrType), memberBindings); + } + + /// + /// Materializes an untyped into a + /// keyed by property name, binding each property value recursively. + /// + /// The query node to bind. + /// The query binder context. + /// The LINQ that builds the dictionary. + private Expression BindResourceConstantNodeAsDictionary(ResourceConstantNode node, QueryBinderContext context) + { + Type dictionaryType = typeof(Dictionary); + NewExpression newDictionary = Expression.New(dictionaryType); + MethodInfo addMethod = dictionaryType.GetMethod("Add", new[] { typeof(string), typeof(object) }); + + List elementInits = new List(); + foreach (KeyValuePair property in node.Properties) + { + // Skip control information such as the "@odata.type" annotation. + if (property.Key == null || property.Key.StartsWith("@", StringComparison.Ordinal)) + { + continue; + } + + Expression valueExpression = Bind(property.Value, context); + if (valueExpression.Type != typeof(object)) + { + valueExpression = Expression.Convert(valueExpression, typeof(object)); + } + + elementInits.Add(Expression.ElementInit(addMethod, Expression.Constant(property.Key, typeof(string)), valueExpression)); } - return Expression.Constant(castedList, listType); + return elementInits.Count == 0 ? (Expression)newDictionary : Expression.ListInit(newDictionary, elementInits); } /// diff --git a/src/Microsoft.AspNetCore.OData/Query/ParameterAliasNodeTranslator.cs b/src/Microsoft.AspNetCore.OData/Query/ParameterAliasNodeTranslator.cs index 6940418c9..b83303ed2 100644 --- a/src/Microsoft.AspNetCore.OData/Query/ParameterAliasNodeTranslator.cs +++ b/src/Microsoft.AspNetCore.OData/Query/ParameterAliasNodeTranslator.cs @@ -208,6 +208,16 @@ public override QueryNode Visit(CollectionConstantNode nodeIn) return nodeIn; } + /// + /// Translate a ResourceConstantNode. + /// + /// The node to be translated. + /// The original node. + public override QueryNode Visit(ResourceConstantNode nodeIn) + { + return nodeIn; + } + /// /// Translate a ConvertNode. /// diff --git a/src/Microsoft.AspNetCore.OData/Query/Validator/FilterQueryValidator.cs b/src/Microsoft.AspNetCore.OData/Query/Validator/FilterQueryValidator.cs index 917ec207f..84db71215 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Validator/FilterQueryValidator.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Validator/FilterQueryValidator.cs @@ -5,6 +5,7 @@ // //------------------------------------------------------------------------------ +using System.Collections.Generic; using System.Diagnostics.Contracts; using System.Runtime.CompilerServices; using Microsoft.AspNetCore.OData.Edm; @@ -225,6 +226,51 @@ protected virtual void ValidateConstantNode(ConstantNode constantNode, FilterVal // No default validation logic here. } + /// + /// Override this method to restrict the resource (inline JSON object) literal inside the filter query. + /// + /// + /// A represents an inline resource literal (e.g. {"Name":"John"}) + /// introduced in OData Library (ODL) 9.0. This method recursively validates each of its property value nodes. + /// + /// The resource constant node to validate. + /// The validator context. + protected virtual void ValidateResourceConstantNode(ResourceConstantNode resourceConstantNode, FilterValidatorContext validatorContext) + { + if (resourceConstantNode == null) + { + return; + } + + foreach (KeyValuePair property in resourceConstantNode.Properties) + { + ValidateQueryNode(property.Value, validatorContext); + } + } + + /// + /// Override this method to restrict the collection (inline JSON array) literal inside the filter query. + /// + /// + /// This method recursively validates each item node of a . As of + /// OData Library (ODL) 9.0 those items may include and nested + /// instances. + /// + /// The collection constant node to validate. + /// The validator context. + protected virtual void ValidateCollectionConstantNode(CollectionConstantNode collectionConstantNode, FilterValidatorContext validatorContext) + { + if (collectionConstantNode == null) + { + return; + } + + foreach (QueryNode item in collectionConstantNode.Items) + { + ValidateQueryNode(item, validatorContext); + } + } + /// /// Override this method to restrict the 'cast' inside the filter query. /// @@ -653,6 +699,10 @@ protected virtual void ValidateCollectionNode(CollectionNode node, FilterValidat ValidateCollectionResourceCastNode(node as CollectionResourceCastNode, validatorContext); break; + case QueryNodeKind.CollectionConstant: + ValidateCollectionConstantNode(node as CollectionConstantNode, validatorContext); + break; + case QueryNodeKind.CollectionFunctionCall: case QueryNodeKind.CollectionResourceFunctionCall: // Unused or have unknown uses. @@ -678,6 +728,10 @@ protected virtual void ValidateSingleValueNode(SingleValueNode node, FilterValid ValidateConstantNode(node as ConstantNode, validatorContext); break; + case QueryNodeKind.ResourceConstant: + ValidateResourceConstantNode(node as ResourceConstantNode, validatorContext); + break; + case QueryNodeKind.Convert: ValidateConvertNode(node as ConvertNode, validatorContext); break; diff --git a/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.bsl b/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.bsl index c4f9f8ca6..61450f10e 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.bsl +++ b/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.bsl @@ -3129,6 +3129,7 @@ public abstract class Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder { protected virtual System.Linq.Expressions.Expression BindNow (Microsoft.OData.UriParser.SingleValueFunctionCallNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) public virtual System.Linq.Expressions.Expression BindPropertyAccessQueryNode (Microsoft.OData.UriParser.SingleValuePropertyAccessNode propertyAccessNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) public virtual System.Linq.Expressions.Expression BindRangeVariable (Microsoft.OData.UriParser.RangeVariable rangeVariable, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + public virtual System.Linq.Expressions.Expression BindResourceConstantNode (Microsoft.OData.UriParser.ResourceConstantNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) protected virtual System.Linq.Expressions.Expression BindRound (Microsoft.OData.UriParser.SingleValueFunctionCallNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) public virtual System.Linq.Expressions.Expression BindSingleComplexNode (Microsoft.OData.UriParser.SingleComplexNode singleComplexNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) public virtual System.Linq.Expressions.Expression BindSingleResourceCastFunctionCall (Microsoft.OData.UriParser.SingleResourceFunctionCallNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) @@ -3372,6 +3373,7 @@ public class Microsoft.AspNetCore.OData.Query.Validator.FilterQueryValidator : I protected virtual void ValidateArithmeticOperator (Microsoft.OData.UriParser.BinaryOperatorNode binaryNode, Microsoft.AspNetCore.OData.Query.Validator.FilterValidatorContext validatorContext) protected virtual void ValidateBinaryOperatorNode (Microsoft.OData.UriParser.BinaryOperatorNode binaryOperatorNode, Microsoft.AspNetCore.OData.Query.Validator.FilterValidatorContext validatorContext) protected virtual void ValidateCollectionComplexNode (Microsoft.OData.UriParser.CollectionComplexNode collectionComplexNode, Microsoft.AspNetCore.OData.Query.Validator.FilterValidatorContext validatorContext) + protected virtual void ValidateCollectionConstantNode (Microsoft.OData.UriParser.CollectionConstantNode collectionConstantNode, Microsoft.AspNetCore.OData.Query.Validator.FilterValidatorContext validatorContext) protected virtual void ValidateCollectionNode (Microsoft.OData.UriParser.CollectionNode node, Microsoft.AspNetCore.OData.Query.Validator.FilterValidatorContext validatorContext) protected virtual void ValidateCollectionPropertyAccessNode (Microsoft.OData.UriParser.CollectionPropertyAccessNode propertyAccessNode, Microsoft.AspNetCore.OData.Query.Validator.FilterValidatorContext validatorContext) protected virtual void ValidateCollectionResourceCastNode (Microsoft.OData.UriParser.CollectionResourceCastNode collectionResourceCastNode, Microsoft.AspNetCore.OData.Query.Validator.FilterValidatorContext validatorContext) @@ -3383,6 +3385,7 @@ public class Microsoft.AspNetCore.OData.Query.Validator.FilterQueryValidator : I protected virtual void ValidateNavigationPropertyNode (Microsoft.OData.UriParser.QueryNode sourceNode, Microsoft.OData.Edm.IEdmNavigationProperty navigationProperty, Microsoft.AspNetCore.OData.Query.Validator.FilterValidatorContext validatorContext) protected virtual void ValidateQueryNode (Microsoft.OData.UriParser.QueryNode node, Microsoft.AspNetCore.OData.Query.Validator.FilterValidatorContext validatorContext) protected virtual void ValidateRangeVariable (Microsoft.OData.UriParser.RangeVariable rangeVariable, Microsoft.AspNetCore.OData.Query.Validator.FilterValidatorContext validatorContext) + protected virtual void ValidateResourceConstantNode (Microsoft.OData.UriParser.ResourceConstantNode resourceConstantNode, Microsoft.AspNetCore.OData.Query.Validator.FilterValidatorContext validatorContext) protected virtual void ValidateSingleComplexNode (Microsoft.OData.UriParser.SingleComplexNode singleComplexNode, Microsoft.AspNetCore.OData.Query.Validator.FilterValidatorContext validatorContext) protected virtual void ValidateSingleResourceCastNode (Microsoft.OData.UriParser.SingleResourceCastNode singleResourceCastNode, Microsoft.AspNetCore.OData.Query.Validator.FilterValidatorContext validatorContext) protected virtual void ValidateSingleResourceFunctionCallNode (Microsoft.OData.UriParser.SingleResourceFunctionCallNode node, Microsoft.AspNetCore.OData.Query.Validator.FilterValidatorContext validatorContext) diff --git a/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/FilterBinderTests.cs b/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/FilterBinderTests.cs index 7089ce98b..b0b4a5ed7 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/FilterBinderTests.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/FilterBinderTests.cs @@ -3480,6 +3480,26 @@ public void NestedDynamicSegment_DoesNotExposeNotMappedClrProperty_OnNonOpenRunt #endregion + #region ResourceConstantNode / CollectionConstantNode (ODL 9.0) end-to-end + + [Fact] + public void BindFilter_ParsesBindsAndInvokes_CollectionOfResourceLiterals_UsingInOperator() + { + // Arrange & Act - a collection of inline resource (JSON object) literals is parsed by ODL 9.0 into a + // CollectionConstantNode whose Items are ResourceConstantNode instances, then bound by QueryBinder. + (Expression, Expression) filters = BindFilterAndVerify( + "SupplierAddress in [{\"City\":\"Redmond\",\"Street\":\"NE 24th St.\"},{\"City\":\"Seattle\",\"Street\":\"Pine St.\"}]", + NotTesting, + NotTesting); + + // Assert - the filter binds and can be invoked. Membership uses the CLR type's default (reference) + // equality for the freshly constructed instances, so an equivalent-but-distinct instance is not matched. + Product product = new Product { SupplierAddress = new Address { City = "Redmond", Street = "NE 24th St." } }; + Assert.False(InvokeFilter(product, filters.Item1)); + } + + #endregion + #region Helpers internal static void InvokeFiltersAndThrows((Expression, Expression) filters, T instance, (Type, bool) expectedValue) { diff --git a/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/QueryBinderTests.cs b/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/QueryBinderTests.cs index e0524842e..6f061bfc2 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/QueryBinderTests.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/QueryBinderTests.cs @@ -582,6 +582,246 @@ public void GetFullPropertyPath_WithSingleValueOpenPropertyAccessNodeInSingleCom // Assert Assert.Equal("Address\\ZipCode", fullPropertyPath); } + #region ResourceConstantNode / CollectionConstantNode (ODL 9.0) + + [Fact] + public void BindResourceConstantNode_ThrowsArgumentNull_ForInputs() + { + // Arrange + QueryBinder binder = new MyQueryBinder(); + ResourceConstantNode node = new ResourceConstantNode(GetAddressTypeReference()); + + // Act & Assert + ExceptionAssert.ThrowsArgumentNull(() => binder.BindResourceConstantNode(null, null), "node"); + ExceptionAssert.ThrowsArgumentNull(() => binder.BindResourceConstantNode(node, null), "context"); + } + + [Fact] + public void BindResourceConstantNode_ConstructsClrObject_FromProperties() + { + // Arrange + QueryBinder binder = new MyQueryBinder(); + IEdmComplexTypeReference addressTypeRef = GetAddressTypeReference(); + ResourceConstantNode node = new ResourceConstantNode(addressTypeRef); + node.Properties.Add(new KeyValuePair("Street", Const("NE 24th St.", EdmCoreModel.Instance.GetString(true)))); + node.Properties.Add(new KeyValuePair("City", Const("Redmond", EdmCoreModel.Instance.GetString(true)))); + node.Properties.Add(new KeyValuePair("HouseNumber", Const(42, EdmCoreModel.Instance.GetInt32(false)))); + + QueryBinderContext context = new QueryBinderContext(TestModel, new ODataQuerySettings(), typeof(Employee)); + + // Act + Expression expression = binder.BindResourceConstantNode(node, context); + + // Assert + Assert.Equal(ExpressionType.MemberInit, expression.NodeType); + Address address = (Address)Expression.Lambda(expression).Compile().DynamicInvoke(); + Assert.NotNull(address); + Assert.Equal("NE 24th St.", address.Street); + Assert.Equal("Redmond", address.City); + Assert.Equal(42, address.HouseNumber); + } + + [Fact] + public void BindResourceConstantNode_BuildsDictionary_ForUntypedResourceLiteral() + { + // Arrange - an untyped resource literal (null ExpectedStructuredType) is materialized into a dictionary. + QueryBinder binder = new MyQueryBinder(); + ResourceConstantNode node = new ResourceConstantNode(null); + node.Properties.Add(new KeyValuePair("Name", Const("John", EdmCoreModel.Instance.GetString(true)))); + node.Properties.Add(new KeyValuePair("Age", Const(30, EdmCoreModel.Instance.GetInt32(false)))); + node.Properties.Add(new KeyValuePair("@odata.type", Const("#Some.Type", EdmCoreModel.Instance.GetString(true)))); + + QueryBinderContext context = new QueryBinderContext(TestModel, new ODataQuerySettings(), typeof(Employee)); + + // Act + Expression expression = binder.BindResourceConstantNode(node, context); + + // Assert - annotation keys (starting with '@') are excluded. + Dictionary values = (Dictionary)Expression.Lambda(expression).Compile().DynamicInvoke(); + Assert.Equal(2, values.Count); + Assert.Equal("John", values["Name"]); + Assert.Equal(30, values["Age"]); + } + + [Fact] + public void BindCollectionConstantNode_BuildsListOfResources_FromItems() + { + // Arrange + QueryBinder binder = new MyQueryBinder(); + IEdmComplexTypeReference addressTypeRef = GetAddressTypeReference(); + + ResourceConstantNode first = new ResourceConstantNode(addressTypeRef); + first.Properties.Add(new KeyValuePair("City", Const("Redmond", EdmCoreModel.Instance.GetString(true)))); + + ResourceConstantNode second = new ResourceConstantNode(addressTypeRef); + second.Properties.Add(new KeyValuePair("City", Const("Seattle", EdmCoreModel.Instance.GetString(true)))); + + EdmCollectionTypeReference collectionTypeRef = new EdmCollectionTypeReference(new EdmCollectionType(addressTypeRef)); + CollectionConstantNode node = new CollectionConstantNode(collectionTypeRef); + node.Items.Add(first); + node.Items.Add(second); + + QueryBinderContext context = new QueryBinderContext(TestModel, new ODataQuerySettings(), typeof(Employee)); + + // Act + Expression expression = binder.BindCollectionConstantNode(node, context); + + // Assert + List
addresses = (List
)Expression.Lambda(expression).Compile().DynamicInvoke(); + Assert.Equal(2, addresses.Count); + Assert.Equal("Redmond", addresses[0].City); + Assert.Equal("Seattle", addresses[1].City); + } + + [Fact] + public void BindCollectionConstantNode_BuildsEmptyListOfResources_ForEmptyItems() + { + // Arrange + QueryBinder binder = new MyQueryBinder(); + IEdmComplexTypeReference addressTypeRef = GetAddressTypeReference(); + EdmCollectionTypeReference collectionTypeRef = new EdmCollectionTypeReference(new EdmCollectionType(addressTypeRef)); + CollectionConstantNode node = new CollectionConstantNode(collectionTypeRef); + + QueryBinderContext context = new QueryBinderContext(TestModel, new ODataQuerySettings(), typeof(Employee)); + + // Act + Expression expression = binder.BindCollectionConstantNode(node, context); + + // Assert + List
addresses = (List
)Expression.Lambda(expression).Compile().DynamicInvoke(); + Assert.Empty(addresses); + } + + [Fact] + public void BindCollectionConstantNode_BuildsListOfPrimitives_FromConstantItems() + { + // Arrange + QueryBinder binder = new MyQueryBinder(); + EdmCollectionTypeReference collectionTypeRef = new EdmCollectionTypeReference(new EdmCollectionType(EdmCoreModel.Instance.GetInt32(false))); + CollectionConstantNode node = new CollectionConstantNode(collectionTypeRef); + node.Items.Add(Const(1, EdmCoreModel.Instance.GetInt32(false))); + node.Items.Add(Const(2, EdmCoreModel.Instance.GetInt32(false))); + node.Items.Add(Const(3, EdmCoreModel.Instance.GetInt32(false))); + + QueryBinderContext context = new QueryBinderContext(TestModel, new ODataQuerySettings(), typeof(Employee)); + + // Act + Expression expression = binder.BindCollectionConstantNode(node, context); + + // Assert + List values = (List)Expression.Lambda(expression).Compile().DynamicInvoke(); + Assert.Equal(new[] { 1, 2, 3 }, values); + } + + [Fact] + public void BindCollectionConstantNode_BuildsListWithMixedResourceAndNullItems() + { + // Arrange - Items contains a ResourceConstantNode and a null ConstantNode. + QueryBinder binder = new MyQueryBinder(); + IEdmComplexTypeReference addressTypeRef = GetAddressTypeReference(); + + ResourceConstantNode resource = new ResourceConstantNode(addressTypeRef); + resource.Properties.Add(new KeyValuePair("City", Const("Redmond", EdmCoreModel.Instance.GetString(true)))); + + EdmCollectionTypeReference collectionTypeRef = new EdmCollectionTypeReference(new EdmCollectionType(addressTypeRef)); + CollectionConstantNode node = new CollectionConstantNode(collectionTypeRef); + node.Items.Add(resource); + node.Items.Add(new ConstantNode(null, "null", addressTypeRef)); + + QueryBinderContext context = new QueryBinderContext(TestModel, new ODataQuerySettings(), typeof(Employee)); + + // Act + Expression expression = binder.BindCollectionConstantNode(node, context); + + // Assert + List
addresses = (List
)Expression.Lambda(expression).Compile().DynamicInvoke(); + Assert.Equal(2, addresses.Count); + Assert.Equal("Redmond", addresses[0].City); + Assert.Null(addresses[1]); + } + + [Fact] + public void BindCollectionConstantNode_BuildsNestedListOfCollections_FromNestedItems() + { + // Arrange - Items contains nested CollectionConstantNode instances (collection of collections). + QueryBinder binder = new MyQueryBinder(); + IEdmTypeReference int32 = EdmCoreModel.Instance.GetInt32(false); + EdmCollectionTypeReference innerCollectionTypeRef = new EdmCollectionTypeReference(new EdmCollectionType(int32)); + EdmCollectionTypeReference outerCollectionTypeRef = new EdmCollectionTypeReference(new EdmCollectionType(innerCollectionTypeRef)); + + CollectionConstantNode inner1 = new CollectionConstantNode(innerCollectionTypeRef); + inner1.Items.Add(Const(1, int32)); + inner1.Items.Add(Const(2, int32)); + + CollectionConstantNode inner2 = new CollectionConstantNode(innerCollectionTypeRef); + inner2.Items.Add(Const(3, int32)); + + CollectionConstantNode outer = new CollectionConstantNode(outerCollectionTypeRef); + outer.Items.Add(inner1); + outer.Items.Add(inner2); + + QueryBinderContext context = new QueryBinderContext(TestModel, new ODataQuerySettings(), typeof(Employee)); + + // Act + Expression expression = binder.BindCollectionConstantNode(outer, context); + + // Assert - the outer list contains two inner integer lists. + System.Collections.IList outerList = (System.Collections.IList)Expression.Lambda(expression).Compile().DynamicInvoke(); + Assert.Equal(2, outerList.Count); + Assert.Equal(new[] { 1, 2 }, ((IEnumerable)outerList[0]).ToArray()); + Assert.Equal(new[] { 3 }, ((IEnumerable)outerList[1]).ToArray()); + } + + [Fact] + public void BindCollectionConstantNode_BuildsHeterogeneousList_ForUntypedCollectionWithMixedItemKinds() + { + // Arrange - an untyped collection literal such as [1, {"Name":"John"}, [2, 3]] whose Items contain a + // ConstantNode, a ResourceConstantNode and a nested CollectionConstantNode. + QueryBinder binder = new MyQueryBinder(); + IEdmTypeReference int32 = EdmCoreModel.Instance.GetInt32(false); + + ResourceConstantNode resource = new ResourceConstantNode(null); + resource.Properties.Add(new KeyValuePair("Name", Const("John", EdmCoreModel.Instance.GetString(true)))); + + EdmCollectionTypeReference innerCollectionTypeRef = new EdmCollectionTypeReference(new EdmCollectionType(int32)); + CollectionConstantNode inner = new CollectionConstantNode(innerCollectionTypeRef); + inner.Items.Add(Const(2, int32)); + inner.Items.Add(Const(3, int32)); + + EdmCollectionTypeReference untypedCollectionTypeRef = new EdmCollectionTypeReference(new EdmCollectionType(EdmCoreModel.Instance.GetUntyped())); + CollectionConstantNode node = new CollectionConstantNode(untypedCollectionTypeRef); + node.Items.Add(Const(1, int32)); + node.Items.Add(resource); + node.Items.Add(inner); + + QueryBinderContext context = new QueryBinderContext(TestModel, new ODataQuerySettings(), typeof(Employee)); + + // Act + Expression expression = binder.BindCollectionConstantNode(node, context); + + // Assert + System.Collections.IList list = (System.Collections.IList)Expression.Lambda(expression).Compile().DynamicInvoke(); + Assert.Equal(3, list.Count); + Assert.Equal(1, list[0]); + Dictionary dictionary = Assert.IsType>(list[1]); + Assert.Equal("John", dictionary["Name"]); + Assert.Equal(new[] { 2, 3 }, ((IEnumerable)list[2]).ToArray()); + } + + private static IEdmComplexTypeReference GetAddressTypeReference() + { + IEdmComplexType addressType = GetEdmComplexTypeFor("Microsoft.AspNetCore.OData.Tests.Models.Address"); + Assert.NotNull(addressType); + return new EdmComplexTypeReference(addressType, true); + } + + private static ConstantNode Const(object value, IEdmTypeReference typeReference) + { + return new ConstantNode(value, value?.ToString(), typeReference); + } + + #endregion + private static SingleValueNode CreateEmployeeRangeVariableReferenceNode() { // Create the type reference and navigation source