Skip to main content

fakecloud_timestream/
query.rs

1//! A real, bounded SQL handler for Amazon Timestream `Query`.
2//!
3//! A faithful Timestream SQL engine (the full Trino-derived time-series
4//! dialect) is out of scope. This module implements a genuine interpreter for
5//! the query shapes the SDK, e2e, and conformance exercise, over the points
6//! ingested by `WriteRecords`:
7//!
8//! * `SELECT * FROM "db"."table"` -- every stored point as a row, with
9//!   AWS-shaped `ColumnInfo`/`Datum` typing: one `VARCHAR` column per dimension
10//!   name, `measure_name` (`VARCHAR`), a `measure_value::<type>` column per
11//!   measure value type present, and `time` (`TIMESTAMP`).
12//! * `SELECT COUNT(*) FROM "db"."table"` -- a single `BIGINT` row.
13//! * optional `WHERE time <op> ago(<n><unit>)` time filter.
14//! * optional `ORDER BY time [ASC|DESC]`.
15//! * optional `LIMIT <n>`.
16//!
17//! Anything outside this subset returns [`QueryError::Validation`] naming the
18//! unsupported construct, so a caller never gets a wrong or silently-empty
19//! success for a query the engine cannot actually run.
20
21use std::sync::OnceLock;
22
23use regex::Regex;
24use serde_json::{json, Value};
25
26use crate::state::StoredRecord;
27
28/// What the `SELECT` list projects.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum Projection {
31    /// `SELECT *`
32    Star,
33    /// `SELECT COUNT(*)`
34    CountStar,
35}
36
37/// A comparison operator in a `WHERE time <op> ...` clause.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum CmpOp {
40    Gt,
41    Ge,
42    Lt,
43    Le,
44    Eq,
45}
46
47/// A parsed, executable query plan.
48#[derive(Debug, Clone)]
49pub struct Plan {
50    pub database: String,
51    pub table: String,
52    pub projection: Projection,
53    /// `(op, threshold_nanos)` derived from `WHERE time <op> ago(...)`.
54    pub time_filter: Option<(CmpOp, i128)>,
55    /// `Some(true)` = `ORDER BY time DESC`, `Some(false)` = ASC.
56    pub order_desc: Option<bool>,
57    pub limit: Option<usize>,
58}
59
60/// Why a query could not be planned/run.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum QueryError {
63    /// A construct outside the supported subset, or a malformed query.
64    Validation(String),
65}
66
67impl QueryError {
68    pub fn message(&self) -> &str {
69        match self {
70            QueryError::Validation(m) => m,
71        }
72    }
73}
74
75/// The materialized result of a plan: AWS-shaped `ColumnInfo` list and `Row`
76/// list, both ready to drop straight into the `Query` response JSON.
77#[derive(Debug, Clone)]
78pub struct QueryResult {
79    pub columns: Vec<Value>,
80    pub rows: Vec<Value>,
81}
82
83fn re_from() -> &'static Regex {
84    static R: OnceLock<Regex> = OnceLock::new();
85    R.get_or_init(|| Regex::new(r#"(?i)\bfrom\s+"?([^"\s.]+)"?\s*\.\s*"?([^"\s.]+)"?"#).unwrap())
86}
87
88fn re_select() -> &'static Regex {
89    static R: OnceLock<Regex> = OnceLock::new();
90    R.get_or_init(|| Regex::new(r#"(?is)^\s*select\s+(.*?)\s+from\b"#).unwrap())
91}
92
93fn re_count() -> &'static Regex {
94    static R: OnceLock<Regex> = OnceLock::new();
95    R.get_or_init(|| Regex::new(r#"(?i)^count\s*\(\s*\*\s*\)$"#).unwrap())
96}
97
98fn re_where_time() -> &'static Regex {
99    static R: OnceLock<Regex> = OnceLock::new();
100    R.get_or_init(|| {
101        Regex::new(r#"(?i)\bwhere\s+time\s*(>=|<=|>|<|=)\s*ago\s*\(\s*(\d+)\s*([a-zA-Z]+)\s*\)"#)
102            .unwrap()
103    })
104}
105
106fn re_where_any() -> &'static Regex {
107    static R: OnceLock<Regex> = OnceLock::new();
108    R.get_or_init(|| Regex::new(r#"(?i)\bwhere\b"#).unwrap())
109}
110
111fn re_order() -> &'static Regex {
112    static R: OnceLock<Regex> = OnceLock::new();
113    R.get_or_init(|| Regex::new(r#"(?i)\border\s+by\s+([A-Za-z0-9_]+)(?:\s+(asc|desc))?"#).unwrap())
114}
115
116fn re_limit() -> &'static Regex {
117    static R: OnceLock<Regex> = OnceLock::new();
118    R.get_or_init(|| Regex::new(r#"(?i)\blimit\s+(\d+)"#).unwrap())
119}
120
121/// Parse a single-unit `ago(<n><unit>)` duration into nanoseconds.
122fn unit_nanos(unit: &str) -> Option<i128> {
123    let n: i128 = match unit.to_lowercase().as_str() {
124        "ns" | "nanosecond" | "nanoseconds" => 1,
125        "us" | "microsecond" | "microseconds" => 1_000,
126        "ms" | "millisecond" | "milliseconds" => 1_000_000,
127        "s" | "sec" | "second" | "seconds" => 1_000_000_000,
128        "m" | "min" | "minute" | "minutes" => 60 * 1_000_000_000,
129        "h" | "hour" | "hours" => 3_600 * 1_000_000_000,
130        "d" | "day" | "days" => 86_400 * 1_000_000_000,
131        _ => return None,
132    };
133    Some(n)
134}
135
136fn now_nanos() -> i128 {
137    chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0) as i128
138}
139
140/// Parse a Timestream `SELECT` into an executable [`Plan`], or return a
141/// [`QueryError::Validation`] naming the unsupported construct.
142pub fn parse_query(sql: &str) -> Result<Plan, QueryError> {
143    let trimmed = sql.trim().trim_end_matches(';').trim();
144    if trimmed.is_empty() {
145        return Err(QueryError::Validation("The query string is empty.".into()));
146    }
147
148    let select_caps = re_select().captures(trimmed).ok_or_else(|| {
149        QueryError::Validation(
150            "Only 'SELECT ... FROM \"database\".\"table\"' queries are supported.".into(),
151        )
152    })?;
153    let select_list = select_caps[1].trim();
154
155    let projection = if select_list == "*" {
156        Projection::Star
157    } else if re_count().is_match(select_list) {
158        Projection::CountStar
159    } else {
160        return Err(QueryError::Validation(format!(
161            "Unsupported projection '{select_list}'. Supported: '*' or 'COUNT(*)'."
162        )));
163    };
164
165    let from_caps = re_from().captures(trimmed).ok_or_else(|| {
166        QueryError::Validation(
167            "Missing or malformed FROM clause; expected FROM \"database\".\"table\".".into(),
168        )
169    })?;
170    let database = from_caps[1].to_string();
171    let table = from_caps[2].to_string();
172
173    // WHERE handling: accept only `WHERE time <op> ago(<n><unit>)`. Any other
174    // WHERE shape is outside the supported subset.
175    let time_filter = if re_where_any().is_match(trimmed) {
176        let caps = re_where_time().captures(trimmed).ok_or_else(|| {
177            QueryError::Validation(
178                "Unsupported WHERE clause. Supported: WHERE time <op> ago(<n><unit>).".into(),
179            )
180        })?;
181        let op = match &caps[1] {
182            ">" => CmpOp::Gt,
183            ">=" => CmpOp::Ge,
184            "<" => CmpOp::Lt,
185            "<=" => CmpOp::Le,
186            "=" => CmpOp::Eq,
187            other => {
188                return Err(QueryError::Validation(format!(
189                    "Unsupported comparison operator '{other}'."
190                )))
191            }
192        };
193        let n: i128 = caps[2].parse().map_err(|_| {
194            QueryError::Validation(format!("Invalid duration magnitude '{}'.", &caps[2]))
195        })?;
196        let unit = &caps[3];
197        let per = unit_nanos(unit).ok_or_else(|| {
198            QueryError::Validation(format!("Unsupported ago() time unit '{unit}'."))
199        })?;
200        Some((op, now_nanos() - n * per))
201    } else {
202        None
203    };
204
205    let order_desc = if let Some(caps) = re_order().captures(trimmed) {
206        let col = caps[1].to_lowercase();
207        if col != "time" {
208            return Err(QueryError::Validation(format!(
209                "ORDER BY is only supported on the 'time' column, not '{}'.",
210                &caps[1]
211            )));
212        }
213        Some(
214            caps.get(2)
215                .map(|m| m.as_str().eq_ignore_ascii_case("desc"))
216                .unwrap_or(false),
217        )
218    } else {
219        None
220    };
221
222    let limit = re_limit()
223        .captures(trimmed)
224        .and_then(|c| c[1].parse::<usize>().ok());
225
226    Ok(Plan {
227        database,
228        table,
229        projection,
230        time_filter,
231        order_desc,
232        limit,
233    })
234}
235
236/// Map a Timestream measure value type onto the scalar column type name.
237fn scalar_type_for_measure(ty: &str) -> &'static str {
238    match ty {
239        "DOUBLE" => "DOUBLE",
240        "BIGINT" => "BIGINT",
241        "BOOLEAN" => "BOOLEAN",
242        "TIMESTAMP" => "TIMESTAMP",
243        _ => "VARCHAR",
244    }
245}
246
247/// Format an epoch-nanoseconds instant as Timestream's timestamp literal,
248/// `YYYY-MM-DD HH:MM:SS.fffffffff`.
249fn format_timestamp(nanos: i128) -> String {
250    let secs = (nanos.div_euclid(1_000_000_000)) as i64;
251    let sub = (nanos.rem_euclid(1_000_000_000)) as u32;
252    match chrono::DateTime::from_timestamp(secs, sub) {
253        Some(dt) => dt.format("%Y-%m-%d %H:%M:%S%.9f").to_string(),
254        None => "1970-01-01 00:00:00.000000000".to_string(),
255    }
256}
257
258fn scalar(v: &str) -> Value {
259    json!({ "ScalarValue": v })
260}
261
262fn null_datum() -> Value {
263    json!({ "NullValue": true })
264}
265
266/// Execute a [`Plan`] against a table's ingested points, producing the
267/// AWS-shaped `ColumnInfo` + `Row` lists.
268pub fn execute(plan: &Plan, records: &[StoredRecord]) -> QueryResult {
269    // Apply the time filter.
270    let mut filtered: Vec<&StoredRecord> = records
271        .iter()
272        .filter(|r| match plan.time_filter {
273            None => true,
274            Some((op, threshold)) => match op {
275                CmpOp::Gt => r.time_nanos > threshold,
276                CmpOp::Ge => r.time_nanos >= threshold,
277                CmpOp::Lt => r.time_nanos < threshold,
278                CmpOp::Le => r.time_nanos <= threshold,
279                CmpOp::Eq => r.time_nanos == threshold,
280            },
281        })
282        .collect();
283
284    // ORDER BY time.
285    if let Some(desc) = plan.order_desc {
286        filtered.sort_by(|a, b| {
287            if desc {
288                b.time_nanos.cmp(&a.time_nanos)
289            } else {
290                a.time_nanos.cmp(&b.time_nanos)
291            }
292        });
293    }
294
295    if let Projection::CountStar = plan.projection {
296        let count = plan
297            .limit
298            .map(|l| filtered.len().min(l))
299            .unwrap_or(filtered.len());
300        return QueryResult {
301            columns: vec![json!({ "Name": "_col0", "Type": { "ScalarType": "BIGINT" } })],
302            rows: vec![json!({ "Data": [scalar(&count.to_string())] })],
303        };
304    }
305
306    // SELECT *: derive the dimension columns and the measure value type columns
307    // present across the (filtered) result set, deterministically ordered.
308    if let Some(limit) = plan.limit {
309        filtered.truncate(limit);
310    }
311
312    let mut dim_names: Vec<String> = Vec::new();
313    let mut measure_types: Vec<String> = Vec::new();
314    for r in &filtered {
315        for (name, _) in &r.dimensions {
316            if !dim_names.iter().any(|d| d == name) {
317                dim_names.push(name.clone());
318            }
319        }
320        if !measure_types.iter().any(|t| t == &r.measure_value_type) {
321            measure_types.push(r.measure_value_type.clone());
322        }
323    }
324    dim_names.sort();
325    measure_types.sort();
326
327    let mut columns: Vec<Value> = Vec::new();
328    for d in &dim_names {
329        columns.push(json!({ "Name": d, "Type": { "ScalarType": "VARCHAR" } }));
330    }
331    columns.push(json!({ "Name": "measure_name", "Type": { "ScalarType": "VARCHAR" } }));
332    for t in &measure_types {
333        columns.push(json!({
334            "Name": format!("measure_value::{}", t.to_lowercase()),
335            "Type": { "ScalarType": scalar_type_for_measure(t) }
336        }));
337    }
338    columns.push(json!({ "Name": "time", "Type": { "ScalarType": "TIMESTAMP" } }));
339
340    let mut rows: Vec<Value> = Vec::with_capacity(filtered.len());
341    for r in &filtered {
342        let mut data: Vec<Value> = Vec::with_capacity(columns.len());
343        for d in &dim_names {
344            match r.dimensions.iter().find(|(n, _)| n == d) {
345                Some((_, v)) => data.push(scalar(v)),
346                None => data.push(null_datum()),
347            }
348        }
349        data.push(scalar(&r.measure_name));
350        for t in &measure_types {
351            if &r.measure_value_type == t {
352                data.push(scalar(&r.measure_value));
353            } else {
354                data.push(null_datum());
355            }
356        }
357        data.push(scalar(&format_timestamp(r.time_nanos)));
358        rows.push(json!({ "Data": data }));
359    }
360
361    QueryResult { columns, rows }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    fn rec(dims: &[(&str, &str)], mn: &str, mv: &str, ty: &str, t: i128) -> StoredRecord {
369        StoredRecord {
370            time_nanos: t,
371            dimensions: dims
372                .iter()
373                .map(|(a, b)| (a.to_string(), b.to_string()))
374                .collect(),
375            measure_name: mn.to_string(),
376            measure_value: mv.to_string(),
377            measure_value_type: ty.to_string(),
378        }
379    }
380
381    #[test]
382    fn parse_select_star() {
383        let p = parse_query(r#"SELECT * FROM "metrics"."cpu""#).unwrap();
384        assert_eq!(p.database, "metrics");
385        assert_eq!(p.table, "cpu");
386        assert_eq!(p.projection, Projection::Star);
387    }
388
389    #[test]
390    fn parse_count_and_order_and_limit() {
391        let p = parse_query(r#"SELECT COUNT(*) FROM "m"."t" ORDER BY time DESC LIMIT 10"#).unwrap();
392        assert_eq!(p.projection, Projection::CountStar);
393        assert_eq!(p.order_desc, Some(true));
394        assert_eq!(p.limit, Some(10));
395    }
396
397    #[test]
398    fn parse_where_ago() {
399        let p = parse_query(r#"SELECT * FROM "m"."t" WHERE time > ago(1h)"#).unwrap();
400        assert!(matches!(p.time_filter, Some((CmpOp::Gt, _))));
401    }
402
403    #[test]
404    fn unsupported_projection_is_validation() {
405        let e = parse_query(r#"SELECT avg(x) FROM "m"."t""#).unwrap_err();
406        assert!(e.message().contains("Unsupported projection"));
407    }
408
409    #[test]
410    fn unsupported_where_is_validation() {
411        let e = parse_query(r#"SELECT * FROM "m"."t" WHERE host = 'a'"#).unwrap_err();
412        assert!(e.message().contains("Unsupported WHERE"));
413    }
414
415    #[test]
416    fn execute_star_projects_dimension_measure_time() {
417        let recs = vec![
418            rec(&[("host", "a")], "cpu", "1.5", "DOUBLE", 1_000_000_000),
419            rec(&[("host", "b")], "cpu", "2.5", "DOUBLE", 2_000_000_000),
420        ];
421        let p = parse_query(r#"SELECT * FROM "m"."t" ORDER BY time ASC"#).unwrap();
422        let out = execute(&p, &recs);
423        let col_names: Vec<&str> = out
424            .columns
425            .iter()
426            .map(|c| c["Name"].as_str().unwrap())
427            .collect();
428        assert_eq!(
429            col_names,
430            vec!["host", "measure_name", "measure_value::double", "time"]
431        );
432        assert_eq!(out.rows.len(), 2);
433        assert_eq!(out.rows[0]["Data"][0]["ScalarValue"], "a");
434    }
435
436    #[test]
437    fn execute_count() {
438        let recs = vec![
439            rec(&[("host", "a")], "cpu", "1", "DOUBLE", 1),
440            rec(&[("host", "b")], "cpu", "2", "DOUBLE", 2),
441        ];
442        let p = parse_query(r#"SELECT COUNT(*) FROM "m"."t""#).unwrap();
443        let out = execute(&p, &recs);
444        assert_eq!(out.rows.len(), 1);
445        assert_eq!(out.rows[0]["Data"][0]["ScalarValue"], "2");
446    }
447
448    #[test]
449    fn execute_limit_truncates() {
450        let recs = vec![
451            rec(&[("host", "a")], "cpu", "1", "DOUBLE", 1),
452            rec(&[("host", "b")], "cpu", "2", "DOUBLE", 2),
453            rec(&[("host", "c")], "cpu", "3", "DOUBLE", 3),
454        ];
455        let p = parse_query(r#"SELECT * FROM "m"."t" ORDER BY time ASC LIMIT 2"#).unwrap();
456        let out = execute(&p, &recs);
457        assert_eq!(out.rows.len(), 2);
458    }
459}