From 94be38cf68692d3e605175801add457936fa7331 Mon Sep 17 00:00:00 2001 From: maumar Date: Sun, 6 Nov 2022 22:49:51 -0800 Subject: [PATCH] Fix to #28648 - Json/Query: translate element access of a json array Converting indexer over list/array into ElementAt, so that nav expansion understands it and can perform pushown and inject MaterializeCollectionNavigation expression where necessary. In translation phase (specifically in ExpandSharedTypeEntities) we recognize the pattern that nav expansion creates and if the root is JsonQueryExpression, we apply collection index over it. JsonQueryExpression path segment now consists of two components - string representing JSON property name and SqlExpression representing collection index (it can be constant, parameter or any arbitrary expression that resolves to int) Deduplication is heavily restricted currently - we only de-duplicate projections whose additional path consists of JSON property accesses only (no collection indexes allowed). All queries projecting entities that need JSON array access must be set to NoTracking (for now). This is because we don't flow those collection index values into shaper. Instead, the ordinal keys are filled with dummy values, which prohibits us from doing proper change tracking. Fixes #28648 --- .../Properties/RelationalStrings.Designer.cs | 6 + .../Properties/RelationalStrings.resx | 3 + ...ToElementAtNormalizingExpressionVisitor.cs | 66 +++ .../Query/JsonQueryExpression.cs | 27 + src/EFCore.Relational/Query/PathSegment.cs | 55 +- .../RelationalQueryTranslationPreprocessor.cs | 1 + ...yableMethodTranslatingExpressionVisitor.cs | 170 ++++++ ...sitor.ShaperProcessingExpressionVisitor.cs | 27 +- .../Query/SqlExpressions/SelectExpression.cs | 32 +- .../Internal/SqlServerQuerySqlGenerator.cs | 31 +- ...qlServerSqlTranslatingExpressionVisitor.cs | 73 ++- .../Query/JsonQueryAdHocTestBase.cs | 187 +++++++ .../Query/JsonQueryTestBase.cs | 485 +++++++++++++++++- .../Query/GearsOfWarQueryTestBase.cs | 14 + .../Query/GearsOfWarQuerySqlServerTest.cs | 11 + .../Query/JsonQueryAdHocSqlServerTest.cs | 52 ++ .../Query/JsonQuerySqlServerTest.cs | 406 ++++++++++++++- .../NorthwindCompiledQuerySqlServerTest.cs | 20 +- .../Query/TPCGearsOfWarQuerySqlServerTest.cs | 11 + .../Query/TPTGearsOfWarQuerySqlServerTest.cs | 11 + .../TemporalGearsOfWarQuerySqlServerTest.cs | 11 + .../TestUtilities/SqlServerCondition.cs | 3 +- .../SqlServerConditionAttribute.cs | 5 + .../TestUtilities/TestEnvironment.cs | 31 ++ .../Query/GearsOfWarQuerySqliteTest.cs | 11 + .../Query/NorthwindCompiledQuerySqliteTest.cs | 20 +- 26 files changed, 1697 insertions(+), 72 deletions(-) create mode 100644 src/EFCore.Relational/Query/Internal/CollectionIndexerToElementAtNormalizingExpressionVisitor.cs diff --git a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs index b7ef50f00fa..88f4c3c55fd 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs +++ b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs @@ -1407,6 +1407,12 @@ public static string ParameterNotObjectArray(object? parameter) public static string PendingAmbientTransaction => GetString("PendingAmbientTransaction"); + /// + /// The query projects an entity mapped to JSON and accesses a JSON collection element. Such queries require 'AsNoTracking' option, even when the parent entity is projected. + /// + public static string ProjectingJsonCollectionElementRequiresNoTracking + => GetString("ProjectingJsonCollectionElementRequiresNoTracking"); + /// /// Unable to translate set operations when both sides don't assign values to the same properties in the nominal type. Please make sure that the same properties are included on both sides, and consider assigning default values if a property doesn't require a specific value. /// diff --git a/src/EFCore.Relational/Properties/RelationalStrings.resx b/src/EFCore.Relational/Properties/RelationalStrings.resx index d10d59b928c..68f6c2b6989 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.resx +++ b/src/EFCore.Relational/Properties/RelationalStrings.resx @@ -950,6 +950,9 @@ This connection was used with an ambient transaction. The original ambient transaction needs to be completed before this connection can be used outside of it. + + The query projects an entity mapped to JSON and accesses a JSON collection element. Such queries require 'AsNoTracking' option, even when the parent entity is projected. + Unable to translate set operations when both sides don't assign values to the same properties in the nominal type. Please make sure that the same properties are included on both sides, and consider assigning default values if a property doesn't require a specific value. diff --git a/src/EFCore.Relational/Query/Internal/CollectionIndexerToElementAtNormalizingExpressionVisitor.cs b/src/EFCore.Relational/Query/Internal/CollectionIndexerToElementAtNormalizingExpressionVisitor.cs new file mode 100644 index 00000000000..dbb12d6ae6a --- /dev/null +++ b/src/EFCore.Relational/Query/Internal/CollectionIndexerToElementAtNormalizingExpressionVisitor.cs @@ -0,0 +1,66 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Query.Internal; + +/// +/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to +/// the same compatibility standards as public APIs. It may be changed or removed without notice in +/// any release. You should only use it directly in your code with extreme caution and knowing that +/// doing so can result in application failures when updating to a new Entity Framework Core release. +/// +public class CollectionIndexerToElementAtNormalizingExpressionVisitor : ExpressionVisitor +{ + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override Expression VisitMethodCall(MethodCallExpression methodCallExpression) + { + // Convert list[x] to list.ElementAt(x) + if (methodCallExpression.Method is { Name: "get_Item", IsStatic: false, DeclaringType: { IsGenericType: true } declaringType } + && declaringType.GetGenericTypeDefinition() == typeof(List<>)) + { + var source = Visit(methodCallExpression.Object!); + var index = Visit(methodCallExpression.Arguments[0]); + var sourceTypeArgument = source.Type.GetSequenceType(); + + return Expression.Call( + QueryableMethods.ElementAt.MakeGenericMethod(sourceTypeArgument), + Expression.Call( + QueryableMethods.AsQueryable.MakeGenericMethod(sourceTypeArgument), + source), + index); + } + + return base.VisitMethodCall(methodCallExpression); + } + + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override Expression VisitBinary(BinaryExpression binaryExpression) + { + // Convert array[x] to array.ElementAt(x) + if (binaryExpression.NodeType == ExpressionType.ArrayIndex) + { + var source = Visit(binaryExpression.Left); + var index = Visit(binaryExpression.Right); + var sourceTypeArgument = source.Type.GetSequenceType(); + + return Expression.Call( + QueryableMethods.ElementAt.MakeGenericMethod(sourceTypeArgument), + Expression.Call( + QueryableMethods.AsQueryable.MakeGenericMethod(sourceTypeArgument), + source), + index); + } + + return base.VisitBinary(binaryExpression); + } +} diff --git a/src/EFCore.Relational/Query/JsonQueryExpression.cs b/src/EFCore.Relational/Query/JsonQueryExpression.cs index d646b593216..705ec78b752 100644 --- a/src/EFCore.Relational/Query/JsonQueryExpression.cs +++ b/src/EFCore.Relational/Query/JsonQueryExpression.cs @@ -161,6 +161,33 @@ public virtual JsonQueryExpression BindNavigation(INavigation navigation) IsNullable || !navigation.ForeignKey.IsRequiredDependent); } + /// + /// Binds a collection element access with this JSON query expression to get the SQL representation. + /// + /// The collection index to bind. + public virtual JsonQueryExpression BindCollectionElement(SqlExpression collectionIndexExpression) + { + // this needs to be changed IF JsonQueryExpression will also be used for collection of primitives + // see issue #28688 + Debug.Assert( + Path.Last().ArrayIndex == null, + "Already accessing JSON array element."); + + var newPath = Path.ToList(); + newPath.Add(new PathSegment(collectionIndexExpression)); + + return new JsonQueryExpression( + EntityType, + JsonColumn, + _keyPropertyMap, + newPath, + EntityType.ClrType, + collection: false, + // TODO: computing nullability might be more complicated when we allow strict mode + // see issue #28656 + nullable: true); + } + /// /// Makes this JSON query expression nullable. /// diff --git a/src/EFCore.Relational/Query/PathSegment.cs b/src/EFCore.Relational/Query/PathSegment.cs index e251ea2fc01..25ac61e9026 100644 --- a/src/EFCore.Relational/Query/PathSegment.cs +++ b/src/EFCore.Relational/Query/PathSegment.cs @@ -7,44 +7,69 @@ namespace Microsoft.EntityFrameworkCore.Query; /// /// -/// A class representing a component of JSON path used in or . +/// A struct representing a component of JSON path used in or . /// /// /// This type is typically used by database providers (and other extensions). It is generally /// not used in application code. /// /// -public class PathSegment +public readonly struct PathSegment { /// - /// Creates a new instance of the class. + /// Creates a new struct representing JSON property access. /// - /// A key which is being accessed in the JSON. - public PathSegment(string key) + /// A name of JSON property which is being accessed. + public PathSegment(string propertyName) { - Key = key; + PropertyName = propertyName; + ArrayIndex = null; } /// - /// The key which is being accessed in the JSON. + /// Creates a new struct representing JSON array element access. /// - public virtual string Key { get; } + /// An index of an element which is being accessed in the JSON array. + public PathSegment(SqlExpression arrayIndex) + { + ArrayIndex = arrayIndex; + PropertyName = null; + } + + /// + /// The name of JSON property which is being accessed. + /// + public string? PropertyName { get; } + + /// + /// The index of an element which is being accessed in the JSON array. + /// + public SqlExpression? ArrayIndex { get; } /// public override string ToString() - => (Key == "$" ? "" : ".") + Key; + { + var arrayIndex = ArrayIndex switch + { + null => "", + SqlConstantExpression { Value: not null } sqlConstant => $"[{sqlConstant.Value}]", + SqlParameterExpression sqlParameter => $"[{sqlParameter.Name}]", + _ => "[(...)]" + }; + + return (PropertyName == "$" ? "" : ".") + (PropertyName ?? arrayIndex); + } /// public override bool Equals(object? obj) - => obj != null - && (ReferenceEquals(this, obj) - || obj is PathSegment pathSegment - && Equals(pathSegment)); + => obj is PathSegment pathSegment && Equals(pathSegment); private bool Equals(PathSegment pathSegment) - => Key == pathSegment.Key; + => PropertyName == pathSegment.PropertyName + && ((ArrayIndex == null && pathSegment.ArrayIndex == null) + || (ArrayIndex != null && ArrayIndex.Equals(pathSegment.ArrayIndex))); /// public override int GetHashCode() - => HashCode.Combine(Key); + => HashCode.Combine(PropertyName, ArrayIndex); } diff --git a/src/EFCore.Relational/Query/RelationalQueryTranslationPreprocessor.cs b/src/EFCore.Relational/Query/RelationalQueryTranslationPreprocessor.cs index 42e5416464b..6077a62c474 100644 --- a/src/EFCore.Relational/Query/RelationalQueryTranslationPreprocessor.cs +++ b/src/EFCore.Relational/Query/RelationalQueryTranslationPreprocessor.cs @@ -37,6 +37,7 @@ public override Expression NormalizeQueryableMethod(Expression expression) expression = new RelationalQueryMetadataExtractingExpressionVisitor(_relationalQueryCompilationContext).Visit(expression); expression = base.NormalizeQueryableMethod(expression); expression = new TableValuedFunctionToQueryRootConvertingExpressionVisitor(QueryCompilationContext.Model).Visit(expression); + expression = new CollectionIndexerToElementAtNormalizingExpressionVisitor().Visit(expression); return expression; } diff --git a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs index 73938ea9691..64b70bdcb13 100644 --- a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using System.Linq; using Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.EntityFrameworkCore.Query.Internal; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; @@ -1668,7 +1669,176 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp ?? methodCallExpression.Update(null!, new[] { source, methodCallExpression.Arguments[1] }); } + // TODO: issue #28688 + // when implementing collection of primitives, make sure EAOD is translated correctly for them + if (methodCallExpression.Method.IsGenericMethod + && (methodCallExpression.Method.GetGenericMethodDefinition() == QueryableMethods.ElementAt + || methodCallExpression.Method.GetGenericMethodDefinition() == QueryableMethods.ElementAtOrDefault)) + { + source = methodCallExpression.Arguments[0]; + var selectMethodCallExpression = default(MethodCallExpression); + + if (source is MethodCallExpression { Method: MethodInfo { IsGenericMethod: true } } sourceMethodCall + && sourceMethodCall.Method.GetGenericMethodDefinition() == QueryableMethods.Select) + { + selectMethodCallExpression = sourceMethodCall; + source = sourceMethodCall.Arguments[0]; + } + + var asQueryableMethodCallExpression = default(MethodCallExpression); + if (source is MethodCallExpression { Method: MethodInfo { IsGenericMethod: true } } maybeAsQueryableMethodCall + && maybeAsQueryableMethodCall.Method.GetGenericMethodDefinition() == QueryableMethods.AsQueryable) + { + asQueryableMethodCallExpression = maybeAsQueryableMethodCall; + source = maybeAsQueryableMethodCall.Arguments[0]; + } + + source = Visit(source); + + if (source is JsonQueryExpression jsonQueryExpression) + { + var collectionIndexExpression = _sqlTranslator.Translate(methodCallExpression.Arguments[1]!); + if (collectionIndexExpression == null) + { + // before we return from failed translation + // we need to bring back methods we may have trimmed above (AsQueryable/Select) + // we translate what we can (source) and rest is the original tree + // so that sql translation can fail later (as the tree will be in unexpected shape) + return PrepareFailedTranslationResult( + source, + asQueryableMethodCallExpression, + selectMethodCallExpression, + methodCallExpression); + } + + var newJsonQuery = jsonQueryExpression.BindCollectionElement(collectionIndexExpression!); + + var entityShaper = new RelationalEntityShaperExpression( + jsonQueryExpression.EntityType, + newJsonQuery, + nullable: true); + + if (selectMethodCallExpression == null) + { + return entityShaper; + } + + var selectorLambda = selectMethodCallExpression.Arguments[1].UnwrapLambdaFromQuote(); + + // short circuit what we know is wrong without a closer look + if (selectorLambda.Body is NewExpression or MemberInitExpression) + { + return PrepareFailedTranslationResult( + source, + asQueryableMethodCallExpression, + selectMethodCallExpression, + methodCallExpression); + } + + var replaced = ReplacingExpressionVisitor.Replace(selectorLambda.Parameters[0], entityShaper, selectorLambda.Body); + var result = Visit(replaced); + + return IsValidSelectorForJsonArrayElementAccess(result, newJsonQuery) + ? result + : PrepareFailedTranslationResult( + source, + asQueryableMethodCallExpression, + selectMethodCallExpression, + methodCallExpression); + } + } + return base.VisitMethodCall(methodCallExpression); + + static Expression PrepareFailedTranslationResult( + Expression source, + MethodCallExpression? asQueryable, + MethodCallExpression? select, + MethodCallExpression elementAt) + { + var result = source; + if (asQueryable != null) + { + result = asQueryable.Update(null, new[] { result }); + } + + if (select != null) + { + result = select.Update(null, new[] { result, select.Arguments[1] }); + } + + return elementAt.Update(null, new[] { result, elementAt.Arguments[1] }); + } + + static bool IsValidSelectorForJsonArrayElementAccess(Expression expression, JsonQueryExpression baselineJsonQuery) + { + // JSON_QUERY($[0]).Property + if (expression is MemberExpression + { + Expression: RelationalEntityShaperExpression { ValueBufferExpression: JsonQueryExpression memberJqe } + } memberExpression + && JsonQueryExpressionIsRootedIn(memberJqe, baselineJsonQuery)) + { + return true; + } + + // MCNE(JSON_QUERY($[0].Collection)) + // MCNE(JSON_QUERY($[0].Collection).AsQueryable()) + // MCNE(JSON_QUERY($[0].Collection).Select(xx => xx.Includes()) + // MCNE(JSON_QUERY($[0].Collection).AsQueryable().Select(xx => xx.Includes()) + if (expression is MaterializeCollectionNavigationExpression mcne) + { + var subquery = mcne.Subquery; + if (subquery is MethodCallExpression { Method: MethodInfo { IsGenericMethod: true } } selectMethodCall + && selectMethodCall.Method.GetGenericMethodDefinition() == QueryableMethods.Select + && selectMethodCall.Arguments[1].UnwrapLambdaFromQuote() is LambdaExpression selectorLambda + && StripIncludes(selectorLambda.Body) == selectorLambda.Parameters[0]) + { + subquery = selectMethodCall.Arguments[0]; + } + + if (subquery is MethodCallExpression { Method: MethodInfo { IsGenericMethod: true } } asQueryableMethodCall + && asQueryableMethodCall.Method.GetGenericMethodDefinition() == QueryableMethods.AsQueryable) + { + subquery = asQueryableMethodCall.Arguments[0]; + } + + if (subquery is JsonQueryExpression subqueryJqe + && JsonQueryExpressionIsRootedIn(subqueryJqe, baselineJsonQuery)) + { + return true; + } + } + + // JSON_QUERY($[0]).Includes() + // JSON_QUERY($[0].Reference).Includes() + // JSON_QUERY($[0]) + // JSON_QUERY($[0].Reference) + expression = StripIncludes(expression); + if (expression is RelationalEntityShaperExpression { ValueBufferExpression: JsonQueryExpression reseJqe } + && JsonQueryExpressionIsRootedIn(reseJqe, baselineJsonQuery)) + { + return true; + } + + return false; + } + + static bool JsonQueryExpressionIsRootedIn(JsonQueryExpression expressionToTest, JsonQueryExpression root) + => expressionToTest.JsonColumn == root.JsonColumn + && expressionToTest.Path.Count >= root.Path.Count + && expressionToTest.Path.Take(root.Path.Count).SequenceEqual(root.Path); + + static Expression StripIncludes(Expression expression) + { + var current = expression; + while (current is IncludeExpression includeExpression) + { + current = includeExpression.EntityExpression; + } + + return current; + } } protected override Expression VisitExtension(Expression extensionExpression) diff --git a/src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.cs b/src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.cs index 92736474a3c..3bab5a75895 100644 --- a/src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.cs +++ b/src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.cs @@ -416,9 +416,14 @@ protected override Expression VisitExtension(Expression extensionExpression) { if (!_variableShaperMapping.TryGetValue(entityShaperExpression.ValueBufferExpression, out var accessor)) { - if (GetProjectionIndex(projectionBindingExpression) is ValueTuple, string[]> + if (GetProjectionIndex(projectionBindingExpression) is ValueTuple, string[], int> jsonProjectionIndex) { + if (_isTracking && jsonProjectionIndex.Item4 > 0) + { + throw new InvalidOperationException(RelationalStrings.ProjectingJsonCollectionElementRequiresNoTracking); + } + // json entity at the root var (jsonElementParameter, keyValuesParameter) = JsonShapingPreProcess( jsonProjectionIndex, @@ -510,8 +515,13 @@ protected override Expression VisitExtension(Expression extensionExpression) case CollectionResultExpression collectionResultExpression when collectionResultExpression.Navigation is INavigation navigation && GetProjectionIndex(collectionResultExpression.ProjectionBindingExpression) - is ValueTuple, string[]> jsonProjectionIndex: + is ValueTuple, string[], int> jsonProjectionIndex: { + if (_isTracking && jsonProjectionIndex.Item4 > 0) + { + throw new InvalidOperationException(RelationalStrings.ProjectingJsonCollectionElementRequiresNoTracking); + } + // json entity collection at the root var (jsonElementParameter, keyValuesParameter) = JsonShapingPreProcess( jsonProjectionIndex, @@ -781,7 +791,7 @@ when collectionResultExpression.Navigation is INavigation navigation // json include case if (projectionBindingExpression != null - && GetProjectionIndex(projectionBindingExpression) is ValueTuple, string[]> + && GetProjectionIndex(projectionBindingExpression) is ValueTuple, string[], int> jsonProjectionIndex) { var (jsonElementParameter, keyValuesParameter) = JsonShapingPreProcess( @@ -1236,16 +1246,17 @@ private Expression CreateJsonShapers( } private (ParameterExpression, ParameterExpression) JsonShapingPreProcess( - ValueTuple, string[]> projectionIndex, + ValueTuple, string[], int> projectionIndex, IEntityType entityType, bool isCollection) { var jsonColumnProjectionIndex = projectionIndex.Item1; var keyInfo = projectionIndex.Item2; var additionalPath = projectionIndex.Item3; + var specifiedCollectionIndexesCount = projectionIndex.Item4; var keyValuesParameter = Expression.Parameter(typeof(object[])); - var keyValues = new Expression[keyInfo.Count]; + var keyValues = new Expression[keyInfo.Count + specifiedCollectionIndexesCount]; for (var i = 0; i < keyInfo.Count; i++) { @@ -1262,6 +1273,12 @@ private Expression CreateJsonShapers( typeof(object)); } + for (var i = 0; i < specifiedCollectionIndexesCount; i++) + { + keyValues[keyInfo.Count + i] = Expression.Convert( + Expression.Constant(0), typeof(object)); + } + var keyValuesInitialize = Expression.NewArrayInit(typeof(object), keyValues); var keyValuesAssignment = Expression.Assign(keyValuesParameter, keyValuesInitialize); diff --git a/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs b/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs index bf688dac9ec..8ebd14a03b3 100644 --- a/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs +++ b/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs @@ -1529,15 +1529,15 @@ Expression CopyProjectionToOuter(SelectExpression innerSelectExpression, Express remappedConstant = Constant(newDictionary); } - else if (constantValue is ValueTuple>, string[]> tuple) + else if (constantValue is ValueTuple, string[], int> tuple) { - var newList = new List>(); + var newList = new List<(IProperty, int)>(); foreach (var item in tuple.Item2) { newList.Add((item.Item1, projectionIndexMap[item.Item2])); } - remappedConstant = Constant((projectionIndexMap[tuple.Item1], newList, tuple.Item3)); + remappedConstant = Constant((projectionIndexMap[tuple.Item1], newList, tuple.Item3, tuple.Item4)); } else { @@ -1590,7 +1590,8 @@ static Dictionary BuildJsonProjection { var ordered = projections .OrderBy(x => $"{x.JsonColumn.TableAlias}.{x.JsonColumn.Name}") - .ThenBy(x => x.Path.Count); + .ThenBy(x => x.Path.Count) + .ThenBy(x => x.Path[^1].ArrayIndex != null); var needed = new List(); foreach (var orderedElement in ordered) @@ -1638,12 +1639,15 @@ ConstantExpression AddEntityProjection(EntityProjectionExpression entityProjecti ConstantExpression AddJsonProjection(JsonQueryExpression jsonQueryExpression, JsonScalarExpression jsonScalarToAdd) { - var additionalPath = new string[0]; + var additionalPath = Array.Empty(); + + // TODO: change this when implementing #29513 + // deduplication doesn't happen currently if the additional path contains indexes + Debug.Assert(jsonQueryExpression.Path.Skip(jsonScalarToAdd.Path.Count).All(x => x.PropertyName != null)); - // this will be more tricky once we support more complicated json path options additionalPath = jsonQueryExpression.Path .Skip(jsonScalarToAdd.Path.Count) - .Select(x => x.Key) + .Select(x => x.PropertyName!) .ToArray(); var jsonColumnIndex = AddToProjection(jsonScalarToAdd); @@ -1656,7 +1660,9 @@ ConstantExpression AddJsonProjection(JsonQueryExpression jsonQueryExpression, Js keyInfo.Add((keyProperty, AddToProjection(keyColumn))); } - return Constant((jsonColumnIndex, keyInfo, additionalPath)); + var specifiedCollectionIndexesCount = jsonScalarToAdd.Path.Count(x => x.ArrayIndex != null); + + return Constant((jsonColumnIndex, keyInfo, additionalPath, specifiedCollectionIndexesCount)); } static IReadOnlyList GetMappedKeyProperties(IKey key) @@ -1697,7 +1703,15 @@ static bool JsonEntityContainedIn(JsonScalarExpression sourceExpression, JsonQue return false; } - return sourcePath.SequenceEqual(targetPath.Take(sourcePath.Count)); + if (!sourcePath.SequenceEqual(targetPath.Take(sourcePath.Count))) + { + return false; + } + + // we can only perform deduplication if there additional path doesn't contain any collection indexes + // collection indexes can only be part of the source path + // see issue #29513 + return targetPath.Skip(sourcePath.Count).All(x => x.ArrayIndex == null); } } diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs index bfac93e291b..ec08ad38f62 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs @@ -321,7 +321,36 @@ protected override Expression VisitJsonScalar(JsonScalarExpression jsonScalarExp Visit(jsonScalarExpression.JsonColumn); - Sql.Append($",'{string.Join("", jsonScalarExpression.Path.Select(e => e.ToString()))}')"); + Sql.Append(",'"); + foreach (var pathSegment in jsonScalarExpression.Path) + { + if (pathSegment.PropertyName != null) + { + Sql.Append((pathSegment.PropertyName == "$" ? "" : ".") + pathSegment.PropertyName); + } + + if (pathSegment.ArrayIndex != null) + { + Sql.Append("["); + + if (pathSegment.ArrayIndex is SqlConstantExpression) + { + Visit(pathSegment.ArrayIndex); + } + else + { + Sql.Append("' + CAST("); + Visit(pathSegment.ArrayIndex); + Sql.Append(" AS "); + Sql.Append(_typeMappingSource.GetMapping(typeof(string)).StoreType); + Sql.Append(") + '"); + } + + Sql.Append("]"); + } + } + + Sql.Append("')"); if (jsonScalarExpression.TypeMapping is not SqlServerJsonTypeMapping and not StringTypeMapping) { diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs index 1e27cda4b69..9a96f61066b 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Linq.Expressions; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal; @@ -68,28 +69,10 @@ protected override Expression VisitBinary(BinaryExpression binaryExpression) if (binaryExpression.NodeType == ExpressionType.ArrayIndex && binaryExpression.Left.Type == typeof(byte[])) { - var left = Visit(binaryExpression.Left); - var right = Visit(binaryExpression.Right); - - if (left is SqlExpression leftSql - && right is SqlExpression rightSql) - { - return Dependencies.SqlExpressionFactory.Convert( - Dependencies.SqlExpressionFactory.Function( - "SUBSTRING", - new[] - { - leftSql, - Dependencies.SqlExpressionFactory.Add( - Dependencies.SqlExpressionFactory.ApplyDefaultTypeMapping(rightSql), - Dependencies.SqlExpressionFactory.Constant(1)), - Dependencies.SqlExpressionFactory.Constant(1) - }, - nullable: true, - argumentsPropagateNullability: new[] { true, true, true }, - typeof(byte[])), - binaryExpression.Type); - } + return TranslateByteArrayElementAccess( + binaryExpression.Left, + binaryExpression.Right, + binaryExpression.Type); } var visitedExpression = base.VisitBinary(binaryExpression); @@ -152,6 +135,52 @@ protected override Expression VisitUnary(UnaryExpression unaryExpression) return base.VisitUnary(unaryExpression); } + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override Expression VisitMethodCall(MethodCallExpression methodCallExpression) + { + if (methodCallExpression is MethodCallExpression { Method: MethodInfo { IsGenericMethod: true } } genericMethodCall + && genericMethodCall.Method.GetGenericMethodDefinition() == EnumerableMethods.ElementAt + && genericMethodCall.Arguments[0].Type == typeof(byte[])) + { + return TranslateByteArrayElementAccess( + genericMethodCall.Arguments[0], + genericMethodCall.Arguments[1], + methodCallExpression.Type); + } + + return base.VisitMethodCall(methodCallExpression); + } + + private Expression TranslateByteArrayElementAccess(Expression array, Expression index, Type resultType) + { + var visitedArray = Visit(array); + var visitedIndex = Visit(index); + + return visitedArray is SqlExpression sqlArray + && visitedIndex is SqlExpression sqlIndex + ? Dependencies.SqlExpressionFactory.Convert( + Dependencies.SqlExpressionFactory.Function( + "SUBSTRING", + new[] + { + sqlArray, + Dependencies.SqlExpressionFactory.Add( + Dependencies.SqlExpressionFactory.ApplyDefaultTypeMapping(sqlIndex), + Dependencies.SqlExpressionFactory.Constant(1)), + Dependencies.SqlExpressionFactory.Constant(1) + }, + nullable: true, + argumentsPropagateNullability: new[] { true, true, true }, + typeof(byte[])), + resultType) + : QueryCompilationContext.NotTranslatedExpression; + } + private static string? GetProviderType(SqlExpression expression) => expression.TypeMapping?.StoreType; } diff --git a/test/EFCore.Relational.Specification.Tests/Query/JsonQueryAdHocTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/JsonQueryAdHocTestBase.cs index ae2442bd052..ce63c8289e2 100644 --- a/test/EFCore.Relational.Specification.Tests/Query/JsonQueryAdHocTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Query/JsonQueryAdHocTestBase.cs @@ -13,6 +13,8 @@ protected JsonQueryAdHocTestBase(ITestOutputHelper testOutputHelper) protected override string StoreName => "JsonQueryAdHocTest"; + #region 29219 + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public virtual async Task Optional_json_properties_materialized_as_null_when_the_element_in_json_is_not_present(bool async) @@ -83,4 +85,189 @@ public class MyJsonEntity29219 public int NonNullableScalar { get; set; } public int? NullableScalar { get; set; } } + + #endregion + + #region ArrayOfPrimitives + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Project_json_array_of_primitives_on_reference(bool async) + { + var contextFactory = await InitializeAsync(seed: SeedArrayOfPrimitives); + using (var context = contextFactory.CreateContext()) + { + var query = context.Entities.OrderBy(x => x.Id).Select(x => new { x.Reference.IntArray, x.Reference.ListOfString }); + + var result = async + ? await query.ToListAsync() + : query.ToList(); + + Assert.Equal(2, result.Count); + Assert.Equal(3, result[0].IntArray.Length); + Assert.Equal(3, result[0].ListOfString.Count); + Assert.Equal(3, result[1].IntArray.Length); + Assert.Equal(3, result[1].ListOfString.Count); + } + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Project_json_array_of_primitives_on_collection(bool async) + { + var contextFactory = await InitializeAsync(seed: SeedArrayOfPrimitives); + using (var context = contextFactory.CreateContext()) + { + var query = context.Entities.OrderBy(x => x.Id).Select(x => new { x.Collection[0].IntArray, x.Collection[1].ListOfString }); + + var result = async + ? await query.ToListAsync() + : query.ToList(); + + Assert.Equal(2, result.Count); + Assert.Equal(3, result[0].IntArray.Length); + Assert.Equal(2, result[0].ListOfString.Count); + Assert.Equal(3, result[1].IntArray.Length); + Assert.Equal(2, result[1].ListOfString.Count); + } + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Project_element_of_json_array_of_primitives(bool async) + { + var contextFactory = await InitializeAsync(seed: SeedArrayOfPrimitives); + using (var context = contextFactory.CreateContext()) + { + var query = context.Entities.OrderBy(x => x.Id).Select(x => new + { + ArrayElement = x.Reference.IntArray[0], + ListElement = x.Reference.ListOfString[1] + }); + + var result = async + ? await query.ToListAsync() + : query.ToList(); + } + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Predicate_based_on_element_of_json_array_of_primitives1(bool async) + { + var contextFactory = await InitializeAsync(seed: SeedArrayOfPrimitives); + using (var context = contextFactory.CreateContext()) + { + var query = context.Entities.Where(x => x.Reference.IntArray[0] == 1); + + if (async) + { + await Assert.ThrowsAsync(() => query.ToListAsync()); + } + else + { + Assert.Throws(() => query.ToList()); + } + } + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Predicate_based_on_element_of_json_array_of_primitives2(bool async) + { + var contextFactory = await InitializeAsync(seed: SeedArrayOfPrimitives); + using (var context = contextFactory.CreateContext()) + { + var query = context.Entities.Where(x => x.Reference.ListOfString[1] == "Bar"); + + if (async) + { + await Assert.ThrowsAsync(() => query.ToListAsync()); + } + else + { + Assert.Throws(() => query.ToList()); + } + } + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Predicate_based_on_element_of_json_array_of_primitives3(bool async) + { + var contextFactory = await InitializeAsync(seed: SeedArrayOfPrimitives); + using (var context = contextFactory.CreateContext()) + { + var query = context.Entities.Where(x => x.Reference.IntArray.AsQueryable().ElementAt(0) == 1 + || x.Reference.ListOfString.AsQueryable().ElementAt(1) == "Bar"); + + if (async) + { + await Assert.ThrowsAsync(() => query.ToListAsync()); + } + else + { + Assert.Throws(() => query.ToList()); + } + } + } + + + protected abstract void SeedArrayOfPrimitives(MyContextArrayOfPrimitives ctx); + + protected class MyContextArrayOfPrimitives : DbContext + { + public MyContextArrayOfPrimitives(DbContextOptions options) + : base(options) + { + } + + public DbSet Entities { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().Property(x => x.Id).ValueGeneratedNever(); + modelBuilder.Entity().OwnsOne(x => x.Reference, b => + { + b.ToJson(); + b.Property(x => x.IntArray).HasConversion( + x => string.Join(" ", x), + x => x.Split(" ", StringSplitOptions.None).Select(v => int.Parse(v)).ToArray(), + new ValueComparer(true)); + + b.Property(x => x.ListOfString).HasConversion( + x => string.Join(" ", x), + x => x.Split(" ", StringSplitOptions.None).ToList(), + new ValueComparer>(true)); + }); + + modelBuilder.Entity().OwnsMany(x => x.Collection, b => + { + b.ToJson(); + b.Property(x => x.IntArray).HasConversion( + x => string.Join(" ", x), + x => x.Split(" ", StringSplitOptions.None).Select(v => int.Parse(v)).ToArray(), + new ValueComparer(true)); + b.Property(x => x.ListOfString).HasConversion( + x => string.Join(" ", x), + x => x.Split(" ", StringSplitOptions.None).ToList(), + new ValueComparer>(true)); + }); + } + } + + public class MyEntityArrayOfPrimitives + { + public int Id { get; set; } + public MyJsonEntityArrayOfPrimitives Reference { get; set; } + public List Collection { get; set; } + } + + public class MyJsonEntityArrayOfPrimitives + { + public int[] IntArray { get; set; } + public List ListOfString { get; set; } + } + + #endregion } diff --git a/test/EFCore.Relational.Specification.Tests/Query/JsonQueryTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/JsonQueryTestBase.cs index dc1ff2e3076..16a09aa36b1 100644 --- a/test/EFCore.Relational.Specification.Tests/Query/JsonQueryTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Query/JsonQueryTestBase.cs @@ -112,7 +112,8 @@ public virtual Task Basic_json_projection_enum_inside_json_entity(bool async) ss => ss.Set().Select( x => new { - x.Id, x.OwnedReferenceRoot.OwnedReferenceBranch.Enum, + x.Id, + x.OwnedReferenceRoot.OwnedReferenceBranch.Enum, }), elementSorter: e => e.Id, elementAsserter: (e, a) => @@ -632,15 +633,482 @@ public virtual Task Json_entity_backtracking(bool async) public virtual Task Json_collection_element_access_in_projection_basic(bool async) => AssertQuery( async, - ss => ss.Set().Select(x => x.OwnedCollectionRoot[0]).AsNoTracking()); + ss => ss.Set().Select(x => x.OwnedCollectionRoot[1]).AsNoTracking()); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_in_projection_using_ElementAt(bool async) + => AssertQuery( + async, + ss => ss.Set().Select(x => x.OwnedCollectionRoot.AsQueryable().ElementAt(1)).AsNoTracking()); - [ConditionalTheory(Skip = "issue #28648")] + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_predicate(bool async) + public virtual Task Json_collection_element_access_in_projection_using_ElementAtOrDefault(bool async) + => AssertQuery( + async, + ss => ss.Set().Select(x => x.OwnedCollectionRoot.AsQueryable().ElementAtOrDefault(1)).AsNoTracking()); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_in_projection_project_collection(bool async) + => AssertQuery( + async, + ss => ss.Set().Select(x => x.OwnedCollectionRoot[1].OwnedCollectionBranch).AsNoTracking(), + elementAsserter: (e, a) => AssertCollection(e, a, ordered: true)); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_in_projection_using_ElementAt_project_collection(bool async) + => AssertQuery( + async, + ss => ss.Set() + .Select(x => x.OwnedCollectionRoot.AsQueryable().ElementAt(1).OwnedCollectionBranch) + .AsNoTracking(), + elementAsserter: (e, a) => AssertCollection(e, a, ordered: true)); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_in_projection_using_ElementAtOrDefault_project_collection(bool async) + => AssertQuery( + async, + ss => ss.Set() + .Select(x => x.OwnedCollectionRoot.AsQueryable().ElementAtOrDefault(1).OwnedCollectionBranch) + .AsNoTracking(), + elementAsserter: (e, a) => AssertCollection(e, a, ordered: true)); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_in_projection_using_parameter(bool async) + { + var prm = 0; + + return AssertQuery( + async, + ss => ss.Set().Select(x => x.OwnedCollectionRoot[prm]).AsNoTracking()); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_in_projection_using_column(bool async) + => AssertQuery( + async, + ss => ss.Set().Select(x => x.OwnedCollectionRoot[x.Id]).AsNoTracking()); + + private static int MyMethod(int value) + => value; + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Json_collection_element_access_in_projection_using_untranslatable_client_method(bool async) + { + var message = (await Assert.ThrowsAsync( + () => AssertQuery( + async, + ss => ss.Set().Select(x => x.OwnedCollectionRoot[MyMethod(x.Id)]).AsNoTracking()))).Message; + + Assert.Equal( + CoreStrings.TranslationFailed("JsonQueryExpression(j.OwnedCollectionRoot, $)"), + message); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Json_collection_element_access_in_projection_using_untranslatable_client_method2(bool async) + { + var message = (await Assert.ThrowsAsync( + () => AssertQuery( + async, + ss => ss.Set().Select(x => x.OwnedCollectionRoot[0].OwnedReferenceBranch.OwnedCollectionLeaf[MyMethod(x.Id)]).AsNoTracking()))).Message; + + Assert.Equal( + CoreStrings.TranslationFailed("JsonQueryExpression(j.OwnedCollectionRoot, $.[0].OwnedReferenceBranch.OwnedCollectionLeaf)"), + message); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_outside_bounds(bool async) + => AssertQuery( + async, + ss => ss.Set().Select(x => x.OwnedCollectionRoot[25]).AsNoTracking(), + ss => ss.Set().Select(x => (JsonOwnedRoot)null)); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_outside_bounds_with_property_access(bool async) + => AssertQueryScalar( + async, + ss => ss.Set().OrderBy(x => x.Id).Select(x => (int?)x.OwnedCollectionRoot[25].Number), + ss => ss.Set().Select(x => (int?)null)); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_in_projection_nested(bool async) + { + var prm = 1; + + return AssertQuery( + async, + ss => ss.Set().Select(x => x.OwnedCollectionRoot[0].OwnedCollectionBranch[prm]).AsNoTracking()); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_in_projection_nested_project_scalar(bool async) + { + var prm = 1; + + return AssertQueryScalar( + async, + ss => ss.Set().Select(x => x.OwnedCollectionRoot[0].OwnedCollectionBranch[prm].Date)); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_in_projection_nested_project_reference(bool async) + { + var prm = 1; + + return AssertQuery( + async, + ss => ss.Set().Select(x => x.OwnedCollectionRoot[0].OwnedCollectionBranch[prm].OwnedReferenceLeaf).AsNoTracking()); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_in_projection_nested_project_collection(bool async) + { + var prm = 1; + + return AssertQuery( + async, + ss => ss.Set() + .OrderBy(x => x.Id) + .Select(x => x.OwnedCollectionRoot[0].OwnedCollectionBranch[prm].OwnedCollectionLeaf) + .AsNoTracking(), + assertOrder: true, + elementAsserter: (e, a) => AssertCollection(e, a, ordered: true)); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_in_projection_nested_project_collection_anonymous_projection(bool async) + { + var prm = 1; + + return AssertQuery( + async, + ss => ss.Set() + .Select(x => new { x.Id, x.OwnedCollectionRoot[0].OwnedCollectionBranch[prm].OwnedCollectionLeaf }) + .AsNoTracking(), + elementSorter: e => e.Id, + elementAsserter: (e, a) => + { + Assert.Equal(e.Id, a.Id); + AssertCollection(e.OwnedCollectionLeaf, a.OwnedCollectionLeaf, ordered: true); + }); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_in_predicate_using_constant(bool async) => AssertQueryScalar( async, ss => ss.Set().Where(x => x.OwnedCollectionRoot[0].Name != "Foo").Select(x => x.Id)); + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_in_predicate_using_variable(bool async) + { + var prm = 1; + + return AssertQueryScalar( + async, + ss => ss.Set().Where(x => x.OwnedCollectionRoot[prm].Name != "Foo").Select(x => x.Id)); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_in_predicate_using_column(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(x => x.OwnedCollectionRoot[x.Id].Name == "e1_c2").Select(x => new { x.Id, x }), + elementSorter: e => e.Id, + elementAsserter: (e, a) => + { + Assert.Equal(e.Id, a.Id); + AssertEqual(e.x, a.x); + }, + entryCount: 40); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_in_predicate_using_complex_expression1(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(x => x.OwnedCollectionRoot[x.Id == 1 ? 0 : 1].Name == "e1_c1").Select(x => new { x.Id, x }), + elementSorter: e => e.Id, + elementAsserter: (e, a) => + { + Assert.Equal(e.Id, a.Id); + AssertEqual(e.x, a.x); + }, + entryCount: 40); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_in_predicate_using_complex_expression2(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(x => x.OwnedCollectionRoot[ss.Set().Max(x => x.Id)].Name == "e1_c2").Select(x => new { x.Id, x }), + elementSorter: e => e.Id, + elementAsserter: (e, a) => + { + Assert.Equal(e.Id, a.Id); + AssertEqual(e.x, a.x); + }, + entryCount: 40); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_in_predicate_using_ElementAt(bool async) + => AssertQueryScalar( + async, + ss => ss.Set().Where(x => x.OwnedCollectionRoot.AsQueryable().ElementAt(1).Name != "Foo").Select(x => x.Id)); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_in_predicate_nested_mix(bool async) + { + var prm = 0; + + return AssertQuery( + async, + ss => ss.Set().Where(x => x.OwnedCollectionRoot[1].OwnedCollectionBranch[prm].OwnedCollectionLeaf[x.Id - 1].SomethingSomething == "e1_c2_c1_c1"), + entryCount: 40); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_element_access_manual_Element_at_and_pushdown(bool async) + => AssertQuery( + async, + ss => ss.Set().Select(x => new + { + x.Id, + CollectionElement = x.OwnedCollectionRoot.Select(xx => xx.Number).ElementAt(0) + })); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative(bool async) + { + var prm = 0; + var message = (await Assert.ThrowsAsync( + () => AssertQuery( + async, + ss => ss.Set().Select(x => new + { + x.Id, + CollectionElement = x.OwnedCollectionRoot[prm].OwnedCollectionBranch.Select(xx => "Foo").ElementAt(0) + })))).Message; + + Assert.Equal(CoreStrings.TranslationFailed("JsonQueryExpression(j.OwnedCollectionRoot, $.[__prm_0].OwnedCollectionBranch)"), message); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative2(bool async) + { + var prm = 0; + var message = (await Assert.ThrowsAsync( + () => AssertQuery( + async, + ss => ss.Set().Select(x => new + { + x.Id, + CollectionElement = x.OwnedCollectionRoot[prm + x.Id].OwnedCollectionBranch.Select(xx => x.Id).ElementAt(0) + })))).Message; + + Assert.Equal(CoreStrings.TranslationFailed("JsonQueryExpression(j.OwnedCollectionRoot, $.[(...)].OwnedCollectionBranch)"), message); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative3(bool async) + { + var message = (await Assert.ThrowsAsync( + () => AssertQuery( + async, + ss => ss.Set().Select(x => new + { + x.Id, + CollectionElement = x.OwnedCollectionRoot.Select(xx => x.OwnedReferenceRoot).ElementAt(0) + })))).Message; + + Assert.Equal(CoreStrings.TranslationFailed("JsonQueryExpression(j.OwnedCollectionRoot, $)"), message); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative4(bool async) + { + var message = (await Assert.ThrowsAsync( + () => AssertQuery( + async, + ss => ss.Set().Select(x => new + { + x.Id, + CollectionElement = x.OwnedCollectionRoot.Select(xx => x.OwnedCollectionRoot).ElementAt(0) + })))).Message; + + Assert.Equal(CoreStrings.TranslationFailed("JsonQueryExpression(j.OwnedCollectionRoot, $)"), message); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative5(bool async) + { + var message = (await Assert.ThrowsAsync( + () => AssertQuery( + async, + ss => ss.Set().Select(x => new + { + x.Id, + CollectionElement = x.OwnedCollectionRoot.Select(xx => new { xx.OwnedReferenceBranch }).ElementAt(0) + })))).Message; + + Assert.Equal(CoreStrings.TranslationFailed("JsonQueryExpression(j.OwnedCollectionRoot, $)"), message); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative6(bool async) + { + var message = (await Assert.ThrowsAsync( + () => AssertQuery( + async, + ss => ss.Set().Select(x => new + { + x.Id, + CollectionElement = x.OwnedCollectionRoot.Select(xx => new JsonEntityBasic { Id = x.Id }).ElementAt(0) + })))).Message; + + Assert.Equal(CoreStrings.TranslationFailed("JsonQueryExpression(j.OwnedCollectionRoot, $)"), message); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_projection_deduplication_with_collection_indexer_in_original(bool async) + => AssertQuery( + async, + ss => ss.Set().Select(x => new + { + x.Id, + Duplicate1 = x.OwnedCollectionRoot[0].OwnedReferenceBranch, + Original = x.OwnedCollectionRoot[0], + Duplicate2 = x.OwnedCollectionRoot[0].OwnedReferenceBranch.OwnedCollectionLeaf + }).AsNoTracking(), + elementSorter: e => e.Id, + elementAsserter: (e, a) => + { + AssertEqual(e.Id, a.Id); + AssertEqual(e.Original, a.Original); + AssertEqual(e.Duplicate1, a.Duplicate1); + AssertCollection(e.Duplicate2, a.Duplicate2, ordered: true); + }); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_projection_deduplication_with_collection_indexer_in_target(bool async) + { + var prm = 1; + + // issue #29513 + return AssertQuery( + async, + ss => ss.Set().Select(x => new + { + x.Id, + Duplicate1 = x.OwnedReferenceRoot.OwnedCollectionBranch[1], + Original = x.OwnedReferenceRoot, + Duplicate2 = x.OwnedReferenceRoot.OwnedReferenceBranch.OwnedCollectionLeaf[prm] + }).AsNoTracking(), + elementSorter: e => e.Id, + elementAsserter: (e, a) => + { + AssertEqual(e.Id, a.Id); + AssertEqual(e.Original, a.Original); + AssertEqual(e.Duplicate1, a.Duplicate1); + AssertEqual(e.Duplicate2, a.Duplicate2); + }); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_projection_deduplication_with_collection_in_original_and_collection_indexer_in_target(bool async) + // issue #29513 + => AssertQuery( + async, + ss => ss.Set().Select(x => new + { + x.Id, + Original = x.OwnedReferenceRoot.OwnedCollectionBranch, + Duplicate = x.OwnedReferenceRoot.OwnedCollectionBranch[1], + }).AsNoTracking(), + elementSorter: e => e.Id, + elementAsserter: (e, a) => + { + AssertEqual(e.Id, a.Id); + AssertCollection(e.Original, a.Original, ordered: true); + AssertEqual(e.Duplicate, a.Duplicate); + }); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Json_collection_element_access_in_projection_requires_NoTracking_even_if_owner_is_present(bool async) + { + var exception = await Assert.ThrowsAsync( + () => AssertQuery( + async, + ss => ss.Set().Select(x => new + { + x, + CollectionElement = x.OwnedCollectionRoot[1] + }), + elementSorter: e => e.x.Id, + elementAsserter: (e, a) => + { + AssertEqual(e.x, a.x); + AssertEqual(e.CollectionElement, a.CollectionElement); + })); + + Assert.Equal(RelationalStrings.ProjectingJsonCollectionElementRequiresNoTracking, exception.Message); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Json_collection_element_access_in_projection_requires_NoTracking_even_if_owner_is_present2(bool async) + { + var message = (await Assert.ThrowsAsync( + () => AssertQuery( + async, + ss => ss.Set().Select(x => new + { + x, + CollectionElement = x.OwnedCollectionRoot[1].OwnedReferenceBranch + }), + elementSorter: e => e.x.Id, + elementAsserter: (e, a) => + { + AssertEqual(e.x, a.x); + AssertEqual(e.CollectionElement, a.CollectionElement); + }))).Message; + + Assert.Equal(RelationalStrings.ProjectingJsonCollectionElementRequiresNoTracking, message); + } + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public virtual Task Json_scalar_required_null_semantics(bool async) @@ -667,6 +1135,15 @@ public virtual Task Group_by_on_json_scalar(bool async) ss => ss.Set() .GroupBy(x => x.OwnedReferenceRoot.Name).Select(x => new { x.Key, Count = x.Count() })); + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Group_by_on_json_scalar_using_collection_indexer(bool async) + => AssertQuery( + async, + ss => ss.Set() + .GroupBy(x => x.OwnedCollectionRoot[0].Name).Select(x => new { x.Key, Count = x.Count() })); + + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public virtual Task Group_by_First_on_json_scalar(bool async) diff --git a/test/EFCore.Specification.Tests/Query/GearsOfWarQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/GearsOfWarQueryTestBase.cs index adb2a61365c..2cf7861456f 100644 --- a/test/EFCore.Specification.Tests/Query/GearsOfWarQueryTestBase.cs +++ b/test/EFCore.Specification.Tests/Query/GearsOfWarQueryTestBase.cs @@ -8224,6 +8224,20 @@ public virtual Task Where_subquery_with_ElementAt_using_column_as_index(bool asy ss => ss.Set().Where(s => s.Members.OrderBy(m => m.Nickname).ElementAt(s.Id).Nickname == "Cole Train"), ss => ss.Set().Where(s => s.Members.OrderBy(m => m.Nickname).ElementAtOrDefault(s.Id).Nickname == "Cole Train")); + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Using_indexer_on_byte_array_and_string_in_projection(bool async) + => AssertQuery( + async, + ss => ss.Set().Select(x => new { x.Id, ByteArray = x.Banner[0], String = x.Name[1] }), + elementSorter: e => e.Id, + elementAsserter: (e, a) => + { + Assert.Equal(e.Id, a.Id); + Assert.Equal(e.ByteArray, a.ByteArray); + Assert.Equal(e.String, a.String); + }); + protected GearsOfWarContext CreateContext() => Fixture.CreateContext(); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/GearsOfWarQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/GearsOfWarQuerySqlServerTest.cs index b5d77fa52af..97fc8406506 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/GearsOfWarQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/GearsOfWarQuerySqlServerTest.cs @@ -10032,6 +10032,17 @@ ORDER BY [g].[Nickname] """); } + public override async Task Using_indexer_on_byte_array_and_string_in_projection(bool async) + { + await base.Using_indexer_on_byte_array_and_string_in_projection(async); + + AssertSql( +""" +SELECT [s].[Id], CAST(SUBSTRING([s].[Banner], 0 + 1, 1) AS tinyint), [s].[Name] +FROM [Squads] AS [s] +"""); + } + private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); } diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/JsonQueryAdHocSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/JsonQueryAdHocSqlServerTest.cs index 1095b9c192a..b6e05e9bd97 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/JsonQueryAdHocSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/JsonQueryAdHocSqlServerTest.cs @@ -43,4 +43,56 @@ protected override void Seed29219(MyContext29219 ctx) ctx.Database.ExecuteSqlRaw(@"INSERT INTO [Entities] ([Id], [Reference], [Collection]) VALUES(3, N'{{ ""NonNullableScalar"" : 30 }}', N'[{{ ""NonNullableScalar"" : 10001 }}]')"); } + + protected override void SeedArrayOfPrimitives(MyContextArrayOfPrimitives ctx) + { + var entity1 = new MyEntityArrayOfPrimitives + { + Id = 1, + Reference = new MyJsonEntityArrayOfPrimitives + { + IntArray = new int[] { 1, 2, 3 }, + ListOfString = new List { "Foo", "Bar", "Baz" } + }, + Collection = new List + { + new MyJsonEntityArrayOfPrimitives + { + IntArray = new int[] { 111, 112, 113 }, + ListOfString = new List { "Foo11", "Bar11" } + }, + new MyJsonEntityArrayOfPrimitives + { + IntArray = new int[] { 211, 212, 213 }, + ListOfString = new List { "Foo12", "Bar12" } + }, + } + }; + + var entity2 = new MyEntityArrayOfPrimitives + { + Id = 2, + Reference = new MyJsonEntityArrayOfPrimitives + { + IntArray = new int[] { 10, 20, 30 }, + ListOfString = new List { "A", "B", "C" } + }, + Collection = new List + { + new MyJsonEntityArrayOfPrimitives + { + IntArray = new int[] { 110, 120, 130 }, + ListOfString = new List { "A1", "Z1" } + }, + new MyJsonEntityArrayOfPrimitives + { + IntArray = new int[] { 210, 220, 230 }, + ListOfString = new List { "A2", "Z2" } + }, + } + }; + + ctx.Entities.AddRange(entity1, entity2); + ctx.SaveChanges(); + } } diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/JsonQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/JsonQuerySqlServerTest.cs index 2470fcd8b92..3b5cd9e219c 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/JsonQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/JsonQuerySqlServerTest.cs @@ -584,20 +584,401 @@ public override async Task Json_collection_element_access_in_projection_basic(bo { await base.Json_collection_element_access_in_projection_basic(async); - // array element access in projection is currently done on the client - issue 28648 AssertSql( """ -SELECT [j].[OwnedCollectionRoot], [j].[Id] +SELECT JSON_QUERY([j].[OwnedCollectionRoot],'$[1]'), [j].[Id] FROM [JsonEntitiesBasic] AS [j] """); } - public override async Task Json_collection_element_access_in_predicate(bool async) + public override async Task Json_collection_element_access_in_projection_using_ElementAt(bool async) { - await base.Json_collection_element_access_in_predicate(async); + await base.Json_collection_element_access_in_projection_using_ElementAt(async); AssertSql( - @""); +""" +SELECT JSON_QUERY([j].[OwnedCollectionRoot],'$[1]'), [j].[Id] +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + public override async Task Json_collection_element_access_in_projection_using_ElementAtOrDefault(bool async) + { + await base.Json_collection_element_access_in_projection_using_ElementAtOrDefault(async); + + AssertSql( +""" +SELECT JSON_QUERY([j].[OwnedCollectionRoot],'$[1]'), [j].[Id] +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + public override async Task Json_collection_element_access_in_projection_project_collection(bool async) + { + await base.Json_collection_element_access_in_projection_project_collection(async); + + AssertSql( +""" +SELECT JSON_QUERY([j].[OwnedCollectionRoot],'$[1].OwnedCollectionBranch'), [j].[Id] +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + public override async Task Json_collection_element_access_in_projection_using_ElementAt_project_collection(bool async) + { + await base.Json_collection_element_access_in_projection_using_ElementAt_project_collection(async); + + AssertSql( +""" +SELECT JSON_QUERY([j].[OwnedCollectionRoot],'$[1].OwnedCollectionBranch'), [j].[Id] +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + public override async Task Json_collection_element_access_in_projection_using_ElementAtOrDefault_project_collection(bool async) + { + await base.Json_collection_element_access_in_projection_using_ElementAtOrDefault_project_collection(async); + + AssertSql( +""" +SELECT JSON_QUERY([j].[OwnedCollectionRoot],'$[1].OwnedCollectionBranch'), [j].[Id] +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] + public override async Task Json_collection_element_access_in_projection_using_parameter(bool async) + { + await base.Json_collection_element_access_in_projection_using_parameter(async); + + AssertSql( +""" +@__prm_0='0' + +SELECT JSON_QUERY([j].[OwnedCollectionRoot],'$[' + CAST(@__prm_0 AS nvarchar(max)) + ']'), [j].[Id] +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] + public override async Task Json_collection_element_access_in_projection_using_column(bool async) + { + await base.Json_collection_element_access_in_projection_using_column(async); + + AssertSql( +""" +SELECT JSON_QUERY([j].[OwnedCollectionRoot],'$[' + CAST([j].[Id] AS nvarchar(max)) + ']'), [j].[Id] +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + public override async Task Json_collection_element_access_in_projection_using_untranslatable_client_method(bool async) + { + await base.Json_collection_element_access_in_projection_using_untranslatable_client_method(async); + + AssertSql(); + } + + public override async Task Json_collection_element_access_in_projection_using_untranslatable_client_method2(bool async) + { + await base.Json_collection_element_access_in_projection_using_untranslatable_client_method2(async); + + AssertSql(); + } + + public override async Task Json_collection_element_access_outside_bounds(bool async) + { + await base.Json_collection_element_access_outside_bounds(async); + + AssertSql( +""" +SELECT JSON_QUERY([j].[OwnedCollectionRoot],'$[25]'), [j].[Id] +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + public override async Task Json_collection_element_access_outside_bounds_with_property_access(bool async) + { + await base.Json_collection_element_access_outside_bounds_with_property_access(async); + + AssertSql( +""" +SELECT CAST(JSON_VALUE([j].[OwnedCollectionRoot],'$[25].Number') AS int) +FROM [JsonEntitiesBasic] AS [j] +ORDER BY [j].[Id] +"""); + } + + [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] + public override async Task Json_collection_element_access_in_projection_nested(bool async) + { + await base.Json_collection_element_access_in_projection_nested(async); + + AssertSql( +""" +@__prm_0='1' + +SELECT JSON_QUERY([j].[OwnedCollectionRoot],'$[0].OwnedCollectionBranch[' + CAST(@__prm_0 AS nvarchar(max)) + ']'), [j].[Id] +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] + public override async Task Json_collection_element_access_in_projection_nested_project_scalar(bool async) + { + await base.Json_collection_element_access_in_projection_nested_project_scalar(async); + + AssertSql( +""" +@__prm_0='1' + +SELECT CAST(JSON_VALUE([j].[OwnedCollectionRoot],'$[0].OwnedCollectionBranch[' + CAST(@__prm_0 AS nvarchar(max)) + '].Date') AS datetime2) +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] + public override async Task Json_collection_element_access_in_projection_nested_project_reference(bool async) + { + await base.Json_collection_element_access_in_projection_nested_project_reference(async); + + AssertSql( +""" +@__prm_0='1' + +SELECT JSON_QUERY([j].[OwnedCollectionRoot],'$[0].OwnedCollectionBranch[' + CAST(@__prm_0 AS nvarchar(max)) + '].OwnedReferenceLeaf'), [j].[Id] +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] + public override async Task Json_collection_element_access_in_projection_nested_project_collection(bool async) + { + await base.Json_collection_element_access_in_projection_nested_project_collection(async); + + AssertSql( +""" +@__prm_0='1' + +SELECT JSON_QUERY([j].[OwnedCollectionRoot],'$[0].OwnedCollectionBranch[' + CAST(@__prm_0 AS nvarchar(max)) + '].OwnedCollectionLeaf'), [j].[Id] +FROM [JsonEntitiesBasic] AS [j] +ORDER BY [j].[Id] +"""); + } + + [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] + public override async Task Json_collection_element_access_in_projection_nested_project_collection_anonymous_projection(bool async) + { + await base.Json_collection_element_access_in_projection_nested_project_collection_anonymous_projection(async); + + AssertSql( +""" +@__prm_0='1' + +SELECT [j].[Id], JSON_QUERY([j].[OwnedCollectionRoot],'$[0].OwnedCollectionBranch[' + CAST(@__prm_0 AS nvarchar(max)) + '].OwnedCollectionLeaf') +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + public override async Task Json_collection_element_access_in_predicate_using_constant(bool async) + { + await base.Json_collection_element_access_in_predicate_using_constant(async); + + AssertSql( +""" +SELECT [j].[Id] +FROM [JsonEntitiesBasic] AS [j] +WHERE JSON_VALUE([j].[OwnedCollectionRoot],'$[0].Name') <> N'Foo' OR (JSON_VALUE([j].[OwnedCollectionRoot],'$[0].Name') IS NULL) +"""); + } + + [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] + public override async Task Json_collection_element_access_in_predicate_using_variable(bool async) + { + await base.Json_collection_element_access_in_predicate_using_variable(async); + + AssertSql( +""" +@__prm_0='1' + +SELECT [j].[Id] +FROM [JsonEntitiesBasic] AS [j] +WHERE JSON_VALUE([j].[OwnedCollectionRoot],'$[' + CAST(@__prm_0 AS nvarchar(max)) + '].Name') <> N'Foo' OR (JSON_VALUE([j].[OwnedCollectionRoot],'$[' + CAST(@__prm_0 AS nvarchar(max)) + '].Name') IS NULL) +"""); + } + + [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] + public override async Task Json_collection_element_access_in_predicate_using_column(bool async) + { + await base.Json_collection_element_access_in_predicate_using_column(async); + + AssertSql( +""" +SELECT [j].[Id], [j].[EntityBasicId], [j].[Name], [j].[OwnedCollectionRoot], [j].[OwnedReferenceRoot] +FROM [JsonEntitiesBasic] AS [j] +WHERE JSON_VALUE([j].[OwnedCollectionRoot],'$[' + CAST([j].[Id] AS nvarchar(max)) + '].Name') = N'e1_c2' +"""); + } + + [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] + public override async Task Json_collection_element_access_in_predicate_using_complex_expression1(bool async) + { + await base.Json_collection_element_access_in_predicate_using_complex_expression1(async); + + AssertSql( +""" +SELECT [j].[Id], [j].[EntityBasicId], [j].[Name], [j].[OwnedCollectionRoot], [j].[OwnedReferenceRoot] +FROM [JsonEntitiesBasic] AS [j] +WHERE JSON_VALUE([j].[OwnedCollectionRoot],'$[' + CAST(CASE + WHEN [j].[Id] = 1 THEN 0 + ELSE 1 +END AS nvarchar(max)) + '].Name') = N'e1_c1' +"""); + } + + [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] + public override async Task Json_collection_element_access_in_predicate_using_complex_expression2(bool async) + { + await base.Json_collection_element_access_in_predicate_using_complex_expression2(async); + + AssertSql( +""" +SELECT [j].[Id], [j].[EntityBasicId], [j].[Name], [j].[OwnedCollectionRoot], [j].[OwnedReferenceRoot] +FROM [JsonEntitiesBasic] AS [j] +WHERE JSON_VALUE([j].[OwnedCollectionRoot],'$[' + CAST(( + SELECT MAX([j].[Id]) + FROM [JsonEntitiesBasic] AS [j]) AS nvarchar(max)) + '].Name') = N'e1_c2' +"""); + } + + public override async Task Json_collection_element_access_in_predicate_using_ElementAt(bool async) + { + await base.Json_collection_element_access_in_predicate_using_ElementAt(async); + + AssertSql( +""" +SELECT [j].[Id] +FROM [JsonEntitiesBasic] AS [j] +WHERE JSON_VALUE([j].[OwnedCollectionRoot],'$[1].Name') <> N'Foo' OR (JSON_VALUE([j].[OwnedCollectionRoot],'$[1].Name') IS NULL) +"""); + } + + [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] + public override async Task Json_collection_element_access_in_predicate_nested_mix(bool async) + { + await base.Json_collection_element_access_in_predicate_nested_mix(async); + + AssertSql( +""" +@__prm_0='0' + +SELECT [j].[Id], [j].[EntityBasicId], [j].[Name], [j].[OwnedCollectionRoot], [j].[OwnedReferenceRoot] +FROM [JsonEntitiesBasic] AS [j] +WHERE JSON_VALUE([j].[OwnedCollectionRoot],'$[1].OwnedCollectionBranch[' + CAST(@__prm_0 AS nvarchar(max)) + '].OwnedCollectionLeaf[' + CAST([j].[Id] - 1 AS nvarchar(max)) + '].SomethingSomething') = N'e1_c2_c1_c1' +"""); + } + + public override async Task Json_collection_element_access_manual_Element_at_and_pushdown(bool async) + { + await base.Json_collection_element_access_manual_Element_at_and_pushdown(async); + + AssertSql( +""" +SELECT [j].[Id], CAST(JSON_VALUE([j].[OwnedCollectionRoot],'$[0].Number') AS int) AS [CollectionElement] +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + public override async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative(bool async) + { + await base.Json_collection_element_access_manual_Element_at_and_pushdown_negative(async); + + AssertSql(); + } + + public override async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative2(bool async) + { + await base.Json_collection_element_access_manual_Element_at_and_pushdown_negative2(async); + + AssertSql(); + } + + public override async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative3(bool async) + { + await base.Json_collection_element_access_manual_Element_at_and_pushdown_negative3(async); + + AssertSql(); + } + + public override async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative4(bool async) + { + await base.Json_collection_element_access_manual_Element_at_and_pushdown_negative4(async); + + AssertSql(); + } + + public override async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative5(bool async) + { + await base.Json_collection_element_access_manual_Element_at_and_pushdown_negative5(async); + + AssertSql(); + } + + public override async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative6(bool async) + { + await base.Json_collection_element_access_manual_Element_at_and_pushdown_negative6(async); + + AssertSql(); + } + + public override async Task Json_projection_deduplication_with_collection_indexer_in_original(bool async) + { + await base.Json_projection_deduplication_with_collection_indexer_in_original(async); + + AssertSql( +""" +SELECT [j].[Id], JSON_QUERY([j].[OwnedCollectionRoot],'$[0]') +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] + public override async Task Json_projection_deduplication_with_collection_indexer_in_target(bool async) + { + await base.Json_projection_deduplication_with_collection_indexer_in_target(async); + + AssertSql( +""" +@__prm_0='1' + +SELECT [j].[Id], JSON_QUERY([j].[OwnedReferenceRoot],'$.OwnedCollectionBranch[1]'), [j].[OwnedReferenceRoot], JSON_QUERY([j].[OwnedReferenceRoot],'$.OwnedReferenceBranch.OwnedCollectionLeaf[' + CAST(@__prm_0 AS nvarchar(max)) + ']') +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + public override async Task Json_projection_deduplication_with_collection_in_original_and_collection_indexer_in_target(bool async) + { + await base.Json_projection_deduplication_with_collection_in_original_and_collection_indexer_in_target(async); + + AssertSql( +""" +SELECT [j].[Id], JSON_QUERY([j].[OwnedReferenceRoot],'$.OwnedCollectionBranch'), JSON_QUERY([j].[OwnedReferenceRoot],'$.OwnedCollectionBranch[1]') +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + public override async Task Json_collection_element_access_in_projection_requires_NoTracking_even_if_owner_is_present(bool async) + { + await base.Json_collection_element_access_in_projection_requires_NoTracking_even_if_owner_is_present(async); + + AssertSql(); + } + + public override async Task Json_collection_element_access_in_projection_requires_NoTracking_even_if_owner_is_present2(bool async) + { + await base.Json_collection_element_access_in_projection_requires_NoTracking_even_if_owner_is_present2(async); + + AssertSql(); } public override async Task Json_scalar_required_null_semantics(bool async) @@ -639,6 +1020,21 @@ GROUP BY [t].[Key] """); } + public override async Task Group_by_on_json_scalar_using_collection_indexer(bool async) + { + await base.Group_by_on_json_scalar_using_collection_indexer(async); + + AssertSql( +""" +SELECT [t].[Key], COUNT(*) AS [Count] +FROM ( + SELECT JSON_VALUE([j].[OwnedCollectionRoot],'$[0].Name') AS [Key] + FROM [JsonEntitiesBasic] AS [j] +) AS [t] +GROUP BY [t].[Key] +"""); + } + public override async Task Group_by_First_on_json_scalar(bool async) { await base.Group_by_First_on_json_scalar(async); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindCompiledQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindCompiledQuerySqlServerTest.cs index 9e878a78a4d..b6d6df1c188 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindCompiledQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindCompiledQuerySqlServerTest.cs @@ -387,7 +387,9 @@ FROM [Customers] AS [c] public override void MakeBinary_does_not_throw_for_unsupported_operator() => Assert.Equal( - CoreStrings.TranslationFailed("DbSet() .Where(c => c.CustomerID == (string)(__parameters[0]))"), + CoreStrings.TranslationFailedWithDetails( + "DbSet() .Where(c => c.CustomerID == (string)__parameters .ElementAt(0))", + CoreStrings.QueryUnableToTranslateMethod("System.Linq.Enumerable", nameof(Enumerable.ElementAt))), Assert.Throws( () => base.MakeBinary_does_not_throw_for_unsupported_operator()).Message.Replace("\r", "").Replace("\n", "")); @@ -400,7 +402,9 @@ public override void Query_with_array_parameter() using (var context = CreateContext()) { Assert.Equal( - CoreStrings.TranslationFailed("DbSet() .Where(c => c.CustomerID == __args[0])"), + CoreStrings.TranslationFailedWithDetails( + "DbSet() .Where(c => c.CustomerID == __args .ElementAt(0))", + CoreStrings.QueryUnableToTranslateMethod("System.Linq.Enumerable", nameof(Enumerable.ElementAt))), Assert.Throws( () => query(context, new[] { "ALFKI" }).First().CustomerID).Message.Replace("\r", "").Replace("\n", "")); } @@ -408,7 +412,9 @@ public override void Query_with_array_parameter() using (var context = CreateContext()) { Assert.Equal( - CoreStrings.TranslationFailed("DbSet() .Where(c => c.CustomerID == __args[0])"), + CoreStrings.TranslationFailedWithDetails( + "DbSet() .Where(c => c.CustomerID == __args .ElementAt(0))", + CoreStrings.QueryUnableToTranslateMethod("System.Linq.Enumerable", nameof(Enumerable.ElementAt))), Assert.Throws( () => query(context, new[] { "ANATR" }).First().CustomerID).Message.Replace("\r", "").Replace("\n", "")); } @@ -423,7 +429,9 @@ public override async Task Query_with_array_parameter_async() using (var context = CreateContext()) { Assert.Equal( - CoreStrings.TranslationFailed("DbSet() .Where(c => c.CustomerID == __args[0])"), + CoreStrings.TranslationFailedWithDetails( + "DbSet() .Where(c => c.CustomerID == __args .ElementAt(0))", + CoreStrings.QueryUnableToTranslateMethod("System.Linq.Enumerable", nameof(Enumerable.ElementAt))), (await Assert.ThrowsAsync( () => Enumerate(query(context, new[] { "ALFKI" })))).Message.Replace("\r", "").Replace("\n", "")); } @@ -431,7 +439,9 @@ public override async Task Query_with_array_parameter_async() using (var context = CreateContext()) { Assert.Equal( - CoreStrings.TranslationFailed("DbSet() .Where(c => c.CustomerID == __args[0])"), + CoreStrings.TranslationFailedWithDetails( + "DbSet() .Where(c => c.CustomerID == __args .ElementAt(0))", + CoreStrings.QueryUnableToTranslateMethod("System.Linq.Enumerable", nameof(Enumerable.ElementAt))), (await Assert.ThrowsAsync( () => Enumerate(query(context, new[] { "ANATR" })))).Message.Replace("\r", "").Replace("\n", "")); } diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/TPCGearsOfWarQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/TPCGearsOfWarQuerySqlServerTest.cs index 648539cedde..c3281b75bf2 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/TPCGearsOfWarQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/TPCGearsOfWarQuerySqlServerTest.cs @@ -13126,6 +13126,17 @@ ORDER BY [t].[Nickname] """); } + public override async Task Using_indexer_on_byte_array_and_string_in_projection(bool async) + { + await base.Using_indexer_on_byte_array_and_string_in_projection(async); + + AssertSql( +""" +SELECT [s].[Id], CAST(SUBSTRING([s].[Banner], 0 + 1, 1) AS tinyint), [s].[Name] +FROM [Squads] AS [s] +"""); + } + private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); } diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/TPTGearsOfWarQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/TPTGearsOfWarQuerySqlServerTest.cs index 94fc8da2bf6..71d9c0fccde 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/TPTGearsOfWarQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/TPTGearsOfWarQuerySqlServerTest.cs @@ -11288,6 +11288,17 @@ ORDER BY [g].[Nickname] """); } + public override async Task Using_indexer_on_byte_array_and_string_in_projection(bool async) + { + await base.Using_indexer_on_byte_array_and_string_in_projection(async); + + AssertSql( +""" +SELECT [s].[Id], CAST(SUBSTRING([s].[Banner], 0 + 1, 1) AS tinyint), [s].[Name] +FROM [Squads] AS [s] +"""); + } + private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); } diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/TemporalGearsOfWarQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/TemporalGearsOfWarQuerySqlServerTest.cs index e26fc12d9d5..2081b1e7282 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/TemporalGearsOfWarQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/TemporalGearsOfWarQuerySqlServerTest.cs @@ -9862,6 +9862,17 @@ ORDER BY [g].[Nickname] """); } + public override async Task Using_indexer_on_byte_array_and_string_in_projection(bool async) + { + await base.Using_indexer_on_byte_array_and_string_in_projection(async); + + AssertSql( +""" +SELECT [s].[Id], CAST(SUBSTRING([s].[Banner], 0 + 1, 1) AS tinyint), [s].[Name] +FROM [Squads] FOR SYSTEM_TIME AS OF '2010-01-01T00:00:00.0000000' AS [s] +"""); + } + private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); } diff --git a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerCondition.cs b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerCondition.cs index c1fa32d4e62..64f7ba6ecce 100644 --- a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerCondition.cs +++ b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerCondition.cs @@ -17,5 +17,6 @@ public enum SqlServerCondition SupportsTemporalTablesCascadeDelete = 1 << 8, SupportsUtf8 = 1 << 9, SupportsFunctions2019 = 1 << 10, - SupportsFunctions2017 = 1 << 11 + SupportsFunctions2017 = 1 << 11, + SupportsJsonPathExpressions = 1 << 12, } diff --git a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerConditionAttribute.cs b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerConditionAttribute.cs index c4f50b7552d..11aea373e8a 100644 --- a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerConditionAttribute.cs +++ b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerConditionAttribute.cs @@ -82,6 +82,11 @@ public ValueTask IsMetAsync() isMet &= TestEnvironment.IsFunctions2017Supported; } + if (Conditions.HasFlag(SqlServerCondition.SupportsJsonPathExpressions)) + { + isMet &= TestEnvironment.SupportsJsonPathExpressions; + } + return new ValueTask(isMet); } diff --git a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/TestEnvironment.cs b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/TestEnvironment.cs index 2320b24b7bb..206b9170cc7 100644 --- a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/TestEnvironment.cs +++ b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/TestEnvironment.cs @@ -43,6 +43,8 @@ public static class TestEnvironment private static bool? _supportsFunctions2019; + private static bool? _supportsJsonPathExpressions; + private static byte? _productMajorVersion; private static int? _engineEdition; @@ -327,6 +329,35 @@ public static bool IsFunctions2019Supported } } + public static bool SupportsJsonPathExpressions + { + get + { + if (!IsConfigured) + { + return false; + } + + if (_supportsJsonPathExpressions.HasValue) + { + return _supportsJsonPathExpressions.Value; + } + + try + { + _productMajorVersion = GetProductMajorVersion(); + + _supportsJsonPathExpressions = _productMajorVersion >= 14 || IsSqlAzure; + } + catch (PlatformNotSupportedException) + { + _supportsJsonPathExpressions = false; + } + + return _supportsJsonPathExpressions.Value; + } + } + public static byte SqlServerMajorVersion => GetProductMajorVersion(); diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/GearsOfWarQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/GearsOfWarQuerySqliteTest.cs index 4dbe4b83639..22881f07e98 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/GearsOfWarQuerySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/GearsOfWarQuerySqliteTest.cs @@ -9433,6 +9433,17 @@ ORDER BY "g"."Nickname" """); } + public override async Task Using_indexer_on_byte_array_and_string_in_projection(bool async) + { + await base.Using_indexer_on_byte_array_and_string_in_projection(async); + + AssertSql( +""" +SELECT "s"."Id", "s"."Banner", "s"."Name" +FROM "Squads" AS "s" +"""); + } + private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); } diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/NorthwindCompiledQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/NorthwindCompiledQuerySqliteTest.cs index f330b1f8e8f..efb82dd7f20 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/NorthwindCompiledQuerySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/NorthwindCompiledQuerySqliteTest.cs @@ -14,7 +14,9 @@ public NorthwindCompiledQuerySqliteTest(NorthwindQuerySqliteFixture Assert.Equal( - CoreStrings.TranslationFailed("DbSet() .Where(c => c.CustomerID == (string)(__parameters[0]))"), + CoreStrings.TranslationFailedWithDetails( + "DbSet() .Where(c => c.CustomerID == (string)__parameters .ElementAt(0))", + CoreStrings.QueryUnableToTranslateMethod("System.Linq.Enumerable", nameof(Enumerable.ElementAt))), Assert.Throws( () => base.MakeBinary_does_not_throw_for_unsupported_operator()).Message.Replace("\r", "").Replace("\n", "")); @@ -27,7 +29,9 @@ public override void Query_with_array_parameter() using (var context = CreateContext()) { Assert.Equal( - CoreStrings.TranslationFailed("DbSet() .Where(c => c.CustomerID == __args[0])"), + CoreStrings.TranslationFailedWithDetails( + "DbSet() .Where(c => c.CustomerID == __args .ElementAt(0))", + CoreStrings.QueryUnableToTranslateMethod("System.Linq.Enumerable", nameof(Enumerable.ElementAt))), Assert.Throws( () => query(context, new[] { "ALFKI" }).First().CustomerID).Message.Replace("\r", "").Replace("\n", "")); } @@ -35,7 +39,9 @@ public override void Query_with_array_parameter() using (var context = CreateContext()) { Assert.Equal( - CoreStrings.TranslationFailed("DbSet() .Where(c => c.CustomerID == __args[0])"), + CoreStrings.TranslationFailedWithDetails( + "DbSet() .Where(c => c.CustomerID == __args .ElementAt(0))", + CoreStrings.QueryUnableToTranslateMethod("System.Linq.Enumerable", nameof(Enumerable.ElementAt))), Assert.Throws( () => query(context, new[] { "ANATR" }).First().CustomerID).Message.Replace("\r", "").Replace("\n", "")); } @@ -50,7 +56,9 @@ public override async Task Query_with_array_parameter_async() using (var context = CreateContext()) { Assert.Equal( - CoreStrings.TranslationFailed("DbSet() .Where(c => c.CustomerID == __args[0])"), + CoreStrings.TranslationFailedWithDetails( + "DbSet() .Where(c => c.CustomerID == __args .ElementAt(0))", + CoreStrings.QueryUnableToTranslateMethod("System.Linq.Enumerable", nameof(Enumerable.ElementAt))), (await Assert.ThrowsAsync( () => Enumerate(query(context, new[] { "ALFKI" })))).Message.Replace("\r", "").Replace("\n", "")); } @@ -58,7 +66,9 @@ public override async Task Query_with_array_parameter_async() using (var context = CreateContext()) { Assert.Equal( - CoreStrings.TranslationFailed("DbSet() .Where(c => c.CustomerID == __args[0])"), + CoreStrings.TranslationFailedWithDetails( + "DbSet() .Where(c => c.CustomerID == __args .ElementAt(0))", + CoreStrings.QueryUnableToTranslateMethod("System.Linq.Enumerable", nameof(Enumerable.ElementAt))), (await Assert.ThrowsAsync( () => Enumerate(query(context, new[] { "ANATR" })))).Message.Replace("\r", "").Replace("\n", "")); }