From 7a572b0d7b8c17bb6425e75583f5836dbe818d36 Mon Sep 17 00:00:00 2001 From: Maurycy Markowski Date: Tue, 1 Aug 2023 05:14:07 -0700 Subject: [PATCH] Support querying over non-primitive JSON collections (#31369) Closes #28616 Co-authored-by: Shay Rojansky --- All.sln.DotSettings | 1 + .../Properties/RelationalStrings.Designer.cs | 20 + .../Properties/RelationalStrings.resx | 9 + .../Query/JsonQueryExpression.cs | 37 +- ...yableMethodTranslatingExpressionVisitor.cs | 72 +- ...sitor.ShaperProcessingExpressionVisitor.cs | 32 +- .../Query/SqlExpressions/SelectExpression.cs | 202 +++++- .../TableValuedFunctionExpression.cs | 16 +- .../Internal/SqlServerJsonPostprocessor.cs | 275 ++++++++ .../Internal/SqlServerOpenJsonExpression.cs | 137 +++- .../Internal/SqlServerQuerySqlGenerator.cs | 85 ++- .../SqlServerQueryTranslationPostprocessor.cs | 162 +---- ...yableMethodTranslatingExpressionVisitor.cs | 133 +++- .../Query/Internal/SqliteQuerySqlGenerator.cs | 79 ++- ...yableMethodTranslatingExpressionVisitor.cs | 224 +++++- .../Internal/JsonEachExpression.cs | 185 +++++ .../Query/JsonQueryFixtureBase.cs | 19 +- .../Query/JsonQueryTestBase.cs | 485 +++++++++---- .../PrimitiveCollectionsQueryTestBase.cs | 35 +- .../Query/JsonQuerySqlServerTest.cs | 642 +++++++++++++++--- ...dPrimitiveCollectionsQuerySqlServerTest.cs | 2 +- ...imitiveCollectionsQueryOldSqlServerTest.cs | 16 + .../PrimitiveCollectionsQuerySqlServerTest.cs | 30 + .../Query/JsonQuerySqliteTest.cs | 184 ++++- .../PrimitiveCollectionsQuerySqliteTest.cs | 30 + 25 files changed, 2576 insertions(+), 536 deletions(-) create mode 100644 src/EFCore.SqlServer/Query/Internal/SqlServerJsonPostprocessor.cs create mode 100644 src/EFCore.Sqlite.Core/Query/SqlExpressions/Internal/JsonEachExpression.cs diff --git a/All.sln.DotSettings b/All.sln.DotSettings index 8ed3bce7378..5d6edfea13f 100644 --- a/All.sln.DotSettings +++ b/All.sln.DotSettings @@ -299,6 +299,7 @@ The .NET Foundation licenses this file to you under the MIT license. True True True + True True True True diff --git a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs index 65b4b33eb2f..a78aed237c8 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs +++ b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs @@ -1033,6 +1033,14 @@ public static string JsonEntityMappedToDifferentViewThanOwner(object? jsonType, GetString("JsonEntityMappedToDifferentViewThanOwner", nameof(jsonType), nameof(viewName), nameof(ownerType), nameof(ownerViewName)), jsonType, viewName, ownerType, ownerViewName); + /// + /// Multiple owned root entities are mapped to the same JSON column '{column}' in table '{table}'. Each owned root entity must map to a different column. + /// + public static string JsonEntityMissingKeyInformation(object? jsonEntity) + => string.Format( + GetString("JsonEntityMissingKeyInformation", nameof(jsonEntity)), + jsonEntity); + /// /// Multiple owned root entities are mapped to the same JSON column '{column}' in table '{table}'. Each owned root entity must map to a different column. /// @@ -1155,6 +1163,12 @@ public static string JsonReaderInvalidTokenType(object? tokenType) GetString("JsonReaderInvalidTokenType", nameof(tokenType)), tokenType); + /// + /// Composing LINQ operators over collections inside JSON documents isn't supported or hasn't been implemented by your EF provider. + /// + public static string JsonQueryLinqOperatorsNotSupported + => GetString("JsonQueryLinqOperatorsNotSupported"); + /// /// Entity {entity} is required but the JSON element containing it is null. /// @@ -1503,6 +1517,12 @@ public static string ReadonlyEntitySaved(object? entityType) public static string RelationalNotInUse => GetString("RelationalNotInUse"); + /// + /// SelectExpression can only be built over a JsonQueryExpression that represents a collection within the JSON document. + /// + public static string SelectCanOnlyBeBuiltOnCollectionJsonQuery + => GetString("SelectCanOnlyBeBuiltOnCollectionJsonQuery"); + /// /// Cannot create a 'SelectExpression' with a custom 'TableExpressionBase' since the result type '{entityType}' is part of a hierarchy and does not contain a discriminator property. /// diff --git a/src/EFCore.Relational/Properties/RelationalStrings.resx b/src/EFCore.Relational/Properties/RelationalStrings.resx index 014bea98e58..6ca3e9b0d28 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.resx +++ b/src/EFCore.Relational/Properties/RelationalStrings.resx @@ -505,6 +505,9 @@ Entity '{jsonType}' is mapped to JSON and also to a view '{viewName}', but its owner '{ownerType}' is mapped to a different view '{ownerViewName}'. Every entity mapped to JSON must also map to the same view as its owner. + + JSON entity '{jsonEntity}' is missing key information. This is not allowed for tracking queries since EF can't correctly build identity for this entity object. + Multiple owned root entities are mapped to the same JSON column '{column}' in table '{table}'. Each owned root entity must map to a different column. @@ -553,6 +556,9 @@ Invalid token type: '{tokenType}'. + + Composing LINQ operators over collections inside JSON documents isn't supported or hasn't been implemented by your EF provider. + Entity {entity} is required but the JSON element containing it is null. @@ -989,6 +995,9 @@ Relational-specific methods can only be used when the context is using a relational database provider. + + SelectExpression can only be built over a JsonQueryExpression that represents a collection within the JSON document. + Cannot create a 'SelectExpression' with a custom 'TableExpressionBase' since the result type '{entityType}' is part of a hierarchy and does not contain a discriminator property. diff --git a/src/EFCore.Relational/Query/JsonQueryExpression.cs b/src/EFCore.Relational/Query/JsonQueryExpression.cs index 3d35d28eb7e..1db6b039d1a 100644 --- a/src/EFCore.Relational/Query/JsonQueryExpression.cs +++ b/src/EFCore.Relational/Query/JsonQueryExpression.cs @@ -16,8 +16,6 @@ namespace Microsoft.EntityFrameworkCore.Query; /// public class JsonQueryExpression : Expression, IPrintableExpression { - private readonly IReadOnlyDictionary _keyPropertyMap; - /// /// Creates a new instance of the class. /// @@ -57,7 +55,7 @@ private JsonQueryExpression( EntityType = entityType; JsonColumn = jsonColumn; IsCollection = collection; - _keyPropertyMap = keyPropertyMap; + KeyPropertyMap = keyPropertyMap; Type = type; Path = path; IsNullable = nullable; @@ -88,6 +86,15 @@ private JsonQueryExpression( /// public virtual bool IsNullable { get; } + /// + /// 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. + /// + [EntityFrameworkInternal] + public virtual IReadOnlyDictionary KeyPropertyMap { get; } + /// public override ExpressionType NodeType => ExpressionType.Extension; @@ -107,7 +114,7 @@ public virtual SqlExpression BindProperty(IProperty property) RelationalStrings.UnableToBindMemberToEntityProjection("property", property.Name, EntityType.DisplayName())); } - if (_keyPropertyMap.TryGetValue(property, out var match)) + if (KeyPropertyMap.TryGetValue(property, out var match)) { return match; } @@ -145,11 +152,11 @@ public virtual JsonQueryExpression BindNavigation(INavigation navigation) newPath.Add(new PathSegment(targetEntityType.GetJsonPropertyName()!)); var newKeyPropertyMap = new Dictionary(); - var targetPrimaryKeyProperties = targetEntityType.FindPrimaryKey()!.Properties.Take(_keyPropertyMap.Count); - var sourcePrimaryKeyProperties = EntityType.FindPrimaryKey()!.Properties.Take(_keyPropertyMap.Count); + var targetPrimaryKeyProperties = targetEntityType.FindPrimaryKey()!.Properties.Take(KeyPropertyMap.Count); + var sourcePrimaryKeyProperties = EntityType.FindPrimaryKey()!.Properties.Take(KeyPropertyMap.Count); foreach (var (target, source) in targetPrimaryKeyProperties.Zip(sourcePrimaryKeyProperties, (t, s) => (t, s))) { - newKeyPropertyMap[target] = _keyPropertyMap[source]; + newKeyPropertyMap[target] = KeyPropertyMap[source]; } return new JsonQueryExpression( @@ -178,7 +185,7 @@ public virtual JsonQueryExpression BindCollectionElement(SqlExpression collectio return new JsonQueryExpression( EntityType, JsonColumn, - _keyPropertyMap, + KeyPropertyMap, newPath, EntityType.ClrType, collection: false, @@ -194,7 +201,7 @@ public virtual JsonQueryExpression BindCollectionElement(SqlExpression collectio public virtual JsonQueryExpression MakeNullable() { var keyPropertyMap = new Dictionary(); - foreach (var (property, columnExpression) in _keyPropertyMap) + foreach (var (property, columnExpression) in KeyPropertyMap) { keyPropertyMap[property] = columnExpression.MakeNullable(); } @@ -223,7 +230,7 @@ protected override Expression VisitChildren(ExpressionVisitor visitor) { var jsonColumn = (ColumnExpression)visitor.Visit(JsonColumn); var newKeyPropertyMap = new Dictionary(); - foreach (var (property, column) in _keyPropertyMap) + foreach (var (property, column) in KeyPropertyMap) { newKeyPropertyMap[property] = (ColumnExpression)visitor.Visit(column); } @@ -242,8 +249,8 @@ public virtual JsonQueryExpression Update( ColumnExpression jsonColumn, IReadOnlyDictionary keyPropertyMap) => jsonColumn != JsonColumn - || keyPropertyMap.Count != _keyPropertyMap.Count - || keyPropertyMap.Zip(_keyPropertyMap, (n, o) => n.Value != o.Value).Any(x => x) + || keyPropertyMap.Count != KeyPropertyMap.Count + || keyPropertyMap.Zip(KeyPropertyMap, (n, o) => n.Value != o.Value).Any(x => x) ? new JsonQueryExpression(EntityType, jsonColumn, keyPropertyMap, Path, Type, IsCollection, IsNullable) : this; @@ -260,16 +267,16 @@ private bool Equals(JsonQueryExpression jsonQueryExpression) && IsCollection.Equals(jsonQueryExpression.IsCollection) && IsNullable == jsonQueryExpression.IsNullable && Path.SequenceEqual(jsonQueryExpression.Path) - && KeyPropertyMapEquals(jsonQueryExpression._keyPropertyMap); + && KeyPropertyMapEquals(jsonQueryExpression.KeyPropertyMap); private bool KeyPropertyMapEquals(IReadOnlyDictionary other) { - if (_keyPropertyMap.Count != other.Count) + if (KeyPropertyMap.Count != other.Count) { return false; } - foreach (var (key, value) in _keyPropertyMap) + foreach (var (key, value) in KeyPropertyMap) { if (!other.TryGetValue(key, out var column) || !value.Equals(column)) { diff --git a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs index 7ae4d0e782a..6d80af4e073 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.Expressions; using Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.EntityFrameworkCore.Query.Internal; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; @@ -223,6 +224,9 @@ when entityQueryRootExpression.GetType() == typeof(EntityQueryRootExpression) char.ToLowerInvariant(sqlParameterExpression.Name.First(c => c != '_')).ToString()) ?? base.VisitExtension(extensionExpression); + case JsonQueryExpression jsonQueryExpression: + return TransformJsonQueryToTable(jsonQueryExpression) ?? base.VisitExtension(extensionExpression); + default: return base.VisitExtension(extensionExpression); } @@ -326,6 +330,19 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp string tableAlias) => null; + /// + /// Invoked when LINQ operators are composed over a collection within a JSON document. + /// Transforms the provided - representing access to the collection - into a provider-specific + /// means to expand the JSON array into a relational table/rowset (e.g. SQL Server OPENJSON). + /// + /// The referencing the JSON array. + /// A if the translation was successful, otherwise . + protected virtual ShapedQueryExpression? TransformJsonQueryToTable(JsonQueryExpression jsonQueryExpression) + { + AddTranslationErrorDetails(RelationalStrings.JsonQueryLinqOperatorsNotSupported); + return null; + } + /// /// Translates an inline collection into a queryable SQL VALUES expression. /// @@ -606,9 +623,9 @@ protected override ShapedQueryExpression TranslateConcat(ShapedQueryExpression s protected override ShapedQueryExpression TranslateDistinct(ShapedQueryExpression source) { var selectExpression = (SelectExpression)source.QueryExpression; - if (selectExpression.Orderings.Count > 0 - && selectExpression.Limit == null - && selectExpression.Offset == null) + + if (selectExpression is { Orderings.Count: > 0, Limit: null, Offset: null } + && !IsNaturallyOrdered(selectExpression)) { _queryCompilationContext.Logger.DistinctAfterOrderByWithoutRowLimitingOperatorWarning(); } @@ -1862,6 +1879,16 @@ protected virtual Expression ApplyInferredTypeMappings( protected virtual bool IsOrdered(SelectExpression selectExpression) => selectExpression.Orderings.Count > 0; + /// + /// Determines whether the given is naturally ordered, meaning that any ordering has been added + /// automatically by EF to preserve e.g. the natural ordering of a JSON array, and not because the original LINQ query contained + /// an explicit ordering. + /// + /// The to check for ordering. + /// Whether is ordered. + protected virtual bool IsNaturallyOrdered(SelectExpression selectExpression) + => false; + private Expression RemapLambdaBody(ShapedQueryExpression shapedQueryExpression, LambdaExpression lambdaExpression) { var lambdaBody = ReplacingExpressionVisitor.Replace( @@ -1923,11 +1950,10 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp source = Visit(source); return TryExpand(source, MemberIdentity.Create(navigationName)) + ?? TryBindPrimitiveCollection(source, navigationName) ?? 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)) @@ -2251,6 +2277,42 @@ static TableExpressionBase FindRootTableExpressionForColumn(ColumnExpression col } } + private Expression? TryBindPrimitiveCollection(Expression? source, string memberName) + { + while (source is IncludeExpression includeExpression) + { + source = includeExpression.EntityExpression; + } + + source = source.UnwrapTypeConversion(out var convertedType); + if (source is not EntityShaperExpression entityShaperExpression) + { + return null; + } + + var entityType = entityShaperExpression.EntityType; + if (convertedType != null) + { + entityType = entityType.GetRootType().GetDerivedTypesInclusive() + .FirstOrDefault(et => et.ClrType == convertedType); + + if (entityType == null) + { + return null; + } + } + + // TODO: Check that the property is a primitive collection property directly once we have that in metadata, rather than + // looking at the type mapping. + var property = entityType.FindProperty(memberName); + if (property?.GetRelationalTypeMapping().ElementTypeMapping is null) + { + return null; + } + + return source.CreateEFPropertyExpression(property); + } + private sealed class AnnotationApplyingExpressionVisitor : ExpressionVisitor { private readonly IReadOnlyList _annotations; diff --git a/src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.cs b/src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.cs index 2482ed30958..722a47075a9 100644 --- a/src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.cs +++ b/src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.cs @@ -502,7 +502,8 @@ protected override Expression VisitExtension(Expression extensionExpression) // json entity at the root var (jsonReaderDataVariable, keyValuesParameter) = JsonShapingPreProcess( jsonProjectionInfo, - entityShaperExpression.EntityType); + entityShaperExpression.EntityType, + isCollection: false); var shaperResult = CreateJsonShapers( entityShaperExpression.EntityType, @@ -594,7 +595,8 @@ when GetProjectionIndex(collectionResultExpression.ProjectionBindingExpression) // json entity collection at the root var (jsonReaderDataVariable, keyValuesParameter) = JsonShapingPreProcess( jsonProjectionInfo, - navigation.TargetEntityType); + navigation.TargetEntityType, + isCollection: true); var shaperResult = CreateJsonShapers( navigation.TargetEntityType, @@ -870,7 +872,8 @@ when GetProjectionIndex(collectionResultExpression.ProjectionBindingExpression) { var (jsonReaderDataVariable, keyValuesParameter) = JsonShapingPreProcess( jsonProjectionInfo, - includeExpression.Navigation.TargetEntityType); + includeExpression.Navigation.TargetEntityType, + includeExpression.Navigation.IsCollection); var shaperResult = CreateJsonShapers( includeExpression.Navigation.TargetEntityType, @@ -1967,7 +1970,8 @@ private static IList PopulateList(IList buffer, IList target) private (ParameterExpression, ParameterExpression) JsonShapingPreProcess( JsonProjectionInfo jsonProjectionInfo, - IEntityType entityType) + IEntityType entityType, + bool isCollection) { var jsonColumnName = entityType.GetContainerColumnName()!; var jsonColumnTypeMapping = (entityType.GetViewOrTableMappings().SingleOrDefault()?.Table @@ -2018,7 +2022,17 @@ private static IList PopulateList(IList buffer, IList target) _expressions.Add(jsonReaderDataAssignment); _expressions.Add(jsonReaderManagerBlock); - var keyValues = new Expression[jsonProjectionInfo.KeyAccessInfo.Count]; + // we should have keyAccessInfo for every PK property of the entity, unless we are generating shaper for the collection + // in that case the final key property will be synthesized in the shaper code + var expectedKeyValuesCount = entityType.FindPrimaryKey()!.Properties.Count - (isCollection ? 1 : 0); + var keyValues = new Expression[expectedKeyValuesCount]; + + if (keyValues.Length != expectedKeyValuesCount && !_isTracking) + { + throw new InvalidOperationException(RelationalStrings.JsonEntityMissingKeyInformation(entityType.ShortName())); + } + + //var keyValues = new Expression[jsonProjectionInfo.KeyAccessInfo.Count]; for (var i = 0; i < jsonProjectionInfo.KeyAccessInfo.Count; i++) { var keyAccessInfo = jsonProjectionInfo.KeyAccessInfo[i]; @@ -2058,6 +2072,14 @@ private static IList PopulateList(IList buffer, IList target) } } + // fill missing keys (with arbitrary values) - this *should* only be missing synthesized keys (CHECK!) + // and those are only used to build identity for purpose of identity resolution in Tracking queries + // missing keys can happen when we do advanced querying of JSON entities (e.g. filters, paging) + for (var i = jsonProjectionInfo.KeyAccessInfo.Count; i < expectedKeyValuesCount; i++) + { + keyValues[i] = Constant(1, typeof(object)); + } + // create key values for initial entity var currentKeyValuesVariable = Variable(typeof(object[]), "currentKeyValues"); var keyValuesAssignment = Assign( diff --git a/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs b/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs index f746e53b096..8a192f60033 100644 --- a/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs +++ b/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs @@ -494,13 +494,13 @@ internal SelectExpression(IEntityType entityType, TableExpressionBase tableExpre throw new InvalidOperationException(RelationalStrings.SelectExpressionNonTphWithCustomTable(entityType.DisplayName())); } - var table = (tableExpressionBase as ITableBasedExpression)?.Table; - Check.DebugAssert(table is not null, "SelectExpression with unexpected missing table"); - var tableReferenceExpression = new TableReferenceExpression(this, tableExpressionBase.Alias!); AddTable(tableExpressionBase, tableReferenceExpression); var propertyExpressions = new Dictionary(); + var table = (tableExpressionBase as ITableBasedExpression)?.Table; + Check.DebugAssert(table is not null, "SelectExpression with unexpected missing table"); + foreach (var property in GetAllPropertiesInHierarchy(entityType)) { propertyExpressions[property] = CreateColumnExpression(property, table, tableReferenceExpression, nullable: false); @@ -520,6 +520,120 @@ internal SelectExpression(IEntityType entityType, TableExpressionBase tableExpre } } + /// + /// 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. + /// + [EntityFrameworkInternal] + public SelectExpression( + JsonQueryExpression jsonQueryExpression, + TableExpressionBase tableExpressionBase, + string identifierColumnName, + Type identifierColumnType, + RelationalTypeMapping identifierColumnTypeMapping) + : base(null) + { + if (!jsonQueryExpression.IsCollection) + { + throw new ArgumentException(RelationalStrings.SelectCanOnlyBeBuiltOnCollectionJsonQuery, nameof(jsonQueryExpression)); + } + + var entityType = jsonQueryExpression.EntityType; + + Check.DebugAssert( + entityType.BaseType is null && !entityType.GetDirectlyDerivedTypes().Any(), + "Inheritance encountered inside a JSON document"); + + var tableReferenceExpression = new TableReferenceExpression(this, tableExpressionBase.Alias!); + AddTable(tableExpressionBase, tableReferenceExpression); + + // Create a dictionary mapping all properties to their ColumnExpressions, for the SelectExpression's projection. + var propertyExpressions = new Dictionary(); + + foreach (var property in GetAllPropertiesInHierarchy(entityType)) + { + // also adding column(s) representing key of the parent (non-JSON) entity, on top of all the projections from OPENJSON/json_each/etc. + if (jsonQueryExpression.KeyPropertyMap.TryGetValue(property, out var ownerKeyColumn)) + { + propertyExpressions[property] = ownerKeyColumn; + continue; + } + + // Skip also properties with no JSON name (i.e. shadow keys containing the index in the collection, which don't actually exist + // in the JSON document and can't be bound to) + if (property.GetJsonPropertyName() is string jsonPropertyName) + { + propertyExpressions[property] = CreateColumnExpression( + tableExpressionBase, jsonPropertyName, property.ClrType, property.GetRelationalTypeMapping(), + /*jsonQueryExpression.IsNullable || */property.IsNullable); + } + } + + var entityProjection = new EntityProjectionExpression(entityType, propertyExpressions); + + var containerColumnName = jsonQueryExpression.EntityType.GetContainerColumnName()!; + var containerColumn = (entityType.GetViewOrTableMappings().SingleOrDefault()?.Table + ?? entityType.GetDefaultMappings().Single().Table) + .FindColumn(containerColumnName)!; + var containerColumnTypeMapping = containerColumn.StoreTypeMapping; + foreach (var ownedJsonNavigation in GetAllNavigationsInHierarchy(entityType) + .Where( + n => n.ForeignKey.IsOwnership + && n.TargetEntityType.IsMappedToJson() + && n.ForeignKey.PrincipalToDependent == n)) + { + var targetEntityType = ownedJsonNavigation.TargetEntityType; + var jsonNavigationName = ownedJsonNavigation.TargetEntityType.GetJsonPropertyName(); + Check.DebugAssert(jsonNavigationName is not null, "Invalid navigation found on JSON-mapped entity"); + var isNullable = containerColumn.IsNullable + || !ownedJsonNavigation.ForeignKey.IsRequiredDependent + || ownedJsonNavigation.IsCollection; + + // The TableExpressionBase represents a relational expansion of the JSON collection. We now need a ColumnExpression to represent + // the specific JSON property (projected as a relational column) which holds the JSON subtree for the target entity. + var column = new ConcreteColumnExpression( + jsonNavigationName, + tableReferenceExpression, + containerColumnTypeMapping.ClrType, + containerColumnTypeMapping, + isNullable); + + // need to remap key property map to use target entity key properties + var newKeyPropertyMap = new Dictionary(); + var targetPrimaryKeyProperties = targetEntityType.FindPrimaryKey()!.Properties.Take(jsonQueryExpression.KeyPropertyMap.Count); + var sourcePrimaryKeyProperties = jsonQueryExpression.EntityType.FindPrimaryKey()!.Properties.Take(jsonQueryExpression.KeyPropertyMap.Count); + foreach (var (target, source) in targetPrimaryKeyProperties.Zip(sourcePrimaryKeyProperties, (t, s) => (t, s))) + { + newKeyPropertyMap[target] = jsonQueryExpression.KeyPropertyMap[source]; + } + + var entityShaperExpression = new RelationalEntityShaperExpression( + targetEntityType, + new JsonQueryExpression( + targetEntityType, + column, + newKeyPropertyMap, + ownedJsonNavigation.ClrType, + ownedJsonNavigation.IsCollection), + isNullable); + + entityProjection.AddNavigationBinding(ownedJsonNavigation, entityShaperExpression); + } + + _projectionMapping[new ProjectionMember()] = entityProjection; + + var identifierColumn = new ConcreteColumnExpression( + identifierColumnName, + tableReferenceExpression, + identifierColumnType.UnwrapNullableType(), + identifierColumnTypeMapping, + identifierColumnType.IsNullableType()); + + _identifier.Add((identifierColumn, identifierColumnTypeMapping!.Comparer)); + } + private void AddJsonNavigationBindings( IEntityType entityType, EntityProjectionExpression entityProjection, @@ -533,17 +647,21 @@ private void AddJsonNavigationBindings( && n.ForeignKey.PrincipalToDependent == n)) { var targetEntityType = ownedJsonNavigation.TargetEntityType; - var jsonColumnName = targetEntityType.GetContainerColumnName()!; - var jsonColumnTypeMapping = (entityType.GetViewOrTableMappings().SingleOrDefault()?.Table + var containerColumnName = targetEntityType.GetContainerColumnName()!; + var containerColumn = (entityType.GetViewOrTableMappings().SingleOrDefault()?.Table ?? entityType.GetDefaultMappings().Single().Table) - .FindColumn(jsonColumnName)!.StoreTypeMapping; - - var jsonColumn = new ConcreteColumnExpression( - jsonColumnName, + .FindColumn(containerColumnName)!; + var cotainerColumnTypeMapping = containerColumn.StoreTypeMapping; + var isNullable = containerColumn.IsNullable + || !ownedJsonNavigation.ForeignKey.IsRequiredDependent + || ownedJsonNavigation.IsCollection; + + var column = new ConcreteColumnExpression( + containerColumnName, tableReferenceExpression, - jsonColumnTypeMapping.ClrType, - jsonColumnTypeMapping, - nullable: !ownedJsonNavigation.ForeignKey.IsRequiredDependent || ownedJsonNavigation.IsCollection); + cotainerColumnTypeMapping.ClrType, + cotainerColumnTypeMapping, + isNullable); // for json collections we need to skip ordinal key (which is always the last one) // simple copy from parent is safe here, because we only do it at top level @@ -564,11 +682,11 @@ private void AddJsonNavigationBindings( targetEntityType, new JsonQueryExpression( targetEntityType, - jsonColumn, + column, keyPropertiesMap, ownedJsonNavigation.ClrType, ownedJsonNavigation.IsCollection), - !ownedJsonNavigation.ForeignKey.IsRequiredDependent); + isNullable); entityProjection.AddNavigationBinding(ownedJsonNavigation, entityShaperExpression); } @@ -666,23 +784,32 @@ public void ApplyDistinct() { if (projection is EntityProjectionExpression entityProjection) { - var primaryKey = entityProjection.EntityType.FindPrimaryKey(); - // If there are any existing identifier then all entity projection must have a key - // else keyless entity would have wiped identifier when generating join. - Check.DebugAssert(primaryKey != null, "primary key is null."); - foreach (var property in primaryKey.Properties) + if (entityProjection.EntityType.IsMappedToJson()) { - entityProjectionIdentifiers.Add(entityProjection.BindProperty(property)); - entityProjectionValueComparers.Add(property.GetKeyValueComparer()); + // for JSON entities identifier is the key that was generated when we convert from json to query root (OPENJSON, json_each, etc) + // but we can't use it for distinct, as it would warp the results + // instead, we will treat every non-key property as identifier + foreach (var property in entityProjection.EntityType.GetDeclaredProperties().Where(p => !p.IsPrimaryKey())) + { + entityProjectionIdentifiers.Add(entityProjection.BindProperty(property)); + entityProjectionValueComparers.Add(property.GetKeyValueComparer()); + } + } + else + { + var primaryKey = entityProjection.EntityType.FindPrimaryKey(); + // If there are any existing identifier then all entity projection must have a key + // else keyless entity would have wiped identifier when generating join. + Check.DebugAssert(primaryKey != null, "primary key is null."); + foreach (var property in primaryKey.Properties) + { + entityProjectionIdentifiers.Add(entityProjection.BindProperty(property)); + entityProjectionValueComparers.Add(property.GetKeyValueComparer()); + } } } else if (projection is JsonQueryExpression jsonQueryExpression) { - if (jsonQueryExpression.IsCollection) - { - throw new InvalidOperationException(RelationalStrings.DistinctOnCollectionNotSupported); - } - var primaryKeyProperties = jsonQueryExpression.EntityType.FindPrimaryKey()!.Properties; var primaryKeyPropertiesCount = jsonQueryExpression.IsCollection ? primaryKeyProperties.Count - 1 @@ -1617,6 +1744,12 @@ ConstantExpression AddEntityProjection(EntityProjectionExpression entityProjecti var dictionary = new Dictionary(); foreach (var property in GetAllPropertiesInHierarchy(entityProjectionExpression.EntityType)) { + if (entityProjectionExpression.EntityType.IsMappedToJson() + && property.IsOrdinalKeyProperty()) + { + continue; + } + dictionary[property] = AddToProjection(entityProjectionExpression.BindProperty(property), null); } @@ -3763,6 +3896,14 @@ EntityProjectionExpression LiftEntityProjectionFromSubquery(EntityProjectionExpr var propertyExpressions = new Dictionary(); foreach (var property in GetAllPropertiesInHierarchy(entityProjection.EntityType)) { + // json entity projection (i.e. JSON entity that was transformed into query root) may have synthesized keys + // but they don't correspond to any columns - we need to skip those + if (entityProjection.EntityType.IsMappedToJson() + && property.IsOrdinalKeyProperty()) + { + continue; + } + var innerColumn = entityProjection.BindProperty(property); var outerColumn = subquery.GenerateOuterColumn(subqueryTableReferenceExpression, innerColumn); projectionMap[innerColumn] = outerColumn; @@ -3813,13 +3954,8 @@ JsonQueryExpression LiftJsonQueryFromSubquery(JsonQueryExpression jsonQueryExpre var newJsonColumn = subquery.GenerateOuterColumn(subqueryTableReferenceExpression, jsonScalarExpression); var newKeyPropertyMap = new Dictionary(); - - var keyProperties = jsonQueryExpression.EntityType.FindPrimaryKey()!.Properties; - var keyPropertyCount = jsonQueryExpression.IsCollection - ? keyProperties.Count - 1 - : keyProperties.Count; - - for (var i = 0; i < keyPropertyCount; i++) + var keyProperties = jsonQueryExpression.KeyPropertyMap.Keys.ToList(); + for (var i = 0; i < keyProperties.Count; i++) { var keyProperty = keyProperties[i]; var innerColumn = jsonQueryExpression.BindProperty(keyProperty); diff --git a/src/EFCore.Relational/Query/SqlExpressions/TableValuedFunctionExpression.cs b/src/EFCore.Relational/Query/SqlExpressions/TableValuedFunctionExpression.cs index b4869e373dc..85e38459a57 100644 --- a/src/EFCore.Relational/Query/SqlExpressions/TableValuedFunctionExpression.cs +++ b/src/EFCore.Relational/Query/SqlExpressions/TableValuedFunctionExpression.cs @@ -128,19 +128,9 @@ public override string? Alias /// protected override Expression VisitChildren(ExpressionVisitor visitor) - { - var changed = false; - var arguments = new SqlExpression[Arguments.Count]; - for (var i = 0; i < arguments.Length; i++) - { - arguments[i] = (SqlExpression)visitor.Visit(Arguments[i]); - changed |= arguments[i] != Arguments[i]; - } - - return changed - ? new TableValuedFunctionExpression(Alias, Name, Schema, IsBuiltIn, arguments, GetAnnotations()) - : this; - } + => visitor.VisitAndConvert(Arguments) is var visitedArguments && visitedArguments == Arguments + ? this + : new TableValuedFunctionExpression(Alias, Name, Schema, IsBuiltIn, visitedArguments, GetAnnotations()); /// /// Creates a new expression that is like this one, but using the supplied children. If all of the children are the same, it will diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerJsonPostprocessor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerJsonPostprocessor.cs new file mode 100644 index 00000000000..b7499aa02a3 --- /dev/null +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerJsonPostprocessor.cs @@ -0,0 +1,275 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using Microsoft.EntityFrameworkCore.SqlServer.Internal; +using Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal; + +/// +/// Converts expressions with WITH (the default) to OPENJSON without WITH when an +/// ordering still exists on the [key] column, i.e. when the ordering of the original JSON array needs to be preserved +/// (e.g. limit/offset). +/// +/// +/// 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 SqlServerJsonPostprocessor : ExpressionVisitor +{ + private readonly IRelationalTypeMappingSource _typeMappingSource; + private readonly ISqlExpressionFactory _sqlExpressionFactory; + + private readonly + Dictionary<(SqlServerOpenJsonExpression, string), (SelectExpression SelectExpression, SqlServerOpenJsonExpression.ColumnInfo + ColumnInfo)> _columnsToRewrite = new(); + + private RelationalTypeMapping? _nvarcharMaxTypeMapping; + + /// + /// 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 SqlServerJsonPostprocessor( + IRelationalTypeMappingSource typeMappingSource, + ISqlExpressionFactory sqlExpressionFactory) + => (_typeMappingSource, _sqlExpressionFactory) = (typeMappingSource, sqlExpressionFactory); + + /// + /// 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 virtual Expression Process(Expression expression) + { + _columnsToRewrite.Clear(); + + return Visit(expression); + } + + /// + /// 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. + /// + [return: NotNullIfNotNull("expression")] + public override Expression? Visit(Expression? expression) + { + switch (expression) + { + case ShapedQueryExpression shapedQueryExpression: + return shapedQueryExpression.UpdateQueryExpression(Visit(shapedQueryExpression.QueryExpression)); + + case SelectExpression selectExpression: + { + TableExpressionBase[]? newTables = null; + Dictionary<(SqlServerOpenJsonExpression, string), SqlServerOpenJsonExpression.ColumnInfo>? columnsToRewrite = null; + + for (var i = 0; i < selectExpression.Tables.Count; i++) + { + var table = selectExpression.Tables[i]; + + if ((table is SqlServerOpenJsonExpression { ColumnInfos: not null } + or JoinExpressionBase { Table: SqlServerOpenJsonExpression { ColumnInfos: not null } }) + && selectExpression.Orderings.Select(o => o.Expression) + .Concat(selectExpression.Projection.Select(p => p.Expression)) + .Any(x => IsKeyColumn(x, table))) + { + // Remove the WITH clause from the OPENJSON expression + var openJsonExpression = (SqlServerOpenJsonExpression)((table as JoinExpressionBase)?.Table ?? table); + var newOpenJsonExpression = openJsonExpression.Update( + openJsonExpression.JsonExpression, + openJsonExpression.Path, + columnInfos: null); + + table = table switch + { + InnerJoinExpression ij => ij.Update(newOpenJsonExpression, ij.JoinPredicate), + LeftJoinExpression lj => lj.Update(newOpenJsonExpression, lj.JoinPredicate), + CrossJoinExpression cj => cj.Update(newOpenJsonExpression), + CrossApplyExpression ca => ca.Update(newOpenJsonExpression), + OuterApplyExpression oa => oa.Update(newOpenJsonExpression), + _ => newOpenJsonExpression, + }; + + foreach (var columnInfo in openJsonExpression.ColumnInfos!) + { + columnsToRewrite ??= new(); + columnsToRewrite.Add((newOpenJsonExpression, columnInfo.Name), columnInfo); + } + + if (newTables is null) + { + newTables = new TableExpressionBase[selectExpression.Tables.Count]; + for (var j = 0; j < i; j++) + { + newTables[j] = selectExpression.Tables[j]; + } + } + } + + if (newTables is not null) + { + newTables[i] = table; + } + } + + // In the common case, we do not have to rewrite any OPENJSON tables. + if (columnsToRewrite is null) + { + Check.DebugAssert(newTables is null, "newTables must be null if columnsToRewrite is null"); + return base.Visit(selectExpression); + } + + var newSelectExpression = newTables is not null + ? selectExpression.Update( + selectExpression.Projection, + newTables, + selectExpression.Predicate, + selectExpression.GroupBy, + selectExpression.Having, + selectExpression.Orderings, + selectExpression.Limit, + selectExpression.Offset) + : selectExpression; + + // when we mark columns for rewrite we don't yet have the updated SelectExpression, so we store the info in temporary dictionary + // and now that we have created new SelectExpression we add it to the proper dictionary that we will use for rewrite + foreach (var columnToRewrite in columnsToRewrite) + { + _columnsToRewrite.Add(columnToRewrite.Key, (newSelectExpression, columnToRewrite.Value)); + } + + // Record the OPENJSON expression and its projected column(s), along with the store type we just removed from the WITH + // clause. Then visit the select expression, adding a cast around the matching ColumnExpressions. + var result = base.Visit(newSelectExpression); + + foreach (var columnsToRewriteKey in columnsToRewrite.Keys) + { + _columnsToRewrite.Remove(columnsToRewriteKey); + } + + return result; + } + + case ColumnExpression columnExpression: + { + var table = columnExpression.Table; + if (table is JoinExpressionBase join) + { + table = join.Table; + } + + return table is SqlServerOpenJsonExpression openJsonTable + && _columnsToRewrite.TryGetValue((openJsonTable, columnExpression.Name), out var columnRewriteInfo) + ? RewriteOpenJsonColumn(columnExpression, columnRewriteInfo.SelectExpression, columnRewriteInfo.ColumnInfo) + : base.Visit(expression); + } + + // JsonScalarExpression over a column coming out of OPENJSON/WITH; this means that the column represents an owned sub- + // entity, and therefore must have AS JSON. Rewrite the column and simply collapse the paths together. + case JsonScalarExpression + { + Json: ColumnExpression { Table: SqlServerOpenJsonExpression openJsonTable } columnExpression + } jsonScalarExpression + when _columnsToRewrite.TryGetValue((openJsonTable, columnExpression.Name), out var columnRewriteInfo): + { + var (selectExpression, columnInfo) = columnRewriteInfo; + + Check.DebugAssert( + columnInfo.AsJson, + "JsonScalarExpression over a column coming out of OPENJSON is only valid when that column represents an owned " + + "sub-entity, which means it must have AS JSON"); + + // The new OPENJSON (without WITH) always projects a `value` column, instead of a properly named column for individual + // values inside; create a new ColumnExpression with that name. + SqlExpression rewrittenColumn = selectExpression.CreateColumnExpression( + columnExpression.Table, "value", columnExpression.Type, _nvarcharMaxTypeMapping, columnExpression.IsNullable); + + // Prepend the path from the OPENJSON/WITH to the path in the JsonScalarExpression + var path = columnInfo.Path is null + ? jsonScalarExpression.Path + : columnInfo.Path.Concat(jsonScalarExpression.Path).ToList(); + + return new JsonScalarExpression( + rewrittenColumn, path, jsonScalarExpression.Type, jsonScalarExpression.TypeMapping, + jsonScalarExpression.IsNullable); + } + + default: + return base.Visit(expression); + } + + static bool IsKeyColumn(SqlExpression sqlExpression, TableExpressionBase table) + => (sqlExpression is ColumnExpression { Name: "key", Table: var keyColumnTable } + && keyColumnTable == table) + || (sqlExpression is SqlUnaryExpression + { + OperatorType: ExpressionType.Convert, + Operand: SqlExpression operand + } + && IsKeyColumn(operand, table)); + + SqlExpression RewriteOpenJsonColumn( + ColumnExpression columnExpression, + SelectExpression selectExpression, + SqlServerOpenJsonExpression.ColumnInfo columnInfo) + { + // We found a ColumnExpression that refers to the OPENJSON table, we need to rewrite it. + + // Binary data (varbinary) is stored in JSON as base64, which OPENJSON knows how to decode as long the type is + // specified in the WITH clause. We're now removing the WITH and applying a relational CAST, but that doesn't work + // for base64 data. + if (columnInfo.TypeMapping is SqlServerByteArrayTypeMapping) + { + throw new InvalidOperationException(SqlServerStrings.QueryingOrderedBinaryJsonCollectionsNotSupported); + } + + // The new OPENJSON (without WITH) always projects a `value` column, instead of a properly named column for individual + // values inside; create a new ColumnExpression with that name. + SqlExpression rewrittenColumn = selectExpression.CreateColumnExpression( + columnExpression.Table, "value", columnExpression.Type, _nvarcharMaxTypeMapping, columnExpression.IsNullable); + + Check.DebugAssert(columnInfo.Path is not null, "Path shouldn't be null in OPENJSON WITH"); + Check.DebugAssert(!columnInfo.AsJson, "AS JSON signifies an owned sub-entity being projected out of OPENJSON/WITH. " + + "Columns referring to that must be wrapped be Json{Scalar,Query}Expression and will have been already dealt with above"); + + if (columnInfo.Path is []) + { + // OPENJSON with WITH specified the store type in the WITH, but the version without just always projects + // nvarchar(max); add a CAST to convert. + if (columnInfo.TypeMapping.StoreType != "nvarchar(max)") + { + _nvarcharMaxTypeMapping ??= _typeMappingSource.FindMapping("nvarchar(max)"); + + rewrittenColumn = _sqlExpressionFactory.Convert( + rewrittenColumn, + columnExpression.Type, + columnInfo.TypeMapping); + } + } + else + { + // Non-primitive collection case - elements in the JSON collection represent a structural type. + // We need JSON_VALUE to get the individual properties out of those fragments. Note that the appropriate CASTs are added + // in SQL generation. + rewrittenColumn = new JsonScalarExpression( + rewrittenColumn, + columnInfo.Path, + columnExpression.Type, + columnExpression.TypeMapping, + columnExpression.IsNullable); + } + + return rewrittenColumn; + } + } +} diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerOpenJsonExpression.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerOpenJsonExpression.cs index 2a12317536f..7fed63fd339 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerOpenJsonExpression.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerOpenJsonExpression.cs @@ -37,8 +37,7 @@ public virtual SqlExpression JsonExpression /// 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 virtual SqlExpression? Path - => Arguments.Count == 1 ? null : Arguments[1]; + public virtual IReadOnlyList? Path { get; } /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to @@ -54,16 +53,74 @@ public virtual SqlExpression? Path /// 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 SqlServerOpenJsonExpression( string alias, SqlExpression jsonExpression, - SqlExpression? path = null, + IReadOnlyList? path = null, IReadOnlyList? columnInfos = null) - : base(alias, "OPENJSON", schema: null, builtIn: true, path is null ? new[] { jsonExpression } : new[] { jsonExpression, path }) + : base(alias, "OPENJSON", schema: null, builtIn: true, new[] { jsonExpression }) { + Path = path; ColumnInfos = columnInfos; } + /// + /// 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 VisitChildren(ExpressionVisitor visitor) + { + var visitedJsonExpression = (SqlExpression)visitor.Visit(JsonExpression); + + PathSegment[]? visitedPath = null; + + if (Path is not null) + { + for (var i = 0; i < Path.Count; i++) + { + var segment = Path[i]; + PathSegment newSegment; + + if (segment.PropertyName is not null) + { + // PropertyName segments are (currently) constants, nothing to visit. + newSegment = segment; + } + else + { + var newArrayIndex = (SqlExpression)visitor.Visit(segment.ArrayIndex)!; + if (newArrayIndex == segment.ArrayIndex) + { + newSegment = segment; + } + else + { + newSegment = new PathSegment(newArrayIndex); + + if (visitedPath is null) + { + visitedPath = new PathSegment[Path.Count]; + for (var j = 0; j < i; i++) + { + visitedPath[j] = Path[j]; + } + } + } + } + + if (visitedPath is not null) + { + visitedPath[i] = newSegment; + } + } + } + + return Update(visitedJsonExpression, visitedPath ?? Path, ColumnInfos); + } + /// /// 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 @@ -72,11 +129,11 @@ public SqlServerOpenJsonExpression( /// public virtual SqlServerOpenJsonExpression Update( SqlExpression jsonExpression, - SqlExpression? path, + IReadOnlyList? path, IReadOnlyList? columnInfos = null) => jsonExpression == JsonExpression - && path == Path - && (columnInfos is null ? ColumnInfos is null : ColumnInfos is not null && columnInfos.SequenceEqual(ColumnInfos)) + && (ReferenceEquals(path, Path) || path is not null && Path is not null && path.SequenceEqual(Path)) + && (ReferenceEquals(columnInfos, ColumnInfos) || columnInfos is not null && ColumnInfos is not null && columnInfos.SequenceEqual(ColumnInfos)) ? this : new SqlServerOpenJsonExpression(Alias, jsonExpression, path, columnInfos); @@ -100,12 +157,26 @@ public virtual TableExpressionBase Clone() return clone; } - /// + /// + /// 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 void Print(ExpressionPrinter expressionPrinter) { expressionPrinter.Append(Name); expressionPrinter.Append("("); - expressionPrinter.VisitCollection(Arguments); + expressionPrinter.Visit(JsonExpression); + + if (Path is not null) + { + expressionPrinter + .Append(", '") + .Append(string.Join(".", Path.Select(e => e.ToString()))) + .Append("'"); + } + expressionPrinter.Append(")"); if (ColumnInfos is not null) @@ -124,11 +195,14 @@ protected override void Print(ExpressionPrinter expressionPrinter) expressionPrinter .Append(columnInfo.Name) .Append(" ") - .Append(columnInfo.StoreType ?? ""); + .Append(columnInfo.TypeMapping.StoreType); if (columnInfo.Path is not null) { - expressionPrinter.Append(" ").Append("'" + columnInfo.Path + "'"); + expressionPrinter + .Append(" '") + .Append(string.Join(".", columnInfo.Path.Select(e => e.ToString()))) + .Append("'"); } if (columnInfo.AsJson) @@ -141,6 +215,7 @@ protected override void Print(ExpressionPrinter expressionPrinter) } PrintAnnotations(expressionPrinter); + expressionPrinter.Append(" AS "); expressionPrinter.Append(Alias); } @@ -149,11 +224,35 @@ protected override void Print(ExpressionPrinter expressionPrinter) public override bool Equals(object? obj) => ReferenceEquals(this, obj) || (obj is SqlServerOpenJsonExpression openJsonExpression && Equals(openJsonExpression)); - private bool Equals(SqlServerOpenJsonExpression openJsonExpression) - => base.Equals(openJsonExpression) - && (ColumnInfos is null - ? openJsonExpression.ColumnInfos is null - : openJsonExpression.ColumnInfos is not null && ColumnInfos.SequenceEqual(openJsonExpression.ColumnInfos)); + private bool Equals(SqlServerOpenJsonExpression other) + { + if (!base.Equals(other) || ColumnInfos?.Count != other.ColumnInfos?.Count) + { + return false; + } + + if (ReferenceEquals(ColumnInfos, other.ColumnInfos)) + { + return true; + } + + for (var i = 0; i < ColumnInfos!.Count; i++) + { + var (columnInfo, otherColumnInfo) = (ColumnInfos[i], other.ColumnInfos![i]); + + if (columnInfo.Name != otherColumnInfo.Name + || !columnInfo.TypeMapping.Equals(otherColumnInfo.TypeMapping) + || (columnInfo.Path is null != otherColumnInfo.Path is null + || (columnInfo.Path is not null + && otherColumnInfo.Path is not null + && columnInfo.Path.SequenceEqual(otherColumnInfo.Path)))) + { + return false; + } + } + + return true; + } /// public override int GetHashCode() @@ -165,5 +264,9 @@ public override int GetHashCode() /// 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 readonly record struct ColumnInfo(string Name, string StoreType, string? Path = null, bool AsJson = false); + public readonly record struct ColumnInfo( + string Name, + RelationalTypeMapping TypeMapping, + IReadOnlyList? Path = null, + bool AsJson = false); } diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs index 22bf1c34c2f..ae02b249422 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs @@ -404,7 +404,8 @@ protected override Expression VisitJsonScalar(JsonScalarExpression jsonScalarExp return jsonScalarExpression; } - if (jsonScalarExpression.TypeMapping is SqlServerJsonTypeMapping) + if (jsonScalarExpression.TypeMapping is SqlServerJsonTypeMapping + || jsonScalarExpression.TypeMapping?.ElementTypeMapping is not null) { Sql.Append("JSON_QUERY("); } @@ -418,8 +419,25 @@ protected override Expression VisitJsonScalar(JsonScalarExpression jsonScalarExp Visit(jsonScalarExpression.Json); - Sql.Append(", '$"); - foreach (var pathSegment in jsonScalarExpression.Path) + Sql.Append(", "); + GenerateJsonPath(jsonScalarExpression.Path); + Sql.Append(")"); + + if (jsonScalarExpression.TypeMapping is not SqlServerJsonTypeMapping and not StringTypeMapping) + { + Sql.Append(" AS "); + Sql.Append(jsonScalarExpression.TypeMapping!.StoreType); + Sql.Append(")"); + } + + return jsonScalarExpression; + } + + private void GenerateJsonPath(IReadOnlyList path) + { + Sql.Append("'$"); + + foreach (var pathSegment in path) { switch (pathSegment) { @@ -434,7 +452,7 @@ protected override Expression VisitJsonScalar(JsonScalarExpression jsonScalarExp // above; before that, arguments must be constant strings. if (arrayIndex is SqlConstantExpression) { - Visit(pathSegment.ArrayIndex); + Visit(arrayIndex); } else if (_sqlServerCompatibilityLevel >= 140) { @@ -458,16 +476,7 @@ protected override Expression VisitJsonScalar(JsonScalarExpression jsonScalarExp } } - Sql.Append("')"); - - if (jsonScalarExpression.TypeMapping is not SqlServerJsonTypeMapping and not StringTypeMapping) - { - Sql.Append(" AS "); - Sql.Append(jsonScalarExpression.TypeMapping!.StoreType); - Sql.Append(")"); - } - - return jsonScalarExpression; + Sql.Append("'"); } /// @@ -480,11 +489,17 @@ protected virtual Expression VisitOpenJsonExpression(SqlServerOpenJsonExpression { // OPENJSON docs: https://learn.microsoft.com/sql/t-sql/functions/openjson-transact-sql - // OPENJSON is a regular table-valued function with a special WITH clause at the end - // Copy-paste from VisitTableValuedFunction, because that appends the 'AS ' but we need to insert WITH before that + // The second argument is the JSON path, which is represented as a list of PathSegments, from which we generate a SQL jsonpath + // expression. Sql.Append("OPENJSON("); - GenerateList(openJsonExpression.Arguments, e => Visit(e)); + Visit(openJsonExpression.JsonExpression); + + if (openJsonExpression.Path is not null) + { + Sql.Append(", "); + GenerateJsonPath(openJsonExpression.Path); + } Sql.Append(")"); @@ -492,27 +507,43 @@ protected virtual Expression VisitOpenJsonExpression(SqlServerOpenJsonExpression { Sql.Append(" WITH ("); - for (var i = 0; i < openJsonExpression.ColumnInfos.Count; i++) + if (openJsonExpression.ColumnInfos is [var singleColumnInfo]) { - var columnInfo = openJsonExpression.ColumnInfos[i]; + GenerateColumnInfo(singleColumnInfo); + } + else + { + Sql.AppendLine(); + using var _ = Sql.Indent(); - if (i > 0) + for (var i = 0; i < openJsonExpression.ColumnInfos.Count; i++) { - Sql.Append(", "); + var columnInfo = openJsonExpression.ColumnInfos[i]; + + if (i > 0) + { + Sql.AppendLine(","); + } + + GenerateColumnInfo(columnInfo); } - Check.DebugAssert(columnInfo.StoreType is not null, "Unset OPENJSON column store type"); + Sql.AppendLine(); + } + Sql.Append(")"); + + void GenerateColumnInfo(SqlServerOpenJsonExpression.ColumnInfo columnInfo) + { Sql .Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(columnInfo.Name)) .Append(" ") - .Append(columnInfo.StoreType); + .Append(columnInfo.TypeMapping.StoreType); if (columnInfo.Path is not null) { - Sql - .Append(" ") - .Append(_typeMappingSource.GetMapping("varchar(max)").GenerateSqlLiteral(columnInfo.Path)); + Sql.Append(" "); + GenerateJsonPath(columnInfo.Path); } if (columnInfo.AsJson) @@ -520,8 +551,6 @@ protected virtual Expression VisitOpenJsonExpression(SqlServerOpenJsonExpression Sql.Append(" AS JSON"); } } - - Sql.Append(")"); } Sql.Append(AliasSeparator).Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(openJsonExpression.Alias)); diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryTranslationPostprocessor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryTranslationPostprocessor.cs index 5d86c0a7704..77941e2722e 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryTranslationPostprocessor.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryTranslationPostprocessor.cs @@ -2,8 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; -using System.Linq.Expressions; -using Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; using Microsoft.EntityFrameworkCore.SqlServer.Internal; using Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal; @@ -18,7 +16,7 @@ namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal; /// public class SqlServerQueryTranslationPostprocessor : RelationalQueryTranslationPostprocessor { - private readonly OpenJsonPostprocessor _openJsonPostprocessor; + private readonly SqlServerJsonPostprocessor _jsonPostprocessor; private readonly SkipWithoutOrderByInSplitQueryVerifier _skipWithoutOrderByInSplitQueryVerifier = new(); /// @@ -34,7 +32,7 @@ public SqlServerQueryTranslationPostprocessor( IRelationalTypeMappingSource typeMappingSource) : base(dependencies, relationalDependencies, queryCompilationContext) { - _openJsonPostprocessor = new(typeMappingSource, relationalDependencies.SqlExpressionFactory); + _jsonPostprocessor = new(typeMappingSource, relationalDependencies.SqlExpressionFactory); } /// @@ -47,7 +45,7 @@ public override Expression Process(Expression query) { query = base.Process(query); - query = _openJsonPostprocessor.Process(query); + query = _jsonPostprocessor.Process(query); _skipWithoutOrderByInSplitQueryVerifier.Visit(query); return query; @@ -85,158 +83,4 @@ private sealed class SkipWithoutOrderByInSplitQueryVerifier : ExpressionVisitor } } } - - /// - /// Converts expressions with WITH (the default) to OPENJSON without WITH when an - /// ordering still exists on the [key] column, i.e. when the ordering of the original JSON array needs to be preserved - /// (e.g. limit/offset). - /// - private sealed class OpenJsonPostprocessor : ExpressionVisitor - { - private readonly IRelationalTypeMappingSource _typeMappingSource; - private readonly ISqlExpressionFactory _sqlExpressionFactory; - private readonly Dictionary<(SqlServerOpenJsonExpression, string), RelationalTypeMapping> _castsToApply = new(); - - public OpenJsonPostprocessor(IRelationalTypeMappingSource typeMappingSource, ISqlExpressionFactory sqlExpressionFactory) - => (_typeMappingSource, _sqlExpressionFactory) = (typeMappingSource, sqlExpressionFactory); - - public Expression Process(Expression expression) - { - _castsToApply.Clear(); - return Visit(expression); - } - - [return: NotNullIfNotNull("expression")] - public override Expression? Visit(Expression? expression) - { - switch (expression) - { - case ShapedQueryExpression shapedQueryExpression: - return shapedQueryExpression.UpdateQueryExpression(Visit(shapedQueryExpression.QueryExpression)); - - case SelectExpression selectExpression: - { - var newTables = default(TableExpressionBase[]); - var appliedCasts = new List<(SqlServerOpenJsonExpression, string)>(); - - for (var i = 0; i < selectExpression.Tables.Count; i++) - { - var table = selectExpression.Tables[i]; - if ((table is SqlServerOpenJsonExpression { ColumnInfos: not null } - or JoinExpressionBase { Table: SqlServerOpenJsonExpression { ColumnInfos: not null } }) - && selectExpression.Orderings.Select(o => o.Expression) - .Concat(selectExpression.Projection.Select(p => p.Expression)) - .Any(x => IsKeyColumn(x, table))) - { - // Remove the WITH clause from the OPENJSON expression - var openJsonExpression = (SqlServerOpenJsonExpression)((table as JoinExpressionBase)?.Table ?? table); - var newOpenJsonExpression = openJsonExpression.Update( - openJsonExpression.JsonExpression, - openJsonExpression.Path, - columnInfos: null); - - TableExpressionBase newTable = table switch - { - InnerJoinExpression ij => ij.Update(newOpenJsonExpression, ij.JoinPredicate), - LeftJoinExpression lj => lj.Update(newOpenJsonExpression, lj.JoinPredicate), - CrossJoinExpression cj => cj.Update(newOpenJsonExpression), - CrossApplyExpression ca => ca.Update(newOpenJsonExpression), - OuterApplyExpression oa => oa.Update(newOpenJsonExpression), - _ => newOpenJsonExpression, - }; - - if (newTables is not null) - { - newTables[i] = newTable; - } - else if (!table.Equals(newTable)) - { - newTables = new TableExpressionBase[selectExpression.Tables.Count]; - for (var j = 0; j < i; j++) - { - newTables[j] = selectExpression.Tables[j]; - } - - newTables[i] = newTable; - } - - foreach (var column in openJsonExpression.ColumnInfos!) - { - var typeMapping = _typeMappingSource.FindMapping(column.StoreType); - Check.DebugAssert( - typeMapping is not null, - $"Could not find mapping for store type {column.StoreType} when converting OPENJSON/WITH"); - - // Binary data (varbinary) is stored in JSON as base64, which OPENJSON knows how to decode as long the type is - // specified in the WITH clause. We're now removing the WITH and applying a relational CAST, but that doesn't work - // for base64 data. - if (typeMapping is SqlServerByteArrayTypeMapping) - { - throw new InvalidOperationException(SqlServerStrings.QueryingOrderedBinaryJsonCollectionsNotSupported); - } - - _castsToApply.Add((newOpenJsonExpression, column.Name), typeMapping); - appliedCasts.Add((newOpenJsonExpression, column.Name)); - } - - continue; - } - - if (newTables is not null) - { - newTables[i] = table; - } - } - - // SelectExpression.Update always creates a new instance - we should avoid it when tables haven't changed - // see #31276 - var newSelectExpression = newTables is not null - ? selectExpression.Update( - selectExpression.Projection, - newTables, - selectExpression.Predicate, - selectExpression.GroupBy, - selectExpression.Having, - selectExpression.Orderings, - selectExpression.Limit, - selectExpression.Offset) - : selectExpression; - - // Record the OPENJSON expression and its projected column(s), along with the store type we just removed from the WITH - // clause. Then visit the select expression, adding a cast around the matching ColumnExpressions. - var result = base.Visit(newSelectExpression); - - foreach (var appliedCast in appliedCasts) - { - _castsToApply.Remove(appliedCast); - } - - return result; - } - - case ColumnExpression columnExpression: - { - return columnExpression.Table switch - { - SqlServerOpenJsonExpression openJsonTable - when _castsToApply.TryGetValue((openJsonTable, columnExpression.Name), out var typeMapping) - => _sqlExpressionFactory.Convert(columnExpression, columnExpression.Type, typeMapping), - JoinExpressionBase { Table: SqlServerOpenJsonExpression innerOpenJsonTable } - when _castsToApply.TryGetValue((innerOpenJsonTable, columnExpression.Name), out var innerTypeMapping) - => _sqlExpressionFactory.Convert(columnExpression, columnExpression.Type, innerTypeMapping), - _ => base.Visit(expression) - }; - } - - default: - return base.Visit(expression); - } - - static bool IsKeyColumn(SqlExpression sqlExpression, TableExpressionBase table) - => (sqlExpression is ColumnExpression { Name: "key", Table: var keyColumnTable } - && keyColumnTable == table) - || (sqlExpression is SqlUnaryExpression { OperatorType: ExpressionType.Convert, - Operand: SqlExpression operand } && IsKeyColumn(operand, table)); - } - } } diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs index 8223af7a1a2..36101a611e9 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs @@ -23,6 +23,8 @@ public class SqlServerQueryableMethodTranslatingExpressionVisitor : RelationalQu private readonly ISqlExpressionFactory _sqlExpressionFactory; private readonly int _sqlServerCompatibilityLevel; + private RelationalTypeMapping? _nvarcharMaxTypeMapping; + /// /// 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 @@ -152,8 +154,8 @@ protected override Expression VisitExtension(Expression extensionExpression) new SqlServerOpenJsonExpression.ColumnInfo { Name = "value", - StoreType = elementTypeMapping.StoreType, - Path = "$" + TypeMapping = elementTypeMapping, + Path = Array.Empty() } }); @@ -208,6 +210,101 @@ protected override Expression VisitExtension(Expression extensionExpression) return new ShapedQueryExpression(selectExpression, shaperExpression); } + /// + /// 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 ShapedQueryExpression TransformJsonQueryToTable(JsonQueryExpression jsonQueryExpression) + { + // Calculate the table alias for the OPENJSON expression based on the last named path segment + // (or the JSON column name if there are none) + var lastNamedPathSegment = jsonQueryExpression.Path.LastOrDefault(ps => ps.PropertyName is not null); + var tableAlias = char.ToLowerInvariant((lastNamedPathSegment.PropertyName ?? jsonQueryExpression.JsonColumn.Name)[0]).ToString(); + + // We now add all of projected entity's the properties and navigations into the OPENJSON's WITH clause. Note that navigations + // get AS JSON, which projects out the JSON sub-document for them as text, which can be further navigated into. + var columnInfos = new List(); + + // We're only interested in properties which actually exist in the JSON, filter out uninteresting shadow keys + foreach (var property in GetAllPropertiesInHierarchy(jsonQueryExpression.EntityType)) + { + if (property.GetJsonPropertyName() is string jsonPropertyName) + { + columnInfos.Add(new() + { + Name = jsonPropertyName, + TypeMapping = property.GetRelationalTypeMapping(), + Path = new PathSegment[] { new(jsonPropertyName) } + }); + } + } + + foreach (var navigation in GetAllNavigationsInHierarchy(jsonQueryExpression.EntityType) + .Where( + n => n.ForeignKey.IsOwnership + && n.TargetEntityType.IsMappedToJson() + && n.ForeignKey.PrincipalToDependent == n)) + { + var jsonNavigationName = navigation.TargetEntityType.GetJsonPropertyName(); + Check.DebugAssert(jsonNavigationName is not null, $"No JSON property name for navigation {navigation.Name}"); + + columnInfos.Add(new() + { + Name = jsonNavigationName, + TypeMapping = _nvarcharMaxTypeMapping ??= _typeMappingSource.FindMapping("nvarchar(max)")!, + Path = new PathSegment[] { new(jsonNavigationName) }, + AsJson = true + }); + } + + var openJsonExpression = new SqlServerOpenJsonExpression( + tableAlias, jsonQueryExpression.JsonColumn, jsonQueryExpression.Path, columnInfos); + +#pragma warning disable EF1001 // Internal EF Core API usage. + var selectExpression = new SelectExpression( + jsonQueryExpression, + openJsonExpression, + "key", + typeof(string), + _typeMappingSource.FindMapping("nvarchar(4000)")!); +#pragma warning restore EF1001 // Internal EF Core API usage. + + // See note on OPENJSON and ordering in TranslateCollection + selectExpression.AppendOrdering( + new OrderingExpression( + _sqlExpressionFactory.Convert( + selectExpression.CreateColumnExpression( + openJsonExpression, + "key", + typeof(string), + typeMapping: _typeMappingSource.FindMapping("nvarchar(4000)"), + columnNullable: false), + typeof(int), + _typeMappingSource.FindMapping(typeof(int))), + ascending: true)); + + return new ShapedQueryExpression( + selectExpression, + new RelationalEntityShaperExpression( + jsonQueryExpression.EntityType, + new ProjectionBindingExpression( + selectExpression, + new ProjectionMember(), + typeof(ValueBuffer)), + false)); + + // TODO: Move these to IEntityType? + static IEnumerable GetAllPropertiesInHierarchy(IEntityType entityType) + => entityType.GetAllBaseTypes().Concat(entityType.GetDerivedTypesInclusive()) + .SelectMany(t => t.GetDeclaredProperties()); + + static IEnumerable GetAllNavigationsInHierarchy(IEntityType entityType) + => entityType.GetAllBaseTypes().Concat(entityType.GetDerivedTypesInclusive()) + .SelectMany(t => t.GetDeclaredNavigations()); + } + /// /// 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 @@ -293,6 +390,30 @@ protected override Expression VisitExtension(Expression extensionExpression) return base.TranslateElementAtOrDefault(source, index, returnDefault); } + /// + /// 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 bool IsNaturallyOrdered(SelectExpression selectExpression) + => selectExpression is + { + Tables: [SqlServerOpenJsonExpression openJsonExpression, ..], + Orderings: + [ + { + Expression: SqlUnaryExpression + { + OperatorType: ExpressionType.Convert, + Operand: ColumnExpression { Name: "key", Table: var orderingTable } + }, + IsAscending: true + } + ] + } + && orderingTable == openJsonExpression; + /// /// 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 @@ -477,8 +598,10 @@ protected virtual SqlServerOpenJsonExpression ApplyTypeMappingsOnOpenJsonExpress return openJsonExpression; } - Check.DebugAssert(openJsonExpression.Path is null, "openJsonExpression.Path is null"); - Check.DebugAssert(openJsonExpression.ColumnInfos is null, "Invalid SqlServerOpenJsonExpression"); + Check.DebugAssert( + openJsonExpression.Path is null, "OpenJsonExpression path is non-null when applying an inferred type mapping"); + Check.DebugAssert( + openJsonExpression.ColumnInfos is null, "OpenJsonExpression has no ColumnInfos when applying an inferred type mapping"); // We need to apply the inferred type mapping in two places: the collection type mapping on the parameter expanded by OPENJSON, // and on the WITH clause determining the conversion out on the SQL Server side @@ -496,7 +619,7 @@ protected virtual SqlServerOpenJsonExpression ApplyTypeMappingsOnOpenJsonExpress return openJsonExpression.Update( parameterExpression.ApplyTypeMapping(parameterTypeMapping), path: null, - new[] { new SqlServerOpenJsonExpression.ColumnInfo("value", elementTypeMapping.StoreType, "$") }); + new[] { new SqlServerOpenJsonExpression.ColumnInfo("value", elementTypeMapping, Array.Empty()) }); } } } diff --git a/src/EFCore.Sqlite.Core/Query/Internal/SqliteQuerySqlGenerator.cs b/src/EFCore.Sqlite.Core/Query/Internal/SqliteQuerySqlGenerator.cs index 8e0622a64ae..656d144b856 100644 --- a/src/EFCore.Sqlite.Core/Query/Internal/SqliteQuerySqlGenerator.cs +++ b/src/EFCore.Sqlite.Core/Query/Internal/SqliteQuerySqlGenerator.cs @@ -43,6 +43,10 @@ protected override Expression VisitExtension(Expression extensionExpression) GenerateRegexp(regexpExpression); return extensionExpression; + case JsonEachExpression jsonEachExpression: + GenerateJsonEach(jsonEachExpression); + return extensionExpression; + default: return base.VisitExtension(extensionExpression); } @@ -123,6 +127,76 @@ private void GenerateRegexp(RegexpExpression regexpExpression, bool negated = fa Visit(regexpExpression.Pattern); } + /// + /// 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 virtual void GenerateJsonEach(JsonEachExpression jsonEachExpression) + { + // json_each docs: https://www.sqlite.org/json1.html#jeach + + // json_each is a regular table-valued function; however, since it accepts an (optional) JSONPATH argument - which we represent + // as IReadOnlyList, and that can only be rendered as a string here in the QuerySqlGenerator, we have a special + // expression type for it. + Sql.Append("json_each("); + + Visit(jsonEachExpression.JsonExpression); + + var path = jsonEachExpression.Path; + + if (path is not null) + { + Sql.Append(", "); + + // Note the difference with the JSONPATH rendering in VisitJsonScalar below, where we take advantage of SQLite's ->> operator + // (we can't do that here). + Sql.Append("'$"); + + var inJsonpathString = true; + + for (var i = 0; i < path.Count; i++) + { + switch (path[i]) + { + case { PropertyName: string propertyName }: + Sql.Append(".").Append(propertyName); + break; + + case { ArrayIndex: SqlExpression arrayIndex }: + Sql.Append("["); + + if (arrayIndex is SqlConstantExpression) + { + Visit(arrayIndex); + } + else + { + Sql.Append("' || "); + Visit(arrayIndex); + Sql.Append(" || '"); + } + + Sql.Append("]"); + break; + + default: + throw new ArgumentOutOfRangeException(); + } + } + + if (inJsonpathString) + { + Sql.Append("'"); + } + } + + Sql.Append(")"); + + Sql.Append(AliasSeparator).Append(Dependencies.SqlGenerationHelper.DelimitIdentifier(jsonEachExpression.Alias)); + } + /// /// 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 @@ -131,16 +205,15 @@ private void GenerateRegexp(RegexpExpression regexpExpression, bool negated = fa /// protected override Expression VisitJsonScalar(JsonScalarExpression jsonScalarExpression) { + Visit(jsonScalarExpression.Json); + // TODO: Stop producing empty JsonScalarExpressions, #30768 var path = jsonScalarExpression.Path; if (path.Count == 0) { - Visit(jsonScalarExpression.Json); return jsonScalarExpression; } - Visit(jsonScalarExpression.Json); - var inJsonpathString = false; for (var i = 0; i < path.Count; i++) diff --git a/src/EFCore.Sqlite.Core/Query/Internal/SqliteQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.Sqlite.Core/Query/Internal/SqliteQueryableMethodTranslatingExpressionVisitor.cs index e13d8407632..400ae1cc01b 100644 --- a/src/EFCore.Sqlite.Core/Query/Internal/SqliteQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.Sqlite.Core/Query/Internal/SqliteQueryableMethodTranslatingExpressionVisitor.cs @@ -4,6 +4,7 @@ using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; using Microsoft.EntityFrameworkCore.Sqlite.Internal; +using Microsoft.EntityFrameworkCore.Sqlite.Query.SqlExpressions.Internal; using Microsoft.EntityFrameworkCore.Sqlite.Storage.Internal; namespace Microsoft.EntityFrameworkCore.Sqlite.Query.Internal; @@ -54,6 +55,15 @@ protected SqliteQueryableMethodTranslatingExpressionVisitor( _areJsonFunctionsSupported = parentVisitor._areJsonFunctionsSupported; } + /// + /// 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 QueryableMethodTranslatingExpressionVisitor CreateSubqueryVisitor() + => new SqliteQueryableMethodTranslatingExpressionVisitor(this); + /// /// 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 @@ -90,15 +100,6 @@ protected SqliteQueryableMethodTranslatingExpressionVisitor( return base.TranslateAny(source, predicate); } - /// - /// 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 QueryableMethodTranslatingExpressionVisitor CreateSubqueryVisitor() - => new SqliteQueryableMethodTranslatingExpressionVisitor(this); - /// /// 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 @@ -215,7 +216,7 @@ protected override QueryableMethodTranslatingExpressionVisitor CreateSubqueryVis } var elementClrType = sqlExpression.Type.GetSequenceType(); - var jsonEachExpression = new TableValuedFunctionExpression(tableAlias, "json_each", new[] { sqlExpression }); + var jsonEachExpression = new JsonEachExpression(tableAlias, sqlExpression); // TODO: This is a temporary CLR type-based check; when we have proper metadata to determine if the element is nullable, use it here var isColumnNullable = elementClrType.IsNullableType(); @@ -250,7 +251,7 @@ protected override QueryableMethodTranslatingExpressionVisitor CreateSubqueryVis "key", typeof(int), typeMapping: _typeMappingSource.FindMapping(typeof(int)), - isColumnNullable), + columnNullable: false), ascending: true)); Expression shaperExpression = new ProjectionBindingExpression( @@ -268,6 +269,160 @@ protected override QueryableMethodTranslatingExpressionVisitor CreateSubqueryVis return new ShapedQueryExpression(selectExpression, shaperExpression); } + /// + /// 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 ShapedQueryExpression TransformJsonQueryToTable(JsonQueryExpression jsonQueryExpression) + { + var entityType = jsonQueryExpression.EntityType; + var textTypeMapping = _typeMappingSource.FindMapping(typeof(string)); + + // TODO: Refactor this out + // Calculate the table alias for the json_each expression based on the last named path segment + // (or the JSON column name if there are none) + var lastNamedPathSegment = jsonQueryExpression.Path.LastOrDefault(ps => ps.PropertyName is not null); + var tableAlias = char.ToLowerInvariant((lastNamedPathSegment.PropertyName ?? jsonQueryExpression.JsonColumn.Name)[0]).ToString(); + + // Handling a non-primitive JSON array is complicated on SQLite; unlike SQL Server OPENJSON and PostgreSQL jsonb_to_recordset, + // SQLite's json_each can only project elements of the array, and not properties within those elements. For example: + // SELECT value FROM json_each('[{"a":1,"b":"foo"}, {"a":2,"b":"bar"}]') + // This will return two rows, each with a string column representing an array element (i.e. {"a":1,"b":"foo"}). To decompose that + // into a and b columns, a further extraction is needed: + // SELECT value ->> 'a' AS a, value ->> 'b' AS b FROM json_each('[{"a":1,"b":"foo"}, {"a":2,"b":"bar"}]') + + // We therefore generate a minimal subquery projecting out all the properties and navigations, wrapped by a SelectExpression + // containing that: + // SELECT ... + // FROM (SELECT value ->> 'a' AS a, value ->> 'b' AS b FROM json_each(, )) AS j + // WHERE j.a = 8; + + // Unfortunately, while the subquery projects the entity, our EntityProjectionExpression currently supports only bare + // ColumnExpression (the above requires JsonScalarExpression). So we hack as if the subquery projects an anonymous type instead, + // with a member for each JSON property that needs to be projected. We then wrap it with a SelectExpression the projects a proper + // EntityProjectionExpression. + + var jsonEachExpression = new JsonEachExpression(tableAlias, jsonQueryExpression.JsonColumn, jsonQueryExpression.Path); + +#pragma warning disable EF1001 // Internal EF Core API usage. + var selectExpression = new SelectExpression( + jsonQueryExpression, + jsonEachExpression, + "key", + typeof(int), + _typeMappingSource.FindMapping(typeof(int))!); +#pragma warning restore EF1001 // Internal EF Core API usage. + + selectExpression.AppendOrdering( + new OrderingExpression( + selectExpression.CreateColumnExpression( + jsonEachExpression, + "key", + typeof(int), + typeMapping: _typeMappingSource.FindMapping(typeof(int)), + columnNullable: false), + ascending: true)); + + var propertyJsonScalarExpression = new Dictionary(); + + var jsonColumn = selectExpression.CreateColumnExpression( + jsonEachExpression, "value", typeof(string), _typeMappingSource.FindMapping(typeof(string))); // TODO: nullable? + + var containerColumnName = entityType.GetContainerColumnName(); + Check.DebugAssert(containerColumnName is not null, "JsonQueryExpression to entity type without a container column name"); + + // First step: build a SelectExpression that will execute json_each and project all properties and navigations out, e.g. + // (SELECT value ->> 'a' AS a, value ->> 'b' AS b FROM json_each(c."JsonColumn", '$.Something.SomeCollection') + + // We're only interested in properties which actually exist in the JSON, filter out uninteresting shadow keys + foreach (var property in GetAllPropertiesInHierarchy(entityType)) + { + if (property.GetJsonPropertyName() is string jsonPropertyName) + { + // HACK: currently the only way to project multiple values from a SelectExpression is to simulate a Select out to an anonymous + // type; this requires the MethodInfos of the anonymous type properties, from which the projection alias gets taken. + // So we create fake members to hold the JSON property name for the alias. + var projectionMember = new ProjectionMember().Append(new FakeMemberInfo(jsonPropertyName)); + + propertyJsonScalarExpression[projectionMember] = new JsonScalarExpression( + jsonColumn, + new[] { new PathSegment(property.GetJsonPropertyName()!) }, + property.ClrType.UnwrapNullableType(), + property.GetRelationalTypeMapping(), + property.IsNullable); + } + } + + foreach (var navigation in GetAllNavigationsInHierarchy(jsonQueryExpression.EntityType) + .Where( + n => n.ForeignKey.IsOwnership + && n.TargetEntityType.IsMappedToJson() + && n.ForeignKey.PrincipalToDependent == n)) + { + var jsonNavigationName = navigation.TargetEntityType.GetJsonPropertyName(); + Check.DebugAssert(jsonNavigationName is not null, "Invalid navigation found on JSON-mapped entity"); + + var projectionMember = new ProjectionMember().Append(new FakeMemberInfo(jsonNavigationName)); + + propertyJsonScalarExpression[projectionMember] = new JsonScalarExpression( + jsonColumn, + new[] { new PathSegment(jsonNavigationName) }, + typeof(string), + textTypeMapping, + !navigation.ForeignKey.IsRequiredDependent); + } + + selectExpression.ReplaceProjection(propertyJsonScalarExpression); + + // Second step: push the above SelectExpression down to a subquery, and project an entity projection from the outer + // SelectExpression, i.e. + // SELECT "t"."a", "t"."b" + // FROM (SELECT value ->> 'a' ... FROM json_each(...)) + + selectExpression.PushdownIntoSubquery(); + var subquery = selectExpression.Tables[0]; + +#pragma warning disable EF1001 // Internal EF Core API usage. + var newOuterSelectExpression = new SelectExpression( + jsonQueryExpression, + subquery, + "key", + typeof(int), + _typeMappingSource.FindMapping(typeof(int))!); +#pragma warning restore EF1001 // Internal EF Core API usage. + + newOuterSelectExpression.AppendOrdering( + new OrderingExpression( + selectExpression.CreateColumnExpression( + subquery, + "key", + typeof(int), + typeMapping: _typeMappingSource.FindMapping(typeof(int)), + columnNullable: false), + ascending: true)); + + return new ShapedQueryExpression( + newOuterSelectExpression, + new RelationalEntityShaperExpression( + jsonQueryExpression.EntityType, + new ProjectionBindingExpression( + newOuterSelectExpression, + new ProjectionMember(), + typeof(ValueBuffer)), + false)); + + // TODO: Move these to IEntityType? + static IEnumerable GetAllPropertiesInHierarchy(IEntityType entityType) + => entityType.GetAllBaseTypes().Concat(entityType.GetDerivedTypesInclusive()) + .SelectMany(t => t.GetDeclaredProperties()); + + static IEnumerable GetAllNavigationsInHierarchy(IEntityType entityType) + => entityType.GetAllBaseTypes().Concat(entityType.GetDerivedTypesInclusive()) + .SelectMany(t => t.GetDeclaredNavigations()); + } + /// /// 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 @@ -335,6 +490,35 @@ protected override QueryableMethodTranslatingExpressionVisitor CreateSubqueryVis return base.TranslateElementAtOrDefault(source, index, returnDefault); } + /// + /// 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 bool IsNaturallyOrdered(SelectExpression selectExpression) + { + return selectExpression is + { + Tables: [var mainTable, ..], + Orderings: + [ + { + Expression: ColumnExpression { Name: "key", Table: var orderingTable } orderingColumn, + IsAscending: true + } + ] + } + && orderingTable == mainTable + && IsJsonEachKeyColumn(orderingColumn); + + bool IsJsonEachKeyColumn(ColumnExpression orderingColumn) + => orderingColumn.Table is JsonEachExpression + || (orderingColumn.Table is SelectExpression subquery + && subquery.Projection.FirstOrDefault(p => p.Alias == "key")?.Expression is ColumnExpression projectedColumn + && IsJsonEachKeyColumn(projectedColumn)); + } + private static Type GetProviderType(SqlExpression expression) => expression.TypeMapping?.Converter?.ProviderClrType ?? expression.TypeMapping?.ClrType @@ -531,4 +715,22 @@ private static SqlExpression ApplyJsonSqlConversion( _ => expression }; + + private sealed class FakeMemberInfo : MemberInfo + { + public FakeMemberInfo(string name) + => Name = name; + + public override string Name { get; } + + public override object[] GetCustomAttributes(bool inherit) + => throw new NotSupportedException(); + public override object[] GetCustomAttributes(Type attributeType, bool inherit) + => throw new NotSupportedException(); + public override bool IsDefined(Type attributeType, bool inherit) + => throw new NotSupportedException(); + public override Type? DeclaringType => throw new NotSupportedException(); + public override MemberTypes MemberType => throw new NotSupportedException(); + public override Type? ReflectedType => throw new NotSupportedException(); + } } diff --git a/src/EFCore.Sqlite.Core/Query/SqlExpressions/Internal/JsonEachExpression.cs b/src/EFCore.Sqlite.Core/Query/SqlExpressions/Internal/JsonEachExpression.cs new file mode 100644 index 00000000000..0a45c8c784d --- /dev/null +++ b/src/EFCore.Sqlite.Core/Query/SqlExpressions/Internal/JsonEachExpression.cs @@ -0,0 +1,185 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace Microsoft.EntityFrameworkCore.Sqlite.Query.SqlExpressions.Internal; + +/// +/// An expression that represents a SQLite json_each function call in a SQL tree. +/// +/// +/// +/// See json_each for more information and examples. +/// +/// +/// 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 JsonEachExpression : TableValuedFunctionExpression, IClonableTableExpressionBase +{ + /// + /// 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 virtual SqlExpression JsonExpression + => Arguments[0]; + + /// + /// 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 virtual IReadOnlyList? Path { get; } + + /// + /// 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 JsonEachExpression( + string alias, + SqlExpression jsonExpression, + IReadOnlyList? path = null) + : base(alias, "json_each", schema: null, builtIn: true, new[] { jsonExpression }) + { + Path = path; + } + + /// + /// 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 VisitChildren(ExpressionVisitor visitor) + { + var visitedJsonExpression = (SqlExpression)visitor.Visit(JsonExpression); + + PathSegment[]? visitedPath = null; + + if (Path is not null) + { + for (var i = 0; i < Path.Count; i++) + { + var segment = Path[i]; + PathSegment newSegment; + + if (segment.PropertyName is not null) + { + // PropertyName segments are (currently) constants, nothing to visit. + newSegment = segment; + } + else + { + var newArrayIndex = (SqlExpression)visitor.Visit(segment.ArrayIndex)!; + if (newArrayIndex == segment.ArrayIndex) + { + newSegment = segment; + } + else + { + newSegment = new PathSegment(newArrayIndex); + + if (visitedPath is null) + { + visitedPath = new PathSegment[Path.Count]; + for (var j = 0; j < i; i++) + { + visitedPath[j] = Path[j]; + } + } + } + } + + if (visitedPath is not null) + { + visitedPath[i] = newSegment; + } + } + } + + return Update(visitedJsonExpression, visitedPath ?? Path); + } + + /// + /// 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 virtual JsonEachExpression Update( + SqlExpression jsonExpression, + IReadOnlyList? path) + => jsonExpression == JsonExpression + && (ReferenceEquals(path, Path) || path is not null && Path is not null && path.SequenceEqual(Path)) + ? this + : new JsonEachExpression(Alias, jsonExpression, path); + + /// + /// 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. + /// + // TODO: Deep clone, see #30982 + public virtual TableExpressionBase Clone() + { + var clone = new JsonEachExpression(Alias, JsonExpression, Path); + + foreach (var annotation in GetAnnotations()) + { + clone.AddAnnotation(annotation.Name, annotation.Value); + } + + return clone; + } + + /// + /// 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 void Print(ExpressionPrinter expressionPrinter) + { + expressionPrinter.Append(Name); + expressionPrinter.Append("("); + expressionPrinter.Visit(JsonExpression); + + if (Path is not null) + { + expressionPrinter + .Append(", '") + .Append(string.Join(".", Path.Select(e => e.ToString()))) + .Append("'"); + } + + expressionPrinter.Append(")"); + + PrintAnnotations(expressionPrinter); + + expressionPrinter.Append(" AS "); + expressionPrinter.Append(Alias); + } + + /// + public override bool Equals(object? obj) + => ReferenceEquals(this, obj) || (obj is JsonEachExpression jsonEachExpression && Equals(jsonEachExpression)); + + private bool Equals(JsonEachExpression other) + => base.Equals(other) + && (ReferenceEquals(Path, other.Path) + || (Path is not null && other.Path is not null && Path.SequenceEqual(other.Path))); + + /// + public override int GetHashCode() + => base.GetHashCode(); +} diff --git a/test/EFCore.Relational.Specification.Tests/Query/JsonQueryFixtureBase.cs b/test/EFCore.Relational.Specification.Tests/Query/JsonQueryFixtureBase.cs index a7a9047061f..7fa889511ef 100644 --- a/test/EFCore.Relational.Specification.Tests/Query/JsonQueryFixtureBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Query/JsonQueryFixtureBase.cs @@ -63,12 +63,15 @@ public virtual ISetSource GetExpectedData() Assert.Equal(ee.Id, aa.Id); Assert.Equal(ee.Name, aa.Name); - AssertOwnedRoot(ee.OwnedReferenceRoot, aa.OwnedReferenceRoot); - - Assert.Equal(ee.OwnedCollectionRoot.Count, aa.OwnedCollectionRoot.Count); - for (var i = 0; i < ee.OwnedCollectionRoot.Count; i++) + if (ee.OwnedReferenceRoot is not null || aa.OwnedReferenceRoot is not null) { - AssertOwnedRoot(ee.OwnedCollectionRoot[i], aa.OwnedCollectionRoot[i]); + AssertOwnedRoot(ee.OwnedReferenceRoot, aa.OwnedReferenceRoot); + + Assert.Equal(ee.OwnedCollectionRoot.Count, aa.OwnedCollectionRoot.Count); + for (var i = 0; i < ee.OwnedCollectionRoot.Count; i++) + { + AssertOwnedRoot(ee.OwnedCollectionRoot[i], aa.OwnedCollectionRoot[i]); + } } } } @@ -320,7 +323,7 @@ public virtual ISetSource GetExpectedData() }, }.ToDictionary(e => e.Key, e => (object)e.Value); - private static void AssertOwnedRoot(JsonOwnedRoot expected, JsonOwnedRoot actual) + public static void AssertOwnedRoot(JsonOwnedRoot expected, JsonOwnedRoot actual) { Assert.Equal(expected.Name, actual.Name); Assert.Equal(expected.Number, actual.Number); @@ -335,7 +338,7 @@ private static void AssertOwnedRoot(JsonOwnedRoot expected, JsonOwnedRoot actual } } - private static void AssertOwnedBranch(JsonOwnedBranch expected, JsonOwnedBranch actual) + public static void AssertOwnedBranch(JsonOwnedBranch expected, JsonOwnedBranch actual) { Assert.Equal(expected.Date, actual.Date); Assert.Equal(expected.Fraction, actual.Fraction); @@ -352,7 +355,7 @@ private static void AssertOwnedBranch(JsonOwnedBranch expected, JsonOwnedBranch } } - private static void AssertOwnedLeaf(JsonOwnedLeaf expected, JsonOwnedLeaf actual) + public static void AssertOwnedLeaf(JsonOwnedLeaf expected, JsonOwnedLeaf actual) => Assert.Equal(expected.SomethingSomething, actual.SomethingSomething); public static void AssertCustomNameRoot(JsonOwnedCustomNameRoot expected, JsonOwnedCustomNameRoot actual) diff --git a/test/EFCore.Relational.Specification.Tests/Query/JsonQueryTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/JsonQueryTestBase.cs index e727769044f..4e53cc0c647 100644 --- a/test/EFCore.Relational.Specification.Tests/Query/JsonQueryTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Query/JsonQueryTestBase.cs @@ -795,28 +795,28 @@ public virtual Task Json_entity_backtracking(bool async) [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_basic(bool async) + public virtual Task Json_collection_index_in_projection_basic(bool async) => AssertQuery( async, 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) + public virtual Task Json_collection_ElementAt_in_projection(bool async) => AssertQuery( async, ss => ss.Set().Select(x => x.OwnedCollectionRoot.AsQueryable().ElementAt(1)).AsNoTracking()); [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_using_ElementAtOrDefault(bool async) + public virtual Task Json_collection_ElementAtOrDefault_in_projection(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) + public virtual Task Json_collection_index_in_projection_project_collection(bool async) => AssertQuery( async, ss => ss.Set().Select(x => x.OwnedCollectionRoot[1].OwnedCollectionBranch).AsNoTracking(), @@ -824,7 +824,7 @@ public virtual Task Json_collection_element_access_in_projection_project_collect [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_using_ElementAt_project_collection(bool async) + public virtual Task Json_collection_ElementAt_project_collection(bool async) => AssertQuery( async, ss => ss.Set() @@ -834,7 +834,7 @@ public virtual Task Json_collection_element_access_in_projection_using_ElementAt [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_using_ElementAtOrDefault_project_collection(bool async) + public virtual Task Json_collection_ElementAtOrDefault_project_collection(bool async) => AssertQuery( async, ss => ss.Set() @@ -844,7 +844,7 @@ public virtual Task Json_collection_element_access_in_projection_using_ElementAt [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_using_parameter(bool async) + public virtual Task Json_collection_index_in_projection_using_parameter(bool async) { var prm = 0; @@ -855,7 +855,7 @@ public virtual Task Json_collection_element_access_in_projection_using_parameter [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_using_column(bool async) + public virtual Task Json_collection_index_in_projection_using_column(bool async) => AssertQuery( async, ss => ss.Set().Select(x => x.OwnedCollectionRoot[x.Id]).AsNoTracking()); @@ -865,35 +865,21 @@ private static int MyMethod(int 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( + public virtual Task Json_collection_index_in_projection_using_untranslatable_client_method(bool async) + => AssertQuery( async, - ss => ss.Set().Select(x => x.OwnedCollectionRoot[MyMethod(x.Id)]).AsNoTracking()))).Message; - - Assert.Equal( - CoreStrings.TranslationFailed("j.OwnedCollectionRoot Q-> "), - message); - } + ss => ss.Set().Select(x => x.OwnedCollectionRoot[MyMethod(x.Id)]).AsNoTracking()); [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( + public virtual Task Json_collection_index_in_projection_using_untranslatable_client_method2(bool async) + => AssertQuery( async, - ss => ss.Set().Select(x => x.OwnedCollectionRoot[0].OwnedReferenceBranch.OwnedCollectionLeaf[MyMethod(x.Id)]).AsNoTracking()))).Message; - - Assert.Equal( - CoreStrings.TranslationFailed("j.OwnedCollectionRoot Q-> [0].OwnedReferenceBranch.OwnedCollectionLeaf"), - message); - } + ss => ss.Set().Select(x => x.OwnedCollectionRoot[0].OwnedReferenceBranch.OwnedCollectionLeaf[MyMethod(x.Id)]).AsNoTracking()); [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_outside_bounds(bool async) + public virtual Task Json_collection_index_outside_bounds(bool async) => AssertQuery( async, ss => ss.Set().Select(x => x.OwnedCollectionRoot[25]).AsNoTracking(), @@ -901,7 +887,7 @@ public virtual Task Json_collection_element_access_outside_bounds(bool async) [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_outside_bounds2(bool async) + public virtual Task Json_collection_index_outside_bounds2(bool async) => AssertQuery( async, ss => ss.Set().Select(x => x.OwnedReferenceRoot.OwnedReferenceBranch.OwnedCollectionLeaf[25]).AsNoTracking(), @@ -909,7 +895,7 @@ public virtual Task Json_collection_element_access_outside_bounds2(bool async) [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_outside_bounds_with_property_access(bool async) + public virtual Task Json_collection_index_outside_bounds_with_property_access(bool async) => AssertQueryScalar( async, ss => ss.Set().OrderBy(x => x.Id).Select(x => (int?)x.OwnedCollectionRoot[25].Number), @@ -917,7 +903,7 @@ public virtual Task Json_collection_element_access_outside_bounds_with_property_ [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_nested(bool async) + public virtual Task Json_collection_index_in_projection_nested(bool async) { var prm = 1; @@ -928,7 +914,7 @@ public virtual Task Json_collection_element_access_in_projection_nested(bool asy [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_nested_project_scalar(bool async) + public virtual Task Json_collection_index_in_projection_nested_project_scalar(bool async) { var prm = 1; @@ -939,7 +925,7 @@ public virtual Task Json_collection_element_access_in_projection_nested_project_ [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_nested_project_reference(bool async) + public virtual Task Json_collection_index_in_projection_nested_project_reference(bool async) { var prm = 1; @@ -950,7 +936,7 @@ public virtual Task Json_collection_element_access_in_projection_nested_project_ [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_nested_project_collection(bool async) + public virtual Task Json_collection_index_in_projection_nested_project_collection(bool async) { var prm = 1; @@ -966,7 +952,7 @@ public virtual Task Json_collection_element_access_in_projection_nested_project_ [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_nested_project_collection_anonymous_projection(bool async) + public virtual Task Json_collection_index_in_projection_nested_project_collection_anonymous_projection(bool async) { var prm = 1; @@ -985,14 +971,14 @@ public virtual Task Json_collection_element_access_in_projection_nested_project_ [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_predicate_using_constant(bool async) + public virtual Task Json_collection_index_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) + public virtual Task Json_collection_index_in_predicate_using_variable(bool async) { var prm = 1; @@ -1003,7 +989,7 @@ public virtual Task Json_collection_element_access_in_predicate_using_variable(b [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_predicate_using_column(bool async) + public virtual Task Json_collection_index_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 }), @@ -1017,7 +1003,7 @@ public virtual Task Json_collection_element_access_in_predicate_using_column(boo [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_predicate_using_complex_expression1(bool async) + public virtual Task Json_collection_index_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 }), @@ -1031,7 +1017,7 @@ public virtual Task Json_collection_element_access_in_predicate_using_complex_ex [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_predicate_using_complex_expression2(bool async) + public virtual Task Json_collection_index_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 }), @@ -1045,14 +1031,14 @@ public virtual Task Json_collection_element_access_in_predicate_using_complex_ex [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_predicate_using_ElementAt(bool async) + public virtual Task Json_collection_ElementAt_in_predicate(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) + public virtual Task Json_collection_index_in_predicate_nested_mix(bool async) { var prm = 0; @@ -1064,7 +1050,7 @@ public virtual Task Json_collection_element_access_in_predicate_nested_mix(bool [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_manual_Element_at_and_pushdown(bool async) + public virtual Task Json_collection_ElementAt_and_pushdown(bool async) => AssertQuery( async, ss => ss.Set().Select(x => new @@ -1075,101 +1061,348 @@ public virtual Task Json_collection_element_access_manual_Element_at_and_pushdow [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; + public virtual Task Json_collection_Any_with_predicate(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(j => j.OwnedReferenceRoot.OwnedCollectionBranch.Any(b => b.OwnedReferenceLeaf.SomethingSomething == "e1_c2_c1_c1"))); + // TODO: Need entries - Assert.Equal(CoreStrings.TranslationFailed("j.OwnedCollectionRoot Q-> [__prm_0].OwnedCollectionBranch"), message); - } + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_Where_ElementAt(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(j => + j.OwnedReferenceRoot.OwnedCollectionBranch + .Where(o => o.Enum == JsonEnum.Three) + .ElementAt(0).OwnedReferenceLeaf.SomethingSomething == "e1_r_c2_r"), + entryCount: 40); [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; + public virtual Task Json_collection_Skip(bool async) + => AssertQuery( + async, + ss => ss.Set() + .Where(j => j.OwnedReferenceRoot.OwnedCollectionBranch + .Skip(1) + .ElementAt(0).OwnedReferenceLeaf.SomethingSomething == "e1_r_c2_r"), + entryCount: 40); - Assert.Equal(CoreStrings.TranslationFailed("j.OwnedCollectionRoot Q-> [(...)].OwnedCollectionBranch"), message); - } + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_OrderByDescending_Skip_ElementAt(bool async) + => AssertQuery( + async, + ss => ss.Set() + .Where(j => j.OwnedReferenceRoot.OwnedCollectionBranch + .OrderByDescending(b => b.Date) + .Skip(1) + .ElementAt(0).OwnedReferenceLeaf.SomethingSomething == "e1_r_c1_r"), + entryCount: 40); + // If this test is failing because of DistinctAfterOrderByWithoutRowLimitingOperatorWarning, this is because EF warns/errors by + // default for Distinct after OrderBy (without Skip/Take); but you likely have a naturally-ordered JSON collection, where the + // ordering has been added by the provider as part of the collection translation. + // Consider overriding RelationalQueryableMethodTranslatingExpressionVisitor.IsNaturallyOrdered() to identify such naturally-ordered + // collections, exempting them from the warning. [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 + public virtual Task Json_collection_Distinct_Count_with_predicate(bool async) + => AssertQuery( + async, + ss => ss.Set() + .Where(j => j.OwnedReferenceRoot.OwnedCollectionBranch + .Distinct() + .Count(b => b.OwnedReferenceLeaf.SomethingSomething == "e1_r_c2_r") == 1), + entryCount: 40); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_within_collection_Count(bool async) + => AssertQuery( + async, + ss => ss.Set() + .Where(j => j.OwnedCollectionRoot.Any(c => c.OwnedCollectionBranch.Count == 2)), + entryCount: 40); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_in_projection_with_composition_count(bool async) + => AssertQueryScalar( + async, + ss => ss.Set() + .OrderBy(x => x.Id) + .Select(x => x.OwnedCollectionRoot.Count)); + + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_in_projection_with_anonymous_projection_of_scalars(bool async) + => AssertQuery( + async, + ss => ss.Set() + .OrderBy(x => x.Id) + .Select(x => x.OwnedCollectionRoot + .Select(xx => new { xx.Name, xx.Number }) + .ToList())); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_in_projection_with_composition_where_and_anonymous_projection_of_scalars(bool async) + => AssertQuery( + async, + ss => ss.Set() + .OrderBy(x => x.Id) + .Select(x => x.OwnedCollectionRoot + .Where(xx => xx.Name == "Foo") + .Select(xx => new { xx.Name, xx.Number }) + .ToList())); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_in_projection_with_composition_where_and_anonymous_projection_of_primitive_arrays(bool async) + => AssertQuery( + async, + ss => ss.Set() + .OrderBy(x => x.Id) + .Select(x => x.OwnedCollectionRoot + .Where(xx => xx.Name == "Foo") + .Select(xx => new { xx.Names, xx.Numbers }) + .ToList())); + + [ConditionalTheory(Skip = "issue #31365")] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_filter_in_projection(bool async) + => AssertQuery( + async, + ss => ss.Set() + .OrderBy(x => x.Id) + .Select(x => x.OwnedCollectionRoot.Where(xx => xx.Name != "Foo").ToList()) + .AsNoTracking()); + + [ConditionalTheory(Skip = "issue #31365")] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_skip_take_in_projection(bool async) + => AssertQuery( + async, + ss => ss.Set() + .OrderBy(x => x.Id) + .Select(x => x.OwnedCollectionRoot.OrderBy(xx => xx.Name).Skip(1).Take(5).ToList()) + .AsNoTracking()); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_skip_take_in_projection_project_into_anonymous_type(bool async) + => AssertQuery( + async, + ss => ss.Set() + .OrderBy(x => x.Id) + .Select(x => x.OwnedCollectionRoot + .OrderBy(xx => xx.Name) + .Skip(1) + .Take(5) + .Select(xx => new + { + xx.Name, + xx.Names, + xx.Number, + xx.Numbers, + xx.OwnedCollectionBranch, + xx.OwnedReferenceBranch + }).ToList()) + .AsNoTracking(), + assertOrder: true, + elementAsserter: (e, a) => + { + AssertCollection(e, a, ordered: true, elementAsserter: (ee, aa) => { - x.Id, - CollectionElement = x.OwnedCollectionRoot.Select(xx => x.OwnedReferenceRoot).ElementAt(0) - })))).Message; + AssertEqual(ee.Name, aa.Name); + AssertCollection(ee.Names, aa.Names, ordered: true); + AssertEqual(ee.Number, aa.Number); + AssertCollection(ee.Numbers, aa.Numbers, ordered: true); + AssertCollection(ee.OwnedCollectionBranch, aa.OwnedCollectionBranch, ordered: true); + AssertEqual(ee.OwnedReferenceBranch, aa.OwnedReferenceBranch); + }); + }); - Assert.Equal(CoreStrings.TranslationFailed("j.OwnedCollectionRoot Q-> "), message); - } + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_skip_take_in_projection_with_json_reference_access_as_final_operation(bool async) + => AssertQuery( + async, + ss => ss.Set() + .OrderBy(x => x.Id) + .Select(x => x.OwnedCollectionRoot + .OrderBy(xx => xx.Name) + .Skip(1) + .Take(5) + .Select(xx => xx.OwnedReferenceBranch).ToList()) + .AsNoTracking(), + assertOrder: true, + elementAsserter: (e, a) => + { + AssertCollection(e, a, ordered: true); + }); + + [ConditionalTheory(Skip = "issue #31365")] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_distinct_in_projection(bool async) + => AssertQuery( + async, + ss => ss.Set() + .OrderBy(x => x.Id) + .Select(x => x.OwnedCollectionRoot.Distinct()) + .AsNoTracking()); + + [ConditionalTheory(Skip = "issue #31365")] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_leaf_filter_in_projection(bool async) + => AssertQuery( + async, + ss => ss.Set() + .OrderBy(x => x.Id) + .Select(x => x.OwnedReferenceRoot.OwnedReferenceBranch.OwnedCollectionLeaf + .Where(xx => xx.SomethingSomething != "Baz").ToList()) + .AsNoTracking()); + + [ConditionalTheory(Skip = "issue #31365")] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_SelectMany(bool async) + => AssertQuery( + async, + ss => ss.Set() + .SelectMany(x => x.OwnedCollectionRoot) + .AsNoTracking()); + + [ConditionalTheory(Skip = "issue #31365")] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_nested_collection_SelectMany(bool async) + => AssertQuery( + async, + ss => ss.Set() + .SelectMany(x => x.OwnedReferenceRoot.OwnedCollectionBranch) + .AsNoTracking()); + + [ConditionalTheory(Skip = "issue #31364")] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_of_primitives_SelectMany(bool async) + => AssertQuery( + async, + ss => ss.Set() + .SelectMany(x => x.OwnedReferenceRoot.Names)); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_of_primitives_index_used_in_predicate(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(x => x.OwnedReferenceRoot.Names[0] == "e1_r1"), + entryCount: 40); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_of_primitives_index_used_in_projection(bool async) + => AssertQueryScalar( + async, + ss => ss.Set().OrderBy(x => x.Id).Select(x => x.OwnedReferenceRoot.OwnedReferenceBranch.Enums[0]), + assertOrder: true); [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative4(bool async) + public virtual Task Json_collection_of_primitives_index_used_in_orderby(bool async) + => AssertQuery( + async, + ss => ss.Set().OrderBy(x => x.OwnedReferenceRoot.Numbers[0]), + assertOrder: true, + entryCount: 40); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Json_collection_of_primitives_contains_in_predicate(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(x => x.OwnedReferenceRoot.Names.Contains("e1_r1")), + assertOrder: true, + entryCount: 40); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Json_collection_index_with_parameter_Select_ElementAt(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; + var prm = 0; - Assert.Equal(CoreStrings.TranslationFailed("j.OwnedCollectionRoot Q-> "), message); + await AssertQuery( + async, + ss => ss.Set().Select( + x => new { x.Id, CollectionElement = x.OwnedCollectionRoot[prm].OwnedCollectionBranch.Select(xx => "Foo").ElementAt(0) })); } [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative5(bool async) + public virtual async Task Json_collection_index_with_expression_Select_ElementAt(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; + var prm = 0; - Assert.Equal(CoreStrings.TranslationFailed("j.OwnedCollectionRoot Q-> "), message); + await AssertQuery( + async, + ss => ss.Set().Select( + j => j.OwnedCollectionRoot[prm + j.Id].OwnedCollectionBranch + .Select(b => b.OwnedReferenceLeaf.SomethingSomething) + .ElementAt(0)), + ss => ss.Set().Select( + j => j.OwnedCollectionRoot.Count > prm + j.Id + ? j.OwnedCollectionRoot[prm + j.Id].OwnedCollectionBranch + .Select(b => b.OwnedReferenceLeaf.SomethingSomething) + .ElementAt(0) + : null)); } [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 + public virtual async Task Json_collection_Select_entity_collection_ElementAt(bool async) + => await AssertQuery( + async, + ss => ss.Set() + .AsNoTracking() + .Select(x => x.OwnedCollectionRoot.Select(xx => xx.OwnedCollectionBranch).ElementAt(0)), + elementAsserter: (e, a) => + { + Assert.Equal(e.Count, a.Count); + for (var i = 0; i < e.Count; i++) { - x.Id, - CollectionElement = x.OwnedCollectionRoot.Select(xx => new JsonEntityBasic { Id = x.Id }).ElementAt(0) - })))).Message; + JsonQueryFixtureBase.AssertOwnedBranch(e[i], a[i]); + } + }); - Assert.Equal(CoreStrings.TranslationFailed("j.OwnedCollectionRoot Q-> "), message); - } + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Json_collection_Select_entity_ElementAt(bool async) + => await AssertQuery( + async, + ss => ss.Set().AsNoTracking().Select(x => + x.OwnedCollectionRoot.Select(xx => xx.OwnedReferenceBranch).ElementAt(0))); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Json_collection_Select_entity_in_anonymous_object_ElementAt(bool async) + => await AssertQuery( + async, + ss => ss.Set().AsNoTracking().OrderBy(x => x.Id).Select(x => + x.OwnedCollectionRoot.Select(xx => new { xx.OwnedReferenceBranch }).ElementAt(0)), + assertOrder: true, + elementAsserter: (e, a) => + { + AssertEqual(e.OwnedReferenceBranch, a.OwnedReferenceBranch); + }); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Json_collection_Select_entity_with_initializer_ElementAt(bool async) + => await AssertQuery( + async, + ss => ss.Set().Select( + x => x.OwnedCollectionRoot.Select(xx => new JsonEntityBasic { Id = x.Id }).ElementAt(0))); [ConditionalTheory] [MemberData(nameof(IsAsyncData))] @@ -1246,7 +1479,7 @@ public virtual Task Json_projection_deduplication_with_collection_in_original_an [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_using_constant_when_owner_is_present(bool async) + public virtual Task Json_collection_index_in_projection_using_constant_when_owner_is_present(bool async) => AssertQuery( async, ss => ss.Set().Select(x => new @@ -1263,7 +1496,7 @@ public virtual Task Json_collection_element_access_in_projection_using_constant_ [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_using_constant_when_owner_is_not_present(bool async) + public virtual Task Json_collection_index_in_projection_using_constant_when_owner_is_not_present(bool async) => AssertQuery( async, ss => ss.Set().Select(x => new @@ -1280,7 +1513,7 @@ public virtual Task Json_collection_element_access_in_projection_using_constant_ [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_using_parameter_when_owner_is_present(bool async) + public virtual Task Json_collection_index_in_projection_using_parameter_when_owner_is_present(bool async) { var prm = 1; @@ -1301,7 +1534,7 @@ public virtual Task Json_collection_element_access_in_projection_using_parameter [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_using_parameter_when_owner_is_not_present(bool async) + public virtual Task Json_collection_index_in_projection_using_parameter_when_owner_is_not_present(bool async) { var prm = 1; @@ -1322,7 +1555,7 @@ public virtual Task Json_collection_element_access_in_projection_using_parameter [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_after_collection_element_access_in_projection_using_constant_when_owner_is_present(bool async) + public virtual Task Json_collection_after_collection_index_in_projection_using_constant_when_owner_is_present(bool async) => AssertQuery( async, ss => ss.Set().Select(x => new @@ -1339,7 +1572,7 @@ public virtual Task Json_collection_after_collection_element_access_in_projectio [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_after_collection_element_access_in_projection_using_constant_when_owner_is_not_present(bool async) + public virtual Task Json_collection_after_collection_index_in_projection_using_constant_when_owner_is_not_present(bool async) => AssertQuery( async, ss => ss.Set().Select(x => new @@ -1356,7 +1589,7 @@ public virtual Task Json_collection_after_collection_element_access_in_projectio [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_after_collection_element_access_in_projection_using_parameter_when_owner_is_present(bool async) + public virtual Task Json_collection_after_collection_index_in_projection_using_parameter_when_owner_is_present(bool async) { var prm = 1; @@ -1377,7 +1610,7 @@ public virtual Task Json_collection_after_collection_element_access_in_projectio [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_after_collection_element_access_in_projection_using_parameter_when_owner_is_not_present(bool async) + public virtual Task Json_collection_after_collection_index_in_projection_using_parameter_when_owner_is_not_present(bool async) { var prm = 1; @@ -1398,7 +1631,7 @@ public virtual Task Json_collection_after_collection_element_access_in_projectio [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_when_owner_is_present_misc1(bool async) + public virtual Task Json_collection_index_in_projection_when_owner_is_present_misc1(bool async) { var prm = 1; @@ -1419,7 +1652,7 @@ public virtual Task Json_collection_element_access_in_projection_when_owner_is_p [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_when_owner_is_not_present_misc1(bool async) + public virtual Task Json_collection_index_in_projection_when_owner_is_not_present_misc1(bool async) { var prm = 1; @@ -1440,7 +1673,7 @@ public virtual Task Json_collection_element_access_in_projection_when_owner_is_n [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_when_owner_is_present_misc2(bool async) + public virtual Task Json_collection_index_in_projection_when_owner_is_present_misc2(bool async) => AssertQuery( async, ss => ss.Set().Select(x => new @@ -1457,7 +1690,7 @@ public virtual Task Json_collection_element_access_in_projection_when_owner_is_p [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_when_owner_is_not_present_misc2(bool async) + public virtual Task Json_collection_index_in_projection_when_owner_is_not_present_misc2(bool async) => AssertQuery( async, ss => ss.Set().Select(x => new @@ -1474,7 +1707,7 @@ public virtual Task Json_collection_element_access_in_projection_when_owner_is_n [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_when_owner_is_present_multiple(bool async) + public virtual Task Json_collection_index_in_projection_when_owner_is_present_multiple(bool async) { var prm = 1; @@ -1511,7 +1744,7 @@ public virtual Task Json_collection_element_access_in_projection_when_owner_is_p [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual Task Json_collection_element_access_in_projection_when_owner_is_not_present_multiple(bool async) + public virtual Task Json_collection_index_in_projection_when_owner_is_not_present_multiple(bool async) { var prm = 1; diff --git a/test/EFCore.Specification.Tests/Query/PrimitiveCollectionsQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/PrimitiveCollectionsQueryTestBase.cs index f4bdaa5e302..b54d968cc90 100644 --- a/test/EFCore.Specification.Tests/Query/PrimitiveCollectionsQueryTestBase.cs +++ b/test/EFCore.Specification.Tests/Query/PrimitiveCollectionsQueryTestBase.cs @@ -455,6 +455,19 @@ public virtual Task Column_collection_Any(bool async) ss => ss.Set().Where(c => c.Ints.Any()), entryCount: 4); + // If this test is failing because of DistinctAfterOrderByWithoutRowLimitingOperatorWarning, this is because EF warns/errors by + // default for Distinct after OrderBy (without Skip/Take); but you likely have a naturally-ordered JSON collection, where the + // ordering has been added by the provider as part of the collection translation. + // Consider overriding RelationalQueryableMethodTranslatingExpressionVisitor.IsNaturallyOrdered() to identify such naturally-ordered + // collections, exempting them from the warning. + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Column_collection_Distinct(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(c => c.Ints.Distinct().Count() == 3), + entryCount: 2); + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public virtual Task Column_collection_projection_from_top_level(bool async) @@ -800,6 +813,25 @@ public virtual Task Project_multiple_collections(bool async) }, assertOrder: true); + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Project_primitive_collections_element(bool async) + => AssertQuery( + async, + ss => ss.Set().Where(x => x.Id < 4).OrderBy(x => x.Id).Select(x => new + { + Indexer = x.Ints[0], + EnumerableElementAt = x.DateTimes.ElementAt(0), + QueryableElementAt = x.Strings.AsQueryable().ElementAt(1) + }), + elementAsserter: (e, a) => + { + AssertEqual(e.Indexer, a.Indexer); + AssertEqual(e.EnumerableElementAt, a.EnumerableElementAt); + AssertEqual(e.QueryableElementAt, a.QueryableElementAt); + }, + assertOrder: true); + public abstract class PrimitiveCollectionsQueryFixtureBase : SharedStoreFixtureBase, IQueryFixtureBase { private PrimitiveArrayData _expectedData; @@ -810,9 +842,6 @@ protected override string StoreName public Func GetContextCreator() => () => CreateContext(); - public override DbContextOptionsBuilder AddOptions(DbContextOptionsBuilder builder) - => base.AddOptions(builder).ConfigureWarnings(c => c.Log(CoreEventId.DistinctAfterOrderByWithoutRowLimitingOperatorWarning)); - protected override void OnModelCreating(ModelBuilder modelBuilder, DbContext context) => modelBuilder.Entity().Property(p => p.Id).ValueGeneratedNever(); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/JsonQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/JsonQuerySqlServerTest.cs index 30a49a728c0..fd705de4e48 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/JsonQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/JsonQuerySqlServerTest.cs @@ -671,9 +671,9 @@ public override async Task Json_entity_backtracking(bool async) @""); } - public override async Task Json_collection_element_access_in_projection_basic(bool async) + public override async Task Json_collection_index_in_projection_basic(bool async) { - await base.Json_collection_element_access_in_projection_basic(async); + await base.Json_collection_index_in_projection_basic(async); AssertSql( """ @@ -682,9 +682,9 @@ FROM [JsonEntitiesBasic] AS [j] """); } - public override async Task Json_collection_element_access_in_projection_using_ElementAt(bool async) + public override async Task Json_collection_ElementAt_in_projection(bool async) { - await base.Json_collection_element_access_in_projection_using_ElementAt(async); + await base.Json_collection_ElementAt_in_projection(async); AssertSql( """ @@ -693,9 +693,9 @@ FROM [JsonEntitiesBasic] AS [j] """); } - public override async Task Json_collection_element_access_in_projection_using_ElementAtOrDefault(bool async) + public override async Task Json_collection_ElementAtOrDefault_in_projection(bool async) { - await base.Json_collection_element_access_in_projection_using_ElementAtOrDefault(async); + await base.Json_collection_ElementAtOrDefault_in_projection(async); AssertSql( """ @@ -704,9 +704,9 @@ FROM [JsonEntitiesBasic] AS [j] """); } - public override async Task Json_collection_element_access_in_projection_project_collection(bool async) + public override async Task Json_collection_index_in_projection_project_collection(bool async) { - await base.Json_collection_element_access_in_projection_project_collection(async); + await base.Json_collection_index_in_projection_project_collection(async); AssertSql( """ @@ -715,9 +715,9 @@ FROM [JsonEntitiesBasic] AS [j] """); } - public override async Task Json_collection_element_access_in_projection_using_ElementAt_project_collection(bool async) + public override async Task Json_collection_ElementAt_project_collection(bool async) { - await base.Json_collection_element_access_in_projection_using_ElementAt_project_collection(async); + await base.Json_collection_ElementAt_project_collection(async); AssertSql( """ @@ -726,9 +726,9 @@ FROM [JsonEntitiesBasic] AS [j] """); } - public override async Task Json_collection_element_access_in_projection_using_ElementAtOrDefault_project_collection(bool async) + public override async Task Json_collection_ElementAtOrDefault_project_collection(bool async) { - await base.Json_collection_element_access_in_projection_using_ElementAtOrDefault_project_collection(async); + await base.Json_collection_ElementAtOrDefault_project_collection(async); AssertSql( """ @@ -738,9 +738,9 @@ FROM [JsonEntitiesBasic] AS [j] } [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] - public override async Task Json_collection_element_access_in_projection_using_parameter(bool async) + public override async Task Json_collection_index_in_projection_using_parameter(bool async) { - await base.Json_collection_element_access_in_projection_using_parameter(async); + await base.Json_collection_index_in_projection_using_parameter(async); AssertSql( """ @@ -752,9 +752,9 @@ FROM [JsonEntitiesBasic] AS [j] } [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] - public override async Task Json_collection_element_access_in_projection_using_column(bool async) + public override async Task Json_collection_index_in_projection_using_column(bool async) { - await base.Json_collection_element_access_in_projection_using_column(async); + await base.Json_collection_index_in_projection_using_column(async); AssertSql( """ @@ -763,23 +763,31 @@ FROM [JsonEntitiesBasic] AS [j] """); } - public override async Task Json_collection_element_access_in_projection_using_untranslatable_client_method(bool async) + public override async Task Json_collection_index_in_projection_using_untranslatable_client_method(bool async) { - await base.Json_collection_element_access_in_projection_using_untranslatable_client_method(async); + var message = (await Assert.ThrowsAsync( + () => base.Json_collection_index_in_projection_using_untranslatable_client_method(async))).Message; - AssertSql(); + Assert.Contains(CoreStrings.QueryUnableToTranslateMethod( + "Microsoft.EntityFrameworkCore.Query.JsonQueryTestBase", + "MyMethod"), + message); } - public override async Task Json_collection_element_access_in_projection_using_untranslatable_client_method2(bool async) + public override async Task Json_collection_index_in_projection_using_untranslatable_client_method2(bool async) { - await base.Json_collection_element_access_in_projection_using_untranslatable_client_method2(async); + var message = (await Assert.ThrowsAsync( + () => base.Json_collection_index_in_projection_using_untranslatable_client_method2(async))).Message; - AssertSql(); + Assert.Contains(CoreStrings.QueryUnableToTranslateMethod( + "Microsoft.EntityFrameworkCore.Query.JsonQueryTestBase", + "MyMethod"), + message); } - public override async Task Json_collection_element_access_outside_bounds(bool async) + public override async Task Json_collection_index_outside_bounds(bool async) { - await base.Json_collection_element_access_outside_bounds(async); + await base.Json_collection_index_outside_bounds(async); AssertSql( """ @@ -788,9 +796,9 @@ FROM [JsonEntitiesBasic] AS [j] """); } - public override async Task Json_collection_element_access_outside_bounds2(bool async) + public override async Task Json_collection_index_outside_bounds2(bool async) { - await base.Json_collection_element_access_outside_bounds2(async); + await base.Json_collection_index_outside_bounds2(async); AssertSql( """ @@ -799,9 +807,9 @@ FROM [JsonEntitiesBasic] AS [j] """); } - public override async Task Json_collection_element_access_outside_bounds_with_property_access(bool async) + public override async Task Json_collection_index_outside_bounds_with_property_access(bool async) { - await base.Json_collection_element_access_outside_bounds_with_property_access(async); + await base.Json_collection_index_outside_bounds_with_property_access(async); AssertSql( """ @@ -812,9 +820,9 @@ ORDER BY [j].[Id] } [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] - public override async Task Json_collection_element_access_in_projection_nested(bool async) + public override async Task Json_collection_index_in_projection_nested(bool async) { - await base.Json_collection_element_access_in_projection_nested(async); + await base.Json_collection_index_in_projection_nested(async); AssertSql( """ @@ -826,9 +834,9 @@ FROM [JsonEntitiesBasic] AS [j] } [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] - public override async Task Json_collection_element_access_in_projection_nested_project_scalar(bool async) + public override async Task Json_collection_index_in_projection_nested_project_scalar(bool async) { - await base.Json_collection_element_access_in_projection_nested_project_scalar(async); + await base.Json_collection_index_in_projection_nested_project_scalar(async); AssertSql( """ @@ -840,9 +848,9 @@ FROM [JsonEntitiesBasic] AS [j] } [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] - public override async Task Json_collection_element_access_in_projection_nested_project_reference(bool async) + public override async Task Json_collection_index_in_projection_nested_project_reference(bool async) { - await base.Json_collection_element_access_in_projection_nested_project_reference(async); + await base.Json_collection_index_in_projection_nested_project_reference(async); AssertSql( """ @@ -854,9 +862,9 @@ FROM [JsonEntitiesBasic] AS [j] } [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] - public override async Task Json_collection_element_access_in_projection_nested_project_collection(bool async) + public override async Task Json_collection_index_in_projection_nested_project_collection(bool async) { - await base.Json_collection_element_access_in_projection_nested_project_collection(async); + await base.Json_collection_index_in_projection_nested_project_collection(async); AssertSql( """ @@ -869,9 +877,9 @@ ORDER BY [j].[Id] } [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] - public override async Task Json_collection_element_access_in_projection_nested_project_collection_anonymous_projection(bool async) + public override async Task Json_collection_index_in_projection_nested_project_collection_anonymous_projection(bool async) { - await base.Json_collection_element_access_in_projection_nested_project_collection_anonymous_projection(async); + await base.Json_collection_index_in_projection_nested_project_collection_anonymous_projection(async); AssertSql( """ @@ -882,9 +890,9 @@ FROM [JsonEntitiesBasic] AS [j] """); } - public override async Task Json_collection_element_access_in_predicate_using_constant(bool async) + public override async Task Json_collection_index_in_predicate_using_constant(bool async) { - await base.Json_collection_element_access_in_predicate_using_constant(async); + await base.Json_collection_index_in_predicate_using_constant(async); AssertSql( """ @@ -895,9 +903,9 @@ WHERE JSON_VALUE([j].[OwnedCollectionRoot], '$[0].Name') <> N'Foo' OR JSON_VALUE } [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] - public override async Task Json_collection_element_access_in_predicate_using_variable(bool async) + public override async Task Json_collection_index_in_predicate_using_variable(bool async) { - await base.Json_collection_element_access_in_predicate_using_variable(async); + await base.Json_collection_index_in_predicate_using_variable(async); AssertSql( """ @@ -910,9 +918,9 @@ WHERE JSON_VALUE([j].[OwnedCollectionRoot], '$[' + CAST(@__prm_0 AS nvarchar(max } [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] - public override async Task Json_collection_element_access_in_predicate_using_column(bool async) + public override async Task Json_collection_index_in_predicate_using_column(bool async) { - await base.Json_collection_element_access_in_predicate_using_column(async); + await base.Json_collection_index_in_predicate_using_column(async); AssertSql( """ @@ -923,9 +931,9 @@ WHERE JSON_VALUE([j].[OwnedCollectionRoot], '$[' + CAST([j].[Id] AS nvarchar(max } [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] - public override async Task Json_collection_element_access_in_predicate_using_complex_expression1(bool async) + public override async Task Json_collection_index_in_predicate_using_complex_expression1(bool async) { - await base.Json_collection_element_access_in_predicate_using_complex_expression1(async); + await base.Json_collection_index_in_predicate_using_complex_expression1(async); AssertSql( """ @@ -939,9 +947,9 @@ 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) + public override async Task Json_collection_index_in_predicate_using_complex_expression2(bool async) { - await base.Json_collection_element_access_in_predicate_using_complex_expression2(async); + await base.Json_collection_index_in_predicate_using_complex_expression2(async); AssertSql( """ @@ -953,9 +961,9 @@ SELECT MAX([j0].[Id]) """); } - public override async Task Json_collection_element_access_in_predicate_using_ElementAt(bool async) + public override async Task Json_collection_ElementAt_in_predicate(bool async) { - await base.Json_collection_element_access_in_predicate_using_ElementAt(async); + await base.Json_collection_ElementAt_in_predicate(async); AssertSql( """ @@ -966,9 +974,9 @@ WHERE JSON_VALUE([j].[OwnedCollectionRoot], '$[1].Name') <> N'Foo' OR JSON_VALUE } [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] - public override async Task Json_collection_element_access_in_predicate_nested_mix(bool async) + public override async Task Json_collection_index_in_predicate_nested_mix(bool async) { - await base.Json_collection_element_access_in_predicate_nested_mix(async); + await base.Json_collection_index_in_predicate_nested_mix(async); AssertSql( """ @@ -980,9 +988,9 @@ WHERE JSON_VALUE([j].[OwnedCollectionRoot], '$[1].OwnedCollectionBranch[' + CAST """); } - public override async Task Json_collection_element_access_manual_Element_at_and_pushdown(bool async) + public override async Task Json_collection_ElementAt_and_pushdown(bool async) { - await base.Json_collection_element_access_manual_Element_at_and_pushdown(async); + await base.Json_collection_ElementAt_and_pushdown(async); AssertSql( """ @@ -991,46 +999,440 @@ FROM [JsonEntitiesBasic] AS [j] """); } - public override async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative(bool async) + public override async Task Json_collection_Any_with_predicate(bool async) { - await base.Json_collection_element_access_manual_Element_at_and_pushdown_negative(async); + await base.Json_collection_Any_with_predicate(async); - AssertSql(); + AssertSql( +""" +SELECT [j].[Id], [j].[EntityBasicId], [j].[Name], [j].[OwnedCollectionRoot], [j].[OwnedReferenceRoot] +FROM [JsonEntitiesBasic] AS [j] +WHERE EXISTS ( + SELECT 1 + FROM OPENJSON([j].[OwnedReferenceRoot], '$.OwnedCollectionBranch') WITH ( + [Date] datetime2 '$.Date', + [Enum] nvarchar(max) '$.Enum', + [Enums] nvarchar(max) '$.Enums', + [Fraction] decimal(18,2) '$.Fraction', + [NullableEnum] nvarchar(max) '$.NullableEnum', + [NullableEnums] nvarchar(max) '$.NullableEnums', + [OwnedCollectionLeaf] nvarchar(max) '$.OwnedCollectionLeaf' AS JSON, + [OwnedReferenceLeaf] nvarchar(max) '$.OwnedReferenceLeaf' AS JSON + ) AS [o] + WHERE JSON_VALUE([o].[OwnedReferenceLeaf], '$.SomethingSomething') = N'e1_c2_c1_c1') +"""); } - public override async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative2(bool async) + public override async Task Json_collection_Where_ElementAt(bool async) { - await base.Json_collection_element_access_manual_Element_at_and_pushdown_negative2(async); + await base.Json_collection_Where_ElementAt(async); - AssertSql(); + AssertSql( +""" +SELECT [j].[Id], [j].[EntityBasicId], [j].[Name], [j].[OwnedCollectionRoot], [j].[OwnedReferenceRoot] +FROM [JsonEntitiesBasic] AS [j] +WHERE ( + SELECT JSON_VALUE([o].[value], '$.OwnedReferenceLeaf.SomethingSomething') + FROM OPENJSON([j].[OwnedReferenceRoot], '$.OwnedCollectionBranch') AS [o] + WHERE JSON_VALUE([o].[value], '$.Enum') = N'Three' + ORDER BY CAST([o].[key] AS int) + OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY) = N'e1_r_c2_r' +"""); } - public override async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative3(bool async) + public override async Task Json_collection_Skip(bool async) { - await base.Json_collection_element_access_manual_Element_at_and_pushdown_negative3(async); + await base.Json_collection_Skip(async); - AssertSql(); + AssertSql( +""" +SELECT [j].[Id], [j].[EntityBasicId], [j].[Name], [j].[OwnedCollectionRoot], [j].[OwnedReferenceRoot] +FROM [JsonEntitiesBasic] AS [j] +WHERE ( + SELECT [t].[c] + FROM ( + SELECT JSON_VALUE([o].[value], '$.OwnedReferenceLeaf.SomethingSomething') AS [c], [o].[key], CAST([o].[key] AS int) AS [c0] + FROM OPENJSON([j].[OwnedReferenceRoot], '$.OwnedCollectionBranch') AS [o] + ORDER BY CAST([o].[key] AS int) + OFFSET 1 ROWS + ) AS [t] + ORDER BY [t].[c0] + OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY) = N'e1_r_c2_r' +"""); } - public override async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative4(bool async) + public override async Task Json_collection_OrderByDescending_Skip_ElementAt(bool async) { - await base.Json_collection_element_access_manual_Element_at_and_pushdown_negative4(async); + await base.Json_collection_OrderByDescending_Skip_ElementAt(async); - AssertSql(); + AssertSql( +""" +SELECT [j].[Id], [j].[EntityBasicId], [j].[Name], [j].[OwnedCollectionRoot], [j].[OwnedReferenceRoot] +FROM [JsonEntitiesBasic] AS [j] +WHERE ( + SELECT [t].[c] + FROM ( + SELECT JSON_VALUE([o].[value], '$.OwnedReferenceLeaf.SomethingSomething') AS [c], [o].[key], CAST(JSON_VALUE([o].[value], '$.Date') AS datetime2) AS [c0] + FROM OPENJSON([j].[OwnedReferenceRoot], '$.OwnedCollectionBranch') AS [o] + ORDER BY CAST(JSON_VALUE([o].[value], '$.Date') AS datetime2) DESC + OFFSET 1 ROWS + ) AS [t] + ORDER BY [t].[c0] DESC + OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY) = N'e1_r_c1_r' +"""); } - public override async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative5(bool async) + public override async Task Json_collection_Distinct_Count_with_predicate(bool async) { - await base.Json_collection_element_access_manual_Element_at_and_pushdown_negative5(async); + await base.Json_collection_Distinct_Count_with_predicate(async); - AssertSql(); + AssertSql( +""" +SELECT [j].[Id], [j].[EntityBasicId], [j].[Name], [j].[OwnedCollectionRoot], [j].[OwnedReferenceRoot] +FROM [JsonEntitiesBasic] AS [j] +WHERE ( + SELECT COUNT(*) + FROM ( + SELECT DISTINCT [j].[Id], [o].[Date], [o].[Enum], [o].[Enums], [o].[Fraction], [o].[NullableEnum], [o].[NullableEnums], [o].[OwnedCollectionLeaf] AS [c], [o].[OwnedReferenceLeaf] AS [c0] + FROM OPENJSON([j].[OwnedReferenceRoot], '$.OwnedCollectionBranch') WITH ( + [Date] datetime2 '$.Date', + [Enum] nvarchar(max) '$.Enum', + [Enums] nvarchar(max) '$.Enums', + [Fraction] decimal(18,2) '$.Fraction', + [NullableEnum] nvarchar(max) '$.NullableEnum', + [NullableEnums] nvarchar(max) '$.NullableEnums', + [OwnedCollectionLeaf] nvarchar(max) '$.OwnedCollectionLeaf' AS JSON, + [OwnedReferenceLeaf] nvarchar(max) '$.OwnedReferenceLeaf' AS JSON + ) AS [o] + WHERE JSON_VALUE([o].[OwnedReferenceLeaf], '$.SomethingSomething') = N'e1_r_c2_r' + ) AS [t]) = 1 +"""); } - public override async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative6(bool async) + public override async Task Json_collection_within_collection_Count(bool async) { - await base.Json_collection_element_access_manual_Element_at_and_pushdown_negative6(async); + await base.Json_collection_within_collection_Count(async); - AssertSql(); + AssertSql( +""" +SELECT [j].[Id], [j].[EntityBasicId], [j].[Name], [j].[OwnedCollectionRoot], [j].[OwnedReferenceRoot] +FROM [JsonEntitiesBasic] AS [j] +WHERE EXISTS ( + SELECT 1 + FROM OPENJSON([j].[OwnedCollectionRoot], '$') WITH ( + [Name] nvarchar(max) '$.Name', + [Names] nvarchar(max) '$.Names', + [Number] int '$.Number', + [Numbers] nvarchar(max) '$.Numbers', + [OwnedCollectionBranch] nvarchar(max) '$.OwnedCollectionBranch' AS JSON, + [OwnedReferenceBranch] nvarchar(max) '$.OwnedReferenceBranch' AS JSON + ) AS [o] + WHERE ( + SELECT COUNT(*) + FROM OPENJSON([o].[OwnedCollectionBranch], '$') WITH ( + [Date] datetime2 '$.Date', + [Enum] nvarchar(max) '$.Enum', + [Enums] nvarchar(max) '$.Enums', + [Fraction] decimal(18,2) '$.Fraction', + [NullableEnum] nvarchar(max) '$.NullableEnum', + [NullableEnums] nvarchar(max) '$.NullableEnums', + [OwnedCollectionLeaf] nvarchar(max) '$.OwnedCollectionLeaf' AS JSON, + [OwnedReferenceLeaf] nvarchar(max) '$.OwnedReferenceLeaf' AS JSON + ) AS [o0]) = 2) +"""); + } + + public override async Task Json_collection_in_projection_with_composition_count(bool async) + { + await base.Json_collection_in_projection_with_composition_count(async); + + AssertSql( +""" +SELECT ( + SELECT COUNT(*) + FROM OPENJSON([j].[OwnedCollectionRoot], '$') WITH ( + [Name] nvarchar(max) '$.Name', + [Names] nvarchar(max) '$.Names', + [Number] int '$.Number', + [Numbers] nvarchar(max) '$.Numbers', + [OwnedCollectionBranch] nvarchar(max) '$.OwnedCollectionBranch' AS JSON, + [OwnedReferenceBranch] nvarchar(max) '$.OwnedReferenceBranch' AS JSON + ) AS [o]) +FROM [JsonEntitiesBasic] AS [j] +ORDER BY [j].[Id] +"""); + } + + public override async Task Json_collection_in_projection_with_anonymous_projection_of_scalars(bool async) + { + await base.Json_collection_in_projection_with_anonymous_projection_of_scalars(async); + + AssertSql( +""" +SELECT [j].[Id], JSON_VALUE([o].[value], '$.Name'), CAST(JSON_VALUE([o].[value], '$.Number') AS int), [o].[key] +FROM [JsonEntitiesBasic] AS [j] +OUTER APPLY OPENJSON([j].[OwnedCollectionRoot], '$') AS [o] +ORDER BY [j].[Id], CAST([o].[key] AS int) +"""); + } + + public override async Task Json_collection_in_projection_with_composition_where_and_anonymous_projection_of_scalars(bool async) + { + await base.Json_collection_in_projection_with_composition_where_and_anonymous_projection_of_scalars(async); + + AssertSql( +""" +SELECT [j].[Id], [t].[Name], [t].[Number], [t].[key] +FROM [JsonEntitiesBasic] AS [j] +OUTER APPLY ( + SELECT JSON_VALUE([o].[value], '$.Name') AS [Name], CAST(JSON_VALUE([o].[value], '$.Number') AS int) AS [Number], [o].[key], CAST([o].[key] AS int) AS [c] + FROM OPENJSON([j].[OwnedCollectionRoot], '$') AS [o] + WHERE JSON_VALUE([o].[value], '$.Name') = N'Foo' +) AS [t] +ORDER BY [j].[Id], [t].[c] +"""); + } + + public override async Task Json_collection_in_projection_with_composition_where_and_anonymous_projection_of_primitive_arrays(bool async) + { + await base.Json_collection_in_projection_with_composition_where_and_anonymous_projection_of_primitive_arrays(async); + + AssertSql( +""" +SELECT [j].[Id], [t].[Names], [t].[Numbers], [t].[key] +FROM [JsonEntitiesBasic] AS [j] +OUTER APPLY ( + SELECT JSON_QUERY([o].[value], '$.Names') AS [Names], JSON_QUERY([o].[value], '$.Numbers') AS [Numbers], [o].[key], CAST([o].[key] AS int) AS [c] + FROM OPENJSON([j].[OwnedCollectionRoot], '$') AS [o] + WHERE JSON_VALUE([o].[value], '$.Name') = N'Foo' +) AS [t] +ORDER BY [j].[Id], [t].[c] +"""); + } + + public override async Task Json_collection_filter_in_projection(bool async) + { + await base.Json_collection_filter_in_projection(async); + + AssertSql(""); + } + + public override async Task Json_collection_skip_take_in_projection(bool async) + { + await base.Json_collection_skip_take_in_projection(async); + + AssertSql(""); + } + + public override async Task Json_collection_skip_take_in_projection_project_into_anonymous_type(bool async) + { + await base.Json_collection_skip_take_in_projection_project_into_anonymous_type(async); + + AssertSql( +""" +SELECT [j].[Id], [t].[c], [t].[c0], [t].[c1], [t].[c2], [t].[c3], [t].[Id], [t].[c4], [t].[key] +FROM [JsonEntitiesBasic] AS [j] +OUTER APPLY ( + SELECT JSON_VALUE([o].[value], '$.Name') AS [c], JSON_QUERY([o].[value], '$.Names') AS [c0], CAST(JSON_VALUE([o].[value], '$.Number') AS int) AS [c1], JSON_QUERY([o].[value], '$.Numbers') AS [c2], JSON_QUERY([o].[value], '$.OwnedCollectionBranch') AS [c3], [j].[Id], JSON_QUERY([o].[value], '$.OwnedReferenceBranch') AS [c4], [o].[key] + FROM OPENJSON([j].[OwnedCollectionRoot], '$') AS [o] + ORDER BY JSON_VALUE([o].[value], '$.Name') + OFFSET 1 ROWS FETCH NEXT 5 ROWS ONLY +) AS [t] +ORDER BY [j].[Id], [t].[c] +"""); + } + + public override async Task Json_collection_skip_take_in_projection_with_json_reference_access_as_final_operation(bool async) + { + await base.Json_collection_skip_take_in_projection_with_json_reference_access_as_final_operation(async); + + AssertSql( +""" +SELECT [j].[Id], [t].[c], [t].[Id], [t].[key] +FROM [JsonEntitiesBasic] AS [j] +OUTER APPLY ( + SELECT JSON_QUERY([o].[value], '$.OwnedReferenceBranch') AS [c], [j].[Id], [o].[key], JSON_VALUE([o].[value], '$.Name') AS [c0] + FROM OPENJSON([j].[OwnedCollectionRoot], '$') AS [o] + ORDER BY JSON_VALUE([o].[value], '$.Name') + OFFSET 1 ROWS FETCH NEXT 5 ROWS ONLY +) AS [t] +ORDER BY [j].[Id], [t].[c0] +"""); + } + + public override async Task Json_collection_distinct_in_projection(bool async) + { + await base.Json_collection_distinct_in_projection(async); + + AssertSql(""); + } + + public override async Task Json_collection_leaf_filter_in_projection(bool async) + { + await base.Json_collection_leaf_filter_in_projection(async); + + AssertSql(""); + } + + public override async Task Json_collection_SelectMany(bool async) + { + await base.Json_collection_SelectMany(async); + + AssertSql(""); + } + + public override async Task Json_nested_collection_SelectMany(bool async) + { + await base.Json_nested_collection_SelectMany(async); + + AssertSql(""); + } + + public override async Task Json_collection_of_primitives_SelectMany(bool async) + { + await base.Json_collection_of_primitives_SelectMany(async); + + AssertSql(""); + } + + public override async Task Json_collection_of_primitives_index_used_in_predicate(bool async) + { + await base.Json_collection_of_primitives_index_used_in_predicate(async); + + AssertSql( +""" +SELECT [j].[Id], [j].[EntityBasicId], [j].[Name], [j].[OwnedCollectionRoot], [j].[OwnedReferenceRoot] +FROM [JsonEntitiesBasic] AS [j] +WHERE JSON_VALUE([j].[OwnedReferenceRoot], '$.Names[0]') = N'e1_r1' +"""); + } + + public override async Task Json_collection_of_primitives_index_used_in_projection(bool async) + { + await base.Json_collection_of_primitives_index_used_in_projection(async); + + AssertSql( +""" +SELECT CAST(JSON_VALUE([j].[OwnedReferenceRoot], '$.OwnedReferenceBranch.Enums[0]') AS int) +FROM [JsonEntitiesBasic] AS [j] +ORDER BY [j].[Id] +"""); + } + + public override async Task Json_collection_of_primitives_index_used_in_orderby(bool async) + { + await base.Json_collection_of_primitives_index_used_in_orderby(async); + + AssertSql( +""" +SELECT [j].[Id], [j].[EntityBasicId], [j].[Name], [j].[OwnedCollectionRoot], [j].[OwnedReferenceRoot] +FROM [JsonEntitiesBasic] AS [j] +ORDER BY CAST(JSON_VALUE([j].[OwnedReferenceRoot], '$.Numbers[0]') AS int) +"""); + } + + public override async Task Json_collection_of_primitives_contains_in_predicate(bool async) + { + await base.Json_collection_of_primitives_contains_in_predicate(async); + + AssertSql( +""" +SELECT [j].[Id], [j].[EntityBasicId], [j].[Name], [j].[OwnedCollectionRoot], [j].[OwnedReferenceRoot] +FROM [JsonEntitiesBasic] AS [j] +WHERE N'e1_r1' IN ( + SELECT [n].[value] + FROM OPENJSON(JSON_QUERY([j].[OwnedReferenceRoot], '$.Names')) WITH ([value] nvarchar(max) '$') AS [n] +) +"""); + } + + [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] + public override async Task Json_collection_index_with_parameter_Select_ElementAt(bool async) + { + await base.Json_collection_index_with_parameter_Select_ElementAt(async); + + AssertSql( +""" +@__prm_0='0' + +SELECT [j].[Id], ( + SELECT N'Foo' + FROM OPENJSON([j].[OwnedCollectionRoot], '$[' + CAST(@__prm_0 AS nvarchar(max)) + '].OwnedCollectionBranch') AS [o] + ORDER BY CAST([o].[key] AS int) + OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY) AS [CollectionElement] +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] + public override async Task Json_collection_index_with_expression_Select_ElementAt(bool async) + { + await base.Json_collection_index_with_expression_Select_ElementAt(async); + + AssertSql( +""" +@__prm_0='0' + +SELECT JSON_VALUE([j].[OwnedCollectionRoot], '$[' + CAST(@__prm_0 + [j].[Id] AS nvarchar(max)) + '].OwnedCollectionBranch[0].OwnedReferenceLeaf.SomethingSomething') +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + public override async Task Json_collection_Select_entity_collection_ElementAt(bool async) + { + await base.Json_collection_Select_entity_collection_ElementAt(async); + + AssertSql( +""" +SELECT JSON_QUERY([j].[OwnedCollectionRoot], '$[0].OwnedCollectionBranch'), [j].[Id] +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + public override async Task Json_collection_Select_entity_ElementAt(bool async) + { + await base.Json_collection_Select_entity_ElementAt(async); + + AssertSql( +""" +SELECT JSON_QUERY([j].[OwnedCollectionRoot], '$[0].OwnedReferenceBranch'), [j].[Id] +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + public override async Task Json_collection_Select_entity_in_anonymous_object_ElementAt(bool async) + { + await base.Json_collection_Select_entity_in_anonymous_object_ElementAt(async); + + AssertSql( +""" +SELECT [t].[c], [t].[Id], [t].[c0] +FROM [JsonEntitiesBasic] AS [j] +OUTER APPLY ( + SELECT JSON_QUERY([o].[value], '$.OwnedReferenceBranch') AS [c], [j].[Id], 1 AS [c0] + FROM OPENJSON([j].[OwnedCollectionRoot], '$') AS [o] + ORDER BY CAST([o].[key] AS int) + OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY +) AS [t] +ORDER BY [j].[Id] +"""); + } + + public override async Task Json_collection_Select_entity_with_initializer_ElementAt(bool async) + { + await base.Json_collection_Select_entity_with_initializer_ElementAt(async); + + AssertSql( +""" +SELECT [t].[Id], [t].[c] +FROM [JsonEntitiesBasic] AS [j] +OUTER APPLY ( + SELECT [j].[Id], 1 AS [c] + FROM OPENJSON([j].[OwnedCollectionRoot], '$') AS [o] + ORDER BY CAST([o].[key] AS int) + OFFSET 0 ROWS FETCH NEXT 1 ROWS ONLY +) AS [t] +"""); } public override async Task Json_projection_deduplication_with_collection_indexer_in_original(bool async) @@ -1072,9 +1474,9 @@ FROM [JsonEntitiesBasic] AS [j] """); } - public override async Task Json_collection_element_access_in_projection_using_constant_when_owner_is_present(bool async) + public override async Task Json_collection_index_in_projection_using_constant_when_owner_is_present(bool async) { - await base.Json_collection_element_access_in_projection_using_constant_when_owner_is_present(async); + await base.Json_collection_index_in_projection_using_constant_when_owner_is_present(async); AssertSql( """ @@ -1083,9 +1485,9 @@ FROM [JsonEntitiesBasic] AS [j] """); } - public override async Task Json_collection_element_access_in_projection_using_constant_when_owner_is_not_present(bool async) + public override async Task Json_collection_index_in_projection_using_constant_when_owner_is_not_present(bool async) { - await base.Json_collection_element_access_in_projection_using_constant_when_owner_is_not_present(async); + await base.Json_collection_index_in_projection_using_constant_when_owner_is_not_present(async); AssertSql( """ @@ -1095,9 +1497,9 @@ FROM [JsonEntitiesBasic] AS [j] } [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] - public override async Task Json_collection_element_access_in_projection_using_parameter_when_owner_is_present(bool async) + public override async Task Json_collection_index_in_projection_using_parameter_when_owner_is_present(bool async) { - await base.Json_collection_element_access_in_projection_using_parameter_when_owner_is_present(async); + await base.Json_collection_index_in_projection_using_parameter_when_owner_is_present(async); AssertSql( """ @@ -1109,9 +1511,9 @@ FROM [JsonEntitiesBasic] AS [j] } [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] - public override async Task Json_collection_element_access_in_projection_using_parameter_when_owner_is_not_present(bool async) + public override async Task Json_collection_index_in_projection_using_parameter_when_owner_is_not_present(bool async) { - await base.Json_collection_element_access_in_projection_using_parameter_when_owner_is_not_present(async); + await base.Json_collection_index_in_projection_using_parameter_when_owner_is_not_present(async); AssertSql( """ @@ -1122,9 +1524,9 @@ FROM [JsonEntitiesBasic] AS [j] """); } - public override async Task Json_collection_after_collection_element_access_in_projection_using_constant_when_owner_is_present(bool async) + public override async Task Json_collection_after_collection_index_in_projection_using_constant_when_owner_is_present(bool async) { - await base.Json_collection_after_collection_element_access_in_projection_using_constant_when_owner_is_present(async); + await base.Json_collection_after_collection_index_in_projection_using_constant_when_owner_is_present(async); AssertSql( """ @@ -1133,9 +1535,9 @@ FROM [JsonEntitiesBasic] AS [j] """); } - public override async Task Json_collection_after_collection_element_access_in_projection_using_constant_when_owner_is_not_present(bool async) + public override async Task Json_collection_after_collection_index_in_projection_using_constant_when_owner_is_not_present(bool async) { - await base.Json_collection_after_collection_element_access_in_projection_using_constant_when_owner_is_not_present(async); + await base.Json_collection_after_collection_index_in_projection_using_constant_when_owner_is_not_present(async); AssertSql( """ @@ -1145,12 +1547,12 @@ FROM [JsonEntitiesBasic] AS [j] } [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] - public override async Task Json_collection_after_collection_element_access_in_projection_using_parameter_when_owner_is_present(bool async) + public override async Task Json_collection_after_collection_index_in_projection_using_parameter_when_owner_is_present(bool async) { - await base.Json_collection_after_collection_element_access_in_projection_using_parameter_when_owner_is_present(async); + await base.Json_collection_after_collection_index_in_projection_using_parameter_when_owner_is_present(async); AssertSql( - """ +""" @__prm_0='1' SELECT [j].[Id], [j].[EntityBasicId], [j].[Name], [j].[OwnedCollectionRoot], [j].[OwnedReferenceRoot], JSON_QUERY([j].[OwnedCollectionRoot], '$[' + CAST(@__prm_0 AS nvarchar(max)) + '].OwnedCollectionBranch'), @__prm_0 @@ -1159,9 +1561,9 @@ FROM [JsonEntitiesBasic] AS [j] } [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] - public override async Task Json_collection_after_collection_element_access_in_projection_using_parameter_when_owner_is_not_present(bool async) + public override async Task Json_collection_after_collection_index_in_projection_using_parameter_when_owner_is_not_present(bool async) { - await base.Json_collection_after_collection_element_access_in_projection_using_parameter_when_owner_is_not_present(async); + await base.Json_collection_after_collection_index_in_projection_using_parameter_when_owner_is_not_present(async); AssertSql( """ @@ -1173,9 +1575,9 @@ FROM [JsonEntitiesBasic] AS [j] } [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] - public override async Task Json_collection_element_access_in_projection_when_owner_is_present_misc1(bool async) + public override async Task Json_collection_index_in_projection_when_owner_is_present_misc1(bool async) { - await base.Json_collection_element_access_in_projection_when_owner_is_present_misc1(async); + await base.Json_collection_index_in_projection_when_owner_is_present_misc1(async); AssertSql( """ @@ -1187,9 +1589,9 @@ FROM [JsonEntitiesBasic] AS [j] } [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] - public override async Task Json_collection_element_access_in_projection_when_owner_is_not_present_misc1(bool async) + public override async Task Json_collection_index_in_projection_when_owner_is_not_present_misc1(bool async) { - await base.Json_collection_element_access_in_projection_when_owner_is_not_present_misc1(async); + await base.Json_collection_index_in_projection_when_owner_is_not_present_misc1(async); AssertSql( """ @@ -1200,9 +1602,9 @@ FROM [JsonEntitiesBasic] AS [j] """); } - public override async Task Json_collection_element_access_in_projection_when_owner_is_present_misc2(bool async) + public override async Task Json_collection_index_in_projection_when_owner_is_present_misc2(bool async) { - await base.Json_collection_element_access_in_projection_when_owner_is_present_misc2(async); + await base.Json_collection_index_in_projection_when_owner_is_present_misc2(async); AssertSql( """ @@ -1211,9 +1613,9 @@ FROM [JsonEntitiesBasic] AS [j] """); } - public override async Task Json_collection_element_access_in_projection_when_owner_is_not_present_misc2(bool async) + public override async Task Json_collection_index_in_projection_when_owner_is_not_present_misc2(bool async) { - await base.Json_collection_element_access_in_projection_when_owner_is_not_present_misc2(async); + await base.Json_collection_index_in_projection_when_owner_is_not_present_misc2(async); AssertSql( """ @@ -1223,9 +1625,9 @@ FROM [JsonEntitiesBasic] AS [j] } [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] - public override async Task Json_collection_element_access_in_projection_when_owner_is_present_multiple(bool async) + public override async Task Json_collection_index_in_projection_when_owner_is_present_multiple(bool async) { - await base.Json_collection_element_access_in_projection_when_owner_is_present_multiple(async); + await base.Json_collection_index_in_projection_when_owner_is_present_multiple(async); AssertSql( """ @@ -1237,9 +1639,9 @@ FROM [JsonEntitiesBasic] AS [j] } [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] - public override async Task Json_collection_element_access_in_projection_when_owner_is_not_present_multiple(bool async) + public override async Task Json_collection_index_in_projection_when_owner_is_not_present_multiple(bool async) { - await base.Json_collection_element_access_in_projection_when_owner_is_not_present_multiple(async); + await base.Json_collection_index_in_projection_when_owner_is_not_present_multiple(async); AssertSql( """ @@ -1294,7 +1696,7 @@ public override async Task Group_by_on_json_scalar_using_collection_indexer(bool 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] @@ -2029,6 +2431,18 @@ WHERE CAST(JSON_VALUE([j].[Reference], '$.BoolConvertedToIntZeroOne') AS int) = """); } + public override async Task Json_predicate_on_bool_converted_to_int_zero_one_with_explicit_comparison(bool async) + { + await base.Json_predicate_on_bool_converted_to_int_zero_one_with_explicit_comparison(async); + + AssertSql( +""" +SELECT [j].[Id], [j].[Reference] +FROM [JsonEntitiesConverters] AS [j] +WHERE CAST(JSON_VALUE([j].[Reference], '$.BoolConvertedToIntZeroOne') AS int) = 0 +"""); + } + public override async Task Json_predicate_on_bool_converted_to_string_True_False(bool async) { await base.Json_predicate_on_bool_converted_to_string_True_False(async); @@ -2041,6 +2455,18 @@ WHERE JSON_VALUE([j].[Reference], '$.BoolConvertedToStringTrueFalse') = N'True' """); } + public override async Task Json_predicate_on_bool_converted_to_string_True_False_with_explicit_comparison(bool async) + { + await base.Json_predicate_on_bool_converted_to_string_True_False_with_explicit_comparison(async); + + AssertSql( + """ +SELECT [j].[Id], [j].[Reference] +FROM [JsonEntitiesConverters] AS [j] +WHERE JSON_VALUE([j].[Reference], '$.BoolConvertedToStringTrueFalse') = N'True' +"""); + } + public override async Task Json_predicate_on_bool_converted_to_string_Y_N(bool async) { await base.Json_predicate_on_bool_converted_to_string_Y_N(async); @@ -2053,6 +2479,18 @@ WHERE JSON_VALUE([j].[Reference], '$.BoolConvertedToStringYN') = N'Y' """); } + public override async Task Json_predicate_on_bool_converted_to_string_Y_N_with_explicit_comparison(bool async) + { + await base.Json_predicate_on_bool_converted_to_string_Y_N_with_explicit_comparison(async); + + AssertSql( +""" +SELECT [j].[Id], [j].[Reference] +FROM [JsonEntitiesConverters] AS [j] +WHERE JSON_VALUE([j].[Reference], '$.BoolConvertedToStringYN') = N'N' +"""); + } + public override async Task Json_predicate_on_int_zero_one_converted_to_bool(bool async) { await base.Json_predicate_on_int_zero_one_converted_to_bool(async); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/NonSharedPrimitiveCollectionsQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/NonSharedPrimitiveCollectionsQuerySqlServerTest.cs index fd2ec6fc1f2..0a7ac9b8b28 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/NonSharedPrimitiveCollectionsQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/NonSharedPrimitiveCollectionsQuerySqlServerTest.cs @@ -332,7 +332,7 @@ FROM [TestEntity] AS [t] WHERE ( SELECT COUNT(*) FROM ( - SELECT CAST([s].[value] AS nvarchar(max)) AS [value], [s].[key], CAST([s].[key] AS int) AS [c] + SELECT [s].[value], [s].[key], CAST([s].[key] AS int) AS [c] FROM OPENJSON([t].[SomeArray]) AS [s] ORDER BY CAST([s].[key] AS int) OFFSET 1 ROWS diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQueryOldSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQueryOldSqlServerTest.cs index ba83bc7c47f..a06b20d0242 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQueryOldSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQueryOldSqlServerTest.cs @@ -396,6 +396,9 @@ public override Task Column_collection_OrderByDescending_ElementAt(bool async) public override Task Column_collection_Any(bool async) => AssertCompatibilityLevelTooLow(() => base.Column_collection_Any(async)); + public override Task Column_collection_Distinct(bool async) + => AssertTranslationFailed(() => base.Column_collection_Distinct(async)); + public override async Task Column_collection_projection_from_top_level(bool async) { await base.Column_collection_projection_from_top_level(async); @@ -567,6 +570,19 @@ public override Task Project_empty_collection_of_nullables_and_collection_only_c // we don't propagate error details from projection => AssertTranslationFailed(() => base.Project_empty_collection_of_nullables_and_collection_only_containing_nulls(async)); + public override async Task Project_primitive_collections_element(bool async) + { + await base.Project_primitive_collections_element(async); + + AssertSql( +""" +SELECT [p].[Ints], [p].[DateTimes], [p].[Strings] +FROM [PrimitiveCollectionsEntity] AS [p] +WHERE [p].[Id] < 4 +ORDER BY [p].[Id] +"""); + } + [ConditionalFact] public virtual void Check_all_tests_overridden() => TestHelpers.AssertAllMethodsOverridden(GetType()); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServerTest.cs index 00e227b6e8b..dd15cb86944 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServerTest.cs @@ -660,6 +660,23 @@ FROM OPENJSON([p].[Ints]) WITH ([value] int '$') AS [i]) """); } + public override async Task Column_collection_Distinct(bool async) + { + await base.Column_collection_Distinct(async); + + AssertSql( +""" +SELECT [p].[Id], [p].[Bool], [p].[Bools], [p].[DateTime], [p].[DateTimes], [p].[Enum], [p].[Enums], [p].[Int], [p].[Ints], [p].[NullableInt], [p].[NullableInts], [p].[String], [p].[Strings] +FROM [PrimitiveCollectionsEntity] AS [p] +WHERE ( + SELECT COUNT(*) + FROM ( + SELECT DISTINCT [i].[value] + FROM OPENJSON([p].[Ints]) WITH ([value] int '$') AS [i] + ) AS [t]) = 3 +"""); + } + public override async Task Column_collection_projection_from_top_level(bool async) { await base.Column_collection_projection_from_top_level(async); @@ -1141,6 +1158,19 @@ WHERE CAST([d0].[value] AS datetime2) > '2000-01-01T00:00:00.0000000' """); } + public override async Task Project_primitive_collections_element(bool async) + { + await base.Project_primitive_collections_element(async); + + AssertSql( +""" +SELECT CAST(JSON_VALUE([p].[Ints], '$[0]') AS int) AS [Indexer], CAST(JSON_VALUE([p].[DateTimes], '$[0]') AS datetime2) AS [EnumerableElementAt], JSON_VALUE([p].[Strings], '$[1]') AS [QueryableElementAt] +FROM [PrimitiveCollectionsEntity] AS [p] +WHERE [p].[Id] < 4 +ORDER BY [p].[Id] +"""); + } + [ConditionalFact] public virtual void Check_all_tests_overridden() => TestHelpers.AssertAllMethodsOverridden(GetType()); diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/JsonQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/JsonQuerySqliteTest.cs index 7d73ffcec7a..24a0efe70d9 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/JsonQuerySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/JsonQuerySqliteTest.cs @@ -67,6 +67,122 @@ public override async Task Project_json_entity_FirstOrDefault_subquery_deduplica () => base.Project_json_entity_FirstOrDefault_subquery_deduplication_outer_reference_and_pruning(async))) .Message); + public override async Task Json_collection_Any_with_predicate(bool async) + { + await base.Json_collection_Any_with_predicate(async); + + AssertSql( +""" +SELECT "j"."Id", "j"."EntityBasicId", "j"."Name", "j"."OwnedCollectionRoot", "j"."OwnedReferenceRoot" +FROM "JsonEntitiesBasic" AS "j" +WHERE EXISTS ( + SELECT 1 + FROM ( + SELECT "o"."value" ->> 'Date' AS "Date", "o"."value" ->> 'Enum' AS "Enum", "o"."value" ->> 'Enums' AS "Enums", "o"."value" ->> 'Fraction' AS "Fraction", "o"."value" ->> 'NullableEnum' AS "NullableEnum", "o"."value" ->> 'NullableEnums' AS "NullableEnums", "o"."value" ->> 'OwnedCollectionLeaf' AS "OwnedCollectionLeaf", "o"."value" ->> 'OwnedReferenceLeaf' AS "OwnedReferenceLeaf", "o"."key" + FROM json_each("j"."OwnedReferenceRoot", '$.OwnedCollectionBranch') AS "o" + ) AS "t" + WHERE "t"."OwnedReferenceLeaf" ->> 'SomethingSomething' = 'e1_c2_c1_c1') +"""); + } + + public override async Task Json_collection_Where_ElementAt(bool async) + { + await base.Json_collection_Where_ElementAt(async); + + AssertSql( +""" +SELECT "j"."Id", "j"."EntityBasicId", "j"."Name", "j"."OwnedCollectionRoot", "j"."OwnedReferenceRoot" +FROM "JsonEntitiesBasic" AS "j" +WHERE ( + SELECT "t"."OwnedReferenceLeaf" ->> 'SomethingSomething' + FROM ( + SELECT "o"."value" ->> 'Date' AS "Date", "o"."value" ->> 'Enum' AS "Enum", "o"."value" ->> 'Enums' AS "Enums", "o"."value" ->> 'Fraction' AS "Fraction", "o"."value" ->> 'NullableEnum' AS "NullableEnum", "o"."value" ->> 'NullableEnums' AS "NullableEnums", "o"."value" ->> 'OwnedCollectionLeaf' AS "OwnedCollectionLeaf", "o"."value" ->> 'OwnedReferenceLeaf' AS "OwnedReferenceLeaf", "o"."key" + FROM json_each("j"."OwnedReferenceRoot", '$.OwnedCollectionBranch') AS "o" + ) AS "t" + WHERE "t"."Enum" = 'Three' + ORDER BY "t"."key" + LIMIT 1 OFFSET 0) = 'e1_r_c2_r' +"""); + } + + public override async Task Json_collection_Skip(bool async) + { + await base.Json_collection_Skip(async); + + AssertSql( +""" +SELECT "j"."Id", "j"."EntityBasicId", "j"."Name", "j"."OwnedCollectionRoot", "j"."OwnedReferenceRoot" +FROM "JsonEntitiesBasic" AS "j" +WHERE ( + SELECT "t0"."c" + FROM ( + SELECT "t"."OwnedReferenceLeaf" ->> 'SomethingSomething' AS "c", "t"."key", "t"."key" AS "key0" + FROM ( + SELECT "o"."value" ->> 'Date' AS "Date", "o"."value" ->> 'Enum' AS "Enum", "o"."value" ->> 'Enums' AS "Enums", "o"."value" ->> 'Fraction' AS "Fraction", "o"."value" ->> 'NullableEnum' AS "NullableEnum", "o"."value" ->> 'NullableEnums' AS "NullableEnums", "o"."value" ->> 'OwnedCollectionLeaf' AS "OwnedCollectionLeaf", "o"."value" ->> 'OwnedReferenceLeaf' AS "OwnedReferenceLeaf", "o"."key" + FROM json_each("j"."OwnedReferenceRoot", '$.OwnedCollectionBranch') AS "o" + ) AS "t" + ORDER BY "t"."key" + LIMIT -1 OFFSET 1 + ) AS "t0" + ORDER BY "t0"."key0" + LIMIT 1 OFFSET 0) = 'e1_r_c2_r' +"""); + } + + public override async Task Json_collection_OrderByDescending_Skip_ElementAt(bool async) + { + await base.Json_collection_OrderByDescending_Skip_ElementAt(async); + + AssertSql( +""" +SELECT "j"."Id", "j"."EntityBasicId", "j"."Name", "j"."OwnedCollectionRoot", "j"."OwnedReferenceRoot" +FROM "JsonEntitiesBasic" AS "j" +WHERE ( + SELECT "t0"."c" + FROM ( + SELECT "t"."OwnedReferenceLeaf" ->> 'SomethingSomething' AS "c", "t"."key", "t"."Date" AS "c0" + FROM ( + SELECT "o"."value" ->> 'Date' AS "Date", "o"."value" ->> 'Enum' AS "Enum", "o"."value" ->> 'Enums' AS "Enums", "o"."value" ->> 'Fraction' AS "Fraction", "o"."value" ->> 'NullableEnum' AS "NullableEnum", "o"."value" ->> 'NullableEnums' AS "NullableEnums", "o"."value" ->> 'OwnedCollectionLeaf' AS "OwnedCollectionLeaf", "o"."value" ->> 'OwnedReferenceLeaf' AS "OwnedReferenceLeaf", "o"."key" + FROM json_each("j"."OwnedReferenceRoot", '$.OwnedCollectionBranch') AS "o" + ) AS "t" + ORDER BY "t"."Date" DESC + LIMIT -1 OFFSET 1 + ) AS "t0" + ORDER BY "t0"."c0" DESC + LIMIT 1 OFFSET 0) = 'e1_r_c1_r' +"""); + } + + public override async Task Json_collection_within_collection_Count(bool async) + { + await base.Json_collection_within_collection_Count(async); + + AssertSql( +""" +SELECT "j"."Id", "j"."EntityBasicId", "j"."Name", "j"."OwnedCollectionRoot", "j"."OwnedReferenceRoot" +FROM "JsonEntitiesBasic" AS "j" +WHERE EXISTS ( + SELECT 1 + FROM ( + SELECT "o"."value" ->> 'Name' AS "Name", "o"."value" ->> 'Names' AS "Names", "o"."value" ->> 'Number' AS "Number", "o"."value" ->> 'Numbers' AS "Numbers", "o"."value" ->> 'OwnedCollectionBranch' AS "OwnedCollectionBranch", "o"."value" ->> 'OwnedReferenceBranch' AS "OwnedReferenceBranch", "o"."key" + FROM json_each("j"."OwnedCollectionRoot", '$') AS "o" + ) AS "t" + WHERE ( + SELECT COUNT(*) + FROM ( + SELECT "o0"."value" ->> 'Date' AS "Date", "o0"."value" ->> 'Enum' AS "Enum", "o0"."value" ->> 'Enums' AS "Enums", "o0"."value" ->> 'Fraction' AS "Fraction", "o0"."value" ->> 'NullableEnum' AS "NullableEnum", "o0"."value" ->> 'NullableEnums' AS "NullableEnums", "o0"."value" ->> 'OwnedCollectionLeaf' AS "OwnedCollectionLeaf", "o0"."value" ->> 'OwnedReferenceLeaf' AS "OwnedReferenceLeaf", "o0"."key" + FROM json_each("t"."OwnedCollectionBranch", '$') AS "o0" + ) AS "t0") = 2) +"""); + } + + public override async Task Json_collection_Select_entity_with_initializer_ElementAt(bool async) + => Assert.Equal( + SqliteStrings.ApplyNotSupported, + (await Assert.ThrowsAsync( + () => base.Json_collection_Select_entity_with_initializer_ElementAt(async))) + .Message); + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public virtual async Task FromSqlInterpolated_on_entity_with_json_with_predicate(bool async) @@ -90,9 +206,9 @@ await AssertQuery( """); } - public override async Task Json_collection_element_access_in_predicate_nested_mix(bool async) + public override async Task Json_collection_index_in_predicate_nested_mix(bool async) { - await base.Json_collection_element_access_in_predicate_nested_mix(async); + await base.Json_collection_index_in_predicate_nested_mix(async); AssertSql( """ @@ -140,6 +256,70 @@ public override async Task Json_predicate_on_bool_converted_to_string_Y_N(bool a """); } + public override async Task Json_collection_in_projection_with_anonymous_projection_of_scalars(bool async) + => Assert.Equal( + SqliteStrings.ApplyNotSupported, + (await Assert.ThrowsAsync( + () => base.Json_collection_in_projection_with_anonymous_projection_of_scalars(async))) + .Message); + + public override async Task Json_collection_in_projection_with_composition_where_and_anonymous_projection_of_scalars(bool async) + => Assert.Equal( + SqliteStrings.ApplyNotSupported, + (await Assert.ThrowsAsync( + () => base.Json_collection_in_projection_with_composition_where_and_anonymous_projection_of_scalars(async))) + .Message); + + public override async Task Json_collection_in_projection_with_composition_where_and_anonymous_projection_of_primitive_arrays(bool async) + => Assert.Equal( + SqliteStrings.ApplyNotSupported, + (await Assert.ThrowsAsync( + () => base.Json_collection_in_projection_with_composition_where_and_anonymous_projection_of_primitive_arrays(async))) + .Message); + + public override async Task Json_collection_Select_entity_in_anonymous_object_ElementAt(bool async) + => Assert.Equal( + SqliteStrings.ApplyNotSupported, + (await Assert.ThrowsAsync( + () => base.Json_collection_Select_entity_in_anonymous_object_ElementAt(async))) + .Message); + + public override async Task Json_collection_skip_take_in_projection_project_into_anonymous_type(bool async) + => Assert.Equal( + SqliteStrings.ApplyNotSupported, + (await Assert.ThrowsAsync( + () => base.Json_collection_skip_take_in_projection_project_into_anonymous_type(async))) + .Message); + + public override async Task Json_collection_skip_take_in_projection_with_json_reference_access_as_final_operation(bool async) + => Assert.Equal( + SqliteStrings.ApplyNotSupported, + (await Assert.ThrowsAsync( + () => base.Json_collection_skip_take_in_projection_with_json_reference_access_as_final_operation(async))) + .Message); + + public override async Task Json_collection_index_in_projection_using_untranslatable_client_method(bool async) + { + var message = (await Assert.ThrowsAsync( + () => base.Json_collection_index_in_projection_using_untranslatable_client_method(async))).Message; + + Assert.Contains(CoreStrings.QueryUnableToTranslateMethod( + "Microsoft.EntityFrameworkCore.Query.JsonQueryTestBase", + "MyMethod"), + message); + } + + public override async Task Json_collection_index_in_projection_using_untranslatable_client_method2(bool async) + { + var message = (await Assert.ThrowsAsync( + () => base.Json_collection_index_in_projection_using_untranslatable_client_method2(async))).Message; + + Assert.Contains(CoreStrings.QueryUnableToTranslateMethod( + "Microsoft.EntityFrameworkCore.Query.JsonQueryTestBase", + "MyMethod"), + message); + } + private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); } diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/PrimitiveCollectionsQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/PrimitiveCollectionsQuerySqliteTest.cs index d3ef5ed33c9..37b34f924f1 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/PrimitiveCollectionsQuerySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/PrimitiveCollectionsQuerySqliteTest.cs @@ -646,6 +646,23 @@ WHERE json_array_length("p"."Ints") > 0 """); } + public override async Task Column_collection_Distinct(bool async) + { + await base.Column_collection_Distinct(async); + + AssertSql( +""" +SELECT "p"."Id", "p"."Bool", "p"."Bools", "p"."DateTime", "p"."DateTimes", "p"."Enum", "p"."Enums", "p"."Int", "p"."Ints", "p"."NullableInt", "p"."NullableInts", "p"."String", "p"."Strings" +FROM "PrimitiveCollectionsEntity" AS "p" +WHERE ( + SELECT COUNT(*) + FROM ( + SELECT DISTINCT "i"."value" + FROM json_each("p"."Ints") AS "i" + ) AS "t") = 3 +"""); + } + public override async Task Column_collection_projection_from_top_level(bool async) { await base.Column_collection_projection_from_top_level(async); @@ -1021,6 +1038,19 @@ public override async Task Project_multiple_collections(bool async) (await Assert.ThrowsAsync( () => base.Project_multiple_collections(async))).Message); + public override async Task Project_primitive_collections_element(bool async) + { + await base.Project_primitive_collections_element(async); + + AssertSql( +""" +SELECT "p"."Ints" ->> 0 AS "Indexer", rtrim(rtrim(strftime('%Y-%m-%d %H:%M:%f', "p"."DateTimes" ->> 0), '0'), '.') AS "EnumerableElementAt", "p"."Strings" ->> 1 AS "QueryableElementAt" +FROM "PrimitiveCollectionsEntity" AS "p" +WHERE "p"."Id" < 4 +ORDER BY "p"."Id" +"""); + } + public override async Task Project_empty_collection_of_nullables_and_collection_only_containing_nulls(bool async) => Assert.Equal( SqliteStrings.ApplyNotSupported,