Skip to main content

fakecloud_logs/
query.rs

1//! CloudWatch Logs Insights query language parser and executor.
2//!
3//! Supports the common subset of CWLI syntax as an ordered pipeline:
4//! - `fields @timestamp, @message` / `display ...` — select output fields
5//! - `filter field = "value"` / `!= ` / `like /pattern/` — filter rows
6//! - `parse @message "* [*] *" as a, b, c` — extract fields via a glob
7//! - `stats count(*) [as alias] by field, ...` — aggregate (count/sum/avg/min/
8//!   max/count_distinct), optionally grouped
9//! - `dedup field, ...` — drop duplicate rows by the given fields
10//! - `sort @timestamp desc` — order rows
11//! - `limit N` — cap row count
12use crate::state::LogEvent;
13use serde_json::{json, Value};
14
15/// A parsed CWLI query: an ordered pipeline of commands.
16#[derive(Debug, Default)]
17pub struct ParsedQuery {
18    pub commands: Vec<Command>,
19}
20
21#[derive(Debug)]
22pub enum Command {
23    Fields(Vec<String>),
24    Display(Vec<String>),
25    Filter(FilterClause),
26    Sort { field: String, desc: bool },
27    Limit(usize),
28    Parse(ParseSpec),
29    Dedup(Vec<String>),
30    Stats { aggs: Vec<AggExpr>, by: Vec<String> },
31}
32
33#[derive(Debug)]
34pub enum FilterClause {
35    /// `filter field = "value"`
36    Equals { field: String, value: String },
37    /// `filter field != "value"`
38    NotEquals { field: String, value: String },
39    /// `filter field like /pattern/` or `filter field like "substring"`
40    Like { field: String, pattern: String },
41}
42
43/// A `parse` directive: glob `pattern` applied to `source`, binding each `*`
44/// wildcard (in order) to the corresponding name in `names`.
45#[derive(Debug)]
46pub struct ParseSpec {
47    pub source: String,
48    pub pattern: String,
49    pub names: Vec<String>,
50}
51
52#[derive(Debug)]
53pub struct AggExpr {
54    pub func: AggFunc,
55    pub field: Option<String>,
56    pub alias: String,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq)]
60pub enum AggFunc {
61    Count,
62    Sum,
63    Avg,
64    Min,
65    Max,
66    CountDistinct,
67}
68
69/// Strip a leading keyword (followed by whitespace) from a command, returning
70/// the remainder.
71fn strip_keyword<'a>(cmd: &'a str, kw: &str) -> Option<&'a str> {
72    let rest = cmd.strip_prefix(kw)?;
73    // The keyword must be followed by whitespace (so `fieldspec` isn't matched
74    // as `fields`).
75    if rest.starts_with(|c: char| c.is_whitespace()) {
76        Some(rest.trim_start())
77    } else {
78        None
79    }
80}
81
82fn parse_field_list(s: &str) -> Vec<String> {
83    s.split(',')
84        .map(|f| f.trim().to_string())
85        .filter(|f| !f.is_empty())
86        .collect()
87}
88
89/// Parse a CWLI query string into a structured pipeline.
90pub fn parse_query(query: &str) -> ParsedQuery {
91    let mut parsed = ParsedQuery::default();
92
93    for raw in query.split('|') {
94        let cmd = raw.trim();
95        if cmd.is_empty() {
96            continue;
97        }
98
99        if let Some(rest) = strip_keyword(cmd, "fields") {
100            parsed
101                .commands
102                .push(Command::Fields(parse_field_list(rest)));
103        } else if let Some(rest) = strip_keyword(cmd, "display") {
104            parsed
105                .commands
106                .push(Command::Display(parse_field_list(rest)));
107        } else if let Some(rest) = strip_keyword(cmd, "filter") {
108            if let Some(clause) = parse_filter_clause(rest.trim()) {
109                parsed.commands.push(Command::Filter(clause));
110            }
111        } else if let Some(rest) = strip_keyword(cmd, "stats") {
112            parsed.commands.push(parse_stats(rest));
113        } else if let Some(rest) = strip_keyword(cmd, "parse") {
114            if let Some(spec) = parse_parse(rest) {
115                parsed.commands.push(Command::Parse(spec));
116            }
117        } else if let Some(rest) = strip_keyword(cmd, "dedup") {
118            parsed.commands.push(Command::Dedup(parse_field_list(rest)));
119        } else if let Some(rest) = strip_keyword(cmd, "sort") {
120            let parts: Vec<&str> = rest.split_whitespace().collect();
121            if !parts.is_empty() {
122                parsed.commands.push(Command::Sort {
123                    field: parts[0].to_string(),
124                    desc: parts.get(1).map(|s| s.eq_ignore_ascii_case("desc")) == Some(true),
125                });
126            }
127        } else if let Some(rest) = strip_keyword(cmd, "limit") {
128            if let Ok(n) = rest.trim().parse::<usize>() {
129                parsed.commands.push(Command::Limit(n));
130            }
131        }
132    }
133
134    parsed
135}
136
137fn parse_filter_clause(s: &str) -> Option<FilterClause> {
138    // Try: field like /pattern/ or field like "substring"
139    if let Some(like_pos) = s.find(" like ") {
140        let field = s[..like_pos].trim().to_string();
141        let pattern_str = s[like_pos + 6..].trim();
142        let pattern = if pattern_str.starts_with('/') && pattern_str.ends_with('/') {
143            pattern_str[1..pattern_str.len() - 1].to_string()
144        } else {
145            unquote(pattern_str)
146        };
147        return Some(FilterClause::Like { field, pattern });
148    }
149
150    if let Some(ne_pos) = s.find(" != ") {
151        let field = s[..ne_pos].trim().to_string();
152        let value = unquote(s[ne_pos + 4..].trim());
153        return Some(FilterClause::NotEquals { field, value });
154    }
155
156    if let Some(eq_pos) = s.find(" = ") {
157        let field = s[..eq_pos].trim().to_string();
158        let value = unquote(s[eq_pos + 3..].trim());
159        return Some(FilterClause::Equals { field, value });
160    }
161
162    None
163}
164
165/// Parse a `stats` command body (the text after `stats`).
166fn parse_stats(rest: &str) -> Command {
167    // Split off the optional `by <fields>` clause (case-insensitive).
168    let lower = rest.to_ascii_lowercase();
169    let (agg_part, by_part) = match lower.find(" by ") {
170        Some(pos) => (&rest[..pos], Some(rest[pos + 4..].trim())),
171        None => (rest, None),
172    };
173    let aggs = split_top_level_commas(agg_part)
174        .iter()
175        .filter_map(|s| parse_agg_expr(s.trim()))
176        .collect();
177    let by = by_part.map(parse_field_list).unwrap_or_default();
178    Command::Stats { aggs, by }
179}
180
181/// Split on commas that are not nested inside parentheses, so
182/// `count(*), avg(latency)` splits but `pct(latency, 99)` doesn't.
183fn split_top_level_commas(s: &str) -> Vec<String> {
184    let mut out = Vec::new();
185    let mut depth = 0i32;
186    let mut current = String::new();
187    for c in s.chars() {
188        match c {
189            '(' => {
190                depth += 1;
191                current.push(c);
192            }
193            ')' => {
194                depth -= 1;
195                current.push(c);
196            }
197            ',' if depth == 0 => {
198                out.push(std::mem::take(&mut current));
199            }
200            _ => current.push(c),
201        }
202    }
203    if !current.trim().is_empty() {
204        out.push(current);
205    }
206    out
207}
208
209fn parse_agg_expr(s: &str) -> Option<AggExpr> {
210    // Split off an optional `as alias`.
211    let (expr, alias) = match s.to_ascii_lowercase().find(" as ") {
212        Some(pos) => (s[..pos].trim(), Some(s[pos + 4..].trim().to_string())),
213        None => (s.trim(), None),
214    };
215    let open = expr.find('(')?;
216    let close = expr.rfind(')')?;
217    if close < open {
218        return None;
219    }
220    let func_name = expr[..open].trim().to_ascii_lowercase();
221    let arg = expr[open + 1..close].trim();
222    let func = match func_name.as_str() {
223        "count" => AggFunc::Count,
224        "sum" => AggFunc::Sum,
225        "avg" | "average" => AggFunc::Avg,
226        "min" => AggFunc::Min,
227        "max" => AggFunc::Max,
228        "count_distinct" | "countdistinct" => AggFunc::CountDistinct,
229        _ => return None,
230    };
231    let field = if arg.is_empty() || arg == "*" {
232        None
233    } else {
234        Some(arg.to_string())
235    };
236    let alias = alias.unwrap_or_else(|| expr.to_string());
237    Some(AggExpr { func, field, alias })
238}
239
240/// Parse a `parse <source> "<glob>" as a, b, ...` command body.
241fn parse_parse(rest: &str) -> Option<ParseSpec> {
242    let rest = rest.trim();
243    // The source token is everything up to the first quote.
244    let quote_pos = rest.find(['"', '\''])?;
245    let source = rest[..quote_pos].trim().to_string();
246    if source.is_empty() {
247        return None;
248    }
249    let quote = rest.as_bytes()[quote_pos] as char;
250    let after = &rest[quote_pos + 1..];
251    let end = after.find(quote)?;
252    let pattern = after[..end].to_string();
253    let tail = after[end + 1..].trim();
254    let names = match tail.to_ascii_lowercase().strip_prefix("as ") {
255        Some(_) => parse_field_list(&tail[3..]),
256        None => Vec::new(),
257    };
258    Some(ParseSpec {
259        source,
260        pattern,
261        names,
262    })
263}
264
265fn unquote(s: &str) -> String {
266    if (s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')) {
267        s[1..s.len() - 1].to_string()
268    } else {
269        s.to_string()
270    }
271}
272
273/// A materialized query row: ordered (field, value) pairs. Insertion order is
274/// preserved so output column ordering is stable.
275#[derive(Clone, Default)]
276struct Record {
277    fields: Vec<(String, String)>,
278}
279
280impl Record {
281    fn get(&self, name: &str) -> Option<&str> {
282        self.fields
283            .iter()
284            .find(|(k, _)| k == name)
285            .map(|(_, v)| v.as_str())
286    }
287
288    fn set(&mut self, name: &str, value: String) {
289        if let Some(entry) = self.fields.iter_mut().find(|(k, _)| k == name) {
290            entry.1 = value;
291        } else {
292            self.fields.push((name.to_string(), value));
293        }
294    }
295}
296
297/// Format a timestamp (epoch millis) the way CWLI renders `@timestamp`.
298fn format_timestamp(ms: i64) -> String {
299    let secs = ms / 1000;
300    let nsecs = ((ms % 1000) * 1_000_000) as u32;
301    match chrono::DateTime::from_timestamp(secs, nsecs) {
302        Some(dt) => dt.format("%Y-%m-%d %H:%M:%S%.3f").to_string(),
303        None => ms.to_string(),
304    }
305}
306
307/// Mint a GetLogRecord-compatible `@ptr`: base64(`<group>|<stream>|<index>`).
308fn encode_ptr(group: &str, stream: &str, index: usize) -> String {
309    use base64::Engine;
310    base64::engine::general_purpose::STANDARD.encode(format!("{group}|{stream}|{index}").as_bytes())
311}
312
313/// Build the base record for a log event, including the synthetic `@`-fields
314/// and any top-level keys discovered from a JSON message body.
315fn build_record(event: &LogEvent, group: &str, stream: &str, index: usize) -> Record {
316    let mut r = Record::default();
317    r.set("@timestamp", format_timestamp(event.timestamp));
318    r.set("@message", event.message.clone());
319    r.set("@logStream", stream.to_string());
320    r.set("@ingestionTime", event.ingestion_time.to_string());
321    r.set("@ptr", encode_ptr(group, stream, index));
322    if let Ok(Value::Object(map)) = serde_json::from_str::<Value>(&event.message) {
323        for (k, v) in map {
324            let value = match v {
325                Value::String(s) => s,
326                other => other.to_string(),
327            };
328            // Discovered JSON fields don't clobber the synthetic ones.
329            if r.get(&k).is_none() {
330                r.set(&k, value);
331            }
332        }
333    }
334    r
335}
336
337/// Resolve a field for filtering/grouping, allowing the `@`-stripped JSON
338/// fallback (e.g. `level` resolves a JSON key `level`).
339fn record_field(r: &Record, name: &str) -> Option<String> {
340    if let Some(v) = r.get(name) {
341        return Some(v.to_string());
342    }
343    let stripped = name.strip_prefix('@').unwrap_or(name);
344    r.get(stripped).map(|v| v.to_string())
345}
346
347fn matches_pattern(haystack: &str, pattern: &str) -> bool {
348    if let Some(inner) = pattern.strip_prefix('^').and_then(|p| p.strip_suffix('$')) {
349        haystack == inner
350    } else if let Some(prefix) = pattern.strip_prefix('^') {
351        haystack.starts_with(prefix)
352    } else if let Some(suffix) = pattern.strip_suffix('$') {
353        haystack.ends_with(suffix)
354    } else {
355        haystack.contains(pattern)
356    }
357}
358
359fn record_matches_filter(r: &Record, clause: &FilterClause) -> bool {
360    match clause {
361        FilterClause::Equals { field, value } => {
362            record_field(r, field).map(|v| v == *value).unwrap_or(false)
363        }
364        FilterClause::NotEquals { field, value } => {
365            record_field(r, field).map(|v| v != *value).unwrap_or(true)
366        }
367        FilterClause::Like { field, pattern } => record_field(r, field)
368            .map(|v| matches_pattern(&v, pattern))
369            .unwrap_or(false),
370    }
371}
372
373/// Apply a glob `parse` to a record, binding each `*` to the matching name.
374fn apply_parse(r: &mut Record, spec: &ParseSpec) {
375    let Some(source) = record_field(r, &spec.source) else {
376        return;
377    };
378    let segments: Vec<&str> = spec.pattern.split('*').collect();
379    let mut captures: Vec<String> = Vec::new();
380    let mut pos = 0usize;
381    // The first segment is a required prefix.
382    if let Some(first) = segments.first() {
383        if !source[pos..].starts_with(first) {
384            return;
385        }
386        pos += first.len();
387    }
388    for seg in segments.iter().skip(1) {
389        if seg.is_empty() {
390            // Trailing wildcard: capture the rest.
391            captures.push(source[pos..].to_string());
392            pos = source.len();
393            continue;
394        }
395        let Some(found) = source[pos..].find(seg) else {
396            return;
397        };
398        captures.push(source[pos..pos + found].to_string());
399        pos += found + seg.len();
400    }
401    for (name, value) in spec.names.iter().zip(captures) {
402        r.set(name, value);
403    }
404}
405
406fn parse_number(s: &str) -> Option<f64> {
407    s.trim().parse::<f64>().ok()
408}
409
410/// Format an aggregate result, dropping a trailing `.0` so counts read as
411/// integers the way CloudWatch renders them.
412fn format_number(n: f64) -> String {
413    if n.fract() == 0.0 && n.abs() < 1e15 {
414        format!("{}", n as i64)
415    } else {
416        format!("{n}")
417    }
418}
419
420fn compute_agg(agg: &AggExpr, group: &[&Record]) -> String {
421    match agg.func {
422        AggFunc::Count => match &agg.field {
423            None => format_number(group.len() as f64),
424            Some(f) => {
425                let n = group
426                    .iter()
427                    .filter(|r| record_field(r, f).is_some())
428                    .count();
429                format_number(n as f64)
430            }
431        },
432        AggFunc::CountDistinct => {
433            let field = match &agg.field {
434                Some(f) => f,
435                None => return "0".to_string(),
436            };
437            let mut seen = std::collections::BTreeSet::new();
438            for r in group {
439                if let Some(v) = record_field(r, field) {
440                    seen.insert(v);
441                }
442            }
443            format_number(seen.len() as f64)
444        }
445        AggFunc::Sum | AggFunc::Avg | AggFunc::Min | AggFunc::Max => {
446            let field = match &agg.field {
447                Some(f) => f,
448                None => return "0".to_string(),
449            };
450            let nums: Vec<f64> = group
451                .iter()
452                .filter_map(|r| record_field(r, field).and_then(|v| parse_number(&v)))
453                .collect();
454            if nums.is_empty() {
455                return "0".to_string();
456            }
457            let v = match agg.func {
458                AggFunc::Sum => nums.iter().sum(),
459                AggFunc::Avg => nums.iter().sum::<f64>() / nums.len() as f64,
460                AggFunc::Min => nums.iter().cloned().fold(f64::INFINITY, f64::min),
461                AggFunc::Max => nums.iter().cloned().fold(f64::NEG_INFINITY, f64::max),
462                _ => unreachable!(),
463            };
464            format_number(v)
465        }
466    }
467}
468
469/// One stream's events for query execution.
470pub struct QueryStream {
471    pub group_name: String,
472    pub stream_name: String,
473    /// Each event paired with its index in the stream's full event list so
474    /// `@ptr` round-trips through GetLogRecord.
475    pub events: Vec<(usize, LogEvent)>,
476}
477
478/// Execute a parsed query against a set of streams, returning results in the
479/// CloudWatch Logs Insights format: an array of rows, each an array of
480/// `{field, value}` objects.
481pub fn execute_query(
482    query: &ParsedQuery,
483    streams: &[QueryStream],
484    start_time_secs: i64,
485    end_time_secs: i64,
486) -> Vec<Value> {
487    // Materialize records inside the time window.
488    let mut records: Vec<Record> = Vec::new();
489    for stream in streams {
490        for (index, event) in &stream.events {
491            let event_time_secs = event.timestamp / 1000;
492            if event_time_secs >= start_time_secs && event_time_secs < end_time_secs {
493                records.push(build_record(
494                    event,
495                    &stream.group_name,
496                    &stream.stream_name,
497                    *index,
498                ));
499            }
500        }
501    }
502
503    // Default ordering is by timestamp ascending; an explicit `sort` overrides.
504    records.sort_by(|a, b| {
505        a.get("@timestamp")
506            .unwrap_or_default()
507            .cmp(b.get("@timestamp").unwrap_or_default())
508    });
509
510    let mut explicit_fields: Option<Vec<String>> = None;
511    let mut aggregated = false;
512
513    for cmd in &query.commands {
514        match cmd {
515            Command::Filter(clause) => {
516                records.retain(|r| record_matches_filter(r, clause));
517            }
518            Command::Parse(spec) => {
519                for r in records.iter_mut() {
520                    apply_parse(r, spec);
521                }
522            }
523            Command::Fields(f) | Command::Display(f) => {
524                explicit_fields = Some(f.clone());
525            }
526            Command::Dedup(fields) => {
527                let mut seen = std::collections::HashSet::new();
528                records.retain(|r| {
529                    let key: Vec<Option<String>> =
530                        fields.iter().map(|f| record_field(r, f)).collect();
531                    seen.insert(format!("{key:?}"))
532                });
533            }
534            Command::Sort { field, desc } => {
535                records.sort_by(|a, b| {
536                    let va = record_field(a, field).unwrap_or_default();
537                    let vb = record_field(b, field).unwrap_or_default();
538                    if *desc {
539                        vb.cmp(&va)
540                    } else {
541                        va.cmp(&vb)
542                    }
543                });
544            }
545            Command::Limit(n) => {
546                records.truncate(*n);
547            }
548            Command::Stats { aggs, by } => {
549                records = run_stats(&records, aggs, by);
550                aggregated = true;
551                // After aggregation, the output columns are the by-fields plus
552                // the agg aliases.
553                let mut cols: Vec<String> = by.clone();
554                cols.extend(aggs.iter().map(|a| a.alias.clone()));
555                explicit_fields = Some(cols);
556            }
557        }
558    }
559
560    // Decide output columns.
561    let mut output_fields: Vec<String> = match explicit_fields {
562        Some(f) => f,
563        None => vec![
564            "@timestamp".to_string(),
565            "@message".to_string(),
566            "@ptr".to_string(),
567        ],
568    };
569    // Raw (non-aggregated) results always carry @ptr so callers can drill in.
570    if !aggregated && !output_fields.iter().any(|f| f == "@ptr") {
571        output_fields.push("@ptr".to_string());
572    }
573
574    records
575        .iter()
576        .map(|r| {
577            let row: Vec<Value> = output_fields
578                .iter()
579                .filter_map(|field| {
580                    record_field(r, field).map(|value| json!({"field": field, "value": value}))
581                })
582                .collect();
583            Value::Array(row)
584        })
585        .collect()
586}
587
588/// Group records and compute the requested aggregates, returning one synthetic
589/// record per group.
590fn run_stats(records: &[Record], aggs: &[AggExpr], by: &[String]) -> Vec<Record> {
591    use std::collections::BTreeMap;
592    let mut groups: BTreeMap<Vec<String>, Vec<&Record>> = BTreeMap::new();
593    for r in records {
594        let key: Vec<String> = by
595            .iter()
596            .map(|f| record_field(r, f).unwrap_or_default())
597            .collect();
598        groups.entry(key).or_default().push(r);
599    }
600
601    let mut out = Vec::new();
602    for (key, group) in groups {
603        let mut rec = Record::default();
604        for (field, value) in by.iter().zip(key.iter()) {
605            rec.set(field, value.clone());
606        }
607        for agg in aggs {
608            rec.set(&agg.alias, compute_agg(agg, &group));
609        }
610        out.push(rec);
611    }
612    out
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618
619    fn stream(name: &str, events: Vec<LogEvent>) -> QueryStream {
620        QueryStream {
621            group_name: "/g".to_string(),
622            stream_name: name.to_string(),
623            events: events.into_iter().enumerate().collect(),
624        }
625    }
626
627    fn ev(ts: i64, msg: &str) -> LogEvent {
628        LogEvent {
629            timestamp: ts,
630            message: msg.to_string(),
631            ingestion_time: ts,
632        }
633    }
634
635    #[test]
636    fn parse_fields_and_limit() {
637        let q = parse_query("fields @timestamp, @message | limit 5");
638        assert_eq!(q.commands.len(), 2);
639        match &q.commands[0] {
640            Command::Fields(f) => assert_eq!(f, &vec!["@timestamp", "@message"]),
641            _ => panic!("expected Fields"),
642        }
643        match &q.commands[1] {
644            Command::Limit(n) => assert_eq!(*n, 5),
645            _ => panic!("expected Limit"),
646        }
647    }
648
649    #[test]
650    fn execute_filters_events() {
651        let streams = vec![stream(
652            "s1",
653            vec![
654                ev(1000000, "ERROR: broke"),
655                ev(2000000, "INFO: ok"),
656                ev(3000000, "ERROR: again"),
657            ],
658        )];
659        let q = parse_query("filter @message like /ERROR/ | limit 10");
660        let r = execute_query(&q, &streams, 0, 10000);
661        assert_eq!(r.len(), 2);
662    }
663
664    #[test]
665    fn execute_json_field_filter() {
666        let streams = vec![stream(
667            "s1",
668            vec![
669                ev(1000000, r#"{"level":"ERROR","msg":"fail"}"#),
670                ev(2000000, r#"{"level":"INFO","msg":"ok"}"#),
671            ],
672        )];
673        let q = parse_query(r#"filter level = "ERROR""#);
674        let r = execute_query(&q, &streams, 0, 10000);
675        assert_eq!(r.len(), 1);
676    }
677
678    #[test]
679    fn execute_not_equals_filter() {
680        let streams = vec![stream(
681            "s1",
682            vec![
683                ev(1000000, r#"{"level":"ERROR"}"#),
684                ev(2000000, r#"{"level":"INFO"}"#),
685            ],
686        )];
687        let q = parse_query(r#"filter level != "ERROR""#);
688        let r = execute_query(&q, &streams, 0, 10000);
689        assert_eq!(r.len(), 1);
690    }
691
692    #[test]
693    fn stats_count_by_field() {
694        let streams = vec![stream(
695            "s1",
696            vec![
697                ev(1000000, r#"{"level":"ERROR"}"#),
698                ev(2000000, r#"{"level":"INFO"}"#),
699                ev(3000000, r#"{"level":"ERROR"}"#),
700                ev(4000000, r#"{"level":"ERROR"}"#),
701            ],
702        )];
703        let q = parse_query("stats count(*) by level");
704        let rows = execute_query(&q, &streams, 0, 10000);
705        assert_eq!(rows.len(), 2);
706        let error_row = rows
707            .iter()
708            .find(|row| {
709                row.as_array()
710                    .unwrap()
711                    .iter()
712                    .any(|f| f["field"] == "level" && f["value"] == "ERROR")
713            })
714            .unwrap();
715        let count = error_row
716            .as_array()
717            .unwrap()
718            .iter()
719            .find(|f| f["field"] == "count(*)")
720            .unwrap();
721        assert_eq!(count["value"], "3");
722    }
723
724    #[test]
725    fn stats_sum_avg() {
726        let streams = vec![stream(
727            "s1",
728            vec![
729                ev(1000000, r#"{"svc":"a","latency":10}"#),
730                ev(2000000, r#"{"svc":"a","latency":30}"#),
731            ],
732        )];
733        let q = parse_query("stats sum(latency) as total, avg(latency) as mean by svc");
734        let rows = execute_query(&q, &streams, 0, 10000);
735        assert_eq!(rows.len(), 1);
736        let row = rows[0].as_array().unwrap();
737        let total = row.iter().find(|f| f["field"] == "total").unwrap();
738        let mean = row.iter().find(|f| f["field"] == "mean").unwrap();
739        assert_eq!(total["value"], "40");
740        assert_eq!(mean["value"], "20");
741    }
742
743    #[test]
744    fn ptr_is_base64_group_stream_index() {
745        use base64::Engine;
746        let streams = vec![stream("s1", vec![ev(1000000, "hello")])];
747        let q = parse_query("fields @message");
748        let rows = execute_query(&q, &streams, 0, 10000);
749        let row = rows[0].as_array().unwrap();
750        let ptr = row.iter().find(|f| f["field"] == "@ptr").unwrap();
751        let decoded = base64::engine::general_purpose::STANDARD
752            .decode(ptr["value"].as_str().unwrap())
753            .unwrap();
754        assert_eq!(String::from_utf8(decoded).unwrap(), "/g|s1|0");
755    }
756
757    #[test]
758    fn parse_glob_extracts_fields() {
759        let streams = vec![stream("s1", vec![ev(1000000, "GET /api 200")])];
760        let q =
761            parse_query("parse @message \"* * *\" as method, path, code | filter code = \"200\"");
762        let rows = execute_query(&q, &streams, 0, 10000);
763        assert_eq!(rows.len(), 1);
764    }
765
766    #[test]
767    fn dedup_drops_duplicates() {
768        let streams = vec![stream(
769            "s1",
770            vec![
771                ev(1000000, r#"{"level":"ERROR"}"#),
772                ev(2000000, r#"{"level":"ERROR"}"#),
773                ev(3000000, r#"{"level":"INFO"}"#),
774            ],
775        )];
776        let q = parse_query("dedup level");
777        let rows = execute_query(&q, &streams, 0, 10000);
778        assert_eq!(rows.len(), 2);
779    }
780
781    #[test]
782    fn sort_desc_by_timestamp() {
783        let streams = vec![stream(
784            "s1",
785            vec![
786                ev(1000000, "first"),
787                ev(3000000, "third"),
788                ev(2000000, "second"),
789            ],
790        )];
791        let q = parse_query("sort @timestamp desc");
792        let rows = execute_query(&q, &streams, 0, 10000);
793        let first = rows[0].as_array().unwrap();
794        let msg = first.iter().find(|f| f["field"] == "@message").unwrap();
795        assert_eq!(msg["value"], "third");
796    }
797}