From 5defd88b87056e0bebe6d41a2f5d2088413b19e5 Mon Sep 17 00:00:00 2001 From: Shay Rojansky Date: Sat, 23 Dec 2023 10:31:11 +0200 Subject: [PATCH] Refactor queryable collection support for non-relational providers (#32506) Also fixing exception ordering issue. Fixes #32505 --- .../Query/RelationalQueryRootProcessor.cs | 2 +- ...yableMethodTranslatingExpressionVisitor.cs | 101 +++++------ ...lationalSqlTranslatingExpressionVisitor.cs | 41 +---- ...yableMethodTranslatingExpressionVisitor.cs | 158 +++++++++--------- .../PrimitiveCollectionsQueryTestBase.cs | 7 + ...imitiveCollectionsQueryOldSqlServerTest.cs | 3 + .../PrimitiveCollectionsQuerySqlServerTest.cs | 12 ++ .../PrimitiveCollectionsQuerySqliteTest.cs | 6 + 8 files changed, 172 insertions(+), 158 deletions(-) diff --git a/src/EFCore.Relational/Query/RelationalQueryRootProcessor.cs b/src/EFCore.Relational/Query/RelationalQueryRootProcessor.cs index ee424e035bf..98dfaa4fa56 100644 --- a/src/EFCore.Relational/Query/RelationalQueryRootProcessor.cs +++ b/src/EFCore.Relational/Query/RelationalQueryRootProcessor.cs @@ -28,7 +28,7 @@ public RelationalQueryRootProcessor( /// /// Indicates that a can be converted to a ; - /// the latter will end up in for + /// the latter will end up in for /// translation to a SQL . /// protected override bool ShouldConvertToInlineQueryRoot(NewArrayExpression newArrayExpression) diff --git a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs index 62d9e026b0c..547a39dab6b 100644 --- a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs @@ -210,19 +210,6 @@ when entityQueryRootExpression.GetType() == typeof(EntityQueryRootExpression) return new ShapedQueryExpression(selectExpression, shaperExpression); } - case InlineQueryRootExpression inlineQueryRootExpression: - return VisitInlineQueryRoot(inlineQueryRootExpression) ?? base.VisitExtension(extensionExpression); - - case ParameterQueryRootExpression parameterQueryRootExpression: - var sqlParameterExpression = - _sqlTranslator.Visit(parameterQueryRootExpression.ParameterExpression) as SqlParameterExpression; - Check.DebugAssert(sqlParameterExpression is not null, "sqlParameterExpression is not null"); - return TranslatePrimitiveCollection( - sqlParameterExpression, - property: null, - char.ToLowerInvariant(sqlParameterExpression.Name.First(c => c != '_')).ToString()) - ?? base.VisitExtension(extensionExpression); - case JsonQueryExpression jsonQueryExpression: return TransformJsonQueryToTable(jsonQueryExpression) ?? base.VisitExtension(extensionExpression); @@ -262,46 +249,64 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp var translated = base.VisitMethodCall(methodCallExpression); - if (translated == QueryCompilationContext.NotTranslatedExpression) + // For Contains over a collection parameter, if the provider hasn't implemented TranslateCollection (e.g. OPENJSON on SQL + // Server), we need to fall back to the previous IN translation. + if (translated == QueryCompilationContext.NotTranslatedExpression + && method.IsGenericMethod + && method.GetGenericMethodDefinition() == QueryableMethods.Contains + && methodCallExpression.Arguments[0] is ParameterQueryRootExpression parameterSource + && TranslateExpression(methodCallExpression.Arguments[1]) is SqlExpression item + && _sqlTranslator.Visit(parameterSource.ParameterExpression) is SqlParameterExpression sqlParameterExpression) { - // Attempt to translate access into a primitive collection property (i.e. array column) - if (_sqlTranslator.TryTranslatePropertyAccess(methodCallExpression, out var translatedExpression, out var property) - && property is IProperty { IsPrimitiveCollection: true } regularProperty - && translatedExpression is SqlExpression sqlExpression) - { - var tableAlias = sqlExpression switch - { - ColumnExpression c => c.Name[..1].ToLowerInvariant(), - JsonScalarExpression { Path: [.., { PropertyName: string propertyName }] } => propertyName[..1].ToLowerInvariant(), - _ => "j" - }; + var inExpression = _sqlExpressionFactory.In(item, sqlParameterExpression); + var selectExpression = new SelectExpression(inExpression); + var shaperExpression = Expression.Convert( + new ProjectionBindingExpression(selectExpression, new ProjectionMember(), typeof(bool?)), typeof(bool)); + var shapedQueryExpression = new ShapedQueryExpression(selectExpression, shaperExpression) + .UpdateResultCardinality(ResultCardinality.Single); + return shapedQueryExpression; + } - if (TranslatePrimitiveCollection(sqlExpression, regularProperty, tableAlias) is - { } primitiveCollectionTranslation) - { - return primitiveCollectionTranslation; - } - } + return translated; + } + + /// + protected override ShapedQueryExpression? TranslateMemberAccess(Expression source, MemberIdentity member) + { + // Attempt to translate access into a primitive collection property (i.e. array column) + if (_sqlTranslator.TryBindMember(_sqlTranslator.Visit(source), member, out var translatedExpression, out var property) + && property is IProperty { IsPrimitiveCollection: true } regularProperty + && translatedExpression is SqlExpression sqlExpression) + { + var tableAlias = sqlExpression switch + { + ColumnExpression c => c.Name[..1].ToLowerInvariant(), + JsonScalarExpression { Path: [.., { PropertyName: string propertyName }] } => propertyName[..1].ToLowerInvariant(), + _ => "j" + }; - // For Contains over a collection parameter, if the provider hasn't implemented TranslateCollection (e.g. OPENJSON on SQL - // Server), we need to fall back to the previous IN translation. - if (method.IsGenericMethod - && method.GetGenericMethodDefinition() == QueryableMethods.Contains - && methodCallExpression.Arguments[0] is ParameterQueryRootExpression parameterSource - && TranslateExpression(methodCallExpression.Arguments[1]) is SqlExpression item - && _sqlTranslator.Visit(parameterSource.ParameterExpression) is SqlParameterExpression sqlParameterExpression) + if (TranslatePrimitiveCollection(sqlExpression, regularProperty, tableAlias) is + { } primitiveCollectionTranslation) { - var inExpression = _sqlExpressionFactory.In(item, sqlParameterExpression); - var selectExpression = new SelectExpression(inExpression); - var shaperExpression = Expression.Convert( - new ProjectionBindingExpression(selectExpression, new ProjectionMember(), typeof(bool?)), typeof(bool)); - var shapedQueryExpression = new ShapedQueryExpression(selectExpression, shaperExpression) - .UpdateResultCardinality(ResultCardinality.Single); - return shapedQueryExpression; + return primitiveCollectionTranslation; } } - return translated; + return null; + } + + /// + protected override ShapedQueryExpression? TranslateParameterQueryRoot(ParameterQueryRootExpression parameterQueryRootExpression) + { + var sqlParameterExpression = + _sqlTranslator.Visit(parameterQueryRootExpression.ParameterExpression) as SqlParameterExpression; + + Check.DebugAssert(sqlParameterExpression is not null, "sqlParameterExpression is not null"); + + return TranslatePrimitiveCollection( + sqlParameterExpression, + property: null, + char.ToLowerInvariant(sqlParameterExpression.Name.First(c => c != '_')).ToString()); } /// @@ -310,7 +315,7 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp /// (no translation). /// /// - /// Inline collections aren't passed to this method; see for the translation of inline + /// Inline collections aren't passed to this method; see for the translation of inline /// collections. /// /// The expression to try to translate as a primitive collection expression. @@ -346,7 +351,7 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp /// /// The inline collection to be translated. /// A queryable SQL VALUES expression. - protected virtual ShapedQueryExpression? VisitInlineQueryRoot(InlineQueryRootExpression inlineQueryRootExpression) + protected override ShapedQueryExpression? TranslateInlineQueryRoot(InlineQueryRootExpression inlineQueryRootExpression) { var elementType = inlineQueryRootExpression.ElementType; diff --git a/src/EFCore.Relational/Query/RelationalSqlTranslatingExpressionVisitor.cs b/src/EFCore.Relational/Query/RelationalSqlTranslatingExpressionVisitor.cs index 59b3a8c882a..23852400324 100644 --- a/src/EFCore.Relational/Query/RelationalSqlTranslatingExpressionVisitor.cs +++ b/src/EFCore.Relational/Query/RelationalSqlTranslatingExpressionVisitor.cs @@ -760,38 +760,6 @@ protected override Expression VisitMemberInit(MemberInitExpression memberInitExp ? sqlConstantExpression : QueryCompilationContext.NotTranslatedExpression; - /// - /// 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 bool TryTranslatePropertyAccess( - Expression expression, - [NotNullWhen(true)] out Expression? translatedExpression, - [NotNullWhen(true)] out IPropertyBase? property) - { - if (expression is MethodCallExpression methodCallExpression) - { - if (methodCallExpression.TryGetEFPropertyArguments(out var source, out var propertyName) - && TryBindMember(Visit(source), MemberIdentity.Create(propertyName), out translatedExpression, out property)) - { - return true; - } - - if (methodCallExpression.TryGetIndexerArguments(_model, out source, out propertyName) - && TryBindMember(Visit(source), MemberIdentity.Create(propertyName), out translatedExpression, out property)) - { - return true; - } - } - - translatedExpression = null; - property = null; - return false; - } - /// protected override Expression VisitMethodCall(MethodCallExpression methodCallExpression) { @@ -1258,7 +1226,14 @@ private bool TryBindMember( [NotNullWhen(true)] out Expression? expression) => TryBindMember(source, member, out expression, out _); - private bool TryBindMember( + /// + /// 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 bool TryBindMember( Expression? source, MemberIdentity member, [NotNullWhen(true)] out Expression? expression, diff --git a/src/EFCore/Query/QueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore/Query/QueryableMethodTranslatingExpressionVisitor.cs index 7c9b31fd1b9..82a0ef3339f 100644 --- a/src/EFCore/Query/QueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore/Query/QueryableMethodTranslatingExpressionVisitor.cs @@ -106,20 +106,28 @@ protected virtual void AddTranslationErrorDetails(string details) /// protected override Expression VisitExtension(Expression extensionExpression) { - if (extensionExpression is QueryRootExpression queryRootExpression) + switch (extensionExpression) { - // This requires exact type match on query root to avoid processing query roots derived from EntityQueryRootExpression, e.g. - // SQL Server TemporalQueryRootExpression. - if (queryRootExpression.GetType() == typeof(EntityQueryRootExpression)) - { - return CreateShapedQueryExpression(((EntityQueryRootExpression)extensionExpression).EntityType); - } + case InlineQueryRootExpression inlineQueryRootExpression: + return TranslateInlineQueryRoot(inlineQueryRootExpression) ?? base.VisitExtension(extensionExpression); - _untranslatedExpression = queryRootExpression; - return QueryCompilationContext.NotTranslatedExpression; - } + case ParameterQueryRootExpression parameterQueryRootExpression: + return TranslateParameterQueryRoot(parameterQueryRootExpression) ?? base.VisitExtension(extensionExpression); + + case QueryRootExpression queryRootExpression: + // This requires exact type match on query root to avoid processing query roots derived from EntityQueryRootExpression, e.g. + // SQL Server TemporalQueryRootExpression. + if (queryRootExpression.GetType() == typeof(EntityQueryRootExpression)) + { + return CreateShapedQueryExpression(((EntityQueryRootExpression)extensionExpression).EntityType); + } - return base.VisitExtension(extensionExpression); + _untranslatedExpression = queryRootExpression; + return QueryCompilationContext.NotTranslatedExpression; + + default: + return base.VisitExtension(extensionExpression); + } } /// @@ -517,6 +525,16 @@ Expression CheckTranslated(ShapedQueryExpression? translated) } } + // The method isn't a LINQ operator on Queryable/QueryableExtensions. + + // Identify property access, e.g. primitive collection property (context.Blogs.Where(b => b.Tags.Contains(...))) + if ((methodCallExpression.TryGetEFPropertyArguments(out var propertyAccessSource, out var propertyName) + || methodCallExpression.TryGetIndexerArguments(QueryCompilationContext.Model, out propertyAccessSource, out propertyName)) + && TranslateMemberAccess(propertyAccessSource, MemberIdentity.Create(propertyName)) is ShapedQueryExpression translation) + { + return translation; + } + return _subquery ? QueryCompilationContext.NotTranslatedExpression : throw new InvalidOperationException(CoreStrings.TranslationFailed(methodCallExpression.Print())); @@ -582,9 +600,7 @@ protected virtual Expression MarkShaperNullable(Expression shaperExpression) /// The shaped query on which the operator is applied. /// The predicate supplied in the call. /// The shaped query after translation. - protected abstract ShapedQueryExpression? TranslateAny( - ShapedQueryExpression source, - LambdaExpression? predicate); + protected abstract ShapedQueryExpression? TranslateAny(ShapedQueryExpression source, LambdaExpression? predicate); /// /// Translates method and other overloads over the given source. @@ -593,10 +609,7 @@ protected virtual Expression MarkShaperNullable(Expression shaperExpression) /// The selector supplied in the call. /// The result type after the operation. /// The shaped query after translation. - protected abstract ShapedQueryExpression? TranslateAverage( - ShapedQueryExpression source, - LambdaExpression? selector, - Type resultType); + protected abstract ShapedQueryExpression? TranslateAverage(ShapedQueryExpression source, LambdaExpression? selector, Type resultType); /// /// Translates method over the given source. @@ -612,9 +625,7 @@ protected virtual Expression MarkShaperNullable(Expression shaperExpression) /// The shaped query on which the operator is applied. /// The other source to perform concat. /// The shaped query after translation. - protected abstract ShapedQueryExpression? TranslateConcat( - ShapedQueryExpression source1, - ShapedQueryExpression source2); + protected abstract ShapedQueryExpression? TranslateConcat(ShapedQueryExpression source1, ShapedQueryExpression source2); /// /// Translates method over the given source. @@ -630,9 +641,7 @@ protected virtual Expression MarkShaperNullable(Expression shaperExpression) /// The shaped query on which the operator is applied. /// The predicate supplied in the call. /// The shaped query after translation. - protected abstract ShapedQueryExpression? TranslateCount( - ShapedQueryExpression source, - LambdaExpression? predicate); + protected abstract ShapedQueryExpression? TranslateCount(ShapedQueryExpression source, LambdaExpression? predicate); /// /// Translates method and other overloads over the given source. @@ -640,9 +649,7 @@ protected virtual Expression MarkShaperNullable(Expression shaperExpression) /// The shaped query on which the operator is applied. /// The default value to use. /// The shaped query after translation. - protected abstract ShapedQueryExpression? TranslateDefaultIfEmpty( - ShapedQueryExpression source, - Expression? defaultValue); + protected abstract ShapedQueryExpression? TranslateDefaultIfEmpty(ShapedQueryExpression source, Expression? defaultValue); /// /// Translates method over the given source. @@ -670,9 +677,7 @@ protected virtual Expression MarkShaperNullable(Expression shaperExpression) /// The shaped query on which the operator is applied. /// The other source to perform except with. /// The shaped query after translation. - protected abstract ShapedQueryExpression? TranslateExcept( - ShapedQueryExpression source1, - ShapedQueryExpression source2); + protected abstract ShapedQueryExpression? TranslateExcept(ShapedQueryExpression source1, ShapedQueryExpression source2); /// /// Translates method or @@ -729,9 +734,7 @@ protected virtual Expression MarkShaperNullable(Expression shaperExpression) /// The shaped query on which the operator is applied. /// The other source to perform intersect with. /// The shaped query after translation. - protected abstract ShapedQueryExpression? TranslateIntersect( - ShapedQueryExpression source1, - ShapedQueryExpression source2); + protected abstract ShapedQueryExpression? TranslateIntersect(ShapedQueryExpression source1, ShapedQueryExpression source2); /// /// Translates @@ -793,9 +796,7 @@ protected virtual Expression MarkShaperNullable(Expression shaperExpression) /// The shaped query on which the operator is applied. /// The predicate supplied in the call. /// The shaped query after translation. - protected abstract ShapedQueryExpression? TranslateLongCount( - ShapedQueryExpression source, - LambdaExpression? predicate); + protected abstract ShapedQueryExpression? TranslateLongCount(ShapedQueryExpression source, LambdaExpression? predicate); /// /// Translates method and other overloads over the given source. @@ -804,10 +805,7 @@ protected virtual Expression MarkShaperNullable(Expression shaperExpression) /// The selector supplied in the call. /// The result type after the operation. /// The shaped query after translation. - protected abstract ShapedQueryExpression? TranslateMax( - ShapedQueryExpression source, - LambdaExpression? selector, - Type resultType); + protected abstract ShapedQueryExpression? TranslateMax(ShapedQueryExpression source, LambdaExpression? selector, Type resultType); /// /// Translates method and other overloads over the given source. @@ -816,10 +814,7 @@ protected virtual Expression MarkShaperNullable(Expression shaperExpression) /// The selector supplied in the call. /// The result type after the operation. /// The shaped query after translation. - protected abstract ShapedQueryExpression? TranslateMin( - ShapedQueryExpression source, - LambdaExpression? selector, - Type resultType); + protected abstract ShapedQueryExpression? TranslateMin(ShapedQueryExpression source, LambdaExpression? selector, Type resultType); /// /// Translates method over the given source. @@ -838,10 +833,7 @@ protected virtual Expression MarkShaperNullable(Expression shaperExpression) /// The key selector supplied in the call. /// A value indicating whether the ordering is ascending or not. /// The shaped query after translation. - protected abstract ShapedQueryExpression? TranslateOrderBy( - ShapedQueryExpression source, - LambdaExpression keySelector, - bool ascending); + protected abstract ShapedQueryExpression? TranslateOrderBy(ShapedQueryExpression source, LambdaExpression keySelector, bool ascending); /// /// Translates method over the given source. @@ -851,8 +843,8 @@ protected virtual Expression MarkShaperNullable(Expression shaperExpression) protected abstract ShapedQueryExpression? TranslateReverse(ShapedQueryExpression source); /// - /// Translates method over the - /// given source. + /// Translates method + /// over the given source. /// /// The shaped query on which the operator is applied. /// The selector supplied in the call. @@ -883,9 +875,7 @@ protected abstract ShapedQueryExpression TranslateSelect( /// The shaped query on which the operator is applied. /// The selector supplied in the call. /// The shaped query after translation. - protected abstract ShapedQueryExpression? TranslateSelectMany( - ShapedQueryExpression source, - LambdaExpression selector); + protected abstract ShapedQueryExpression? TranslateSelectMany(ShapedQueryExpression source, LambdaExpression selector); /// /// Translates method or @@ -909,9 +899,7 @@ protected abstract ShapedQueryExpression TranslateSelect( /// The shaped query on which the operator is applied. /// The count supplied in the call. /// The shaped query after translation. - protected abstract ShapedQueryExpression? TranslateSkip( - ShapedQueryExpression source, - Expression count); + protected abstract ShapedQueryExpression? TranslateSkip(ShapedQueryExpression source, Expression count); /// /// Translates method over the given @@ -920,9 +908,7 @@ protected abstract ShapedQueryExpression TranslateSelect( /// The shaped query on which the operator is applied. /// The predicate supplied in the call. /// The shaped query after translation. - protected abstract ShapedQueryExpression? TranslateSkipWhile( - ShapedQueryExpression source, - LambdaExpression predicate); + protected abstract ShapedQueryExpression? TranslateSkipWhile(ShapedQueryExpression source, LambdaExpression predicate); /// /// Translates method and other overloads over the given source. @@ -931,10 +917,7 @@ protected abstract ShapedQueryExpression TranslateSelect( /// The selector supplied in the call. /// The result type after the operation. /// The shaped query after translation. - protected abstract ShapedQueryExpression? TranslateSum( - ShapedQueryExpression source, - LambdaExpression? selector, - Type resultType); + protected abstract ShapedQueryExpression? TranslateSum(ShapedQueryExpression source, LambdaExpression? selector, Type resultType); /// /// Translates method over the given source. @@ -951,9 +934,7 @@ protected abstract ShapedQueryExpression TranslateSelect( /// The shaped query on which the operator is applied. /// The predicate supplied in the call. /// The shaped query after translation. - protected abstract ShapedQueryExpression? TranslateTakeWhile( - ShapedQueryExpression source, - LambdaExpression predicate); + protected abstract ShapedQueryExpression? TranslateTakeWhile(ShapedQueryExpression source, LambdaExpression predicate); /// /// Translates or @@ -964,10 +945,7 @@ protected abstract ShapedQueryExpression TranslateSelect( /// The key selector supplied in the call. /// A value indicating whether the ordering is ascending or not. /// The shaped query after translation. - protected abstract ShapedQueryExpression? TranslateThenBy( - ShapedQueryExpression source, - LambdaExpression keySelector, - bool ascending); + protected abstract ShapedQueryExpression? TranslateThenBy(ShapedQueryExpression source, LambdaExpression keySelector, bool ascending); /// /// Translates method over the given source. @@ -975,9 +953,7 @@ protected abstract ShapedQueryExpression TranslateSelect( /// The shaped query on which the operator is applied. /// The other source to perform union with. /// The shaped query after translation. - protected abstract ShapedQueryExpression? TranslateUnion( - ShapedQueryExpression source1, - ShapedQueryExpression source2); + protected abstract ShapedQueryExpression? TranslateUnion(ShapedQueryExpression source1, ShapedQueryExpression source2); /// /// Translates method over the given source. @@ -985,7 +961,37 @@ protected abstract ShapedQueryExpression TranslateSelect( /// The shaped query on which the operator is applied. /// The predicate supplied in the call. /// The shaped query after translation. - protected abstract ShapedQueryExpression? TranslateWhere( - ShapedQueryExpression source, - LambdaExpression predicate); + protected abstract ShapedQueryExpression? TranslateWhere(ShapedQueryExpression source, LambdaExpression predicate); + + #region Queryable collection support + + /// + /// Translates a member access. Used when a property on an entity type represents a collection on which queryable LINQ operators + /// may be composed. + /// + /// The shaped query on which the property access is applied. + /// The member being accessed. + /// The shaped query after translation. + protected virtual ShapedQueryExpression? TranslateMemberAccess(Expression source, MemberIdentity member) + => null; + + /// + /// Translates an , which represents a queryable collection expressed inline within the + /// query. + /// + /// The inline query root expression to be translated. + /// The shaped query after translation. + protected virtual ShapedQueryExpression? TranslateInlineQueryRoot(InlineQueryRootExpression inlineQueryRootExpression) + => null; + + /// + /// Translates a , which represents a queryable collection referenced as a parameter + /// within the query. + /// + /// The parameter query root expression to be translated. + /// The shaped query after translation. + protected virtual ShapedQueryExpression? TranslateParameterQueryRoot(ParameterQueryRootExpression parameterQueryRootExpression) + => null; + + #endregion Queryable collection support } diff --git a/test/EFCore.Specification.Tests/Query/PrimitiveCollectionsQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/PrimitiveCollectionsQueryTestBase.cs index bd7a745dca5..76dbfe1a559 100644 --- a/test/EFCore.Specification.Tests/Query/PrimitiveCollectionsQueryTestBase.cs +++ b/test/EFCore.Specification.Tests/Query/PrimitiveCollectionsQueryTestBase.cs @@ -512,6 +512,13 @@ public virtual Task Column_collection_Distinct(bool async) async, ss => ss.Set().Where(c => c.Ints.Distinct().Count() == 3)); + [ConditionalTheory] // #32505 + [MemberData(nameof(IsAsyncData))] + public virtual Task Column_collection_SelectMany(bool async) + => AssertQuery( + async, + ss => ss.Set().SelectMany(c => c.Ints)); + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public virtual Task Column_collection_projection_from_top_level(bool async) diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQueryOldSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQueryOldSqlServerTest.cs index e9b66808514..2b665048776 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQueryOldSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQueryOldSqlServerTest.cs @@ -505,6 +505,9 @@ public override Task Column_collection_Any(bool async) public override Task Column_collection_Distinct(bool async) => AssertTranslationFailed(() => base.Column_collection_Distinct(async)); + public override Task Column_collection_SelectMany(bool async) + => AssertTranslationFailed(() => base.Column_collection_SelectMany(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 8bd0373a694..742e3f8c909 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServerTest.cs @@ -811,6 +811,18 @@ FROM OPENJSON([p].[Ints]) WITH ([value] int '$') AS [i] """); } + public override async Task Column_collection_SelectMany(bool async) + { + await base.Column_collection_SelectMany(async); + + AssertSql( + """ +SELECT [i].[value] +FROM [PrimitiveCollectionsEntity] AS [p] +CROSS APPLY OPENJSON([p].[Ints]) WITH ([value] int '$') AS [i] +"""); + } + 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/PrimitiveCollectionsQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/PrimitiveCollectionsQuerySqliteTest.cs index 31b098064a8..a426d3525d8 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/PrimitiveCollectionsQuerySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/PrimitiveCollectionsQuerySqliteTest.cs @@ -790,6 +790,12 @@ FROM json_each("p"."Ints") AS "i" """); } + public override async Task Column_collection_SelectMany(bool async) + => Assert.Equal( + SqliteStrings.ApplyNotSupported, + (await Assert.ThrowsAsync( + () => base.Column_collection_SelectMany(async))).Message); + public override async Task Column_collection_projection_from_top_level(bool async) { await base.Column_collection_projection_from_top_level(async);