qcraft_core/render/
ctx.rs1use crate::ast::value::Value;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum ParamStyle {
6 Dollar,
8 QMark,
10 Percent,
12}
13
14pub 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 pub fn parameterize(&self) -> bool {
43 self.parameterize
44 }
45
46 pub fn finish(self) -> (String, Vec<Value>) {
50 (self.sql, self.params)
51 }
52
53 pub fn sql(&self) -> &str {
55 &self.sql
56 }
57
58 pub fn params(&self) -> &[Value] {
60 &self.params
61 }
62
63 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 pub fn ident(&mut self, name: &str) -> &mut Self {
75 self.space_if_needed();
76 self.sql.push('"');
77 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 pub fn param(&mut self, val: Value) -> &mut Self {
90 self.placeholder();
91 self.params.push(val);
92 self
93 }
94
95 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 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 pub fn operator(&mut self, op: &str) -> &mut Self {
126 self.sql.push_str(op);
127 self
128 }
129
130 pub fn paren_open(&mut self) -> &mut Self {
132 self.space_if_needed();
133 self.sql.push('(');
134 self
135 }
136
137 pub fn paren_close(&mut self) -> &mut Self {
139 self.sql.push(')');
140 self
141 }
142
143 pub fn comma(&mut self) -> &mut Self {
145 self.sql.push_str(", ");
146 self
147 }
148
149 pub fn space(&mut self) -> &mut Self {
151 self.sql.push(' ');
152 self
153 }
154
155 pub fn write(&mut self, s: &str) -> &mut Self {
157 self.sql.push_str(s);
158 self
159 }
160
161 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 self.sql.push('%');
181 i += 2;
182 } else if bytes[i + 1] == b's' {
183 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 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 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)); 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}