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 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}