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.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 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 pub fn operator(&mut self, op: &str) -> &mut Self {
119 self.sql.push_str(op);
120 self
121 }
122
123 pub fn paren_open(&mut self) -> &mut Self {
125 self.space_if_needed();
126 self.sql.push('(');
127 self
128 }
129
130 pub fn paren_close(&mut self) -> &mut Self {
132 self.sql.push(')');
133 self
134 }
135
136 pub fn comma(&mut self) -> &mut Self {
138 self.sql.push_str(", ");
139 self
140 }
141
142 pub fn space(&mut self) -> &mut Self {
144 self.sql.push(' ');
145 self
146 }
147
148 pub fn write(&mut self, s: &str) -> &mut Self {
150 self.sql.push_str(s);
151 self
152 }
153
154 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 self.sql.push('%');
174 i += 2;
175 } else if bytes[i + 1] == b's' {
176 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 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 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)); 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}