Skip to main content

rustlavel_db/
dialect.rs

1//! What differs between one SQL database and another.
2//!
3//! The query builder, the schema builder and the migrator are written once; a
4//! [`Dialect`] supplies the handful of things the databases genuinely disagree
5//! about — how an identifier is quoted, what a bound parameter looks like, what
6//! a column type is called, and how a generated key is read back.
7//!
8//! Everything a dialect answers is pure string generation, so all three are
9//! tested without a database anywhere near them.
10
11use rustlavel_core::{Error, Result};
12
13/// A column type as the schema builder thinks of it, before any database has
14/// had an opinion.
15///
16/// Logical rather than literal: `Timestamp` is `timestamptz` on PostgreSQL,
17/// `datetime(6)` on MySQL and `datetime2` on SQL Server, and a migration should
18/// not have to know that.
19#[derive(Debug, Clone, PartialEq)]
20pub enum ColumnType {
21    /// The conventional auto-incrementing primary key.
22    Id,
23    /// A UUID primary key, defaulted by the database.
24    UuidId,
25    SmallInteger,
26    Integer,
27    BigInteger,
28    /// Approximate; for money use [`ColumnType::Decimal`].
29    Float,
30    Decimal { precision: u32, scale: u32 },
31    Boolean,
32    String { length: u32 },
33    Text,
34    Json,
35    Uuid,
36    Date,
37    Time,
38    Timestamp,
39    Binary,
40    /// An escape hatch for a type the framework does not model.
41    Raw(String),
42}
43
44/// How a database hands back the key it generated for an inserted row.
45#[derive(Debug, Clone, PartialEq)]
46pub enum ReturningStyle {
47    /// `insert into … values (…) returning "id"` — PostgreSQL.
48    Suffix,
49    /// `insert into … (…) output inserted.[id] values (…)` — SQL Server puts it
50    /// between the column list and `values`, so it cannot be appended.
51    OutputClause,
52    /// Not supported: the key is read with a second statement. MySQL.
53    SeparateQuery(&'static str),
54}
55
56/// The differences between one SQL database and another.
57pub trait Dialect: Send + Sync + std::fmt::Debug + 'static {
58    /// `postgres`, `mysql`, `sqlserver`.
59    fn name(&self) -> &'static str;
60
61    /// Wrap one identifier so a keyword or an unusual name is still valid.
62    ///
63    /// The identifier has already been validated; this only quotes it.
64    fn quote(&self, identifier: &str) -> String;
65
66    /// The placeholder for the `position`-th bound parameter, counting from 1.
67    fn placeholder(&self, position: usize) -> String;
68
69    /// The type name for a logical column type.
70    fn column_type(&self, kind: &ColumnType) -> String;
71
72    /// The expression for "now", used by `timestamps()`.
73    fn now(&self) -> &'static str;
74
75    /// The expression that generates a UUID, when the database has one.
76    fn uuid_default(&self) -> Option<&'static str>;
77
78    /// How a generated key comes back from an insert.
79    fn returning(&self) -> ReturningStyle;
80
81    /// `limit … offset …`, in whatever form this database accepts.
82    ///
83    /// `ordered` says whether the query already has an `order by`, because SQL
84    /// Server's paging syntax requires one and will not accept paging without.
85    fn limit_offset(&self, limit: Option<i64>, offset: Option<i64>, ordered: bool) -> String;
86
87    /// Whether `create table if not exists` is understood.
88    fn supports_if_not_exists_table(&self) -> bool {
89        true
90    }
91
92    /// Whether `create index if not exists` is understood.
93    ///
94    /// PostgreSQL has it; MySQL and SQL Server do not, so the schema builder
95    /// emits a plain `create index` and a repeated migration would fail — which
96    /// is correct, since migrations run once.
97    fn supports_if_not_exists_index(&self) -> bool {
98        false
99    }
100
101    /// Whether a `boolean` column really is one.
102    ///
103    /// MySQL stores it as `tinyint(1)` and SQL Server as `bit`, so both hand
104    /// back a number where PostgreSQL hands back a boolean. The row decoder
105    /// uses this to convert.
106    fn booleans_are_integers(&self) -> bool {
107        false
108    }
109
110    /// The longest identifier this database accepts.
111    fn max_identifier_length(&self) -> usize {
112        63
113    }
114
115    /// The DDL for the migration tracking table.
116    ///
117    /// The key column has to say `primary key`: MySQL refuses an
118    /// `auto_increment` column that is not one, and a real server is the only
119    /// thing that will tell you so.
120    fn migrations_table_sql(&self, table: &str) -> String {
121        format!(
122            "create table if not exists {} (\n  \
123             id {} primary key,\n  \
124             name {} not null unique,\n  \
125             batch {} not null,\n  \
126             ran_at {} not null default {}\n)",
127            self.quote(table),
128            self.column_type(&ColumnType::Id),
129            self.column_type(&ColumnType::String { length: 255 }),
130            self.column_type(&ColumnType::Integer),
131            self.column_type(&ColumnType::Timestamp),
132            self.now()
133        )
134    }
135
136    /// How a column is added in an `alter table`.
137    ///
138    /// PostgreSQL and MySQL say `add column`; SQL Server rejects the keyword on
139    /// `add` while requiring it on `drop`, which is asymmetric enough that
140    /// nobody guesses it right.
141    fn add_column_clause(&self) -> &'static str {
142        "add column"
143    }
144
145    /// Start a transaction.
146    ///
147    /// T-SQL wants the word `transaction`; the others are happy with `begin`
148    /// alone and reject `begin transaction` is fine there too, but the bare
149    /// form is what their documentation uses.
150    fn begin_sql(&self) -> &'static str {
151        "begin"
152    }
153
154    fn commit_sql(&self) -> &'static str {
155        "commit"
156    }
157
158    fn rollback_sql(&self) -> &'static str {
159        "rollback"
160    }
161
162    fn savepoint_sql(&self, name: &str) -> String {
163        format!("savepoint {name}")
164    }
165
166    fn rollback_to_savepoint_sql(&self, name: &str) -> String {
167        format!("rollback to savepoint {name}")
168    }
169
170    /// How a `select` claims the row it picks so no other worker takes it,
171    /// as `(hint after the table, clause before the limit)`.
172    ///
173    /// A queue needs exactly this primitive and nothing weaker: a plain
174    /// `select` then `update` is a read-then-write race that runs a job twice,
175    /// and a lock without *skip* serialises every worker behind one row
176    /// instead of letting them take the rows after it.
177    ///
178    /// **Two positions, because SQL Server's form is not a suffix.**
179    /// PostgreSQL and MySQL append `for update skip locked` at the end;
180    /// SQL Server says `with (updlock, readpast, rowlock)` immediately after
181    /// the table name, and putting it at the end is a syntax error. Returning
182    /// one string would have made the caller guess.
183    ///
184    /// The default is no locking at all, which is right only for a database
185    /// that serialises writers by itself — see SQLite below.
186    fn skip_locked(&self) -> (&'static str, &'static str) {
187        ("", "")
188    }
189
190    /// The expression naming the schema this connection is working in.
191    ///
192    /// `information_schema` is standard; the way you ask "which schema am I in"
193    /// is not.
194    fn current_schema_expression(&self) -> &'static str;
195
196    /// A query returning one row per table in the current schema, with the name
197    /// in the first column.
198    ///
199    /// `migrate:fresh` enumerates and drops rather than running one clever
200    /// statement, because only PostgreSQL has an anonymous block to put a loop
201    /// in — and the enumerate-then-drop shape works identically everywhere.
202    fn list_tables_sql(&self) -> &'static str;
203
204    /// Turn off foreign key enforcement while tables are being dropped, so the
205    /// order they come back in does not matter.
206    fn disable_foreign_keys_sql(&self) -> Option<&'static str> {
207        None
208    }
209
210    fn enable_foreign_keys_sql(&self) -> Option<&'static str> {
211        None
212    }
213
214    /// Drop one table, including anything depending on it.
215    fn drop_table_sql(&self, table: &str) -> String {
216        format!("drop table if exists {}", self.quote(table))
217    }
218}
219
220/// Quote a possibly-qualified name one part at a time.
221pub fn quote_qualified(dialect: &dyn Dialect, name: &str) -> Result<String> {
222    let parts: Result<Vec<String>> = name
223        .split('.')
224        .map(|part| {
225            validate_identifier(part, dialect.max_identifier_length())
226                .map(|_| dialect.quote(part))
227        })
228        .collect();
229    Ok(parts?.join("."))
230}
231
232/// Reject anything that is not a plain identifier.
233///
234/// Identifiers cannot be sent as bound parameters, so every place the framework
235/// interpolates one into SQL passes through here first.
236pub fn validate_identifier(name: &str, max_length: usize) -> Result<()> {
237    let valid = !name.is_empty()
238        && name.len() <= max_length
239        && name.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
240        && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
241
242    if valid {
243        Ok(())
244    } else {
245        Err(Error::msg(format!(
246            "`{name}` is not a valid SQL identifier. Identifiers may contain letters, digits and \
247             underscores, must not start with a digit, and must be at most {max_length} characters."
248        )))
249    }
250}
251
252// --- PostgreSQL ---
253
254#[derive(Debug, Default, Clone, Copy)]
255pub struct Postgres;
256
257impl Dialect for Postgres {
258    fn name(&self) -> &'static str {
259        "postgres"
260    }
261
262    fn quote(&self, identifier: &str) -> String {
263        format!("\"{identifier}\"")
264    }
265
266    fn placeholder(&self, position: usize) -> String {
267        format!("${position}")
268    }
269
270    fn column_type(&self, kind: &ColumnType) -> String {
271        match kind {
272            ColumnType::Id => "bigserial".into(),
273            ColumnType::UuidId | ColumnType::Uuid => "uuid".into(),
274            ColumnType::SmallInteger => "smallint".into(),
275            ColumnType::Integer => "integer".into(),
276            ColumnType::BigInteger => "bigint".into(),
277            ColumnType::Float => "double precision".into(),
278            ColumnType::Decimal { precision, scale } => format!("numeric({precision}, {scale})"),
279            ColumnType::Boolean => "boolean".into(),
280            ColumnType::String { length } => format!("varchar({length})"),
281            ColumnType::Text => "text".into(),
282            ColumnType::Json => "jsonb".into(),
283            ColumnType::Date => "date".into(),
284            ColumnType::Time => "time".into(),
285            ColumnType::Timestamp => "timestamptz".into(),
286            ColumnType::Binary => "bytea".into(),
287            ColumnType::Raw(sql) => sql.clone(),
288        }
289    }
290
291    fn now(&self) -> &'static str {
292        "now()"
293    }
294
295    fn uuid_default(&self) -> Option<&'static str> {
296        Some("gen_random_uuid()")
297    }
298
299    fn returning(&self) -> ReturningStyle {
300        ReturningStyle::Suffix
301    }
302
303    fn limit_offset(&self, limit: Option<i64>, offset: Option<i64>, _ordered: bool) -> String {
304        let mut out = String::new();
305        if let Some(limit) = limit {
306            out.push_str(&format!(" limit {}", limit.max(0)));
307        }
308        if let Some(offset) = offset {
309            out.push_str(&format!(" offset {}", offset.max(0)));
310        }
311        out
312    }
313
314    fn supports_if_not_exists_index(&self) -> bool {
315        true
316    }
317
318    fn skip_locked(&self) -> (&'static str, &'static str) {
319        ("", " for update skip locked")
320    }
321
322    fn current_schema_expression(&self) -> &'static str {
323        "current_schema()"
324    }
325
326    fn list_tables_sql(&self) -> &'static str {
327        "select tablename from pg_tables where schemaname = current_schema()"
328    }
329
330    fn drop_table_sql(&self, table: &str) -> String {
331        // `cascade` also removes the foreign keys pointing at it, which is why
332        // PostgreSQL needs no enforcement switch.
333        format!("drop table if exists {} cascade", self.quote(table))
334    }
335}
336
337// --- SQLite ---
338
339/// SQLite's SQL.
340///
341/// **This dialect compiles unconditionally; the driver beside it does not.**
342/// A dialect is string formatting with no dependency at all, so the generated
343/// SQL can be asserted in a build that cannot open a SQLite file — which is
344/// how the other three are tested too.
345///
346/// SQLite is not a server and its SQL reflects that. Three differences are
347/// load-bearing:
348///
349/// * **Types are advisory.** A column has a *type affinity*, not a type, and
350///   a `varchar(255)` accepts a megabyte. The affinities below are the ones
351///   the framework's [`ColumnType`]s map onto; the lengths are kept in the SQL
352///   because they document intent and cost nothing.
353/// * **`integer primary key` is the row id itself**, and auto-increments with
354///   no keyword. `autoincrement` exists but only stops row ids being reused
355///   after a delete, at the cost of a second table — which is not what the
356///   other three do, so it is not asked for here.
357/// * **Foreign keys are off unless switched on per connection.** That is a
358///   SQLite default, not a choice this dialect can make; the driver turns them
359///   on for every connection it opens.
360#[derive(Debug, Default, Clone, Copy)]
361pub struct Sqlite;
362
363impl Dialect for Sqlite {
364    fn name(&self) -> &'static str {
365        "sqlite"
366    }
367
368    fn quote(&self, identifier: &str) -> String {
369        format!("\"{identifier}\"")
370    }
371
372    fn placeholder(&self, _position: usize) -> String {
373        "?".into()
374    }
375
376    fn column_type(&self, kind: &ColumnType) -> String {
377        match kind {
378            // No `autoincrement`: `integer primary key` *is* the row id, and
379            // it increments on its own. The schema builder appends the
380            // `primary key`, which is what makes this the row id rather than
381            // an ordinary integer column.
382            ColumnType::Id => "integer".into(),
383            ColumnType::SmallInteger | ColumnType::Integer | ColumnType::BigInteger => {
384                "integer".into()
385            }
386            ColumnType::Float => "real".into(),
387            // Affinity `numeric`, which keeps the value exact when it is
388            // written as a string. SQLite has no true decimal; money in a
389            // SQLite database belongs in an integer of minor units, the way
390            // `rustlavel-ledger` stores it.
391            ColumnType::Decimal { precision, scale } => format!("numeric({precision}, {scale})"),
392            // SQLite has no boolean. 0 and 1, which is why
393            // `booleans_are_integers` is true below.
394            ColumnType::Boolean => "integer".into(),
395            ColumnType::String { length } => format!("varchar({length})"),
396            ColumnType::Text | ColumnType::Json => "text".into(),
397            ColumnType::UuidId | ColumnType::Uuid => "text".into(),
398            ColumnType::Date | ColumnType::Time | ColumnType::Timestamp => "text".into(),
399            ColumnType::Binary => "blob".into(),
400            ColumnType::Raw(sql) => sql.clone(),
401        }
402    }
403
404    fn now(&self) -> &'static str {
405        "current_timestamp"
406    }
407
408    fn uuid_default(&self) -> Option<&'static str> {
409        // SQLite has no uuid function, and inventing one out of `randomblob`
410        // would produce something that looks like a UUID without being one.
411        // The application generates the id.
412        None
413    }
414
415    fn returning(&self) -> ReturningStyle {
416        // SQLite has had `returning` since 3.35, but the row id is what the
417        // framework wants and `last_insert_rowid()` has always been there —
418        // so this works against an old system SQLite as well as a new one.
419        ReturningStyle::SeparateQuery("select last_insert_rowid()")
420    }
421
422    fn limit_offset(&self, limit: Option<i64>, offset: Option<i64>, _ordered: bool) -> String {
423        let mut out = String::new();
424        if let Some(limit) = limit {
425            out.push_str(&format!(" limit {}", limit.max(0)));
426        }
427        if let Some(offset) = offset {
428            // SQLite rejects `offset` without `limit`, which the other three
429            // accept. -1 is its documented "no limit".
430            if limit.is_none() {
431                out.push_str(" limit -1");
432            }
433            out.push_str(&format!(" offset {}", offset.max(0)));
434        }
435        out
436    }
437
438    fn supports_if_not_exists_index(&self) -> bool {
439        true
440    }
441
442    fn booleans_are_integers(&self) -> bool {
443        true
444    }
445
446    fn max_identifier_length(&self) -> usize {
447        // SQLite imposes no limit worth the name. Kept at PostgreSQL's 63 so a
448        // schema that works here works there — the point of developing against
449        // SQLite is that the result runs on a server afterwards.
450        63
451    }
452
453    fn current_schema_expression(&self) -> &'static str {
454        "'main'"
455    }
456
457    /// **Nothing, and here that is correct rather than missing.** SQLite has
458    /// no row locks — a write transaction locks the whole database — so two
459    /// workers cannot both claim one job however the `select` is written. What
460    /// makes it safe is `begin immediate` below, not a clause here.
461    fn skip_locked(&self) -> (&'static str, &'static str) {
462        ("", "")
463    }
464
465    /// `begin immediate`, not the bare `begin` the other three use.
466    ///
467    /// SQLite's default transaction is *deferred*: it takes a read lock on the
468    /// first `select` and only tries to upgrade at the first write. Two
469    /// transactions that both read and then both try to upgrade deadlock, and
470    /// SQLite breaks the tie by failing one with `SQLITE_BUSY` immediately —
471    /// without waiting for `busy_timeout`, because waiting cannot help. Two
472    /// queue workers are exactly that shape.
473    ///
474    /// `begin immediate` takes the write lock up front, so the second worker
475    /// waits its turn instead of failing.
476    fn begin_sql(&self) -> &'static str {
477        "begin immediate"
478    }
479
480    fn list_tables_sql(&self) -> &'static str {
481        // `sqlite_%` is reserved for SQLite's own bookkeeping, and dropping one
482        // is an error rather than a no-op.
483        "select name from sqlite_master where type = 'table' and name not like 'sqlite_%'"
484    }
485
486    fn disable_foreign_keys_sql(&self) -> Option<&'static str> {
487        // Not a statement SQLite will honour inside a transaction — it is
488        // silently ignored there. `migrate:fresh` drops outside one, which is
489        // where it works.
490        Some("pragma foreign_keys = off")
491    }
492
493    fn enable_foreign_keys_sql(&self) -> Option<&'static str> {
494        Some("pragma foreign_keys = on")
495    }
496}
497
498// --- MySQL ---
499
500#[derive(Debug, Default, Clone, Copy)]
501pub struct MySql;
502
503impl Dialect for MySql {
504    fn name(&self) -> &'static str {
505        "mysql"
506    }
507
508    fn quote(&self, identifier: &str) -> String {
509        format!("`{identifier}`")
510    }
511
512    fn placeholder(&self, _position: usize) -> String {
513        // MySQL binds by position in order, not by number.
514        "?".into()
515    }
516
517    fn column_type(&self, kind: &ColumnType) -> String {
518        match kind {
519            // Signed, matching PostgreSQL's bigserial and SQL Server's bigint
520            // identity. MySQL's convention is unsigned, but then a `bigint`
521            // foreign key cannot reference it — MySQL requires the types to
522            // match exactly, signedness included.
523            ColumnType::Id => "bigint not null auto_increment".into(),
524            // MySQL has no uuid type; 36 characters holds the canonical form.
525            ColumnType::UuidId | ColumnType::Uuid => "char(36)".into(),
526            ColumnType::SmallInteger => "smallint".into(),
527            ColumnType::Integer => "int".into(),
528            ColumnType::BigInteger => "bigint".into(),
529            ColumnType::Float => "double".into(),
530            ColumnType::Decimal { precision, scale } => format!("decimal({precision}, {scale})"),
531            // `boolean` is an alias for tinyint(1); spelled out so the schema
532            // says what the database actually stores.
533            ColumnType::Boolean => "tinyint(1)".into(),
534            ColumnType::String { length } => format!("varchar({length})"),
535            ColumnType::Text => "text".into(),
536            ColumnType::Json => "json".into(),
537            ColumnType::Date => "date".into(),
538            ColumnType::Time => "time".into(),
539            // Fractional seconds are not the default and cannot be added later
540            // without rewriting the table.
541            ColumnType::Timestamp => "datetime(6)".into(),
542            ColumnType::Binary => "longblob".into(),
543            ColumnType::Raw(sql) => sql.clone(),
544        }
545    }
546
547    fn now(&self) -> &'static str {
548        "current_timestamp(6)"
549    }
550
551    fn uuid_default(&self) -> Option<&'static str> {
552        // Only from MySQL 8.0.13, and only in an expression default; left off
553        // so the schema builder does not emit something a 5.7 server rejects.
554        None
555    }
556
557    fn returning(&self) -> ReturningStyle {
558        ReturningStyle::SeparateQuery("select last_insert_id()")
559    }
560
561    fn limit_offset(&self, limit: Option<i64>, offset: Option<i64>, _ordered: bool) -> String {
562        let mut out = String::new();
563        match (limit, offset) {
564            // MySQL cannot offset without a limit, so an offset alone gets the
565            // largest limit the syntax allows.
566            (None, Some(offset)) => {
567                out.push_str(&format!(" limit 18446744073709551615 offset {}", offset.max(0)));
568            }
569            (Some(limit), offset) => {
570                out.push_str(&format!(" limit {}", limit.max(0)));
571                if let Some(offset) = offset {
572                    out.push_str(&format!(" offset {}", offset.max(0)));
573                }
574            }
575            (None, None) => {}
576        }
577        out
578    }
579
580    fn booleans_are_integers(&self) -> bool {
581        true
582    }
583
584    fn max_identifier_length(&self) -> usize {
585        64
586    }
587
588    fn current_schema_expression(&self) -> &'static str {
589        // MySQL has no schemas separate from databases; the current database is
590        // the schema.
591        "database()"
592    }
593
594    /// The same words PostgreSQL uses, and **MySQL 8.0 or newer**.
595    /// `skip locked` arrived in 8.0; on 5.7 this is a syntax error rather than
596    /// a silent fallback, which is the better failure — a queue that quietly
597    /// dropped the `skip` would run jobs twice.
598    fn skip_locked(&self) -> (&'static str, &'static str) {
599        ("", " for update skip locked")
600    }
601
602    fn list_tables_sql(&self) -> &'static str {
603        "select table_name from information_schema.tables \
604         where table_schema = database() and table_type = 'BASE TABLE'"
605    }
606
607    fn disable_foreign_keys_sql(&self) -> Option<&'static str> {
608        Some("set foreign_key_checks = 0")
609    }
610
611    fn enable_foreign_keys_sql(&self) -> Option<&'static str> {
612        Some("set foreign_key_checks = 1")
613    }
614}
615
616// --- SQL Server ---
617
618#[derive(Debug, Default, Clone, Copy)]
619pub struct SqlServer;
620
621impl Dialect for SqlServer {
622    fn name(&self) -> &'static str {
623        "sqlserver"
624    }
625
626    fn quote(&self, identifier: &str) -> String {
627        format!("[{identifier}]")
628    }
629
630    fn placeholder(&self, position: usize) -> String {
631        format!("@P{position}")
632    }
633
634    fn column_type(&self, kind: &ColumnType) -> String {
635        match kind {
636            ColumnType::Id => "bigint identity(1,1)".into(),
637            ColumnType::UuidId | ColumnType::Uuid => "uniqueidentifier".into(),
638            ColumnType::SmallInteger => "smallint".into(),
639            ColumnType::Integer => "int".into(),
640            ColumnType::BigInteger => "bigint".into(),
641            ColumnType::Float => "float".into(),
642            ColumnType::Decimal { precision, scale } => format!("decimal({precision}, {scale})"),
643            ColumnType::Boolean => "bit".into(),
644            // `n` prefixed: the framework speaks UTF-8, and nvarchar is the type
645            // that stores it without a collation surprise.
646            ColumnType::String { length } => format!("nvarchar({length})"),
647            ColumnType::Text | ColumnType::Json => "nvarchar(max)".into(),
648            ColumnType::Date => "date".into(),
649            ColumnType::Time => "time".into(),
650            ColumnType::Timestamp => "datetime2".into(),
651            ColumnType::Binary => "varbinary(max)".into(),
652            ColumnType::Raw(sql) => sql.clone(),
653        }
654    }
655
656    fn now(&self) -> &'static str {
657        "sysutcdatetime()"
658    }
659
660    fn uuid_default(&self) -> Option<&'static str> {
661        Some("newid()")
662    }
663
664    fn returning(&self) -> ReturningStyle {
665        ReturningStyle::OutputClause
666    }
667
668    fn limit_offset(&self, limit: Option<i64>, offset: Option<i64>, ordered: bool) -> String {
669        if limit.is_none() && offset.is_none() {
670            return String::new();
671        }
672
673        // `offset … fetch next …` is only legal after an `order by`, so an
674        // unordered paged query gets a placeholder ordering rather than a
675        // syntax error the caller cannot explain.
676        let mut out = String::new();
677        if !ordered {
678            out.push_str(" order by (select null)");
679        }
680        out.push_str(&format!(" offset {} rows", offset.unwrap_or(0).max(0)));
681        if let Some(limit) = limit {
682            out.push_str(&format!(" fetch next {} rows only", limit.max(0)));
683        }
684        out
685    }
686
687    fn supports_if_not_exists_table(&self) -> bool {
688        false
689    }
690
691    fn booleans_are_integers(&self) -> bool {
692        true
693    }
694
695    fn max_identifier_length(&self) -> usize {
696        128
697    }
698
699    fn migrations_table_sql(&self, table: &str) -> String {
700        // No `if not exists`; the catalogue is checked instead.
701        format!(
702            "if object_id('{table}', 'U') is null create table {} (\n  \
703             [id] bigint identity(1,1) primary key,\n  \
704             [name] nvarchar(255) not null unique,\n  \
705             [batch] int not null,\n  \
706             [ran_at] datetime2 not null default sysutcdatetime()\n)",
707            self.quote(table)
708        )
709    }
710
711    fn add_column_clause(&self) -> &'static str {
712        "add"
713    }
714
715    fn begin_sql(&self) -> &'static str {
716        "begin transaction"
717    }
718
719    fn commit_sql(&self) -> &'static str {
720        "commit transaction"
721    }
722
723    fn rollback_sql(&self) -> &'static str {
724        "rollback transaction"
725    }
726
727    fn savepoint_sql(&self, name: &str) -> String {
728        // T-SQL has no `savepoint` keyword; a named save point is made and
729        // returned to with `transaction`.
730        format!("save transaction {name}")
731    }
732
733    fn rollback_to_savepoint_sql(&self, name: &str) -> String {
734        format!("rollback transaction {name}")
735    }
736
737    fn current_schema_expression(&self) -> &'static str {
738        "schema_name()"
739    }
740
741    /// A table hint, which is why this returns two pieces rather than one.
742    /// `updlock` takes the update lock the row is about to need, `readpast` is
743    /// T-SQL's `skip locked`, and `rowlock` stops the engine escalating to a
744    /// page or table lock and serialising every worker.
745    fn skip_locked(&self) -> (&'static str, &'static str) {
746        (" with (updlock, readpast, rowlock)", "")
747    }
748
749    fn list_tables_sql(&self) -> &'static str {
750        // `is_ms_shipped = 0` excludes the system tables SQL Server keeps in
751        // some databases; without it, `migrate:fresh` pointed at `master` would
752        // try to drop Microsoft's own.
753        "select t.name from sys.tables t \
754         where t.is_ms_shipped = 0 and schema_name(t.schema_id) = schema_name()"
755    }
756
757    fn disable_foreign_keys_sql(&self) -> Option<&'static str> {
758        // Undocumented but long-standing: applies to every table at once.
759        Some("exec sp_MSforeachtable 'alter table ? nocheck constraint all'")
760    }
761
762    fn enable_foreign_keys_sql(&self) -> Option<&'static str> {
763        Some("exec sp_MSforeachtable 'alter table ? with check check constraint all'")
764    }
765}
766
767/// Build a dialect from its name.
768pub fn by_name(name: &str) -> Result<Box<dyn Dialect>> {
769    match name.to_ascii_lowercase().as_str() {
770        "postgres" | "postgresql" | "pgsql" => Ok(Box::new(Postgres)),
771        "mysql" | "mariadb" => Ok(Box::new(MySql)),
772        "sqlserver" | "mssql" => Ok(Box::new(SqlServer)),
773        other => Err(Error::msg(format!(
774            "`{other}` is not a database this framework speaks. Available: postgres, mysql, sqlserver."
775        ))),
776    }
777}
778
779#[cfg(test)]
780mod tests {
781    use super::*;
782
783    fn all() -> Vec<Box<dyn Dialect>> {
784        vec![Box::new(Postgres), Box::new(MySql), Box::new(SqlServer)]
785    }
786
787    #[test]
788    fn each_dialect_quotes_the_way_its_database_expects() {
789        assert_eq!(Postgres.quote("users"), "\"users\"");
790        assert_eq!(MySql.quote("users"), "`users`");
791        assert_eq!(SqlServer.quote("users"), "[users]");
792    }
793
794    #[test]
795    fn placeholders_differ_in_kind_not_just_spelling() {
796        assert_eq!(Postgres.placeholder(1), "$1");
797        assert_eq!(Postgres.placeholder(3), "$3");
798
799        // MySQL binds positionally, so every placeholder is the same token.
800        assert_eq!(MySql.placeholder(1), "?");
801        assert_eq!(MySql.placeholder(3), "?");
802
803        assert_eq!(SqlServer.placeholder(3), "@P3");
804    }
805
806    #[test]
807    fn a_qualified_name_is_quoted_one_part_at_a_time() {
808        assert_eq!(
809            quote_qualified(&Postgres, "public.users").unwrap(),
810            "\"public\".\"users\""
811        );
812        assert_eq!(quote_qualified(&MySql, "shop.orders").unwrap(), "`shop`.`orders`");
813        assert_eq!(quote_qualified(&SqlServer, "dbo.users").unwrap(), "[dbo].[users]");
814    }
815
816    #[test]
817    fn an_injected_identifier_is_rejected_by_every_dialect() {
818        for dialect in all() {
819            for hostile in ["users; drop table users", "a b", "1abc", "", "us\"er"] {
820                assert!(
821                    quote_qualified(dialect.as_ref(), hostile).is_err(),
822                    "{} accepted {hostile:?}",
823                    dialect.name()
824                );
825            }
826        }
827    }
828
829    #[test]
830    fn identifier_length_limits_follow_the_database() {
831        let long = "a".repeat(100);
832
833        assert!(validate_identifier(&long, Postgres.max_identifier_length()).is_err());
834        assert!(validate_identifier(&long, MySql.max_identifier_length()).is_err());
835        assert!(validate_identifier(&long, SqlServer.max_identifier_length()).is_ok());
836    }
837
838    #[test]
839    fn the_key_column_is_auto_incrementing_everywhere() {
840        assert_eq!(Postgres.column_type(&ColumnType::Id), "bigserial");
841        assert_eq!(MySql.column_type(&ColumnType::Id), "bigint not null auto_increment");
842        assert_eq!(SqlServer.column_type(&ColumnType::Id), "bigint identity(1,1)");
843    }
844
845    #[test]
846    fn text_and_json_map_to_what_each_database_actually_has() {
847        assert_eq!(Postgres.column_type(&ColumnType::Json), "jsonb");
848        assert_eq!(MySql.column_type(&ColumnType::Json), "json");
849        // SQL Server has no JSON type; it stores the document as text.
850        assert_eq!(SqlServer.column_type(&ColumnType::Json), "nvarchar(max)");
851    }
852
853    #[test]
854    fn a_string_column_carries_its_length_everywhere() {
855        let kind = ColumnType::String { length: 120 };
856
857        assert_eq!(Postgres.column_type(&kind), "varchar(120)");
858        assert_eq!(MySql.column_type(&kind), "varchar(120)");
859        assert_eq!(SqlServer.column_type(&kind), "nvarchar(120)");
860    }
861
862    #[test]
863    fn paging_uses_each_databases_own_syntax() {
864        assert_eq!(Postgres.limit_offset(Some(10), Some(20), true), " limit 10 offset 20");
865        assert_eq!(MySql.limit_offset(Some(10), Some(20), true), " limit 10 offset 20");
866        assert_eq!(
867            SqlServer.limit_offset(Some(10), Some(20), true),
868            " offset 20 rows fetch next 10 rows only"
869        );
870    }
871
872    #[test]
873    fn sql_server_supplies_an_ordering_when_paging_has_none() {
874        // `offset` is a syntax error without `order by`, and a caller cannot
875        // debug an error the builder could have avoided.
876        let paged = SqlServer.limit_offset(Some(10), None, false);
877        assert!(paged.starts_with(" order by (select null)"), "{paged}");
878
879        // With an ordering already present, none is added.
880        assert!(!SqlServer.limit_offset(Some(10), None, true).contains("order by"));
881    }
882
883    #[test]
884    fn mysql_cannot_offset_without_a_limit() {
885        let offset_only = MySql.limit_offset(None, Some(20), true);
886
887        assert!(offset_only.contains("limit 18446744073709551615"), "{offset_only}");
888        assert!(offset_only.ends_with("offset 20"));
889    }
890
891    #[test]
892    fn no_paging_produces_no_clause() {
893        for dialect in all() {
894            assert_eq!(dialect.limit_offset(None, None, true), "", "{}", dialect.name());
895        }
896    }
897
898    #[test]
899    fn generated_keys_come_back_differently() {
900        assert_eq!(Postgres.returning(), ReturningStyle::Suffix);
901        assert_eq!(SqlServer.returning(), ReturningStyle::OutputClause);
902        assert_eq!(
903            MySql.returning(),
904            ReturningStyle::SeparateQuery("select last_insert_id()")
905        );
906    }
907
908    #[test]
909    fn the_migration_table_is_valid_for_each_database() {
910        let postgres = Postgres.migrations_table_sql("rustlavel_migrations");
911        assert!(postgres.contains("create table if not exists \"rustlavel_migrations\""));
912        assert!(postgres.contains("bigserial primary key"));
913
914        let mysql = MySql.migrations_table_sql("rustlavel_migrations");
915        assert!(mysql.contains("`rustlavel_migrations`"));
916        // MySQL rejects an auto_increment column that is not a key.
917        assert!(mysql.contains("auto_increment primary key"), "{mysql}");
918
919        // SQL Server has no `if not exists`, so it checks the catalogue.
920        let sqlserver = SqlServer.migrations_table_sql("rustlavel_migrations");
921        assert!(sqlserver.starts_with("if object_id("));
922        assert!(sqlserver.contains("identity(1,1)"));
923    }
924
925    #[test]
926    fn transaction_control_uses_each_databases_own_words() {
927        // `begin` alone is a syntax error in T-SQL, which would have broken
928        // every transaction on SQL Server.
929        assert_eq!(Postgres.begin_sql(), "begin");
930        assert_eq!(MySql.begin_sql(), "begin");
931        assert_eq!(SqlServer.begin_sql(), "begin transaction");
932
933        assert_eq!(SqlServer.commit_sql(), "commit transaction");
934        assert_eq!(SqlServer.rollback_sql(), "rollback transaction");
935        assert_eq!(SqlServer.savepoint_sql("sp1"), "save transaction sp1");
936        assert_eq!(SqlServer.rollback_to_savepoint_sql("sp1"), "rollback transaction sp1");
937
938        assert_eq!(Postgres.savepoint_sql("sp1"), "savepoint sp1");
939        assert_eq!(Postgres.rollback_to_savepoint_sql("sp1"), "rollback to savepoint sp1");
940    }
941
942    /// The clause a queue uses to claim a row, per database.
943    ///
944    /// Written as a table because the shapes genuinely differ: two of them
945    /// append a clause, SQL Server puts a hint after the table name, and
946    /// SQLite has no row locks at all. A queue that assumed one shape is what
947    /// this test exists to stop.
948    #[test]
949    fn each_database_claims_a_row_in_its_own_way() {
950        assert_eq!(Postgres.skip_locked(), ("", " for update skip locked"));
951        assert_eq!(MySql.skip_locked(), ("", " for update skip locked"));
952        assert_eq!(SqlServer.skip_locked(), (" with (updlock, readpast, rowlock)", ""));
953        // No clause — SQLite locks the whole database for a writer, so the
954        // guarantee comes from `begin immediate` instead.
955        assert_eq!(Sqlite.skip_locked(), ("", ""));
956        assert_eq!(Sqlite.begin_sql(), "begin immediate");
957
958        // The hint and the clause are never both set: a statement builder
959        // appends both, and a database wanting two locks would be a mistake in
960        // one of the rows above.
961        for dialect in [&Postgres as &dyn Dialect, &MySql, &SqlServer, &Sqlite] {
962            let (hint, clause) = dialect.skip_locked();
963            assert!(
964                hint.is_empty() || clause.is_empty(),
965                "{} sets both a hint and a clause",
966                dialect.name()
967            );
968        }
969    }
970
971    #[test]
972    fn schema_expressions_are_what_each_database_calls_them() {
973        assert_eq!(Postgres.current_schema_expression(), "current_schema()");
974        assert_eq!(MySql.current_schema_expression(), "database()");
975        assert_eq!(SqlServer.current_schema_expression(), "schema_name()");
976    }
977
978    #[test]
979    fn every_dialect_can_enumerate_its_own_tables() {
980        for dialect in all() {
981            let sql = dialect.list_tables_sql();
982
983            assert!(sql.starts_with("select "), "{}: {sql}", dialect.name());
984            // The query must be scoped to the current schema, or `migrate:fresh`
985            // would reach into someone else's database.
986            assert!(
987                sql.contains("current_schema()")
988                    || sql.contains("database()")
989                    || sql.contains("schema_name()"),
990                "{} does not scope its table list: {sql}",
991                dialect.name()
992            );
993        }
994    }
995
996    #[test]
997    fn sql_server_adds_a_column_without_saying_column() {
998        // Confirmed against a live server: `add column` is a syntax error there,
999        // while `drop column` is required. The asymmetry is real.
1000        assert_eq!(Postgres.add_column_clause(), "add column");
1001        assert_eq!(MySql.add_column_clause(), "add column");
1002        assert_eq!(SqlServer.add_column_clause(), "add");
1003    }
1004
1005    #[test]
1006    fn sql_server_never_lists_microsofts_own_tables() {
1007        // `master` ships system tables in dbo; dropping those is not what
1008        // `migrate:fresh` is for.
1009        assert!(SqlServer.list_tables_sql().contains("is_ms_shipped = 0"));
1010    }
1011
1012    #[test]
1013    fn dropping_a_table_takes_its_dependants_with_it() {
1014        // PostgreSQL says so explicitly; the others need the enforcement
1015        // switched off around the whole run instead.
1016        assert!(Postgres.drop_table_sql("users").ends_with("cascade"));
1017        assert!(Postgres.disable_foreign_keys_sql().is_none());
1018
1019        assert_eq!(MySql.drop_table_sql("users"), "drop table if exists `users`");
1020        assert!(MySql.disable_foreign_keys_sql().is_some());
1021        assert!(MySql.enable_foreign_keys_sql().is_some());
1022
1023        assert_eq!(SqlServer.drop_table_sql("users"), "drop table if exists [users]");
1024        assert!(SqlServer.disable_foreign_keys_sql().is_some());
1025    }
1026
1027    #[test]
1028    fn dialects_are_found_by_the_names_people_use() {
1029        for (name, expected) in [
1030            ("postgres", "postgres"),
1031            ("postgresql", "postgres"),
1032            ("mysql", "mysql"),
1033            ("mariadb", "mysql"),
1034            ("sqlserver", "sqlserver"),
1035            ("mssql", "sqlserver"),
1036            ("MySQL", "mysql"),
1037        ] {
1038            assert_eq!(by_name(name).unwrap().name(), expected, "for {name}");
1039        }
1040    }
1041
1042    #[test]
1043    fn an_unknown_database_lists_the_ones_that_exist() {
1044        let error = by_name("oracle").unwrap_err().to_string();
1045
1046        assert!(error.contains("postgres, mysql, sqlserver"), "{error}");
1047    }
1048}