Skip to main content

qail_core/transpiler/
traits.rs

1//! Transpiler traits and utilities.
2
3/// SQL reserved words that must be quoted when used as identifiers.
4pub const RESERVED_WORDS: &[&str] = &[
5    "order",
6    "group",
7    "user",
8    "table",
9    "select",
10    "from",
11    "where",
12    "join",
13    "left",
14    "right",
15    "inner",
16    "outer",
17    "on",
18    "and",
19    "or",
20    "not",
21    "null",
22    "true",
23    "false",
24    "limit",
25    "offset",
26    "as",
27    "in",
28    "is",
29    "like",
30    "between",
31    "having",
32    "union",
33    "all",
34    "distinct",
35    "case",
36    "when",
37    "then",
38    "else",
39    "end",
40    "create",
41    "alter",
42    "drop",
43    "insert",
44    "update",
45    "delete",
46    "index",
47    "key",
48    "primary",
49    "foreign",
50    "references",
51    "default",
52    "constraint",
53    "check",
54];
55
56/// Escape an identifier if it's a reserved word or contains special chars.
57/// Handles dotted identifiers (e.g., `table.column`) by quoting each part.
58pub fn escape_identifier(name: &str) -> String {
59    if name.contains('.') {
60        return name
61            .split('.')
62            .map(escape_single_identifier)
63            .collect::<Vec<_>>()
64            .join(".");
65    }
66    escape_single_identifier(name)
67}
68
69/// Escape a single identifier part (no dots).
70fn escape_single_identifier(name: &str) -> String {
71    let lower = name.to_lowercase();
72    let needs_escaping = RESERVED_WORDS.contains(&lower.as_str())
73        || name.chars().any(|c| !c.is_alphanumeric() && c != '_')
74        || name.chars().next().map(|c| c.is_numeric()).unwrap_or(false);
75
76    if needs_escaping {
77        format!("\"{}\"", name.replace('"', "\"\""))
78    } else {
79        name.to_string()
80    }
81}
82
83/// Escape content placed inside a SQL single-quoted string literal.
84///
85/// Interior NUL bytes are preserved so lower-level protocol encoders or C FFI
86/// output conversion reject invalid PostgreSQL text instead of silently
87/// executing a mutated statement.
88pub fn escape_sql_string_literal(value: &str) -> String {
89    value.replace('\'', "''")
90}
91
92fn is_sql_placeholder(value: &str) -> bool {
93    value == "?"
94        || value
95            .strip_prefix('$')
96            .is_some_and(|rest| !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()))
97        || value
98            .strip_prefix("@p")
99            .is_some_and(|rest| !rest.is_empty() && rest.chars().all(|c| c.is_ascii_digit()))
100        || value.strip_prefix(':').is_some_and(|rest| {
101            !rest.is_empty() && rest.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
102        })
103}
104
105fn sql_json_path_argument(path: &str) -> String {
106    if is_sql_placeholder(path) {
107        path.to_string()
108    } else {
109        format!("'{}'", escape_sql_string_literal(path))
110    }
111}
112
113/// Trait for dialect-specific SQL generation.
114pub trait SqlGenerator {
115    /// Quote an identifier (table or column name).
116    fn quote_identifier(&self, name: &str) -> String;
117    /// Generate the parameter placeholder (e.g., $1, ?, @p1) for a given index.
118    fn placeholder(&self, index: usize) -> String;
119    /// Get the fuzzy matching operator (ILIKE vs LIKE).
120    fn fuzzy_operator(&self) -> &str;
121    /// Get the boolean literal (true/false vs 1/0).
122    fn bool_literal(&self, val: bool) -> String;
123    /// Generate string concatenation expression (e.g. 'a' || 'b' vs CONCAT('a', 'b')).
124    fn string_concat(&self, parts: &[&str]) -> String;
125    /// Generate LIMIT/OFFSET clause.
126    fn limit_offset(&self, limit: Option<usize>, offset: Option<usize>) -> String;
127    /// Generate JSON access syntax.
128    /// path components are the keys to traverse.
129    /// Default implementation returns "col"."key1"."key2" (Standard SQL composite).
130    fn json_access(&self, col: &str, path: &[&str]) -> String {
131        let mut parts = vec![self.quote_identifier(col)];
132        for key in path {
133            parts.push(self.quote_identifier(key));
134        }
135        parts.join(".")
136    }
137    /// Generate JSON/Array contains expression.
138    /// Default implementation returns Postgres-compatible `col @> value`.
139    fn json_contains(&self, col: &str, value: &str) -> String {
140        format!("{} @> {}", col, value)
141    }
142    /// Generate JSON key exists expression.
143    /// Default implementation returns Postgres-compatible `col ? 'key'`.
144    fn json_key_exists(&self, col: &str, key: &str) -> String {
145        format!("{} ? {}", col, key)
146    }
147
148    /// JSON_EXISTS - check if path exists in JSON (Postgres 17+, SQL/JSON standard)
149    fn json_exists(&self, col: &str, path: &str) -> String {
150        format!("JSON_EXISTS({}, {})", col, sql_json_path_argument(path))
151    }
152
153    /// JSON_QUERY - extract JSON object/array at path (Postgres 17+, SQL/JSON standard)
154    fn json_query(&self, col: &str, path: &str) -> String {
155        format!("JSON_QUERY({}, {})", col, sql_json_path_argument(path))
156    }
157
158    /// JSON_VALUE - extract scalar value at path (Postgres 17+, SQL/JSON standard)
159    fn json_value(&self, col: &str, path: &str) -> String {
160        format!("JSON_VALUE({}, {})", col, sql_json_path_argument(path))
161    }
162
163    /// Generate IN array check (col IN value)
164    /// Default: Postgres-style `col = ANY(value)` for array params
165    fn in_array(&self, col: &str, value: &str) -> String {
166        format!("{} = ANY({})", col, value)
167    }
168
169    /// Generate NOT IN array check (col NOT IN value)
170    /// Default: Postgres-style `col != ALL(value)` for array params
171    fn not_in_array(&self, col: &str, value: &str) -> String {
172        format!("{} != ALL({})", col, value)
173    }
174}