1use rustlavel_core::{Error, Result};
12
13#[derive(Debug, Clone, PartialEq)]
20pub enum ColumnType {
21 Id,
23 UuidId,
25 SmallInteger,
26 Integer,
27 BigInteger,
28 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 Raw(String),
42}
43
44#[derive(Debug, Clone, PartialEq)]
46pub enum ReturningStyle {
47 Suffix,
49 OutputClause,
52 SeparateQuery(&'static str),
54}
55
56pub trait Dialect: Send + Sync + std::fmt::Debug + 'static {
58 fn name(&self) -> &'static str;
60
61 fn quote(&self, identifier: &str) -> String;
65
66 fn placeholder(&self, position: usize) -> String;
68
69 fn column_type(&self, kind: &ColumnType) -> String;
71
72 fn now(&self) -> &'static str;
74
75 fn uuid_default(&self) -> Option<&'static str>;
77
78 fn returning(&self) -> ReturningStyle;
80
81 fn limit_offset(&self, limit: Option<i64>, offset: Option<i64>, ordered: bool) -> String;
86
87 fn supports_if_not_exists_table(&self) -> bool {
89 true
90 }
91
92 fn supports_if_not_exists_index(&self) -> bool {
98 false
99 }
100
101 fn booleans_are_integers(&self) -> bool {
107 false
108 }
109
110 fn max_identifier_length(&self) -> usize {
112 63
113 }
114
115 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 fn add_column_clause(&self) -> &'static str {
142 "add column"
143 }
144
145 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 fn current_schema_expression(&self) -> &'static str;
175
176 fn list_tables_sql(&self) -> &'static str;
183
184 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 fn drop_table_sql(&self, table: &str) -> String {
196 format!("drop table if exists {}", self.quote(table))
197 }
198}
199
200pub 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
212pub 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#[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 format!("drop table if exists {} cascade", self.quote(table))
310 }
311}
312
313#[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 "?".into()
330 }
331
332 fn column_type(&self, kind: &ColumnType) -> String {
333 match kind {
334 ColumnType::Id => "bigint not null auto_increment".into(),
339 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 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 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 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 (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 "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#[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 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 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 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 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 "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 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
566pub 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 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 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 let paged = SqlServer.limit_offset(Some(10), None, false);
676 assert!(paged.starts_with(" order by (select null)"), "{paged}");
677
678 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 assert!(mysql.contains("auto_increment primary key"), "{mysql}");
717
718 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 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 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 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 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 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}