Skip to content

Decimal text beyond 28 significant digits is rounded silently when a literal or JSON array is read #115

Closed

Description

@CaffeinatedCoder

Decimal text with more than 28 significant digits is rounded to decimal precision when a literal, a set literal or a JSON array is read, so the value in memory is a different number from the text, and from what PostgreSQL holds for the same text. Measured on main (7f4dc47) against PostgreSQL 17.11:

var r = DecimalRange.Parse("[1.00000000000000000000000000001,2)", null);
r.ToString();           // [1.0000000000000000000000000000,2)
r.Contains(1m);         // true

DecimalSet.Parse("{1.00000000000000000000000000001,1}", null).Count;          // 1
JsonSerializer.Deserialize<DecimalSet>("[1.00000000000000000000000000001,1]"); // {1.0000000000000000000000000000}
SELECT '[1.00000000000000000000000000001,2)'::numrange @> 1::numeric;     -- f
SELECT cardinality('{1.00000000000000000000000000001,1}'::numeric[]);     -- 2

A magnitude that doesn't fit (1e30) throws OverflowException, which is right; only excess precision is folded in silently. That is decimal.Parse's own behaviour (DecimalRange.ParseValue, DecimalSet.ParseValue, ValueSetElementJsonConverter's decimal leg) and the library adds no round-trip check.

Authority. The column path is loud: Npgsql throws OverflowException: Numeric value does not fit in a System.Decimal reading the same numrange, numeric or numeric[] value, which is what docs/architecture.md promises for a value that can't be materialized. The text paths — a JSON document, an API payload, a configuration literal — are the only ones that round.

Fix (10.0.0): refuse a decimal element whose text doesn't round-trip (format the parsed value and compare, or count significant digits) with a FormatException, on Parse/TryParse, the set literal and the JSON element read. Rejects input that parses today → 10.0.0 under the release rule.

Tests: one row per entry point in TemporalLiteralStrictnessContractTests' sibling for numerics, or a new DecimalPrecisionContractTests; a seed is the check removed.

Found in the 10.0.0 bug hunt (code-analyst reading, reproduced here). Severity low: 29-significant-digit inputs are rare, and the fix only turns a rounding into an error.

Activity

  1. added this to the 10.0.0 milestone on Oct 17, 2026
  2. added
    bugSomething isn't working
    silentAnswers wrongly without throwing — fixes go in the next major, never deferred
    breakingChanges an answer or a signature — needs a major
    area/jsonSystem.Text.Json and TypeConverter wire forms
    area/literalsParse, TryParse and formatting
    package/coreAffects CodoMetis.ValueRanges
    severity/lowCosmetic, message or documentation-level difference
    on Oct 17, 2026
  3. CaffeinatedCoder commented on Oct 17, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Oracle check (main at 3ecfa10): no oracle on main catches this (pending). Verified by a run: every class in oracles.json passes on 3ecfa10 with the defect present, and no candidate is registered for it.
    Gap: in axes.md (Literals and JSON), the Literal corpus axis has no more than 28 significant digits value crossed with Entry point (DecimalRange.Parse/TryParse, the DecimalSet literal, the JSON array) against numeric_in. Claim J06 (decimal grammar) covers 1e3, 1,5 and $1.5, but not precision. Route B step 3 is the sweep expected to cover it.

  4. CaffeinatedCoder commented on Oct 17, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Would be caught today by LiteralCorpusProbeTests (#135). This is retroactive: the 10.0.0 bug hunt found it first. Row: DecimalRange beyond 28 digits [1.00000000000000000000000000001,). The parse legs agree, but the EF write-back does not: the SQL constant and the JSON document both carry [1.0000000000000000000000000000,), while PostgreSQL reads [1.00000000000000000000000000001,) from the same text. Also caught on RangeSet<DecimalRange> and DecimalSet. Verified against postgres:17 on main + #135, where the defect is still present. The proof is the candidates.json rows {type} beyond 28 digits → #115.

  5. CaffeinatedCoder commented on Oct 18, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Fixed in #166 (stacked on #165): DecimalLiteral reads the text's digits and exponent exactly and compares them with the parsed decimal's mantissa and scale, so only text naming a number decimal cannot hold is refused (FormatException), every other spelling still reads with its scale, and a magnitude beyond decimal still overflows. The range side is fixed in the same PR: DecimalRange.ParseValue had the same decimal.Parse call, so the shared helper is the single place; RangeFormat, IsoTemporal and NodaPatterns are untouched. The primitive DecimalSet now reads its JSON elements through ParseValue whenever System.Text.Json's own decimal converter would have, from the token's text. The three candidate rows (DecimalRange, RangeSet<DecimalRange>, DecimalSet) are removed and the corpus probe counts the family as a domain refusal. Proven by reverting the fix.

  6. CaffeinatedCoder commented on Oct 18, 2026

    @CaffeinatedCoder
    OwnerAuthor

    Range side re-checked for the 10.0.0 literal group: #166 covered it. DecimalRange.ParseValue → DecimalLiteral.Parse is the only decimal parse on a range path in src/, every range entry point reaches it, and LiteralCorpusProbeTests reports no decimal rows. Noted in #177.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/jsonSystem.Text.Json and TypeConverter wire formsarea/literalsParse, TryParse and formattingarea/rangesRange unions, variants and the shape enginesbreakingChanges an answer or a signature — needs a majorbugSomething isn't workingfound-by/oracleFound by the generated-oracle machinery (claims × API surfaces)package/coreAffects CodoMetis.ValueRangesseverity/lowCosmetic, message or documentation-level differencesilentAnswers wrongly without throwing — fixes go in the next major, never deferred

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions