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    // ── Internal helpers ──
155
156    /// Add a space if the buffer doesn't end with whitespace or `(`.
157    fn space_if_needed(&mut self) {
158        if let Some(last) = self.sql.as_bytes().last() {
159            if !matches!(last, b' ' | b'\n' | b'\t' | b'(' | b'.') {
160                self.sql.push(' ');
161            }
162        }
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169
170    #[test]
171    fn keyword_auto_spacing() {
172        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
173        ctx.keyword("SELECT").keyword("*").keyword("FROM");
174        assert_eq!(ctx.sql(), "SELECT * FROM");
175    }
176
177    #[test]
178    fn ident_quoted() {
179        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
180        ctx.keyword("SELECT").ident("user name");
181        assert_eq!(ctx.sql(), "SELECT \"user name\"");
182    }
183
184    #[test]
185    fn ident_escapes_quotes() {
186        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
187        ctx.ident("has\"quote");
188        assert_eq!(ctx.sql(), "\"has\"\"quote\"");
189    }
190
191    #[test]
192    fn param_dollar_style() {
193        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
194        ctx.keyword("WHERE")
195            .ident("age")
196            .operator(" > ")
197            .param(Value::Int(18));
198        let (sql, params) = ctx.finish();
199        assert_eq!(sql, "WHERE \"age\" > $1");
200        assert_eq!(params, vec![Value::Int(18)]);
201    }
202
203    #[test]
204    fn param_qmark_style() {
205        let mut ctx = RenderCtx::new(ParamStyle::QMark);
206        ctx.keyword("WHERE")
207            .ident("age")
208            .operator(" > ")
209            .param(Value::Int(18));
210        let (sql, params) = ctx.finish();
211        assert_eq!(sql, "WHERE \"age\" > ?");
212        assert_eq!(params, vec![Value::Int(18)]);
213    }
214
215    #[test]
216    fn multiple_params() {
217        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
218        ctx.param(Value::Int(1))
219            .comma()
220            .param(Value::Str("hello".into()));
221        let (sql, params) = ctx.finish();
222        assert_eq!(sql, "$1, $2");
223        assert_eq!(params, vec![Value::Int(1), Value::Str("hello".into())]);
224    }
225
226    #[test]
227    fn paren_no_extra_space() {
228        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
229        ctx.keyword("CAST")
230            .paren_open()
231            .ident("x")
232            .keyword("AS")
233            .keyword("TEXT")
234            .paren_close();
235        assert_eq!(ctx.sql(), "CAST (\"x\" AS TEXT)");
236    }
237
238    #[test]
239    fn string_literal_escaping() {
240        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
241        ctx.string_literal("it's a test");
242        assert_eq!(ctx.sql(), "'it''s a test'");
243    }
244
245    #[test]
246    fn chaining() {
247        let mut ctx = RenderCtx::new(ParamStyle::Dollar);
248        ctx.keyword("CREATE")
249            .keyword("TABLE")
250            .keyword("IF NOT EXISTS")
251            .ident("users")
252            .paren_open();
253        ctx.ident("id").keyword("BIGINT").keyword("NOT NULL");
254        ctx.paren_close();
255        assert_eq!(
256            ctx.sql(),
257            r#"CREATE TABLE IF NOT EXISTS "users" ("id" BIGINT NOT NULL)"#
258        );
259    }
260}