Skip to content

Read INT128-backed NUMERIC/DECIMAL values without overflowing or rounding - #1305

Open
fdcastel wants to merge 1 commit into
FirebirdSQL:masterfrom
fdcastel:fix-numeric38-read
Open

fdcastel wants to merge 1 commit into
FirebirdSQL:masterfrom
fdcastel:fix-numeric38-read

Conversation

@fdcastel

@fdcastel fdcastel commented Nov 7, 2026

Copy link
Copy Markdown
Member

Fixes #1301.

Summary

INT128-backed NUMERIC/DECIMAL values are no longer converted to System.Decimal while the row is decoded. They are converted exactly instead, so a row can always be read, the connection stays usable, and nothing is rounded silently.

Changes

  • TypeDecoder.DecodeInt128Numeric / BigDecimal.Create return a decimal when the value fits exactly, using the smallest scale. Otherwise they return an internal BigDecimal (an unscaled BigInteger plus a scale).
  • Both clients use it: the managed reader (GdsStatement.ReadRawValue[Async]) and the native client (DbField.SetValue). The native client now also decodes NUMERIC(38, 0)/DECIMAL(38, 0) as numeric, consistent with GetFieldType() and with the managed client.
  • For a value that does not fit decimal:
    • GetValue, GetDecimal and GetFieldValue<decimal> throw OverflowException, for that column only.
    • GetString returns the exact value, in the current culture like decimal.
    • GetDouble and GetFloat return the nearest value.
    • GetFieldValue<BigInteger> returns integral values exactly. This also works for values that fit decimal; fractional values throw OverflowException.
  • DbValue.GetDecodedValue[Async]: the native client copies decoded values into the row without the conversion that GetValue performs.

Behavior changes (breaking)

  1. Values with more than 28 fractional digits now throw instead of being rounded. For example, cast('1E-30' as numeric(38, 30)): master returned 0, now GetValue/GetDecimal throw OverflowException and GetString returns 0.000000000000000000000000000001.
  2. Native client, NUMERIC(38, 0)/DECIMAL(38, 0). Master's GetValue returned BigInteger, now it returns decimal (as GetFieldType() always reported, and as the managed client always did). For values beyond decimal, GetValue now throws OverflowException; GetFieldValue<BigInteger> still returns them exactly.
  3. Side effect: an integral decimal parameter value can now be passed to an INT128 parameter, because the same DbValue.GetInt128 serves both. It threw InvalidCastException before.

All other values decode exactly as before. I compared master's decoding with this one on 2,000,000 random values (1 to 38 digits, scale 0 to 38, seeded):

Outcome Count
both decimal, identical value and scale 1,136,464
both decimal, different 0
master threw OverflowException; now exact 479,201
master returned a silently rounded decimal; now exact (change 1) 384,335
master returned the exact value, but now BigDecimal 0

The same comparison measured decoding time (Stopwatch, boxed like the row decoder; one machine):

Value Master This PR
22-digit NUMERIC(38,6) 164 ns 22 ns
1234.5 66 ns 50 ns
123 as NUMERIC(38,0) 2.5 ns 5.0 ns

Tests

  • BigDecimalTests (NoServer):
    • exact/inexact boundaries: decimal.MaxValue ± 1, scale 28/29, trailing zeros,
    • formatting, including a non-invariant culture,
    • rounding and integral conversion.
  • FbLongNumericsSupportTests:
    • values that fit decimal, including NUMERIC(38, 30) value 1 and NUMERIC(38, 0),
    • values that do not fit: the row is read, other columns are readable, GetString is exact, GetDouble is nearest, GetValue/GetDecimal throw, and the connection is still usable afterwards,
    • GetFieldValue<BigInteger>.
  • Without the fix, 14 tests fail on the embedded client. With it, the embedded + NoServer suite passes: 648 passed, 0 failed.

…ding

NUMERIC/DECIMAL with precision 19-38 were converted to System.Decimal while
the row was decoded:

- Values beyond System.Decimal (e.g. 38 digits) threw OverflowException in
  the middle of decoding the row. On the managed (wire) client this left the
  rest of the packet unread and desynchronized the connection: every later
  command on it failed with "ArgumentOutOfRangeException: operation=...".
- Values with large scale but small magnitude overflowed too, e.g.
  1.000...0 as NUMERIC(38,30).
- Values with more than 28 fractional digits were rounded silently, e.g.
  1E-30 as NUMERIC(38,30) was read as 0.
- The native client returned BigInteger for NUMERIC(38,0)/DECIMAL(38,0)
  although GetFieldType() reports System.Decimal (managed returned decimal).

Now the value is converted exactly: values that fit System.Decimal are
returned exactly as before (smallest scale, e.g. 1234.5), on both clients.
Values that do not fit are kept exactly (internal BigDecimal), so the row
is always read and the connection stays usable; for those values:

- GetValue/GetDecimal/GetFieldValue<decimal> throw OverflowException
  (only when that column is accessed),
- GetString returns the exact value,
- GetDouble/GetFloat return the nearest value,
- GetFieldValue<BigInteger> returns integral values exactly (also for
  values that fit decimal; fractional values throw OverflowException).

As GetFieldValue<BigInteger> and INT128 parameters share DbValue.GetInt128,
an integral decimal parameter value can now be passed to an INT128 parameter
(it threw InvalidCastException before).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

NUMERIC(19..38) values beyond System.Decimal break the row (and the connection), small values are rounded silently

1 participant