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