diff --git a/docs/oracles.md b/docs/oracles.md index 184c6fc..134fa18 100644 --- a/docs/oracles.md +++ b/docs/oracles.md @@ -226,6 +226,21 @@ query, nullable query, route value or form field, answers 200 with `null` for th shape alike. It is registered as one candidate per range type, scoped by the line's `variant minimal TryParse` prefix so the JSON-body and MVC cells of the same variants stay held. +Route B's step 3 is `LiteralCorpusProbeTests` (integration project, #123): literal corpus × entry point × type, +judged by PostgreSQL's own text input on the same text. The corpus is generated per element kind from the +families `axes.md` lists; the authority is `pg_input_is_valid($1, type)` and the canonical text, asked +for every literal of a type in one statement; the entry points are `Parse` and `TryParse` over a string +and a span, JSON, the `TypeConverter` and the EF Core JSON document reader, plus the EF Core write-back +as a SQL constant and a JSON document, compared on the server as typed values. Its first run turned the +pair's two known cells (#114, #115) into 120 rows over 23 types, reached #117 on the literal path +(`[x,infinity)` loses the last day), and found eight more defects (#126–#133): a +sub-microsecond literal is a different value on each side, integer and array forms PostgreSQL 16 reads +are refused, `GuidSet` follows `Guid.Parse` instead of `uuid_in`, `LocalDateTime` refuses an offset, +`DateTimeOffset` refuses offsets beyond ±14:00, `(,max]` is an undocumented boundary row, and a NUL +character reaches a `text` element. A row the documents name as a refusal (J06, J07), a value outside the +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`. + ## 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 012fe28..8ebd66f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -63,6 +63,8 @@ Coverage of the arities *as a family* lives in the conventions suite, because it The literal grammars themselves follow PostgreSQL's `range_in` and `array_in`. `RangeLiteralGrammarContractTests` (conventions) sweeps every range type: only an absent bound is infinite (a quoted `""` or a whitespace-only bound is refused), quotes and backslash escapes work anywhere in a bound, and only ASCII whitespace is skipped. `SetParseFormatTests` pins the array grammar case by case (escapes in unquoted elements, nested braces and stray quotes refused) plus a round-trip sweep over every structural character. `LiteralGrammarParityTests` (integration) hands the same text to PostgreSQL and requires the same answer — refused by both, or the same value. `WrapperGrammarContractTests` (conventions) holds every wrapper arity, closed over a deliberately lenient element and over its representative one, to its primitive sibling's element grammar through every entry point. +`LiteralCorpusProbeTests` (integration, route B's third probe) widens that parity sweep to a corpus and to every entry point. Per element kind it generates the structural families of each grammar (bounds, equal and inverted bounds per bracket pair, `empty` in every case, quoting, escapes, trailing junk, Unicode whitespace, array dimensions and NULLs, multirange canonicalization) and the texts the element input function reads differently (sign, leading zeros, exponents, `NaN`, non-decimal integers, more than 28 digits, `infinity` spellings, `epoch`/`now`/`today`, non-ISO dates, `24:00:00`, offsets, sub-microsecond digits, uuid spellings, NUL), about 100 literals for each of the 48 range, multirange and value set types. The server answers each with `pg_input_is_valid` and its canonical text in one batched statement per type; `Parse(string)`, `Parse(span)`, both `TryParse` overloads, JSON, the `TypeConverter` and the EF Core JSON document reader must all agree with each other and with it, and a value the library reads is written back through EF Core as a SQL constant and as a JSON document and compared with the original on the server as a typed value — which is what sees a precision the parser dropped. A refusal of a value outside the CLR domain, or of a form `docs/serialization.md` names (strict ISO 8601, J07; the `numeric` grammar, J06), is counted rather than reported. The whole sweep takes about five seconds; its known disagreements are candidates keyed `{type} {family} `. + `test/CodoMetis.ValueRanges.AspNetCore.Tests/` runs the host-level behaviour with no ValueRanges registration at all: configuration binding through the `TypeConverter`, a `TestServer` host with MVC and minimal-API endpoints (query binding, JSON bodies both ways), and the OpenAPI document generated with and without the documented `ValueRangesSchemaTransformer` — whose patterns are checked to accept every literal form the parsers accept, and whose source is held verbatim against `docs/serialization.md`. `HostCultureProbeTests` there is the host half of route B's second probe sweep (#124): one `TestServer` with a minimal-API route group and a closed `ProbeController` per declared type (every union, each of its five variants, every `RangeSet` and every set type, 107 in all), each bound from a query, a nullable query, a route value, a form field and a JSON body, under four request cultures set by `UseRequestLocalization` from `Accept-Language`. A valid value must come back with 200, the right runtime type and the same JSON value; a malformed literal, or a literal of another shape for a variant, must get 400. Each handler echoes the culture it ran under, so a row whose culture never arrived fails rather than passing vacuously. It links `RangeProbes`, `SetProbes`, `WrapperElements` and `Reflect` from the conventions project instead of copying them, and runs about 12,400 requests in about three seconds. diff --git a/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/LiteralCorpusProbeTests.cs b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/LiteralCorpusProbeTests.cs new file mode 100644 index 0000000..d9af8dd --- /dev/null +++ b/test/CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests/LiteralCorpusProbeTests.cs @@ -0,0 +1,721 @@ +using System.ComponentModel; +using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; +using CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests.Fuzzing; +using CodoMetis.ValueRanges.Oracles; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage; +using NodaTime; +using Npgsql; +using NpgsqlTypes; + +namespace CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests; + +/// +/// Route B, step 3 (#105, #123, test/oracles/axes.md): literal corpus × entry point × type, +/// judged against PostgreSQL's own text input on the same text. For every literal of the corpus the +/// server is asked pg_input_is_valid($1, type) and $1::type::text, and every entry point +/// of the library must give the server's answer: both refuse, or both read the same value. +/// +/// +/// +/// asks the server about quoted, escaped and whitespace bounds +/// through TryParse. The rest of what PostgreSQL's range_in, multirange_in, +/// array_in and the element input functions distinguish was never asked, and #114 (an inverted +/// literal read as empty) and #115 (decimal text beyond 28 significant digits rounded) were each one +/// cell of this product, found once and not kept. So the corpus is generated per element kind: the +/// structural families of each grammar (bounds, equal and inverted bounds, empty in every case, +/// quoting, escapes, trailing junk, Unicode whitespace, dimensions, multirange canonicalization) over +/// four ascending element texts, and per element kind the texts its input function reads differently +/// (sign, leading zeros, exponents, NaN, infinity spellings, 24:00:00, more than +/// 28 digits, PostgreSQL's special inputs epoch/now/today, non-ISO dates …), +/// each placed in a bound or an array element. +/// +/// +/// Per literal the library answers through Parse(string), Parse(span), +/// TryParse(string), TryParse(span), the JSON converter (a range or multirange as its +/// literal; a value set as a one-element JSON array where the element is expressible as JSON), the +/// TypeConverter and the EF Core JSON document reader. All of them must agree. Against the +/// server, a value the library reads is written back through EF Core twice — as the SQL constant and +/// as the JSON document text — and the server must read each as the value it read from the original +/// literal: compared on the server as typed values (= for ranges, containment both ways for +/// arrays), so a precision the parser dropped shows up even though re-parsing the server's output +/// through the same parser would hide it. A wrapper arity is compared through its own parser instead, +/// since its element type validates and may normalize (TextKey trims); the EF legs are its +/// closed sibling's. +/// +/// +/// Two kinds of refusal are not disagreements. Domain: the server reads a value the CLR type +/// cannot hold (NaN, numeric infinity, 24:00:00 as a TimeOnly, a year +/// beyond 9999, a NULL element, a two-dimensional array); the library refuses both the literal and the +/// server's own output (docs/architecture.md#domain-width-and-the-boundary). Documented: +/// a family docs/serialization.md says the library refuses although PostgreSQL reads it — the +/// strict ISO 8601 temporal grammar (claim J07) and the numeric grammar of sign, digits, point +/// and exponent (J06). Both are counted; acceptance of such a literal is still compared. +/// +/// +/// One [DynamicData] row per type, so a candidate is scoped by the type's label. A failure line +/// is {type} {family} {literal}: …, and a candidate's row is {type} {family} : one +/// family is one defect class. The server round trips are batched (three to four statements per type +/// over one connection), so the sweep adds seconds, not minutes, to the integration run. +/// +/// +[TestClass] +public sealed class LiteralCorpusProbeTests +{ + private static readonly CultureInfo Invariant = CultureInfo.InvariantCulture; + + private static IRelationalTypeMappingSource Mappings { get; } = new IntegrationDbContext().GetService(); + + /// One row per discovered type; the label is the prefix every failure line and candidate row starts with. + public sealed record ProbeRow(string Label, Func, Task> Sweep) + { + public override string ToString() => Label; + } + + /// What one row ran: literals, the cells (literal × entry point), and how the refusals were judged. + public sealed record Tally(int Literals, int Cells, int BothRefuse, int Domain, int Documented, int Compared); + + public static IEnumerable Rows + { + get + { + foreach (var (range, element) in FuzzTypes.Ranges) + { + if (KindOf(element) is not { } kind) continue; + var multirange = typeof(RangeSet<,>).MakeGenericType(range, element); + yield return [Row(range.Name, range, kind, Grammar.Range, closedSibling: null)]; + yield return [Row($"RangeSet<{range.Name}>", multirange, kind, Grammar.Multirange, closedSibling: null)]; + } + + foreach (var (set, _, primitive) in FuzzTypes.Sets) + { + if (KindOf(primitive) is not { } kind) continue; + var label = set.IsGenericType ? $"{set.Name.Split('`')[0]}<{set.GetGenericArguments()[0].Name}>" : set.Name; + Type? sibling = set.IsGenericType + ? set.Assembly.GetExportedTypes().Single(t => t.Name == set.Name.Split('`')[0] && !t.IsGenericType) + : null; + yield return [Row(label, set, kind, Grammar.Array, sibling)]; + } + } + } + + private static ProbeRow Row(string label, Type type, Kind kind, Grammar grammar, Type? closedSibling) + { + var method = typeof(LiteralCorpusProbeTests).GetMethod(nameof(Sweep), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)! + .MakeGenericMethod(type); + return new ProbeRow(label, (connection, failures) => + (Task)method.Invoke(null, [connection, failures, label, kind, grammar, closedSibling])!); + } + + /// The floor that keeps the row list from passing by finding nothing. + [TestMethod] + public void Discovery_FindsEveryRow() + { + // 10 ranges and their 10 multiranges (YearMonthRange's literal is not a daterange literal), + // 28 set types (14 families × closed, wrapper; YearMonthSet likewise). + const int knownRows = 48; + var rows = Rows.Count(); + Assert.IsGreaterThanOrEqualTo(knownRows, rows, $"Found {rows} rows, fewer than {knownRows}."); + } + + [TestMethod] + [DynamicData(nameof(Rows))] + public async Task EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt(ProbeRow row) + { + ContainerLifecycle.RequireDatabase(); + await using var connection = new NpgsqlConnection(ContainerLifecycle.ConnectionString); + await connection.OpenAsync(); + + var failures = new List(); + var tally = await row.Sweep(connection, failures); + + Console.WriteLine($"{row}: {tally}"); + Assert.IsGreaterThanOrEqualTo(60, tally.Literals, $"{row}: only {tally.Literals} literals ran."); + Assert.IsGreaterThanOrEqualTo(10, tally.BothRefuse, $"{row}: only {tally.BothRefuse} literals were refused by both sides."); + Assert.IsGreaterThanOrEqualTo(10, tally.Compared, $"{row}: only {tally.Compared} literals were read by both sides."); + + Candidates.Verdict(nameof(LiteralCorpusProbeTests), failures, + $"{row}: of {tally.Literals} literals, these are read differently from PostgreSQL", shown: 40, rowPrefix: row.Label + " "); + } + + // ========================================================================= + // The sweep + // ========================================================================= + + private enum Grammar { Range, Multirange, Array } + + private enum Kind { Int2, Int4, Int8, Numeric, Date, Timestamp, Timestamptz, Time, Uuid, Text } + + /// One entry point's answer: a value and its literal, a refusal, or an exception no refusal should be. + private sealed record Answer(bool Accepted, object? Value, string Text, bool Threw = false) + { + public static Answer Refused(Exception e) => new(false, null, $"refused ({e.GetType().Name})"); + + public static Answer Unexpected(Exception e) => new(false, null, $"threw {e.GetType().Name}: {e.Message}", Threw: true); + + public bool SameAs(Answer other) => + Accepted == other.Accepted && (!Accepted || Equals(Value, other.Value) && Text == other.Text); + } + + private static async Task Sweep( + NpgsqlConnection connection, List failures, string label, Kind kind, Grammar grammar, Type? closedSibling) + where TSelf : ISpanParsable + { + var corpus = Corpus(kind, grammar).ToList(); + var mapping = Mappings.FindMapping(typeof(TSelf)) ?? throw new InvalidOperationException($"No mapping for {typeof(TSelf)}."); + var storeType = mapping.StoreType; + var isWrapper = closedSibling is not null; + + var server = await ReadOnServer(connection, storeType, grammar, [.. corpus.Select(c => c.Literal)]); + + int cells = 0, bothRefuse = 0, domain = 0, documented = 0, compared = 0; + var writeBack = new List<(int Index, string Leg, string Text)>(); + var library = new Answer[corpus.Count]; + + for (var i = 0; i < corpus.Count; i++) + { + var (family, literal, element) = corpus[i]; + var head = $"{label} {family} {Show(literal)}:"; + + // --- every entry point, against Parse(string) ----------------------------------------- + var legs = EntryPoints(literal, element, grammar, mapping).ToList(); + cells += legs.Count; + var parse = legs[0].Answer; + library[i] = parse; + foreach (var (leg, answer) in legs) + { + if (answer.Threw) + failures.Add($"{head} {leg} {answer.Text}"); + else if (!answer.SameAs(parse)) + failures.Add($"{head} {leg} {answer.Text}, Parse(string) {parse.Text}"); + } + + // --- against the server --------------------------------------------------------------- + var pg = server[i]; + if (!pg.Valid) + { + if (parse.Accepted) failures.Add($"{head} library {parse.Text}, PostgreSQL refuses"); + else bothRefuse++; + continue; + } + + // The server's canonical text can leave the CLR domain while the value does not — the + // discrete (,9999-12-31] prints as (,10000-01-01) — so for a closed type an unreadable + // canonical text is judged by the write-back below, and only a refusal of both is domain. + var readsServerText = TSelf.TryParse(pg.Text, Invariant, out var fromServer); + if (!pg.Representable || !readsServerText && (isWrapper || !parse.Accepted)) + { + // The server reads a value the CLR type cannot hold: a refusal is the only right answer. + if (parse.Accepted) + failures.Add($"{head} library {parse.Text}, PostgreSQL reads {Show(pg.Text!)}, which {label} cannot hold"); + else domain++; + continue; + } + + if (!parse.Accepted) + { + if (DocumentedRefusal(family, kind) is not null) documented++; + else failures.Add($"{head} library {parse.Text}, PostgreSQL reads {Show(pg.Text!)}"); + continue; + } + + compared++; + if (readsServerText && !Equals(parse.Value, fromServer)) + failures.Add($"{head} library {parse.Text}, PostgreSQL reads {Show(pg.Text!)} = {fromServer}"); + + if (isWrapper) continue; + writeBack.Add((i, "SQL constant", mapping.GenerateSqlLiteral(parse.Value))); + writeBack.Add((i, "JSON document", JsonSerializer.Deserialize(mapping.JsonValueReaderWriter!.ToJsonString(parse.Value!))!)); + } + + // --- the value written back through EF Core, read by the server as the original literal ---- + if (writeBack.Count > 0) + { + var constants = writeBack.Where(w => w.Leg == "SQL constant").ToList(); + var evaluated = await EvaluateOnServer(connection, [.. constants.Select(c => c.Text)]); + var texts = writeBack.Select(w => w.Leg == "SQL constant" ? evaluated[constants.IndexOf(w)] : w.Text).ToArray(); + var verdicts = await CompareOnServer(connection, storeType, grammar, + [.. writeBack.Select(w => corpus[w.Index].Literal)], texts); + + for (var k = 0; k < writeBack.Count; k++) + { + var (index, leg, text) = writeBack[k]; + var (family, literal, _) = corpus[index]; + var head = $"{label} {family} {Show(literal)}:"; + switch (verdicts[k]) + { + case (null, _): + failures.Add($"{head} {leg} {Show(text)} of library {library[index].Text} is refused by PostgreSQL ({Show(texts[k] ?? "error")})"); + break; + // docs/efcore.md#json-columns: the store spells MinValue / MaxValue -infinity / infinity, + // as Npgsql sends them, so a literal naming an extreme is stored as an infinity. + case (false, var read) when IsTemporal(kind) && read!.Contains("infinity") && !server[index].Text!.Contains("infinity"): + documented++; + break; + case (false, var read): + failures.Add($"{head} {leg} of library {library[index].Text} reads {Show(read!)}, PostgreSQL reads {Show(server[index].Text!)}"); + break; + } + } + } + + return new Tally(corpus.Count, cells, bothRefuse, domain, documented, compared); + } + + /// Each entry point's answer; Parse(string) first, which the others are compared with. + private static IEnumerable<(string Leg, Answer Answer)> EntryPoints(string literal, string? element, Grammar grammar, RelationalTypeMapping mapping) + where TSelf : ISpanParsable + { + yield return ("Parse(string)", Try(() => TSelf.Parse(literal, Invariant))); + yield return ("Parse(span)", Try(() => TSelf.Parse(literal.AsSpan(), Invariant))); + yield return ("TryParse(string)", TryNeverThrows(() => (TSelf.TryParse(literal, Invariant, out var r), r))); + yield return ("TryParse(span)", TryNeverThrows(() => (TSelf.TryParse(literal.AsSpan(), Invariant, out var r), r))); + + if (grammar != Grammar.Array) + yield return ("JSON", Try(() => JsonSerializer.Deserialize(JsonSerializer.Serialize(literal))!)); + else if (element is not null && JsonElementArray(element, mapping) is { } array) + yield return ($"JSON {array}", Try(() => JsonSerializer.Deserialize(array)!)); + + // An empty string converts to null (claim J14) — never to a value, so it is the refusal here. + yield return ("TypeConverter", Try(() => (TSelf?)TypeDescriptor.GetConverter(typeof(TSelf)).ConvertFromInvariantString(literal) + ?? throw new FormatException("null"))); + + yield return ("EF JSON document", Try(() => (TSelf)mapping.JsonValueReaderWriter!.FromJsonString(JsonSerializer.Serialize(literal)))); + + // A refusal is FormatException (the literal), OverflowException (an element too large), + // JsonException (the converter), or what TypeDescriptor wraps them in; anything else is reported. + static Answer Try(Func read) + { + try + { + return Accepted(read()); + } + catch (Exception e) when (e is FormatException or OverflowException or JsonException or NotSupportedException or ArgumentException) + { + return Answer.Refused(e); + } + catch (Exception e) + { + return Answer.Unexpected(e); + } + } + + static Answer TryNeverThrows(Func<(bool, TSelf?)> read) + { + try + { + var (parsed, value) = read(); + return parsed ? Accepted(value!) : new Answer(false, null, "refused (false)"); + } + catch (Exception e) + { + return Answer.Unexpected(e); + } + } + + static Answer Accepted(TSelf value) => new(true, value, ((IFormattable)value!).ToString(null, Invariant)); + } + + // A value set's JSON is an array, not the literal, so its JSON leg is the one-element array of an + // element the literal names bare: a number for the numeric families, a string otherwise. Text is + // left out — a JSON string is not parsed — and so is anything JSON cannot spell as written. + private static readonly Regex JsonNumber = new(@"^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$", RegexOptions.CultureInvariant); + + private static string? JsonElementArray(string element, RelationalTypeMapping mapping) + { + // A Guid element is read by System.Text.Json's own converter, whose grammar is not the + // literal's (docs/serialization.md: only the temporal sets read JSON with the literal grammar). + var store = mapping.StoreType; + if (store.StartsWith("text", StringComparison.Ordinal) || store.StartsWith("uuid", StringComparison.Ordinal)) return null; + if (element.Length == 0 || element.Trim() != element || element.IndexOfAny(['"', '\\', '{', '}', ',']) >= 0) return null; + + var numeric = store is "smallint[]" or "integer[]" or "bigint[]" or "numeric[]"; + if (numeric) return JsonNumber.IsMatch(element) ? $"[{element}]" : null; + return $"[{JsonSerializer.Serialize(element)}]"; + } + + // ========================================================================= + // The server + // ========================================================================= + + private sealed record ServerAnswer(bool Valid, string? Text, bool Representable); + + /// + /// pg_input_is_valid and the canonical text for every literal in one statement; a literal the + /// soft-error path cannot report (a NUL byte the protocol refuses) sends the batch to one statement each. + /// + private static async Task ReadOnServer(NpgsqlConnection connection, string storeType, Grammar grammar, string[] literals) + { + // An array is printed without its dimension decoration ([0:1]={1,2} is the set {1,2}): a set + // has no index, and the decoration is not something its literal has to read back. + var (text, representable) = grammar == Grammar.Array + ? ($"ARRAY(SELECT unnest(l::{storeType}))::text", + $"CASE WHEN array_ndims(l::{storeType}) > 1 THEN false ELSE array_position(l::{storeType}, NULL) IS NULL END") + : ($"(l::{storeType})::text", "true"); + + var sql = $""" + SELECT ok, CASE WHEN ok THEN {text} END, CASE WHEN ok THEN {representable} END + FROM (SELECT l, i, pg_input_is_valid(l, $2) AS ok + FROM unnest($1::text[]) WITH ORDINALITY AS u(l, i)) AS s + ORDER BY i + """; + try + { + return await Read(literals); + } + catch (Exception e) when (e is PostgresException or NpgsqlException or ArgumentException) + { + var answers = new ServerAnswer[literals.Length]; + for (var i = 0; i < literals.Length; i++) + { + try + { + answers[i] = (await Read([literals[i]]))[0]; + } + catch (Exception inner) when (inner is PostgresException or NpgsqlException or ArgumentException) + { + answers[i] = new ServerAnswer(false, null, false); + } + } + + return answers; + } + + async Task Read(string[] batch) + { + await using var command = new NpgsqlCommand(sql, connection); + command.Parameters.Add(new NpgsqlParameter { Value = batch, NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text }); + command.Parameters.Add(new NpgsqlParameter { Value = storeType, NpgsqlDbType = NpgsqlDbType.Text }); + var answers = new List(); + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + { + answers.Add(new ServerAnswer(reader.GetBoolean(0), + reader.IsDBNull(1) ? null : reader.GetString(1), + !reader.IsDBNull(2) && reader.GetBoolean(2))); + } + + return [.. answers]; + } + } + + /// The server's text for each SQL constant EF Core generated, or null where the server refuses it. + private static async Task EvaluateOnServer(NpgsqlConnection connection, string[] constants) + { + if (constants.Length == 0) return []; + try + { + await using var command = new NpgsqlCommand($"SELECT ARRAY[{string.Join(", ", constants.Select(c => $"({c})::text"))}]", connection); + return (string?[])(await command.ExecuteScalarAsync())!; + } + catch (PostgresException) + { + var texts = new string?[constants.Length]; + for (var i = 0; i < constants.Length; i++) + { + try + { + await using var command = new NpgsqlCommand($"SELECT ({constants[i]})::text", connection); + texts[i] = (string?)await command.ExecuteScalarAsync(); + } + catch (PostgresException) + { + texts[i] = null; + } + } + + return texts; + } + } + + /// + /// For each pair, whether the server reads the library's text as the value it reads from the + /// literal — typed equality for a range or multirange, containment both ways for an array — and + /// its canonical text; (null, …) where it refuses the library's text. + /// + private static async Task<(bool? Same, string? Read)[]> CompareOnServer( + NpgsqlConnection connection, string storeType, Grammar grammar, string[] literals, string?[] texts) + { + var same = grammar == Grammar.Array + ? $"a::{storeType} @> b::{storeType} AND a::{storeType} <@ b::{storeType}" + : $"a::{storeType} = b::{storeType}"; + var sql = $""" + SELECT CASE WHEN ok THEN {same} END, CASE WHEN ok THEN (b::{storeType})::text END + FROM (SELECT a, b, i, b IS NOT NULL AND pg_input_is_valid(b, $3) AS ok + FROM unnest($1::text[], $2::text[]) WITH ORDINALITY AS u(a, b, i)) AS s + ORDER BY i + """; + await using var command = new NpgsqlCommand(sql, connection); + command.Parameters.Add(new NpgsqlParameter { Value = literals, NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text }); + command.Parameters.Add(new NpgsqlParameter { Value = texts, NpgsqlDbType = NpgsqlDbType.Array | NpgsqlDbType.Text }); + command.Parameters.Add(new NpgsqlParameter { Value = storeType, NpgsqlDbType = NpgsqlDbType.Text }); + + var verdicts = new List<(bool?, string?)>(); + await using var reader = await command.ExecuteReaderAsync(); + while (await reader.ReadAsync()) + verdicts.Add((reader.IsDBNull(0) ? null : reader.GetBoolean(0), reader.IsDBNull(1) ? null : reader.GetString(1))); + return [.. verdicts]; + } + + // ========================================================================= + // The corpus + // ========================================================================= + + /// + /// Families docs/serialization.md says the library refuses although PostgreSQL reads them: + /// a refusal there is counted, not reported. An acceptance is still compared with the server. + /// + private static string? DocumentedRefusal(string family, Kind kind) => family switch + { + // J07, #dates-and-times-are-strict-iso-8601: "Temporal bounds and elements accept strict ISO + // 8601 only … and nothing else." PostgreSQL's datetime input is lenient: it reads other + // orders, words, zone names and 24:00, and skips punctuation it does not know. + "non-ISO temporal" or "special input" or "infinity spelling" or "date with a time" or "timestamp without time" + or "timestamptz without offset" or "zone name" or "time with an offset" or "hour 24 in a timestamp" + or "leap second" or "stray punctuation" when IsTemporal(kind) => "J07", + // J06, #parse-a-literal: "Decimal bounds follow PostgreSQL's numeric grammar: sign, digits, + // point and exponent." PostgreSQL 16 also reads 0x, 0o, 0b and digit separators. + "non-decimal numeric" or "digit separator numeric" when kind == Kind.Numeric => "J06", + _ => null + }; + + private static bool IsTemporal(Kind kind) => kind is Kind.Date or Kind.Timestamp or Kind.Timestamptz or Kind.Time; + + private sealed record ElementCorpus(string[] Ascending, (string Text, string Family)[] Elements); + + private static Kind? KindOf(Type primitive) => primitive switch + { + _ when primitive == typeof(short) => Kind.Int2, + _ when primitive == typeof(int) => Kind.Int4, + _ when primitive == typeof(long) => Kind.Int8, + _ when primitive == typeof(decimal) => Kind.Numeric, + _ when primitive == typeof(DateOnly) || primitive == typeof(LocalDate) => Kind.Date, + _ when primitive == typeof(DateTime) || primitive == typeof(LocalDateTime) => Kind.Timestamp, + _ when primitive == typeof(DateTimeOffset) || primitive == typeof(Instant) => Kind.Timestamptz, + _ when primitive == typeof(TimeOnly) || primitive == typeof(LocalTime) => Kind.Time, + _ when primitive == typeof(Guid) => Kind.Uuid, + _ when primitive == typeof(string) => Kind.Text, + // YearMonth's literal (2026-01) is not the text of the daterange it is stored as. + _ => null + }; + + private static ElementCorpus Elements(Kind kind) => kind switch + { + Kind.Int2 => Integers("32767", "-32768", "32768", "-32769"), + Kind.Int4 => Integers("2147483647", "-2147483648", "2147483648", "-2147483649"), + Kind.Int8 => Integers("9223372036854775807", "-9223372036854775808", "9223372036854775808", "-9223372036854775809"), + Kind.Numeric => new(["1.5", "2", "5.25", "9"], + [ + ("1.5", "element"), ("+1.5", "sign"), ("-1.5", "sign"), ("-0", "sign"), (".5", "point"), ("5.", "point"), + ("1e3", "exponent"), ("1E-3", "exponent"), ("1.5e+2", "exponent"), ("007.50", "leading zeros"), ("1.50", "scale"), + ("NaN", "numeric special"), ("nan", "numeric special"), ("Infinity", "numeric special"), ("-Infinity", "numeric special"), ("inf", "numeric special"), + ("1.00000000000000000000000000001", "beyond 28 digits"), ("0.00000000000000000000000000001", "beyond 28 digits"), + ("1e-30", "beyond 28 digits"), ("79228162514264337593543950335.1", "beyond 28 digits"), + ("79228162514264337593543950335", "extreme"), ("79228162514264337593543950336", "overflow"), ("1e30", "overflow"), + ("0x1F", "non-decimal numeric"), ("0o17", "non-decimal numeric"), ("0b101", "non-decimal numeric"), ("1_000.5", "digit separator numeric"), + ("1,5", "group separator"), ("1.5.5", "malformed element"), ("$1", "malformed element"), ("1 5", "malformed element"), ("e3", "malformed element") + ]), + Kind.Date => new(["2026-01-01", "2026-01-02", "2026-02-01", "2026-03-01"], + [ + ("2026-01-01", "element"), ("0001-01-01", "extreme"), ("9999-12-31", "extreme"), + ("10000-01-01", "beyond the CLR domain"), ("0001-01-01 BC", "beyond the CLR domain"), ("2026-02-30", "malformed element"), + ("infinity", "infinity"), ("-infinity", "infinity"), ("Infinity", "infinity spelling"), ("INFINITY", "infinity spelling"), + ("+infinity", "infinity spelling"), ("- infinity", "infinity spelling"), + ("epoch", "special input"), ("now", "special input"), ("today", "special input"), ("tomorrow", "special input"), + ("yesterday", "special input"), ("J2461042", "special input"), + ("2026-1-1", "non-ISO temporal"), ("20260101", "non-ISO temporal"), ("Jan 1 2026", "non-ISO temporal"), + ("01/02/2026", "non-ISO temporal"), ("2026-01-01T00:00:00", "date with a time"), ("2026-01-01 10:00", "date with a time") + ]), + Kind.Timestamp => new(["2026-01-01 10:00:00", "2026-01-01 10:00:00.000001", "2026-01-02 00:00:00", "2026-02-01 00:00:00"], + [ + ("2026-01-01 10:00:00", "element"), ("2026-01-01T10:00:00", "T separator"), ("2026-01-01 10:00", "HH:mm"), + ("2026-01-01 10:00:00.123456", "fraction"), ("2026-01-01 10:00:00.1234567", "sub-microsecond"), ("2026-01-01 10:00:00.0000015", "sub-microsecond"), + ("2026-01-01 10:00:00+02", "offset"), ("2026-01-01 10:00:00Z", "offset"), ("2026-01-01 10:00:00+05:30", "offset"), + ("0001-01-01 00:00:00", "extreme"), ("9999-12-31 23:59:59.999999", "extreme"), ("10000-01-01 00:00:00", "beyond the CLR domain"), + ("2026-01-01 24:00:00", "hour 24 in a timestamp"), ("2026-01-01 23:59:60", "leap second"), ("2026-01-01", "timestamp without time"), + ("infinity", "infinity"), ("-infinity", "infinity"), ("Infinity", "infinity spelling"), ("+infinity", "infinity spelling"), + ("epoch", "special input"), ("now", "special input"), ("today", "special input"), ("allballs", "special input"), + ("2026-1-1 10:00:00", "non-ISO temporal"), ("Jan 1 2026 10:00", "non-ISO temporal"), ("2026-01-01 10:00:00 BC", "beyond the CLR domain"), + ("2026-01-01 25:00:00", "malformed element") + ]), + Kind.Timestamptz => new(["2026-01-01 10:00:00+00", "2026-01-01 10:00:00.000001+00", "2026-01-02 00:00:00+00", "2026-02-01 00:00:00+00"], + [ + ("2026-01-01 10:00:00+00", "element"), ("2026-01-01 10:00:00+05:30", "offset"), ("2026-01-01 10:00:00Z", "offset"), + ("2026-01-01T10:00:00+00", "T separator"), ("2026-01-01 10:00:00-03", "offset"), ("2026-01-01 10:00:00+05:53:28", "seconds offset"), + ("2026-01-01 10:00:00+14:00", "offset"), ("2026-01-01 10:00:00+15:00", "offset range"), ("2026-01-01 10:00:00+15:59", "offset range"), + ("2026-01-01 10:00:00.123456+00", "fraction"), ("2026-01-01 10:00:00.1234567+00", "sub-microsecond"), + ("2026-01-01 10:00:00", "timestamptz without offset"), ("2026-01-01 10:00:00 UTC", "zone name"), ("2026-01-01 10:00:00 Europe/Berlin", "zone name"), + ("2026-01-01 24:00:00+00", "hour 24 in a timestamp"), ("0001-01-01 00:00:00+00", "extreme"), + ("infinity", "infinity"), ("-infinity", "infinity"), ("Infinity", "infinity spelling"), + ("epoch", "special input"), ("now", "special input"), ("2026-1-1 10:00:00+00", "non-ISO temporal") + ]), + Kind.Time => new(["09:00:00", "09:00:00.000001", "12:00:00", "17:30:00"], + [ + ("09:00:00", "element"), ("09:00", "HH:mm"), ("9:00", "non-ISO temporal"), ("0900", "non-ISO temporal"), ("09:00 PM", "non-ISO temporal"), + ("09:00:00.123456", "fraction"), ("09:00:00.1234567", "sub-microsecond"), ("00:00:00", "extreme"), ("23:59:59.999999", "extreme"), + ("24:00:00", "24:00"), ("24:00", "24:00"), ("24:00:00.000001", "24:00"), ("23:59:60", "leap second"), ("25:00:00", "malformed element"), + ("09:00:00+02", "time with an offset"), ("T09:00:00", "non-ISO temporal"), ("allballs", "special input"), ("now", "special input"), + ("infinity", "infinity spelling") + ]), + Kind.Uuid => new(["00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002", + "6f9619ff-8b86-d011-b42d-00c04fc964ff", "ffffffff-ffff-ffff-ffff-ffffffffffff"], + [ + ("6f9619ff-8b86-d011-b42d-00c04fc964ff", "element"), ("6F9619FF-8B86-D011-B42D-00C04FC964FF", "upper case"), + ("6f9619ff8b86d011b42d00c04fc964ff", "no hyphens"), ("6f96-19ff-8b86-d011-b42d-00c0-4fc9-64ff", "hyphen groups"), + ("{6f9619ff-8b86-d011-b42d-00c04fc964ff}", "braces"), ("(6f9619ff-8b86-d011-b42d-00c04fc964ff)", "parentheses"), + ("6f9619ff-8b86-d011-b42d-00c04fc964f", "malformed element"), ("6f9619ff-8b86-d011-b42d-00c04fc964ffa", "malformed element"), + ("0x6f9619ff8b86d011b42d00c04fc964ff", "malformed element"), ("urn:uuid:6f9619ff-8b86-d011-b42d-00c04fc964ff", "malformed element") + ]), + Kind.Text => new(["a", "b", "c", "d"], + [ + ("a", "element"), ("NULL", "null element"), ("null", "null element"), ("ab c", "inner whitespace"), ("é", "non-ASCII"), + ("\u00A0a", "unicode whitespace"), ("a\u2003", "unicode whitespace"), ("a\u0000b", "NUL"), ("\"a\"\"b\"", "nested quotes"), + ("a\\,b", "backslash escape"), ("\\\"a", "backslash escape"), ("a\\\\b", "backslash escape"), ("\\N", "backslash escape") + ]), + _ => throw new ArgumentOutOfRangeException(nameof(kind)) + }; + + private static ElementCorpus Integers(string max, string min, string aboveMax, string belowMin) => new(["1", "2", "5", "9"], + [ + ("1", "element"), ("+1", "sign"), ("-1", "sign"), ("-0", "sign"), ("007", "leading zeros"), ("1e3", "exponent"), ("1.0", "fraction"), + ("1.5", "fraction"), (max, "extreme"), (min, "extreme"), (aboveMax, "overflow"), (belowMin, "overflow"), + ("0x1F", "non-decimal integer"), ("0o17", "non-decimal integer"), ("0b101", "non-decimal integer"), ("1_000", "digit separator integer"), + ("\uFF11", "unicode digit"), ("\u0663", "unicode digit"), ("1 2", "malformed element"), ("- 1", "malformed element"), + ("NaN", "numeric special"), ("infinity", "numeric special") + ]); + + /// The literals for one element kind and grammar, as (family, literal, element) — element set for the element families. + private static IEnumerable<(string Family, string Literal, string? Element)> Corpus(Kind kind, Grammar grammar) + { + // Bare: range_in and array_in keep whitespace inside a bound or element, so a timestamp needs no + // quotes; the quoted families spell them. + var elements = Elements(kind); + var (a, b, c, d) = (elements.Ascending[0], elements.Ascending[1], elements.Ascending[2], elements.Ascending[3]); + + IEnumerable<(string, string)> structural = grammar switch + { + Grammar.Range => RangeFamilies(a, b, c, d), + Grammar.Multirange => MultirangeFamilies(a, b, c, d), + _ => ArrayFamilies(a, b, c, d, kind) + }; + + foreach (var (family, literal) in structural) + yield return (family, literal, null); + + // An infinite temporal bound next to a finite one: the form daterange(x, 'infinity') prints. + if (IsTemporal(kind) && kind != Kind.Time && grammar != Grammar.Array) + { + foreach (var range in new[] { $"[{a},infinity)", $"[{a},infinity]", $"(-infinity,{a}]", $"[-infinity,{a})", "[-infinity,infinity)" }) + yield return ("infinity bound", grammar == Grammar.Range ? range : $"{{{range}}}", null); + } + + foreach (var (text, family) in elements.Elements) + { + switch (grammar) + { + case Grammar.Range: + yield return (family, $"[{Quote(text)},)", text); + yield return (family, $"(,{Quote(text)}]", text); + break; + case Grammar.Multirange: + yield return (family, $"{{[{Quote(text)},)}}", text); + break; + default: + yield return (family, $"{{{ArrayElement(text)}}}", text); + yield return (family, $"{{\"{text.Replace("\\", "\\\\").Replace("\"", "\\\"")}\"}}", null); + break; + } + } + } + + // A bound with a comma or a bracket is quoted, as PostgreSQL prints it; one that holds a quote or a + // backslash is the element's own test of them and stays as written. + private static string Quote(string text) => + text.IndexOfAny([',', '(', ')', '[', ']', '{', '}']) >= 0 && !text.Contains('"') && !text.Contains('\\') + ? $"\"{text}\"" + : text; + + // An array element is written bare unless array_in would split it; the families that test + // quoting and escapes spell it themselves. + private static string ArrayElement(string text) => + text.IndexOfAny(['{', '}', ',']) >= 0 && !text.Contains('"') && !text.Contains('\\') ? $"\"{text}\"" : text; + + private static IEnumerable<(string, string)> RangeFamilies(string a, string b, string c, string d) + { + foreach (var literal in new[] { $"[{a},{b})", $"({a},{b}]", $"[{a},{b}]", $"({a},{b})", $"[{a},{d})", $"(,{b})", $"[,{b})", $"({a},)", $"({a},]", "(,)", "[,]" }) + yield return ("bounds", literal); + foreach (var literal in new[] { $"[{a},{a}]", $"[{a},{a})", $"({a},{a}]", $"({a},{a})" }) + yield return ("equal bounds", literal); + foreach (var literal in new[] { $"[{b},{a})", $"({b},{a}]", $"[{b},{a}]", $"({b},{a})", $"[{d},{a})" }) + yield return ("inverted", literal); + foreach (var literal in new[] { "empty", "EMPTY", "Empty", " empty ", "\tempty\n", "\"empty\"", "empty x", "emptyx", "empt", "\u00A0empty", "(empty)" }) + yield return ("empty keyword", literal); + foreach (var literal in new[] { "", " ", "\t" }) + yield return ("blank", literal); + foreach (var literal in new[] { $"[\"{a}\",\"{b}\")", $"[\"{a}\",{b})", $"[\"\",{b})", $"[{a},\"\")", $"[{a}\"\",{b})", $"[\"\"{a},{b})", $"[\"{a},{b})" }) + yield return ("quoted bound", literal); + foreach (var literal in new[] { $"[\\{a},{b})", $"[{a},{b}\\)", $"[{a}\\,{b},{c})", $"[\"\\{a}\",{b})" }) + yield return ("backslash escape", literal); + foreach (var literal in new[] { $"[{a},{b})x", $"[{a},{b}) x", $"[{a},{b});", $"[{a},{b}))", $"x[{a},{b})", $"[{a},{b}", $"[{a} {b})", $"[{a},{b},{c})", $"{{[{a},{b})}}", $"[{a};{b})", $"[{a},{b}]]", $"{a},{b}" }) + yield return ("junk", literal); + // A bound that keeps a quote, a backslash or a bracket: no element type reads one, except + // that PostgreSQL's datetime input skips punctuation it does not know. + foreach (var literal in new[] { $"[\"{a}\"\"\",{b})", $"[\\\\{a},{b})", $"[[{a},{b})" }) + yield return ("stray punctuation", literal); + foreach (var literal in new[] { $" [{a},{b}) ", $"\t[{a},{b})\n", $"[ {a} , {b} )", $"[\" {a}\",{b})", $"[{a},\"{b} \")", $"[\\ {a},{b})", $"\u00A0[{a},{b})", $"[{a},{b})\u2003", $"[\u00A0{a},{b})", $"\u3000[{a},{b})", $"[{a},{b})\u0085" }) + yield return ("whitespace", literal); + } + + private static IEnumerable<(string, string)> MultirangeFamilies(string a, string b, string c, string d) + { + foreach (var literal in new[] { "{}", "{ }", $"{{[{a},{b})}}", $"{{[{a},{b}),[{c},{d})}}", $"{{(,{a}),[{c},)}}", "{(,)}", $"{{[{a},{b}],({c},{d}]}}" }) + yield return ("bounds", literal); + foreach (var literal in new[] { $"{{[{a},{c}),[{b},{d})}}", $"{{[{a},{b}),[{b},{c})}}", $"{{[{c},{d}),[{a},{b})}}", $"{{[{a},{b}),[{a},{b})}}", + "{empty}", $"{{empty,[{a},{b})}}", $"{{[{a},{a})}}", $"{{(,),[{a},{b})}}", $"{{[{a},{d}),[{b},{c})}}" }) + yield return ("canonicalized", literal); + foreach (var literal in new[] { $"{{[{b},{a})}}", $"{{[{b},{a}),[{c},{d})}}", $"{{[{c},{d}),({d},{c}]}}" }) + yield return ("inverted", literal); + foreach (var literal in new[] { "empty", "{EMPTY}", $"{{Empty,[{a},{b})}}", "{ empty }", "{\"empty\"}" }) + yield return ("empty keyword", literal); + foreach (var literal in new[] { "", " " }) + yield return ("blank", literal); + foreach (var literal in new[] { $"{{[{a},{b})}}x", $"{{[{a},{b})", $"[{a},{b})", $"{{[{a},{b}),}}", $"{{,[{a},{b})}}", $"{{[{a},{b}) [{c},{d})}}", + $"{{[{a},{b})}};", $"{{{{[{a},{b})}}}}", $"{{\"[{a},{b})\"}}", $"{{[{a},{b}),,[{c},{d})}}", $"x{{[{a},{b})}}" }) + yield return ("junk", literal); + foreach (var literal in new[] { $" {{[{a},{b})}} ", $"{{ [{a},{b}) , [{c},{d}) }}", $"{{[\" {a}\",{b})}}", $"\u00A0{{[{a},{b})}}", $"{{[{a},{b})}}\u2003", $"{{\u00A0[{a},{b})}}" }) + yield return ("whitespace", literal); + foreach (var literal in new[] { $"{{[\"{a}\",\"{b}\")}}", $"{{[\"\",{b})}}", $"{{[\\{a},{b})}}" }) + yield return ("quoted bound", literal); + } + + private static IEnumerable<(string, string)> ArrayFamilies(string a, string b, string c, string d, Kind kind) + { + foreach (var literal in new[] { "{}", "{ }", $"{{{a}}}", $"{{{a},{b}}}", $"{{{b},{a}}}", $"{{{a},{a}}}", $"{{{a},{b},{a}}}", $"{{{d},{c},{b},{a}}}" }) + yield return ("bounds", literal); + foreach (var literal in new[] { $"{{\"{a}\"}}", $"{{\"{a}\",\"{b}\"}}", $"{{\"{a}\" , \"{b}\"}}", "{\"\"}", $"{{\"{a}\"{b}}}", $"{{{a}\"\"}}", $"{{\"{a}\"\"{b}\"}}" }) + yield return ("quoted element", literal); + foreach (var literal in new[] { "{NULL}", $"{{{a},NULL}}", "{null}", "{\"NULL\"}", "{NuLl}" }) + yield return ("null element", literal); + foreach (var literal in new[] { $"{{{{{a}}},{{{b}}}}}", $"{{{{{a},{b}}}}}", $"[1:1][1:1]={{{{{a}}}}}" }) + yield return ("dimensions", literal); + foreach (var literal in new[] { $"[1:1]={{{a}}}", $"[0:1]={{{a},{b}}}", $"[1:2]={{{a}}}", "{{}}", "{{},{}}" }) + yield return ("dimension decoration", literal); + foreach (var literal in new[] { $"{{{a}}}x", $"{{{a}", a, $"{{{a},}}", $"{{,{a}}}", $"{{{a},,{b}}}", $"{{{a}}}}}", $"{{{{{a}}}", $"{{{a}}};", $"x{{{a}}}", $"{{{a}}}{{{b}}}" }) + yield return ("junk", literal); + foreach (var literal in new[] { $" {{{a}}} ", $"{{ {a} }}", $"{{ {a} , {b} }}", $"{{\" {a}\"}}", $"{{\"{a} \"}}", $"{{\\ {a}}}", $"\u00A0{{{a}}}", $"{{{a}}}\u2003", $"\t{{{a}}}\n", $"{{{a}\u00A0}}" }) + yield return ("whitespace", literal); + foreach (var literal in new[] { "", " ", "empty" }) + yield return ("blank", literal); + foreach (var literal in new[] { $"{{\\{a}}}", $"{{\"\\{a}\"}}", $"{{{a}\\,{b}}}" }) + yield return ("backslash escape", literal); + + // An element spelled as each of its neighbours prints it: a duplicate written two ways is one element. + if (kind == Kind.Text) yield return ("quoted element", $"{{{a},\"{a}\"}}"); + } + + private static string Show(string literal) + => string.Concat(literal.Select(ch => ch < 0x20 || ch > 0x7E ? $"\\u{(int)ch:X4}" : ch.ToString())); +} diff --git a/test/oracles/axes.md b/test/oracles/axes.md index 98fb1d6..d50c2e6 100644 --- a/test/oracles/axes.md +++ b/test/oracles/axes.md @@ -15,14 +15,16 @@ found by one such pair, as a one-off that was not kept. | `JsonSerializerOptions` | default, camelCase, snake_case, `ReferenceHandler.Preserve`, source-generated context, `NumberHandling.AllowReadingFromString`; `WriteAsString`; `AllowDuplicateProperties = false`; `DictionaryKeyPolicy`; a union converter on the options; a converter on a property | `JsonOptionsProbeTests` (the first six × declared type); `SetJsonConverterTests` (`WriteAsString`, closed vs wrapper); `JsonDictionaryKeyTests` (duplicates, key policy); `JsonConverterPrecedenceTests`, `JsonDefaultContractTests` (union converter × declared type); `SetSourceGenerationTests`, `InterfaceDeclaredJsonTests` (a context missing the element or runtime type) | | Current culture | invariant, de-DE, sv-SE, fa-IR, th-TH | `LiteralCultureContractTests` (`ToString` × type × shape); `CultureEntryPointProbeTests` (every entry point below and JSON × type × union and variant) | | Explicit provider | null, invariant, th-TH, de-DE, a custom provider | `LiteralProviderContractTests` (format and parse × type × shape) | -| Entry point | `Parse(string)`, `Parse(span)`, `TryParse`, JSON value, JSON dictionary key, `TypeConverter`, configuration binding, interpolation and `string.Format` | `TemporalLiteralStrictnessContractTests` (a non-ISO element × every entry point × temporal type); `SpanParsingTests` (string vs span); `CultureEntryPointProbeTests` (all of them, null and current-culture provider, × culture × type) | -| Literal corpus | quoted, escaped and whitespace bounds; empty quoted bound; element whitespace; inverted bounds; `infinity` spellings; `24:00:00`; more than 28 digits | `RangeLiteralGrammarContractTests`, `SetElementWhiteSpaceContractTests`, `LiteralGrammarParityTests` (the first three, against PostgreSQL); the rest are #114 and #115, found once and not swept | +| Entry point | `Parse(string)`, `Parse(span)`, `TryParse`, JSON value, JSON dictionary key, `TypeConverter`, configuration binding, interpolation and `string.Format`, EF Core JSON document read; EF Core SQL constant and JSON document write | `TemporalLiteralStrictnessContractTests` (a non-ISO element × every entry point × temporal type); `SpanParsingTests` (string vs span); `CultureEntryPointProbeTests` (all of them, null and current-culture provider, × culture × type); `LiteralCorpusProbeTests` (`Parse`, `TryParse`, JSON value, `TypeConverter`, EF Core JSON document read, EF Core SQL constant and JSON document write × the corpus, against PostgreSQL) | +| Literal corpus | quoted, escaped and whitespace bounds; empty quoted bound; element whitespace; inverted and equal bounds per bracket pair; `empty` in every case; trailing junk; nested quotes; Unicode whitespace; array dimensions and NULLs; multirange canonicalization; an infinite temporal bound next to a finite one; per element kind: sign, leading zeros, exponent, `NaN`, non-decimal and digit separators, more than 28 digits, overflow, `infinity` spellings, `epoch`/`now`/`today`, non-ISO dates, `24:00:00`, leap second, offsets and zone names, sub-microsecond digits, uuid spellings, NUL | `RangeLiteralGrammarContractTests`, `SetElementWhiteSpaceContractTests`, `LiteralGrammarParityTests` (the first three, against PostgreSQL); `LiteralCorpusProbeTests` (all of it × every type × entry point, against PostgreSQL) | | Element value | one pair per element type (`RangeProbes.Bounds`), one probe list per element type (`SetProbes`) | every conventions sweep; extremes only in `DifferentialFuzzParityTests` (EF) and `TemporalLiteralGrammarTests` | -Pairs no test varies: literal corpus × entry point against `psql -At` on the same text (#114 and #115 are -cells); a NodaTime element converter registered by `ConfigureForNodaTime` × declared type; options × -dictionary key beyond default and `DictionaryKeyPolicy`. (Current culture × JSON and entry point × culture × -type are varied by `CultureEntryPointProbeTests` and `HostCultureProbeTests` since #124.) +Pairs no test varies: literal corpus × a value set's JSON array beyond one bare element +(`LiteralCorpusProbeTests` reads `[E]` only); literal corpus × dictionary key; a NodaTime element converter +registered by `ConfigureForNodaTime` × declared type; options × dictionary key beyond default and +`DictionaryKeyPolicy`. (Current culture × JSON and entry point × culture × type are varied by +`CultureEntryPointProbeTests` and `HostCultureProbeTests` since #124; literal corpus × entry point against +PostgreSQL by `LiteralCorpusProbeTests` since #123.) ## TypeConverter, configuration and web binding (J13–J15, J31) diff --git a/test/oracles/candidates.json b/test/oracles/candidates.json index 2fff84b..a0b5496 100644 --- a/test/oracles/candidates.json +++ b/test/oracles/candidates.json @@ -171,5 +171,810 @@ "family": "minimal API binds a variant-typed parameter through the union's TryParse: 200 with null for a valid literal and for another shape", "issue": "#116", "method": "EveryHostEntryPoint_BindsTheInvariantValue_UnderEveryRequestCulture" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "GuidSet whitespace ", + "family": "Guid.Parse trims whitespace uuid_in rejects", + "issue": "#78", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "GuidSet whitespace ", + "family": "Guid.Parse trims whitespace uuid_in rejects", + "issue": "#78", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DateRange inverted ", + "family": "an inverted range literal parses as empty; PostgreSQL rejects it", + "issue": "#114", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DateTimeOffsetRange inverted ", + "family": "an inverted range literal parses as empty; PostgreSQL rejects it", + "issue": "#114", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DateTimeRange inverted ", + "family": "an inverted range literal parses as empty; PostgreSQL rejects it", + "issue": "#114", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DecimalRange inverted ", + "family": "an inverted range literal parses as empty; PostgreSQL rejects it", + "issue": "#114", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "InstantRange inverted ", + "family": "an inverted range literal parses as empty; PostgreSQL rejects it", + "issue": "#114", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int32Range inverted ", + "family": "an inverted range literal parses as empty; PostgreSQL rejects it", + "issue": "#114", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int64Range inverted ", + "family": "an inverted range literal parses as empty; PostgreSQL rejects it", + "issue": "#114", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "LocalDateRange inverted ", + "family": "an inverted range literal parses as empty; PostgreSQL rejects it", + "issue": "#114", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "LocalDateTimeRange inverted ", + "family": "an inverted range literal parses as empty; PostgreSQL rejects it", + "issue": "#114", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet inverted ", + "family": "an inverted range literal parses as empty; PostgreSQL rejects it", + "issue": "#114", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet inverted ", + "family": "an inverted range literal parses as empty; PostgreSQL rejects it", + "issue": "#114", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet inverted ", + "family": "an inverted range literal parses as empty; PostgreSQL rejects it", + "issue": "#114", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet inverted ", + "family": "an inverted range literal parses as empty; PostgreSQL rejects it", + "issue": "#114", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet inverted ", + "family": "an inverted range literal parses as empty; PostgreSQL rejects it", + "issue": "#114", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet inverted ", + "family": "an inverted range literal parses as empty; PostgreSQL rejects it", + "issue": "#114", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet inverted ", + "family": "an inverted range literal parses as empty; PostgreSQL rejects it", + "issue": "#114", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet inverted ", + "family": "an inverted range literal parses as empty; PostgreSQL rejects it", + "issue": "#114", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet inverted ", + "family": "an inverted range literal parses as empty; PostgreSQL rejects it", + "issue": "#114", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet inverted ", + "family": "an inverted range literal parses as empty; PostgreSQL rejects it", + "issue": "#114", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "TimeRange inverted ", + "family": "an inverted range literal parses as empty; PostgreSQL rejects it", + "issue": "#114", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DecimalRange beyond 28 digits ", + "family": "decimal text beyond 28 significant digits is rounded; PostgreSQL keeps it", + "issue": "#115", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DecimalSet beyond 28 digits ", + "family": "decimal text beyond 28 significant digits is rounded; PostgreSQL keeps it", + "issue": "#115", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet beyond 28 digits ", + "family": "decimal text beyond 28 significant digits is rounded; PostgreSQL keeps it", + "issue": "#115", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DateTimeOffsetRange sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DateTimeOffsetSet sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DateTimeOffsetSet sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DateTimeRange sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DateTimeSet sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DateTimeSet sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "InstantRange sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "InstantSet sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "InstantSet sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "LocalDateTimeRange sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "LocalDateTimeSet sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "LocalDateTimeSet sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "LocalTimeSet sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "LocalTimeSet sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "TimeRange sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "TimeSet sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "TimeSet sub-microsecond ", + "family": "sub-microsecond digits: kept to 100 ns and stored truncated; PostgreSQL rounds half-to-even", + "issue": "#126", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int16Set digit separator integer ", + "family": "integer input refuses 0x, 0o, 0b and digit separators that PostgreSQL 16 reads", + "issue": "#127", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int16Set non-decimal integer ", + "family": "integer input refuses 0x, 0o, 0b and digit separators that PostgreSQL 16 reads", + "issue": "#127", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int16Set digit separator integer ", + "family": "integer input refuses 0x, 0o, 0b and digit separators that PostgreSQL 16 reads", + "issue": "#127", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int16Set non-decimal integer ", + "family": "integer input refuses 0x, 0o, 0b and digit separators that PostgreSQL 16 reads", + "issue": "#127", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int32Range digit separator integer ", + "family": "integer input refuses 0x, 0o, 0b and digit separators that PostgreSQL 16 reads", + "issue": "#127", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int32Range non-decimal integer ", + "family": "integer input refuses 0x, 0o, 0b and digit separators that PostgreSQL 16 reads", + "issue": "#127", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int32Set digit separator integer ", + "family": "integer input refuses 0x, 0o, 0b and digit separators that PostgreSQL 16 reads", + "issue": "#127", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int32Set non-decimal integer ", + "family": "integer input refuses 0x, 0o, 0b and digit separators that PostgreSQL 16 reads", + "issue": "#127", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int32Set digit separator integer ", + "family": "integer input refuses 0x, 0o, 0b and digit separators that PostgreSQL 16 reads", + "issue": "#127", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int32Set non-decimal integer ", + "family": "integer input refuses 0x, 0o, 0b and digit separators that PostgreSQL 16 reads", + "issue": "#127", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int64Range digit separator integer ", + "family": "integer input refuses 0x, 0o, 0b and digit separators that PostgreSQL 16 reads", + "issue": "#127", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int64Range non-decimal integer ", + "family": "integer input refuses 0x, 0o, 0b and digit separators that PostgreSQL 16 reads", + "issue": "#127", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int64Set digit separator integer ", + "family": "integer input refuses 0x, 0o, 0b and digit separators that PostgreSQL 16 reads", + "issue": "#127", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int64Set non-decimal integer ", + "family": "integer input refuses 0x, 0o, 0b and digit separators that PostgreSQL 16 reads", + "issue": "#127", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int64Set digit separator integer ", + "family": "integer input refuses 0x, 0o, 0b and digit separators that PostgreSQL 16 reads", + "issue": "#127", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int64Set non-decimal integer ", + "family": "integer input refuses 0x, 0o, 0b and digit separators that PostgreSQL 16 reads", + "issue": "#127", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet digit separator integer ", + "family": "integer input refuses 0x, 0o, 0b and digit separators that PostgreSQL 16 reads", + "issue": "#127", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet non-decimal integer ", + "family": "integer input refuses 0x, 0o, 0b and digit separators that PostgreSQL 16 reads", + "issue": "#127", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet digit separator integer ", + "family": "integer input refuses 0x, 0o, 0b and digit separators that PostgreSQL 16 reads", + "issue": "#127", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet non-decimal integer ", + "family": "integer input refuses 0x, 0o, 0b and digit separators that PostgreSQL 16 reads", + "issue": "#127", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DateSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DateSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DateTimeOffsetSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DateTimeOffsetSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DateTimeSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DateTimeSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DecimalSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DecimalSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "GuidSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "GuidSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "InstantSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "InstantSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int16Set dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int16Set dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int32Set dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int32Set dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int64Set dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int64Set dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "LocalDateSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "LocalDateSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "LocalDateTimeSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "LocalDateTimeSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "LocalTimeSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "LocalTimeSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "StringSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "StringSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "TimeSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "TimeSet dimension decoration ", + "family": "array_in's dimension decoration and {{}} are refused", + "issue": "#128", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "GuidSet hyphen groups ", + "family": "Guid.Parse's grammar, not uuid_in's", + "issue": "#129", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "GuidSet parentheses ", + "family": "Guid.Parse's grammar, not uuid_in's", + "issue": "#129", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "GuidSet hyphen groups ", + "family": "Guid.Parse's grammar, not uuid_in's", + "issue": "#129", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "GuidSet parentheses ", + "family": "Guid.Parse's grammar, not uuid_in's", + "issue": "#129", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "LocalDateTimeRange offset ", + "family": "LocalDateTime refuses Z and an offset that timestamp input drops", + "issue": "#130", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "LocalDateTimeSet offset ", + "family": "LocalDateTime refuses Z and an offset that timestamp input drops", + "issue": "#130", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "LocalDateTimeSet offset ", + "family": "LocalDateTime refuses Z and an offset that timestamp input drops", + "issue": "#130", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet offset ", + "family": "LocalDateTime refuses Z and an offset that timestamp input drops", + "issue": "#130", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DateTimeOffsetRange offset range ", + "family": "DateTimeOffset refuses offsets beyond ±14:00 that timestamptz reads", + "issue": "#131", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DateTimeOffsetSet offset range ", + "family": "DateTimeOffset refuses offsets beyond ±14:00 that timestamptz reads", + "issue": "#131", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DateTimeOffsetSet offset range ", + "family": "DateTimeOffset refuses offsets beyond ±14:00 that timestamptz reads", + "issue": "#131", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet offset range ", + "family": "DateTimeOffset refuses offsets beyond ±14:00 that timestamptz reads", + "issue": "#131", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int32Range extreme (,2147483647]:", + "family": "(,max] needs max + 1 to canonicalize; PostgreSQL raises 22003", + "issue": "#132", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "Int64Range extreme (,9223372036854775807]:", + "family": "(,max] needs max + 1 to canonicalize; PostgreSQL raises 22003", + "issue": "#132", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "StringSet NUL ", + "family": "a NUL character PostgreSQL text cannot hold", + "issue": "#133", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "StringSet NUL ", + "family": "a NUL character PostgreSQL text cannot hold", + "issue": "#133", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "DateRange infinity bound ", + "family": "a discrete bound exclusive at ±infinity is canonicalized one step inside the domain", + "issue": "#117", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "LocalDateRange infinity bound ", + "family": "a discrete bound exclusive at ±infinity is canonicalized one step inside the domain", + "issue": "#117", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet infinity bound ", + "family": "a discrete bound exclusive at ±infinity is canonicalized one step inside the domain", + "issue": "#117", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" + }, + { + "oracle": "LiteralCorpusProbeTests", + "row": "RangeSet infinity bound ", + "family": "a discrete bound exclusive at ±infinity is canonicalized one step inside the domain", + "issue": "#117", + "method": "EveryEntryPoint_ReadsTheLiteralAsPostgresReadsIt" } ] diff --git a/test/oracles/claims.json b/test/oracles/claims.json index e5fd849..defb0f4 100644 --- a/test/oracles/claims.json +++ b/test/oracles/claims.json @@ -801,7 +801,8 @@ "RangeParseFormatTests", "SetParseFormatTests", "LiteralGrammarParityTests", - "WrapperGrammarContractTests" + "WrapperGrammarContractTests", + "LiteralCorpusProbeTests" ] }, { @@ -893,7 +894,8 @@ "*Set.Parse" ], "heldBy": [ - "SpanParsingTests" + "SpanParsingTests", + "LiteralCorpusProbeTests" ] }, { @@ -909,7 +911,8 @@ "heldBy": [ "RangeLiteralGrammarContractTests", "LiteralGrammarParityTests", - "RangeSetOptimizationTests" + "RangeSetOptimizationTests", + "LiteralCorpusProbeTests" ] }, { @@ -1279,7 +1282,25 @@ ], "heldBy": [ "RangeLiteralGrammarContractTests", - "LiteralGrammarParityTests" + "LiteralGrammarParityTests", + "LiteralCorpusProbeTests" + ] + }, + { + "id": "J34-literal-is-the-servers-text", + "source": "docs/serialization.md#literals-parsing-and-json", + "quote": "The text is the PostgreSQL literal, the same text the server itself reads and prints.", + "claim": "For every range, multirange and value set type and every text of the literal corpus, Parse(string), Parse(span), TryParse(string), TryParse(span), the JSON converter, the TypeConverter and the EF Core JSON document reader all refuse it where PostgreSQL's input function refuses it, and all read the value PostgreSQL reads where it reads one, and EF Core writes that value back (SQL constant, JSON document) as text PostgreSQL reads as the same value — except a refusal docs/serialization.md names (J06, J07), a value outside the CLR type's domain, and an extreme the store spells ±infinity.", + "oracle": "differential", + "surfaces": [ + "*Range.Parse", + "*Range.TryParse", + "RangeSet.Parse", + "*Set.Parse", + "*Set.ParseValue" + ], + "heldBy": [ + "LiteralCorpusProbeTests" ] }, { @@ -2189,7 +2210,8 @@ ], "heldBy": [ "SetParseFormatTests", - "LiteralGrammarParityTests" + "LiteralGrammarParityTests", + "LiteralCorpusProbeTests" ] }, { diff --git a/test/oracles/oracles.json b/test/oracles/oracles.json index a23beff..9ac288f 100644 --- a/test/oracles/oracles.json +++ b/test/oracles/oracles.json @@ -220,5 +220,14 @@ "project": "CodoMetis.ValueRanges.AspNetCore.Tests", "kind": "differential", "against": "the invariant value, for every discovered range, variant, multirange and set type × query, nullable query, route value, form field and JSON body through MVC and a minimal API × four request cultures set by RequestLocalization: 200 with the value or 400, never 200 with null (test/oracles/axes.md)" + }, + { + "class": "LiteralCorpusProbeTests", + "project": "CodoMetis.ValueRanges.EFCore.PostgreSQL.IntegrationTests", + "kind": "differential", + "against": "PostgreSQL's own text input on the same text (pg_input_is_valid and the canonical text), for every range, multirange and value set type × the literal corpus × seven entry points and the EF Core write-back (test/oracles/axes.md)", + "needs": [ + "docker" + ] } ] diff --git a/test/oracles/seeds/empty-keyword-matched-as-prefix.patch b/test/oracles/seeds/empty-keyword-matched-as-prefix.patch new file mode 100644 index 0000000..8342199 --- /dev/null +++ b/test/oracles/seeds/empty-keyword-matched-as-prefix.patch @@ -0,0 +1,17 @@ +Seed: the empty keyword is matched as a prefix, so "emptyx" and "empty x" read as the empty range where PostgreSQL's range_in refuses them +Oracles: LiteralCorpusProbeTests +Fails: the empty keyword family of every range type against PostgreSQL on the same text + +diff --git a/src/CodoMetis.ValueRanges/Internals/RangeFormat.cs b/src/CodoMetis.ValueRanges/Internals/RangeFormat.cs +index f463698..4925a99 100644 +--- a/src/CodoMetis.ValueRanges/Internals/RangeFormat.cs ++++ b/src/CodoMetis.ValueRanges/Internals/RangeFormat.cs +@@ -14,7 +14,7 @@ internal static class RangeFormat + { + s = LiteralWhiteSpace.Trim(s); + +- if (s.Equals("empty", StringComparison.OrdinalIgnoreCase)) ++ if (s.StartsWith("empty", StringComparison.OrdinalIgnoreCase)) + return TRange.Empty; + + if (s.Length < 3) diff --git a/test/oracles/seeds/quoted-bound-whitespace-kept.patch b/test/oracles/seeds/quoted-bound-whitespace-kept.patch index 384cf8c..5737639 100644 --- a/test/oracles/seeds/quoted-bound-whitespace-kept.patch +++ b/test/oracles/seeds/quoted-bound-whitespace-kept.patch @@ -1,5 +1,5 @@ Seed: whitespace inside a quoted or escaped range bound is kept, so [" 2026-01-01",2026-02-01) throws where PostgreSQL's range_in reads [2026-01-01,2026-02-01) -Oracles: RangeLiteralGrammarContractTests, LiteralGrammarParityTests +Oracles: RangeLiteralGrammarContractTests, LiteralGrammarParityTests, LiteralCorpusProbeTests Fails: the quoted and escaped bound-whitespace rows of the range_in grammar sweep for every temporal range type; the bound-whitespace parity sweep against PostgreSQL History: shipped before 9.1.0 (#45) diff --git a/test/oracles/seeds/temporal-set-element-whitespace-kept.patch b/test/oracles/seeds/temporal-set-element-whitespace-kept.patch index debe477..08541d9 100644 --- a/test/oracles/seeds/temporal-set-element-whitespace-kept.patch +++ b/test/oracles/seeds/temporal-set-element-whitespace-kept.patch @@ -1,5 +1,5 @@ Seed: a temporal value set keeps whitespace at either end of a quoted or escaped element, so {" 2026-01-01"} throws where PostgreSQL's date_in reads {2026-01-01} -Oracles: SetElementWhiteSpaceContractTests, LiteralGrammarParityTests +Oracles: SetElementWhiteSpaceContractTests, LiteralGrammarParityTests, LiteralCorpusProbeTests Fails: the strips rows of every temporal set family and arity, Parse, TryParse, TypeConverter and JSON; the element-whitespace parity sweep against PostgreSQL for date[], timestamp[], timestamptz[] and time[] History: shipped before 9.1.0 (#70)