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