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.placeholder();
91        self.params.push(val);
92        self
93    }
94
95    /// Write a parameter placeholder (`$N`, `?`, `%s`) without pushing a value.
96    /// Used for unbound params in executemany/batch operations.
97    pub fn placeholder(&mut self) -> &mut Self {
98        self.space_if_needed();
99        self.param_index += 1;
100        match self.param_style {
101            ParamStyle::Dollar => {
102                self.sql.push('$');
103                self.sql.push_str(&self.param_index.to_string());
104            }
105            ParamStyle::QMark => {
106                self.sql.push('?');
107            }
108            ParamStyle::Percent => {
109                self.sql.push_str("%s");
110            }
111        }
112        self
113    }
114
115    /// Write a string literal with proper escaping: `'value'`.
116    pub fn string_literal(&mut self, s: &str) -> &mut Self {
117        self.space_if_needed();
118        self.sql.push('\'');
119        self.sql.push_str(&s.replace('\'', "''"));
120        self.sql.push('\'');
121        self
122    }
123
124    /// Write an operator: `::`, `=`, `>`, `||`, etc.
125    pub fn operator(&mut self, op: &str) -> &mut Self {
126        self.sql.push_str(op);
127        self
128    }
129
130    /// Write opening parenthesis `(`, with space if needed.
131    pub fn paren_open(&mut self) -> &mut Self {
132        self.space_if_needed();
133        self.sql.push('(');
134        self
135    }
136
137    /// Write closing parenthesis `)`.
138    pub fn paren_close(&mut self) -> &mut Self {
139        self.sql.push(')');
140        self
141    }
142
143    /// Write a comma separator `, `.
144    pub fn comma(&mut self) -> &mut Self {
145        self.sql.push_str(", ");
146        self
147    }
148
149    /// Write an explicit space.
150    pub fn space(&mut self) -> &mut Self {
151        self.sql.push(' ');
152        self
153    }
154
155    /// Write arbitrary text (escape hatch, use sparingly).
156    pub fn write(&mut self, s: &str) -> &mut Self {
157        self.sql.push_str(s);
158        self
159    }
160
161    /// Write raw SQL with `%s` placeholders, replacing them with
162    /// dialect-appropriate parameter markers and storing the values.
163    ///
164    /// Escaping rules (Django-style):
165    /// - `%s`  → dialect placeholder (`$1`, `?`, `%s`) + stores value
166    /// - `%%`  → literal `%`
167    /// - `%%s` → literal `%s` (because `%%` becomes `%`, then `s` is just text)
168    pub fn raw_with_params(&mut self, sql: &str, params: &[Value]) -> &mut Self {
169        self.space_if_needed();
170
171        let bytes = sql.as_bytes();
172        let len = bytes.len();
173        let mut i = 0;
174        let mut param_idx = 0;
175
176        while i < len {
177            if bytes[i] == b'%' && i + 1 < len {
178                if bytes[i + 1] == b'%' {
179                    // %% → literal %
180                    self.sql.push('%');
181                    i += 2;
182                } else if bytes[i + 1] == b's' {
183                    // %s → placeholder
184                    if param_idx < params.len() {
185                        self.param_index += 1;
186                        match self.param_style {
187                            ParamStyle::Dollar => {
188                                self.sql.push('$');
189                                self.sql.push_str(&self.param_index.to_string());
190                            }
191                            ParamStyle::QMark => {
192                                self.sql.push('?');
193                            }
194                            ParamStyle::Percent => {
195                                self.sql.push_str("%s");
196                            }
197                        }
198                        self.params.push(params[param_idx].clone());
199                        param_idx += 1;
200                    }
201                    i += 2;
202                } else {
203                    self.sql.push('%');
204                    i += 1;
205                }
206            } else {
207                self.sql.push(bytes[i] as char);
208                i += 1;
209            }
210        }
211
212        self
213    }
214
215    // ── Internal helpers ──
216
217    /// Add a space if the buffer doesn't end with whitespace or `(`.
218    fn space_if_needed(&mut self) {
219        if let Some(last) = self.sql.as_bytes().last() {
220            if !matches!(last, b' ' | b'\n' | b'\t' | b'(' | b'.') {
221                self.sql.push(' ');
222            }
223        }
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn keyword_auto_spacing() {
233        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
234        ctx.keyword("SELECT").keyword("*").keyword("FROM");
235        assert_eq!(ctx.sql(), "SELECT * FROM");
236    }
237
238    #[test]
239    fn ident_quoted() {
240        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
241        ctx.keyword("SELECT").ident("user name");
242        assert_eq!(ctx.sql(), "SELECT \"user name\"");
243    }
244
245    #[test]
246    fn ident_escapes_quotes() {
247        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
248        ctx.ident("has\"quote");
249        assert_eq!(ctx.sql(), "\"has\"\"quote\"");
250    }
251
252    #[test]
253    fn param_dollar_style() {
254        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
255        ctx.keyword("WHERE")
256            .ident("age")
257            .operator(" > ")
258            .param(Value::Int(18));
259        let (sql, params) = ctx.finish();
260        assert_eq!(sql, "WHERE \"age\" > $1");
261        assert_eq!(params, vec![Value::Int(18)]);
262    }
263
264    #[test]
265    fn param_qmark_style() {
266        let mut ctx = RenderCtx::new(ParamStyle::QMark);
267        ctx.keyword("WHERE")
268            .ident("age")
269            .operator(" > ")
270            .param(Value::Int(18));
271        let (sql, params) = ctx.finish();
272        assert_eq!(sql, "WHERE \"age\" > ?");
273        assert_eq!(params, vec![Value::Int(18)]);
274    }
275
276    #[test]
277    fn multiple_params() {
278        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
279        ctx.param(Value::Int(1))
280            .comma()
281            .param(Value::Str("hello".into()));
282        let (sql, params) = ctx.finish();
283        assert_eq!(sql, "$1, $2");
284        assert_eq!(params, vec![Value::Int(1), Value::Str("hello".into())]);
285    }
286
287    #[test]
288    fn paren_no_extra_space() {
289        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
290        ctx.keyword("CAST")
291            .write("(")
292            .ident("x")
293            .keyword("AS")
294            .keyword("TEXT")
295            .paren_close();
296        assert_eq!(ctx.sql(), "CAST(\"x\" AS TEXT)");
297    }
298
299    #[test]
300    fn string_literal_escaping() {
301        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
302        ctx.string_literal("it's a test");
303        assert_eq!(ctx.sql(), "'it''s a test'");
304    }
305
306    #[test]
307    fn chaining() {
308        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
309        ctx.keyword("CREATE")
310            .keyword("TABLE")
311            .keyword("IF NOT EXISTS")
312            .ident("users")
313            .paren_open();
314        ctx.ident("id").keyword("BIGINT").keyword("NOT NULL");
315        ctx.paren_close();
316        assert_eq!(
317            ctx.sql(),
318            r#"CREATE TABLE IF NOT EXISTS "users" ("id" BIGINT NOT NULL)"#
319        );
320    }
321
322    #[test]
323    fn raw_with_params_dollar() {
324        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
325        ctx.raw_with_params(
326            "age > %s AND name = %s",
327            &[Value::Int(18), Value::Str("Alice".into())],
328        );
329        let (sql, params) = ctx.finish();
330        assert_eq!(sql, "age > $1 AND name = $2");
331        assert_eq!(params, vec![Value::Int(18), Value::Str("Alice".into())]);
332    }
333
334    #[test]
335    fn raw_with_params_qmark() {
336        let mut ctx = RenderCtx::new(ParamStyle::QMark);
337        ctx.raw_with_params(
338            "age > %s AND name = %s",
339            &[Value::Int(18), Value::Str("Alice".into())],
340        );
341        let (sql, params) = ctx.finish();
342        assert_eq!(sql, "age > ? AND name = ?");
343        assert_eq!(params, vec![Value::Int(18), Value::Str("Alice".into())]);
344    }
345
346    #[test]
347    fn raw_with_params_percent_escape() {
348        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
349        ctx.raw_with_params("val LIKE '%%foo%%'", &[]);
350        assert_eq!(ctx.sql(), "val LIKE '%foo%'");
351    }
352
353    #[test]
354    fn raw_with_params_double_percent_s() {
355        // %%s → literal %s (not a placeholder)
356        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
357        ctx.raw_with_params("format is %%s", &[]);
358        assert_eq!(ctx.sql(), "format is %s");
359    }
360
361    #[test]
362    fn raw_with_params_mixed() {
363        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
364        ctx.raw_with_params(
365            "x = %s AND fmt = '%%s' AND y = %s",
366            &[Value::Int(1), Value::Int(2)],
367        );
368        let (sql, params) = ctx.finish();
369        assert_eq!(sql, "x = $1 AND fmt = '%s' AND y = $2");
370        assert_eq!(params, vec![Value::Int(1), Value::Int(2)]);
371    }
372
373    #[test]
374    fn raw_with_params_continues_param_index() {
375        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
376        ctx.param(Value::Int(0)); // $1
377        ctx.raw_with_params("AND x = %s", &[Value::Int(1)]);
378        let (sql, params) = ctx.finish();
379        assert_eq!(sql, "$1 AND x = $2");
380        assert_eq!(params, vec![Value::Int(0), Value::Int(1)]);
381    }
382}