diff --git a/CHANGELOG.md b/CHANGELOG.md
index dff11e1..f5634ba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
-v0.3.1 (Unreleased)
+v0.4.0 (Unreleased)
---
+### Advanced queries
+* **Parameterized collection `Contains` binds a single native `Array(T)` parameter.** A captured collection used with `Contains` (`ids.Contains(x.Id)`) now translates to `has({ids:Array(T)}, column)` — one bound array parameter — instead of EF Core's default one-scalar-parameter-per-element `IN (…)` expansion. This removes the parameter-count / query-size ceilings that large `IN` lists hit and keeps the query text (and plan-cache key) independent of the collection size. The array's element store type is aligned to the tested column (`Array(Int64)`, `Array(String)`, `Array(UUID)`, …). Per-query overrides are honored via the standard markers: `EF.MultipleParameters(…)` keeps the one-parameter-per-element expansion and `EF.Constant(…)` inlines the values as literals. Verified natively-serialized element types are the integers, floating point, `decimal`, `bool`, `string`, `Guid`, `DateTime`/`DateTime64`, `Date`/`Date32` (`DateOnly`), the big integers `Int128`/`Int256`/`UInt128`/`UInt256` (`BigInteger`), and `IPv4`/`IPv6` (`IPAddress`). The array-parameter path is deliberately scoped; these cases fall back to the standard per-element expansion (`IN (…)`) instead: a **nullable** tested column (so `NOT`-Contains null semantics match `x NOT IN (…)`), element types that need a **value conversion** (e.g. a CLR enum → `Enum8`), element CLR types the driver doesn't serialize natively inside an array (currently `TimeSpan`/`Time`), and collections used as a **queryable source** (joins, `Where(…).Contains(…)`, which keep the `SELECT … UNION ALL …` rewrite). The collection is bound as a single parameter value, so very large lists can hit the server's `http_max_field_value_size` (default 128 KiB); raise that setting for extreme cases. ([#39](https://github.com/ClickHouse/ClickHouse.EntityFrameworkCore/issues/39))
+
+### Dependencies
+* Bumped `ClickHouse.Driver` from 1.1.0 to 1.3.0. Among other fixes, 1.3.0 quotes temporal (`DateTime`/`Date`) elements inside array parameters correctly, which is what lets those element types use the single-array-parameter `Contains` path above.
+
### Bug fixes
* `Sum`/`SumAsync` over a `double` or `float` column no longer throws `InvalidCastException`. EF Core wraps a top-level aggregate so the empty case returns `0`, supplying that fallback as a boxed `Int32` carrying the `Float64`/`Float32` mapping; the literal generators now convert rather than unbox. The `Float32` read path also converts, since ClickHouse widens `sum(Float32)` to `Float64` (which the driver's `GetFloat()` refuses to downcast). ([#46](https://github.com/ClickHouse/ClickHouse.EntityFrameworkCore/issues/46))
diff --git a/README.md b/README.md
index 1247051..bb8de68 100644
--- a/README.md
+++ b/README.md
@@ -88,6 +88,8 @@ This provider is in active development. It supports **LINQ queries**, **inserts*
`Where`, `OrderBy`, `Take`, `Skip`, `Select`, `First`, `Single`, `Any`, `Count`, `Distinct`, `AsNoTracking`
+A `Contains` filter over a captured collection (`Where(x => ids.Contains(x.Id))`) is sent as a single native `Array(T)` parameter — `has({ids:Array(Int64)}, …)` — rather than one parameter per element, so large `IN`-style filters don't hit the parameter-count ceiling. Use `EF.Constant(ids)` to inline the values or `EF.MultipleParameters(ids)` to force one parameter per element for a specific query.
+
### GROUP BY & Aggregates
`GroupBy` with `Count`, `LongCount`, `Sum`, `Average`, `Min`, `Max` — including `HAVING` (`.Where()` after `.GroupBy()`), multiple aggregates in a single projection, and `OrderBy` on aggregate results.
diff --git a/RELEASENOTES.md b/RELEASENOTES.md
index dd56054..04e5225 100644
--- a/RELEASENOTES.md
+++ b/RELEASENOTES.md
@@ -1,5 +1,11 @@
-v0.3.1 (Unreleased)
+v0.4.0 (Unreleased)
---
+### Advanced queries
+* **Large `Contains` filters no longer blow the parameter limit.** Querying with a captured collection — `Where(x => ids.Contains(x.Id))` — now sends the whole list as a single ClickHouse array parameter (`has({ids:Array(Int64)}, …)`) instead of one parameter per element. Big `IN`-style filters that previously failed once the list grew past the server's parameter ceiling now work, and the generated SQL stays small and reusable across list sizes. For a specific query you can still opt out: `EF.Constant(ids)` inlines the values as literals and `EF.MultipleParameters(ids)` keeps the old one-parameter-per-element behavior. The array shortcut applies to integer, floating-point, `decimal`, `bool`, `string`, `Guid`, date/`DateTime`, big-integer, and IP-address values; nullable columns, enums, `Time`/`TimeSpan` values, and collections used in joins keep their existing translation. (Very large lists — hundreds of thousands of elements — may hit ClickHouse's default 128 KiB parameter-value limit; raise `http_max_field_value_size` if you need that.) ([#39](https://github.com/ClickHouse/ClickHouse.EntityFrameworkCore/issues/39))
+
+### Dependencies
+* Updated the underlying `ClickHouse.Driver` to 1.3.0.
+
### Bug fixes
* Summing a `double` or `float` column (`.SumAsync(x => x.Value)`) no longer throws `InvalidCastException`. Two ClickHouse-specific mismatches were biting: EF Core hands the float literal generator a boxed `Int32` `0` as the empty-result fallback, and ClickHouse widens `sum(Float32)` to `Float64` so the driver couldn't read it back as a `float`. Both the literal generation and the `Float32` read path now convert instead of hard-casting. ([#46](https://github.com/ClickHouse/ClickHouse.EntityFrameworkCore/issues/46)) (Thanks to @HotTotem!)
diff --git a/src/EFCore.ClickHouse/EFCore.ClickHouse.csproj b/src/EFCore.ClickHouse/EFCore.ClickHouse.csproj
index 2e616f1..9d36a85 100644
--- a/src/EFCore.ClickHouse/EFCore.ClickHouse.csproj
+++ b/src/EFCore.ClickHouse/EFCore.ClickHouse.csproj
@@ -26,7 +26,7 @@
-
+
diff --git a/src/EFCore.ClickHouse/Query/Internal/ClickHouseSqlNullabilityProcessor.cs b/src/EFCore.ClickHouse/Query/Internal/ClickHouseSqlNullabilityProcessor.cs
index 5a7a481..ebbaf78 100644
--- a/src/EFCore.ClickHouse/Query/Internal/ClickHouseSqlNullabilityProcessor.cs
+++ b/src/EFCore.ClickHouse/Query/Internal/ClickHouseSqlNullabilityProcessor.cs
@@ -1,12 +1,33 @@
using System.Linq.Expressions;
using ClickHouse.EntityFrameworkCore.Query.Expressions.Internal;
+using ClickHouse.EntityFrameworkCore.Storage.Internal.Mapping;
+using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Query;
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
+using Microsoft.EntityFrameworkCore.Storage;
namespace ClickHouse.EntityFrameworkCore.Query.Internal;
public class ClickHouseSqlNullabilityProcessor : SqlNullabilityProcessor
{
+ // Element CLR types whose collections ClickHouse.Driver serializes correctly as a single bound
+ // array parameter value (verified empirically against the pinned driver). Anything not listed here
+ // falls back to EF Core's per-element expansion, which serializes each element on its own — so an
+ // unlisted or newly-mapped type stays correct (just unoptimized) rather than failing at runtime.
+ // • DateTime covers DateTime/DateTime64; DateOnly covers Date/Date32; BigInteger covers
+ // Int128/Int256/UInt128/UInt256; IPAddress covers IPv4/IPv6.
+ // • TimeSpan (Time/Time64) is deliberately excluded: the driver emits its array elements without
+ // the quoting ClickHouse needs, so `Array(Time)` parameters fail to parse (CANNOT_READ_ARRAY).
+ private static readonly HashSet ArrayParameterElementTypes =
+ [
+ typeof(byte), typeof(sbyte), typeof(short), typeof(ushort),
+ typeof(int), typeof(uint), typeof(long), typeof(ulong),
+ typeof(float), typeof(double), typeof(decimal),
+ typeof(bool), typeof(string), typeof(Guid),
+ typeof(DateTime), typeof(DateOnly),
+ typeof(System.Numerics.BigInteger), typeof(System.Net.IPAddress),
+ ];
+
public ClickHouseSqlNullabilityProcessor(
RelationalParameterBasedSqlProcessorDependencies dependencies,
RelationalParameterBasedSqlProcessorParameters parameters)
@@ -14,6 +35,97 @@ public ClickHouseSqlNullabilityProcessor(
{
}
+ ///
+ /// Rewrites column IN {collectionParameter} — a captured int[]/List<T>/etc.
+ /// used with Contains — into has({p:Array(T)}, column), binding the whole collection
+ /// as a single native ClickHouse array parameter instead of EF Core's default one-scalar-parameter-
+ /// per-element expansion (IN (p1, …, pN)).
+ ///
+ /// A single bound array avoids the parameter-count / query-size ceilings that large IN lists
+ /// hit, and keeps the query text (and plan-cache key) independent of the collection size — ClickHouse
+ /// is OLAP and does not reuse plans by parameterization, so there is no downside to a bound array over
+ /// inlined constants.
+ ///
+ ///
+ /// This is the provider default for a plain captured collection. Per-query overrides win: EF Core
+ /// wires the marker methods to , so
+ /// EF.MultipleParameters(...) keeps the one-parameter-per-element expansion and
+ /// EF.Constant(...) inlines the values as literals — both handled by the base implementation.
+ /// The model-wide UseParameterizedCollectionMode knob is intentionally not consulted here: it
+ /// also governs the collection-as-queryable path (joins, Where(...).Contains(...)), which
+ /// ClickHouse translates via SELECT … UNION ALL … and which does not support
+ /// . Inline value lists and subquery IN are
+ /// likewise left to the base implementation.
+ ///
+ ///
+ protected override SqlExpression VisitIn(
+ InExpression inExpression,
+ bool allowOptimizedExpansion,
+ out bool nullable)
+ {
+ // An unmarked collection parameter (TranslationMode == null) takes the provider default of a
+ // single array parameter; an explicit EF.Parameter(...) selects it too. EF.MultipleParameters
+ // and EF.Constant carry a non-matching TranslationMode and fall through to the base expansion.
+ if (inExpression.ValuesParameter is { } valuesParameter
+ && valuesParameter.TranslationMode is null or ParameterTranslationMode.Parameter)
+ {
+ return VisitCollectionParameterIn(inExpression, valuesParameter, allowOptimizedExpansion, out nullable);
+ }
+
+ return base.VisitIn(inExpression, allowOptimizedExpansion, out nullable);
+ }
+
+ private SqlExpression VisitCollectionParameterIn(
+ InExpression inExpression,
+ SqlParameterExpression valuesParameter,
+ bool allowOptimizedExpansion,
+ out bool nullable)
+ {
+ // Process the tested item (usually a column) for nullability first, mirroring the base
+ // VisitIn contract.
+ var item = Visit(inExpression.Item, out var itemNullable);
+
+ // The tested item carries the authoritative element store type (the column's mapping). Align
+ // the array parameter's element type to it so the parameter serializes with the column's
+ // ClickHouse type (Int64 vs Int32, FixedString(N) vs String, …).
+ var elementMapping = (item.TypeMapping ?? valuesParameter.TypeMapping?.ElementTypeMapping) as RelationalTypeMapping;
+
+ // Bail to the base per-element expansion when a single native array parameter would be wrong
+ // or unserializable:
+ // - nullable item: `has(arr, item)` yields a concrete 0 for a NULL item rather than NULL, so
+ // `NOT has(...)` would KEEP NULL rows whereas `x NOT IN (...)` drops them (SQL 3-valued
+ // logic). The base path handles null compensation, so defer to it for nullable columns; the
+ // common large-list case (non-nullable keys) still gets the array parameter.
+ // - no element mapping → no store type to build Array(T) from;
+ // - the element needs a value converter (e.g. a CLR enum → Enum8) → the whole collection is
+ // handed to the driver un-converted, which it can't serialize;
+ // - the element CLR type isn't one the driver serializes correctly inside an array (see
+ // ArrayParameterElementTypes — notably TimeSpan/Time is excluded).
+ // The base expansion serializes each element individually, so all these cases still work.
+ if (itemNullable
+ || elementMapping is null
+ || elementMapping.Converter is not null
+ || !ArrayParameterElementTypes.Contains(Nullable.GetUnderlyingType(elementMapping.ClrType) ?? elementMapping.ClrType))
+ {
+ return base.VisitIn(inExpression, allowOptimizedExpansion, out nullable);
+ }
+
+ // `has(array, non-null item)` is never NULL and matches `item IN (...)` for a non-null item.
+ nullable = false;
+
+ var arrayMapping = new ClickHouseArrayTypeMapping(elementMapping);
+ var arrayParameter = valuesParameter.ApplyTypeMapping(arrayMapping);
+ var alignedItem = Dependencies.SqlExpressionFactory.ApplyTypeMapping(item, elementMapping)!;
+
+ return Dependencies.SqlExpressionFactory.Function(
+ "has",
+ [arrayParameter, alignedItem],
+ nullable: false,
+ argumentsPropagateNullability: [false, false],
+ typeof(bool),
+ Dependencies.TypeMappingSource.FindMapping(typeof(bool)));
+ }
+
protected override SqlExpression VisitSqlBinary(
SqlBinaryExpression sqlBinaryExpression,
bool allowOptimizedExpansion,
diff --git a/test/EFCore.ClickHouse.FunctionalTests/EFCore.ClickHouse.FunctionalTests.csproj b/test/EFCore.ClickHouse.FunctionalTests/EFCore.ClickHouse.FunctionalTests.csproj
index 7f51093..6b2c019 100644
--- a/test/EFCore.ClickHouse.FunctionalTests/EFCore.ClickHouse.FunctionalTests.csproj
+++ b/test/EFCore.ClickHouse.FunctionalTests/EFCore.ClickHouse.FunctionalTests.csproj
@@ -9,7 +9,7 @@
-
+
all
diff --git a/test/EFCore.ClickHouse.Tests/ClickHouseQuerySqlGeneratorTests.cs b/test/EFCore.ClickHouse.Tests/ClickHouseQuerySqlGeneratorTests.cs
index fa8f402..1ea5037 100644
--- a/test/EFCore.ClickHouse.Tests/ClickHouseQuerySqlGeneratorTests.cs
+++ b/test/EFCore.ClickHouse.Tests/ClickHouseQuerySqlGeneratorTests.cs
@@ -52,17 +52,21 @@ public async Task InlineCollection_Contains_RoundTrips()
}
[Fact]
- public void InlineCollection_Contains_DoesNotTranslateToHas()
+ public void CapturedCollection_Contains_TranslatesToArrayParameter()
{
using var ctx = new TestDbContext(_fixture.ConnectionString);
+ // A captured collection used with Contains binds as a single native Array(T) parameter
+ // (issue #39) rather than expanding to one parameter per element.
var ids = new long[] { 2, 4, 6 };
var sql = ctx.TestEntities
.Where(e => ids.Contains(e.Id))
.ToQueryString();
- Assert.DoesNotContain("has(", sql);
+ Assert.Contains("has({", sql);
+ Assert.Contains(":Array(Int64)}", sql);
+ Assert.DoesNotContain(" IN (", sql);
}
[Fact]
diff --git a/test/EFCore.ClickHouse.Tests/EFCore.ClickHouse.Tests.csproj b/test/EFCore.ClickHouse.Tests/EFCore.ClickHouse.Tests.csproj
index e570710..9f28695 100644
--- a/test/EFCore.ClickHouse.Tests/EFCore.ClickHouse.Tests.csproj
+++ b/test/EFCore.ClickHouse.Tests/EFCore.ClickHouse.Tests.csproj
@@ -9,7 +9,7 @@
-
+
diff --git a/test/EFCore.ClickHouse.Tests/ExtendedTypeMappingTests.cs b/test/EFCore.ClickHouse.Tests/ExtendedTypeMappingTests.cs
index 610031d..3ff1f65 100644
--- a/test/EFCore.ClickHouse.Tests/ExtendedTypeMappingTests.cs
+++ b/test/EFCore.ClickHouse.Tests/ExtendedTypeMappingTests.cs
@@ -1630,19 +1630,20 @@ public async Task DbSetRoot_SkipTakeFirst_StillUseEfBaseTranslation()
}
[Fact]
- public async Task LocalArray_Contains_DoesNotUseHas()
+ public async Task LocalArray_Contains_UsesArrayParameterNotArrayColumnHelper()
{
- // Local in-memory arrays must NOT be routed through the array helpers — they have no
- // ClickHouseArrayTypeMapping, so the type-mapping gate must reject them and let EF's
- // inline-collection pipeline emit IN-style SQL instead of has(). This is the
- // simplest shape that pins the gate: Contains is the most likely to mistranslate if
- // the structural pre-filter ever loosens.
+ // A captured local array must NOT be mistaken for a mapped Array(T) column by the array-column
+ // translator (whose gate, LooksLikeArrayColumnAccess, rejects it). Since issue #39 it is instead
+ // bound as a single native Array(T) *parameter* — has({p:Array(Int64)}, `id`), where the array is
+ // the parameter and the column is the tested item — rather than expanded to one parameter per
+ // element. This pins both behaviors: the parameter placeholder confirms it's the param-array path.
await using var ctx = new ArrayDbContext(_fixture.ConnectionString);
var localIds = new long[] { 1L, 3L };
var query = ctx.Entities.Where(e => localIds.Contains(e.Id));
var sql = query.ToQueryString();
- Assert.DoesNotContain("has(", sql);
+ Assert.Contains("has({", sql);
+ Assert.Contains(":Array(Int64)}", sql);
var results = await query.OrderBy(e => e.Id).ToListAsync();
Assert.Equal([1L, 3L], results.Select(r => r.Id));
diff --git a/test/EFCore.ClickHouse.Tests/ParameterizedContainsTests.cs b/test/EFCore.ClickHouse.Tests/ParameterizedContainsTests.cs
new file mode 100644
index 0000000..6e75de7
--- /dev/null
+++ b/test/EFCore.ClickHouse.Tests/ParameterizedContainsTests.cs
@@ -0,0 +1,420 @@
+using System.Net;
+using System.Numerics;
+using Microsoft.EntityFrameworkCore;
+using Xunit;
+
+namespace EFCore.ClickHouse.Tests;
+
+public enum ContainsColor { Red = 1, Green = 2, Blue = 3 }
+
+public class ParamContainsEntity
+{
+ public long Id { get; set; }
+ public int IntVal { get; set; }
+ public string StrVal { get; set; } = "";
+ public Guid GuidVal { get; set; }
+ public ContainsColor Color { get; set; }
+ public DateTime DateVal { get; set; }
+ public decimal DecimalVal { get; set; }
+ public bool BoolVal { get; set; }
+ public int? NullableInt { get; set; }
+ public DateOnly DateOnlyVal { get; set; }
+ public BigInteger BigVal { get; set; }
+ public IPAddress IpVal { get; set; } = IPAddress.Loopback;
+}
+
+public class ParamContainsDbContext : DbContext
+{
+ public DbSet Entities => Set();
+
+ private readonly string _connectionString;
+
+ public ParamContainsDbContext(string connectionString)
+ {
+ _connectionString = connectionString;
+ }
+
+ protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
+ => optionsBuilder.UseClickHouse(_connectionString);
+
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
+ {
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("param_contains");
+ entity.HasKey(e => e.Id);
+ entity.Property(e => e.Id).HasColumnName("id");
+ entity.Property(e => e.IntVal).HasColumnName("int_val");
+ entity.Property(e => e.StrVal).HasColumnName("str_val");
+ entity.Property(e => e.GuidVal).HasColumnName("guid_val").HasColumnType("UUID");
+ entity.Property(e => e.Color).HasColumnName("color").HasColumnType("Enum8('Red'=1,'Green'=2,'Blue'=3)");
+ entity.Property(e => e.DateVal).HasColumnName("date_val").HasColumnType("DateTime");
+ entity.Property(e => e.DecimalVal).HasColumnName("decimal_val").HasColumnType("Decimal(18, 4)");
+ entity.Property(e => e.BoolVal).HasColumnName("bool_val").HasColumnType("Bool");
+ entity.Property(e => e.NullableInt).HasColumnName("nullable_int").HasColumnType("Nullable(Int32)");
+ entity.Property(e => e.DateOnlyVal).HasColumnName("date_only_val").HasColumnType("Date32");
+ entity.Property(e => e.BigVal).HasColumnName("big_val").HasColumnType("Int128");
+ entity.Property(e => e.IpVal).HasColumnName("ip_val").HasColumnType("IPv4");
+ });
+ }
+}
+
+public class ParamContainsFixture : IAsyncLifetime
+{
+ public string ConnectionString { get; private set; } = string.Empty;
+
+ public static readonly Guid Guid1 = new("11111111-1111-1111-1111-111111111111");
+ public static readonly Guid Guid2 = new("22222222-2222-2222-2222-222222222222");
+ public static readonly Guid Guid3 = new("33333333-3333-3333-3333-333333333333");
+
+ public async Task InitializeAsync()
+ {
+ ConnectionString = await SharedContainer.GetConnectionStringAsync();
+
+ using var connection = new global::ClickHouse.Driver.ADO.ClickHouseConnection(ConnectionString);
+ await connection.OpenAsync();
+
+ using var createCmd = connection.CreateCommand();
+ createCmd.CommandText = """
+ CREATE TABLE param_contains (
+ id Int64,
+ int_val Int32,
+ str_val String,
+ guid_val UUID,
+ color Enum8('Red'=1,'Green'=2,'Blue'=3),
+ date_val DateTime,
+ decimal_val Decimal(18, 4),
+ bool_val Bool,
+ nullable_int Nullable(Int32),
+ date_only_val Date32,
+ big_val Int128,
+ ip_val IPv4
+ ) ENGINE = MergeTree()
+ ORDER BY id
+ """;
+ await createCmd.ExecuteNonQueryAsync();
+
+ using var insertCmd = connection.CreateCommand();
+ insertCmd.CommandText = $"""
+ INSERT INTO param_contains VALUES
+ (1, 10, 'alpha', toUUID('{Guid1}'), 'Red', '2021-01-01 00:00:00', 1.5, true, 100, '2021-01-01', 111, '10.0.0.1'),
+ (2, 20, 'beta', toUUID('{Guid2}'), 'Green', '2022-02-02 00:00:00', 2.5, false, NULL, '2022-02-02', 222, '10.0.0.2'),
+ (3, 30, 'gamma', toUUID('{Guid3}'), 'Blue', '2023-03-03 00:00:00', 3.5, true, 300, '2023-03-03', 333, '10.0.0.3')
+ """;
+ await insertCmd.ExecuteNonQueryAsync();
+ }
+
+ public Task DisposeAsync() => Task.CompletedTask;
+}
+
+///
+/// Integration tests for issue #39: a captured collection used with Contains translates to a
+/// single native ClickHouse array parameter (has({p:Array(T)}, column)) instead of one bound
+/// parameter per element, with per-query EF.Constant/EF.MultipleParameters opt-outs.
+///
+public class ParameterizedContainsTests : IClassFixture
+{
+ private readonly ParamContainsFixture _fixture;
+
+ public ParameterizedContainsTests(ParamContainsFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ [Fact]
+ public async Task Int64_Collection_TranslatesToArrayParameter()
+ {
+ await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString);
+ var ids = new[] { 1L, 3L };
+
+ var query = ctx.Entities.Where(e => ids.Contains(e.Id));
+ var sql = query.ToQueryString();
+
+ // One array parameter, not one parameter per element.
+ Assert.Contains("has({", sql);
+ Assert.Contains(":Array(Int64)}", sql);
+ Assert.DoesNotContain(" IN (", sql);
+
+ var rows = await query.OrderBy(e => e.Id).Select(e => e.Id).ToListAsync();
+ Assert.Equal([1L, 3L], rows);
+ }
+
+ [Fact]
+ public async Task Int32_Collection_AlignsElementStoreType()
+ {
+ await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString);
+ var vals = new[] { 10, 30 };
+
+ var query = ctx.Entities.Where(e => vals.Contains(e.IntVal));
+ Assert.Contains(":Array(Int32)}", query.ToQueryString());
+
+ var rows = await query.OrderBy(e => e.Id).Select(e => e.Id).ToListAsync();
+ Assert.Equal([1L, 3L], rows);
+ }
+
+ [Fact]
+ public async Task String_Collection_TranslatesAndMatches()
+ {
+ await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString);
+ var names = new List { "alpha", "gamma" };
+
+ var query = ctx.Entities.Where(e => names.Contains(e.StrVal));
+ Assert.Contains(":Array(String)}", query.ToQueryString());
+
+ var rows = await query.OrderBy(e => e.Id).Select(e => e.Id).ToListAsync();
+ Assert.Equal([1L, 3L], rows);
+ }
+
+ [Fact]
+ public async Task Guid_Collection_TranslatesAndMatches()
+ {
+ await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString);
+ var guids = new[] { ParamContainsFixture.Guid1, ParamContainsFixture.Guid3 };
+
+ var query = ctx.Entities.Where(e => guids.Contains(e.GuidVal));
+ Assert.Contains(":Array(UUID)}", query.ToQueryString());
+
+ var rows = await query.OrderBy(e => e.Id).Select(e => e.Id).ToListAsync();
+ Assert.Equal([1L, 3L], rows);
+ }
+
+ [Fact]
+ public async Task DateTime_Collection_TranslatesAndMatches()
+ {
+ await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString);
+ var dates = new[] { new DateTime(2021, 1, 1), new DateTime(2023, 3, 3) };
+
+ var query = ctx.Entities.Where(e => dates.Contains(e.DateVal));
+ Assert.Contains(":Array(DateTime)}", query.ToQueryString());
+
+ var rows = await query.OrderBy(e => e.Id).Select(e => e.Id).ToListAsync();
+ Assert.Equal([1L, 3L], rows);
+ }
+
+ [Fact]
+ public async Task DateOnly_Collection_TranslatesAndMatches()
+ {
+ await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString);
+ var dates = new[] { new DateOnly(2021, 1, 1), new DateOnly(2023, 3, 3) };
+
+ var query = ctx.Entities.Where(e => dates.Contains(e.DateOnlyVal));
+ Assert.Contains(":Array(Date32)}", query.ToQueryString());
+
+ var rows = await query.OrderBy(e => e.Id).Select(e => e.Id).ToListAsync();
+ Assert.Equal([1L, 3L], rows);
+ }
+
+ [Fact]
+ public async Task BigInteger_Collection_TranslatesAndMatches()
+ {
+ await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString);
+ var vals = new BigInteger[] { 111, 333 };
+
+ var query = ctx.Entities.Where(e => vals.Contains(e.BigVal));
+ Assert.Contains(":Array(Int128)}", query.ToQueryString());
+
+ var rows = await query.OrderBy(e => e.Id).Select(e => e.Id).ToListAsync();
+ Assert.Equal([1L, 3L], rows);
+ }
+
+ [Fact]
+ public async Task IPAddress_Collection_TranslatesAndMatches()
+ {
+ await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString);
+ var ips = new[] { IPAddress.Parse("10.0.0.1"), IPAddress.Parse("10.0.0.3") };
+
+ var query = ctx.Entities.Where(e => ips.Contains(e.IpVal));
+ Assert.Contains(":Array(IPv4)}", query.ToQueryString());
+
+ var rows = await query.OrderBy(e => e.Id).Select(e => e.Id).ToListAsync();
+ Assert.Equal([1L, 3L], rows);
+ }
+
+ [Fact]
+ public async Task Decimal_Collection_TranslatesAndMatches()
+ {
+ await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString);
+ var amounts = new[] { 1.5m, 3.5m };
+
+ var query = ctx.Entities.Where(e => amounts.Contains(e.DecimalVal));
+ Assert.Contains("has({", query.ToQueryString());
+
+ var rows = await query.OrderBy(e => e.Id).Select(e => e.Id).ToListAsync();
+ Assert.Equal([1L, 3L], rows);
+ }
+
+ [Fact]
+ public async Task Bool_Collection_TranslatesAndMatches()
+ {
+ await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString);
+ var flags = new[] { true };
+
+ var query = ctx.Entities.Where(e => flags.Contains(e.BoolVal));
+ Assert.Contains("has({", query.ToQueryString());
+
+ var rows = await query.OrderBy(e => e.Id).Select(e => e.Id).ToListAsync();
+ Assert.Equal([1L, 3L], rows);
+ }
+
+ [Fact]
+ public async Task NullableColumn_Contains_FallsBackToPreserveNullSemantics()
+ {
+ await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString);
+ var vals = new int?[] { 100, 300 };
+
+ // A nullable tested column falls back to the base expansion rather than the has(...) array
+ // path: `has(arr, NULL)` returns a concrete 0, so a negated `has` would treat a NULL row
+ // differently from `x NOT IN (...)`. Deferring to the base path keeps whatever null semantics
+ // the standard IN translation produces, so the array-parameter path never changes them.
+ var positive = ctx.Entities.Where(e => vals.Contains(e.NullableInt));
+ Assert.DoesNotContain("has({", positive.ToQueryString());
+ Assert.Equal([1L, 3L], await positive.OrderBy(e => e.Id).Select(e => e.Id).ToListAsync());
+ }
+
+ [Fact]
+ public async Task Enum_Collection_FallsBackToPerElementExpansion()
+ {
+ await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString);
+ var colors = new[] { ContainsColor.Red, ContainsColor.Blue };
+
+ // A CLR enum maps to Enum8 via a value converter. The driver can't serialize the raw enum
+ // collection as a native array, so this element type falls back to the standard per-element
+ // expansion (the converter is applied to each parameter) rather than the has(...) array path.
+ var query = ctx.Entities.Where(e => colors.Contains(e.Color));
+ var sql = query.ToQueryString();
+ Assert.DoesNotContain("has({", sql);
+ Assert.Contains(" IN (", sql);
+
+ var rows = await query.OrderBy(e => e.Id).Select(e => e.Id).ToListAsync();
+ Assert.Equal([1L, 3L], rows);
+ }
+
+ [Fact]
+ public async Task EmptyCollection_MatchesNoRows()
+ {
+ await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString);
+ var ids = System.Array.Empty();
+
+ var query = ctx.Entities.Where(e => ids.Contains(e.Id));
+ // Still a single array parameter — no special-casing to `WHERE 1=0`.
+ Assert.Contains("has({", query.ToQueryString());
+
+ var count = await query.CountAsync();
+ Assert.Equal(0, count);
+ }
+
+ [Fact]
+ public async Task LargeCollection_SendsSingleParameter()
+ {
+ await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString);
+ // Comfortably beyond the ~10k one-parameter-per-element ceiling that large IN lists hit,
+ // while staying within the server's default parameter-value size limit.
+ var ids = Enumerable.Range(1, 12_000).Select(i => (long)i).ToArray();
+
+ var query = ctx.Entities.Where(e => ids.Contains(e.Id));
+
+ // Query text carries exactly one parameter placeholder regardless of collection size.
+ var sql = query.ToQueryString();
+ Assert.Contains("has({", sql);
+ Assert.Single(System.Text.RegularExpressions.Regex.Matches(sql, ":Array(Int64)}".Replace("(", "\\(").Replace(")", "\\)")));
+
+ var count = await query.CountAsync();
+ Assert.Equal(3, count);
+ }
+
+ [Fact]
+ public async Task ListContains_TranslatesToArrayParameter()
+ {
+ await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString);
+ var ids = new List { 2L };
+
+ var query = ctx.Entities.Where(e => ids.Contains(e.Id));
+ Assert.Contains("has({", query.ToQueryString());
+
+ var rows = await query.Select(e => e.Id).ToListAsync();
+ Assert.Equal([2L], rows);
+ }
+
+ [Fact]
+ public async Task EfConstant_InlinesValues()
+ {
+ await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString);
+ var ids = new[] { 1L, 2L };
+
+ var query = ctx.Entities.Where(e => EF.Constant(ids).Contains(e.Id));
+ var sql = query.ToQueryString();
+
+ // EF.Constant opts out of the array parameter and inlines the values as literals.
+ Assert.DoesNotContain("has({", sql);
+ Assert.Contains(" IN (1, 2)", sql);
+
+ var rows = await query.OrderBy(e => e.Id).Select(e => e.Id).ToListAsync();
+ Assert.Equal([1L, 2L], rows);
+ }
+
+ [Fact]
+ public async Task EfMultipleParameters_ExpandsToOneParameterPerElement()
+ {
+ await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString);
+ var ids = new[] { 1L, 2L };
+
+ var query = ctx.Entities.Where(e => EF.MultipleParameters(ids).Contains(e.Id));
+ var sql = query.ToQueryString();
+
+ // EF.MultipleParameters keeps the legacy one-parameter-per-element expansion.
+ Assert.DoesNotContain("has({", sql);
+ Assert.Contains(" IN ({", sql);
+
+ var rows = await query.OrderBy(e => e.Id).Select(e => e.Id).ToListAsync();
+ Assert.Equal([1L, 2L], rows);
+ }
+
+ [Fact]
+ public async Task InlineCollection_StillInlines()
+ {
+ await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString);
+
+ // A literal (non-captured) collection is not a parameter, so it continues to inline.
+ var query = ctx.Entities.Where(e => new[] { 1L, 2L }.Contains(e.Id));
+ var sql = query.ToQueryString();
+
+ Assert.DoesNotContain("has({", sql);
+ Assert.Contains(" IN (1, 2)", sql);
+
+ var rows = await query.OrderBy(e => e.Id).Select(e => e.Id).ToListAsync();
+ Assert.Equal([1L, 2L], rows);
+ }
+
+ [Fact]
+ public async Task ParameterCollectionJoin_StillUsesUnionAll()
+ {
+ await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString);
+ var ids = new[] { 1L, 3L };
+
+ // A collection parameter used as a JOIN source is NOT a Contains, so it keeps the
+ // SELECT … UNION ALL … rewrite (the array-parameter path is Contains-only).
+ var query = from e in ctx.Entities
+ join id in ids on e.Id equals id
+ orderby e.Id
+ select e.Id;
+ var sql = query.ToQueryString();
+
+ Assert.Contains("UNION ALL", sql);
+ Assert.DoesNotContain("has({", sql);
+
+ Assert.Equal([1L, 3L], await query.ToListAsync());
+ }
+
+ [Fact]
+ public async Task NegatedContains_MatchesComplement()
+ {
+ await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString);
+ var ids = new[] { 1L };
+
+ var query = ctx.Entities.Where(e => !ids.Contains(e.Id));
+ Assert.Contains("has({", query.ToQueryString());
+
+ var rows = await query.OrderBy(e => e.Id).Select(e => e.Id).ToListAsync();
+ Assert.Equal([2L, 3L], rows);
+ }
+}