Skip to main content

dibs_sql/
lib.rs

1//! SQL AST and rendering.
2//!
3//! Build SQL as a typed AST, then render to a string with automatic
4//! parameter numbering and formatting.
5
6use strid::*;
7
8mod expr;
9pub use expr::*;
10
11mod render;
12pub use render::*;
13
14mod stmt;
15pub use stmt::*;
16
17/// Result of rendering SQL.
18#[derive(Debug, Clone)]
19pub struct RenderedSql {
20    /// The SQL string with $1, $2, etc. placeholders.
21    pub sql: String,
22
23    /// Parameter names in order (maps to $1, $2, etc.).
24    pub params: Vec<ParamName>,
25}
26
27/// The name of a table or table alias.
28///
29/// Used in FROM, JOIN, INSERT INTO, UPDATE, and DELETE clauses.
30#[braid]
31pub struct TableName;
32
33/// The name of a column or column alias.
34///
35/// Used in SELECT, INSERT, UPDATE SET, and RETURNING clauses.
36#[braid]
37pub struct ColumnName;
38
39/// The name of a query parameter.
40///
41/// Parameters are named (e.g., `handle`) and automatically assigned
42/// positional placeholders (`$1`, `$2`, etc.) during rendering.
43#[braid]
44pub struct ParamName;
45
46/// A PostgreSQL type name for casts.
47///
48/// Used in type cast expressions like `$1::text[]` or `value::integer`.
49#[braid]
50pub struct PgType;
51
52/// A PostgreSQL string literal wrapper.
53///
54/// Display writes the value escaped and quoted with single quotes.
55///
56/// # Example
57/// ```
58/// use dibs_sql::Lit;
59/// assert_eq!(format!("{}", Lit("foo")), "'foo'");
60/// assert_eq!(format!("{}", Lit("it's")), "'it''s'");
61/// ```
62pub struct Lit<T: AsRef<str>>(pub T);
63
64impl<T: AsRef<str>> std::fmt::Display for Lit<T> {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        write!(f, "'")?;
67        for c in self.0.as_ref().chars() {
68            if c == '\'' {
69                write!(f, "''")?;
70            } else {
71                write!(f, "{}", c)?;
72            }
73        }
74        write!(f, "'")
75    }
76}
77
78/// A PostgreSQL identifier wrapper.
79///
80/// Display writes the value escaped and quoted with double quotes.
81///
82/// # Example
83/// ```
84/// use dibs_sql::Ident;
85/// assert_eq!(format!("{}", Ident("user")), "\"user\"");
86/// assert_eq!(format!("{}", Ident("bla\"h")), "\"bla\"\"h\"");
87/// ```
88pub struct Ident<T: AsRef<str>>(pub T);
89
90impl<T: AsRef<str>> std::fmt::Display for Ident<T> {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        write!(f, "\"")?;
93        for c in self.0.as_ref().chars() {
94            if c == '"' {
95                write!(f, "\"\"")?;
96            } else {
97                write!(f, "{}", c)?;
98            }
99        }
100        write!(f, "\"")
101    }
102}
103
104/// Escape a string literal for SQL.
105pub fn escape_string(s: &str) -> String {
106    format!("{}", Lit(s))
107}
108
109/// Quote a PostgreSQL identifier.
110///
111/// Always quotes identifiers to avoid issues with reserved keywords like
112/// `user`, `order`, `table`, `group`, etc. Doubles any embedded quotes.
113pub fn quote_ident(name: &str) -> String {
114    format!("{}", Ident(name))
115}
116
117/// Generate a standard index name for a table and columns.
118///
119/// Uses the convention `idx_{table}_{columns}` where columns are joined by underscore.
120pub fn index_name(table: &str, columns: &[impl AsRef<str>]) -> String {
121    let cols: Vec<&str> = columns.iter().map(|c| c.as_ref()).collect();
122    format!("idx_{}_{}", table, cols.join("_"))
123}
124
125/// Generate a standard unique index name for a table and columns.
126///
127/// Uses the convention `uq_{table}_{columns}` where columns are joined by underscore.
128pub fn unique_index_name(table: &str, columns: &[impl AsRef<str>]) -> String {
129    let cols: Vec<&str> = columns.iter().map(|c| c.as_ref()).collect();
130    format!("uq_{}_{}", table, cols.join("_"))
131}
132
133/// Generate a deterministic CHECK constraint name for a table and expression.
134///
135/// Constraint names must be unique within a schema, so we include the table name
136/// and a stable hash of the expression (after whitespace normalization).
137pub fn check_constraint_name(table: &str, expr: &str) -> String {
138    let normalized = normalize_sql_expr_for_hash(expr);
139    let hex = blake3::hash(normalized.as_bytes()).to_hex().to_string();
140    let suffix = &hex[..16];
141
142    const PG_IDENT_MAX: usize = 63;
143    let prefix_overhead = "ck__".len(); // "ck_" + "_" between table and suffix
144    let suffix_len = suffix.len();
145    let max_table_len = PG_IDENT_MAX.saturating_sub(prefix_overhead + suffix_len);
146
147    let table_part = if table.len() <= max_table_len {
148        table
149    } else {
150        // Table names are expected to be ASCII snake_case; still, avoid splitting UTF-8.
151        let mut len = max_table_len.min(table.len());
152        while len > 0 && !table.is_char_boundary(len) {
153            len -= 1;
154        }
155        &table[..len]
156    };
157
158    format!("ck_{}_{}", table_part, suffix)
159}
160
161/// Generate a deterministic trigger name for a trigger-enforced check.
162///
163/// Trigger names are scoped to a table in Postgres, but we still include the table name
164/// and a stable hash of the expression for readability and determinism.
165pub fn trigger_check_name(table: &str, expr: &str) -> String {
166    let normalized = normalize_sql_expr_for_hash(expr);
167    let hex = blake3::hash(normalized.as_bytes()).to_hex().to_string();
168    let suffix = &hex[..16];
169
170    const PG_IDENT_MAX: usize = 63;
171    let prefix_overhead = "trgck__".len(); // "trgck_" + "_" between table and suffix
172    let suffix_len = suffix.len();
173    let max_table_len = PG_IDENT_MAX.saturating_sub(prefix_overhead + suffix_len);
174
175    let table_part = if table.len() <= max_table_len {
176        table
177    } else {
178        let mut len = max_table_len.min(table.len());
179        while len > 0 && !table.is_char_boundary(len) {
180            len -= 1;
181        }
182        &table[..len]
183    };
184
185    format!("trgck_{}_{}", table_part, suffix)
186}
187
188/// Derive the trigger function name for a trigger-enforced check.
189///
190/// The function name is derived from the trigger name (hashed) so we don't
191/// accidentally exceed Postgres' identifier length limit.
192pub fn trigger_check_function_name(trigger_name: &str) -> String {
193    let hex = blake3::hash(trigger_name.as_bytes()).to_hex().to_string();
194    format!("trgfn_{}", &hex[..20])
195}
196
197fn normalize_sql_expr_for_hash(expr: &str) -> String {
198    let mut out = String::with_capacity(expr.len());
199    let mut pending_space = false;
200
201    let mut in_single_quote = false;
202    let mut in_double_quote = false;
203
204    let mut chars = expr.chars().peekable();
205    while let Some(ch) = chars.next() {
206        if in_single_quote {
207            out.push(ch);
208            if ch == '\'' {
209                // SQL escapes single quotes by doubling them: ''
210                if matches!(chars.peek(), Some('\'')) {
211                    out.push(chars.next().expect("peeked"));
212                } else {
213                    in_single_quote = false;
214                }
215            }
216            continue;
217        }
218
219        if in_double_quote {
220            out.push(ch);
221            if ch == '"' {
222                // SQL escapes double quotes in identifiers by doubling them: ""
223                if matches!(chars.peek(), Some('"')) {
224                    out.push(chars.next().expect("peeked"));
225                } else {
226                    in_double_quote = false;
227                }
228            }
229            continue;
230        }
231
232        match ch {
233            '\'' => {
234                if pending_space && !out.is_empty() {
235                    out.push(' ');
236                }
237                pending_space = false;
238                out.push('\'');
239                in_single_quote = true;
240            }
241            '"' => {
242                if pending_space && !out.is_empty() {
243                    out.push(' ');
244                }
245                pending_space = false;
246                out.push('"');
247                in_double_quote = true;
248            }
249            c if c.is_whitespace() => {
250                pending_space = true;
251            }
252            c => {
253                if pending_space && !out.is_empty() {
254                    out.push(' ');
255                }
256                pending_space = false;
257                out.push(c);
258            }
259        }
260    }
261
262    out.trim().to_string()
263}