Skip to content

Oversized decimal in SQLGetData reports SQLSTATE 07006 instead of 22003 #281

Description

Describe the bug

An oversized decimal/numeric value converted through SQLGetData into a numeric C type reports SQLSTATE 07006 ("restricted data type attribute violation") instead of 22003 ("numeric value out of range").

07006 means the requested C type is not a legal target for this SQL type. But decimalSQL_C_SLONG is a legal pairing — it is this particular value that will not fit. An application branching on SQLSTATE to distinguish "I asked for the wrong type" from "this row had a value too big for the buffer I gave you" gets the wrong answer, and the first is not retryable per-row while the second is.

The root cause is that numeric_source in mssql-odbc/src/api/fetch_convert.rs returns Option, so two distinct conditions collapse into the same None:

ColumnValues::Decimal(d) | ColumnValues::Numeric(d) => {
    let m = i128::try_from(d.magnitude()?).ok()?;   // <- both failures return None
    ...
}

and the sole caller maps every None to 07006:

numeric_source(value).ok_or(ConvError::Restricted)   // fetch_convert.rs:255

None legitimately means 07006 for a binary or GUID column, which has no numeric interpretation at all. For a decimal column it means the value overflowed. Swapping the error at the Decimal arm alone is not enough — numeric_source needs to distinguish the two, e.g. by returning Result<NumericSource, ConvError> or by having the decimal arm signal out-of-range separately.

The same function already returns 22003 for the analogous character-source case (fetch_convert.rs:250), so fixing this also makes the function self-consistent.

This is reachable from the wire, not only through FFI. Two failure modes reach the ?:

  1. d.magnitude() returns None for more than four significant words. After Guard decimal magnitude reassembly against 128-bit shift overflow #237 the wire decoder caps the count at 4, so this one is FFI-only.
  2. i128::try_from fails for a four-word magnitude above i128::MAX. The wire decoder validates the word count, not that the magnitude fits the column's declared precision, so a 17-byte payload — a legal length — carrying a magnitude in (i128::MAX, u128::MAX] is accepted by the decoder and fails here. A valid decimal(38, s) never exceeds 10^38 - 1 ≈ 2^126.2, so this is a malformed or hostile payload, but it is one a server can send.

Case 2 also means the string and typed paths disagree about the same value: SQL_C_CHAR renders it, SQL_C_SLONG reports the conversion as illegal.

Steps to reproduce

Verified against mssql-odbc at 39532382 with a temporary unit test (since case 2 needs a malformed payload a real server will not normally send):

use mssql_tds::datatypes::decoder::DecimalParts;

// A 17-byte wire payload of all 0xFF: 4 words, legal length, magnitude u128::MAX.
let d = DecimalParts {
    is_positive: true,
    scale: 0,
    precision: 38,
    int_parts: vec![-1, -1, -1, -1],
};

let mut out: i64 = 0;
let mut ind: SqlLen = 0;
let err = unsafe {
    convert_integer_c(
        &ColumnValues::Decimal(d),
        SQL_C_SBIGINT,
        (&mut out as *mut i64).cast(),
        &mut ind,
    )
};

Observed output:

magnitude       = Some(340282366920938463463374607431768211455)
i128::try_from  = false
Display         = 340282366920938463463374607431768211455
convert result  = Err(Restricted)      // -> SQLSTATE 07006

Equivalently over a live connection: have a server send a decimal(38, 0) column whose 16-byte magnitude exceeds i128::MAX, then SQLGetData that column into SQL_C_SBIGINT.

Expected behavior

SQLGetData returns SQL_ERROR with SQLSTATE 22003, "Numeric value out of range" — the conversion is legal, the value does not fit.

07006 should remain reserved for source/target pairings that are genuinely illegal, such as a binary or GUID column into a numeric C type.

Actual behavior

SQLGetData returns SQL_ERROR with SQLSTATE 07006, "Restricted data type attribute violation", implying decimal cannot be fetched as an integer at all.

Internally: ConvError::Restricted rather than ConvError::OutOfRange.

Version

mssql-odbc 0.1.0, commit 39532382 (branch for #237)

Affected crate

mssql-odbc

Note: mssql-odbc is not in the "Affected crate" dropdown in .github/ISSUE_TEMPLATE/bug_report.yml, which still lists only mssql-tds, mssql-js, mssql-tds-cli, mssql-mock-tds, and mssql-py-core. Worth adding separately.

Additional context

Came out of review discussion on #237 (comment). The 07006 behavior arrived with #217; #237 made numeric_source the single shared entry point for both the string and typed paths, so the fix is now in one place rather than two.

Notes for whoever picks this up:

  • Not a drive-by change. A returned SQLSTATE is observable behavior an application can branch on, which is why it was deliberately kept out of Guard decimal magnitude reassembly against 128-bit shift overflow #237. It deserves its own PR.
  • One unit test pins the current behavior: decimal_limbs_are_reassembled_and_bounded in fetch_convert.rs asserts ConvError::Restricted for vec![1, 0, 0, 0, 1]. It will need updating, and it is the right place to add a case for the wire-reachable i128::MAX boundary above, which nothing currently covers.
  • I grepped for 07006 across mssql-odbc: no ODBC e2e test pins it for the decimal path, so the blast radius is that one unit test. bind_param.rs's unsupported_conversion_returns_07006 is a different code path and unaffected.
  • Worth checking msodbcsql parity while in here — the pipeline already runs a parity comparison against the reference driver, so it can confirm which SQLSTATE msodbcsql returns for the same input rather than reasoning from the spec alone.
  • Consider whether the decoder should also range-check a decimal magnitude against its declared precision, which would close case 2 at the wire instead of at the conversion. That is arguably a separate mssql-tds issue, and is related in spirit to mssql-tds: SQL_VARIANT narrows a u32 data length to u8, desynchronizing the token stream #280.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions