Skip to main content

dactyl_db/query/
mod.rs

1//! Query analysis and bounded dialect preparation.
2//!
3//! Dactyl uses a total lexical scanner rather than pretending to be a full SQL
4//! parser. It rejects constructs that cannot be translated safely and applies
5//! only explicit, semantics-bounded rewrites when the caller enables them.
6
7mod dialect;
8mod lexer;
9
10use crate::error::DactylError;
11
12pub use dialect::{first_unsupported, Construct, Dialect};
13
14/// Outcome of analyzing a query without selecting an adapter.
15#[derive(Debug, Clone)]
16pub struct Analyzed {
17    /// All dialect-specific constructs the lexer found, in source order.
18    pub constructs: Vec<Construct>,
19    /// Inline `-- dactyl: <datastore>` override, if any.
20    pub inline_override: Option<&'static str>,
21    /// A directive-stripping rewrite plan. Adapter-specific rewrites are
22    /// selected by [`QueryAnalyzer::prepare`].
23    pub rewrite: Rewrite,
24}
25
26/// A no-op or directive-stripping rewrite plan.
27#[derive(Debug, Clone)]
28pub enum Rewrite {
29    /// Pass the query through unchanged.
30    Identity,
31    /// A rewritten query string.
32    Replaced(String),
33}
34
35impl Rewrite {
36    /// Apply the rewrite and return the SQL string to execute.
37    pub fn apply(&self, original: &str) -> String {
38        match self {
39            Rewrite::Identity => original.to_string(),
40            Rewrite::Replaced(s) => s.clone(),
41        }
42    }
43}
44
45/// The analyzer. Cheap to construct; carries no state.
46#[derive(Debug, Default, Clone, Copy)]
47pub struct QueryAnalyzer;
48
49impl QueryAnalyzer {
50    pub fn new() -> Self {
51        Self
52    }
53
54    /// Lex a SQL string and produce an [`Analyzed`] value.
55    pub fn analyze(&self, query: &str) -> Analyzed {
56        let (inline_override, remainder) = lexer::strip_dactyl_directive(query);
57        let tokens = lexer::tokenize(&remainder);
58        let constructs = detect_constructs(&tokens);
59        let rewrite = if constructs.is_empty() && inline_override.is_none() {
60            Rewrite::Identity
61        } else {
62            Rewrite::Replaced(remainder)
63        };
64        Analyzed {
65            constructs,
66            inline_override,
67            rewrite,
68        }
69    }
70
71    /// Analyze and prepare SQL for one concrete adapter dialect.
72    ///
73    /// Inline directives are validated against the connection's selected
74    /// dialect. Unsupported constructs are rejected by default. When
75    /// `allow_rewrites` is true, only the small set of loss-bounded rewrites
76    /// below is applied; constructs with no safe translation still fail.
77    pub fn prepare(
78        &self,
79        query: &str,
80        dialect: Dialect,
81        allow_rewrites: bool,
82    ) -> Result<String, DactylError> {
83        let analyzed = self.analyze(query);
84        if let Some(override_ds) = analyzed.inline_override {
85            let override_dialect = dialect_of(override_ds).ok_or_else(|| {
86                DactylError::Routing(format!("unknown inline datastore {override_ds:?}"))
87            })?;
88            if override_dialect != dialect {
89                return Err(DactylError::Routing(format!(
90                    "inline datastore {override_ds:?} does not match the active connection"
91                )));
92            }
93        }
94
95        let unsupported = first_unsupported(&analyzed.constructs, dialect);
96        let (_, remainder) = lexer::strip_dactyl_directive(query);
97        if unsupported.is_none() {
98            return Ok(remainder);
99        }
100        if !allow_rewrites {
101            return Err(DactylError::Unsupported {
102                construct: unsupported.expect("checked above"),
103            });
104        }
105
106        rewrite_for_dialect(&remainder, &analyzed.constructs, dialect).ok_or_else(|| {
107            DactylError::Unsupported {
108                construct: unsupported.expect("checked above"),
109            }
110        })
111    }
112}
113
114/// Walk the token stream and collect dialect-specific constructs.
115fn detect_constructs(tokens: &[lexer::Token]) -> Vec<Construct> {
116    let mut out = Vec::new();
117    let mut i = 0;
118    while i < tokens.len() {
119        if let lexer::Token::Word(w) = &tokens[i] {
120            match w.as_str() {
121                "json_each" => out.push(Construct::JsonEach),
122                "json_tree" => out.push(Construct::JsonTree),
123                "without" => {
124                    if let Some(lexer::Token::Word(n)) = tokens.get(i + 1) {
125                        if n == "rowid" {
126                            out.push(Construct::WithoutRowId);
127                            i += 1;
128                        }
129                    }
130                }
131                "strict" => out.push(Construct::Strict),
132                "jsonb" => out.push(Construct::Jsonb),
133                "returning" => out.push(Construct::Returning),
134                "ilike" => out.push(Construct::Ilike),
135                "gen_random_uuid" => out.push(Construct::GenRandomUuid),
136                "now" => out.push(Construct::NowFn),
137                _ => {}
138            }
139        } else if let lexer::Token::Op(o) = &tokens[i] {
140            match o.as_str() {
141                "->>" => out.push(Construct::JsonArrowText),
142                "->" => out.push(Construct::JsonArrow),
143                "@>" => out.push(Construct::JsonContains),
144                "<@" => out.push(Construct::JsonContained),
145                _ => {}
146            }
147        }
148        // `?` is also a valid SQLite parameter placeholder. The lexical
149        // scanner therefore never classifies it as JSON existence syntax.
150        i += 1;
151    }
152    out
153}
154
155/// Apply only rewrites whose semantics are stable for the supported adapters.
156/// Table-valued JSON functions, JSON containment, and UUID generation remain
157/// explicit errors because a lexical substitution would silently change data.
158fn rewrite_for_dialect(sql: &str, constructs: &[Construct], dialect: Dialect) -> Option<String> {
159    let mut rewritten = sql.to_string();
160    for &construct in constructs {
161        match (dialect, construct) {
162            (Dialect::Sqlite, Construct::Ilike) => {
163                rewritten = replace_word(&rewritten, "ilike", "like");
164            }
165            (Dialect::Sqlite, Construct::NowFn) => {
166                rewritten = replace_now_function(&rewritten);
167            }
168            (Dialect::Sqlite, Construct::Jsonb) => {
169                rewritten = replace_word(&rewritten, "jsonb", "text");
170            }
171            (Dialect::Postgres, Construct::Strict) => {
172                rewritten = replace_word(&rewritten, "strict", "");
173            }
174            (_, Construct::JsonEach)
175            | (_, Construct::JsonTree)
176            | (_, Construct::WithoutRowId)
177            | (_, Construct::JsonContains)
178            | (_, Construct::JsonContained)
179            | (_, Construct::JsonExists)
180            | (_, Construct::GenRandomUuid) => return None,
181            (_, Construct::JsonArrowText | Construct::JsonArrow | Construct::Returning) => {
182                if !construct.supported_by(dialect) {
183                    return None;
184                }
185            }
186            (_, Construct::Ilike | Construct::NowFn | Construct::Jsonb | Construct::Strict) => {
187                if !construct.supported_by(dialect) {
188                    return None;
189                }
190            }
191        }
192    }
193    Some(rewritten)
194}
195
196fn replace_word(input: &str, needle: &str, replacement: &str) -> String {
197    rewrite_unquoted(input, |segment| {
198        replace_word_unquoted(segment, needle, replacement)
199    })
200}
201
202fn replace_word_unquoted(input: &str, needle: &str, replacement: &str) -> String {
203    let mut out = String::with_capacity(input.len());
204    let lower = input.to_ascii_lowercase();
205    let bytes = input.as_bytes();
206    let needle_bytes = needle.as_bytes();
207    let mut i = 0;
208    while i < bytes.len() {
209        let end = i + needle_bytes.len();
210        let boundary_before =
211            i == 0 || (!bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_');
212        let boundary_after =
213            end >= bytes.len() || (!bytes[end].is_ascii_alphanumeric() && bytes[end] != b'_');
214        if end <= bytes.len()
215            && boundary_before
216            && boundary_after
217            && &lower.as_bytes()[i..end] == needle_bytes
218        {
219            out.push_str(replacement);
220            i = end;
221        } else {
222            out.push(bytes[i] as char);
223            i += 1;
224        }
225    }
226    out
227}
228
229fn replace_now_function(input: &str) -> String {
230    rewrite_unquoted(input, replace_now_function_unquoted)
231}
232
233fn replace_now_function_unquoted(input: &str) -> String {
234    let bytes = input.as_bytes();
235    let lower = input.to_ascii_lowercase();
236    let mut out = String::with_capacity(input.len());
237    let mut i = 0;
238    while i < bytes.len() {
239        if lower.as_bytes()[i..].starts_with(b"now")
240            && (i == 0 || (!bytes[i - 1].is_ascii_alphanumeric() && bytes[i - 1] != b'_'))
241            && (i + 3 == bytes.len()
242                || (!bytes[i + 3].is_ascii_alphanumeric() && bytes[i + 3] != b'_'))
243        {
244            let mut j = i + 3;
245            while j < bytes.len() && bytes[j].is_ascii_whitespace() {
246                j += 1;
247            }
248            if j + 1 < bytes.len() && bytes[j] == b'(' && bytes[j + 1] == b')' {
249                out.push_str("CURRENT_TIMESTAMP");
250                i = j + 2;
251                continue;
252            }
253        }
254        out.push(bytes[i] as char);
255        i += 1;
256    }
257    out
258}
259
260/// Apply a transformation only to SQL code, preserving string literals and
261/// comments byte-for-byte. This keeps dialect rewrites from changing data or
262/// documentation embedded in the query.
263fn rewrite_unquoted<F>(input: &str, mut transform: F) -> String
264where
265    F: FnMut(&str) -> String,
266{
267    let bytes = input.as_bytes();
268    let mut out = String::with_capacity(input.len());
269    let mut segment_start = 0;
270    let mut i = 0;
271    while i < bytes.len() {
272        let quote = if bytes[i] == b'\'' || bytes[i] == b'"' {
273            Some(bytes[i])
274        } else {
275            None
276        };
277        let comment = (bytes[i] == b'-' && i + 1 < bytes.len() && bytes[i + 1] == b'-')
278            || (bytes[i] == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*');
279        if quote.is_none() && !comment {
280            i += 1;
281            continue;
282        }
283
284        out.push_str(&transform(&input[segment_start..i]));
285        let protected_start = i;
286        if let Some(delimiter) = quote {
287            i += 1;
288            while i < bytes.len() {
289                if bytes[i] == delimiter {
290                    if i + 1 < bytes.len() && bytes[i + 1] == delimiter {
291                        i += 2;
292                        continue;
293                    }
294                    i += 1;
295                    break;
296                }
297                i += 1;
298            }
299        } else if bytes[i] == b'-' {
300            while i < bytes.len() && bytes[i] != b'\n' {
301                i += 1;
302            }
303        } else {
304            i += 2;
305            while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
306                i += 1;
307            }
308            if i + 1 < bytes.len() {
309                i += 2;
310            } else {
311                i = bytes.len();
312            }
313        }
314        out.push_str(&input[protected_start..i]);
315        segment_start = i;
316    }
317    out.push_str(&transform(&input[segment_start..]));
318    out
319}
320
321/// Map a datastore name to its native dialect.
322pub fn dialect_of(datastore: &str) -> Option<Dialect> {
323    match datastore {
324        "sqlite" => Some(Dialect::Sqlite),
325        "neon" | "postgres" | "postgresql" | "pg" => Some(Dialect::Postgres),
326        _ => None,
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn prepare_strips_directive_and_rejects_unsafe_mismatch() {
336        let analyzer = QueryAnalyzer::new();
337        let sql = analyzer
338            .prepare("-- dactyl: sqlite\nselect ?1", Dialect::Sqlite, false)
339            .unwrap();
340        assert_eq!(sql, "select ?1");
341        assert!(matches!(
342            analyzer.prepare("select data @> $1", Dialect::Sqlite, true),
343            Err(DactylError::Unsupported { .. })
344        ));
345    }
346
347    #[test]
348    fn safe_rewrites_are_explicit() {
349        let analyzer = QueryAnalyzer::new();
350        let sql = analyzer
351            .prepare(
352                "select now() where name ilike $1 and note = 'now() ilike'",
353                Dialect::Sqlite,
354                true,
355            )
356            .unwrap();
357        assert!(sql.contains("CURRENT_TIMESTAMP"));
358        assert!(sql.contains("like"));
359        assert!(sql.contains("'now() ilike'"));
360    }
361
362    #[test]
363    fn question_placeholders_are_not_json_operators() {
364        let analyzed = QueryAnalyzer::new().analyze("select ?1, ? from values");
365        assert!(analyzed.constructs.is_empty());
366    }
367}