use crate::ast::{Arg, FnCall};
use crate::value::Value;
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",
];
pub(crate) fn collator_for(call: &FnCall) -> Option<icu_collator::CollatorBorrowed<'static>> {
let tag = match call.args.first()? {
crate::ast::Arg::Lit(l) => l.to_string(),
_ => return None,
};
let locale: icu_locale_core::Locale =
tag.parse().unwrap_or(icu_locale_core::Locale::UNKNOWN);
icu_collator::Collator::try_new(
icu_collator::CollatorPreferences::from(&locale),
icu_collator::options::CollatorOptions::default(),
)
.ok()
}
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()
.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" => 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| 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)
}
pub fn apply(call: &FnCall, input: Vec<Value>) -> Vec<Value> {
match call.name.as_str() {
"count" => vec![Value::Int(input.len() as i64)],
"sum" => vec![sum(&input)],
"product" => vec![product(&input)],
"min" => extreme(input, std::cmp::Ordering::Less),
"max" => extreme(input, std::cmp::Ordering::Greater),
"mean" | "avg" => vec![mean(&input)],
"median" => vec![median(&input)],
"stddev" => vec![spread(&input, f64::sqrt)],
"variance" => vec![spread(&input, |v| v)],
"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(Value::compare),
}
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,
}
}
fn sum(input: &[Value]) -> Value {
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]) -> Value {
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]) -> Value {
let mut nums: Vec<f64> = input.iter().filter_map(Value::numeric).collect();
if nums.is_empty() {
return Value::Null;
}
nums.sort_by(|a, b| a.partial_cmp(b).expect("no NaN from Value::numeric"));
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) -> Value {
let nums: Vec<f64> = 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) -> bool {
v.numeric().is_some()
|| v.temporal_reading().is_some()
|| v.durational_reading().is_some()
|| v.unital_reading().is_some()
}
fn extreme(input: Vec<Value>, want: std::cmp::Ordering) -> Vec<Value> {
match input
.into_iter()
.filter(has_reading)
.reduce(|a, b| if a.compare(&b) == 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)
}
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 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]
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())),
["еда", "ёж", "Öl", "Zebra"]
);
assert_eq!(
texts(apply(&call(Some("sv-SE")), words())),
["Zebra", "Öl", "еда", "ёж"]
);
assert_eq!(
texts(apply(&call(Some("de-DE")), words())),
["Öl", "Zebra", "еда", "ёж"]
);
assert_eq!(
texts(apply(&call(None), words())),
["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]);
}
#[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]);
}
}