diff --git a/src/EFCore.Relational/Infrastructure/RelationalDbContextOptionsBuilder.cs b/src/EFCore.Relational/Infrastructure/RelationalDbContextOptionsBuilder.cs index ee0e59414f7..79aa8424ed3 100644 --- a/src/EFCore.Relational/Infrastructure/RelationalDbContextOptionsBuilder.cs +++ b/src/EFCore.Relational/Infrastructure/RelationalDbContextOptionsBuilder.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.ComponentModel; +using Microsoft.EntityFrameworkCore.Internal; namespace Microsoft.EntityFrameworkCore.Infrastructure; @@ -159,6 +160,54 @@ public virtual TBuilder ExecutionStrategy( => WithOption( e => (TExtension)e.WithExecutionStrategyFactory(Check.NotNull(getExecutionStrategy, nameof(getExecutionStrategy)))); + /// + /// Configures the context to translate parameterized collections to inline constants. + /// + /// + /// + /// When a LINQ query contains a parameterized collection, by default EF Core parameterizes the entire collection as a single + /// SQL parameter, if possible. For example, on SQL Server, the LINQ query Where(b => ids.Contains(b.Id) is translated to + /// WHERE [b].[Id] IN (SELECT [i].[value] FROM OPENJSON(@__ids_0) ...). While this helps with query plan caching, it can + /// produce worse query plans for certain query types. + /// + /// + /// instructs EF to translate the collection to a set of constants: + /// WHERE [b].[Id] IN (1, 2, 3). This can produce better query plans for certain query types, but can also lead to query + /// plan bloat. + /// + /// + /// Note that it's possible to cause EF to translate a specific collection in a specific query to constants by wrapping the + /// parameterized collection in : Where(b => EF.Constant(ids).Contains(b.Id). This overrides + /// the default. + /// + /// + public virtual TBuilder TranslateParameterizedCollectionsToConstants() + => WithOption(e => (TExtension)e.WithParameterizedCollectionTranslationMode(ParameterizedCollectionTranslationMode.Constantize)); + + /// + /// Configures the context to translate parameterized collections to inline constants. + /// + /// + /// + /// When a LINQ query contains a parameterized collection, by default EF Core parameterizes the entire collection as a single + /// SQL parameter, if possible. For example, on SQL Server, the LINQ query Where(b => ids.Contains(b.Id) is translated to + /// WHERE [b].[Id] IN (SELECT [i].[value] FROM OPENJSON(@__ids_0) ...). While this helps with query plan caching, it can + /// produce worse query plans for certain query types. + /// + /// + /// instructs EF to translate the collection to a set of constants: + /// WHERE [b].[Id] IN (1, 2, 3). This can produce better query plans for certain query types, but can also lead to query + /// plan bloat. + /// + /// + /// Note that it's possible to cause EF to translate a specific collection in a specific query to constants by wrapping the + /// parameterized collection in : Where(b => EF.Constant(ids).Contains(b.Id). This overrides + /// the default. + /// + /// + public virtual TBuilder TranslateParameterizedCollectionsToParameters() + => WithOption(e => (TExtension)e.WithParameterizedCollectionTranslationMode(ParameterizedCollectionTranslationMode.Parameterize)); + /// /// Sets an option by cloning the extension used to store the settings. This ensures the builder /// does not modify options that are already in use elsewhere. diff --git a/src/EFCore.Relational/Infrastructure/RelationalOptionsExtension.cs b/src/EFCore.Relational/Infrastructure/RelationalOptionsExtension.cs index f8adafd96d9..7309db9eea5 100644 --- a/src/EFCore.Relational/Infrastructure/RelationalOptionsExtension.cs +++ b/src/EFCore.Relational/Infrastructure/RelationalOptionsExtension.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Text; +using Microsoft.EntityFrameworkCore.Internal; namespace Microsoft.EntityFrameworkCore.Infrastructure; @@ -33,10 +34,10 @@ public abstract class RelationalOptionsExtension : IDbContextOptionsExtension private QuerySplittingBehavior? _querySplittingBehavior; private string? _migrationsAssembly; private Assembly? _migrationsAssemblyObject; - private string? _migrationsHistoryTableName; private string? _migrationsHistoryTableSchema; private Func? _executionStrategyFactory; + private ParameterizedCollectionTranslationMode? _parameterizedCollectionTranslationMode; /// /// Creates a new set of options with everything set to default values. @@ -63,6 +64,7 @@ protected RelationalOptionsExtension(RelationalOptionsExtension copyFrom) _migrationsHistoryTableName = copyFrom._migrationsHistoryTableName; _migrationsHistoryTableSchema = copyFrom._migrationsHistoryTableSchema; _executionStrategyFactory = copyFrom._executionStrategyFactory; + _parameterizedCollectionTranslationMode = copyFrom._parameterizedCollectionTranslationMode; } /// @@ -381,6 +383,26 @@ public virtual RelationalOptionsExtension WithExecutionStrategyFactory( return clone; } + /// + /// Configured translation mode for parameterized collections. + /// + public virtual ParameterizedCollectionTranslationMode? ParameterizedCollectionTranslationMode + => _parameterizedCollectionTranslationMode; + + /// + /// Creates a new instance with all options the same as for this instance, but with the given option changed. + /// It is unusual to call this method directly. Instead use . + /// + /// The option to change. + public virtual RelationalOptionsExtension WithParameterizedCollectionTranslationMode(ParameterizedCollectionTranslationMode parameterizedCollectionTranslationMode) + { + var clone = Clone(); + + clone._parameterizedCollectionTranslationMode = parameterizedCollectionTranslationMode; + + return clone; + } + /// /// Finds an existing registered on the given options /// or throws if none has been registered. This is typically used to find some relational @@ -540,6 +562,11 @@ public override string LogFragment builder.Append(Extension._migrationsHistoryTableName ?? HistoryRepository.DefaultTableName).Append(' '); } + if (Extension._parameterizedCollectionTranslationMode != null) + { + builder.Append("ParameterizedCollectionTranslationMode=").Append(Extension._parameterizedCollectionTranslationMode).Append(' '); + } + _logFragment = builder.ToString(); } diff --git a/src/EFCore.Relational/Internal/ParameterizedCollectionTranslationMode.cs b/src/EFCore.Relational/Internal/ParameterizedCollectionTranslationMode.cs new file mode 100644 index 00000000000..cfb59de6f08 --- /dev/null +++ b/src/EFCore.Relational/Internal/ParameterizedCollectionTranslationMode.cs @@ -0,0 +1,29 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Internal; + +/// +/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to +/// the same compatibility standards as public APIs. It may be changed or removed without notice in +/// any release. You should only use it directly in your code with extreme caution and knowing that +/// doing so can result in application failures when updating to a new Entity Framework Core release. +/// +public enum ParameterizedCollectionTranslationMode +{ + /// + /// 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. + /// + Constantize = 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. + /// + Parameterize, +} diff --git a/src/EFCore.Relational/Query/Internal/RelationalCommandCache.cs b/src/EFCore.Relational/Query/Internal/RelationalCommandCache.cs index 19a8c30bdb3..005287f3348 100644 --- a/src/EFCore.Relational/Query/Internal/RelationalCommandCache.cs +++ b/src/EFCore.Relational/Query/Internal/RelationalCommandCache.cs @@ -36,7 +36,7 @@ public RelationalCommandCache( IRelationalParameterBasedSqlProcessorFactory relationalParameterBasedSqlProcessorFactory, Expression queryExpression, bool useRelationalNulls, - HashSet parametersToConstantize) + IReadOnlySet parametersToConstantize) { _memoryCache = memoryCache; _querySqlGeneratorFactory = querySqlGeneratorFactory; diff --git a/src/EFCore.Relational/Query/RelationalParameterBasedSqlProcessorParameters.cs b/src/EFCore.Relational/Query/RelationalParameterBasedSqlProcessorParameters.cs index bbd689d9ab9..5ae6f011a46 100644 --- a/src/EFCore.Relational/Query/RelationalParameterBasedSqlProcessorParameters.cs +++ b/src/EFCore.Relational/Query/RelationalParameterBasedSqlProcessorParameters.cs @@ -16,7 +16,7 @@ public sealed record RelationalParameterBasedSqlProcessorParameters /// /// A collection of parameter names to constantize. /// - public HashSet ParametersToConstantize { get; init; } + public IReadOnlySet ParametersToConstantize { get; init; } /// /// Creates a new instance of . @@ -24,7 +24,7 @@ public sealed record RelationalParameterBasedSqlProcessorParameters /// A value indicating if relational nulls should be used. /// A collection of parameter names to constantize. [EntityFrameworkInternal] - public RelationalParameterBasedSqlProcessorParameters(bool useRelationalNulls, HashSet parametersToConstantize) + public RelationalParameterBasedSqlProcessorParameters(bool useRelationalNulls, IReadOnlySet parametersToConstantize) { UseRelationalNulls = useRelationalNulls; ParametersToConstantize = parametersToConstantize; diff --git a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs index a98c0d070c1..141ac9b515c 100644 --- a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics.CodeAnalysis; +using Microsoft.EntityFrameworkCore.Internal; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Metadata.Internal; using Microsoft.EntityFrameworkCore.Query.Internal; @@ -242,7 +243,8 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp && method.GetGenericMethodDefinition() == QueryableMethods.Contains && methodCallExpression.Arguments[0] is ParameterQueryRootExpression parameterSource && TranslateExpression(methodCallExpression.Arguments[1]) is SqlExpression item - && _sqlTranslator.Visit(parameterSource.ParameterExpression) is SqlParameterExpression sqlParameterExpression) + && _sqlTranslator.Visit(parameterSource.ParameterExpression) is SqlParameterExpression sqlParameterExpression + && !QueryCompilationContext.ParametersToNotConstantize.Contains(sqlParameterExpression.Name)) { var inExpression = _sqlExpressionFactory.In(item, sqlParameterExpression); var selectExpression = new SelectExpression(inExpression, _sqlAliasManager); @@ -294,9 +296,13 @@ JsonScalarExpression jsonScalar Check.DebugAssert(sqlParameterExpression is not null, "sqlParameterExpression is not null"); - var tableAlias = _sqlAliasManager.GenerateTableAlias(sqlParameterExpression.Name.TrimStart('_')); + var primitiveCollectionsBehavior = RelationalOptionsExtension.Extract(QueryCompilationContext.ContextOptions) + .ParameterizedCollectionTranslationMode; - if (QueryCompilationContext.ParametersToConstantize.Contains(sqlParameterExpression.Name)) + var tableAlias = _sqlAliasManager.GenerateTableAlias(sqlParameterExpression.Name.TrimStart('_')); + if (QueryCompilationContext.ParametersToConstantize.Contains(sqlParameterExpression.Name) + || (primitiveCollectionsBehavior == ParameterizedCollectionTranslationMode.Constantize + && !QueryCompilationContext.ParametersToNotConstantize.Contains(sqlParameterExpression.Name))) { var valuesExpression = new ValuesExpression( tableAlias, diff --git a/src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.cs b/src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.cs index 494399d8d0b..9934844e44c 100644 --- a/src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.cs +++ b/src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.cs @@ -14,7 +14,7 @@ namespace Microsoft.EntityFrameworkCore.Query; /// public partial class RelationalShapedQueryCompilingExpressionVisitor : ShapedQueryCompilingExpressionVisitor { - private readonly HashSet _parametersToConstantize; + private readonly IReadOnlySet _parametersToConstantize; private readonly Type _contextType; private readonly ISet _tags; private readonly bool _threadSafetyChecksEnabled; @@ -53,7 +53,7 @@ public RelationalShapedQueryCompilingExpressionVisitor( { RelationalDependencies = relationalDependencies; - _parametersToConstantize = QueryCompilationContext.ParametersToConstantize; + _parametersToConstantize = (IReadOnlySet)QueryCompilationContext.ParametersToConstantize; _relationalParameterBasedSqlProcessor = relationalDependencies.RelationalParameterBasedSqlProcessorFactory.Create(new RelationalParameterBasedSqlProcessorParameters(_useRelationalNulls, _parametersToConstantize)); diff --git a/src/EFCore.Relational/Query/SqlNullabilityProcessor.cs b/src/EFCore.Relational/Query/SqlNullabilityProcessor.cs index 3b740ca419e..b0e5a8429e0 100644 --- a/src/EFCore.Relational/Query/SqlNullabilityProcessor.cs +++ b/src/EFCore.Relational/Query/SqlNullabilityProcessor.cs @@ -56,7 +56,7 @@ public SqlNullabilityProcessor( /// /// A collection of parameter names to constantize. /// - protected virtual HashSet ParametersToConstantize { get; } + protected virtual IReadOnlySet ParametersToConstantize { get; } /// /// Dictionary of current parameter values in use. diff --git a/src/EFCore/Query/Internal/ExpressionTreeFuncletizer.cs b/src/EFCore/Query/Internal/ExpressionTreeFuncletizer.cs index 492635b383d..455e147dbf9 100644 --- a/src/EFCore/Query/Internal/ExpressionTreeFuncletizer.cs +++ b/src/EFCore/Query/Internal/ExpressionTreeFuncletizer.cs @@ -944,9 +944,9 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCall) } argumentState = argumentState with { StateType = StateType.EvaluatableWithCapturedVariable }; - var evaluatedArgument = ProcessEvaluatableRoot(argument, ref argumentState); + var evaluatedArgument = ProcessEvaluatableRoot(argument, ref argumentState, forceEvaluation: true); _state = argumentState; - return evaluatedArgument; + return Call(method, evaluatedArgument); } } } @@ -1827,7 +1827,7 @@ private static StateType CombineStateTypes(StateType stateType1, StateType state } [return: NotNullIfNotNull(nameof(evaluatableRoot))] - private Expression? ProcessEvaluatableRoot(Expression? evaluatableRoot, ref State state) + private Expression? ProcessEvaluatableRoot(Expression? evaluatableRoot, ref State state, bool forceEvaluation = false) { if (evaluatableRoot is null) { @@ -1849,7 +1849,7 @@ private static StateType CombineStateTypes(StateType stateType1, StateType state // We have some cases where a node is evaluatable, but only as part of a larger subtree, and should not be evaluated as a tree root. // For these cases, the node's state has a notEvaluatableAsRootHandler lambda, which we can invoke to make evaluate the node's // children (as needed), but not itself. - if (TryHandleNonEvaluatableAsRoot(evaluatableRoot, state, evaluateAsParameter, out var result)) + if (!forceEvaluation && TryHandleNonEvaluatableAsRoot(evaluatableRoot, state, evaluateAsParameter, out var result)) { return result; } diff --git a/src/EFCore/Query/Internal/QueryableMethodNormalizingExpressionVisitor.cs b/src/EFCore/Query/Internal/QueryableMethodNormalizingExpressionVisitor.cs index 140637f03cf..38c7dd9f0bb 100644 --- a/src/EFCore/Query/Internal/QueryableMethodNormalizingExpressionVisitor.cs +++ b/src/EFCore/Query/Internal/QueryableMethodNormalizingExpressionVisitor.cs @@ -112,17 +112,29 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp return expression; } - if (method.DeclaringType == typeof(EF) - && method.Name == nameof(EF.Constant)) + if (method.DeclaringType == typeof(EF)) { - if (!_isEfConstantSupported) + switch (method.Name) { - throw new InvalidOperationException(CoreStrings.EFConstantNotSupported); - } + case nameof(EF.Constant): + { + if (!_isEfConstantSupported) + { + throw new InvalidOperationException(CoreStrings.EFConstantNotSupported); + } - var parameterExpression = (ParameterExpression)Visit(methodCallExpression.Arguments[0]); - _queryCompilationContext.ParametersToConstantize.Add(parameterExpression.Name!); - return parameterExpression; + var parameterExpression = (ParameterExpression)Visit(methodCallExpression.Arguments[0]); + _queryCompilationContext.ParametersToConstantize.Add(parameterExpression.Name!); + return parameterExpression; + } + + case nameof(EF.Parameter): + { + var parameterExpression = (ParameterExpression)Visit(methodCallExpression.Arguments[0]); + _queryCompilationContext.ParametersToNotConstantize.Add(parameterExpression.Name!); + return parameterExpression; + } + } } // Normalize list[x] to list.ElementAt(x) diff --git a/src/EFCore/Query/QueryCompilationContext.cs b/src/EFCore/Query/QueryCompilationContext.cs index 4c1784f9721..8a6005e1923 100644 --- a/src/EFCore/Query/QueryCompilationContext.cs +++ b/src/EFCore/Query/QueryCompilationContext.cs @@ -62,7 +62,19 @@ public class QueryCompilationContext /// not used in application code. /// /// - public virtual HashSet ParametersToConstantize { get; } = new(StringComparer.Ordinal); + public virtual ISet ParametersToConstantize { get; } = new HashSet(StringComparer.Ordinal); + + /// + /// + /// Names of parameters on which was used. Such parameters are later not transformed into + /// constants even when parameterized collection constantization is configured as the default. + /// + /// + /// This property is typically used by database providers (and other extensions). It is generally + /// not used in application code. + /// + /// + public virtual ISet ParametersToNotConstantize { get; } = new HashSet(StringComparer.Ordinal); private static readonly IReadOnlySet EmptySet = new HashSet(); diff --git a/test/EFCore.Cosmos.FunctionalTests/Query/PrimitiveCollectionsQueryCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/Query/PrimitiveCollectionsQueryCosmosTest.cs index a931d3dccc6..6c9d017e956 100644 --- a/test/EFCore.Cosmos.FunctionalTests/Query/PrimitiveCollectionsQueryCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/Query/PrimitiveCollectionsQueryCosmosTest.cs @@ -517,6 +517,41 @@ FROM a IN (SELECT VALUE [@__i_0]) """); }); + public override Task Inline_collection_Contains_with_EF_Parameter(bool async) + => CosmosTestHelpers.Instance.NoSyncTest( + async, async a => + { + await base.Inline_collection_Contains_with_EF_Parameter(async); + + AssertSql( + """ +@__p_0='[2,999,1000]' + +SELECT VALUE c +FROM root c +WHERE ARRAY_CONTAINS(@__p_0, c["Id"]) +"""); + }); + + public override Task Inline_collection_Count_with_column_predicate_with_EF_Parameter(bool async) + => CosmosTestHelpers.Instance.NoSyncTest( + async, async a => + { + await base.Inline_collection_Count_with_column_predicate_with_EF_Parameter(async); + + AssertSql( + """ +@__p_0='[2,999,1000]' + +SELECT VALUE c +FROM root c +WHERE (( + SELECT VALUE COUNT(1) + FROM p IN (SELECT VALUE @__p_0) + WHERE (p > c["Id"])) = 2) +"""); + }); + public override Task Parameter_collection_Count(bool async) => CosmosTestHelpers.Instance.NoSyncTest( async, async a => diff --git a/test/EFCore.Relational.Specification.Tests/Query/NonSharedPrimitiveCollectionsQueryRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/NonSharedPrimitiveCollectionsQueryRelationalTestBase.cs index 83de7279aa4..cafccdba0d1 100644 --- a/test/EFCore.Relational.Specification.Tests/Query/NonSharedPrimitiveCollectionsQueryRelationalTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Query/NonSharedPrimitiveCollectionsQueryRelationalTestBase.cs @@ -12,6 +12,10 @@ public abstract class NonSharedPrimitiveCollectionsQueryRelationalTestBase : Non public override Task Array_of_byte() => AssertTranslationFailed(() => TestArray((byte)1, (byte)2)); + protected abstract DbContextOptionsBuilder SetTranslateParameterizedCollectionsToConstants(DbContextOptionsBuilder optionsBuilder); + + protected abstract DbContextOptionsBuilder SetTranslateParameterizedCollectionsToParameters(DbContextOptionsBuilder optionsBuilder); + [ConditionalFact] public virtual async Task Column_collection_inside_json_owned_entity() { @@ -34,6 +38,170 @@ public virtual async Task Column_collection_inside_json_owned_entity() Assert.Equivalent(new[] { "foo", "bar" }, result.Owned.Strings); } + [ConditionalFact] + public virtual async Task Parameter_collection_Count_with_column_predicate_with_default_constants() + { + var contextFactory = await InitializeAsync( + onConfiguring: b => SetTranslateParameterizedCollectionsToConstants(b), + seed: context => + { + context.AddRange( + new TestEntity { Id = 1 }, + new TestEntity { Id = 100 }); + return context.SaveChangesAsync(); + }); + + await using var context = contextFactory.CreateContext(); + + var ids = new[] { 2, 999 }; + var result = await context.Set().Where(c => ids.Count(i => i > c.Id) == 1).Select(x => x.Id).ToListAsync(); + Assert.Equivalent(new[] { 100 }, result); + } + + [ConditionalFact] + public virtual async Task Parameter_collection_of_ints_Contains_int_with_default_constants() + { + var contextFactory = await InitializeAsync( + onConfiguring: b => SetTranslateParameterizedCollectionsToConstants(b), + seed: context => + { + context.AddRange( + new TestEntity { Id = 1 }, + new TestEntity { Id = 2 }, + new TestEntity { Id = 100 }); + return context.SaveChangesAsync(); + }); + + await using var context = contextFactory.CreateContext(); + + var ints = new[] { 2, 999 }; + var result = await context.Set().Where(c => ints.Contains(c.Id)).Select(x => x.Id).ToListAsync(); + Assert.Equivalent(new[] { 2 }, result); + } + + [ConditionalFact] + public virtual async Task Parameter_collection_Count_with_column_predicate_with_default_constants_EF_Parameter() + { + var contextFactory = await InitializeAsync( + onConfiguring: b => SetTranslateParameterizedCollectionsToConstants(b), + seed: context => + { + context.AddRange( + new TestEntity { Id = 1 }, + new TestEntity { Id = 100 }); + return context.SaveChangesAsync(); + }); + + await using var context = contextFactory.CreateContext(); + + var ids = new[] { 2, 999 }; + var result = await context.Set().Where(c => EF.Parameter(ids).Count(i => i > c.Id) == 1).Select(x => x.Id).ToListAsync(); + Assert.Equivalent(new[] { 100 }, result); + } + + [ConditionalFact] + public virtual async Task Parameter_collection_of_ints_Contains_int_with_default_constants_EF_Parameter() + { + var contextFactory = await InitializeAsync( + onConfiguring: b => SetTranslateParameterizedCollectionsToConstants(b), + seed: context => + { + context.AddRange( + new TestEntity { Id = 1 }, + new TestEntity { Id = 2 }, + new TestEntity { Id = 100 }); + return context.SaveChangesAsync(); + }); + + await using var context = contextFactory.CreateContext(); + + var ints = new[] { 2, 999 }; + var result = await context.Set().Where(c => EF.Parameter(ints).Contains(c.Id)).Select(x => x.Id).ToListAsync(); + Assert.Equivalent(new[] { 2 }, result); + } + + [ConditionalFact] + public virtual async Task Parameter_collection_Count_with_column_predicate_with_default_parameters() + { + var contextFactory = await InitializeAsync( + onConfiguring: b => SetTranslateParameterizedCollectionsToParameters(b), + seed: context => + { + context.AddRange( + new TestEntity { Id = 1 }, + new TestEntity { Id = 100 }); + return context.SaveChangesAsync(); + }); + + await using var context = contextFactory.CreateContext(); + + var ids = new[] { 2, 999 }; + var result = await context.Set().Where(c => ids.Count(i => i > c.Id) == 1).Select(x => x.Id).ToListAsync(); + Assert.Equivalent(new[] { 100 }, result); + } + + [ConditionalFact] + public virtual async Task Parameter_collection_of_ints_Contains_int_with_default_parameters() + { + var contextFactory = await InitializeAsync( + onConfiguring: b => SetTranslateParameterizedCollectionsToParameters(b), + seed: context => + { + context.AddRange( + new TestEntity { Id = 1 }, + new TestEntity { Id = 2 }, + new TestEntity { Id = 100 }); + return context.SaveChangesAsync(); + }); + + await using var context = contextFactory.CreateContext(); + + var ints = new[] { 2, 999 }; + var result = await context.Set().Where(c => ints.Contains(c.Id)).Select(x => x.Id).ToListAsync(); + Assert.Equivalent(new[] { 2 }, result); + } + + [ConditionalFact] + public virtual async Task Parameter_collection_Count_with_column_predicate_with_default_parameters_EF_Constant() + { + var contextFactory = await InitializeAsync( + onConfiguring: b => SetTranslateParameterizedCollectionsToParameters(b), + seed: context => + { + context.AddRange( + new TestEntity { Id = 1 }, + new TestEntity { Id = 100 }); + return context.SaveChangesAsync(); + }); + + await using var context = contextFactory.CreateContext(); + + var ids = new[] { 2, 999 }; + var result = await context.Set().Where(c => EF.Constant(ids).Count(i => i > c.Id) == 1).Select(x => x.Id).ToListAsync(); + Assert.Equivalent(new[] { 100 }, result); + } + + [ConditionalFact] + public virtual async Task Parameter_collection_of_ints_Contains_int_with_default_parameters_EF_Constant() + { + var contextFactory = await InitializeAsync( + onConfiguring: b => SetTranslateParameterizedCollectionsToParameters(b), + seed: context => + { + context.AddRange( + new TestEntity { Id = 1 }, + new TestEntity { Id = 2 }, + new TestEntity { Id = 100 }); + return context.SaveChangesAsync(); + }); + + await using var context = contextFactory.CreateContext(); + + var ints = new[] { 2, 999 }; + var result = await context.Set().Where(c => EF.Constant(ints).Contains(c.Id)).Select(x => x.Id).ToListAsync(); + Assert.Equivalent(new[] { 2 }, result); + } + protected class TestOwner { public int Id { get; set; } diff --git a/test/EFCore.Specification.Tests/Query/PrimitiveCollectionsQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/PrimitiveCollectionsQueryTestBase.cs index 5b041cbfc25..94de007cf69 100644 --- a/test/EFCore.Specification.Tests/Query/PrimitiveCollectionsQueryTestBase.cs +++ b/test/EFCore.Specification.Tests/Query/PrimitiveCollectionsQueryTestBase.cs @@ -287,6 +287,26 @@ public virtual Task Inline_collection_with_single_parameter_element_Count(bool a ss => ss.Set().Where(c => new[] { i }.Count(i => i > c.Id) == 1)); } + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Inline_collection_Contains_with_EF_Parameter(bool async) + { + return AssertQuery( + async, + ss => ss.Set().Where(c => EF.Parameter(new[] { 2, 999, 1000 }).Contains(c.Id)), + ss => ss.Set().Where(c => new[] { 2, 999, 1000 }.Contains(c.Id))); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Inline_collection_Count_with_column_predicate_with_EF_Parameter(bool async) + { + return AssertQuery( + async, + ss => ss.Set().Where(c => EF.Parameter(new[] { 2, 999, 1000 }).Count(i => i > c.Id) == 2), + ss => ss.Set().Where(c => new[] { 2, 999, 1000 }.Count(i => i > c.Id) == 2)); + } + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public virtual Task Parameter_collection_Count(bool async) diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/NonSharedPrimitiveCollectionsQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/NonSharedPrimitiveCollectionsQuerySqlServerTest.cs index b446632b715..31ec7f7f27e 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/NonSharedPrimitiveCollectionsQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/NonSharedPrimitiveCollectionsQuerySqlServerTest.cs @@ -11,6 +11,20 @@ namespace Microsoft.EntityFrameworkCore.Query; public class NonSharedPrimitiveCollectionsQuerySqlServerTest : NonSharedPrimitiveCollectionsQueryRelationalTestBase { + protected override DbContextOptionsBuilder SetTranslateParameterizedCollectionsToConstants(DbContextOptionsBuilder optionsBuilder) + { + new SqlServerDbContextOptionsBuilder(optionsBuilder).TranslateParameterizedCollectionsToConstants(); + + return optionsBuilder; + } + + protected override DbContextOptionsBuilder SetTranslateParameterizedCollectionsToParameters(DbContextOptionsBuilder optionsBuilder) + { + new SqlServerDbContextOptionsBuilder(optionsBuilder).TranslateParameterizedCollectionsToParameters(); + + return optionsBuilder; + } + #region Support for specific element types public override async Task Array_of_string() @@ -779,6 +793,128 @@ FROM [TestEntityWithOwned] AS [t] """); } + public override async Task Parameter_collection_Count_with_column_predicate_with_default_constants() + { + await base.Parameter_collection_Count_with_column_predicate_with_default_constants(); + + AssertSql( + """ +SELECT [t].[Id] +FROM [TestEntity] AS [t] +WHERE ( + SELECT COUNT(*) + FROM (VALUES (2), (999)) AS [i]([Value]) + WHERE [i].[Value] > [t].[Id]) = 1 +"""); + } + + public override async Task Parameter_collection_of_ints_Contains_int_with_default_constants() + { + await base.Parameter_collection_of_ints_Contains_int_with_default_constants(); + + AssertSql( + """ +SELECT [t].[Id] +FROM [TestEntity] AS [t] +WHERE [t].[Id] IN (2, 999) +"""); + } + + public override async Task Parameter_collection_Count_with_column_predicate_with_default_constants_EF_Parameter() + { + await base.Parameter_collection_Count_with_column_predicate_with_default_constants_EF_Parameter(); + + AssertSql( + """ +@__ids_0='[2,999]' (Size = 4000) + +SELECT [t].[Id] +FROM [TestEntity] AS [t] +WHERE ( + SELECT COUNT(*) + FROM OPENJSON(@__ids_0) WITH ([value] int '$') AS [i] + WHERE [i].[value] > [t].[Id]) = 1 +"""); + } + + public override async Task Parameter_collection_of_ints_Contains_int_with_default_constants_EF_Parameter() + { + await base.Parameter_collection_of_ints_Contains_int_with_default_constants_EF_Parameter(); + + AssertSql( + """ +@__ints_0='[2,999]' (Size = 4000) + +SELECT [t].[Id] +FROM [TestEntity] AS [t] +WHERE [t].[Id] IN ( + SELECT [i].[value] + FROM OPENJSON(@__ints_0) WITH ([value] int '$') AS [i] +) +"""); + } + + public override async Task Parameter_collection_Count_with_column_predicate_with_default_parameters() + { + await base.Parameter_collection_Count_with_column_predicate_with_default_parameters(); + + AssertSql( + """ +@__ids_0='[2,999]' (Size = 4000) + +SELECT [t].[Id] +FROM [TestEntity] AS [t] +WHERE ( + SELECT COUNT(*) + FROM OPENJSON(@__ids_0) WITH ([value] int '$') AS [i] + WHERE [i].[value] > [t].[Id]) = 1 +"""); + } + + public override async Task Parameter_collection_of_ints_Contains_int_with_default_parameters() + { + await base.Parameter_collection_of_ints_Contains_int_with_default_parameters(); + + AssertSql( + """ +@__ints_0='[2,999]' (Size = 4000) + +SELECT [t].[Id] +FROM [TestEntity] AS [t] +WHERE [t].[Id] IN ( + SELECT [i].[value] + FROM OPENJSON(@__ints_0) WITH ([value] int '$') AS [i] +) +"""); + } + + public override async Task Parameter_collection_Count_with_column_predicate_with_default_parameters_EF_Constant() + { + await base.Parameter_collection_Count_with_column_predicate_with_default_parameters_EF_Constant(); + + AssertSql( + """ +SELECT [t].[Id] +FROM [TestEntity] AS [t] +WHERE ( + SELECT COUNT(*) + FROM (VALUES (2), (999)) AS [i]([Value]) + WHERE [i].[Value] > [t].[Id]) = 1 +"""); + } + + public override async Task Parameter_collection_of_ints_Contains_int_with_default_parameters_EF_Constant() + { + await base.Parameter_collection_of_ints_Contains_int_with_default_parameters_EF_Constant(); + + AssertSql( + """ +SELECT [t].[Id] +FROM [TestEntity] AS [t] +WHERE [t].[Id] IN (2, 999) +"""); + } + [ConditionalFact] public virtual async Task Same_parameter_with_different_type_mappings() { diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQueryOldSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQueryOldSqlServerTest.cs index 077c136ab9a..c09c7a89029 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQueryOldSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQueryOldSqlServerTest.cs @@ -453,6 +453,12 @@ SELECT COUNT(*) """); } + public override Task Inline_collection_Contains_with_EF_Parameter(bool async) + => AssertCompatibilityLevelTooLow(() => base.Inline_collection_Contains_with_EF_Parameter(async)); + + public override Task Inline_collection_Count_with_column_predicate_with_EF_Parameter(bool async) + => AssertCompatibilityLevelTooLow(() => base.Inline_collection_Count_with_column_predicate_with_EF_Parameter(async)); + public override Task Parameter_collection_Count(bool async) => AssertCompatibilityLevelTooLow(() => base.Parameter_collection_Count(async)); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServer160Test.cs b/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServer160Test.cs index b1159553a81..00a87df1eb7 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServer160Test.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServer160Test.cs @@ -418,6 +418,40 @@ SELECT COUNT(*) """); } + public override async Task Inline_collection_Contains_with_EF_Parameter(bool async) + { + await base.Inline_collection_Contains_with_EF_Parameter(async); + + AssertSql( + """ +@__p_0='[2,999,1000]' (Size = 4000) + +SELECT [p].[Id], [p].[Bool], [p].[Bools], [p].[DateTime], [p].[DateTimes], [p].[Enum], [p].[Enums], [p].[Int], [p].[Ints], [p].[NullableInt], [p].[NullableInts], [p].[NullableString], [p].[NullableStrings], [p].[String], [p].[Strings] +FROM [PrimitiveCollectionsEntity] AS [p] +WHERE [p].[Id] IN ( + SELECT [p0].[value] + FROM OPENJSON(@__p_0) WITH ([value] int '$') AS [p0] +) +"""); + } + + public override async Task Inline_collection_Count_with_column_predicate_with_EF_Parameter(bool async) + { + await base.Inline_collection_Count_with_column_predicate_with_EF_Parameter(async); + + AssertSql( + """ +@__p_0='[2,999,1000]' (Size = 4000) + +SELECT [p].[Id], [p].[Bool], [p].[Bools], [p].[DateTime], [p].[DateTimes], [p].[Enum], [p].[Enums], [p].[Int], [p].[Ints], [p].[NullableInt], [p].[NullableInts], [p].[NullableString], [p].[NullableStrings], [p].[String], [p].[Strings] +FROM [PrimitiveCollectionsEntity] AS [p] +WHERE ( + SELECT COUNT(*) + FROM OPENJSON(@__p_0) WITH ([value] int '$') AS [p0] + WHERE [p0].[value] > [p].[Id]) = 2 +"""); + } + public override async Task Parameter_collection_Count(bool async) { await base.Parameter_collection_Count(async); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServerTest.cs index d99fb9ea774..330a3159cf7 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/PrimitiveCollectionsQuerySqlServerTest.cs @@ -441,6 +441,40 @@ SELECT COUNT(*) """); } + public override async Task Inline_collection_Contains_with_EF_Parameter(bool async) + { + await base.Inline_collection_Contains_with_EF_Parameter(async); + + AssertSql( + """ +@__p_0='[2,999,1000]' (Size = 4000) + +SELECT [p].[Id], [p].[Bool], [p].[Bools], [p].[DateTime], [p].[DateTimes], [p].[Enum], [p].[Enums], [p].[Int], [p].[Ints], [p].[NullableInt], [p].[NullableInts], [p].[NullableString], [p].[NullableStrings], [p].[String], [p].[Strings] +FROM [PrimitiveCollectionsEntity] AS [p] +WHERE [p].[Id] IN ( + SELECT [p0].[value] + FROM OPENJSON(@__p_0) WITH ([value] int '$') AS [p0] +) +"""); + } + + public override async Task Inline_collection_Count_with_column_predicate_with_EF_Parameter(bool async) + { + await base.Inline_collection_Count_with_column_predicate_with_EF_Parameter(async); + + AssertSql( + """ +@__p_0='[2,999,1000]' (Size = 4000) + +SELECT [p].[Id], [p].[Bool], [p].[Bools], [p].[DateTime], [p].[DateTimes], [p].[Enum], [p].[Enums], [p].[Int], [p].[Ints], [p].[NullableInt], [p].[NullableInts], [p].[NullableString], [p].[NullableStrings], [p].[String], [p].[Strings] +FROM [PrimitiveCollectionsEntity] AS [p] +WHERE ( + SELECT COUNT(*) + FROM OPENJSON(@__p_0) WITH ([value] int '$') AS [p0] + WHERE [p0].[value] > [p].[Id]) = 2 +"""); + } + public override async Task Parameter_collection_Count(bool async) { await base.Parameter_collection_Count(async); diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/NonSharedPrimitiveCollectionsQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/NonSharedPrimitiveCollectionsQuerySqliteTest.cs index 8b2d2dc53f7..ae95f98db33 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/NonSharedPrimitiveCollectionsQuerySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/NonSharedPrimitiveCollectionsQuerySqliteTest.cs @@ -7,6 +7,20 @@ namespace Microsoft.EntityFrameworkCore.Query; public class NonSharedPrimitiveCollectionsQuerySqliteTest : NonSharedPrimitiveCollectionsQueryRelationalTestBase { + protected override DbContextOptionsBuilder SetTranslateParameterizedCollectionsToConstants(DbContextOptionsBuilder optionsBuilder) + { + new SqliteDbContextOptionsBuilder(optionsBuilder).TranslateParameterizedCollectionsToConstants(); + + return optionsBuilder; + } + + protected override DbContextOptionsBuilder SetTranslateParameterizedCollectionsToParameters(DbContextOptionsBuilder optionsBuilder) + { + new SqliteDbContextOptionsBuilder(optionsBuilder).TranslateParameterizedCollectionsToParameters(); + + return optionsBuilder; + } + #region Support for specific element types public override async Task Array_of_int() @@ -322,6 +336,128 @@ LIMIT 2 """); } + public override async Task Parameter_collection_Count_with_column_predicate_with_default_constants() + { + await base.Parameter_collection_Count_with_column_predicate_with_default_constants(); + + AssertSql( + """ +SELECT "t"."Id" +FROM "TestEntity" AS "t" +WHERE ( + SELECT COUNT(*) + FROM (SELECT 2 AS "Value" UNION ALL VALUES (999)) AS "i" + WHERE "i"."Value" > "t"."Id") = 1 +"""); + } + + public override async Task Parameter_collection_of_ints_Contains_int_with_default_constants() + { + await base.Parameter_collection_of_ints_Contains_int_with_default_constants(); + + AssertSql( + """ +SELECT "t"."Id" +FROM "TestEntity" AS "t" +WHERE "t"."Id" IN (2, 999) +"""); + } + + public override async Task Parameter_collection_Count_with_column_predicate_with_default_constants_EF_Parameter() + { + await base.Parameter_collection_Count_with_column_predicate_with_default_constants_EF_Parameter(); + + AssertSql( + """ +@__ids_0='[2,999]' (Size = 7) + +SELECT "t"."Id" +FROM "TestEntity" AS "t" +WHERE ( + SELECT COUNT(*) + FROM json_each(@__ids_0) AS "i" + WHERE "i"."value" > "t"."Id") = 1 +"""); + } + + public override async Task Parameter_collection_of_ints_Contains_int_with_default_constants_EF_Parameter() + { + await base.Parameter_collection_of_ints_Contains_int_with_default_constants_EF_Parameter(); + + AssertSql( + """ +@__ints_0='[2,999]' (Size = 7) + +SELECT "t"."Id" +FROM "TestEntity" AS "t" +WHERE "t"."Id" IN ( + SELECT "i"."value" + FROM json_each(@__ints_0) AS "i" +) +"""); + } + + public override async Task Parameter_collection_Count_with_column_predicate_with_default_parameters() + { + await base.Parameter_collection_Count_with_column_predicate_with_default_parameters(); + + AssertSql( + """ +@__ids_0='[2,999]' (Size = 7) + +SELECT "t"."Id" +FROM "TestEntity" AS "t" +WHERE ( + SELECT COUNT(*) + FROM json_each(@__ids_0) AS "i" + WHERE "i"."value" > "t"."Id") = 1 +"""); + } + + public override async Task Parameter_collection_of_ints_Contains_int_with_default_parameters() + { + await base.Parameter_collection_of_ints_Contains_int_with_default_parameters(); + + AssertSql( + """ +@__ints_0='[2,999]' (Size = 7) + +SELECT "t"."Id" +FROM "TestEntity" AS "t" +WHERE "t"."Id" IN ( + SELECT "i"."value" + FROM json_each(@__ints_0) AS "i" +) +"""); + } + + public override async Task Parameter_collection_Count_with_column_predicate_with_default_parameters_EF_Constant() + { + await base.Parameter_collection_Count_with_column_predicate_with_default_parameters_EF_Constant(); + + AssertSql( + """ +SELECT "t"."Id" +FROM "TestEntity" AS "t" +WHERE ( + SELECT COUNT(*) + FROM (SELECT 2 AS "Value" UNION ALL VALUES (999)) AS "i" + WHERE "i"."Value" > "t"."Id") = 1 +"""); + } + + public override async Task Parameter_collection_of_ints_Contains_int_with_default_parameters_EF_Constant() + { + await base.Parameter_collection_of_ints_Contains_int_with_default_parameters_EF_Constant(); + + AssertSql( + """ +SELECT "t"."Id" +FROM "TestEntity" AS "t" +WHERE "t"."Id" IN (2, 999) +"""); + } + protected override ITestStoreFactory TestStoreFactory => SqliteTestStoreFactory.Instance; } diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/PrimitiveCollectionsQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/PrimitiveCollectionsQuerySqliteTest.cs index 641a258fd1b..269c5dc9194 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/PrimitiveCollectionsQuerySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/PrimitiveCollectionsQuerySqliteTest.cs @@ -431,6 +431,40 @@ SELECT COUNT(*) """); } + public override async Task Inline_collection_Contains_with_EF_Parameter(bool async) + { + await base.Inline_collection_Contains_with_EF_Parameter(async); + + AssertSql( + """ +@__p_0='[2,999,1000]' (Size = 12) + +SELECT "p"."Id", "p"."Bool", "p"."Bools", "p"."DateTime", "p"."DateTimes", "p"."Enum", "p"."Enums", "p"."Int", "p"."Ints", "p"."NullableInt", "p"."NullableInts", "p"."NullableString", "p"."NullableStrings", "p"."String", "p"."Strings" +FROM "PrimitiveCollectionsEntity" AS "p" +WHERE "p"."Id" IN ( + SELECT "p0"."value" + FROM json_each(@__p_0) AS "p0" +) +"""); + } + + public override async Task Inline_collection_Count_with_column_predicate_with_EF_Parameter(bool async) + { + await base.Inline_collection_Count_with_column_predicate_with_EF_Parameter(async); + + AssertSql( + """ +@__p_0='[2,999,1000]' (Size = 12) + +SELECT "p"."Id", "p"."Bool", "p"."Bools", "p"."DateTime", "p"."DateTimes", "p"."Enum", "p"."Enums", "p"."Int", "p"."Ints", "p"."NullableInt", "p"."NullableInts", "p"."NullableString", "p"."NullableStrings", "p"."String", "p"."Strings" +FROM "PrimitiveCollectionsEntity" AS "p" +WHERE ( + SELECT COUNT(*) + FROM json_each(@__p_0) AS "p0" + WHERE "p0"."value" > "p"."Id") = 2 +"""); + } + public override async Task Parameter_collection_Count(bool async) { await base.Parameter_collection_Count(async);