1pub mod conditions;
6pub mod ddl;
8pub mod dialect;
10pub mod dml;
12pub(crate) mod identifier;
13pub mod policy;
15pub mod sql;
17pub mod traits;
19
20pub 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#[derive(Debug, Clone, PartialEq, Default)]
35pub struct TranspileResult {
36 pub sql: String,
38 pub params: Vec<Value>,
40 pub named_params: Vec<String>,
42}
43
44impl TranspileResult {
45 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 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
64pub trait ToSqlParameterized {
66 fn to_sql_parameterized(&self) -> TranspileResult {
68 self.to_sql_parameterized_with_dialect(Dialect::default())
69 }
70 fn to_sql_parameterized_with_dialect(&self, dialect: Dialect) -> TranspileResult;
72}
73
74pub trait ToSql {
76 fn to_sql(&self) -> String {
78 self.to_sql_with_dialect(Dialect::default())
79 }
80 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 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 Action::TxnStart => "BEGIN TRANSACTION;".to_string(), 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 Action::JsonTable => dml::json_table::build_json_table(self, dialect),
124 Action::Export => dml::select::build_select(self, dialect),
126 Action::Truncate => format!("TRUNCATE TABLE {}", escape_identifier(&self.table)),
128 Action::Explain => format!("EXPLAIN {}", dml::select::build_select(self, dialect)),
130 Action::ExplainAnalyze => format!(
132 "EXPLAIN ANALYZE {}",
133 dml::select::build_select(self, dialect)
134 ),
135 Action::Lock => format!(
137 "LOCK TABLE {} IN ACCESS EXCLUSIVE MODE",
138 escape_identifier(&self.table)
139 ),
140 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 Action::RefreshMaterializedView => {
167 format!(
168 "REFRESH MATERIALIZED VIEW {}",
169 escape_identifier(&self.table)
170 )
171 }
172 Action::DropMaterializedView => {
174 format!(
175 "DROP MATERIALIZED VIEW IF EXISTS {}",
176 escape_identifier(&self.table)
177 )
178 }
179 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 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 Action::CreateView => {
233 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 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 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 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 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 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 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(), 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(¶m_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}