diff --git a/AGENTS.md b/AGENTS.md index 4212b02..6ea2fb3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ so moving a project cannot silently retarget a test. - `test/CodoMetis.ValueRanges.NodaTime.Tests/` — NodaTime satellite unit tests - `test/CodoMetis.ValueRanges.EFCore.PostgreSQL.Tests/` — EF Core SQL translation tests (no database) - `test/CodoMetis.ValueRanges.EFCore.PostgreSQL.NodaTime.Tests/` — NodaTime EF translation tests (no database) -- `test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/` — Live PostgreSQL via Testcontainers (needs Docker; Inconclusive without, but hard failure under CI=true). Covers BCL and NodaTime types. Authority on PostgreSQL semantics — run when changing translations or range algebra +- `test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/` — Live PostgreSQL via Testcontainers (needs Docker; Inconclusive without, but hard failure under CI=true). Covers BCL and NodaTime types. Authority on PostgreSQL semantics — run when changing translations or range algebra. `CapturedCollectionProbeTests` sends every discovered range and set type as a captured collection (`Contains`, `Any`, `Count`); it links `RangeProbes` and `SetProbes` from the conventions project rather than copying them - `test/CodoMetis.ValueRanges.EFCore.PostgreSQL.InfinityDisabled.IntegrationTests/` — The same live layer for configuration that is per process: its runtimeconfig sets `Npgsql.DisableDateTimeInfinityConversions`, which Npgsql and the plugin each read once, so it cannot be flipped inside the other project's run. Holds the temporal store rule at the extremes with the conversion off. Own container; CI's integration job runs every `test/*IntegrationTests/` - `test/CodoMetis.ValueRanges.AspNetCore.Tests/` — Host-level behaviour with no ValueRanges registration: configuration binding (`TypeConverter`), MVC and minimal-API binding and JSON bodies via `TestServer`, and the documented OpenAPI schema transformer (`ValueRangesSchemaTransformer.cs`), which `docs/serialization.md` must carry verbatim — edit the test copy, then the doc. `HostCultureProbeTests` binds every discovered type (core and NodaTime) through both hosts under four request cultures; it links `RangeProbes`, `SetProbes`, `WrapperElements` and `Reflect` from the conventions project rather than copying them - `test/CodoMetis.ValueRanges.Conventions.Tests/` — Repo-level conventions: changelog consistency and form, packaging metadata, release wiring, the SECURITY.md supported-versions table, value set contract compliance, EF mapping parity, the default JSON contract. Everything is discovered (projects by globbing `src/`, types by reflection), so adding a package or a type needs no edit here diff --git a/docs/oracles.md b/docs/oracles.md index 134fa18..e48bcff 100644 --- a/docs/oracles.md +++ b/docs/oracles.md @@ -241,6 +241,16 @@ character reaches a `text` element. A row the documents name as a refusal (J06, CLR type's domain, and an extreme the store spells `±infinity` are counted, not reported, so the classification is part of what the class asserts and lives in its `DocumentedRefusal`. +The fourth (#136) is the captured collection, the EF Core operand position no other sweep sends. +`CapturedCollectionProbeTests` (integration project) reads every range and value set type from a captured +array, `List`, `HashSet` and `IEnumerable` holding lists chosen to tell a list from the multirange it would +collapse into (empty, overlapping, adjacent, with an empty value, a duplicate, every shape), through +`Contains`, `Any` and `Count`, alone and beside a `Contains` that reads the same collection. The oracle is +the same projection run in memory over the rows as loaded. Its first run registered #76, measured wider (a +range collection no `Contains` reads also fails loudly, as `42809` for `Any(==)` and for `timerange`), and +found #137: `Any` or `Count` with a predicate over an empty collection the plugin sends one parameter per +element throws. + ## How to use it **Run the oracles.** They are ordinary tests; `dotnet test` runs them all. The registered classes are diff --git a/docs/testing.md b/docs/testing.md index 8ebd66f..e1cc623 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -213,6 +213,14 @@ three families, none of which a hand-picked sweep had shown: `IsUnboundedStart`/ `(,)` (#109), `RangeSet.Overlaps` on every continuous type (#106), and adjacency at the date extremes (#110). Its seeds plant a tied-end `Intersect` and a value set `IsSubsetOf` that ignores the tail. +The fuzz sweep binds an operand as a column, a constant or a single parameter, never as a collection. +`CapturedCollectionProbeTests` covers that operand position: every discovered range and value set type, +read from a captured collection through `Contains`, `Any` and `Count`, compared with the same projection in +memory ([oracles.md](oracles.md#axesmd--what-a-consumer-varies)). Its lists are built from the rows as +loaded, so the store's microsecond truncation does not count as a disagreement. It reuses the fuzz sweep's +type discovery (`FuzzTypes`), links `RangeProbes` and `SetProbes` from the conventions project, and runs in +about five seconds. + ### The Multirange Oracle `SmallModelMultirangeOracleTests` covers what the other two do not reach. The single-range oracle diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/CapturedCollectionProbeTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/CapturedCollectionProbeTests.cs new file mode 100644 index 0000000..506b369 --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/CapturedCollectionProbeTests.cs @@ -0,0 +1,394 @@ +using System.Collections.Concurrent; +using System.Linq.Expressions; +using System.Reflection; +using CodoMetis.ValueRanges.Conventions.Tests; +using CodoMetis.ValueRanges.Core; +using CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests.Fuzzing; +using CodoMetis.ValueRanges.Oracles; +using Microsoft.EntityFrameworkCore; +using Npgsql; + +namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests; + +/// +/// A captured collection of ranges or value sets read by a query — wanted.Contains(r.Value), +/// wanted.Any(…), wanted.Count(…) — for every discovered range and value set type, +/// executed and compared with the same expression in memory. +/// +/// +/// +/// The captured collection value of the EF Core operand-position axis (test/oracles/axes.md). +/// The other sweeps bind an operand as a column, an inlined constant or a single parameter; none sends +/// a collection, which EF Core binds as one array parameter of the element type. Three defects came +/// through that gap: a collection of value sets is an array of arrays, which Npgsql could not write +/// (#46); a collection of ranges went out untyped, Npgsql inferred a multirange, and = ANY failed +/// on the server (#71); and Any/Count over that multirange read its ranges merged and its +/// empties dropped, without an error (#76). +/// +/// +/// The product, per type: four collection forms (array, List, HashSet, IEnumerable) +/// × six lists chosen to tell a list from its multirange (empty, overlapping, adjacent, holding an empty +/// value, a duplicate, every shape) × two query shapes. Alone projects only Any and +/// Count forms; beside Contains projects a Contains reading the same collection +/// first, because whether one does decides how the plugin binds a range collection. The oracle is the +/// projection itself, compiled and run over the rows as loaded. A disagreement goes through +/// with its issue. +/// +/// +[TestClass] +public sealed class CapturedCollectionProbeTests +{ + private const string Alone = "alone"; + private const string BesideContains = "beside Contains"; + + [ClassInitialize] + public static async Task CreateDatabase(TestContext _) + { + if (ContainerLifecycle.ConnectionString is null) return; + + await using (var connection = new NpgsqlConnection(ContainerLifecycle.ConnectionString)) + { + await connection.OpenAsync(); + await using var command = new NpgsqlCommand($"CREATE DATABASE {CollectionProbeDbContext.DatabaseName}", connection); + await command.ExecuteNonQueryAsync(); + } + + await using var context = new CollectionProbeDbContext(); + await context.Database.EnsureCreatedAsync(); + } + + [TestMethod] + public async Task Ranges_AnswerAsInMemory() + { + ContainerLifecycle.RequireDatabase(); + Assert.IsGreaterThanOrEqualTo(11, FuzzTypes.Ranges.Count, "range type discovery found too few types"); + + var method = typeof(CapturedCollectionProbeTests).GetMethod(nameof(SweepRange), BindingFlags.NonPublic | BindingFlags.Static)!; + var lines = await RunAll(FuzzTypes.Ranges.Select(t => (Func>>)( + () => (Task>)method.MakeGenericMethod(t.Range, t.Element).Invoke(null, [])!))); + + Candidates.Verdict(nameof(CapturedCollectionProbeTests), lines, + "A captured collection of ranges answers differently in the database and in memory", + method: nameof(Ranges_AnswerAsInMemory)); + } + + [TestMethod] + public async Task ValueSets_AnswerAsInMemory() + { + ContainerLifecycle.RequireDatabase(); + Assert.IsGreaterThanOrEqualTo(30, FuzzTypes.Sets.Count, "value set type discovery found too few types"); + + var method = typeof(CapturedCollectionProbeTests).GetMethod(nameof(SweepSet), BindingFlags.NonPublic | BindingFlags.Static)!; + var lines = await RunAll(FuzzTypes.Sets.Select(t => (Func>>)( + () => (Task>)method.MakeGenericMethod(t.Set, t.Element).Invoke(null, [])!))); + + Candidates.Verdict(nameof(CapturedCollectionProbeTests), lines, + "A captured collection of value sets answers differently in the database and in memory", + method: nameof(ValueSets_AnswerAsInMemory)); + } + + private static async Task> RunAll(IEnumerable>>> sweeps) + { + var lines = new ConcurrentBag>(); + await Parallel.ForEachAsync(sweeps, new ParallelOptions { MaxDegreeOfParallelism = 4 }, + async (sweep, _) => lines.Add(await sweep())); + return [.. lines.SelectMany(l => l).Order(StringComparer.Ordinal)]; + } + + // ========================================================================= + // The products + // ========================================================================= + + private static Task> SweepRange() + where TRange : class, IRangeFactory, IRange + where T : struct, IComparable, IEquatable + { + var (low, high) = ((T)RangeProbes.Bounds[typeof(T)].Low, (T)RangeProbes.Bounds[typeof(T)].High); + + // The rows, and the lists as indexes into them. + const int empty = 0, infinite = 1, finite = 2, onward = 3, before = 4, until = 5; + + return Sweep( + typeof(TRange).Name, + [ + TRange.Empty, + TRange.Infinite, + TRange.CreateFinite(low, high, startInclusive: true, endInclusive: false), + TRange.CreateUnboundedEnd(low, startInclusive: true), // overlaps finite + TRange.CreateUnboundedStart(low, endInclusive: false), // adjacent to finite + TRange.CreateUnboundedStart(high, endInclusive: false) // contains finite + ], + [ + ("empty", []), + ("overlapping", [finite, onward]), + ("adjacent", [before, finite]), + ("with an empty range", [empty, finite]), + ("duplicate", [finite, finite]), + ("every shape", [infinite, empty, finite, onward, before, until]) + ], + [ + ("Any(==)", Aggregate(nameof(Enumerable.Any), Equal())), + ("Any(Overlaps)", Aggregate(nameof(Enumerable.Any), Pred((w, c) => w.Overlaps(c)))), + ("Any(IsEmpty)", Aggregate(nameof(Enumerable.Any), Pred((w, _) => w.IsEmpty()))), + ("Count(Overlaps)", Aggregate(nameof(Enumerable.Count), Pred((w, c) => w.Overlaps(c)))), + ("Count(IsAdjacentTo)", Aggregate(nameof(Enumerable.Count), Pred((w, c) => w.IsAdjacentTo(c)))), + ("Count()", Aggregate(nameof(Enumerable.Count), null)) + ]); + } + + private static Task> SweepSet() + where TSet : class, IValueSetFactory, IValueSet + where T : IEquatable + { + if (!SetProbes.HasProbes(typeof(T))) throw new InvalidOperationException($"SetProbes has no elements for {typeof(T)}."); + var probes = SetProbes.For(); + + const int empty = 0, first = 1, all = 2, last = 3; + + return Sweep( + FuzzTypes.Name(typeof(TSet)), + [TSet.Empty, TSet.From([probes[0]]), TSet.From(probes), TSet.From([probes[^1]])], + [ + ("empty", []), + ("overlapping", [all, first]), + ("with an empty set", [empty, first]), + ("duplicate", [first, first]), + ("every value", [empty, first, all, last]) + ], + [ + ("Any(==)", Aggregate(nameof(Enumerable.Any), Equal())), + ("Any(Overlaps)", Aggregate(nameof(Enumerable.Any), Pred((w, c) => w.Overlaps(c)))), + ("Any(IsEmpty)", Aggregate(nameof(Enumerable.Any), IsEmpty())), + ("Count(IsSubsetOf)", Aggregate(nameof(Enumerable.Count), Pred((w, c) => w.IsSubsetOf(c)))), + ("Count(Overlaps)", Aggregate(nameof(Enumerable.Count), Pred((w, c) => w.Overlaps(c)))), + ("Count()", Aggregate(nameof(Enumerable.Count), null)) + ]); + } + + // ========================================================================= + // The engine + // ========================================================================= + + /// The four ways a consumer holds the collection, as fields, so EF Core parameterizes it as a captured local. + private sealed class Forms(TItem[] items) + { + public readonly TItem[] Array = items; + public readonly List List = [.. items]; + public readonly HashSet HashSet = [.. items]; + public readonly IEnumerable Enumerable = items.ToList(); + } + + private static readonly string[] FormNames = + [nameof(Forms.Array), nameof(Forms.List), nameof(Forms.HashSet), nameof(Forms.Enumerable)]; + + private static async Task> Sweep( + string subject, TItem[] values, (string Name, int[] Rows)[] lists, (string Name, LambdaExpression Op)[] ops) + where TItem : class + { + await using (var write = new CollectionProbeDbContext()) + { + write.AddRange(values.Select((value, index) => new CollectionRow { Id = index + 1, Value = value })); + await write.SaveChangesAsync(); + } + + List> rows; + await using (var read = new CollectionProbeDbContext()) + rows = await read.Set>().AsNoTracking().OrderBy(r => r.Id).ToListAsync(); + + var failures = new List(); + // A list holds the values as loaded, not as built: a temporal finer than the store's microsecond + // is truncated on the way in (the store rule, TemporalStoreParityTests), and a consumer who + // captures values read them back first. + foreach (var (listName, indexes) in lists) + { + var forms = Expression.Constant(new Forms([.. indexes.Select(index => rows[index].Value)])); + foreach (var form in FormNames) + { + var collection = Expression.Field(forms, form); + var contains = ("Contains", ContainsOf(collection)); + + foreach (var (shape, companion) in new[] { (Alone, ((string, LambdaExpression)?)null), (BesideContains, contains) }) + { + var label = $"{subject} over {listName} as {form}, {shape}"; + failures.AddRange(await Compare(label, rows, collection, companion, ops)); + } + } + } + + return failures; + } + + /// + /// Runs the operations as one projection, after the companion when there is one, and compares each + /// row with memory. A query that fails is run again once per operation — still beside the companion, + /// since whether one reads the collection decides how it is bound — so the failure names the + /// operation that spoiled it. + /// + private static async Task> Compare( + string label, List> rows, Expression collection, + (string Name, LambdaExpression Op)? companion, (string Name, LambdaExpression Op)[] ops) + where TItem : class + { + (string Name, LambdaExpression Op)[] asked = companion is { } first ? [first, .. ops] : ops; + var projection = Projection(collection, asked); + var memory = projection.Compile(); + + try + { + return Differences(label, rows, memory, await Database(projection), [.. asked.Select(a => a.Name)]); + } + catch (Exception) + { + // One operation spoiled the query; ask each alone below. + } + + var lines = new List(); + foreach (var single in asked) + { + (string Name, LambdaExpression Op)[] pair = companion is { } with && with.Name != single.Name ? [with, single] : [single]; + var one = Projection(collection, pair); + try + { + lines.AddRange(Differences(label, rows, one.Compile(), await Database(one), [.. pair.Select(p => p.Name)]) + .Where(line => line.Contains($": {single.Name} for ", StringComparison.Ordinal))); + } + catch (Exception exception) + { + var inner = exception.InnerException ?? exception; + lines.Add($"{label}: {single.Name} threw {inner.GetType().Name}: {inner.Message.Split('\n')[0].Trim()}"); + } + } + + return lines; + } + + private static async Task> Database(Expression, FuzzOut>> projection) + where TItem : class + { + await using var db = new CollectionProbeDbContext(); + var results = await db.Set>().AsNoTracking().Select(projection).ToListAsync(); + return results.ToDictionary(r => r.Id, r => r.Values); + } + + private static List Differences( + string label, List> rows, Func, FuzzOut> memory, + Dictionary database, string[] names) + where TItem : class + { + var lines = new List(); + foreach (var row in rows) + { + var expected = memory(row).Values; + if (!database.TryGetValue(row.Id, out var actual)) + { + lines.Add($"{label}: row {row.Value} not returned"); + continue; + } + + for (var index = 0; index < names.Length; index++) + { + if (Equals(expected[index], actual[index])) continue; + lines.Add($"{label}: {names[index]} for {row.Value} → memory={expected[index]}, database={actual[index]}"); + } + } + + return lines; + } + + /// r => new FuzzOut { Id = r.Id, Values = [op1(collection, r.Value), …] }. + private static Expression, FuzzOut>> Projection( + Expression collection, (string Name, LambdaExpression Op)[] asked) + where TItem : class + { + var row = Expression.Parameter(typeof(CollectionRow), "r"); + var column = Expression.Property(row, nameof(CollectionRow.Value)); + var body = Expression.MemberInit( + Expression.New(typeof(FuzzOut)), + Expression.Bind(typeof(FuzzOut).GetProperty(nameof(FuzzOut.Id))!, Expression.Property(row, nameof(CollectionRow.Id))), + Expression.Bind( + typeof(FuzzOut).GetProperty(nameof(FuzzOut.Values))!, + Expression.NewArrayInit( + typeof(object), + asked.Select(a => Expression.Convert(FuzzEngine.Splice(a.Op, collection, column), typeof(object)))))); + return Expression.Lambda, FuzzOut>>(body, row); + } + + /// + /// collection.Contains(c) as a consumer writes it: the instance method where the form has one + /// (List, HashSet), Enumerable.Contains otherwise. + /// + private static LambdaExpression ContainsOf(MemberExpression collection) + { + var list = Expression.Parameter(collection.Type, "list"); + var c = Expression.Parameter(typeof(TItem), "c"); + var instance = collection.Type.GetMethod(nameof(Enumerable.Contains), [typeof(TItem)]); + Expression call = instance is not null && !collection.Type.IsArray + ? Expression.Call(list, instance, c) + : Expression.Call(typeof(Enumerable), nameof(Enumerable.Contains), [typeof(TItem)], list, c); + return Expression.Lambda(call, list, c); + } + + /// (list, c) => list.Any(w => pred(w, c)), or list.Count() without a predicate. + private static LambdaExpression Aggregate(string method, LambdaExpression? pred) + { + var list = Expression.Parameter(typeof(IEnumerable), "list"); + var c = Expression.Parameter(typeof(TItem), "c"); + var w = Expression.Parameter(typeof(TItem), "w"); + Expression call = pred is null + ? Expression.Call(typeof(Enumerable), method, [typeof(TItem)], list) + : Expression.Call(typeof(Enumerable), method, [typeof(TItem)], list, + Expression.Lambda(FuzzEngine.Splice(pred, w, c), w)); + return Expression.Lambda(call, list, c); + } + + private static LambdaExpression Pred(Expression> pred) => pred; + + /// == through the type's own operator, which a generic lambda cannot name. + private static LambdaExpression Equal() + { + var (w, c) = (Expression.Parameter(typeof(TItem), "w"), Expression.Parameter(typeof(TItem), "c")); + return Expression.Lambda(Expression.Equal(w, c), w, c); + } + + /// IsEmpty is an instance property of each set type, not of an interface, so it is reached by name. + private static LambdaExpression IsEmpty() + { + var (w, c) = (Expression.Parameter(typeof(TSet), "w"), Expression.Parameter(typeof(TSet), "c")); + return Expression.Lambda(Expression.Property(w, nameof(StringSet.IsEmpty)), w, c); + } +} + +/// One value of a range or value set type, in the table of that type. +public sealed class CollectionRow where TValue : class +{ + public int Id { get; set; } + + public TValue Value { get; set; } = null!; +} + +/// +/// The tables of , one per discovered type, in a database of +/// their own so the rows never meet another test's. +/// +public sealed class CollectionProbeDbContext : DbContext +{ + internal const string DatabaseName = "valueranges_collections"; + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder.UseNpgsql( + new NpgsqlConnectionStringBuilder(ContainerLifecycle.ConnectionString) { Database = DatabaseName }.ConnectionString, + npgsql => npgsql.UseValueRangesNodaTime() + .ConfigureDataSource(dataSource => dataSource.EnableUnmappedTypes())); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + foreach (var type in FuzzTypes.Ranges.Select(t => t.Range).Concat(FuzzTypes.Sets.Select(t => t.Set))) + { + var row = modelBuilder.Entity(typeof(CollectionRow<>).MakeGenericType(type)); + row.ToTable($"collection_{FuzzTypes.Name(type)}"); + row.Property(nameof(CollectionRow.Id)).ValueGeneratedNever(); + } + + modelBuilder.HasPostgresRange("timerange", "time"); + } +} diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests.csproj b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests.csproj index c89f89b..ec995ae 100644 --- a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests.csproj +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests.csproj @@ -27,6 +27,9 @@ --> + + + diff --git a/test/oracles/axes.md b/test/oracles/axes.md index d50c2e6..f0cdc86 100644 --- a/test/oracles/axes.md +++ b/test/oracles/axes.md @@ -47,7 +47,7 @@ request culture and configuration × every type are varied by `HostCultureProbeT |---|---|---| | Type | every range, multirange and set type, BCL and NodaTime | `MappingParityTests`, `DifferentialFuzzParityTests`; `ShapeMatrixParityTests` over `int4range`, `numrange`, `daterange` only | | Shape pair | 5 × 5 ordered | `ShapeMatrixParityTests` (against PostgreSQL), `SmallModelOracleTests` (against set theory) | -| Operand position | column × column, column × parameter, column × inlined constant, constant element, server-side constructor | `MultirangeCoverageTests`, `InlineRangeConstantTests`, `InlineRangeConstantExecutionTests`; `DifferentialFuzzParityTests` (three legs, constant and parameter per probe) | +| Operand position | column × column, column × parameter, column × inlined constant, constant element, server-side constructor, captured collection (array, `List`, `HashSet`, `IEnumerable`) read by `Contains`, `Any`, `Count` | `MultirangeCoverageTests`, `InlineRangeConstantTests`, `InlineRangeConstantExecutionTests`; `DifferentialFuzzParityTests` (three legs, constant and parameter per probe); `CapturedCollectionProbeTests` (captured collection × every range and set type × list contents × alone or beside `Contains`) | | Store text route | binary parameter, SQL literal, JSON document | `TemporalStoreParityTests`, `TemporalStoreTextTests`, `JsonColumnTests` | | `Npgsql.DisableDateTimeInfinityConversions` | off, on | every integration project (off); `InfinityConversionsDisabledTests` (on, temporal extremes only — per process, hence its own project) | | Temporal value | sub-microsecond, the extremes, each `DateTimeKind` | `TemporalStoreParityTests`, `RangeRoundTripTests`, `ProviderConversionTests` | diff --git a/test/oracles/candidates.json b/test/oracles/candidates.json index a0b5496..5678b54 100644 --- a/test/oracles/candidates.json +++ b/test/oracles/candidates.json @@ -976,5 +976,18 @@ "family": "a discrete bound exclusive at ±infinity is canonicalized one step inside the domain", "issue": "#117", "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "CapturedCollectionProbeTests", + "row": ", alone: ", + "family": "a range collection no Contains reads goes out as one parameter Npgsql types as a multirange: unnest merges and drops empties, Any(==) fails with 42809, timerange cannot be written", + "issue": "#76", + "method": "Ranges_AnswerAsInMemory" + }, + { + "oracle": "CapturedCollectionProbeTests", + "row": " over empty as ", + "family": "an empty captured collection cannot be a VALUES query root", + "issue": "#137" } ] diff --git a/test/oracles/claims.json b/test/oracles/claims.json index defb0f4..5e84640 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -666,7 +666,8 @@ ], "heldBy": [ "SetCollectionContainsTests", - "SetCollectionTranslationTests" + "SetCollectionTranslationTests", + "CapturedCollectionProbeTests" ] }, { @@ -680,7 +681,8 @@ ], "heldBy": [ "RangeCollectionContainsTests", - "RangeCollectionTranslationTests" + "RangeCollectionTranslationTests", + "CapturedCollectionProbeTests" ] }, { diff --git a/test/oracles/oracles.json b/test/oracles/oracles.json index 9ac288f..2b4703f 100644 --- a/test/oracles/oracles.json +++ b/test/oracles/oracles.json @@ -229,5 +229,14 @@ "needs": [ "docker" ] + }, + { + "class": "CapturedCollectionProbeTests", + "project": "CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests", + "kind": "differential", + "against": "the same projection in memory, for every discovered range and value set type × a captured array, List, HashSet and IEnumerable × six lists (empty, overlapping, adjacent, with an empty value, duplicate, every shape) × Contains, Any and Count, alone and beside a Contains (test/oracles/axes.md)", + "needs": [ + "docker" + ] } ] diff --git a/test/oracles/seeds/set-collection-sent-as-one-parameter.patch b/test/oracles/seeds/set-collection-sent-as-one-parameter.patch new file mode 100644 index 0000000..dbf1b7f --- /dev/null +++ b/test/oracles/seeds/set-collection-sent-as-one-parameter.patch @@ -0,0 +1,18 @@ +Seed: a captured collection of value sets is no longer sent one parameter per set, so it goes out as one array of arrays that Npgsql cannot write +Oracles: CapturedCollectionProbeTests +Fails: every value set type through every collection form and list, Contains, Any and Count alike, alone and beside Contains (NotSupportedException: jagged collections) +History: shipped before 9.1.0 (#46) + +diff --git a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesQueryExpressionInterceptor.cs b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesQueryExpressionInterceptor.cs +index 8c1bebd..c2ed8b2 100644 +--- a/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesQueryExpressionInterceptor.cs ++++ b/src/CodoMetis.ValueRanges.EFCore.PostgreSQL/Query/ValueRangesQueryExpressionInterceptor.cs +@@ -100,7 +100,7 @@ public sealed class ValueRangesQueryExpressionInterceptor : IQueryExpressionInte + => node is QueryParameterExpression parameter + && (parameter.TranslationMode ?? defaultMode) == ParameterTranslationMode.Parameter + && Element(parameter.Type) is var (_, isSet) +- && (isSet || rangeContainsSources.Contains(parameter.Name)) ++ && !isSet && rangeContainsSources.Contains(parameter.Name) + ? new QueryParameterExpression(parameter.Name, parameter.Type, ParameterTranslationMode.MultipleParameters) + : base.VisitExtension(node); +