use crate::ast::{Arg, FnCall};
use crate::value::Value;
type Scale<'a> = &'a dyn Fn(&str) -> Option<(f64, String)>;
const SCALAR: &[&str] = &[
"upper",
"lower",
"trim",
"chars",
"wc",
"lines",
"words",
"split",
"round",
"floor",
"ceil",
"abs",
"json",
"xml",
"record",
"rec",
"default",
"datetime",
"epoch",
"isoformat",
"year",
"month",
"day",
"hour",
"minute",
"second",
"weekday",
"date",
"seconds",
"minutes",
"hours",
"days",
"duration",
"td",
"strptime",
"tp",
"quantity",
"convert",
"isodate",
"isomonth",
"isoweek",
"strftime",
"tfmt",
"sh",
"sha256",
"base64",
"base64url",
"base32",
"crockford32",
"hex",
"decode",
"dec",
];
const AGGREGATE: &[&str] = &[
"count", "sum", "product", "min", "max", "mean", "avg", "median", "stddev", "variance", "sort",
"unique", "reverse", "first", "last", "join", "ungroup", "window", "shift",
];
pub fn context_only(name: &str) -> bool {
matches!(name, "ungroup" | "window" | "shift")
}
const KEYED: &[&str] = &[
"sort_by",
"unique_by",
"min_by",
"max_by",
"top",
"bottom",
"group",
];
#[cfg(feature = "colligo")]
pub(crate) fn collator_for(call: &FnCall) -> Option<colligo::Collator> {
let tag = match call.args.first()? {
crate::ast::Arg::Lit(l) => l.to_string(),
_ => return None,
};
Some(
colligo::Collator::builder(&tag)
.allow_approximate(true)
.build()
.unwrap_or_else(|_| colligo::Collator::root()),
)
}
#[cfg(not(feature = "colligo"))]
pub(crate) struct NeverCollator(std::convert::Infallible);
#[cfg(not(feature = "colligo"))]
impl NeverCollator {
pub(crate) fn compare(&self, _a: &str, _b: &str) -> std::cmp::Ordering {
match self.0 {}
}
}
#[cfg(not(feature = "colligo"))]
pub(crate) fn collator_for(_call: &FnCall) -> Option<NeverCollator> {
None
}
pub fn known_keyed(name: &str) -> bool {
KEYED.contains(&name)
}
pub fn known_scalar(name: &str) -> bool {
SCALAR.contains(&name)
}
pub fn known_agg(name: &str) -> bool {
AGGREGATE.contains(&name) || known_keyed(name)
}
pub fn apply_scalar(
call: &FnCall,
topic: Value,
scale: &dyn Fn(&str) -> Option<(f64, String)>,
) -> Vec<Value> {
let text = topic.to_string();
match call.name.as_str() {
"upper" => vec![Value::Str(text.to_uppercase())],
"lower" => vec![Value::Str(text.to_lowercase())],
"trim" => vec![Value::Str(text.trim().to_string())],
"chars" => vec![Value::Int(text.chars().count() as i64)],
"wc" => vec![Value::Int(text.split_whitespace().count() as i64)],
"lines" => text.lines().map(|s| Value::Str(s.to_string())).collect(),
"words" => text
.split_whitespace()
.map(|s| Value::Str(s.to_string()))
.collect(),
"split" => {
let sep = arg_str(call, 0, ",");
text.split(sep).map(|s| Value::Str(s.to_string())).collect()
}
"sha256" => vec![Value::Str(crate::encoding::sha256_hex(text.as_bytes()))],
"base64" => vec![Value::Str(crate::encoding::base64(text.as_bytes()))],
"base64url" => vec![Value::Str(crate::encoding::base64url(text.as_bytes()))],
"base32" => vec![Value::Str(crate::encoding::base32(text.as_bytes()))],
"crockford32" => vec![Value::Str(crate::encoding::crockford32(text.as_bytes()))],
"decode" | "dec" => {
let scheme = arg_str(call, 0, "");
vec![match scheme {
"json" => crate::encoding::json_to_value(&text).unwrap_or(Value::Null),
"yaml" => crate::encoding::yaml_to_value(&text).unwrap_or(Value::Null),
"toml" => crate::encoding::toml_to_value(&text).unwrap_or(Value::Null),
"xml" => crate::encoding::xml_to_value(&text).unwrap_or(Value::Null),
_ => crate::encoding::decode(scheme, &text)
.and_then(|b| String::from_utf8(b).ok())
.map(Value::Str)
.unwrap_or(Value::Null),
}]
}
"hex" => vec![Value::Str(crate::encoding::hex(text.as_bytes()))],
"datetime" => vec![match &topic {
Value::Instant { .. } => topic.clone(),
Value::Str(s) => crate::temporal::parse_iso(s)
.map(|(secs, nanos, offset_min)| Value::Instant {
secs,
nanos,
offset_min,
})
.unwrap_or(Value::Null),
other => other
.temporal_reading()
.map(|(secs, nanos)| Value::Instant {
secs,
nanos,
offset_min: None,
})
.unwrap_or(Value::Null),
}],
"epoch" => vec![
topic
.temporal_reading()
.map(|(secs, _)| Value::Int(secs))
.unwrap_or(Value::Null),
],
"isoformat" => vec![
topic
.temporal_reading()
.map(|(s, n)| Value::Str(crate::temporal::format_instant(s, n, None)))
.unwrap_or(Value::Null),
],
"year" | "month" | "day" | "hour" | "minute" | "second" => {
vec![
topic
.temporal_reading()
.map(|(secs, _)| {
let (y, mo, d, h, mi, se) = crate::temporal::components(secs);
Value::Int(match call.name.as_str() {
"year" => y,
"month" => mo as i64,
"day" => d as i64,
"hour" => h as i64,
"minute" => mi as i64,
_ => se as i64,
})
})
.unwrap_or(Value::Null),
]
}
"isodate" | "isomonth" | "isoweek" => vec![
topic
.temporal_reading()
.map(|(secs, _)| {
let (y, mo, d, ..) = crate::temporal::components(secs);
Value::Str(match call.name.as_str() {
"isodate" => format!("{y:04}-{mo:02}-{d:02}"),
"isomonth" => format!("{y:04}-{mo:02}"),
_ => {
let (gy, gw) = crate::temporal::iso_week(secs);
format!("{gy:04}-W{gw:02}")
}
})
})
.unwrap_or(Value::Null),
],
"strftime" | "tfmt" => {
let fmt = arg_str(call, 0, "%Y-%m-%dT%H:%M:%S").to_string();
vec![match &topic {
Value::Instant {
secs,
nanos,
offset_min,
} => Value::Str(crate::temporal::strftime(&fmt, *secs, *nanos, *offset_min)),
other => other
.temporal_reading()
.map(|(s, n)| Value::Str(crate::temporal::strftime(&fmt, s, n, None)))
.unwrap_or(Value::Null),
}]
}
"weekday" => vec![
topic
.temporal_reading()
.map(|(secs, _)| Value::Int(crate::temporal::weekday(secs) as i64))
.unwrap_or(Value::Null),
],
"date" => vec![
topic
.temporal_reading()
.map(|(secs, _)| Value::Instant {
secs: secs.div_euclid(86400) * 86400,
nanos: 0,
offset_min: None,
})
.unwrap_or(Value::Null),
],
"seconds" | "minutes" | "hours" | "days" => {
let unit: f64 = match call.name.as_str() {
"seconds" => 1.0,
"minutes" => 60.0,
"hours" => 3600.0,
_ => 86400.0,
};
vec![
topic
.numeric()
.map(|n| {
let total = n * unit;
Value::Duration {
secs: total.floor() as i64,
nanos: ((total - total.floor()) * 1e9) as u32,
}
})
.unwrap_or(Value::Null),
]
}
"duration" | "td" => vec![
topic
.durational_reading()
.or_else(|| match &topic {
Value::Str(s) => crate::temporal::span_from_units(s, scale),
_ => None,
})
.map(|(secs, nanos)| Value::Duration { secs, nanos })
.unwrap_or(Value::Null),
],
"strptime" | "tp" => {
let fmt = arg_str(call, 0, "%Y-%m-%dT%H:%M:%S").to_string();
vec![match &topic {
Value::Str(s) => crate::temporal::strptime(s, &fmt)
.map(|(secs, nanos, offset_min)| Value::Instant {
secs,
nanos,
offset_min,
})
.unwrap_or(Value::Null),
_ => Value::Null,
}]
}
"quantity" => vec![match &topic {
Value::Quantity { .. } => topic.clone(),
Value::Str(s) => crate::quantity::parse_unit_text_with(s, scale)
.map(|(value, base, wv, wu)| Value::Quantity {
value,
base,
written: Some((wv, wu)),
})
.unwrap_or(Value::Null),
_ => Value::Null,
}],
"convert" => {
let target = arg_str(call, 0, "").to_string();
vec![match &topic {
Value::Quantity { value, base, .. } => match scale(&target) {
Some((factor, tbase)) if &tbase == base => Value::Quantity {
value: *value,
base: base.clone(),
written: Some((*value / factor, target)),
},
_ => Value::Null,
},
_ => Value::Null,
}]
}
"round" | "floor" | "ceil" if matches!(topic, Value::Quantity { .. }) => {
let Value::Quantity {
value,
base,
written,
} = &topic
else {
unreachable!()
};
let (wv, wu) = written.clone().unwrap_or_else(|| (*value, base.clone()));
let rounded = match call.name.as_str() {
"round" => match round_digits(call) {
Some(d) => round_to(wv, d),
None => wv.round(),
},
"floor" => wv.floor(),
_ => wv.ceil(),
};
let factor = scale(&wu).map(|(f, _)| f).unwrap_or(1.0);
vec![Value::Quantity {
value: rounded * factor,
base: base.clone(),
written: Some((rounded, wu)),
}]
}
"round" => vec![numeric_scalar(&topic, |n| match round_digits(call) {
Some(d) => Value::Float(round_to(n, d)),
None => Value::Int(n.round() as i64),
})],
"floor" => vec![numeric_scalar(&topic, |n| Value::Int(n.floor() as i64))],
"ceil" => vec![numeric_scalar(&topic, |n| Value::Int(n.ceil() as i64))],
"abs" => vec![match topic {
Value::Int(n) => Value::Int(n.abs()),
Value::Quantity { .. } => Value::Null,
other => numeric_scalar(&other, |n| Value::Float(n.abs())),
}],
"s" => {
let pattern = arg_str(call, 0, "");
let replacement = arg_str(call, 1, "");
let mods = arg_str(call, 2, "");
let case = if mods.contains('i') { "(?i)" } else { "" };
let Ok(re) = regex::Regex::new(&format!("{case}{pattern}")) else {
return vec![topic];
};
let out = if mods.contains('g') {
re.replace_all(&text, replacement)
} else {
re.replace(&text, replacement)
};
vec![Value::Str(out.into_owned())]
}
"default" => vec![match topic {
Value::Null => call
.args
.first()
.and_then(|a| match a {
Arg::Lit(v) => Some(v.clone()),
Arg::Expr(_) | Arg::Range(_, _) => None,
})
.unwrap_or(Value::Null),
other => other,
}],
"json" => vec![Value::Str(topic.to_json())],
_ => vec![topic],
}
}
fn numeric_scalar(topic: &Value, f: impl Fn(f64) -> Value) -> Value {
topic.numeric().map(f).unwrap_or(Value::Null)
}
fn round_digits(call: &FnCall) -> Option<i64> {
match call.args.first() {
Some(Arg::Lit(Value::Int(d))) => Some(*d),
_ => None,
}
}
fn round_to(n: f64, d: i64) -> f64 {
let factor = 10f64.powi(d.clamp(-30, 30) as i32);
let scaled = n * factor;
if scaled.is_finite() {
scaled.round() / factor
} else {
n
}
}
pub fn apply(call: &FnCall, input: Vec<Value>, scale: Scale) -> Vec<Value> {
match call.name.as_str() {
"count" => vec![Value::Int(input.len() as i64)],
"sum" => vec![sum(&input, scale)],
"product" => vec![product(&input)],
"min" => extreme(input, std::cmp::Ordering::Less, scale),
"max" => extreme(input, std::cmp::Ordering::Greater, scale),
"mean" | "avg" => vec![mean(&input, scale)],
"median" => vec![median(&input, scale)],
"stddev" => vec![spread(&input, f64::sqrt, scale)],
"variance" => vec![spread(&input, |v| v, scale)],
"join" => vec![Value::Str(join(&input, arg_str(call, 0, "")))],
"sort" => {
let mut v = input;
match collator_for(call) {
Some(c) => v.sort_by(|a, b| c.compare(&a.to_string(), &b.to_string())),
None => v.sort_by(|a, b| a.compare_with(b, scale)),
}
v
}
"reverse" => {
let mut v = input;
v.reverse();
v
}
"unique" => {
let mut seen: Vec<String> = Vec::new();
input
.into_iter()
.filter(|v| {
let k = v.to_string();
if seen.contains(&k) {
false
} else {
seen.push(k);
true
}
})
.collect()
}
"first" => input.into_iter().next().into_iter().collect(),
"last" => input.into_iter().next_back().into_iter().collect(),
_ => input,
}
}
enum DurFold {
Absent,
Spans(Vec<(i64, u32)>),
Unsound,
}
fn durational_fold(input: &[Value]) -> DurFold {
if !input.iter().any(|v| matches!(v, Value::Duration { .. })) {
return DurFold::Absent;
}
match input.iter().map(Value::durational_reading).collect() {
Some(spans) => DurFold::Spans(spans),
None => DurFold::Unsound,
}
}
enum QuantFold {
Absent,
Same {
base: String,
unit: Option<(f64, String)>,
mags: Vec<f64>,
},
Unsound,
}
fn quantital_fold(input: &[Value], scale: Scale) -> QuantFold {
if !input.iter().any(|v| matches!(v, Value::Quantity { .. })) {
return QuantFold::Absent;
}
let mut base: Option<String> = None;
let mut unit: Option<(f64, String)> = None;
let mut unit_ok = true;
let mut mags = Vec::with_capacity(input.len());
for v in input {
let (value, b, written): (f64, String, Option<(f64, String)>) = match v {
Value::Quantity {
value,
base,
written,
} => (*value, base.clone(), written.clone()),
Value::Str(s) => match crate::quantity::parse_unit_text_with(s, scale) {
Some((bv, b, wmag, wunit)) => (bv, b, Some((wmag, wunit))),
None => return QuantFold::Unsound,
},
_ => return QuantFold::Unsound,
};
match &base {
None => base = Some(b),
Some(prev) if *prev == b => {}
Some(_) => return QuantFold::Unsound,
}
match (unit_ok, &written) {
(true, Some((mag, u))) if *mag != 0.0 => match &unit {
None => unit = Some((value / mag, u.clone())),
Some((_, prev)) if prev == u => {}
Some(_) => unit_ok = false,
},
_ => unit_ok = false,
}
mags.push(value);
}
match base {
Some(base) => QuantFold::Same {
base,
unit: unit.filter(|_| unit_ok),
mags,
},
None => QuantFold::Absent,
}
}
fn quantital_result(value: f64, base: String, unit: Option<(f64, String)>) -> Value {
Value::Quantity {
value,
base,
written: unit.map(|(f, u)| (value / f, u)),
}
}
fn sum(input: &[Value], scale: Scale) -> Value {
match quantital_fold(input, scale) {
QuantFold::Unsound => return Value::Null,
QuantFold::Same { base, unit, mags } => {
let value = mags.iter().sum();
return quantital_result(value, base, unit);
}
QuantFold::Absent => {}
}
let spans = match durational_fold(input) {
DurFold::Unsound => return Value::Null,
DurFold::Spans(spans) => Some(spans),
DurFold::Absent => None,
};
if let Some(spans) = spans {
let mut secs: i64 = 0;
let mut nanos: i64 = 0;
for (s, n) in spans {
match secs.checked_add(s) {
Some(t) => secs = t,
None => return Value::Null,
}
nanos += n as i64;
}
match secs.checked_add(nanos.div_euclid(1_000_000_000)) {
Some(t) => secs = t,
None => return Value::Null,
}
return Value::Duration {
secs,
nanos: nanos.rem_euclid(1_000_000_000) as u32,
};
}
if !input.iter().any(|v| v.numeric().is_some()) {
return Value::Null;
}
if input.iter().all(|v| matches!(v, Value::Int(_))) {
let mut acc: i64 = 0;
let mut overflowed = false;
for v in input {
if let Value::Int(n) = v {
match acc.checked_add(*n) {
Some(s) => acc = s,
None => {
overflowed = true;
break;
}
}
}
}
if !overflowed {
return Value::Int(acc);
}
}
Value::Float(input.iter().filter_map(Value::numeric).sum())
}
fn product(input: &[Value]) -> Value {
if input.iter().all(|v| matches!(v, Value::Int(_))) {
let mut acc: i64 = 1;
let mut overflowed = false;
for v in input {
if let Value::Int(n) = v {
match acc.checked_mul(*n) {
Some(p) => acc = p,
None => {
overflowed = true;
break;
}
}
}
}
if !overflowed {
return Value::Int(acc);
}
}
Value::Float(input.iter().filter_map(Value::numeric).product())
}
fn mean(input: &[Value], scale: Scale) -> Value {
match quantital_fold(input, scale) {
QuantFold::Unsound => return Value::Null,
QuantFold::Same { base, unit, mags } => {
let value = mags.iter().sum::<f64>() / mags.len() as f64;
return quantital_result(value, base, unit);
}
QuantFold::Absent => {}
}
let spans = match durational_fold(input) {
DurFold::Unsound => return Value::Null,
DurFold::Spans(spans) => Some(spans),
DurFold::Absent => None,
};
if let Some(spans) = spans {
let total: i128 = spans
.iter()
.map(|(s, n)| *s as i128 * 1_000_000_000 + *n as i128)
.sum();
let avg = total / spans.len() as i128;
return Value::Duration {
secs: (avg.div_euclid(1_000_000_000)) as i64,
nanos: avg.rem_euclid(1_000_000_000) as u32,
};
}
let nums: Vec<f64> = input.iter().filter_map(Value::numeric).collect();
if nums.is_empty() {
Value::Null
} else {
Value::Float(nums.iter().sum::<f64>() / nums.len() as f64)
}
}
fn median(input: &[Value], scale: Scale) -> Value {
match quantital_fold(input, scale) {
QuantFold::Unsound => return Value::Null,
QuantFold::Same {
base,
unit,
mut mags,
} => {
mags.sort_by(f64::total_cmp);
let mid = mags.len() / 2;
let value = if mags.len() % 2 == 1 {
mags[mid]
} else {
(mags[mid - 1] + mags[mid]) / 2.0
};
return quantital_result(value, base, unit);
}
QuantFold::Absent => {}
}
if matches!(durational_fold(input), DurFold::Unsound) {
return Value::Null;
}
let mut nums: Vec<f64> = input.iter().filter_map(Value::numeric).collect();
if nums.is_empty() {
return Value::Null;
}
nums.sort_by(f64::total_cmp);
let mid = nums.len() / 2;
if nums.len() % 2 == 1 {
let m = nums[mid];
if input.iter().all(|v| matches!(v, Value::Int(_))) {
Value::Int(m as i64)
} else {
Value::Float(m)
}
} else {
Value::Float((nums[mid - 1] + nums[mid]) / 2.0)
}
}
fn spread(input: &[Value], finish: impl Fn(f64) -> f64, scale: Scale) -> Value {
let nums: Vec<f64> = match quantital_fold(input, scale) {
QuantFold::Unsound => return Value::Null,
QuantFold::Same { mags, .. } => mags,
QuantFold::Absent => {
if matches!(durational_fold(input), DurFold::Unsound) {
return Value::Null;
}
input.iter().filter_map(Value::numeric).collect()
}
};
if nums.is_empty() {
return Value::Null;
}
let n = nums.len() as f64;
let m = nums.iter().sum::<f64>() / n;
let var = nums.iter().map(|x| (x - m) * (x - m)).sum::<f64>() / n;
Value::Float(finish(var))
}
fn has_reading(v: &Value, scale: Scale) -> bool {
v.numeric().is_some()
|| v.temporal_reading().is_some()
|| v.durational_reading().is_some()
|| match v {
Value::Str(s) => crate::quantity::parse_unit_text_with(s, scale).is_some(),
Value::Quantity { .. } => true,
_ => false,
}
}
fn extreme(input: Vec<Value>, want: std::cmp::Ordering, scale: Scale) -> Vec<Value> {
if matches!(quantital_fold(&input, scale), QuantFold::Unsound) {
return vec![Value::Null];
}
match input
.into_iter()
.filter(|v| has_reading(v, scale))
.reduce(|a, b| {
if a.compare_with(&b, scale) == want {
a
} else {
b
}
}) {
Some(v) => vec![v],
None => vec![Value::Null],
}
}
fn join(input: &[Value], sep: &str) -> String {
input
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(sep)
}
fn arg_str<'a>(call: &'a FnCall, n: usize, default: &'a str) -> &'a str {
match call.args.get(n) {
Some(Arg::Lit(Value::Str(s))) => s,
_ => default,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn agg(name: &str, input: Vec<Value>) -> Vec<Value> {
let call = FnCall {
name: name.into(),
args: Vec::new(),
};
apply(&call, input, &crate::quantity::scale_expr)
}
fn sc(name: &str, topic: Value) -> Vec<Value> {
let call = FnCall {
name: name.into(),
args: Vec::new(),
};
apply_scalar(&call, topic, &crate::quantity::scale_expr)
}
fn ints(ns: &[i64]) -> Vec<Value> {
ns.iter().map(|&n| Value::Int(n)).collect()
}
#[test]
fn mean_and_alias() {
assert_eq!(agg("mean", ints(&[1, 2, 3, 4])), vec![Value::Float(2.5)]);
assert_eq!(agg("avg", ints(&[1, 2, 3, 4])), vec![Value::Float(2.5)]);
assert_eq!(agg("mean", vec![]), vec![Value::Null]);
}
#[test]
fn quantital_folds_type_and_refuse() {
let q = |value: f64, written: Option<(f64, &str)>| Value::Quantity {
value,
base: "kg*m^2/s^3".into(),
written: written.map(|(m, u)| (m, u.to_string())),
};
let watts = vec![q(142.5, Some((142.5, "W"))), q(290.0, Some((290.0, "W")))];
assert_eq!(
agg("sum", watts.clone()),
vec![q(432.5, Some((432.5, "W")))]
);
assert_eq!(agg("mean", watts), vec![q(216.25, Some((216.25, "W")))]);
let mixed = vec![q(1200.0, Some((1.2, "kW"))), q(350.0, Some((350.0, "W")))];
assert_eq!(agg("sum", mixed.clone()), vec![q(1550.0, None)]);
assert_eq!(agg("median", mixed), vec![q(775.0, None)]);
let bad = vec![
q(100.0, Some((100.0, "W"))),
Value::Quantity {
value: 2000.0,
base: "m".into(),
written: Some((2.0, "km".into())),
},
];
for f in ["sum", "mean", "median", "min", "max", "stddev"] {
assert_eq!(agg(f, bad.clone()), vec![Value::Null], "{f}");
}
assert_eq!(
agg("sum", vec![q(1.0, None), Value::Int(5)]),
vec![Value::Null]
);
}
#[test]
fn durational_sum_and_mean() {
let d = |secs: i64| Value::Duration { secs, nanos: 0 };
assert_eq!(
agg("sum", vec![d(2700), d(43200), d(10800)]),
vec![d(56700)] );
assert_eq!(
agg("mean", vec![d(2700), d(43200), d(10800)]),
vec![d(18900)] );
assert_eq!(
agg(
"sum",
vec![d(60), Value::Str("2min".into()), Value::Int(60)]
),
vec![d(240)]
);
assert_eq!(
agg("sum", vec![d(60), Value::Str("soon".into())]),
vec![Value::Null]
);
assert_eq!(
agg("sum", vec![d(60), Value::Str("5".into())]),
vec![Value::Null]
);
assert_eq!(
agg("mean", vec![d(60), Value::Str("5".into())]),
vec![Value::Null]
);
assert_eq!(
agg("median", vec![d(60), Value::Str("5".into())]),
vec![Value::Null]
);
assert_eq!(
agg("stddev", vec![d(60), Value::Str("5".into())]),
vec![Value::Null]
);
}
#[test]
fn compare_is_a_total_order() {
assert_eq!(
agg(
"sort",
vec![
Value::Int(10),
Value::Str("1z".into()),
Value::Str("9".into())
]
),
vec![
Value::Str("9".into()),
Value::Int(10),
Value::Str("1z".into())
]
);
assert_eq!(
agg(
"sort",
vec![
Value::Str("b".into()),
Value::Str("2h".into()),
Value::Int(5),
Value::Null,
Value::Bool(true),
]
),
vec![
Value::Null,
Value::Bool(true),
Value::Int(5),
Value::Str("2h".into()), Value::Str("b".into()),
]
);
}
#[test]
fn compare_with_pays_the_custom_resolver() {
use std::cmp::Ordering;
let custom = |e: &str| (e == "zorkmid").then(|| (3.0, "m".to_string()));
let (a, b) = (
Value::Str("10 zorkmid".into()),
Value::Str("5 zorkmid".into()),
);
assert_eq!(a.compare_with(&b, &custom), Ordering::Greater);
assert_eq!(
a.compare_with(&b, &crate::quantity::scale_expr),
Ordering::Less
);
}
#[test]
fn quantital_fold_lifts_text_refuses_numbers() {
let q = |value: f64, written: Option<(f64, &str)>| Value::Quantity {
value,
base: "B".into(),
written: written.map(|(v, u)| (v, u.to_string())),
};
let mixed = vec![
q(10_000_000.0, Some((10.0, "MB"))),
Value::Str("5MB".into()),
];
assert_eq!(agg("sum", mixed.clone())[0].to_string(), "15 MB");
assert_eq!(agg("mean", mixed.clone())[0].to_string(), "7.5 MB");
assert_eq!(agg("min", mixed)[0].to_string(), "5MB");
assert_eq!(
agg("sum", vec![q(10_000_000.0, None), Value::Int(5)]),
vec![Value::Null]
);
assert_eq!(
agg("sum", vec![q(10_000_000.0, None), Value::Str("5km".into())]),
vec![Value::Null]
);
assert_eq!(
agg(
"sum",
vec![q(10_000_000.0, None), Value::Str("soon".into())]
),
vec![Value::Null]
);
}
#[test]
fn extremes_pair_unit_text_unitally() {
assert_eq!(
agg(
"max",
vec![Value::Str("5MB".into()), Value::Str("10MB".into())]
),
vec![Value::Str("10MB".into())]
);
assert_eq!(
agg(
"min",
vec![Value::Str("5MB".into()), Value::Str("10MB".into())]
),
vec![Value::Str("5MB".into())]
);
assert_eq!(
agg("max", vec![Value::Str("5MB".into()), Value::Int(10)]),
vec![Value::Str("5MB".into())]
);
}
#[test]
fn median_survives_nan() {
let vs = vec![Value::Float(f64::NAN), Value::Int(1), Value::Int(3)];
assert_eq!(agg("median", vs).len(), 1);
}
#[test]
fn median_odd_even_empty() {
assert_eq!(agg("median", ints(&[5, 1, 3])), vec![Value::Int(3)]);
assert_eq!(agg("median", ints(&[4, 1, 3, 2])), vec![Value::Float(2.5)]);
assert_eq!(
agg(
"median",
vec![Value::Float(1.5), Value::Float(2.5), Value::Float(9.0)]
),
vec![Value::Float(2.5)]
);
assert_eq!(agg("median", vec![]), vec![Value::Null]);
}
#[test]
fn spread_measures() {
let data = ints(&[2, 4, 4, 4, 5, 5, 7, 9]);
assert_eq!(agg("variance", data.clone()), vec![Value::Float(4.0)]);
assert_eq!(agg("stddev", data), vec![Value::Float(2.0)]);
assert_eq!(agg("stddev", vec![]), vec![Value::Null]);
}
#[test]
#[cfg(feature = "colligo")]
fn locale_collation() {
let call = |loc: Option<&str>| FnCall {
name: "sort".into(),
args: loc
.map(|l| vec![crate::ast::Arg::Lit(Value::Str(l.into()))])
.unwrap_or_default(),
};
let words = || {
vec![
Value::Str("ёж".into()),
Value::Str("Öl".into()),
Value::Str("еда".into()),
Value::Str("Zebra".into()),
]
};
let texts =
|vs: Vec<Value>| -> Vec<String> { vs.into_iter().map(|v| v.to_string()).collect() };
assert_eq!(
texts(apply(
&call(Some("ru-RU")),
words(),
&crate::quantity::scale_expr
)),
["еда", "ёж", "Öl", "Zebra"]
);
assert_eq!(
texts(apply(
&call(Some("sv-SE")),
words(),
&crate::quantity::scale_expr
)),
["Zebra", "Öl", "еда", "ёж"]
);
assert_eq!(
texts(apply(
&call(Some("de-DE")),
words(),
&crate::quantity::scale_expr
)),
["Öl", "Zebra", "еда", "ёж"]
);
assert_eq!(
texts(apply(&call(None), words(), &crate::quantity::scale_expr)),
["Zebra", "Öl", "еда", "ёж"]
);
}
#[test]
fn encodings() {
let apply1 = |name: &str, v: &str| {
apply_scalar(
&FnCall {
name: name.into(),
args: Vec::new(),
},
Value::Str(v.into()),
&crate::quantity::scale_expr,
)
.pop()
.unwrap()
.to_string()
};
assert_eq!(apply1("base64", "Sapiens"), "U2FwaWVucw==");
assert_eq!(apply1("base64url", "Sapiens"), "U2FwaWVucw");
assert_eq!(apply1("base32", "foobar"), "MZXW6YTBOI======");
assert_eq!(apply1("hex", "quarb"), "7175617262");
assert_eq!(
apply1("sha256", "abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn numeric_scalars() {
assert_eq!(sc("round", Value::Float(2.5)), vec![Value::Int(3)]);
assert_eq!(sc("round", Value::Str("2.4".into())), vec![Value::Int(2)]);
assert_eq!(sc("floor", Value::Float(2.9)), vec![Value::Int(2)]);
assert_eq!(sc("ceil", Value::Float(2.1)), vec![Value::Int(3)]);
assert_eq!(sc("abs", Value::Int(-4)), vec![Value::Int(4)]);
assert_eq!(sc("abs", Value::Float(-1.5)), vec![Value::Float(1.5)]);
assert_eq!(sc("round", Value::Str("n/a".into())), vec![Value::Null]);
}
fn sc_args(name: &str, args: Vec<Arg>, topic: Value) -> Vec<Value> {
let call = FnCall {
name: name.into(),
args,
};
apply_scalar(&call, topic, &crate::quantity::scale_expr)
}
#[test]
fn round_with_digits() {
let d = |n: i64| vec![Arg::Lit(Value::Int(n))];
assert_eq!(
sc_args("round", d(2), Value::Float(68.99000000000001)),
vec![Value::Float(68.99)]
);
assert_eq!(
sc_args("round", d(2), Value::Float(3.5700000000000003)),
vec![Value::Float(3.57)]
);
assert_eq!(
sc_args("round", d(0), Value::Float(2.5)),
vec![Value::Float(3.0)]
);
assert_eq!(
sc_args("round", d(-1), Value::Float(2568.5)),
vec![Value::Float(2570.0)]
);
assert_eq!(
sc_args("round", d(1), Value::Str("2.44".into())),
vec![Value::Float(2.4)]
);
assert_eq!(
sc_args("round", d(2), Value::Str("n/a".into())),
vec![Value::Null]
);
let q = Value::Quantity {
value: 3.14159,
base: "m".into(),
written: Some((3.14159, "m".into())),
};
assert_eq!(
sc_args("round", d(2), q),
vec![Value::Quantity {
value: 3.14,
base: "m".into(),
written: Some((3.14, "m".into())),
}]
);
}
#[test]
fn length_alias_is_gone() {
assert!(!known_agg("length"));
assert!(known_agg("mean") && known_agg("avg") && known_agg("median"));
assert!(known_scalar("round") && known_scalar("abs"));
}
#[test]
fn isoformat_renders_utc() {
let (secs, nanos, offset_min) =
crate::temporal::parse_iso("2024-02-15T14:26:40+01:00").unwrap();
assert_eq!(offset_min, Some(60));
let inst = Value::Instant {
secs,
nanos,
offset_min,
};
assert_eq!(
sc("isoformat", inst),
vec![Value::Str("2024-02-15T13:26:40".into())]
);
assert_eq!(
sc("isoformat", Value::Str("2024-02-15T14:26:40+01:00".into())),
vec![Value::Str("2024-02-15T13:26:40".into())]
);
}
#[test]
fn quantity_rounders_use_written_unit() {
let q = Value::Quantity {
value: 5700.0,
base: "m".into(),
written: Some((5.7, "km".into())),
};
assert_eq!(
sc("round", q.clone()),
vec![Value::Quantity {
value: 6000.0,
base: "m".into(),
written: Some((6.0, "km".into())),
}]
);
assert_eq!(
sc("floor", q.clone()),
vec![Value::Quantity {
value: 5000.0,
base: "m".into(),
written: Some((5.0, "km".into())),
}]
);
assert_eq!(
sc("ceil", q),
vec![Value::Quantity {
value: 6000.0,
base: "m".into(),
written: Some((6.0, "km".into())),
}]
);
}
#[test]
fn abs_over_quantity_is_null() {
let q = Value::Quantity {
value: -5000.0,
base: "m".into(),
written: Some((-5.0, "km".into())),
};
assert_eq!(sc("abs", q), vec![Value::Null]);
}
#[test]
fn numeric_reductions_over_empty_are_null() {
assert_eq!(agg("sum", vec![]), vec![Value::Null]);
assert_eq!(agg("min", vec![]), vec![Value::Null]);
assert_eq!(agg("max", vec![]), vec![Value::Null]);
assert_eq!(agg("sum", vec![Value::Str("x".into())]), vec![Value::Null]);
assert_eq!(agg("product", vec![]), vec![Value::Int(1)]);
}
#[test]
fn sum_and_product_promote_on_overflow() {
let big = 9_000_000_000_000_000_000i64;
assert_eq!(
agg("sum", vec![Value::Int(big), Value::Int(big)]),
vec![Value::Float(big as f64 + big as f64)]
);
let m = 4_000_000_000i64;
assert_eq!(
agg("product", vec![Value::Int(m), Value::Int(m)]),
vec![Value::Float(m as f64 * m as f64)]
);
}
#[test]
fn extreme_skips_missing_and_keeps_typed() {
assert_eq!(
agg("max", vec![Value::Str("banana".into()), Value::Int(42)]),
vec![Value::Int(42)]
);
assert_eq!(
agg("min", vec![Value::Str("apple".into()), Value::Int(5)]),
vec![Value::Int(5)]
);
assert_eq!(
agg(
"max",
vec![Value::Str("512.3292".into()), Value::Str("80".into())]
),
vec![Value::Str("512.3292".into())]
);
let a = Value::Instant {
secs: 100,
nanos: 0,
offset_min: None,
};
let b = Value::Instant {
secs: 200,
nanos: 0,
offset_min: None,
};
assert_eq!(agg("max", vec![a.clone(), b.clone()]), vec![b.clone()]);
assert_eq!(agg("min", vec![a.clone(), b]), vec![a]);
}
}