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..6a16c3e39a0 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs +++ b/src/EFCore.Relational/Properties/RelationalStrings.Designer.cs @@ -1155,6 +1155,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 +1509,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..d680d55eb6b 100644 --- a/src/EFCore.Relational/Properties/RelationalStrings.resx +++ b/src/EFCore.Relational/Properties/RelationalStrings.resx @@ -553,6 +553,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 +992,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..1ae159c5fe1 100644 --- a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs @@ -223,6 +223,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 +329,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 +622,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 +1878,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( diff --git a/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs b/src/EFCore.Relational/Query/SqlExpressions/SelectExpression.cs index f746e53b096..19fcc3d1661 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,95 @@ internal SelectExpression(IEntityType entityType, TableExpressionBase tableExpre } } + /// + /// Constructs a over a collection within a JSON document. + /// + /// + /// The collection within a JSON document which the will represent. + /// + /// + /// The table for the ; typically a provider-specific table-valued function that converts a JSON + /// array to a relational table/rowset (e.g. SQL Server OPENJSON) + /// + public SelectExpression(JsonQueryExpression jsonQueryExpression, TableExpressionBase tableExpressionBase) + : 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)) + { + // 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(), property.IsNullable); + } + } + + var entityProjection = new EntityProjectionExpression(entityType, propertyExpressions); + + var containerColumnName = jsonQueryExpression.EntityType.GetContainerColumnName()!; + var jsonColumnTypeMapping = (entityType.GetViewOrTableMappings().SingleOrDefault()?.Table + ?? entityType.GetDefaultMappings().Single().Table) + .FindColumn(containerColumnName)!.StoreTypeMapping; + + foreach (var navigation in GetAllNavigationsInHierarchy(entityType) + .Where( + n => n.ForeignKey.IsOwnership + && n.TargetEntityType.IsMappedToJson() + && n.ForeignKey.PrincipalToDependent == n)) + { + var targetEntityType = navigation.TargetEntityType; + + var jsonNavigationName = navigation.TargetEntityType.GetJsonPropertyName(); + Check.DebugAssert(jsonNavigationName is not null, "Invalid navigation found on JSON-mapped entity"); + + // 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 jsonColumn = new ConcreteColumnExpression( + jsonNavigationName, + tableReferenceExpression, + jsonColumnTypeMapping.ClrType, + jsonColumnTypeMapping, + nullable: !navigation.ForeignKey.IsRequiredDependent || navigation.IsCollection); + + var entityShaperExpression = new RelationalEntityShaperExpression( + targetEntityType, + new JsonQueryExpression( + targetEntityType, + jsonColumn, + jsonQueryExpression.KeyPropertyMap, + navigation.ClrType, + navigation.IsCollection), + !navigation.ForeignKey.IsRequiredDependent); + + entityProjection.AddNavigationBinding(navigation, entityShaperExpression); + } + + _projectionMapping[new ProjectionMember()] = entityProjection; + + foreach (var (property, column) in jsonQueryExpression.KeyPropertyMap) + { + _identifier.Add((column, property.GetKeyValueComparer())); + } + } + private void AddJsonNavigationBindings( IEntityType entityType, EntityProjectionExpression entityProjection, @@ -534,16 +623,20 @@ private void AddJsonNavigationBindings( { var targetEntityType = ownedJsonNavigation.TargetEntityType; var jsonColumnName = targetEntityType.GetContainerColumnName()!; - var jsonColumnTypeMapping = (entityType.GetViewOrTableMappings().SingleOrDefault()?.Table + var jsonColumn = (entityType.GetViewOrTableMappings().SingleOrDefault()?.Table ?? entityType.GetDefaultMappings().Single().Table) - .FindColumn(jsonColumnName)!.StoreTypeMapping; + .FindColumn(jsonColumnName)!; + var jsonColumnTypeMapping = jsonColumn.StoreTypeMapping; + var isNullable = jsonColumn.IsNullable + || !ownedJsonNavigation.ForeignKey.IsRequiredDependent + || ownedJsonNavigation.IsCollection; - var jsonColumn = new ConcreteColumnExpression( + var jsonColumnExpression = new ConcreteColumnExpression( jsonColumnName, tableReferenceExpression, jsonColumnTypeMapping.ClrType, jsonColumnTypeMapping, - nullable: !ownedJsonNavigation.ForeignKey.IsRequiredDependent || ownedJsonNavigation.IsCollection); + 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 +657,11 @@ private void AddJsonNavigationBindings( targetEntityType, new JsonQueryExpression( targetEntityType, - jsonColumn, + jsonColumnExpression, keyPropertiesMap, ownedJsonNavigation.ClrType, ownedJsonNavigation.IsCollection), - !ownedJsonNavigation.ForeignKey.IsRequiredDependent); + isNullable); entityProjection.AddNavigationBinding(ownedJsonNavigation, entityShaperExpression); } 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/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..730c2e8cc5b 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs @@ -418,8 +418,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 +451,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 +475,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 +488,18 @@ 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 + // OPENJSON is a regular table-valued function with an optional special WITH clause at the end. + // The second argument is the JSON path, which can either be a regular SqlExpression, or a list of PathSegments, from which we + // generate a constant JSONPATH from. 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..345646f7228 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryTranslationPostprocessor.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryTranslationPostprocessor.cs @@ -2,9 +2,9 @@ // 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 System.Text; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using Microsoft.EntityFrameworkCore.SqlServer.Infrastructure.Internal; using Microsoft.EntityFrameworkCore.SqlServer.Internal; using Microsoft.EntityFrameworkCore.SqlServer.Storage.Internal; @@ -18,7 +18,7 @@ namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal; /// public class SqlServerQueryTranslationPostprocessor : RelationalQueryTranslationPostprocessor { - private readonly OpenJsonPostprocessor _openJsonPostprocessor; + private readonly JsonPostprocessor _openJsonPostprocessor; private readonly SkipWithoutOrderByInSplitQueryVerifier _skipWithoutOrderByInSplitQueryVerifier = new(); /// @@ -91,18 +91,22 @@ private sealed class SkipWithoutOrderByInSplitQueryVerifier : ExpressionVisitor /// 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 sealed class JsonPostprocessor : ExpressionVisitor { private readonly IRelationalTypeMappingSource _typeMappingSource; private readonly ISqlExpressionFactory _sqlExpressionFactory; - private readonly Dictionary<(SqlServerOpenJsonExpression, string), RelationalTypeMapping> _castsToApply = new(); + private readonly Dictionary<(SqlServerOpenJsonExpression, string), (SelectExpression, SqlServerOpenJsonExpression.ColumnInfo)> _columnsToRewrite = new(); - public OpenJsonPostprocessor(IRelationalTypeMappingSource typeMappingSource, ISqlExpressionFactory sqlExpressionFactory) + private RelationalTypeMapping? _nvarcharMaxTypeMapping, _nvarchar4000TypeMapping; + + public JsonPostprocessor( + IRelationalTypeMappingSource typeMappingSource, + ISqlExpressionFactory sqlExpressionFactory) => (_typeMappingSource, _sqlExpressionFactory) = (typeMappingSource, sqlExpressionFactory); public Expression Process(Expression expression) { - _castsToApply.Clear(); + _columnsToRewrite.Clear(); return Visit(expression); } @@ -176,6 +180,11 @@ public Expression Process(Expression expression) } _castsToApply.Add((newOpenJsonExpression, column.Name), typeMapping); + +jhjkshjkshfkjshkj + _columnsToRewrite.Add((newOpenJsonExpression, columnInfo.Name), new(newSelectExpression, columnInfo)); +fshjhsjkhfkjshjkfshkj + appliedCasts.Add((newOpenJsonExpression, column.Name)); } @@ -203,18 +212,19 @@ public Expression Process(Expression expression) : 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. + // clause. Then visit the select expression, replacing all matching ColumnExpressions - see below for the details. var result = base.Visit(newSelectExpression); foreach (var appliedCast in appliedCasts) { - _castsToApply.Remove(appliedCast); + _columnsToRewrite.Remove((newOpenJsonExpression, column.Name)); } return result; } - case ColumnExpression columnExpression: + case ColumnExpression { Table: SqlServerOpenJsonExpression openJsonTable, Name: var name } columnExpression + when _columnsToRewrite.TryGetValue((openJsonTable, name), out var columnRewriteInfo): { return columnExpression.Table switch { @@ -226,6 +236,76 @@ when _castsToApply.TryGetValue((innerOpenJsonTable, columnExpression.Name), out => _sqlExpressionFactory.Convert(columnExpression, columnExpression.Type, innerTypeMapping), _ => base.Visit(expression) }; + // We found a ColumnExpression that refers to the OPENJSON table, we need to rewrite it. + + var (selectExpression, columnInfo) = columnRewriteInfo; + + // 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); + + // If the WITH column info contained a path, we need to wrap the new column expression with a JSON_VALUE for that path. + if (columnInfo.Path is not (null or [])) + { + if (columnInfo.AsJson) + { + throw new InvalidOperationException( + "IMPOSSIBLE. 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 below"); + } + + _nvarchar4000TypeMapping ??= _typeMappingSource.FindMapping("nvarchar(4000)"); + + rewrittenColumn = new JsonScalarExpression( + rewrittenColumn, columnInfo.Path, rewrittenColumn.Type, _nvarchar4000TypeMapping, columnExpression.IsNullable); + } + + // OPENJSON with WITH specified the store type in the WITH, but the version without just always projects + // nvarchar(max); add a CAST to convert. Note that for AS JSON the type mapping is always nvarchar(max), and we don't + // need to add a CAST over the JSON_QUERY returned above. + if (columnInfo.TypeMapping.StoreType != "nvarchar(max)") + { + _nvarcharMaxTypeMapping ??= _typeMappingSource.FindMapping("nvarchar(max)"); + + rewrittenColumn = _sqlExpressionFactory.Convert( + rewrittenColumn, + columnExpression.Type, + columnInfo.TypeMapping); + } + + return rewrittenColumn; + } + + // 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: diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerQueryableMethodTranslatingExpressionVisitor.cs index 8223af7a1a2..2f3ca276932 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,94 @@ 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); + + var selectExpression = new SelectExpression(jsonQueryExpression, openJsonExpression); + + // 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 +383,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 +591,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 +612,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..929fcd9c39e 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,146 @@ 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); + + var selectExpression = new SelectExpression(jsonQueryExpression, jsonEachExpression); + + 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]; + + var newOuterSelectExpression = new SelectExpression(jsonQueryExpression, subquery); + + 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 +476,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 +701,22 @@ private static SqlExpression ApplyJsonSqlConversion( _ => expression }; + + private 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..16cee0af707 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,7 +865,7 @@ 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) + public virtual async Task Json_collection_index_in_projection_using_untranslatable_client_method(bool async) { var message = (await Assert.ThrowsAsync( () => AssertQuery( @@ -879,7 +879,7 @@ public virtual async Task Json_collection_element_access_in_projection_using_unt [ConditionalTheory] [MemberData(nameof(IsAsyncData))] - public virtual async Task Json_collection_element_access_in_projection_using_untranslatable_client_method2(bool async) + public virtual async Task Json_collection_index_in_projection_using_untranslatable_client_method2(bool async) { var message = (await Assert.ThrowsAsync( () => AssertQuery( @@ -893,7 +893,7 @@ public virtual async Task Json_collection_element_access_in_projection_using_unt [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(), @@ -909,7 +909,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 +917,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 +928,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 +939,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 +950,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 +966,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 +985,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 +1003,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 +1017,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 +1031,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 +1045,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 +1064,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 +1075,143 @@ 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 - { - x.Id, - CollectionElement = x.OwnedCollectionRoot.Select(xx => x.OwnedReferenceRoot).ElementAt(0) - })))).Message; + 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); - Assert.Equal(CoreStrings.TranslationFailed("j.OwnedCollectionRoot Q-> "), message); - } + [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 async Task Json_collection_element_access_manual_Element_at_and_pushdown_negative4(bool async) + 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().Select(x => + x.OwnedCollectionRoot.Select(xx => new { xx.OwnedReferenceBranch }).ElementAt(0))); + + [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 +1288,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 @@ -1280,7 +1322,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; @@ -1322,7 +1364,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 @@ -1356,7 +1398,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; @@ -1398,7 +1440,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; @@ -1440,7 +1482,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 @@ -1474,7 +1516,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; diff --git a/test/EFCore.Specification.Tests/Query/PrimitiveCollectionsQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/PrimitiveCollectionsQueryTestBase.cs index f4bdaa5e302..d9576f758e4 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: 1); + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public virtual Task Column_collection_projection_from_top_level(bool async) @@ -810,9 +823,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..519ea1a0e56 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,23 @@ 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); + await base.Json_collection_index_in_projection_using_untranslatable_client_method(async); AssertSql(); } - 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); + await base.Json_collection_index_in_projection_using_untranslatable_client_method2(async); AssertSql(); } - 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( """ @@ -799,9 +799,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 +812,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 +826,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 +840,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 +854,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 +869,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 +882,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 +895,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 +910,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 +923,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 +939,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 +953,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 +966,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 +980,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 +991,208 @@ 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', + [Fraction] decimal(18,2) '$.Fraction', + [NullableEnum] nvarchar(max) '$.NullableEnum', + [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], [j].[Id], 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].[OwnedReferenceLeaf], '$.SomethingSomething') AS [c], [j].[Id], [o].[Date] AS [c0] + FROM OPENJSON([j].[OwnedReferenceRoot], '$.OwnedCollectionBranch') WITH ( + [Date] datetime2 '$.Date', + [Enum] nvarchar(max) '$.Enum', + [Fraction] decimal(18,2) '$.Fraction', + [NullableEnum] nvarchar(max) '$.NullableEnum', + [OwnedCollectionLeaf] nvarchar(max) '$.OwnedCollectionLeaf' AS JSON, + [OwnedReferenceLeaf] nvarchar(max) '$.OwnedReferenceLeaf' AS JSON + ) AS [o] + ORDER BY [o].[Date] 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(); } - 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', + [Number] int '$.Number', + [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', + [Fraction] decimal(18,2) '$.Fraction', + [NullableEnum] nvarchar(max) '$.NullableEnum', + [OwnedCollectionLeaf] nvarchar(max) '$.OwnedCollectionLeaf' AS JSON, + [OwnedReferenceLeaf] nvarchar(max) '$.OwnedReferenceLeaf' AS JSON + ) AS [o0]) = 2) +"""); + } + + 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] +"""); + } + + 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 JSON_QUERY([j].[OwnedCollectionRoot], '$[0].OwnedReferenceBranch'), [j].[Id] +FROM [JsonEntitiesBasic] AS [j] +"""); + } + + 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 +1234,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( """ @@ -1095,9 +1257,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( """ @@ -1147,7 +1309,7 @@ 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) { - 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( """ @@ -1175,7 +1337,7 @@ FROM [JsonEntitiesBasic] AS [j] [SqlServerCondition(SqlServerCondition.SupportsJsonPathExpressions)] public override async Task Json_collection_element_access_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( """ @@ -1202,7 +1364,7 @@ FROM [JsonEntitiesBasic] AS [j] public override async Task Json_collection_element_access_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( """ @@ -1223,9 +1385,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( """ diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQueryOldSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQueryOldSqlServerTest.cs index ba83bc7c47f..ba8054e16d2 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); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServerTest.cs index 00e227b6e8b..46d6b8029d0 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); diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/JsonQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/JsonQuerySqliteTest.cs index 7d73ffcec7a..07777fcc77f 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" ->> 'Fraction' AS "Fraction", "o"."value" ->> 'NullableEnum' AS "NullableEnum", "o"."value" ->> 'OwnedCollectionLeaf' AS "OwnedCollectionLeaf", "o"."value" ->> 'OwnedReferenceLeaf' AS "OwnedReferenceLeaf", "j"."Id", "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" ->> 'Fraction' AS "Fraction", "o"."value" ->> 'NullableEnum' AS "NullableEnum", "o"."value" ->> 'OwnedCollectionLeaf' AS "OwnedCollectionLeaf", "o"."value" ->> 'OwnedReferenceLeaf' AS "OwnedReferenceLeaf", "j"."Id", "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", "j"."Id", "t"."key" + FROM ( + SELECT "o"."value" ->> 'Date' AS "Date", "o"."value" ->> 'Enum' AS "Enum", "o"."value" ->> 'Fraction' AS "Fraction", "o"."value" ->> 'NullableEnum' AS "NullableEnum", "o"."value" ->> 'OwnedCollectionLeaf' AS "OwnedCollectionLeaf", "o"."value" ->> 'OwnedReferenceLeaf' AS "OwnedReferenceLeaf", "j"."Id", "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"."key" + 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", "j"."Id", "t"."Date" AS "c0" + FROM ( + SELECT "o"."value" ->> 'Date' AS "Date", "o"."value" ->> 'Enum' AS "Enum", "o"."value" ->> 'Fraction' AS "Fraction", "o"."value" ->> 'NullableEnum' AS "NullableEnum", "o"."value" ->> 'OwnedCollectionLeaf' AS "OwnedCollectionLeaf", "o"."value" ->> 'OwnedReferenceLeaf' AS "OwnedReferenceLeaf", "j"."Id", "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" ->> 'Number' AS "Number", "o"."value" ->> 'OwnedCollectionBranch' AS "OwnedCollectionBranch", "o"."value" ->> 'OwnedReferenceBranch' AS "OwnedReferenceBranch", "j"."Id", "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" ->> 'Fraction' AS "Fraction", "o0"."value" ->> 'NullableEnum' AS "NullableEnum", "o0"."value" ->> 'OwnedCollectionLeaf' AS "OwnedCollectionLeaf", "o0"."value" ->> 'OwnedReferenceLeaf' AS "OwnedReferenceLeaf", "j"."Id", "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.Project_json_entity_FirstOrDefault_subquery_deduplication_outer_reference_and_pruning(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( """ diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/PrimitiveCollectionsQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/PrimitiveCollectionsQuerySqliteTest.cs index d3ef5ed33c9..f7d783a5a87 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);