fix(render): trim IEEE-754 noise from displayed decimal arithmetic (#32)

`decimal` is stored as exact TEXT, but SQLite has no native decimal type,
so arithmetic/aggregation implicitly coerces it to an IEEE-754 double.
The computed result carries no playground type, so `sum(price * qty)`
rendered the double's full noise — `298.59999999999997` for `298.60` — a
confusing, off-topic float lesson for a teaching tool.

Add `format_real_display`: round REAL values to 15 significant figures
(a double's reliable precision) then take the shortest round-tripping
form, collapsing `298.59999999999997` to `298.6`. Wired into `format_cell`
(result-set / `show data` cells) only — the sole surface where the noise
appears, since it arises from arithmetic.

Every other f64->string path keeps full precision for semantic, not
cosmetic, reasons: CSV persistence stays byte-exact for round-trip;
`render_value` is a canonical identity key for the uniqueness dry-runs
(dry_run_unique, check_uniqueness_collisions), where rounding would
report collisions the exact-valued engine wouldn't; FK-key matching and
EXPLAIN-SQL literals likewise stay exact.

ADR-0005 Amendment 1; +7 tests.
This commit is contained in:
claude@clouddev1
2026-06-12 14:42:22 +00:00
parent 7e4bc122be
commit 3d4a0fd45e
4 changed files with 244 additions and 2 deletions
+61
View File
@@ -175,6 +175,67 @@ fn open_project_db() -> (project::Project, Database, tempfile::TempDir) {
(project, db, dir)
}
#[test]
fn decimal_aggregation_display_trims_ieee754_noise() {
// Issue #32: `decimal` is stored as exact TEXT, but SQLite
// coerces it to an IEEE-754 double for arithmetic/aggregation,
// so `sum(price * qty)` would render `298.59999999999997` for
// `298.60`. The display layer rounds computed REAL cells to ~15
// significant figures, trimming that noise — while raw decimal
// columns stay byte-exact (TEXT, untouched).
let (_p, db, _dir) = open_project_db();
let rt = rt();
rt.block_on(async {
db.create_table(
"Products".to_string(),
vec![
ColumnSpec::new("id", Type::Serial),
ColumnSpec::new("price", Type::Decimal),
ColumnSpec::new("qty", Type::Int),
],
vec!["id".to_string()],
None,
)
.await
.expect("create");
for (price, qty) in [("19.99", 3), ("5.49", 7), ("100.10", 2)] {
db.insert(
"Products".to_string(),
Some(vec!["price".to_string(), "qty".to_string()]),
vec![
Value::Number(price.to_string()),
Value::Number(qty.to_string()),
],
None,
)
.await
.expect("insert");
}
});
// The reported case: the aggregate no longer leaks float noise.
let agg = rt
.block_on(db.run_select("select sum(price * qty) from Products".to_string(), None))
.expect("aggregate select");
assert_eq!(
agg.rows[0][0].as_deref(),
Some("298.6"),
"sum(price*qty) must trim IEEE-754 noise (298.60), not show 298.59999999999997",
);
// Raw decimal column is still exact — TEXT storage preserves
// the input string verbatim, including the trailing zero.
let raw = rt
.block_on(db.run_select("select price from Products".to_string(), None))
.expect("raw decimal select");
let prices: Vec<&str> = raw.rows.iter().map(|r| r[0].as_deref().unwrap()).collect();
assert_eq!(
prices,
vec!["19.99", "5.49", "100.10"],
"raw decimal column must stay byte-exact (TEXT storage untouched)",
);
}
#[test]
fn database_run_select_constant_returns_a_single_row() {
let (_p, db, _dir) = open_project_db();