diff --git a/src/Microsoft.OData.Client/ALinq/ApplyQueryOptionExpression.cs b/src/Microsoft.OData.Client/ALinq/ApplyQueryOptionExpression.cs index 010046e9cc..d1c3d7a07f 100644 --- a/src/Microsoft.OData.Client/ALinq/ApplyQueryOptionExpression.cs +++ b/src/Microsoft.OData.Client/ALinq/ApplyQueryOptionExpression.cs @@ -32,6 +32,8 @@ internal ApplyQueryOptionExpression(Type type) { this.Aggregations = new List(); this.filterExpressions = new List(); + this.GroupingExpressions = new List(); + this.KeySelectorMap = new Dictionary(StringComparer.Ordinal); } /// @@ -72,6 +74,20 @@ internal Expression GetPredicate() return this.filterExpressions.Aggregate((leftExpr, rightExpr) => Expression.And(leftExpr, rightExpr)); } + /// + /// The individual expressions that make up the GroupBy selector. + /// + internal List GroupingExpressions { get; private set; } + + /// + /// Gets a mapping of member names in the GroupBy key selector to their respective expressions. + /// + /// + /// This property will contain a mapping of the member {Name} to the expression {d1.Product.Category.Name} given the following GroupBy expression: + /// dsc.CreateQuery<Sale>("Sales").GroupBy(d1 => d1.Product.Category.Name, (d2, d3) => new { CategoryName = d2, AverageAmount = d3.Average(d4 => d4.Amount) }) + /// + internal Dictionary KeySelectorMap { get; private set; } + /// /// Structure for an aggregation. Holds lambda expression plus enum indicating aggregation method /// diff --git a/src/Microsoft.OData.Client/ALinq/DataServiceQueryProvider.cs b/src/Microsoft.OData.Client/ALinq/DataServiceQueryProvider.cs index 666daaa106..ecbf4a7574 100644 --- a/src/Microsoft.OData.Client/ALinq/DataServiceQueryProvider.cs +++ b/src/Microsoft.OData.Client/ALinq/DataServiceQueryProvider.cs @@ -172,9 +172,28 @@ internal QueryComponents Translate(Expression e) UriWriter.Translate(this.Context, addTrailingParens, e, out uri, out version); ResourceExpression re = e as ResourceExpression; - Type lastSegmentType = re.Projection == null ? re.ResourceType : re.Projection.Selector.Parameters[0].Type; + ApplyQueryOptionExpression applyQueryOptionExpr = (re as QueryableResourceExpression)?.Apply; + Type lastSegmentType; + + // The KeySelectorMap property is initialized and populated with a least one item if we're dealing with a GroupBy expression. + // In that case, the selector in the Projection will take the following form: + // (d2, d3) => new <>f_AnonymousType13`2(CategoryName = d2, AverageAmount = d3.Average(d4 => d4)) + // We examine the 2nd parameter to determine the type of values in the IGrouping + // The TElement type implements IEnumerable and the first generic argument should be our last segment type + if (applyQueryOptionExpr?.KeySelectorMap?.Count > 0) + { + lastSegmentType = re.Projection.Selector.Parameters[1].Type.GetGenericArguments()[0]; + } + else + { + lastSegmentType = re.Projection == null ? re.ResourceType : re.Projection.Selector.Parameters[0].Type; + } + LambdaExpression selector = re.Projection == null ? null : re.Projection.Selector; - return new QueryComponents(uri, version, lastSegmentType, selector, normalizerRewrites); + QueryComponents queryComponents = new QueryComponents(uri, version, lastSegmentType, selector, normalizerRewrites); + queryComponents.GroupByKeySelectorMap = applyQueryOptionExpr?.KeySelectorMap; + + return queryComponents; } /// diff --git a/src/Microsoft.OData.Client/ALinq/GroupByAnalyzer.cs b/src/Microsoft.OData.Client/ALinq/GroupByAnalyzer.cs new file mode 100644 index 0000000000..efbe1d6b50 --- /dev/null +++ b/src/Microsoft.OData.Client/ALinq/GroupByAnalyzer.cs @@ -0,0 +1,526 @@ +//--------------------------------------------------------------------- +// +// Copyright (C) Microsoft Corporation. All rights reserved. See License.txt in the project root for license information. +// +//--------------------------------------------------------------------- + +namespace Microsoft.OData.Client +{ + #region Namespaces + + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.Linq.Expressions; + using System.Reflection; + using Microsoft.OData.UriParser.Aggregation; + + #endregion Namespaces + + /// + /// Analyzes an expression to check whether it can be satisfied with $apply. + /// + internal static class GroupByAnalyzer + { + /// + /// Analyzes to check whether it can be satisfied with $apply. + /// The Projection property of is set + /// when is analyzed successfully. + /// + /// Expression to analyze. + /// The resource set expression (source) for the GroupBy. + /// The resource expression in scope. + /// true if can be satisfied with $apply; false otherwise. + internal static bool Analyze( + MethodCallExpression methodCallExpr, + QueryableResourceExpression source, + ResourceExpression resourceExpr) + { + Debug.Assert(source != null, $"{nameof(source)} != null"); + Debug.Assert(resourceExpr != null, $"{nameof(resourceExpr)} != null"); + Debug.Assert(methodCallExpr != null, $"{nameof(methodCallExpr)} != null"); + Debug.Assert(methodCallExpr.Arguments.Count == 3, $"{nameof(methodCallExpr)}.Arguments.Count == 3"); + + LambdaExpression keySelector; + // Key selector should be a single argument lambda: d1 => ... + if (!ResourceBinder.PatternRules.MatchSingleArgumentLambda(methodCallExpr.Arguments[1], out keySelector)) + { + return false; + } + + LambdaExpression resultSelector; + // Result selector should be a double argument lambda: (d2, d3) => ... + if (!ResourceBinder.PatternRules.MatchDoubleArgumentLambda(methodCallExpr.Arguments[2], out resultSelector)) + { + return false; + } + + // Analyze result selector - Must be a simple instantiation. + // A result selector where the body is a single argument lambda is also valid: (d2, d3) => d3.Average(d4 => d4.Amount) + // However, this presents a challenge since an alias is required in the aggregate transformation: aggregate(Amount with average as [alias]) + // We could circumvent this by defaulting to [aggregate method]+[aggregate property], e.g., AverageAmount + // but this is deferred to a future release + if (resultSelector.Body.NodeType != ExpressionType.MemberInit && resultSelector.Body.NodeType != ExpressionType.New) + { + return false; + } + + // Analyze key selector + if (!AnalyzeGroupByKeySelector(source, keySelector)) + { + return false; + } + + // Analyze result selector + GroupByResultSelectorAnalyzer.Analyze(source, resultSelector); + + resourceExpr.Projection = new ProjectionQueryOptionExpression(resultSelector.Body.Type, resultSelector, new List()); + + return true; + } + + /// + /// Analyzes a GroupBy key selector for property/ies that the input sequence is grouped by. + /// + /// The resource set expression (source) for the GroupBy. + /// Key selector expression to analyze. + /// true if analyzed successfully; false otherwise + private static bool AnalyzeGroupByKeySelector(QueryableResourceExpression input, LambdaExpression keySelector) + { + Debug.Assert(input != null, $"{nameof(input)} != null"); + Debug.Assert(keySelector != null, $"{nameof(keySelector)} != null"); + + EnsureApplyInitialized(input); + Debug.Assert(input.Apply != null, $"{nameof(input)}.Apply != null"); + + // Scenario 1: GroupBy(d1 => d1.Property) and GroupBy(d1 = d1.NavProperty...Property) + if (keySelector.Body is MemberExpression memberExpr) + { + // Validate grouping expression + ResourceBinder.ValidationRules.ValidateGroupingExpression(memberExpr); + + Expression boundExpression; + if (!TryBindToInput(input, keySelector, out boundExpression)) + { + return false; + } + + input.Apply.GroupingExpressions.Add(boundExpression); + input.Apply.KeySelectorMap.Add(memberExpr.Member.Name, memberExpr); + + return true; + } + + // Scenario 2: GroupBy(d1 => [Constant]) + if (keySelector.Body is ConstantExpression constExpr) + { + input.Apply.KeySelectorMap.Add(constExpr.Value.ToString(), constExpr); + + return true; + } + + // Check whether the key selector body is a simple instantiation + if (keySelector.Body.NodeType != ExpressionType.MemberInit && keySelector.Body.NodeType != ExpressionType.New) + { + return false; + } + + // Scenario 3: GroupBy(d1 => new { d1.Property1, ..., d1.PropertyN }) + // Scenario 4: GroupBy(d1 => new Cls { ClsProperty1 = d1.Property1, ..., ClsPropertyN = d1.PropertyN }) - not common but possible + GroupByKeySelectorAnalyzer.Analyze(input, keySelector); + + return true; + } + + /// + /// Ensure apply query option for the resource set is initialized + /// + /// The resource set expression (source) for the GroupBy. + private static void EnsureApplyInitialized(QueryableResourceExpression input) + { + Debug.Assert(input != null, $"{nameof(input)} != null"); + + if (input.Apply == null) + { + ResourceBinder.AddSequenceQueryOption(input, new ApplyQueryOptionExpression(input.Type)); + } + } + + private static bool TryBindToInput(ResourceExpression input, LambdaExpression le, out Expression bound) + { + List referencedInputs = new List(); + bound = InputBinder.Bind(le.Body, input, le.Parameters[0], referencedInputs); + + if (referencedInputs.Count > 1 || (referencedInputs.Count == 1 && referencedInputs[0] != input)) + { + bound = null; + } + + return bound != null; + } + + /// + /// Analyzes a GroupBy key selector for property or properties that the input sequence is grouped by. + /// + private sealed class GroupByKeySelectorAnalyzer : DataServiceALinqExpressionVisitor + { + /// The input resource, as a queryable resource. + private readonly QueryableResourceExpression input; + + /// The key selector lambda parameter. + private readonly ParameterExpression lambdaParameter; + + /// + /// Creates an expression. + /// + /// The source expression. + /// The parameter of the key selector expression, e.g., d1 => new { d1.ProductId, d1.CustomerId }. + private GroupByKeySelectorAnalyzer(QueryableResourceExpression source, ParameterExpression paramExpr) + { + this.input = source; + this.lambdaParameter = paramExpr; + } + + /// + /// Analyzes a GroupBy key selector for property or properties that the input sequence is grouped by. + /// + /// The resource set expression (source) for the GroupBy. + /// Key selector expression to analyze. + internal static void Analyze(QueryableResourceExpression input, LambdaExpression keySelector) + { + Debug.Assert(input != null, $"{nameof(input)} != null"); + Debug.Assert(keySelector != null, $"{nameof(keySelector)} != null"); + Debug.Assert(keySelector.Parameters.Count == 1, $"{nameof(keySelector)}.Parameters.Count == 1"); + + GroupByKeySelectorAnalyzer keySelectorAnalyzer = new GroupByKeySelectorAnalyzer(input, keySelector.Parameters[0]); + + keySelectorAnalyzer.Visit(keySelector); + } + + /// + internal override NewExpression VisitNew(NewExpression nex) + { + // Maintain a mapping of grouping expression and respective member + // The mapping is cross-referenced if any of the grouping expressions + // is referenced in result selector + if (nex.Members != null && nex.Members.Count == nex.Arguments.Count) + { + // This block caters for the following scenario: + // + // dataServiceContext.Sales.GroupBy( + // d1 => new { d1.ProductId }, + // (d2, d3) => new { AverageAmount = d3.Average(d4 => d4.Amount) }) + for (int i = 0; i < nex.Arguments.Count; i++) + { + this.input.Apply.KeySelectorMap.Add(nex.Members[i].Name, nex.Arguments[i]); + } + } + else if (nex.Arguments.Count > 0 && nex.Constructor.GetParameters().Length > 0) + { + // Constructor initialization in key selector not supported + throw new NotSupportedException(Strings.ALinq_InvalidGroupByKeySelector(nex)); + } + + return base.VisitNew(nex); + } + + /// + internal override MemberAssignment VisitMemberAssignment(MemberAssignment assignment) + { + // Maintain a mapping of grouping expression and respective member + // The mapping is cross-referenced if any of the grouping expressions + // is referenced in result selector + this.input.Apply.KeySelectorMap.Add(assignment.Member.Name, assignment.Expression); + + return base.VisitMemberAssignment(assignment); + } + + /// + internal override Expression VisitMemberAccess(MemberExpression m) + { + // Validate the grouping expression + ResourceBinder.ValidationRules.ValidateGroupingExpression(m); + + List referencedInputs = new List(); + Expression boundExpression = InputBinder.Bind(m, this.input, this.lambdaParameter, referencedInputs); + + if (referencedInputs.Count == 1 && referencedInputs[0] == this.input) + { + EnsureApplyInitialized(this.input); + Debug.Assert(input.Apply != null, $"{nameof(input)}.Apply != null"); + + this.input.Apply.GroupingExpressions.Add(boundExpression); + } + + return m; + } + } + + /// + /// Analyzes expression for aggregate expressions in the GroupBy result selector. + /// + private sealed class GroupByResultSelectorAnalyzer : DataServiceALinqExpressionVisitor + { + /// The input resource, as a queryable resource + private readonly QueryableResourceExpression input; + + /// + /// Mapping of expressions in the GroupBy result selector to a key-value pair + /// of the respective member name and type. + /// + /// + /// This property will contain a mapping of the expression {d3.Average(d4 => d4.Amount)} to a key-value pair + /// of the respective member name {AverageAmount} and type {decimal} given the following GroupBy expression: + /// dsc.CreateQuery<Sale>("Sales").GroupBy(d1 => d1.Product.Category.Name, (d2, d3) => new { CategoryName = d2, AverageAmount = d3.Average(d4 => d4.Amount) }) + /// + private readonly IDictionary> resultSelectorMap; + + /// Tracks the member a method call is mapped to when analyzing the GroupBy result selector. + private readonly Stack memberInScope; + + /// + /// Creates an expression. + /// + /// The resource set expression (source) for the GroupBy. + private GroupByResultSelectorAnalyzer(QueryableResourceExpression input) + { + this.input = input; + this.resultSelectorMap = new Dictionary>(ReferenceEqualityComparer.Instance); + this.memberInScope = new Stack(); + } + + /// + /// Analyzes expression for aggregate expressions in the GroupBy result selector. + /// + /// The resource set expression (source) for the GroupBy. + /// Result selector expression to analyze. + internal static void Analyze(QueryableResourceExpression input, LambdaExpression resultSelector) + { + Debug.Assert(input != null, $"{nameof(input)} != null"); + Debug.Assert(resultSelector != null, $"{nameof(resultSelector)} != null"); + + GroupByResultSelectorAnalyzer resultSelectorAnalyzer = new GroupByResultSelectorAnalyzer(input); + + resultSelectorAnalyzer.Visit(resultSelector); + } + + /// + internal override NewExpression VisitNew(NewExpression nex) + { + ParameterInfo[] parameters; + + // Maintain a mapping of expression argument and respective member + if (nex.Members != null && nex.Members.Count == nex.Arguments.Count) + { + // This block caters for the following scenario: + // + // dataServiceContext.Sales.GroupBy( + // d1 => d1.ProductId, + // (d2, d3) => new { AverageAmount = d3.Average(d4 => d4.Amount) }) + + for (int i = 0; i < nex.Arguments.Count; i++) + { + this.resultSelectorMap.Add( + nex.Arguments[i], + new KeyValuePair(nex.Members[i].Name, (nex.Members[i] as PropertyInfo).PropertyType)); + } + } + else if (nex.Arguments.Count > 0 && (parameters = nex.Constructor.GetParameters()).Length >= nex.Arguments.Count) + { + // Use of >= in the above if statement caters for optional parameters + + // Given the following class definition: + // + // class GroupedResult + // { + // public GroupedResult(decimal averageAmount) { AverageAmount = averageAmount } + // public decimal AverageAmount { get; } + // } + // + // this block caters for the following scenario: + // + // dataServiceContext.Sales.GroupBy( + // d1 => d1.ProductId, + // (d2, d3) => new GroupedResult(d3.Average(d4 => d4.Amount))) + + for (int i = 0; i < nex.Arguments.Count; i++) + { + this.resultSelectorMap.Add( + nex.Arguments[i], + new KeyValuePair(parameters[i].Name, parameters[i].ParameterType)); + } + } + + return base.VisitNew(nex); + } + + /// + internal override MemberAssignment VisitMemberAssignment(MemberAssignment assignment) + { + // Maintain a mapping of expression argument and respective member + this.resultSelectorMap.Add( + assignment.Expression, + new KeyValuePair(assignment.Member.Name, (assignment.Member as PropertyInfo).PropertyType)); + + return base.VisitMemberAssignment(assignment); + } + + /// + internal override Expression VisitMethodCall(MethodCallExpression m) + { + if (this.resultSelectorMap.TryGetValue(m, out KeyValuePair member)) + { + this.memberInScope.Push(member.Key); + } + + // Caters for the following scenarios: + // 1). + // dataServiceContext.Sales.GroupBy( + // d1 => d1.Time.Year, + // (d2, d3) => new { YearStr = d2.ToString() }); + // 2). + // dataServiceContext.Sales.GroupBy( + // d1 => d1.ProductId, + // (d2, d3) => new { AverageAmount = d3.Average(d4 => d4.Amount).ToString() }); + if (m.Method.Name == "ToString") + { + if (m.Object is MethodCallExpression) + { + return Expression.Call(this.VisitMethodCall(m.Object as MethodCallExpression), m.Method, m.Arguments); + } + + return base.VisitMethodCall(m); + } + + SequenceMethod sequenceMethod; + Expression result; + ReflectionUtil.TryIdentifySequenceMethod(m.Method, out sequenceMethod); + + switch (sequenceMethod) + { + case SequenceMethod.SumIntSelector: + case SequenceMethod.SumDoubleSelector: + case SequenceMethod.SumDecimalSelector: + case SequenceMethod.SumLongSelector: + case SequenceMethod.SumSingleSelector: + case SequenceMethod.SumNullableIntSelector: + case SequenceMethod.SumNullableDoubleSelector: + case SequenceMethod.SumNullableDecimalSelector: + case SequenceMethod.SumNullableLongSelector: + case SequenceMethod.SumNullableSingleSelector: + result = this.AnalyzeAggregation(m, AggregationMethod.Sum); + + break; + case SequenceMethod.AverageIntSelector: + case SequenceMethod.AverageDoubleSelector: + case SequenceMethod.AverageDecimalSelector: + case SequenceMethod.AverageLongSelector: + case SequenceMethod.AverageSingleSelector: + case SequenceMethod.AverageNullableIntSelector: + case SequenceMethod.AverageNullableDoubleSelector: + case SequenceMethod.AverageNullableDecimalSelector: + case SequenceMethod.AverageNullableLongSelector: + case SequenceMethod.AverageNullableSingleSelector: + result = this.AnalyzeAggregation(m, AggregationMethod.Average); + + break; + case SequenceMethod.MinIntSelector: + case SequenceMethod.MinDoubleSelector: + case SequenceMethod.MinDecimalSelector: + case SequenceMethod.MinLongSelector: + case SequenceMethod.MinSingleSelector: + case SequenceMethod.MinNullableIntSelector: + case SequenceMethod.MinNullableDoubleSelector: + case SequenceMethod.MinNullableDecimalSelector: + case SequenceMethod.MinNullableLongSelector: + case SequenceMethod.MinNullableSingleSelector: + result = this.AnalyzeAggregation(m, AggregationMethod.Min); + + break; + case SequenceMethod.MaxIntSelector: + case SequenceMethod.MaxDoubleSelector: + case SequenceMethod.MaxDecimalSelector: + case SequenceMethod.MaxLongSelector: + case SequenceMethod.MaxSingleSelector: + case SequenceMethod.MaxNullableIntSelector: + case SequenceMethod.MaxNullableDoubleSelector: + case SequenceMethod.MaxNullableDecimalSelector: + case SequenceMethod.MaxNullableLongSelector: + case SequenceMethod.MaxNullableSingleSelector: + result = this.AnalyzeAggregation(m, AggregationMethod.Max); + + break; + case SequenceMethod.Count: + case SequenceMethod.LongCount: + // Add aggregation to $apply aggregations + AddAggregation(null, AggregationMethod.VirtualPropertyCount); + result = m; + + break; + case SequenceMethod.CountDistinctSelector: + result = this.AnalyzeAggregation(m, AggregationMethod.CountDistinct); + + break; + default: + throw Error.MethodNotSupported(m); + }; + + if (this.resultSelectorMap.ContainsKey(m)) + { + this.memberInScope.Pop(); + } + + return result; + } + + /// + /// Analyzes an aggregation expression. + /// + /// Expression to analyze. + /// The aggregation method. + /// The analyzed aggregate expression. + private Expression AnalyzeAggregation(MethodCallExpression methodCallExpr, AggregationMethod aggregationMethod) + { + Debug.Assert(methodCallExpr != null, $"{nameof(methodCallExpr)} != null"); + + if (methodCallExpr.Arguments.Count != 2) + { + return methodCallExpr; + } + + LambdaExpression lambdaExpr = ResourceBinder.StripTo(methodCallExpr.Arguments[1]); + if (lambdaExpr == null) + { + return methodCallExpr; + } + + ResourceBinder.ValidationRules.DisallowExpressionEndWithTypeAs(lambdaExpr.Body, methodCallExpr.Method.Name); + + Expression selector; + if (!TryBindToInput(input, lambdaExpr, out selector)) + { + // UNSUPPORTED: Lambda should reference the input, and only the input + return methodCallExpr; + } + + // Add aggregation to $apply aggregations + AddAggregation(selector, aggregationMethod); + + return methodCallExpr; + } + + /// + /// Adds aggregation to $apply aggregations. + /// + /// The selector. + /// The aggregation method. + private void AddAggregation(Expression selector, AggregationMethod aggregationMethod) + { + if (this.memberInScope.Count > 0) + { + this.input.AddAggregation(selector, aggregationMethod, this.memberInScope.Peek()); + } + } + } + } +} diff --git a/src/Microsoft.OData.Client/ALinq/QueryComponents.cs b/src/Microsoft.OData.Client/ALinq/QueryComponents.cs index ece6290a44..6c9e99448c 100644 --- a/src/Microsoft.OData.Client/ALinq/QueryComponents.cs +++ b/src/Microsoft.OData.Client/ALinq/QueryComponents.cs @@ -223,6 +223,15 @@ internal bool HasSelectQueryOption /// URI with additional query options added if required. internal Uri Uri { get; set; } + /// + /// Gets or sets a mapping of member names in the GroupBy key selector to their respective expressions. + /// + /// + /// This property will contain a mapping of the member {Name} to the expression {d1.Product.Category.Name} given the following GroupBy expression: + /// dsc.CreateQuery<Sale>("Sales").GroupBy(d1 => d1.Product.Category.Name, (d2, d3) => new { CategoryName = d2, AverageAmount = d3.Average(d4 => d4.Amount) }) + /// + internal Dictionary GroupByKeySelectorMap { get; set; } + /// /// Determines whether or not the specified query string contains the $select query option. /// diff --git a/src/Microsoft.OData.Client/ALinq/QueryableResourceExpression.cs b/src/Microsoft.OData.Client/ALinq/QueryableResourceExpression.cs index 4eaff076a0..141ef45799 100644 --- a/src/Microsoft.OData.Client/ALinq/QueryableResourceExpression.cs +++ b/src/Microsoft.OData.Client/ALinq/QueryableResourceExpression.cs @@ -13,6 +13,7 @@ namespace Microsoft.OData.Client using System.Linq; using System.Linq.Expressions; using System.Reflection; + using Microsoft.OData.UriParser.Aggregation; /// Queryable Resource Expression, the base class for ResourceSetExpression and SingletonExpression [DebuggerDisplay("QueryableResourceExpression {Source}.{MemberExpression}")] @@ -371,8 +372,24 @@ internal void AddFilter(IEnumerable predicateConjuncts) this.keyPredicateConjuncts.Clear(); } - internal void AddApply(Expression aggregateExpr, OData.UriParser.Aggregation.AggregationMethod aggregationMethod) + /// + /// Adds an aggregation expression to the $apply expression of this resource expression. + /// If the resource expression already contains a $filter expression, + /// the predicate conjuncts of the $filter expression are moved + /// to the predicate conjuncts of the $apply expression and + /// the $filter expression removed. + /// The predicate conjuncts in the $apply expression are used to generate + /// a filter transformation used to restrict the set of data to be aggregated. + /// + /// The aggregation expression. + /// The aggregation method. + /// The aggregation alias. + internal void AddAggregation(Expression aggregationExpr, AggregationMethod aggregationMethod, string aggregationAlias = "") { + // Aggregation expression should not be null if the aggregation method is anything other than VirtualPropertyCount ($count) + Debug.Assert(aggregationExpr != null || aggregationMethod == AggregationMethod.VirtualPropertyCount, + "aggregationExpr != null || aggregationMethod == AggregationMethod.VirtualPropertyCount"); + if (this.OrderBy != null) { throw new NotSupportedException(Strings.ALinq_QueryOptionOutOfOrder("apply", "orderby")); @@ -400,12 +417,12 @@ internal void AddApply(Expression aggregateExpr, OData.UriParser.Aggregation.Agg // The $apply query option is evaluated first, then other query options ($filter, $orderby, $select) are evaluated, // if applicable, on the result of $apply in their normal order. // http://docs.oasis-open.org/odata/odata-data-aggregation-ext/v4.0/cs02/odata-data-aggregation-ext-v4.0-cs02.html#_Toc435016590 - + // If a Where appears before an aggregation method (e.g. Average, Sum, etc) or GroupBy, // the conjuncts of the filter expression will be used to restrict the set of data to be aggregated. // They will not appear on the $filter query option. Instead, we use them to construct a filter transformation. // E.g. /Sales?$apply=filter(Amount gt 1)/aggregate(Amount with average as AverageAmount) - + // If a Where appears after an aggregation method or GroupBy, the conjuncts should appear // on a $filter query option after the $apply. // E.g. /Sales?$apply=groupby((Product/Color),aggregate(Amount with average as AverageAmount))&$filter=Product/Color eq 'Brown' @@ -418,7 +435,7 @@ internal void AddApply(Expression aggregateExpr, OData.UriParser.Aggregation.Agg this.RemoveFilterExpression(); } - this.Apply.Aggregations.Add(new ApplyQueryOptionExpression.Aggregation(aggregateExpr, aggregationMethod)); + this.Apply.Aggregations.Add(new ApplyQueryOptionExpression.Aggregation(aggregationExpr, aggregationMethod, aggregationAlias)); } /// diff --git a/src/Microsoft.OData.Client/ALinq/ResourceBinder.cs b/src/Microsoft.OData.Client/ALinq/ResourceBinder.cs index c99fc39a85..780459ca57 100644 --- a/src/Microsoft.OData.Client/ALinq/ResourceBinder.cs +++ b/src/Microsoft.OData.Client/ALinq/ResourceBinder.cs @@ -831,11 +831,64 @@ private static Expression AnalyzeAggregation(MethodCallExpression aggregationExp return aggregationExpr; } - resourceExpr.AddApply(selector, aggregationMethod); + resourceExpr.AddAggregation(selector, aggregationMethod); return resourceExpr; } + /// + /// Analyzes a GroupBy(source, keySelector, resultSelector) method expression to + /// determine whether it can be satisfied with $apply. + /// + /// Expression to analyze. + /// + /// , or a new resource set expression + /// for the target resource in the analyzed expression. + /// + /// true if can be satisfied with $apply; false otherwise. + private bool AnalyzeGroupBy(MethodCallExpression methodCallExpr, out Expression expr) + { + Debug.Assert(methodCallExpr != null, "methodCallExpr != null"); + + expr = methodCallExpr; + + // Total 3 arguments expected + // GroupBy( + // IQueryable source, + // Expression> keySelector, + // Expression, TResult>> resultSelector) + if (methodCallExpr.Arguments.Count != 3) + { + return false; + } + + SequenceMethod sequenceMethod; + + if (!ReflectionUtil.TryIdentifySequenceMethod(methodCallExpr.Method, out sequenceMethod) || + sequenceMethod != SequenceMethod.GroupByResultSelector) + { + return false; + } + + // We expect a resource expression as the first argument. + QueryableResourceExpression source = this.Visit(methodCallExpr.Arguments[0]) as QueryableResourceExpression; + if (source == null) + { + return false; + } + + ResourceExpression resourceExpr = source.CreateCloneWithNewType(methodCallExpr.Method.ReturnType); + + if (!GroupByAnalyzer.Analyze(methodCallExpr, source, resourceExpr)) + { + return false; + } + + expr = resourceExpr; + + return true; + } + /// Ensures that there's a limit on the cardinality of a query. /// for the method to limit First/Single(OrDefault). /// Maximum cardinality to allow. @@ -1308,6 +1361,7 @@ internal override Expression VisitQueryableResourceExpression(QueryableResourceE // since we could be adding query options to the root, create a new one which can be mutable. return QueryableResourceExpression.CreateNavigationResourceExpression(rse.NodeType, rse.Type, rse.Source, rse.MemberExpression, rse.ResourceType, null /*expandPaths*/, CountOption.None, null /*customQueryOptions*/, null /*projection*/, rse.ResourceTypeAs, rse.UriVersion, rse.OperationName, rse.OperationParameters); } + return rse; } @@ -1373,7 +1427,7 @@ private static Expression AnalyzeCountMethod(MethodCallExpression mce) return rseCopy; } - private static void AddSequenceQueryOption(ResourceExpression target, QueryOptionExpression qoe) + internal static void AddSequenceQueryOption(ResourceExpression target, QueryOptionExpression qoe) { QueryableResourceExpression rse = (QueryableResourceExpression)target; rse.ConvertKeyToFilterExpression(); @@ -1464,13 +1518,16 @@ internal override Expression VisitMethodCall(MethodCallExpression mce) { // The leaf projection can be one of Select(source, selector) or // SelectMany(source, collectionSelector, resultSelector). - if (sequenceMethod == SequenceMethod.Select || - sequenceMethod == SequenceMethod.SelectManyResultSelector) + if ((sequenceMethod == SequenceMethod.Select || + sequenceMethod == SequenceMethod.SelectManyResultSelector) && + this.AnalyzeProjection(mce, sequenceMethod, out e)) { - if (this.AnalyzeProjection(mce, sequenceMethod, out e)) - { - return e; - } + return e; + } + else if (sequenceMethod == SequenceMethod.GroupByResultSelector && + this.AnalyzeGroupBy(mce, out e)) + { + return e; } } @@ -3032,6 +3089,46 @@ internal static void ValidateAggregateExpression(Expression expr) } } } + + /// + /// Checks whether the specified is a valid grouping expression. + /// + /// The grouping expression + internal static void ValidateGroupingExpression(Expression expr) + { + MemberExpression memberExpr = StripTo(expr); + Debug.Assert(memberExpr != null, "memberExpr != null"); + + // NOTE: Based on the spec, if the property path leads to a single-valued navigation + // property, this means grouping by the entity-id of the related entities. + // However, that support is not implemented in OData WebApi. At the moment, grouping + // expression must evaluate to a single-valued primitive property + + // Disallow unsupported scenarios like the following: + // - GroupBy(d1 => d1.Property.Length) + // - GroupBy(d1 => d1.CollectionProperty.Count) + MemberExpression containingExpr = StripTo(memberExpr.Expression); + if (containingExpr != null) + { + if (PrimitiveType.IsKnownNullableType(containingExpr.Type)) + { + throw new NotSupportedException(Strings.ALinq_InvalidGroupingExpression(memberExpr)); + } + + Type collectionType = ClientTypeUtil.GetImplementationType(containingExpr.Type, typeof(ICollection<>)); + if (collectionType != null) + { + throw new NotSupportedException(Strings.ALinq_InvalidGroupingExpression(memberExpr)); + } + } + + // Disallow grouping expressions that evaluate to a single-valued complex or navigation property + // Due to feature gap in OData WebApi + if (!PrimitiveType.IsKnownNullableType(memberExpr.Type)) + { + throw new NotSupportedException(Strings.ALinq_InvalidGroupingExpression(memberExpr)); + } + } } // TODO: By default, C#/Vb compilers uses declaring type for property expression when diff --git a/src/Microsoft.OData.Client/ALinq/UriWriter.cs b/src/Microsoft.OData.Client/ALinq/UriWriter.cs index 20ab6426b2..59c73a9b8d 100644 --- a/src/Microsoft.OData.Client/ALinq/UriWriter.cs +++ b/src/Microsoft.OData.Client/ALinq/UriWriter.cs @@ -591,20 +591,61 @@ internal void VisitCustomQueryOptions(DictionaryApplyQueryOptionExpression expression to visit internal void VisitQueryOptionExpression(ApplyQueryOptionExpression applyQueryOptionExpr) { - if (applyQueryOptionExpr.Aggregations.Count == 0) + // GroupBy with no aggregations is supported e.g. /Customers?$apply=groupby((Name)) + if (applyQueryOptionExpr.Aggregations.Count == 0 && applyQueryOptionExpr.GroupingExpressions.Count == 0) { return; } + StringBuilder applyOptionBuilder = new StringBuilder(); + string aggregateTransformation = string.Empty; // E.g. filter(Amount gt 1) string filterTransformation = ConstructFilterTransformation(applyQueryOptionExpr); - // E.g. aggregate(Prop with sum as SumProp, Prop with average as AverageProp) - string aggregateTransformation = ConstructAggregateTransformation(applyQueryOptionExpr.Aggregations); - string applyExpression = string.IsNullOrWhiteSpace(filterTransformation) ? string.Empty : filterTransformation + "/"; - applyExpression += aggregateTransformation; + if (!string.IsNullOrEmpty(filterTransformation)) + { + applyOptionBuilder.Append(filterTransformation); + applyOptionBuilder.Append("/"); + } + + if (applyQueryOptionExpr.Aggregations.Count > 0) + { + // E.g. aggregate(Prop with sum as SumProp, Prop with average as AverageProp) + aggregateTransformation = ConstructAggregateTransformation(applyQueryOptionExpr.Aggregations); + } - this.AddAsCachedQueryOption(UriHelper.DOLLARSIGN + UriHelper.OPTIONAPPLY, applyExpression); + if (applyQueryOptionExpr.GroupingExpressions.Count == 0) + { + applyOptionBuilder.Append(aggregateTransformation); + + // E.g. $apply=aggregate(Prop with sum as SumProp, Prop with average as AverageProp) + // Or $apply=filter(Amount gt 1)/aggregate(Prop with sum as SumProp, Prop with average as AverageProp) + this.AddAsCachedQueryOption(UriHelper.DOLLARSIGN + UriHelper.OPTIONAPPLY, applyOptionBuilder.ToString()); + } + else + { + // E.g (Prop1, Prop2, ..., PropN) + string groupingPropertiesExpr = ConstructGroupingExpression(applyQueryOptionExpr.GroupingExpressions); + + StringBuilder groupByBuilder = new StringBuilder(); + groupByBuilder.Append(applyOptionBuilder.ToString()); // This should add filter transformation if any + groupByBuilder.Append(UriHelper.GROUPBY); + groupByBuilder.Append(UriHelper.LEFTPAREN); + groupByBuilder.Append(groupingPropertiesExpr); + + if (!string.IsNullOrEmpty(aggregateTransformation)) + { + // Scenario: GroupBy(d1 => d1.Prop, (d1, d2) => new { Prop = d1 }) + groupByBuilder.Append(UriHelper.COMMA); + groupByBuilder.Append(aggregateTransformation); + } + + groupByBuilder.Append(UriHelper.RIGHTPAREN); + + // E.g. $apply=groupby((Category),aggregate(Prop with sum as SumProp, Prop with average as AverageProp)) + // Or $apply=filter(Amount gt 1)/groupby((Category),aggregate(Prop with sum as SumProp, Prop with average as AverageProp)) + this.AddAsCachedQueryOption(UriHelper.DOLLARSIGN + UriHelper.OPTIONAPPLY, groupByBuilder.ToString()); + } } /// @@ -692,6 +733,35 @@ private string ConstructAggregateTransformation(IList + /// Constructs a $apply grouping expression. + /// E.g. (Prop1, Prop2, ..., PropN) + /// + /// List of grouping expressions. + /// The grouping expression. + private string ConstructGroupingExpression(IList groupingExpressions) + { + StringBuilder builder = new StringBuilder(); + builder.Append(UriHelper.LEFTPAREN); + int i = 0; + + while (true) + { + Expression groupingExpression = groupingExpressions[i]; + builder.Append(this.ExpressionToString(groupingExpression, /*inPath*/ false)); + + if (++i == groupingExpressions.Count) + { + break; + } + builder.Append(UriHelper.COMMA); + } + + builder.Append(UriHelper.RIGHTPAREN); + + return builder.ToString(); + } + /// /// Caches query option to be grouped /// diff --git a/src/Microsoft.OData.Client/GroupByProjectionPlanCompiler.cs b/src/Microsoft.OData.Client/GroupByProjectionPlanCompiler.cs new file mode 100644 index 0000000000..e2363aa0c5 --- /dev/null +++ b/src/Microsoft.OData.Client/GroupByProjectionPlanCompiler.cs @@ -0,0 +1,885 @@ +//--------------------------------------------------------------------- +// +// Copyright (C) Microsoft Corporation. All rights reserved. See License.txt in the project root for license information. +// +//--------------------------------------------------------------------- + +namespace Microsoft.OData.Client +{ + #region Namespaces + + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.Globalization; + using System.Linq; + using System.Linq.Expressions; + using System.Reflection; + using Microsoft.OData.Client.Materialization; + using Microsoft.OData.Client.Metadata; + + #endregion Namespaces + + /// + /// Use this class to create a for a given projection lambda. + /// + internal class GroupByProjectionPlanCompiler : ALinqExpressionVisitor + { + #region Private fields + + /// Creates dynamic methods that wrap calls to internal methods. + private static readonly DynamicProxyMethodGenerator dynamicProxyMethodGenerator = new DynamicProxyMethodGenerator(); + + /// Annotations being tracked on this tree. + private readonly Dictionary annotations; + + /// Expression that refers to the materializer. + private readonly ParameterExpression materializerExpression; + + /// Tracks rewrite-to-source rewrites introduced by expression normalizer. + private readonly Dictionary normalizerRewrites; + + /// Number to suffix to identifiers to help with debugging. + private int identifierId; + + /// Path builder used to help with tracking state while compiling. + private GroupByProjectionPathBuilder pathBuilder; + + /// Whether the top level projection has been found. + private bool topLevelProjectionFound; + + /// Mapping of expressions in the GroupBy result selector to info required during projection plan compilation. + private Dictionary resultSelectorMap; + + /// Mapping of member names in the GroupBy key selector to their respective expressions. + private readonly Dictionary keySelectorMap; + + #endregion Private fields + + #region Constructors + + /// + /// Initializes a new instance. + /// + /// Rewrites introduced by normalizer. + /// Mapping of member names in the GroupBy key selector to their respective expressions. + private GroupByProjectionPlanCompiler( + Dictionary normalizerRewrites, + Dictionary keySelectorMap) + { + this.annotations = new Dictionary(ReferenceEqualityComparer.Instance); + this.materializerExpression = Expression.Parameter(typeof(object), "mat"); + this.normalizerRewrites = normalizerRewrites; + this.pathBuilder = new GroupByProjectionPathBuilder(); + this.resultSelectorMap = new Dictionary(ReferenceEqualityComparer.Instance); + this.keySelectorMap = keySelectorMap; + } + + #endregion Constructors + + #region Internal methods. + + /// Creates a projection plan from the specified . + /// Projection expression. + /// Tracks rewrite-to-source rewrites introduced by expression normalizer. + /// Mapping of member names in the GroupBy key selector to their respective expressions. + /// A new instance. + internal static ProjectionPlan CompilePlan( + LambdaExpression projection, + Dictionary normalizerRewrites, + Dictionary keySelectorMap) + { + Debug.Assert(projection != null, "projection != null"); + Debug.Assert(projection.Parameters.Count >= 1, "projection.Parameters.Count >= 1"); + Debug.Assert( + projection.Body.NodeType == ExpressionType.Constant || + projection.Body.NodeType == ExpressionType.MemberInit || + projection.Body.NodeType == ExpressionType.MemberAccess || + projection.Body.NodeType == ExpressionType.Convert || + projection.Body.NodeType == ExpressionType.ConvertChecked || + projection.Body.NodeType == ExpressionType.New, + "projection.Body.NodeType == Constant, MemberInit, MemberAccess, Convert(Checked) New"); + + GroupByProjectionPlanCompiler rewriter = new GroupByProjectionPlanCompiler(normalizerRewrites, keySelectorMap); + GroupByProjectionAnalyzer.Analyze(rewriter, projection); + + Expression plan = rewriter.Visit(projection); + + ProjectionPlan result = new ProjectionPlan(); + result.Plan = (Func)((LambdaExpression)plan).Compile(); + result.ProjectedType = projection.Body.Type; +#if DEBUG + result.SourceProjection = projection; + result.TargetProjection = plan; +#endif + return result; + } + + /// + /// MemberExpression visit method + /// + /// The MemberExpression expression to visit + /// The visited MemberExpression expression + internal override Expression VisitMemberAccess(MemberExpression m) + { + Debug.Assert(m != null, "m != null"); + + Expression baseSourceExpression = m.Expression; + + // If primitive or nullable primitive, allow member access... i.e. calling Value on nullable + if (PrimitiveType.IsKnownNullableType(baseSourceExpression.Type)) + { + return base.VisitMemberAccess(m); + } + + Expression baseTargetExpression = this.Visit(baseSourceExpression); + ExpressionAnnotation annotation; + if (this.annotations.TryGetValue(baseTargetExpression, out annotation)) + { + return this.RebindMemberAccess(m, annotation); + } + + if (this.resultSelectorMap.TryGetValue(m, out MappingInfo mappingInfo)) + { + this.BindProjectedExpression(mappingInfo.GroupingExpression); + + return this.Visit(mappingInfo.GroupingExpression); + } + + return Expression.MakeMemberAccess(baseTargetExpression, m.Member); + } + + /// Parameter visit method. + /// Parameter to visit. + /// Resulting expression. + /// + /// The parameter may get rewritten as a materializing projection if + /// it refers to an entity outside of member binding. In this case, + /// it becomes a standalone tracked entity. + /// + internal override Expression VisitParameter(ParameterExpression p) + { + Debug.Assert(p != null, "p != null"); + + // If this parameter isn't interesting, we're not doing any rewrites. + ExpressionAnnotation annotation; + if (this.annotations.TryGetValue(p, out annotation)) + { + return this.RebindParameter(p, annotation); + } + + // Scenario: GroupBy(d1 => d1.Prop, (d2, d3) => new { ProductId = d2, ... }) + if (this.resultSelectorMap.TryGetValue(p, out MappingInfo mappingInfo)) + { + Debug.Assert(mappingInfo.GroupingExpression != null, "mappingInfo.GroupingExpression != null"); + + this.BindProjectedExpression(mappingInfo.GroupingExpression); + + return this.Visit(mappingInfo.GroupingExpression); + } + + return base.VisitParameter(p); + } + + /// Visits a method call expression. + /// Expression to visit. + /// A (possibly rewritten) expression for . + internal override Expression VisitMethodCall(MethodCallExpression m) + { + Debug.Assert(m != null, "m != null"); + + Expression original = this.GetExpressionBeforeNormalization(m); + if (original != m) + { + return this.Visit(original); + } + + SequenceMethod sequenceMethod; + ReflectionUtil.TryIdentifySequenceMethod(m.Method, out sequenceMethod); + + switch (sequenceMethod) + { + case SequenceMethod.SumIntSelector: + case SequenceMethod.SumDoubleSelector: + case SequenceMethod.SumDecimalSelector: + case SequenceMethod.SumLongSelector: + case SequenceMethod.SumSingleSelector: + case SequenceMethod.SumNullableIntSelector: + case SequenceMethod.SumNullableDoubleSelector: + case SequenceMethod.SumNullableDecimalSelector: + case SequenceMethod.SumNullableLongSelector: + case SequenceMethod.SumNullableSingleSelector: + case SequenceMethod.AverageIntSelector: + case SequenceMethod.AverageDoubleSelector: + case SequenceMethod.AverageDecimalSelector: + case SequenceMethod.AverageLongSelector: + case SequenceMethod.AverageSingleSelector: + case SequenceMethod.AverageNullableIntSelector: + case SequenceMethod.AverageNullableDoubleSelector: + case SequenceMethod.AverageNullableDecimalSelector: + case SequenceMethod.AverageNullableLongSelector: + case SequenceMethod.AverageNullableSingleSelector: + case SequenceMethod.MinIntSelector: + case SequenceMethod.MinDoubleSelector: + case SequenceMethod.MinDecimalSelector: + case SequenceMethod.MinLongSelector: + case SequenceMethod.MinSingleSelector: + case SequenceMethod.MinNullableIntSelector: + case SequenceMethod.MinNullableDoubleSelector: + case SequenceMethod.MinNullableDecimalSelector: + case SequenceMethod.MinNullableLongSelector: + case SequenceMethod.MinNullableSingleSelector: + case SequenceMethod.MaxIntSelector: + case SequenceMethod.MaxDoubleSelector: + case SequenceMethod.MaxDecimalSelector: + case SequenceMethod.MaxLongSelector: + case SequenceMethod.MaxSingleSelector: + case SequenceMethod.MaxNullableIntSelector: + case SequenceMethod.MaxNullableDoubleSelector: + case SequenceMethod.MaxNullableDecimalSelector: + case SequenceMethod.MaxNullableLongSelector: + case SequenceMethod.MaxNullableSingleSelector: + case SequenceMethod.Count: + case SequenceMethod.LongCount: + case SequenceMethod.CountDistinctSelector: + return this.RebindMethodCallForAggregationMethod(m); + default: + return base.VisitMethodCall(m); + } + } + + /// + /// Visit + /// + /// Expression to visit + /// an expression + internal override Expression Visit(Expression exp) + { + if (exp == null) + { + // Parent expression may contain null children, in this case just return + return exp; + } + + return base.Visit(exp); + } + + /// LambdaExpression visit method. + /// The LambdaExpression to visit + /// The visited LambdaExpression + internal override Expression VisitLambda(LambdaExpression lambda) + { + Debug.Assert(lambda != null, "lambda != null"); + + Expression result; + ParameterExpression lambdaParameter = lambda.Parameters[lambda.Parameters.Count - 1]; + if (!this.topLevelProjectionFound || lambda.Parameters.Count >= 1 && ClientTypeUtil.TypeOrElementTypeIsEntity(lambdaParameter.Type)) + { + this.topLevelProjectionFound = true; + + ParameterExpression expectedTypeParameter = Expression.Parameter(typeof(Type), "type" + this.identifierId.ToString(CultureInfo.InvariantCulture)); + ParameterExpression entryParameter = Expression.Parameter(typeof(object), "entry" + this.identifierId.ToString(CultureInfo.InvariantCulture)); + this.identifierId++; + + this.pathBuilder.EnterLambdaScope(lambda, entryParameter, expectedTypeParameter); + ProjectionPath parameterPath = new ProjectionPath(lambdaParameter, expectedTypeParameter, entryParameter); + ProjectionPathSegment parameterSegment = new ProjectionPathSegment(parameterPath, null, null); + parameterPath.Add(parameterSegment); + this.annotations[lambdaParameter] = new ExpressionAnnotation { Segment = parameterSegment }; + + Expression body = this.Visit(lambda.Body); + + // Value types must be boxed explicitly; the lambda initialization + // won't do it for us (type-compatible types still work, so all + // references will work fine with System.Object). + if (body.Type.IsValueType()) + { + body = Expression.Convert(body, typeof(object)); + } + + result = Expression.Lambda>( + body, + this.materializerExpression, + entryParameter, + expectedTypeParameter); + + this.pathBuilder.LeaveLambdaScope(); + } + else + { + result = base.VisitLambda(lambda); + } + + return result; + } + + #endregion Internal methods. + + #region Private methods. + + /// Generates a call to a static method on AtomMaterializer. + /// Name of method to invoke. + /// Arguments to pass to method. + /// The constructed expression. + /// + /// There is no support for overload resolution - method names in AtomMaterializer + /// must be unique. + /// + private static Expression CallMaterializer(string methodName, params Expression[] arguments) + { + return CallMaterializerWithType(methodName, null, arguments); + } + + /// Generates a call to a static method on AtomMaterializer. + /// Name of method to invoke. + /// Type arguments for method (possibly null). + /// Arguments to pass to method. + /// The constructed expression. + /// + /// There is no support for overload resolution - method names in AtomMaterializer + /// must be unique. + /// + private static Expression CallMaterializerWithType(string methodName, Type[] typeArguments, params Expression[] arguments) + { + Debug.Assert(methodName != null, "methodName != null"); + Debug.Assert(arguments != null, "arguments != null"); + + MethodInfo method = typeof(ODataEntityMaterializerInvoker).GetMethod(methodName, false /*isPublic*/, true /*isStatic*/); + Debug.Assert(method != null, "method != null - found " + methodName); + if (typeArguments != null) + { + method = method.MakeGenericMethod(typeArguments); + } + + return dynamicProxyMethodGenerator.GetCallWrapper(method, arguments); + } + + /// Creates an expression that calls ProjectionValueForPath. + /// Expression for root entry for paths. + /// Expression for expected type for entry. + /// Path to pull value from. + /// A new expression with the call instance. + private Expression CallValueForPath(Expression entry, Expression entryType, ProjectionPath path) + { + Debug.Assert(entry != null, "entry != null"); + Debug.Assert(path != null, "path != null"); + + Expression result = CallMaterializer("ProjectionValueForPath", this.materializerExpression, entry, entryType, Expression.Constant(path, typeof(object))); + this.annotations.Add(result, new ExpressionAnnotation() { Segment = path[path.Count - 1] }); + return result; + } + + /// Creates an expression that calls ProjectionValueForPath. + /// Expression for root entry for paths. + /// Expression for expected type for entry. + /// Path to pull value from. + /// Path to convert result for. + /// A new expression with the call instance. + private Expression CallValueForPathWithType(Expression entry, Expression entryType, ProjectionPath path, Type type) + { + Debug.Assert(entry != null, "entry != null"); + Debug.Assert(path != null, "path != null"); + + Expression value = this.CallValueForPath(entry, entryType, path); + Expression result = Expression.Convert(value, type); + this.annotations.Add(result, new ExpressionAnnotation() { Segment = path[path.Count - 1] }); + return result; + } + + /// Gets an expression before its rewrite. + /// Expression to check. + /// The expression before normalization. + private Expression GetExpressionBeforeNormalization(Expression expression) + { + Debug.Assert(expression != null, "expression != null"); + if (this.normalizerRewrites != null) + { + Expression original; + if (this.normalizerRewrites.TryGetValue(expression, out original)) + { + expression = original; + } + } + + return expression; + } + + /// Rebinds the specified parameter expression as a path-based access. + /// Expression to rebind. + /// Annotation for the expression to rebind. + /// The rebound expression. + private Expression RebindParameter(Expression expression, ExpressionAnnotation annotation) + { + Debug.Assert(expression != null, "expression != null"); + Debug.Assert(annotation != null, "annotation != null"); + + Expression result; + result = this.CallValueForPathWithType( + annotation.Segment.StartPath.RootEntry, + annotation.Segment.StartPath.ExpectedRootType, + annotation.Segment.StartPath, + expression.Type); + + // Refresh the annotation so the next one that comes along + // doesn't start off with an already-written path. + ProjectionPath parameterPath = new ProjectionPath( + annotation.Segment.StartPath.Root, + annotation.Segment.StartPath.ExpectedRootType, + annotation.Segment.StartPath.RootEntry); + ProjectionPathSegment parameterSegment = new ProjectionPathSegment(parameterPath, null, null); + parameterPath.Add(parameterSegment); + this.annotations[expression] = new ExpressionAnnotation() { Segment = parameterSegment }; + + return result; + } + + /// Rebinds the specified member access expression into a path-based value retrieval method call. + /// Member expression. + /// Annotation for the base portion of the expression. + /// A rebound expression. + private Expression RebindMemberAccess(MemberExpression m, ExpressionAnnotation baseAnnotation) + { + Debug.Assert(m != null, "m != null"); + Debug.Assert(baseAnnotation != null, "baseAnnotation != null"); + + // This actually modifies the path for the underlying + // segments, but that shouldn't be a problem. Actually + // we should be able to remove it from the dictionary. + // There should be no aliasing problems, because + // annotations always come from target expression + // that are generated anew (except parameters, + // but those) + ProjectionPathSegment memberSegment = new ProjectionPathSegment(baseAnnotation.Segment.StartPath, m); + baseAnnotation.Segment.StartPath.Add(memberSegment); + return this.CallValueForPathWithType( + baseAnnotation.Segment.StartPath.RootEntry, + baseAnnotation.Segment.StartPath.ExpectedRootType, + baseAnnotation.Segment.StartPath, + m.Type); + } + + private void BindProjectedExpression(Expression expr) + { + ParameterExpression paramExpr; + + switch (expr.NodeType) + { + case ExpressionType.Parameter: + paramExpr = expr as ParameterExpression; + break; + case ExpressionType.MemberAccess: + MemberExpression memberExpr = expr as MemberExpression; + while (memberExpr.Expression is MemberExpression) + { + memberExpr = memberExpr.Expression as MemberExpression; + } + + paramExpr = memberExpr.Expression as ParameterExpression; + break; + case ExpressionType.Constant: + paramExpr = null; + break; + default: + throw Error.NotSupported(); + } + + if (paramExpr != null && !this.annotations.TryGetValue(paramExpr, out _)) + { + ProjectionPath parameterPath = new ProjectionPath(paramExpr, this.pathBuilder.ExpectedParamTypeInScope, this.pathBuilder.ParameterEntryInScope); + ProjectionPathSegment parameterSegment = new ProjectionPathSegment(parameterPath, null, null); + parameterPath.Add(parameterSegment); + this.annotations[paramExpr] = new ExpressionAnnotation { Segment = parameterSegment }; + } + } + + private Expression RebindMethodCallForAggregationMethod(MethodCallExpression methodCallExpr) + { + Debug.Assert(methodCallExpr != null, $"{nameof(methodCallExpr)} != null"); + Debug.Assert(methodCallExpr.Arguments.Count == 2 || methodCallExpr.Method.Name == "Count" || methodCallExpr.Method.Name == "LongCount", + $"{methodCallExpr.Method.Name} is not a supported aggregation method"); + Debug.Assert(methodCallExpr.Arguments.Count == 1 || !(methodCallExpr.Method.Name == "Count" || methodCallExpr.Method.Name == "LongCount"), + $"{methodCallExpr.Method.Name} is not a supported aggregation method"); + + if (!this.resultSelectorMap.TryGetValue(methodCallExpr, out MappingInfo mappingInfo)) + { + return methodCallExpr; + } + + Debug.Assert(mappingInfo.Member != null, "mappingInfo.Member != null"); + Debug.Assert(mappingInfo.Type != null, "mappingInfo.Type != null"); + string targetName = mappingInfo.Member; + Type targetType = mappingInfo.Type; + + Expression baseSourceExpression = methodCallExpr.Arguments[0]; + Expression baseTargetExpression = this.Visit(baseSourceExpression); + + ExpressionAnnotation annotation; + if (!this.annotations.TryGetValue(baseTargetExpression, out annotation)) + { + return methodCallExpr; + } + + ProjectionPathSegment memberSegment = new ProjectionPathSegment(annotation.Segment.StartPath, targetName, targetType); + annotation.Segment.StartPath.Add(memberSegment); + + Expression value = CallMaterializer( + "ProjectionDynamicValueForPath", + this.materializerExpression, + annotation.Segment.StartPath.RootEntry, + Expression.Constant(targetType, typeof(Type)), + Expression.Constant(annotation.Segment.StartPath, typeof(object))); + this.annotations.Add(value, new ExpressionAnnotation { Segment = annotation.Segment.StartPath[annotation.Segment.StartPath.Count - 1] }); + Expression result = Expression.Convert(value, targetType); + this.annotations.Add(result, new ExpressionAnnotation { Segment = annotation.Segment.StartPath[annotation.Segment.StartPath.Count - 1] }); + + return result; + } + + #endregion Private methods. + + #region Inner Types + + /// Annotates an expression, typically from the target tree. + private class ExpressionAnnotation + { + /// Segment that marks the path found to an expression. + internal ProjectionPathSegment Segment + { + get; + set; + } + } + + private class MappingInfo + { + public string Member { get; set; } + public Type Type { get; set; } + public Expression GroupingExpression { get; set; } + } + + /// + /// This class analyzes the GroupBy result selector to establish a mapping between + /// the expressions and the property they correspond to in the JSON response. + /// + /// + /// For example, + /// CategoryName corresponds to Product/Category/Name, + /// YearStr corresponds to Time/Year, + /// d3.Average(d4 => d4.Amount) corresponds to AverageAmount, + /// d3.Sum(d4 => d4.Amount) corresponds to SumAmount, + /// in the following GroupBy expression: + /// dataServiceContext.Sales.GroupBy( + /// d1 => new { CategoryName = d1.Product.Category.Name, d1.Time.Year }, + /// (d2, d3) => new + /// { + /// CategoryName = d2.CategoryName, + /// YearStr = d2.Year.ToString(), + /// AverageAmount = d3.Average(d4 => d4.Amount).ToString(), + /// SumAmount = d4.Sum(d4 => d4.Amount) + /// }); + /// + private class GroupByProjectionAnalyzer : ALinqExpressionVisitor + { + private GroupByProjectionPlanCompiler compiler; + private readonly Dictionary> memberMap; + private readonly Stack memberInScope; + + /// + /// Initializes a new instance. + /// + private GroupByProjectionAnalyzer() + { + this.memberMap = new Dictionary>(ReferenceEqualityComparer.Instance); + this.memberInScope = new Stack(); + } + + /// + /// Analyzes the GroupBy result selector to establish a mapping between + /// the expressions and the property they correspond to in the JSON response. + /// + /// The parent instance. + /// The lambda expression to analyze. + internal static void Analyze(GroupByProjectionPlanCompiler compiler, LambdaExpression resultSelector) + { + GroupByProjectionAnalyzer analyzer = new GroupByProjectionAnalyzer(); + analyzer.compiler = compiler; + + analyzer.Visit(resultSelector.Body); + } + + /// + internal override Expression VisitMemberAccess(MemberExpression m) + { + Debug.Assert(m != null, "m != null"); + + Expression baseSourceExpression = m.Expression; + + // if primitive or nullable primitive, allow member access... i.e. calling Value on nullable + if (PrimitiveType.IsKnownNullableType(baseSourceExpression.Type)) + { + return base.VisitMemberAccess(m); + } + + if (compiler.keySelectorMap.TryGetValue(m.Member.Name, out Expression groupingExpression)) + { + compiler.resultSelectorMap.Add( + m, + new MappingInfo + { + GroupingExpression = groupingExpression + }); + } + + return m; + } + + /// + internal override Expression VisitParameter(ParameterExpression p) + { + if (compiler.keySelectorMap.Count == 1) + { + compiler.resultSelectorMap.Add( + p, + new MappingInfo + { + GroupingExpression = compiler.keySelectorMap.ElementAt(0).Value + }); + } + + return p; + } + + /// + internal override Expression VisitMethodCall(MethodCallExpression m) + { + Debug.Assert(m != null, "m != null"); + + if (this.memberMap.TryGetValue(m, out KeyValuePair member)) + { + this.memberInScope.Push(member.Key); + } + + Expression original = compiler.GetExpressionBeforeNormalization(m); + if (original != m) + { + return this.Visit(original); + } + + Expression result; + + SequenceMethod sequenceMethod; + ReflectionUtil.TryIdentifySequenceMethod(m.Method, out sequenceMethod); + + switch (sequenceMethod) + { + case SequenceMethod.SumIntSelector: + case SequenceMethod.SumDoubleSelector: + case SequenceMethod.SumDecimalSelector: + case SequenceMethod.SumLongSelector: + case SequenceMethod.SumSingleSelector: + case SequenceMethod.SumNullableIntSelector: + case SequenceMethod.SumNullableDoubleSelector: + case SequenceMethod.SumNullableDecimalSelector: + case SequenceMethod.SumNullableLongSelector: + case SequenceMethod.SumNullableSingleSelector: + case SequenceMethod.AverageIntSelector: + case SequenceMethod.AverageDoubleSelector: + case SequenceMethod.AverageDecimalSelector: + case SequenceMethod.AverageLongSelector: + case SequenceMethod.AverageSingleSelector: + case SequenceMethod.AverageNullableIntSelector: + case SequenceMethod.AverageNullableDoubleSelector: + case SequenceMethod.AverageNullableDecimalSelector: + case SequenceMethod.AverageNullableLongSelector: + case SequenceMethod.AverageNullableSingleSelector: + case SequenceMethod.MinIntSelector: + case SequenceMethod.MinDoubleSelector: + case SequenceMethod.MinDecimalSelector: + case SequenceMethod.MinLongSelector: + case SequenceMethod.MinSingleSelector: + case SequenceMethod.MinNullableIntSelector: + case SequenceMethod.MinNullableDoubleSelector: + case SequenceMethod.MinNullableDecimalSelector: + case SequenceMethod.MinNullableLongSelector: + case SequenceMethod.MinNullableSingleSelector: + case SequenceMethod.MaxIntSelector: + case SequenceMethod.MaxDoubleSelector: + case SequenceMethod.MaxDecimalSelector: + case SequenceMethod.MaxLongSelector: + case SequenceMethod.MaxSingleSelector: + case SequenceMethod.MaxNullableIntSelector: + case SequenceMethod.MaxNullableDoubleSelector: + case SequenceMethod.MaxNullableDecimalSelector: + case SequenceMethod.MaxNullableLongSelector: + case SequenceMethod.MaxNullableSingleSelector: + case SequenceMethod.Count: + case SequenceMethod.LongCount: + case SequenceMethod.CountDistinctSelector: + compiler.resultSelectorMap.Add( + m, + new MappingInfo + { + GroupingExpression = null, + Member = this.memberInScope.Peek(), + Type = m.Type + }); + result = m; + + break; + default: + result = base.VisitMethodCall(m); + + break; + } + + if (this.memberMap.ContainsKey(m)) + { + this.memberInScope.Pop(); + } + + return result; + } + + /// + internal override NewExpression VisitNew(NewExpression nex) + { + ParameterInfo[] parameters; + if (nex.Members != null && nex.Members.Count == nex.Arguments.Count) + { + for (int i = 0; i < nex.Arguments.Count; i++) + { + PropertyInfo property = nex.Members[i] as PropertyInfo; + Debug.Assert(property != null, $"property != null"); + + this.memberMap.Add(nex.Arguments[i], new KeyValuePair(property.Name, property.PropertyType)); + } + } + else if (nex.Arguments.Count > 0 && (parameters = nex.Constructor.GetParameters()).Length >= nex.Arguments.Count) + { + for (int i = 0; i < nex.Arguments.Count; i++) + { + this.memberMap.Add(nex.Arguments[i], new KeyValuePair(parameters[i].Name, parameters[i].ParameterType)); + } + } + + return base.VisitNew(nex); + } + + /// + internal override MemberAssignment VisitMemberAssignment(MemberAssignment assignment) + { + // Maintain a mapping of expression argument and respective member + PropertyInfo property = assignment.Member as PropertyInfo; + Debug.Assert(property != null, $"property != null"); + + this.memberMap.Add(assignment.Expression, new KeyValuePair(property.Name, property.PropertyType)); + + return base.VisitMemberAssignment(assignment); + } + } + + /// + /// Use this class to help keep track of projection paths built + /// while compiling a projection-based materialization plan. + /// + private class GroupByProjectionPathBuilder + { + /// Stack of lambda expressions in scope. + private readonly Stack parameterExpressions; + + /// + /// Stack of expected type expression for . + /// + private readonly Stack parameterExpressionTypes; + + /// Stack of 'entry' parameter expressions. + private readonly Stack parameterEntries; + + /// Stack of projection (target-tree) types for parameters. + private readonly Stack parameterProjectionTypes; + + /// Initializes a new instance. + internal GroupByProjectionPathBuilder() + { + this.parameterExpressions = new Stack(); + this.parameterExpressionTypes = new Stack(); + this.parameterEntries = new Stack(); + this.parameterProjectionTypes = new Stack(); + } + + /// Expression for the expected type parameter. + internal Expression ExpectedParamTypeInScope + { + get + { + Debug.Assert(this.parameterExpressionTypes.Count > 0, "this.parameterExpressionTypes.Count > 0"); + return this.parameterExpressionTypes.Peek(); + } + } + + /// Expression for the entity parameter in the source tree lambda. + internal Expression LambdaParameterInScope + { + get + { + return this.parameterExpressions.Peek(); + } + } + + /// Expression for the entry parameter in the target tree. + internal Expression ParameterEntryInScope + { + get + { + return this.parameterEntries.Peek(); + } + } + + /// Provides a string representation of this object. + /// String representation of this object. + public override string ToString() + { + string result = "GroupByProjectionPathBuilder: "; + if (this.parameterExpressions.Count == 0) + { + result += "(empty)"; + } + else + { + result += " param:" + this.ParameterEntryInScope; + } + + return result; + } + + /// Records that a lambda scope has been entered when visiting a projection. + /// Lambda being visited. + /// Expression to the entry parameter from the target tree. + /// Expression to the entry-expected-type from the target tree. + internal void EnterLambdaScope(LambdaExpression lambda, Expression entry, Expression expectedType) + { + Debug.Assert(lambda != null, "lambda != null"); + Debug.Assert(lambda.Parameters.Count == 2, "lambda.Parameters.Count == 2"); + + ParameterExpression param = lambda.Parameters[1]; + Type projectionType = lambda.Body.Type; + + this.parameterExpressions.Push(param); + this.parameterExpressionTypes.Push(expectedType); + this.parameterEntries.Push(entry); + this.parameterProjectionTypes.Push(projectionType); + } + + /// Records that a lambda scope has been left when visiting a projection. + internal void LeaveLambdaScope() + { + this.parameterExpressions.Pop(); + this.parameterExpressionTypes.Pop(); + this.parameterEntries.Pop(); + this.parameterProjectionTypes.Pop(); + } + } + + #endregion Inner Types + } +} diff --git a/src/Microsoft.OData.Client/Materialization/MaterializerEntry.cs b/src/Microsoft.OData.Client/Materialization/MaterializerEntry.cs index 8cc908949d..de815eae35 100644 --- a/src/Microsoft.OData.Client/Materialization/MaterializerEntry.cs +++ b/src/Microsoft.OData.Client/Materialization/MaterializerEntry.cs @@ -263,7 +263,7 @@ public static MaterializerEntry GetEntry(ODataResource entry) /// The link. public void AddNestedResourceInfo(ODataNestedResourceInfo link) { - if (this.IsTracking) + if (this.IsTracking && !this.Entry.IsTransient) { this.EntityDescriptor.AddNestedResourceInfo(link.Name, link.Url); Uri associationLinkUrl = link.AssociationLinkUrl; diff --git a/src/Microsoft.OData.Client/Materialization/ODataEntityMaterializer.cs b/src/Microsoft.OData.Client/Materialization/ODataEntityMaterializer.cs index 3a17c8b899..7b978fa569 100644 --- a/src/Microsoft.OData.Client/Materialization/ODataEntityMaterializer.cs +++ b/src/Microsoft.OData.Client/Materialization/ODataEntityMaterializer.cs @@ -803,6 +803,56 @@ internal object ProjectionValueForPath(MaterializerEntry entry, Type expectedTyp return result; } + /// Projects a simple dynamic value from the specified . + /// Root entry for paths. + /// Expected type for the dynamic property value. + /// Path to pull value for. + /// The value for the specified . + /// + /// This method will not instantiate entity types, except to satisfy requests + /// for payload-driven feeds or leaf entities. + /// + internal object ProjectionDynamicValueForPath(MaterializerEntry entry, Type expectedPropertyType, ProjectionPath path) + { + Debug.Assert(entry != null, "entry != null"); + Debug.Assert(entry.Entry != null, "entry.Entry != null"); + Debug.Assert(path != null, "path != null"); + Debug.Assert(expectedPropertyType != null, "expectedPropertyType != null"); + + object result = null; + ODataProperty odataProperty = null; + IEnumerable properties = entry.Entry.Properties; + + for (int i = 0; i < path.Count; i++) + { + var segment = path[i]; + if (segment.Member == null) + { + continue; + } + + string propertyName = segment.Member; + + odataProperty = properties.Where(p => p.Name == propertyName).FirstOrDefault(); + + if (odataProperty == null) + { + throw new InvalidOperationException(DSClient.Strings.AtomMaterializer_PropertyMissing(propertyName)); + } + + if (odataProperty.Value == null && !ClientTypeUtil.CanAssignNull(expectedPropertyType)) + { + throw new InvalidOperationException(DSClient.Strings.AtomMaterializer_CannotAssignNull(odataProperty.Name, expectedPropertyType)); + } + + this.entryValueMaterializationPolicy.MaterializePrimitiveDataValue(expectedPropertyType, odataProperty); + + return odataProperty.GetMaterializedValue(); + } + + return result; + } + #endregion Projection support. /// Clears the materialization log of activity. @@ -919,7 +969,16 @@ private static ProjectionPlan CreatePlan(QueryComponents queryComponents) } else { - result = ProjectionPlanCompiler.CompilePlan(projection, queryComponents.NormalizerRewrites); + // The KeySelectorMap property is initialized and populated with a least one item if we're dealing with a GroupBy expression. + if (queryComponents.GroupByKeySelectorMap?.Count > 0) + { + result = GroupByProjectionPlanCompiler.CompilePlan(projection, queryComponents.NormalizerRewrites, queryComponents.GroupByKeySelectorMap); + } + else + { + result = ProjectionPlanCompiler.CompilePlan(projection, queryComponents.NormalizerRewrites); + } + result.LastSegmentType = queryComponents.LastSegmentType; } diff --git a/src/Microsoft.OData.Client/Materialization/ODataEntityMaterializerInvoker.cs b/src/Microsoft.OData.Client/Materialization/ODataEntityMaterializerInvoker.cs index cafb80ded1..dc79df58ed 100644 --- a/src/Microsoft.OData.Client/Materialization/ODataEntityMaterializerInvoker.cs +++ b/src/Microsoft.OData.Client/Materialization/ODataEntityMaterializerInvoker.cs @@ -137,6 +137,24 @@ internal static object ProjectionValueForPath(object materializer, object entry, return ((ODataEntityMaterializer)materializer).ProjectionValueForPath(MaterializerEntry.GetEntry((ODataResource)entry), expectedType, (ProjectionPath)path); } + /// Projects a simple dynamic value from the specified . + /// Materializer under which projection is taking place. + /// Root entry for paths. + /// Expected type for dynamic value. + /// Path to pull value for. + /// The value for the specified . + /// + /// This method will not instantiate entity types, except to satisfy requests + /// for payload-driven feeds or leaf entities. + /// + internal static object ProjectionDynamicValueForPath(object materializer, object entry, Type expectedPropertyType, object path) + { + Debug.Assert(typeof(ODataEntityMaterializer).IsAssignableFrom(materializer.GetType()), "typeof(ODataEntityMaterializer).IsAssignableFrom(materializer.GetType())"); + Debug.Assert(entry.GetType() == typeof(ODataResource), "entry.GetType() == typeof(ODataResource)"); + Debug.Assert(path.GetType() == typeof(ProjectionPath), "path.GetType() == typeof(ProjectionPath)"); + return ((ODataEntityMaterializer)materializer).ProjectionDynamicValueForPath(MaterializerEntry.GetEntry((ODataResource)entry), expectedPropertyType, (ProjectionPath)path); + } + /// Materializes an entry with no special selection. /// Materializer under which materialization should take place. /// Entry with object to materialize. diff --git a/src/Microsoft.OData.Client/Microsoft.OData.Client.Common.txt b/src/Microsoft.OData.Client/Microsoft.OData.Client.Common.txt index 4e8237670e..41bfb9cc53 100644 --- a/src/Microsoft.OData.Client/Microsoft.OData.Client.Common.txt +++ b/src/Microsoft.OData.Client/Microsoft.OData.Client.Common.txt @@ -212,6 +212,8 @@ ALinq_TypeTokenWithNoTrailingNavProp=Found an illegal type token '{0}' without a ALinq_ContainsNotValidOnEmptyCollection=The Contains method cannot be used with an empty collection. ALinq_AggregationMethodNotSupported=The aggregation method '{0}' is not supported. ALinq_InvalidAggregateExpression=The expression '{0}' is not a valid aggregate expression. The aggregate expression must evaluate to a single-valued property path to an aggregatable property. +ALinq_InvalidGroupingExpression=The expression '{0}' is not a valid expression for grouping. The grouping expression must evaluate to a single-valued property path, i.e., a path ending in a single-valued primitive. +ALinq_InvalidGroupByKeySelector=The expression '{0}' in the GroupBy key selector is not supported. DSKAttribute_MustSpecifyAtleastOnePropertyName=DataServiceKey attribute must specify at least one property name. diff --git a/src/Microsoft.OData.Client/Microsoft.OData.Client.cs b/src/Microsoft.OData.Client/Microsoft.OData.Client.cs index 0c4a3285c0..d5523dc53a 100644 --- a/src/Microsoft.OData.Client/Microsoft.OData.Client.cs +++ b/src/Microsoft.OData.Client/Microsoft.OData.Client.cs @@ -210,6 +210,8 @@ internal sealed class TextRes { internal const string ALinq_ContainsNotValidOnEmptyCollection = "ALinq_ContainsNotValidOnEmptyCollection"; internal const string ALinq_AggregationMethodNotSupported = "ALinq_AggregationMethodNotSupported"; internal const string ALinq_InvalidAggregateExpression = "ALinq_InvalidAggregateExpression"; + internal const string ALinq_InvalidGroupingExpression = "ALinq_InvalidGroupingExpression"; + internal const string ALinq_InvalidGroupByKeySelector = "ALinq_InvalidGroupByKeySelector"; internal const string DSKAttribute_MustSpecifyAtleastOnePropertyName = "DSKAttribute_MustSpecifyAtleastOnePropertyName"; internal const string DataServiceCollection_LoadRequiresTargetCollectionObserved = "DataServiceCollection_LoadRequiresTargetCollectionObserved"; internal const string DataServiceCollection_CannotStopTrackingChildCollection = "DataServiceCollection_CannotStopTrackingChildCollection"; diff --git a/src/Microsoft.OData.Client/Microsoft.OData.Client.txt b/src/Microsoft.OData.Client/Microsoft.OData.Client.txt index 3587e7be74..f8f3cc8475 100644 --- a/src/Microsoft.OData.Client/Microsoft.OData.Client.txt +++ b/src/Microsoft.OData.Client/Microsoft.OData.Client.txt @@ -211,6 +211,8 @@ ALinq_TypeTokenWithNoTrailingNavProp=Found an illegal type token '{0}' without a ALinq_ContainsNotValidOnEmptyCollection=The Contains method cannot be used with an empty collection. ALinq_AggregationMethodNotSupported=The aggregation method '{0}' is not supported. ALinq_InvalidAggregateExpression=The expression '{0}' is not a valid aggregate expression. The aggregate expression must evaluate to a single-valued property path to an aggregatable property. +ALinq_InvalidGroupingExpression=The expression '{0}' is not a valid expression for grouping. The grouping expression must evaluate to a single-valued property path, i.e., a path ending in a single-valued primitive. +ALinq_InvalidGroupByKeySelector=The expression '{0}' in the GroupBy key selector is not supported. DSKAttribute_MustSpecifyAtleastOnePropertyName=DataServiceKey attribute must specify at least one property name. diff --git a/src/Microsoft.OData.Client/Parameterized.Microsoft.OData.Client.cs b/src/Microsoft.OData.Client/Parameterized.Microsoft.OData.Client.cs index 86180246fb..921b1df14d 100644 --- a/src/Microsoft.OData.Client/Parameterized.Microsoft.OData.Client.cs +++ b/src/Microsoft.OData.Client/Parameterized.Microsoft.OData.Client.cs @@ -1831,6 +1831,22 @@ internal static string ALinq_InvalidAggregateExpression(object p0) return Microsoft.OData.Client.TextRes.GetString(Microsoft.OData.Client.TextRes.ALinq_InvalidAggregateExpression, p0); } + /// + /// A string like "The expression '{0}' is not a valid expression for grouping. The grouping expression must evaluate to a single-valued property path, i.e., a path ending in a single-valued primitive." + /// + internal static string ALinq_InvalidGroupingExpression(object p0) + { + return Microsoft.OData.Client.TextRes.GetString(Microsoft.OData.Client.TextRes.ALinq_InvalidGroupingExpression, p0); + } + + /// + /// A string like "The expression '{0}' in the GroupBy key selector is not supported." + /// + internal static string ALinq_InvalidGroupByKeySelector(object p0) + { + return Microsoft.OData.Client.TextRes.GetString(Microsoft.OData.Client.TextRes.ALinq_InvalidGroupByKeySelector, p0); + } + /// /// A string like "DataServiceKey attribute must specify at least one property name." /// diff --git a/src/Microsoft.OData.Client/ProjectionPlanCompiler.cs b/src/Microsoft.OData.Client/ProjectionPlanCompiler.cs index 417ee00f6b..4f571ae7da 100644 --- a/src/Microsoft.OData.Client/ProjectionPlanCompiler.cs +++ b/src/Microsoft.OData.Client/ProjectionPlanCompiler.cs @@ -7,8 +7,6 @@ //// Uncomment the following line to trace projection building activity. ////#define TRACE_CLIENT_PROJECTIONS -using System.Globalization; - namespace Microsoft.OData.Client { #region Namespaces @@ -16,6 +14,7 @@ namespace Microsoft.OData.Client using System; using System.Collections.Generic; using System.Diagnostics; + using System.Globalization; using System.Linq; using System.Linq.Expressions; using System.Reflection; diff --git a/test/FunctionalTests/Microsoft.OData.Client.Tests/ALinq/DollarApplyGroupByTests.cs b/test/FunctionalTests/Microsoft.OData.Client.Tests/ALinq/DollarApplyGroupByTests.cs new file mode 100644 index 0000000000..c43e7ac424 --- /dev/null +++ b/test/FunctionalTests/Microsoft.OData.Client.Tests/ALinq/DollarApplyGroupByTests.cs @@ -0,0 +1,2034 @@ +//--------------------------------------------------------------------- +// +// Copyright (C) Microsoft Corporation. All rights reserved. See License.txt in the project root for license information. +// +//--------------------------------------------------------------------- + +using System; +using System.Linq; +using Xunit; + +namespace Microsoft.OData.Client.Tests.ALinq +{ + public class DollarApplyGroupByTests : DollarApplyTestsBase + { + public DollarApplyGroupByTests() : base() + { + } + + [Fact] + public void GroupByResultSelector_Sum_ByConstant() + { + var queryable = this.dsContext.CreateQuery(numbersEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => 1, (d2, d3) => new + { + SumIntProp = d3.Sum(d4 => d4.IntProp), + SumNullableIntProp = d3.Sum(d4 => d4.NullableIntProp), + SumDoubleProp = d3.Sum(d4 => d4.DoubleProp), + SumNullableDoubleProp = d3.Sum(d4 => d4.NullableDoubleProp), + SumDecimalProp = d3.Sum(d4 => d4.DecimalProp), + SumNullableDecimalProp = d3.Sum(d4 => d4.NullableDecimalProp), + SumLongProp = d3.Sum(d4 => d4.LongProp), + SumNullableLongProp = d3.Sum(d4 => d4.NullableLongProp), + SumSingleProp = d3.Sum(d4 => d4.SingleProp), + SumNullableSingleProp = d3.Sum(d4 => d4.NullableSingleProp), + }); + + Assert.Equal( + string.Format( + "{0}/Numbers?$apply=aggregate(" + + "IntProp with sum as SumIntProp,NullableIntProp with sum as SumNullableIntProp," + + "DoubleProp with sum as SumDoubleProp,NullableDoubleProp with sum as SumNullableDoubleProp," + + "DecimalProp with sum as SumDecimalProp,NullableDecimalProp with sum as SumNullableDecimalProp," + + "LongProp with sum as SumLongProp,NullableLongProp with sum as SumNullableLongProp," + + "SingleProp with sum as SumSingleProp,NullableSingleProp with sum as SumNullableSingleProp)", serviceUri), + aggregateQuery.ToString() + ); + + MockGroupBy_Sum_ByConstant(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Single(aggregateResult); + + var singleResult = aggregateResult.First(); + + Assert.Equal(506, singleResult.SumIntProp); + Assert.Equal(530, singleResult.SumNullableIntProp); + Assert.Equal(464.72, singleResult.SumDoubleProp); + Assert.Equal(534.02, singleResult.SumNullableDoubleProp); + Assert.Equal(559.4M, singleResult.SumDecimalProp); + Assert.Equal(393.7M, singleResult.SumNullableDecimalProp); + Assert.Equal(1298L, singleResult.SumLongProp); + Assert.Equal(993L, singleResult.SumNullableLongProp); + Assert.Equal(333.79f, singleResult.SumSingleProp); + Assert.Equal(528.44f, singleResult.SumNullableSingleProp); + } + + [Fact] + public void GroupByResultSelector_Sum_BySingleProperty() + { + var queryable = this.dsContext.CreateQuery(numbersEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => d1.RowParity, (d2, d3) => new + { + RowParity = d2, + SumIntProp = d3.Sum(d4 => d4.IntProp), + SumNullableIntProp = d3.Sum(d4 => d4.NullableIntProp), + SumDoubleProp = d3.Sum(d4 => d4.DoubleProp), + SumNullableDoubleProp = d3.Sum(d4 => d4.NullableDoubleProp), + SumDecimalProp = d3.Sum(d4 => d4.DecimalProp), + SumNullableDecimalProp = d3.Sum(d4 => d4.NullableDecimalProp), + SumLongProp = d3.Sum(d4 => d4.LongProp), + SumNullableLongProp = d3.Sum(d4 => d4.NullableLongProp), + SumSingleProp = d3.Sum(d4 => d4.SingleProp), + SumNullableSingleProp = d3.Sum(d4 => d4.NullableSingleProp), + }); + + Assert.Equal( + string.Format( + "{0}/Numbers?$apply=groupby((RowParity),aggregate(" + + "IntProp with sum as SumIntProp,NullableIntProp with sum as SumNullableIntProp," + + "DoubleProp with sum as SumDoubleProp,NullableDoubleProp with sum as SumNullableDoubleProp," + + "DecimalProp with sum as SumDecimalProp,NullableDecimalProp with sum as SumNullableDecimalProp," + + "LongProp with sum as SumLongProp,NullableLongProp with sum as SumNullableLongProp," + + "SingleProp with sum as SumSingleProp,NullableSingleProp with sum as SumNullableSingleProp))", serviceUri), + aggregateQuery.ToString() + ); + + MockGroupBy_Sum_BySingleProperty(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(2, aggregateResult.Length); + Assert.Equal("Even", aggregateResult[0].RowParity); + Assert.Equal("Odd", aggregateResult[1].RowParity); + } + + [Fact] + public void GroupByResultSelector_Sum_ByMultipleProperties() + { + var queryable = this.dsContext.CreateQuery(numbersEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => new { d1.RowParity, d1.RowCategory }, (d2, d3) => new + { + d2.RowParity, + d2.RowCategory, + SumIntProp = d3.Sum(d4 => d4.IntProp), + SumNullableIntProp = d3.Sum(d4 => d4.NullableIntProp), + SumDoubleProp = d3.Sum(d4 => d4.DoubleProp), + SumNullableDoubleProp = d3.Sum(d4 => d4.NullableDoubleProp), + SumDecimalProp = d3.Sum(d4 => d4.DecimalProp), + SumNullableDecimalProp = d3.Sum(d4 => d4.NullableDecimalProp), + SumLongProp = d3.Sum(d4 => d4.LongProp), + SumNullableLongProp = d3.Sum(d4 => d4.NullableLongProp), + SumSingleProp = d3.Sum(d4 => d4.SingleProp), + SumNullableSingleProp = d3.Sum(d4 => d4.NullableSingleProp), + }); + + Assert.Equal( + string.Format( + "{0}/Numbers?$apply=groupby((RowParity,RowCategory),aggregate(" + + "IntProp with sum as SumIntProp,NullableIntProp with sum as SumNullableIntProp," + + "DoubleProp with sum as SumDoubleProp,NullableDoubleProp with sum as SumNullableDoubleProp," + + "DecimalProp with sum as SumDecimalProp,NullableDecimalProp with sum as SumNullableDecimalProp," + + "LongProp with sum as SumLongProp,NullableLongProp with sum as SumNullableLongProp," + + "SingleProp with sum as SumSingleProp,NullableSingleProp with sum as SumNullableSingleProp))", serviceUri), + aggregateQuery.ToString() + ); + + MockGroupBy_Sum_ByMultipleProperties(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(3, aggregateResult.Length); + Assert.True(aggregateResult[0].RowParity.Equals("Even") && aggregateResult[0].RowCategory.Equals("Composite")); + Assert.True(aggregateResult[1].RowParity.Equals("Odd") && aggregateResult[1].RowCategory.Equals("None")); + Assert.True(aggregateResult[2].RowParity.Equals("Odd") && aggregateResult[2].RowCategory.Equals("Prime")); + } + + [Fact] + public void GroupByResultSelector_Average_ByConstant() + { + var queryable = this.dsContext.CreateQuery(numbersEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => 1, (d2, d3) => new + { + AverageIntProp = d3.Average(d4 => d4.IntProp), + AverageNullableIntProp = d3.Average(d4 => d4.NullableIntProp), + AverageDoubleProp = d3.Average(d4 => d4.DoubleProp), + AverageNullableDoubleProp = d3.Average(d4 => d4.NullableDoubleProp), + AverageDecimalProp = d3.Average(d4 => d4.DecimalProp), + AverageNullableDecimalProp = d3.Average(d4 => d4.NullableDecimalProp), + AverageLongProp = d3.Average(d4 => d4.LongProp), + AverageNullableLongProp = d3.Average(d4 => d4.NullableLongProp), + AverageSingleProp = d3.Average(d4 => d4.SingleProp), + AverageNullableSingleProp = d3.Average(d4 => d4.NullableSingleProp), + }); + + Assert.Equal( + string.Format( + "{0}/Numbers?$apply=aggregate(" + + "IntProp with average as AverageIntProp,NullableIntProp with average as AverageNullableIntProp," + + "DoubleProp with average as AverageDoubleProp,NullableDoubleProp with average as AverageNullableDoubleProp," + + "DecimalProp with average as AverageDecimalProp,NullableDecimalProp with average as AverageNullableDecimalProp," + + "LongProp with average as AverageLongProp,NullableLongProp with average as AverageNullableLongProp," + + "SingleProp with average as AverageSingleProp,NullableSingleProp with average as AverageNullableSingleProp)", serviceUri), + aggregateQuery.ToString() + ); + + MockGroupBy_Average_ByConstant(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Single(aggregateResult); + + var singleResult = aggregateResult.First(); + + Assert.Equal(101.2, singleResult.AverageIntProp); + Assert.Equal(132.5, singleResult.AverageNullableIntProp); + Assert.Equal(92.944, singleResult.AverageDoubleProp); + Assert.Equal(133.505, singleResult.AverageNullableDoubleProp); + Assert.Equal(111.88M, singleResult.AverageDecimalProp); + Assert.Equal(98.425M, singleResult.AverageNullableDecimalProp); + Assert.Equal(259.6, singleResult.AverageLongProp); + Assert.Equal(248.25, singleResult.AverageNullableLongProp); + Assert.Equal(66.758f, singleResult.AverageSingleProp); + Assert.Equal(132.11f, singleResult.AverageNullableSingleProp); + } + + [Fact] + public void GroupByResultSelector_Average_BySingleProperty() + { + var queryable = this.dsContext.CreateQuery(numbersEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => d1.RowParity, (d2, d3) => new + { + RowParity = d2, + AverageIntProp = d3.Average(d4 => d4.IntProp), + AverageNullableIntProp = d3.Average(d4 => d4.NullableIntProp), + AverageDoubleProp = d3.Average(d4 => d4.DoubleProp), + AverageNullableDoubleProp = d3.Average(d4 => d4.NullableDoubleProp), + AverageDecimalProp = d3.Average(d4 => d4.DecimalProp), + AverageNullableDecimalProp = d3.Average(d4 => d4.NullableDecimalProp), + AverageLongProp = d3.Average(d4 => d4.LongProp), + AverageNullableLongProp = d3.Average(d4 => d4.NullableLongProp), + AverageSingleProp = d3.Average(d4 => d4.SingleProp), + AverageNullableSingleProp = d3.Average(d4 => d4.NullableSingleProp), + }); + + Assert.Equal( + string.Format( + "{0}/Numbers?$apply=groupby((RowParity),aggregate(" + + "IntProp with average as AverageIntProp,NullableIntProp with average as AverageNullableIntProp," + + "DoubleProp with average as AverageDoubleProp,NullableDoubleProp with average as AverageNullableDoubleProp," + + "DecimalProp with average as AverageDecimalProp,NullableDecimalProp with average as AverageNullableDecimalProp," + + "LongProp with average as AverageLongProp,NullableLongProp with average as AverageNullableLongProp," + + "SingleProp with average as AverageSingleProp,NullableSingleProp with average as AverageNullableSingleProp))", serviceUri), + aggregateQuery.ToString() + ); + + MockGroupBy_Average_BySingleProperty(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(2, aggregateResult.Length); + Assert.Equal("Even", aggregateResult[0].RowParity); + Assert.Equal("Odd", aggregateResult[1].RowParity); + } + + [Fact] + public void GroupByResultSelector_Average_ByMultipleProperties() + { + var queryable = this.dsContext.CreateQuery(numbersEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => new { d1.RowParity, d1.RowCategory }, (d2, d3) => new + { + d2.RowParity, + d2.RowCategory, + AverageIntProp = d3.Average(d4 => d4.IntProp), + AverageNullableIntProp = d3.Average(d4 => d4.NullableIntProp), + AverageDoubleProp = d3.Average(d4 => d4.DoubleProp), + AverageNullableDoubleProp = d3.Average(d4 => d4.NullableDoubleProp), + AverageDecimalProp = d3.Average(d4 => d4.DecimalProp), + AverageNullableDecimalProp = d3.Average(d4 => d4.NullableDecimalProp), + AverageLongProp = d3.Average(d4 => d4.LongProp), + AverageNullableLongProp = d3.Average(d4 => d4.NullableLongProp), + AverageSingleProp = d3.Average(d4 => d4.SingleProp), + AverageNullableSingleProp = d3.Average(d4 => d4.NullableSingleProp), + }); + + Assert.Equal( + string.Format( + "{0}/Numbers?$apply=groupby((RowParity,RowCategory),aggregate(" + + "IntProp with average as AverageIntProp,NullableIntProp with average as AverageNullableIntProp," + + "DoubleProp with average as AverageDoubleProp,NullableDoubleProp with average as AverageNullableDoubleProp," + + "DecimalProp with average as AverageDecimalProp,NullableDecimalProp with average as AverageNullableDecimalProp," + + "LongProp with average as AverageLongProp,NullableLongProp with average as AverageNullableLongProp," + + "SingleProp with average as AverageSingleProp,NullableSingleProp with average as AverageNullableSingleProp))", serviceUri), + aggregateQuery.ToString() + ); + + MockGroupBy_Average_ByMultipleProperties(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(3, aggregateResult.Length); + Assert.True(aggregateResult[0].RowParity.Equals("Even") && aggregateResult[0].RowCategory.Equals("Composite")); + Assert.True(aggregateResult[1].RowParity.Equals("Odd") && aggregateResult[1].RowCategory.Equals("None")); + Assert.True(aggregateResult[2].RowParity.Equals("Odd") && aggregateResult[2].RowCategory.Equals("Prime")); + } + + [Fact] + public void GroupByResultSelector_Min_ByConstant() + { + var queryable = this.dsContext.CreateQuery(numbersEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => 1, (d2, d3) => new + { + MinIntProp = d3.Min(d4 => d4.IntProp), + MinNullableIntProp = d3.Min(d4 => d4.NullableIntProp), + MinDoubleProp = d3.Min(d4 => d4.DoubleProp), + MinNullableDoubleProp = d3.Min(d4 => d4.NullableDoubleProp), + MinDecimalProp = d3.Min(d4 => d4.DecimalProp), + MinNullableDecimalProp = d3.Min(d4 => d4.NullableDecimalProp), + MinLongProp = d3.Min(d4 => d4.LongProp), + MinNullableLongProp = d3.Min(d4 => d4.NullableLongProp), + MinSingleProp = d3.Min(d4 => d4.SingleProp), + MinNullableSingleProp = d3.Min(d4 => d4.NullableSingleProp), + }); + + Assert.Equal( + string.Format( + "{0}/Numbers?$apply=aggregate(" + + "IntProp with min as MinIntProp,NullableIntProp with min as MinNullableIntProp," + + "DoubleProp with min as MinDoubleProp,NullableDoubleProp with min as MinNullableDoubleProp," + + "DecimalProp with min as MinDecimalProp,NullableDecimalProp with min as MinNullableDecimalProp," + + "LongProp with min as MinLongProp,NullableLongProp with min as MinNullableLongProp," + + "SingleProp with min as MinSingleProp,NullableSingleProp with min as MinNullableSingleProp)", serviceUri), + aggregateQuery.ToString() + ); + + MockGroupBy_Min_ByConstant(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Single(aggregateResult); + + var singleResult = aggregateResult.First(); + + Assert.Equal(63, singleResult.MinIntProp); + Assert.Equal(34, singleResult.MinNullableIntProp); + Assert.Equal(2.34, singleResult.MinDoubleProp); + Assert.Equal(16.1, singleResult.MinNullableDoubleProp); + Assert.Equal(42.70M, singleResult.MinDecimalProp); + Assert.Equal(12.90M, singleResult.MinNullableDecimalProp); + Assert.Equal(220L, singleResult.MinLongProp); + Assert.Equal(201L, singleResult.MinNullableLongProp); + Assert.Equal(1.29f, singleResult.MinSingleProp); + Assert.Equal(81.94f, singleResult.MinNullableSingleProp); + } + + [Fact] + public void GroupByResultSelector_Min_BySingleProperty() + { + var queryable = this.dsContext.CreateQuery(numbersEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => d1.RowParity, (d2, d3) => new + { + RowParity = d2, + MinIntProp = d3.Min(d4 => d4.IntProp), + MinNullableIntProp = d3.Min(d4 => d4.NullableIntProp), + MinDoubleProp = d3.Min(d4 => d4.DoubleProp), + MinNullableDoubleProp = d3.Min(d4 => d4.NullableDoubleProp), + MinDecimalProp = d3.Min(d4 => d4.DecimalProp), + MinNullableDecimalProp = d3.Min(d4 => d4.NullableDecimalProp), + MinLongProp = d3.Min(d4 => d4.LongProp), + MinNullableLongProp = d3.Min(d4 => d4.NullableLongProp), + MinSingleProp = d3.Min(d4 => d4.SingleProp), + MinNullableSingleProp = d3.Min(d4 => d4.NullableSingleProp), + }); + + Assert.Equal( + string.Format( + "{0}/Numbers?$apply=groupby((RowParity),aggregate(" + + "IntProp with min as MinIntProp,NullableIntProp with min as MinNullableIntProp," + + "DoubleProp with min as MinDoubleProp,NullableDoubleProp with min as MinNullableDoubleProp," + + "DecimalProp with min as MinDecimalProp,NullableDecimalProp with min as MinNullableDecimalProp," + + "LongProp with min as MinLongProp,NullableLongProp with min as MinNullableLongProp," + + "SingleProp with min as MinSingleProp,NullableSingleProp with min as MinNullableSingleProp))", serviceUri), + aggregateQuery.ToString() + ); + + MockGroupBy_Min_BySingleProperty(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(2, aggregateResult.Length); + Assert.Equal("Even", aggregateResult[0].RowParity); + Assert.Equal("Odd", aggregateResult[1].RowParity); + } + + [Fact] + public void GroupByResultSelector_Min_ByMultiProperties() + { + var queryable = this.dsContext.CreateQuery(numbersEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => new { d1.RowParity, d1.RowCategory }, (d2, d3) => new + { + d2.RowParity, + d2.RowCategory, + MinIntProp = d3.Min(d4 => d4.IntProp), + MinNullableIntProp = d3.Min(d4 => d4.NullableIntProp), + MinDoubleProp = d3.Min(d4 => d4.DoubleProp), + MinNullableDoubleProp = d3.Min(d4 => d4.NullableDoubleProp), + MinDecimalProp = d3.Min(d4 => d4.DecimalProp), + MinNullableDecimalProp = d3.Min(d4 => d4.NullableDecimalProp), + MinLongProp = d3.Min(d4 => d4.LongProp), + MinNullableLongProp = d3.Min(d4 => d4.NullableLongProp), + MinSingleProp = d3.Min(d4 => d4.SingleProp), + MinNullableSingleProp = d3.Min(d4 => d4.NullableSingleProp), + }); + + Assert.Equal( + string.Format( + "{0}/Numbers?$apply=groupby((RowParity,RowCategory),aggregate(" + + "IntProp with min as MinIntProp,NullableIntProp with min as MinNullableIntProp," + + "DoubleProp with min as MinDoubleProp,NullableDoubleProp with min as MinNullableDoubleProp," + + "DecimalProp with min as MinDecimalProp,NullableDecimalProp with min as MinNullableDecimalProp," + + "LongProp with min as MinLongProp,NullableLongProp with min as MinNullableLongProp," + + "SingleProp with min as MinSingleProp,NullableSingleProp with min as MinNullableSingleProp))", serviceUri), + aggregateQuery.ToString() + ); + + MockGroupBy_Min_ByMultipleProperties(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(3, aggregateResult.Length); + Assert.True(aggregateResult[0].RowParity.Equals("Even") && aggregateResult[0].RowCategory.Equals("Composite")); + Assert.True(aggregateResult[1].RowParity.Equals("Odd") && aggregateResult[1].RowCategory.Equals("None")); + Assert.True(aggregateResult[2].RowParity.Equals("Odd") && aggregateResult[2].RowCategory.Equals("Prime")); + } + + [Fact] + public void GroupByResultSelector_Max_ByConstant() + { + var queryable = this.dsContext.CreateQuery(numbersEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => 1, (d2, d3) => new + { + MaxIntProp = d3.Max(d4 => d4.IntProp), + MaxNullableIntProp = d3.Max(d4 => d4.NullableIntProp), + MaxDoubleProp = d3.Max(d4 => d4.DoubleProp), + MaxNullableDoubleProp = d3.Max(d4 => d4.NullableDoubleProp), + MaxDecimalProp = d3.Max(d4 => d4.DecimalProp), + MaxNullableDecimalProp = d3.Max(d4 => d4.NullableDecimalProp), + MaxLongProp = d3.Max(d4 => d4.LongProp), + MaxNullableLongProp = d3.Max(d4 => d4.NullableLongProp), + MaxSingleProp = d3.Max(d4 => d4.SingleProp), + MaxNullableSingleProp = d3.Max(d4 => d4.NullableSingleProp), + }); + + Assert.Equal( + string.Format("{0}/Numbers?$apply=aggregate(" + + "IntProp with max as MaxIntProp,NullableIntProp with max as MaxNullableIntProp," + + "DoubleProp with max as MaxDoubleProp,NullableDoubleProp with max as MaxNullableDoubleProp," + + "DecimalProp with max as MaxDecimalProp,NullableDecimalProp with max as MaxNullableDecimalProp," + + "LongProp with max as MaxLongProp,NullableLongProp with max as MaxNullableLongProp," + + "SingleProp with max as MaxSingleProp,NullableSingleProp with max as MaxNullableSingleProp)", serviceUri), + aggregateQuery.ToString() + ); + + MockGroupBy_Max_ByConstant(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Single(aggregateResult); + + var singleResult = aggregateResult.First(); + + Assert.Equal(141, singleResult.MaxIntProp); + Assert.Equal(199, singleResult.MaxNullableIntProp); + Assert.Equal(155.85, singleResult.MaxDoubleProp); + Assert.Equal(178.49, singleResult.MaxNullableDoubleProp); + Assert.Equal(173.90M, singleResult.MaxDecimalProp); + Assert.Equal(157.30M, singleResult.MaxNullableDecimalProp); + Assert.Equal(300L, singleResult.MaxLongProp); + Assert.Equal(295L, singleResult.MaxNullableLongProp); + Assert.Equal(171.22f, singleResult.MaxSingleProp); + Assert.Equal(174.99f, singleResult.MaxNullableSingleProp); + } + + [Fact] + public void GroupByResultSelector_Max_BySingleProperty() + { + var queryable = this.dsContext.CreateQuery(numbersEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => d1.RowParity, (d2, d3) => new + { + RowParity = d2, + MaxIntProp = d3.Max(d4 => d4.IntProp), + MaxNullableIntProp = d3.Max(d4 => d4.NullableIntProp), + MaxDoubleProp = d3.Max(d4 => d4.DoubleProp), + MaxNullableDoubleProp = d3.Max(d4 => d4.NullableDoubleProp), + MaxDecimalProp = d3.Max(d4 => d4.DecimalProp), + MaxNullableDecimalProp = d3.Max(d4 => d4.NullableDecimalProp), + MaxLongProp = d3.Max(d4 => d4.LongProp), + MaxNullableLongProp = d3.Max(d4 => d4.NullableLongProp), + MaxSingleProp = d3.Max(d4 => d4.SingleProp), + MaxNullableSingleProp = d3.Max(d4 => d4.NullableSingleProp), + }); + + Assert.Equal( + string.Format("{0}/Numbers?$apply=groupby((RowParity),aggregate(" + + "IntProp with max as MaxIntProp,NullableIntProp with max as MaxNullableIntProp," + + "DoubleProp with max as MaxDoubleProp,NullableDoubleProp with max as MaxNullableDoubleProp," + + "DecimalProp with max as MaxDecimalProp,NullableDecimalProp with max as MaxNullableDecimalProp," + + "LongProp with max as MaxLongProp,NullableLongProp with max as MaxNullableLongProp," + + "SingleProp with max as MaxSingleProp,NullableSingleProp with max as MaxNullableSingleProp))", serviceUri), + aggregateQuery.ToString() + ); + + MockGroupBy_Max_BySingleProperty(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(2, aggregateResult.Length); + Assert.Equal("Even", aggregateResult[0].RowParity); + Assert.Equal("Odd", aggregateResult[1].RowParity); + } + + [Fact] + public void GroupByResultSelector_Max_ByMultipleProperties() + { + var queryable = this.dsContext.CreateQuery(numbersEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => new { d1.RowParity, d1.RowCategory }, (d2, d3) => new + { + d2.RowParity, + d2.RowCategory, + MaxIntProp = d3.Max(d4 => d4.IntProp), + MaxNullableIntProp = d3.Max(d4 => d4.NullableIntProp), + MaxDoubleProp = d3.Max(d4 => d4.DoubleProp), + MaxNullableDoubleProp = d3.Max(d4 => d4.NullableDoubleProp), + MaxDecimalProp = d3.Max(d4 => d4.DecimalProp), + MaxNullableDecimalProp = d3.Max(d4 => d4.NullableDecimalProp), + MaxLongProp = d3.Max(d4 => d4.LongProp), + MaxNullableLongProp = d3.Max(d4 => d4.NullableLongProp), + MaxSingleProp = d3.Max(d4 => d4.SingleProp), + MaxNullableSingleProp = d3.Max(d4 => d4.NullableSingleProp), + }); + + Assert.Equal( + string.Format("{0}/Numbers?$apply=groupby((RowParity,RowCategory),aggregate(" + + "IntProp with max as MaxIntProp,NullableIntProp with max as MaxNullableIntProp," + + "DoubleProp with max as MaxDoubleProp,NullableDoubleProp with max as MaxNullableDoubleProp," + + "DecimalProp with max as MaxDecimalProp,NullableDecimalProp with max as MaxNullableDecimalProp," + + "LongProp with max as MaxLongProp,NullableLongProp with max as MaxNullableLongProp," + + "SingleProp with max as MaxSingleProp,NullableSingleProp with max as MaxNullableSingleProp))", serviceUri), + aggregateQuery.ToString() + ); + + MockGroupBy_Max_ByMultipleProperties(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(3, aggregateResult.Length); + Assert.True(aggregateResult[0].RowParity.Equals("Even") && aggregateResult[0].RowCategory.Equals("Composite")); + Assert.True(aggregateResult[1].RowParity.Equals("Odd") && aggregateResult[1].RowCategory.Equals("None")); + Assert.True(aggregateResult[2].RowParity.Equals("Odd") && aggregateResult[2].RowCategory.Equals("Prime")); + } + + [Fact] + public void GroupByResultSelector_Count_ByConstant() + { + var queryable = this.dsContext.CreateQuery(numbersEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => 1, (d2, d3) => new + { + Count = d3.Count(), + CountDistinct = d3.CountDistinct(d4 => d4.RowCategory) + }); + + Assert.Equal( + string.Format("{0}/Numbers?$apply=aggregate(" + + "$count as Count,RowCategory with countdistinct as CountDistinct)", serviceUri), + aggregateQuery.ToString() + ); + + MockGroupBy_Count_ByConstant(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Single(aggregateResult); + Assert.Equal(5, aggregateResult[0].Count); + Assert.Equal(3, aggregateResult[0].CountDistinct); + } + + [Fact] + public void GroupByResultSelector_Count_BySingleProperty() + { + var queryable = this.dsContext.CreateQuery(numbersEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => d1.RowParity, (d2, d3) => new + { + RowParity = d2, + Count = d3.Count(), + CountDistinct = d3.CountDistinct(d4 => d4.RowCategory) + }); + + Assert.Equal( + string.Format("{0}/Numbers?$apply=groupby((RowParity),aggregate(" + + "$count as Count,RowCategory with countdistinct as CountDistinct))", serviceUri), + aggregateQuery.ToString() + ); + + MockGroupBy_Count_BySingleProperty(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(2, aggregateResult.Length); + Assert.Equal("Even", aggregateResult[0].RowParity); + Assert.Equal("Odd", aggregateResult[1].RowParity); + } + + [Fact] + public void GroupByResultSelector_Count_ByMultipleProperties() + { + var queryable = this.dsContext.CreateQuery(numbersEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => new { d1.RowParity, d1.RowCategory }, (d2, d3) => new + { + d2.RowParity, + d2.RowCategory, + Count = d3.Count(), + CountDistinct = d3.CountDistinct(d4 => d4.RowCategory) + }); + + Assert.Equal( + string.Format("{0}/Numbers?$apply=groupby((RowParity,RowCategory),aggregate(" + + "$count as Count,RowCategory with countdistinct as CountDistinct))", serviceUri), + aggregateQuery.ToString() + ); + + MockGroupBy_Count_ByMultipleProperties(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(3, aggregateResult.Length); + Assert.True(aggregateResult[0].RowParity.Equals("Even") && aggregateResult[0].RowCategory.Equals("Composite")); + Assert.True(aggregateResult[1].RowParity.Equals("Odd") && aggregateResult[1].RowCategory.Equals("None")); + Assert.True(aggregateResult[2].RowParity.Equals("Odd") && aggregateResult[2].RowCategory.Equals("Prime")); + } + + [Fact] + public void GroupByResultSelector_MixedScenarios() + { + var queryable = this.dsContext.CreateQuery(numbersEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => d1.RowParity, (d2, d3) => new + { + RowParity = d2, + SumIntProp = d3.Sum(d4 => d4.IntProp), + AverageDoubleProp = d3.Average(d4 => d4.DoubleProp), + MinDecimalProp = d3.Min(d4 => d4.DecimalProp), + MaxLongProp = d3.Max(d4 => d4.LongProp), + Count = d3.Count(), + CountDistinct = d3.CountDistinct(d4 => d4.RowCategory) + }); + + Assert.Equal( + string.Format("{0}/Numbers?$apply=groupby((RowParity),aggregate(" + + "IntProp with sum as SumIntProp,DoubleProp with average as AverageDoubleProp," + + "DecimalProp with min as MinDecimalProp,LongProp with max as MaxLongProp," + + "$count as Count,RowCategory with countdistinct as CountDistinct))", serviceUri), + aggregateQuery.ToString() + ); + + MockGroupBy_WithMixedAggregations(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(2, aggregateResult.Length); + Assert.Equal("Even", aggregateResult[0].RowParity); + Assert.Equal("Odd", aggregateResult[1].RowParity); + } + + [Fact] + public void GroupByResultSelector_UsingMemberInitialization() + { + var queryable = this.dsContext.CreateQuery(numbersEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => d1.RowParity, (d2, d3) => new NumbersGroupedResult + { + RowParity = d2, + SumIntProp = d3.Sum(d4 => d4.IntProp), + AverageDoubleProp = d3.Average(d4 => d4.DoubleProp), + MinDecimalProp = d3.Min(d4 => d4.DecimalProp), + MaxLongProp = d3.Max(d4 => d4.LongProp), + Count = d3.Count(), + CountDistinct = d3.CountDistinct(d4 => d4.RowCategory) + }); + + Assert.Equal( + string.Format("{0}/Numbers?$apply=groupby((RowParity),aggregate(" + + "IntProp with sum as SumIntProp,DoubleProp with average as AverageDoubleProp," + + "DecimalProp with min as MinDecimalProp,LongProp with max as MaxLongProp," + + "$count as Count,RowCategory with countdistinct as CountDistinct))", serviceUri), + aggregateQuery.ToString() + ); + + MockGroupBy_WithMixedAggregations(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(2, aggregateResult.Length); + Assert.Equal("Even", aggregateResult[0].RowParity); + Assert.Equal("Odd", aggregateResult[1].RowParity); + } + + [Fact] + public void GroupByResultSelector_BySingleNavProperty() + { + var queryable = this.dsContext.CreateQuery(salesEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => d1.Product.Color, + (d1, d2) => new + { + Color = d1, + SumAmount = d2.Sum(d3 => d3.Amount), + AvgAmount = d2.Average(d3 => d3.Amount), + MinAmount = d2.Min(d3 => d3.Amount), + MaxAmount = d2.Max(d3 => d3.Amount) + }); + + Assert.Equal( + string.Format("{0}/Sales?$apply=groupby((Product/Color),aggregate(" + + "Amount with sum as SumAmount,Amount with average as AvgAmount," + + "Amount with min as MinAmount,Amount with max as MaxAmount))", serviceUri), + aggregateQuery.ToString()); + + MockGroupBySingleNavProperty(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(2, aggregateResult.Length); + Assert.Equal("Brown", aggregateResult[0].Color); + Assert.Equal(6M, aggregateResult[0].AvgAmount); + Assert.Equal(8M, aggregateResult[0].MaxAmount); + Assert.Equal("White", aggregateResult[1].Color); + Assert.Equal(12M, aggregateResult[1].SumAmount); + Assert.Equal(1M, aggregateResult[1].MinAmount); + } + + [Fact] + public void GroupByResultSelector_ByMultipleNavProperties() + { + var queryable = this.dsContext.CreateQuery(salesEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => new { d1.Product.Color, d1.Customer.Country }, + (d1, d2) => new + { + d1.Color, + d1.Country, + SumAmount = d2.Sum(d3 => d3.Amount), + AvgAmount = d2.Average(d3 => d3.Amount), + MinAmount = d2.Min(d3 => d3.Amount), + MaxAmount = d2.Max(d3 => d3.Amount) + }); + + Assert.Equal( + string.Format("{0}/Sales?$apply=groupby((Product/Color,Customer/Country),aggregate(" + + "Amount with sum as SumAmount,Amount with average as AvgAmount," + + "Amount with min as MinAmount,Amount with max as MaxAmount))", serviceUri), + aggregateQuery.ToString()); + + MockGroupByMultipleNavProperties(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(2, aggregateResult.Length); + Assert.Equal("Brown", aggregateResult[0].Color); + Assert.Equal(6M, aggregateResult[0].AvgAmount); + Assert.Equal(8M, aggregateResult[0].MaxAmount); + Assert.Equal("Netherlands", aggregateResult[1].Country); + Assert.Equal(5M, aggregateResult[1].SumAmount); + Assert.Equal(1M, aggregateResult[1].MinAmount); + } + + [Fact] + public void GroupByResultSelector_BySingleNavProperty_TargetingNavProperty() + { + var queryable = this.dsContext.CreateQuery(salesEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => d1.Currency.Code, + (d1, d2) => new + { + Currency = d1, + SumTaxRate = d2.Sum(d3 => d3.Product.TaxRate), + AvgTaxRate = d2.Average(d3 => d3.Product.TaxRate), + MinTaxRate = d2.Min(d3 => d3.Product.TaxRate), + MaxTaxRate = d2.Max(d3 => d3.Product.TaxRate) + }); + + Assert.Equal( + string.Format("{0}/Sales?$apply=groupby((Currency/Code),aggregate(" + + "Product/TaxRate with sum as SumTaxRate,Product/TaxRate with average as AvgTaxRate," + + "Product/TaxRate with min as MinTaxRate,Product/TaxRate with max as MaxTaxRate))", serviceUri), + aggregateQuery.ToString()); + + MockGroupBySingleNavProperty_AggregationsTargetingNavProperty(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(2, aggregateResult.Length); + Assert.Equal("EUR", aggregateResult[0].Currency); + Assert.Equal(0.113333M, aggregateResult[0].AvgTaxRate); + Assert.Equal(0.14M, aggregateResult[0].MaxTaxRate); + Assert.Equal("USD", aggregateResult[1].Currency); + Assert.Equal(0.46M, aggregateResult[1].SumTaxRate); + Assert.Equal(0.06M, aggregateResult[1].MinTaxRate); + } + + [Fact] + public void GroupByResultSelector_ByMultipleNavProperties_TargetingNavProperty() + { + var queryable = this.dsContext.CreateQuery(salesEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => new { d1.Product.Color, d1.Customer.Country }, + (d1, d2) => new + { + d1.Color, + d1.Country, + SumTaxRate = d2.Sum(d3 => d3.Product.TaxRate), + AvgTaxRate = d2.Average(d3 => d3.Product.TaxRate), + MinTaxRate = d2.Min(d3 => d3.Product.TaxRate), + MaxTaxRate = d2.Max(d3 => d3.Product.TaxRate) + }); + + Assert.Equal( + string.Format("{0}/Sales?$apply=groupby((Product/Color,Customer/Country),aggregate(" + + "Product/TaxRate with sum as SumTaxRate,Product/TaxRate with average as AvgTaxRate," + + "Product/TaxRate with min as MinTaxRate,Product/TaxRate with max as MaxTaxRate))", serviceUri), + aggregateQuery.ToString()); + + MockGroupByMultipleNavProperties_AggregationsTargetingNavProperty(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(2, aggregateResult.Length); + Assert.Equal("White", aggregateResult[0].Color); + Assert.Equal(0.113333M, aggregateResult[0].AvgTaxRate); + Assert.Equal(0.14M, aggregateResult[0].MaxTaxRate); + Assert.Equal("USA", aggregateResult[1].Country); + Assert.Equal(0.12M, aggregateResult[1].SumTaxRate); + Assert.Equal(0.06M, aggregateResult[1].MinTaxRate); + } + + [Fact] + public void GroupByResultSelector_ByConstant_TargetingNavProperty() + { + var queryable = this.dsContext.CreateQuery(salesEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => 1, + (d1, d2) => new + { + SumTaxRate = d2.Sum(d3 => d3.Product.TaxRate), + AvgTaxRate = d2.Average(d3 => d3.Product.TaxRate), + MinTaxRate = d2.Min(d3 => d3.Product.TaxRate), + MaxTaxRate = d2.Max(d3 => d3.Product.TaxRate) + }); + + Assert.Equal( + string.Format("{0}/Sales?$apply=aggregate(" + + "Product/TaxRate with sum as SumTaxRate,Product/TaxRate with average as AvgTaxRate," + + "Product/TaxRate with min as MinTaxRate,Product/TaxRate with max as MaxTaxRate)", serviceUri), + aggregateQuery.ToString()); + + MockGroupByConstant_AggregationsTargetingNavProperty(); + + var aggregateResult = Assert.Single(aggregateQuery.ToArray()); + + Assert.Equal(0.8M, aggregateResult.SumTaxRate); + Assert.Equal(0.06M, aggregateResult.MinTaxRate); + } + + [Fact] + public void GroupByResultSelector_ByConstant_MixedScenarios() + { + var queryable = this.dsContext.CreateQuery(salesEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => 1, + (d1, d2) => new + { + GroupingConstant = d1, + GibberishConstant = "dfksjfl", + SumTaxRate = d2.Sum(d3 => d3.Product.TaxRate), + AvgAmount = d2.Average(d3 => d3.Amount), + MinTaxRate = d2.Min(d3 => d3.Product.TaxRate), + MaxAmount = d2.Max(d3 => d3.Amount), + GroupCount = d2.Count(), + DistinctCurrency = d2.CountDistinct(d3 => d3.Currency.Code) + }); + + Assert.Equal( + string.Format("{0}/Sales?$apply=aggregate(" + + "Product/TaxRate with sum as SumTaxRate,Amount with average as AvgAmount," + + "Product/TaxRate with min as MinTaxRate,Amount with max as MaxAmount," + + "$count as GroupCount,Currency/Code with countdistinct as DistinctCurrency)", serviceUri), + aggregateQuery.ToString()); + + MockGroupByConstant_MixedScenarios(); + + var aggregateResult = Assert.Single(aggregateQuery.ToArray()); + + Assert.Equal(1, aggregateResult.GroupingConstant); + Assert.Equal("dfksjfl", aggregateResult.GibberishConstant); + Assert.Equal(3M, aggregateResult.AvgAmount); + Assert.Equal(8M, aggregateResult.MaxAmount); + Assert.Equal(2, aggregateResult.DistinctCurrency); + } + + [Fact] + public void GroupByResultSelector_BySingleNavProperty_MixedScenarios() + { + var queryable = this.dsContext.CreateQuery(salesEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => d1.Product.Category.Id, + (d1, d2) => new + { + GibberishConstant = "dfksjfl", + CategoryId = d1, + SumTaxRate = d2.Sum(d3 => d3.Product.TaxRate), + AvgAmount = d2.Average(d3 => d3.Amount), + MinTaxRate = d2.Min(d3 => d3.Product.TaxRate), + MaxAmount = d2.Max(d3 => d3.Amount), + GroupCount = d2.Count(), + DistinctCurrency = d2.CountDistinct(d3 => d3.Currency.Code) + }); + + Assert.Equal( + string.Format("{0}/Sales?$apply=groupby((Product/Category/Id),aggregate(" + + "Product/TaxRate with sum as SumTaxRate,Amount with average as AvgAmount," + + "Product/TaxRate with min as MinTaxRate,Amount with max as MaxAmount," + + "$count as GroupCount,Currency/Code with countdistinct as DistinctCurrency))", serviceUri), + aggregateQuery.ToString()); + + MockGroupBySingleNavProperty_MixedScenarios(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(2, aggregateResult.Length); + Assert.Equal("dfksjfl", aggregateResult[0].GibberishConstant); + Assert.Equal(4M, aggregateResult[0].AvgAmount); + Assert.Equal(8M, aggregateResult[0].MaxAmount); + Assert.Equal(2, aggregateResult[0].DistinctCurrency); + Assert.Equal("PG2", aggregateResult[1].CategoryId); + Assert.Equal(0.56M, aggregateResult[1].SumTaxRate); + Assert.Equal(0.14M, aggregateResult[1].MinTaxRate); + Assert.Equal(4, aggregateResult[1].GroupCount); + } + + [Fact] + public void GroupByResultSelector_ByMultipleNavProperties_MixedScenarios() + { + var queryable = this.dsContext.CreateQuery(salesEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => new { d1.Product.Category.Id, d1.Customer.Country }, + (d1, d2) => new + { + GibberishConstant = "dfksjfl", + CategoryId = d1.Id, + d1.Country, + SumTaxRate = d2.Sum(d3 => d3.Product.TaxRate), + AvgAmount = d2.Average(d3 => d3.Amount), + MinTaxRate = d2.Min(d3 => d3.Product.TaxRate), + MaxAmount = d2.Max(d3 => d3.Amount), + GroupCount = d2.Count(), + DistinctCurrency = d2.CountDistinct(d3 => d3.Currency.Code) + }); + + Assert.Equal( + string.Format("{0}/Sales?$apply=groupby((Product/Category/Id,Customer/Country),aggregate(" + + "Product/TaxRate with sum as SumTaxRate,Amount with average as AvgAmount," + + "Product/TaxRate with min as MinTaxRate,Amount with max as MaxAmount," + + "$count as GroupCount,Currency/Code with countdistinct as DistinctCurrency))", serviceUri), + aggregateQuery.ToString()); + + MockGroupByMultipleNavProperties_MixedScenarios(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(2, aggregateResult.Length); + Assert.Equal("dfksjfl", aggregateResult[0].GibberishConstant); + Assert.Equal("Netherlands", aggregateResult[0].Country); + Assert.Equal(2M, aggregateResult[0].AvgAmount); + Assert.Equal(2M, aggregateResult[0].MaxAmount); + Assert.Equal(1, aggregateResult[0].DistinctCurrency); + Assert.Equal("PG2", aggregateResult[1].CategoryId); + Assert.Equal(0.28M, aggregateResult[1].SumTaxRate); + Assert.Equal(0.14M, aggregateResult[1].MinTaxRate); + Assert.Equal(2, aggregateResult[1].GroupCount); + } + + [Fact] + public void GroupByResultSelector_ByConstant_UsingMemberInitialization() + { + var queryable = this.dsContext.CreateQuery(salesEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => 1, + (d1, d2) => new SalesGroupedResult01 + { + GroupingConstant = d1, + GibberishConstant = "dfksjfl", + SumTaxRate = d2.Sum(d3 => d3.Product.TaxRate), + AvgAmount = d2.Average(d3 => d3.Amount), + MinTaxRate = d2.Min(d3 => d3.Product.TaxRate), + MaxAmount = d2.Max(d3 => d3.Amount), + GroupCount = d2.Count(), + DistinctCurrency = d2.CountDistinct(d3 => d3.Currency.Code) + }); + + Assert.Equal( + string.Format("{0}/Sales?$apply=aggregate(" + + "Product/TaxRate with sum as SumTaxRate,Amount with average as AvgAmount," + + "Product/TaxRate with min as MinTaxRate,Amount with max as MaxAmount," + + "$count as GroupCount,Currency/Code with countdistinct as DistinctCurrency)", serviceUri), + aggregateQuery.ToString()); + + MockGroupByConstant_MixedScenarios(); + + var aggregateResult = Assert.Single(aggregateQuery.ToArray()); + + Assert.Equal(1, aggregateResult.GroupingConstant); + Assert.Equal("dfksjfl", aggregateResult.GibberishConstant); + Assert.Equal(0.8M, aggregateResult.SumTaxRate); + Assert.Equal(0.06M, aggregateResult.MinTaxRate); + Assert.Equal(8, aggregateResult.GroupCount); + } + + [Fact] + public void GroupByResultSelector_BySingleNavProperty_UsingMemberInitialization() + { + var queryable = this.dsContext.CreateQuery(salesEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => d1.Product.Category.Id, + (d1, d2) => new SalesGroupedResult02 + { + GibberishConstant = "dfksjfl", + CategoryId = d1, + SumTaxRate = d2.Sum(d3 => d3.Product.TaxRate), + AvgAmount = d2.Average(d3 => d3.Amount), + MinTaxRate = d2.Min(d3 => d3.Product.TaxRate), + MaxAmount = d2.Max(d3 => d3.Amount), + GroupCount = d2.Count(), + DistinctCurrency = d2.CountDistinct(d3 => d3.Currency.Code) + }); + + Assert.Equal( + string.Format("{0}/Sales?$apply=groupby((Product/Category/Id),aggregate(" + + "Product/TaxRate with sum as SumTaxRate,Amount with average as AvgAmount," + + "Product/TaxRate with min as MinTaxRate,Amount with max as MaxAmount," + + "$count as GroupCount,Currency/Code with countdistinct as DistinctCurrency))", serviceUri), + aggregateQuery.ToString()); + + MockGroupBySingleNavProperty_MixedScenarios(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(2, aggregateResult.Length); + Assert.Equal("PG1", aggregateResult[0].CategoryId); + Assert.Equal(0.24M, aggregateResult[0].SumTaxRate); + Assert.Equal(0.06M, aggregateResult[0].MinTaxRate); + Assert.Equal(4, aggregateResult[0].GroupCount); + Assert.Equal("dfksjfl", aggregateResult[1].GibberishConstant); + Assert.Equal(2M, aggregateResult[1].AvgAmount); + Assert.Equal(4M, aggregateResult[1].MaxAmount); + Assert.Equal(2, aggregateResult[1].DistinctCurrency); + } + + [Fact] + public void GroupByResultSelector_ByMultipleNavProperties_UsingMemberInitialization() + { + var queryable = this.dsContext.CreateQuery(salesEntitySetName); + + var aggregateQuery = queryable.GroupBy(d1 => new { d1.Product.Category.Id, d1.Customer.Country }, + (d1, d2) => new SalesGroupedResult03 + { + GibberishConstant = "dfksjfl", + CategoryId = d1.Id, + Country = d1.Country, + SumTaxRate = d2.Sum(d3 => d3.Product.TaxRate), + AvgAmount = d2.Average(d3 => d3.Amount), + MinTaxRate = d2.Min(d3 => d3.Product.TaxRate), + MaxAmount = d2.Max(d3 => d3.Amount), + GroupCount = d2.Count(), + DistinctCurrency = d2.CountDistinct(d3 => d3.Currency.Code) + }); + + Assert.Equal( + string.Format("{0}/Sales?$apply=groupby((Product/Category/Id,Customer/Country),aggregate(" + + "Product/TaxRate with sum as SumTaxRate,Amount with average as AvgAmount," + + "Product/TaxRate with min as MinTaxRate,Amount with max as MaxAmount," + + "$count as GroupCount,Currency/Code with countdistinct as DistinctCurrency))", serviceUri), + aggregateQuery.ToString()); + + MockGroupByMultipleNavProperties_MixedScenarios(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(2, aggregateResult.Length); + Assert.Equal("dfksjfl", aggregateResult[0].GibberishConstant); + Assert.Equal("PG1", aggregateResult[0].CategoryId); + Assert.Equal(0.06M, aggregateResult[0].SumTaxRate); + Assert.Equal(0.06M, aggregateResult[0].MinTaxRate); + Assert.Equal(1, aggregateResult[0].GroupCount); + Assert.Equal("Netherlands", aggregateResult[1].Country); + Assert.Equal(1.5M, aggregateResult[1].AvgAmount); + Assert.Equal(2M, aggregateResult[1].MaxAmount); + Assert.Equal(1, aggregateResult[1].DistinctCurrency); + } + + [Fact] + public void GroupByResultSelector_UsingConstructorInitialization() + { + var queryable = this.dsContext.CreateQuery(salesEntitySetName); + + var aggregateQuery = queryable.GroupBy( + d1 => d1.Product.Category.Name, + (d1, d2) => new SalesGroupedResult04(d1, d2.Average(d3 => d3.Amount))); + + Assert.Equal( + string.Format("{0}/Sales?$apply=groupby((Product/Category/Name),aggregate(" + + "Amount with average as averageAmount))", serviceUri), + aggregateQuery.ToString()); + + MockGroupBy_ConstructorInitialization(); + + var aggregateResult = Assert.Single(aggregateQuery.ToArray()); + + Assert.Equal("Food", aggregateResult.CategoryName); + Assert.Equal(4M, aggregateResult.AverageAmount); + } + + [Fact] + public void GroupByResultSelector_UsingConstructorAndMemberInitialization() + { + var queryable = this.dsContext.CreateQuery(salesEntitySetName); + + var aggregateQuery = queryable.GroupBy( + d1 => d1.Product.Category.Name, + (d1, d2) => new SalesGroupedResult05(d2.Average(d3 => d3.Amount)) + { + CategoryName = d1 + }); + + Assert.Equal( + string.Format("{0}/Sales?$apply=groupby((Product/Category/Name),aggregate(" + + "Amount with average as averageAmount))", serviceUri), + aggregateQuery.ToString()); + + MockGroupBy_ConstructorInitialization(); + + var aggregateResult = Assert.Single(aggregateQuery.ToArray()); + + Assert.Equal("Food", aggregateResult.CategoryName); + Assert.Equal(4M, aggregateResult.AverageAmount); + } + + [Fact] + public void GroupByResultSelector_WithSupportedMemberAccessOnConstantGroupingExpression() + { + var queryable = this.dsContext.CreateQuery(salesEntitySetName); + + var aggregateQuery = queryable.GroupBy( + d1 => "foobar", + (d2, d3) => new + { + FoobarLength = d2.Length, + AverageAmount = d3.Average(d4 => d4.Amount) + }); + + Assert.Equal( + string.Format("{0}/Sales?$apply=aggregate(Amount with average as AverageAmount)", serviceUri), + aggregateQuery.ToString()); + + string mockResponse = string.Format("{{\"@odata.context\":\"{0}/$metadata#Sales" + + "(AverageAmount)\"," + + "\"value\":[{{\"@odata.id\":null,\"AverageAmount\":4.000000}}]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + + var aggregateResultEnumerator = aggregateQuery.GetEnumerator(); + aggregateResultEnumerator.MoveNext(); + var aggregateResult = aggregateResultEnumerator.Current; + + Assert.Equal(6, aggregateResult.FoobarLength); + Assert.Equal(4M, aggregateResult.AverageAmount); + } + + [Fact] + public void GroupByResultSelector_WithSupportedMemberAccessOnSinglePropertyGroupingExpression() + { + var queryable = this.dsContext.CreateQuery(salesEntitySetName); + + var aggregateQuery = queryable.GroupBy( + d1 => d1.Time.Year, + (d2, d3) => new + { + YearStr = d2.ToString(), + AverageAmount = d3.Average(d4 => d4.Amount) + }); + + Assert.Equal( + string.Format("{0}/Sales?$apply=groupby((Time/Year),aggregate(Amount with average as AverageAmount))", serviceUri), + aggregateQuery.ToString()); + + string mockResponse = string.Format("{{\"@odata.context\":\"{0}/$metadata#Sales" + + "(Time(Year),AverageAmount)\"," + + "\"value\":[{{\"@odata.id\":null,\"Time\":{{\"@odata.id\":null,\"Year\":2012}},\"AverageAmount\":4.000000}}]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + + var aggregateResultEnumerator = aggregateQuery.GetEnumerator(); + aggregateResultEnumerator.MoveNext(); + var aggregateResult = aggregateResultEnumerator.Current; + + Assert.Equal("2012", aggregateResult.YearStr); + Assert.Equal(4M, aggregateResult.AverageAmount); + } + + [Fact] + public void GroupByResultSelector_WithSupportedMethodCallOnKnownPrimitiveTypes() + { + var queryable = this.dsContext.CreateQuery(salesEntitySetName); + + var aggregateQuery = queryable.GroupBy( + d1 => new { d1.Time.Year, CategoryName = d1.Product.Category.Name, d1.CurrencyCode }, + (d2, d3) => new + { + FoobarLength = "foobar".Length, + TenStr = 10.ToString(), + YearStr = d2.Year.ToString(), + CategoryNameLength = d2.CategoryName.Length, + d2.CurrencyCode, + AverageAmount = d3.Average(d4 => d4.Amount).ToString(), + SumAmount = d3.Sum(d4 => d4.Amount), + MinAmount = d3.Min(d4 => d4.Amount).ToString() + }); + + Assert.Equal( + string.Format("{0}/Sales?$apply=groupby((Time/Year,Product/Category/Name,CurrencyCode)," + + "aggregate(Amount with average as AverageAmount,Amount with sum as SumAmount,Amount with min as MinAmount))", serviceUri), + aggregateQuery.ToString()); + + string mockResponse = string.Format("{{\"@odata.context\":\"{0}/$metadata#Sales" + + "(Time(Year),Product(Category(Name)),AverageAmount)\"," + + "\"value\":[{{\"@odata.id\":null,\"CurrencyCode\":\"EUR\",\"AverageAmount\":1.500000,\"SumAmount\":3.00,\"MinAmount\":1.00," + + "\"Time\":{{\"@odata.id\":null,\"Year\":2012}}," + + "\"Product\":{{\"@odata.id\":null,\"Category\":{{\"@odata.id\":null,\"Name\":\"Non-Food\"}}}}}}]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + + var aggregateResultEnumerator = aggregateQuery.GetEnumerator(); + aggregateResultEnumerator.MoveNext(); + var aggregateResult = aggregateResultEnumerator.Current; + + Assert.Equal(6, aggregateResult.FoobarLength); + Assert.Equal("10", aggregateResult.TenStr); + Assert.Equal("2012", aggregateResult.YearStr); + Assert.Equal(8, aggregateResult.CategoryNameLength); + Assert.Equal("EUR", aggregateResult.CurrencyCode); + Assert.Equal("1.5", aggregateResult.AverageAmount); + Assert.Equal(3M, aggregateResult.SumAmount); + Assert.Equal("1", aggregateResult.MinAmount); + } + + [Fact] + public void GroupByResultSelector_UsingConstructorAndMemberInitializationWithSupportedMethodCallOnKnownPrimitiveTypes() + { + var queryable = this.dsContext.CreateQuery(salesEntitySetName); + + var aggregateQuery = queryable.GroupBy( + d1 => new { d1.Time.Year, CategoryName = d1.Product.Category.Name, d1.CurrencyCode }, + (d2, d3) => new SalesGroupedResult06(d2.CategoryName.Length, d2.CurrencyCode, d3.Average(d4 => d4.Amount).ToString()) + { + FoobarLength = "foobar".Length, + TenStr = 10.ToString(), + YearStr = d2.Year.ToString(), + SumAmount = d3.Sum(d4 => d4.Amount), + MinAmount = d3.Min(d4 => d4.Amount).ToString() + }); + + Assert.Equal( + string.Format("{0}/Sales?$apply=groupby((Time/Year,Product/Category/Name,CurrencyCode)," + + "aggregate(Amount with average as averageAmount,Amount with sum as SumAmount,Amount with min as MinAmount))", serviceUri), + aggregateQuery.ToString()); + + string mockResponse = string.Format("{{\"@odata.context\":\"{0}/$metadata#Sales" + + "(Time(Year),Product(Category(Name)),AverageAmount)\"," + + "\"value\":[{{\"@odata.id\":null,\"CurrencyCode\":\"EUR\",\"averageAmount\":1.500000,\"SumAmount\":3.00,\"MinAmount\":1.00," + + "\"Time\":{{\"@odata.id\":null,\"Year\":2012}}," + + "\"Product\":{{\"@odata.id\":null,\"Category\":{{\"@odata.id\":null,\"Name\":\"Non-Food\"}}}}}}]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + + var aggregateResultEnumerator = aggregateQuery.GetEnumerator(); + aggregateResultEnumerator.MoveNext(); + var aggregateResult = aggregateResultEnumerator.Current; + + Assert.Equal(6, aggregateResult.FoobarLength); + Assert.Equal("10", aggregateResult.TenStr); + Assert.Equal("2012", aggregateResult.YearStr); + Assert.Equal(8, aggregateResult.CategoryNameLength); + Assert.Equal("EUR", aggregateResult.CurrencyCode); + Assert.Equal("1.5", aggregateResult.AverageAmount); + Assert.Equal(3M, aggregateResult.SumAmount); + Assert.Equal("1", aggregateResult.MinAmount); + } + + [Fact] + public void GroupByResultSelector_UsingMemberInitializationInKeySelector() + { + var queryable = this.dsContext.CreateQuery(salesEntitySetName); + + var aggregateQuery = queryable.GroupBy( + d1 => new SalesGroupingKey01 { Color = d1.Product.Color, Country = d1.Customer.Country }, + (d1, d2) => new + { + d1.Color, + d1.Country, + SumAmount = d2.Sum(d3 => d3.Amount), + AvgAmount = d2.Average(d3 => d3.Amount), + MinAmount = d2.Min(d3 => d3.Amount), + MaxAmount = d2.Max(d3 => d3.Amount) + }); + + Assert.Equal( + string.Format("{0}/Sales?$apply=groupby((Product/Color,Customer/Country),aggregate(" + + "Amount with sum as SumAmount,Amount with average as AvgAmount," + + "Amount with min as MinAmount,Amount with max as MaxAmount))", serviceUri), + aggregateQuery.ToString()); + + MockGroupByMultipleNavProperties(); + + var aggregateResult = aggregateQuery.ToArray(); + + Assert.Equal(2, aggregateResult.Length); + Assert.Equal("Brown", aggregateResult[0].Color); + Assert.Equal(6M, aggregateResult[0].AvgAmount); + Assert.Equal(8M, aggregateResult[0].MaxAmount); + Assert.Equal("Netherlands", aggregateResult[1].Country); + Assert.Equal(5M, aggregateResult[1].SumAmount); + Assert.Equal(1M, aggregateResult[1].MinAmount); + } + + [Fact] + public void GroupByResultSelector_ConstructorInitializationInKeySelectorNotPermitted() + { + var queryable = this.dsContext.CreateQuery(salesEntitySetName); + + var aggregateQuery = queryable.GroupBy( + d1 => new SalesGroupingKey02(d1.Product.Color, d1.Customer.Country), + (d1, d2) => new + { + d1.Color, + d1.Country, + AvgAmount = d2.Average(d3 => d3.Amount), + SumAmount = d2.Sum(d3 => d3.Amount) + }); + + Assert.Equal( + "Error translating Linq expression to URI: " + Strings.ALinq_InvalidGroupByKeySelector("new SalesGroupingKey02(d1.Product.Color, d1.Customer.Country)"), + aggregateQuery.ToString()); + } + + [Fact] + public void GroupByResultSelector_OnFilteredInputSet_ExpressionTranslatedToExpectedUri() + { + // Arrange + var queryable = this.dsContext.CreateQuery(salesEntitySetName); + + var query = queryable.Where(d => d.CurrencyCode.Equals("USD")) + .GroupBy(d1 => new { d1.Product.Color }, (d1, d2) => new + { + ProductAvgTaxRate = d2.Average(d3 => d3.Product.TaxRate) + }); + + // Act & Assert + var expectedAggregateUri = $"{serviceUri}/{salesEntitySetName}?$apply=filter(CurrencyCode eq 'USD')" + + $"/groupby((Product/Color),aggregate(Product/TaxRate with average as ProductAvgTaxRate))"; + Assert.Equal(expectedAggregateUri, query.ToString()); + } + + #region Mock Aggregation Responses + + private void MockGroupBy_Sum_ByConstant() + { + string mockResponse = string.Format("{{" + + "\"@odata.context\":\"{0}/$metadata#Numbers(" + + "SumIntProp,SumNullableIntProp," + + "SumDoubleProp,SumNullableDoubleProp," + + "SumDecimalProp,SumNullableDecimalProp," + + "SumLongProp,SumNullableLongProp," + + "SumSingleProp,SumNullableSingleProp)\"," + + "\"value\":[" + + "{{\"@odata.id\":null," + + "\"SumIntProp\":506,\"SumNullableIntProp\":530," + + "\"SumDoubleProp\":464.72,\"SumNullableDoubleProp\":534.02," + + "\"SumDecimalProp\":559.4,\"SumNullableDecimalProp\":393.7," + + "\"SumLongProp\":1298,\"SumNullableLongProp\":993," + + "\"SumSingleProp\":333.79,\"SumNullableSingleProp\":528.44}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupBy_Sum_BySingleProperty() + { + string mockResponse = string.Format("{{" + + "\"@odata.context\":\"{0}/$metadata#Numbers(" + + "RowParity," + + "SumIntProp,SumNullableIntProp," + + "SumDoubleProp,SumNullableDoubleProp," + + "SumDecimalProp,SumNullableDecimalProp," + + "SumLongProp,SumNullableLongProp," + + "SumSingleProp,SumNullableSingleProp)\"," + + "\"value\":[" + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Even\"," + + "\"SumIntProp\":132,\"SumNullableIntProp\":146," + + "\"SumDoubleProp\":46.53,\"SumNullableDoubleProp\":343.8," + + "\"SumDecimalProp\":342.30,\"SumNullableDecimalProp\":100.60," + + "\"SumLongProp\":481,\"SumNullableLongProp\":544," + + "\"SumSingleProp\":221.88,\"SumNullableSingleProp\":286.03}}," + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Odd\"," + + "\"SumIntProp\":374,\"SumNullableIntProp\":384," + + "\"SumDoubleProp\":418.19,\"SumNullableDoubleProp\":190.22," + + "\"SumDecimalProp\":217.10,\"SumNullableDecimalProp\":293.10," + + "\"SumLongProp\":817,\"SumNullableLongProp\":449," + + "\"SumSingleProp\":111.91,\"SumNullableSingleProp\":242.41}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupBy_Sum_ByMultipleProperties() + { + string mockResponse = string.Format("{{" + + "\"@odata.context\":\"{0}/$metadata#Numbers(" + + "RowParity,RowCategory," + + "SumIntProp,SumNullableIntProp," + + "SumDoubleProp,SumNullableDoubleProp," + + "SumDecimalProp,SumNullableDecimalProp," + + "SumLongProp,SumNullableLongProp," + + "SumSingleProp,SumNullableSingleProp)\"," + + "\"value\":[" + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Even\",\"RowCategory\":\"Composite\"," + + "\"SumIntProp\":63,\"SumNullableIntProp\":146," + + "\"SumDoubleProp\":44.19,\"SumNullableDoubleProp\":165.31," + + "\"SumDecimalProp\":173.90,\"SumNullableDecimalProp\":null," + + "\"SumLongProp\":259,\"SumNullableLongProp\":249," + + "\"SumSingleProp\":171.22,\"SumNullableSingleProp\":174.99}}," + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Odd\",\"RowCategory\":\"None\"," + + "\"SumIntProp\":109,\"SumNullableIntProp\":199," + + "\"SumDoubleProp\":155.85,\"SumNullableDoubleProp\":null," + + "\"SumDecimalProp\":101.60,\"SumNullableDecimalProp\":122.90," + + "\"SumLongProp\":300,\"SumNullableLongProp\":201," + + "\"SumSingleProp\":107.66,\"SumNullableSingleProp\":81.94}}," + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Odd\",\"RowCategory\":\"Prime\"," + + "\"SumIntProp\":265,\"SumNullableIntProp\":185," + + "\"SumDoubleProp\":262.34,\"SumNullableDoubleProp\":190.22," + + "\"SumDecimalProp\":115.50,\"SumNullableDecimalProp\":170.20," + + "\"SumLongProp\":517,\"SumNullableLongProp\":248," + + "\"SumSingleProp\":4.25,\"SumNullableSingleProp\":160.47}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupBy_Average_ByConstant() + { + string mockResponse = string.Format("{{" + + "\"@odata.context\":\"{0}/$metadata#Numbers(" + + "AverageIntProp,AverageNullableIntProp," + + "AverageDoubleProp,AverageNullableDoubleProp," + + "AverageDecimalProp,AverageNullableDecimalProp," + + "AverageLongProp,AverageNullableLongProp," + + "AverageSingleProp,AverageNullableSingleProp)\"," + + "\"value\":[" + + "{{\"@odata.id\":null," + + "\"AverageIntProp\":101.2,\"AverageNullableIntProp\":132.5," + + "\"AverageDoubleProp\":92.944,\"AverageNullableDoubleProp\":133.505," + + "\"AverageDecimalProp\":111.88,\"AverageNullableDecimalProp\":98.425," + + "\"AverageLongProp\":259.6,\"AverageNullableLongProp\":248.25," + + "\"AverageSingleProp\":66.758,\"AverageNullableSingleProp\":132.11}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupBy_Average_BySingleProperty() + { + string mockResponse = string.Format("{{" + + "\"@odata.context\":\"{0}/$metadata#Numbers(" + + "RowParity," + + "AverageIntProp,AverageNullableIntProp," + + "AverageDoubleProp,AverageNullableDoubleProp," + + "AverageDecimalProp,AverageNullableDecimalProp," + + "AverageLongProp,AverageNullableLongProp," + + "AverageSingleProp,AverageNullableSingleProp)\"," + + "\"value\":[" + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Even\"," + + "\"AverageIntProp\":66.0,\"AverageNullableIntProp\":146.0," + + "\"AverageDoubleProp\":23.265,\"AverageNullableDoubleProp\":171.9," + + "\"AverageDecimalProp\":171.15,\"AverageNullableDecimalProp\":100.60," + + "\"AverageLongProp\":240.5,\"AverageNullableLongProp\":272.0," + + "\"AverageSingleProp\":110.94,\"AverageNullableSingleProp\":143.015}}," + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Odd\"," + + "\"AverageIntProp\":124.67,\"AverageNullableIntProp\":128.0," + + "\"AverageDoubleProp\":139.397,\"AverageNullableDoubleProp\":95.11," + + "\"AverageDecimalProp\":72.37,\"AverageNullableDecimalProp\":97.70," + + "\"AverageLongProp\":272.33,\"AverageNullableLongProp\":224.5," + + "\"AverageSingleProp\":37.30,\"AverageNullableSingleProp\":121.205}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupBy_Average_ByMultipleProperties() + { + string mockResponse = string.Format("{{" + + "\"@odata.context\":\"{0}/$metadata#Numbers(" + + "RowParity,RowCategory," + + "AverageIntProp,AverageNullableIntProp," + + "AverageDoubleProp,AverageNullableDoubleProp," + + "AverageDecimalProp,AverageNullableDecimalProp," + + "AverageLongProp,AverageNullableLongProp," + + "AverageSingleProp,AverageNullableSingleProp)\"," + + "\"value\":[" + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Even\",\"RowCategory\":\"Composite\"," + + "\"AverageNullableIntProp\":146,\"AverageIntProp\":63," + + "\"AverageNullableDoubleProp\":165.31,\"AverageDoubleProp\":44.19," + + "\"AverageNullableDecimalProp\":null,\"AverageDecimalProp\":173.9," + + "\"AverageNullableLongProp\":249,\"AverageLongProp\":259," + + "\"AverageNullableSingleProp\":174.99,\"AverageSingleProp\":171.22}}," + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Odd\",\"RowCategory\":\"None\"," + + "\"AverageNullableIntProp\":199,\"AverageIntProp\":109," + + "\"AverageNullableDoubleProp\":null,\"AverageDoubleProp\":155.85," + + "\"AverageNullableDecimalProp\":122.9,\"AverageDecimalProp\":101.6," + + "\"AverageNullableLongProp\":201,\"AverageLongProp\":300," + + "\"AverageNullableSingleProp\":81.94,\"AverageSingleProp\":107.66}}," + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Odd\",\"RowCategory\":\"Prime\"," + + "\"AverageNullableIntProp\":92.5,\"AverageIntProp\":132.5," + + "\"AverageNullableDoubleProp\":95.11,\"AverageDoubleProp\":131.17," + + "\"AverageNullableDecimalProp\":85.1,\"AverageDecimalProp\":57.75," + + "\"AverageNullableLongProp\":248,\"AverageLongProp\":258.5," + + "\"AverageNullableSingleProp\":160.47,\"AverageSingleProp\":2.125}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupBy_Min_ByConstant() + { + string mockResponse = string.Format("{{" + + "\"@odata.context\":\"{0}/$metadata#Numbers(" + + "MinIntProp,MinNullableIntProp," + + "MinDoubleProp,MinNullableDoubleProp," + + "MinDecimalProp,MinNullableDecimalProp," + + "MinLongProp,MinNullableLongProp," + + "MinSingleProp,MinNullableSingleProp)\"," + + "\"value\":[" + + "{{\"@odata.id\":null," + + "\"MinIntProp\":63,\"MinNullableIntProp\":34," + + "\"MinDoubleProp\":2.34,\"MinNullableDoubleProp\":16.1," + + "\"MinDecimalProp\":42.70,\"MinNullableDecimalProp\":12.90," + + "\"MinLongProp\":220,\"MinNullableLongProp\":201," + + "\"MinSingleProp\":1.29,\"MinNullableSingleProp\":81.94}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupBy_Min_BySingleProperty() + { + string mockResponse = string.Format("{{" + + "\"@odata.context\":\"{0}/$metadata#Numbers(" + + "RowParity," + + "MinIntProp,MinNullableIntProp," + + "MinDoubleProp,MinNullableDoubleProp," + + "MinDecimalProp,MinNullableDecimalProp," + + "MinLongProp,MinNullableLongProp," + + "MinSingleProp,MinNullableSingleProp)\"," + + "\"value\":[" + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Even\"," + + "\"MinIntProp\":63,\"MinNullableIntProp\":146," + + "\"MinDoubleProp\":2.34,\"MinNullableDoubleProp\":165.31," + + "\"MinDecimalProp\":168.40,\"MinNullableDecimalProp\":100.60," + + "\"MinLongProp\":222,\"MinNullableLongProp\":249," + + "\"MinSingleProp\":50.66,\"MinNullableSingleProp\":111.04}}," + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Odd\"," + + "\"MinIntProp\":109,\"MinNullableIntProp\":34," + + "\"MinDoubleProp\":129.37,\"MinNullableDoubleProp\":16.1," + + "\"MinDecimalProp\":42.70,\"MinNullableDecimalProp\":12.90," + + "\"MinLongProp\":220,\"MinNullableLongProp\":201," + + "\"MinSingleProp\":1.29,\"MinNullableSingleProp\":81.94}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupBy_Min_ByMultipleProperties() + { + string mockResponse = string.Format("{{" + + "\"@odata.context\":\"{0}/$metadata#Numbers(" + + "RowParity,RowCategory" + + "MinIntProp,MinNullableIntProp," + + "MinDoubleProp,MinNullableDoubleProp," + + "MinDecimalProp,MinNullableDecimalProp," + + "MinLongProp,MinNullableLongProp," + + "MinSingleProp,MinNullableSingleProp)\"," + + "\"value\":[" + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Even\",\"RowCategory\":\"Composite\"," + + "\"MinNullableIntProp\":146,\"MinIntProp\":63," + + "\"MinNullableDoubleProp\":165.31,\"MinDoubleProp\":44.19," + + "\"MinNullableDecimalProp\":null,\"MinDecimalProp\":173.9," + + "\"MinNullableLongProp\":249,\"MinLongProp\":259," + + "\"MinNullableSingleProp\":174.99,\"MinSingleProp\":171.22}}," + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Odd\",\"RowCategory\":\"None\"," + + "\"MinNullableIntProp\":199,\"MinIntProp\":109," + + "\"MinNullableDoubleProp\":null,\"MinDoubleProp\":155.85," + + "\"MinNullableDecimalProp\":122.9,\"MinDecimalProp\":101.6," + + "\"MinNullableLongProp\":201,\"MinLongProp\":300," + + "\"MinNullableSingleProp\":81.94,\"MinSingleProp\":107.66}}," + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Odd\",\"RowCategory\":\"Prime\"," + + "\"MinNullableIntProp\":34,\"MinIntProp\":124," + + "\"MinNullableDoubleProp\":16.1,\"MinDoubleProp\":129.37," + + "\"MinNullableDecimalProp\":12.9,\"MinDecimalProp\":42.7," + + "\"MinNullableLongProp\":248,\"MinLongProp\":220," + + "\"MinNullableSingleProp\":160.47,\"MinSingleProp\":1.29}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupBy_Max_ByConstant() + { + string mockResponse = string.Format("{{" + + "\"@odata.context\":\"{0}/$metadata#Numbers(" + + "MaxIntProp,MaxNullableIntProp," + + "MaxDoubleProp,MaxNullableDoubleProp," + + "MaxDecimalProp,MaxNullableDecimalProp," + + "MaxLongProp,MaxNullableLongProp," + + "MaxSingleProp,MaxNullableSingleProp)\"," + + "\"value\":[{{" + + "\"@odata.id\":null," + + "\"MaxIntProp\":141,\"MaxNullableIntProp\":199," + + "\"MaxDoubleProp\":155.85,\"MaxNullableDoubleProp\":178.49," + + "\"MaxDecimalProp\":173.90,\"MaxNullableDecimalProp\":157.30," + + "\"MaxLongProp\":300,\"MaxNullableLongProp\":295," + + "\"MaxSingleProp\":171.22,\"MaxNullableSingleProp\":174.99}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupBy_Max_BySingleProperty() + { + string mockResponse = string.Format("{{" + + "\"@odata.context\":\"{0}/$metadata#Numbers(" + + "RowParity," + + "MaxIntProp,MaxNullableIntProp," + + "MaxDoubleProp,MaxNullableDoubleProp," + + "MaxDecimalProp,MaxNullableDecimalProp," + + "MaxLongProp,MaxNullableLongProp," + + "MaxSingleProp,MaxNullableSingleProp)\"," + + "\"value\":[" + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Even\"," + + "\"MaxIntProp\":69,\"MaxNullableIntProp\":146," + + "\"MaxDoubleProp\":44.19,\"MaxNullableDoubleProp\":178.49," + + "\"MaxDecimalProp\":173.90,\"MaxNullableDecimalProp\":100.60," + + "\"MaxLongProp\":259,\"MaxNullableLongProp\":295," + + "\"MaxSingleProp\":171.22,\"MaxNullableSingleProp\":174.99}}," + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Odd\"," + + "\"MaxIntProp\":141,\"MaxNullableIntProp\":199," + + "\"MaxDoubleProp\":155.85,\"MaxNullableDoubleProp\":174.12," + + "\"MaxDecimalProp\":101.60,\"MaxNullableDecimalProp\":157.30," + + "\"MaxLongProp\":300,\"MaxNullableLongProp\":248," + + "\"MaxSingleProp\":107.66,\"MaxNullableSingleProp\":160.47}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupBy_Max_ByMultipleProperties() + { + string mockResponse = string.Format("{{" + + "\"@odata.context\":\"{0}/$metadata#Numbers(" + + "RowParity,RowCategory," + + "MaxIntProp,MaxNullableIntProp," + + "MaxDoubleProp,MaxNullableDoubleProp," + + "MaxDecimalProp,MaxNullableDecimalProp," + + "MaxLongProp,MaxNullableLongProp," + + "MaxSingleProp,MaxNullableSingleProp)\"," + + "\"value\":[" + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Even\",\"RowCategory\":\"Composite\"," + + "\"MaxNullableIntProp\":146,\"MaxIntProp\":63," + + "\"MaxNullableDoubleProp\":165.31,\"MaxDoubleProp\":44.19," + + "\"MaxNullableDecimalProp\":null,\"MaxDecimalProp\":173.9," + + "\"MaxNullableLongProp\":249,\"MaxLongProp\":259," + + "\"MaxNullableSingleProp\":174.99,\"MaxSingleProp\":171.22}}," + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Odd\",\"RowCategory\":\"None\"," + + "\"MaxNullableIntProp\":199,\"MaxIntProp\":109," + + "\"MaxNullableDoubleProp\":null,\"MaxDoubleProp\":155.85," + + "\"MaxNullableDecimalProp\":122.9,\"MaxDecimalProp\":101.6," + + "\"MaxNullableLongProp\":201,\"MaxLongProp\":300," + + "\"MaxNullableSingleProp\":81.94,\"MaxSingleProp\":107.66}}," + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Odd\",\"RowCategory\":\"Prime\"," + + "\"MaxNullableIntProp\":151,\"MaxIntProp\":141," + + "\"MaxNullableDoubleProp\":174.12,\"MaxDoubleProp\":132.97," + + "\"MaxNullableDecimalProp\":157.3,\"MaxDecimalProp\":72.8," + + "\"MaxNullableLongProp\":248,\"MaxLongProp\":297," + + "\"MaxNullableSingleProp\":160.47,\"MaxSingleProp\":2.96}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupBy_Count_ByConstant() + { + string mockResponse = string.Format("{{" + + "\"@odata.context\":\"{0}/$metadata#Numbers(Count,CountDistinct)\"," + + "\"value\":[" + + "{{\"@odata.id\":null,\"CountDistinct\":3,\"Count\":5}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupBy_Count_BySingleProperty() + { + string mockResponse = string.Format("{{" + + "\"@odata.context\":\"{0}/$metadata#Numbers(RowParity,Count,CountDistinct)\"," + + "\"value\":[" + + "{{\"@odata.id\":null,\"RowParity\":\"Even\",\"CountDistinct\":2,\"Count\":2}}," + + "{{\"@odata.id\":null,\"RowParity\":\"Odd\",\"CountDistinct\":2,\"Count\":3}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupBy_Count_ByMultipleProperties() + { + string mockResponse = string.Format("{{" + + "\"@odata.context\":\"{0}/$metadata#Numbers(RowParity,RowCategory,Count,CountDistinct)\"," + + "\"value\":[" + + "{{\"@odata.id\":null,\"RowCategory\":\"Composite\",\"RowParity\":\"Even\",\"CountDistinct\":1,\"Count\":1}}," + + "{{\"@odata.id\":null,\"RowCategory\":\"None\",\"RowParity\":\"Odd\",\"CountDistinct\":1,\"Count\":1}}," + + "{{\"@odata.id\":null,\"RowCategory\":\"Prime\",\"RowParity\":\"Odd\",\"CountDistinct\":1,\"Count\":2}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupBy_WithMixedAggregations() + { + string mockResponse = string.Format("{{\"@odata.context\":\"{0}/$metadata#Numbers(" + + "RowParity,SumIntProp,AverageDoubleProp,MinDecimalProp,MaxLongProp,Count,CountDistinct)\"," + + "\"value\":[" + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Even\"," + + "\"Count\":2,\"CountDistinct\":2," + + "\"SumIntProp\":132,\"AverageDoubleProp\":23.265," + + "\"MinDecimalProp\":168.4,\"MaxLongProp\":259}}," + + "{{\"@odata.id\":null," + + "\"RowParity\":\"Odd\"," + + "\"Count\":3,\"CountDistinct\":2," + + "\"SumIntProp\":374,\"AverageDoubleProp\":139.4," + + "\"MinDecimalProp\":42.7,\"MaxLongProp\":300}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupBy_ConstructorInitialization() + { + string mockResponse = string.Format("{{\"@odata.context\":\"{0}/$metadata#Sales" + + "(Product(Category(Name)),averageAmount)\"," + + "\"value\":[" + + "{{\"@odata.id\":null,\"averageAmount\":4.000000," + + "\"Product\":{{\"@odata.id\":null,\"Category\":{{\"@odata.id\":null,\"Name\":\"Food\"}}}}}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockCountDistinct() + { + string mockResponse = string.Format("{{\"@odata.context\":\"{0}/$metadata#Numbers(" + + "CountDistinctRowParity)\"," + + "\"value\":[{{\"@odata.id\":null,\"CountDistinctRowParity\":3}}]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockCountDistinct_TargetingNavProperty() + { + string mockResponse = string.Format("{{\"@odata.context\":\"{0}/$metadata#Numbers(" + + "CountDistinctCustomerCountry)\"," + + "\"value\":[{{\"@odata.id\":null,\"CountDistinctCustomerCountry\":2}}]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupBySingleNavProperty() + { + string mockResponse = string.Format("{{\"@odata.context\":\"{0}/$metadata#Sales" + + "(Product(Color),SumAmount,AvgAmount,MinAmount,MaxAmount)\"," + + "\"value\":[" + + "{{\"@odata.id\":null,\"MaxAmount\":8.00,\"MinAmount\":4.00,\"AvgAmount\":6.000000,\"SumAmount\":12.00," + + "\"Product\":{{\"@odata.id\":null,\"Color\":\"Brown\"}}}}," + + "{{\"@odata.id\":null,\"MaxAmount\":4.00,\"MinAmount\":1.00,\"AvgAmount\":2.000000,\"SumAmount\":12.00," + + "\"Product\":{{\"@odata.id\":null,\"Color\":\"White\"}}}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupByMultipleNavProperties() + { + string mockResponse = string.Format("{{\"@odata.context\":\"{0}/$metadata#Sales" + + "(Product(Color),Customer(Country),SumAmount,AvgAmount,MinAmount,MaxAmount)\"," + + "\"value\":[" + + "{{\"@odata.id\":null,\"MaxAmount\":8.00,\"MinAmount\":4.00,\"AvgAmount\":6.000000,\"SumAmount\":12.00," + + "\"Customer\":{{\"@odata.id\":null,\"Country\":\"USA\"}}," + + "\"Product\":{{\"@odata.id\":null,\"Color\":\"Brown\"}}}}," + + "{{\"@odata.id\":null,\"MaxAmount\":2.00,\"MinAmount\":1.00,\"AvgAmount\":1.666666,\"SumAmount\":5.00," + + "\"Customer\":{{\"@odata.id\":null,\"Country\":\"Netherlands\"}}," + + "\"Product\":{{\"@odata.id\":null,\"Color\":\"White\"}}}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupBySingleNavProperty_AggregationsTargetingNavProperty() + { + string mockResponse = string.Format("{{\"@odata.context\":\"{0}/$metadata#Sales" + + "(Currency(Code),SumTaxRate,AvgTaxRate,MinTaxRate,MaxTaxRate)\"," + + "\"value\":[" + + "{{\"@odata.id\":null,\"MaxTaxRate\":0.14,\"MinTaxRate\":0.06,\"AvgTaxRate\":0.113333,\"SumTaxRate\":0.34," + + "\"Currency\":{{\"@odata.id\":null,\"Code\":\"EUR\"}}}}," + + "{{\"@odata.id\":null,\"MaxTaxRate\":0.14,\"MinTaxRate\":0.06,\"AvgTaxRate\":0.092000,\"SumTaxRate\":0.46," + + "\"Currency\":{{\"@odata.id\":null,\"Code\":\"USD\"}}}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupByMultipleNavProperties_AggregationsTargetingNavProperty() + { + string mockResponse = string.Format("{{\"@odata.context\":\"{0}/$metadata#Sales" + + "(Product(Color),Customer(Country),SumTaxRate,AvgTaxRate,MinTaxRate,MaxTaxRate)\"," + + "\"value\":[" + + "{{\"@odata.id\":null,\"MaxTaxRate\":0.14,\"MinTaxRate\":0.06,\"AvgTaxRate\":0.113333,\"SumTaxRate\":0.34," + + "\"Customer\":{{\"@odata.id\":null,\"Country\":\"Netherlands\"}}," + + "\"Product\":{{\"@odata.id\":null,\"Color\":\"White\"}}}}," + + "{{\"@odata.id\":null,\"MaxTaxRate\":0.06,\"MinTaxRate\":0.06,\"AvgTaxRate\":0.060000,\"SumTaxRate\":0.12," + + "\"Customer\":{{\"@odata.id\":null,\"Country\":\"USA\"}}," + + "\"Product\":{{\"@odata.id\":null,\"Color\":\"Brown\"}}}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupByConstant_AggregationsTargetingNavProperty() + { + string mockResponse = string.Format("{{\"@odata.context\":\"{0}/$metadata#Sales" + + "(SumTaxRate,AvgTaxRate,MinTaxRate,MaxTaxRate)\"," + + "\"value\":[" + + "{{\"@odata.id\":null,\"MaxTaxRate\":0.14,\"MinTaxRate\":0.06,\"AvgTaxRate\":0.100000,\"SumTaxRate\":0.80}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupByConstant_MixedScenarios() + { + string mockResponse = string.Format("{{\"@odata.context\":\"{0}/$metadata#Sales" + + "(SumTaxRate,AvgAmount,MinTaxRate,MaxAmount,GroupCount,DistinctCurrency)\"," + + "\"value\":[" + + "{{\"@odata.id\":null,\"DistinctCurrency\":2,\"GroupCount\":8," + + "\"MaxAmount\":8.00,\"MinTaxRate\":0.06,\"AvgAmount\":3.000000,\"SumTaxRate\":0.80}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupBySingleNavProperty_MixedScenarios() + { + string mockResponse = string.Format("{{\"@odata.context\":\"{0}/$metadata#Sales" + + "(Product(Category(Id)),SumTaxRate,AvgAmount,MinTaxRate,MaxAmount,GroupCount,DistinctCurrency)\"," + + "\"value\":[" + + "{{\"@odata.id\":null,\"DistinctCurrency\":2,\"GroupCount\":4," + + "\"MaxAmount\":8.00,\"MinTaxRate\":0.06,\"AvgAmount\":4.000000,\"SumTaxRate\":0.24," + + "\"Product\":{{\"@odata.id\":null,\"Category\":{{\"@odata.id\":null,\"Id\":\"PG1\"}}}}}}," + + "{{\"@odata.id\":null,\"DistinctCurrency\":2,\"GroupCount\":4," + + "\"MaxAmount\":4.00,\"MinTaxRate\":0.14,\"AvgAmount\":2.000000,\"SumTaxRate\":0.56," + + "\"Product\":{{\"@odata.id\":null,\"Category\":{{\"@odata.id\":null,\"Id\":\"PG2\"}}}}}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + private void MockGroupByMultipleNavProperties_MixedScenarios() + { + string mockResponse = string.Format("{{\"@odata.context\":\"{0}/$metadata#Sales" + + "(Product(Category(Id)),Customer(Country),SumTaxRate,AvgAmount,MinTaxRate,MaxAmount,GroupCount,DistinctCurrency)\"," + + "\"value\":[" + + "{{\"@odata.id\":null,\"DistinctCurrency\":1,\"GroupCount\":1," + + "\"MaxAmount\":2.00,\"MinTaxRate\":0.06,\"AvgAmount\":2.000000,\"SumTaxRate\":0.06," + + "\"Customer\":{{\"@odata.id\":null,\"Country\":\"Netherlands\"}}," + + "\"Product\":{{\"@odata.id\":null,\"Category\":{{\"@odata.id\":null,\"Id\":\"PG1\"}}}}}}," + + "{{\"@odata.id\":null,\"DistinctCurrency\":1,\"GroupCount\":2," + + "\"MaxAmount\":2.00,\"MinTaxRate\":0.14,\"AvgAmount\":1.500000,\"SumTaxRate\":0.28," + + "\"Customer\":{{\"@odata.id\":null,\"Country\":\"Netherlands\"}}," + + "\"Product\":{{\"@odata.id\":null,\"Category\":{{\"@odata.id\":null,\"Id\":\"PG2\"}}}}}}" + + "]}}", serviceUri); + + InterceptRequestAndMockResponse(mockResponse); + } + + #endregion Mock Aggregation Responses + + #region Helper Classes + + class NumbersGroupedResult + { + public string RowParity { get; set; } + public int SumIntProp { get; set; } + public double AverageDoubleProp { get; set; } + public decimal MinDecimalProp { get; set; } + public long MaxLongProp { get; set; } + public int Count { get; set; } + public int CountDistinct { get; set; } + } + + class SalesGroupedResult + { + public decimal SumTaxRate { get; set; } + public decimal AvgAmount { get; set; } + public decimal MinTaxRate { get; set; } + public decimal MaxAmount { get; set; } + public int GroupCount { get; set; } + public int DistinctCurrency { get; set; } + } + + class SalesGroupedResult01 : SalesGroupedResult + { + public int GroupingConstant { get; set; } + public string GibberishConstant { get; set; } + } + + class SalesGroupedResult02 : SalesGroupedResult + { + public string GibberishConstant { get; set; } + public string CategoryId { get; set; } + } + + class SalesGroupedResult03 : SalesGroupedResult + { + public string GibberishConstant { get; set; } + public string CategoryId { get; set; } + public string Country { get; set; } + } + + class SalesGroupedResult04 + { + public SalesGroupedResult04(string categoryName, decimal averageAmount) + { + CategoryName = categoryName; + AverageAmount = averageAmount; + } + + public string CategoryName { get; } + public decimal AverageAmount { get; } + } + + class SalesGroupedResult05 + { + public SalesGroupedResult05(decimal averageAmount) + { + AverageAmount = averageAmount; + } + + public string CategoryName { get; set; } + public decimal AverageAmount { get; } + } + + class SalesGroupedResult06 + { + public SalesGroupedResult06(int categoryNameLength, string currencyCode, string averageAmount) + { + this.CategoryNameLength = categoryNameLength; + this.CurrencyCode = currencyCode; + this.AverageAmount = averageAmount; + } + + public string TenStr { get; set; } + public int FoobarLength { get; set; } + public string YearStr { get; set; } + public int CategoryNameLength { get; } + public string CurrencyCode { get; } + public string AverageAmount { get; } + public decimal SumAmount { get; set; } + public string MinAmount { get; set; } + } + + class SalesGroupedResult07 + { + public string CategoryName { get; set; } + public decimal AverageAmount { get; set; } + } + + class SalesGroupingKey01 + { + public string Color { get; set; } + public string Country { get; set; } + } + + class SalesGroupingKey02 + { + public SalesGroupingKey02(string color, string country) + { + this.Color = color; + this.Country = country; + } + public string Color { get; } + public string Country { get; } + } + + #endregion Helper Classes + } +} diff --git a/test/FunctionalTests/Microsoft.OData.Client.Tests/ALinq/DollarApplyTestsBase.cs b/test/FunctionalTests/Microsoft.OData.Client.Tests/ALinq/DollarApplyTestsBase.cs index 1822fd5633..0ef252d62d 100644 --- a/test/FunctionalTests/Microsoft.OData.Client.Tests/ALinq/DollarApplyTestsBase.cs +++ b/test/FunctionalTests/Microsoft.OData.Client.Tests/ALinq/DollarApplyTestsBase.cs @@ -37,6 +37,7 @@ public DollarApplyTestsBase() EdmModel model = BuildEdmModel(); dsContext = new DataServiceContext(new Uri(serviceUri)); + dsContext.ResolveName = (type) => $"NS.{type.Name}"; dsContext.Format.UseJson(model); } @@ -70,7 +71,7 @@ private static EdmModel BuildEdmModel() saleEntity.AddStructuralProperty("Amount", EdmCoreModel.Instance.GetDecimal(false)); var productEntity = new EdmEntityType("NS", "Product"); - productEntity.AddKeys(productEntity.AddStructuralProperty("Id", EdmCoreModel.Instance.GetInt32(false))); + productEntity.AddKeys(productEntity.AddStructuralProperty("Id", EdmCoreModel.Instance.GetString(false))); productEntity.AddStructuralProperty("CategoryId", EdmCoreModel.Instance.GetString(false)); productEntity.AddStructuralProperty("Name", EdmCoreModel.Instance.GetString(false)); productEntity.AddStructuralProperty("Color", EdmCoreModel.Instance.GetString(false)); @@ -89,6 +90,12 @@ private static EdmModel BuildEdmModel() currencyEntity.AddKeys(currencyEntity.AddStructuralProperty("Code", EdmCoreModel.Instance.GetString(false))); currencyEntity.AddStructuralProperty("Name", EdmCoreModel.Instance.GetString(false)); + var timeEntity = new EdmEntityType("NS", "Time"); + timeEntity.AddKeys(timeEntity.AddStructuralProperty("Date", EdmCoreModel.Instance.GetString(false))); + timeEntity.AddStructuralProperty("Month", EdmCoreModel.Instance.GetString(false)); + timeEntity.AddStructuralProperty("Quarter", EdmCoreModel.Instance.GetString(false)); + timeEntity.AddStructuralProperty("Year", EdmCoreModel.Instance.GetInt32(false)); + // Associations saleEntity.AddBidirectionalNavigation( new EdmNavigationPropertyInfo { Name = "Customer", Target = customerEntity, TargetMultiplicity = EdmMultiplicity.One }, @@ -98,6 +105,8 @@ private static EdmModel BuildEdmModel() new EdmNavigationPropertyInfo { Name = "Sales", Target = saleEntity, TargetMultiplicity = EdmMultiplicity.Many }); saleEntity.AddUnidirectionalNavigation( new EdmNavigationPropertyInfo { Name = "Currency", Target = currencyEntity, TargetMultiplicity = EdmMultiplicity.One }); + saleEntity.AddUnidirectionalNavigation( + new EdmNavigationPropertyInfo { Name = "Time", Target = timeEntity, TargetMultiplicity = EdmMultiplicity.One }); productEntity.AddBidirectionalNavigation( new EdmNavigationPropertyInfo { Name = "Category", Target = categoryEntity, TargetMultiplicity = EdmMultiplicity.One }, @@ -111,6 +120,7 @@ private static EdmModel BuildEdmModel() model.AddElement(customerEntity); model.AddElement(categoryEntity); model.AddElement(currencyEntity); + model.AddElement(timeEntity); model.AddElement(entityContainer); entityContainer.AddEntitySet(numbersEntitySetName, numberEntity); @@ -337,6 +347,7 @@ public class Sale public string CustomerId { get; set; } public Customer Customer { get; set; } public string Date { get; set; } + public Time Time { get; set; } public string ProductId { get; set; } public Product Product { get; set; } public string CurrencyCode { get; set; } @@ -376,6 +387,14 @@ public class Currency public string Name { get; set; } } + public class Time + { + public string Date { get; set; } + public string Month { get; set; } + public string Quarter { get; set; } + public int Year { get; set; } + } + #endregion } } diff --git a/test/FunctionalTests/Microsoft.OData.Client.Tests/Microsoft.OData.Client.Tests.csproj b/test/FunctionalTests/Microsoft.OData.Client.Tests/Microsoft.OData.Client.Tests.csproj index fdca92dcbb..3790eae77a 100644 --- a/test/FunctionalTests/Microsoft.OData.Client.Tests/Microsoft.OData.Client.Tests.csproj +++ b/test/FunctionalTests/Microsoft.OData.Client.Tests/Microsoft.OData.Client.Tests.csproj @@ -2,7 +2,7 @@ Debug AnyCPU - true + false Microsoft.OData.Client.Tests net452;netcoreapp2.1;netcoreapp3.1 ..\..\..\..\sln\