From 7e2a7b89529db291d117b2de9422f536b657414c Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Tue, 7 Jul 2026 17:52:20 +0200 Subject: [PATCH 1/2] Translate parameterized collection Contains to a native array parameter (#39) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A captured collection used with Contains (ids.Contains(x.Id)) now binds as a single native Array(T) parameter — has({ids:Array(T)}, column) — 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. Implemented in ClickHouseSqlNullabilityProcessor.VisitIn: an InExpression with a collection ValuesParameter is rewritten to has() when the parameter is unmarked (provider default) or explicitly EF.Parameter. The array element store type is aligned to the tested column's mapping. Per-query markers are honored — EF.MultipleParameters keeps the per-element expansion and EF.Constant inlines the values. Element types requiring a value conversion (e.g. CLR enum -> Enum8) and collections used as a queryable source (joins, Where(...).Contains(...)) fall back to the existing expansion / SELECT ... UNION ALL ... path. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 +- README.md | 2 + RELEASENOTES.md | 5 +- .../ClickHouseSqlNullabilityProcessor.cs | 108 +++++ .../ClickHouseQuerySqlGeneratorTests.cs | 8 +- .../ExtendedTypeMappingTests.cs | 15 +- .../ParameterizedContainsTests.cs | 373 ++++++++++++++++++ 7 files changed, 505 insertions(+), 11 deletions(-) create mode 100644 test/EFCore.ClickHouse.Tests/ParameterizedContainsTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index dff11e1..3806b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ -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. 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 (**temporal types** — `DateTime`/`DateTimeOffset`/`DateOnly`/`TimeOnly`/`TimeSpan`), and collections used as a **queryable source** (joins, `Where(…).Contains(…)`, which keep the `SELECT … UNION ALL …` rewrite). The natively-serialized element types are the integers, floating point, `decimal`, `bool`, `string`, and `Guid`. 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)) + ### 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..2e75877 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,5 +1,8 @@ -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`, and `Guid` values; nullable columns, enums, date/time 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)) + ### 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/Query/Internal/ClickHouseSqlNullabilityProcessor.cs b/src/EFCore.ClickHouse/Query/Internal/ClickHouseSqlNullabilityProcessor.cs index 5a7a481..cfecb84 100644 --- a/src/EFCore.ClickHouse/Query/Internal/ClickHouseSqlNullabilityProcessor.cs +++ b/src/EFCore.ClickHouse/Query/Internal/ClickHouseSqlNullabilityProcessor.cs @@ -1,12 +1,29 @@ 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 the ClickHouse driver serializes correctly as a single bound + // array parameter value. Restricted to natively-serializable scalars — integers, floating point, + // decimal, bool, string, and Guid (→ UUID). Temporal types (DateTime, DateTimeOffset, DateOnly, + // TimeOnly, TimeSpan) are deliberately excluded: the driver emits their array elements without the + // quoting ClickHouse needs, so `Array(DateTime)` parameters fail to parse. Anything not listed here + // falls back to EF Core's per-element expansion, which serializes each element on its own. + 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), + ]; + public ClickHouseSqlNullabilityProcessor( RelationalParameterBasedSqlProcessorDependencies dependencies, RelationalParameterBasedSqlProcessorParameters parameters) @@ -14,6 +31,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 temporal types are 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.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/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..4345902 --- /dev/null +++ b/test/EFCore.ClickHouse.Tests/ParameterizedContainsTests.cs @@ -0,0 +1,373 @@ +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 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)"); + }); + } +} + +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) + ) 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), + (2, 20, 'beta', toUUID('{Guid2}'), 'Green', '2022-02-02 00:00:00', 2.5, false, NULL), + (3, 30, 'gamma', toUUID('{Guid3}'), 'Blue', '2023-03-03 00:00:00', 3.5, true, 300) + """; + 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_FallsBackToPerElementExpansion() + { + await using var ctx = new ParamContainsDbContext(_fixture.ConnectionString); + var dates = new[] { new DateTime(2021, 1, 1), new DateTime(2023, 3, 3) }; + + // DateTime is excluded from the array-parameter path: the driver serializes DateTime array + // elements without the quoting ClickHouse needs, so Array(DateTime) parameters fail to parse. + // It falls back to the per-element expansion, which serializes each value correctly. + var query = ctx.Entities.Where(e => dates.Contains(e.DateVal)); + Assert.DoesNotContain("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 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); + } +} From e7c62ef55ba268ead07ae0e9bae158e7cea6598c Mon Sep 17 00:00:00 2001 From: Alex Soffronow-Pagonidis Date: Wed, 8 Jul 2026 10:23:42 +0200 Subject: [PATCH 2/2] Bump ClickHouse.Driver to 1.3.0 and widen array-parameter element types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps ClickHouse.Driver 1.1.0 → 1.3.0 (full unit + functional suites green on 1.3.0). 1.3.0 quotes temporal elements inside array parameters correctly, so the parameterized-Contains array path (#39) can now cover more types. Expanded ArrayParameterElementTypes, verified empirically against 1.3.0, to add DateTime/DateTime64, Date/Date32 (DateOnly), Int128/Int256/UInt128/UInt256 (BigInteger), and IPv4/IPv6 (IPAddress). TimeSpan/Time stays excluded — the driver still serializes its array elements unquoted (CANNOT_READ_ARRAY). Added integration tests for the new element types. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 5 +- RELEASENOTES.md | 5 +- .../EFCore.ClickHouse.csproj | 2 +- .../ClickHouseSqlNullabilityProcessor.cs | 18 +++-- .../EFCore.ClickHouse.FunctionalTests.csproj | 2 +- .../EFCore.ClickHouse.Tests.csproj | 2 +- .../ParameterizedContainsTests.cs | 65 ++++++++++++++++--- 7 files changed, 78 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3806b21..f5634ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,10 @@ 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. 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 (**temporal types** — `DateTime`/`DateTimeOffset`/`DateOnly`/`TimeOnly`/`TimeSpan`), and collections used as a **queryable source** (joins, `Where(…).Contains(…)`, which keep the `SELECT … UNION ALL …` rewrite). The natively-serialized element types are the integers, floating point, `decimal`, `bool`, `string`, and `Guid`. 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)) +* **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/RELEASENOTES.md b/RELEASENOTES.md index 2e75877..04e5225 100644 --- a/RELEASENOTES.md +++ b/RELEASENOTES.md @@ -1,7 +1,10 @@ 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`, and `Guid` values; nullable columns, enums, date/time 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)) +* **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 cfecb84..ebbaf78 100644 --- a/src/EFCore.ClickHouse/Query/Internal/ClickHouseSqlNullabilityProcessor.cs +++ b/src/EFCore.ClickHouse/Query/Internal/ClickHouseSqlNullabilityProcessor.cs @@ -10,18 +10,22 @@ namespace ClickHouse.EntityFrameworkCore.Query.Internal; public class ClickHouseSqlNullabilityProcessor : SqlNullabilityProcessor { - // Element CLR types whose collections the ClickHouse driver serializes correctly as a single bound - // array parameter value. Restricted to natively-serializable scalars — integers, floating point, - // decimal, bool, string, and Guid (→ UUID). Temporal types (DateTime, DateTimeOffset, DateOnly, - // TimeOnly, TimeSpan) are deliberately excluded: the driver emits their array elements without the - // quoting ClickHouse needs, so `Array(DateTime)` parameters fail to parse. Anything not listed here - // falls back to EF Core's per-element expansion, which serializes each element on its own. + // 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( @@ -96,7 +100,7 @@ private SqlExpression VisitCollectionParameterIn( // - 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 temporal types are excluded). + // ArrayParameterElementTypes — notably TimeSpan/Time is excluded). // The base expansion serializes each element individually, so all these cases still work. if (itemNullable || elementMapping is null 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/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/ParameterizedContainsTests.cs b/test/EFCore.ClickHouse.Tests/ParameterizedContainsTests.cs index 4345902..6e75de7 100644 --- a/test/EFCore.ClickHouse.Tests/ParameterizedContainsTests.cs +++ b/test/EFCore.ClickHouse.Tests/ParameterizedContainsTests.cs @@ -1,3 +1,5 @@ +using System.Net; +using System.Numerics; using Microsoft.EntityFrameworkCore; using Xunit; @@ -16,6 +18,9 @@ public class ParamContainsEntity 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 @@ -47,6 +52,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) 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"); }); } } @@ -77,7 +85,10 @@ color Enum8('Red'=1,'Green'=2,'Blue'=3), date_val DateTime, decimal_val Decimal(18, 4), bool_val Bool, - nullable_int Nullable(Int32) + nullable_int Nullable(Int32), + date_only_val Date32, + big_val Int128, + ip_val IPv4 ) ENGINE = MergeTree() ORDER BY id """; @@ -86,9 +97,9 @@ ORDER BY id 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), - (2, 20, 'beta', toUUID('{Guid2}'), 'Green', '2022-02-02 00:00:00', 2.5, false, NULL), - (3, 30, 'gamma', toUUID('{Guid3}'), 'Blue', '2023-03-03 00:00:00', 3.5, true, 300) + (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(); } @@ -168,16 +179,52 @@ public async Task Guid_Collection_TranslatesAndMatches() } [Fact] - public async Task DateTime_Collection_FallsBackToPerElementExpansion() + 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) }; - // DateTime is excluded from the array-parameter path: the driver serializes DateTime array - // elements without the quoting ClickHouse needs, so Array(DateTime) parameters fail to parse. - // It falls back to the per-element expansion, which serializes each value correctly. var query = ctx.Entities.Where(e => dates.Contains(e.DateVal)); - Assert.DoesNotContain("has({", query.ToQueryString()); + 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);