From 5f1336c5beeab57a72d60b03cabd1d4ee6eff141 Mon Sep 17 00:00:00 2001 From: Kennedy Kangethe Date: Fri, 10 Dec 2021 10:10:39 +0300 Subject: [PATCH] Restructure AggregationBinder --- .../Abstracts/ContainerBuilderExtensions.cs | 1 + .../Common/TypeHelper.cs | 95 + .../Microsoft.AspNetCore.OData.xml | 387 +++- .../Properties/SRResources.Designer.cs | 19 +- .../Properties/SRResources.resx | 3 + .../PublicAPI.Unshipped.txt | 43 + .../Container/AggregationPropertyContainer.cs | 29 +- .../IAggregationPropertyContainerOfT.cs | 70 + .../Query/Expressions/AggregationBinder.cs | 915 ++++---- .../Query/Expressions/BinderExtensions.cs | 73 + .../Query/Expressions/ComputeBinder.cs | 2 +- .../Query/Expressions/ExpressionBinderBase.cs | 24 +- .../Expressions/ExpressionBinderHelper.cs | 6 +- .../Query/Expressions/IAggregationBinder.cs | 102 + .../Query/Expressions/QueryBinder.cs | 332 ++- .../Query/Expressions/QueryBinderContext.cs | 44 +- .../Expressions/TransformationBinderBase.cs | 2 +- .../Query/ODataQueryContextExtensions.cs | 17 + .../Query/Query/ApplyQueryOptions.cs | 13 +- .../Query/QueryConstants.cs | 39 + .../Query/Wrapper/DynamicTypeWrapper.cs | 6 +- .../Query/Wrapper/FlatteningWrapperOfT.cs | 5 +- .../Query/Wrapper/GroupByWrapper.cs | 8 +- .../Query/Wrapper/IFlatteningWrapperOfT.cs | 19 + .../Query/Wrapper/IGroupByWrapperOfT.cs | 21 + .../DollarApply/DollarApplyController.cs | 98 + .../DollarApply/DollarApplyCustomMethods.cs | 30 + .../DollarApply/DollarApplyDataModel.cs | 66 + .../DollarApply/DollarApplyDataSource.cs | 119 ++ .../DollarApply/DollarApplyEdmModel.cs | 48 + .../DollarApply/DollarApplyTests.cs | 1904 +++++++++++++++++ .../TestAggregationPropertyContainer.cs | 112 + .../Query/Container/TestPropertyMapper.cs | 19 + .../Expressions/TestAggregationBinder.cs | 469 ++++ .../Query/Wrapper/TestFlatteningWrapper.cs | 36 + .../Query/Wrapper/TestGroupByWrapper.cs | 117 + ...icrosoft.AspNetCore.OData.E2E.Tests.csproj | 6 - ...rosoft.AspNetCore.OData.PublicApi.Net5.bsl | 18 + ...rosoft.AspNetCore.OData.PublicApi.Net6.bsl | 42 + ...t.AspNetCore.OData.PublicApi.NetCore31.bsl | 42 + .../Expressions/AggregationBinderTests.cs | 50 +- 41 files changed, 4882 insertions(+), 569 deletions(-) create mode 100644 src/Microsoft.AspNetCore.OData/Query/Container/IAggregationPropertyContainerOfT.cs create mode 100644 src/Microsoft.AspNetCore.OData/Query/Expressions/IAggregationBinder.cs create mode 100644 src/Microsoft.AspNetCore.OData/Query/QueryConstants.cs create mode 100644 src/Microsoft.AspNetCore.OData/Query/Wrapper/IFlatteningWrapperOfT.cs create mode 100644 src/Microsoft.AspNetCore.OData/Query/Wrapper/IGroupByWrapperOfT.cs create mode 100644 test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyController.cs create mode 100644 test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyCustomMethods.cs create mode 100644 test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyDataModel.cs create mode 100644 test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyDataSource.cs create mode 100644 test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyEdmModel.cs create mode 100644 test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyTests.cs create mode 100644 test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Container/TestAggregationPropertyContainer.cs create mode 100644 test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Container/TestPropertyMapper.cs create mode 100644 test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Expressions/TestAggregationBinder.cs create mode 100644 test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Wrapper/TestFlatteningWrapper.cs create mode 100644 test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Wrapper/TestGroupByWrapper.cs diff --git a/src/Microsoft.AspNetCore.OData/Abstracts/ContainerBuilderExtensions.cs b/src/Microsoft.AspNetCore.OData/Abstracts/ContainerBuilderExtensions.cs index 67ee0e706..8af4b8cf2 100644 --- a/src/Microsoft.AspNetCore.OData/Abstracts/ContainerBuilderExtensions.cs +++ b/src/Microsoft.AspNetCore.OData/Abstracts/ContainerBuilderExtensions.cs @@ -110,6 +110,7 @@ public static IContainerBuilder AddDefaultWebApiServices(this IContainerBuilder builder.AddService(ServiceLifetime.Singleton); builder.AddService(ServiceLifetime.Singleton); builder.AddService(ServiceLifetime.Singleton); + builder.AddService(ServiceLifetime.Singleton); // HttpRequestScope. builder.AddService(ServiceLifetime.Scoped); diff --git a/src/Microsoft.AspNetCore.OData/Common/TypeHelper.cs b/src/Microsoft.AspNetCore.OData/Common/TypeHelper.cs index 04574ff39..2c875fe9d 100644 --- a/src/Microsoft.AspNetCore.OData/Common/TypeHelper.cs +++ b/src/Microsoft.AspNetCore.OData/Common/TypeHelper.cs @@ -8,6 +8,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; using System.Linq; @@ -42,6 +43,100 @@ public static bool IsDynamicTypeWrapper(this Type type) public static bool IsComputeWrapper(this Type type, out Type entityType) => IsTypeWrapper(typeof(ComputeWrapper<>), type, out entityType); + public static bool IsFlatteningWrapper(this Type type) + { + if (type == null) + { + return false; + } + + if (type.IsGenericType) + { + Type genericTypeDefinition = type.GetGenericTypeDefinition(); + + // Default implementation + if (genericTypeDefinition == typeof(FlatteningWrapper<>)) + { + Debug.Assert( + typeof(DynamicTypeWrapper).IsAssignableFrom(genericTypeDefinition) + && genericTypeDefinition.GetInterfaces().Any(d => d.IsGenericType && d.GetGenericTypeDefinition() == typeof(IFlatteningWrapper<>)) + && genericTypeDefinition.GetInterfaces().Any(d => d.IsGenericType && d.GetGenericTypeDefinition() == typeof(IGroupByWrapper<>)), + "FlatteningWrapper must inherit from DynamicTypeWrapper and implement IFlatteningWrapper and IGroupByWrapper"); + return true; + } + + // FlatteningWrapper must inherit from DynamicTypeWrapper + if (!typeof(DynamicTypeWrapper).IsAssignableFrom(genericTypeDefinition)) + { + return false; + } + + // Custom implementation + Type[] genericTypeInterfaces = genericTypeDefinition.GetInterfaces(); + bool typeImplementsIFlatteningWrapper = false; + bool typeImplementsIGroupByWrapper = false; + for (int i = 0; i < genericTypeInterfaces.Length; i++) + { + Type genericTypeInterface = genericTypeInterfaces[i]; + // FlatteningWrapper must implement IFlatteningWrapper and IGroupByWrapper + if (genericTypeInterface.IsGenericType && genericTypeInterface.GetGenericTypeDefinition() == typeof(IFlatteningWrapper<>)) + { + typeImplementsIFlatteningWrapper = true; + } + + if (genericTypeInterface.IsGenericType && genericTypeInterface.GetGenericTypeDefinition() == typeof(IGroupByWrapper<>)) + { + typeImplementsIGroupByWrapper = true; + } + + if (typeImplementsIFlatteningWrapper && typeImplementsIGroupByWrapper) + { + return true; + } + } + } + + return false; + } + + public static bool IsGroupByWrapper(this Type type) + { + if (type == null || type.IsValueType || type == typeof(string)) + { + return false; + } + + // Default implementation + if (typeof(GroupByWrapper).IsAssignableFrom(type)) + { + Debug.Assert( + typeof(DynamicTypeWrapper).IsAssignableFrom(type) + && type.GetInterfaces().Any(d => d.IsGenericType && d.GetGenericTypeDefinition() == typeof(IGroupByWrapper<>)), + "GroupByWrapper must inherit from DynamicTypeWrapper and implement IGroupByWrapper"); + return true; + } + + // GroupByWrapper must inherit from DynamicTypeWrapper + if (!typeof(DynamicTypeWrapper).IsAssignableFrom(type)) + { + return false; + } + + // Custom implementation + Type[] typeInterfaces = type.GetInterfaces(); + for (int i = 0; i < typeInterfaces.Length; i++) + { + Type typeInterface = typeInterfaces[i]; + // GroupByWrapper must implement IGroupByWrapper + if (typeInterface.IsGenericType && typeInterface.GetGenericTypeDefinition() == typeof(IGroupByWrapper<>)) + { + return true; + } + } + + return false; + } + private static bool IsTypeWrapper(Type wrappedType, Type type, out Type entityType) { if (type == null) diff --git a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml index c3ef3e007..655c743ad 100644 --- a/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml +++ b/src/Microsoft.AspNetCore.OData/Microsoft.AspNetCore.OData.xml @@ -7425,6 +7425,11 @@ Looks up a localized string similar to The property '{0}' cannot be used in the $orderby query option.. + + + Looks up a localized string similar to Transformation kind '{0}' is not supported as a child transformation of kind '{1}'.. + + Looks up a localized string similar to Transformation kind {0} is not supported.. @@ -8185,7 +8190,7 @@ Represent properties used in groupby and aggregate clauses to make them accessible in further clauses/transformations - + When we have $apply=groupby((Prop1,Prop2, Prop3))&$orderby=Prop1, Prop2 We will have following expression in .GroupBy $it => new AggregationPropertyContainer() { @@ -8201,10 +8206,10 @@ } } when in $orderby (see AggregationBinder CollectProperties method) - Prop1 could be referenced us $it => (string)$it.Value - Prop2 could be referenced us $it => (int)$it.Next.Value - Prop3 could be referenced us $it => (int)$it.Next.Next.Value - Generic type for Value is used to avoid type casts for on primitive types that not supported in EF + Prop1 could be referenced as $it => (string)$it.Value + Prop2 could be referenced as $it => (int)$it.Next.Value + Prop3 could be referenced as $it => (int)$it.Next.Next.Value + Generic type for Value is used to avoid type casts for primitive types that are not supported in EF Also we have 4 use cases and base type have all required properties to support no cast usage. 1. Primitive property with Next @@ -8212,8 +8217,64 @@ 3. Nested property with Next 4. Nested property without Next However, EF doesn't allow to set different properties for the same type in two places in an lambda-expression => using new type with just new name to workaround that issue - - + + + + + Represent properties used in groupby and aggregate clauses of $apply query to make them accessible in further clauses/transformations. + + The type that declares a member of this type. Type T must implement . + + When we have $apply=groupby((Prop1,Prop2,Prop3))&$orderby=Prop1,Prop2 + where implements , + we will have following expression in .GroupBy: + $it => new AggregationPropertyContainer() { + Name = "Prop1", + Value = $it.Prop1, /* string */ + Next = new AggregationPropertyContainer() { + Name = "Prop2", + Value = $it.Prop2, /* int */ + Next = new LastInChain() { + Name = "Prop3", + Value = $it.Prop3 /* int */ + } + } + } + When in $orderby, + Prop1 could be referenced as $it => (string)$it.Value, + Prop2 could be referenced as $it => (int)$it.Next.Value, + Prop3 could be referenced as $it => (int)$it.Next.Next.Value. + Generic type for Value is used to avoid type casts for primitive types that are not supported in Entity Framework. + Also, we have 4 use cases and this interface declares all required properties to support no cast usage. + 1). Primitive property with Next + 2). Primitive property without Next + 3). Nested property with Next + 4). Nested property without Next. + However, Entity Framework doesn't allow to set different properties for the same type in two places in a lambda expression. + Using new type with just new name to workaround that issue. + + + + Gets or sets the name of the property. + + + Gets or sets the value of the property. + + + Gets or sets the nested value of the property. + + + Gets or sets the next property container. + + + + Adds the properties in this container to the given dictionary. + + The dictionary to which the properties in this container should be added. + The property mapper to use for mapping + between the names of properties in this container and the names that + should be used when adding the properties to the given dictionary. + A value indicating whether auto-selected properties should be included. @@ -8855,27 +8916,102 @@ The original . The new after the ETag has been applied. - - - Pre flattens properties referenced in aggregate clause to avoid generation of nested queries by EF. - For query like groupby((A), aggregate(B/C with max as Alias1, B/D with max as Alias2)) we need to generate - .Select( - $it => new FlattenninWrapper () { - Source = $it, // Will used in groupby stage - Container = new { - Value = $it.B.C - Next = new { - Value = $it.B.D - } - } - } - ) - Also we need to populate expressions to access B/C and B/D in aggregate stage. It will look like: - B/C : $it.Container.Value - B/D : $it.Container.Next.Value + + + The default implementation to bind an OData $apply represented by to an . + + + + + The parameter name for current type. - - Query with Select that flattens properties + + + + + + + + + + + + + Creates an expression for an aggregate. + + The parameter representing the group. + The aggregate expression. + The element type at the base of the transformation. + The query binder context. + An expression representing the aggregate. + + + + + Creates an expression for an entity set aggregate. + + The parameter representing the group. + The entity set aggregate expression. + The element type at the base of the transformation. + The query binder context. + An expression for the entity set aggregate. + + + + Creates an expression for a property aggregate. + + The + The aggregate expression. + + The query binder context. + An expression for a property aggregate. + + + + Creates a list of from a collection of . + + GroupBy nodes. + The query binder context. + A list of representing properties in the GroupBy clause. + + + + Gets a collection of from a . + + The transformation node. + A collection of . + + + + Gets a collection of from a . + + The query binder context. + The . + A collection of aggregate expressions. + + + + Fixes return types for custom aggregation methods. + + The aggregation expressions. + + + + + + Fixes return type for custom aggregation method. + + The aggregation expression + The query binder context. + The + + + + Gets a custom aggregation method for the aggregation expression. + + The aggregation expression. + The query binder context. + The custom method. @@ -8967,6 +9103,18 @@ An instance of the . The applied result. + + + Translate an OData $apply parse tree represented by to + an and applies it to an . + + The built in + The original . + The OData $apply parse tree. + An instance of the . + The type of wrapper used to create an expression from the $apply parse tree. + The applied result. + The base class for all expression binders. @@ -9075,6 +9223,87 @@ The query binder context. The filter binder result. + + + Exposes the ability to translate an OData $apply parse tree represented by a to + an . + + + + + Flattens properties referenced in aggregate clause to avoid generation of nested queries by Entity Framework. + For query like groupby((A),aggregate(B/C with max as Alias1,B/D with max as Alias2)), generates an expression like: + .Select($it => new FlatteningWrapper() { + Source = $it, + Container = new { + Value = $it.B.C + Next = new { + Value = $it.B.D + } + } + }) + Also populate expressions to access B/C and B/D in aggregate stage to look like: + B/C : $it.Container.Value + B/D : $it.Container.Next.Value + + The . + The original . + The query binder context. + The parameter at the root of the current query binder context. The parameter can be reinitialized in the course of flattening. + Mapping of flattened single value nodes and their values. For example { {$it.B.C, $it.Value}, {$it.B.D, $it.Next.Value} } + Query with Select expression with flattened properties. + + + + Translates an OData $apply parse tree represented by a to + an . + + The OData $apply parse tree represented by . + An instance of the . + + Generates an expression structured like: + $it => new DynamicTypeWrapper() + { + GroupByContainer => new AggregationPropertyContainer() { + Name = "Prop1", + Value = $it.Prop1, + Next = new AggregationPropertyContainer() { + Name = "Prop2", + Value = $it.Prop2, + Next = new LastInChain() { + Name = "Prop3", + Value = $it.Prop3 + } + } + } + }) + + The generated LINQ expression representing the OData $apply parse tree. + + + + Translates an OData $apply parse tree represented by a to + an . + + The OData $apply parse tree represented by . + An instance of the . + + Generates an expression structured like: + $it => New DynamicType2() + { + GroupByContainer = $it.Key.GroupByContainer /// If groupby clause present + Container => new AggregationPropertyContainer() { + Name = "Alias1", + Value = $it.AsQueryable().Sum(i => i.AggregatableProperty), + Next = new LastInChain() { + Name = "Alias2", + Value = $it.AsQueryable().Sum(i => i.AggregatableProperty) + } + } + } + + The generated LINQ expression representing the OData $apply parse tree. + Exposes the ability to translate an OData $filter represented by to the . @@ -9426,6 +9655,23 @@ The query binder context. The LINQ created. + + + Creates an from the . + + The to be bound. + An instance of the . + The for the base element. + The created . + + + + Creates a LINQ that represents the semantics of the . + + They query node to create an expression from. + The query binder context. + The LINQ created. + Recognize $it.Source where $it is FlatteningWrapper @@ -9467,6 +9713,13 @@ The query binder context. Returns null if no aggregations were used so far + + + Wrap a value type with Expression.Convert. + + The to be wrapped. + The wrapped + Binds property to the source node. @@ -9889,6 +10142,23 @@ The parameter name. + + + Sets the specified parameter + + The parameter name. + The parameter expression. + + + + The type of the element in a transformation query. + + + + + A mapping of flattened single value nodes and their values. For example { {$it.B.C, $it.Value}, {$it.B.D, $it.Next.Value} } + + Exposes the ability to translate an OData $select or $expand parse tree represented by to @@ -10443,6 +10713,13 @@ The query context. The built . + + + Gets the . + + The query context. + The built . + Gets the . @@ -11119,6 +11396,32 @@ The node to be translated. The translated node. + + + Constant values used in aggregation operation. + + + + Name for property. + + + Name for property. + + + Name for property. + + + Name for property. + + + Name for property. + + + Name for property. + + + Name for property. + An implementation of that applies an action filter to @@ -12989,10 +13292,10 @@ - Attempts to get the value of the Property called from the underlying Entity. + Attempts to get the value of the property called from the underlying entity. - The name of the Property - The new value of the Property + The name of the property + The new value of the property True if successful @@ -13017,12 +13320,12 @@ - Gets or sets the property container that contains the properties being expanded. + Gets or sets the property container that contains the grouping properties. - Gets or sets the property container that contains the properties being expanded. + Gets or sets the property container that contains the aggregation properties. @@ -13031,6 +13334,26 @@ + + + Represents the result of flattening properties referenced in aggregate clause of a $apply query. + + Flattening is necessary to avoid generation of nested queries by Entity Framework. + + + Gets or sets the source object that contains the properties to be flattened. + + + + Represents the result of a $apply query operation. + + + + Gets or sets the property container that contains the grouping properties. + + + Gets or sets the property container that contains the aggregation properties. + Represents the result of a $select and $expand query operation. diff --git a/src/Microsoft.AspNetCore.OData/Properties/SRResources.Designer.cs b/src/Microsoft.AspNetCore.OData/Properties/SRResources.Designer.cs index 7d586aaa1..586d46203 100644 --- a/src/Microsoft.AspNetCore.OData/Properties/SRResources.Designer.cs +++ b/src/Microsoft.AspNetCore.OData/Properties/SRResources.Designer.cs @@ -1239,6 +1239,15 @@ internal static string NotSortablePropertyUsedInOrderBy { } } + /// + /// Looks up a localized string similar to Transformation kind '{0}' is not supported as a child transformation of kind '{1}'.. + /// + internal static string NotSupportedChildTransformationKind { + get { + return ResourceManager.GetString("NotSupportedChildTransformationKind", resourceCulture); + } + } + /// /// Looks up a localized string similar to Transformation kind {0} is not supported.. /// @@ -1796,18 +1805,16 @@ internal static string TypeMustBeEnumOrNullableEnum { return ResourceManager.GetString("TypeMustBeEnumOrNullableEnum", resourceCulture); } } - + /// /// Looks up a localized string similar to The type '{0}' must be an open type. The dynamic properties container property is only expected on open types.. /// - internal static string TypeMustBeOpenType - { - get - { + internal static string TypeMustBeOpenType { + get { return ResourceManager.GetString("TypeMustBeOpenType", resourceCulture); } } - + /// /// Looks up a localized string similar to The type '{0}' does not inherit from and is not a base type of '{1}'.. /// diff --git a/src/Microsoft.AspNetCore.OData/Properties/SRResources.resx b/src/Microsoft.AspNetCore.OData/Properties/SRResources.resx index e7981a72f..7e5fa4644 100644 --- a/src/Microsoft.AspNetCore.OData/Properties/SRResources.resx +++ b/src/Microsoft.AspNetCore.OData/Properties/SRResources.resx @@ -748,4 +748,7 @@ The type '{0}' must be an open type. The dynamic properties container property is only expected on open types. + + Transformation kind '{0}' is not supported as a child transformation of kind '{1}'. + \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt b/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt index 860064bd0..fef8d7f80 100644 --- a/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt +++ b/src/Microsoft.AspNetCore.OData/PublicAPI.Unshipped.txt @@ -791,6 +791,16 @@ Microsoft.AspNetCore.OData.Query.ComputeQueryOption.ResultClrType.get -> System. Microsoft.AspNetCore.OData.Query.ComputeQueryOption.Validate(Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) -> void Microsoft.AspNetCore.OData.Query.ComputeQueryOption.Validator.get -> Microsoft.AspNetCore.OData.Query.Validator.IComputeQueryValidator Microsoft.AspNetCore.OData.Query.ComputeQueryOption.Validator.set -> void +Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer +Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer.Name.get -> string +Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer.Name.set -> void +Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer.NestedValue.get -> T +Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer.NestedValue.set -> void +Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer.Next.get -> Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer +Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer.Next.set -> void +Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer.ToDictionaryCore(System.Collections.Generic.Dictionary dictionary, Microsoft.AspNetCore.OData.Query.Container.IPropertyMapper propertyMapper, bool includeAutoSelected) -> void +Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer.Value.get -> object +Microsoft.AspNetCore.OData.Query.Container.IAggregationPropertyContainer.Value.set -> void Microsoft.AspNetCore.OData.Query.Container.IPropertyMapper Microsoft.AspNetCore.OData.Query.Container.IPropertyMapper.MapProperty(string propertyName) -> string Microsoft.AspNetCore.OData.Query.Container.ITruncatedCollection @@ -888,10 +898,16 @@ Microsoft.AspNetCore.OData.Query.ETag.this[string key].set -> void Microsoft.AspNetCore.OData.Query.ETag Microsoft.AspNetCore.OData.Query.ETag.ApplyTo(System.Linq.IQueryable query) -> System.Linq.IQueryable Microsoft.AspNetCore.OData.Query.ETag.ETag() -> void +Microsoft.AspNetCore.OData.Query.Expressions.AggregationBinder +Microsoft.AspNetCore.OData.Query.Expressions.AggregationBinder.AggregationBinder() -> void Microsoft.AspNetCore.OData.Query.Expressions.BinderExtensions Microsoft.AspNetCore.OData.Query.Expressions.ExpressionBinderBase Microsoft.AspNetCore.OData.Query.Expressions.FilterBinder Microsoft.AspNetCore.OData.Query.Expressions.FilterBinder.FilterBinder() -> void +Microsoft.AspNetCore.OData.Query.Expressions.IAggregationBinder +Microsoft.AspNetCore.OData.Query.Expressions.IAggregationBinder.BindGroupBy(Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression +Microsoft.AspNetCore.OData.Query.Expressions.IAggregationBinder.BindSelect(Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression +Microsoft.AspNetCore.OData.Query.Expressions.IAggregationBinder.FlattenReferencedProperties(Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, System.Linq.IQueryable query, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, out System.Linq.Expressions.ParameterExpression contextParameter, out System.Collections.Generic.IDictionary flattenedPropertiesMap) -> System.Linq.IQueryable Microsoft.AspNetCore.OData.Query.Expressions.IFilterBinder Microsoft.AspNetCore.OData.Query.Expressions.IFilterBinder.BindFilter(Microsoft.OData.UriParser.FilterClause filterClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression Microsoft.AspNetCore.OData.Query.Expressions.IOrderByBinder @@ -919,6 +935,7 @@ Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.ComputedProperti Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.CurrentParameter.get -> System.Linq.Expressions.ParameterExpression Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.ElementClrType.get -> System.Type Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.ElementType.get -> Microsoft.OData.Edm.IEdmType +Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.FlattenedPropertiesMap.get -> System.Collections.Generic.IDictionary Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.GetParameter(string name) -> System.Linq.Expressions.ParameterExpression Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.Model.get -> Microsoft.OData.Edm.IEdmModel Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.NavigationSource.get -> Microsoft.OData.Edm.IEdmNavigationSource @@ -929,6 +946,7 @@ Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.QuerySettings.ge Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.RemoveParameter(string name) -> void Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.Source.get -> System.Linq.Expressions.Expression Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.Source.set -> void +Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext.TransformationElementType.get -> System.Type Microsoft.AspNetCore.OData.Query.Expressions.SelectExpandBinder Microsoft.AspNetCore.OData.Query.Expressions.SelectExpandBinder.FilterBinder.get -> Microsoft.AspNetCore.OData.Query.Expressions.IFilterBinder Microsoft.AspNetCore.OData.Query.Expressions.SelectExpandBinder.OrderByBinder.get -> Microsoft.AspNetCore.OData.Query.Expressions.IOrderByBinder @@ -1217,6 +1235,24 @@ Microsoft.AspNetCore.OData.Query.Validator.TopQueryValidator.TopQueryValidator() Microsoft.AspNetCore.OData.Query.Wrapper.DynamicTypeWrapper Microsoft.AspNetCore.OData.Query.Wrapper.DynamicTypeWrapper.DynamicTypeWrapper() -> void Microsoft.AspNetCore.OData.Query.Wrapper.DynamicTypeWrapper.TryGetPropertyValue(string propertyName, out object value) -> bool +Microsoft.AspNetCore.OData.Query.Wrapper.IAggregationPropertyContainer +Microsoft.AspNetCore.OData.Query.Wrapper.IAggregationPropertyContainer.Name.get -> string +Microsoft.AspNetCore.OData.Query.Wrapper.IAggregationPropertyContainer.Name.set -> void +Microsoft.AspNetCore.OData.Query.Wrapper.IAggregationPropertyContainer.NestedValue.get -> T +Microsoft.AspNetCore.OData.Query.Wrapper.IAggregationPropertyContainer.NestedValue.set -> void +Microsoft.AspNetCore.OData.Query.Wrapper.IAggregationPropertyContainer.Next.get -> Microsoft.AspNetCore.OData.Query.Wrapper.IAggregationPropertyContainer +Microsoft.AspNetCore.OData.Query.Wrapper.IAggregationPropertyContainer.Next.set -> void +Microsoft.AspNetCore.OData.Query.Wrapper.IAggregationPropertyContainer.ToDictionaryCore(System.Collections.Generic.Dictionary dictionary, Microsoft.AspNetCore.OData.Query.Container.IPropertyMapper propertyMapper, bool includeAutoSelected) -> void +Microsoft.AspNetCore.OData.Query.Wrapper.IAggregationPropertyContainer.Value.get -> object +Microsoft.AspNetCore.OData.Query.Wrapper.IAggregationPropertyContainer.Value.set -> void +Microsoft.AspNetCore.OData.Query.Wrapper.IFlatteningWrapper +Microsoft.AspNetCore.OData.Query.Wrapper.IFlatteningWrapper.Source.get -> T +Microsoft.AspNetCore.OData.Query.Wrapper.IFlatteningWrapper.Source.set -> void +Microsoft.AspNetCore.OData.Query.Wrapper.IGroupByWrapper +Microsoft.AspNetCore.OData.Query.Wrapper.IGroupByWrapper.Container.get -> T +Microsoft.AspNetCore.OData.Query.Wrapper.IGroupByWrapper.Container.set -> void +Microsoft.AspNetCore.OData.Query.Wrapper.IGroupByWrapper.GroupByContainer.get -> T +Microsoft.AspNetCore.OData.Query.Wrapper.IGroupByWrapper.GroupByContainer.set -> void Microsoft.AspNetCore.OData.Query.Wrapper.ISelectExpandWrapper Microsoft.AspNetCore.OData.Query.Wrapper.ISelectExpandWrapper.ToDictionary() -> System.Collections.Generic.IDictionary Microsoft.AspNetCore.OData.Query.Wrapper.ISelectExpandWrapper.ToDictionary(System.Func propertyMapperProvider) -> System.Collections.Generic.IDictionary @@ -1750,6 +1786,7 @@ static Microsoft.AspNetCore.OData.ODataServiceCollectionExtensions.AddODataQuery static Microsoft.AspNetCore.OData.ODataUriFunctions.AddCustomUriFunction(string functionName, Microsoft.OData.UriParser.FunctionSignatureWithReturnType functionSignature, System.Reflection.MethodInfo methodInfo) -> void static Microsoft.AspNetCore.OData.ODataUriFunctions.RemoveCustomUriFunction(string functionName, Microsoft.OData.UriParser.FunctionSignatureWithReturnType functionSignature, System.Reflection.MethodInfo methodInfo) -> bool static Microsoft.AspNetCore.OData.Query.EnableQueryAttribute.CreateErrorResponse(string message, System.Exception exception = null) -> Microsoft.AspNetCore.Mvc.SerializableError +static Microsoft.AspNetCore.OData.Query.Expressions.BinderExtensions.ApplyBind(this Microsoft.AspNetCore.OData.Query.Expressions.IAggregationBinder binder, System.Linq.IQueryable source, Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, out System.Type resultClrType) -> System.Linq.IQueryable static Microsoft.AspNetCore.OData.Query.Expressions.BinderExtensions.ApplyBind(this Microsoft.AspNetCore.OData.Query.Expressions.IFilterBinder binder, System.Collections.IEnumerable query, Microsoft.OData.UriParser.FilterClause filterClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Collections.IEnumerable static Microsoft.AspNetCore.OData.Query.Expressions.BinderExtensions.ApplyBind(this Microsoft.AspNetCore.OData.Query.Expressions.IFilterBinder binder, System.Linq.Expressions.Expression source, Microsoft.OData.UriParser.FilterClause filterClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression static Microsoft.AspNetCore.OData.Query.Expressions.BinderExtensions.ApplyBind(this Microsoft.AspNetCore.OData.Query.Expressions.IFilterBinder binder, System.Linq.IQueryable query, Microsoft.OData.UriParser.FilterClause filterClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.IQueryable @@ -1761,6 +1798,7 @@ static Microsoft.AspNetCore.OData.Query.Expressions.BinderExtensions.ApplyBind(t static Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.ApplyNullPropagationForFilterBody(System.Linq.Expressions.Expression body, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression static Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.GetDynamicPropertyContainer(Microsoft.OData.UriParser.CollectionOpenPropertyAccessNode openCollectionNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Reflection.PropertyInfo static Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.GetDynamicPropertyContainer(Microsoft.OData.UriParser.SingleValueOpenPropertyAccessNode openNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Reflection.PropertyInfo +static Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.WrapConvert(System.Linq.Expressions.Expression expression) -> System.Linq.Expressions.Expression static Microsoft.AspNetCore.OData.Query.HttpRequestODataQueryExtensions.GetETag(this Microsoft.AspNetCore.Http.HttpRequest request, Microsoft.Net.Http.Headers.EntityTagHeaderValue entityTagHeaderValue) -> Microsoft.AspNetCore.OData.Query.ETag static Microsoft.AspNetCore.OData.Query.HttpRequestODataQueryExtensions.GetETag(this Microsoft.AspNetCore.Http.HttpRequest request, Microsoft.Net.Http.Headers.EntityTagHeaderValue entityTagHeaderValue) -> Microsoft.AspNetCore.OData.Query.ETag static Microsoft.AspNetCore.OData.Query.ODataQueryOptions.IsSystemQueryOption(string queryOptionName) -> bool @@ -1870,12 +1908,16 @@ virtual Microsoft.AspNetCore.OData.Query.EnableQueryAttribute.CreateQueryOptions virtual Microsoft.AspNetCore.OData.Query.EnableQueryAttribute.GetModel(System.Type elementClrType, Microsoft.AspNetCore.Http.HttpRequest request, Microsoft.AspNetCore.Mvc.Abstractions.ActionDescriptor actionDescriptor) -> Microsoft.OData.Edm.IEdmModel virtual Microsoft.AspNetCore.OData.Query.EnableQueryAttribute.ValidateQuery(Microsoft.AspNetCore.Http.HttpRequest request, Microsoft.AspNetCore.OData.Query.ODataQueryOptions queryOptions) -> void virtual Microsoft.AspNetCore.OData.Query.ETag.ApplyTo(System.Linq.IQueryable query) -> System.Linq.IQueryable +virtual Microsoft.AspNetCore.OData.Query.Expressions.AggregationBinder.BindGroupBy(Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression +virtual Microsoft.AspNetCore.OData.Query.Expressions.AggregationBinder.BindSelect(Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression +virtual Microsoft.AspNetCore.OData.Query.Expressions.AggregationBinder.FlattenReferencedProperties(Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, System.Linq.IQueryable query, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, out System.Linq.Expressions.ParameterExpression contextParameter, out System.Collections.Generic.IDictionary flattenedPropertiesMap) -> System.Linq.IQueryable virtual Microsoft.AspNetCore.OData.Query.Expressions.ExpressionBinderBase.BindCollectionConstantNode(Microsoft.OData.UriParser.CollectionConstantNode node) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.ExpressionBinderBase.BindConstantNode(Microsoft.OData.UriParser.ConstantNode constantNode) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.ExpressionBinderBase.BindSingleValueFunctionCallNode(Microsoft.OData.UriParser.SingleValueFunctionCallNode node) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.FilterBinder.BindFilter(Microsoft.OData.UriParser.FilterClause filterClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.OrderByBinder.BindOrderBy(Microsoft.OData.UriParser.OrderByClause orderByClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> Microsoft.AspNetCore.OData.Query.Expressions.OrderByBinderResult virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.Bind(Microsoft.OData.UriParser.QueryNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression +virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.BindAccessExpression(Microsoft.OData.UriParser.QueryNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, System.Linq.Expressions.Expression baseElement = null) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.BindAllNode(Microsoft.OData.UriParser.AllNode allNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.BindAnyNode(Microsoft.OData.UriParser.AnyNode anyNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.BindBinaryOperatorNode(Microsoft.OData.UriParser.BinaryOperatorNode binaryOperatorNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression @@ -1923,6 +1965,7 @@ virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.BindToLower(Mic virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.BindToUpper(Microsoft.OData.UriParser.SingleValueFunctionCallNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.BindTrim(Microsoft.OData.UriParser.SingleValueFunctionCallNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.BindUnaryOperatorNode(Microsoft.OData.UriParser.UnaryOperatorNode unaryOperatorNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression +virtual Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder.CreateOpenPropertyAccessExpression(Microsoft.OData.UriParser.SingleValueOpenPropertyAccessNode openNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression virtual Microsoft.AspNetCore.OData.Query.Expressions.SelectExpandBinder.BindComputedProperty(System.Linq.Expressions.Expression source, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, string computedProperty, System.Collections.Generic.IList includedProperties) -> void virtual Microsoft.AspNetCore.OData.Query.Expressions.SelectExpandBinder.BindOrderByProperties(Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, System.Linq.Expressions.Expression source, Microsoft.OData.Edm.IEdmStructuredType structuredType, System.Collections.Generic.IList includedProperties, bool isSelectedAll) -> void virtual Microsoft.AspNetCore.OData.Query.Expressions.SelectExpandBinder.BindSelectExpand(Microsoft.OData.UriParser.SelectExpandClause selectExpandClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) -> System.Linq.Expressions.Expression diff --git a/src/Microsoft.AspNetCore.OData/Query/Container/AggregationPropertyContainer.cs b/src/Microsoft.AspNetCore.OData/Query/Container/AggregationPropertyContainer.cs index 7e59b0c08..9a1494e9c 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Container/AggregationPropertyContainer.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Container/AggregationPropertyContainer.cs @@ -16,7 +16,7 @@ namespace Microsoft.AspNetCore.OData.Query.Container /// /// Represent properties used in groupby and aggregate clauses to make them accessible in further clauses/transformations /// - /// + /// /// When we have $apply=groupby((Prop1,Prop2, Prop3))&$orderby=Prop1, Prop2 /// We will have following expression in .GroupBy /// $it => new AggregationPropertyContainer() { @@ -32,10 +32,10 @@ namespace Microsoft.AspNetCore.OData.Query.Container /// } /// } /// when in $orderby (see AggregationBinder CollectProperties method) - /// Prop1 could be referenced us $it => (string)$it.Value - /// Prop2 could be referenced us $it => (int)$it.Next.Value - /// Prop3 could be referenced us $it => (int)$it.Next.Next.Value - /// Generic type for Value is used to avoid type casts for on primitive types that not supported in EF + /// Prop1 could be referenced as $it => (string)$it.Value + /// Prop2 could be referenced as $it => (int)$it.Next.Value + /// Prop3 could be referenced as $it => (int)$it.Next.Next.Value + /// Generic type for Value is used to avoid type casts for primitive types that are not supported in EF /// /// Also we have 4 use cases and base type have all required properties to support no cast usage. /// 1. Primitive property with Next @@ -43,9 +43,8 @@ namespace Microsoft.AspNetCore.OData.Query.Container /// 3. Nested property with Next /// 4. Nested property without Next /// However, EF doesn't allow to set different properties for the same type in two places in an lambda-expression => using new type with just new name to workaround that issue - /// - /// - internal class AggregationPropertyContainer : NamedProperty + /// + internal class AggregationPropertyContainer : NamedProperty, IAggregationPropertyContainer { public GroupByWrapper NestedValue { @@ -59,7 +58,7 @@ public GroupByWrapper NestedValue } } - public AggregationPropertyContainer Next { get; set; } + public IAggregationPropertyContainer Next { get; set; } public override void ToDictionaryCore(Dictionary dictionary, IPropertyMapper propertyMapper, bool includeAutoSelected) @@ -99,9 +98,9 @@ public static Expression CreateNextNamedPropertyContainer(IList memberBindings = new List(); - memberBindings.Add(Expression.Bind(namedPropertyType.GetProperty("Name"), property.Name)); + memberBindings.Add(Expression.Bind(namedPropertyType.GetProperty(QueryConstants.AggregationPropertyContainerNameProperty), property.Name)); if (property.Value.Type == typeof(GroupByWrapper)) { - memberBindings.Add(Expression.Bind(namedPropertyType.GetProperty("NestedValue"), property.Value)); + memberBindings.Add(Expression.Bind(namedPropertyType.GetProperty(QueryConstants.AggregationPropertyContainerNestedValueProperty), property.Value)); } else { - memberBindings.Add(Expression.Bind(namedPropertyType.GetProperty("Value"), property.Value)); + memberBindings.Add(Expression.Bind(namedPropertyType.GetProperty(QueryConstants.AggregationPropertyContainerValueProperty), property.Value)); } if (next != null) { - memberBindings.Add(Expression.Bind(namedPropertyType.GetProperty("Next"), next)); + memberBindings.Add(Expression.Bind(namedPropertyType.GetProperty(QueryConstants.AggregationPropertyContainerNextProperty), next)); } if (property.NullCheck != null) diff --git a/src/Microsoft.AspNetCore.OData/Query/Container/IAggregationPropertyContainerOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Container/IAggregationPropertyContainerOfT.cs new file mode 100644 index 000000000..a74fbfa35 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/Container/IAggregationPropertyContainerOfT.cs @@ -0,0 +1,70 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Collections.Generic; +using Microsoft.AspNetCore.OData.Query.Wrapper; + +namespace Microsoft.AspNetCore.OData.Query.Container +{ + /// + /// Represent properties used in groupby and aggregate clauses of $apply query to make them accessible in further clauses/transformations. + /// + /// The type that declares a member of this type. Type T must implement . + /// + /// When we have $apply=groupby((Prop1,Prop2,Prop3))&$orderby=Prop1,Prop2 + /// where implements , + /// we will have following expression in .GroupBy: + /// $it => new AggregationPropertyContainer() { + /// Name = "Prop1", + /// Value = $it.Prop1, /* string */ + /// Next = new AggregationPropertyContainer() { + /// Name = "Prop2", + /// Value = $it.Prop2, /* int */ + /// Next = new LastInChain() { + /// Name = "Prop3", + /// Value = $it.Prop3 /* int */ + /// } + /// } + /// } + /// When in $orderby, + /// Prop1 could be referenced as $it => (string)$it.Value, + /// Prop2 could be referenced as $it => (int)$it.Next.Value, + /// Prop3 could be referenced as $it => (int)$it.Next.Next.Value. + /// Generic type for Value is used to avoid type casts for primitive types that are not supported in Entity Framework. + /// Also, we have 4 use cases and this interface declares all required properties to support no cast usage. + /// 1). Primitive property with Next + /// 2). Primitive property without Next + /// 3). Nested property with Next + /// 4). Nested property without Next. + /// However, Entity Framework doesn't allow to set different properties for the same type in two places in a lambda expression. + /// Using new type with just new name to workaround that issue. + /// + public interface IAggregationPropertyContainer + { + /// Gets or sets the name of the property. + string Name { get; set; } + + /// Gets or sets the value of the property. + object Value { get; set; } + + /// Gets or sets the nested value of the property. + T NestedValue { get; set; } + + /// Gets or sets the next property container. + IAggregationPropertyContainer Next { get; set; } + + /// + /// Adds the properties in this container to the given dictionary. + /// + /// The dictionary to which the properties in this container should be added. + /// The property mapper to use for mapping + /// between the names of properties in this container and the names that + /// should be used when adding the properties to the given dictionary. + /// A value indicating whether auto-selected properties should be included. + void ToDictionaryCore(Dictionary dictionary, IPropertyMapper propertyMapper, bool includeAutoSelected); + } +} diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/AggregationBinder.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/AggregationBinder.cs index 982060916..810693c9b 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Expressions/AggregationBinder.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/AggregationBinder.cs @@ -7,8 +7,8 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; +using System.Diagnostics; using System.Globalization; using System.Linq; using System.Linq.Expressions; @@ -18,612 +18,691 @@ using Microsoft.AspNetCore.OData.Query.Wrapper; using Microsoft.OData; using Microsoft.OData.Edm; -using Microsoft.OData.ModelBuilder; -using Microsoft.OData.UriParser; using Microsoft.OData.UriParser.Aggregation; +using Microsoft.OData.UriParser; namespace Microsoft.AspNetCore.OData.Query.Expressions { - [SuppressMessage("Microsoft.Maintainability", "CA1506:AvoidExcessiveClassCoupling", Justification = "Relies on many ODataLib classes.")] - internal class AggregationBinder : TransformationBinderBase + /// + /// The default implementation to bind an OData $apply represented by to an . + /// + public class AggregationBinder : QueryBinder, IAggregationBinder { - private const string GroupByContainerProperty = "GroupByContainer"; - private TransformationNode _transformation; - - private IEnumerable _aggregateExpressions; - private IEnumerable _groupingProperties; - - private Type _groupByClrType; + /// + /// The parameter name for current type. + /// + private const string DollarThis = "$this"; - internal AggregationBinder(ODataQuerySettings settings, IAssemblyResolver assembliesResolver, Type elementType, - IEdmModel model, TransformationNode transformation) - : base(settings, assembliesResolver, elementType, model) + /// + public virtual Expression BindGroupBy(TransformationNode transformationNode, QueryBinderContext context) { - Contract.Assert(transformation != null); - - _transformation = transformation; - - switch (transformation.Kind) + if (transformationNode == null) { - case TransformationNodeKind.Aggregate: - var aggregateClause = this._transformation as AggregateTransformationNode; - _aggregateExpressions = FixCustomMethodReturnTypes(aggregateClause.AggregateExpressions); - ResultClrType = typeof(NoGroupByAggregationWrapper); - break; - case TransformationNodeKind.GroupBy: - var groupByClause = this._transformation as GroupByTransformationNode; - _groupingProperties = groupByClause.GroupingProperties; - if (groupByClause.ChildTransformations != null) - { - if (groupByClause.ChildTransformations.Kind == TransformationNodeKind.Aggregate) - { - var aggregationNode = (AggregateTransformationNode)groupByClause.ChildTransformations; - _aggregateExpressions = FixCustomMethodReturnTypes(aggregationNode.AggregateExpressions); - } - else - { - throw new NotImplementedException(); - } - } - - _groupByClrType = typeof(GroupByWrapper); - ResultClrType = typeof(AggregationWrapper); - break; - default: - throw new NotSupportedException(String.Format(CultureInfo.InvariantCulture, - SRResources.NotSupportedTransformationKind, transformation.Kind)); + throw Error.ArgumentNull(nameof(transformationNode)); } - _groupByClrType = _groupByClrType ?? typeof(NoGroupByWrapper); - } - - private static Expression WrapDynamicCastIfNeeded(Expression propertyAccessor) - { - if (propertyAccessor.Type == typeof(object)) + if (context == null) { - return Expression.Call(null, ExpressionHelperMethods.ConvertToDecimal, propertyAccessor); + throw Error.ArgumentNull(nameof(context)); } - return propertyAccessor; - } + LambdaExpression groupByLambda = null; + IEnumerable groupingProperties = GetGroupingProperties(transformationNode); - private IEnumerable FixCustomMethodReturnTypes(IEnumerable aggregateExpressions) - { - return aggregateExpressions.Select(x => + if (groupingProperties?.Any() == true) { - var ae = x as AggregateExpression; - return ae != null ? FixCustomMethodReturnType(ae) : x; - }); - } + // Generates the expression: + // .GroupBy($it => new DynamicTypeWrapper() + // { + // GroupByContainer => new AggregationPropertyContainer() { + // Name = "Prop1", + // Value = $it.Prop1, + // Next = new AggregationPropertyContainer() { + // Name = "Prop2", + // Value = $it.Prop2, // int + // Next = new LastInChain() { + // Name = "Prop3", + // Value = $it.Prop3 + // } + // } + // } + // }) + + List properties = CreateGroupByMemberAssignments(groupingProperties, context); + + PropertyInfo wrapperProperty = typeof(GroupByWrapper).GetProperty(QueryConstants.GroupByWrapperGroupByContainerProperty); + List wrapperTypeMemberAssignments = new List(capacity: 1) + { + Expression.Bind(wrapperProperty, AggregationPropertyContainer.CreateNextNamedPropertyContainer(properties)) + }; - private AggregateExpression FixCustomMethodReturnType(AggregateExpression expression) - { - if (expression.Method != AggregationMethod.Custom) + groupByLambda = Expression.Lambda( + Expression.MemberInit(Expression.New(typeof(GroupByWrapper)), wrapperTypeMemberAssignments), + context.CurrentParameter); + } + else { - return expression; + // No GroupBy properties + // .GroupBy($it => new NoGroupByWrapper()) + groupByLambda = Expression.Lambda(Expression.New(typeof(NoGroupByWrapper)), context.CurrentParameter); } - var customMethod = GetCustomMethod(expression); - - // var typeReference = customMethod.ReturnType.GetEdmPrimitiveTypeReference(); - var typeReference = Model.GetEdmPrimitiveTypeReference(customMethod.ReturnType); - - return new AggregateExpression(expression.Expression, expression.MethodDefinition, expression.Alias, typeReference); + return groupByLambda; } - private MethodInfo GetCustomMethod(AggregateExpression expression) + /// + public virtual Expression BindSelect(TransformationNode transformationNode, QueryBinderContext context) { - var propertyLambda = Expression.Lambda(BindAccessor(expression.Expression), this.LambdaParameter); - Type inputType = propertyLambda.Body.Type; - - string methodToken = expression.MethodDefinition.MethodLabel; - var customFunctionAnnotations = Model.GetAnnotationValue(Model); - - MethodInfo customMethod; - if (!customFunctionAnnotations.GetMethodInfo(methodToken, inputType, out customMethod)) + if (transformationNode == null) { - throw new ODataException( - Error.Format( - SRResources.AggregationNotSupportedForType, - expression.Method, - expression.Expression, - inputType)); + throw Error.ArgumentNull(nameof(transformationNode)); } - return customMethod; - } - - public IQueryable Bind(IQueryable query) - { - PreprocessQuery(query); + if (context == null) + { + throw Error.ArgumentNull(nameof(context)); + } - query = FlattenReferencedProperties(query); + // Generates the expression: + // .Select($it => New DynamicType2() + // { + // GroupByContainer = $it.Key.GroupByContainer // If groupby section present + // Container => new AggregationPropertyContainer() { + // Name = "Alias1", + // Value = $it.AsQuaryable().Sum(i => i.AggregatableProperty), + // Next = new LastInChain() { + // Name = "Alias2", + // Value = $it.AsQuaryable().Sum(i => i.AggregatableProperty) + // } + // } + // }) + + Type groupByClrType = transformationNode.Kind == TransformationNodeKind.GroupBy ? typeof(GroupByWrapper) : typeof(NoGroupByWrapper); + Type groupingType = typeof(IGrouping<,>).MakeGenericType(groupByClrType, context.TransformationElementType); + Type resultClrType = transformationNode.Kind == TransformationNodeKind.Aggregate ? typeof(NoGroupByAggregationWrapper) : typeof(AggregationWrapper); + ParameterExpression groupingParameter = Expression.Parameter(groupingType, "$it"); + + IEnumerable aggregateExpressions = GetAggregateExpressions(transformationNode, context); + IEnumerable groupingProperties = GetGroupingProperties(transformationNode); - // Answer is query.GroupBy($it => new DynamicType1() {...}).Select($it => new DynamicType2() {...}) - // We are doing Grouping even if only aggregate was specified to have a IQuaryable after aggregation - IQueryable grouping = BindGroupBy(query); + List wrapperTypeMemberAssignments = new List(); - IQueryable result = BindSelect(grouping); + // Setting GroupByContainer property when we have GroupBy properties + if (groupingProperties?.Any() == true) + { + PropertyInfo wrapperProperty = resultClrType.GetProperty(QueryConstants.GroupByWrapperGroupByContainerProperty); - return result; - } + wrapperTypeMemberAssignments.Add( + Expression.Bind(wrapperProperty, + Expression.Property(Expression.Property(groupingParameter, "Key"), QueryConstants.GroupByWrapperGroupByContainerProperty))); + } - /// - /// Pre flattens properties referenced in aggregate clause to avoid generation of nested queries by EF. - /// For query like groupby((A), aggregate(B/C with max as Alias1, B/D with max as Alias2)) we need to generate - /// .Select( - /// $it => new FlattenninWrapper () { - /// Source = $it, // Will used in groupby stage - /// Container = new { - /// Value = $it.B.C - /// Next = new { - /// Value = $it.B.D - /// } - /// } - /// } - /// ) - /// Also we need to populate expressions to access B/C and B/D in aggregate stage. It will look like: - /// B/C : $it.Container.Value - /// B/D : $it.Container.Next.Value - /// - /// - /// Query with Select that flattens properties - private IQueryable FlattenReferencedProperties(IQueryable query) - { - if (_aggregateExpressions != null - && _aggregateExpressions.OfType().Any(e => e.Method != AggregationMethod.VirtualPropertyCount) - && _groupingProperties != null - && _groupingProperties.Any() - && (FlattenedPropertyContainer == null || !FlattenedPropertyContainer.Any())) + // Setting Container property when we have aggregation clauses + if (aggregateExpressions != null) { - var wrapperType = typeof(FlatteningWrapper<>).MakeGenericType(this.ElementType); - var sourceProperty = wrapperType.GetProperty("Source"); - List wta = new List(); - wta.Add(Expression.Bind(sourceProperty, this.LambdaParameter)); - - var aggrregatedPropertiesToFlatten = _aggregateExpressions.OfType().Where(e => e.Method != AggregationMethod.VirtualPropertyCount).ToList(); - // Generated Select will be stack like, meaning that first property in the list will be deepest one - // For example if we add $it.B.C, $it.B.D, select will look like - // new { - // Value = $it.B.C - // Next = new { - // Value = $it.B.D - // } - // } - // We are generated references (in currentContainerExpression) from the beginning of the Select ($it.Value, then $it.Next.Value etc.) - // We have proper match we need insert properties in reverse order - // After this - // properties = { $it.B.D, $it.B.C} - // _preFlattendMAp = { {$it.B.C, $it.Value}, {$it.B.D, $it.Next.Value} } - var properties = new NamedPropertyExpression[aggrregatedPropertiesToFlatten.Count]; - var aliasIdx = aggrregatedPropertiesToFlatten.Count - 1; - var aggParam = Expression.Parameter(wrapperType, "$it"); - var currentContainerExpression = Expression.Property(aggParam, GroupByContainerProperty); - foreach (var aggExpression in aggrregatedPropertiesToFlatten) + List properties = new List(); + foreach (AggregateExpressionBase aggregateExpression in aggregateExpressions) { - var alias = "Property" + aliasIdx.ToString(CultureInfo.CurrentCulture); // We just need unique alias, we aren't going to use it - - // Add Value = $it.B.C - var propAccessExpression = BindAccessor(aggExpression.Expression); - var type = propAccessExpression.Type; - propAccessExpression = WrapConvert(propAccessExpression); - properties[aliasIdx] = new NamedPropertyExpression(Expression.Constant(alias), propAccessExpression); - - // Save $it.Container.Next.Value for future use - UnaryExpression flatAccessExpression = Expression.Convert( - Expression.Property(currentContainerExpression, "Value"), - type); - currentContainerExpression = Expression.Property(currentContainerExpression, "Next"); - _preFlattenedMap.Add(aggExpression.Expression, flatAccessExpression); - aliasIdx--; + properties.Add(new NamedPropertyExpression( + Expression.Constant(aggregateExpression.Alias), + CreateAggregateExpression(groupingParameter, aggregateExpression, context.TransformationElementType, context))); } - var wrapperProperty = ResultClrType.GetProperty(GroupByContainerProperty); - - wta.Add(Expression.Bind(wrapperProperty, AggregationPropertyContainer.CreateNextNamedPropertyContainer(properties))); - - var flatLambda = Expression.Lambda(Expression.MemberInit(Expression.New(wrapperType), wta), LambdaParameter); - - query = ExpressionHelpers.Select(query, flatLambda, this.ElementType); - - // We applied flattening let .GroupBy know about it. - this.LambdaParameter = aggParam; + PropertyInfo wrapperProperty = resultClrType.GetProperty(QueryConstants.GroupByWrapperContainerProperty); + wrapperTypeMemberAssignments.Add(Expression.Bind(wrapperProperty, AggregationPropertyContainer.CreateNextNamedPropertyContainer(properties))); } - return query; + MemberInitExpression body = Expression.MemberInit( + Expression.New(resultClrType), + wrapperTypeMemberAssignments); + + return Expression.Lambda(body, groupingParameter); } - private Dictionary _preFlattenedMap = new Dictionary(); - - private IQueryable BindSelect(IQueryable grouping) + /// + public virtual IQueryable FlattenReferencedProperties( + TransformationNode transformationNode, + IQueryable query, + QueryBinderContext context, + out ParameterExpression contextParameter, + out IDictionary flattenedPropertiesMap) { - // Should return following expression - // .Select($it => New DynamicType2() - // { - // GroupByContainer = $it.Key.GroupByContainer // If groupby section present - // Container => new AggregationPropertyContainer() { - // Name = "Alias1", - // Value = $it.AsQuaryable().Sum(i => i.AggregatableProperty), - // Next = new LastInChain() { - // Name = "Alias2", - // Value = $it.AsQuaryable().Sum(i => i.AggregatableProperty) - // } - // } - // }) - var groupingType = typeof(IGrouping<,>).MakeGenericType(this._groupByClrType, this.ElementType); - ParameterExpression accum = Expression.Parameter(groupingType, "$it"); + if (transformationNode == null) + { + throw Error.ArgumentNull(nameof(transformationNode)); + } - List wrapperTypeMemberAssignments = new List(); + if (query == null) + { + throw Error.ArgumentNull(nameof(query)); + } - // Setting GroupByContainer property when previous step was grouping - if (this._groupingProperties != null && this._groupingProperties.Any()) + if (context == null) { - var wrapperProperty = this.ResultClrType.GetProperty(GroupByContainerProperty); + throw Error.ArgumentNull(nameof(context)); + } + + flattenedPropertiesMap = null; + contextParameter = context.CurrentParameter; - wrapperTypeMemberAssignments.Add(Expression.Bind(wrapperProperty, Expression.Property(Expression.Property(accum, "Key"), GroupByContainerProperty))); + if (context.FlattenedProperties?.Any() == true) + { + return query; } - // Setting Container property when we have aggregation clauses - if (_aggregateExpressions != null) + IEnumerable groupingProperties = GetGroupingProperties(transformationNode); + // Aggregate expressions to flatten - excludes VirtualPropertyCount + List aggregateExpressions = GetAggregateExpressions(transformationNode, context)?.OfType() + .Where(e => e.Method != AggregationMethod.VirtualPropertyCount).ToList(); + + if ((aggregateExpressions?.Count ?? 0) == 0 || !(groupingProperties?.Any() == true)) { - var properties = new List(); - foreach (var aggExpression in _aggregateExpressions) - { - properties.Add(new NamedPropertyExpression(Expression.Constant(aggExpression.Alias), CreateAggregationExpression(accum, aggExpression, this.ElementType))); - } + return query; + } - var wrapperProperty = ResultClrType.GetProperty("Container"); - wrapperTypeMemberAssignments.Add(Expression.Bind(wrapperProperty, AggregationPropertyContainer.CreateNextNamedPropertyContainer(properties))); + Type wrapperType = typeof(FlatteningWrapper<>).MakeGenericType(context.TransformationElementType); + PropertyInfo sourceProperty = wrapperType.GetProperty(QueryConstants.FlatteningWrapperSourceProperty); + List wrapperTypeMemberAssignments = new List + { + Expression.Bind(sourceProperty, context.CurrentParameter) + }; + + // Generated Select will be stack-like; meaning that first property in the list will be deepest one + // For example if we add $it.B.C, $it.B.D, Select will look like + // new { + // Value = $it.B.C + // Next = new { + // Value = $it.B.D + // } + // } + + // We are generating references (in containerExpression) from the beginning of the Select ($it.Value, then $it.Next.Value etc.) + // We have proper match we need insert properties in reverse order + // After this, + // properties = { $it.B.D, $it.B.C } + // PreFlattenedMap = { {$it.B.C, $it.Value}, {$it.B.D, $it.Next.Value} } + + int aliasIdx = aggregateExpressions.Count - 1; + NamedPropertyExpression[] properties = new NamedPropertyExpression[aggregateExpressions.Count]; + + flattenedPropertiesMap = new Dictionary(aggregateExpressions.Count); + contextParameter = Expression.Parameter(wrapperType, "$it"); + MemberExpression containerExpression = Expression.Property(contextParameter, QueryConstants.GroupByWrapperGroupByContainerProperty); + + for (int i = 0; i < aggregateExpressions.Count; i++) + { + AggregateExpression aggregateExpression = aggregateExpressions[i]; + + string alias = string.Concat("Property", aliasIdx.ToString(CultureInfo.CurrentCulture)); // We just need unique alias, we aren't going to use it + + // Add Value = $it.B.C + Expression propertyAccessExpression = BindAccessExpression(aggregateExpression.Expression, context); + Type type = propertyAccessExpression.Type; + propertyAccessExpression = WrapConvert(propertyAccessExpression); + properties[aliasIdx] = new NamedPropertyExpression(Expression.Constant(alias), propertyAccessExpression); + + // Save $it.Container.Next.Value for future use + UnaryExpression flattenedAccessExpression = Expression.Convert( + Expression.Property(containerExpression, QueryConstants.AggregationPropertyContainerValueProperty), + type); + containerExpression = Expression.Property(containerExpression, QueryConstants.AggregationPropertyContainerNextProperty); + flattenedPropertiesMap.Add(aggregateExpression.Expression, flattenedAccessExpression); + aliasIdx--; } - var initilizedMember = - Expression.MemberInit(Expression.New(ResultClrType), wrapperTypeMemberAssignments); - var selectLambda = Expression.Lambda(initilizedMember, accum); + PropertyInfo wrapperProperty = typeof(AggregationWrapper).GetProperty(QueryConstants.GroupByWrapperGroupByContainerProperty); + + wrapperTypeMemberAssignments.Add(Expression.Bind(wrapperProperty, AggregationPropertyContainer.CreateNextNamedPropertyContainer(properties))); - var result = ExpressionHelpers.Select(grouping, selectLambda, groupingType); - return result; + LambdaExpression flattenedLambda = Expression.Lambda(Expression.MemberInit(Expression.New(wrapperType), wrapperTypeMemberAssignments), context.CurrentParameter); + + query = ExpressionHelpers.Select(query, flattenedLambda, context.TransformationElementType); + + return query; } - private List CreateSelectMemberAssigments(Type type, MemberExpression propertyAccessor, - IEnumerable properties) + private static Expression WrapDynamicCastIfNeeded(Expression propertyAccessExpression) { - var wrapperTypeMemberAssignments = new List(); - if (_groupingProperties != null) + if (propertyAccessExpression.Type == typeof(object)) { - foreach (var node in properties) - { - var nodePropertyAccessor = Expression.Property(propertyAccessor, node.Name); - var member = type.GetMember(node.Name).Single(); - if (node.Expression != null) - { - wrapperTypeMemberAssignments.Add(Expression.Bind(member, nodePropertyAccessor)); - } - else - { - var memberType = (member as PropertyInfo).PropertyType; - var expr = Expression.MemberInit(Expression.New(memberType), - CreateSelectMemberAssigments(memberType, nodePropertyAccessor, node.ChildTransformations)); - wrapperTypeMemberAssignments.Add(Expression.Bind(member, expr)); - } - } + return Expression.Call(null, ExpressionHelperMethods.ConvertToDecimal, propertyAccessExpression); } - return wrapperTypeMemberAssignments; + return propertyAccessExpression; } - private Expression CreateAggregationExpression(ParameterExpression accum, AggregateExpressionBase expression, Type baseType) + /// + /// Creates an expression for an aggregate. + /// + /// The parameter representing the group. + /// The aggregate expression. + /// The element type at the base of the transformation. + /// The query binder context. + /// An expression representing the aggregate. + /// + private Expression CreateAggregateExpression(ParameterExpression groupingParameter, AggregateExpressionBase aggregateExpression, Type baseType, QueryBinderContext context) { - switch (expression.AggregateKind) + switch (aggregateExpression.AggregateKind) { case AggregateExpressionKind.PropertyAggregate: - return CreatePropertyAggregateExpression(accum, expression as AggregateExpression, baseType); + return CreatePropertyAggregateExpression(groupingParameter, aggregateExpression as AggregateExpression, baseType, context); case AggregateExpressionKind.EntitySetAggregate: - return CreateEntitySetAggregateExpression(accum, expression as EntitySetAggregateExpression, baseType); + return CreateEntitySetAggregateExpression(groupingParameter, aggregateExpression as EntitySetAggregateExpression, baseType, context); default: - throw new ODataException(Error.Format(SRResources.AggregateKindNotSupported, expression.AggregateKind)); + throw new ODataException(Error.Format(SRResources.AggregateKindNotSupported, aggregateExpression.AggregateKind)); } } + /// + /// Creates an expression for an entity set aggregate. + /// + /// The parameter representing the group. + /// The entity set aggregate expression. + /// The element type at the base of the transformation. + /// The query binder context. + /// An expression for the entity set aggregate. private Expression CreateEntitySetAggregateExpression( - ParameterExpression accum, EntitySetAggregateExpression expression, Type baseType) + ParameterExpression groupingParameter, + EntitySetAggregateExpression entitySetAggregateExpression, + Type baseType, + QueryBinderContext context) { - // Should return following expression + // Generates the expression: // $it => $it.AsQueryable() // .SelectMany($it => $it.SomeEntitySet) // .GroupBy($gr => new Object()) // .Select($p => new DynamicTypeWrapper() // { - // AliasOne = $p.AsQueryable().AggMethodOne($it => $it.SomePropertyOfSomeEntitySet), - // AliasTwo = $p.AsQueryable().AggMethodTwo($it => $it.AnotherPropertyOfSomeEntitySet), + // Alias1 = $p.AsQueryable().AggregateMethod1($it => $it.SomePropertyOfSomeEntitySet), + // Alias2 = $p.AsQueryable().AggregateMethod2($it => $it.AnotherPropertyOfSomeEntitySet), // ... // AliasN = ... , // A nested expression of this same format. // ... // }) List wrapperTypeMemberAssignments = new List(); - var asQueryableMethod = ExpressionHelperMethods.QueryableAsQueryable.MakeGenericMethod(baseType); - Expression asQueryableExpression = Expression.Call(null, asQueryableMethod, accum); + MethodInfo asQueryableMethod = ExpressionHelperMethods.QueryableAsQueryable.MakeGenericMethod(baseType); + Expression asQueryableExpression = Expression.Call(null, asQueryableMethod, groupingParameter); // Create lambda to access the entity set from expression - var source = BindAccessor(expression.Expression.Source); - string propertyName = Model.GetClrPropertyName(expression.Expression.NavigationProperty); + Expression source = BindAccessExpression(entitySetAggregateExpression.Expression.Source, context); + string propertyName = context.Model.GetClrPropertyName(entitySetAggregateExpression.Expression.NavigationProperty); - var property = Expression.Property(source, propertyName); + MemberExpression property = Expression.Property(source, propertyName); - var baseElementType = source.Type; - var selectedElementType = property.Type.GenericTypeArguments.Single(); + Type baseElementType = source.Type; + Type selectedElementType = property.Type.GenericTypeArguments.Single(); // Create method to get property collections to aggregate - MethodInfo selectManyMethod - = ExpressionHelperMethods.EnumerableSelectManyGeneric.MakeGenericMethod(baseElementType, selectedElementType); + MethodInfo selectManyMethod = ExpressionHelperMethods.EnumerableSelectManyGeneric.MakeGenericMethod(baseElementType, selectedElementType); - // Create the lambda that access the property in the selectMany clause. - var selectManyParam = Expression.Parameter(baseElementType, "$it"); - var propertyExpression = Expression.Property(selectManyParam, expression.Expression.NavigationProperty.Name); + // Create the lambda that access the property in the SelectMany clause. + ParameterExpression selectManyParam = Expression.Parameter(baseElementType, "$it"); + MemberExpression propertyExpression = Expression.Property(selectManyParam, entitySetAggregateExpression.Expression.NavigationProperty.Name); // Collection selector body is IQueryable, we need to adjust the type to IEnumerable, to match the SelectMany signature // therefore the delegate type is specified explicitly - var collectionSelectorLambdaType = typeof(Func<,>).MakeGenericType( + Type collectionSelectorLambdaType = typeof(Func<,>).MakeGenericType( source.Type, typeof(IEnumerable<>).MakeGenericType(selectedElementType)); - var selectManyLambda = Expression.Lambda(collectionSelectorLambdaType, propertyExpression, selectManyParam); + LambdaExpression selectManyLambda = Expression.Lambda(collectionSelectorLambdaType, propertyExpression, selectManyParam); // Get expression to get collection of entities - var entitySet = Expression.Call(null, selectManyMethod, asQueryableExpression, selectManyLambda); + MethodCallExpression entitySet = Expression.Call(null, selectManyMethod, asQueryableExpression, selectManyLambda); // Getting method and lambda expression of groupBy - var groupKeyType = typeof(object); + Type groupKeyType = typeof(object); MethodInfo groupByMethod = ExpressionHelperMethods.EnumerableGroupByGeneric.MakeGenericMethod(selectedElementType, groupKeyType); - var groupByLambda = Expression.Lambda( + LambdaExpression groupByLambda = Expression.Lambda( Expression.New(groupKeyType), Expression.Parameter(selectedElementType, "$gr")); // Group entities in a single group to apply select - var groupedEntitySet = Expression.Call(null, groupByMethod, entitySet, groupByLambda); + MethodCallExpression groupedEntitySet = Expression.Call(null, groupByMethod, entitySet, groupByLambda); - var groupingType = typeof(IGrouping<,>).MakeGenericType(groupKeyType, selectedElementType); - ParameterExpression innerAccum = Expression.Parameter(groupingType, "$p"); + Type groupingType = typeof(IGrouping<,>).MakeGenericType(groupKeyType, selectedElementType); + ParameterExpression innerGroupingParameter = Expression.Parameter(groupingType, "$p"); // Nested properties // Create dynamicTypeWrapper to encapsulate the aggregate result - var properties = new List(); - foreach (var aggExpression in expression.Children) + List properties = new List(); + foreach (AggregateExpressionBase aggregateExpression in entitySetAggregateExpression.Children) { - properties.Add(new NamedPropertyExpression(Expression.Constant(aggExpression.Alias), CreateAggregationExpression(innerAccum, aggExpression, selectedElementType))); + properties.Add(new NamedPropertyExpression( + Expression.Constant(aggregateExpression.Alias), + CreateAggregateExpression(innerGroupingParameter, aggregateExpression, selectedElementType, context))); } - var nestedResultType = typeof(EntitySetAggregationWrapper); - var wrapperProperty = nestedResultType.GetProperty("Container"); + Type nestedResultType = typeof(EntitySetAggregationWrapper); + PropertyInfo wrapperProperty = nestedResultType.GetProperty(QueryConstants.GroupByWrapperContainerProperty); wrapperTypeMemberAssignments.Add(Expression.Bind(wrapperProperty, AggregationPropertyContainer.CreateNextNamedPropertyContainer(properties))); - var initializedMember = - Expression.MemberInit(Expression.New(nestedResultType), wrapperTypeMemberAssignments); - var selectLambda = Expression.Lambda(initializedMember, innerAccum); + MemberInitExpression initializedMember = Expression.MemberInit(Expression.New(nestedResultType), wrapperTypeMemberAssignments); + LambdaExpression selectLambda = Expression.Lambda(initializedMember, innerGroupingParameter); // Get select method - MethodInfo selectMethod = - ExpressionHelperMethods.EnumerableSelectGeneric.MakeGenericMethod( - groupingType, - selectLambda.Body.Type); + MethodInfo selectMethod = ExpressionHelperMethods.EnumerableSelectGeneric.MakeGenericMethod( + groupingType, + selectLambda.Body.Type); return Expression.Call(null, selectMethod, groupedEntitySet, selectLambda); } - private Expression CreatePropertyAggregateExpression(ParameterExpression accum, AggregateExpression expression, Type baseType) + /// + /// Creates an expression for a property aggregate. + /// + /// The + /// The aggregate expression. + /// + /// The query binder context. + /// An expression for a property aggregate. + private Expression CreatePropertyAggregateExpression( + ParameterExpression groupingParameter, + AggregateExpression aggregateExpression, + Type baseType, + QueryBinderContext context) { - // accumulate type is IGrouping<,baseType> that implements IEnumerable + // groupingParameter type is IGrouping<,baseType> that implements IEnumerable // we need cast it to IEnumerable during expression building (IEnumerable)$it - // however for EF6 we need to use $it.AsQueryable() due to limitations in types of casts that will properly translated - Expression asQuerableExpression = null; - if (ClassicEF) - { - var asQuerableMethod = ExpressionHelperMethods.QueryableAsQueryable.MakeGenericMethod(baseType); - asQuerableExpression = Expression.Call(null, asQuerableMethod, accum); - } - else - { - var queryableType = typeof(IEnumerable<>).MakeGenericType(baseType); - asQuerableExpression = Expression.Convert(accum, queryableType); - } + Type queryableType = typeof(IEnumerable<>).MakeGenericType(baseType); + Expression queryableExpression = Expression.Convert(groupingParameter, queryableType); - // $count is a virtual property, so there's not a propertyLambda to create. - if (expression.Method == AggregationMethod.VirtualPropertyCount) + // $count is a virtual property, so there's no propertyLambda to create. + if (aggregateExpression.Method == AggregationMethod.VirtualPropertyCount) { - var countMethod = (ClassicEF - ? ExpressionHelperMethods.QueryableCountGeneric - : ExpressionHelperMethods.EnumerableCountGeneric).MakeGenericMethod(baseType); - return WrapConvert(Expression.Call(null, countMethod, asQuerableExpression)); + MethodInfo countMethod = ExpressionHelperMethods.EnumerableCountGeneric.MakeGenericMethod(baseType); + return WrapConvert(Expression.Call(null, countMethod, queryableExpression)); } - Expression body; - - var lambdaParameter = baseType == this.ElementType ? this.LambdaParameter : Expression.Parameter(baseType, "$it"); - if (!this._preFlattenedMap.TryGetValue(expression.Expression, out body)) + ParameterExpression lambdaParameter = baseType == context.TransformationElementType ? context.CurrentParameter : Expression.Parameter(baseType, "$it"); + if (!(context.FlattenedPropertiesMap?.TryGetValue(aggregateExpression.Expression, out Expression body) == true)) { - body = BindAccessor(expression.Expression, lambdaParameter); + body = BindAccessExpression(aggregateExpression.Expression, context, lambdaParameter); } + LambdaExpression propertyLambda = Expression.Lambda(body, lambdaParameter); - Expression aggregationExpression; + Expression propertyAggregateExpression; - switch (expression.Method) + switch (aggregateExpression.Method) { case AggregationMethod.Min: { - var minMethod = (ClassicEF - ? ExpressionHelperMethods.QueryableMin - : ExpressionHelperMethods.EnumerableMin).MakeGenericMethod(baseType, - propertyLambda.Body.Type); - aggregationExpression = Expression.Call(null, minMethod, asQuerableExpression, propertyLambda); + MethodInfo minMethod = ExpressionHelperMethods.EnumerableMin.MakeGenericMethod(baseType, propertyLambda.Body.Type); + propertyAggregateExpression = Expression.Call(null, minMethod, queryableExpression, propertyLambda); } + break; + case AggregationMethod.Max: { - var maxMethod = (ClassicEF - ? ExpressionHelperMethods.QueryableMax - : ExpressionHelperMethods.EnumerableMax).MakeGenericMethod(baseType, - propertyLambda.Body.Type); - aggregationExpression = Expression.Call(null, maxMethod, asQuerableExpression, propertyLambda); + MethodInfo maxMethod = ExpressionHelperMethods.EnumerableMax.MakeGenericMethod(baseType, propertyLambda.Body.Type); + propertyAggregateExpression = Expression.Call(null, maxMethod, queryableExpression, propertyLambda); } + break; + case AggregationMethod.Sum: { - MethodInfo sumGenericMethod; - // For Dynamic properties cast to decimal + // For dynamic properties, cast to decimal Expression propertyExpression = WrapDynamicCastIfNeeded(body); propertyLambda = Expression.Lambda(propertyExpression, lambdaParameter); - if ( - !(ClassicEF - ? ExpressionHelperMethods.QueryableSumGenerics - : ExpressionHelperMethods.EnumerableSumGenerics).TryGetValue(propertyExpression.Type, - out sumGenericMethod)) + if (!ExpressionHelperMethods.EnumerableSumGenerics.TryGetValue(propertyExpression.Type, out MethodInfo sumGenericMethod)) { - throw new ODataException(Error.Format(SRResources.AggregationNotSupportedForType, - expression.Method, expression.Expression, propertyExpression.Type)); + throw new ODataException(Error.Format( + SRResources.AggregationNotSupportedForType, + aggregateExpression.Method, + aggregateExpression.Expression, + propertyExpression.Type)); } - var sumMethod = sumGenericMethod.MakeGenericMethod(baseType); - aggregationExpression = Expression.Call(null, sumMethod, asQuerableExpression, propertyLambda); + MethodInfo sumMethod = sumGenericMethod.MakeGenericMethod(baseType); + propertyAggregateExpression = Expression.Call(null, sumMethod, queryableExpression, propertyLambda); - // For Dynamic properties cast back to object - if (propertyLambda.Type == typeof(object)) + // For dynamic properties, cast back to object + if (body.Type == typeof(object)) { - aggregationExpression = Expression.Convert(aggregationExpression, typeof(object)); + propertyAggregateExpression = Expression.Convert(propertyAggregateExpression, typeof(object)); } } + break; + case AggregationMethod.Average: { - MethodInfo averageGenericMethod; - // For Dynamic properties cast to decimal + // For dynamic properties, cast dynamic to decimal Expression propertyExpression = WrapDynamicCastIfNeeded(body); propertyLambda = Expression.Lambda(propertyExpression, lambdaParameter); - if ( - !(ClassicEF - ? ExpressionHelperMethods.QueryableAverageGenerics - : ExpressionHelperMethods.EnumerableAverageGenerics).TryGetValue(propertyExpression.Type, - out averageGenericMethod)) + if (!ExpressionHelperMethods.EnumerableAverageGenerics.TryGetValue(propertyExpression.Type, out MethodInfo averageGenericMethod)) { - throw new ODataException(Error.Format(SRResources.AggregationNotSupportedForType, - expression.Method, expression.Expression, propertyExpression.Type)); + throw new ODataException(Error.Format( + SRResources.AggregationNotSupportedForType, + aggregateExpression.Method, + aggregateExpression.Expression, + propertyExpression.Type)); } - var averageMethod = averageGenericMethod.MakeGenericMethod(baseType); - aggregationExpression = Expression.Call(null, averageMethod, asQuerableExpression, propertyLambda); + MethodInfo averageMethod = averageGenericMethod.MakeGenericMethod(baseType); + propertyAggregateExpression = Expression.Call(null, averageMethod, queryableExpression, propertyLambda); - // For Dynamic properties cast back to object - if (propertyLambda.Type == typeof(object)) + // For dynamic properties, cast back to object + if (body.Type == typeof(object)) { - aggregationExpression = Expression.Convert(aggregationExpression, typeof(object)); + propertyAggregateExpression = Expression.Convert(propertyAggregateExpression, typeof(object)); } } + break; + case AggregationMethod.CountDistinct: { - // I select the specific field - var selectMethod = - (ClassicEF - ? ExpressionHelperMethods.QueryableSelectGeneric - : ExpressionHelperMethods.EnumerableSelectGeneric).MakeGenericMethod(this.ElementType, - propertyLambda.Body.Type); - Expression queryableSelectExpression = Expression.Call(null, selectMethod, asQuerableExpression, - propertyLambda); - - // I run distinct over the set of items - var distinctMethod = - (ClassicEF - ? ExpressionHelperMethods.QueryableDistinct - : ExpressionHelperMethods.EnumerableDistinct).MakeGenericMethod(propertyLambda.Body.Type); + MethodInfo selectMethod = ExpressionHelperMethods.EnumerableSelectGeneric.MakeGenericMethod( + context.TransformationElementType, + propertyLambda.Body.Type); + + Expression queryableSelectExpression = Expression.Call(null, selectMethod, queryableExpression, propertyLambda); + + // Expression to get distinct items + MethodInfo distinctMethod = ExpressionHelperMethods.EnumerableDistinct.MakeGenericMethod(propertyLambda.Body.Type); Expression distinctExpression = Expression.Call(null, distinctMethod, queryableSelectExpression); - // I count the distinct items as the aggregation expression - var countMethod = - (ClassicEF - ? ExpressionHelperMethods.QueryableCountGeneric - : ExpressionHelperMethods.EnumerableCountGeneric).MakeGenericMethod(propertyLambda.Body.Type); - aggregationExpression = Expression.Call(null, countMethod, distinctExpression); + // Expression to get count of distinct items + MethodInfo countMethod = ExpressionHelperMethods.EnumerableCountGeneric.MakeGenericMethod(propertyLambda.Body.Type); + propertyAggregateExpression = Expression.Call(null, countMethod, distinctExpression); } + break; + case AggregationMethod.Custom: { - MethodInfo customMethod = GetCustomMethod(expression); - var selectMethod = - (ClassicEF - ? ExpressionHelperMethods.QueryableSelectGeneric - : ExpressionHelperMethods.EnumerableSelectGeneric).MakeGenericMethod(this.ElementType, propertyLambda.Body.Type); - var selectExpression = Expression.Call(null, selectMethod, asQuerableExpression, propertyLambda); - aggregationExpression = Expression.Call(null, customMethod, selectExpression); + MethodInfo customMethod = GetCustomMethod(aggregateExpression, context); + MethodInfo selectMethod = ExpressionHelperMethods.EnumerableSelectGeneric + .MakeGenericMethod(context.TransformationElementType, propertyLambda.Body.Type); + MethodCallExpression queryableSelectExpression = Expression.Call(null, selectMethod, queryableExpression, propertyLambda); + propertyAggregateExpression = Expression.Call(null, customMethod, queryableSelectExpression); } + break; + default: - throw new ODataException(Error.Format(SRResources.AggregationMethodNotSupported, expression.Method)); + throw new ODataException(Error.Format(SRResources.AggregationMethodNotSupported, aggregateExpression.Method)); } - return WrapConvert(aggregationExpression); + return WrapConvert(propertyAggregateExpression); } - private IQueryable BindGroupBy(IQueryable query) + /// + /// Creates a list of from a collection of . + /// + /// GroupBy nodes. + /// The query binder context. + /// A list of representing properties in the GroupBy clause. + private List CreateGroupByMemberAssignments(IEnumerable groupByNodes, QueryBinderContext context) { - LambdaExpression groupLambda = null; - Type elementType = query.ElementType; - if (_groupingProperties != null && _groupingProperties.Any()) + List properties = new List(); + + foreach (GroupByPropertyNode groupByNode in groupByNodes) { - // Generates expression - // .GroupBy($it => new DynamicTypeWrapper() - // { - // GroupByContainer => new AggregationPropertyContainer() { - // Name = "Prop1", - // Value = $it.Prop1, - // Next = new AggregationPropertyContainer() { - // Name = "Prop2", - // Value = $it.Prop2, // int - // Next = new LastInChain() { - // Name = "Prop3", - // Value = $it.Prop3 - // } - // } - // } - // }) - List properties = CreateGroupByMemberAssignments(_groupingProperties); - - var wrapperProperty = typeof(GroupByWrapper).GetProperty(GroupByContainerProperty); - List wta = new List(); - wta.Add(Expression.Bind(wrapperProperty, AggregationPropertyContainer.CreateNextNamedPropertyContainer(properties))); - groupLambda = Expression.Lambda(Expression.MemberInit(Expression.New(typeof(GroupByWrapper)), wta), LambdaParameter); + string propertyName = groupByNode.Name; + + if (groupByNode.Expression != null) + { + properties.Add(new NamedPropertyExpression( + Expression.Constant(propertyName), + WrapConvert(BindAccessExpression(groupByNode.Expression, context)))); + } + else + { + PropertyInfo wrapperProperty = typeof(GroupByWrapper).GetProperty(QueryConstants.GroupByWrapperGroupByContainerProperty); + + List wrapperTypeMemberAssignments = new List(capacity: 1) + { + Expression.Bind( + wrapperProperty, + AggregationPropertyContainer.CreateNextNamedPropertyContainer( + CreateGroupByMemberAssignments(groupByNode.ChildTransformations, context))) + }; + + properties.Add(new NamedPropertyExpression( + Expression.Constant(propertyName), + Expression.MemberInit(Expression.New(typeof(GroupByWrapper)), wrapperTypeMemberAssignments))); + } } - else + + return properties; + } + + /// + /// Gets a collection of from a . + /// + /// The transformation node. + /// A collection of . + private static IEnumerable GetGroupingProperties(TransformationNode transformationNode) + { + if (transformationNode.Kind == TransformationNodeKind.GroupBy) { - // We do not have properties to aggregate - // .GroupBy($it => new NoGroupByWrapper()) - groupLambda = Expression.Lambda(Expression.New(this._groupByClrType), this.LambdaParameter); + GroupByTransformationNode groupByClause = transformationNode as GroupByTransformationNode; + + return groupByClause.GroupingProperties; } - return ExpressionHelpers.GroupBy(query, groupLambda, elementType, this._groupByClrType); + return null; } - private List CreateGroupByMemberAssignments(IEnumerable nodes) + /// + /// Gets a collection of from a . + /// + /// The query binder context. + /// The . + /// A collection of aggregate expressions. + private IEnumerable GetAggregateExpressions(TransformationNode transformationNode, QueryBinderContext context) { - var properties = new List(); - foreach (var grpProp in nodes) + Contract.Assert(transformationNode != null); + Contract.Assert(context != null); + + IEnumerable aggregateExpressions = null; + + switch (transformationNode.Kind) { - var propertyName = grpProp.Name; - if (grpProp.Expression != null) - { - properties.Add(new NamedPropertyExpression(Expression.Constant(propertyName), WrapConvert(BindAccessor(grpProp.Expression)))); - } - else - { - var wrapperProperty = typeof(GroupByWrapper).GetProperty(GroupByContainerProperty); - List wta = new List(); - wta.Add(Expression.Bind(wrapperProperty, AggregationPropertyContainer.CreateNextNamedPropertyContainer(CreateGroupByMemberAssignments(grpProp.ChildTransformations)))); - properties.Add(new NamedPropertyExpression(Expression.Constant(propertyName), Expression.MemberInit(Expression.New(typeof(GroupByWrapper)), wta))); - } + case TransformationNodeKind.Aggregate: + AggregateTransformationNode aggregateClause = transformationNode as AggregateTransformationNode; + aggregateExpressions = FixCustomMethodReturnTypes(aggregateClause.AggregateExpressions, context); + + break; + + case TransformationNodeKind.GroupBy: + GroupByTransformationNode groupByClause = transformationNode as GroupByTransformationNode; + if (groupByClause.ChildTransformations != null) + { + if (groupByClause.ChildTransformations.Kind == TransformationNodeKind.Aggregate) + { + AggregateTransformationNode aggregationNode = groupByClause.ChildTransformations as AggregateTransformationNode; + aggregateExpressions = FixCustomMethodReturnTypes(aggregationNode.AggregateExpressions, context); + } + else + { + throw new NotSupportedException(string.Format( + CultureInfo.InvariantCulture, + SRResources.NotSupportedChildTransformationKind, + groupByClause.ChildTransformations.Kind, + transformationNode.Kind)); + } + } + + break; + + default: + throw new NotSupportedException(string.Format( + CultureInfo.InvariantCulture, + SRResources.NotSupportedTransformationKind, + transformationNode.Kind)); } - return properties; + return aggregateExpressions; + } + + /// + /// Fixes return types for custom aggregation methods. + /// + /// The aggregation expressions. + /// + /// + private IEnumerable FixCustomMethodReturnTypes(IEnumerable aggregateExpressions, QueryBinderContext context) + { + return aggregateExpressions.Select(exp => + { + AggregateExpression aggregationExpression = exp as AggregateExpression; + + return aggregationExpression?.Method == AggregationMethod.Custom ? FixCustomMethodReturnType(aggregationExpression, context) : exp; + }); + } + + /// + /// Fixes return type for custom aggregation method. + /// + /// The aggregation expression + /// The query binder context. + /// The + private AggregateExpression FixCustomMethodReturnType(AggregateExpression aggregationExpression, QueryBinderContext context) + { + Debug.Assert(aggregationExpression != null, $"{nameof(aggregationExpression)} != null"); + Debug.Assert(aggregationExpression.Method == AggregationMethod.Custom, $"{nameof(aggregationExpression)}.Method == {nameof(AggregationMethod.Custom)}"); + + MethodInfo customMethod = GetCustomMethod(aggregationExpression, context); + + IEdmPrimitiveTypeReference typeReference = context.Model.GetEdmPrimitiveTypeReference(customMethod.ReturnType); + + return new AggregateExpression(aggregationExpression.Expression, aggregationExpression.MethodDefinition, aggregationExpression.Alias, typeReference); + } + + /// + /// Gets a custom aggregation method for the aggregation expression. + /// + /// The aggregation expression. + /// The query binder context. + /// The custom method. + private MethodInfo GetCustomMethod(AggregateExpression aggregationExpression, QueryBinderContext context) + { + LambdaExpression propertyLambda = Expression.Lambda(BindAccessExpression(aggregationExpression.Expression, context), context.CurrentParameter); + Type inputType = propertyLambda.Body.Type; + + string methodToken = aggregationExpression.MethodDefinition.MethodLabel; + CustomAggregateMethodAnnotation customMethodAnnotations = context.Model.GetAnnotationValue(context.Model); + + MethodInfo customMethod; + if (!customMethodAnnotations.GetMethodInfo(methodToken, inputType, out customMethod)) + { + throw new ODataException(Error.Format( + SRResources.AggregationNotSupportedForType, + aggregationExpression.Method, + aggregationExpression.Expression, + inputType)); + } + + return customMethod; } } } diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/BinderExtensions.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/BinderExtensions.cs index d2bcca4c8..6c48e68ab 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Expressions/BinderExtensions.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/BinderExtensions.cs @@ -7,11 +7,14 @@ using System; using System.Collections; +using System.Collections.Generic; using System.Diagnostics.Contracts; using System.Linq; using System.Linq.Expressions; using System.Reflection; +using Microsoft.AspNetCore.OData.Query.Wrapper; using Microsoft.OData.UriParser; +using Microsoft.OData.UriParser.Aggregation; namespace Microsoft.AspNetCore.OData.Query.Expressions { @@ -20,6 +23,8 @@ namespace Microsoft.AspNetCore.OData.Query.Expressions /// public static class BinderExtensions { + private const string DollarThis = "$this"; + /// /// Translates an OData $filter represented by to and apply to . /// @@ -355,5 +360,73 @@ public static IQueryable ApplyBind(this ISearchBinder binder, IQueryable source, Expression searchExp = binder.BindSearch(searchClause, context); return ExpressionHelpers.Where(source, searchExp, context.ElementClrType); } + + /// + /// Translate an OData $apply parse tree represented by to + /// an and applies it to an . + /// + /// The built in + /// The original . + /// The OData $apply parse tree. + /// An instance of the . + /// The type of wrapper used to create an expression from the $apply parse tree. + /// The applied result. + public static IQueryable ApplyBind(this IAggregationBinder binder, IQueryable source, TransformationNode transformationNode, QueryBinderContext context, out Type resultClrType) + { + if (binder == null) + { + throw Error.ArgumentNull(nameof(binder)); + } + + if (source == null) + { + throw Error.ArgumentNull(nameof(source)); + } + + if (transformationNode == null) + { + throw Error.ArgumentNull(nameof(transformationNode)); + } + + if (context == null) + { + throw Error.ArgumentNull(nameof(context)); + } + + // Relevance of this call before FlattenReferencedProperties call? + context.EnsureFlattenedProperties(context.CurrentParameter, source); + + source = binder.FlattenReferencedProperties( + transformationNode, + source, + context, + out ParameterExpression contextParameter, + out IDictionary flattenedPropertiesMap); + + context.FlattenedPropertiesMap = flattenedPropertiesMap; + if (contextParameter != null) + { + context.SetParameter(DollarThis, contextParameter); + } + + // We are aiming for: query.GroupBy($it => new DynamicType1 {...}).Select($it => new DynamicType2 {...}) + // We are doing Grouping even if only aggregate was specified to have a IQueryable after aggregation + + LambdaExpression groupByLambda = binder.BindGroupBy(transformationNode, context) as LambdaExpression; + Contract.Assert(groupByLambda != null, "groupLambda != null"); + Type groupByType = groupByLambda.Body.Type; + + // Invoke GroupBy method + IQueryable grouping = ExpressionHelpers.GroupBy(source, groupByLambda, source.ElementType, groupByType); + + LambdaExpression selectLambda = binder.BindSelect(transformationNode, context) as LambdaExpression; + Contract.Assert(selectLambda != null, "selectLambda != null"); + resultClrType = selectLambda.Body.Type; + + // Invoke Select method + Type groupingType = typeof(IGrouping<,>).MakeGenericType(groupByType, context.TransformationElementType); + + return ExpressionHelpers.Select(grouping, selectLambda, groupingType); + } } } diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/ComputeBinder.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/ComputeBinder.cs index cab2acd09..2237e7a95 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Expressions/ComputeBinder.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/ComputeBinder.cs @@ -72,7 +72,7 @@ public IQueryable Bind(IQueryable query) wrapperTypeMemberAssignments.Add(Expression.Bind(wrapperProperty, wrapperPropertyValueExpression)); // Set new compute properties - wrapperProperty = ResultClrType.GetProperty("Container"); + wrapperProperty = ResultClrType.GetProperty(QueryConstants.GroupByWrapperContainerProperty); wrapperTypeMemberAssignments.Add(Expression.Bind(wrapperProperty, AggregationPropertyContainer.CreateNextNamedPropertyContainer(properties))); var initilizedMember = diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/ExpressionBinderBase.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/ExpressionBinderBase.cs index bcfca94b5..b7759c2fd 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Expressions/ExpressionBinderBase.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/ExpressionBinderBase.cs @@ -812,8 +812,14 @@ private static MethodCallExpression SkipFilters(MethodCallExpression expression) private static void CollectContainerAssignments(Expression source, MethodCallExpression expression, Dictionary result) { - CollectAssigments(result, Expression.Property(source, "GroupByContainer"), ExtractContainerExpression(expression.Arguments.FirstOrDefault() as MethodCallExpression, "GroupByContainer")); - CollectAssigments(result, Expression.Property(source, "Container"), ExtractContainerExpression(expression, "Container")); + CollectAssigments( + result, + Expression.Property(source, QueryConstants.GroupByWrapperGroupByContainerProperty), + ExtractContainerExpression(expression.Arguments.FirstOrDefault() as MethodCallExpression, QueryConstants.GroupByWrapperGroupByContainerProperty)); + CollectAssigments( + result, + Expression.Property(source, QueryConstants.GroupByWrapperContainerProperty), + ExtractContainerExpression(expression, QueryConstants.GroupByWrapperContainerProperty)); } private static void CollectAssigments(IDictionary flattenPropertyContainer, Expression source, MemberInitExpression expression, string prefix = null) @@ -830,15 +836,15 @@ private static void CollectAssigments(IDictionary flattenPro foreach (var expr in expression.Bindings.OfType()) { var initExpr = expr.Expression as MemberInitExpression; - if (initExpr != null && expr.Member.Name == "Next") + if (initExpr != null && expr.Member.Name == QueryConstants.AggregationPropertyContainerNextProperty) { nextExpression = initExpr; } - else if (expr.Member.Name == "Name") + else if (expr.Member.Name == QueryConstants.AggregationPropertyContainerNameProperty) { nameToAdd = (expr.Expression as ConstantExpression).Value as string; } - else if (expr.Member.Name == "Value" || expr.Member.Name == "NestedValue") + else if (expr.Member.Name == QueryConstants.AggregationPropertyContainerValueProperty || expr.Member.Name == QueryConstants.AggregationPropertyContainerNestedValueProperty) { resultType = expr.Expression.Type; if (resultType == typeof(object) && expr.Expression.NodeType == ExpressionType.Convert) @@ -860,22 +866,22 @@ private static void CollectAssigments(IDictionary flattenPro if (typeof(GroupByWrapper).IsAssignableFrom(resultType)) { - flattenPropertyContainer.Add(nameToAdd, Expression.Property(source, "NestedValue")); + flattenPropertyContainer.Add(nameToAdd, Expression.Property(source, QueryConstants.AggregationPropertyContainerNestedValueProperty)); } else { - flattenPropertyContainer.Add(nameToAdd, Expression.Convert(Expression.Property(source, "Value"), resultType)); + flattenPropertyContainer.Add(nameToAdd, Expression.Convert(Expression.Property(source, QueryConstants.AggregationPropertyContainerValueProperty), resultType)); } if (nextExpression != null) { - CollectAssigments(flattenPropertyContainer, Expression.Property(source, "Next"), nextExpression, prefix); + CollectAssigments(flattenPropertyContainer, Expression.Property(source, QueryConstants.AggregationPropertyContainerNextProperty), nextExpression, prefix); } if (nestedExpression != null) { var nestedAccessor = ((nestedExpression as MemberInitExpression).Bindings.First() as MemberAssignment).Expression as MemberInitExpression; - var newSource = Expression.Property(Expression.Property(source, "NestedValue"), "GroupByContainer"); + var newSource = Expression.Property(Expression.Property(source, QueryConstants.AggregationPropertyContainerNestedValueProperty), QueryConstants.GroupByWrapperGroupByContainerProperty); CollectAssigments(flattenPropertyContainer, newSource, nestedAccessor, nameToAdd); } } diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/ExpressionBinderHelper.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/ExpressionBinderHelper.cs index 444be0ed2..a24c0f093 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Expressions/ExpressionBinderHelper.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/ExpressionBinderHelper.cs @@ -359,7 +359,7 @@ private static IEnumerable ExtractValueFromNullableArguments(IEnumer public static Expression ExtractValueFromNullableExpression(Expression source) { - return Nullable.GetUnderlyingType(source.Type) != null ? Expression.Property(source, "Value") : source; + return Nullable.GetUnderlyingType(source.Type) != null ? Expression.Property(source, QueryConstants.AggregationPropertyContainerValueProperty) : source; } public static Expression BindHas(Expression left, Expression flag, ODataQuerySettings querySettings) @@ -733,13 +733,13 @@ public static Expression BindCastToStringType(Expression source) // Entity Framework doesn't have ToString method for enum types. // Convert enum types to their underlying numeric types. sourceValue = Expression.Convert( - Expression.Property(source, "Value"), + Expression.Property(source, QueryConstants.AggregationPropertyContainerValueProperty), Enum.GetUnderlyingType(TypeHelper.GetUnderlyingTypeOrSelf(source.Type))); } else { // Entity Framework has ToString method for numeric types. - sourceValue = Expression.Property(source, "Value"); + sourceValue = Expression.Property(source, QueryConstants.AggregationPropertyContainerValueProperty); } // Entity Framework doesn't have ToString method for nullable numeric types. diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/IAggregationBinder.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/IAggregationBinder.cs new file mode 100644 index 000000000..0eaaa6288 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/IAggregationBinder.cs @@ -0,0 +1,102 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using Microsoft.OData.UriParser; +using Microsoft.OData.UriParser.Aggregation; + +namespace Microsoft.AspNetCore.OData.Query.Expressions +{ + /// + /// Exposes the ability to translate an OData $apply parse tree represented by a to + /// an . + /// + public interface IAggregationBinder + { + /// + /// Flattens properties referenced in aggregate clause to avoid generation of nested queries by Entity Framework. + /// For query like groupby((A),aggregate(B/C with max as Alias1,B/D with max as Alias2)), generates an expression like: + /// .Select($it => new FlatteningWrapper() { + /// Source = $it, + /// Container = new { + /// Value = $it.B.C + /// Next = new { + /// Value = $it.B.D + /// } + /// } + /// }) + /// Also populate expressions to access B/C and B/D in aggregate stage to look like: + /// B/C : $it.Container.Value + /// B/D : $it.Container.Next.Value + /// + /// The . + /// The original . + /// The query binder context. + /// The parameter at the root of the current query binder context. The parameter can be reinitialized in the course of flattening. + /// Mapping of flattened single value nodes and their values. For example { {$it.B.C, $it.Value}, {$it.B.D, $it.Next.Value} } + /// Query with Select expression with flattened properties. + IQueryable FlattenReferencedProperties( + TransformationNode transformationNode, + IQueryable query, + QueryBinderContext context, + out ParameterExpression contextParameter, + out IDictionary flattenedPropertiesMap); + + /// + /// Translates an OData $apply parse tree represented by a to + /// an . + /// + /// The OData $apply parse tree represented by . + /// An instance of the . + /// + /// Generates an expression structured like: + /// $it => new DynamicTypeWrapper() + /// { + /// GroupByContainer => new AggregationPropertyContainer() { + /// Name = "Prop1", + /// Value = $it.Prop1, + /// Next = new AggregationPropertyContainer() { + /// Name = "Prop2", + /// Value = $it.Prop2, + /// Next = new LastInChain() { + /// Name = "Prop3", + /// Value = $it.Prop3 + /// } + /// } + /// } + /// }) + /// + /// The generated LINQ expression representing the OData $apply parse tree. + Expression BindGroupBy(TransformationNode transformationNode, QueryBinderContext context); + + /// + /// Translates an OData $apply parse tree represented by a to + /// an . + /// + /// The OData $apply parse tree represented by . + /// An instance of the . + /// + /// Generates an expression structured like: + /// $it => New DynamicType2() + /// { + /// GroupByContainer = $it.Key.GroupByContainer /// If groupby clause present + /// Container => new AggregationPropertyContainer() { + /// Name = "Alias1", + /// Value = $it.AsQueryable().Sum(i => i.AggregatableProperty), + /// Next = new LastInChain() { + /// Name = "Alias2", + /// Value = $it.AsQueryable().Sum(i => i.AggregatableProperty) + /// } + /// } + /// } + /// + /// The generated LINQ expression representing the OData $apply parse tree. + Expression BindSelect(TransformationNode transformationNode, QueryBinderContext context); + } +} diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinder.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinder.cs index fcec54d60..25601b879 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinder.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinder.cs @@ -956,6 +956,144 @@ public virtual Expression BindCollectionConstantNode(CollectionConstantNode node return Expression.Constant(castedList, listType); } + + /// + /// Creates an from the . + /// + /// The to be bound. + /// An instance of the . + /// The for the base element. + /// The created . + public virtual Expression BindAccessExpression(QueryNode node, QueryBinderContext context, Expression baseElement = null) + { + if (node == null) + { + throw Error.ArgumentNull(nameof(node)); + } + + if (context == null) + { + throw Error.ArgumentNull(nameof(context)); + } + + switch (node.Kind) + { + case QueryNodeKind.ResourceRangeVariableReference: + return context.CurrentParameter.Type.IsFlatteningWrapper() + ? (Expression)Expression.Property(context.CurrentParameter, QueryConstants.FlatteningWrapperSourceProperty) + : context.CurrentParameter; + + case QueryNodeKind.SingleValuePropertyAccess: + SingleValuePropertyAccessNode singleValueNode = node as SingleValuePropertyAccessNode; + return CreatePropertyAccessExpression( + BindAccessExpression(singleValueNode.Source, context, baseElement), + context, + singleValueNode.Property, + GetFullPropertyPath(singleValueNode)); + + case QueryNodeKind.AggregatedCollectionPropertyNode: + AggregatedCollectionPropertyNode aggregatedCollectionNode = node as AggregatedCollectionPropertyNode; + return CreatePropertyAccessExpression( + BindAccessExpression(aggregatedCollectionNode.Source, context, baseElement), + context, + aggregatedCollectionNode.Property); + + case QueryNodeKind.SingleComplexNode: + SingleComplexNode singleComplexNode = node as SingleComplexNode; + return CreatePropertyAccessExpression( + BindAccessExpression(singleComplexNode.Source, context, baseElement), + context, + singleComplexNode.Property, + GetFullPropertyPath(singleComplexNode)); + + case QueryNodeKind.SingleValueOpenPropertyAccess: + SingleValueOpenPropertyAccessNode openNode = node as SingleValueOpenPropertyAccessNode; + return GetFlattenedPropertyExpression(openNode.Name, context) ?? CreateOpenPropertyAccessExpression(openNode, context); + + case QueryNodeKind.None: + case QueryNodeKind.SingleNavigationNode: + SingleNavigationNode navigationNode = node as SingleNavigationNode; + return CreatePropertyAccessExpression( + BindAccessExpression(navigationNode.Source, context), + context, + navigationNode.NavigationProperty, + GetFullPropertyPath(navigationNode)); + + case QueryNodeKind.BinaryOperator: + BinaryOperatorNode binaryNode = node as BinaryOperatorNode; + Expression leftExpression = BindAccessExpression(binaryNode.Left, context, baseElement); + Expression rightExpression = BindAccessExpression(binaryNode.Right, context, baseElement); + return ExpressionBinderHelper.CreateBinaryExpression( + binaryNode.OperatorKind, + leftExpression, + rightExpression, + liftToNull: true, + context.QuerySettings); + + case QueryNodeKind.Convert: + ConvertNode convertNode = node as ConvertNode; + return CreateConvertExpression(convertNode, BindAccessExpression(convertNode.Source, context, baseElement), context); + + case QueryNodeKind.CollectionNavigationNode: + return baseElement ?? context.CurrentParameter; + + case QueryNodeKind.SingleValueFunctionCall: + return BindSingleValueFunctionCallNode(node as SingleValueFunctionCallNode, context); + + case QueryNodeKind.Constant: + return BindConstantNode(node as ConstantNode, context); + + default: + throw Error.NotSupported( + SRResources.QueryNodeBindingNotSupported, + node.Kind, + typeof(AggregationBinder).Name); + } + } + + /// + /// Creates a LINQ that represents the semantics of the . + /// + /// They query node to create an expression from. + /// The query binder context. + /// The LINQ created. + public virtual Expression CreateOpenPropertyAccessExpression(SingleValueOpenPropertyAccessNode openNode, QueryBinderContext context) + { + Expression source = BindAccessExpression(openNode.Source, context); + + // First check that property exists in source + // It's the case when we are apply transformation based on earlier transformation + if (source.Type.GetProperty(openNode.Name) != null) + { + return Expression.Property(source, openNode.Name); + } + + // Property doesn't exists go for dynamic properties dictionary + PropertyInfo prop = GetDynamicPropertyContainer(openNode, context); + MemberExpression propertyAccessExpression = Expression.Property(source, prop.Name); + IndexExpression readDictionaryIndexerExpression = Expression.Property(propertyAccessExpression, + DictionaryStringObjectIndexerName, Expression.Constant(openNode.Name)); + MethodCallExpression containsKeyExpression = Expression.Call(propertyAccessExpression, + propertyAccessExpression.Type.GetMethod("ContainsKey"), Expression.Constant(openNode.Name)); + ConstantExpression nullExpression = Expression.Constant(null); + + if (context.QuerySettings.HandleNullPropagation == HandleNullPropagationOption.True) + { + BinaryExpression dynamicDictIsNotNull = Expression.NotEqual(propertyAccessExpression, Expression.Constant(null)); + BinaryExpression dynamicDictIsNotNullAndContainsKey = Expression.AndAlso(dynamicDictIsNotNull, containsKeyExpression); + return Expression.Condition( + dynamicDictIsNotNullAndContainsKey, + readDictionaryIndexerExpression, + nullExpression); + } + else + { + return Expression.Condition( + containsKeyExpression, + readDictionaryIndexerExpression, + nullExpression); + } + } #endregion #region Private helper methods @@ -978,10 +1116,10 @@ private static bool IsFlatteningSource(Expression source, QueryBinderContext con { var member = source as MemberExpression; return member != null - && context.CurrentParameter.Type.IsGenericType - && context.CurrentParameter.Type.GetGenericTypeDefinition() == typeof(FlatteningWrapper<>) + && context.CurrentParameter.Type.IsFlatteningWrapper() && member.Expression == context.CurrentParameter; } + #endregion #region Protected methods @@ -1046,7 +1184,7 @@ protected static PropertyInfo GetDynamicPropertyContainer(CollectionOpenProperty /// Returns null if no aggregations were used so far protected Expression GetFlattenedPropertyExpression(string propertyPath, QueryBinderContext context) { - if (context == null || context.FlattenedProperties == null || !context.FlattenedProperties.Any()) + if (!(context?.FlattenedProperties?.Any() == true)) { return null; } @@ -1060,6 +1198,27 @@ protected Expression GetFlattenedPropertyExpression(string propertyPath, QueryBi // TODO: sam xu, return null? // throw new ODataException(Error.Format(SRResources.PropertyOrPathWasRemovedFromContext, propertyPath)); } + + /// + /// Wrap a value type with Expression.Convert. + /// + /// The to be wrapped. + /// The wrapped + protected static Expression WrapConvert(Expression expression) + { + if (expression == null) + { + throw Error.ArgumentNull(nameof(expression)); + } + + // Expression that we are generating looks like Value = $it.PropertyName where Value is defined as object and PropertyName can be value + // Proper .NET expression must look like as Value = (object) $it.PropertyName for proper boxing or AccessViolationException will be thrown + // Cast to object isn't translatable by EF6 as a result skipping (object) in that case + // Update: We have removed support for EF6 + return (!expression.Type.IsValueType) + ? expression + : Expression.Convert(expression, typeof(object)); + } #endregion internal string GetFullPropertyPath(SingleValueNode node) @@ -1069,25 +1228,25 @@ internal string GetFullPropertyPath(SingleValueNode node) switch (node.Kind) { case QueryNodeKind.SingleComplexNode: - var complexNode = (SingleComplexNode)node; + SingleComplexNode complexNode = (SingleComplexNode)node; path = complexNode.Property.Name; parent = complexNode.Source; break; case QueryNodeKind.SingleValuePropertyAccess: - var propertyNode = ((SingleValuePropertyAccessNode)node); + SingleValuePropertyAccessNode propertyNode = (SingleValuePropertyAccessNode)node; path = propertyNode.Property.Name; parent = propertyNode.Source; break; case QueryNodeKind.SingleNavigationNode: - var navNode = ((SingleNavigationNode)node); + SingleNavigationNode navNode = (SingleNavigationNode)node; path = navNode.NavigationProperty.Name; parent = navNode.Source; break; - } + } if (parent != null) { - var parentPath = GetFullPropertyPath(parent); + string parentPath = GetFullPropertyPath(parent); if (parentPath != null) { path = parentPath + "\\" + path; @@ -1121,13 +1280,12 @@ internal Expression CreatePropertyAccessExpression(Expression source, QueryBinde } else { - // return GetFlattenedPropertyExpression(propertyPath, context) - // ?? ConvertNonStandardPrimitives(GetPropertyExpression(source, (!propertyPath.Contains("\\", StringComparison.Ordinal) ? "Instance\\" : String.Empty) + propertyName), context); - + // TODO: Verify behaviour for custom IAggregationBinder implementation bool isAggregated = context.ElementClrType == typeof(AggregationWrapper); + propertyPath = context.ElementClrType.IsComputeWrapper(out _) && !propertyPath.Contains('\\') ? string.Concat("Instance\\", propertyName) : propertyName; return GetFlattenedPropertyExpression(propertyPath, context) - ?? ConvertNonStandardPrimitives(GetPropertyExpression(source, propertyName, isAggregated), context); + ?? ConvertNonStandardPrimitives(GetPropertyExpression(source, propertyPath), context); } } @@ -1151,6 +1309,7 @@ internal static Expression GetPropertyExpression(Expression source, string prope propertyValue = Expression.Property(propertyValue, propertyName); } } + return propertyValue; } @@ -1239,7 +1398,7 @@ internal static Expression ConvertNonStandardPrimitives(Expression source, Query return source; } - internal Expression CreateConvertExpression(ConvertNode convertNode, Expression source, QueryBinderContext context) + internal static Expression CreateConvertExpression(ConvertNode convertNode, Expression source, QueryBinderContext context) { Type conversionType = context.Model.GetClrType(convertNode.TypeReference, context.AssembliesResolver); @@ -1393,6 +1552,153 @@ internal Expression BindCastToEnumType(Type sourceType, Type targetClrType, Quer } } + internal static IDictionary GetFlattenedProperties(ParameterExpression source, QueryBinderContext context, IQueryable query) + { + if (!query.ElementType.IsGroupByWrapper()) + { + return null; + } + + MethodCallExpression expression = query.Expression as MethodCallExpression; + if (expression == null) + { + return null; + } + + // After $apply we could have other clauses, like $filter, $orderby etc. + // Skip all filter expressions + expression = SkipFilters(expression); + + if (expression == null) + { + return null; + } + + Dictionary flattenedPropertiesMap = new Dictionary(); + CollectContainerAssignments(source, expression, flattenedPropertiesMap); + if (query?.ElementType?.IsComputeWrapper(out _) == true) + { + MemberExpression instanceProperty = Expression.Property(source, "Instance"); + if (typeof(DynamicTypeWrapper).IsAssignableFrom(instanceProperty.Type)) + { + MethodCallExpression computeExpression = expression.Arguments.FirstOrDefault() as MethodCallExpression; + computeExpression = SkipFilters(computeExpression); + if (computeExpression != null) + { + CollectContainerAssignments(instanceProperty, computeExpression, flattenedPropertiesMap); + } + } + } + + return flattenedPropertiesMap; + } + + private static MethodCallExpression SkipFilters(MethodCallExpression expression) + { + while (expression.Method.Name == "Where") + { + expression = expression.Arguments.FirstOrDefault() as MethodCallExpression; + } + + return expression; + } + + private static void CollectContainerAssignments(Expression source, MethodCallExpression expression, Dictionary result) + { + CollectAssignments( + result, + Expression.Property(source, QueryConstants.GroupByWrapperGroupByContainerProperty), + ExtractContainerExpression(expression.Arguments.FirstOrDefault() as MethodCallExpression, QueryConstants.GroupByWrapperGroupByContainerProperty)); + CollectAssignments( + result, + Expression.Property(source, QueryConstants.GroupByWrapperContainerProperty), + ExtractContainerExpression(expression, QueryConstants.GroupByWrapperContainerProperty)); + } + + private static void CollectAssignments(IDictionary flattenPropertyContainer, Expression source, MemberInitExpression expression, string prefix = null) + { + if (expression == null) + { + return; + } + + string nameToAdd = null; + Type resultType = null; + MemberInitExpression nextExpression = null; + Expression nestedExpression = null; + foreach (var expr in expression.Bindings.OfType()) + { + var initExpr = expr.Expression as MemberInitExpression; + if (initExpr != null && expr.Member.Name == QueryConstants.AggregationPropertyContainerNextProperty) + { + nextExpression = initExpr; + } + else if (expr.Member.Name == QueryConstants.AggregationPropertyContainerNameProperty) + { + nameToAdd = (expr.Expression as ConstantExpression).Value as string; + } + else if (expr.Member.Name == QueryConstants.AggregationPropertyContainerValueProperty || expr.Member.Name == QueryConstants.AggregationPropertyContainerNestedValueProperty) + { + resultType = expr.Expression.Type; + if (resultType == typeof(object) && expr.Expression.NodeType == ExpressionType.Convert) + { + resultType = ((UnaryExpression)expr.Expression).Operand.Type; + } + + if (resultType.IsGroupByWrapper()) + { + nestedExpression = expr.Expression; + } + } + } + + if (prefix != null) + { + nameToAdd = prefix + "\\" + nameToAdd; + } + + if (resultType.IsGroupByWrapper()) + { + flattenPropertyContainer.Add(nameToAdd, Expression.Property(source, QueryConstants.AggregationPropertyContainerNestedValueProperty)); + } + else + { + flattenPropertyContainer.Add(nameToAdd, Expression.Convert(Expression.Property(source, QueryConstants.AggregationPropertyContainerValueProperty), resultType)); + } + + if (nextExpression != null) + { + CollectAssignments(flattenPropertyContainer, Expression.Property(source, QueryConstants.AggregationPropertyContainerNextProperty), nextExpression, prefix); + } + + if (nestedExpression != null) + { + var nestedAccessor = ((nestedExpression as MemberInitExpression).Bindings.First() as MemberAssignment).Expression as MemberInitExpression; + var newSource = Expression.Property(Expression.Property(source, QueryConstants.AggregationPropertyContainerNestedValueProperty), QueryConstants.GroupByWrapperGroupByContainerProperty); + CollectAssignments(flattenPropertyContainer, newSource, nestedAccessor, nameToAdd); + } + } + + private static MemberInitExpression ExtractContainerExpression(MethodCallExpression expression, string containerName) + { + if (expression == null || expression.Arguments.Count < 2) + { + return null; + } + + var memberInitExpression = ((expression.Arguments[1] as UnaryExpression).Operand as LambdaExpression).Body as MemberInitExpression; + if (memberInitExpression != null) + { + var containerAssignment = memberInitExpression.Bindings.FirstOrDefault(m => m.Member.Name == containerName) as MemberAssignment; + if (containerAssignment != null) + { + return containerAssignment.Expression as MemberInitExpression; + } + } + + return null; + } + private static Expression Any(Expression source, Expression filter) { Contract.Assert(source != null); diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinderContext.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinderContext.cs index 2c93bc859..593f8149a 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinderContext.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/QueryBinderContext.cs @@ -62,8 +62,8 @@ public QueryBinderContext(IEdmModel model, ODataQuerySettings querySettings, Typ ElementType = Model.GetEdmTypeReference(ElementClrType)?.Definition; - // Check if element type is null and not of AggregationWrapper type and not of NoGroupByAggregationWrapper type. - if (ElementType == null && ElementClrType != typeof(AggregationWrapper) && ElementClrType != typeof(NoGroupByAggregationWrapper)) + // Check if element type is null and not of DynamicTypeWrapper or its derived types. + if (ElementType == null && !typeof(DynamicTypeWrapper).IsAssignableFrom(ElementClrType)) { throw new ODataException(Error.Format(SRResources.ClrTypeNotInModel, ElementClrType.FullName)); } @@ -72,8 +72,8 @@ public QueryBinderContext(IEdmModel model, ODataQuerySettings querySettings, Typ // Here: // $this -> instance in EmailAddresses // $it -> instance in Customers - // When we process $select=..., we create QueryBindContext, the input clrType is "Customer". - // When we process nested $filter, we create another QueryBindContext, the input clrType is "string". + // When we process $select=..., we create QueryBinderContext, the input clrType is "Customer". + // When we process nested $filter, we create another QueryBinderContext, the input clrType is "string". ParameterExpression thisParameters = Expression.Parameter(clrType, DollarIt); _lambdaParameters = new Dictionary(); @@ -209,6 +209,34 @@ public void RemoveParameter(string name) } } + /// + /// Sets the specified parameter + /// + /// The parameter name. + /// The parameter expression. + internal void SetParameter(string name, ParameterExpression parameterExpr) + { + if (name != null) + { + _lambdaParameters[name] = parameterExpr; + } + } + + #region Aggregation + + // TODO: Can we update ElementClrType from SetParameter and reference ElementClrType where TransformationElementType is used? + /// + /// The type of the element in a transformation query. + /// + public Type TransformationElementType { get { return this.CurrentParameter.Type; } } + + /// + /// A mapping of flattened single value nodes and their values. For example { {$it.B.C, $it.Value}, {$it.B.D, $it.Next.Value} } + /// + public IDictionary FlattenedPropertiesMap { get; internal set; } + + #endregion Aggregation + internal (string, ParameterExpression) HandleLambdaParameters(IEnumerable rangeVariables) { ParameterExpression lambdaIt = null; @@ -278,12 +306,10 @@ internal void AddComputedProperties(IEnumerable computedPrope internal void EnsureFlattenedProperties(ParameterExpression source, IQueryable query) { - TransformationBinderBase binder = new TransformationBinderBase(this.QuerySettings, this.AssembliesResolver, this.ElementClrType, this.Model) + if (query != null) { - BaseQuery = query - }; - - this.FlattenedProperties = binder.GetFlattenedProperties(source); + this.FlattenedProperties = QueryBinder.GetFlattenedProperties(source, this, query); + } } } } diff --git a/src/Microsoft.AspNetCore.OData/Query/Expressions/TransformationBinderBase.cs b/src/Microsoft.AspNetCore.OData/Query/Expressions/TransformationBinderBase.cs index 683b7b5f4..ead9127ce 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Expressions/TransformationBinderBase.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Expressions/TransformationBinderBase.cs @@ -96,7 +96,7 @@ protected Expression BindAccessor(QueryNode node, Expression baseElement = null) { case QueryNodeKind.ResourceRangeVariableReference: return this.LambdaParameter.Type.IsGenericType && this.LambdaParameter.Type.GetGenericTypeDefinition() == typeof(FlatteningWrapper<>) - ? (Expression)Expression.Property(this.LambdaParameter, "Source") + ? (Expression)Expression.Property(this.LambdaParameter, QueryConstants.FlatteningWrapperSourceProperty) : this.LambdaParameter; case QueryNodeKind.SingleValuePropertyAccess: var propAccessNode = node as SingleValuePropertyAccessNode; diff --git a/src/Microsoft.AspNetCore.OData/Query/ODataQueryContextExtensions.cs b/src/Microsoft.AspNetCore.OData/Query/ODataQueryContextExtensions.cs index b6cceb486..2c3ba1878 100644 --- a/src/Microsoft.AspNetCore.OData/Query/ODataQueryContextExtensions.cs +++ b/src/Microsoft.AspNetCore.OData/Query/ODataQueryContextExtensions.cs @@ -121,6 +121,23 @@ public static IOrderByBinder GetOrderByBinder(this ODataQueryContext context) return binder ?? new OrderByBinder(); } + /// + /// Gets the . + /// + /// The query context. + /// The built . + public static IAggregationBinder GetAggregationBinder(this ODataQueryContext context) + { + if (context == null) + { + throw Error.ArgumentNull(nameof(context)); + } + + IAggregationBinder binder = context.RequestContainer?.GetService(); + + return binder ?? new AggregationBinder(); + } + /// /// Gets the . /// diff --git a/src/Microsoft.AspNetCore.OData/Query/Query/ApplyQueryOptions.cs b/src/Microsoft.AspNetCore.OData/Query/Query/ApplyQueryOptions.cs index 69aef541c..cf9ca561c 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Query/ApplyQueryOptions.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Query/ApplyQueryOptions.cs @@ -146,23 +146,24 @@ public IQueryable ApplyTo(IQueryable query, ODataQuerySettings querySettings) } } - foreach (var transformation in applyClause.Transformations) + foreach (TransformationNode transformation in applyClause.Transformations) { if (transformation.Kind == TransformationNodeKind.Aggregate || transformation.Kind == TransformationNodeKind.GroupBy) { - var binder = new AggregationBinder(querySettings, assembliesResolver, ResultClrType, Context.Model, transformation); - query = binder.Bind(query); - this.ResultClrType = binder.ResultClrType; + QueryBinderContext queryBinderContext = new QueryBinderContext(Context.Model, querySettings, ResultClrType); + IAggregationBinder binder = Context.GetAggregationBinder(); + query = binder.ApplyBind(query, transformation, queryBinderContext, out Type resultClrType); + this.ResultClrType = resultClrType; } else if (transformation.Kind == TransformationNodeKind.Compute) { - var binder = new ComputeBinder(querySettings, assembliesResolver, ResultClrType, Context.Model, (ComputeTransformationNode)transformation); + ComputeBinder binder = new ComputeBinder(querySettings, assembliesResolver, ResultClrType, Context.Model, (ComputeTransformationNode)transformation); query = binder.Bind(query); this.ResultClrType = binder.ResultClrType; } else if (transformation.Kind == TransformationNodeKind.Filter) { - var filterTransformation = transformation as FilterTransformationNode; + FilterTransformationNode filterTransformation = transformation as FilterTransformationNode; IFilterBinder binder = Context.GetFilterBinder(); QueryBinderContext binderContext = new QueryBinderContext(Context.Model, querySettings, ResultClrType); diff --git a/src/Microsoft.AspNetCore.OData/Query/QueryConstants.cs b/src/Microsoft.AspNetCore.OData/Query/QueryConstants.cs new file mode 100644 index 000000000..ead217f22 --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/QueryConstants.cs @@ -0,0 +1,39 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.Query.Container; +using Microsoft.AspNetCore.OData.Query.Wrapper; + +namespace Microsoft.AspNetCore.OData.Query +{ + /// + /// Constant values used in aggregation operation. + /// + internal static class QueryConstants + { + /// Name for property. + public const string GroupByWrapperContainerProperty = "Container"; + + /// Name for property. + public const string GroupByWrapperGroupByContainerProperty = "GroupByContainer"; + + /// Name for property. + public const string AggregationPropertyContainerNameProperty = "Name"; + + /// Name for property. + public const string AggregationPropertyContainerValueProperty = "Value"; + + /// Name for property. + public const string AggregationPropertyContainerNestedValueProperty = "NestedValue"; + + /// Name for property. + public const string AggregationPropertyContainerNextProperty = "Next"; + + /// Name for property. + public const string FlatteningWrapperSourceProperty = "Source"; + } +} diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/DynamicTypeWrapper.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/DynamicTypeWrapper.cs index 254182495..ecefa9517 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Wrapper/DynamicTypeWrapper.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/DynamicTypeWrapper.cs @@ -21,10 +21,10 @@ public abstract class DynamicTypeWrapper public abstract Dictionary Values { get; } /// - /// Attempts to get the value of the Property called from the underlying Entity. + /// Attempts to get the value of the property called from the underlying entity. /// - /// The name of the Property - /// The new value of the Property + /// The name of the property + /// The new value of the property /// True if successful [SuppressMessage("Microsoft.Design", "CA1007:UseGenericsWhereAppropriate", Justification = "Generics not appropriate here")] public bool TryGetPropertyValue(string propertyName, out object value) diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/FlatteningWrapperOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/FlatteningWrapperOfT.cs index 4cf37eb9e..83dddbe80 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Wrapper/FlatteningWrapperOfT.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/FlatteningWrapperOfT.cs @@ -6,15 +6,14 @@ //------------------------------------------------------------------------------ using System; -using System.Diagnostics.Contracts; using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.AspNetCore.OData.Query.Container; namespace Microsoft.AspNetCore.OData.Query.Wrapper { - internal class FlatteningWrapper : GroupByWrapper + internal class FlatteningWrapper : GroupByWrapper, IGroupByWrapper, IFlatteningWrapper { - // TODO: how to use 'Source'? public T Source { get; set; } } diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/GroupByWrapper.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/GroupByWrapper.cs index 5bc0ff162..4b6a1981d 100644 --- a/src/Microsoft.AspNetCore.OData/Query/Wrapper/GroupByWrapper.cs +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/GroupByWrapper.cs @@ -15,18 +15,18 @@ namespace Microsoft.AspNetCore.OData.Query.Wrapper { - internal class GroupByWrapper : DynamicTypeWrapper + internal class GroupByWrapper : DynamicTypeWrapper, IGroupByWrapper { private Dictionary _values; protected static readonly IPropertyMapper DefaultPropertyMapper = new IdentityPropertyMapper(); /// - /// Gets or sets the property container that contains the properties being expanded. + /// Gets or sets the property container that contains the grouping properties. /// public virtual AggregationPropertyContainer GroupByContainer { get; set; } /// - /// Gets or sets the property container that contains the properties being expanded. + /// Gets or sets the property container that contains the aggregation properties. /// public virtual AggregationPropertyContainer Container { get; set; } @@ -80,7 +80,7 @@ private void EnsureValues() if (this.Container != null) { - _values.MergeWithReplace(this.Container.ToDictionary(DefaultPropertyMapper)); + this._values.MergeWithReplace(this.Container.ToDictionary(DefaultPropertyMapper)); } } } diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/IFlatteningWrapperOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/IFlatteningWrapperOfT.cs new file mode 100644 index 000000000..2ba77f9fd --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/IFlatteningWrapperOfT.cs @@ -0,0 +1,19 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.AspNetCore.OData.Query.Wrapper +{ + /// + /// Represents the result of flattening properties referenced in aggregate clause of a $apply query. + /// + /// Flattening is necessary to avoid generation of nested queries by Entity Framework. + public interface IFlatteningWrapper + { + /// Gets or sets the source object that contains the properties to be flattened. + T Source { get; set; } + } +} diff --git a/src/Microsoft.AspNetCore.OData/Query/Wrapper/IGroupByWrapperOfT.cs b/src/Microsoft.AspNetCore.OData/Query/Wrapper/IGroupByWrapperOfT.cs new file mode 100644 index 000000000..c53f803fb --- /dev/null +++ b/src/Microsoft.AspNetCore.OData/Query/Wrapper/IGroupByWrapperOfT.cs @@ -0,0 +1,21 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.AspNetCore.OData.Query.Wrapper +{ + /// + /// Represents the result of a $apply query operation. + /// + public interface IGroupByWrapper + { + /// Gets or sets the property container that contains the grouping properties. + T GroupByContainer { get; set; } + + /// Gets or sets the property container that contains the aggregation properties. + T Container { get; set; } + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyController.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyController.cs new file mode 100644 index 000000000..cc7cbc5f3 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyController.cs @@ -0,0 +1,98 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Collections.Generic; +using System.Linq; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Routing.Controllers; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply +{ + public class SalesController : ODataController + { + private readonly DollarApplyDbContext db; + + public SalesController(DollarApplyDbContext db) + { + this.db = db; + SeedDatabase(this.db); + } + + [EnableQuery] + public ActionResult> Get() + { + return db.Sales; + } + + private void SeedDatabase(DollarApplyDbContext db) + { + db.Database.EnsureCreated(); + + if (!db.Sales.Any()) + { + var pg1Category = new Category { Id = "PG1", Name = "Food" }; + var pg2Category = new Category { Id = "PG2", Name = "Non-Food" }; + + db.Categories.AddRange(new[] + { + pg1Category, + pg2Category + }); + + var p1Product = new Product { Id = "P1", Name = "Sugar", Category = pg1Category, TaxRate = 0.06m }; + var p2Product = new Product { Id = "P2", Name = "Coffee", Category = pg1Category, TaxRate = 0.06m }; + var p3Product = new Product { Id = "P3", Name = "Paper", Category = pg2Category, TaxRate = 0.14m }; + var p4Product = new Product { Id = "P4", Name = "Pencil", Category = pg2Category, TaxRate = 0.14m }; + + db.Products.AddRange(new[] + { + p1Product, + p2Product, + p3Product, + p4Product + }); + + var c1Customer = new Customer { Id = "C1", Name = "Joe" }; + var c2Customer = new Customer { Id = "C2", Name = "Sue" }; + var c3Customer = new Customer { Id = "C3", Name = "Sue" }; + var c4Customer = new Customer { Id = "C4", Name = "Luc" }; + + db.Customers.AddRange(new[] + { + c1Customer, + c2Customer, + c3Customer, + c4Customer + }); + + db.Sales.AddRange(new[] + { + new Sale { Id = 1, Year = 2022, Quarter = "2022-1", Customer = c1Customer, Product = p3Product, Amount = 1 }, + new Sale { Id = 2, Year = 2022, Quarter = "2022-2", Customer = c1Customer, Product = p1Product, Amount = 2 }, + new Sale { Id = 3, Year = 2022, Quarter = "2022-3", Customer = c1Customer, Product = p2Product, Amount = 4 }, + new Sale { Id = 4, Year = 2022, Quarter = "2022-1", Customer = c2Customer, Product = p2Product, Amount = 8 }, + new Sale { Id = 5, Year = 2022, Quarter = "2022-4", Customer = c2Customer, Product = p3Product, Amount = 4 }, + new Sale { Id = 6, Year = 2022, Quarter = "2022-2", Customer = c3Customer, Product = p1Product, Amount = 2 }, + new Sale { Id = 7, Year = 2022, Quarter = "2022-3", Customer = c3Customer, Product = p3Product, Amount = 1 }, + new Sale { Id = 8, Year = 2022, Quarter = "2022-4", Customer = c3Customer, Product = p3Product, Amount = 2 }, + }); + + db.SaveChanges(); + } + } + } + + public class EmployeesController : ODataController + { + [EnableQuery] + public ActionResult> Get() + { + return DataSource.Employees; + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyCustomMethods.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyCustomMethods.cs new file mode 100644 index 000000000..6a6bb3d79 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyCustomMethods.cs @@ -0,0 +1,30 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply +{ + internal static class DollarApplyCustomMethods + { + public static double StdDev(IEnumerable values) + { + var count = values.Count(); + if (count <= 0) + { + return 0; + } + + var average = values.Average(); + var sumOfSquaresOfDifferences = values.Sum(value => (value - average) * (value - average)); + + return Math.Sqrt((double)sumOfSquaresOfDifferences / (count - 1)); + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyDataModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyDataModel.cs new file mode 100644 index 000000000..a028c452e --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyDataModel.cs @@ -0,0 +1,66 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply +{ + public class Category + { + public string Id { get; set; } + public string Name { get; set; } + } + + public class Product + { + public string Id { get; set; } + public string Name { get; set; } + public Category Category { get; set; } + public decimal TaxRate { get; set; } + } + + public class Customer + { + public string Id { get; set; } + public string Name { get; set; } + } + + public class Sale + { + + public int Id { get; set; } + public int Year { get; set; } + public string Quarter { get; set; } + public Customer Customer { get; set; } + public Product Product { get; set; } + public decimal Amount { get; set; } + } + + public class Employee + { + public int Id { get; set; } + public string Name { get; set; } + public decimal BaseSalary { get; set; } + public Address Address { get; set; } + public Company Company { get; set; } + public Dictionary DynamicProperties { get; set; } + } + + public class Address + { + public string City { get; set; } + public string State { get; set; } + } + + public class Company + { + [Key] + public string Name { get; set; } + public Employee VP { get; set; } + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyDataSource.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyDataSource.cs new file mode 100644 index 000000000..9e75476ea --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyDataSource.cs @@ -0,0 +1,119 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System.Collections.Generic; +using System.Linq; +using Microsoft.EntityFrameworkCore; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply +{ + public class DollarApplyDbContext : DbContext + { + public DollarApplyDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Categories { get; set; } + + public DbSet Products { get; set; } + + public DbSet Customers { get; set; } + + public DbSet Sales { get; set; } + } + + public class DataSource + { + private static readonly Company company; + private static readonly List employees; + + static DataSource() + { + company = new Company + { + Name = "Northwind Traders" + }; + + employees = new List + { + new Employee + { + Id = 1, + Name = "Nancy Davolio", + BaseSalary = 1300, + Address = new Address + { + City = "Seattle", + State = "WA" + }, + Company = company, + DynamicProperties = new Dictionary + { + { "Commission", 250 }, + { "Gender", "Female" } + } + }, + new Employee + { + Id = 2, + Name = "Andrew Fuller", + BaseSalary = 1500, + Address = new Address + { + City = "Tacoma", + State = "WA" + }, + Company = company, + DynamicProperties = new Dictionary + { + { "Commission", 190 }, + { "Gender", "Male" } + } + }, + new Employee + { + Id = 3, + Name = "Janet Leverling", + BaseSalary = 1100, + Address = new Address + { + City = "Kirkland", + State = "WA" + }, + Company = company, + DynamicProperties = new Dictionary + { + { "Commission", 370 }, + { "Gender", "Female" } + } + }, + new Employee + { + Id = 9, + Name = "Anne Dodsworth", + BaseSalary = 1000, + Address = new Address + { + City = "London", + State = "UK" + }, + Company = company, + DynamicProperties = new Dictionary + { + { "Commission", 310 }, + { "Gender", "Female" } + } + }, + }; + + company.VP = employees.First(e => e.Id == 2); + } + + public static List Employees => employees; + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyEdmModel.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyEdmModel.cs new file mode 100644 index 000000000..c237725c7 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyEdmModel.cs @@ -0,0 +1,48 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Reflection; +using Microsoft.AspNetCore.OData.Edm; +using Microsoft.OData.Edm; +using Microsoft.OData.ModelBuilder; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply +{ + public class DollarApplyEdmModel + { + public static IEdmModel GetEdmModel() + { + var builder = new ODataConventionModelBuilder(); + + builder.EntitySet("Categories"); + builder.EntitySet("Products"); + builder.EntitySet("Customers"); + builder.EntitySet("Sales"); + builder.EntitySet("Employees"); + builder.ComplexType
(); + builder.Singleton("Company"); + + var model = builder.GetEdmModel(); + + var stdevMethodAnnotation = new CustomAggregateMethodAnnotation(); + var stdevMethod = new Dictionary + { + { + typeof(decimal), + typeof(DollarApplyCustomMethods).GetMethod("StdDev", BindingFlags.Static | BindingFlags.Public) + } + }; + + stdevMethodAnnotation.AddMethod("custom.stdev", stdevMethod); + model.SetAnnotationValue(model, stdevMethodAnnotation); + + return model; + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyTests.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyTests.cs new file mode 100644 index 000000000..346248d57 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/DollarApplyTests.cs @@ -0,0 +1,1904 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +// Conditional compilation due to known bug affecting EF Core and lower +#if NET6_0_OR_GREATER +using System; +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Wrapper; +using Microsoft.AspNetCore.OData.E2E.Tests.Extensions; +using Microsoft.AspNetCore.OData.Query.Expressions; +using Microsoft.AspNetCore.OData.TestCommon; +using Microsoft.AspNetCore.OData.TestCommon.Query.Expressions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json.Linq; +using Xunit; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply +{ + public class DollarApplyTests : WebApiTestBase + { + public DollarApplyTests(WebApiTestFixture fixture) + : base(fixture) + { + } + + protected static void UpdateConfigureServices(IServiceCollection services) + { + var model = DollarApplyEdmModel.GetEdmModel(); + + services.AddDbContext(opt => opt.UseInMemoryDatabase(Guid.NewGuid().ToString())); + services.ConfigureControllers(typeof(SalesController), typeof(EmployeesController)); + + services.AddControllers().AddOData(options => + { + options.EnableQueryFeatures(); + options.AddRouteComponents("default", model); + options.AddRouteComponents("custom", model, (nestedServices) => + { + nestedServices.AddSingleton(); + }); + }).AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new TestGroupByWrapperConverter()); + }); + } + + protected static void UpdateConfigure(IApplicationBuilder app) + { + app.UseRouting(); + app.UseEndpoints(endpoints => + { + endpoints.MapControllers(); + }); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Quarter))")] + [InlineData("custom/Sales?$apply=groupby((Quarter))")] + public async Task TestGroupByPrimitivePropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + Assert.Equal("2022-1", Assert.IsType(result[0]).Value("Quarter")); + Assert.Equal("2022-2", Assert.IsType(result[1]).Value("Quarter")); + Assert.Equal("2022-3", Assert.IsType(result[2]).Value("Quarter")); + Assert.Equal("2022-4", Assert.IsType(result[3]).Value("Quarter")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Quarter,Year))")] + [InlineData("custom/Sales?$apply=groupby((Quarter,Year))")] + public async Task TestGroupByMultiplePropertiesAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal("2022", resultAt0.Value("Year")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal("2022", resultAt1.Value("Year")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal("2022", resultAt2.Value("Year")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal("2022", resultAt3.Value("Year")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Name))")] + [InlineData("custom/Sales?$apply=groupby((Product/Name))")] + public async Task TestGroupByNestedPropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(3, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + Assert.Equal("Paper", resultAt0.Value("Product").Value("Name")); + Assert.Equal("Sugar", resultAt1.Value("Product").Value("Name")); + Assert.Equal("Coffee", resultAt2.Value("Product").Value("Name")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Name,Customer/Id))")] + [InlineData("custom/Sales?$apply=groupby((Product/Name,Customer/Id))")] + public async Task TestGroupByMultipleNestedPropertiesAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(7, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + var resultAt4 = Assert.IsType(result[4]); + var resultAt5 = Assert.IsType(result[5]); + var resultAt6 = Assert.IsType(result[6]); + Assert.Equal("Paper", resultAt0.Value("Product").Value("Name")); + Assert.Equal("C1", resultAt0.Value("Customer").Value("Id")); + Assert.Equal("Sugar", resultAt1.Value("Product").Value("Name")); + Assert.Equal("C1", resultAt1.Value("Customer").Value("Id")); + Assert.Equal("Coffee", resultAt2.Value("Product").Value("Name")); + Assert.Equal("C1", resultAt2.Value("Customer").Value("Id")); + Assert.Equal("Coffee", resultAt3.Value("Product").Value("Name")); + Assert.Equal("C2", resultAt3.Value("Customer").Value("Id")); + Assert.Equal("Paper", resultAt4.Value("Product").Value("Name")); + Assert.Equal("C2", resultAt4.Value("Customer").Value("Id")); + Assert.Equal("Sugar", resultAt5.Value("Product").Value("Name")); + Assert.Equal("C3", resultAt6.Value("Customer").Value("Id")); + Assert.Equal("Paper", resultAt6.Value("Product").Value("Name")); + Assert.Equal("C3", resultAt6.Value("Customer").Value("Id")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Category/Name))")] + [InlineData("custom/Sales?$apply=groupby((Product/Category/Name))")] + public async Task TestGroupByMultiNestedPropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + Assert.Equal("Non-Food", resultAt0.Value("Product").Value("Category").Value("Name")); + Assert.Equal("Food", resultAt1.Value("Product").Value("Category").Value("Name")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Category/Name,Customer/Id))")] + [InlineData("custom/Sales?$apply=groupby((Product/Category/Name,Customer/Id))")] + public async Task TestGroupByHybridNestedPropertiesAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(6, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + var resultAt4 = Assert.IsType(result[4]); + var resultAt5 = Assert.IsType(result[5]); + Assert.Equal("Non-Food", resultAt0.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C1", resultAt0.Value("Customer").Value("Id")); + Assert.Equal("Food", resultAt1.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C1", resultAt1.Value("Customer").Value("Id")); + Assert.Equal("Food", resultAt2.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C2", resultAt2.Value("Customer").Value("Id")); + Assert.Equal("Non-Food", resultAt3.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C2", resultAt3.Value("Customer").Value("Id")); + Assert.Equal("Food", resultAt4.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C3", resultAt4.Value("Customer").Value("Id")); + Assert.Equal("Non-Food", resultAt5.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C3", resultAt5.Value("Customer").Value("Id")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Quarter,Product/Name))")] + [InlineData("custom/Sales?$apply=groupby((Quarter,Product/Name))")] + public async Task TestGroupByNestedAndNonNestedPropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(6, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + var resultAt4 = Assert.IsType(result[4]); + var resultAt5 = Assert.IsType(result[5]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal("Paper", resultAt0.Value("Product").Value("Name")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal("Sugar", resultAt1.Value("Product").Value("Name")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal("Coffee", resultAt2.Value("Product").Value("Name")); + Assert.Equal("2022-1", resultAt3.Value("Quarter")); + Assert.Equal("Coffee", resultAt3.Value("Product").Value("Name")); + Assert.Equal("2022-4", resultAt4.Value("Quarter")); + Assert.Equal("Paper", resultAt4.Value("Product").Value("Name")); + Assert.Equal("2022-3", resultAt5.Value("Quarter")); + Assert.Equal("Paper", resultAt5.Value("Product").Value("Name")); + } + + [Theory] + [InlineData("default/Sales?$apply=aggregate(Amount with sum as SumAmount)")] + [InlineData("custom/Sales?$apply=aggregate(Amount with sum as SumAmount)")] + public async Task TestAggregatePrimitivePropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(24m, Assert.Single(result).Value("SumAmount")); + } + + [Theory] + [InlineData("default/Sales?$apply=aggregate(Product/TaxRate with min as MinTaxRate)")] + [InlineData("custom/Sales?$apply=aggregate(Product/TaxRate with min as MinTaxRate)")] + public async Task TestAggregateNestedPropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(0.06m, Assert.Single(result).Value("MinTaxRate")); + } + + [Theory] + [InlineData("default/Sales?$apply=aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate)")] + [InlineData("custom/Sales?$apply=aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate)")] + public async Task TestAggregateMultiplePropertiesAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + var resultAt0 = Assert.Single(result); + Assert.Equal(3m, resultAt0.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt0.Value("MaxTaxRate")); + } + + [Theory] + [InlineData("default/Sales?$apply=aggregate($count as SalesCount)")] + [InlineData("custom/Sales?$apply=aggregate($count as SalesCount)")] + public async Task TestAggregateDollarCountAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(8, Assert.Single(result).Value("SalesCount")); + } + + [Theory] + [InlineData("default/Sales?$apply=aggregate(Product/Name with countdistinct as DistinctProducts)")] + [InlineData("custom/Sales?$apply=aggregate(Product/Name with countdistinct as DistinctProducts)")] + public async Task TestAggregateNestedPropertyWithCountDistinctAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(3, Assert.Single(result).Value("DistinctProducts")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Quarter),aggregate(Amount with sum as SumAmount))")] + [InlineData("custom/Sales?$apply=groupby((Quarter),aggregate(Amount with sum as SumAmount))")] + public async Task TestGroupByPrimitivePropertyAndAggregatePrimitivePropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal(9m, resultAt0.Value("SumAmount")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal(4m, resultAt1.Value("SumAmount")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal(5m, resultAt2.Value("SumAmount")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal(6m, resultAt3.Value("SumAmount")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Quarter),aggregate(Product/TaxRate with min as MinTaxRate))")] + [InlineData("custom/Sales?$apply=groupby((Quarter),aggregate(Product/TaxRate with min as MinTaxRate))")] + public async Task TestGroupByPrimitivePropertyAndAggregateNestedPropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal(0.06m, resultAt0.Value("MinTaxRate")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal(0.06m, resultAt1.Value("MinTaxRate")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal(0.06m, resultAt2.Value("MinTaxRate")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal(0.14m, resultAt3.Value("MinTaxRate")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Quarter),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))")] + [InlineData("custom/Sales?$apply=groupby((Quarter),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))")] + public async Task TestGroupByPrimitivePropertyAndAggregateMultiplePropertiesAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal(4.5m, resultAt0.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt0.Value("MaxTaxRate")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal(2m, resultAt1.Value("AverageAmount")); + Assert.Equal(0.06m, resultAt1.Value("MaxTaxRate")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal(2.5m, resultAt2.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt2.Value("MaxTaxRate")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal(3m, resultAt3.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt3.Value("MaxTaxRate")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Quarter,Year),aggregate(Amount with sum as SumAmount))")] + [InlineData("custom/Sales?$apply=groupby((Quarter,Year),aggregate(Amount with sum as SumAmount))")] + public async Task TestGroupByMultiplePropertiesAndAggregatePrimitivePropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal(2022, resultAt0.Value("Year")); + Assert.Equal(9m, resultAt0.Value("SumAmount")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal(2022, resultAt1.Value("Year")); + Assert.Equal(4m, resultAt1.Value("SumAmount")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal(2022, resultAt2.Value("Year")); + Assert.Equal(5m, resultAt2.Value("SumAmount")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal(2022, resultAt3.Value("Year")); + Assert.Equal(6m, resultAt3.Value("SumAmount")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Quarter,Year),aggregate(Product/TaxRate with min as MinTaxRate))")] + [InlineData("custom/Sales?$apply=groupby((Quarter,Year),aggregate(Product/TaxRate with min as MinTaxRate))")] + public async Task TestGroupByMultiplePropertiesAndAggregateNestedPropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal(2022, resultAt0.Value("Year")); + Assert.Equal(0.06m, resultAt0.Value("MinTaxRate")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal(2022, resultAt1.Value("Year")); + Assert.Equal(0.06m, resultAt1.Value("MinTaxRate")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal(2022, resultAt3.Value("Year")); + Assert.Equal(0.06m, resultAt2.Value("MinTaxRate")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal(2022, resultAt3.Value("Year")); + Assert.Equal(0.14m, resultAt3.Value("MinTaxRate")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Quarter,Year),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))")] + [InlineData("custom/Sales?$apply=groupby((Quarter,Year),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))")] + public async Task TestGroupByMultiplePropertiesAndAggregateMultiplePropertiesAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal(2022, resultAt0.Value("Year")); + Assert.Equal(4.5m, resultAt0.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt0.Value("MaxTaxRate")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal(2022, resultAt2.Value("Year")); + Assert.Equal(2m, resultAt1.Value("AverageAmount")); + Assert.Equal(0.06m, resultAt1.Value("MaxTaxRate")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal(2022, resultAt2.Value("Year")); + Assert.Equal(2.5m, resultAt2.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt2.Value("MaxTaxRate")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal(2022, resultAt3.Value("Year")); + Assert.Equal(3m, resultAt3.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt3.Value("MaxTaxRate")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Name),aggregate(Amount with sum as SumAmount))")] + [InlineData("custom/Sales?$apply=groupby((Product/Name),aggregate(Amount with sum as SumAmount))")] + public async Task TestGroupByNestedPropertyAndAggregatePrimitivePropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(3, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + Assert.Equal("Paper", resultAt0.Value("Product").Value("Name")); + Assert.Equal(8m, resultAt0.Value("SumAmount")); + Assert.Equal("Sugar", resultAt1.Value("Product").Value("Name")); + Assert.Equal(4m, resultAt1.Value("SumAmount")); + Assert.Equal("Coffee", resultAt2.Value("Product").Value("Name")); + Assert.Equal(12m, resultAt2.Value("SumAmount")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Name),aggregate(Product/TaxRate with min as MinTaxRate))")] + [InlineData("custom/Sales?$apply=groupby((Product/Name),aggregate(Product/TaxRate with min as MinTaxRate))")] + public async Task TestGroupByNestedPropertyAndAggregateNestedPropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(3, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + Assert.Equal("Paper", resultAt0.Value("Product").Value("Name")); + Assert.Equal(0.14m, resultAt0.Value("MinTaxRate")); + Assert.Equal("Sugar", resultAt1.Value("Product").Value("Name")); + Assert.Equal(0.06m, resultAt1.Value("MinTaxRate")); + Assert.Equal("Coffee", resultAt2.Value("Product").Value("Name")); + Assert.Equal(0.06m, resultAt2.Value("MinTaxRate")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Name),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))")] + [InlineData("custom/Sales?$apply=groupby((Product/Name),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))")] + public async Task TestGroupByNestedPropertyAndAggregateMultiplePropertiesAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(3, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + Assert.Equal("Paper", resultAt0.Value("Product").Value("Name")); + Assert.Equal(2m, resultAt0.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt0.Value("MaxTaxRate")); + Assert.Equal("Sugar", resultAt1.Value("Product").Value("Name")); + Assert.Equal(2m, resultAt1.Value("AverageAmount")); + Assert.Equal(0.06m, resultAt1.Value("MaxTaxRate")); + Assert.Equal("Coffee", resultAt2.Value("Product").Value("Name")); + Assert.Equal(6m, resultAt2.Value("AverageAmount")); + Assert.Equal(0.06m, resultAt2.Value("MaxTaxRate")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Name,Customer/Id),aggregate(Amount with sum as SumAmount))")] + [InlineData("custom/Sales?$apply=groupby((Product/Name,Customer/Id),aggregate(Amount with sum as SumAmount))")] + public async Task TestGroupByMultipleNestedPropertiesAndAggregatePrimitivePropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(7, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + var resultAt4 = Assert.IsType(result[4]); + var resultAt5 = Assert.IsType(result[5]); + var resultAt6 = Assert.IsType(result[6]); + Assert.Equal("Paper", resultAt0.Value("Product").Value("Name")); + Assert.Equal("C1", resultAt0.Value("Customer").Value("Id")); + Assert.Equal(1m, resultAt0.Value("SumAmount")); + Assert.Equal("Sugar", resultAt1.Value("Product").Value("Name")); + Assert.Equal("C1", resultAt1.Value("Customer").Value("Id")); + Assert.Equal(2m, resultAt1.Value("SumAmount")); + Assert.Equal("Coffee", resultAt2.Value("Product").Value("Name")); + Assert.Equal("C1", resultAt2.Value("Customer").Value("Id")); + Assert.Equal(4m, resultAt2.Value("SumAmount")); + Assert.Equal("Coffee", resultAt3.Value("Product").Value("Name")); + Assert.Equal("C2", resultAt3.Value("Customer").Value("Id")); + Assert.Equal(8m, resultAt3.Value("SumAmount")); + Assert.Equal("Paper", resultAt4.Value("Product").Value("Name")); + Assert.Equal("C2", resultAt4.Value("Customer").Value("Id")); + Assert.Equal(4m, resultAt4.Value("SumAmount")); + Assert.Equal("Sugar", resultAt5.Value("Product").Value("Name")); + Assert.Equal("C3", resultAt6.Value("Customer").Value("Id")); + Assert.Equal(2m, resultAt5.Value("SumAmount")); + Assert.Equal("Paper", resultAt6.Value("Product").Value("Name")); + Assert.Equal("C3", resultAt6.Value("Customer").Value("Id")); + Assert.Equal(3m, resultAt6.Value("SumAmount")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Name,Customer/Id),aggregate(Product/TaxRate with min as MinTaxRate))")] + [InlineData("custom/Sales?$apply=groupby((Product/Name,Customer/Id),aggregate(Product/TaxRate with min as MinTaxRate))")] + public async Task TestGroupByMultipleNestedPropertiesAndAggregateNestedPropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(7, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + var resultAt4 = Assert.IsType(result[4]); + var resultAt5 = Assert.IsType(result[5]); + var resultAt6 = Assert.IsType(result[6]); + Assert.Equal("Paper", resultAt0.Value("Product").Value("Name")); + Assert.Equal("C1", resultAt0.Value("Customer").Value("Id")); + Assert.Equal(0.14m, resultAt0.Value("MinTaxRate")); + Assert.Equal("Sugar", resultAt1.Value("Product").Value("Name")); + Assert.Equal("C1", resultAt1.Value("Customer").Value("Id")); + Assert.Equal(0.06m, resultAt1.Value("MinTaxRate")); + Assert.Equal("Coffee", resultAt2.Value("Product").Value("Name")); + Assert.Equal("C1", resultAt2.Value("Customer").Value("Id")); + Assert.Equal(0.06m, resultAt2.Value("MinTaxRate")); + Assert.Equal("Coffee", resultAt3.Value("Product").Value("Name")); + Assert.Equal("C2", resultAt3.Value("Customer").Value("Id")); + Assert.Equal(0.06m, resultAt3.Value("MinTaxRate")); + Assert.Equal("Paper", resultAt4.Value("Product").Value("Name")); + Assert.Equal("C2", resultAt4.Value("Customer").Value("Id")); + Assert.Equal(0.14m, resultAt4.Value("MinTaxRate")); + Assert.Equal("Sugar", resultAt5.Value("Product").Value("Name")); + Assert.Equal("C3", resultAt6.Value("Customer").Value("Id")); + Assert.Equal(0.06m, resultAt5.Value("MinTaxRate")); + Assert.Equal("Paper", resultAt6.Value("Product").Value("Name")); + Assert.Equal("C3", resultAt6.Value("Customer").Value("Id")); + Assert.Equal(0.14m, resultAt6.Value("MinTaxRate")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Name,Customer/Id),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))")] + [InlineData("custom/Sales?$apply=groupby((Product/Name,Customer/Id),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))")] + public async Task TestGroupByMultipleNestedPropertiesAndAggregateMultiplePropertiesAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(7, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + var resultAt4 = Assert.IsType(result[4]); + var resultAt5 = Assert.IsType(result[5]); + var resultAt6 = Assert.IsType(result[6]); + Assert.Equal("Paper", resultAt0.Value("Product").Value("Name")); + Assert.Equal("C1", resultAt0.Value("Customer").Value("Id")); + Assert.Equal(1m, resultAt0.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt0.Value("MaxTaxRate")); + Assert.Equal("Sugar", resultAt1.Value("Product").Value("Name")); + Assert.Equal("C1", resultAt1.Value("Customer").Value("Id")); + Assert.Equal(2m, resultAt1.Value("AverageAmount")); + Assert.Equal(0.06m, resultAt1.Value("MaxTaxRate")); + Assert.Equal("Coffee", resultAt2.Value("Product").Value("Name")); + Assert.Equal("C1", resultAt2.Value("Customer").Value("Id")); + Assert.Equal(4m, resultAt2.Value("AverageAmount")); + Assert.Equal(0.06m, resultAt2.Value("MaxTaxRate")); + Assert.Equal("Coffee", resultAt3.Value("Product").Value("Name")); + Assert.Equal("C2", resultAt3.Value("Customer").Value("Id")); + Assert.Equal(8m, resultAt3.Value("AverageAmount")); + Assert.Equal(0.06m, resultAt3.Value("MaxTaxRate")); + Assert.Equal("Paper", resultAt4.Value("Product").Value("Name")); + Assert.Equal("C2", resultAt4.Value("Customer").Value("Id")); + Assert.Equal(4m, resultAt4.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt4.Value("MaxTaxRate")); + Assert.Equal("Sugar", resultAt5.Value("Product").Value("Name")); + Assert.Equal("C3", resultAt6.Value("Customer").Value("Id")); + Assert.Equal(2m, resultAt5.Value("AverageAmount")); + Assert.Equal(0.06m, resultAt5.Value("MaxTaxRate")); + Assert.Equal("Paper", resultAt6.Value("Product").Value("Name")); + Assert.Equal("C3", resultAt6.Value("Customer").Value("Id")); + Assert.Equal(1.5m, resultAt6.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt6.Value("MaxTaxRate")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Category/Name),aggregate(Amount with sum as SumAmount))")] + [InlineData("custom/Sales?$apply=groupby((Product/Category/Name),aggregate(Amount with sum as SumAmount))")] + public async Task TestGroupByMultiNestedPropertyAndAggregatePrimitivePropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + Assert.Equal("Non-Food", resultAt0.Value("Product").Value("Category").Value("Name")); + Assert.Equal(8m, resultAt0.Value("SumAmount")); + Assert.Equal("Food", resultAt1.Value("Product").Value("Category").Value("Name")); + Assert.Equal(16m, resultAt1.Value("SumAmount")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Category/Name),aggregate(Product/TaxRate with min as MinTaxRate))")] + [InlineData("custom/Sales?$apply=groupby((Product/Category/Name),aggregate(Product/TaxRate with min as MinTaxRate))")] + public async Task TestGroupByMultiNestedPropertyAndAggregateNestedPropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + Assert.Equal("Non-Food", resultAt0.Value("Product").Value("Category").Value("Name")); + Assert.Equal(0.14m, resultAt0.Value("MinTaxRate")); + Assert.Equal("Food", resultAt1.Value("Product").Value("Category").Value("Name")); + Assert.Equal(0.06m, resultAt1.Value("MinTaxRate")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Category/Name),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))")] + [InlineData("custom/Sales?$apply=groupby((Product/Category/Name),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))")] + public async Task TestGroupByMultiNestedPropertyAndAggregateMultiplePropertiesAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + Assert.Equal("Non-Food", resultAt0.Value("Product").Value("Category").Value("Name")); + Assert.Equal(2m, resultAt0.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt0.Value("MaxTaxRate")); + Assert.Equal("Food", resultAt1.Value("Product").Value("Category").Value("Name")); + Assert.Equal(4m, resultAt1.Value("AverageAmount")); + Assert.Equal(0.06m, resultAt1.Value("MaxTaxRate")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Category/Name,Customer/Id),aggregate(Amount with sum as SumAmount))")] + [InlineData("custom/Sales?$apply=groupby((Product/Category/Name,Customer/Id),aggregate(Amount with sum as SumAmount))")] + public async Task TestGroupByHybridNestedPropertiesAndAggregatePrimitivePropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(6, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + var resultAt4 = Assert.IsType(result[4]); + var resultAt5 = Assert.IsType(result[5]); + Assert.Equal("Non-Food", resultAt0.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C1", resultAt0.Value("Customer").Value("Id")); + Assert.Equal(1m, resultAt0.Value("SumAmount")); + Assert.Equal("Food", resultAt1.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C1", resultAt1.Value("Customer").Value("Id")); + Assert.Equal(6m, resultAt1.Value("SumAmount")); + Assert.Equal("Food", resultAt2.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C2", resultAt2.Value("Customer").Value("Id")); + Assert.Equal(8m, resultAt2.Value("SumAmount")); + Assert.Equal("Non-Food", resultAt3.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C2", resultAt3.Value("Customer").Value("Id")); + Assert.Equal(4m, resultAt3.Value("SumAmount")); + Assert.Equal("Food", resultAt4.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C3", resultAt4.Value("Customer").Value("Id")); + Assert.Equal(2m, resultAt4.Value("SumAmount")); + Assert.Equal("Non-Food", resultAt5.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C3", resultAt5.Value("Customer").Value("Id")); + Assert.Equal(3m, resultAt5.Value("SumAmount")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Category/Name,Customer/Id),aggregate(Product/TaxRate with min as MinTaxRate))")] + [InlineData("custom/Sales?$apply=groupby((Product/Category/Name,Customer/Id),aggregate(Product/TaxRate with min as MinTaxRate))")] + public async Task TestGroupByHybridNestedPropertiesAndAggregateNestedPropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(6, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + var resultAt4 = Assert.IsType(result[4]); + var resultAt5 = Assert.IsType(result[5]); + Assert.Equal("Non-Food", resultAt0.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C1", resultAt0.Value("Customer").Value("Id")); + Assert.Equal(0.14m, resultAt0.Value("MinTaxRate")); + Assert.Equal("Food", resultAt1.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C1", resultAt1.Value("Customer").Value("Id")); + Assert.Equal(0.06m, resultAt1.Value("MinTaxRate")); + Assert.Equal("Food", resultAt2.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C2", resultAt2.Value("Customer").Value("Id")); + Assert.Equal(0.06m, resultAt2.Value("MinTaxRate")); + Assert.Equal("Non-Food", resultAt3.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C2", resultAt3.Value("Customer").Value("Id")); + Assert.Equal(0.14m, resultAt3.Value("MinTaxRate")); + Assert.Equal("Food", resultAt4.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C3", resultAt4.Value("Customer").Value("Id")); + Assert.Equal(0.06m, resultAt4.Value("MinTaxRate")); + Assert.Equal("Non-Food", resultAt5.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C3", resultAt5.Value("Customer").Value("Id")); + Assert.Equal(0.14m, resultAt5.Value("MinTaxRate")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Category/Name,Customer/Id),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))")] + [InlineData("custom/Sales?$apply=groupby((Product/Category/Name,Customer/Id),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))")] + public async Task TestGroupByHybridNestedPropertiesAndAggregateMultiplePropertiesAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(6, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + var resultAt4 = Assert.IsType(result[4]); + var resultAt5 = Assert.IsType(result[5]); + Assert.Equal("Non-Food", resultAt0.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C1", resultAt0.Value("Customer").Value("Id")); + Assert.Equal(1m, resultAt0.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt0.Value("MaxTaxRate")); + Assert.Equal("Food", resultAt1.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C1", resultAt1.Value("Customer").Value("Id")); + Assert.Equal(3m, resultAt1.Value("AverageAmount")); + Assert.Equal(0.06m, resultAt1.Value("MaxTaxRate")); + Assert.Equal("Food", resultAt2.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C2", resultAt2.Value("Customer").Value("Id")); + Assert.Equal(8m, resultAt2.Value("AverageAmount")); + Assert.Equal(0.06m, resultAt2.Value("MaxTaxRate")); + Assert.Equal("Non-Food", resultAt3.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C2", resultAt3.Value("Customer").Value("Id")); + Assert.Equal(4m, resultAt3.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt3.Value("MaxTaxRate")); + Assert.Equal("Food", resultAt4.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C3", resultAt4.Value("Customer").Value("Id")); + Assert.Equal(2m, resultAt4.Value("AverageAmount")); + Assert.Equal(0.06m, resultAt4.Value("MaxTaxRate")); + Assert.Equal("Non-Food", resultAt5.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C3", resultAt5.Value("Customer").Value("Id")); + Assert.Equal(1.5m, resultAt5.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt5.Value("MaxTaxRate")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Quarter,Product/Name),aggregate(Amount with sum as SumAmount))")] + [InlineData("custom/Sales?$apply=groupby((Quarter,Product/Name),aggregate(Amount with sum as SumAmount))")] + public async Task TestGroupByNestedAndNonNestedPropertyAndAggregatePrimitivePropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(6, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + var resultAt4 = Assert.IsType(result[4]); + var resultAt5 = Assert.IsType(result[5]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal("Paper", resultAt0.Value("Product").Value("Name")); + Assert.Equal(1m, resultAt0.Value("SumAmount")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal("Sugar", resultAt1.Value("Product").Value("Name")); + Assert.Equal(4m, resultAt1.Value("SumAmount")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal("Coffee", resultAt2.Value("Product").Value("Name")); + Assert.Equal(4m, resultAt2.Value("SumAmount")); + Assert.Equal("2022-1", resultAt3.Value("Quarter")); + Assert.Equal("Coffee", resultAt3.Value("Product").Value("Name")); + Assert.Equal(8m, resultAt3.Value("SumAmount")); + Assert.Equal("2022-4", resultAt4.Value("Quarter")); + Assert.Equal("Paper", resultAt4.Value("Product").Value("Name")); + Assert.Equal(6m, resultAt4.Value("SumAmount")); + Assert.Equal("2022-3", resultAt5.Value("Quarter")); + Assert.Equal("Paper", resultAt5.Value("Product").Value("Name")); + Assert.Equal(1m, resultAt5.Value("SumAmount")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Quarter,Product/Name),aggregate(Product/TaxRate with min as MinTaxRate))")] + [InlineData("custom/Sales?$apply=groupby((Quarter,Product/Name),aggregate(Product/TaxRate with min as MinTaxRate))")] + public async Task TestGroupByNestedAndNonNestedPropertyAndAggregateNestedPropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(6, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + var resultAt4 = Assert.IsType(result[4]); + var resultAt5 = Assert.IsType(result[5]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal("Paper", resultAt0.Value("Product").Value("Name")); + Assert.Equal(0.14m, resultAt0.Value("MinTaxRate")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal("Sugar", resultAt1.Value("Product").Value("Name")); + Assert.Equal(0.06m, resultAt1.Value("MinTaxRate")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal("Coffee", resultAt2.Value("Product").Value("Name")); + Assert.Equal(0.06m, resultAt2.Value("MinTaxRate")); + Assert.Equal("2022-1", resultAt3.Value("Quarter")); + Assert.Equal("Coffee", resultAt3.Value("Product").Value("Name")); + Assert.Equal(0.06m, resultAt3.Value("MinTaxRate")); + Assert.Equal("2022-4", resultAt4.Value("Quarter")); + Assert.Equal("Paper", resultAt4.Value("Product").Value("Name")); + Assert.Equal(0.14m, resultAt4.Value("MinTaxRate")); + Assert.Equal("2022-3", resultAt5.Value("Quarter")); + Assert.Equal("Paper", resultAt5.Value("Product").Value("Name")); + Assert.Equal(0.14m, resultAt5.Value("MinTaxRate")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Quarter,Product/Name),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))")] + [InlineData("custom/Sales?$apply=groupby((Quarter,Product/Name),aggregate(Amount with average as AverageAmount,Product/TaxRate with max as MaxTaxRate))")] + public async Task TestGroupByNestedAndNonNestedPropertyAndAggregateMultiplePropertiesAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(6, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + var resultAt4 = Assert.IsType(result[4]); + var resultAt5 = Assert.IsType(result[5]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal("Paper", resultAt0.Value("Product").Value("Name")); + Assert.Equal(1m, resultAt0.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt0.Value("MaxTaxRate")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal("Sugar", resultAt1.Value("Product").Value("Name")); + Assert.Equal(2m, resultAt1.Value("AverageAmount")); + Assert.Equal(0.06m, resultAt1.Value("MaxTaxRate")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal("Coffee", resultAt2.Value("Product").Value("Name")); + Assert.Equal(4m, resultAt2.Value("AverageAmount")); + Assert.Equal(0.06m, resultAt2.Value("MaxTaxRate")); + Assert.Equal("2022-1", resultAt3.Value("Quarter")); + Assert.Equal("Coffee", resultAt3.Value("Product").Value("Name")); + Assert.Equal(8m, resultAt3.Value("AverageAmount")); + Assert.Equal(0.06m, resultAt3.Value("MaxTaxRate")); + Assert.Equal("2022-4", resultAt4.Value("Quarter")); + Assert.Equal("Paper", resultAt4.Value("Product").Value("Name")); + Assert.Equal(3m, resultAt4.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt4.Value("MaxTaxRate")); + Assert.Equal("2022-3", resultAt5.Value("Quarter")); + Assert.Equal("Paper", resultAt5.Value("Product").Value("Name")); + Assert.Equal(1m, resultAt5.Value("AverageAmount")); + Assert.Equal(0.14m, resultAt5.Value("MaxTaxRate")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Quarter),aggregate($count as SalesCount))")] + [InlineData("custom/Sales?$apply=groupby((Quarter),aggregate($count as SalesCount))")] + public async Task TestGroupByPrimitivePropertyAndAggregateDollarCountAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal(2, resultAt0.Value("SalesCount")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal(2, resultAt1.Value("SalesCount")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal(2, resultAt2.Value("SalesCount")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal(2, resultAt3.Value("SalesCount")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Quarter,Year),aggregate($count as SalesCount))")] + [InlineData("custom/Sales?$apply=groupby((Quarter,Year),aggregate($count as SalesCount))")] + public async Task TestGroupByMultiplePropertiesAndAggregateDollarCountAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal(2022, resultAt0.Value("Year")); + Assert.Equal(2, resultAt0.Value("SalesCount")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal(2022, resultAt1.Value("Year")); + Assert.Equal(2, resultAt1.Value("SalesCount")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal(2022, resultAt2.Value("Year")); + Assert.Equal(2, resultAt2.Value("SalesCount")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal(2022, resultAt3.Value("Year")); + Assert.Equal(2, resultAt3.Value("SalesCount")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Name),aggregate($count as SalesCount))")] + [InlineData("custom/Sales?$apply=groupby((Product/Name),aggregate($count as SalesCount))")] + public async Task TestGroupByNestedPropertyAndAggregateDollarCountAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(3, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + Assert.Equal("Paper", resultAt0.Value("Product").Value("Name")); + Assert.Equal(4, resultAt0.Value("SalesCount")); + Assert.Equal("Sugar", resultAt1.Value("Product").Value("Name")); + Assert.Equal(2, resultAt1.Value("SalesCount")); + Assert.Equal("Coffee", resultAt2.Value("Product").Value("Name")); + Assert.Equal(2, resultAt2.Value("SalesCount")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Name,Customer/Id),aggregate($count as SalesCount))")] + [InlineData("custom/Sales?$apply=groupby((Product/Name,Customer/Id),aggregate($count as SalesCount))")] + public async Task TestGroupByMultipleNestedPropertiesAndAggregateDollarCountAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(7, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + var resultAt4 = Assert.IsType(result[4]); + var resultAt5 = Assert.IsType(result[5]); + var resultAt6 = Assert.IsType(result[6]); + Assert.Equal("Paper", resultAt0.Value("Product").Value("Name")); + Assert.Equal("C1", resultAt0.Value("Customer").Value("Id")); + Assert.Equal(1, resultAt0.Value("SalesCount")); + Assert.Equal("Sugar", resultAt1.Value("Product").Value("Name")); + Assert.Equal("C1", resultAt1.Value("Customer").Value("Id")); + Assert.Equal(1, resultAt1.Value("SalesCount")); + Assert.Equal("Coffee", resultAt2.Value("Product").Value("Name")); + Assert.Equal("C1", resultAt2.Value("Customer").Value("Id")); + Assert.Equal(1, resultAt2.Value("SalesCount")); + Assert.Equal("Coffee", resultAt3.Value("Product").Value("Name")); + Assert.Equal("C2", resultAt3.Value("Customer").Value("Id")); + Assert.Equal(1, resultAt3.Value("SalesCount")); + Assert.Equal("Paper", resultAt4.Value("Product").Value("Name")); + Assert.Equal("C2", resultAt4.Value("Customer").Value("Id")); + Assert.Equal(1, resultAt4.Value("SalesCount")); + Assert.Equal("Sugar", resultAt5.Value("Product").Value("Name")); + Assert.Equal("C3", resultAt6.Value("Customer").Value("Id")); + Assert.Equal(1, resultAt5.Value("SalesCount")); + Assert.Equal("Paper", resultAt6.Value("Product").Value("Name")); + Assert.Equal("C3", resultAt6.Value("Customer").Value("Id")); + Assert.Equal(2, resultAt6.Value("SalesCount")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Category/Name),aggregate($count as SalesCount))")] + [InlineData("custom/Sales?$apply=groupby((Product/Category/Name),aggregate($count as SalesCount))")] + public async Task TestGroupByMultiNestedPropertyAndAggregateDollarCountAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + Assert.Equal("Non-Food", resultAt0.Value("Product").Value("Category").Value("Name")); + Assert.Equal(4, resultAt0.Value("SalesCount")); + Assert.Equal("Food", resultAt1.Value("Product").Value("Category").Value("Name")); + Assert.Equal(4, resultAt1.Value("SalesCount")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Category/Name,Customer/Id),aggregate($count as SalesCount))")] + [InlineData("custom/Sales?$apply=groupby((Product/Category/Name,Customer/Id),aggregate($count as SalesCount))")] + public async Task TestGroupByMultipleHybridNestedPropertiesAndAggregateDollarCountAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(6, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + var resultAt4 = Assert.IsType(result[4]); + var resultAt5 = Assert.IsType(result[5]); + Assert.Equal("Non-Food", resultAt0.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C1", resultAt0.Value("Customer").Value("Id")); + Assert.Equal(1, resultAt0.Value("SalesCount")); + Assert.Equal("Food", resultAt1.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C1", resultAt1.Value("Customer").Value("Id")); + Assert.Equal(2, resultAt1.Value("SalesCount")); + Assert.Equal("Food", resultAt2.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C2", resultAt2.Value("Customer").Value("Id")); + Assert.Equal(1, resultAt2.Value("SalesCount")); + Assert.Equal("Non-Food", resultAt3.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C2", resultAt3.Value("Customer").Value("Id")); + Assert.Equal(1, resultAt3.Value("SalesCount")); + Assert.Equal("Food", resultAt4.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C3", resultAt4.Value("Customer").Value("Id")); + Assert.Equal(1, resultAt4.Value("SalesCount")); + Assert.Equal("Non-Food", resultAt5.Value("Product").Value("Category").Value("Name")); + Assert.Equal("C3", resultAt5.Value("Customer").Value("Id")); + Assert.Equal(2, resultAt5.Value("SalesCount")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Quarter,Product/Name),aggregate($count as SalesCount))")] + [InlineData("custom/Sales?$apply=groupby((Quarter,Product/Name),aggregate($count as SalesCount))")] + public async Task TestGroupByNestedAndNonNestedPropertiesAndAggregateDollarCountAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(6, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + var resultAt4 = Assert.IsType(result[4]); + var resultAt5 = Assert.IsType(result[5]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal("Paper", resultAt0.Value("Product").Value("Name")); + Assert.Equal(1, resultAt0.Value("SalesCount")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal("Sugar", resultAt1.Value("Product").Value("Name")); + Assert.Equal(2, resultAt1.Value("SalesCount")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal("Coffee", resultAt2.Value("Product").Value("Name")); + Assert.Equal(1, resultAt2.Value("SalesCount")); + Assert.Equal("2022-1", resultAt3.Value("Quarter")); + Assert.Equal("Coffee", resultAt3.Value("Product").Value("Name")); + Assert.Equal(1, resultAt3.Value("SalesCount")); + Assert.Equal("2022-4", resultAt4.Value("Quarter")); + Assert.Equal("Paper", resultAt4.Value("Product").Value("Name")); + Assert.Equal(2, resultAt4.Value("SalesCount")); + Assert.Equal("2022-3", resultAt5.Value("Quarter")); + Assert.Equal("Paper", resultAt5.Value("Product").Value("Name")); + Assert.Equal(1, resultAt5.Value("SalesCount")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Quarter),aggregate(Product/Name with countdistinct as DistinctProducts))")] + [InlineData("custom/Sales?$apply=groupby((Quarter),aggregate(Product/Name with countdistinct as DistinctProducts))")] + public async Task TestGroupByPrimitivePropertyAndAggregateNestedPropertyWithCountDistinctAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal(2, resultAt0.Value("DistinctProducts")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal(1, resultAt1.Value("DistinctProducts")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal(2, resultAt2.Value("DistinctProducts")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal(1, resultAt3.Value("DistinctProducts")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Quarter,Year),aggregate(Product/Name with countdistinct as DistinctProducts))")] + [InlineData("custom/Sales?$apply=groupby((Quarter,Year),aggregate(Product/Name with countdistinct as DistinctProducts))")] + public async Task TestGroupByMultiplePropertiesAndAggregateNestedPropertyWithCountDistinctAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + var resultAt3 = Assert.IsType(result[3]); + Assert.Equal("2022-1", resultAt0.Value("Quarter")); + Assert.Equal(2, resultAt0.Value("DistinctProducts")); + Assert.Equal(2022, resultAt0.Value("Year")); + Assert.Equal("2022-2", resultAt1.Value("Quarter")); + Assert.Equal(1, resultAt1.Value("DistinctProducts")); + Assert.Equal(2022, resultAt1.Value("Year")); + Assert.Equal("2022-3", resultAt2.Value("Quarter")); + Assert.Equal(2, resultAt2.Value("DistinctProducts")); + Assert.Equal(2022, resultAt2.Value("Year")); + Assert.Equal("2022-4", resultAt3.Value("Quarter")); + Assert.Equal(1, resultAt3.Value("DistinctProducts")); + Assert.Equal(2022, resultAt3.Value("Year")); + } + + [Theory] + [InlineData("default/Sales?$apply=aggregate(Amount with custom.stdev as StdDev)")] + [InlineData("custom/Sales?$apply=aggregate(Amount with custom.stdev as StdDev)")] + public async Task TestAggregatePrimitivePropertyWithCustomAggregateFunctionAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2.32992949004287, Assert.Single(result).Value("StdDev")); + } + + [Theory] + [InlineData("default/Sales?$apply=aggregate(Product/TaxRate with custom.stdev as StdDev)")] + [InlineData("custom/Sales?$apply=aggregate(Product/TaxRate with custom.stdev as StdDev)")] + public async Task TestAggregateNestedPropertyWithCustomAggregateFunctionAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(0.042761798705987904, Assert.Single(result).Value("StdDev")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Product/Category/Name),aggregate(Amount with custom.stdev as StdDev))")] + [InlineData("custom/Sales?$apply=groupby((Product/Category/Name),aggregate(Amount with custom.stdev as StdDev))")] + public async Task TestGroupByMultiNestedPropertyAndAggregatePrimitivePropertyWithCustomAggregateFunctionAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + Assert.Equal("Non-Food", resultAt0.Value("Product").Value("Category").Value("Name")); + Assert.Equal(1.4142135623730951, resultAt0.Value("StdDev")); + Assert.Equal("Food", resultAt1.Value("Product").Value("Category").Value("Name")); + Assert.Equal(2.8284271247461903, resultAt1.Value("StdDev")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Customer/Id),aggregate(Product/TaxRate with custom.stdev as StdDev))")] + [InlineData("custom/Sales?$apply=groupby((Customer/Id),aggregate(Product/TaxRate with custom.stdev as StdDev))")] + public async Task TestGroupByNestedPropertyAndAggregateNestedPropertyWithCustomAggregateFunctionAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(3, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + Assert.Equal("C1", resultAt0.Value("Customer").Value("Id")); + Assert.Equal(0.046188021535170064, resultAt0.Value("StdDev")); + Assert.Equal("C2", resultAt1.Value("Customer").Value("Id")); + Assert.Equal(0.0565685424949238, resultAt1.Value("StdDev")); + Assert.Equal("C3", resultAt2.Value("Customer").Value("Id")); + Assert.Equal(0.046188021535170064, resultAt2.Value("StdDev")); + } + + [Theory] + [InlineData("default/Employees?$apply=groupby((Gender))")] + [InlineData("custom/Employees?$apply=groupby((Gender))")] + public async Task TestGroupByDynamicPrimitivePropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + Assert.Equal("Female", Assert.IsType(result[0]).Value("Gender")); + Assert.Equal("Male", Assert.IsType(result[1]).Value("Gender")); + } + + [Theory] + [InlineData("default/Employees?$apply=aggregate(Commission with average as AverageCommission)")] + [InlineData("custom/Employees?$apply=aggregate(Commission with average as AverageCommission)")] + public async Task TestAggregateSingleDynamicPropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(280, Assert.Single(result).Value("AverageCommission")); + } + + [Theory] + [InlineData("default/Employees?$apply=groupby((Gender),aggregate(Commission with sum as SumCommission))")] + [InlineData("custom/Employees?$apply=groupby((Gender),aggregate(Commission with sum as SumCommission))")] + public async Task TestGroupByDynamicPrimitivePropertyAndAggregateDynamicPrimitivePropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + Assert.Equal("Female", resultAt0.Value("Gender")); + Assert.Equal(930m, resultAt0.Value("SumCommission")); + Assert.Equal("Male", resultAt1.Value("Gender")); + Assert.Equal(190m, resultAt1.Value("SumCommission")); + } + + [Theory] + [InlineData("default/Employees?$apply=groupby((Gender),aggregate(BaseSalary with max as MaxSalary))")] + [InlineData("custom/Employees?$apply=groupby((Gender),aggregate(BaseSalary with max as MaxSalary))")] + public async Task TestGroupByDynamicPrimitivePropertyAndAggregatePrimitivePropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + Assert.Equal("Female", resultAt0.Value("Gender")); + Assert.Equal(1300m, resultAt0.Value("MaxSalary")); + Assert.Equal("Male", resultAt1.Value("Gender")); + Assert.Equal(1500m, resultAt1.Value("MaxSalary")); + } + + [Theory] + [InlineData("default/Sales?$apply=groupby((Quarter),aggregate(Amount with min as MinAmount))/groupby((Quarter))")] + [InlineData("custom/Sales?$apply=groupby((Quarter),aggregate(Amount with min as MinAmount))/groupby((Quarter))")] + public async Task TestGroupByPrimitivePropertyAndAggregatePrimitivePropertyThenGroupByPrimitivePropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(4, result.Count); + Assert.Equal("2022-1", Assert.IsType(result[0]).Value("Quarter")); + Assert.Equal("2022-2", Assert.IsType(result[1]).Value("Quarter")); + Assert.Equal("2022-3", Assert.IsType(result[2]).Value("Quarter")); + Assert.Equal("2022-4", Assert.IsType(result[3]).Value("Quarter")); + } + + [Theory] + [InlineData("default/Sales?$apply=compute(length(Product/Name) as ProductNameLength)/aggregate(ProductNameLength with sum as CombinedProductNameLength)")] + [InlineData("custom/Sales?$apply=compute(length(Product/Name) as ProductNameLength)/aggregate(ProductNameLength with sum as CombinedProductNameLength)")] + public async Task TestComputeNestedStringPropertyLengthThenAggregateComputedPropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(42, Assert.Single(result).Value("CombinedProductNameLength")); + } + + [Theory] + [InlineData("default/Sales?$apply=compute(length(Product/Name) as ProductNameLength)/aggregate(ProductNameLength add Id with sum as CombinedProductNameLength)")] + [InlineData("custom/Sales?$apply=compute(length(Product/Name) as ProductNameLength)/aggregate(ProductNameLength add Id with sum as CombinedProductNameLength)")] + public async Task TestComputeNestedStringPropertyLengthThenAggregateSumOfComputedAndPrimitivePropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(78, Assert.IsType(Assert.Single(result)).Value("CombinedProductNameLength")); + } + + [Theory] + [InlineData("default/Sales?$apply=compute(length(Product/Name) as ProductNameLength)/groupby((Product/Name),aggregate(Id with sum as Total, ProductNameLength with max as MaxProductNameLength))")] + [InlineData("custom/Sales?$apply=compute(length(Product/Name) as ProductNameLength)/groupby((Product/Name),aggregate(Id with sum as Total, ProductNameLength with max as MaxProductNameLength))")] + public async Task TestComputeNestedStringPropertyLengthThenGroupByNestedPropertyAndAggregatePrimitiveAndComputedPropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(3, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + Assert.Equal(5, resultAt0.Value("MaxProductNameLength")); + Assert.Equal(21, resultAt0.Value("Total")); + Assert.Equal("Paper", Assert.IsType(resultAt0.GetValue("Product")).Value("Name")); + Assert.Equal(5, resultAt1.Value("MaxProductNameLength")); + Assert.Equal(8, resultAt1.Value("Total")); + Assert.Equal("Sugar", Assert.IsType(resultAt1.GetValue("Product")).Value("Name")); + Assert.Equal(6, resultAt2.Value("MaxProductNameLength")); + Assert.Equal(7, resultAt2.Value("Total")); + Assert.Equal("Coffee", Assert.IsType(resultAt2.GetValue("Product")).Value("Name")); + } + + [Theory] + [InlineData("default/Employees?$apply=groupby((Address/City,Address/State))/groupby((Address/State),aggregate(Address/City with max as MaxCity))")] + [InlineData("custom/Employees?$apply=groupby((Address/City,Address/State))/groupby((Address/State),aggregate(Address/City with max as MaxCity))")] + public async Task TestGroupByMultipleNestedPropertiesThenGroupByNestedPropertyAndAggregateNestedPropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + Assert.Equal("Tacoma", resultAt0.Value("MaxCity")); + Assert.Equal("WA", Assert.IsType(resultAt0.GetValue("Address")).Value("State")); + Assert.Equal("London", resultAt1.Value("MaxCity")); + Assert.Equal("UK", Assert.IsType(resultAt1.GetValue("Address")).Value("State")); + } + + [Theory] + [InlineData("default/Employees?$apply=groupby((Company/VP/Address/City,Company/VP/Address/State))/groupby((Company/VP/Address/State),aggregate(Company/VP/Address/City with max as MaxCity))")] + [InlineData("custom/Employees?$apply=groupby((Company/VP/Address/City,Company/VP/Address/State))/groupby((Company/VP/Address/State),aggregate(Company/VP/Address/City with max as MaxCity))")] + public async Task TestGroupByMultipleMultiNestedPropertiesThenGroupByMultiNestedPropertyAndAggregateMultiNestedPropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + var resultAt0 = Assert.IsType(Assert.Single(result)); + Assert.Equal("Tacoma", resultAt0.Value("MaxCity")); + var company = Assert.IsType(resultAt0.GetValue("Company")); + var vp = Assert.IsType(company.GetValue("VP")); + var address = Assert.IsType(vp.GetValue("Address")); + Assert.Equal("WA", address.Value("State")); + } + + [Theory] + [InlineData("default/Employees?$apply=groupby((Company/VP/Name,Company/VP/BaseSalary))/groupby((Company/VP/BaseSalary),aggregate(Company/VP/Name with max as MaxName))")] + [InlineData("custom/Employees?$apply=groupby((Company/VP/Name,Company/VP/BaseSalary))/groupby((Company/VP/BaseSalary),aggregate(Company/VP/Name with max as MaxName))")] + public async Task TestGroupByMultipleMultiNestedPropertiesThenGroupByMultiNestedPropertyAndAggregateMultiNestedPropertyWithMaxAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + var resultAt0 = Assert.IsType(Assert.Single(result)); + Assert.Equal("Andrew Fuller", resultAt0.Value("MaxName")); + var company = Assert.IsType(resultAt0.GetValue("Company")); + var vp = Assert.IsType(company.GetValue("VP")); + Assert.Equal(1500m, vp.Value("BaseSalary")); + } + + [Theory] + [InlineData("default/Employees?$apply=groupby((Company/VP/Name,Company/VP/BaseSalary))/groupby((Company/VP/Name),aggregate(Company/VP/BaseSalary with average as AverageBaseSalary))")] + [InlineData("custom/Employees?$apply=groupby((Company/VP/Name,Company/VP/BaseSalary))/groupby((Company/VP/Name),aggregate(Company/VP/BaseSalary with average as AverageBaseSalary))")] + public async Task TestGroupByMultipleMultiNestedPropertiesThenGroupByMultiNestedPropertyAndAverageMultiNestedPropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + var resultAt0 = Assert.IsType(Assert.Single(result)); + Assert.Equal(1500m, resultAt0.Value("AverageBaseSalary")); + var company = Assert.IsType(resultAt0.GetValue("Company")); + var vp = Assert.IsType(company.GetValue("VP")); + Assert.Equal("Andrew Fuller", vp.Value("Name")); + } + + [Theory] + [InlineData("default/Employees?$apply=groupby((Address/State),aggregate(BaseSalary with min as MinBaseSalary))/groupby((Address/State))")] + [InlineData("custom/Employees?$apply=groupby((Address/State),aggregate(BaseSalary with min as MinBaseSalary))/groupby((Address/State))")] + public async Task TestGroupByNestedPropertyAndAggregatePrimitivePropertyThenGroupByNestedPropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + var addressAt0 = Assert.IsType(result[0]).GetValue("Address"); + var addressAt1 = Assert.IsType(result[1]).GetValue("Address"); + Assert.Equal("WA", Assert.IsType(addressAt0).Value("State")); + Assert.Equal("UK", Assert.IsType(addressAt1).Value("State")); + } + + [Theory] + [InlineData("default/Employees?$apply=compute(length(Name) as NameLen)/aggregate(NameLen with sum as TotalLen)")] + [InlineData("custom/Employees?$apply=compute(length(Name) as NameLen)/aggregate(NameLen with sum as TotalLen)")] + public async Task TestComputeStringPropertyLengthThenAggregateComputedPropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(55, Assert.IsType(Assert.Single(result)).Value("TotalLen")); + } + + [Theory] + [InlineData("default/Employees?$apply=compute(length(Name) as NameLen)/aggregate(NameLen add Id with sum as TotalLen)")] + [InlineData("custom/Employees?$apply=compute(length(Name) as NameLen)/aggregate(NameLen add Id with sum as TotalLen)")] + public async Task TestComputeStringPropertyLengthThenAggregateSumOfComputedPropertyAndPrimitivePropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(70, Assert.IsType(Assert.Single(result)).Value("TotalLen")); + } + + [Theory] + [InlineData("default/Employees?$apply=compute(length(Address/State) as StateLen)/groupby((Address/State),aggregate(Id with sum as Total,StateLen with max as MaxStateLen))")] + [InlineData("custom/Employees?$apply=compute(length(Address/State) as StateLen)/groupby((Address/State),aggregate(Id with sum as Total,StateLen with max as MaxStateLen))")] + public async Task TestComputeNestedStringPropertyLengthThenGroupByNestedPropertyAndAggregateComputedAndPrimitivePropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + Assert.Equal(2, resultAt0.Value("MaxStateLen")); + Assert.Equal(6, resultAt0.Value("Total")); + Assert.Equal("WA", Assert.IsType(resultAt0.GetValue("Address")).Value("State")); + Assert.Equal(2, resultAt1.Value("MaxStateLen")); + Assert.Equal(9, resultAt1.Value("Total")); + Assert.Equal("UK", Assert.IsType(resultAt1.GetValue("Address")).Value("State")); + } + + [Theory] + [InlineData("default/Employees?$apply=compute(length(Name) as NameLen)/groupby((NameLen),aggregate(Id with sum as Total))")] + [InlineData("custom/Employees?$apply=compute(length(Name) as NameLen)/groupby((NameLen),aggregate(Id with sum as Total))")] + public async Task TestComputeStringPropertyLengthThenGroupByComputedPropertyAndAggregatePrimitivePropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(3, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + var resultAt2 = Assert.IsType(result[2]); + Assert.Equal(13, resultAt0.Value("NameLen")); + Assert.Equal(3, resultAt0.Value("Total")); + Assert.Equal(15, resultAt1.Value("NameLen")); + Assert.Equal(3, resultAt1.Value("Total")); + Assert.Equal(14, resultAt2.Value("NameLen")); + Assert.Equal(9, resultAt2.Value("Total")); + } + + [Theory] + [InlineData("default/Employees?$apply=compute(length(Address/City) as CityLength)/groupby((Address/State),aggregate(Address/City with max as MaxCity,Address/City with min as MinCity,CityLength with max as MaxCityLen))")] + [InlineData("custom/Employees?$apply=compute(length(Address/City) as CityLength)/groupby((Address/State),aggregate(Address/City with max as MaxCity,Address/City with min as MinCity,CityLength with max as MaxCityLen))")] + public async Task TestComputeNestedStringPropertyLengthThenGroupByNestedPropertyAndAggregateComputedAndNestedPropertiesAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + Assert.Equal(8, resultAt0.Value("MaxCityLen")); + Assert.Equal("Kirkland", resultAt0.Value("MinCity")); + Assert.Equal("Tacoma", resultAt0.Value("MaxCity")); + Assert.Equal("WA", Assert.IsType(resultAt0.GetValue("Address")).Value("State")); + Assert.Equal(6, resultAt1.Value("MaxCityLen")); + Assert.Equal("London", resultAt1.Value("MinCity")); + Assert.Equal("London", resultAt1.Value("MaxCity")); + Assert.Equal("UK", Assert.IsType(resultAt1.GetValue("Address")).Value("State")); + } + + [Theory] + [InlineData("default/Employees?$apply=groupby((Gender),aggregate(BaseSalary with max as MaxSalary))&$orderby=MaxSalary desc")] + [InlineData("custom/Employees?$apply=groupby((Gender),aggregate(BaseSalary with max as MaxSalary))&$orderby=MaxSalary desc")] + public async Task TestGroupByPrimitivePropertyAndAggregatePrimitivePropertyThenOrderByAggregatedPropertyAsync(string queryUrl) + { + // Arrange & Act + var response = await SetupAndFireRequestAsync(queryUrl); + + // Assert + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + var content = await response.Content.ReadAsObject(); + var result = content.GetValue("value") as JArray; + Assert.NotNull(result); + Assert.Equal(2, result.Count); + var resultAt0 = Assert.IsType(result[0]); + var resultAt1 = Assert.IsType(result[1]); + Assert.Equal("Male", resultAt0.Value("Gender")); + Assert.Equal(1500m, resultAt0.Value("MaxSalary")); + Assert.Equal("Female", resultAt1.Value("Gender")); + Assert.Equal(1300m, resultAt1.Value("MaxSalary")); + } + + private Task SetupAndFireRequestAsync(string queryUrl) + { + var request = new HttpRequestMessage(HttpMethod.Get, queryUrl); + request.Headers.Accept.Add(MediaTypeWithQualityHeaderValue.Parse("application/json;odata.metadata=none")); + var client = CreateClient(); + + return client.SendAsync(request); + } + } +} +#endif diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Container/TestAggregationPropertyContainer.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Container/TestAggregationPropertyContainer.cs new file mode 100644 index 000000000..0f718b500 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Container/TestAggregationPropertyContainer.cs @@ -0,0 +1,112 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Diagnostics.Contracts; +using System.Linq.Expressions; +using Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Wrapper; +using Microsoft.AspNetCore.OData.Query.Container; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Container +{ + internal class TestAggregationPropertyContainer : IAggregationPropertyContainer + { + public string Name { get; set; } + + public object Value { get; set; } + + public TestGroupByWrapper NestedValue + { + get { return (TestGroupByWrapper)this.Value; } + set { Value = value; } + } + + public IAggregationPropertyContainer Next { get; set; } + + public void ToDictionaryCore( + Dictionary dictionary, + IPropertyMapper propertyMapper, + bool includeAutoSelected) + { + Contract.Assert(dictionary != null); + + if (Name != null && includeAutoSelected) + { + string mappedName = propertyMapper.MapProperty(Name); + if (mappedName != null) + { + if (String.IsNullOrEmpty(mappedName)) + { + throw Error.InvalidOperation(SRResources.InvalidPropertyMapping, Name); + } + + dictionary.Add(mappedName, Value); + } + } + + if (Next != null) + { + Next.ToDictionaryCore(dictionary, propertyMapper, includeAutoSelected); + } + } + + public static Expression CreateNextNamedPropertyContainer(IList namedProperties) + { + Expression container = null; + + // Build the linked list of properties + for (int i = 0; i < namedProperties.Count; i++) + { + var property = namedProperties[i]; + Type namedPropertyType = null; + if (container != null) + { + namedPropertyType = (property.Value.Type == typeof(TestGroupByWrapper)) ? typeof(NestedProperty) : typeof(TestAggregationPropertyContainer); + } + else + { + namedPropertyType = (property.Value.Type == typeof(TestGroupByWrapper)) ? typeof(NestedPropertyLastInChain) : typeof(LastInChain); + } + + var bindings = new List + { + Expression.Bind(namedPropertyType.GetProperty("Name"), property.Name) + }; + + if (property.Value.Type == typeof(TestGroupByWrapper)) + { + bindings.Add(Expression.Bind(namedPropertyType.GetProperty("NestedValue"), property.Value)); + } + else + { + bindings.Add(Expression.Bind(namedPropertyType.GetProperty("Value"), property.Value)); + } + + if (container != null) + { + bindings.Add(Expression.Bind(namedPropertyType.GetProperty("Next"), container)); + } + + if (property.NullCheck != null) + { + bindings.Add(Expression.Bind(namedPropertyType.GetProperty("IsNull"), property.NullCheck)); + } + + container = Expression.MemberInit(Expression.New(namedPropertyType), bindings); + } + + return container; + } + + private class NestedProperty : TestAggregationPropertyContainer { } + + private class LastInChain : TestAggregationPropertyContainer { } + + private class NestedPropertyLastInChain : TestAggregationPropertyContainer { } + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Container/TestPropertyMapper.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Container/TestPropertyMapper.cs new file mode 100644 index 000000000..414616d9b --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Container/TestPropertyMapper.cs @@ -0,0 +1,19 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using Microsoft.AspNetCore.OData.Query.Container; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Container +{ + internal class TestPropertyMapper : IPropertyMapper + { + public string MapProperty(string propertyName) + { + return propertyName; + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Expressions/TestAggregationBinder.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Expressions/TestAggregationBinder.cs new file mode 100644 index 000000000..011adb8ab --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Expressions/TestAggregationBinder.cs @@ -0,0 +1,469 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Diagnostics.Contracts; +using System.Globalization; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using Microsoft.AspNetCore.OData.Query; +using Microsoft.AspNetCore.OData.Query.Container; +using Microsoft.AspNetCore.OData.Query.Expressions; +using Microsoft.OData.Edm; +using Microsoft.OData; +using Microsoft.OData.UriParser.Aggregation; +using Microsoft.AspNetCore.OData.Edm; +using Microsoft.OData.UriParser; +using Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Wrapper; +using Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Container; + +namespace Microsoft.AspNetCore.OData.TestCommon.Query.Expressions +{ + public class TestAggregationBinder : QueryBinder, IAggregationBinder + { + private const string GroupingPropertiesContainerProperty = "GroupByContainer"; + private const string AggregationPropertiesContainerProperty = "Container"; + + public virtual Expression BindGroupBy(TransformationNode transformationNode, QueryBinderContext context) + { + Debug.Assert(transformationNode != null, "transformationNode != null"); + Debug.Assert(context != null, "context != null"); + + if (transformationNode is GroupByTransformationNode groupByTransformationNode && groupByTransformationNode.GroupingProperties?.Any() == true) + { + var groupingProperties = CreateGroupByMemberAssignments(groupByTransformationNode.GroupingProperties, context); + + var groupingPropertiesContainerProperty = typeof(TestGroupByWrapper).GetProperty(GroupingPropertiesContainerProperty); + var memberAssignments = new List(capacity: 1) + { + Expression.Bind( + typeof(TestGroupByWrapper).GetProperty(GroupingPropertiesContainerProperty), + TestAggregationPropertyContainer.CreateNextNamedPropertyContainer(groupingProperties)) + }; + + return Expression.Lambda( + Expression.MemberInit(Expression.New(typeof(TestGroupByWrapper)), memberAssignments), + context.CurrentParameter); + } + + return Expression.Lambda(Expression.New(typeof(TestGroupByWrapper)), context.CurrentParameter); + } + + public virtual Expression BindSelect(TransformationNode transformationNode, QueryBinderContext context) + { + Debug.Assert(transformationNode != null, "transformationNode != null"); + Debug.Assert(context != null, "context != null"); + + var groupByClrType = typeof(TestGroupByWrapper); + var groupingType = typeof(IGrouping<,>).MakeGenericType(groupByClrType, context.TransformationElementType); + var resultClrType = typeof(TestGroupByWrapper); + + var groupingParam = Expression.Parameter(groupingType, "$it"); + var memberAssignments = new List(); + + if (transformationNode is GroupByTransformationNode groupByTransformationNode && groupByTransformationNode.GroupingProperties?.Any() == true) + { + memberAssignments.Add( + Expression.Bind( + resultClrType.GetProperty(GroupingPropertiesContainerProperty), + Expression.Property(Expression.Property(groupingParam, "Key"), GroupingPropertiesContainerProperty) + )); + } + + // If there are aggregate expressions + var aggregateExpressions = GetAggregateExpressions(transformationNode, context); + if (aggregateExpressions?.Any() == true) + { + var aggregationProperties = new List(); + foreach (var aggregateExpr in aggregateExpressions) + { + // For simplicity, we handle property aggregations + if (!(aggregateExpr.AggregateKind == AggregateExpressionKind.PropertyAggregate)) + { + throw new NotSupportedException("Aggregate expression kind not supported."); + } + + var propertyAggregateExpr = CreatePropertyAggregateExpression(groupingParam, aggregateExpr as AggregateExpression, context.TransformationElementType, context); + aggregationProperties.Add(new NamedPropertyExpression(Expression.Constant(aggregateExpr.Alias), propertyAggregateExpr)); + } + + memberAssignments.Add( + Expression.Bind(resultClrType.GetProperty(AggregationPropertiesContainerProperty), + TestAggregationPropertyContainer.CreateNextNamedPropertyContainer(aggregationProperties))); + } + + return Expression.Lambda( + Expression.MemberInit(Expression.New(resultClrType), memberAssignments), + groupingParam); + } + + public virtual IQueryable FlattenReferencedProperties( + TransformationNode transformationNode, + IQueryable query, + QueryBinderContext context, + out ParameterExpression contextParameter, + out IDictionary flattenedPropertiesMap) + { + if (transformationNode == null) + { + throw Error.ArgumentNull(nameof(transformationNode)); + } + + if (query == null) + { + throw Error.ArgumentNull(nameof(query)); + } + + if (context == null) + { + throw Error.ArgumentNull(nameof(context)); + } + + flattenedPropertiesMap = null; + contextParameter = context.CurrentParameter; + + if (context.FlattenedProperties?.Any() == true) + { + return query; + } + + IEnumerable groupingProperties = null; + if (transformationNode.Kind == TransformationNodeKind.GroupBy) + { + groupingProperties = (transformationNode as GroupByTransformationNode)?.GroupingProperties; + } + + // Aggregate expressions to flatten - excludes VirtualPropertyCount + List aggregateExpressions = GetAggregateExpressions(transformationNode, context)?.OfType() + .Where(e => e.Method != AggregationMethod.VirtualPropertyCount).ToList(); + + if ((aggregateExpressions?.Count ?? 0) == 0 || !(groupingProperties?.Any() == true)) + { + return query; + } + + Type wrapperType = typeof(TestFlatteningWrapper<>).MakeGenericType(context.TransformationElementType); + PropertyInfo sourceProperty = wrapperType.GetProperty(QueryConstants.FlatteningWrapperSourceProperty); + List wrapperTypeMemberAssignments = new List + { + Expression.Bind(sourceProperty, context.CurrentParameter) + }; + + // Generated Select will be stack-like; meaning that first property in the list will be deepest one + // For example if we add $it.B.C, $it.B.D, Select will look like + // new { + // Value = $it.B.C + // Next = new { + // Value = $it.B.D + // } + // } + + // We are generating references (in currentContainerExpr) from the beginning of the Select ($it.Value, then $it.Next.Value etc.) + // We have proper match we need insert properties in reverse order + // After this, + // properties = { $it.B.D, $it.B.C } + // PreFlattenedMap = { {$it.B.C, $it.Value}, {$it.B.D, $it.Next.Value} } + + int aliasIdx = aggregateExpressions.Count - 1; + NamedPropertyExpression[] properties = new NamedPropertyExpression[aggregateExpressions.Count]; + + flattenedPropertiesMap = new Dictionary(aggregateExpressions.Count); + contextParameter = Expression.Parameter(wrapperType, "$it"); + MemberExpression containerExpr = Expression.Property(contextParameter, GroupingPropertiesContainerProperty); + + for (int i = 0; i < aggregateExpressions.Count; i++) + { + AggregateExpression aggregateExpression = aggregateExpressions[i]; + + string alias = string.Concat("Property", aliasIdx.ToString(CultureInfo.CurrentCulture)); // We just need unique alias, we aren't going to use it + + // Add Value = $it.B.C + Expression propertyAccessExpression = BindAccessExpression(aggregateExpression.Expression, context); + Type type = propertyAccessExpression.Type; + propertyAccessExpression = WrapConvert(propertyAccessExpression); + properties[aliasIdx] = new NamedPropertyExpression(Expression.Constant(alias), propertyAccessExpression); + + // Save $it.Container.Next.Value for future use + UnaryExpression flattenedAccessExpression = Expression.Convert( + Expression.Property(containerExpr, "Value"), + type); + containerExpr = Expression.Property(containerExpr, "Next"); + flattenedPropertiesMap.Add(aggregateExpression.Expression, flattenedAccessExpression); + aliasIdx--; + } + + PropertyInfo wrapperProperty = typeof(TestGroupByWrapper).GetProperty(GroupingPropertiesContainerProperty); + + wrapperTypeMemberAssignments.Add(Expression.Bind(wrapperProperty, TestAggregationPropertyContainer.CreateNextNamedPropertyContainer(properties))); + + LambdaExpression flattenedLambda = Expression.Lambda(Expression.MemberInit(Expression.New(wrapperType), wrapperTypeMemberAssignments), context.CurrentParameter); + + query = ExpressionHelpers.Select(query, flattenedLambda, context.TransformationElementType); + + return query; + } + + private IList CreateGroupByMemberAssignments(IEnumerable groupByPropertyNodes, QueryBinderContext context) + { + var namedProperties = new List(); + + foreach (var groupByPropertyNode in groupByPropertyNodes) + { + if (groupByPropertyNode.Expression != null) + { + namedProperties.Add(new NamedPropertyExpression( + Expression.Constant(groupByPropertyNode.Name), + WrapConvert(BindAccessExpression(groupByPropertyNode.Expression, context)))); + } + else + { + var memberAssignments = new List(capacity: 1) + { + Expression.Bind( + typeof(TestGroupByWrapper).GetProperty(GroupingPropertiesContainerProperty), + TestAggregationPropertyContainer.CreateNextNamedPropertyContainer( + CreateGroupByMemberAssignments(groupByPropertyNode.ChildTransformations, context))) + }; + + namedProperties.Add(new NamedPropertyExpression( + Expression.Constant(groupByPropertyNode.Name), + Expression.MemberInit(Expression.New(typeof(TestGroupByWrapper)), memberAssignments))); + } + } + + return namedProperties; + } + + private Expression CreatePropertyAggregateExpression(ParameterExpression groupingParam, AggregateExpression aggregateExpr, Type baseType, QueryBinderContext context) + { + var queryableType = typeof(IEnumerable<>).MakeGenericType(baseType); + var queryableExpr = Expression.Convert(groupingParam, queryableType); + + if (aggregateExpr.Method == AggregationMethod.VirtualPropertyCount) + { + MethodInfo countMethod = ExpressionHelperMethods.EnumerableCountGeneric.MakeGenericMethod(baseType); + return WrapConvert(Expression.Call(null, countMethod, queryableExpr)); + } + + var lambdaParam = baseType == context.TransformationElementType ? context.CurrentParameter : Expression.Parameter(baseType, "$it"); + if (!(context.FlattenedPropertiesMap?.TryGetValue(aggregateExpr.Expression, out Expression body) == true)) + { + body = BindAccessExpression(aggregateExpr.Expression, context, lambdaParam); + } + + var propertyLambda = Expression.Lambda(body, lambdaParam); + + Expression propertyAggregateExpr; + + switch (aggregateExpr.Method) + { + case AggregationMethod.Min: + var minMethod = ExpressionHelperMethods.EnumerableMin.MakeGenericMethod(baseType, propertyLambda.Body.Type); + propertyAggregateExpr = Expression.Call(null, minMethod, queryableExpr, propertyLambda); + + break; + case AggregationMethod.Max: + var maxMethod = ExpressionHelperMethods.EnumerableMax.MakeGenericMethod(baseType, propertyLambda.Body.Type); + propertyAggregateExpr = Expression.Call(null, maxMethod, queryableExpr, propertyLambda); + + break; + case AggregationMethod.Sum: + { + // For dynamic properties, cast dynamic to decimal + var propertyDynamicCastExpr = WrapDynamicCastIfNeeded(body); + propertyLambda = Expression.Lambda(propertyDynamicCastExpr, lambdaParam); + + if (!ExpressionHelperMethods.EnumerableSumGenerics.TryGetValue(propertyDynamicCastExpr.Type, out MethodInfo sumGenericMethod)) + { + throw new NotSupportedException( + $"Aggregation '{aggregateExpr.Method}' not supported for property '{aggregateExpr.Expression}' of type '{propertyDynamicCastExpr.Type}'."); + } + + var sumMethod = sumGenericMethod.MakeGenericMethod(baseType); + propertyAggregateExpr = Expression.Call(null, sumMethod, queryableExpr, propertyLambda); + + // For dynamic properties, cast dynamic to back to object + if (propertyLambda.Type == typeof(object)) + { + propertyAggregateExpr = Expression.Convert(propertyAggregateExpr, typeof(object)); + } + } + + break; + case AggregationMethod.Average: + { + // For dynamic properties, cast dynamic to decimal + var propertyDynamicCastExpr = WrapDynamicCastIfNeeded(body); + propertyLambda = Expression.Lambda(propertyDynamicCastExpr, lambdaParam); + + if (!ExpressionHelperMethods.EnumerableAverageGenerics.TryGetValue(propertyDynamicCastExpr.Type, out MethodInfo averageGenericMethod)) + { + throw new NotSupportedException( + $"Aggregation '{aggregateExpr.Method}' not supported for property '{aggregateExpr.Expression}' of type '{propertyDynamicCastExpr.Type}'."); + } + + var averageMethod = averageGenericMethod.MakeGenericMethod(baseType); + propertyAggregateExpr = Expression.Call(null, averageMethod, queryableExpr, propertyLambda); + + // For dynamic properties, cast dynamic to back to object + if (propertyLambda.Type == typeof(object)) + { + propertyAggregateExpr = Expression.Convert(propertyAggregateExpr, typeof(object)); + } + } + + break; + case AggregationMethod.CountDistinct: + { + MethodInfo selectMethod = ExpressionHelperMethods.EnumerableSelectGeneric.MakeGenericMethod( + context.TransformationElementType, + propertyLambda.Body.Type); + + Expression queryableSelectExpr = Expression.Call(null, selectMethod, queryableExpr, propertyLambda); + + // Run distinct over the set of items + MethodInfo distinctMethod = ExpressionHelperMethods.EnumerableDistinct.MakeGenericMethod(propertyLambda.Body.Type); + Expression distinctExpr = Expression.Call(null, distinctMethod, queryableSelectExpr); + + // Count the distinct items as the aggregation expression + MethodInfo countMethod = ExpressionHelperMethods.EnumerableCountGeneric.MakeGenericMethod(propertyLambda.Body.Type); + propertyAggregateExpr = Expression.Call(null, countMethod, distinctExpr); + } + + break; + case AggregationMethod.Custom: + { + var customMethod = GetCustomMethod(aggregateExpr, context); + var selectMethod = ExpressionHelperMethods.EnumerableSelectGeneric.MakeGenericMethod(context.TransformationElementType, propertyLambda.Body.Type); + var queryableSelectExpr = Expression.Call(null, selectMethod, queryableExpr, propertyLambda); + propertyAggregateExpr = Expression.Call(null, customMethod, queryableSelectExpr); + } + + break; + default: + throw new NotSupportedException($"{aggregateExpr.Method} method not supported"); + } + + return WrapConvert(propertyAggregateExpr); + } + + private static Expression WrapDynamicCastIfNeeded(Expression propertyAccessExpr) + { + if (propertyAccessExpr.Type == typeof(object)) + { + return Expression.Call(null, ExpressionHelperMethods.ConvertToDecimal, propertyAccessExpr); + } + + return propertyAccessExpr; + } + + /// + /// Gets a collection of from a . + /// + /// The query binder context. + /// The . + /// A collection of aggregate expressions. + private IEnumerable GetAggregateExpressions(TransformationNode transformationNode, QueryBinderContext context) + { + Contract.Assert(transformationNode != null); + Contract.Assert(context != null); + + IEnumerable aggregateExpressions = null; + + switch (transformationNode.Kind) + { + case TransformationNodeKind.Aggregate: + AggregateTransformationNode aggregateClause = transformationNode as AggregateTransformationNode; + return aggregateClause.AggregateExpressions.Select(exp => + { + AggregateExpression aggregationExpr = exp as AggregateExpression; + + return aggregationExpr?.Method == AggregationMethod.Custom ? FixCustomMethodReturnType(aggregationExpr, context) : exp; + }); + + case TransformationNodeKind.GroupBy: + GroupByTransformationNode groupByClause = transformationNode as GroupByTransformationNode; + if (groupByClause.ChildTransformations != null) + { + if (groupByClause.ChildTransformations.Kind == TransformationNodeKind.Aggregate) + { + AggregateTransformationNode aggregationNode = groupByClause.ChildTransformations as AggregateTransformationNode; + return aggregationNode.AggregateExpressions.Select(exp => + { + AggregateExpression aggregationExpr = exp as AggregateExpression; + + return aggregationExpr?.Method == AggregationMethod.Custom ? FixCustomMethodReturnType(aggregationExpr, context) : exp; + }); + } + else + { + throw new NotSupportedException( + $"Transformation kind '{groupByClause.ChildTransformations.Kind}' is not supported as a child transformation of kind '{transformationNode.Kind}'"); + } + } + + break; + + default: + throw new NotSupportedException(string.Format( + CultureInfo.InvariantCulture, + SRResources.NotSupportedTransformationKind, + transformationNode.Kind)); + } + + return aggregateExpressions; + } + + /// + /// Fixes return type for custom aggregation method. + /// + /// The aggregation expression + /// The query binder context. + /// The + private AggregateExpression FixCustomMethodReturnType(AggregateExpression aggregationExpression, QueryBinderContext context) + { + Debug.Assert(aggregationExpression != null, $"{nameof(aggregationExpression)} != null"); + Debug.Assert(aggregationExpression.Method == AggregationMethod.Custom, $"{nameof(aggregationExpression)}.Method == {nameof(AggregationMethod.Custom)}"); + + MethodInfo customMethod = GetCustomMethod(aggregationExpression, context); + + IEdmPrimitiveTypeReference typeReference = context.Model.GetEdmPrimitiveTypeReference(customMethod.ReturnType); + + return new AggregateExpression(aggregationExpression.Expression, aggregationExpression.MethodDefinition, aggregationExpression.Alias, typeReference); + } + + /// + /// Gets a custom aggregation method for the aggregation expression. + /// + /// The aggregation expression. + /// The query binder context. + /// The custom method. + private MethodInfo GetCustomMethod(AggregateExpression aggregationExpression, QueryBinderContext context) + { + LambdaExpression propertyLambda = Expression.Lambda(BindAccessExpression(aggregationExpression.Expression, context), context.CurrentParameter); + Type inputType = propertyLambda.Body.Type; + + string methodToken = aggregationExpression.MethodDefinition.MethodLabel; + CustomAggregateMethodAnnotation customMethodAnnotations = context.Model.GetAnnotationValue(context.Model); + + MethodInfo customMethod; + if (!customMethodAnnotations.GetMethodInfo(methodToken, inputType, out customMethod)) + { + throw new ODataException(Error.Format( + SRResources.AggregationNotSupportedForType, + aggregationExpression.Method, + aggregationExpression.Expression, + inputType)); + } + + return customMethod; + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Wrapper/TestFlatteningWrapper.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Wrapper/TestFlatteningWrapper.cs new file mode 100644 index 000000000..877dd8dec --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Wrapper/TestFlatteningWrapper.cs @@ -0,0 +1,36 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Container; +using Microsoft.AspNetCore.OData.Query.Wrapper; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Wrapper +{ + internal class TestFlatteningWrapper : TestGroupByWrapper, IGroupByWrapper, IFlatteningWrapper + { + public T Source { get; set; } + } + + internal class TestFlatteningWrapperConverter : JsonConverter> + { + public override TestFlatteningWrapper Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(Error.Format(SRResources.JsonConverterDoesnotSupportRead, typeof(TestFlatteningWrapper<>).Name)); + } + + public override void Write(Utf8JsonWriter writer, TestFlatteningWrapper value, JsonSerializerOptions options) + { + if (value != null) + { + JsonSerializer.Serialize(writer, value.Values, options); + } + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Wrapper/TestGroupByWrapper.cs b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Wrapper/TestGroupByWrapper.cs new file mode 100644 index 000000000..c3d64c082 --- /dev/null +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/DollarApply/Query/Wrapper/TestGroupByWrapper.cs @@ -0,0 +1,117 @@ +//----------------------------------------------------------------------------- +// +// Copyright (c) .NET Foundation and Contributors. All rights reserved. +// See License.txt in the project root for license information. +// +//------------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.OData.Common; +using Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Container; +using Microsoft.AspNetCore.OData.Query.Container; +using Microsoft.AspNetCore.OData.Query.Wrapper; + +namespace Microsoft.AspNetCore.OData.E2E.Tests.DollarApply.Query.Wrapper +{ + /// + /// Test wrapper for GroupBy and aggregation transformations. + /// + /// Overriding Equals and GetHashCode is especially important where input source is an in-memory collection of objects. + internal class TestGroupByWrapper : DynamicTypeWrapper, IGroupByWrapper + { + private Dictionary values; + protected static readonly IPropertyMapper testPropertyMapper = new TestPropertyMapper(); + + /// + /// Gets or sets the property container that contains the grouping properties + /// + public TestAggregationPropertyContainer GroupByContainer { get; set; } + + /// + /// Gets or sets the property container that contains the aggregation properties + /// + public TestAggregationPropertyContainer Container { get; set; } + + public override Dictionary Values + { + get + { + EnsureValues(); + + return this.values; + } + } + + private void EnsureValues() + { + if (this.values == null) + { + if (this.GroupByContainer != null) + { + Dictionary dictionary = new Dictionary(); + + this.GroupByContainer.ToDictionaryCore(dictionary, testPropertyMapper, true); + this.values = dictionary; + } + else + { + this.values = new Dictionary(); + } + + if (this.Container != null) + { + Dictionary dictionary = new Dictionary(); + + this.Container.ToDictionaryCore(dictionary, testPropertyMapper, true); + this.values.MergeWithReplace(dictionary); + } + } + } + + /// + public override bool Equals(object obj) + { + var compareWith = obj as TestGroupByWrapper; + if (compareWith == null) + { + return false; + } + var dictionary1 = this.Values; + var dictionary2 = compareWith.Values; + return dictionary1.Count == dictionary2.Count && !dictionary1.Except(dictionary2).Any(); + } + + /// + public override int GetHashCode() + { + EnsureValues(); + long hash = 1870403278L; //Arbitrary number from Anonymous Type GetHashCode implementation + foreach (var v in this.Values.Values) + { + hash = (hash * -1521134295L) + (v == null ? 0 : v.GetHashCode()); + } + + return (int)hash; + } + } + + internal class TestGroupByWrapperConverter : JsonConverter + { + public override TestGroupByWrapper Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + throw new NotImplementedException(); + } + + public override void Write(Utf8JsonWriter writer, TestGroupByWrapper wrapper, JsonSerializerOptions options) + { + if (wrapper != null) + { + JsonSerializer.Serialize(writer, wrapper.Values, options); + } + } + } +} diff --git a/test/Microsoft.AspNetCore.OData.E2E.Tests/Microsoft.AspNetCore.OData.E2E.Tests.csproj b/test/Microsoft.AspNetCore.OData.E2E.Tests/Microsoft.AspNetCore.OData.E2E.Tests.csproj index 803a12eb9..0285e4c5b 100644 --- a/test/Microsoft.AspNetCore.OData.E2E.Tests/Microsoft.AspNetCore.OData.E2E.Tests.csproj +++ b/test/Microsoft.AspNetCore.OData.E2E.Tests/Microsoft.AspNetCore.OData.E2E.Tests.csproj @@ -11,12 +11,6 @@ - - - - - - diff --git a/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.Net5.bsl b/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.Net5.bsl index 030b3a7eb..608548f9e 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.Net5.bsl +++ b/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.Net5.bsl @@ -2923,6 +2923,24 @@ public class Microsoft.AspNetCore.OData.Query.Validator.TopQueryValidator { public virtual void Validate (Microsoft.AspNetCore.OData.Query.TopQueryOption topQueryOption, Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) } +public interface Microsoft.AspNetCore.OData.Query.Wrapper.IAggregationPropertyContainer`1 { + string Name { public abstract get; public abstract set; } + T NestedValue { public abstract get; public abstract set; } + Microsoft.AspNetCore.OData.Query.Wrapper.IAggregationPropertyContainer`1 Next { public abstract get; public abstract set; } + object Value { public abstract get; public abstract set; } + + void ToDictionaryCore (System.Collections.Generic.Dictionary`2[[System.String],[System.Object]] dictionary, Microsoft.AspNetCore.OData.Query.Container.IPropertyMapper propertyMapper, bool includeAutoSelected) +} + +public interface Microsoft.AspNetCore.OData.Query.Wrapper.IFlatteningWrapper`1 { + T Source { public abstract get; public abstract set; } +} + +public interface Microsoft.AspNetCore.OData.Query.Wrapper.IGroupByWrapper`1 { + T Container { public abstract get; public abstract set; } + T GroupByContainer { public abstract get; public abstract set; } +} + public interface Microsoft.AspNetCore.OData.Query.Wrapper.ISelectExpandWrapper { System.Collections.Generic.IDictionary`2[[System.String],[System.Object]] ToDictionary () System.Collections.Generic.IDictionary`2[[System.String],[System.Object]] ToDictionary (System.Func`3[[Microsoft.OData.Edm.IEdmModel],[Microsoft.OData.Edm.IEdmStructuredType],[Microsoft.AspNetCore.OData.Query.Container.IPropertyMapper]] propertyMapperProvider) diff --git a/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.Net6.bsl b/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.Net6.bsl index 14d7864c4..d865471d8 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.Net6.bsl +++ b/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.Net6.bsl @@ -2761,6 +2761,12 @@ public class Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection`1 : System.Nullable`1[[System.Int64]] TotalCount { public virtual get; } } +public interface Microsoft.AspNetCore.OData.Query.Expressions.IAggregationBinder { + System.Linq.Expressions.Expression BindGroupBy (Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + System.Linq.Expressions.Expression BindSelect (Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + System.Linq.IQueryable FlattenReferencedProperties (Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, System.Linq.IQueryable query, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, out System.Linq.Expressions.ParameterExpression& contextParameter, out System.Collections.Generic.IDictionary`2[[Microsoft.OData.UriParser.SingleValueNode, Microsoft.OData.Core, Version=7.21.6.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35],[System.Linq.Expressions.Expression, System.Linq.Expressions, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a]]& flattenedPropertiesMap) +} + public interface Microsoft.AspNetCore.OData.Query.Expressions.IFilterBinder { System.Linq.Expressions.Expression BindFilter (Microsoft.OData.UriParser.FilterClause filterClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) } @@ -2795,6 +2801,7 @@ public abstract class Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder { protected static System.Linq.Expressions.Expression ApplyNullPropagationForFilterBody (System.Linq.Expressions.Expression body, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) public virtual System.Linq.Expressions.Expression Bind (Microsoft.OData.UriParser.QueryNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + public virtual System.Linq.Expressions.Expression BindAccessExpression (Microsoft.OData.UriParser.QueryNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, params System.Linq.Expressions.Expression baseElement) public virtual System.Linq.Expressions.Expression BindAllNode (Microsoft.OData.UriParser.AllNode allNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) public virtual System.Linq.Expressions.Expression BindAnyNode (Microsoft.OData.UriParser.AnyNode anyNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) protected System.Linq.Expressions.Expression[] BindArguments (System.Collections.Generic.IEnumerable`1[[Microsoft.OData.UriParser.QueryNode]] nodes, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) @@ -2843,9 +2850,11 @@ public abstract class Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder { protected virtual System.Linq.Expressions.Expression BindToUpper (Microsoft.OData.UriParser.SingleValueFunctionCallNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) protected virtual System.Linq.Expressions.Expression BindTrim (Microsoft.OData.UriParser.SingleValueFunctionCallNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) public virtual System.Linq.Expressions.Expression BindUnaryOperatorNode (Microsoft.OData.UriParser.UnaryOperatorNode unaryOperatorNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + public virtual System.Linq.Expressions.Expression CreateOpenPropertyAccessExpression (Microsoft.OData.UriParser.SingleValueOpenPropertyAccessNode openNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) protected static System.Reflection.PropertyInfo GetDynamicPropertyContainer (Microsoft.OData.UriParser.CollectionOpenPropertyAccessNode openCollectionNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) protected static System.Reflection.PropertyInfo GetDynamicPropertyContainer (Microsoft.OData.UriParser.SingleValueOpenPropertyAccessNode openNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) protected System.Linq.Expressions.Expression GetFlattenedPropertyExpression (string propertyPath, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + protected static System.Linq.Expressions.Expression WrapConvert (System.Linq.Expressions.Expression expression) } [ @@ -2882,6 +2891,11 @@ public sealed class Microsoft.AspNetCore.OData.Query.Expressions.BinderExtension ] public static object ApplyBind (Microsoft.AspNetCore.OData.Query.Expressions.ISelectExpandBinder binder, object source, Microsoft.OData.UriParser.SelectExpandClause selectExpandClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + [ + ExtensionAttribute(), + ] + public static System.Linq.IQueryable ApplyBind (Microsoft.AspNetCore.OData.Query.Expressions.IAggregationBinder binder, System.Linq.IQueryable source, Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, out System.Type& resultClrType) + [ ExtensionAttribute(), ] @@ -2893,6 +2907,14 @@ public sealed class Microsoft.AspNetCore.OData.Query.Expressions.BinderExtension public static System.Linq.IQueryable ApplyBind (Microsoft.AspNetCore.OData.Query.Expressions.IOrderByBinder binder, System.Linq.IQueryable query, Microsoft.OData.UriParser.OrderByClause orderByClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, bool alreadyOrdered) } +public class Microsoft.AspNetCore.OData.Query.Expressions.AggregationBinder : Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder, IAggregationBinder { + public AggregationBinder () + + public virtual System.Linq.Expressions.Expression BindGroupBy (Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + public virtual System.Linq.Expressions.Expression BindSelect (Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + public virtual System.Linq.IQueryable FlattenReferencedProperties (Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, System.Linq.IQueryable query, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, out System.Linq.Expressions.ParameterExpression& contextParameter, out System.Collections.Generic.IDictionary`2[[Microsoft.OData.UriParser.SingleValueNode, Microsoft.OData.Core, Version=7.21.6.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35],[System.Linq.Expressions.Expression, System.Linq.Expressions, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a]]& flattenedPropertiesMap) +} + public class Microsoft.AspNetCore.OData.Query.Expressions.FilterBinder : Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder, IFilterBinder { public FilterBinder () @@ -2922,10 +2944,12 @@ public class Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext { System.Linq.Expressions.ParameterExpression CurrentParameter { public get; } System.Type ElementClrType { public get; } Microsoft.OData.Edm.IEdmType ElementType { public get; } + System.Collections.Generic.IDictionary`2[[Microsoft.OData.UriParser.SingleValueNode],[System.Linq.Expressions.Expression]] FlattenedPropertiesMap { public get; } Microsoft.OData.Edm.IEdmModel Model { public get; } Microsoft.OData.Edm.IEdmNavigationSource NavigationSource { public get; public set; } Microsoft.AspNetCore.OData.Query.ODataQuerySettings QuerySettings { public get; } System.Linq.Expressions.Expression Source { public get; public set; } + System.Type TransformationElementType { public get; } public System.Linq.Expressions.ParameterExpression GetParameter (string name) public void RemoveParameter (string name) @@ -3158,6 +3182,24 @@ public class Microsoft.AspNetCore.OData.Query.Validator.TopQueryValidator : ITop public virtual void Validate (Microsoft.AspNetCore.OData.Query.TopQueryOption topQueryOption, Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) } +public interface Microsoft.AspNetCore.OData.Query.Wrapper.IAggregationPropertyContainer`1 { + string Name { public abstract get; public abstract set; } + T NestedValue { public abstract get; public abstract set; } + Microsoft.AspNetCore.OData.Query.Wrapper.IAggregationPropertyContainer`1 Next { public abstract get; public abstract set; } + object Value { public abstract get; public abstract set; } + + void ToDictionaryCore (System.Collections.Generic.Dictionary`2[[System.String],[System.Object]] dictionary, Microsoft.AspNetCore.OData.Query.Container.IPropertyMapper propertyMapper, bool includeAutoSelected) +} + +public interface Microsoft.AspNetCore.OData.Query.Wrapper.IFlatteningWrapper`1 { + T Source { public abstract get; public abstract set; } +} + +public interface Microsoft.AspNetCore.OData.Query.Wrapper.IGroupByWrapper`1 { + T Container { public abstract get; public abstract set; } + T GroupByContainer { public abstract get; public abstract set; } +} + public interface Microsoft.AspNetCore.OData.Query.Wrapper.ISelectExpandWrapper { System.Collections.Generic.IDictionary`2[[System.String],[System.Object]] ToDictionary () System.Collections.Generic.IDictionary`2[[System.String],[System.Object]] ToDictionary (System.Func`3[[Microsoft.OData.Edm.IEdmModel],[Microsoft.OData.Edm.IEdmStructuredType],[Microsoft.AspNetCore.OData.Query.Container.IPropertyMapper]] propertyMapperProvider) diff --git a/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.NetCore31.bsl b/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.NetCore31.bsl index 14d7864c4..f63d4dd37 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.NetCore31.bsl +++ b/test/Microsoft.AspNetCore.OData.Tests/PublicApi/Microsoft.AspNetCore.OData.PublicApi.NetCore31.bsl @@ -2761,6 +2761,12 @@ public class Microsoft.AspNetCore.OData.Query.Container.TruncatedCollection`1 : System.Nullable`1[[System.Int64]] TotalCount { public virtual get; } } +public interface Microsoft.AspNetCore.OData.Query.Expressions.IAggregationBinder { + System.Linq.Expressions.Expression BindGroupBy (Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + System.Linq.Expressions.Expression BindSelect (Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + System.Linq.IQueryable FlattenReferencedProperties (Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, System.Linq.IQueryable query, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, out System.Linq.Expressions.ParameterExpression& contextParameter, out System.Collections.Generic.IDictionary`2[[Microsoft.OData.UriParser.SingleValueNode, Microsoft.OData.Core, Version=7.21.6.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35],[System.Linq.Expressions.Expression, System.Linq.Expressions, Version=4.2.2.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a]]& flattenedPropertiesMap) +} + public interface Microsoft.AspNetCore.OData.Query.Expressions.IFilterBinder { System.Linq.Expressions.Expression BindFilter (Microsoft.OData.UriParser.FilterClause filterClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) } @@ -2795,6 +2801,7 @@ public abstract class Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder { protected static System.Linq.Expressions.Expression ApplyNullPropagationForFilterBody (System.Linq.Expressions.Expression body, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) public virtual System.Linq.Expressions.Expression Bind (Microsoft.OData.UriParser.QueryNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + public virtual System.Linq.Expressions.Expression BindAccessExpression (Microsoft.OData.UriParser.QueryNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, params System.Linq.Expressions.Expression baseElement) public virtual System.Linq.Expressions.Expression BindAllNode (Microsoft.OData.UriParser.AllNode allNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) public virtual System.Linq.Expressions.Expression BindAnyNode (Microsoft.OData.UriParser.AnyNode anyNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) protected System.Linq.Expressions.Expression[] BindArguments (System.Collections.Generic.IEnumerable`1[[Microsoft.OData.UriParser.QueryNode]] nodes, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) @@ -2843,9 +2850,11 @@ public abstract class Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder { protected virtual System.Linq.Expressions.Expression BindToUpper (Microsoft.OData.UriParser.SingleValueFunctionCallNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) protected virtual System.Linq.Expressions.Expression BindTrim (Microsoft.OData.UriParser.SingleValueFunctionCallNode node, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) public virtual System.Linq.Expressions.Expression BindUnaryOperatorNode (Microsoft.OData.UriParser.UnaryOperatorNode unaryOperatorNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + public virtual System.Linq.Expressions.Expression CreateOpenPropertyAccessExpression (Microsoft.OData.UriParser.SingleValueOpenPropertyAccessNode openNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) protected static System.Reflection.PropertyInfo GetDynamicPropertyContainer (Microsoft.OData.UriParser.CollectionOpenPropertyAccessNode openCollectionNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) protected static System.Reflection.PropertyInfo GetDynamicPropertyContainer (Microsoft.OData.UriParser.SingleValueOpenPropertyAccessNode openNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) protected System.Linq.Expressions.Expression GetFlattenedPropertyExpression (string propertyPath, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + protected static System.Linq.Expressions.Expression WrapConvert (System.Linq.Expressions.Expression expression) } [ @@ -2882,6 +2891,11 @@ public sealed class Microsoft.AspNetCore.OData.Query.Expressions.BinderExtension ] public static object ApplyBind (Microsoft.AspNetCore.OData.Query.Expressions.ISelectExpandBinder binder, object source, Microsoft.OData.UriParser.SelectExpandClause selectExpandClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + [ + ExtensionAttribute(), + ] + public static System.Linq.IQueryable ApplyBind (Microsoft.AspNetCore.OData.Query.Expressions.IAggregationBinder binder, System.Linq.IQueryable source, Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, out System.Type& resultClrType) + [ ExtensionAttribute(), ] @@ -2893,6 +2907,14 @@ public sealed class Microsoft.AspNetCore.OData.Query.Expressions.BinderExtension public static System.Linq.IQueryable ApplyBind (Microsoft.AspNetCore.OData.Query.Expressions.IOrderByBinder binder, System.Linq.IQueryable query, Microsoft.OData.UriParser.OrderByClause orderByClause, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, bool alreadyOrdered) } +public class Microsoft.AspNetCore.OData.Query.Expressions.AggregationBinder : Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder, IAggregationBinder { + public AggregationBinder () + + public virtual System.Linq.Expressions.Expression BindGroupBy (Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + public virtual System.Linq.Expressions.Expression BindSelect (Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context) + public virtual System.Linq.IQueryable FlattenReferencedProperties (Microsoft.OData.UriParser.Aggregation.TransformationNode transformationNode, System.Linq.IQueryable query, Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext context, out System.Linq.Expressions.ParameterExpression& contextParameter, out System.Collections.Generic.IDictionary`2[[Microsoft.OData.UriParser.SingleValueNode, Microsoft.OData.Core, Version=7.21.6.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35],[System.Linq.Expressions.Expression, System.Linq.Expressions, Version=4.2.2.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a]]& flattenedPropertiesMap) +} + public class Microsoft.AspNetCore.OData.Query.Expressions.FilterBinder : Microsoft.AspNetCore.OData.Query.Expressions.QueryBinder, IFilterBinder { public FilterBinder () @@ -2922,10 +2944,12 @@ public class Microsoft.AspNetCore.OData.Query.Expressions.QueryBinderContext { System.Linq.Expressions.ParameterExpression CurrentParameter { public get; } System.Type ElementClrType { public get; } Microsoft.OData.Edm.IEdmType ElementType { public get; } + System.Collections.Generic.IDictionary`2[[Microsoft.OData.UriParser.SingleValueNode],[System.Linq.Expressions.Expression]] FlattenedPropertiesMap { public get; } Microsoft.OData.Edm.IEdmModel Model { public get; } Microsoft.OData.Edm.IEdmNavigationSource NavigationSource { public get; public set; } Microsoft.AspNetCore.OData.Query.ODataQuerySettings QuerySettings { public get; } System.Linq.Expressions.Expression Source { public get; public set; } + System.Type TransformationElementType { public get; } public System.Linq.Expressions.ParameterExpression GetParameter (string name) public void RemoveParameter (string name) @@ -3158,6 +3182,24 @@ public class Microsoft.AspNetCore.OData.Query.Validator.TopQueryValidator : ITop public virtual void Validate (Microsoft.AspNetCore.OData.Query.TopQueryOption topQueryOption, Microsoft.AspNetCore.OData.Query.Validator.ODataValidationSettings validationSettings) } +public interface Microsoft.AspNetCore.OData.Query.Wrapper.IAggregationPropertyContainer`1 { + string Name { public abstract get; public abstract set; } + T NestedValue { public abstract get; public abstract set; } + Microsoft.AspNetCore.OData.Query.Wrapper.IAggregationPropertyContainer`1 Next { public abstract get; public abstract set; } + object Value { public abstract get; public abstract set; } + + void ToDictionaryCore (System.Collections.Generic.Dictionary`2[[System.String],[System.Object]] dictionary, Microsoft.AspNetCore.OData.Query.Container.IPropertyMapper propertyMapper, bool includeAutoSelected) +} + +public interface Microsoft.AspNetCore.OData.Query.Wrapper.IFlatteningWrapper`1 { + T Source { public abstract get; public abstract set; } +} + +public interface Microsoft.AspNetCore.OData.Query.Wrapper.IGroupByWrapper`1 { + T Container { public abstract get; public abstract set; } + T GroupByContainer { public abstract get; public abstract set; } +} + public interface Microsoft.AspNetCore.OData.Query.Wrapper.ISelectExpandWrapper { System.Collections.Generic.IDictionary`2[[System.String],[System.Object]] ToDictionary () System.Collections.Generic.IDictionary`2[[System.String],[System.Object]] ToDictionary (System.Func`3[[Microsoft.OData.Edm.IEdmModel],[Microsoft.OData.Edm.IEdmStructuredType],[Microsoft.AspNetCore.OData.Query.Container.IPropertyMapper]] propertyMapperProvider) diff --git a/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/AggregationBinderTests.cs b/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/AggregationBinderTests.cs index e0c083ff3..9d10c2dbc 100644 --- a/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/AggregationBinderTests.cs +++ b/test/Microsoft.AspNetCore.OData.Tests/Query/Expressions/AggregationBinderTests.cs @@ -183,22 +183,12 @@ public void GroupByAndMultipleAggregations() + ".Select($it => new AggregationWrapper() {GroupByContainer = $it.Key.GroupByContainer, Container = new AggregationPropertyContainer() {Name = CategoryID, Value = Convert(Convert($it).Sum($it => Convert($it.GroupByContainer.Next.Value))), Next = new LastInChain() {Name = SupplierID, Value = Convert(Convert($it).Sum($it => Convert($it.GroupByContainer.Value))), }, }, })"); } - [Fact] - public void ClassicEFQueryShape() - { - var filters = VerifyQueryDeserialization( - "aggregate(SupplierID with sum as SupplierID)", - ".GroupBy($it => new NoGroupByWrapper())" - + ".Select($it => new NoGroupByAggregationWrapper() {Container = new LastInChain() {Name = SupplierID, Value = $it.AsQueryable().Sum($it => $it.SupplierID), }, })", - classicEF: true); - } - - private Expression VerifyQueryDeserialization(string filter, string expectedResult = null, Action settingsCustomizer = null, bool classicEF = false) + private Expression VerifyQueryDeserialization(string filter, string expectedResult = null, Action settingsCustomizer = null) { - return VerifyQueryDeserialization(filter, expectedResult, settingsCustomizer, classicEF); + return VerifyQueryDeserialization(filter, expectedResult, settingsCustomizer); } - private Expression VerifyQueryDeserialization(string clauseString, string expectedResult = null, Action settingsCustomizer = null, bool classicEF = false) where T : class + private Expression VerifyQueryDeserialization(string clauseString, string expectedResult = null, Action settingsCustomizer = null) where T : class { IEdmModel model = GetModel(); ApplyClause clause = CreateApplyNode(clauseString, model, typeof(T)); @@ -214,23 +204,16 @@ private Expression VerifyQueryDeserialization(string clauseString, string exp return settings; }; - var binder = classicEF - ? new AggregationBinderEFFake( - customizeSettings(new ODataQuerySettings { HandleNullPropagation = HandleNullPropagationOption.False }), - assembliesResolver, - typeof(T), - model, - clause.Transformations.First()) - : new AggregationBinder( - customizeSettings(new ODataQuerySettings { HandleNullPropagation = HandleNullPropagationOption.False }), - assembliesResolver, - typeof(T), - model, - clause.Transformations.First()); + QueryBinderContext queryBinderContext = new QueryBinderContext( + model, + customizeSettings(new ODataQuerySettings { HandleNullPropagation = HandleNullPropagationOption.False }), + typeof(T)); + + var binder = new AggregationBinder(); var query = Enumerable.Empty().AsQueryable(); - var queryResult = binder.Bind(query); + var queryResult = binder.ApplyBind(query, clause.Transformations.First(), queryBinderContext, out Type resultClrType); var applyExpr = queryResult.Expression; @@ -285,18 +268,5 @@ private IEdmModel GetModel() where T : class } return value; } - - private class AggregationBinderEFFake : AggregationBinder - { - internal AggregationBinderEFFake(ODataQuerySettings settings, IAssemblyResolver assembliesResolver, Type elementType, IEdmModel model, TransformationNode transformation) - : base(settings, assembliesResolver, elementType, model, transformation) - { - } - - internal override bool IsClassicEF(IQueryable query) - { - return true; - } - } } }