Skip to main content

qail_core/transpiler/
mod.rs

1//! SQL Transpiler for QAIL AST.
2//!
3
4/// Condition-to-SQL conversion.
5pub mod conditions;
6/// DDL statement transpilation (CREATE TABLE, ALTER TABLE, etc.).
7pub mod ddl;
8/// SQL dialect selection (PostgreSQL).
9pub mod dialect;
10/// DML statement transpilation (INSERT, UPDATE, DELETE).
11pub mod dml;
12pub(crate) mod identifier;
13/// RLS policy transpilation (CREATE POLICY).
14pub mod policy;
15/// Core SQL generation utilities.
16pub mod sql;
17/// Transpiler traits (SqlGenerator, escape_identifier).
18pub mod traits;
19
20/// Vector-search transpilers.
21pub mod nosql;
22pub use nosql::qdrant::ToQdrant;
23
24#[cfg(test)]
25mod tests;
26
27use crate::ast::*;
28pub use conditions::ConditionToSql;
29pub use dialect::Dialect;
30pub use traits::SqlGenerator;
31pub use traits::{escape_identifier, escape_sql_string_literal};
32
33/// Result of transpilation with extracted parameters.
34#[derive(Debug, Clone, PartialEq, Default)]
35pub struct TranspileResult {
36    /// The SQL template with placeholders (e.g., $1, $2 or ?, ?)
37    pub sql: String,
38    /// The extracted parameter values in order
39    pub params: Vec<Value>,
40    /// Names of named parameters in order they appear (for :name → $n mapping)
41    pub named_params: Vec<String>,
42}
43
44impl TranspileResult {
45    /// Create a new TranspileResult.
46    pub fn new(sql: impl Into<String>, params: Vec<Value>) -> Self {
47        Self {
48            sql: sql.into(),
49            params,
50            named_params: vec![],
51        }
52    }
53
54    /// Create a result with no parameters.
55    pub fn sql_only(sql: impl Into<String>) -> Self {
56        Self {
57            sql: sql.into(),
58            params: Vec::new(),
59            named_params: Vec::new(),
60        }
61    }
62}
63
64/// Trait for converting AST nodes to parameterized SQL.
65pub trait ToSqlParameterized {
66    /// Convert to SQL with extracted parameters (default dialect).
67    fn to_sql_parameterized(&self) -> TranspileResult {
68        self.to_sql_parameterized_with_dialect(Dialect::default())
69    }
70    /// Convert to SQL with extracted parameters for specific dialect.
71    fn to_sql_parameterized_with_dialect(&self, dialect: Dialect) -> TranspileResult;
72}
73
74/// Trait for converting AST nodes to SQL.
75pub trait ToSql {
76    /// Convert this node to a SQL string using default dialect.
77    fn to_sql(&self) -> String {
78        self.to_sql_with_dialect(Dialect::default())
79    }
80    /// Convert this node to a SQL string with specific dialect.
81    fn to_sql_with_dialect(&self, dialect: Dialect) -> String;
82}
83
84impl ToSql for Qail {
85    fn to_sql_with_dialect(&self, dialect: Dialect) -> String {
86        match self.action {
87            Action::Get => dml::select::build_select(self, dialect),
88            Action::Cnt => {
89                // Build a count query: SELECT COUNT(*) FROM table WHERE ...
90                let count_columns = [Expr::Aggregate {
91                    col: "*".to_string(),
92                    func: AggregateFunc::Count,
93                    distinct: false,
94                    filter: None,
95                    alias: None,
96                }];
97                dml::select::build_select_with_columns(self, dialect, &count_columns)
98            }
99            Action::Set => dml::update::build_update(self, dialect),
100            Action::Del => dml::delete::build_delete(self, dialect),
101            Action::Add => dml::insert::build_insert(self, dialect),
102            Action::Merge => dml::merge::build_merge(self, dialect),
103            Action::Gen => format!("-- gen::{}  (generates Rust struct, not SQL)", self.table),
104            Action::Make => ddl::build_create_table(self, dialect),
105            Action::Mod => ddl::build_alter_table(self, dialect),
106            Action::Over => dml::window::build_window(self, dialect),
107            Action::With => dml::cte::build_cte(self, dialect),
108            Action::Index => ddl::build_create_index(self, dialect),
109            Action::DropIndex => format!("DROP INDEX IF EXISTS {}", escape_identifier(&self.table)),
110            Action::Alter => ddl::build_alter_add_column(self, dialect),
111            Action::AlterAddConstraint => ddl::build_alter_add_check_constraint(self, dialect),
112            Action::AlterDropConstraint => ddl::build_alter_drop_constraint(self, dialect),
113            Action::AlterDrop => ddl::build_alter_drop_column(self, dialect),
114            Action::AlterType => ddl::build_alter_column_type(self, dialect),
115            // Stubs
116            Action::TxnStart => "BEGIN TRANSACTION;".to_string(), // Default stub
117            Action::TxnCommit => "COMMIT;".to_string(),
118            Action::TxnRollback => "ROLLBACK;".to_string(),
119            Action::Put => dml::upsert::build_upsert(self, dialect),
120            Action::Drop => format!("DROP TABLE {}", escape_identifier(&self.table)),
121            Action::DropCol | Action::RenameCol => ddl::build_alter_column(self, dialect),
122            // JSON features
123            Action::JsonTable => dml::json_table::build_json_table(self, dialect),
124            // COPY protocol (AST-native in qail-pg, generates SELECT for fallback)
125            Action::Export => dml::select::build_select(self, dialect),
126            // TRUNCATE TABLE
127            Action::Truncate => format!("TRUNCATE TABLE {}", escape_identifier(&self.table)),
128            // EXPLAIN - wrap SELECT query
129            Action::Explain => format!("EXPLAIN {}", dml::select::build_select(self, dialect)),
130            // EXPLAIN ANALYZE - execute and analyze query
131            Action::ExplainAnalyze => format!(
132                "EXPLAIN ANALYZE {}",
133                dml::select::build_select(self, dialect)
134            ),
135            // LOCK TABLE
136            Action::Lock => format!(
137                "LOCK TABLE {} IN ACCESS EXCLUSIVE MODE",
138                escape_identifier(&self.table)
139            ),
140            // CREATE MATERIALIZED VIEW - uses source_query for the view definition
141            Action::CreateMaterializedView => {
142                if let Some(source) = &self.source_query {
143                    format!(
144                        "CREATE MATERIALIZED VIEW {} AS {}",
145                        escape_identifier(&self.table),
146                        source.to_sql_with_dialect(dialect)
147                    )
148                } else if let Some(query) = &self.payload {
149                    match checked_sql_query_fragment(query, "materialized view query") {
150                        Ok(query) => format!(
151                            "CREATE MATERIALIZED VIEW {} AS {}",
152                            escape_identifier(&self.table),
153                            query
154                        ),
155                        Err(err) => err,
156                    }
157                } else {
158                    format!(
159                        "CREATE MATERIALIZED VIEW {} AS {}",
160                        escape_identifier(&self.table),
161                        dml::select::build_select(self, dialect)
162                    )
163                }
164            }
165            // REFRESH MATERIALIZED VIEW
166            Action::RefreshMaterializedView => {
167                format!(
168                    "REFRESH MATERIALIZED VIEW {}",
169                    escape_identifier(&self.table)
170                )
171            }
172            // DROP MATERIALIZED VIEW
173            Action::DropMaterializedView => {
174                format!(
175                    "DROP MATERIALIZED VIEW IF EXISTS {}",
176                    escape_identifier(&self.table)
177                )
178            }
179            // LISTEN/NOTIFY (Pub/Sub)
180            Action::Listen => {
181                if let Some(ch) = &self.channel {
182                    format!("LISTEN {}", quote_single_identifier(ch))
183                } else {
184                    "LISTEN".to_string()
185                }
186            }
187            Action::Notify => {
188                if let Some(ch) = &self.channel {
189                    if let Some(msg) = &self.payload {
190                        format!(
191                            "NOTIFY {}, '{}'",
192                            quote_single_identifier(ch),
193                            escape_sql_string_literal(msg)
194                        )
195                    } else {
196                        format!("NOTIFY {}", quote_single_identifier(ch))
197                    }
198                } else {
199                    "NOTIFY".to_string()
200                }
201            }
202            Action::Unlisten => {
203                if let Some(ch) = &self.channel {
204                    format!("UNLISTEN {}", quote_single_identifier(ch))
205                } else {
206                    "UNLISTEN *".to_string()
207                }
208            }
209            // Savepoints
210            Action::Savepoint => {
211                if let Some(name) = &self.savepoint_name {
212                    format!("SAVEPOINT {}", quote_single_identifier(name))
213                } else {
214                    "SAVEPOINT".to_string()
215                }
216            }
217            Action::ReleaseSavepoint => {
218                if let Some(name) = &self.savepoint_name {
219                    format!("RELEASE SAVEPOINT {}", quote_single_identifier(name))
220                } else {
221                    "RELEASE SAVEPOINT".to_string()
222                }
223            }
224            Action::RollbackToSavepoint => {
225                if let Some(name) = &self.savepoint_name {
226                    format!("ROLLBACK TO SAVEPOINT {}", quote_single_identifier(name))
227                } else {
228                    "ROLLBACK TO SAVEPOINT".to_string()
229                }
230            }
231            // Views
232            Action::CreateView => {
233                // A plain view runs against its base tables with the view
234                // OWNER's rights, so RLS on those tables is evaluated as the
235                // owner and effectively bypassed. `security_invoker` makes
236                // Postgres use the CALLER's rights instead (PG15+).
237                let opts = if self.view_security_invoker {
238                    " WITH (security_invoker = true)"
239                } else {
240                    ""
241                };
242                if let Some(source) = &self.source_query {
243                    format!(
244                        "CREATE VIEW {}{} AS {}",
245                        escape_identifier(&self.table),
246                        opts,
247                        source.to_sql_with_dialect(dialect)
248                    )
249                } else if let Some(query) = &self.payload {
250                    match checked_sql_query_fragment(query, "view query") {
251                        Ok(query) => {
252                            format!(
253                                "CREATE VIEW {}{} AS {}",
254                                escape_identifier(&self.table),
255                                opts,
256                                query
257                            )
258                        }
259                        Err(err) => err,
260                    }
261                } else {
262                    format!(
263                        "CREATE VIEW {}{} AS {}",
264                        escape_identifier(&self.table),
265                        opts,
266                        dml::select::build_select(self, dialect)
267                    )
268                }
269            }
270            Action::DropView => format!("DROP VIEW IF EXISTS {}", escape_identifier(&self.table)),
271            // Vector database operations - use qail-qdrant driver instead
272            operators::Action::Search | operators::Action::Upsert | operators::Action::Scroll => {
273                format!(
274                    "-- Vector operation {:?} not supported in SQL. Use qail-qdrant driver.",
275                    self.action
276                )
277            }
278            operators::Action::CreateCollection | operators::Action::DeleteCollection => {
279                format!(
280                    "-- Vector DDL {:?} not supported in SQL. Use qail-qdrant driver.",
281                    self.action
282                )
283            }
284            // Function and Trigger operations
285            operators::Action::CreateFunction => {
286                if let Some(func) = &self.function_def {
287                    let Some(args) = function_args_to_sql(&func.args) else {
288                        return "/* ERROR: Invalid function arguments */".to_string();
289                    };
290                    if !is_safe_sql_type_fragment(&func.returns) {
291                        return "/* ERROR: Invalid function return type */".to_string();
292                    }
293                    let lang = func.language.as_deref().unwrap_or("plpgsql");
294                    let volatility = if let Some(volatility) = func.volatility.as_deref() {
295                        if volatility.trim().is_empty() {
296                            String::new()
297                        } else if let Some(volatility) = volatility_to_sql(volatility) {
298                            format!(" {volatility}")
299                        } else {
300                            return "/* ERROR: Invalid function volatility */".to_string();
301                        }
302                    } else {
303                        String::new()
304                    };
305                    let body = dollar_quote_block(&func.body);
306                    format!(
307                        "CREATE OR REPLACE FUNCTION {}({}) RETURNS {} LANGUAGE {}{} AS {}",
308                        escape_identifier(&func.name),
309                        args,
310                        func.returns.trim(),
311                        escape_identifier(lang),
312                        volatility,
313                        body
314                    )
315                } else {
316                    "-- CreateFunction requires function_def".to_string()
317                }
318            }
319            operators::Action::DropFunction => {
320                if let Some(signature) = &self.payload {
321                    format!(
322                        "DROP FUNCTION IF EXISTS {}",
323                        function_signature_to_sql(signature)
324                    )
325                } else {
326                    format!(
327                        "DROP FUNCTION IF EXISTS {}()",
328                        escape_identifier(&self.table)
329                    )
330                }
331            }
332            operators::Action::CreateTrigger => {
333                if let Some(trig) = &self.trigger_def {
334                    let timing = match trig.timing {
335                        crate::ast::TriggerTiming::Before => "BEFORE",
336                        crate::ast::TriggerTiming::After => "AFTER",
337                        crate::ast::TriggerTiming::InsteadOf => "INSTEAD OF",
338                    };
339                    let events: Vec<String> = trig
340                        .events
341                        .iter()
342                        .map(|e| match e {
343                            crate::ast::TriggerEvent::Insert => "INSERT".to_string(),
344                            crate::ast::TriggerEvent::Update if !trig.update_columns.is_empty() => {
345                                format!(
346                                    "UPDATE OF {}",
347                                    trig.update_columns
348                                        .iter()
349                                        .map(|column| escape_identifier(column))
350                                        .collect::<Vec<_>>()
351                                        .join(", ")
352                                )
353                            }
354                            crate::ast::TriggerEvent::Update => "UPDATE".to_string(),
355                            crate::ast::TriggerEvent::Delete => "DELETE".to_string(),
356                            crate::ast::TriggerEvent::Truncate => "TRUNCATE".to_string(),
357                        })
358                        .collect();
359                    let for_each = if trig.for_each_row {
360                        "FOR EACH ROW"
361                    } else {
362                        "FOR EACH STATEMENT"
363                    };
364                    format!(
365                        "CREATE TRIGGER {} {} {} ON {} {} EXECUTE FUNCTION {}()",
366                        escape_identifier(&trig.name),
367                        timing,
368                        events.join(" OR "),
369                        escape_identifier(&trig.table),
370                        for_each,
371                        escape_identifier(&trig.execute_function)
372                    )
373                } else {
374                    "-- CreateTrigger requires trigger_def".to_string()
375                }
376            }
377            operators::Action::DropTrigger => {
378                if let Some((table, trigger)) = self.table.rsplit_once('.') {
379                    format!(
380                        "DROP TRIGGER IF EXISTS {} ON {}",
381                        escape_identifier(trigger),
382                        escape_identifier(table)
383                    )
384                } else {
385                    format!("DROP TRIGGER IF EXISTS {}", escape_identifier(&self.table))
386                }
387            }
388            // Phase 7: Extensions, Comments, Sequences
389            Action::CreateExtension => ddl::build_create_extension(self, dialect),
390            Action::DropExtension => ddl::build_drop_extension(self, dialect),
391            Action::CommentOn => ddl::build_comment_on(self, dialect),
392            Action::CreateSequence => ddl::build_create_sequence(self, dialect),
393            Action::DropSequence => ddl::build_drop_sequence(self, dialect),
394            Action::CreateEnum => ddl::build_create_enum(self, dialect),
395            Action::DropEnum => ddl::build_drop_enum(self, dialect),
396            Action::AlterEnumAddValue => ddl::build_alter_enum_add_value(self, dialect),
397            // ALTER TABLE property operations (from diff engine)
398            Action::AlterSetNotNull => {
399                let [Expr::Named(col)] = self.columns.as_slice() else {
400                    return "/* ERROR: ALTER SET NOT NULL requires exactly one named column */"
401                        .to_string();
402                };
403                if col.trim().is_empty() {
404                    return "/* ERROR: ALTER SET NOT NULL column cannot be empty */".to_string();
405                }
406                format!(
407                    "ALTER TABLE {} ALTER COLUMN {} SET NOT NULL",
408                    escape_identifier(&self.table),
409                    escape_identifier(col)
410                )
411            }
412            Action::AlterDropNotNull => {
413                let [Expr::Named(col)] = self.columns.as_slice() else {
414                    return "/* ERROR: ALTER DROP NOT NULL requires exactly one named column */"
415                        .to_string();
416                };
417                if col.trim().is_empty() {
418                    return "/* ERROR: ALTER DROP NOT NULL column cannot be empty */".to_string();
419                }
420                format!(
421                    "ALTER TABLE {} ALTER COLUMN {} DROP NOT NULL",
422                    escape_identifier(&self.table),
423                    escape_identifier(col)
424                )
425            }
426            Action::AlterSetDefault => {
427                let [Expr::Named(col)] = self.columns.as_slice() else {
428                    return "/* ERROR: ALTER SET DEFAULT requires exactly one named column */"
429                        .to_string();
430                };
431                if col.trim().is_empty() {
432                    return "/* ERROR: ALTER SET DEFAULT column cannot be empty */".to_string();
433                }
434                let Some(default_expr) = self.payload.as_deref() else {
435                    return "/* ERROR: ALTER SET DEFAULT requires a default expression */"
436                        .to_string();
437                };
438                if default_expr.trim().is_empty()
439                    || default_expr.contains('\0')
440                    || contains_unquoted_statement_delimiter(default_expr)
441                {
442                    return "/* ERROR: Invalid default expression */".to_string();
443                }
444                format!(
445                    "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {}",
446                    escape_identifier(&self.table),
447                    escape_identifier(col),
448                    default_expr.trim()
449                )
450            }
451            Action::AlterDropDefault => {
452                let [Expr::Named(col)] = self.columns.as_slice() else {
453                    return "/* ERROR: ALTER DROP DEFAULT requires exactly one named column */"
454                        .to_string();
455                };
456                if col.trim().is_empty() {
457                    return "/* ERROR: ALTER DROP DEFAULT column cannot be empty */".to_string();
458                }
459                format!(
460                    "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT",
461                    escape_identifier(&self.table),
462                    escape_identifier(col)
463                )
464            }
465            Action::AlterEnableRls => {
466                format!(
467                    "ALTER TABLE {} ENABLE ROW LEVEL SECURITY",
468                    escape_identifier(&self.table)
469                )
470            }
471            Action::AlterDisableRls => {
472                format!(
473                    "ALTER TABLE {} DISABLE ROW LEVEL SECURITY",
474                    escape_identifier(&self.table)
475                )
476            }
477            Action::AlterForceRls => {
478                format!(
479                    "ALTER TABLE {} FORCE ROW LEVEL SECURITY",
480                    escape_identifier(&self.table)
481                )
482            }
483            Action::AlterNoForceRls => {
484                format!(
485                    "ALTER TABLE {} NO FORCE ROW LEVEL SECURITY",
486                    escape_identifier(&self.table)
487                )
488            }
489            // Session & procedural commands
490            Action::Call => {
491                format!("CALL {}", call_target_to_sql(&self.table))
492            }
493            Action::Do => {
494                let body = self.payload.as_deref().unwrap_or("");
495                let lang = if self.table.is_empty() {
496                    "plpgsql"
497                } else {
498                    &self.table
499                };
500                format!(
501                    "DO {} LANGUAGE {}",
502                    dollar_quote_block(body),
503                    escape_identifier(lang)
504                )
505            }
506            Action::SessionSet => {
507                let value = self.payload.as_deref().unwrap_or("");
508                format!(
509                    "SET {} = '{}'",
510                    session_setting_name_to_sql(&self.table),
511                    escape_sql_string_literal(value)
512                )
513            }
514            Action::SessionShow => {
515                format!("SHOW {}", session_setting_name_to_sql(&self.table))
516            }
517            Action::SessionReset => {
518                format!("RESET {}", session_setting_name_to_sql(&self.table))
519            }
520            Action::CreateDatabase => {
521                format!("CREATE DATABASE {}", escape_identifier(&self.table))
522            }
523            Action::DropDatabase => {
524                format!("DROP DATABASE IF EXISTS {}", escape_identifier(&self.table))
525            }
526            Action::Grant => {
527                let role = self.payload.as_deref().unwrap_or("");
528                if let Some(privs) = privileges_to_sql(&self.columns) {
529                    format!(
530                        "GRANT {} ON {} TO {}",
531                        privs,
532                        escape_identifier(&self.table),
533                        escape_identifier(role)
534                    )
535                } else {
536                    "/* ERROR: Invalid privileges */".to_string()
537                }
538            }
539            Action::Revoke => {
540                let role = self.payload.as_deref().unwrap_or("");
541                if let Some(privs) = privileges_to_sql(&self.columns) {
542                    format!(
543                        "REVOKE {} ON {} FROM {}",
544                        privs,
545                        escape_identifier(&self.table),
546                        escape_identifier(role)
547                    )
548                } else {
549                    "/* ERROR: Invalid privileges */".to_string()
550                }
551            }
552            Action::CreatePolicy => {
553                if let Some(policy) = &self.policy_def {
554                    policy::create_policy_sql(policy)
555                } else {
556                    "-- CreatePolicy requires policy_def".to_string()
557                }
558            }
559            Action::DropPolicy => {
560                if let Some(policy) = &self.policy_def {
561                    policy::drop_policy_sql(&policy.name, &policy.table)
562                } else if let Some(policy_name) = &self.payload {
563                    policy::drop_policy_sql(policy_name, &self.table)
564                } else {
565                    "-- DropPolicy requires policy name + table".to_string()
566                }
567            }
568        }
569    }
570}
571
572fn session_setting_name_to_sql(name: &str) -> String {
573    if is_valid_session_setting_name(name) {
574        name.to_string()
575    } else {
576        escape_identifier(name)
577    }
578}
579
580fn quote_single_identifier(name: &str) -> String {
581    format!("\"{}\"", name.replace('"', "\"\""))
582}
583
584fn dollar_quote_block(body: &str) -> String {
585    for idx in 0..=body.len() {
586        let tag = if idx == 0 {
587            String::new()
588        } else {
589            format!("qail_body_{idx}")
590        };
591        let delimiter = format!("${tag}$");
592        if !body.contains(&delimiter) {
593            return format!("{delimiter} {body} {delimiter}");
594        }
595    }
596
597    format!("'{}'", escape_sql_string_literal(body))
598}
599
600fn call_target_to_sql(target: &str) -> String {
601    let target = target.trim().trim_end_matches(';').trim();
602    if target.is_empty()
603        || target.contains('\0')
604        || target.contains(';')
605        || target.contains("--")
606        || target.contains("/*")
607        || target.contains("*/")
608    {
609        return escape_identifier(target);
610    }
611
612    match target.split_once('(') {
613        Some((name, args)) if args.ends_with(')') && !args[..args.len() - 1].contains('(') => {
614            format!("{}({}", escape_identifier(name.trim()), args)
615        }
616        None => escape_identifier(target),
617        _ => escape_identifier(target),
618    }
619}
620
621fn contains_unquoted_statement_delimiter(value: &str) -> bool {
622    let bytes = value.as_bytes();
623    let mut i = 0;
624    let mut in_single = false;
625    let mut in_double = false;
626
627    while i < bytes.len() {
628        let b = bytes[i];
629        if b == 0 {
630            return true;
631        }
632
633        if in_single {
634            if b == b'\'' {
635                if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
636                    i += 2;
637                    continue;
638                }
639                in_single = false;
640            }
641            i += 1;
642            continue;
643        }
644
645        if in_double {
646            if b == b'"' {
647                if i + 1 < bytes.len() && bytes[i + 1] == b'"' {
648                    i += 2;
649                    continue;
650                }
651                in_double = false;
652            }
653            i += 1;
654            continue;
655        }
656
657        match b {
658            b'\'' => in_single = true,
659            b'"' => in_double = true,
660            b';' => return true,
661            b'-' if i + 1 < bytes.len() && bytes[i + 1] == b'-' => return true,
662            b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => return true,
663            _ => {}
664        }
665        i += 1;
666    }
667
668    false
669}
670
671fn checked_sql_query_fragment(query: &str, context: &str) -> Result<String, String> {
672    let query = query.trim();
673    if query.is_empty() || query.contains('\0') || contains_unquoted_statement_delimiter(query) {
674        return Err(format!("/* ERROR: Invalid {context} */"));
675    }
676    Ok(query.to_string())
677}
678
679fn privilege_to_sql(privilege: &str) -> Option<&'static str> {
680    match privilege.trim().to_ascii_uppercase().as_str() {
681        "SELECT" => Some("SELECT"),
682        "INSERT" => Some("INSERT"),
683        "UPDATE" => Some("UPDATE"),
684        "DELETE" => Some("DELETE"),
685        "TRUNCATE" => Some("TRUNCATE"),
686        "REFERENCES" => Some("REFERENCES"),
687        "TRIGGER" => Some("TRIGGER"),
688        "USAGE" => Some("USAGE"),
689        "CREATE" => Some("CREATE"),
690        "CONNECT" => Some("CONNECT"),
691        "TEMP" | "TEMPORARY" => Some("TEMPORARY"),
692        "EXECUTE" => Some("EXECUTE"),
693        "ALL" | "ALL PRIVILEGES" => Some("ALL PRIVILEGES"),
694        _ => None,
695    }
696}
697
698fn privileges_to_sql(columns: &[Expr]) -> Option<String> {
699    if columns.is_empty() {
700        None
701    } else {
702        let mut privileges = Vec::with_capacity(columns.len());
703        for column in columns {
704            let Expr::Named(privilege) = column else {
705                return None;
706            };
707            let sql = privilege_to_sql(privilege)?;
708            privileges.push(sql);
709        }
710        Some(privileges.join(", "))
711    }
712}
713
714fn is_safe_sql_type_fragment(fragment: &str) -> bool {
715    let fragment = fragment.trim();
716    !fragment.is_empty()
717        && !fragment.contains('\0')
718        && !fragment.contains(';')
719        && !fragment.contains('\'')
720        && !fragment.contains('"')
721        && !fragment.contains("--")
722        && !fragment.contains("/*")
723        && !fragment.contains("*/")
724        && fragment.bytes().all(|b| {
725            b.is_ascii_alphanumeric()
726                || matches!(
727                    b,
728                    b'_' | b'.' | b' ' | b'(' | b')' | b',' | b'[' | b']' | b'%' | b'+' | b'-'
729                )
730        })
731}
732
733fn volatility_to_sql(volatility: &str) -> Option<&'static str> {
734    match volatility.trim().to_ascii_uppercase().as_str() {
735        "VOLATILE" => Some("VOLATILE"),
736        "STABLE" => Some("STABLE"),
737        "IMMUTABLE" => Some("IMMUTABLE"),
738        _ => None,
739    }
740}
741
742fn function_arg_to_sql(arg: &str) -> Option<String> {
743    let arg = arg.trim();
744    if !is_safe_sql_type_fragment(arg) {
745        return None;
746    }
747
748    let mut parts = arg.split_whitespace().collect::<Vec<_>>();
749    if parts.is_empty() {
750        return None;
751    }
752    if parts.len() == 1 {
753        return Some(parts[0].to_string());
754    }
755
756    let mode = match parts[0].to_ascii_uppercase().as_str() {
757        "IN" | "OUT" | "INOUT" | "VARIADIC" => Some(parts.remove(0).to_ascii_uppercase()),
758        _ => None,
759    };
760    if parts.len() < 2 {
761        return None;
762    }
763
764    let name = escape_identifier(parts.remove(0));
765    let type_fragment = parts.join(" ");
766    if !is_safe_sql_type_fragment(&type_fragment) {
767        return None;
768    }
769
770    let mut rendered = String::new();
771    if let Some(mode) = mode {
772        rendered.push_str(&mode);
773        rendered.push(' ');
774    }
775    rendered.push_str(&name);
776    rendered.push(' ');
777    rendered.push_str(type_fragment.trim());
778    Some(rendered)
779}
780
781fn function_args_to_sql(args: &[String]) -> Option<String> {
782    let mut rendered = Vec::with_capacity(args.len());
783    for arg in args {
784        rendered.push(function_arg_to_sql(arg)?);
785    }
786    Some(rendered.join(", "))
787}
788
789fn split_top_level_args(args: &str) -> Option<Vec<&str>> {
790    let mut result = Vec::new();
791    let mut start = 0;
792    let mut depth = 0usize;
793    for (idx, ch) in args.char_indices() {
794        match ch {
795            '(' => depth += 1,
796            ')' => depth = depth.checked_sub(1)?,
797            ',' if depth == 0 => {
798                result.push(args[start..idx].trim());
799                start = idx + ch.len_utf8();
800            }
801            _ => {}
802        }
803    }
804    if depth != 0 {
805        return None;
806    }
807    let tail = args[start..].trim();
808    if !tail.is_empty() {
809        result.push(tail);
810    }
811    Some(result)
812}
813
814fn function_signature_to_sql(signature: &str) -> String {
815    let signature = signature.trim().trim_end_matches(';').trim();
816    if signature.is_empty()
817        || signature.contains('\0')
818        || signature.contains(';')
819        || signature.contains("--")
820        || signature.contains("/*")
821        || signature.contains("*/")
822    {
823        return escape_identifier(signature);
824    }
825
826    match signature.split_once('(') {
827        Some((name, args)) if args.ends_with(')') => {
828            let args = &args[..args.len() - 1];
829            let Some(parts) = split_top_level_args(args) else {
830                return escape_identifier(signature);
831            };
832            let mut rendered_args = Vec::new();
833            for part in parts {
834                if part.is_empty() {
835                    continue;
836                }
837                if !is_safe_sql_type_fragment(part) {
838                    return escape_identifier(signature);
839                }
840                rendered_args.push(part.trim().to_string());
841            }
842            format!(
843                "{}({})",
844                escape_identifier(name.trim()),
845                rendered_args.join(", ")
846            )
847        }
848        None => escape_identifier(signature),
849        _ => escape_identifier(signature),
850    }
851}
852
853fn is_valid_session_setting_name(name: &str) -> bool {
854    !name.is_empty()
855        && name.split('.').all(|part| {
856            let mut chars = part.chars();
857            matches!(chars.next(), Some(ch) if ch.is_ascii_alphabetic() || ch == '_')
858                && chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
859        })
860}
861
862impl ToSqlParameterized for Qail {
863    fn to_sql_parameterized_with_dialect(&self, dialect: Dialect) -> TranspileResult {
864        // Use the full ToSql implementation which handles CTEs, JOINs, etc.
865        // Then post-process to extract named parameters for binding
866        let full_sql = self.to_sql_with_dialect(dialect);
867        let (sql, named_params) = replace_named_params_outside_sql_literals(&full_sql);
868
869        TranspileResult {
870            sql,
871            params: Vec::new(), // Positional params not used, named_params provides mapping
872            named_params,
873        }
874    }
875}
876
877fn replace_named_params_outside_sql_literals(sql: &str) -> (String, Vec<String>) {
878    let mut named_params: Vec<String> = Vec::new();
879    let mut seen_params: std::collections::HashMap<String, usize> =
880        std::collections::HashMap::new();
881    let mut result = String::with_capacity(sql.len());
882    let mut param_index = 1;
883    let mut i = 0;
884    let mut state = SqlScanState::Normal;
885
886    while i < sql.len() {
887        match &state {
888            SqlScanState::Normal => {
889                if sql[i..].starts_with("--") {
890                    result.push_str("--");
891                    i += 2;
892                    state = SqlScanState::LineComment;
893                    continue;
894                }
895                if sql[i..].starts_with("/*") {
896                    result.push_str("/*");
897                    i += 2;
898                    state = SqlScanState::BlockComment;
899                    continue;
900                }
901                if sql[i..].starts_with("::") {
902                    result.push_str("::");
903                    i += 2;
904                    continue;
905                }
906                if let Some(delimiter) = sql_dollar_quote_delimiter_at(sql, i) {
907                    result.push_str(&delimiter);
908                    i += delimiter.len();
909                    state = SqlScanState::DollarQuoted(delimiter);
910                    continue;
911                }
912
913                let Some((ch, next_i)) = next_sql_char(sql, i) else {
914                    break;
915                };
916                match ch {
917                    '\'' => {
918                        result.push(ch);
919                        i = next_i;
920                        state = SqlScanState::SingleQuoted;
921                    }
922                    '"' => {
923                        result.push(ch);
924                        i = next_i;
925                        state = SqlScanState::DoubleQuoted;
926                    }
927                    ':' => {
928                        let Some((next, mut cursor)) = next_sql_char(sql, next_i) else {
929                            result.push(ch);
930                            i = next_i;
931                            continue;
932                        };
933                        if is_named_param_start(next) {
934                            let mut param_name = String::new();
935                            param_name.push(next);
936                            while let Some((candidate, candidate_next)) = next_sql_char(sql, cursor)
937                            {
938                                if is_named_param_continue(candidate) {
939                                    param_name.push(candidate);
940                                    cursor = candidate_next;
941                                } else {
942                                    break;
943                                }
944                            }
945
946                            let idx = if let Some(&existing) = seen_params.get(&param_name) {
947                                existing
948                            } else {
949                                let idx = param_index;
950                                seen_params.insert(param_name.clone(), idx);
951                                named_params.push(param_name);
952                                param_index += 1;
953                                idx
954                            };
955                            result.push('$');
956                            result.push_str(&idx.to_string());
957                            i = cursor;
958                        } else {
959                            result.push(ch);
960                            i = next_i;
961                        }
962                    }
963                    _ => {
964                        result.push(ch);
965                        i = next_i;
966                    }
967                }
968            }
969            SqlScanState::SingleQuoted => {
970                let Some((ch, next_i)) = next_sql_char(sql, i) else {
971                    break;
972                };
973                result.push(ch);
974                i = next_i;
975                if ch == '\'' {
976                    if sql[i..].starts_with('\'') {
977                        result.push('\'');
978                        i += 1;
979                    } else {
980                        state = SqlScanState::Normal;
981                    }
982                }
983            }
984            SqlScanState::DoubleQuoted => {
985                let Some((ch, next_i)) = next_sql_char(sql, i) else {
986                    break;
987                };
988                result.push(ch);
989                i = next_i;
990                if ch == '"' {
991                    if sql[i..].starts_with('"') {
992                        result.push('"');
993                        i += 1;
994                    } else {
995                        state = SqlScanState::Normal;
996                    }
997                }
998            }
999            SqlScanState::LineComment => {
1000                let Some((ch, next_i)) = next_sql_char(sql, i) else {
1001                    break;
1002                };
1003                result.push(ch);
1004                i = next_i;
1005                if ch == '\n' {
1006                    state = SqlScanState::Normal;
1007                }
1008            }
1009            SqlScanState::BlockComment => {
1010                if sql[i..].starts_with("*/") {
1011                    result.push_str("*/");
1012                    i += 2;
1013                    state = SqlScanState::Normal;
1014                    continue;
1015                }
1016                let Some((ch, next_i)) = next_sql_char(sql, i) else {
1017                    break;
1018                };
1019                result.push(ch);
1020                i = next_i;
1021            }
1022            SqlScanState::DollarQuoted(delimiter) => {
1023                if sql[i..].starts_with(delimiter) {
1024                    result.push_str(delimiter);
1025                    i += delimiter.len();
1026                    state = SqlScanState::Normal;
1027                    continue;
1028                }
1029                let Some((ch, next_i)) = next_sql_char(sql, i) else {
1030                    break;
1031                };
1032                result.push(ch);
1033                i = next_i;
1034            }
1035        }
1036    }
1037
1038    (result, named_params)
1039}
1040
1041#[derive(Debug, Clone, PartialEq, Eq)]
1042enum SqlScanState {
1043    Normal,
1044    SingleQuoted,
1045    DoubleQuoted,
1046    LineComment,
1047    BlockComment,
1048    DollarQuoted(String),
1049}
1050
1051fn next_sql_char(sql: &str, idx: usize) -> Option<(char, usize)> {
1052    let ch = sql.get(idx..)?.chars().next()?;
1053    Some((ch, idx + ch.len_utf8()))
1054}
1055
1056fn is_named_param_start(ch: char) -> bool {
1057    ch.is_ascii_alphabetic() || ch == '_'
1058}
1059
1060fn is_named_param_continue(ch: char) -> bool {
1061    ch.is_ascii_alphanumeric() || ch == '_'
1062}
1063
1064fn sql_dollar_quote_delimiter_at(sql: &str, idx: usize) -> Option<String> {
1065    if !sql.get(idx..)?.starts_with('$') {
1066        return None;
1067    }
1068    let rest = sql.get(idx + 1..)?;
1069    for (offset, ch) in rest.char_indices() {
1070        if ch == '$' {
1071            let tag = &rest[..offset];
1072            if tag.is_empty()
1073                || (is_named_param_start(tag.chars().next()?)
1074                    && tag.chars().all(is_named_param_continue))
1075            {
1076                return Some(sql[idx..idx + offset + 2].to_string());
1077            }
1078            return None;
1079        }
1080        if !is_named_param_continue(ch) {
1081            return None;
1082        }
1083    }
1084    None
1085}