Skip to main content

dactyl_db/query/
mod.rs

1//! Query analysis pipeline.
2//!
3//! [`QueryAnalyzer`] is a tiny lexical scanner that:
4//!   1. Detects the `-- dactyl: <datastore>` inline directive.
5//!   2. Detects a fixed list of dialect-specific constructs.
6//!   3. Returns [`Analyzed`] so the caller (lib.rs / the `query!` macro) can
7//!      decide whether to rewrite, error, or pass through.
8//!
9//! The rewriter itself is intentionally a no-op for the first pass — see the
10//! follow-up issue for the full plan. The pipeline is wired so swapping in the
11//! real rewriter is a one-function change.
12
13mod dialect;
14mod lexer;
15
16pub use dialect::{first_unsupported, Construct, Dialect};
17
18/// Outcome of analyzing a single query.
19#[derive(Debug, Clone)]
20pub struct Analyzed {
21    /// All dialect-specific constructs the lexer found, in source order.
22    pub constructs: Vec<Construct>,
23    /// Inline `-- dactyl: <datastore>` override, if any.
24    pub inline_override: Option<&'static str>,
25    /// A rewriter plan. The first pass emits identity-only rewrites; callers
26    /// that enable `optimize = true` can apply it transparently.
27    pub rewrite: Rewrite,
28}
29
30/// A no-op or trivial rewrite plan.
31///
32/// The rewriter is a follow-up; for the first pass, the only non-identity
33/// action is dropping whitespace. Future passes will fill in the structured
34/// transformations.
35#[derive(Debug, Clone)]
36pub enum Rewrite {
37    /// Pass the query through unchanged.
38    Identity,
39    /// A rewritten query string. Identity in this pass.
40    Replaced(String),
41}
42
43impl Rewrite {
44    /// Apply the rewrite and return the SQL string to execute.
45    ///
46    /// We always return an owned `String` to avoid lifetime gymnastics in the
47    /// caller — the analyzer already materializes the rewritten string.
48    pub fn apply(&self, original: &str) -> String {
49        match self {
50            Rewrite::Identity => original.to_string(),
51            Rewrite::Replaced(s) => s.clone(),
52        }
53    }
54}
55
56/// The analyzer. Cheap to construct; carries no state.
57#[derive(Debug, Default, Clone, Copy)]
58pub struct QueryAnalyzer;
59
60impl QueryAnalyzer {
61    /// Create a new analyzer.
62    pub fn new() -> Self {
63        Self
64    }
65
66    /// Lex a SQL string and produce an [`Analyzed`] value.
67    ///
68    /// The analyzer never raises an error: invalid SQL is treated as portable
69    /// SQL and produces an empty `constructs` list. Errors are produced
70    /// downstream when the adapter fails to parse or execute the string.
71    pub fn analyze(&self, query: &str) -> Analyzed {
72        let (inline_override, remainder) = lexer::strip_dactyl_directive(query);
73        let tokens = lexer::tokenize(&remainder);
74        let constructs = detect_constructs(&tokens);
75        let rewrite = if constructs.is_empty() {
76            Rewrite::Identity
77        } else {
78            // first-pass: identity rewrite. The plumbing is here so callers
79            // can already exercise the optimize=true code path.
80            Rewrite::Replaced(remainder)
81        };
82        Analyzed {
83            constructs,
84            inline_override,
85            rewrite,
86        }
87    }
88}
89
90/// Walk the token stream and collect dialect-specific constructs.
91fn detect_constructs(tokens: &[lexer::Token]) -> Vec<Construct> {
92    let mut out = Vec::new();
93    let mut i = 0;
94    while i < tokens.len() {
95        if let lexer::Token::Word(w) = &tokens[i] {
96            match w.as_str() {
97                "json_each" => out.push(Construct::JsonEach),
98                "json_tree" => out.push(Construct::JsonTree),
99                "without" => {
100                    // WITHOUT ROWID is two tokens
101                    if i + 1 < tokens.len() {
102                        if let lexer::Token::Word(n) = &tokens[i + 1] {
103                            if n == "rowid" {
104                                out.push(Construct::WithoutRowId);
105                                i += 1;
106                            }
107                        }
108                    }
109                }
110                "strict" => out.push(Construct::Strict),
111                "jsonb" => out.push(Construct::Jsonb),
112                "returning" => out.push(Construct::Returning),
113                "ilike" => out.push(Construct::Ilike),
114                "gen_random_uuid" => out.push(Construct::GenRandomUuid),
115                "now" => out.push(Construct::NowFn),
116                _ => {}
117            }
118        } else if let lexer::Token::Op(o) = &tokens[i] {
119            match o.as_str() {
120                "->>" => out.push(Construct::JsonArrowText),
121                "->" => out.push(Construct::JsonArrow),
122                "@>" => out.push(Construct::JsonContains),
123                "<@" => out.push(Construct::JsonContained),
124                _ => {}
125            }
126        } else if matches!(tokens[i], lexer::Token::Question) {
127            // `?` is the JSON existence operator in postgres JSONB context.
128            // The lexer is context-free; we conservatively flag every bare `?`
129            // so callers can decide.
130            out.push(Construct::JsonExists);
131        }
132        i += 1;
133    }
134    out
135}
136
137/// Map a datastore name to its native dialect. Returns `None` for unknown
138/// names; callers should treat that as `DactylError::UnknownDatastore`.
139pub fn dialect_of(datastore: &str) -> Option<Dialect> {
140    match datastore {
141        "sqlite" => Some(Dialect::Sqlite),
142        "neon" | "postgres" | "postgresql" | "pg" => Some(Dialect::Postgres),
143        _ => None,
144    }
145}