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..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 @@ -53,6 +56,59 @@ seuil = "0.1" serde_json = "1" ``` +## Decimal arithmetic (optional) + +Enable the `bigdecimal` feature to preserve decimal precision. Until this feature +is released on crates.io, use the Git revision containing the implementation: + +```toml +[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 +`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; + +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 +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..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 @@ -53,6 +56,59 @@ seuil = "0.1" serde_json = "1" ``` +## Decimal arithmetic (optional) + +Enable the `bigdecimal` feature to preserve decimal precision. Until this feature +is released on crates.io, use the Git revision containing the implementation: + +```toml +[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 +`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; + +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 +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..a41a772 100644 --- a/docs/src/installation.md +++ b/docs/src/installation.md @@ -16,7 +16,27 @@ 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. Until this feature is released on crates.io, use the Git revision +containing the implementation: + +```toml +seuil = { git = "https://github.com/copyleftdev/seuil-rs", rev = "0d8d280d8da4384e2c1625c21d8512a1aa0ec7ff", 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