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    /// The expression naming the schema this connection is working in.
171    ///
172    /// `information_schema` is standard; the way you ask "which schema am I in"
173    /// is not.
174    fn current_schema_expression(&self) -> &'static str;
175
176    /// A query returning one row per table in the current schema, with the name
177    /// in the first column.
178    ///
179    /// `migrate:fresh` enumerates and drops rather than running one clever
180    /// statement, because only PostgreSQL has an anonymous block to put a loop
181    /// in — and the enumerate-then-drop shape works identically everywhere.
182    fn list_tables_sql(&self) -> &'static str;
183
184    /// Turn off foreign key enforcement while tables are being dropped, so the
185    /// order they come back in does not matter.
186    fn disable_foreign_keys_sql(&self) -> Option<&'static str> {
187        None
188    }
189
190    fn enable_foreign_keys_sql(&self) -> Option<&'static str> {
191        None
192    }
193
194    /// Drop one table, including anything depending on it.
195    fn drop_table_sql(&self, table: &str) -> String {
196        format!("drop table if exists {}", self.quote(table))
197    }
198}
199
200/// Quote a possibly-qualified name one part at a time.
201pub fn quote_qualified(dialect: &dyn Dialect, name: &str) -> Result<String> {
202    let parts: Result<Vec<String>> = name
203        .split('.')
204        .map(|part| {
205            validate_identifier(part, dialect.max_identifier_length())
206                .map(|_| dialect.quote(part))
207        })
208        .collect();
209    Ok(parts?.join("."))
210}
211
212/// Reject anything that is not a plain identifier.
213///
214/// Identifiers cannot be sent as bound parameters, so every place the framework
215/// interpolates one into SQL passes through here first.
216pub fn validate_identifier(name: &str, max_length: usize) -> Result<()> {
217    let valid = !name.is_empty()
218        && name.len() <= max_length
219        && name.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
220        && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
221
222    if valid {
223        Ok(())
224    } else {
225        Err(Error::msg(format!(
226            "`{name}` is not a valid SQL identifier. Identifiers may contain letters, digits and \
227             underscores, must not start with a digit, and must be at most {max_length} characters."
228        )))
229    }
230}
231
232// --- PostgreSQL ---
233
234#[derive(Debug, Default, Clone, Copy)]
235pub struct Postgres;
236
237impl Dialect for Postgres {
238    fn name(&self) -> &'static str {
239        "postgres"
240    }
241
242    fn quote(&self, identifier: &str) -> String {
243        format!("\"{identifier}\"")
244    }
245
246    fn placeholder(&self, position: usize) -> String {
247        format!("${position}")
248    }
249
250    fn column_type(&self, kind: &ColumnType) -> String {
251        match kind {
252            ColumnType::Id => "bigserial".into(),
253            ColumnType::UuidId | ColumnType::Uuid => "uuid".into(),
254            ColumnType::SmallInteger => "smallint".into(),
255            ColumnType::Integer => "integer".into(),
256            ColumnType::BigInteger => "bigint".into(),
257            ColumnType::Float => "double precision".into(),
258            ColumnType::Decimal { precision, scale } => format!("numeric({precision}, {scale})"),
259            ColumnType::Boolean => "boolean".into(),
260            ColumnType::String { length } => format!("varchar({length})"),
261            ColumnType::Text => "text".into(),
262            ColumnType::Json => "jsonb".into(),
263            ColumnType::Date => "date".into(),
264            ColumnType::Time => "time".into(),
265            ColumnType::Timestamp => "timestamptz".into(),
266            ColumnType::Binary => "bytea".into(),
267            ColumnType::Raw(sql) => sql.clone(),
268        }
269    }
270
271    fn now(&self) -> &'static str {
272        "now()"
273    }
274
275    fn uuid_default(&self) -> Option<&'static str> {
276        Some("gen_random_uuid()")
277    }
278
279    fn returning(&self) -> ReturningStyle {
280        ReturningStyle::Suffix
281    }
282
283    fn limit_offset(&self, limit: Option<i64>, offset: Option<i64>, _ordered: bool) -> String {
284        let mut out = String::new();
285        if let Some(limit) = limit {
286            out.push_str(&format!(" limit {}", limit.max(0)));
287        }
288        if let Some(offset) = offset {
289            out.push_str(&format!(" offset {}", offset.max(0)));
290        }
291        out
292    }
293
294    fn supports_if_not_exists_index(&self) -> bool {
295        true
296    }
297
298    fn current_schema_expression(&self) -> &'static str {
299        "current_schema()"
300    }
301
302    fn list_tables_sql(&self) -> &'static str {
303        "select tablename from pg_tables where schemaname = current_schema()"
304    }
305
306    fn drop_table_sql(&self, table: &str) -> String {
307        // `cascade` also removes the foreign keys pointing at it, which is why
308        // PostgreSQL needs no enforcement switch.
309        format!("drop table if exists {} cascade", self.quote(table))
310    }
311}
312
313// --- MySQL ---
314
315#[derive(Debug, Default, Clone, Copy)]
316pub struct MySql;
317
318impl Dialect for MySql {
319    fn name(&self) -> &'static str {
320        "mysql"
321    }
322
323    fn quote(&self, identifier: &str) -> String {
324        format!("`{identifier}`")
325    }
326
327    fn placeholder(&self, _position: usize) -> String {
328        // MySQL binds by position in order, not by number.
329        "?".into()
330    }
331
332    fn column_type(&self, kind: &ColumnType) -> String {
333        match kind {
334            // Signed, matching PostgreSQL's bigserial and SQL Server's bigint
335            // identity. MySQL's convention is unsigned, but then a `bigint`
336            // foreign key cannot reference it — MySQL requires the types to
337            // match exactly, signedness included.
338            ColumnType::Id => "bigint not null auto_increment".into(),
339            // MySQL has no uuid type; 36 characters holds the canonical form.
340            ColumnType::UuidId | ColumnType::Uuid => "char(36)".into(),
341            ColumnType::SmallInteger => "smallint".into(),
342            ColumnType::Integer => "int".into(),
343            ColumnType::BigInteger => "bigint".into(),
344            ColumnType::Float => "double".into(),
345            ColumnType::Decimal { precision, scale } => format!("decimal({precision}, {scale})"),
346            // `boolean` is an alias for tinyint(1); spelled out so the schema
347            // says what the database actually stores.
348            ColumnType::Boolean => "tinyint(1)".into(),
349            ColumnType::String { length } => format!("varchar({length})"),
350            ColumnType::Text => "text".into(),
351            ColumnType::Json => "json".into(),
352            ColumnType::Date => "date".into(),
353            ColumnType::Time => "time".into(),
354            // Fractional seconds are not the default and cannot be added later
355            // without rewriting the table.
356            ColumnType::Timestamp => "datetime(6)".into(),
357            ColumnType::Binary => "longblob".into(),
358            ColumnType::Raw(sql) => sql.clone(),
359        }
360    }
361
362    fn now(&self) -> &'static str {
363        "current_timestamp(6)"
364    }
365
366    fn uuid_default(&self) -> Option<&'static str> {
367        // Only from MySQL 8.0.13, and only in an expression default; left off
368        // so the schema builder does not emit something a 5.7 server rejects.
369        None
370    }
371
372    fn returning(&self) -> ReturningStyle {
373        ReturningStyle::SeparateQuery("select last_insert_id()")
374    }
375
376    fn limit_offset(&self, limit: Option<i64>, offset: Option<i64>, _ordered: bool) -> String {
377        let mut out = String::new();
378        match (limit, offset) {
379            // MySQL cannot offset without a limit, so an offset alone gets the
380            // largest limit the syntax allows.
381            (None, Some(offset)) => {
382                out.push_str(&format!(" limit 18446744073709551615 offset {}", offset.max(0)));
383            }
384            (Some(limit), offset) => {
385                out.push_str(&format!(" limit {}", limit.max(0)));
386                if let Some(offset) = offset {
387                    out.push_str(&format!(" offset {}", offset.max(0)));
388                }
389            }
390            (None, None) => {}
391        }
392        out
393    }
394
395    fn booleans_are_integers(&self) -> bool {
396        true
397    }
398
399    fn max_identifier_length(&self) -> usize {
400        64
401    }
402
403    fn current_schema_expression(&self) -> &'static str {
404        // MySQL has no schemas separate from databases; the current database is
405        // the schema.
406        "database()"
407    }
408
409    fn list_tables_sql(&self) -> &'static str {
410        "select table_name from information_schema.tables \
411         where table_schema = database() and table_type = 'BASE TABLE'"
412    }
413
414    fn disable_foreign_keys_sql(&self) -> Option<&'static str> {
415        Some("set foreign_key_checks = 0")
416    }
417
418    fn enable_foreign_keys_sql(&self) -> Option<&'static str> {
419        Some("set foreign_key_checks = 1")
420    }
421}
422
423// --- SQL Server ---
424
425#[derive(Debug, Default, Clone, Copy)]
426pub struct SqlServer;
427
428impl Dialect for SqlServer {
429    fn name(&self) -> &'static str {
430        "sqlserver"
431    }
432
433    fn quote(&self, identifier: &str) -> String {
434        format!("[{identifier}]")
435    }
436
437    fn placeholder(&self, position: usize) -> String {
438        format!("@P{position}")
439    }
440
441    fn column_type(&self, kind: &ColumnType) -> String {
442        match kind {
443            ColumnType::Id => "bigint identity(1,1)".into(),
444            ColumnType::UuidId | ColumnType::Uuid => "uniqueidentifier".into(),
445            ColumnType::SmallInteger => "smallint".into(),
446            ColumnType::Integer => "int".into(),
447            ColumnType::BigInteger => "bigint".into(),
448            ColumnType::Float => "float".into(),
449            ColumnType::Decimal { precision, scale } => format!("decimal({precision}, {scale})"),
450            ColumnType::Boolean => "bit".into(),
451            // `n` prefixed: the framework speaks UTF-8, and nvarchar is the type
452            // that stores it without a collation surprise.
453            ColumnType::String { length } => format!("nvarchar({length})"),
454            ColumnType::Text | ColumnType::Json => "nvarchar(max)".into(),
455            ColumnType::Date => "date".into(),
456            ColumnType::Time => "time".into(),
457            ColumnType::Timestamp => "datetime2".into(),
458            ColumnType::Binary => "varbinary(max)".into(),
459            ColumnType::Raw(sql) => sql.clone(),
460        }
461    }
462
463    fn now(&self) -> &'static str {
464        "sysutcdatetime()"
465    }
466
467    fn uuid_default(&self) -> Option<&'static str> {
468        Some("newid()")
469    }
470
471    fn returning(&self) -> ReturningStyle {
472        ReturningStyle::OutputClause
473    }
474
475    fn limit_offset(&self, limit: Option<i64>, offset: Option<i64>, ordered: bool) -> String {
476        if limit.is_none() && offset.is_none() {
477            return String::new();
478        }
479
480        // `offset … fetch next …` is only legal after an `order by`, so an
481        // unordered paged query gets a placeholder ordering rather than a
482        // syntax error the caller cannot explain.
483        let mut out = String::new();
484        if !ordered {
485            out.push_str(" order by (select null)");
486        }
487        out.push_str(&format!(" offset {} rows", offset.unwrap_or(0).max(0)));
488        if let Some(limit) = limit {
489            out.push_str(&format!(" fetch next {} rows only", limit.max(0)));
490        }
491        out
492    }
493
494    fn supports_if_not_exists_table(&self) -> bool {
495        false
496    }
497
498    fn booleans_are_integers(&self) -> bool {
499        true
500    }
501
502    fn max_identifier_length(&self) -> usize {
503        128
504    }
505
506    fn migrations_table_sql(&self, table: &str) -> String {
507        // No `if not exists`; the catalogue is checked instead.
508        format!(
509            "if object_id('{table}', 'U') is null create table {} (\n  \
510             [id] bigint identity(1,1) primary key,\n  \
511             [name] nvarchar(255) not null unique,\n  \
512             [batch] int not null,\n  \
513             [ran_at] datetime2 not null default sysutcdatetime()\n)",
514            self.quote(table)
515        )
516    }
517
518    fn add_column_clause(&self) -> &'static str {
519        "add"
520    }
521
522    fn begin_sql(&self) -> &'static str {
523        "begin transaction"
524    }
525
526    fn commit_sql(&self) -> &'static str {
527        "commit transaction"
528    }
529
530    fn rollback_sql(&self) -> &'static str {
531        "rollback transaction"
532    }
533
534    fn savepoint_sql(&self, name: &str) -> String {
535        // T-SQL has no `savepoint` keyword; a named save point is made and
536        // returned to with `transaction`.
537        format!("save transaction {name}")
538    }
539
540    fn rollback_to_savepoint_sql(&self, name: &str) -> String {
541        format!("rollback transaction {name}")
542    }
543
544    fn current_schema_expression(&self) -> &'static str {
545        "schema_name()"
546    }
547
548    fn list_tables_sql(&self) -> &'static str {
549        // `is_ms_shipped = 0` excludes the system tables SQL Server keeps in
550        // some databases; without it, `migrate:fresh` pointed at `master` would
551        // try to drop Microsoft's own.
552        "select t.name from sys.tables t \
553         where t.is_ms_shipped = 0 and schema_name(t.schema_id) = schema_name()"
554    }
555
556    fn disable_foreign_keys_sql(&self) -> Option<&'static str> {
557        // Undocumented but long-standing: applies to every table at once.
558        Some("exec sp_MSforeachtable 'alter table ? nocheck constraint all'")
559    }
560
561    fn enable_foreign_keys_sql(&self) -> Option<&'static str> {
562        Some("exec sp_MSforeachtable 'alter table ? with check check constraint all'")
563    }
564}
565
566/// Build a dialect from its name.
567pub fn by_name(name: &str) -> Result<Box<dyn Dialect>> {
568    match name.to_ascii_lowercase().as_str() {
569        "postgres" | "postgresql" | "pgsql" => Ok(Box::new(Postgres)),
570        "mysql" | "mariadb" => Ok(Box::new(MySql)),
571        "sqlserver" | "mssql" => Ok(Box::new(SqlServer)),
572        other => Err(Error::msg(format!(
573            "`{other}` is not a database this framework speaks. Available: postgres, mysql, sqlserver."
574        ))),
575    }
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    fn all() -> Vec<Box<dyn Dialect>> {
583        vec![Box::new(Postgres), Box::new(MySql), Box::new(SqlServer)]
584    }
585
586    #[test]
587    fn each_dialect_quotes_the_way_its_database_expects() {
588        assert_eq!(Postgres.quote("users"), "\"users\"");
589        assert_eq!(MySql.quote("users"), "`users`");
590        assert_eq!(SqlServer.quote("users"), "[users]");
591    }
592
593    #[test]
594    fn placeholders_differ_in_kind_not_just_spelling() {
595        assert_eq!(Postgres.placeholder(1), "$1");
596        assert_eq!(Postgres.placeholder(3), "$3");
597
598        // MySQL binds positionally, so every placeholder is the same token.
599        assert_eq!(MySql.placeholder(1), "?");
600        assert_eq!(MySql.placeholder(3), "?");
601
602        assert_eq!(SqlServer.placeholder(3), "@P3");
603    }
604
605    #[test]
606    fn a_qualified_name_is_quoted_one_part_at_a_time() {
607        assert_eq!(
608            quote_qualified(&Postgres, "public.users").unwrap(),
609            "\"public\".\"users\""
610        );
611        assert_eq!(quote_qualified(&MySql, "shop.orders").unwrap(), "`shop`.`orders`");
612        assert_eq!(quote_qualified(&SqlServer, "dbo.users").unwrap(), "[dbo].[users]");
613    }
614
615    #[test]
616    fn an_injected_identifier_is_rejected_by_every_dialect() {
617        for dialect in all() {
618            for hostile in ["users; drop table users", "a b", "1abc", "", "us\"er"] {
619                assert!(
620                    quote_qualified(dialect.as_ref(), hostile).is_err(),
621                    "{} accepted {hostile:?}",
622                    dialect.name()
623                );
624            }
625        }
626    }
627
628    #[test]
629    fn identifier_length_limits_follow_the_database() {
630        let long = "a".repeat(100);
631
632        assert!(validate_identifier(&long, Postgres.max_identifier_length()).is_err());
633        assert!(validate_identifier(&long, MySql.max_identifier_length()).is_err());
634        assert!(validate_identifier(&long, SqlServer.max_identifier_length()).is_ok());
635    }
636
637    #[test]
638    fn the_key_column_is_auto_incrementing_everywhere() {
639        assert_eq!(Postgres.column_type(&ColumnType::Id), "bigserial");
640        assert_eq!(MySql.column_type(&ColumnType::Id), "bigint not null auto_increment");
641        assert_eq!(SqlServer.column_type(&ColumnType::Id), "bigint identity(1,1)");
642    }
643
644    #[test]
645    fn text_and_json_map_to_what_each_database_actually_has() {
646        assert_eq!(Postgres.column_type(&ColumnType::Json), "jsonb");
647        assert_eq!(MySql.column_type(&ColumnType::Json), "json");
648        // SQL Server has no JSON type; it stores the document as text.
649        assert_eq!(SqlServer.column_type(&ColumnType::Json), "nvarchar(max)");
650    }
651
652    #[test]
653    fn a_string_column_carries_its_length_everywhere() {
654        let kind = ColumnType::String { length: 120 };
655
656        assert_eq!(Postgres.column_type(&kind), "varchar(120)");
657        assert_eq!(MySql.column_type(&kind), "varchar(120)");
658        assert_eq!(SqlServer.column_type(&kind), "nvarchar(120)");
659    }
660
661    #[test]
662    fn paging_uses_each_databases_own_syntax() {
663        assert_eq!(Postgres.limit_offset(Some(10), Some(20), true), " limit 10 offset 20");
664        assert_eq!(MySql.limit_offset(Some(10), Some(20), true), " limit 10 offset 20");
665        assert_eq!(
666            SqlServer.limit_offset(Some(10), Some(20), true),
667            " offset 20 rows fetch next 10 rows only"
668        );
669    }
670
671    #[test]
672    fn sql_server_supplies_an_ordering_when_paging_has_none() {
673        // `offset` is a syntax error without `order by`, and a caller cannot
674        // debug an error the builder could have avoided.
675        let paged = SqlServer.limit_offset(Some(10), None, false);
676        assert!(paged.starts_with(" order by (select null)"), "{paged}");
677
678        // With an ordering already present, none is added.
679        assert!(!SqlServer.limit_offset(Some(10), None, true).contains("order by"));
680    }
681
682    #[test]
683    fn mysql_cannot_offset_without_a_limit() {
684        let offset_only = MySql.limit_offset(None, Some(20), true);
685
686        assert!(offset_only.contains("limit 18446744073709551615"), "{offset_only}");
687        assert!(offset_only.ends_with("offset 20"));
688    }
689
690    #[test]
691    fn no_paging_produces_no_clause() {
692        for dialect in all() {
693            assert_eq!(dialect.limit_offset(None, None, true), "", "{}", dialect.name());
694        }
695    }
696
697    #[test]
698    fn generated_keys_come_back_differently() {
699        assert_eq!(Postgres.returning(), ReturningStyle::Suffix);
700        assert_eq!(SqlServer.returning(), ReturningStyle::OutputClause);
701        assert_eq!(
702            MySql.returning(),
703            ReturningStyle::SeparateQuery("select last_insert_id()")
704        );
705    }
706
707    #[test]
708    fn the_migration_table_is_valid_for_each_database() {
709        let postgres = Postgres.migrations_table_sql("rustlavel_migrations");
710        assert!(postgres.contains("create table if not exists \"rustlavel_migrations\""));
711        assert!(postgres.contains("bigserial primary key"));
712
713        let mysql = MySql.migrations_table_sql("rustlavel_migrations");
714        assert!(mysql.contains("`rustlavel_migrations`"));
715        // MySQL rejects an auto_increment column that is not a key.
716        assert!(mysql.contains("auto_increment primary key"), "{mysql}");
717
718        // SQL Server has no `if not exists`, so it checks the catalogue.
719        let sqlserver = SqlServer.migrations_table_sql("rustlavel_migrations");
720        assert!(sqlserver.starts_with("if object_id("));
721        assert!(sqlserver.contains("identity(1,1)"));
722    }
723
724    #[test]
725    fn transaction_control_uses_each_databases_own_words() {
726        // `begin` alone is a syntax error in T-SQL, which would have broken
727        // every transaction on SQL Server.
728        assert_eq!(Postgres.begin_sql(), "begin");
729        assert_eq!(MySql.begin_sql(), "begin");
730        assert_eq!(SqlServer.begin_sql(), "begin transaction");
731
732        assert_eq!(SqlServer.commit_sql(), "commit transaction");
733        assert_eq!(SqlServer.rollback_sql(), "rollback transaction");
734        assert_eq!(SqlServer.savepoint_sql("sp1"), "save transaction sp1");
735        assert_eq!(SqlServer.rollback_to_savepoint_sql("sp1"), "rollback transaction sp1");
736
737        assert_eq!(Postgres.savepoint_sql("sp1"), "savepoint sp1");
738        assert_eq!(Postgres.rollback_to_savepoint_sql("sp1"), "rollback to savepoint sp1");
739    }
740
741    #[test]
742    fn every_dialect_can_name_the_schema_it_is_in() {
743        assert_eq!(Postgres.current_schema_expression(), "current_schema()");
744        assert_eq!(MySql.current_schema_expression(), "database()");
745        assert_eq!(SqlServer.current_schema_expression(), "schema_name()");
746    }
747
748    #[test]
749    fn every_dialect_can_enumerate_its_own_tables() {
750        for dialect in all() {
751            let sql = dialect.list_tables_sql();
752
753            assert!(sql.starts_with("select "), "{}: {sql}", dialect.name());
754            // The query must be scoped to the current schema, or `migrate:fresh`
755            // would reach into someone else's database.
756            assert!(
757                sql.contains("current_schema()")
758                    || sql.contains("database()")
759                    || sql.contains("schema_name()"),
760                "{} does not scope its table list: {sql}",
761                dialect.name()
762            );
763        }
764    }
765
766    #[test]
767    fn sql_server_adds_a_column_without_saying_column() {
768        // Confirmed against a live server: `add column` is a syntax error there,
769        // while `drop column` is required. The asymmetry is real.
770        assert_eq!(Postgres.add_column_clause(), "add column");
771        assert_eq!(MySql.add_column_clause(), "add column");
772        assert_eq!(SqlServer.add_column_clause(), "add");
773    }
774
775    #[test]
776    fn sql_server_never_lists_microsofts_own_tables() {
777        // `master` ships system tables in dbo; dropping those is not what
778        // `migrate:fresh` is for.
779        assert!(SqlServer.list_tables_sql().contains("is_ms_shipped = 0"));
780    }
781
782    #[test]
783    fn dropping_a_table_takes_its_dependants_with_it() {
784        // PostgreSQL says so explicitly; the others need the enforcement
785        // switched off around the whole run instead.
786        assert!(Postgres.drop_table_sql("users").ends_with("cascade"));
787        assert!(Postgres.disable_foreign_keys_sql().is_none());
788
789        assert_eq!(MySql.drop_table_sql("users"), "drop table if exists `users`");
790        assert!(MySql.disable_foreign_keys_sql().is_some());
791        assert!(MySql.enable_foreign_keys_sql().is_some());
792
793        assert_eq!(SqlServer.drop_table_sql("users"), "drop table if exists [users]");
794        assert!(SqlServer.disable_foreign_keys_sql().is_some());
795    }
796
797    #[test]
798    fn dialects_are_found_by_the_names_people_use() {
799        for (name, expected) in [
800            ("postgres", "postgres"),
801            ("postgresql", "postgres"),
802            ("mysql", "mysql"),
803            ("mariadb", "mysql"),
804            ("sqlserver", "sqlserver"),
805            ("mssql", "sqlserver"),
806            ("MySQL", "mysql"),
807        ] {
808            assert_eq!(by_name(name).unwrap().name(), expected, "for {name}");
809        }
810    }
811
812    #[test]
813    fn an_unknown_database_lists_the_ones_that_exist() {
814        let error = by_name("oracle").unwrap_err().to_string();
815
816        assert!(error.contains("postgres, mysql, sqlserver"), "{error}");
817    }
818}