qail_core/transpiler/
mod.rs

1//! SQL Transpiler for QAIL AST.
2//!
3
4pub mod conditions;
5pub mod ddl;
6pub mod dialect;
7pub mod dml;
8pub mod sql;
9pub mod traits;
10
11// NoSQL transpilers (organized in nosql/ subdirectory)
12pub mod nosql;
13pub use nosql::dynamo::ToDynamo;
14pub use nosql::mongo::ToMongo;
15pub use nosql::qdrant::ToQdrant;
16
17#[cfg(test)]
18mod tests;
19
20use crate::ast::*;
21pub use conditions::ConditionToSql;
22pub use dialect::Dialect;
23pub use traits::SqlGenerator;
24pub use traits::escape_identifier;
25
26/// Result of transpilation with extracted parameters.
27#[derive(Debug, Clone, PartialEq, Default)]
28pub struct TranspileResult {
29    /// The SQL template with placeholders (e.g., $1, $2 or ?, ?)
30    pub sql: String,
31    /// The extracted parameter values in order
32    pub params: Vec<Value>,
33    /// Names of named parameters in order they appear (for :name → $n mapping)
34    pub named_params: Vec<String>,
35}
36
37impl TranspileResult {
38    /// Create a new TranspileResult.
39    pub fn new(sql: impl Into<String>, params: Vec<Value>) -> Self {
40        Self {
41            sql: sql.into(),
42            params,
43            named_params: vec![],
44        }
45    }
46
47    /// Create a result with no parameters.
48    pub fn sql_only(sql: impl Into<String>) -> Self {
49        Self {
50            sql: sql.into(),
51            params: Vec::new(),
52            named_params: Vec::new(),
53        }
54    }
55}
56
57/// Trait for converting AST nodes to parameterized SQL.
58pub trait ToSqlParameterized {
59    /// Convert to SQL with extracted parameters (default dialect).
60    fn to_sql_parameterized(&self) -> TranspileResult {
61        self.to_sql_parameterized_with_dialect(Dialect::default())
62    }
63    /// Convert to SQL with extracted parameters for specific dialect.
64    fn to_sql_parameterized_with_dialect(&self, dialect: Dialect) -> TranspileResult;
65}
66
67/// Trait for converting AST nodes to SQL.
68pub trait ToSql {
69    /// Convert this node to a SQL string using default dialect.
70    fn to_sql(&self) -> String {
71        self.to_sql_with_dialect(Dialect::default())
72    }
73    /// Convert this node to a SQL string with specific dialect.
74    fn to_sql_with_dialect(&self, dialect: Dialect) -> String;
75}
76
77impl ToSql for Qail {
78    fn to_sql_with_dialect(&self, dialect: Dialect) -> String {
79        match self.action {
80            Action::Get => dml::select::build_select(self, dialect),
81            Action::Set => dml::update::build_update(self, dialect),
82            Action::Del => dml::delete::build_delete(self, dialect),
83            Action::Add => dml::insert::build_insert(self, dialect),
84            Action::Gen => format!("-- gen::{}  (generates Rust struct, not SQL)", self.table),
85            Action::Make => ddl::build_create_table(self, dialect),
86            Action::Mod => ddl::build_alter_table(self, dialect),
87            Action::Over => dml::window::build_window(self, dialect),
88            Action::With => dml::cte::build_cte(self, dialect),
89            Action::Index => ddl::build_create_index(self, dialect),
90            Action::DropIndex => format!("DROP INDEX {}", self.table),
91            Action::Alter => ddl::build_alter_add_column(self, dialect),
92            Action::AlterDrop => ddl::build_alter_drop_column(self, dialect),
93            Action::AlterType => ddl::build_alter_column_type(self, dialect),
94            // Stubs
95            Action::TxnStart => "BEGIN TRANSACTION;".to_string(), // Default stub
96            Action::TxnCommit => "COMMIT;".to_string(),
97            Action::TxnRollback => "ROLLBACK;".to_string(),
98            Action::Put => dml::upsert::build_upsert(self, dialect),
99            Action::Drop => format!("DROP TABLE {}", self.table),
100            Action::DropCol | Action::RenameCol => ddl::build_alter_column(self, dialect),
101            // JSON features
102            Action::JsonTable => dml::json_table::build_json_table(self, dialect),
103            // COPY protocol (AST-native in qail-pg, generates SELECT for fallback)
104            Action::Export => dml::select::build_select(self, dialect),
105            // TRUNCATE TABLE
106            Action::Truncate => format!("TRUNCATE TABLE {}", self.table),
107            // EXPLAIN - wrap SELECT query
108            Action::Explain => format!("EXPLAIN {}", dml::select::build_select(self, dialect)),
109            // EXPLAIN ANALYZE - execute and analyze query
110            Action::ExplainAnalyze => format!(
111                "EXPLAIN ANALYZE {}",
112                dml::select::build_select(self, dialect)
113            ),
114            // LOCK TABLE
115            Action::Lock => format!("LOCK TABLE {} IN ACCESS EXCLUSIVE MODE", self.table),
116            // CREATE MATERIALIZED VIEW - uses source_query for the view definition
117            Action::CreateMaterializedView => {
118                if let Some(source) = &self.source_query {
119                    format!(
120                        "CREATE MATERIALIZED VIEW {} AS {}",
121                        self.table,
122                        source.to_sql_with_dialect(dialect)
123                    )
124                } else {
125                    format!(
126                        "CREATE MATERIALIZED VIEW {} AS {}",
127                        self.table,
128                        dml::select::build_select(self, dialect)
129                    )
130                }
131            }
132            // REFRESH MATERIALIZED VIEW
133            Action::RefreshMaterializedView => format!("REFRESH MATERIALIZED VIEW {}", self.table),
134            // DROP MATERIALIZED VIEW
135            Action::DropMaterializedView => format!("DROP MATERIALIZED VIEW {}", self.table),
136            // LISTEN/NOTIFY (Pub/Sub)
137            Action::Listen => {
138                if let Some(ch) = &self.channel {
139                    format!("LISTEN {}", ch)
140                } else {
141                    "LISTEN".to_string()
142                }
143            }
144            Action::Notify => {
145                if let Some(ch) = &self.channel {
146                    if let Some(msg) = &self.payload {
147                        format!("NOTIFY {}, '{}'", ch, msg)
148                    } else {
149                        format!("NOTIFY {}", ch)
150                    }
151                } else {
152                    "NOTIFY".to_string()
153                }
154            }
155            Action::Unlisten => {
156                if let Some(ch) = &self.channel {
157                    format!("UNLISTEN {}", ch)
158                } else {
159                    "UNLISTEN *".to_string()
160                }
161            }
162            // Savepoints
163            Action::Savepoint => {
164                if let Some(name) = &self.savepoint_name {
165                    format!("SAVEPOINT {}", name)
166                } else {
167                    "SAVEPOINT".to_string()
168                }
169            }
170            Action::ReleaseSavepoint => {
171                if let Some(name) = &self.savepoint_name {
172                    format!("RELEASE SAVEPOINT {}", name)
173                } else {
174                    "RELEASE SAVEPOINT".to_string()
175                }
176            }
177            Action::RollbackToSavepoint => {
178                if let Some(name) = &self.savepoint_name {
179                    format!("ROLLBACK TO SAVEPOINT {}", name)
180                } else {
181                    "ROLLBACK TO SAVEPOINT".to_string()
182                }
183            }
184            // Views
185            Action::CreateView => {
186                if let Some(source) = &self.source_query {
187                    format!(
188                        "CREATE VIEW {} AS {}",
189                        self.table,
190                        source.to_sql_with_dialect(dialect)
191                    )
192                } else {
193                    format!(
194                        "CREATE VIEW {} AS {}",
195                        self.table,
196                        dml::select::build_select(self, dialect)
197                    )
198                }
199            }
200            Action::DropView => format!("DROP VIEW IF EXISTS {}", self.table),
201        }
202    }
203}
204
205impl ToSqlParameterized for Qail {
206    fn to_sql_parameterized_with_dialect(&self, dialect: Dialect) -> TranspileResult {
207        // Use the full ToSql implementation which handles CTEs, JOINs, etc.
208        // Then post-process to extract named parameters for binding
209        let full_sql = self.to_sql_with_dialect(dialect);
210
211        // and replace them with positional parameters ($1, $2, etc.)
212        let mut named_params: Vec<String> = Vec::new();
213        let mut seen_params: std::collections::HashMap<String, usize> =
214            std::collections::HashMap::new();
215        let mut result = String::with_capacity(full_sql.len());
216        let mut chars = full_sql.chars().peekable();
217        let mut param_index = 1;
218
219        while let Some(c) = chars.next() {
220            if c == ':'
221                && let Some(&next) = chars.peek()
222            {
223                if next == ':' {
224                    result.push(':');
225                    result.push(chars.next().unwrap());
226                    continue;
227                }
228                if next.is_ascii_alphabetic() || next == '_' {
229                    let mut param_name = String::new();
230                    while let Some(&ch) = chars.peek() {
231                        if ch.is_ascii_alphanumeric() || ch == '_' {
232                            param_name.push(chars.next().unwrap());
233                        } else {
234                            break;
235                        }
236                    }
237
238                    let idx = if let Some(&existing) = seen_params.get(&param_name) {
239                        existing
240                    } else {
241                        let idx = param_index;
242                        seen_params.insert(param_name.clone(), idx);
243                        named_params.push(param_name);
244                        param_index += 1;
245                        idx
246                    };
247
248                    result.push('$');
249                    result.push_str(&idx.to_string());
250                    continue;
251                }
252            }
253            result.push(c);
254        }
255
256        TranspileResult {
257            sql: result,
258            params: Vec::new(), // Positional params not used, named_params provides mapping
259            named_params,
260        }
261    }
262}