From 0d8d280d8da4384e2c1625c21d8512a1aa0ec7ff Mon Sep 17 00:00:00 2001 From: zuub-don Date: Thu, 22 Oct 2026 21:37:17 -0700 Subject: [PATCH 1/2] feat: add opt-in BigDecimal evaluation support Preserve arbitrary-precision JSON numbers and expression literals with the bigdecimal feature. Use decimal arithmetic in numeric functions and operators, with precise comparisons, sorting, hashing, and serialization. Accept decimal strings in numeric functions and return string results when string operands are supplied, while keeping the default behavior unchanged. Add regression coverage for issue #1, precision boundaries, rounding, mixed inputs, bindings, and invalid arguments. Correct the example's expected total to 300.55. Document precision and power/rounding limits and run both feature configurations in CI. Validation: workspace tests, Clippy with warnings denied, formatting, and documentation builds pass. The compatibility report retains four existing failures; decimal mode adds nine expected semantic differences. Refs #1 --- .github/workflows/ci.yml | 2 + CHANGELOG.md | 9 + README.md | 43 +++ crates/seuil/Cargo.toml | 5 + crates/seuil/README.md | 43 +++ crates/seuil/src/decimal.rs | 27 ++ crates/seuil/src/evaluator/engine.rs | 47 +++- crates/seuil/src/evaluator/functions/array.rs | 8 + crates/seuil/src/evaluator/functions/mod.rs | 4 + .../evaluator/functions/numeric_decimal.rs | 260 ++++++++++++++++++ .../seuil/src/evaluator/functions/object.rs | 2 + .../seuil/src/evaluator/functions/type_ops.rs | 2 + crates/seuil/src/evaluator/value/impls.rs | 17 ++ crates/seuil/src/evaluator/value/mod.rs | 67 ++++- crates/seuil/src/evaluator/value/serialize.rs | 15 + crates/seuil/src/lib.rs | 4 + crates/seuil/src/parser/ast.rs | 2 + crates/seuil/src/parser/pratt.rs | 2 + crates/seuil/src/parser/process.rs | 4 + crates/seuil/src/parser/tokenizer.rs | 39 ++- crates/seuil/tests/bigdecimal.rs | 229 +++++++++++++++ crates/seuil/tests/issue_1.rs | 22 ++ docs/src/installation.md | 21 +- 23 files changed, 853 insertions(+), 21 deletions(-) create mode 100644 crates/seuil/src/decimal.rs create mode 100644 crates/seuil/src/evaluator/functions/numeric_decimal.rs create mode 100644 crates/seuil/tests/bigdecimal.rs create mode 100644 crates/seuil/tests/issue_1.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc45742..430f44b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,7 @@ jobs: components: clippy, rustfmt - run: cargo fmt --all -- --check - run: cargo clippy --workspace -- -D warnings + - run: cargo clippy --workspace --features seuil/bigdecimal -- -D warnings - run: cargo build --workspace test: @@ -30,6 +31,7 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - run: cargo test --workspace + - run: cargo test --workspace --features seuil/bigdecimal vopr: name: VOPR Campaign (10K seeds) diff --git a/CHANGELOG.md b/CHANGELOG.md index 01ddaad..938fb71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- Opt-in `bigdecimal` feature for decimal arithmetic, numeric functions, and + arbitrary-precision JSON numbers. Numeric functions also accept decimal strings + and return strings when given string operands (issue #1). +- Precision regression tests and CI coverage with the feature enabled and disabled. + ## [0.1.0] - 2026-04-04 ### Added diff --git a/README.md b/README.md index 1cde6ca..82689a9 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,49 @@ seuil = "0.1" serde_json = "1" ``` +## Decimal arithmetic (optional) + +Enable the `bigdecimal` feature to preserve decimal precision: + +```toml +seuil = { version = "0.1", features = ["bigdecimal"] } +``` + +This also enables `serde_json/arbitrary_precision`. Parse JSON text with +`evaluate_str` or `serde_json::from_str` to retain every input digit; a Rust `f64` +literal passed to `json!` has already been rounded before Seuil receives it. + +```rust +use seuil::Seuil; + +let expr = Seuil::compile("orders[status='paid'].amount ~> $sum()")?; +let result = expr.evaluate_str(r#"{"orders":[ + {"status":"paid","amount":"100.30"}, + {"status":"pending","amount":"50.40"}, + {"status":"paid","amount":"200.25"} +]}"#)?; +assert_eq!(result, serde_json::json!("300.55")); +``` + +Numeric functions accept decimal strings as well as numbers. If an operand is a +string, the numeric result is a normalized decimal string; number-only inputs +produce JSON numbers. `$number` explicitly converts a string to a number. +Ordinary strings retain their type outside numeric functions. Arithmetic +operators still require numbers: use `$number` to convert a string first. + +Addition, subtraction, multiplication, remainder, comparisons, and aggregates +use decimal arithmetic. Division, averages, square roots, and powers use +BigDecimal's precision rules (normally 100 significant digits for inexact +results). `$round` uses half-even rounding and accepts precision from -10,000 to +10,000. `$power` supports integer and half-integer exponents; other fractional +exponents return `D3061` instead of falling back to floating point. `$random` +retains the existing environment-provided random number behavior. + +The feature intentionally extends JSONata's numeric semantics: decimal strings +are accepted, numbers can exceed the `f64` range, and `$string` preserves numeric +precision instead of truncating to 15 digits. The default feature set retains +the existing floating-point behavior. + ## API ```rust diff --git a/crates/seuil/Cargo.toml b/crates/seuil/Cargo.toml index 43d497b..4886faa 100644 --- a/crates/seuil/Cargo.toml +++ b/crates/seuil/Cargo.toml @@ -12,7 +12,12 @@ categories = ["parser-implementations", "encoding", "data-structures", "compiler documentation = "https://docs.rs/seuil" rust-version = "1.77.0" +[features] +default = [] +bigdecimal = ["dep:bigdecimal", "serde_json/arbitrary_precision"] + [dependencies] +bigdecimal = { version = "0.4", optional = true } bumpalo = { workspace = true } hashbrown = { workspace = true } indexmap = { workspace = true } diff --git a/crates/seuil/README.md b/crates/seuil/README.md index 1cde6ca..82689a9 100644 --- a/crates/seuil/README.md +++ b/crates/seuil/README.md @@ -53,6 +53,49 @@ seuil = "0.1" serde_json = "1" ``` +## Decimal arithmetic (optional) + +Enable the `bigdecimal` feature to preserve decimal precision: + +```toml +seuil = { version = "0.1", features = ["bigdecimal"] } +``` + +This also enables `serde_json/arbitrary_precision`. Parse JSON text with +`evaluate_str` or `serde_json::from_str` to retain every input digit; a Rust `f64` +literal passed to `json!` has already been rounded before Seuil receives it. + +```rust +use seuil::Seuil; + +let expr = Seuil::compile("orders[status='paid'].amount ~> $sum()")?; +let result = expr.evaluate_str(r#"{"orders":[ + {"status":"paid","amount":"100.30"}, + {"status":"pending","amount":"50.40"}, + {"status":"paid","amount":"200.25"} +]}"#)?; +assert_eq!(result, serde_json::json!("300.55")); +``` + +Numeric functions accept decimal strings as well as numbers. If an operand is a +string, the numeric result is a normalized decimal string; number-only inputs +produce JSON numbers. `$number` explicitly converts a string to a number. +Ordinary strings retain their type outside numeric functions. Arithmetic +operators still require numbers: use `$number` to convert a string first. + +Addition, subtraction, multiplication, remainder, comparisons, and aggregates +use decimal arithmetic. Division, averages, square roots, and powers use +BigDecimal's precision rules (normally 100 significant digits for inexact +results). `$round` uses half-even rounding and accepts precision from -10,000 to +10,000. `$power` supports integer and half-integer exponents; other fractional +exponents return `D3061` instead of falling back to floating point. `$random` +retains the existing environment-provided random number behavior. + +The feature intentionally extends JSONata's numeric semantics: decimal strings +are accepted, numbers can exceed the `f64` range, and `$string` preserves numeric +precision instead of truncating to 15 digits. The default feature set retains +the existing floating-point behavior. + ## API ```rust diff --git a/crates/seuil/src/decimal.rs b/crates/seuil/src/decimal.rs new file mode 100644 index 0000000..a958044 --- /dev/null +++ b/crates/seuil/src/decimal.rs @@ -0,0 +1,27 @@ +//! Decimal conversion shared by the opt-in evaluator and numeric functions. +use bigdecimal::BigDecimal; + +use crate::evaluator::value::Value; +use crate::{Error, Result}; + +/// Parse the decimal text directly; never pass arbitrary-precision input through f64. +pub(crate) fn parse(text: &str) -> Result { + text.parse() + .map_err(|_| Error::D3137Error("Invalid decimal number".into())) +} + +pub(crate) fn number(value: &Value<'_>) -> Result { + match value { + Value::Number(n) if n.is_finite() => parse(&n.to_string()), + Value::Decimal(n) => parse(n), + _ => Err(Error::D3137Error("Expected a finite decimal number".into())), + } +} + +/// Keep the existing small-number representation when its decimal text is exact. +/// Larger values remain arena-owned text, so BigDecimal's heap allocations are +/// dropped after each operation rather than leaked inside the bump arena. +pub(crate) fn lossless_f64(value: &BigDecimal) -> Option { + let n = value.to_string().parse::().ok()?; + (n.is_finite() && parse(&n.to_string()).ok().as_ref() == Some(value)).then_some(n) +} diff --git a/crates/seuil/src/evaluator/engine.rs b/crates/seuil/src/evaluator/engine.rs index dec5ff7..bc187ee 100644 --- a/crates/seuil/src/evaluator/engine.rs +++ b/crates/seuil/src/evaluator/engine.rs @@ -159,6 +159,8 @@ impl<'a, 'env> Evaluator<'a, 'env> { AstKind::Bool(b) => Ok(self.val_bool(b)), AstKind::String(ref s) => Ok(Value::string(self.arena, s)), AstKind::Number(n) => Ok(Value::number(self.arena, n)), + #[cfg(feature = "bigdecimal")] + AstKind::Decimal(ref n) => Ok(Value::decimal(self.arena, crate::decimal::parse(n)?)), AstKind::Regex(ref r) => Ok(self.arena.alloc(Value::Regex(r.clone()))), AstKind::Block(ref exprs) => self.evaluate_block(exprs, input), @@ -299,6 +301,10 @@ impl<'a, 'env> Evaluator<'a, 'env> { let result = self.evaluate(value, input)?; match result { Value::Undefined => Ok(self.undefined()), + #[cfg(feature = "bigdecimal")] + Value::Decimal(_) => { + Ok(Value::decimal(self.arena, -crate::decimal::number(result)?)) + } Value::Number(n) if result.is_valid_number()? => { Ok(Value::number(self.arena, -*n)) } @@ -372,7 +378,7 @@ impl<'a, 'env> Evaluator<'a, 'env> { let l = if lhs.is_undefined() { return Ok(self.undefined()); } else if lhs.is_valid_number()? { - lhs.as_f64() + lhs } else { return Err(Error::T2001LeftSideNotNumber(node.span, op.to_string())); }; @@ -380,11 +386,28 @@ impl<'a, 'env> Evaluator<'a, 'env> { let r = if rhs.is_undefined() { return Ok(self.undefined()); } else if rhs.is_valid_number()? { - rhs.as_f64() + rhs } else { return Err(Error::T2002RightSideNotNumber(node.span, op.to_string())); }; + #[cfg(feature = "bigdecimal")] + { + let l = crate::decimal::number(lhs)?; + let r = crate::decimal::number(rhs)?; + if !matches!(op, BinaryOp::Divide | BinaryOp::Modulus) || r != 0 { + let result = match op { + BinaryOp::Add => &l + &r, + BinaryOp::Subtract => &l - &r, + BinaryOp::Multiply => &l * &r, + BinaryOp::Divide => &l / &r, + BinaryOp::Modulus => &l % &r, + _ => return Err(Error::D3137Error("Invalid decimal operator".into())), + }; + return Ok(Value::decimal(self.arena, result)); + } + } + let (l, r) = (l.as_f64(), r.as_f64()); let result = match op { BinaryOp::Add => l + r, BinaryOp::Subtract => l - r, @@ -421,8 +444,10 @@ impl<'a, 'env> Evaluator<'a, 'env> { } if lhs.is_number() && rhs.is_number() { - let l = lhs.as_f64(); - let r = rhs.as_f64(); + #[cfg(not(feature = "bigdecimal"))] + let (l, r) = (lhs.as_f64(), rhs.as_f64()); + #[cfg(feature = "bigdecimal")] + let (l, r) = (crate::decimal::number(lhs)?, crate::decimal::number(rhs)?); return Ok(self.val_bool(match op { BinaryOp::LessThan => l < r, BinaryOp::LessThanEqual => l <= r, @@ -949,6 +974,16 @@ impl<'a, 'env> Evaluator<'a, 'env> { let ord = match (aa, bb) { (Value::String(ref sa), Value::String(ref sb)) => sa.cmp(sb), + #[cfg(feature = "bigdecimal")] + (a, b) if a.is_number() && b.is_number() => { + match (crate::decimal::number(a), crate::decimal::number(b)) { + (Ok(a), Ok(b)) => a.cmp(&b), + (Err(e), _) | (_, Err(e)) => { + sort_error = Some(e); + std::cmp::Ordering::Equal + } + } + } (Value::Number(na), Value::Number(nb)) => na.total_cmp(nb), _ => { sort_error = Some(Error::T2007CompareTypeMismatch( @@ -1761,6 +1796,8 @@ fn simple_stringify<'a>(value: &'a Value<'a>, buf: &mut String) { Value::Undefined => {} Value::Null => buf.push_str("null"), Value::Bool(b) => buf.push_str(if *b { "true" } else { "false" }), + #[cfg(feature = "bigdecimal")] + Value::Decimal(n) => buf.push_str(n), Value::Number(n) => { if n.is_nan() || n.is_infinite() { buf.push_str("null"); @@ -1818,6 +1855,8 @@ mod tests { Value::Null => serde_json::Value::Null, Value::Bool(b) => serde_json::Value::Bool(*b), Value::Number(n) => serde_json::json!(*n), + #[cfg(feature = "bigdecimal")] + Value::Decimal(n) => serde_json::from_str(n).unwrap(), Value::String(ref s) => serde_json::Value::String(s.as_str().to_string()), Value::Array(ref a, _) => { let arr: Vec = a.iter().map(|v| value_to_json(v)).collect(); diff --git a/crates/seuil/src/evaluator/functions/array.rs b/crates/seuil/src/evaluator/functions/array.rs index 151da63..0ea5ba2 100644 --- a/crates/seuil/src/evaluator/functions/array.rs +++ b/crates/seuil/src/evaluator/functions/array.rs @@ -109,6 +109,10 @@ pub fn fn_sort<'a>(context: FnContext<'a, '_>, args: &[&'a Value<'a>]) -> Result let cmp_result = (context.apply_fn)(span, context.input, comparator, &[a, b])?; // Comparator should return a number. Negative = a < b, positive = a > b. // Or a boolean: true = swap (a > b). + #[cfg(feature = "bigdecimal")] + if let Value::Decimal(_) = cmp_result { + return Ok(crate::decimal::number(cmp_result)? > 0); + } if let Value::Number(n) = cmp_result { Ok(*n > 0.0) } else if let Value::Bool(b) = cmp_result { @@ -130,6 +134,10 @@ pub fn fn_sort<'a>(context: FnContext<'a, '_>, args: &[&'a Value<'a>]) -> Result let sorted = merge_sort( unsorted, &|a: &'a Value<'a>, b: &'a Value<'a>| match (a, b) { + #[cfg(feature = "bigdecimal")] + (a, b) if a.is_number() && b.is_number() => { + Ok(crate::decimal::number(a)? > crate::decimal::number(b)?) + } (Value::Number(a), Value::Number(b)) => Ok(a > b), (Value::String(a), Value::String(b)) => Ok(a > b), _ => Err(Error::D3070InvalidDefaultSort(Span::at(context.char_index))), diff --git a/crates/seuil/src/evaluator/functions/mod.rs b/crates/seuil/src/evaluator/functions/mod.rs index 3600d5d..6cdf5d6 100644 --- a/crates/seuil/src/evaluator/functions/mod.rs +++ b/crates/seuil/src/evaluator/functions/mod.rs @@ -7,6 +7,10 @@ pub mod array; pub mod datetime; pub mod hof; +#[cfg(not(feature = "bigdecimal"))] +pub mod numeric; +#[cfg(feature = "bigdecimal")] +#[path = "numeric_decimal.rs"] pub mod numeric; pub mod object; pub mod string; diff --git a/crates/seuil/src/evaluator/functions/numeric_decimal.rs b/crates/seuil/src/evaluator/functions/numeric_decimal.rs new file mode 100644 index 0000000..05b0922 --- /dev/null +++ b/crates/seuil/src/evaluator/functions/numeric_decimal.rs @@ -0,0 +1,260 @@ +//! Numeric functions for the opt-in decimal mode. +use bigdecimal::{BigDecimal, RoundingMode, ToPrimitive, Zero}; + +use super::{max_args, min_args}; +use crate::decimal::{number, parse}; +use crate::evaluator::value::{ArrayFlags, FnContext, Value}; +use crate::{Error, Result, Span}; + +fn argument(context: FnContext<'_, '_>, value: &Value<'_>, index: usize) -> Result { + let result = match value { + Value::String(s) => parse(s), + _ => number(value), + }; + result.map_err(|_| { + Error::T0410ArgumentNotValid( + Span::at(context.char_index), + index, + context.name.to_string(), + ) + }) +} + +fn output<'a>(context: FnContext<'a, '_>, n: BigDecimal, string: bool) -> &'a Value<'a> { + if string { + Value::string(context.arena, &n.normalized().to_string()) + } else { + Value::decimal(context.arena, n) + } +} + +pub fn fn_number<'a>(context: FnContext<'a, '_>, args: &[&'a Value<'a>]) -> Result<&'a Value<'a>> { + max_args!(context, args, 1); + let arg = args + .first() + .copied() + .unwrap_or_else(|| Value::undefined(context.arena)); + match arg { + Value::Undefined => Ok(arg), + Value::Number(_) | Value::Decimal(_) => Ok(arg), + Value::Bool(b) => Ok(Value::number(context.arena, i32::from(*b))), + Value::String(s) => { + let n = parse(s).map_err(|_| { + Error::D3030NonNumericCast(Span::at(context.char_index), arg.to_string()) + })?; + Ok(output(context, n, false)) + } + _ => Err(Error::T0410ArgumentNotValid( + Span::at(context.char_index), + 1, + context.name.to_string(), + )), + } +} + +fn unary<'a>( + context: FnContext<'a, '_>, + args: &[&'a Value<'a>], + op: fn(BigDecimal) -> BigDecimal, +) -> Result<&'a Value<'a>> { + max_args!(context, args, 1); + let arg = args + .first() + .copied() + .unwrap_or_else(|| Value::undefined(context.arena)); + if arg.is_undefined() { + return Ok(arg); + } + Ok(output( + context, + op(argument(context, arg, 1)?), + arg.is_string(), + )) +} + +pub fn fn_abs<'a>(context: FnContext<'a, '_>, args: &[&'a Value<'a>]) -> Result<&'a Value<'a>> { + unary(context, args, |n| n.abs()) +} + +pub fn fn_floor<'a>(context: FnContext<'a, '_>, args: &[&'a Value<'a>]) -> Result<&'a Value<'a>> { + unary(context, args, |n| { + n.with_scale_round(0, RoundingMode::Floor) + }) +} + +pub fn fn_ceil<'a>(context: FnContext<'a, '_>, args: &[&'a Value<'a>]) -> Result<&'a Value<'a>> { + unary(context, args, |n| { + n.with_scale_round(0, RoundingMode::Ceiling) + }) +} + +pub fn fn_round<'a>(context: FnContext<'a, '_>, args: &[&'a Value<'a>]) -> Result<&'a Value<'a>> { + max_args!(context, args, 2); + let arg = args + .first() + .copied() + .unwrap_or_else(|| Value::undefined(context.arena)); + if arg.is_undefined() { + return Ok(arg); + } + let n = argument(context, arg, 1)?; + let precision = if let Some(p) = args.get(1) { + let p = argument(context, p, 2)?; + if !p.is_integer() { + return Err(Error::T0410ArgumentNotValid( + Span::at(context.char_index), + 2, + context.name.to_string(), + )); + } + p.to_i64().ok_or_else(|| { + Error::T0410ArgumentNotValid(Span::at(context.char_index), 2, context.name.to_string()) + })? + } else { + 0 + }; + // A scale is an allocation request, so reject pathological precision values. + if !(-10_000..=10_000).contains(&precision) { + return Err(Error::T0410ArgumentNotValid( + Span::at(context.char_index), + 2, + context.name.to_string(), + )); + } + Ok(output( + context, + n.with_scale_round(precision, RoundingMode::HalfEven), + arg.is_string(), + )) +} + +pub fn fn_sqrt<'a>(context: FnContext<'a, '_>, args: &[&'a Value<'a>]) -> Result<&'a Value<'a>> { + max_args!(context, args, 1); + let arg = args + .first() + .copied() + .unwrap_or_else(|| Value::undefined(context.arena)); + if arg.is_undefined() { + return Ok(arg); + } + let n = argument(context, arg, 1)?; + let result = n + .sqrt() + .ok_or_else(|| Error::D3060SqrtNegative(Span::at(context.char_index), n.to_string()))?; + Ok(output(context, result, arg.is_string())) +} + +pub fn fn_power<'a>(context: FnContext<'a, '_>, args: &[&'a Value<'a>]) -> Result<&'a Value<'a>> { + max_args!(context, args, 2); + let arg = args + .first() + .copied() + .unwrap_or_else(|| Value::undefined(context.arena)); + if arg.is_undefined() { + return Ok(arg); + } + let exp = args + .get(1) + .copied() + .unwrap_or_else(|| Value::undefined(context.arena)); + let n = argument(context, arg, 1)?; + let e = argument(context, exp, 2)?; + let error = || { + Error::D3061PowUnrepresentable(Span::at(context.char_index), n.to_string(), e.to_string()) + }; + // Half-integer powers can also be evaluated entirely with decimal arithmetic. + let doubled = &e * 2i32; + let (base, exponent) = if e.is_integer() { + (n.clone(), e.to_i64().ok_or_else(error)?) + } else if doubled.is_integer() { + ( + n.sqrt().ok_or_else(error)?, + doubled.to_i64().ok_or_else(error)?, + ) + } else { + return Err(error()); + }; + if exponent == i64::MIN || (base.is_zero() && exponent < 0) { + return Err(error()); + } + let result = if exponent == 0 { + BigDecimal::from(1) + } else { + base.powi(exponent) + }; + Ok(output(context, result, arg.is_string() || exp.is_string())) +} + +pub fn fn_random<'a>(context: FnContext<'a, '_>, args: &[&'a Value<'a>]) -> Result<&'a Value<'a>> { + max_args!(context, args, 0); + Ok(Value::number(context.arena, rand::random::())) +} + +#[derive(Clone, Copy)] +enum Aggregate { + Sum, + Average, + Min, + Max, +} + +fn aggregate<'a>( + context: FnContext<'a, '_>, + args: &[&'a Value<'a>], + op: Aggregate, +) -> Result<&'a Value<'a>> { + max_args!(context, args, 1); + let arg = args + .first() + .copied() + .unwrap_or_else(|| Value::undefined(context.arena)); + if arg.is_undefined() { + return Ok(arg); + } + let arr = Value::wrap_in_array_if_needed(context.arena, arg, ArrayFlags::empty()); + if arr.is_empty() && !matches!(op, Aggregate::Sum) { + return Ok(Value::undefined(context.arena)); + } + let mut result = BigDecimal::zero(); + let mut string = false; + let mut count = 0u64; + for member in arr.members() { + let n = argument(context, member, 1).map_err(|_| { + Error::T0412ArgumentMustBeArrayOfType( + Span::at(context.char_index), + 1, + context.name.to_string(), + "number".into(), + ) + })?; + string |= member.is_string(); + result = match op { + Aggregate::Sum | Aggregate::Average => result + n, + Aggregate::Min if count == 0 || n < result => n, + Aggregate::Max if count == 0 || n > result => n, + _ => result, + }; + count += 1; + } + if matches!(op, Aggregate::Average) { + result = result / BigDecimal::from(count); + } + Ok(output(context, result, string)) +} + +pub fn fn_sum<'a>(context: FnContext<'a, '_>, args: &[&'a Value<'a>]) -> Result<&'a Value<'a>> { + min_args!(context, args, 1); + aggregate(context, args, Aggregate::Sum) +} + +pub fn fn_average<'a>(context: FnContext<'a, '_>, args: &[&'a Value<'a>]) -> Result<&'a Value<'a>> { + aggregate(context, args, Aggregate::Average) +} + +pub fn fn_min<'a>(context: FnContext<'a, '_>, args: &[&'a Value<'a>]) -> Result<&'a Value<'a>> { + aggregate(context, args, Aggregate::Min) +} + +pub fn fn_max<'a>(context: FnContext<'a, '_>, args: &[&'a Value<'a>]) -> Result<&'a Value<'a>> { + aggregate(context, args, Aggregate::Max) +} diff --git a/crates/seuil/src/evaluator/functions/object.rs b/crates/seuil/src/evaluator/functions/object.rs index 593ee7f..5984dff 100644 --- a/crates/seuil/src/evaluator/functions/object.rs +++ b/crates/seuil/src/evaluator/functions/object.rs @@ -306,6 +306,8 @@ pub fn fn_type<'a>(context: FnContext<'a, '_>, args: &[&'a Value<'a>]) -> Result Value::Undefined => return Ok(Value::undefined(context.arena)), Value::Null => "null", Value::Number(..) => "number", + #[cfg(feature = "bigdecimal")] + Value::Decimal(..) => "number", Value::Bool(..) => "boolean", Value::String(..) => "string", Value::Array(..) | Value::Range(..) => "array", diff --git a/crates/seuil/src/evaluator/functions/type_ops.rs b/crates/seuil/src/evaluator/functions/type_ops.rs index 9116dfb..d0e773e 100644 --- a/crates/seuil/src/evaluator/functions/type_ops.rs +++ b/crates/seuil/src/evaluator/functions/type_ops.rs @@ -20,6 +20,8 @@ pub fn fn_boolean<'a>(context: FnContext<'a, '_>, args: &[&'a Value<'a>]) -> Res Value::Undefined => Value::undefined(context.arena), Value::Null => Value::bool_val(context.arena, false), Value::Bool(b) => Value::bool_val(context.arena, *b), + #[cfg(feature = "bigdecimal")] + Value::Decimal(_) => Value::bool_val(context.arena, arg.is_truthy()), Value::Number(n) => { arg.is_valid_number()?; Value::bool_val(context.arena, *n != 0.0) diff --git a/crates/seuil/src/evaluator/value/impls.rs b/crates/seuil/src/evaluator/value/impls.rs index f5f5a1d..f7cde24 100644 --- a/crates/seuil/src/evaluator/value/impls.rs +++ b/crates/seuil/src/evaluator/value/impls.rs @@ -6,6 +6,12 @@ use super::Value; impl<'a> PartialEq> for Value<'a> { fn eq(&self, other: &Value<'a>) -> bool { + #[cfg(feature = "bigdecimal")] + if self.is_number() && other.is_number() { + if let (Ok(l), Ok(r)) = (crate::decimal::number(self), crate::decimal::number(other)) { + return l == r; + } + } match (self, other) { (Value::Undefined, Value::Undefined) => true, (Value::Null, Value::Null) => true, @@ -59,6 +65,8 @@ impl std::fmt::Debug for Value<'_> { Value::Undefined => write!(f, "undefined"), Value::Null => write!(f, "null"), Value::Number(n) => n.fmt(f), + #[cfg(feature = "bigdecimal")] + Value::Decimal(n) => f.write_str(n), Value::Bool(b) => b.fmt(f), Value::String(s) => s.fmt(f), Value::Array(a, _) => a.fmt(f), @@ -83,12 +91,21 @@ impl std::fmt::Display for Value<'_> { impl Hash for Value<'_> { fn hash(&self, state: &mut H) { + #[cfg(feature = "bigdecimal")] + if let Ok(n) = crate::decimal::number(self) { + // Decimal and f64 values that compare equal must hash equally. + std::mem::discriminant(&Value::Number(0.0)).hash(state); + n.normalized().to_string().hash(state); + return; + } // Discriminant tag std::mem::discriminant(self).hash(state); match self { Value::Undefined => {} Value::Null => {} Value::Number(n) => n.to_bits().hash(state), + #[cfg(feature = "bigdecimal")] + Value::Decimal(n) => n.hash(state), Value::Bool(b) => b.hash(state), Value::String(s) => s.hash(state), Value::Array(a, _) => a.hash(state), diff --git a/crates/seuil/src/evaluator/value/mod.rs b/crates/seuil/src/evaluator/value/mod.rs index ccffeb6..9ca0536 100644 --- a/crates/seuil/src/evaluator/value/mod.rs +++ b/crates/seuil/src/evaluator/value/mod.rs @@ -40,6 +40,8 @@ pub enum Value<'a> { Undefined, Null, Number(f64), + #[cfg(feature = "bigdecimal")] + Decimal(BumpString<'a>), Bool(bool), String(BumpString<'a>), Regex(std::boxed::Box), @@ -117,12 +119,30 @@ impl<'a> Value<'a> { arena.alloc(Value::Number(value.into())) } + #[cfg(feature = "bigdecimal")] + pub fn decimal(arena: &'a Bump, value: bigdecimal::BigDecimal) -> &'a mut Value<'a> { + if let Some(n) = crate::decimal::lossless_f64(&value) { + return Value::number(arena, n); + } + arena.alloc(Value::Decimal(BumpString::from_str_in( + &value.to_string(), + arena, + ))) + } + pub fn number_from_u128(arena: &'a Bump, value: u128) -> Result<&'a mut Value<'a>> { - let value_f64 = value as f64; - if value_f64 as u128 != value { - return Err(Error::D1001NumberOutOfRange(value_f64)); + #[cfg(feature = "bigdecimal")] + { + Ok(Value::decimal(arena, bigdecimal::BigDecimal::from(value))) + } + #[cfg(not(feature = "bigdecimal"))] + { + let value_f64 = value as f64; + if value_f64 as u128 != value { + return Err(Error::D1001NumberOutOfRange(value_f64)); + } + Ok(arena.alloc(Value::Number(value_f64))) } - Ok(arena.alloc(Value::Number(value_f64))) } pub fn string(arena: &'a Bump, value: &str) -> &'a mut Value<'a> { @@ -199,12 +219,18 @@ impl<'a> Value<'a> { } pub fn is_number(&self) -> bool { + #[cfg(feature = "bigdecimal")] + if matches!(self, Value::Decimal(..)) { + return true; + } matches!(self, Value::Number(..)) } pub fn is_integer(&self) -> bool { match self { Value::Number(n) => n.is_finite() && n.trunc() == *n, + #[cfg(feature = "bigdecimal")] + Value::Decimal(n) => crate::decimal::parse(n).is_ok_and(|n| n.is_integer()), _ => false, } } @@ -230,6 +256,11 @@ impl<'a> Value<'a> { pub fn is_valid_number(&self) -> Result { match self { + #[cfg(feature = "bigdecimal")] + Value::Decimal(n) => { + crate::decimal::parse(n)?; + Ok(true) + } Value::Number(n) => { if n.is_nan() { Ok(false) @@ -244,6 +275,10 @@ impl<'a> Value<'a> { } pub fn is_finite(&self) -> bool { + #[cfg(feature = "bigdecimal")] + if matches!(self, Value::Decimal(..)) { + return true; + } matches!(self, Value::Number(n) if n.is_finite()) } @@ -256,6 +291,10 @@ impl<'a> Value<'a> { pub fn try_as_f64(&self) -> Result { match self { Value::Number(n) => Ok(*n), + #[cfg(feature = "bigdecimal")] + Value::Decimal(n) => n + .parse::() + .map_err(|_| Error::D3137Error("Invalid decimal".into())), _ => Err(Error::D1001NumberOutOfRange(0.0)), } } @@ -270,6 +309,8 @@ impl<'a> Value<'a> { /// Panicking accessor — only use when type is guaranteed. pub fn as_f64(&self) -> f64 { match self { + #[cfg(feature = "bigdecimal")] + Value::Decimal(n) => n.parse::().unwrap_or(f64::NAN), Value::Number(n) => *n, _ => panic!("Value::as_f64 called on non-number"), } @@ -277,6 +318,8 @@ impl<'a> Value<'a> { pub fn as_usize(&self) -> usize { match self { + #[cfg(feature = "bigdecimal")] + Value::Decimal(n) => n.parse::().unwrap_or(f64::NAN) as usize, Value::Number(n) => *n as usize, _ => panic!("Value::as_usize called on non-number"), } @@ -284,6 +327,8 @@ impl<'a> Value<'a> { pub fn as_isize(&self) -> isize { match self { + #[cfg(feature = "bigdecimal")] + Value::Decimal(n) => n.parse::().unwrap_or(f64::NAN) as isize, Value::Number(n) => *n as isize, _ => panic!("Value::as_isize called on non-number"), } @@ -432,6 +477,8 @@ impl<'a> Value<'a> { Value::Undefined => false, Value::Null => false, Value::Number(n) => *n != 0.0, + #[cfg(feature = "bigdecimal")] + Value::Decimal(n) => crate::decimal::parse(n).is_ok_and(|n| n != 0), Value::Bool(b) => *b, Value::String(ref s) => !s.is_empty(), Value::Array(ref a, _) => match a.len() { @@ -453,6 +500,8 @@ impl<'a> Value<'a> { Value::Undefined => arena.alloc(Value::Undefined), Value::Null => Value::null(arena), Value::Number(n) => Value::number(arena, *n), + #[cfg(feature = "bigdecimal")] + Value::Decimal(n) => arena.alloc(Value::Decimal(BumpString::from_str_in(n, arena))), Value::Bool(b) => arena.alloc(Value::Bool(*b)), Value::String(s) => arena.alloc(Value::String(s.clone())), Value::Array(a, f) => Value::array_from(arena, a.clone(), *f), @@ -541,7 +590,17 @@ impl<'a> Value<'a> { match json { serde_json::Value::Null => Value::null(arena), serde_json::Value::Bool(b) => arena.alloc(Value::Bool(*b)), + #[cfg(not(feature = "bigdecimal"))] serde_json::Value::Number(n) => Value::number(arena, n.as_f64().unwrap()), + #[cfg(feature = "bigdecimal")] + serde_json::Value::Number(n) => { + // serde_json already validated this number's text. + let text = n.to_string(); + match crate::decimal::parse(&text) { + Ok(decimal) => Value::decimal(arena, decimal), + Err(_) => arena.alloc(Value::Decimal(BumpString::from_str_in(&text, arena))), + } + } serde_json::Value::String(s) => Value::string(arena, s), serde_json::Value::Array(a) => { let array = Value::array_with_capacity(arena, a.len(), ArrayFlags::empty()); diff --git a/crates/seuil/src/evaluator/value/serialize.rs b/crates/seuil/src/evaluator/value/serialize.rs index c77f837..88afe23 100644 --- a/crates/seuil/src/evaluator/value/serialize.rs +++ b/crates/seuil/src/evaluator/value/serialize.rs @@ -157,6 +157,7 @@ impl Serializer { self.write_char(b'"'); } + #[cfg(not(feature = "bigdecimal"))] #[inline(always)] fn write_number(&mut self, number: f64) { const MAX_SIGNIFICANT_DIGITS: usize = 15; @@ -256,11 +257,25 @@ impl Serializer { Value::Undefined => {} Value::Null => self.write(b"null"), Value::String(ref string) => self.write_string(string), + #[cfg(feature = "bigdecimal")] + Value::Decimal(n) => { + crate::decimal::parse(n)?; + self.write(n.as_bytes()); + } Value::Number(n) => { if self.fail_on_invalid_numbers { value.is_valid_number()?; } + #[cfg(not(feature = "bigdecimal"))] self.write_number(*n); + #[cfg(feature = "bigdecimal")] + if n.is_finite() { + let mut buffer = dtoa::Buffer::new(); + let text = buffer.format_finite(*n); + self.write(text.strip_suffix(".0").unwrap_or(text).as_bytes()); + } else { + self.write(b"null"); + } } Value::Bool(true) => self.write(b"true"), Value::Bool(false) => self.write(b"false"), diff --git a/crates/seuil/src/lib.rs b/crates/seuil/src/lib.rs index 62b589b..9871cad 100644 --- a/crates/seuil/src/lib.rs +++ b/crates/seuil/src/lib.rs @@ -32,6 +32,8 @@ pub mod clock; pub mod datetime; +#[cfg(feature = "bigdecimal")] +mod decimal; pub mod errors; pub mod evaluator; pub mod parser; @@ -369,6 +371,8 @@ fn value_to_json(val: &evaluator::value::Value<'_>) -> serde_json::Value { Value::Null => serde_json::Value::Null, Value::Bool(b) => serde_json::Value::Bool(*b), Value::Number(n) => serde_json::json!(*n), + #[cfg(feature = "bigdecimal")] + Value::Decimal(n) => serde_json::from_str(n).unwrap_or(serde_json::Value::Null), Value::String(ref s) => serde_json::Value::String(s.as_str().to_string()), Value::Array(ref a, _) => { let arr: Vec = a.iter().map(|v| value_to_json(v)).collect(); diff --git a/crates/seuil/src/parser/ast.rs b/crates/seuil/src/parser/ast.rs index db8ae71..1beabaa 100644 --- a/crates/seuil/src/parser/ast.rs +++ b/crates/seuil/src/parser/ast.rs @@ -160,6 +160,8 @@ pub enum AstKind { Bool(bool), String(String), Number(f64), + #[cfg(feature = "bigdecimal")] + Decimal(String), Regex(Box), Name(String), Var(String), diff --git a/crates/seuil/src/parser/pratt.rs b/crates/seuil/src/parser/pratt.rs index a0c6a15..5969aa9 100644 --- a/crates/seuil/src/parser/pratt.rs +++ b/crates/seuil/src/parser/pratt.rs @@ -42,6 +42,8 @@ impl Symbol for Token { TokenKind::Bool(ref v) => Ok(Ast::new(AstKind::Bool(*v), self.span)), TokenKind::Str(ref v) => Ok(Ast::new(AstKind::String(v.clone()), self.span)), TokenKind::Number(v) => Ok(Ast::new(AstKind::Number(v), self.span)), + #[cfg(feature = "bigdecimal")] + TokenKind::Decimal(ref v) => Ok(Ast::new(AstKind::Decimal(v.clone()), self.span)), TokenKind::Regex(ref v) => Ok(Ast::new(AstKind::Regex(v.clone()), self.span)), TokenKind::Name(ref v) => Ok(Ast::new(AstKind::Name(v.clone()), self.span)), TokenKind::Var(ref v) => Ok(Ast::new(AstKind::Var(v.clone()), self.span)), diff --git a/crates/seuil/src/parser/process.rs b/crates/seuil/src/parser/process.rs index 89b4cfb..60991f6 100644 --- a/crates/seuil/src/parser/process.rs +++ b/crates/seuil/src/parser/process.rs @@ -189,6 +189,10 @@ fn process_path(span: Span, lhs: &mut Box, rhs: &mut Box) -> Result { return Err(Error::S0213InvalidStep(step.span, "TODO".to_string())); } + #[cfg(feature = "bigdecimal")] + AstKind::Decimal(..) => { + return Err(Error::S0213InvalidStep(step.span, "number".to_string())); + } AstKind::String(ref s) => { step.kind = AstKind::Name(s.clone()); } diff --git a/crates/seuil/src/parser/tokenizer.rs b/crates/seuil/src/parser/tokenizer.rs index 8db4691..9b310cd 100644 --- a/crates/seuil/src/parser/tokenizer.rs +++ b/crates/seuil/src/parser/tokenizer.rs @@ -62,6 +62,8 @@ pub enum TokenKind { Bool(bool), Str(String), Number(f64), + #[cfg(feature = "bigdecimal")] + Decimal(String), Regex(Box), // Identifiers @@ -118,6 +120,8 @@ impl std::fmt::Display for TokenKind { Str(v) => write!(f, "\"{v}\""), Regex(v) => write!(f, "/{}/", v.as_pattern()), Number(v) => write!(f, "{v}"), + #[cfg(feature = "bigdecimal")] + Decimal(v) => write!(f, "{v}"), Name(v) => write!(f, "{v}"), Var(v) => write!(f, "${v}"), } @@ -632,18 +636,29 @@ impl<'a> Tokenizer<'a> { } let slice = &self.input[self.start_byte_index..self.byte_index]; - let n = slice - .parse::() - .map_err(|_| Error::S0201SyntaxError(self.current_span(), slice.to_string()))?; - - match n.classify() { - std::num::FpCategory::Infinite - | std::num::FpCategory::Nan - | std::num::FpCategory::Subnormal => Err(Error::S0102LexedNumberOutOfRange( - self.current_span(), - self.token_string(), - )), - _ => Ok(TokenKind::Number(n)), + #[cfg(feature = "bigdecimal")] + { + let decimal = crate::decimal::parse(slice) + .map_err(|_| Error::S0201SyntaxError(self.current_span(), slice.to_string()))?; + Ok(match crate::decimal::lossless_f64(&decimal) { + Some(n) => TokenKind::Number(n), + None => TokenKind::Decimal(decimal.to_string()), + }) + } + #[cfg(not(feature = "bigdecimal"))] + { + let n = slice + .parse::() + .map_err(|_| Error::S0201SyntaxError(self.current_span(), slice.to_string()))?; + match n.classify() { + std::num::FpCategory::Infinite + | std::num::FpCategory::Nan + | std::num::FpCategory::Subnormal => Err(Error::S0102LexedNumberOutOfRange( + self.current_span(), + self.token_string(), + )), + _ => Ok(TokenKind::Number(n)), + } } } } diff --git a/crates/seuil/tests/bigdecimal.rs b/crates/seuil/tests/bigdecimal.rs new file mode 100644 index 0000000..52aaf4e --- /dev/null +++ b/crates/seuil/tests/bigdecimal.rs @@ -0,0 +1,229 @@ +#![cfg(feature = "bigdecimal")] + +use bigdecimal::BigDecimal; +use serde_json::{json, Value}; +use seuil::{EvalConfig, Seuil}; + +fn exact(expr: &str, data: &str, expected: &str) { + let actual = Seuil::compile(expr).unwrap().evaluate_str(data).unwrap(); + assert!( + actual.is_number(), + "{expr}: expected JSON number, got {actual}" + ); + assert_eq!( + actual.to_string().parse::().unwrap(), + expected.parse::().unwrap(), + "{expr}" + ); +} + +#[test] +fn arbitrary_precision_json_and_expression_literals() { + exact( + "$sum(amounts)", + r#"{"amounts":[9007199254740993,0.00000000000000000001]}"#, + "9007199254740993.00000000000000000001", + ); + exact( + "9007199254740993 + 0.00000000000000000001", + "null", + "9007199254740993.00000000000000000001", + ); + exact("$sum([0.1, 0.2])", "null", "0.3"); + exact("$", "1e400", "1e400"); + exact("$sum($)", "[1e400,1e400]", "2e400"); + exact("$", "1e-400", "1e-400"); +} + +#[test] +fn exact_arithmetic_and_comparisons() { + for (expr, expected) in [ + ("9007199254740993 - 9007199254740992", "1"), + ("9007199254740993 * 3", "27021597764222979"), + ("9007199254740993 / 3", "3002399751580331"), + ("9007199254740993 % 2", "1"), + ("-9007199254740993", "-9007199254740993"), + ("0.1 + 0.2", "0.3"), + ("1e-310 + 1e-310", "2e-310"), + ("$power(0, 0)", "1"), + ("$power(4, 0.5)", "2"), + ("$power(4, -0.5)", "0.5"), + ] { + exact(expr, "null", expected); + } + for expr in [ + "9007199254740993 > 9007199254740992", + "9007199254740993 != 9007199254740992", + "9007199254740993 = $number('9007199254740993')", + "0.1 + 0.2 = 0.3", + ] { + assert_eq!( + Seuil::compile(expr).unwrap().evaluate_empty().unwrap(), + json!(true), + "{expr}" + ); + } +} + +#[test] +fn numeric_strings_and_mixed_aggregates() { + for (expr, expected) in [ + ("$sum(['100.30', '200.25'])", "300.55"), + ("$sum(['9007199254740993', 1])", "9007199254740994"), + ("$average(['0.1', '0.2'])", "0.15"), + ( + "$min(['9007199254740993', '9007199254740992'])", + "9007199254740992", + ), + ( + "$max(['9007199254740993', '9007199254740992'])", + "9007199254740993", + ), + ("$abs('-9007199254740993.25')", "9007199254740993.25"), + ("$floor('-9007199254740993.25')", "-9007199254740994"), + ("$ceil('-9007199254740993.25')", "-9007199254740993"), + ("$round('2.345', 2)", "2.34"), + ("$round('2.355', 2)", "2.36"), + ("$round('-2.355', 2)", "-2.36"), + ("$round('1250', -2)", "1200"), + ( + "$power('9007199254740993', 2)", + "81129638414606699710187514626049", + ), + ("$power('2', -3)", "0.125"), + ("$sqrt('4')", "2"), + ("$sum(['0.1','0.2']) ~> $abs()", "0.3"), + ] { + assert_eq!( + Seuil::compile(expr).unwrap().evaluate_empty().unwrap(), + json!(expected), + "{expr}" + ); + } + exact( + "$number('9007199254740993.000000000001')", + "null", + "9007199254740993.000000000001", + ); +} + +#[test] +fn numeric_inputs_remain_numbers() { + for (expr, expected) in [ + ("$sum($)", "18014398509481987"), + ("$average($)", "9007199254740993.5"), + ("$min($)", "9007199254740993"), + ("$max($)", "9007199254740994"), + ] { + exact(expr, "[9007199254740993,9007199254740994]", expected); + } + exact("$abs($)", "-9007199254740993.25", "9007199254740993.25"); + exact("$floor($)", "9007199254740993.25", "9007199254740993"); + exact("$ceil($)", "9007199254740993.25", "9007199254740994"); + exact( + "$round($, 2)", + "9007199254740993.125", + "9007199254740993.12", + ); + exact( + "$sqrt($)", + "81129638414606699710187514626049", + "9007199254740993", + ); +} + +#[test] +fn decimal_values_integrate_with_sort_type_boolean_and_string() { + let data = "[9007199254740993,9007199254740992,9007199254740994]"; + let expected: Value = + serde_json::from_str("[9007199254740992,9007199254740993,9007199254740994]").unwrap(); + // Compare decimal values, allowing the ordinary number representation for exact small values. + for expr in ["$sort($)", "$^(<$)"] { + let result = Seuil::compile(expr).unwrap().evaluate_str(data).unwrap(); + let result = result.as_array().unwrap(); + for (a, b) in result.iter().zip(expected.as_array().unwrap()) { + assert_eq!( + a.to_string().parse::().unwrap(), + b.to_string().parse::().unwrap() + ); + } + assert_eq!(result.len(), 3); + } + for (expr, expected) in [ + ("$type(9007199254740993)", json!("number")), + ("$boolean(9007199254740993)", json!(true)), + ("$string(9007199254740993)", json!("9007199254740993")), + ("$string(0.1234567890123456)", json!("0.1234567890123456")), + ("9007199254740993 & ''", json!("9007199254740993")), + ( + "$count($distinct([9007199254740993, $number('9007199254740993')]))", + json!(1.0), + ), + ] { + assert_eq!( + Seuil::compile(expr).unwrap().evaluate_empty().unwrap(), + expected, + "{expr}" + ); + } +} + +#[test] +fn bindings_and_nested_results_preserve_precision() { + let mut bindings = serde_json::Map::new(); + bindings.insert( + "amount".into(), + serde_json::from_str("9007199254740993.123456789").unwrap(), + ); + let result = Seuil::compile("{'amount': [$amount]}") + .unwrap() + .evaluate_with_config_and_bindings(&Value::Null, &EvalConfig::default(), Some(&bindings)) + .unwrap(); + assert_eq!( + result["amount"][0].to_string(), + "9007199254740993.123456789" + ); + assert_eq!( + Seuil::compile("name") + .unwrap() + .evaluate(&json!({"name":"00123"})) + .unwrap(), + json!("00123") + ); +} + +#[test] +fn invalid_values_return_errors_and_empty_inputs_keep_semantics() { + for expr in [ + "$sum(['oops'])", + "$sum(['NaN'])", + "$sum(['Infinity'])", + "$sum([null])", + "$sum([true])", + "$sqrt('-1')", + "$power('0', -1)", + "$round('1.2', 0.5)", + "$round('1', 9223372036854775807)", + "$round('1', -9223372036854775808)", + "$power('2', 0.3)", + ] { + assert!( + Seuil::compile(expr).unwrap().evaluate_empty().is_err(), + "{expr}" + ); + } + assert_eq!( + Seuil::compile("$sum([])") + .unwrap() + .evaluate_empty() + .unwrap(), + json!(0.0) + ); + for expr in ["$average([])", "$min([])", "$max([])", "$sum(missing)"] { + assert_eq!( + Seuil::compile(expr).unwrap().evaluate_empty().unwrap(), + Value::Null, + "{expr}" + ); + } +} diff --git a/crates/seuil/tests/issue_1.rs b/crates/seuil/tests/issue_1.rs new file mode 100644 index 0000000..4e3ab5a --- /dev/null +++ b/crates/seuil/tests/issue_1.rs @@ -0,0 +1,22 @@ +use serde_json::json; +use seuil::Seuil; + +#[test] +fn issue_1_paid_order_decimal_sum() -> seuil::Result<()> { + let expr = Seuil::compile("orders[status='paid'].amount ~> $sum()")?; + let data = json!({ + "orders": [ + {"status": "paid", "amount": "100.30"}, + {"status": "pending", "amount": "50.40"}, + {"status": "paid", "amount": "200.25"} + ] + }); + #[cfg(feature = "bigdecimal")] + assert_eq!(expr.evaluate(&data)?, json!("300.55")); + #[cfg(not(feature = "bigdecimal"))] + assert!(matches!( + expr.evaluate(&data), + Err(seuil::Error::T0412ArgumentMustBeArrayOfType(..)) + )); + Ok(()) +} diff --git a/docs/src/installation.md b/docs/src/installation.md index 1c481d0..f792a64 100644 --- a/docs/src/installation.md +++ b/docs/src/installation.md @@ -16,7 +16,26 @@ The MSRV is **1.77.0**. This is enforced in CI and specified in `Cargo.toml` via ## Feature Flags -seuil-rs has no optional feature flags. All 47 built-in functions, all operators, and the full JSONata language are available by default. +All built-in functions and operators are available by default. Enable optional +`bigdecimal` support for arbitrary-precision JSON numbers and decimal numeric +functions: + +```toml +seuil = { version = "0.1", features = ["bigdecimal"] } +``` + +This enables `serde_json/arbitrary_precision`. Use `evaluate_str` or parse JSON +text to avoid rounding inputs through Rust floating-point literals. Numeric +functions accept decimal strings and return strings when given string operands; +number-only inputs return JSON numbers. `$number` converts explicitly to a number. +For example, `$sum(["100.30", "200.25"])` returns `"300.55"`. + +Inexact division, averages, roots, and powers follow BigDecimal's precision rules +(normally 100 significant digits). `$power` supports integer and half-integer +exponents; other fractional exponents return `D3061`. `$round` uses half-even +rounding with precision between -10,000 and 10,000. `$random` remains unchanged. +With this feature, string conversion preserves decimal precision and values can +exceed the floating-point range. Without it, existing behavior is unchanged. ## Dependencies From 8bb32f56411dbb19488550764fd61f84ae25db01 Mon Sep 17 00:00:00 2001 From: zuub-don Date: Thu, 22 Oct 2026 21:40:30 -0700 Subject: [PATCH 2/2] docs: clarify BigDecimal quick-start example --- README.md | 31 ++++++++++++++++++++++--------- crates/seuil/README.md | 31 ++++++++++++++++++++++--------- docs/src/installation.md | 5 +++-- 3 files changed, 47 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 82689a9..9f29712 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,9 @@ let result = expr.evaluate(&data)?; assert_eq!(result, serde_json::json!(300.0)); ``` +For exact decimal amounts such as `"100.30"`, see the optional +[BigDecimal example](#decimal-arithmetic-optional) below. + ## Why seuil? - **99.6% JSONata spec compliance** — 1,027 of 1,031 official test cases pass @@ -55,10 +58,13 @@ serde_json = "1" ## Decimal arithmetic (optional) -Enable the `bigdecimal` feature to preserve decimal precision: +Enable the `bigdecimal` feature to preserve decimal precision. Until this feature +is released on crates.io, use the Git revision containing the implementation: ```toml -seuil = { version = "0.1", features = ["bigdecimal"] } +[dependencies] +seuil = { git = "https://github.com/copyleftdev/seuil-rs", rev = "0d8d280d8da4384e2c1625c21d8512a1aa0ec7ff", features = ["bigdecimal"] } +serde_json = "1" ``` This also enables `serde_json/arbitrary_precision`. Parse JSON text with @@ -68,13 +74,20 @@ literal passed to `json!` has already been rounded before Seuil receives it. ```rust use seuil::Seuil; -let expr = Seuil::compile("orders[status='paid'].amount ~> $sum()")?; -let result = expr.evaluate_str(r#"{"orders":[ - {"status":"paid","amount":"100.30"}, - {"status":"pending","amount":"50.40"}, - {"status":"paid","amount":"200.25"} -]}"#)?; -assert_eq!(result, serde_json::json!("300.55")); +fn main() -> seuil::Result<()> { + let expr = Seuil::compile("orders[status='paid'].amount ~> $sum()")?; + let data = serde_json::json!({ + "orders": [ + {"status": "paid", "amount": "100.30"}, + {"status": "pending", "amount": "50.40"}, + {"status": "paid", "amount": "200.25"} + ] + }); + + let result = expr.evaluate(&data)?; + assert_eq!(result, serde_json::json!("300.55")); // 100.30 + 200.25 + Ok(()) +} ``` Numeric functions accept decimal strings as well as numbers. If an operand is a diff --git a/crates/seuil/README.md b/crates/seuil/README.md index 82689a9..9f29712 100644 --- a/crates/seuil/README.md +++ b/crates/seuil/README.md @@ -32,6 +32,9 @@ let result = expr.evaluate(&data)?; assert_eq!(result, serde_json::json!(300.0)); ``` +For exact decimal amounts such as `"100.30"`, see the optional +[BigDecimal example](#decimal-arithmetic-optional) below. + ## Why seuil? - **99.6% JSONata spec compliance** — 1,027 of 1,031 official test cases pass @@ -55,10 +58,13 @@ serde_json = "1" ## Decimal arithmetic (optional) -Enable the `bigdecimal` feature to preserve decimal precision: +Enable the `bigdecimal` feature to preserve decimal precision. Until this feature +is released on crates.io, use the Git revision containing the implementation: ```toml -seuil = { version = "0.1", features = ["bigdecimal"] } +[dependencies] +seuil = { git = "https://github.com/copyleftdev/seuil-rs", rev = "0d8d280d8da4384e2c1625c21d8512a1aa0ec7ff", features = ["bigdecimal"] } +serde_json = "1" ``` This also enables `serde_json/arbitrary_precision`. Parse JSON text with @@ -68,13 +74,20 @@ literal passed to `json!` has already been rounded before Seuil receives it. ```rust use seuil::Seuil; -let expr = Seuil::compile("orders[status='paid'].amount ~> $sum()")?; -let result = expr.evaluate_str(r#"{"orders":[ - {"status":"paid","amount":"100.30"}, - {"status":"pending","amount":"50.40"}, - {"status":"paid","amount":"200.25"} -]}"#)?; -assert_eq!(result, serde_json::json!("300.55")); +fn main() -> seuil::Result<()> { + let expr = Seuil::compile("orders[status='paid'].amount ~> $sum()")?; + let data = serde_json::json!({ + "orders": [ + {"status": "paid", "amount": "100.30"}, + {"status": "pending", "amount": "50.40"}, + {"status": "paid", "amount": "200.25"} + ] + }); + + let result = expr.evaluate(&data)?; + assert_eq!(result, serde_json::json!("300.55")); // 100.30 + 200.25 + Ok(()) +} ``` Numeric functions accept decimal strings as well as numbers. If an operand is a diff --git a/docs/src/installation.md b/docs/src/installation.md index f792a64..a41a772 100644 --- a/docs/src/installation.md +++ b/docs/src/installation.md @@ -18,10 +18,11 @@ The MSRV is **1.77.0**. This is enforced in CI and specified in `Cargo.toml` via All built-in functions and operators are available by default. Enable optional `bigdecimal` support for arbitrary-precision JSON numbers and decimal numeric -functions: +functions. Until this feature is released on crates.io, use the Git revision +containing the implementation: ```toml -seuil = { version = "0.1", features = ["bigdecimal"] } +seuil = { git = "https://github.com/copyleftdev/seuil-rs", rev = "0d8d280d8da4384e2c1625c21d8512a1aa0ec7ff", features = ["bigdecimal"] } ``` This enables `serde_json/arbitrary_precision`. Use `evaluate_str` or parse JSON