Skip to content

Add an opt-in exact-money feature that decodes MONEY and SMALLMONEY exactly - #461

Open
fmcmac wants to merge 1 commit into
tiberius-rs:mainfrom
fmcmac:exact-money
Open

fmcmac wants to merge 1 commit into
tiberius-rs:mainfrom
fmcmac:exact-money

Conversation

@fmcmac

@fmcmac fmcmac commented Oct 17, 2026

Copy link
Copy Markdown

MONEY and SMALLMONEY travel on the wire as a scaled integer: value × 10,000. MONEY uses an i64, SMALLMONEY an i32. Tiberius decodes them with ((high << 32) as f64 + low) / 1e4. An f64 holds integers exactly only up to 2^53. MONEY's scaled range reaches about 9.2 × 10^18.

So digits disappear before the value reaches the caller:

SELECT CAST(900719925474099.0001 AS MONEY), CAST(900719925474099.0002 AS MONEY);

Both columns read back as 900719925474099.0. Both MONEY extremes round too: 922337203685477.5807 reads as 922337203685477.6.

Change

This PR adds a default-off Cargo feature, exact-money. It adds no dependency and no new value type.

With the feature on:

  • Values decode as ColumnData::Numeric(Some(Numeric::new_with_scale(scaled, 4))).
  • The 2 words combine as (high << 32) | low in an i64, so every value is exact.
  • Every money null decodes as ColumnData::Numeric(None). That covers the MONEYN length-0 value and every money metadata null placeholder.

With the feature off, decoding is unchanged from current main:

  • Values decode as ColumnData::F64, through the same arithmetic.
  • A MONEYN null, and the nullable placeholder, stay F64(None).
  • The fixed-length SMALLMONEY placeholder stays F32(None).

The feature gates the decoder and the placeholders together. A build never mixes F64 values with Numeric(None) nulls.

The feature also keeps the width of nullable money metadata. A nullable SMALLMONEY arrives as VarLenSized(Money, 4). With the feature on, Column::column_type() reports it as ColumnType::Money4, like its NOT NULL FixedLen(Money4) form. Length 8 stays ColumnType::Money. This follows how Intn and Floatn already map their widths. Callers that derive a decimal precision from the column type can then give SMALLMONEY precision 10 whatever its nullability. Without the feature, nullable SMALLMONEY still reports ColumnType::Money, as it does today.

TypeInfo::VarLenSizedPrecision { ty: Money, .. } still fails with Error::Protocol. MONEY type info carries no precision or scale, so that form is never valid. The existing test decode_varlen_sized_precision_unsupported_errors keeps pinning it.

Contract with exact-money on

  • row.get::<f64, _>() on a money or smallmoney column panics, populated or NULL.
  • row.try_get::<f64, _>() returns Error::Conversion.
  • Read these columns as Numeric.
  • Read them as rust_decimal::Decimal with the rust_decimal feature.
  • Read them as BigDecimal with the bigdecimal feature.
  • Encoding is unchanged. Bulk loads and parameters still accept f64 or Numeric for money columns.
  • Nullable SMALLMONEY reports ColumnType::Money4.

Tests

Run both feature states: cargo test, then cargo test --features exact-money.

  • Unit tests cover both types at both extremes, adjacent values past 2^53, negatives, the word boundary and NULL.
  • They cover both wire forms through ColumnData::decode: FixedLen(Money/Money4) and VarLenSized(Money, 8/4).
  • row::tests pins the accessor contract per state, through the real decoder and metadata placeholders.
  • row::tests pins ColumnType for VarLenSized(Money) at lengths 8 and 4 in each state.
  • The live test money_smallmoney_column_types pins the column type of all 4 money columns per state, on a populated and a zero-row result.
  • The live test money_accessor_contract pins get and try_get on NOT NULL and nullable columns, populated and NULL.
  • With the feature on, the live test money_smallmoney_decode_exactly round-trips the large values and extremes through SQL Server.
  • Without the feature, the existing money assertions run unchanged.

Why opt-in, and why f64 does not accept Numeric

Opt-in keeps this a non-breaking change. Default builds see no change.

An alternative is to make f64: FromSql accept Numeric with a lossy conversion. That would not recreate the decoder loss: the row would still hold the exact Numeric. But FromSql sees only the ColumnData, not the column type. So f64 would also start accepting every numeric and decimal column. That broadens decimal conversion for all callers, so I left it out.

A later breaking release could make exact-money the default. That is the maintainers' call.

MONEY and SMALLMONEY travel as a scaled integer (value * 10^4) in an
i64 or i32. The decoder divides it in f64, which holds integers exactly
only up to 2^53, so large values lose digits:
900719925474099.0001 reads back as 900719925474099.0, and both MONEY
extremes round.

The new default-off `exact-money` feature decodes both types as a
scale-4 Numeric, combining the words as (high << 32) | low in an i64.
The feature switches the decoded value and every money null placeholder
together, so a build never mixes F64 values with Numeric(None) nulls.

With `exact-money`, a nullable SMALLMONEY (VarLenSized(Money) with
length 4) reports ColumnType::Money4, matching its NOT NULL form, the
way Intn and Floatn already map their widths. Length 8 stays Money.

With `exact-money`, row.get::<f64, _>() on a money column panics and
row.try_get::<f64, _>() returns Error::Conversion, populated or NULL.
Read these columns as Numeric, or as Decimal or BigDecimal with the
`rust_decimal` or `bigdecimal` features. Encoding is unchanged.
VarLenSizedPrecision(Money) stays a protocol error.

Default builds are unchanged: values are F64, a MONEYN null and its
placeholder are F64(None), the fixed-length SMALLMONEY placeholder is
F32(None), and nullable SMALLMONEY reports ColumnType::Money.

Unit and live tests pin the decoder, the placeholders, the column types
and the get/try_get contract in both feature states.

This branch has not been deployed

No deployments
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.

1 participant