Skip to main content

architect_sdk/db/
dialect.rs

1//! The `Dialect` trait — every database backend implements this.
2//!
3//! All methods are on `&self` so the same dialect object can be stored in `Arc<dyn Dialect>`.
4//! The trait is object-safe: no generics, no associated types.
5
6use super::types::{CanonicalType, TypeCategory, TypeSupport};
7
8/// A database dialect encapsulates all SQL syntax and type-mapping differences between
9/// database engines. The SDK calls dialect methods instead of hardcoding Postgres strings,
10/// so adding a new database is a matter of implementing this trait.
11pub trait Dialect: Send + Sync + 'static {
12    /// Short name for log messages and errors (e.g. "postgres", "mysql", "sqlite").
13    fn name(&self) -> &'static str;
14
15    // ── Type system ───────────────────────────────────────────────────────────
16
17    /// DDL type string for CREATE TABLE (e.g. "TIMESTAMPTZ", "DATETIME", "TEXT").
18    fn ddl_type(&self, t: &CanonicalType) -> String;
19
20    /// Type name used in parameter cast expressions, or `None` when no cast is needed.
21    /// Postgres: becomes `$n::cast`.  MySQL/SQLite: cast is omitted (binding handles type).
22    fn cast_name(&self, t: &CanonicalType) -> Option<String>;
23
24    /// Broad category for RSQL operator validation.
25    fn type_category(&self, t: &CanonicalType) -> TypeCategory;
26
27    /// How well this dialect supports the canonical type.
28    fn type_support(&self, t: &CanonicalType) -> TypeSupport;
29
30    // ── Identifier quoting ────────────────────────────────────────────────────
31
32    /// Wrap an identifier in dialect-specific delimiters.
33    /// Postgres/SQLite: double-quotes.  MySQL: backticks.
34    fn quote_ident(&self, s: &str) -> String;
35
36    // ── Parameter placeholders ────────────────────────────────────────────────
37
38    /// Positional placeholder for the n-th parameter (1-based).
39    /// Postgres: `$1`.  MySQL/SQLite: `?`.
40    fn placeholder(&self, n: usize) -> String;
41
42    /// Wrap a placeholder with a type cast where required.
43    /// Postgres: `$1::uuid`.  MySQL/SQLite: placeholder returned unchanged.
44    fn cast_expr(&self, placeholder: &str, cast: &str) -> String;
45
46    // ── SQL functions ─────────────────────────────────────────────────────────
47
48    /// Current-timestamp function name/expression.
49    fn now_fn(&self) -> &'static str;
50
51    /// Expression that generates a random UUID as a column DEFAULT.
52    fn uuid_default_expr(&self) -> &'static str;
53
54    // ── DML clauses ───────────────────────────────────────────────────────────
55
56    /// RETURNING clause appended to INSERT/UPDATE/DELETE, or empty string when unsupported.
57    fn returning_clause(&self, cols: &str) -> String;
58
59    /// Upsert conflict suffix.
60    /// `conflict_cols`: columns that identify the conflict.
61    /// `set_pairs`: pre-built "col = value" pairs for the update branch.
62    fn upsert_conflict(&self, conflict_cols: &[&str], set_pairs: &str) -> String;
63
64    // ── JSON aggregation (related-entity includes) ────────────────────────────
65
66    /// Build a scalar subquery returning a single JSON object for a to-one include.
67    /// `col_exprs`: already-quoted column expressions.
68    /// `from_clause`: `"schema"."table" WHERE ...` fragment.
69    fn to_one_subquery(&self, col_exprs: &[String], from_clause: &str) -> String;
70
71    /// Build a scalar subquery returning a JSON array for a to-many include.
72    fn to_many_subquery(&self, col_exprs: &[String], from_clause: &str) -> String;
73
74    // ── JSON path extraction (extensible fields) ───────────────────────────────────
75
76    /// Extract a top-level key from a JSON/JSONB column as text.
77    /// `col` is an already-quoted column expression; `key` is the raw JSON key (escaped here).
78    /// Postgres: `(col ->> 'key')`.  MySQL/SQLite: `col->>'$.key'`.
79    fn json_extract_text(&self, col: &str, key: &str) -> String;
80
81    /// Extract a JSON key and cast it to `t` so comparisons and ORDER BY are type-correct.
82    /// For text-like types this is equivalent to [`Dialect::json_extract_text`].
83    fn json_extract_typed(&self, col: &str, key: &str, t: &CanonicalType) -> String;
84
85    /// Case-insensitive LIKE comparison fragment: `col <ci-like> placeholder`.
86    /// Postgres: `col ILIKE ph`.  MySQL: `LOWER(col) LIKE LOWER(ph)`.  SQLite: `col LIKE ph`
87    /// (SQLite LIKE is case-insensitive for ASCII by default).
88    fn case_insensitive_like(&self, col: &str, placeholder: &str) -> String;
89
90    // ── System-table DDL helpers ──────────────────────────────────────────────
91
92    /// DDL fragment for a JSON/JSONB payload column (e.g. "JSONB", "JSON", "TEXT").
93    fn sys_json_type(&self) -> &'static str;
94
95    /// Timestamp type name (without NOT NULL / DEFAULT).
96    fn sys_timestamp_type(&self) -> &'static str;
97
98    /// NOT NULL timestamp column with a now() default — convenience built from above.
99    fn sys_timestamp_default(&self) -> String {
100        format!(
101            "{} NOT NULL DEFAULT {}",
102            self.sys_timestamp_type(),
103            self.now_fn()
104        )
105    }
106
107    /// Auto-incrementing large integer for surrogate PKs.
108    /// e.g. "BIGSERIAL", "BIGINT AUTO_INCREMENT", "INTEGER".
109    fn sys_bigserial_type(&self) -> &'static str;
110
111    /// DDL type for a raw binary payload column (e.g. "BYTEA", "BLOB").
112    fn sys_bytes_type(&self) -> &'static str;
113
114    /// Timestamp type used in audit table columns (no DEFAULT — values supplied explicitly).
115    fn audit_timestamp_type(&self) -> &'static str;
116
117    // ── Multi-tenancy ─────────────────────────────────────────────────────────
118
119    /// Whether this dialect supports `CREATE SCHEMA` DDL.
120    /// Postgres: true. MySQL: false (uses databases). SQLite: false (no user-defined schemas).
121    fn supports_schemas(&self) -> bool {
122        true
123    }
124
125    /// DDL fragment for a column that holds a timestamp defaulting to N hours from now.
126    /// Returns `None` when the dialect has no constant-expression equivalent (SQLite).
127    /// Callers should make the column nullable and omit the DEFAULT when `None` is returned.
128    fn default_now_plus_hours(&self, hours: u32) -> Option<String> {
129        Some(format!("NOW() + INTERVAL '{} hours'", hours))
130    }
131
132    /// Whether this dialect natively supports row-level security (CREATE POLICY etc.).
133    fn supports_rls(&self) -> bool;
134
135    /// Whether this dialect supports named enum types (CREATE TYPE … AS ENUM).
136    fn supports_named_enum_types(&self) -> bool;
137
138    /// Whether this dialect supports INCLUDE columns on indexes (Postgres 11+).
139    fn supports_index_include(&self) -> bool;
140
141    /// SQL statement that sets a session-local tenant identifier before a query.
142    /// Returns `None` when the dialect has no such mechanism.
143    fn set_tenant_session_sql(&self, tenant_id: &str) -> Option<String>;
144
145    // ── Read-only query sandbox (reports) ─────────────────────────────────────
146    // Statements issued inside a transaction to sandbox an ad-hoc read-only query. Each returns
147    // `None` when the dialect has no equivalent, in which case the caller simply omits it (the
148    // guarantee then rests on the other layers, e.g. a read-only DB role).
149
150    /// Make the current transaction read-only (blocks writes and writable CTEs).
151    fn set_read_only_sql(&self) -> Option<String> {
152        None
153    }
154
155    /// Bound the running time of statements in the current transaction, in milliseconds.
156    fn set_statement_timeout_sql(&self, _ms: u64) -> Option<String> {
157        None
158    }
159
160    /// Drop to a specific (read-only) DB role for the current transaction. `role` is a bare
161    /// identifier supplied by an operator via env var, quoted by the dialect.
162    fn set_role_sql(&self, _role: &str) -> Option<String> {
163        None
164    }
165
166    // ── Idempotent DDL ────────────────────────────────────────────────────────
167
168    /// Whether `ALTER TABLE … ADD COLUMN IF NOT EXISTS` is valid syntax.
169    /// Postgres: true. MySQL/SQLite: false (they need a pre-flight existence check).
170    fn supports_add_column_if_not_exists(&self) -> bool {
171        false
172    }
173
174    /// Whether a database error code (SQLSTATE for Postgres, error number for MySQL) means
175    /// "the object I tried to create already exists" — a migration step that is already applied.
176    fn is_duplicate_object_code(&self, _code: &str) -> bool {
177        false
178    }
179
180    // ── Introspection ─────────────────────────────────────────────────────────
181    //
182    // These queries feed `db::introspect`, which lets the migration executor skip steps whose
183    // effect is already present in the physical database. Every selected value must come back
184    // as text so one decoding path works for all dialects.
185
186    /// One row per column in `schema`:
187    /// `(table_name, column_name, data_type, is_nullable 'YES'/'NO', has_default 'YES'/'NO')`.
188    fn introspect_columns_sql(&self, schema: &str) -> String {
189        format!(
190            "SELECT table_name, column_name, data_type, is_nullable, \
191             CASE WHEN column_default IS NULL THEN 'NO' ELSE 'YES' END \
192             FROM information_schema.columns WHERE table_schema = '{}'",
193            escape_literal(schema)
194        )
195    }
196
197    /// One row per index in `schema`: `(index_name)`. `None` when the dialect cannot report them.
198    fn introspect_indexes_sql(&self, _schema: &str) -> Option<String> {
199        None
200    }
201
202    /// One row per table constraint in `schema`: `(table_name, constraint_name)`.
203    /// `None` when the dialect cannot report them.
204    fn introspect_constraints_sql(&self, schema: &str) -> Option<String> {
205        Some(format!(
206            "SELECT table_name, constraint_name FROM information_schema.table_constraints \
207             WHERE constraint_schema = '{}'",
208            escape_literal(schema)
209        ))
210    }
211}
212
213/// Escape a value for inlining into a single-quoted SQL literal.
214/// Introspection queries take schema names from config, never from request input.
215pub fn escape_literal(s: &str) -> String {
216    s.replace('\'', "''")
217}