Skip to main content

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}