Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions hyperdb-api-core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Fixed

- `Numeric`'s `Display` implementation no longer drops the sign of negative
values with magnitude less than 1 (the open interval `(-1, 0)`). Values such
as `-0.5` previously rendered as `0.5000` because the sign was derived from
the integer part, which is `0` for sub-unit magnitudes. The sign is now
computed explicitly and the magnitude formatted via `unsigned_abs`, which
also removes a latent `i128::MIN` overflow panic.

## [0.1.1] - 2026-05-13

### Added
Expand Down
41 changes: 35 additions & 6 deletions hyperdb-api-core/src/types/special.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1225,14 +1225,19 @@ impl fmt::Display for Numeric {
if self.scale == 0 {
write!(f, "{}", self.value)
} else {
let divisor = 10i128.pow(u32::from(self.scale));
let int_part = self.value / divisor;
let frac_part = (self.value % divisor).abs();
// Compute the sign explicitly and format the magnitude. Deriving
// the sign from `int_part` alone loses it whenever `|value| < 1`
// (the integer part is `0`, which prints without a sign), so
// values like -0.5 would render as "0.5000". `unsigned_abs` also
// avoids the `i128::MIN` overflow that `.abs()` would hit.
let divisor = 10u128.pow(u32::from(self.scale));
let sign = if self.value < 0 { "-" } else { "" };
let abs = self.value.unsigned_abs();
let int_part = abs / divisor;
let frac_part = abs % divisor;
write!(
f,
"{}.{:0width$}",
int_part,
frac_part,
"{sign}{int_part}.{frac_part:0width$}",
width = self.scale as usize
)
}
Expand Down Expand Up @@ -2221,6 +2226,30 @@ mod tests {
assert_eq!(num.to_string(), "123.45");
}

#[test]
fn test_numeric_display_negative_sign_preserved() {
// Regression: values in (-1, 0) must keep their sign. The integer
// part is 0 for these, which previously dropped the minus sign and
// rendered -0.5000 as "0.5000".
assert_eq!(Numeric::new(-5000, 4).to_string(), "-0.5000");
assert_eq!(Numeric::new(-9990, 4).to_string(), "-0.9990");
assert_eq!(Numeric::new(-1, 4).to_string(), "-0.0001");

// |value| >= 1 already worked; guard against regressions.
assert_eq!(Numeric::new(-15000, 4).to_string(), "-1.5000");
assert_eq!(Numeric::new(-10000, 4).to_string(), "-1.0000");

// Zero and positive sub-unit values must not gain a spurious sign.
assert_eq!(Numeric::new(0, 4).to_string(), "0.0000");
assert_eq!(Numeric::new(5000, 4).to_string(), "0.5000");

// scale == 0 path keeps negative integers intact.
assert_eq!(Numeric::new(-1, 0).to_string(), "-1");

// i128::MIN must not panic (unsigned_abs avoids the .abs() overflow).
let _ = Numeric::new(i128::MIN, 4).to_string();
}

#[test]
fn test_numeric_from_binary_with_scale() {
// Unscaled value 123 with scale 2 = 1.23
Expand Down
16 changes: 16 additions & 0 deletions hyperdb-api-node/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Fixed

- `NUMERIC` columns are now decoded correctly. Previously the bindings read
numerics with `getF64`, which reinterpreted the raw unscaled-integer bytes as
an IEEE-754 double and returned garbage values or `NaN`. Numerics are now
decoded schema-aware (honoring the column scale): `getString` returns the
exact decimal text (preserving scale and sign, including sub-unit negatives
such as `-0.5000`), `getFloat64` returns the correct (possibly lossy) value,
and `getInt32`/`getInt64` return the truncated integer. `getBigInt` on a
`NUMERIC(p, 0)` column now preserves the full unscaled value (use it instead
of `getInt64` for integer NUMERIC values above `Number.MAX_SAFE_INTEGER`); on
a `NUMERIC(p, scale>0)` column it returns `null` (use `getString` for exact
text or `getFloat64` for a lossy value). The columnar fast path
(`executeQueryColumnar`) surfaces numerics as correct `f64` values instead of
garbage. Relates to [#84](https://github.com/tableau/hyper-api-rust/issues/84).

## [0.1.1] - 2026-05-13

### Added
Expand Down
116 changes: 116 additions & 0 deletions hyperdb-api-node/__test__/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,122 @@ async function main() {
const verify = await conn.executeQuery('SELECT COUNT(*) FROM arrow_t');
console.log(` Verified COUNT(*): ${verify[0].getBigInt(0)}`);

// 18b. NUMERIC decoding — sign, scale, precision (row-wise + columnar)
console.log('\n18b. NUMERIC types...');
await conn.executeCommand('DROP TABLE IF EXISTS numeric_test');
await conn.executeCommand(
'CREATE TABLE numeric_test (id INT NOT NULL, n NUMERIC(20,4), hp NUMERIC(38,12))'
);
await conn.executeCommand(
`INSERT INTO numeric_test VALUES
(1, 123.4500, 0),
(2, -67.8900, 0),
(3, -0.5000, 0),
(4, -0.9990, 0),
(5, 0.0000, 123456789.123456789012)`
);

const numRows = await conn.executeQuery(
'SELECT id, n, hp FROM numeric_test ORDER BY id'
);

// Exact decimal text via getString (preserves scale + sign).
const nStrings = numRows.map((r) => r.getString(1));
console.log(` getString(n): ${JSON.stringify(nStrings)}`);
assert.equal(nStrings[0], '123.4500', 'positive numeric exact text');
assert.equal(nStrings[1], '-67.8900', 'negative numeric exact text');
// Regression: sub-unit negatives must keep their sign (issue #84).
assert.equal(nStrings[2], '-0.5000', 'sub-unit negative keeps sign');
assert.equal(nStrings[3], '-0.9990', 'sub-unit negative keeps sign');

// getFloat64 must be the real value, not a reinterpreted-bytes garbage/NaN.
const nFloats = numRows.map((r) => r.getFloat64(1));
console.log(` getFloat64(n): ${JSON.stringify(nFloats)}`);
assert.ok(Math.abs(nFloats[0] - 123.45) < 1e-9, 'positive f64');
assert.ok(Math.abs(nFloats[1] - -67.89) < 1e-9, 'negative f64');
assert.ok(Math.abs(nFloats[2] - -0.5) < 1e-12, 'sub-unit negative f64 keeps sign');
assert.ok(nFloats[2] < 0, 'sub-unit negative f64 is negative');
assert.ok(nFloats.every((v) => !Number.isNaN(v)), 'no NaN from NUMERIC decode');

// High precision (>15 significant digits): exact via string, lossy via f64.
const hp = numRows[4].getString(2);
console.log(` getString(hp): ${hp}`);
assert.equal(hp, '123456789.123456789012', 'high-precision exact text');

// AVG over a numeric column returns a numeric — decode it correctly.
const avgRow = await conn.executeQuery(
'SELECT AVG(n) FROM numeric_test WHERE id <= 4'
);
const avg = avgRow[0].getFloat64(0);
console.log(` AVG(n) where id<=4: ${avg}`);
// (123.45 - 67.89 - 0.5 - 0.999) / 4 = 13.51525
assert.ok(Math.abs(avg - 13.51525) < 1e-6, 'AVG numeric decodes correctly');

// Columnar path surfaces NUMERIC as f64 — must match row-wise, not garbage.
const numColStream = conn.executeQueryColumnar(
'SELECT id, n FROM numeric_test ORDER BY id'
);
const numChunk = await numColStream.nextChunk();
const nCol = numChunk.getFloat64Column(1);
console.log(` columnar getFloat64Column(n): [${Array.from(nCol).join(', ')}]`);
assert.ok(Math.abs(nCol[0] - 123.45) < 1e-9, 'columnar positive');
assert.ok(Math.abs(nCol[2] - -0.5) < 1e-12, 'columnar sub-unit negative keeps sign');
assert.ok(Array.from(nCol).every((v) => !Number.isNaN(v)), 'columnar no NaN');

// NUMERIC(p, 0) integer-shaped paths: getInt32 / getInt64 / getBigInt.
// Use a separate table so we can exercise scale=0 specifically.
await conn.executeCommand('DROP TABLE IF EXISTS numeric_int_test');
await conn.executeCommand(
'CREATE TABLE numeric_int_test (id INT NOT NULL, big NUMERIC(38,0))'
);
// Includes a value > 2^53 to exercise the BigInt path's precision
// preservation (i64::MAX = 9223372036854775807 > Number.MAX_SAFE_INTEGER).
await conn.executeCommand(
`INSERT INTO numeric_int_test VALUES
(1, 42),
(2, -42),
(3, 9223372036854775807)`
);

const intRows = await conn.executeQuery(
'SELECT id, big FROM numeric_int_test ORDER BY id'
);

// getInt32 / getInt64 narrow through f64 — fine for small values.
assert.equal(intRows[0].getInt32(1), 42, 'NUMERIC(p,0) -> Int32 positive');
assert.equal(intRows[1].getInt32(1), -42, 'NUMERIC(p,0) -> Int32 negative');
assert.equal(intRows[0].getInt64(1), 42, 'NUMERIC(p,0) -> Int64 positive');
assert.equal(intRows[1].getInt64(1), -42, 'NUMERIC(p,0) -> Int64 negative');

// getBigInt preserves full precision for NUMERIC(p, 0); a value above
// Number.MAX_SAFE_INTEGER must round-trip exactly.
const bigSmall = intRows[0].getBigInt(1);
const bigNeg = intRows[1].getBigInt(1);
const bigLarge = intRows[2].getBigInt(1);
console.log(
` getBigInt(NUMERIC(38,0)): ${bigSmall}, ${bigNeg}, ${bigLarge}`
);
assert.equal(bigSmall, 42n, 'BigInt small positive');
assert.equal(bigNeg, -42n, 'BigInt small negative');
assert.equal(
bigLarge,
9223372036854775807n,
'BigInt preserves precision above 2^53'
);

// getBigInt on a non-zero-scale NUMERIC must return null (the cell is
// not an integer; callers should use getString or getFloat64).
const decBigInt = numRows[0].getBigInt(1);
assert.equal(
decBigInt,
null,
'getBigInt on NUMERIC(p, scale>0) returns null'
);

await conn.executeCommand('DROP TABLE numeric_int_test');
await conn.executeCommand('DROP TABLE numeric_test');
console.log(' All NUMERIC tests passed ✓');

// 19. Clean up
console.log('\n19. Cleaning up...');
await catalog.dropTable('test_users');
Expand Down
62 changes: 0 additions & 62 deletions hyperdb-api-node/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 11 additions & 1 deletion hyperdb-api-node/src/columnar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -252,11 +252,21 @@ pub(crate) fn extract_chunk_columnar(
v[row_idx] = f64::from(row.get_f32(col_idx).unwrap_or(0.0));
}
}
hyperdb_api::SqlType::Double | hyperdb_api::SqlType::Numeric { .. } => {
hyperdb_api::SqlType::Double => {
if let ColumnData::Float64(ref mut v) = columns[col_idx] {
v[row_idx] = row.get_f64(col_idx).unwrap_or(0.0);
}
}
hyperdb_api::SqlType::Numeric { .. } => {
// Schema-aware decode then narrow to f64. `get_f64` must NOT
// be used: it reinterprets the unscaled-integer bytes as an
// IEEE-754 double (garbage/NaN). The columnar fast path
// surfaces numerics as f64 (lossy for >15 sig digits); the
// row-wise path preserves exact text via `getString`.
if let ColumnData::Float64(ref mut v) = columns[col_idx] {
v[row_idx] = row.get_numeric(col_idx).map_or(0.0, |n| n.to_f64());
}
}
hyperdb_api::SqlType::Date => {
if let ColumnData::Strings(ref mut v) = columns[col_idx] {
v[row_idx] = row
Expand Down
Loading
Loading