Skip to main content

eui_core/host/
query.rs

1//! VAPP-189 (S1): THE records query, the Rust twin of
2//! `packages/exponential-ui/src/host/query.ts`, locked by
3//! `fixtures/records-query.json` (`parseCases`, `sourceCases`, `jsonCases`,
4//! `valueCases`, `expandCases`; `bucketCases` lock [`crate::dates`]). A
5//! binding's URL sugar (`records:<kind>?q`, a host's own spelling like
6//! Exponential's `exp:records?kind=<kind>&q`) parses INTO the structured
7//! [`RecordsQuery`]; [`records_value`] evaluates it over the rows of any
8//! store. The desktop host calls it directly; the Swift and Compose SDKs
9//! reach it through `exponential-ui-ffi` (`parse_records_query_json`,
10//! `parse_records_json`, `parse_records_source_json`, `records_value_json`).
11//!
12//! VAPP-190 (Q1): the JSON form ([`parse_records_json`], a package's
13//! `queries`, the `q=<json>` param): `group` (any field, `author`, date
14//! buckets), `aggregate`, `running`, `before`, `in` / `between` and `or`.
15
16use std::cmp::Ordering;
17use std::collections::{BTreeMap, HashMap};
18
19use serde::{Deserialize, Serialize};
20use serde_json::{json, Map, Value};
21
22use super::signature::canonical_json;
23use super::sources::{parse_source, source_with_params, ParsedSource};
24use super::{js_trim, utf16_cmp};
25use crate::dates::{date_bucket, days_in_month, weekday_of, DateBucket, DATE_BUCKETS};
26use crate::json::{number, number_to_string};
27
28/// The binding scheme: `records:<kind>[?query]`.
29pub const RECORDS_SCHEME: &str = "records";
30/// The URL sugar's defaults and caps (catalog/host.json `package.records.query`).
31pub const RECORDS_SORT_DEFAULT: &str = "createdAt";
32pub const RECORDS_ORDER_DEFAULT: &str = "desc";
33pub const RECORDS_LIMIT_DEFAULT: u32 = 100;
34pub const RECORDS_LIMIT_MAX: u32 = 500;
35/// Query keys with a meaning of their own; never a field filter.
36pub const RECORDS_RESERVED_PARAMS: &[&str] = &["app", "before", "by", "kind", "limit", "mine", "month", "order", "q", "query", "sort", "sum", "user"];
37/// The keys every value row carries next to its data (a data key never shadows one).
38pub const RECORDS_ROW_KEYS: &[&str] = &["id", "createdAt", "updatedAt", "createdBy", "authorId", "author"];
39/// The filter operators: `eq` is the bare key, the others a `.<op>` suffix
40/// (`in` and `between` take comma-separated values).
41pub const RECORDS_FILTER_OPS: &[&str] = &["eq", "gt", "gte", "lt", "lte", "in", "between"];
42/// The JSON form's spellings of the operators.
43pub const RECORDS_OP_ALIASES: &[(&str, &str)] = &[("=", "eq"), ("==", "eq"), (">", "gt"), (">=", "gte"), ("<", "lt"), ("<=", "lte")];
44pub const RECORDS_AGGREGATE_OPS: &[&str] = &["sum", "avg", "min", "max", "count", "countDistinct", "first", "last"];
45pub const RECORDS_RUNNING_OPS: &[&str] = &["sum", "count", "avg", "min", "max"];
46/// Names an aggregate may not take (a group's and a total's own keys).
47pub const RECORDS_AGGREGATE_RESERVED: &[&str] = &["count", "sum", "running", "rows"];
48/// The month view's names (en-US, the same ×4).
49pub const RECORDS_WEEKDAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
50pub const RECORDS_MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
53#[serde(rename_all = "lowercase")]
54pub enum RecordsFilterOp {
55    Eq,
56    Gt,
57    Gte,
58    Lt,
59    Lte,
60    In,
61    Between,
62}
63
64impl RecordsFilterOp {
65    pub fn as_str(self) -> &'static str {
66        match self {
67            Self::Eq => "eq",
68            Self::Gt => "gt",
69            Self::Gte => "gte",
70            Self::Lt => "lt",
71            Self::Lte => "lte",
72            Self::In => "in",
73            Self::Between => "between",
74        }
75    }
76
77    /// An operator by name (`eq` … `between`) or its JSON spelling (`<`, `>=`, `==`…).
78    pub fn parse(text: &str) -> Option<Self> {
79        let name = RECORDS_OP_ALIASES.iter().find(|(alias, _)| *alias == text).map_or(text, |(_, op)| op);
80        match name {
81            "eq" => Some(Self::Eq),
82            "gt" => Some(Self::Gt),
83            "gte" => Some(Self::Gte),
84            "lt" => Some(Self::Lt),
85            "lte" => Some(Self::Lte),
86            "in" => Some(Self::In),
87            "between" => Some(Self::Between),
88            _ => None,
89        }
90    }
91
92    /// The suffix operators (`<field>.<op>`); `eq` is the bare key.
93    fn suffix(text: &str) -> Option<Self> {
94        match text {
95            "gt" => Some(Self::Gt),
96            "gte" => Some(Self::Gte),
97            "lt" => Some(Self::Lt),
98            "lte" => Some(Self::Lte),
99            "in" => Some(Self::In),
100            "between" => Some(Self::Between),
101            _ => None,
102        }
103    }
104
105    fn is_list(self) -> bool {
106        matches!(self, Self::In | Self::Between)
107    }
108}
109
110/// A condition's value: one text, or a list (`in`; `between` = [low, high]).
111#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
112#[serde(untagged)]
113pub enum RecordsFilterValue {
114    One(String),
115    Many(Vec<String>),
116}
117
118/// One condition: `eq` / `in` compare as text, the ranges (`between`
119/// inclusive) as numbers when both sides parse, else as text.
120#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
121pub struct RecordsFilter {
122    pub field: String,
123    pub op: RecordsFilterOp,
124    pub value: RecordsFilterValue,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
128#[serde(rename_all = "lowercase")]
129pub enum RecordsOrder {
130    Asc,
131    Desc,
132}
133
134#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
135pub struct RecordsSortKey {
136    pub field: String,
137    pub order: RecordsOrder,
138}
139
140/// `by=<dateField>&month=YYYY-MM`: the rows of one month, every day listed.
141#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
142pub struct RecordsMonthView {
143    pub field: String,
144    /// `YYYY-MM`; `None` = the month of `now`.
145    pub key: Option<String>,
146}
147
148/// One group-by key: a field (`author` = the author, by name), or a date
149/// field cut into buckets. The group's column is named `field`.
150#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
151pub struct RecordsGroupKey {
152    pub field: String,
153    pub bucket: Option<DateBucket>,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
157#[serde(rename_all = "camelCase")]
158pub enum RecordsAggregateOp {
159    Sum,
160    Avg,
161    Min,
162    Max,
163    Count,
164    CountDistinct,
165    First,
166    Last,
167}
168
169impl RecordsAggregateOp {
170    pub fn parse(text: &str) -> Option<Self> {
171        match text {
172            "sum" => Some(Self::Sum),
173            "avg" => Some(Self::Avg),
174            "min" => Some(Self::Min),
175            "max" => Some(Self::Max),
176            "count" => Some(Self::Count),
177            "countDistinct" => Some(Self::CountDistinct),
178            "first" => Some(Self::First),
179            "last" => Some(Self::Last),
180            _ => None,
181        }
182    }
183}
184
185/// `name: [op, field?]`: a column of every group (and of `totals`).
186#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
187pub struct RecordsAggregate {
188    pub name: String,
189    pub op: RecordsAggregateOp,
190    /// `None` = `count` of the rows.
191    pub field: Option<String>,
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
195#[serde(rename_all = "lowercase")]
196pub enum RecordsRunningOp {
197    Sum,
198    Count,
199    Avg,
200    Min,
201    Max,
202}
203
204impl RecordsRunningOp {
205    pub fn parse(text: &str) -> Option<Self> {
206        match text {
207            "sum" => Some(Self::Sum),
208            "count" => Some(Self::Count),
209            "avg" => Some(Self::Avg),
210            "min" => Some(Self::Min),
211            "max" => Some(Self::Max),
212            _ => None,
213        }
214    }
215}
216
217/// A cumulative value of `field` over the groups (or rows, or month days) in order.
218#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
219pub struct RecordsRunning {
220    pub field: String,
221    pub op: RecordsRunningOp,
222}
223
224/// The rows whose `field` is before `date` are left out of the rows and
225/// groups, totalled into `before` and seed every running value.
226#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
227pub struct RecordsBefore {
228    pub field: String,
229    pub date: String,
230}
231
232/// The structured query.
233#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
234pub struct RecordsQuery {
235    /// The kind; `None` = no kind (the value is null).
236    pub from: Option<String>,
237    /// Another app's slug (the HOST resolves whose rows); `None` = the hosting app.
238    pub app: Option<String>,
239    /// Only the viewer's rows (by author: `authorId`, else `createdBy`).
240    pub mine: bool,
241    /// Only this user's rows (by author).
242    pub user: Option<String>,
243    /// Every condition holds; by field, then operator (the URL sugar).
244    #[serde(rename = "where")]
245    pub where_: Vec<RecordsFilter>,
246    /// VAPP-190: one of these alternatives holds too (each: every condition).
247    #[serde(default)]
248    pub or: Vec<Vec<RecordsFilter>>,
249    /// Sort keys in order; ties always fall back to the id.
250    pub sort: Vec<RecordsSortKey>,
251    pub limit: u32,
252    /// Σ of this field over the matches (before the limit).
253    pub sum: Option<String>,
254    pub month: Option<RecordsMonthView>,
255    /// VAPP-190: the group-by keys.
256    #[serde(default)]
257    pub group: Vec<RecordsGroupKey>,
258    /// VAPP-190: the aggregates, by name.
259    #[serde(default)]
260    pub aggregate: Vec<RecordsAggregate>,
261    /// VAPP-190: the running values, by field.
262    #[serde(default)]
263    pub running: Vec<RecordsRunning>,
264    #[serde(default)]
265    pub before: Option<RecordsBefore>,
266}
267
268impl Default for RecordsQuery {
269    fn default() -> Self {
270        RecordsQuery {
271            from: None,
272            app: None,
273            mine: false,
274            user: None,
275            where_: Vec::new(),
276            or: Vec::new(),
277            sort: vec![RecordsSortKey { field: RECORDS_SORT_DEFAULT.to_string(), order: RecordsOrder::Desc }],
278            limit: RECORDS_LIMIT_DEFAULT,
279            sum: None,
280            month: None,
281            group: Vec::new(),
282            aggregate: Vec::new(),
283            running: Vec::new(),
284            before: None,
285        }
286    }
287}
288
289/// A problem with a JSON query, a JSON pointer into it.
290#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291pub struct RecordsQueryIssue {
292    pub path: String,
293    pub message: String,
294}
295
296/// JavaScript's `Number(string)`: trimmed, empty = 0, `0x` / `0o` / `0b`
297/// integers, `[+-]Infinity`, a decimal literal; anything else NaN.
298pub(crate) fn js_string_number(s: &str) -> f64 {
299    let t = js_trim(s);
300    if t.is_empty() {
301        return 0.0;
302    }
303    for (prefix, radix) in [("0x", 16), ("0X", 16), ("0o", 8), ("0O", 8), ("0b", 2), ("0B", 2)] {
304        if let Some(digits) = t.strip_prefix(prefix) {
305            if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) {
306                return f64::NAN;
307            }
308            return digits.chars().fold(0.0, |n, c| n * f64::from(radix) + f64::from(c.to_digit(radix).unwrap_or(0)));
309        }
310    }
311    let (sign, body) = match t.as_bytes()[0] {
312        b'-' => (-1.0, &t[1..]),
313        b'+' => (1.0, &t[1..]),
314        _ => (1.0, t),
315    };
316    if body == "Infinity" {
317        return sign * f64::INFINITY;
318    }
319    let b = body.as_bytes();
320    let mut i = 0;
321    let digits = |i: &mut usize| {
322        let start = *i;
323        while b.get(*i).is_some_and(u8::is_ascii_digit) {
324            *i += 1;
325        }
326        *i - start
327    };
328    let int = digits(&mut i);
329    let frac = if b.get(i) == Some(&b'.') {
330        i += 1;
331        digits(&mut i)
332    } else {
333        0
334    };
335    if int == 0 && frac == 0 {
336        return f64::NAN;
337    }
338    if matches!(b.get(i), Some(b'e' | b'E')) {
339        i += 1;
340        if matches!(b.get(i), Some(b'+' | b'-')) {
341            i += 1;
342        }
343        if digits(&mut i) == 0 {
344            return f64::NAN;
345        }
346    }
347    if i != b.len() {
348        return f64::NAN;
349    }
350    body.parse::<f64>().map(|n| sign * n).unwrap_or(f64::NAN)
351}
352
353/// `YYYY-MM` with a month 01–12.
354fn is_month_key(text: &str) -> bool {
355    let b = text.as_bytes();
356    b.len() == 7 && b[..4].iter().all(u8::is_ascii_digit) && b[4] == b'-' && matches!(&text[5..7], "01" | "02" | "03" | "04" | "05" | "06" | "07" | "08" | "09" | "10" | "11" | "12")
357}
358
359fn trimmed(v: Option<&str>) -> Option<String> {
360    v.map(js_trim).filter(|s| !s.is_empty()).map(str::to_string)
361}
362
363/// Query params (as [`super::parse_source`] gives them) → the query. `kind`
364/// = the source's own name (the neutral `records:<kind>`), which wins over a
365/// `kind` param. Unknown keys are field filters. VAPP-190: `q=<json>` = the
366/// JSON form ([`parse_records_json`]; the other params are ignored).
367pub fn parse_records_query(params: &BTreeMap<String, String>, kind: Option<&str>) -> RecordsQuery {
368    let from = trimmed(kind.or(params.get("kind").map(String::as_str)));
369    if let Some(q) = params.get("q").filter(|q| !js_trim(q).is_empty()) {
370        let json = serde_json::from_str::<Value>(q).ok().filter(Value::is_object).unwrap_or_else(|| json!({}));
371        return parse_records_json(&json, from.as_deref()).0;
372    }
373    let limit_raw = params.get("limit").map_or(f64::NAN, |l| js_string_number(l));
374    let mut keys: Vec<&String> = params.keys().collect();
375    keys.sort_by(|a, b| utf16_cmp(a, b));
376    let mut where_ = Vec::new();
377    for key in keys {
378        if key.is_empty() || RECORDS_RESERVED_PARAMS.contains(&key.as_str()) {
379            continue;
380        }
381        let value = params[key].clone();
382        match key.rfind('.').and_then(|dot| RecordsFilterOp::suffix(&key[dot + 1..]).map(|op| (dot, op))) {
383            Some((dot, op)) if dot > 0 => {
384                let value = if op.is_list() { RecordsFilterValue::Many(value.split(',').map(str::to_string).collect()) } else { RecordsFilterValue::One(value) };
385                where_.push(RecordsFilter { field: key[..dot].to_string(), op, value });
386            }
387            _ => where_.push(RecordsFilter { field: key.clone(), op: RecordsFilterOp::Eq, value: RecordsFilterValue::One(value) }),
388        }
389    }
390    where_.sort_by(|a, b| utf16_cmp(&a.field, &b.field).then_with(|| utf16_cmp(a.op.as_str(), b.op.as_str())));
391    let order = match params.get("order").map(String::as_str) {
392        Some("asc") => RecordsOrder::Asc,
393        Some("desc") => RecordsOrder::Desc,
394        _ if RECORDS_ORDER_DEFAULT == "asc" => RecordsOrder::Asc,
395        _ => RecordsOrder::Desc,
396    };
397    let limit = if limit_raw.is_finite() && limit_raw.fract() == 0.0 && limit_raw > 0.0 { limit_raw.min(f64::from(RECORDS_LIMIT_MAX)) as u32 } else { RECORDS_LIMIT_DEFAULT };
398    let by = trimmed(params.get("by").map(String::as_str));
399    let before = trimmed(params.get("before").map(String::as_str));
400    RecordsQuery {
401        from,
402        app: trimmed(params.get("app").map(String::as_str)),
403        mine: matches!(params.get("mine").map(String::as_str), Some("1" | "true")),
404        user: trimmed(params.get("user").map(String::as_str)),
405        where_,
406        sort: vec![RecordsSortKey { field: trimmed(params.get("sort").map(String::as_str)).unwrap_or_else(|| RECORDS_SORT_DEFAULT.to_string()), order }],
407        limit,
408        sum: trimmed(params.get("sum").map(String::as_str)),
409        before: before.map(|date| RecordsBefore { field: by.clone().unwrap_or_else(|| RECORDS_SORT_DEFAULT.to_string()), date }),
410        month: by.map(|field| RecordsMonthView { field, key: params.get("month").filter(|m| is_month_key(m)).cloned() }),
411        ..RecordsQuery::default()
412    }
413}
414
415/// `records:<kind>?…` → the query; `None` = not a records source.
416pub fn parse_records_source(source: &ParsedSource) -> Option<RecordsQuery> {
417    (source.scheme == RECORDS_SCHEME).then(|| parse_records_query(&source.params, Some(&source.name)))
418}
419
420/// A JSON scalar as the query's text (a number as JS prints it); `None` = not a scalar.
421fn scalar_text(v: &Value) -> Option<String> {
422    match v {
423        Value::Null => Some(String::new()),
424        Value::String(s) => Some(s.clone()),
425        Value::Bool(b) => Some(b.to_string()),
426        Value::Number(n) => Some(number_to_string(n.as_f64().unwrap_or(0.0))),
427        _ => None,
428    }
429}
430
431fn issue(issues: &mut Vec<RecordsQueryIssue>, path: impl Into<String>, message: impl Into<String>) {
432    issues.push(RecordsQueryIssue { path: path.into(), message: message.into() });
433}
434
435/// `[field, op, value…]` → a condition.
436fn parse_condition(entry: &Value, at: &str, issues: &mut Vec<RecordsQueryIssue>) -> Option<RecordsFilter> {
437    let Some(list) = entry.as_array().filter(|l| l.len() >= 3) else {
438        issue(issues, at, "a condition is [field, op, value]");
439        return None;
440    };
441    let Some(field) = list[0].as_str().map(js_trim).filter(|f| !f.is_empty()) else {
442        issue(issues, format!("{at}/0"), "a condition's field is a non-empty string");
443        return None;
444    };
445    let Some(op) = list[1].as_str().and_then(RecordsFilterOp::parse) else {
446        issue(issues, format!("{at}/1"), format!("unknown operator {}", crate::json::to_js_string(&list[1])));
447        return None;
448    };
449    let rest = &list[2..];
450    if op == RecordsFilterOp::In {
451        let values: Option<Vec<String>> = match rest {
452            [Value::Array(items)] => items.iter().map(scalar_text).collect(),
453            _ => None,
454        };
455        let Some(values) = values else {
456            issue(issues, format!("{at}/2"), "in takes one list of values");
457            return None;
458        };
459        return Some(RecordsFilter { field: field.to_string(), op, value: RecordsFilterValue::Many(values) });
460    }
461    let values: Option<Vec<String>> = rest.iter().map(scalar_text).collect();
462    let want = if op == RecordsFilterOp::Between { 2 } else { 1 };
463    let Some(mut values) = values.filter(|v| v.len() == want) else {
464        issue(issues, format!("{at}/2"), if op == RecordsFilterOp::Between { "between takes a low and a high value".to_string() } else { format!("{} takes one value", op.as_str()) });
465        return None;
466    };
467    let value = if op == RecordsFilterOp::Between { RecordsFilterValue::Many(values) } else { RecordsFilterValue::One(values.remove(0)) };
468    Some(RecordsFilter { field: field.to_string(), op, value })
469}
470
471const QUERY_KEYS: &[&str] = &["from", "app", "mine", "user", "where", "sort", "limit", "sum", "month", "group", "aggregate", "running", "before"];
472
473fn sorted_keys(map: &Map<String, Value>) -> Vec<&String> {
474    let mut keys: Vec<&String> = map.keys().collect();
475    keys.sort_by(|a, b| utf16_cmp(a, b));
476    keys
477}
478
479/// VAPP-190: the JSON form (a package's `queries`, the `q` param) → the
480/// query and every problem with it (a problem drops the part, the rest
481/// still runs). `kind` (a source's own) wins over `from`.
482pub fn parse_records_json(json: &Value, kind: Option<&str>) -> (RecordsQuery, Vec<RecordsQueryIssue>) {
483    let mut issues = Vec::new();
484    let mut query = RecordsQuery::default();
485    let Some(obj) = json.as_object() else {
486        issue(&mut issues, "", "a query is an object");
487        query.from = trimmed(kind);
488        return (query, issues);
489    };
490    for key in sorted_keys(obj) {
491        if !QUERY_KEYS.contains(&key.as_str()) && !key.starts_with('$') {
492            issue(&mut issues, format!("/{key}"), format!("unknown key {key}"));
493        }
494    }
495    let str_of = |key: &str, issues: &mut Vec<RecordsQueryIssue>| -> Option<String> {
496        match obj.get(key) {
497            None | Some(Value::Null) => None,
498            Some(Value::String(s)) => trimmed(Some(s)),
499            Some(_) => {
500                issue(issues, format!("/{key}"), format!("{key} is a string"));
501                None
502            }
503        }
504    };
505    query.from = match trimmed(kind) {
506        Some(k) => Some(k),
507        None => str_of("from", &mut issues),
508    };
509    query.app = str_of("app", &mut issues);
510    query.user = str_of("user", &mut issues);
511    query.sum = str_of("sum", &mut issues);
512    match obj.get("mine") {
513        None => {}
514        Some(Value::Bool(b)) => query.mine = *b,
515        Some(_) => issue(&mut issues, "/mine", "mine is a boolean"),
516    }
517    match obj.get("where") {
518        None => {}
519        Some(Value::Array(list)) => {
520            for (i, entry) in list.iter().enumerate() {
521                let at = format!("/where/{i}");
522                if let Some(alt) = entry.as_object() {
523                    let Some(alternatives) = alt.get("or").and_then(Value::as_array).filter(|_| alt.len() == 1) else {
524                        issue(&mut issues, at, "an alternative is {or: [[condition…]…]}");
525                        continue;
526                    };
527                    if !query.or.is_empty() {
528                        issue(&mut issues, at, "one or per query");
529                        continue;
530                    }
531                    for (j, alt) in alternatives.iter().enumerate() {
532                        let Some(conds) = alt.as_array() else {
533                            issue(&mut issues, format!("{at}/or/{j}"), "an alternative is a list of conditions");
534                            continue;
535                        };
536                        let parsed: Vec<RecordsFilter> = conds.iter().enumerate().filter_map(|(k, c)| parse_condition(c, &format!("{at}/or/{j}/{k}"), &mut issues)).collect();
537                        query.or.push(parsed);
538                    }
539                    continue;
540                }
541                if let Some(parsed) = parse_condition(entry, &at, &mut issues) {
542                    query.where_.push(parsed);
543                }
544            }
545        }
546        Some(_) => issue(&mut issues, "/where", "where is a list of conditions"),
547    }
548    if let Some(sort) = obj.get("sort") {
549        let single = sort.is_string();
550        let list: Option<Vec<Value>> = if single { Some(vec![sort.clone()]) } else { sort.as_array().cloned() };
551        match list {
552            None => issue(&mut issues, "/sort", "sort is a field or a list of [field, order]"),
553            Some(list) => {
554                let mut keys = Vec::new();
555                for (i, entry) in list.iter().enumerate() {
556                    let asc = Value::String("asc".into());
557                    let (name, order): (Option<&Value>, Option<&Value>) = match entry {
558                        Value::String(_) => (Some(entry), Some(&asc)),
559                        Value::Array(pair) => (pair.first(), if pair.len() > 1 { pair.get(1) } else { Some(&asc) }),
560                        _ => (None, None),
561                    };
562                    let name = name.and_then(Value::as_str).map(js_trim).filter(|n| !n.is_empty());
563                    let order = match order.and_then(Value::as_str) {
564                        Some("asc") => Some(RecordsOrder::Asc),
565                        Some("desc") => Some(RecordsOrder::Desc),
566                        _ => None,
567                    };
568                    match (name, order) {
569                        (Some(name), Some(order)) => keys.push(RecordsSortKey { field: name.to_string(), order }),
570                        _ => issue(&mut issues, if single { "/sort".to_string() } else { format!("/sort/{i}") }, "a sort key is field or [field, asc|desc]"),
571                    }
572                }
573                if !keys.is_empty() {
574                    query.sort = keys;
575                }
576            }
577        }
578    }
579    if let Some(limit) = obj.get("limit") {
580        let n = match limit {
581            Value::Number(n) => n.as_f64().unwrap_or(f64::NAN),
582            Value::String(s) if !js_trim(s).is_empty() => js_string_number(s),
583            _ => f64::NAN,
584        };
585        if n.is_finite() && n.fract() == 0.0 && n >= 1.0 {
586            query.limit = n.min(f64::from(RECORDS_LIMIT_MAX)) as u32;
587        } else {
588            issue(&mut issues, "/limit", "limit is a positive integer");
589        }
590    }
591    match obj.get("month") {
592        None | Some(Value::Null) => {}
593        Some(m) => {
594            let field = m.get("field").and_then(Value::as_str).map(js_trim).filter(|f| !f.is_empty());
595            let key = m.get("key");
596            match (m.as_object(), field) {
597                (Some(_), Some(field)) if matches!(key, None | Some(Value::Null) | Some(Value::String(_))) => {
598                    query.month = Some(RecordsMonthView { field: field.to_string(), key: key.and_then(Value::as_str).filter(|k| is_month_key(k)).map(str::to_string) });
599                }
600                _ => issue(&mut issues, "/month", "month is {field, key?}"),
601            }
602        }
603    }
604    match obj.get("group") {
605        None => {}
606        Some(Value::Array(list)) => {
607            for (i, entry) in list.iter().enumerate() {
608                let at = format!("/group/{i}");
609                let key = match entry {
610                    Value::String(s) if !js_trim(s).is_empty() => Some(RecordsGroupKey { field: js_trim(s).to_string(), bucket: None }),
611                    Value::Object(o) if o.len() == 2 => {
612                        let field = o.get("bucket").and_then(Value::as_str).map(js_trim).filter(|f| !f.is_empty());
613                        let bucket = o.get("by").and_then(Value::as_str).and_then(DateBucket::parse);
614                        field.zip(bucket).map(|(field, bucket)| RecordsGroupKey { field: field.to_string(), bucket: Some(bucket) })
615                    }
616                    _ => None,
617                };
618                let Some(key) = key else {
619                    issue(&mut issues, at, format!("a group key is a field or {{bucket: field, by: {}}}", DATE_BUCKETS.join("|")));
620                    continue;
621                };
622                if query.group.iter().any(|g| g.field == key.field || (key.field == "author" && g.field == "authorId") || (key.field == "authorId" && g.field == "author")) {
623                    issue(&mut issues, at, format!("duplicate group {}", key.field));
624                    continue;
625                }
626                query.group.push(key);
627            }
628        }
629        Some(_) => issue(&mut issues, "/group", "group is a list of keys"),
630    }
631    match obj.get("aggregate") {
632        None => {}
633        Some(Value::Object(aggs)) => {
634            for name in sorted_keys(aggs) {
635                let def = &aggs[name];
636                let at = format!("/aggregate/{name}");
637                if js_trim(name).is_empty() || RECORDS_AGGREGATE_RESERVED.contains(&name.as_str()) || query.group.iter().any(|g| g.field == *name || (g.field == "author" && name == "authorId")) {
638                    issue(&mut issues, at, format!("the name {name} is taken"));
639                    continue;
640                }
641                let list = def.as_array();
642                let op = list.and_then(|l| l.first()).and_then(Value::as_str).and_then(RecordsAggregateOp::parse);
643                let len = list.map_or(0, Vec::len);
644                let field = list.filter(|l| l.len() > 1).and_then(|l| l[1].as_str()).map(js_trim).filter(|f| !f.is_empty());
645                match op {
646                    Some(op) if len <= 2 && !(len > 1 && field.is_none()) && (op == RecordsAggregateOp::Count || field.is_some()) => {
647                        query.aggregate.push(RecordsAggregate { name: name.clone(), op, field: field.map(str::to_string) });
648                    }
649                    _ => issue(&mut issues, at, format!("an aggregate is [{}, field] (count: the field is optional)", RECORDS_AGGREGATE_OPS.join("|"))),
650                }
651            }
652        }
653        Some(_) => issue(&mut issues, "/aggregate", "aggregate is {name: [op, field?]}"),
654    }
655    match obj.get("running") {
656        None => {}
657        Some(Value::Object(running)) => {
658            for name in sorted_keys(running) {
659                match running[name].as_str().and_then(RecordsRunningOp::parse) {
660                    Some(op) if !js_trim(name).is_empty() => query.running.push(RecordsRunning { field: name.clone(), op }),
661                    _ => issue(&mut issues, format!("/running/{name}"), format!("a running value is {{field: {}}}", RECORDS_RUNNING_OPS.join("|"))),
662                }
663            }
664        }
665        Some(_) => issue(&mut issues, "/running", "running is {field: op}"),
666    }
667    match obj.get("before") {
668        None | Some(Value::Null) => {}
669        Some(Value::String(date)) => {
670            let implied = query.group.iter().find(|g| g.bucket.is_some()).map(|g| g.field.clone()).or_else(|| query.month.as_ref().map(|m| m.field.clone())).unwrap_or_else(|| RECORDS_SORT_DEFAULT.to_string());
671            query.before = trimmed(Some(date)).map(|date| RecordsBefore { field: implied, date });
672        }
673        Some(b) => {
674            let field = b.as_object().and_then(|o| o.get("field")).and_then(Value::as_str).map(js_trim).filter(|f| !f.is_empty());
675            match (field, b.get("date").and_then(Value::as_str)) {
676                (Some(field), Some(date)) => query.before = trimmed(Some(date)).map(|date| RecordsBefore { field: field.to_string(), date }),
677                _ => issue(&mut issues, "/before", "before is a date or {field, date}"),
678            }
679        }
680    }
681    (query, issues)
682}
683
684/// `${name}` in every string of a JSON query → `params[name]` ("" when unset).
685pub fn fill_query_templates(value: &Value, params: &BTreeMap<String, String>) -> Value {
686    match value {
687        Value::String(s) => {
688            let mut out = String::new();
689            let mut rest = s.as_str();
690            while let Some(start) = rest.find("${") {
691                let Some(len) = rest[start + 2..].find('}') else { break };
692                out.push_str(&rest[..start]);
693                let name = js_trim(&rest[start + 2..start + 2 + len]);
694                out.push_str(params.get(name).map_or("", String::as_str));
695                rest = &rest[start + 2 + len + 1..];
696            }
697            out.push_str(rest);
698            Value::String(out)
699        }
700        Value::Array(items) => Value::Array(items.iter().map(|v| fill_query_templates(v, params)).collect()),
701        Value::Object(map) => Value::Object(map.iter().map(|(k, v)| (k.clone(), fill_query_templates(v, params))).collect()),
702        other => other.clone(),
703    }
704}
705
706/// VAPP-190: a package binding `<scheme>:<name>?query=<id>&<params>` naming
707/// one of the package's `queries` → the same source with that query, its
708/// `${param}` templates filled from the other params, as `q=<canonical
709/// JSON>` (a `kind` param stays). Anything else is returned as written.
710pub fn expand_query_source(source: &str, queries: Option<&Value>) -> String {
711    let Some(parsed) = parse_source(source) else { return source.to_string() };
712    let Some(id) = parsed.params.get("query") else { return source.to_string() };
713    let Some(query) = queries.and_then(Value::as_object).and_then(|q| q.get(id)) else { return source.to_string() };
714    let mut params = parsed.params.clone();
715    params.remove("query");
716    let kind = params.remove("kind");
717    let filled = fill_query_templates(query, &params);
718    let base = js_trim(source);
719    let head = base.split_once('?').map_or(base, |(h, _)| h);
720    let mut extra = Map::new();
721    if let Some(kind) = kind {
722        extra.insert("kind".into(), Value::String(kind));
723    }
724    extra.insert("q".into(), Value::String(canonical_json(&filled)));
725    source_with_params(head, Some(&Value::Object(extra)))
726}
727
728/// One stored record as a host hands it to the evaluator (borrowed).
729#[derive(Debug, Clone, Copy, PartialEq)]
730pub struct RecordRowRef<'a> {
731    pub id: &'a str,
732    pub kind: &'a str,
733    pub data: &'a Value,
734    pub created_by: Option<&'a str>,
735    /// Whose record it is (#VAPP-187); `None` = `created_by`.
736    pub author_id: Option<&'a str>,
737    pub created_at: &'a str,
738    pub updated_at: &'a str,
739}
740
741impl<'a> RecordRowRef<'a> {
742    /// The row's author: `authorId`, else `createdBy`.
743    pub fn author(&self) -> Option<&'a str> {
744        self.author_id.or(self.created_by)
745    }
746}
747
748/// One stored record, owned (the FFI's rows JSON).
749#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
750#[serde(rename_all = "camelCase")]
751pub struct RecordRow {
752    pub id: String,
753    pub kind: String,
754    #[serde(default)]
755    pub data: Value,
756    #[serde(default)]
757    pub created_by: Option<String>,
758    #[serde(default)]
759    pub author_id: Option<String>,
760    pub created_at: String,
761    pub updated_at: String,
762}
763
764impl RecordRow {
765    pub fn as_ref(&self) -> RecordRowRef<'_> {
766        RecordRowRef {
767            id: &self.id,
768            kind: &self.kind,
769            data: &self.data,
770            created_by: self.created_by.as_deref(),
771            author_id: self.author_id.as_deref(),
772            created_at: &self.created_at,
773            updated_at: &self.updated_at,
774        }
775    }
776}
777
778/// What [`records_value`] needs besides the rows.
779#[derive(Debug, Clone, Copy)]
780pub struct RecordsContext<'a> {
781    /// The viewer's user id (`None` = signed out: `mine` matches nothing).
782    pub me: Option<&'a str>,
783    /// ISO 8601 (UTC): the month view's default month and `today`.
784    pub now: &'a str,
785    /// user id → display name (`author`; "" when unknown).
786    pub names: &'a HashMap<String, String>,
787}
788
789/// A field as a JS value (`undefined` = `Missing`).
790enum Field<'a> {
791    Text(&'a str),
792    Json(&'a Value),
793    Missing,
794}
795
796fn num_text(s: &str) -> Option<f64> {
797    if js_trim(s).is_empty() {
798        return None;
799    }
800    Some(js_string_number(s)).filter(|f| f.is_finite())
801}
802
803fn num(v: &Field) -> Option<f64> {
804    match v {
805        Field::Json(Value::Number(n)) => n.as_f64().filter(|f| f.is_finite()),
806        Field::Json(Value::String(s)) => num_text(s),
807        Field::Text(s) => num_text(s),
808        Field::Json(_) | Field::Missing => None,
809    }
810}
811
812fn num_value(v: Option<&Value>) -> Option<f64> {
813    v.and_then(|v| num(&Field::Json(v)))
814}
815
816fn text(v: &Field) -> String {
817    match v {
818        Field::Missing | Field::Json(Value::Null) => String::new(),
819        Field::Text(s) => (*s).to_string(),
820        Field::Json(Value::String(s)) => s.clone(),
821        Field::Json(Value::Bool(b)) => b.to_string(),
822        Field::Json(Value::Number(n)) => number_to_string(n.as_f64().unwrap_or(0.0)),
823        Field::Json(other) => canonical_json(other),
824    }
825}
826
827/// The field as JSON (`undefined` = null).
828fn json_of(v: &Field) -> Value {
829    match v {
830        Field::Missing => Value::Null,
831        Field::Text(s) => Value::String((*s).to_string()),
832        Field::Json(v) => (*v).clone(),
833    }
834}
835
836/// Numbers when both parse, else text (UTF-16 code units, no locale).
837fn compare(a: &Field, b: &Field) -> Ordering {
838    if let (Some(na), Some(nb)) = (num(a), num(b)) {
839        return na.partial_cmp(&nb).unwrap_or(Ordering::Equal);
840    }
841    utf16_cmp(&text(a), &text(b))
842}
843
844fn holds(filter: &RecordsFilter, value: &Field) -> bool {
845    let list: Vec<&str> = match &filter.value {
846        RecordsFilterValue::One(v) => vec![v.as_str()],
847        RecordsFilterValue::Many(vs) => vs.iter().map(String::as_str).collect(),
848    };
849    match filter.op {
850        RecordsFilterOp::Eq => match &filter.value {
851            RecordsFilterValue::One(v) => text(value) == *v,
852            RecordsFilterValue::Many(_) => false,
853        },
854        RecordsFilterOp::In => {
855            let t = text(value);
856            list.iter().any(|v| *v == t)
857        }
858        RecordsFilterOp::Between => list.len() == 2 && compare(value, &Field::Text(list[0])).is_ge() && compare(value, &Field::Text(list[1])).is_le(),
859        op => {
860            let Some(bound) = list.first().filter(|_| list.len() == 1) else { return false };
861            let c = compare(value, &Field::Text(bound));
862            match op {
863                RecordsFilterOp::Gt => c.is_gt(),
864                RecordsFilterOp::Gte => c.is_ge(),
865                RecordsFilterOp::Lt => c.is_lt(),
866                _ => c.is_le(),
867            }
868        }
869    }
870}
871
872fn data_field<'a>(row: &RecordRowRef<'a>, name: &str) -> Field<'a> {
873    match row.data.as_object().and_then(|o| o.get(name)) {
874        Some(v) => Field::Json(v),
875        None => Field::Missing,
876    }
877}
878
879static NULL: Value = Value::Null;
880
881/// A field as the query reads it: a row key (`authorId` = the author), else the data's.
882fn field<'a>(row: &RecordRowRef<'a>, name: &str) -> Field<'a> {
883    match name {
884        "createdAt" => Field::Text(row.created_at),
885        "updatedAt" => Field::Text(row.updated_at),
886        "id" => Field::Text(row.id),
887        "createdBy" => row.created_by.map_or(Field::Json(&NULL), Field::Text),
888        "authorId" => row.author().map_or(Field::Json(&NULL), Field::Text),
889        _ => data_field(row, name),
890    }
891}
892
893fn parse_month(key: &str) -> (i64, i64) {
894    let (y, m) = key.split_once('-').unwrap_or(("1970", "1"));
895    (y.parse().unwrap_or(1970), m.parse::<i64>().unwrap_or(1).clamp(1, 12))
896}
897
898/// `YYYY-MM` ± `by` months.
899pub fn shift_month(key: &str, by: i64) -> String {
900    let (y, m) = parse_month(key);
901    let index = y * 12 + (m - 1) + by;
902    format!("{}-{:02}", index.div_euclid(12), index.rem_euclid(12) + 1)
903}
904
905/// `October 2026`.
906pub fn month_label(key: &str) -> String {
907    let (y, m) = parse_month(key);
908    format!("{} {y}", RECORDS_MONTHS[(m - 1) as usize])
909}
910
911/// One day of the month view.
912#[derive(Debug, Clone, PartialEq)]
913pub struct RecordsDay {
914    pub date: String,
915    pub day: i64,
916    pub weekday: &'static str,
917    pub count: u64,
918    pub sum: f64,
919}
920
921/// Every day of `key` (`YYYY-MM`), empty.
922pub fn month_days(key: &str) -> Vec<RecordsDay> {
923    let (y, m) = parse_month(key);
924    (1..=days_in_month(y, m)).map(|day| RecordsDay { date: format!("{key}-{day:02}"), day, weekday: RECORDS_WEEKDAYS[weekday_of(y, m, day)], count: 0, sum: 0.0 }).collect()
925}
926
927/// JS `Math.round(n * 1e6) / 1e6`: sums like 0.1 + 0.2 stay readable (and identical ×4).
928fn round(n: f64) -> f64 {
929    (n * 1_000_000.0 + 0.5).floor() / 1_000_000.0
930}
931
932/// `s.slice(0, n)` (dates and ISO timestamps: ASCII).
933fn prefix(s: &str, n: usize) -> String {
934    s.chars().take(n).collect()
935}
936
937/// One aggregate over rows in the query's order.
938fn aggregate_of(agg: &RecordsAggregate, rows: &[&RecordRowRef]) -> Value {
939    let values: Vec<Field> = match &agg.field {
940        Some(f) => rows.iter().map(|r| field(r, f)).collect(),
941        None => Vec::new(),
942    };
943    match agg.op {
944        RecordsAggregateOp::Count => json!(if agg.field.is_none() { rows.len() } else { values.iter().filter(|v| !text(v).is_empty()).count() }),
945        RecordsAggregateOp::Sum => number(round(values.iter().fold(0.0, |acc, v| acc + num(v).unwrap_or(0.0)))),
946        RecordsAggregateOp::Avg => {
947            let nums: Vec<f64> = values.iter().filter_map(num).collect();
948            if nums.is_empty() {
949                Value::Null
950            } else {
951                number(round(nums.iter().fold(0.0, |a, b| a + b) / nums.len() as f64))
952            }
953        }
954        RecordsAggregateOp::Min | RecordsAggregateOp::Max => {
955            let mut best: Option<&Field> = None;
956            for v in &values {
957                if text(v).is_empty() {
958                    continue;
959                }
960                let better = match best {
961                    None => true,
962                    Some(b) if agg.op == RecordsAggregateOp::Min => compare(v, b).is_lt(),
963                    Some(b) => compare(v, b).is_gt(),
964                };
965                if better {
966                    best = Some(v);
967                }
968            }
969            best.map_or(Value::Null, |b| num(b).map_or_else(|| Value::String(text(b)), number))
970        }
971        RecordsAggregateOp::CountDistinct => {
972            let mut seen: Vec<String> = values.iter().map(text).filter(|t| !t.is_empty()).collect();
973            seen.sort();
974            seen.dedup();
975            json!(seen.len())
976        }
977        RecordsAggregateOp::First => values.first().map_or(Value::Null, json_of),
978        RecordsAggregateOp::Last => values.last().map_or(Value::Null, json_of),
979    }
980}
981
982fn sum_of(row: &RecordRowRef, f: &str) -> f64 {
983    num_value(row.data.as_object().and_then(|o| o.get(f))).unwrap_or(0.0)
984}
985
986/// count, sum?, then every aggregate: a group's, `totals`' and `before`'s columns.
987fn summary(query: &RecordsQuery, rows: &[&RecordRowRef], out: &mut Map<String, Value>) {
988    out.insert("count".into(), json!(rows.len()));
989    if let Some(sum) = &query.sum {
990        out.insert("sum".into(), number(round(rows.iter().fold(0.0, |acc, row| acc + sum_of(row, sum)))));
991    }
992    for agg in &query.aggregate {
993        out.insert(agg.name.clone(), aggregate_of(agg, rows));
994    }
995}
996
997#[derive(Default, Clone, Copy)]
998struct RunningAcc {
999    sum: f64,
1000    count: u64,
1001    nums: u64,
1002    min: Option<f64>,
1003    max: Option<f64>,
1004}
1005
1006/// The running values: fed row by row, read after each step.
1007struct Running<'q> {
1008    list: &'q [RecordsRunning],
1009    acc: Vec<RunningAcc>,
1010}
1011
1012impl<'q> Running<'q> {
1013    fn new(list: &'q [RecordsRunning]) -> Self {
1014        Running { list, acc: vec![RunningAcc::default(); list.len()] }
1015    }
1016
1017    fn add(&mut self, row: &RecordRowRef) {
1018        for (r, a) in self.list.iter().zip(self.acc.iter_mut()) {
1019            a.count += 1;
1020            let Some(n) = num(&field(row, &r.field)) else { continue };
1021            a.sum += n;
1022            a.nums += 1;
1023            a.min = Some(a.min.map_or(n, |m| if n < m { n } else { m }));
1024            a.max = Some(a.max.map_or(n, |m| if n > m { n } else { m }));
1025        }
1026    }
1027
1028    fn read(&self) -> Value {
1029        let mut out = Map::new();
1030        for (r, a) in self.list.iter().zip(&self.acc) {
1031            let v = match r.op {
1032                RecordsRunningOp::Sum => number(round(a.sum)),
1033                RecordsRunningOp::Count => json!(a.count),
1034                RecordsRunningOp::Avg if a.nums > 0 => number(round(a.sum / a.nums as f64)),
1035                RecordsRunningOp::Avg => Value::Null,
1036                RecordsRunningOp::Min => a.min.map_or(Value::Null, number),
1037                RecordsRunningOp::Max => a.max.map_or(Value::Null, number),
1038            };
1039            out.insert(r.field.clone(), v);
1040        }
1041        Value::Object(out)
1042    }
1043}
1044
1045/// A group's column: `author` = the author's name (+ `authorId`), a bucket =
1046/// the date's bucket key, any other field its value.
1047fn group_columns(key: &RecordsGroupKey, row: &RecordRowRef, names: &HashMap<String, String>, cols: &mut Map<String, Value>) -> String {
1048    if key.field == "author" {
1049        let author = row.author();
1050        cols.insert("author".into(), json!(author.and_then(|a| names.get(a)).cloned().unwrap_or_default()));
1051        cols.insert("authorId".into(), json!(author));
1052        return author.unwrap_or("").to_string();
1053    }
1054    let v = field(row, &key.field);
1055    if let Some(bucket) = key.bucket {
1056        let b = date_bucket(&text(&v), bucket);
1057        cols.insert(key.field.clone(), json!(b));
1058        return b.unwrap_or_default();
1059    }
1060    cols.insert(key.field.clone(), json_of(&v));
1061    text(&v)
1062}
1063
1064/// The value a records binding emits for `query` over `rows` (every row of
1065/// the app, any kind): `None` without a kind; else {rows, count, sum?,
1066/// month?, groups?, totals?, before?}: the matches sorted (ties by id) then
1067/// cut at the limit, `count` and `sum` before the limit.
1068pub fn records_value(rows: &[RecordRowRef], query: &RecordsQuery, ctx: RecordsContext) -> Option<Value> {
1069    let kind = query.from.as_deref()?;
1070    let view = query.month.as_ref();
1071    let month_key: Option<String> = view.map(|v| v.key.clone().unwrap_or_else(|| prefix(ctx.now, 7)));
1072    let mut before: Vec<&RecordRowRef> = Vec::new();
1073    let mut matched: Vec<&RecordRowRef> = Vec::new();
1074    for row in rows {
1075        if row.kind != kind {
1076            continue;
1077        }
1078        let author = row.author();
1079        if query.mine && (ctx.me.is_none() || author != ctx.me) {
1080            continue;
1081        }
1082        if let Some(user) = &query.user {
1083            if author != Some(user.as_str()) {
1084                continue;
1085            }
1086        }
1087        if !query.where_.iter().all(|f| holds(f, &field(row, &f.field))) {
1088            continue;
1089        }
1090        if !query.or.is_empty() && !query.or.iter().any(|alt| alt.iter().all(|f| holds(f, &field(row, &f.field)))) {
1091            continue;
1092        }
1093        if let Some(b) = &query.before {
1094            let t = text(&field(row, &b.field));
1095            if !t.is_empty() && compare(&Field::Text(&t), &Field::Text(&b.date)).is_lt() {
1096                before.push(row);
1097                continue;
1098            }
1099        }
1100        if let (Some(view), Some(key)) = (view, &month_key) {
1101            if !text(&data_field(row, &view.field)).starts_with(key.as_str()) {
1102                continue;
1103            }
1104        }
1105        matched.push(row);
1106    }
1107    let order = |a: &&RecordRowRef, b: &&RecordRowRef| {
1108        for key in &query.sort {
1109            let c = compare(&field(a, &key.field), &field(b, &key.field));
1110            if c.is_ne() {
1111                return if key.order == RecordsOrder::Asc { c } else { c.reverse() };
1112            }
1113        }
1114        utf16_cmp(a.id, b.id)
1115    };
1116    matched.sort_by(order);
1117    before.sort_by(order);
1118    let seeded = || {
1119        let mut r = Running::new(&query.running);
1120        for row in &before {
1121            r.add(row);
1122        }
1123        r
1124    };
1125    let mut row_running = (!query.running.is_empty() && query.group.is_empty()).then(seeded);
1126    let out_rows: Vec<Value> = matched
1127        .iter()
1128        .take(query.limit as usize)
1129        .map(|row| {
1130            let mut obj: Map<String, Value> = row.data.as_object().cloned().unwrap_or_default();
1131            obj.insert("id".into(), json!(row.id));
1132            obj.insert("createdAt".into(), json!(row.created_at));
1133            obj.insert("updatedAt".into(), json!(row.updated_at));
1134            obj.insert("createdBy".into(), json!(row.created_by));
1135            obj.insert("authorId".into(), json!(row.author()));
1136            let author = row.author().and_then(|a| ctx.names.get(a)).cloned().unwrap_or_default();
1137            obj.insert("author".into(), json!(author));
1138            if let Some(r) = row_running.as_mut() {
1139                r.add(row);
1140                obj.insert("running".into(), r.read());
1141            }
1142            Value::Object(obj)
1143        })
1144        .collect();
1145    let mut out = Map::new();
1146    out.insert("rows".into(), Value::Array(out_rows));
1147    out.insert("count".into(), json!(matched.len()));
1148    if let Some(sum) = &query.sum {
1149        let total = matched.iter().fold(0.0, |acc, row| acc + sum_of(row, sum));
1150        out.insert("sum".into(), number(round(total)));
1151    }
1152    if let (Some(view), Some(key)) = (view, &month_key) {
1153        let mut days = month_days(key);
1154        let mut per_day: Vec<Vec<&RecordRowRef>> = vec![Vec::new(); days.len()];
1155        for row in &matched {
1156            let date = prefix(&text(&data_field(row, &view.field)), 10);
1157            let Some(i) = days.iter().position(|d| d.date == date) else {
1158                continue;
1159            };
1160            let day = &mut days[i];
1161            day.count += 1;
1162            if let Some(sum) = &query.sum {
1163                day.sum = round(day.sum + sum_of(row, sum));
1164            }
1165            per_day[i].push(row);
1166        }
1167        let mut runnings: Vec<Option<Value>> = vec![None; days.len()];
1168        if !query.running.is_empty() {
1169            let mut r = seeded();
1170            for (i, rows) in per_day.iter().enumerate() {
1171                for row in rows {
1172                    r.add(row);
1173                }
1174                runnings[i] = Some(r.read());
1175            }
1176        }
1177        let labels: Vec<String> = days.iter().map(|d| d.day.to_string()).collect();
1178        let counts: Vec<u64> = days.iter().map(|d| d.count).collect();
1179        let sums: Vec<Value> = days.iter().map(|d| number(d.sum)).collect();
1180        let days_json: Vec<Value> = days
1181            .iter()
1182            .zip(runnings)
1183            .map(|(d, running)| {
1184                let mut day = json!({"date": d.date, "day": d.day, "weekday": d.weekday, "count": d.count, "sum": number(d.sum)});
1185                if let (Some(running), Some(obj)) = (running, day.as_object_mut()) {
1186                    obj.insert("running".into(), running);
1187                }
1188                day
1189            })
1190            .collect();
1191        out.insert(
1192            "month".into(),
1193            json!({
1194                "key": key,
1195                "label": month_label(key),
1196                "prev": shift_month(key, -1),
1197                "next": shift_month(key, 1),
1198                "today": prefix(ctx.now, 10),
1199                "days": days_json,
1200                "labels": labels,
1201                "counts": counts,
1202                "sums": sums,
1203            }),
1204        );
1205    }
1206    if !query.group.is_empty() {
1207        struct Group<'r, 'a> {
1208            parts: Vec<String>,
1209            rows: Vec<&'r RecordRowRef<'a>>,
1210            out: Map<String, Value>,
1211        }
1212        let mut groups: Vec<Group> = Vec::new();
1213        let mut index: HashMap<Vec<String>, usize> = HashMap::new();
1214        for row in &matched {
1215            let mut cols = Map::new();
1216            let parts: Vec<String> = query.group.iter().map(|key| group_columns(key, row, ctx.names, &mut cols)).collect();
1217            match index.get(&parts) {
1218                Some(&i) => groups[i].rows.push(row),
1219                None => {
1220                    index.insert(parts.clone(), groups.len());
1221                    groups.push(Group { parts, rows: vec![row], out: cols });
1222                }
1223            }
1224        }
1225        for g in &mut groups {
1226            let rows = g.rows.clone();
1227            summary(query, &rows, &mut g.out);
1228        }
1229        let mut columns: Vec<&str> = Vec::new();
1230        for g in &query.group {
1231            if g.field == "author" {
1232                columns.extend(["author", "authorId"]);
1233            } else {
1234                columns.push(&g.field);
1235            }
1236        }
1237        columns.push("count");
1238        if query.sum.is_some() {
1239            columns.push("sum");
1240        }
1241        columns.extend(query.aggregate.iter().map(|a| a.name.as_str()));
1242        let sort_keys: Vec<&RecordsSortKey> = query.sort.iter().filter(|k| columns.contains(&k.field.as_str())).collect();
1243        let col = |g: &Group, name: &str| -> Value { g.out.get(name).cloned().unwrap_or(Value::Null) };
1244        groups.sort_by(|a, b| {
1245            for key in &sort_keys {
1246                let (va, vb) = (col(a, &key.field), col(b, &key.field));
1247                let c = compare(&Field::Json(&va), &Field::Json(&vb));
1248                if c.is_ne() {
1249                    return if key.order == RecordsOrder::Asc { c } else { c.reverse() };
1250                }
1251            }
1252            for key in &query.group {
1253                let (va, vb) = (col(a, &key.field), col(b, &key.field));
1254                let c = compare(&Field::Json(&va), &Field::Json(&vb));
1255                if c.is_ne() {
1256                    return c;
1257                }
1258            }
1259            for (pa, pb) in a.parts.iter().zip(&b.parts) {
1260                let c = utf16_cmp(pa, pb);
1261                if c.is_ne() {
1262                    return c;
1263                }
1264            }
1265            Ordering::Equal
1266        });
1267        if !query.running.is_empty() {
1268            let mut r = seeded();
1269            for g in &mut groups {
1270                for row in &g.rows {
1271                    r.add(row);
1272                }
1273                g.out.insert("running".into(), r.read());
1274            }
1275        }
1276        out.insert("groups".into(), Value::Array(groups.into_iter().map(|g| Value::Object(g.out)).collect()));
1277    }
1278    if !query.aggregate.is_empty() {
1279        let mut totals = Map::new();
1280        summary(query, &matched, &mut totals);
1281        out.insert("totals".into(), Value::Object(totals));
1282    }
1283    if query.before.is_some() {
1284        let mut b = Map::new();
1285        summary(query, &before, &mut b);
1286        out.insert("before".into(), Value::Object(b));
1287    }
1288    Some(Value::Object(out))
1289}
1290
1291/// [`records_value`] over owned rows (the FFI's).
1292pub fn records_value_owned(rows: &[RecordRow], query: &RecordsQuery, ctx: RecordsContext) -> Option<Value> {
1293    let refs: Vec<RecordRowRef> = rows.iter().map(RecordRow::as_ref).collect();
1294    records_value(&refs, query, ctx)
1295}
1296
1297#[cfg(test)]
1298mod tests {
1299    use super::*;
1300
1301    #[test]
1302    fn number_parses_like_js() {
1303        assert_eq!(js_string_number(" 12 "), 12.0);
1304        assert_eq!(js_string_number("0x10"), 16.0);
1305        assert_eq!(js_string_number("1e2"), 100.0);
1306        assert_eq!(js_string_number(""), 0.0);
1307        assert!(js_string_number("12px").is_nan());
1308        assert!(js_string_number("inf").is_nan());
1309        assert!(js_string_number("-0x1").is_nan());
1310        assert_eq!(js_string_number("-Infinity"), f64::NEG_INFINITY);
1311    }
1312
1313    #[test]
1314    fn months_shift_across_years() {
1315        assert_eq!(shift_month("2026-01", -1), "2025-12");
1316        assert_eq!(shift_month("2026-12", 1), "2027-01");
1317        assert_eq!(shift_month("2026-03", -15), "2024-12");
1318        assert_eq!(month_label("2026-10"), "October 2026");
1319        assert_eq!(month_days("2028-02").len(), 29);
1320    }
1321}