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}