1use std::borrow::Cow;
16use std::cmp::Ordering;
17use std::collections::{BTreeMap, HashMap};
18
19use rustc_hash::FxHashMap;
20use serde::{Deserialize, Serialize};
21use serde_json::{json, Map, Value};
22
23use super::signature::canonical_json;
24use super::sources::{parse_source, source_with_params, ParsedSource};
25use super::{js_trim, utf16_cmp};
26use super::calendar::{
27 calendar_month, calendar_timeline, calendar_week_view, day_of, period_start, shift_period, span_of, tones_of, CalendarSpan, CALENDAR_LANES_DEFAULT, CALENDAR_LANES_MAX, TIMELINE_COLUMNS_DEFAULT, TIMELINE_COLUMNS_MAX, TIMELINE_SCALES,
28};
29use crate::dates::{date_bucket, days_in_month, parse_day, weekday_of, DateBucket, DATE_BUCKETS};
30use crate::json::{number, number_to_string};
31
32pub const RECORDS_SCHEME: &str = "records";
34pub const RECORDS_SORT_DEFAULT: &str = "createdAt";
36pub const RECORDS_ORDER_DEFAULT: &str = "desc";
37pub const RECORDS_LIMIT_DEFAULT: u32 = 100;
38pub const RECORDS_LIMIT_MAX: u32 = 500;
39pub const RECORDS_RESERVED_PARAMS: &[&str] = &["app", "before", "by", "columns", "day", "expand", "lanes", "limit", "mine", "month", "order", "q", "query", "row", "scale", "sort", "start", "sum", "tone", "until", "user", "view"];
41pub const RECORDS_ROW_KEYS: &[&str] = &["id", "createdAt", "updatedAt", "createdBy", "authorId", "author"];
43pub const RECORDS_FILTER_OPS: &[&str] = &["eq", "gt", "gte", "lt", "lte", "in", "between"];
46pub const RECORDS_OP_ALIASES: &[(&str, &str)] = &[("=", "eq"), ("==", "eq"), (">", "gt"), (">=", "gte"), ("<", "lt"), ("<=", "lte")];
48pub const RECORDS_AGGREGATE_OPS: &[&str] = &["sum", "avg", "min", "max", "count", "countDistinct", "first", "last"];
49pub const RECORDS_RUNNING_OPS: &[&str] = &["sum", "count", "avg", "min", "max"];
50pub const RECORDS_AGGREGATE_RESERVED: &[&str] = &["count", "sum", "running", "rows"];
52pub const RECORDS_WEEKDAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
54pub const RECORDS_MONTHS: [&str; 12] = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"];
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58pub enum RecordsFilterOp {
59 Eq,
60 Gt,
61 Gte,
62 Lt,
63 Lte,
64 In,
65 Between,
66}
67
68impl RecordsFilterOp {
69 pub fn as_str(self) -> &'static str {
70 match self {
71 Self::Eq => "eq",
72 Self::Gt => "gt",
73 Self::Gte => "gte",
74 Self::Lt => "lt",
75 Self::Lte => "lte",
76 Self::In => "in",
77 Self::Between => "between",
78 }
79 }
80
81 pub fn parse(text: &str) -> Option<Self> {
83 let name = RECORDS_OP_ALIASES.iter().find(|(alias, _)| *alias == text).map_or(text, |(_, op)| op);
84 match name {
85 "eq" => Some(Self::Eq),
86 "gt" => Some(Self::Gt),
87 "gte" => Some(Self::Gte),
88 "lt" => Some(Self::Lt),
89 "lte" => Some(Self::Lte),
90 "in" => Some(Self::In),
91 "between" => Some(Self::Between),
92 _ => None,
93 }
94 }
95
96 fn suffix(text: &str) -> Option<Self> {
98 match text {
99 "gt" => Some(Self::Gt),
100 "gte" => Some(Self::Gte),
101 "lt" => Some(Self::Lt),
102 "lte" => Some(Self::Lte),
103 "in" => Some(Self::In),
104 "between" => Some(Self::Between),
105 _ => None,
106 }
107 }
108
109 fn is_list(self) -> bool {
110 matches!(self, Self::In | Self::Between)
111 }
112}
113
114#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
116#[serde(untagged)]
117pub enum RecordsFilterValue {
118 One(String),
119 Many(Vec<String>),
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
125pub struct RecordsFilter {
126 pub field: String,
127 pub op: RecordsFilterOp,
128 pub value: RecordsFilterValue,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
132#[serde(rename_all = "lowercase")]
133pub enum RecordsOrder {
134 Asc,
135 Desc,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
139pub struct RecordsSortKey {
140 pub field: String,
141 pub order: RecordsOrder,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
146pub struct RecordsMonthView {
147 pub field: String,
148 pub key: Option<String>,
150 pub until: Option<String>,
152 pub day: Option<String>,
154 pub lanes: u32,
156 pub tone: Option<String>,
158}
159
160#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
162pub struct RecordsWeekView {
163 pub field: String,
164 pub key: Option<String>,
166 pub until: Option<String>,
167 pub lanes: u32,
168 pub tone: Option<String>,
169}
170
171#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
174pub struct RecordsTimelineView {
175 pub field: String,
176 pub until: Option<String>,
177 pub row: Option<String>,
179 pub scale: String,
181 pub start: Option<String>,
183 pub columns: u32,
184 pub tone: Option<String>,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
190pub struct RecordsGroupKey {
191 pub field: String,
192 pub bucket: Option<DateBucket>,
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
196#[serde(rename_all = "camelCase")]
197pub enum RecordsAggregateOp {
198 Sum,
199 Avg,
200 Min,
201 Max,
202 Count,
203 CountDistinct,
204 First,
205 Last,
206}
207
208impl RecordsAggregateOp {
209 pub fn parse(text: &str) -> Option<Self> {
210 match text {
211 "sum" => Some(Self::Sum),
212 "avg" => Some(Self::Avg),
213 "min" => Some(Self::Min),
214 "max" => Some(Self::Max),
215 "count" => Some(Self::Count),
216 "countDistinct" => Some(Self::CountDistinct),
217 "first" => Some(Self::First),
218 "last" => Some(Self::Last),
219 _ => None,
220 }
221 }
222}
223
224#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
226pub struct RecordsAggregate {
227 pub name: String,
228 pub op: RecordsAggregateOp,
229 pub field: Option<String>,
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
234#[serde(rename_all = "lowercase")]
235pub enum RecordsRunningOp {
236 Sum,
237 Count,
238 Avg,
239 Min,
240 Max,
241}
242
243impl RecordsRunningOp {
244 pub fn parse(text: &str) -> Option<Self> {
245 match text {
246 "sum" => Some(Self::Sum),
247 "count" => Some(Self::Count),
248 "avg" => Some(Self::Avg),
249 "min" => Some(Self::Min),
250 "max" => Some(Self::Max),
251 _ => None,
252 }
253 }
254}
255
256#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
258pub struct RecordsRunning {
259 pub field: String,
260 pub op: RecordsRunningOp,
261}
262
263#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
266pub struct RecordsBefore {
267 pub field: String,
268 pub date: String,
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
273pub struct RecordsQuery {
274 pub from: Option<String>,
276 pub app: Option<String>,
278 pub mine: bool,
280 pub user: Option<String>,
282 #[serde(rename = "where")]
284 pub where_: Vec<RecordsFilter>,
285 #[serde(default)]
287 pub or: Vec<Vec<RecordsFilter>>,
288 pub sort: Vec<RecordsSortKey>,
290 pub limit: u32,
291 pub sum: Option<String>,
293 pub month: Option<RecordsMonthView>,
294 #[serde(default)]
296 pub week: Option<RecordsWeekView>,
297 #[serde(default)]
298 pub timeline: Option<RecordsTimelineView>,
299 #[serde(default)]
301 pub group: Vec<RecordsGroupKey>,
302 #[serde(default)]
304 pub aggregate: Vec<RecordsAggregate>,
305 #[serde(default)]
307 pub running: Vec<RecordsRunning>,
308 #[serde(default)]
309 pub before: Option<RecordsBefore>,
310 #[serde(default)]
313 pub expand: Vec<String>,
314}
315
316impl Default for RecordsQuery {
317 fn default() -> Self {
318 RecordsQuery {
319 from: None,
320 app: None,
321 mine: false,
322 user: None,
323 where_: Vec::new(),
324 or: Vec::new(),
325 sort: vec![RecordsSortKey { field: RECORDS_SORT_DEFAULT.to_string(), order: RecordsOrder::Desc }],
326 limit: RECORDS_LIMIT_DEFAULT,
327 sum: None,
328 month: None,
329 week: None,
330 timeline: None,
331 group: Vec::new(),
332 aggregate: Vec::new(),
333 running: Vec::new(),
334 before: None,
335 expand: Vec::new(),
336 }
337 }
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342pub struct RecordsQueryIssue {
343 pub path: String,
344 pub message: String,
345}
346
347pub(crate) fn js_string_number(s: &str) -> f64 {
350 let t = js_trim(s);
351 if t.is_empty() {
352 return 0.0;
353 }
354 for (prefix, radix) in [("0x", 16), ("0X", 16), ("0o", 8), ("0O", 8), ("0b", 2), ("0B", 2)] {
355 if let Some(digits) = t.strip_prefix(prefix) {
356 if digits.is_empty() || !digits.chars().all(|c| c.is_digit(radix)) {
357 return f64::NAN;
358 }
359 return digits.chars().fold(0.0, |n, c| n * f64::from(radix) + f64::from(c.to_digit(radix).unwrap_or(0)));
360 }
361 }
362 let (sign, body) = match t.as_bytes()[0] {
363 b'-' => (-1.0, &t[1..]),
364 b'+' => (1.0, &t[1..]),
365 _ => (1.0, t),
366 };
367 if body == "Infinity" {
368 return sign * f64::INFINITY;
369 }
370 let b = body.as_bytes();
371 let mut i = 0;
372 let digits = |i: &mut usize| {
373 let start = *i;
374 while b.get(*i).is_some_and(u8::is_ascii_digit) {
375 *i += 1;
376 }
377 *i - start
378 };
379 let int = digits(&mut i);
380 let frac = if b.get(i) == Some(&b'.') {
381 i += 1;
382 digits(&mut i)
383 } else {
384 0
385 };
386 if int == 0 && frac == 0 {
387 return f64::NAN;
388 }
389 if matches!(b.get(i), Some(b'e' | b'E')) {
390 i += 1;
391 if matches!(b.get(i), Some(b'+' | b'-')) {
392 i += 1;
393 }
394 if digits(&mut i) == 0 {
395 return f64::NAN;
396 }
397 }
398 if i != b.len() {
399 return f64::NAN;
400 }
401 body.parse::<f64>().map(|n| sign * n).unwrap_or(f64::NAN)
402}
403
404fn is_month_key(text: &str) -> bool {
406 let b = text.as_bytes();
407 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")
408}
409
410fn trimmed(v: Option<&str>) -> Option<String> {
411 v.map(js_trim).filter(|s| !s.is_empty()).map(str::to_string)
412}
413
414fn day_key(v: Option<&str>) -> Option<String> {
416 v.filter(|t| t.len() == 10 && parse_day(t).is_some()).map(str::to_string)
417}
418
419fn bounded_int(v: &Value, max: u32) -> Option<u32> {
421 let n = match v {
422 Value::Number(n) => n.as_f64().unwrap_or(f64::NAN),
423 Value::String(s) if !js_trim(s).is_empty() => js_string_number(s),
424 _ => f64::NAN,
425 };
426 (n.is_finite() && n.fract() == 0.0 && n >= 1.0 && n <= f64::from(max)).then_some(n as u32)
427}
428
429fn bounded_param(v: Option<&String>, max: u32) -> Option<u32> {
430 v.and_then(|s| bounded_int(&Value::String(s.clone()), max))
431}
432
433pub fn parse_records_query(params: &BTreeMap<String, String>, kind: Option<&str>) -> RecordsQuery {
438 let from = trimmed(kind);
439 if let Some(q) = params.get("q").filter(|q| !js_trim(q).is_empty()) {
440 let json = serde_json::from_str::<Value>(q).ok().filter(Value::is_object).unwrap_or_else(|| json!({}));
441 return parse_records_json(&json, from.as_deref()).0;
442 }
443 let limit_raw = params.get("limit").map_or(f64::NAN, |l| js_string_number(l));
444 let mut keys: Vec<&String> = params.keys().collect();
445 keys.sort_by(|a, b| utf16_cmp(a, b));
446 let mut where_ = Vec::new();
447 for key in keys {
448 if key.is_empty() || RECORDS_RESERVED_PARAMS.contains(&key.as_str()) {
449 continue;
450 }
451 let value = params[key].clone();
452 match key.rfind('.').and_then(|dot| RecordsFilterOp::suffix(&key[dot + 1..]).map(|op| (dot, op))) {
453 Some((dot, op)) if dot > 0 => {
454 let value = if op.is_list() { RecordsFilterValue::Many(value.split(',').map(str::to_string).collect()) } else { RecordsFilterValue::One(value) };
455 where_.push(RecordsFilter { field: key[..dot].to_string(), op, value });
456 }
457 _ => where_.push(RecordsFilter { field: key.clone(), op: RecordsFilterOp::Eq, value: RecordsFilterValue::One(value) }),
458 }
459 }
460 where_.sort_by(|a, b| utf16_cmp(&a.field, &b.field).then_with(|| utf16_cmp(a.op.as_str(), b.op.as_str())));
461 let order = match params.get("order").map(String::as_str) {
462 Some("asc") => RecordsOrder::Asc,
463 Some("desc") => RecordsOrder::Desc,
464 _ if RECORDS_ORDER_DEFAULT == "asc" => RecordsOrder::Asc,
465 _ => RecordsOrder::Desc,
466 };
467 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 };
468 let by = trimmed(params.get("by").map(String::as_str));
469 let before = trimmed(params.get("before").map(String::as_str));
470 let view = match params.get("view").map(String::as_str) {
472 Some(v @ ("week" | "timeline")) => v,
473 _ => "month",
474 };
475 let until = trimmed(params.get("until").map(String::as_str));
476 let tone = trimmed(params.get("tone").map(String::as_str));
477 let lanes = bounded_param(params.get("lanes"), CALENDAR_LANES_MAX).unwrap_or(CALENDAR_LANES_DEFAULT);
478 let day = day_key(params.get("day").map(String::as_str));
479 RecordsQuery {
480 from,
481 app: trimmed(params.get("app").map(String::as_str)),
482 mine: matches!(params.get("mine").map(String::as_str), Some("1" | "true")),
483 user: trimmed(params.get("user").map(String::as_str)),
484 where_,
485 sort: vec![RecordsSortKey { field: trimmed(params.get("sort").map(String::as_str)).unwrap_or_else(|| RECORDS_SORT_DEFAULT.to_string()), order }],
486 limit,
487 sum: trimmed(params.get("sum").map(String::as_str)),
488 before: before.map(|date| RecordsBefore { field: by.clone().unwrap_or_else(|| RECORDS_SORT_DEFAULT.to_string()), date }),
489 month: by.clone().filter(|_| view == "month").map(|field| RecordsMonthView { field, key: params.get("month").filter(|m| is_month_key(m)).cloned(), until: until.clone(), day: day.clone(), lanes, tone: tone.clone() }),
490 week: by.clone().filter(|_| view == "week").map(|field| RecordsWeekView { field, key: day.clone(), until: until.clone(), lanes, tone: tone.clone() }),
491 timeline: by.clone().filter(|_| view == "timeline").map(|field| RecordsTimelineView {
492 field,
493 until: until.clone(),
494 row: trimmed(params.get("row").map(String::as_str)),
495 scale: params.get("scale").filter(|s| TIMELINE_SCALES.contains(&s.as_str())).cloned().unwrap_or_else(|| "week".to_string()),
496 start: day_key(params.get("start").map(String::as_str)),
497 columns: bounded_param(params.get("columns"), TIMELINE_COLUMNS_MAX).unwrap_or(TIMELINE_COLUMNS_DEFAULT),
498 tone: tone.clone(),
499 }),
500 expand: {
501 let mut out: Vec<String> = Vec::new();
502 for f in params.get("expand").map(String::as_str).unwrap_or("").split(',').map(js_trim).filter(|f| !f.is_empty()) {
503 if !out.iter().any(|o| o == f) {
504 out.push(f.to_string());
505 }
506 }
507 out
508 },
509 ..RecordsQuery::default()
510 }
511}
512
513pub fn parse_records_source(source: &ParsedSource) -> Option<RecordsQuery> {
515 (source.scheme == RECORDS_SCHEME).then(|| parse_records_query(&source.params, Some(&source.name)))
516}
517
518fn scalar_text(v: &Value) -> Option<String> {
520 match v {
521 Value::Null => Some(String::new()),
522 Value::String(s) => Some(s.clone()),
523 Value::Bool(b) => Some(b.to_string()),
524 Value::Number(n) => Some(number_to_string(n.as_f64().unwrap_or(0.0))),
525 _ => None,
526 }
527}
528
529fn issue(issues: &mut Vec<RecordsQueryIssue>, path: impl Into<String>, message: impl Into<String>) {
530 issues.push(RecordsQueryIssue { path: path.into(), message: message.into() });
531}
532
533fn parse_condition(entry: &Value, at: &str, issues: &mut Vec<RecordsQueryIssue>) -> Option<RecordsFilter> {
535 let Some(list) = entry.as_array().filter(|l| l.len() >= 3) else {
536 issue(issues, at, "a condition is [field, op, value]");
537 return None;
538 };
539 let Some(field) = list[0].as_str().map(js_trim).filter(|f| !f.is_empty()) else {
540 issue(issues, format!("{at}/0"), "a condition's field is a non-empty string");
541 return None;
542 };
543 let Some(op) = list[1].as_str().and_then(RecordsFilterOp::parse) else {
544 issue(issues, format!("{at}/1"), format!("unknown operator {}", crate::json::to_js_string(&list[1])));
545 return None;
546 };
547 let rest = &list[2..];
548 if op == RecordsFilterOp::In {
549 let values: Option<Vec<String>> = match rest {
550 [Value::Array(items)] => items.iter().map(scalar_text).collect(),
551 _ => None,
552 };
553 let Some(values) = values else {
554 issue(issues, format!("{at}/2"), "in takes one list of values");
555 return None;
556 };
557 return Some(RecordsFilter { field: field.to_string(), op, value: RecordsFilterValue::Many(values) });
558 }
559 let values: Option<Vec<String>> = rest.iter().map(scalar_text).collect();
560 let want = if op == RecordsFilterOp::Between { 2 } else { 1 };
561 let Some(mut values) = values.filter(|v| v.len() == want) else {
562 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()) });
563 return None;
564 };
565 let value = if op == RecordsFilterOp::Between { RecordsFilterValue::Many(values) } else { RecordsFilterValue::One(values.remove(0)) };
566 Some(RecordsFilter { field: field.to_string(), op, value })
567}
568
569const QUERY_KEYS: &[&str] = &["from", "app", "mine", "user", "where", "sort", "limit", "sum", "month", "week", "timeline", "group", "aggregate", "running", "before", "expand"];
570
571fn view_keys(name: &str) -> &'static [&'static str] {
573 match name {
574 "month" => &["field", "key", "until", "day", "lanes", "tone"],
575 "week" => &["field", "key", "until", "lanes", "tone"],
576 _ => &["field", "until", "row", "scale", "start", "columns", "tone"],
577 }
578}
579
580fn parse_view_json(name: &str, v: &Value, issues: &mut Vec<RecordsQueryIssue>) -> Option<Map<String, Value>> {
583 let keys = view_keys(name);
584 let field = v.get("field").and_then(Value::as_str).map(js_trim).filter(|f| !f.is_empty());
585 let (Some(obj), Some(field)) = (v.as_object(), field) else {
586 let shape: Vec<String> = keys.iter().map(|k| if *k == "field" { (*k).to_string() } else { format!("{k}?") }).collect();
587 issue(issues, format!("/{name}"), format!("{name} is {{{}}}", shape.join(", ")));
588 return None;
589 };
590 let mut out = Map::new();
591 out.insert("field".into(), json!(field));
592 for key in sorted_keys(obj) {
593 if key == "field" {
594 continue;
595 }
596 let value = &obj[key.as_str()];
597 let at = format!("/{name}/{key}");
598 if !keys.contains(&key.as_str()) {
599 issue(issues, at, format!("unknown key {key}"));
600 } else if value.is_null() {
601 continue;
602 } else if key == "lanes" || key == "columns" {
603 let max = if key == "lanes" { CALENDAR_LANES_MAX } else { TIMELINE_COLUMNS_MAX };
604 match bounded_int(value, max) {
605 Some(n) => {
606 out.insert(key.clone(), json!(n));
607 }
608 None => issue(issues, at, format!("{key} is an integer 1–{max}")),
609 }
610 } else if key == "scale" {
611 match value.as_str().filter(|s| TIMELINE_SCALES.contains(s)) {
612 Some(s) => {
613 out.insert(key.clone(), json!(s));
614 }
615 None => issue(issues, at, format!("scale is {}", TIMELINE_SCALES.join("|"))),
616 }
617 } else if let Some(s) = value.as_str() {
618 out.insert(key.clone(), trimmed(Some(s)).map_or(Value::Null, Value::String));
619 } else {
620 issue(issues, at, format!("{key} is a string"));
621 }
622 }
623 Some(out)
624}
625
626fn sorted_keys(map: &Map<String, Value>) -> Vec<&String> {
627 let mut keys: Vec<&String> = map.keys().collect();
628 keys.sort_by(|a, b| utf16_cmp(a, b));
629 keys
630}
631
632pub fn parse_records_json(json: &Value, kind: Option<&str>) -> (RecordsQuery, Vec<RecordsQueryIssue>) {
636 let mut issues = Vec::new();
637 let mut query = RecordsQuery::default();
638 let Some(obj) = json.as_object() else {
639 issue(&mut issues, "", "a query is an object");
640 query.from = trimmed(kind);
641 return (query, issues);
642 };
643 for key in sorted_keys(obj) {
644 if !QUERY_KEYS.contains(&key.as_str()) && !key.starts_with('$') {
645 issue(&mut issues, format!("/{key}"), format!("unknown key {key}"));
646 }
647 }
648 let str_of = |key: &str, issues: &mut Vec<RecordsQueryIssue>| -> Option<String> {
649 match obj.get(key) {
650 None | Some(Value::Null) => None,
651 Some(Value::String(s)) => trimmed(Some(s)),
652 Some(_) => {
653 issue(issues, format!("/{key}"), format!("{key} is a string"));
654 None
655 }
656 }
657 };
658 query.from = match trimmed(kind) {
659 Some(k) => Some(k),
660 None => str_of("from", &mut issues),
661 };
662 query.app = str_of("app", &mut issues);
663 query.user = str_of("user", &mut issues);
664 query.sum = str_of("sum", &mut issues);
665 match obj.get("mine") {
666 None => {}
667 Some(Value::Bool(b)) => query.mine = *b,
668 Some(_) => issue(&mut issues, "/mine", "mine is a boolean"),
669 }
670 match obj.get("where") {
671 None => {}
672 Some(Value::Array(list)) => {
673 for (i, entry) in list.iter().enumerate() {
674 let at = format!("/where/{i}");
675 if let Some(alt) = entry.as_object() {
676 let Some(alternatives) = alt.get("or").and_then(Value::as_array).filter(|_| alt.len() == 1) else {
677 issue(&mut issues, at, "an alternative is {or: [[condition…]…]}");
678 continue;
679 };
680 if !query.or.is_empty() {
681 issue(&mut issues, at, "one or per query");
682 continue;
683 }
684 for (j, alt) in alternatives.iter().enumerate() {
685 let Some(conds) = alt.as_array() else {
686 issue(&mut issues, format!("{at}/or/{j}"), "an alternative is a list of conditions");
687 continue;
688 };
689 let parsed: Vec<RecordsFilter> = conds.iter().enumerate().filter_map(|(k, c)| parse_condition(c, &format!("{at}/or/{j}/{k}"), &mut issues)).collect();
690 query.or.push(parsed);
691 }
692 continue;
693 }
694 if let Some(parsed) = parse_condition(entry, &at, &mut issues) {
695 query.where_.push(parsed);
696 }
697 }
698 }
699 Some(_) => issue(&mut issues, "/where", "where is a list of conditions"),
700 }
701 if let Some(sort) = obj.get("sort") {
702 let single = sort.is_string();
703 let list: Option<Vec<Value>> = if single { Some(vec![sort.clone()]) } else { sort.as_array().cloned() };
704 match list {
705 None => issue(&mut issues, "/sort", "sort is a field or a list of [field, order]"),
706 Some(list) => {
707 let mut keys = Vec::new();
708 for (i, entry) in list.iter().enumerate() {
709 let asc = Value::String("asc".into());
710 let (name, order): (Option<&Value>, Option<&Value>) = match entry {
711 Value::String(_) => (Some(entry), Some(&asc)),
712 Value::Array(pair) => (pair.first(), if pair.len() > 1 { pair.get(1) } else { Some(&asc) }),
713 _ => (None, None),
714 };
715 let name = name.and_then(Value::as_str).map(js_trim).filter(|n| !n.is_empty());
716 let order = match order.and_then(Value::as_str) {
717 Some("asc") => Some(RecordsOrder::Asc),
718 Some("desc") => Some(RecordsOrder::Desc),
719 _ => None,
720 };
721 match (name, order) {
722 (Some(name), Some(order)) => keys.push(RecordsSortKey { field: name.to_string(), order }),
723 _ => issue(&mut issues, if single { "/sort".to_string() } else { format!("/sort/{i}") }, "a sort key is field or [field, asc|desc]"),
724 }
725 }
726 if !keys.is_empty() {
727 query.sort = keys;
728 }
729 }
730 }
731 }
732 if let Some(limit) = obj.get("limit") {
733 let n = match limit {
734 Value::Number(n) => n.as_f64().unwrap_or(f64::NAN),
735 Value::String(s) if !js_trim(s).is_empty() => js_string_number(s),
736 _ => f64::NAN,
737 };
738 if n.is_finite() && n.fract() == 0.0 && n >= 1.0 {
739 query.limit = n.min(f64::from(RECORDS_LIMIT_MAX)) as u32;
740 } else {
741 issue(&mut issues, "/limit", "limit is a positive integer");
742 }
743 }
744 for name in ["month", "week", "timeline"] {
746 let Some(raw) = obj.get(name).filter(|v| !v.is_null()) else { continue };
747 if query.month.is_some() || query.week.is_some() || query.timeline.is_some() {
748 issue(&mut issues, format!("/{name}"), "one calendar view per query");
749 continue;
750 }
751 let Some(v) = parse_view_json(name, raw, &mut issues) else { continue };
752 let str_at = |k: &str| v.get(k).and_then(Value::as_str).map(str::to_string);
753 let lanes = v.get("lanes").and_then(Value::as_u64).map_or(CALENDAR_LANES_DEFAULT, |n| n as u32);
754 let field = str_at("field").unwrap_or_default();
755 match name {
756 "month" => query.month = Some(RecordsMonthView { field, key: str_at("key").filter(|k| is_month_key(k)), until: str_at("until"), day: day_key(str_at("day").as_deref()), lanes, tone: str_at("tone") }),
757 "week" => query.week = Some(RecordsWeekView { field, key: day_key(str_at("key").as_deref()), until: str_at("until"), lanes, tone: str_at("tone") }),
758 _ => {
759 query.timeline = Some(RecordsTimelineView {
760 field,
761 until: str_at("until"),
762 row: str_at("row"),
763 scale: str_at("scale").unwrap_or_else(|| "week".to_string()),
764 start: day_key(str_at("start").as_deref()),
765 columns: v.get("columns").and_then(Value::as_u64).map_or(TIMELINE_COLUMNS_DEFAULT, |n| n as u32),
766 tone: str_at("tone"),
767 })
768 }
769 }
770 }
771 match obj.get("group") {
772 None => {}
773 Some(Value::Array(list)) => {
774 for (i, entry) in list.iter().enumerate() {
775 let at = format!("/group/{i}");
776 let key = match entry {
777 Value::String(s) if !js_trim(s).is_empty() => Some(RecordsGroupKey { field: js_trim(s).to_string(), bucket: None }),
778 Value::Object(o) if o.len() == 2 => {
779 let field = o.get("bucket").and_then(Value::as_str).map(js_trim).filter(|f| !f.is_empty());
780 let bucket = o.get("by").and_then(Value::as_str).and_then(DateBucket::parse);
781 field.zip(bucket).map(|(field, bucket)| RecordsGroupKey { field: field.to_string(), bucket: Some(bucket) })
782 }
783 _ => None,
784 };
785 let Some(key) = key else {
786 issue(&mut issues, at, format!("a group key is a field or {{bucket: field, by: {}}}", DATE_BUCKETS.join("|")));
787 continue;
788 };
789 if query.group.iter().any(|g| g.field == key.field || (key.field == "author" && g.field == "authorId") || (key.field == "authorId" && g.field == "author")) {
790 issue(&mut issues, at, format!("duplicate group {}", key.field));
791 continue;
792 }
793 query.group.push(key);
794 }
795 }
796 Some(_) => issue(&mut issues, "/group", "group is a list of keys"),
797 }
798 match obj.get("aggregate") {
799 None => {}
800 Some(Value::Object(aggs)) => {
801 for name in sorted_keys(aggs) {
802 let def = &aggs[name];
803 let at = format!("/aggregate/{name}");
804 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")) {
805 issue(&mut issues, at, format!("the name {name} is taken"));
806 continue;
807 }
808 let list = def.as_array();
809 let op = list.and_then(|l| l.first()).and_then(Value::as_str).and_then(RecordsAggregateOp::parse);
810 let len = list.map_or(0, Vec::len);
811 let field = list.filter(|l| l.len() > 1).and_then(|l| l[1].as_str()).map(js_trim).filter(|f| !f.is_empty());
812 match op {
813 Some(op) if len <= 2 && !(len > 1 && field.is_none()) && (op == RecordsAggregateOp::Count || field.is_some()) => {
814 query.aggregate.push(RecordsAggregate { name: name.clone(), op, field: field.map(str::to_string) });
815 }
816 _ => issue(&mut issues, at, format!("an aggregate is [{}, field] (count: the field is optional)", RECORDS_AGGREGATE_OPS.join("|"))),
817 }
818 }
819 }
820 Some(_) => issue(&mut issues, "/aggregate", "aggregate is {name: [op, field?]}"),
821 }
822 match obj.get("running") {
823 None => {}
824 Some(Value::Object(running)) => {
825 for name in sorted_keys(running) {
826 match running[name].as_str().and_then(RecordsRunningOp::parse) {
827 Some(op) if !js_trim(name).is_empty() => query.running.push(RecordsRunning { field: name.clone(), op }),
828 _ => issue(&mut issues, format!("/running/{name}"), format!("a running value is {{field: {}}}", RECORDS_RUNNING_OPS.join("|"))),
829 }
830 }
831 }
832 Some(_) => issue(&mut issues, "/running", "running is {field: op}"),
833 }
834 match obj.get("before") {
835 None | Some(Value::Null) => {}
836 Some(Value::String(date)) => {
837 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()))
838 .or_else(|| query.week.as_ref().map(|m| m.field.clone()))
839 .or_else(|| query.timeline.as_ref().map(|m| m.field.clone()))
840 .unwrap_or_else(|| RECORDS_SORT_DEFAULT.to_string());
841 query.before = trimmed(Some(date)).map(|date| RecordsBefore { field: implied, date });
842 }
843 Some(b) => {
844 let field = b.as_object().and_then(|o| o.get("field")).and_then(Value::as_str).map(js_trim).filter(|f| !f.is_empty());
845 match (field, b.get("date").and_then(Value::as_str)) {
846 (Some(field), Some(date)) => query.before = trimmed(Some(date)).map(|date| RecordsBefore { field: field.to_string(), date }),
847 _ => issue(&mut issues, "/before", "before is a date or {field, date}"),
848 }
849 }
850 }
851 match obj.get("expand") {
853 None => {}
854 Some(Value::Array(list)) => {
855 for (i, entry) in list.iter().enumerate() {
856 match entry.as_str().map(js_trim).filter(|f| !f.is_empty()) {
857 None => issue(&mut issues, format!("/expand/{i}"), "an expanded field is a non-empty string"),
858 Some(f) if query.expand.iter().any(|e| e == f) => issue(&mut issues, format!("/expand/{i}"), format!("duplicate expand {f}")),
859 Some(f) => query.expand.push(f.to_string()),
860 }
861 }
862 }
863 Some(_) => issue(&mut issues, "/expand", "expand is a list of ref fields"),
864 }
865 (query, issues)
866}
867
868pub fn fill_query_templates(value: &Value, params: &BTreeMap<String, String>) -> Value {
870 match value {
871 Value::String(s) => {
872 let mut out = String::new();
873 let mut rest = s.as_str();
874 while let Some(start) = rest.find("${") {
875 let Some(len) = rest[start + 2..].find('}') else { break };
876 out.push_str(&rest[..start]);
877 let name = js_trim(&rest[start + 2..start + 2 + len]);
878 out.push_str(params.get(name).map_or("", String::as_str));
879 rest = &rest[start + 2 + len + 1..];
880 }
881 out.push_str(rest);
882 Value::String(out)
883 }
884 Value::Array(items) => Value::Array(items.iter().map(|v| fill_query_templates(v, params)).collect()),
885 Value::Object(map) => Value::Object(map.iter().map(|(k, v)| (k.clone(), fill_query_templates(v, params))).collect()),
886 other => other.clone(),
887 }
888}
889
890pub fn expand_query_source(source: &str, queries: Option<&Value>) -> String {
895 let Some(parsed) = parse_source(source) else { return source.to_string() };
896 let Some(id) = parsed.params.get("query") else { return source.to_string() };
897 let Some(query) = queries.and_then(Value::as_object).and_then(|q| q.get(id)) else { return source.to_string() };
898 let mut params = parsed.params.clone();
899 params.remove("query");
900 let filled = fill_query_templates(query, ¶ms);
901 let base = js_trim(source);
902 let head = base.split_once('?').map_or(base, |(h, _)| h);
903 source_with_params(head, Some(&json!({ "q": canonical_json(&filled) })))
904}
905
906#[derive(Debug, Clone, Copy, PartialEq)]
908pub struct RecordRowRef<'a> {
909 pub id: &'a str,
910 pub kind: &'a str,
911 pub data: &'a Value,
912 pub created_by: Option<&'a str>,
913 pub author_id: Option<&'a str>,
915 pub created_at: &'a str,
916 pub updated_at: &'a str,
917}
918
919impl<'a> RecordRowRef<'a> {
920 pub fn author(&self) -> Option<&'a str> {
922 self.author_id.or(self.created_by)
923 }
924}
925
926#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
928#[serde(rename_all = "camelCase")]
929pub struct RecordRow {
930 pub id: String,
931 pub kind: String,
932 #[serde(default)]
933 pub data: Value,
934 #[serde(default)]
935 pub created_by: Option<String>,
936 #[serde(default)]
937 pub author_id: Option<String>,
938 pub created_at: String,
939 pub updated_at: String,
940}
941
942impl RecordRow {
943 pub fn as_ref(&self) -> RecordRowRef<'_> {
944 RecordRowRef {
945 id: &self.id,
946 kind: &self.kind,
947 data: &self.data,
948 created_by: self.created_by.as_deref(),
949 author_id: self.author_id.as_deref(),
950 created_at: &self.created_at,
951 updated_at: &self.updated_at,
952 }
953 }
954}
955
956#[derive(Debug, Clone, Copy)]
958pub struct RecordsContext<'a> {
959 pub me: Option<&'a str>,
961 pub now: &'a str,
963 pub today: Option<&'a str>,
966 pub names: &'a HashMap<String, String>,
968 pub computed: Option<&'a BTreeMap<String, String>>,
971 pub refs: Option<&'a Value>,
974 pub issues: Option<&'a Map<String, Value>>,
976 pub transitions: Option<&'a super::record_transitions::RecordTransitions>,
979 pub role: Option<&'a str>,
981}
982
983pub fn kind_refs(records: Option<&Value>, kind: Option<&str>) -> Option<Value> {
986 let def = records?.as_object()?.get(kind?)?.as_object()?;
987 let mut out = Map::new();
988 for (name, target) in def.get("refs")?.as_object()? {
989 match target {
990 Value::String(t) if t == "issue" || t == "member" => {
991 out.insert(name.clone(), target.clone());
992 }
993 Value::Object(o) => {
994 if let Some(k) = o.get("kind").and_then(Value::as_str).filter(|k| !k.is_empty()) {
995 out.insert(name.clone(), json!({ "kind": k }));
996 }
997 }
998 _ => {}
999 }
1000 }
1001 (!out.is_empty()).then_some(Value::Object(out))
1002}
1003
1004fn value_row(row: &RecordRowRef, names: &HashMap<String, String>) -> Map<String, Value> {
1006 let mut obj: Map<String, Value> = row.data.as_object().cloned().unwrap_or_default();
1007 obj.insert("id".into(), json!(row.id));
1008 obj.insert("createdAt".into(), json!(row.created_at));
1009 obj.insert("updatedAt".into(), json!(row.updated_at));
1010 obj.insert("createdBy".into(), json!(row.created_by));
1011 obj.insert("authorId".into(), json!(row.author()));
1012 let author = row.author().and_then(|a| names.get(a)).cloned().unwrap_or_default();
1013 obj.insert("author".into(), json!(author));
1014 obj
1015}
1016
1017fn expanded_ref(target: &Value, value: Option<&Value>, by_id: &HashMap<&str, &RecordRowRef>, ctx: &RecordsContext) -> Value {
1022 let id = match value {
1023 Some(Value::String(s)) => s.clone(),
1024 Some(Value::Number(n)) => number_to_string(n.as_f64().unwrap_or(0.0)),
1025 _ => String::new(),
1026 };
1027 if id.is_empty() {
1028 return Value::Null;
1029 }
1030 match target {
1031 Value::String(t) if t == "issue" => ctx.issues.and_then(|m| m.get(&id)).cloned().unwrap_or(Value::Null),
1032 Value::String(t) if t == "member" => ctx.names.get(&id).map_or(Value::Null, |name| json!({ "id": id, "name": name })),
1033 _ => {
1034 let kind = target.get("kind").and_then(Value::as_str).unwrap_or("");
1035 by_id.get(id.as_str()).filter(|r| r.kind == kind).map_or(Value::Null, |r| Value::Object(value_row(r, ctx.names)))
1036 }
1037 }
1038}
1039
1040pub const RECORDS_COMPUTED_MAX: usize = 16;
1042pub const RECORDS_COMPUTED_ROW_KEYS: [&str; 5] = ["id", "createdAt", "updatedAt", "createdBy", "authorId"];
1044
1045pub fn kind_computed(records: Option<&Value>, kind: Option<&str>) -> Option<BTreeMap<String, String>> {
1048 let def = records?.as_object()?.get(kind?)?.as_object()?;
1049 let out: BTreeMap<String, String> = def.get("computed")?.as_object()?.iter().filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))).collect();
1050 (!out.is_empty()).then_some(out)
1051}
1052
1053pub fn computed_data(rows: &[RecordRowRef], kind: &str, computed: &BTreeMap<String, String>, now: &str) -> Vec<Option<Value>> {
1060 let fields: Vec<(&String, Option<crate::cel::Node>, Vec<String>)> = computed
1061 .iter()
1062 .map(|(name, source)| {
1063 let ast = crate::cel::parse_expression(source).ok();
1064 let reads = ast.as_ref().map(crate::cel::expression_names).unwrap_or_default();
1065 (name, ast, reads)
1066 })
1067 .collect();
1068 let env = crate::cel::ExpressionEnv { now: Some(crate::cel::parse_timestamp(now).unwrap_or(0.0)), offset: None, max_steps: None };
1069 rows.iter()
1070 .map(|row| {
1071 if row.kind != kind {
1072 return None;
1073 }
1074 let mut vars: Map<String, Value> = row.data.as_object().cloned().unwrap_or_default();
1075 vars.insert("id".into(), Value::String(row.id.to_string()));
1076 vars.insert("createdAt".into(), Value::String(row.created_at.to_string()));
1077 vars.insert("updatedAt".into(), Value::String(row.updated_at.to_string()));
1078 vars.insert("createdBy".into(), row.created_by.map_or(Value::Null, |s| Value::String(s.to_string())));
1079 vars.insert("authorId".into(), row.author().map_or(Value::Null, |s| Value::String(s.to_string())));
1080 let mut data: Map<String, Value> = row.data.as_object().cloned().unwrap_or_default();
1081 for (name, ast, reads) in &fields {
1082 for r in reads {
1083 if !vars.contains_key(r) {
1084 vars.insert(r.clone(), Value::Null);
1085 }
1086 }
1087 let value = ast.as_ref().and_then(|ast| crate::cel::evaluate_node(ast, &vars, &env).ok()).unwrap_or(Value::Null);
1088 data.insert((*name).clone(), value);
1089 }
1090 Some(Value::Object(data))
1091 })
1092 .collect()
1093}
1094
1095#[derive(Clone, Copy)]
1097enum Field<'a> {
1098 Text(&'a str),
1099 Json(&'a Value),
1100 Missing,
1101}
1102
1103fn num_text(s: &str) -> Option<f64> {
1104 if js_trim(s).is_empty() {
1105 return None;
1106 }
1107 Some(js_string_number(s)).filter(|f| f.is_finite())
1108}
1109
1110fn num(v: &Field) -> Option<f64> {
1111 match v {
1112 Field::Json(Value::Number(n)) => n.as_f64().filter(|f| f.is_finite()),
1113 Field::Json(Value::String(s)) => num_text(s),
1114 Field::Text(s) => num_text(s),
1115 Field::Json(_) | Field::Missing => None,
1116 }
1117}
1118
1119fn num_value(v: Option<&Value>) -> Option<f64> {
1120 v.and_then(|v| num(&Field::Json(v)))
1121}
1122
1123fn text<'a>(v: &Field<'a>) -> Cow<'a, str> {
1126 match *v {
1127 Field::Missing | Field::Json(Value::Null) => Cow::Borrowed(""),
1128 Field::Text(s) => Cow::Borrowed(s),
1129 Field::Json(Value::String(s)) => Cow::Borrowed(s.as_str()),
1130 Field::Json(Value::Bool(b)) => Cow::Borrowed(if *b { "true" } else { "false" }),
1131 Field::Json(Value::Number(n)) => Cow::Owned(number_to_string(n.as_f64().unwrap_or(0.0))),
1132 Field::Json(other) => Cow::Owned(canonical_json(other)),
1133 }
1134}
1135
1136fn json_of(v: &Field) -> Value {
1138 match v {
1139 Field::Missing => Value::Null,
1140 Field::Text(s) => Value::String((*s).to_string()),
1141 Field::Json(v) => (*v).clone(),
1142 }
1143}
1144
1145enum Key<'a> {
1149 Num(f64, Field<'a>),
1151 Text(Cow<'a, str>),
1152}
1153
1154impl<'a> Key<'a> {
1155 fn of(v: Field<'a>) -> Self {
1156 match num(&v) {
1157 Some(n) => Key::Num(n, v),
1158 None => Key::Text(text(&v)),
1159 }
1160 }
1161
1162 fn text_ref(&self) -> &str {
1164 match self {
1165 Key::Num(..) => "",
1166 Key::Text(t) => t,
1167 }
1168 }
1169
1170 fn text(&self) -> Cow<'_, str> {
1171 match self {
1172 Key::Num(_, f) => text(f),
1173 Key::Text(t) => Cow::Borrowed(t),
1174 }
1175 }
1176}
1177
1178fn cmp_keys(a: &Key, b: &Key) -> Ordering {
1180 if let (Key::Num(na, _), Key::Num(nb, _)) = (a, b) {
1181 return na.partial_cmp(nb).unwrap_or(Ordering::Equal);
1182 }
1183 utf16_cmp(&a.text(), &b.text())
1184}
1185
1186fn compare(a: &Field, b: &Field) -> Ordering {
1187 cmp_keys(&Key::of(*a), &Key::of(*b))
1188}
1189
1190struct Cond<'q> {
1192 filter: &'q RecordsFilter,
1193 bounds: Vec<Key<'q>>,
1194}
1195
1196impl<'q> Cond<'q> {
1197 fn new(filter: &'q RecordsFilter) -> Self {
1198 let bounds = match (&filter.value, filter.op) {
1199 (_, RecordsFilterOp::Eq | RecordsFilterOp::In) => Vec::new(),
1200 (RecordsFilterValue::One(v), _) => vec![Key::of(Field::Text(v))],
1201 (RecordsFilterValue::Many(vs), _) => vs.iter().map(|v| Key::of(Field::Text(v))).collect(),
1202 };
1203 Cond { filter, bounds }
1204 }
1205
1206 fn holds(&self, row: &RecordRowRef) -> bool {
1207 let value = field(row, &self.filter.field);
1208 match self.filter.op {
1209 RecordsFilterOp::Eq => match &self.filter.value {
1210 RecordsFilterValue::One(v) => text(&value) == v.as_str(),
1211 RecordsFilterValue::Many(_) => false,
1212 },
1213 RecordsFilterOp::In => {
1214 let t = text(&value);
1215 match &self.filter.value {
1216 RecordsFilterValue::One(v) => t == v.as_str(),
1217 RecordsFilterValue::Many(vs) => vs.iter().any(|v| t == v.as_str()),
1218 }
1219 }
1220 RecordsFilterOp::Between => self.bounds.len() == 2 && {
1221 let k = Key::of(value);
1222 cmp_keys(&k, &self.bounds[0]).is_ge() && cmp_keys(&k, &self.bounds[1]).is_le()
1223 },
1224 op => {
1225 let [bound] = self.bounds.as_slice() else { return false };
1226 let c = cmp_keys(&Key::of(value), bound);
1227 match op {
1228 RecordsFilterOp::Gt => c.is_gt(),
1229 RecordsFilterOp::Gte => c.is_ge(),
1230 RecordsFilterOp::Lt => c.is_lt(),
1231 _ => c.is_le(),
1232 }
1233 }
1234 }
1235 }
1236}
1237
1238fn data_field<'a>(row: &RecordRowRef<'a>, name: &str) -> Field<'a> {
1239 match row.data.as_object().and_then(|o| o.get(name)) {
1240 Some(v) => Field::Json(v),
1241 None => Field::Missing,
1242 }
1243}
1244
1245static NULL: Value = Value::Null;
1246
1247fn field<'a>(row: &RecordRowRef<'a>, name: &str) -> Field<'a> {
1249 match name {
1250 "createdAt" => Field::Text(row.created_at),
1251 "updatedAt" => Field::Text(row.updated_at),
1252 "id" => Field::Text(row.id),
1253 "createdBy" => row.created_by.map_or(Field::Json(&NULL), Field::Text),
1254 "authorId" => row.author().map_or(Field::Json(&NULL), Field::Text),
1255 _ => data_field(row, name),
1256 }
1257}
1258
1259fn parse_month(key: &str) -> (i64, i64) {
1260 let (y, m) = key.split_once('-').unwrap_or(("1970", "1"));
1261 (y.parse().unwrap_or(1970), m.parse::<i64>().unwrap_or(1).clamp(1, 12))
1262}
1263
1264pub fn shift_month(key: &str, by: i64) -> String {
1266 let (y, m) = parse_month(key);
1267 let index = y * 12 + (m - 1) + by;
1268 format!("{}-{:02}", index.div_euclid(12), index.rem_euclid(12) + 1)
1269}
1270
1271pub fn month_label(key: &str) -> String {
1273 let (y, m) = parse_month(key);
1274 format!("{} {y}", RECORDS_MONTHS[(m - 1) as usize])
1275}
1276
1277#[derive(Debug, Clone, PartialEq)]
1279pub struct RecordsDay {
1280 pub date: String,
1281 pub day: i64,
1282 pub weekday: &'static str,
1283 pub count: u64,
1284 pub sum: f64,
1285}
1286
1287pub fn month_days(key: &str) -> Vec<RecordsDay> {
1289 let (y, m) = parse_month(key);
1290 (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()
1291}
1292
1293fn round(n: f64) -> f64 {
1295 (n * 1_000_000.0 + 0.5).floor() / 1_000_000.0
1296}
1297
1298fn prefix(s: &str, n: usize) -> String {
1300 s.chars().take(n).collect()
1301}
1302
1303fn aggregate_of(agg: &RecordsAggregate, rows: &[&RecordRowRef]) -> Value {
1305 let values: Vec<Field> = match &agg.field {
1306 Some(f) => rows.iter().map(|r| field(r, f)).collect(),
1307 None => Vec::new(),
1308 };
1309 match agg.op {
1310 RecordsAggregateOp::Count => json!(if agg.field.is_none() { rows.len() } else { values.iter().filter(|v| !text(v).is_empty()).count() }),
1311 RecordsAggregateOp::Sum => number(round(values.iter().fold(0.0, |acc, v| acc + num(v).unwrap_or(0.0)))),
1312 RecordsAggregateOp::Avg => {
1313 let nums: Vec<f64> = values.iter().filter_map(num).collect();
1314 if nums.is_empty() {
1315 Value::Null
1316 } else {
1317 number(round(nums.iter().fold(0.0, |a, b| a + b) / nums.len() as f64))
1318 }
1319 }
1320 RecordsAggregateOp::Min | RecordsAggregateOp::Max => {
1321 let mut best: Option<&Field> = None;
1322 for v in &values {
1323 if text(v).is_empty() {
1324 continue;
1325 }
1326 let better = match best {
1327 None => true,
1328 Some(b) if agg.op == RecordsAggregateOp::Min => compare(v, b).is_lt(),
1329 Some(b) => compare(v, b).is_gt(),
1330 };
1331 if better {
1332 best = Some(v);
1333 }
1334 }
1335 best.map_or(Value::Null, |b| num(b).map_or_else(|| Value::String(text(b).into_owned()), number))
1336 }
1337 RecordsAggregateOp::CountDistinct => {
1338 let mut seen: Vec<Cow<str>> = values.iter().map(text).filter(|t| !t.is_empty()).collect();
1339 seen.sort();
1340 seen.dedup();
1341 json!(seen.len())
1342 }
1343 RecordsAggregateOp::First => values.first().map_or(Value::Null, json_of),
1344 RecordsAggregateOp::Last => values.last().map_or(Value::Null, json_of),
1345 }
1346}
1347
1348fn sum_of(row: &RecordRowRef, f: &str) -> f64 {
1349 num_value(row.data.as_object().and_then(|o| o.get(f))).unwrap_or(0.0)
1350}
1351
1352fn summary(query: &RecordsQuery, rows: &[&RecordRowRef], out: &mut Map<String, Value>) {
1354 out.insert("count".into(), json!(rows.len()));
1355 if let Some(sum) = &query.sum {
1356 out.insert("sum".into(), number(round(rows.iter().fold(0.0, |acc, row| acc + sum_of(row, sum)))));
1357 }
1358 for agg in &query.aggregate {
1359 out.insert(agg.name.clone(), aggregate_of(agg, rows));
1360 }
1361}
1362
1363#[derive(Default, Clone, Copy)]
1364struct RunningAcc {
1365 sum: f64,
1366 count: u64,
1367 nums: u64,
1368 min: Option<f64>,
1369 max: Option<f64>,
1370}
1371
1372struct Running<'q> {
1374 list: &'q [RecordsRunning],
1375 acc: Vec<RunningAcc>,
1376}
1377
1378impl<'q> Running<'q> {
1379 fn new(list: &'q [RecordsRunning]) -> Self {
1380 Running { list, acc: vec![RunningAcc::default(); list.len()] }
1381 }
1382
1383 fn add(&mut self, row: &RecordRowRef) {
1384 for (r, a) in self.list.iter().zip(self.acc.iter_mut()) {
1385 a.count += 1;
1386 let Some(n) = num(&field(row, &r.field)) else { continue };
1387 a.sum += n;
1388 a.nums += 1;
1389 a.min = Some(a.min.map_or(n, |m| if n < m { n } else { m }));
1390 a.max = Some(a.max.map_or(n, |m| if n > m { n } else { m }));
1391 }
1392 }
1393
1394 fn read(&self) -> Value {
1395 let mut out = Map::new();
1396 for (r, a) in self.list.iter().zip(&self.acc) {
1397 let v = match r.op {
1398 RecordsRunningOp::Sum => number(round(a.sum)),
1399 RecordsRunningOp::Count => json!(a.count),
1400 RecordsRunningOp::Avg if a.nums > 0 => number(round(a.sum / a.nums as f64)),
1401 RecordsRunningOp::Avg => Value::Null,
1402 RecordsRunningOp::Min => a.min.map_or(Value::Null, number),
1403 RecordsRunningOp::Max => a.max.map_or(Value::Null, number),
1404 };
1405 out.insert(r.field.clone(), v);
1406 }
1407 Value::Object(out)
1408 }
1409}
1410
1411fn group_part<'a>(key: &RecordsGroupKey, row: &RecordRowRef<'a>) -> Cow<'a, str> {
1414 if key.field == "author" {
1415 return Cow::Borrowed(row.author().unwrap_or(""));
1416 }
1417 let v = field(row, &key.field);
1418 match key.bucket {
1419 Some(bucket) => Cow::Owned(date_bucket(&text(&v), bucket).unwrap_or_default()),
1420 None => text(&v),
1421 }
1422}
1423
1424fn group_columns(key: &RecordsGroupKey, row: &RecordRowRef, names: &HashMap<String, String>, cols: &mut Map<String, Value>) {
1427 if key.field == "author" {
1428 let author = row.author();
1429 cols.insert("author".into(), json!(author.and_then(|a| names.get(a)).cloned().unwrap_or_default()));
1430 cols.insert("authorId".into(), json!(author));
1431 return;
1432 }
1433 let v = field(row, &key.field);
1434 if let Some(bucket) = key.bucket {
1435 cols.insert(key.field.clone(), json!(date_bucket(&text(&v), bucket)));
1436 return;
1437 }
1438 cols.insert(key.field.clone(), json_of(&v));
1439}
1440
1441fn prefix_str(s: &str, n: usize) -> &str {
1443 s.char_indices().nth(n).map_or(s, |(i, _)| &s[..i])
1444}
1445
1446enum Col<'a> {
1450 Num(Vec<f64>),
1451 Rank(Vec<u32>),
1452 Mixed(Vec<Key<'a>>),
1453}
1454
1455impl<'a> Col<'a> {
1456 fn of(rows: &[&RecordRowRef<'a>], name: &str) -> Self {
1457 let keys: Vec<Key<'a>> = rows.iter().map(|row| Key::of(field(row, name))).collect();
1458 if keys.iter().all(|k| matches!(k, Key::Num(..))) {
1459 return Col::Num(keys.iter().map(|k| if let Key::Num(n, _) = k { *n } else { 0.0 }).collect());
1460 }
1461 if !keys.iter().all(|k| matches!(k, Key::Text(_))) {
1462 return Col::Mixed(keys);
1463 }
1464 let mut ranks: FxHashMap<&str, u32> = FxHashMap::default();
1465 for k in &keys {
1466 ranks.entry(k.text_ref()).or_insert(0);
1467 }
1468 let mut distinct: Vec<&str> = ranks.keys().copied().collect();
1469 distinct.sort_unstable_by(|a, b| utf16_cmp(a, b));
1470 for (i, t) in distinct.iter().enumerate() {
1471 ranks.insert(t, i as u32);
1472 }
1473 Col::Rank(keys.iter().map(|k| ranks[k.text_ref()]).collect())
1474 }
1475
1476 fn cmp(&self, a: usize, b: usize) -> Ordering {
1477 match self {
1478 Col::Num(v) => v[a].partial_cmp(&v[b]).unwrap_or(Ordering::Equal),
1479 Col::Rank(v) => v[a].cmp(&v[b]),
1480 Col::Mixed(v) => cmp_keys(&v[a], &v[b]),
1481 }
1482 }
1483}
1484
1485fn sorted<'r, 'a>(rows: Vec<&'r RecordRowRef<'a>>, sort: &[RecordsSortKey]) -> Vec<&'r RecordRowRef<'a>> {
1491 let cols: Vec<(Col<'a>, bool)> = sort.iter().map(|s| (Col::of(&rows, &s.field), s.order == RecordsOrder::Asc)).collect();
1492 let ascii: Vec<bool> = rows.iter().map(|row| row.id.is_ascii()).collect();
1493 let order = |a: usize, b: usize| {
1494 for (col, asc) in &cols {
1495 let c = col.cmp(a, b);
1496 if c.is_ne() {
1497 return if *asc { c } else { c.reverse() };
1498 }
1499 }
1500 let (ia, ib) = (rows[a].id, rows[b].id);
1501 if ascii[a] || ascii[b] {
1502 ia.as_bytes().cmp(ib.as_bytes())
1503 } else {
1504 utf16_cmp(ia, ib)
1505 }
1506 };
1507 let mut idx: Vec<usize> = (0..rows.len()).collect();
1508 if cols.iter().any(|(c, _)| matches!(c, Col::Mixed(_))) {
1509 idx.sort_by(|&a, &b| order(a, b));
1510 } else {
1511 idx.sort_unstable_by(|&a, &b| order(a, b).then(a.cmp(&b)));
1512 }
1513 idx.into_iter().map(|i| rows[i]).collect()
1514}
1515
1516pub fn records_value(rows: &[RecordRowRef], query: &RecordsQuery, ctx: RecordsContext) -> Option<Value> {
1521 records_value_in(rows, rows, query, ctx)
1522}
1523
1524fn records_value_in(all: &[RecordRowRef], rows: &[RecordRowRef], query: &RecordsQuery, ctx: RecordsContext) -> Option<Value> {
1527 let kind = query.from.as_deref()?;
1528 if let Some(computed) = ctx.computed.filter(|c| !c.is_empty()) {
1530 let data = computed_data(rows, kind, computed, ctx.now);
1531 let rows: Vec<RecordRowRef> = rows.iter().zip(&data).map(|(row, d)| RecordRowRef { data: d.as_ref().unwrap_or(row.data), ..*row }).collect();
1532 return records_value_in(all, &rows, query, RecordsContext { computed: None, ..ctx });
1533 }
1534 let expand: Vec<(&str, &Value)> = query.expand.iter().filter_map(|name| ctx.refs.and_then(|r| r.get(name)).map(|t| (name.as_str(), t))).collect();
1536 let by_id: HashMap<&str, &RecordRowRef> = if expand.is_empty() { HashMap::new() } else { all.iter().map(|r| (r.id, r)).collect() };
1537 let view = query.month.as_ref();
1538 let today: &str = ctx.today.filter(|t| is_day_text(t)).unwrap_or_else(|| prefix_str(ctx.now, 10));
1539 let month_key: Option<String> = view.map(|v| v.key.clone().unwrap_or_else(|| prefix(today, 7)));
1540 let today_n = day_of(today).unwrap_or(0);
1544 let cal: Option<(&str, Option<&str>, Option<&str>)> = query
1545 .month
1546 .as_ref()
1547 .map(|m| (m.field.as_str(), m.until.as_deref(), m.tone.as_deref()))
1548 .or_else(|| query.week.as_ref().map(|w| (w.field.as_str(), w.until.as_deref(), w.tone.as_deref())))
1549 .or_else(|| query.timeline.as_ref().map(|t| (t.field.as_str(), t.until.as_deref(), t.tone.as_deref())));
1550 let window: Option<(i64, i64)> = if let (Some(_), Some(key)) = (view.filter(|v| v.until.is_some()), &month_key) {
1551 let (y, m) = parse_month(key);
1552 Some((crate::dates::day_number(y, m, 1), crate::dates::day_number(y, m, days_in_month(y, m))))
1553 } else if let Some(w) = &query.week {
1554 let monday = period_start(w.key.as_deref().and_then(day_of).unwrap_or(today_n), "week");
1555 Some((monday, monday + 6))
1556 } else if let Some(t) = &query.timeline {
1557 let p0 = period_start(t.start.as_deref().and_then(day_of).unwrap_or(today_n), &t.scale);
1558 Some((p0, shift_period(p0, &t.scale, i64::from(t.columns)) - 1))
1559 } else {
1560 None
1561 };
1562 let span_of_row = |row: &RecordRowRef| -> Option<(i64, i64)> {
1563 let (f, until, _) = cal?;
1564 let start = text(&data_field(row, f));
1565 let end = until.map(|u| text(&data_field(row, u)).into_owned());
1566 span_of(&start, end.as_deref())
1567 };
1568 let where_: Vec<Cond> = query.where_.iter().map(Cond::new).collect();
1569 let or: Vec<Vec<Cond>> = query.or.iter().map(|alt| alt.iter().map(Cond::new).collect()).collect();
1570 let before_date = query.before.as_ref().map(|b| Key::of(Field::Text(&b.date)));
1571 let mut before: Vec<&RecordRowRef> = Vec::new();
1572 let mut matched: Vec<&RecordRowRef> = Vec::new();
1573 for row in rows {
1574 if row.kind != kind {
1575 continue;
1576 }
1577 let author = row.author();
1578 if query.mine && (ctx.me.is_none() || author != ctx.me) {
1579 continue;
1580 }
1581 if let Some(user) = &query.user {
1582 if author != Some(user.as_str()) {
1583 continue;
1584 }
1585 }
1586 if !where_.iter().all(|c| c.holds(row)) {
1587 continue;
1588 }
1589 if !or.is_empty() && !or.iter().any(|alt| alt.iter().all(|c| c.holds(row))) {
1590 continue;
1591 }
1592 if let (Some(b), Some(date)) = (&query.before, &before_date) {
1593 let t = text(&field(row, &b.field));
1594 if !t.is_empty() && cmp_keys(&Key::of(Field::Text(&t)), date).is_lt() {
1595 before.push(row);
1596 continue;
1597 }
1598 }
1599 if let Some((from, to)) = window {
1600 match span_of_row(row) {
1601 Some((start, end)) if end >= from && start <= to => {}
1602 _ => continue,
1603 }
1604 } else if let (Some(view), Some(key)) = (view, &month_key) {
1605 if !text(&data_field(row, &view.field)).starts_with(key.as_str()) {
1606 continue;
1607 }
1608 }
1609 matched.push(row);
1610 }
1611 let matched = sorted(matched, &query.sort);
1612 let before = sorted(before, &query.sort);
1613 let seeded = || {
1614 let mut r = Running::new(&query.running);
1615 for row in &before {
1616 r.add(row);
1617 }
1618 r
1619 };
1620 let mut row_running = (!query.running.is_empty() && query.group.is_empty()).then(seeded);
1621 let present = |row: &RecordRowRef| -> Map<String, Value> {
1623 let mut obj = value_row(row, ctx.names);
1624 for (name, target) in &expand {
1625 obj.insert((*name).to_string(), expanded_ref(target, row.data.get(*name), &by_id, &ctx));
1626 }
1627 if let Some(t) = ctx.transitions {
1628 let viewer = super::record_transitions::TransitionViewer { id: ctx.me, role: ctx.role };
1629 let ownership = json!({"authorId": row.author_id, "createdBy": row.created_by});
1630 let next = super::record_transitions::transitions_from(Some(t), Some(row.data), Some(&viewer), Some(&ownership));
1631 obj.insert(super::record_transitions::RECORD_TRANSITIONS_ROW_KEY.into(), json!(next));
1632 }
1633 obj
1634 };
1635 let out_rows: Vec<Value> = matched
1636 .iter()
1637 .take(query.limit as usize)
1638 .map(|row| {
1639 let mut obj = present(row);
1640 if let Some(r) = row_running.as_mut() {
1641 r.add(row);
1642 obj.insert("running".into(), r.read());
1643 }
1644 Value::Object(obj)
1645 })
1646 .collect();
1647 let mut out = Map::new();
1648 out.insert("rows".into(), Value::Array(out_rows));
1649 out.insert("count".into(), json!(matched.len()));
1650 if let Some(sum) = &query.sum {
1651 let total = matched.iter().fold(0.0, |acc, row| acc + sum_of(row, sum));
1652 out.insert("sum".into(), number(round(total)));
1653 }
1654 if let (Some(view), Some(key)) = (view, &month_key) {
1655 let mut days = month_days(key);
1656 let mut per_day: Vec<Vec<&RecordRowRef>> = vec![Vec::new(); days.len()];
1657 for row in &matched {
1658 let t = text(&data_field(row, &view.field));
1659 let date = prefix_str(&t, 10);
1660 let Some(i) = days.iter().position(|d| d.date == date) else {
1661 continue;
1662 };
1663 let day = &mut days[i];
1664 day.count += 1;
1665 if let Some(sum) = &query.sum {
1666 day.sum = round(day.sum + sum_of(row, sum));
1667 }
1668 per_day[i].push(row);
1669 }
1670 let mut runnings: Vec<Option<Value>> = vec![None; days.len()];
1671 if !query.running.is_empty() {
1672 let mut r = seeded();
1673 for (i, rows) in per_day.iter().enumerate() {
1674 for row in rows {
1675 r.add(row);
1676 }
1677 runnings[i] = Some(r.read());
1678 }
1679 }
1680 let labels: Vec<String> = days.iter().map(|d| d.day.to_string()).collect();
1681 let counts: Vec<u64> = days.iter().map(|d| d.count).collect();
1682 let sums: Vec<Value> = days.iter().map(|d| number(d.sum)).collect();
1683 let days_json: Vec<Value> = days
1684 .iter()
1685 .zip(runnings)
1686 .map(|(d, running)| {
1687 let mut day = json!({"date": d.date, "day": d.day, "weekday": d.weekday, "count": d.count, "sum": number(d.sum)});
1688 if let (Some(running), Some(obj)) = (running, day.as_object_mut()) {
1689 obj.insert("running".into(), running);
1690 }
1691 day
1692 })
1693 .collect();
1694 out.insert(
1695 "month".into(),
1696 json!({
1697 "key": key,
1698 "label": month_label(key),
1699 "prev": shift_month(key, -1),
1700 "next": shift_month(key, 1),
1701 "today": today,
1702 "days": days_json,
1703 "labels": labels,
1704 "counts": counts,
1705 "sums": sums,
1706 }),
1707 );
1708 }
1709 if let Some((_, _, tone_field)) = cal {
1712 let placed: Vec<(usize, &&RecordRowRef, i64, i64)> = matched.iter().enumerate().filter_map(|(order, row)| span_of_row(row).map(|(s, e)| (order, row, s, e))).collect();
1713 let tone_texts: Vec<String> = placed.iter().map(|(_, row, _, _)| tone_field.map_or(String::new(), |t| text(&field(row, t)).into_owned())).collect();
1714 let tones = tones_of(&tone_texts);
1715 let row_field = query.timeline.as_ref().and_then(|t| t.row.as_deref());
1716 let spans: Vec<CalendarSpan> = placed
1717 .iter()
1718 .zip(tones)
1719 .map(|((order, row, start, end), tone)| CalendarSpan {
1720 id: row.id.to_string(),
1721 start: *start,
1722 end: *end,
1723 tone,
1724 value: Value::Object(present(row)),
1725 order: *order,
1726 row_key: row_field.map(|f| text(&field(row, f)).into_owned()),
1727 })
1728 .collect();
1729 if let (Some(v), Some(key), Some(Value::Object(month))) = (view, &month_key, out.get_mut("month")) {
1730 let (y, m) = parse_month(key);
1731 for (k, val) in calendar_month(y, m, days_in_month(y, m), &spans, v.lanes, today_n, v.day.as_deref()) {
1732 month.insert(k, val);
1733 }
1734 } else if let (Some(w), Some((from, _))) = (&query.week, window) {
1735 out.insert("week".into(), calendar_week_view(from, &spans, w.lanes, today_n));
1736 } else if let (Some(t), Some((from, _))) = (&query.timeline, window) {
1737 out.insert("timeline".into(), calendar_timeline(from, &spans, &t.scale, t.columns, today_n));
1738 }
1739 }
1740 if !query.group.is_empty() {
1741 struct Group<'r, 'a> {
1742 parts: Vec<Cow<'a, str>>,
1743 rows: Vec<&'r RecordRowRef<'a>>,
1744 out: Map<String, Value>,
1745 }
1746 let mut groups: Vec<Group> = Vec::new();
1747 let mut index: FxHashMap<Vec<Cow<str>>, usize> = FxHashMap::default();
1748 let mut parts: Vec<Cow<str>> = Vec::with_capacity(query.group.len());
1749 for row in &matched {
1750 parts.clear();
1751 parts.extend(query.group.iter().map(|key| group_part(key, row)));
1752 match index.get(parts.as_slice()) {
1753 Some(&i) => groups[i].rows.push(row),
1754 None => {
1755 let mut cols = Map::new();
1756 for key in &query.group {
1757 group_columns(key, row, ctx.names, &mut cols);
1758 }
1759 index.insert(parts.clone(), groups.len());
1760 groups.push(Group { parts: parts.clone(), rows: vec![row], out: cols });
1761 }
1762 }
1763 }
1764 for g in &mut groups {
1765 summary(query, &g.rows, &mut g.out);
1766 }
1767 let mut columns: Vec<&str> = Vec::new();
1768 for g in &query.group {
1769 if g.field == "author" {
1770 columns.extend(["author", "authorId"]);
1771 } else {
1772 columns.push(&g.field);
1773 }
1774 }
1775 columns.push("count");
1776 if query.sum.is_some() {
1777 columns.push("sum");
1778 }
1779 columns.extend(query.aggregate.iter().map(|a| a.name.as_str()));
1780 let sort_keys: Vec<&RecordsSortKey> = query.sort.iter().filter(|k| columns.contains(&k.field.as_str())).collect();
1781 let col = |g: &Group, name: &str| -> Value { g.out.get(name).cloned().unwrap_or(Value::Null) };
1782 groups.sort_by(|a, b| {
1783 for key in &sort_keys {
1784 let (va, vb) = (col(a, &key.field), col(b, &key.field));
1785 let c = compare(&Field::Json(&va), &Field::Json(&vb));
1786 if c.is_ne() {
1787 return if key.order == RecordsOrder::Asc { c } else { c.reverse() };
1788 }
1789 }
1790 for key in &query.group {
1791 let (va, vb) = (col(a, &key.field), col(b, &key.field));
1792 let c = compare(&Field::Json(&va), &Field::Json(&vb));
1793 if c.is_ne() {
1794 return c;
1795 }
1796 }
1797 for (pa, pb) in a.parts.iter().zip(&b.parts) {
1798 let c = utf16_cmp(pa, pb);
1799 if c.is_ne() {
1800 return c;
1801 }
1802 }
1803 Ordering::Equal
1804 });
1805 if !query.running.is_empty() {
1806 let mut r = seeded();
1807 for g in &mut groups {
1808 for row in &g.rows {
1809 r.add(row);
1810 }
1811 g.out.insert("running".into(), r.read());
1812 }
1813 }
1814 out.insert("groups".into(), Value::Array(groups.into_iter().map(|g| Value::Object(g.out)).collect()));
1815 }
1816 if !query.aggregate.is_empty() {
1817 let mut totals = Map::new();
1818 summary(query, &matched, &mut totals);
1819 out.insert("totals".into(), Value::Object(totals));
1820 }
1821 if query.before.is_some() {
1822 let mut b = Map::new();
1823 summary(query, &before, &mut b);
1824 out.insert("before".into(), Value::Object(b));
1825 }
1826 Some(Value::Object(out))
1827}
1828
1829pub fn records_value_owned(rows: &[RecordRow], query: &RecordsQuery, ctx: RecordsContext) -> Option<Value> {
1831 let refs: Vec<RecordRowRef> = rows.iter().map(RecordRow::as_ref).collect();
1832 records_value(&refs, query, ctx)
1833}
1834
1835#[cfg(test)]
1836mod tests {
1837 use super::*;
1838
1839 #[test]
1840 fn number_parses_like_js() {
1841 assert_eq!(js_string_number(" 12 "), 12.0);
1842 assert_eq!(js_string_number("0x10"), 16.0);
1843 assert_eq!(js_string_number("1e2"), 100.0);
1844 assert_eq!(js_string_number(""), 0.0);
1845 assert!(js_string_number("12px").is_nan());
1846 assert!(js_string_number("inf").is_nan());
1847 assert!(js_string_number("-0x1").is_nan());
1848 assert_eq!(js_string_number("-Infinity"), f64::NEG_INFINITY);
1849 }
1850
1851 #[test]
1852 fn months_shift_across_years() {
1853 assert_eq!(shift_month("2026-01", -1), "2025-12");
1854 assert_eq!(shift_month("2026-12", 1), "2027-01");
1855 assert_eq!(shift_month("2026-03", -15), "2024-12");
1856 assert_eq!(month_label("2026-10"), "October 2026");
1857 assert_eq!(month_days("2028-02").len(), 29);
1858 }
1859}
1860
1861fn is_day_text(t: &str) -> bool {
1863 let b = t.as_bytes();
1864 b.len() == 10 && b.iter().enumerate().all(|(i, c)| if i == 4 || i == 7 { *c == b'-' } else { c.is_ascii_digit() })
1865}