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            // Vector database operations - use qail-qdrant driver instead
202            operators::Action::Search | operators::Action::Upsert | operators::Action::Scroll => {
203                format!("-- Vector operation {:?} not supported in SQL. Use qail-qdrant driver.", self.action)
204            }
205            operators::Action::CreateCollection | operators::Action::DeleteCollection => {
206                format!("-- Vector DDL {:?} not supported in SQL. Use qail-qdrant driver.", self.action)
207            }
208            // Function and Trigger operations
209            operators::Action::CreateFunction => {
210                if let Some(func) = &self.function_def {
211                    let lang = func.language.as_deref().unwrap_or("plpgsql");
212                    format!(
213                        "CREATE OR REPLACE FUNCTION {}() RETURNS {} LANGUAGE {} AS $$ {} $$",
214                        func.name, func.returns, lang, func.body
215                    )
216                } else {
217                    "-- CreateFunction requires function_def".to_string()
218                }
219            }
220            operators::Action::DropFunction => {
221                format!("DROP FUNCTION IF EXISTS {}()", self.table)
222            }
223            operators::Action::CreateTrigger => {
224                if let Some(trig) = &self.trigger_def {
225                    let timing = match trig.timing {
226                        crate::ast::TriggerTiming::Before => "BEFORE",
227                        crate::ast::TriggerTiming::After => "AFTER",
228                        crate::ast::TriggerTiming::InsteadOf => "INSTEAD OF",
229                    };
230                    let events: Vec<&str> = trig.events.iter().map(|e| match e {
231                        crate::ast::TriggerEvent::Insert => "INSERT",
232                        crate::ast::TriggerEvent::Update => "UPDATE",
233                        crate::ast::TriggerEvent::Delete => "DELETE",
234                        crate::ast::TriggerEvent::Truncate => "TRUNCATE",
235                    }).collect();
236                    let for_each = if trig.for_each_row { "FOR EACH ROW" } else { "FOR EACH STATEMENT" };
237                    // Prepend DROP for idempotency - PostgreSQL has no CREATE OR REPLACE TRIGGER
238                    format!(
239                        "DROP TRIGGER IF EXISTS {} ON {};\nCREATE TRIGGER {} {} {} ON {} {} EXECUTE FUNCTION {}()",
240                        trig.name, trig.table,
241                        trig.name, timing, events.join(" OR "), trig.table, for_each, trig.execute_function
242                    )
243                } else {
244                    "-- CreateTrigger requires trigger_def".to_string()
245                }
246            }
247            operators::Action::DropTrigger => {
248                format!("DROP TRIGGER IF EXISTS {} ON {}", self.table, self.table)
249            }
250            // Redis operations - use qail-redis driver instead
251            Action::RedisGet
252            | Action::RedisSet
253            | Action::RedisDel
254            | Action::RedisIncr
255            | Action::RedisDecr
256            | Action::RedisTtl
257            | Action::RedisExpire
258            | Action::RedisExists
259            | Action::RedisMGet
260            | Action::RedisMSet
261            | Action::RedisPing => {
262                format!(
263                    "-- Redis operation {:?} not supported in SQL. Use qail-redis driver.",
264                    self.action
265                )
266            }
267        }
268    }
269}
270
271impl ToSqlParameterized for Qail {
272    fn to_sql_parameterized_with_dialect(&self, dialect: Dialect) -> TranspileResult {
273        // Use the full ToSql implementation which handles CTEs, JOINs, etc.
274        // Then post-process to extract named parameters for binding
275        let full_sql = self.to_sql_with_dialect(dialect);
276
277        // and replace them with positional parameters ($1, $2, etc.)
278        let mut named_params: Vec<String> = Vec::new();
279        let mut seen_params: std::collections::HashMap<String, usize> =
280            std::collections::HashMap::new();
281        let mut result = String::with_capacity(full_sql.len());
282        let mut chars = full_sql.chars().peekable();
283        let mut param_index = 1;
284
285        while let Some(c) = chars.next() {
286            if c == ':'
287                && let Some(&next) = chars.peek()
288            {
289                if next == ':' {
290                    result.push(':');
291                    result.push(chars.next().unwrap());
292                    continue;
293                }
294                if next.is_ascii_alphabetic() || next == '_' {
295                    let mut param_name = String::new();
296                    while let Some(&ch) = chars.peek() {
297                        if ch.is_ascii_alphanumeric() || ch == '_' {
298                            param_name.push(chars.next().unwrap());
299                        } else {
300                            break;
301                        }
302                    }
303
304                    let idx = if let Some(&existing) = seen_params.get(&param_name) {
305                        existing
306                    } else {
307                        let idx = param_index;
308                        seen_params.insert(param_name.clone(), idx);
309                        named_params.push(param_name);
310                        param_index += 1;
311                        idx
312                    };
313
314                    result.push('$');
315                    result.push_str(&idx.to_string());
316                    continue;
317                }
318            }
319            result.push(c);
320        }
321
322        TranspileResult {
323            sql: result,
324            params: Vec::new(), // Positional params not used, named_params provides mapping
325            named_params,
326        }
327    }
328}