Skip to main content

qcraft_core/render/
ctx.rs

1use crate::ast::value::Value;
2
3/// Style of parameter placeholders.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum ParamStyle {
6    /// PostgreSQL / asyncpg: `$1`, `$2`, `$3`
7    Dollar,
8    /// SQLite / MySQL: `?`
9    QMark,
10    /// psycopg / DB-API 2.0: `%s`
11    Percent,
12}
13
14/// Rendering context: accumulates SQL string and parameters.
15///
16/// Provides a semantic, chainable API for building SQL output.
17pub struct RenderCtx {
18    sql: String,
19    params: Vec<Value>,
20    param_style: ParamStyle,
21    param_index: usize,
22    parameterize: bool,
23}
24
25impl RenderCtx {
26    pub fn new(param_style: ParamStyle) -> Self {
27        Self {
28            sql: String::with_capacity(256),
29            params: Vec::new(),
30            param_style,
31            param_index: 0,
32            parameterize: false,
33        }
34    }
35
36    pub fn with_parameterize(mut self, parameterize: bool) -> Self {
37        self.parameterize = parameterize;
38        self
39    }
40
41    /// Whether values should be rendered as parameter placeholders.
42    pub fn parameterize(&self) -> bool {
43        self.parameterize
44    }
45
46    // ── Result ──
47
48    /// Consume the context and return the SQL string and parameters.
49    pub fn finish(self) -> (String, Vec<Value>) {
50        (self.sql, self.params)
51    }
52
53    /// Get the current SQL string (for inspection).
54    pub fn sql(&self) -> &str {
55        &self.sql
56    }
57
58    /// Get the current parameters (for inspection).
59    pub fn params(&self) -> &[Value] {
60        &self.params
61    }
62
63    // ── Semantic writing methods (chainable) ──
64
65    /// Write a SQL keyword. Adds a leading space if the buffer is non-empty
66    /// and doesn't end with `(` or whitespace.
67    pub fn keyword(&mut self, kw: &str) -> &mut Self {
68        self.space_if_needed();
69        self.sql.push_str(kw);
70        self
71    }
72
73    /// Write a quoted identifier: `"name"`.
74    pub fn ident(&mut self, name: &str) -> &mut Self {
75        self.space_if_needed();
76        self.sql.push('"');
77        // Escape double quotes within identifier
78        if name.contains('"') {
79            self.sql.push_str(&name.replace('"', "\"\""));
80        } else {
81            self.sql.push_str(name);
82        }
83        self.sql.push('"');
84        self
85    }
86
87    /// Write a parameterized value. Appends the placeholder ($1, ?, etc.)
88    /// and stores the value.
89    pub fn param(&mut self, val: Value) -> &mut Self {
90        self.space_if_needed();
91        self.param_index += 1;
92        match self.param_style {
93            ParamStyle::Dollar => {
94                self.sql.push('$');
95                self.sql.push_str(&self.param_index.to_string());
96            }
97            ParamStyle::QMark => {
98                self.sql.push('?');
99            }
100            ParamStyle::Percent => {
101                self.sql.push_str("%s");
102            }
103        }
104        self.params.push(val);
105        self
106    }
107
108    /// Write a string literal with proper escaping: `'value'`.
109    pub fn string_literal(&mut self, s: &str) -> &mut Self {
110        self.space_if_needed();
111        self.sql.push('\'');
112        self.sql.push_str(&s.replace('\'', "''"));
113        self.sql.push('\'');
114        self
115    }
116
117    /// Write an operator: `::`, `=`, `>`, `||`, etc.
118    pub fn operator(&mut self, op: &str) -> &mut Self {
119        self.sql.push_str(op);
120        self
121    }
122
123    /// Write opening parenthesis `(`, with space if needed.
124    pub fn paren_open(&mut self) -> &mut Self {
125        self.space_if_needed();
126        self.sql.push('(');
127        self
128    }
129
130    /// Write closing parenthesis `)`.
131    pub fn paren_close(&mut self) -> &mut Self {
132        self.sql.push(')');
133        self
134    }
135
136    /// Write a comma separator `, `.
137    pub fn comma(&mut self) -> &mut Self {
138        self.sql.push_str(", ");
139        self
140    }
141
142    /// Write an explicit space.
143    pub fn space(&mut self) -> &mut Self {
144        self.sql.push(' ');
145        self
146    }
147
148    /// Write arbitrary text (escape hatch, use sparingly).
149    pub fn write(&mut self, s: &str) -> &mut Self {
150        self.sql.push_str(s);
151        self
152    }
153
154    /// Write raw SQL with `%s` placeholders, replacing them with
155    /// dialect-appropriate parameter markers and storing the values.
156    ///
157    /// Escaping rules (Django-style):
158    /// - `%s`  → dialect placeholder (`$1`, `?`, `%s`) + stores value
159    /// - `%%`  → literal `%`
160    /// - `%%s` → literal `%s` (because `%%` becomes `%`, then `s` is just text)
161    pub fn raw_with_params(&mut self, sql: &str, params: &[Value]) -> &mut Self {
162        self.space_if_needed();
163
164        let bytes = sql.as_bytes();
165        let len = bytes.len();
166        let mut i = 0;
167        let mut param_idx = 0;
168
169        while i < len {
170            if bytes[i] == b'%' && i + 1 < len {
171                if bytes[i + 1] == b'%' {
172                    // %% → literal %
173                    self.sql.push('%');
174                    i += 2;
175                } else if bytes[i + 1] == b's' {
176                    // %s → placeholder
177                    if param_idx < params.len() {
178                        self.param_index += 1;
179                        match self.param_style {
180                            ParamStyle::Dollar => {
181                                self.sql.push('$');
182                                self.sql.push_str(&self.param_index.to_string());
183                            }
184                            ParamStyle::QMark => {
185                                self.sql.push('?');
186                            }
187                            ParamStyle::Percent => {
188                                self.sql.push_str("%s");
189                            }
190                        }
191                        self.params.push(params[param_idx].clone());
192                        param_idx += 1;
193                    }
194                    i += 2;
195                } else {
196                    self.sql.push('%');
197                    i += 1;
198                }
199            } else {
200                self.sql.push(bytes[i] as char);
201                i += 1;
202            }
203        }
204
205        self
206    }
207
208    // ── Internal helpers ──
209
210    /// Add a space if the buffer doesn't end with whitespace or `(`.
211    fn space_if_needed(&mut self) {
212        if let Some(last) = self.sql.as_bytes().last() {
213            if !matches!(last, b' ' | b'\n' | b'\t' | b'(' | b'.') {
214                self.sql.push(' ');
215            }
216        }
217    }
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn keyword_auto_spacing() {
226        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
227        ctx.keyword("SELECT").keyword("*").keyword("FROM");
228        assert_eq!(ctx.sql(), "SELECT * FROM");
229    }
230
231    #[test]
232    fn ident_quoted() {
233        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
234        ctx.keyword("SELECT").ident("user name");
235        assert_eq!(ctx.sql(), "SELECT \"user name\"");
236    }
237
238    #[test]
239    fn ident_escapes_quotes() {
240        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
241        ctx.ident("has\"quote");
242        assert_eq!(ctx.sql(), "\"has\"\"quote\"");
243    }
244
245    #[test]
246    fn param_dollar_style() {
247        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
248        ctx.keyword("WHERE")
249            .ident("age")
250            .operator(" > ")
251            .param(Value::Int(18));
252        let (sql, params) = ctx.finish();
253        assert_eq!(sql, "WHERE \"age\" > $1");
254        assert_eq!(params, vec![Value::Int(18)]);
255    }
256
257    #[test]
258    fn param_qmark_style() {
259        let mut ctx = RenderCtx::new(ParamStyle::QMark);
260        ctx.keyword("WHERE")
261            .ident("age")
262            .operator(" > ")
263            .param(Value::Int(18));
264        let (sql, params) = ctx.finish();
265        assert_eq!(sql, "WHERE \"age\" > ?");
266        assert_eq!(params, vec![Value::Int(18)]);
267    }
268
269    #[test]
270    fn multiple_params() {
271        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
272        ctx.param(Value::Int(1))
273            .comma()
274            .param(Value::Str("hello".into()));
275        let (sql, params) = ctx.finish();
276        assert_eq!(sql, "$1, $2");
277        assert_eq!(params, vec![Value::Int(1), Value::Str("hello".into())]);
278    }
279
280    #[test]
281    fn paren_no_extra_space() {
282        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
283        ctx.keyword("CAST")
284            .write("(")
285            .ident("x")
286            .keyword("AS")
287            .keyword("TEXT")
288            .paren_close();
289        assert_eq!(ctx.sql(), "CAST(\"x\" AS TEXT)");
290    }
291
292    #[test]
293    fn string_literal_escaping() {
294        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
295        ctx.string_literal("it's a test");
296        assert_eq!(ctx.sql(), "'it''s a test'");
297    }
298
299    #[test]
300    fn chaining() {
301        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
302        ctx.keyword("CREATE")
303            .keyword("TABLE")
304            .keyword("IF NOT EXISTS")
305            .ident("users")
306            .paren_open();
307        ctx.ident("id").keyword("BIGINT").keyword("NOT NULL");
308        ctx.paren_close();
309        assert_eq!(
310            ctx.sql(),
311            r#"CREATE TABLE IF NOT EXISTS "users" ("id" BIGINT NOT NULL)"#
312        );
313    }
314
315    #[test]
316    fn raw_with_params_dollar() {
317        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
318        ctx.raw_with_params(
319            "age > %s AND name = %s",
320            &[Value::Int(18), Value::Str("Alice".into())],
321        );
322        let (sql, params) = ctx.finish();
323        assert_eq!(sql, "age > $1 AND name = $2");
324        assert_eq!(params, vec![Value::Int(18), Value::Str("Alice".into())]);
325    }
326
327    #[test]
328    fn raw_with_params_qmark() {
329        let mut ctx = RenderCtx::new(ParamStyle::QMark);
330        ctx.raw_with_params(
331            "age > %s AND name = %s",
332            &[Value::Int(18), Value::Str("Alice".into())],
333        );
334        let (sql, params) = ctx.finish();
335        assert_eq!(sql, "age > ? AND name = ?");
336        assert_eq!(params, vec![Value::Int(18), Value::Str("Alice".into())]);
337    }
338
339    #[test]
340    fn raw_with_params_percent_escape() {
341        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
342        ctx.raw_with_params("val LIKE '%%foo%%'", &[]);
343        assert_eq!(ctx.sql(), "val LIKE '%foo%'");
344    }
345
346    #[test]
347    fn raw_with_params_double_percent_s() {
348        // %%s → literal %s (not a placeholder)
349        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
350        ctx.raw_with_params("format is %%s", &[]);
351        assert_eq!(ctx.sql(), "format is %s");
352    }
353
354    #[test]
355    fn raw_with_params_mixed() {
356        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
357        ctx.raw_with_params(
358            "x = %s AND fmt = '%%s' AND y = %s",
359            &[Value::Int(1), Value::Int(2)],
360        );
361        let (sql, params) = ctx.finish();
362        assert_eq!(sql, "x = $1 AND fmt = '%s' AND y = $2");
363        assert_eq!(params, vec![Value::Int(1), Value::Int(2)]);
364    }
365
366    #[test]
367    fn raw_with_params_continues_param_index() {
368        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
369        ctx.param(Value::Int(0)); // $1
370        ctx.raw_with_params("AND x = %s", &[Value::Int(1)]);
371        let (sql, params) = ctx.finish();
372        assert_eq!(sql, "$1 AND x = $2");
373        assert_eq!(params, vec![Value::Int(0), Value::Int(1)]);
374    }
375}