From ed78e71c9eeac67a24163205c44d8ec683a6d796 Mon Sep 17 00:00:00 2001 From: Tam Nguyen Duc <1218621+tamnd@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:02:30 +0700 Subject: [PATCH] An exact decimal is a ZuDecimal The engine grew a decimal type: an integer of unscaled units and the scale that says how large a unit is. This client had no way to receive one and no way to send one, so a CAST to DECIMAL came back as something else or not at all. A class, because JavaScript has no exact number to be. A number is an IEEE double and a tenth is not a binary fraction, so 0.1 + 0.2 is not 0.3 and a price read into one is not the price. A bigint is exact and whole, which is half of what a decimal is. That is the same decision ZuDate is, for the same reason: the runtime has no type for the value, and inventing one that loses it would be worse than naming it. The scale rides on the value rather than only on the column, because CAST('1.20' AS DECIMAL(5, 2)) in a RETURN has no column to ask and still has two places. ZuDecimal.parse reads the scale out of the text it was given, so 1.20 and 1.2 are the same number written with different care about how well it is known, and each prints back the way it was written. ZuDecimal.of takes the pair for a caller who has one. Both factories refuse what the carrier cannot hold, and they say which limit was reached: 38 digits is the widest decimal here and the widest DECIMAL(p, s) that may be declared. A NaN and an infinity are refused at the call rather than turned into anything, because an exact number is what a decimal is. The parse checks the width itself: the engine's own parse takes a scale and does not compare the result against the maximum, and 39 ones fit an i128. Nothing on the way in is re-checked. Every ZuDecimal was built by a factory that refused what a decimal cannot hold, or handed back by the engine, so the two numbers read out of one are two this engine already holds. The column side needed nothing: a decimal column is a complex column and rides the per-value path, and zu-arrow already writes Decimal128. --- Cargo.lock | 22 ++-- Cargo.toml | 6 +- README.md | 3 +- binding.d.cts | 104 +++++++++++++++++++ conformance/values.mjs | 16 ++- etc/zudb.api.md | 13 +++ src/value.rs | 229 ++++++++++++++++++++++++++++++++++++++++- test/exports.test.mjs | 1 + test/values.test.mjs | 144 +++++++++++++++++++++++++- types/header.d.ts | 12 +++ zudb.cjs | 1 + zudb.mjs | 1 + 12 files changed, 534 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index df527ed..6818c05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1755,7 +1755,7 @@ dependencies = [ [[package]] name = "zu" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=9dcb7b42b50f3a802cae62b18d9e99f30efefad1#9dcb7b42b50f3a802cae62b18d9e99f30efefad1" +source = "git+https://github.com/tamnd/zu?rev=cfdc70f291719309b96a8300dc8425154d0fd5d5#cfdc70f291719309b96a8300dc8425154d0fd5d5" dependencies = [ "zu-common", "zu-encoding", @@ -1771,7 +1771,7 @@ dependencies = [ [[package]] name = "zu-arrow" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=9dcb7b42b50f3a802cae62b18d9e99f30efefad1#9dcb7b42b50f3a802cae62b18d9e99f30efefad1" +source = "git+https://github.com/tamnd/zu?rev=cfdc70f291719309b96a8300dc8425154d0fd5d5#cfdc70f291719309b96a8300dc8425154d0fd5d5" dependencies = [ "arrow", "zu-common", @@ -1781,7 +1781,7 @@ dependencies = [ [[package]] name = "zu-common" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=9dcb7b42b50f3a802cae62b18d9e99f30efefad1#9dcb7b42b50f3a802cae62b18d9e99f30efefad1" +source = "git+https://github.com/tamnd/zu?rev=cfdc70f291719309b96a8300dc8425154d0fd5d5#cfdc70f291719309b96a8300dc8425154d0fd5d5" dependencies = [ "thiserror", ] @@ -1789,7 +1789,7 @@ dependencies = [ [[package]] name = "zu-encoding" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=9dcb7b42b50f3a802cae62b18d9e99f30efefad1#9dcb7b42b50f3a802cae62b18d9e99f30efefad1" +source = "git+https://github.com/tamnd/zu?rev=cfdc70f291719309b96a8300dc8425154d0fd5d5#cfdc70f291719309b96a8300dc8425154d0fd5d5" dependencies = [ "ruzstd", "zu-common", @@ -1798,7 +1798,7 @@ dependencies = [ [[package]] name = "zu-exec" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=9dcb7b42b50f3a802cae62b18d9e99f30efefad1#9dcb7b42b50f3a802cae62b18d9e99f30efefad1" +source = "git+https://github.com/tamnd/zu?rev=cfdc70f291719309b96a8300dc8425154d0fd5d5#cfdc70f291719309b96a8300dc8425154d0fd5d5" dependencies = [ "zu-common", "zu-query", @@ -1808,7 +1808,7 @@ dependencies = [ [[package]] name = "zu-query" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=9dcb7b42b50f3a802cae62b18d9e99f30efefad1#9dcb7b42b50f3a802cae62b18d9e99f30efefad1" +source = "git+https://github.com/tamnd/zu?rev=cfdc70f291719309b96a8300dc8425154d0fd5d5#cfdc70f291719309b96a8300dc8425154d0fd5d5" dependencies = [ "crossbeam-deque", "zu-common", @@ -1819,7 +1819,7 @@ dependencies = [ [[package]] name = "zu-s3" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=9dcb7b42b50f3a802cae62b18d9e99f30efefad1#9dcb7b42b50f3a802cae62b18d9e99f30efefad1" +source = "git+https://github.com/tamnd/zu?rev=cfdc70f291719309b96a8300dc8425154d0fd5d5#cfdc70f291719309b96a8300dc8425154d0fd5d5" dependencies = [ "crc32c", "object_store", @@ -1830,7 +1830,7 @@ dependencies = [ [[package]] name = "zu-sqlite" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=9dcb7b42b50f3a802cae62b18d9e99f30efefad1#9dcb7b42b50f3a802cae62b18d9e99f30efefad1" +source = "git+https://github.com/tamnd/zu?rev=cfdc70f291719309b96a8300dc8425154d0fd5d5#cfdc70f291719309b96a8300dc8425154d0fd5d5" dependencies = [ "rusqlite", "zu-common", @@ -1840,7 +1840,7 @@ dependencies = [ [[package]] name = "zu-storage" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=9dcb7b42b50f3a802cae62b18d9e99f30efefad1#9dcb7b42b50f3a802cae62b18d9e99f30efefad1" +source = "git+https://github.com/tamnd/zu?rev=cfdc70f291719309b96a8300dc8425154d0fd5d5#cfdc70f291719309b96a8300dc8425154d0fd5d5" dependencies = [ "zu-common", "zu-encoding", @@ -1849,7 +1849,7 @@ dependencies = [ [[package]] name = "zu-vector" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=9dcb7b42b50f3a802cae62b18d9e99f30efefad1#9dcb7b42b50f3a802cae62b18d9e99f30efefad1" +source = "git+https://github.com/tamnd/zu?rev=cfdc70f291719309b96a8300dc8425154d0fd5d5#cfdc70f291719309b96a8300dc8425154d0fd5d5" dependencies = [ "zu-common", ] @@ -1857,7 +1857,7 @@ dependencies = [ [[package]] name = "zu-zu1" version = "0.0.1" -source = "git+https://github.com/tamnd/zu?rev=9dcb7b42b50f3a802cae62b18d9e99f30efefad1#9dcb7b42b50f3a802cae62b18d9e99f30efefad1" +source = "git+https://github.com/tamnd/zu?rev=cfdc70f291719309b96a8300dc8425154d0fd5d5#cfdc70f291719309b96a8300dc8425154d0fd5d5" dependencies = [ "crc32c", "loom", diff --git a/Cargo.toml b/Cargo.toml index 85fc526..d121319 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,14 +18,14 @@ crate-type = ["cdylib"] # with (ADR 0002), so a revision is the honest way to say which one. # A local checkout is used instead with a `paths` override in # `.cargo/config.toml`, which is untracked on purpose. -zudb = { package = "zu", git = "https://github.com/tamnd/zu", rev = "9dcb7b42b50f3a802cae62b18d9e99f30efefad1" } -zu-common = { git = "https://github.com/tamnd/zu", rev = "9dcb7b42b50f3a802cae62b18d9e99f30efefad1" } +zudb = { package = "zu", git = "https://github.com/tamnd/zu", rev = "cfdc70f291719309b96a8300dc8425154d0fd5d5" } +zu-common = { git = "https://github.com/tamnd/zu", rev = "cfdc70f291719309b96a8300dc8425154d0fd5d5" } # The one translation from a result into Arrow, which lives in the engine # tree so that every client agrees about what a column becomes. `ipc` is # the only feature this client turns on: the C Data Interface hands over # a pointer and nothing in a JavaScript runtime can read one, so the way # out here is the bytes of an IPC stream. -zu-arrow = { git = "https://github.com/tamnd/zu", rev = "9dcb7b42b50f3a802cae62b18d9e99f30efefad1", features = ["ipc"] } +zu-arrow = { git = "https://github.com/tamnd/zu", rev = "cfdc70f291719309b96a8300dc8425154d0fd5d5", features = ["ipc"] } # N-API by way of napi-rs (ADR 0002). `napi9` is the version of N-API # this addon declares it needs, which is what makes one binary work # across Node 24, Node 26, Electron and Bun without a rebuild: the diff --git a/README.md b/README.md index f437e7d..bad2e99 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ The rows are an array, so iterating them is `for (const row of rows)` and nothin ## Decisions worth knowing before you start - **INT64 is `bigint`.** By default, everywhere. A JavaScript number stops being exact at 2^53 and zu's integers go to 2^63, so a count that came back as a number would be a count you cannot trust. `{ bigIntMode: "number" }` asks for the other spelling, and the section below is what it costs. +- **An exact decimal is a `ZuDecimal`.** `CAST('1.20' AS DECIMAL(5, 2))` comes back with both places, and `ZuDecimal.parse('1.20')` goes in as a parameter the same way. Not a `number`, for the reason INT64 is not one and a stronger one: a tenth is not a binary fraction, so `0.1 + 0.2` is not `0.3` and a price held as a double is not the price. `toString()` is the lossless spelling and what `JSON.stringify` writes, and `toNumber()` is the conversion offered rather than done. - **Nothing blocks the event loop.** Every native call runs on libuv's threadpool and hands back a promise before the statement has started. There is no synchronous variant, and the ones that arrive later will say in their own documentation that they belong in scripts, not servers. - **`await using` is the intended scoping.** A connection is `Symbol.asyncDispose`, and `close()` stays public for callers who cannot use the syntax. - **A failure is an ordinary `Error`.** Every `catch`, logger and rejection handler already knows what to do with one. What makes it a zu error is the fields, and none of them has to be parsed back out of the message: `code` is the GQLSTATUS and picks the branch, `condition` is the standard's own words for it, `line` and `column` and `excerpt` underline the token, and `retryable` decides whether a retry loop goes round again. A mistake this client caught before the engine saw it carries no `code` and is named `ZuUsageError`, so a caller mapping codes to branches can tell a missing code from one it does not recognize. `isZuError(caught)` is the exported guard for the `catch` clause, where the value is `unknown` and could be anything at all, and in TypeScript it narrows to the full shape. @@ -75,7 +76,7 @@ The switches come across, including `bigIntMode` and `temporal`, because a pool ## What works today -`connect`, `query`, `exec`, `stream`, `close`, `dispose` and `await using`. `duplicate`, for a second connection made from the first. Named parameters both ways, including lists, records and nesting. Every scalar the engine has, including BYTES as a `Uint8Array` both ways, plus nodes, edges and paths with their tables named rather than numbered, and `ZuDate`, `ZuTime`, `ZuTimestamp` and `ZuDuration`, with `{ temporal: true }` and `toTemporal()` for the runtimes that have `Temporal`. Read-only connections, databases in memory, memory and thread limits. `bigIntMode`, per statement or per connection. An `AbortSignal` on any statement, and `rowsRead` and `progress` for watching the one running now. The full error surface above, and `isZuError` to recognize it. Streaming, as an async iterable, as batches and as a Web Stream. Transactions, with `inTransaction` on the connection. An appender, for loading rows a batch at a time, and `load` for building a whole database out of columns and an edge list. Registered frames, so an Arrow table or an object of typed arrays is something a statement can match on without the rows being copied. `columnar`, for a result read down its columns as the buffers themselves rather than across its rows as objects, and `arrow`, for the same result as an Arrow IPC stream that any Arrow reader takes. Prepared statements, compiled at the line that asked and run as often as wanted, and `explain` and `profile`, as a tree a program walks and as the listing a person reads. Both module formats, typed separately. +`connect`, `query`, `exec`, `stream`, `close`, `dispose` and `await using`. `duplicate`, for a second connection made from the first. Named parameters both ways, including lists, records and nesting. Every scalar the engine has, including BYTES as a `Uint8Array` and DECIMAL as a `ZuDecimal`, both ways, plus nodes, edges and paths with their tables named rather than numbered, and `ZuDate`, `ZuTime`, `ZuTimestamp` and `ZuDuration`, with `{ temporal: true }` and `toTemporal()` for the runtimes that have `Temporal`. Read-only connections, databases in memory, memory and thread limits. `bigIntMode`, per statement or per connection. An `AbortSignal` on any statement, and `rowsRead` and `progress` for watching the one running now. The full error surface above, and `isZuError` to recognize it. Streaming, as an async iterable, as batches and as a Web Stream. Transactions, with `inTransaction` on the connection. An appender, for loading rows a batch at a time, and `load` for building a whole database out of columns and an edge list. Registered frames, so an Arrow table or an object of typed arrays is something a statement can match on without the rows being copied. `columnar`, for a result read down its columns as the buffers themselves rather than across its rows as objects, and `arrow`, for the same result as an Arrow IPC stream that any Arrow reader takes. Prepared statements, compiled at the line that asked and run as often as wanted, and `explain` and `profile`, as a tree a program walks and as the listing a person reads. Both module formats, typed separately. Build it with `npm run build`, and run the suite with `npm test`. The shared cross-client corpus runs through `npm run corpus`, and `conformance/README.md` says what it checks and the one case this client cannot answer. Nothing is published yet, so `npm i zudb` is not a thing you can type at anybody's terminal, but everything it will do is built and installed on every run of the release workflow. diff --git a/binding.d.cts b/binding.d.cts index c1a25c0..43a963c 100644 --- a/binding.d.cts +++ b/binding.d.cts @@ -83,6 +83,11 @@ export type ZuTemporalValue = typeof globalThis extends { * BYTES is a `Uint8Array` and not a string. The bytes are octets and * need not be text at all, so decoding them is the caller's call to * make rather than this client's to make for them. + * + * DECIMAL is a `ZuDecimal` and not a `number`, for the reason INT64 is + * not one and a stronger one: a tenth is not a binary fraction, so a + * price that came back as a number would not be the price, and how many + * places it is known to would be gone as well. */ export type ZuValue = | null @@ -93,6 +98,7 @@ export type ZuValue = | ZuNode | ZuRel | ZuPath + | ZuDecimal | ZuDate | ZuTime | ZuTimestamp @@ -119,6 +125,11 @@ export type ZuValue = * binds at all: an `Int32Array` is a buffer somebody meant to load * rather than a value a statement holds, so it is refused instead of * being read as the empty object it has no properties to be. + * + * A `ZuDecimal` binds as DECIMAL and is the only way to send one. A + * `number` never becomes one, because a caller who wrote `0.1` gave the + * double that is not a tenth, and reading it as a decimal would put a + * number nobody wrote into the query. */ export type ZuParam = | null @@ -127,6 +138,7 @@ export type ZuParam = | number | bigint | string + | ZuDecimal | ZuDate | ZuTime | ZuTimestamp @@ -1406,6 +1418,98 @@ export declare class ZuDate { toJSON(): object } +/** + * An exact decimal: an integer of units, and how many of its digits + * are after the point. + * + * A class because JavaScript has no exact number to be. A `number` is + * an IEEE double and a tenth is not a binary fraction, so `0.1 + 0.2` + * is not `0.3` and a price read into one is not the price. A `bigint` + * is exact and whole, which is half of what a decimal is. So this is + * the same decision [`ZuDate`] is: the runtime has no type for the + * value, and inventing one that loses it would be worse than naming + * it. + * + * The scale rides on the value rather than only on the column it came + * from, because `CAST('1.20' AS DECIMAL(5, 2))` in a `RETURN` has no + * column to ask and still has two places. `1.20` and `1.2` are the + * same number written with different care about how well it is known, + * they compare as the same number, and each prints back the way it was + * written. + */ +export declare class ZuDecimal { + /** + * The decimal a piece of text spells, at the scale it was written + * at, so `ZuDecimal.parse('1.20')` has two places and prints back + * as `1.20`. + * + * This is the one to reach for. A decimal usually arrives written + * down, out of a form or a config file or a column of a CSV, and + * the text carries the scale along with the number. `of` is for a + * caller who already holds the pair. + * + * An exponent is taken, because `1E3` is a number somebody writes, + * and it moves the point rather than the value: `1.5e3` is fifteen + * hundred at no places and not `1.500`. + * + * Throws on text that is not an exact number, which is a NaN, an + * infinity, and anything with more digits than a decimal here + * holds. + */ + static parse(text: string): ZuDecimal + /** + * A decimal of `unscaled` units, each one ten to the minus + * `scale`, so `ZuDecimal.of(120n, 2)` is `1.20`. + * + * Nothing is normalised: the scale given is the scale kept, and a + * trailing nought is a digit the caller said they know. + * + * Throws when the pair is not one a decimal here holds, which is + * more than thirty eight digits in the integer or a point further + * right than any `DECIMAL(p, s)` could declare. + */ + static of(unscaled: bigint, scale: number): ZuDecimal + /** + * The integer the value is counted in units of, which is `120n` + * for `1.20`. A `bigint`, because thirty eight digits is past what + * a `number` tells apart from its neighbours. + */ + get unscaled(): bigint + /** + * How many of the digits are after the point, which is `2` for + * `1.20`. + */ + get scale(): number + /** + * The number written out, with the point where the scale says it + * is and never an exponent. This is the lossless spelling and the + * one `parse` reads back. + */ + toString(): string + /** + * The nearest `number`, for the arithmetic JavaScript can do and + * the chart that is going to plot it anyway. + * + * The conversion is where the exactness stops, and it is offered + * rather than done because that is the caller's call to make. A + * decimal of more than about fifteen digits does not survive it, + * and neither does most of what the type exists for: three tenths + * is not a double. `toString` is the one that loses nothing. + */ + toNumber(): number + /** + * The number as its own text, for the reason [`ZuNode::to_json`] + * gives and one more. + * + * A string rather than the two fields, because the two fields + * include a `bigint`, which has no JSON spelling, and because the + * text is the whole value and reads back through `parse`. A JSON + * number would be a double again, which is the thing this type + * exists to not be. + */ + toJSON(): string +} + /** * A duration, which is a count of months or a count of nanoseconds * and never both. diff --git a/conformance/values.mjs b/conformance/values.mjs index d3221e6..35355bb 100644 --- a/conformance/values.mjs +++ b/conformance/values.mjs @@ -41,7 +41,7 @@ * value this client holds exactly. */ -import { ZuDate, ZuDuration, ZuNode, ZuRel, ZuTime, ZuTimestamp } from 'zudb' +import { ZuDate, ZuDecimal, ZuDuration, ZuNode, ZuRel, ZuTime, ZuTimestamp } from 'zudb' import { quote, refuse } from './reader.mjs' import { @@ -566,6 +566,14 @@ export function same(want, got) { want.nanos === got.nanos ) } + if (want instanceof ZuDecimal) { + // The scale as well as the number. Two decimals of one number at + // two scales are one value to the engine and print differently, and + // what a case asserts is what a reader would see. + return ( + got instanceof ZuDecimal && want.scale === got.scale && want.unscaled === got.unscaled + ) + } if (want instanceof Uint8Array) { if (!(got instanceof Uint8Array) || want.length !== got.length) return false for (let i = 0; i < want.length; i++) if (want[i] !== got[i]) return false @@ -608,6 +616,12 @@ export function show(value) { if (typeof value === 'number') return `FLOAT64 "${showFloat(value)}"` if (typeof value === 'string') return `STRING ${quote(value)}` if (value instanceof Uint8Array) return `BYTES "${hexits(value)}"` + // A decimal is a value a statement can hand back today even though + // DECIMAL is still a reserved name a case may not write, since CAST + // reaches one and no case declares one. That makes this the got side + // of a report and never the want side, and a report that could not + // print what it got would be the least useful moment to find out. + if (value instanceof ZuDecimal) return `DECIMAL "${value.toString()}"` if (value instanceof ZuDate) return `DATE "${showDate(value.days)}"` if (value instanceof ZuTime) { if ((value.offset ?? null) === null) return `LOCALTIME "${showClock(value.nanos)}"` diff --git a/etc/zudb.api.md b/etc/zudb.api.md index 4ff949a..4ed887e 100644 --- a/etc/zudb.api.md +++ b/etc/zudb.api.md @@ -195,6 +195,17 @@ export class ZuDate { toTemporal(): ZuPlainDate } +// @public +export class ZuDecimal { + static of(unscaled: bigint, scale: number): ZuDecimal + static parse(text: string): ZuDecimal + get scale(): number + toJSON(): string + toNumber(): number + toString(): string + get unscaled(): bigint +} + // @public export class ZuDuration { get kind(): string @@ -320,6 +331,7 @@ export type ZuParam = | number | bigint | string +| ZuDecimal | ZuDate | ZuTime | ZuTimestamp @@ -530,6 +542,7 @@ export type ZuValue = | ZuNode | ZuRel | ZuPath +| ZuDecimal | ZuDate | ZuTime | ZuTimestamp diff --git a/src/value.rs b/src/value.rs index b784ebe..6ba3ef5 100644 --- a/src/value.rs +++ b/src/value.rs @@ -27,7 +27,8 @@ use std::collections::HashMap; use napi::bindgen_prelude::*; use napi::{Env, ValueType}; use napi_derive::napi; -use zu_common::{DurationKind, Temporal}; +use zu_common::decimal::MAX_DIGITS; +use zu_common::{Decimal, DurationKind, Temporal}; use zudb::query::Value; use zudb::zu1::catalog::Catalog; @@ -272,6 +273,209 @@ impl ZuRel { } } +/// An exact decimal: an integer of units, and how many of its digits +/// are after the point. +/// +/// A class because JavaScript has no exact number to be. A `number` is +/// an IEEE double and a tenth is not a binary fraction, so `0.1 + 0.2` +/// is not `0.3` and a price read into one is not the price. A `bigint` +/// is exact and whole, which is half of what a decimal is. So this is +/// the same decision [`ZuDate`] is: the runtime has no type for the +/// value, and inventing one that loses it would be worse than naming +/// it. +/// +/// The scale rides on the value rather than only on the column it came +/// from, because `CAST('1.20' AS DECIMAL(5, 2))` in a `RETURN` has no +/// column to ask and still has two places. `1.20` and `1.2` are the +/// same number written with different care about how well it is known, +/// they compare as the same number, and each prints back the way it was +/// written. +#[napi] +pub struct ZuDecimal { + value: Decimal, +} + +#[napi] +impl ZuDecimal { + /// The decimal a piece of text spells, at the scale it was written + /// at, so `ZuDecimal.parse('1.20')` has two places and prints back + /// as `1.20`. + /// + /// This is the one to reach for. A decimal usually arrives written + /// down, out of a form or a config file or a column of a CSV, and + /// the text carries the scale along with the number. `of` is for a + /// caller who already holds the pair. + /// + /// An exponent is taken, because `1E3` is a number somebody writes, + /// and it moves the point rather than the value: `1.5e3` is fifteen + /// hundred at no places and not `1.500`. + /// + /// Throws on text that is not an exact number, which is a NaN, an + /// infinity, and anything with more digits than a decimal here + /// holds. + #[napi(factory)] + pub fn parse(env: &Env, text: String) -> Result { + let places = written(&text); + if let Some(after) = places + && after > i64::from(MAX_DIGITS) + { + return Err(usage( + env, + format!( + "the decimal {text} has {after} digits after the point, and a decimal here \ + holds at most {MAX_DIGITS}" + ), + )); + } + // Text with no readable exponent falls through to the refusal + // below rather than getting one of its own, because `1e` and + // `1eX` are the same thing a NaN is: not a number, said with a + // different set of letters. + let read = places + .and_then(|after| u16::try_from(after).ok()) + .and_then(|scale| Decimal::parse(&text, scale)); + let value = match read { + Some(value) => value, + None => { + return Err(usage( + env, + format!( + "{text} is not a decimal this engine holds: it takes an exact number, so \ + a NaN and an infinity are both outside it" + ), + )); + } + }; + if value.digits() > MAX_DIGITS { + return Err(usage(env, wide(&text))); + } + Ok(ZuDecimal { value }) + } + + /// A decimal of `unscaled` units, each one ten to the minus + /// `scale`, so `ZuDecimal.of(120n, 2)` is `1.20`. + /// + /// Nothing is normalised: the scale given is the scale kept, and a + /// trailing nought is a digit the caller said they know. + /// + /// Throws when the pair is not one a decimal here holds, which is + /// more than thirty eight digits in the integer or a point further + /// right than any `DECIMAL(p, s)` could declare. + #[napi(factory)] + pub fn of(env: &Env, unscaled: BigInt, scale: u32) -> Result { + let (units, lossless) = unscaled.get_i128(); + if !lossless || Decimal::new(units, 0).digits() > MAX_DIGITS { + return Err(usage(env, wide("the unscaled integer"))); + } + if scale > u32::from(MAX_DIGITS) { + return Err(usage( + env, + format!( + "the scale is {scale}, and a decimal here holds at most {MAX_DIGITS} digits \ + after the point" + ), + )); + } + Ok(ZuDecimal { + value: Decimal::new(units, scale as u16), + }) + } + + /// The integer the value is counted in units of, which is `120n` + /// for `1.20`. A `bigint`, because thirty eight digits is past what + /// a `number` tells apart from its neighbours. + #[napi(getter)] + pub fn unscaled(&self) -> BigInt { + let magnitude = self.value.unscaled().unsigned_abs(); + BigInt { + sign_bit: self.value.unscaled() < 0, + words: vec![magnitude as u64, (magnitude >> 64) as u64], + } + } + + /// How many of the digits are after the point, which is `2` for + /// `1.20`. + #[napi(getter)] + pub fn scale(&self) -> u32 { + u32::from(self.value.scale()) + } + + /// The number written out, with the point where the scale says it + /// is and never an exponent. This is the lossless spelling and the + /// one `parse` reads back. + #[napi(js_name = "toString")] + pub fn to_text(&self) -> String { + self.value.to_string() + } + + /// The nearest `number`, for the arithmetic JavaScript can do and + /// the chart that is going to plot it anyway. + /// + /// The conversion is where the exactness stops, and it is offered + /// rather than done because that is the caller's call to make. A + /// decimal of more than about fifteen digits does not survive it, + /// and neither does most of what the type exists for: three tenths + /// is not a double. `toString` is the one that loses nothing. + #[napi(js_name = "toNumber")] + pub fn to_number(&self) -> f64 { + self.value.to_f64() + } + + /// The number as its own text, for the reason [`ZuNode::to_json`] + /// gives and one more. + /// + /// A string rather than the two fields, because the two fields + /// include a `bigint`, which has no JSON spelling, and because the + /// text is the whole value and reads back through `parse`. A JSON + /// number would be a double again, which is the thing this type + /// exists to not be. + #[napi(js_name = "toJSON")] + pub fn to_json(&self) -> String { + self.value.to_string() + } +} + +/// How many digits a piece of text writes after the point, once the +/// exponent has moved it, and `None` for text carrying no exponent a +/// number could have. +/// +/// Not a `split_once('.')` on the whole text, which is what the +/// exponent is here for: the fraction of `1.5e3` is three digits long +/// as written and none of them is after the point once the `e3` has +/// been applied. Reading the scale here rather than asking the caller +/// for it is the point of `parse`, since the text they have already +/// says what it is. +/// +/// An `i64` rather than the `u16` a scale is, so that a count too large +/// to be one is a number the refusal can print. A negative count is a +/// point moved past the last digit, which is a whole number and a scale +/// of nought. +fn written(text: &str) -> Option { + let text = text.trim(); + let rest = text.strip_prefix('-').unwrap_or(text); + let rest = rest.strip_prefix('+').unwrap_or(rest); + let (mantissa, exponent) = match rest.split_once(['e', 'E']) { + Some((mantissa, exponent)) => (mantissa, exponent.parse::().ok()?), + None => (rest, 0), + }; + let after = match mantissa.split_once('.') { + Some((_, fraction)) => i64::try_from(fraction.len()).ok()?, + None => 0, + }; + Some(after.checked_sub(i64::from(exponent))?.max(0)) +} + +/// The one sentence both factories refuse a number too wide with, since +/// it is one rule: thirty eight digits is what the carrier holds and +/// what `DECIMAL(p, s)` may be declared with, and those are the same +/// number on purpose. +fn wide(what: &str) -> String { + format!( + "{what} is wider than {MAX_DIGITS} digits, which is the most a decimal here holds and the \ + most DECIMAL(p, s) may be declared with" + ) +} + /// A date, as days from 1970-01-01. /// /// A count rather than a set of fields, which is what the engine @@ -573,6 +777,12 @@ pub fn to_js<'env>( // bytes need not be UTF-8 at all, and a client that decoded // them would refuse half the values the type exists for. Value::Bytes(bytes) => Uint8Array::new(bytes.clone()).into_unknown(env), + // GV17, an exact decimal. Not a `number`, which would lose both + // halves of it: a tenth is not a binary fraction, and how many + // places the value is known to is not in a double at all. + Value::Decimal(d) => ZuDecimal { value: *d } + .into_instance(env)? + .into_unknown(env), Value::Node { table, offset } => node(*table, *offset, &shape.names) .into_instance(env)? .into_unknown(env), @@ -829,6 +1039,23 @@ fn from_object(env: &Env, name: &str, value: Unknown<'_>, depth: usize) -> Resul if let Some(temporal) = temporal_from(env, &value)? { return Ok(Value::Temporal(temporal)); } + // A decimal, up here for the reason the four classes above are: it + // holds its fields behind getters on the prototype, so read as a + // plain object it would bind as `{}` and compare against nothing. + // + // Not re-checked on the way in. Every one of these was built by a + // factory that refused the pairs a decimal cannot hold, or by the + // engine handing one back, so the two numbers read out here are two + // this engine already holds. + if ZuDecimal::instance_of(env, &value)? { + let object = Object::from_unknown(value)?; + let unscaled: BigInt = object.get_named_property("unscaled")?; + let scale: u32 = object.get_named_property("scale")?; + return Ok(Value::Decimal(Decimal::new( + unscaled.get_i128().0, + scale as u16, + ))); + } // A `Uint8Array` binds as GV35, a byte string, which is the one // type whose values are octets rather than text. This goes before // the array and before the record, because a typed array is diff --git a/test/exports.test.mjs b/test/exports.test.mjs index fdb348e..9884414 100644 --- a/test/exports.test.mjs +++ b/test/exports.test.mjs @@ -28,6 +28,7 @@ const SURFACE = [ 'Prepared', 'ZuStream', 'ZuCursor', + 'ZuDecimal', 'ZuDate', 'ZuTime', 'ZuTimestamp', diff --git a/test/values.test.mjs b/test/values.test.mjs index cf27534..91a6e55 100644 --- a/test/values.test.mjs +++ b/test/values.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { ZuDate, ZuDuration, ZuTime, ZuTimestamp } from 'zudb' +import { ZuDate, ZuDecimal, ZuDuration, ZuTime, ZuTimestamp } from 'zudb' import { fresh, twoPeople } from './helper.mjs' // What a parameter binds as is what comes back, so one statement that @@ -152,3 +152,145 @@ test('a plain object shaped like a date is a record, not a date', async (t) => { assert.ok(!(back instanceof ZuDate)) assert.deepEqual(back, { days: 19723n }) }) + +test('a decimal comes back with the digits it was written with', async (t) => { + const { conn } = await fresh(t) + + // CAST is the only way to reach one today: a literal has no decimal + // spelling yet and no column is declared DECIMAL, so this is where a + // decimal comes from and the reason the test asks for one this way. + const rows = await conn.query("RETURN CAST('1.20' AS DECIMAL(5, 2)) AS v") + const v = rows[0].v + + assert.ok(v instanceof ZuDecimal, `a decimal came back as ${v?.constructor?.name}`) + assert.equal(v.unscaled, 120n) + assert.equal(v.scale, 2) + // Both places, which is the whole point. A float would have had + // neither the value nor the count of digits. + assert.equal(v.toString(), '1.20') +}) + +test('a decimal keeps its sign and its noughts', async (t) => { + const { conn } = await fresh(t) + + for (const [text, spelled] of [ + ['0', '0'], + ['1.20', '1.20'], + ['-0.05', '-0.05'], + ['1234', '1234'], + ['-1234.5678', '-1234.5678'], + ['0.005', '0.005'], + ['0.000', '0.000'], + ]) { + const places = text.includes('.') ? text.split('.')[1].length : 0 + const rows = await conn.query(`RETURN CAST('${text}' AS DECIMAL(38, ${places})) AS v`) + assert.equal(rows[0].v.toString(), spelled) + assert.equal(rows[0].v.scale, places) + } +}) + +test('a decimal wider than an INT64 arrives whole', async (t) => { + const { conn } = await fresh(t) + + // Thirty eight digits, which is the widest DECIMAL(p, s) may be + // declared and the widest the i128 behind it holds. A bigint carries + // it here for the reason it carries an INT64. + const digits = '1'.repeat(38) + const rows = await conn.query(`RETURN CAST('${digits}' AS DECIMAL(38, 0)) AS v`) + + assert.equal(rows[0].v.unscaled, BigInt(digits)) + assert.equal(rows[0].v.toString(), digits) +}) + +test('a decimal goes in as a parameter and comes back the same', async (t) => { + const { conn } = await fresh(t) + + const back = await roundTrip(conn, ZuDecimal.parse('1.20')) + + assert.ok(back instanceof ZuDecimal) + assert.equal(back.unscaled, 120n) + assert.equal(back.scale, 2) + assert.equal(back.toString(), '1.20') +}) + +test('a decimal parameter is not read as a float', async (t) => { + const { conn } = await fresh(t) + + // Three tenths is not a double, so a decimal that had gone through + // one would come back as something that is not three tenths. + const back = await roundTrip(conn, ZuDecimal.parse('0.3')) + + assert.equal(back.unscaled, 3n) + assert.equal(back.scale, 1) + assert.equal(back.toString(), '0.3') +}) + +test('a decimal built from the pair is the one the text spells', async (t) => { + const { conn } = await fresh(t) + + const built = ZuDecimal.of(120n, 2) + assert.equal(built.toString(), '1.20') + assert.equal((await roundTrip(conn, built)).toString(), '1.20') + + // Nothing is normalised, so the scale asked for is the scale kept + // even where the last digit is a nought that carries no value. + assert.equal(ZuDecimal.of(1200n, 3).toString(), '1.200') + assert.equal(ZuDecimal.of(-5n, 2).toString(), '-0.05') + assert.equal(ZuDecimal.of(0n, 0).toString(), '0') +}) + +test('an exponent moves the point rather than the value', async (t) => { + // `1.5e3` is fifteen hundred at no places. Reading the scale off the + // text without applying the exponent would make it `1.500`, which is + // a thousandth of the number somebody wrote. + assert.equal(ZuDecimal.parse('1.5e3').toString(), '1500') + assert.equal(ZuDecimal.parse('1.5e3').scale, 0) + assert.equal(ZuDecimal.parse('1E-3').toString(), '0.001') + assert.equal(ZuDecimal.parse('+2.50').toString(), '2.50') +}) + +test('a decimal that is not a number is refused at the call', async () => { + for (const text of ['NaN', 'Infinity', '-Infinity', 'nope', '', '1.2.3']) { + assert.throws( + () => ZuDecimal.parse(text), + (err) => { + assert.equal(err.name, 'ZuUsageError') + assert.match(err.message, /exact number/) + return true + }, + `${JSON.stringify(text)} was taken for a decimal`, + ) + } +}) + +test('a decimal wider than the carrier says so', async () => { + // Thirty nine digits, one past what DECIMAL(p, s) may declare and one + // past what the i128 behind it holds. + assert.throws(() => ZuDecimal.parse('1'.repeat(39)), /wider than 38 digits/) + assert.throws(() => ZuDecimal.of(10n ** 38n, 0), /wider than 38 digits/) + assert.throws(() => ZuDecimal.parse('1e-100'), /100 digits after the point/) + assert.throws(() => ZuDecimal.of(1n, 39), /at most 38 digits after the point/) +}) + +test('a decimal reads back as itself and as the nearest number', async () => { + const d = ZuDecimal.parse('-1234.5678') + + // The text is the lossless spelling and the one `parse` reads back, + // so a decimal round trips through it and through JSON. + assert.equal(ZuDecimal.parse(d.toString()).toString(), '-1234.5678') + assert.equal(JSON.stringify({ d }), '{"d":"-1234.5678"}') + + assert.equal(d.toNumber(), -1234.5678) + // And the loss, said out loud: a tenth is not a binary fraction, so + // the number is near the decimal rather than equal to it. + assert.notEqual(ZuDecimal.parse('0.1').toNumber() + ZuDecimal.parse('0.2').toNumber(), 0.3) +}) + +test('a plain object shaped like a decimal is a record, not a decimal', async (t) => { + const { conn } = await fresh(t) + + const back = await roundTrip(conn, { unscaled: 120n, scale: 2 }) + + assert.ok(!(back instanceof ZuDecimal)) + assert.deepEqual(back, { unscaled: 120n, scale: 2n }) +}) diff --git a/types/header.d.ts b/types/header.d.ts index a6cebf7..a60a295 100644 --- a/types/header.d.ts +++ b/types/header.d.ts @@ -83,6 +83,11 @@ export type ZuTemporalValue = typeof globalThis extends { * BYTES is a `Uint8Array` and not a string. The bytes are octets and * need not be text at all, so decoding them is the caller's call to * make rather than this client's to make for them. + * + * DECIMAL is a `ZuDecimal` and not a `number`, for the reason INT64 is + * not one and a stronger one: a tenth is not a binary fraction, so a + * price that came back as a number would not be the price, and how many + * places it is known to would be gone as well. */ export type ZuValue = | null @@ -93,6 +98,7 @@ export type ZuValue = | ZuNode | ZuRel | ZuPath + | ZuDecimal | ZuDate | ZuTime | ZuTimestamp @@ -119,6 +125,11 @@ export type ZuValue = * binds at all: an `Int32Array` is a buffer somebody meant to load * rather than a value a statement holds, so it is refused instead of * being read as the empty object it has no properties to be. + * + * A `ZuDecimal` binds as DECIMAL and is the only way to send one. A + * `number` never becomes one, because a caller who wrote `0.1` gave the + * double that is not a tenth, and reading it as a decimal would put a + * number nobody wrote into the query. */ export type ZuParam = | null @@ -127,6 +138,7 @@ export type ZuParam = | number | bigint | string + | ZuDecimal | ZuDate | ZuTime | ZuTimestamp diff --git a/zudb.cjs b/zudb.cjs index 9a85eaf..7f57175 100644 --- a/zudb.cjs +++ b/zudb.cjs @@ -239,6 +239,7 @@ module.exports = { // the types either way, and a name that types can see and `require` // cannot is a program that compiles and then throws. ZuCursor: binding.ZuCursor, + ZuDecimal: binding.ZuDecimal, ZuDate: binding.ZuDate, ZuTime: binding.ZuTime, ZuTimestamp: binding.ZuTimestamp, diff --git a/zudb.mjs b/zudb.mjs index 0b39cbe..73dfe9d 100644 --- a/zudb.mjs +++ b/zudb.mjs @@ -27,6 +27,7 @@ export const Appender = zudb.Appender export const Prepared = zudb.Prepared export const ZuStream = zudb.ZuStream export const ZuCursor = zudb.ZuCursor +export const ZuDecimal = zudb.ZuDecimal export const ZuDate = zudb.ZuDate export const ZuTime = zudb.ZuTime export const ZuTimestamp = zudb.ZuTimestamp