dbrest_core/query/sql_builder.rs
1//! Core SQL builder for constructing parameterized queries.
2//!
3//! Sits at the foundation of the query module. Every other module in `query/`
4//! uses `SqlBuilder` to accumulate SQL text and bind parameters.
5//!
6//! # Design
7//!
8//! `SqlBuilder` wraps a `String` buffer and a `Vec<SqlParam>`. As SQL fragments
9//! are appended, user-supplied values are stored in the param list while the
10//! buffer receives `$1`, `$2`, … placeholders. The final `build()` call returns
11//! the completed SQL string paired with the ordered parameter vector, ready for
12//! `sqlx::query_with`.
13//!
14//! # SQL Example
15//!
16//! ```sql
17//! -- After pushing: push("SELECT "), push_ident("name"), push(" FROM "),
18//! -- push_qi(qi), push(" WHERE "), push_ident("age"), push(" >= "),
19//! -- push_param(Text("18"))
20//! SELECT "name" FROM "public"."users" WHERE "age" >= $1
21//! ```
22
23use bytes::Bytes;
24
25use crate::types::identifiers::QualifiedIdentifier;
26
27// ==========================================================================
28// SqlParam — typed bind parameter
29// ==========================================================================
30
31/// A bind parameter for a parameterized SQL query.
32///
33/// Created by `SqlBuilder::push_param` and consumed by the database executor.
34/// Each variant maps to a different sqlx encode path.
35#[derive(Debug, Clone)]
36pub enum SqlParam {
37 /// A text value (`TEXT` / `VARCHAR`).
38 Text(String),
39 /// A JSON value (`JSONB` / `JSON`). The bytes contain valid JSON.
40 Json(Bytes),
41 /// Raw binary data (`BYTEA`).
42 Binary(Bytes),
43 /// An explicit SQL NULL.
44 Null,
45}
46
47// ==========================================================================
48// SqlBuilder — accumulator for SQL text + bind parameters
49// ==========================================================================
50
51/// Accumulates SQL text and bind parameters into a parameterized query.
52///
53/// Constructed via `SqlBuilder::new()`, populated with `push*` methods, and
54/// finalised with `build()` which returns `(String, Vec<SqlParam>)`.
55///
56/// # Invariants
57///
58/// - `push_param` always appends `$N` where N = params.len() after the push.
59/// - Identifiers are always double-quote escaped via `push_ident` / `push_qi`.
60/// - Literals are single-quote escaped via `push_literal`.
61#[derive(Debug, Clone)]
62pub struct SqlBuilder {
63 /// The SQL text buffer.
64 buffer: String,
65 /// Ordered bind parameters referenced by `$N` placeholders.
66 params: Vec<SqlParam>,
67}
68
69impl SqlBuilder {
70 /// Create an empty builder.
71 pub fn new() -> Self {
72 Self {
73 buffer: String::with_capacity(256),
74 params: Vec::new(),
75 }
76 }
77
78 /// Create a builder pre-loaded with the given SQL text.
79 pub fn with_sql(sql: impl Into<String>) -> Self {
80 Self {
81 buffer: sql.into(),
82 params: Vec::new(),
83 }
84 }
85
86 // ------------------------------------------------------------------
87 // Push methods
88 // ------------------------------------------------------------------
89
90 /// Append raw SQL text (no escaping).
91 pub fn push(&mut self, s: &str) {
92 self.buffer.push_str(s);
93 }
94
95 /// Append a single character.
96 pub fn push_char(&mut self, c: char) {
97 self.buffer.push(c);
98 }
99
100 /// Append a double-quoted SQL identifier.
101 ///
102 /// Internal double-quotes are doubled per the SQL standard.
103 ///
104 /// # SQL Example
105 /// ```sql
106 /// -- push_ident("user\"name") produces:
107 /// "user""name"
108 /// ```
109 pub fn push_ident(&mut self, ident: &str) {
110 self.buffer.push('"');
111 for ch in ident.chars() {
112 if ch == '"' {
113 self.buffer.push('"');
114 }
115 self.buffer.push(ch);
116 }
117 self.buffer.push('"');
118 }
119
120 /// Append a schema-qualified identifier (`"schema"."name"`).
121 ///
122 /// If the schema is empty, only the name is emitted.
123 ///
124 /// # SQL Example
125 /// ```sql
126 /// -- push_qi(QI { schema: "public", name: "users" }) produces:
127 /// "public"."users"
128 /// ```
129 pub fn push_qi(&mut self, qi: &QualifiedIdentifier) {
130 if !qi.schema.is_empty() {
131 self.push_ident(&qi.schema);
132 self.buffer.push('.');
133 }
134 self.push_ident(&qi.name);
135 }
136
137 /// Append a single-quoted SQL literal.
138 ///
139 /// Single-quotes are doubled. If the value contains a backslash, the
140 /// PostgreSQL E-string syntax (`E'...'`) is used so that `\\` is treated
141 /// as a literal backslash regardless of `standard_conforming_strings`.
142 ///
143 /// # SQL Example
144 /// ```sql
145 /// -- push_literal("it's") produces:
146 /// 'it''s'
147 /// -- push_literal("back\\slash") produces:
148 /// E'back\\slash'
149 /// ```
150 pub fn push_literal(&mut self, s: &str) {
151 let has_backslash = s.contains('\\');
152 if has_backslash {
153 self.buffer.push('E');
154 }
155 self.buffer.push('\'');
156 for ch in s.chars() {
157 if ch == '\'' {
158 self.buffer.push('\'');
159 }
160 self.buffer.push(ch);
161 }
162 self.buffer.push('\'');
163 }
164
165 /// Append a bind-parameter placeholder (`$N`) and store the value.
166 ///
167 /// The placeholder index is `self.params.len() + 1` (1-based).
168 pub fn push_param(&mut self, param: SqlParam) {
169 self.params.push(param);
170 self.buffer.push_str(&format!("${}", self.params.len()));
171 }
172
173 /// Current number of bind parameters.
174 pub fn param_count(&self) -> usize {
175 self.params.len()
176 }
177
178 /// Whether the builder is empty (no SQL text).
179 pub fn is_empty(&self) -> bool {
180 self.buffer.is_empty()
181 }
182
183 /// Current length of the SQL text buffer.
184 pub fn sql_len(&self) -> usize {
185 self.buffer.len()
186 }
187
188 // ------------------------------------------------------------------
189 // Compound helpers
190 // ------------------------------------------------------------------
191
192 /// Append `items` separated by `sep`, where each item is written by
193 /// the callback `f`.
194 ///
195 /// # Behaviour
196 ///
197 /// Does nothing if `items` is empty. Does not emit a trailing separator.
198 pub fn push_separated<T, F>(&mut self, sep: &str, items: &[T], f: F)
199 where
200 F: Fn(&mut SqlBuilder, &T),
201 {
202 for (i, item) in items.iter().enumerate() {
203 if i > 0 {
204 self.buffer.push_str(sep);
205 }
206 f(self, item);
207 }
208 }
209
210 /// Merge another builder's SQL and params into this one.
211 ///
212 /// The merged builder's `$N` placeholders are rewritten to continue
213 /// from this builder's current param count.
214 pub fn push_builder(&mut self, other: &SqlBuilder) {
215 let offset = self.params.len();
216 if offset == 0 {
217 // No rewriting needed
218 self.buffer.push_str(&other.buffer);
219 } else {
220 // Rewrite $N placeholders
221 let mut rest = other.buffer.as_str();
222 while let Some(idx) = rest.find('$') {
223 self.buffer.push_str(&rest[..idx]);
224 rest = &rest[idx + 1..];
225 // Parse the number after $
226 let num_end = rest
227 .find(|c: char| !c.is_ascii_digit())
228 .unwrap_or(rest.len());
229 if num_end > 0 {
230 let n: usize = rest[..num_end].parse().unwrap_or(0);
231 self.buffer.push_str(&format!("${}", n + offset));
232 rest = &rest[num_end..];
233 } else {
234 self.buffer.push('$');
235 }
236 }
237 self.buffer.push_str(rest);
238 }
239 self.params.extend(other.params.iter().cloned());
240 }
241
242 // ------------------------------------------------------------------
243 // Finalisation
244 // ------------------------------------------------------------------
245
246 /// Consume the builder and return the SQL string and parameters.
247 pub fn build(self) -> (String, Vec<SqlParam>) {
248 (self.buffer, self.params)
249 }
250
251 /// Borrow the current SQL text (for debugging / assertions).
252 pub fn sql(&self) -> &str {
253 &self.buffer
254 }
255
256 /// Borrow the current parameters (for debugging / assertions).
257 pub fn params(&self) -> &[SqlParam] {
258 &self.params
259 }
260}
261
262impl Default for SqlBuilder {
263 fn default() -> Self {
264 Self::new()
265 }
266}
267
268// ==========================================================================
269// Standalone escape functions
270// ==========================================================================
271
272/// Escape a SQL identifier by doubling internal double-quotes.
273///
274/// Does **not** wrap in quotes — use `quote_ident` for that.
275///
276/// # Example
277/// ```
278/// assert_eq!(dbrest::query::sql_builder::escape_ident("col"), "col");
279/// assert_eq!(dbrest::query::sql_builder::escape_ident("a\"b"), "a\"\"b");
280/// ```
281pub fn escape_ident(s: &str) -> String {
282 s.replace('"', "\"\"")
283}
284
285/// Double-quote a SQL identifier.
286///
287/// # Example
288/// ```
289/// assert_eq!(dbrest::query::sql_builder::quote_ident("col"), "\"col\"");
290/// ```
291pub fn quote_ident(s: &str) -> String {
292 format!("\"{}\"", escape_ident(s))
293}
294
295/// Escape a SQL literal by doubling single-quotes.
296///
297/// Returns the escaped content **without** surrounding quotes.
298pub fn escape_literal(s: &str) -> String {
299 s.replace('\'', "''")
300}
301
302/// Single-quote a SQL literal (with E-string for backslashes).
303///
304/// # Example
305/// ```
306/// assert_eq!(dbrest::query::sql_builder::quote_literal("it's"), "'it''s'");
307/// assert_eq!(dbrest::query::sql_builder::quote_literal("a\\b"), "E'a\\b'");
308/// ```
309pub fn quote_literal(s: &str) -> String {
310 let escaped = escape_literal(s);
311 if s.contains('\\') {
312 format!("E'{}'", escaped)
313 } else {
314 format!("'{}'", escaped)
315 }
316}
317
318// ==========================================================================
319// Tests
320// ==========================================================================
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325
326 // ------------------------------------------------------------------
327 // SqlBuilder basic push
328 // ------------------------------------------------------------------
329
330 #[test]
331 fn test_push_raw_sql() {
332 let mut b = SqlBuilder::new();
333 b.push("SELECT 1");
334 assert_eq!(b.sql(), "SELECT 1");
335 }
336
337 #[test]
338 fn test_push_char() {
339 let mut b = SqlBuilder::new();
340 b.push("a");
341 b.push_char('b');
342 assert_eq!(b.sql(), "ab");
343 }
344
345 #[test]
346 fn test_push_ident_simple() {
347 let mut b = SqlBuilder::new();
348 b.push_ident("users");
349 assert_eq!(b.sql(), "\"users\"");
350 }
351
352 #[test]
353 fn test_push_ident_with_quote() {
354 let mut b = SqlBuilder::new();
355 b.push_ident("user\"name");
356 assert_eq!(b.sql(), "\"user\"\"name\"");
357 }
358
359 #[test]
360 fn test_push_qi_qualified() {
361 let mut b = SqlBuilder::new();
362 let qi = QualifiedIdentifier::new("public", "users");
363 b.push_qi(&qi);
364 assert_eq!(b.sql(), "\"public\".\"users\"");
365 }
366
367 #[test]
368 fn test_push_qi_unqualified() {
369 let mut b = SqlBuilder::new();
370 let qi = QualifiedIdentifier::unqualified("users");
371 b.push_qi(&qi);
372 assert_eq!(b.sql(), "\"users\"");
373 }
374
375 #[test]
376 fn test_push_literal_simple() {
377 let mut b = SqlBuilder::new();
378 b.push_literal("hello");
379 assert_eq!(b.sql(), "'hello'");
380 }
381
382 #[test]
383 fn test_push_literal_with_quote() {
384 let mut b = SqlBuilder::new();
385 b.push_literal("it's");
386 assert_eq!(b.sql(), "'it''s'");
387 }
388
389 #[test]
390 fn test_push_literal_with_backslash() {
391 let mut b = SqlBuilder::new();
392 b.push_literal("back\\slash");
393 assert_eq!(b.sql(), "E'back\\slash'");
394 }
395
396 // ------------------------------------------------------------------
397 // Bind parameters
398 // ------------------------------------------------------------------
399
400 #[test]
401 fn test_push_param() {
402 let mut b = SqlBuilder::new();
403 b.push("SELECT * WHERE id = ");
404 b.push_param(SqlParam::Text("42".to_string()));
405 assert_eq!(b.sql(), "SELECT * WHERE id = $1");
406 assert_eq!(b.param_count(), 1);
407 }
408
409 #[test]
410 fn test_push_multiple_params() {
411 let mut b = SqlBuilder::new();
412 b.push_param(SqlParam::Text("a".into()));
413 b.push(", ");
414 b.push_param(SqlParam::Text("b".into()));
415 b.push(", ");
416 b.push_param(SqlParam::Null);
417 assert_eq!(b.sql(), "$1, $2, $3");
418 assert_eq!(b.param_count(), 3);
419 }
420
421 // ------------------------------------------------------------------
422 // push_separated
423 // ------------------------------------------------------------------
424
425 #[test]
426 fn test_push_separated_empty() {
427 let mut b = SqlBuilder::new();
428 let items: Vec<String> = vec![];
429 b.push_separated(", ", &items, |b, item| b.push(item));
430 assert_eq!(b.sql(), "");
431 }
432
433 #[test]
434 fn test_push_separated_one() {
435 let mut b = SqlBuilder::new();
436 let items = vec!["a".to_string()];
437 b.push_separated(", ", &items, |b, item| b.push(item));
438 assert_eq!(b.sql(), "a");
439 }
440
441 #[test]
442 fn test_push_separated_many() {
443 let mut b = SqlBuilder::new();
444 let items = vec!["a".to_string(), "b".to_string(), "c".to_string()];
445 b.push_separated(", ", &items, |b, item| b.push_ident(item));
446 assert_eq!(b.sql(), "\"a\", \"b\", \"c\"");
447 }
448
449 // ------------------------------------------------------------------
450 // push_builder (merge)
451 // ------------------------------------------------------------------
452
453 #[test]
454 fn test_push_builder_no_offset() {
455 let mut a = SqlBuilder::new();
456 a.push("A ");
457
458 let mut b = SqlBuilder::new();
459 b.push("B ");
460 b.push_param(SqlParam::Text("x".into()));
461
462 a.push_builder(&b);
463 assert_eq!(a.sql(), "A B $1");
464 assert_eq!(a.param_count(), 1);
465 }
466
467 #[test]
468 fn test_push_builder_with_offset() {
469 let mut a = SqlBuilder::new();
470 a.push_param(SqlParam::Text("p1".into()));
471 a.push(" AND ");
472
473 let mut b = SqlBuilder::new();
474 b.push_param(SqlParam::Text("p2".into()));
475 b.push(" OR ");
476 b.push_param(SqlParam::Text("p3".into()));
477
478 a.push_builder(&b);
479 assert_eq!(a.sql(), "$1 AND $2 OR $3");
480 assert_eq!(a.param_count(), 3);
481 }
482
483 // ------------------------------------------------------------------
484 // build
485 // ------------------------------------------------------------------
486
487 #[test]
488 fn test_build() {
489 let mut b = SqlBuilder::new();
490 b.push("SELECT ");
491 b.push_ident("name");
492 b.push(" FROM ");
493 b.push_qi(&QualifiedIdentifier::new("public", "users"));
494 b.push(" WHERE ");
495 b.push_ident("age");
496 b.push(" >= ");
497 b.push_param(SqlParam::Text("18".into()));
498
499 let (sql, params) = b.build();
500 assert_eq!(
501 sql,
502 "SELECT \"name\" FROM \"public\".\"users\" WHERE \"age\" >= $1"
503 );
504 assert_eq!(params.len(), 1);
505 }
506
507 // ------------------------------------------------------------------
508 // Standalone escape / quote functions
509 // ------------------------------------------------------------------
510
511 #[test]
512 fn test_escape_ident() {
513 assert_eq!(escape_ident("simple"), "simple");
514 assert_eq!(escape_ident("with\"quote"), "with\"\"quote");
515 }
516
517 #[test]
518 fn test_quote_ident_fn() {
519 assert_eq!(quote_ident("col"), "\"col\"");
520 assert_eq!(quote_ident("a\"b"), "\"a\"\"b\"");
521 }
522
523 #[test]
524 fn test_escape_literal() {
525 assert_eq!(escape_literal("hello"), "hello");
526 assert_eq!(escape_literal("it's"), "it''s");
527 }
528
529 #[test]
530 fn test_quote_literal_fn() {
531 assert_eq!(quote_literal("hello"), "'hello'");
532 assert_eq!(quote_literal("it's"), "'it''s'");
533 assert_eq!(quote_literal("a\\b"), "E'a\\b'");
534 }
535
536 // ------------------------------------------------------------------
537 // with_sql constructor
538 // ------------------------------------------------------------------
539
540 #[test]
541 fn test_with_sql() {
542 let b = SqlBuilder::with_sql("SELECT 1");
543 assert_eq!(b.sql(), "SELECT 1");
544 assert!(b.params().is_empty());
545 }
546
547 // ------------------------------------------------------------------
548 // Edge cases
549 // ------------------------------------------------------------------
550
551 #[test]
552 fn test_empty_builder() {
553 let b = SqlBuilder::new();
554 assert!(b.is_empty());
555 assert_eq!(b.sql_len(), 0);
556 let (sql, params) = b.build();
557 assert_eq!(sql, "");
558 assert!(params.is_empty());
559 }
560
561 #[test]
562 fn test_push_ident_empty_string() {
563 let mut b = SqlBuilder::new();
564 b.push_ident("");
565 assert_eq!(b.sql(), "\"\"");
566 }
567
568 #[test]
569 fn test_push_literal_empty_string() {
570 let mut b = SqlBuilder::new();
571 b.push_literal("");
572 assert_eq!(b.sql(), "''");
573 }
574}