Skip to main content

vespertide_query/sql/
create_table.rs

1use sea_query::{Alias, ForeignKey, Index, Table, TableCreateStatement};
2
3use vespertide_core::{ColumnDef, ColumnType, ComplexColumnType, TableConstraint};
4
5use super::helpers::{
6    build_create_enum_type_sql, build_create_with_checks, build_sea_column_def_with_table,
7    collect_sqlite_enum_check_clauses, to_sea_fk_action,
8};
9use super::types::{BuiltQuery, DatabaseBackend};
10use crate::error::QueryError;
11
12pub(crate) fn build_create_table_for_backend(
13    backend: DatabaseBackend,
14    table: &str,
15    columns: &[ColumnDef],
16    constraints: &[TableConstraint],
17) -> TableCreateStatement {
18    let mut stmt = Table::create().table(Alias::new(table)).to_owned();
19    add_create_table_columns(&mut stmt, backend, table, columns, constraints);
20    add_create_table_constraints(&mut stmt, backend, table, columns, constraints);
21    stmt
22}
23
24fn add_create_table_columns(
25    stmt: &mut TableCreateStatement,
26    backend: DatabaseBackend,
27    table: &str,
28    columns: &[ColumnDef],
29    constraints: &[TableConstraint],
30) {
31    let has_table_primary_key = constraints
32        .iter()
33        .any(|c| matches!(c, TableConstraint::PrimaryKey { .. }));
34    // The vast majority of tables have no `PrimaryKey { auto_increment: true }`
35    // constraint, so skip the `HashSet` allocation + constraint scan entirely in
36    // that common case; an empty set produces byte-identical output.
37    let auto_increment_columns = if constraints.iter().any(|c| {
38        matches!(
39            c,
40            TableConstraint::PrimaryKey {
41                auto_increment: true,
42                ..
43            }
44        )
45    }) {
46        collect_auto_increment_columns(constraints)
47    } else {
48        std::collections::HashSet::new()
49    };
50
51    for column in columns {
52        let mut col = build_sea_column_def_with_table(backend, table, column);
53        if column.primary_key.is_some() && !has_table_primary_key {
54            col.primary_key();
55        }
56        if auto_increment_columns.contains(column.name.as_str())
57            && column.r#type.supports_auto_increment()
58        {
59            if matches!(backend, DatabaseBackend::Sqlite) {
60                col.primary_key();
61            }
62            col.auto_increment();
63        }
64        stmt.col(col);
65    }
66}
67
68fn collect_auto_increment_columns(
69    constraints: &[TableConstraint],
70) -> std::collections::HashSet<&str> {
71    constraints
72        .iter()
73        .filter_map(|c| {
74            if let TableConstraint::PrimaryKey {
75                columns: pk_cols,
76                auto_increment: true,
77                ..
78            } = c
79            {
80                Some(pk_cols.iter().map(AsRef::as_ref))
81            } else {
82                None
83            }
84        })
85        .flatten()
86        .collect()
87}
88
89fn add_create_table_constraints(
90    stmt: &mut TableCreateStatement,
91    backend: DatabaseBackend,
92    table: &str,
93    columns: &[ColumnDef],
94    constraints: &[TableConstraint],
95) {
96    for constraint in constraints {
97        match constraint {
98            TableConstraint::PrimaryKey {
99                columns: pk_cols,
100                auto_increment,
101                ..
102            } => add_primary_key_constraint(stmt, backend, columns, pk_cols, *auto_increment),
103            TableConstraint::Unique {
104                name,
105                columns: unique_cols,
106                ..
107            } => add_mysql_unique_constraint(stmt, backend, table, unique_cols, name.as_deref()),
108            TableConstraint::ForeignKey { .. } => {
109                add_foreign_key_constraint(stmt, table, constraint);
110            }
111            // CHECK is emitted elsewhere; indexes are created separately. Both
112            // are no-ops during CREATE TABLE constraint assembly.
113            TableConstraint::Check { .. } | TableConstraint::Index { .. } => {}
114            _ => {
115                unreachable!("TableConstraint is #[non_exhaustive]; all variants are matched above")
116            }
117        }
118    }
119}
120
121fn add_primary_key_constraint(
122    stmt: &mut TableCreateStatement,
123    backend: DatabaseBackend,
124    columns: &[ColumnDef],
125    pk_cols: &[impl AsRef<str>],
126    auto_increment: bool,
127) {
128    if should_skip_sqlite_auto_increment_pk(backend, columns, pk_cols, auto_increment) {
129        return;
130    }
131
132    let mut pk_idx = Index::create();
133    for c in pk_cols {
134        pk_idx.col(Alias::new(c.as_ref()));
135    }
136    stmt.primary_key(&mut pk_idx);
137}
138
139fn should_skip_sqlite_auto_increment_pk(
140    backend: DatabaseBackend,
141    columns: &[ColumnDef],
142    pk_cols: &[impl AsRef<str>],
143    auto_increment: bool,
144) -> bool {
145    matches!(backend, DatabaseBackend::Sqlite)
146        && auto_increment
147        && pk_cols.iter().all(|col_name| {
148            columns
149                .iter()
150                .any(|c| c.name == col_name.as_ref() && c.r#type.supports_auto_increment())
151        })
152}
153
154fn add_mysql_unique_constraint(
155    stmt: &mut TableCreateStatement,
156    backend: DatabaseBackend,
157    table: &str,
158    unique_cols: &[impl AsRef<str>],
159    name: Option<&str>,
160) {
161    if !matches!(backend, DatabaseBackend::MySql) {
162        return;
163    }
164
165    let index_name = super::helpers::build_unique_constraint_name(table, unique_cols, name);
166    let mut idx = Index::create().name(&index_name).unique().to_owned();
167    for col in unique_cols {
168        idx.col(Alias::new(col.as_ref()));
169    }
170    stmt.index(&mut idx);
171}
172
173fn add_foreign_key_constraint(
174    stmt: &mut TableCreateStatement,
175    table: &str,
176    constraint: &TableConstraint,
177) {
178    if let TableConstraint::ForeignKey {
179        name,
180        columns: fk_cols,
181        ref_table,
182        ref_columns,
183        on_delete,
184        on_update,
185        ..
186    } = constraint
187    {
188        let fk_name = super::helpers::build_foreign_key_name(table, fk_cols, name.as_deref());
189        let mut fk = ForeignKey::create().name(&fk_name).to_owned();
190        fk.from_tbl(Alias::new(table));
191        for col in fk_cols {
192            fk.from_col(Alias::new(col));
193        }
194        fk.to_tbl(Alias::new(ref_table));
195        for col in ref_columns {
196            fk.to_col(Alias::new(col));
197        }
198        if let Some(action) = on_delete {
199            fk.on_delete(to_sea_fk_action(action));
200        }
201        if let Some(action) = on_update {
202            fk.on_update(to_sea_fk_action(action));
203        }
204        stmt.foreign_key(&mut fk);
205    }
206}
207
208pub fn build_create_table(
209    backend: DatabaseBackend,
210    table: &str,
211    columns: &[ColumnDef],
212    constraints: &[TableConstraint],
213) -> Result<Vec<BuiltQuery>, QueryError> {
214    // Normalize the table to convert inline constraints to table-level
215    // This ensures we don't have duplicate constraints if both inline and table-level are defined
216    let table_def = vespertide_core::TableDef {
217        description: None,
218        name: table.into(),
219        columns: columns.to_vec(),
220        constraints: constraints.to_vec(),
221    };
222    let normalized = table_def.normalize().map_err(|e| {
223        QueryError::SchemaError(format!("Failed to normalize table '{table}': {e}"))
224    })?;
225
226    // Use normalized columns and constraints for SQL generation
227    let columns = &normalized.columns;
228    let constraints = &normalized.constraints;
229
230    // Pre-size for the CREATE TABLE statement plus up to one CREATE INDEX per
231    // constraint (uniques + indexes); enum-type `Raw`s are rare and bounded by
232    // the enum-column count, so a small over/under-estimate costs at most one
233    // realloc, never correctness.
234    let mut queries = Vec::with_capacity(1 + constraints.len());
235
236    // Create enum types first (PostgreSQL only)
237    // Collect unique enum types to avoid duplicates.
238    // The vast majority of tables have no enum columns, so skip the `HashSet`
239    // allocation + column scan entirely in that common case; the loop pushes
240    // nothing when there are no enum columns, so the output is byte-identical.
241    let has_enum_columns = columns.iter().any(|c| {
242        matches!(
243            c.r#type,
244            ColumnType::Complex(ComplexColumnType::Enum { .. })
245        )
246    });
247    if has_enum_columns {
248        let mut created_enums: std::collections::HashSet<&str> = std::collections::HashSet::new();
249        for column in columns {
250            if let ColumnType::Complex(ComplexColumnType::Enum { name, .. }) = &column.r#type
251                && created_enums.insert(name.as_str())
252                && let Some(create_type_sql) = build_create_enum_type_sql(table, &column.r#type)
253            {
254                queries.push(BuiltQuery::Raw(create_type_sql));
255            }
256        }
257    }
258
259    // Build CREATE TABLE
260    // For MySQL, include unique constraints in CREATE TABLE (no partition needed).
261    // For Postgres and SQLite, exclude them here — they are emitted below as
262    // separate CREATE UNIQUE INDEX statements, so partition off the uniques only
263    // in that branch (the MySQL path discards both halves, so allocating them
264    // there is pure waste).
265    let has_unique_constraints = constraints
266        .iter()
267        .any(|c| matches!(c, TableConstraint::Unique { .. }));
268    let create_table_stmt = if matches!(backend, DatabaseBackend::MySql) || !has_unique_constraints
269    {
270        // MySQL keeps uniques inline; the PG/SQLite path emits them as separate
271        // CREATE UNIQUE INDEX statements below, so when there are no uniques to
272        // strip the constraint slice can pass straight through with no clone.
273        build_create_table_for_backend(backend, table, columns, constraints)
274    } else {
275        // PG/SQLite with at least one unique: partition off the uniques into an
276        // owned Vec so they are excluded from the inline CREATE TABLE.
277        let table_constraints_owned: Vec<TableConstraint> = constraints
278            .iter()
279            .filter(|c| !matches!(c, TableConstraint::Unique { .. }))
280            .cloned()
281            .collect();
282        build_create_table_for_backend(backend, table, columns, &table_constraints_owned)
283    };
284
285    // For SQLite, add CHECK constraints for enum columns
286    if matches!(backend, DatabaseBackend::Sqlite) {
287        let enum_check_clauses = collect_sqlite_enum_check_clauses(table, columns);
288        if enum_check_clauses.is_empty() {
289            queries.push(BuiltQuery::CreateTable(Box::new(create_table_stmt)));
290        } else {
291            // Embed CHECK constraints into the CREATE TABLE statement via the
292            // single source of truth for CHECK injection.
293            queries.push(build_create_with_checks(
294                backend,
295                &create_table_stmt,
296                &enum_check_clauses,
297            ));
298        }
299    } else {
300        queries.push(BuiltQuery::CreateTable(Box::new(create_table_stmt)));
301    }
302
303    // For Postgres and SQLite, add unique constraints as separate CREATE UNIQUE INDEX statements
304    if matches!(backend, DatabaseBackend::Postgres | DatabaseBackend::Sqlite) {
305        for constraint in constraints {
306            if let TableConstraint::Unique {
307                name,
308                columns: unique_cols,
309                ..
310            } = constraint
311            {
312                // Always generate a proper name: uq_{table}_{key} or uq_{table}_{columns}
313                let index_name = super::helpers::build_unique_constraint_name(
314                    table,
315                    unique_cols,
316                    name.as_deref(),
317                );
318                let mut idx = Index::create()
319                    .table(Alias::new(table))
320                    .name(&index_name)
321                    .unique()
322                    .to_owned();
323                for col in unique_cols {
324                    idx.col(Alias::new(col));
325                }
326                queries.push(BuiltQuery::CreateIndex(Box::new(idx)));
327            }
328        }
329    }
330
331    // Add Index constraints as CREATE INDEX statements (for all backends)
332    for constraint in constraints {
333        if let TableConstraint::Index {
334            name,
335            columns: index_cols,
336        } = constraint
337        {
338            // Always generate a proper name: ix_{table}_{key} or ix_{table}_{columns}
339            let index_name = super::helpers::build_index_name(table, index_cols, name.as_deref());
340            let mut idx = Index::create()
341                .table(Alias::new(table))
342                .name(&index_name)
343                .to_owned();
344            for col in index_cols {
345                idx.col(Alias::new(col));
346            }
347            queries.push(BuiltQuery::CreateIndex(Box::new(idx)));
348        }
349    }
350
351    Ok(queries)
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::test_support::{col, joined_sql};
358    use insta::{assert_snapshot, with_settings};
359    use rstest::rstest;
360    use vespertide_core::{ColumnType, EnumValues, SimpleColumnType};
361
362    fn join_queries(queries: &[BuiltQuery], backend: DatabaseBackend, separator: &str) -> String {
363        let mut sql = String::new();
364        for (index, query) in queries.iter().enumerate() {
365            if index > 0 {
366                sql.push_str(separator);
367            }
368            sql.push_str(&query.build(backend));
369        }
370        sql
371    }
372
373    #[rstest]
374    #[case::create_table_postgres(
375        "create_table_postgres",
376        DatabaseBackend::Postgres,
377        &["CREATE TABLE \"users\" ( \"id\" integer )"]
378    )]
379    #[case::create_table_mysql(
380        "create_table_mysql",
381        DatabaseBackend::MySql,
382        &["CREATE TABLE `users` ( `id` int )"]
383    )]
384    #[case::create_table_sqlite(
385        "create_table_sqlite",
386        DatabaseBackend::Sqlite,
387        &["CREATE TABLE \"users\" ( \"id\" integer )"]
388    )]
389    fn test_create_table(
390        #[case] title: &str,
391        #[case] backend: DatabaseBackend,
392        #[case] expected: &[&str],
393    ) {
394        let result = build_create_table(
395            backend,
396            "users",
397            &[col("id", ColumnType::Simple(SimpleColumnType::Integer))],
398            &[],
399        )
400        .unwrap();
401        let sql = join_queries(&result, backend, "\n");
402        for exp in expected {
403            assert!(
404                sql.contains(exp),
405                "Expected SQL to contain '{exp}', got: {sql}"
406            );
407        }
408
409        with_settings!({ snapshot_suffix => format!("create_table_{}", title) }, {
410            assert_snapshot!(sql);
411        });
412    }
413
414    #[rstest]
415    #[case::inline_unique_postgres(DatabaseBackend::Postgres)]
416    #[case::inline_unique_mysql(DatabaseBackend::MySql)]
417    #[case::inline_unique_sqlite(DatabaseBackend::Sqlite)]
418    fn test_create_table_with_inline_unique(#[case] backend: DatabaseBackend) {
419        // Test that inline unique constraint is converted to table-level during normalization.
420        // build_create_table now normalizes the table, so inline unique becomes a CREATE UNIQUE INDEX.
421        use vespertide_core::schema::str_or_bool::StrOrBoolOrArray;
422
423        let mut email_col = col("email", ColumnType::Simple(SimpleColumnType::Text));
424        email_col.unique = Some(StrOrBoolOrArray::Bool(true));
425
426        let result = build_create_table(
427            backend,
428            "users",
429            &[
430                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
431                email_col,
432            ],
433            // No explicit table-level unique constraint passed, but normalize will create one from inline
434            &[],
435        )
436        .unwrap();
437        let sql = join_queries(&result, backend, "\n");
438
439        // After normalization, inline unique should produce UNIQUE constraint in SQL
440        assert!(
441            sql.contains("UNIQUE") || sql.to_uppercase().contains("UNIQUE"),
442            "Normalized unique constraint should be in SQL, but not found: {sql}"
443        );
444        with_settings!({ snapshot_suffix => format!("create_table_with_inline_unique_{:?}", backend) }, {
445            assert_snapshot!(sql);
446        });
447    }
448
449    #[rstest]
450    #[case::table_level_unique_postgres(DatabaseBackend::Postgres)]
451    #[case::table_level_unique_mysql(DatabaseBackend::MySql)]
452    #[case::table_level_unique_sqlite(DatabaseBackend::Sqlite)]
453    fn test_create_table_with_table_level_unique(#[case] backend: DatabaseBackend) {
454        // Test table-level unique constraint (lines 53-54, 56-58, 60-61)
455        let result = build_create_table(
456            backend,
457            "users",
458            &[
459                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
460                col("email", ColumnType::Simple(SimpleColumnType::Text)),
461            ],
462            &[TableConstraint::Unique {
463                name: Some("uq_email".into()),
464                columns: vec!["email".into()],
465                strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
466                    keep: vespertide_core::KeepPolicy::First,
467                },
468            }],
469        )
470        .unwrap();
471        let sql = join_queries(&result, backend, "\n");
472        assert!(sql.contains("CREATE TABLE"));
473        // Verify unique constraint is present
474        match backend {
475            DatabaseBackend::MySql => {
476                assert!(
477                    sql.contains("UNIQUE"),
478                    "MySQL should have UNIQUE in CREATE TABLE: {sql}"
479                );
480            }
481            _ => {
482                // For Postgres and SQLite, unique constraint should be in a separate CREATE UNIQUE INDEX statement
483                assert!(
484                    sql.contains("CREATE UNIQUE INDEX"),
485                    "Postgres/SQLite should have CREATE UNIQUE INDEX: {sql}"
486                );
487            }
488        }
489        with_settings!({ snapshot_suffix => format!("create_table_with_table_level_unique_{:?}", backend) }, {
490            assert_snapshot!(sql);
491        });
492    }
493
494    /// The unique-only fixture above cannot see whether the PG/SQLite branch
495    /// *strips* uniques or *keeps only* uniques - both leave the same effective
496    /// constraint set when the unique is the sole entry. Pairing the unique with
497    /// a PRIMARY KEY makes the two behaviours diverge: keeping only uniques
498    /// would drop the PK from the emitted CREATE TABLE.
499    #[rstest]
500    #[case::unique_beside_pk_postgres(DatabaseBackend::Postgres)]
501    #[case::unique_beside_pk_mysql(DatabaseBackend::MySql)]
502    #[case::unique_beside_pk_sqlite(DatabaseBackend::Sqlite)]
503    fn test_create_table_unique_alongside_primary_key(#[case] backend: DatabaseBackend) {
504        let result = build_create_table(
505            backend,
506            "users",
507            &[
508                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
509                col("email", ColumnType::Simple(SimpleColumnType::Text)),
510            ],
511            &[
512                TableConstraint::PrimaryKey {
513                    columns: vec!["id".into()],
514                    auto_increment: false,
515                    strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(),
516                },
517                TableConstraint::Unique {
518                    name: Some("uq_email".into()),
519                    columns: vec!["email".into()],
520                    strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
521                        keep: vespertide_core::KeepPolicy::First,
522                    },
523                },
524            ],
525        )
526        .unwrap();
527        let sql = join_queries(&result, backend, "\n");
528
529        assert!(
530            sql.contains("PRIMARY KEY"),
531            "the PK must survive the unique partitioning: {sql}"
532        );
533        with_settings!({ snapshot_suffix => format!("create_table_unique_alongside_primary_key_{:?}", backend) }, {
534            assert_snapshot!(sql);
535        });
536    }
537
538    #[rstest]
539    #[case::table_level_unique_no_name_postgres(DatabaseBackend::Postgres)]
540    #[case::table_level_unique_no_name_mysql(DatabaseBackend::MySql)]
541    #[case::table_level_unique_no_name_sqlite(DatabaseBackend::Sqlite)]
542    fn test_create_table_with_table_level_unique_no_name(#[case] backend: DatabaseBackend) {
543        // Test table-level unique constraint without name (lines 53-54, 56-58, 60-61)
544        let result = build_create_table(
545            backend,
546            "users",
547            &[
548                col("id", ColumnType::Simple(SimpleColumnType::Integer)),
549                col("email", ColumnType::Simple(SimpleColumnType::Text)),
550            ],
551            &[TableConstraint::Unique {
552                name: None,
553                columns: vec!["email".into()],
554                strategy: vespertide_core::UniqueConstraintStrategy::DeleteDuplicates {
555                    keep: vespertide_core::KeepPolicy::First,
556                },
557            }],
558        )
559        .unwrap();
560        let sql = join_queries(&result, backend, "\n");
561        assert!(sql.contains("CREATE TABLE"));
562        // Verify unique constraint is present
563        match backend {
564            DatabaseBackend::MySql => {
565                assert!(
566                    sql.contains("UNIQUE"),
567                    "MySQL should have UNIQUE in CREATE TABLE: {sql}"
568                );
569            }
570            _ => {
571                // For Postgres and SQLite, unique constraint should be in a separate CREATE UNIQUE INDEX statement
572                assert!(
573                    sql.contains("CREATE UNIQUE INDEX"),
574                    "Postgres/SQLite should have CREATE UNIQUE INDEX: {sql}"
575                );
576            }
577        }
578        with_settings!({ snapshot_suffix => format!("create_table_with_table_level_unique_no_name_{:?}", backend) }, {
579            assert_snapshot!(sql);
580        });
581    }
582
583    #[rstest]
584    #[case::postgres(DatabaseBackend::Postgres)]
585    #[case::mysql(DatabaseBackend::MySql)]
586    #[case::sqlite(DatabaseBackend::Sqlite)]
587    fn test_create_table_with_enum_column(#[case] backend: DatabaseBackend) {
588        // Test creating a table with an enum column (should create enum type first for PostgreSQL)
589        let columns = vec![
590            ColumnDef {
591                name: "id".into(),
592                r#type: ColumnType::Simple(SimpleColumnType::Integer),
593                nullable: false,
594                default: None,
595                comment: None,
596                primary_key: None,
597                unique: None,
598                index: None,
599                foreign_key: None,
600            },
601            ColumnDef {
602                name: "status".into(),
603                r#type: ColumnType::Complex(ComplexColumnType::Enum {
604                    name: "user_status".into(),
605                    values: EnumValues::String(vec![
606                        "active".into(),
607                        "inactive".into(),
608                        "pending".into(),
609                    ]),
610                }),
611                nullable: false,
612                default: Some("'active'".into()),
613                comment: None,
614                primary_key: None,
615                unique: None,
616                index: None,
617                foreign_key: None,
618            },
619        ];
620        let constraints = vec![TableConstraint::PrimaryKey {
621            auto_increment: false,
622            columns: vec!["id".into()],
623            strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(),
624        }];
625
626        let result = build_create_table(backend, "users", &columns, &constraints);
627        assert!(result.is_ok());
628        let queries = result.unwrap();
629        let sql = join_queries(&queries, backend, ";\n");
630
631        with_settings!({ snapshot_suffix => format!("create_table_with_enum_column_{:?}", backend) }, {
632            assert_snapshot!(sql);
633        });
634    }
635
636    #[rstest]
637    #[case::auto_increment_postgres(DatabaseBackend::Postgres)]
638    #[case::auto_increment_mysql(DatabaseBackend::MySql)]
639    #[case::auto_increment_sqlite(DatabaseBackend::Sqlite)]
640    fn test_create_table_with_auto_increment_primary_key(#[case] backend: DatabaseBackend) {
641        // Test that auto_increment on primary key generates SERIAL/AUTO_INCREMENT/AUTOINCREMENT
642        let columns = vec![ColumnDef {
643            name: "id".into(),
644            r#type: ColumnType::Simple(SimpleColumnType::Integer),
645            nullable: false,
646            default: None,
647            comment: None,
648            primary_key: None,
649            unique: None,
650            index: None,
651            foreign_key: None,
652        }];
653        let constraints = vec![TableConstraint::PrimaryKey {
654            auto_increment: true,
655            columns: vec!["id".into()],
656            strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(),
657        }];
658
659        let result = build_create_table(backend, "users", &columns, &constraints);
660        assert!(result.is_ok());
661        let queries = result.unwrap();
662        let sql = join_queries(&queries, backend, ";\n");
663
664        // Verify auto_increment is applied correctly for each backend
665        match backend {
666            DatabaseBackend::Postgres => {
667                // sea-query 1.0.0-rc.33 renders PG auto-increment as the
668                // SQL-standard `GENERATED BY DEFAULT AS IDENTITY` (PG 10+),
669                // whereas the 1.0.0 final line used `SERIAL`. Both express an
670                // auto-incrementing PK; accept either form.
671                assert!(
672                    sql.contains("SERIAL")
673                        || sql.contains("serial")
674                        || sql.contains("IDENTITY")
675                        || sql.contains("identity"),
676                    "PostgreSQL should use SERIAL or IDENTITY for auto_increment, got: {sql}"
677                );
678            }
679            DatabaseBackend::MySql => {
680                assert!(
681                    sql.contains("AUTO_INCREMENT") || sql.contains("auto_increment"),
682                    "MySQL should use AUTO_INCREMENT for auto_increment, got: {sql}"
683                );
684            }
685            DatabaseBackend::Sqlite => {
686                assert!(
687                    sql.contains("AUTOINCREMENT") || sql.contains("autoincrement"),
688                    "SQLite should use AUTOINCREMENT for auto_increment, got: {sql}"
689                );
690            }
691        }
692
693        with_settings!({ snapshot_suffix => format!("create_table_with_auto_increment_{:?}", backend) }, {
694            assert_snapshot!(sql);
695        });
696    }
697
698    #[rstest]
699    #[case::auto_increment_unsupported_type_postgres(DatabaseBackend::Postgres)]
700    #[case::auto_increment_unsupported_type_mysql(DatabaseBackend::MySql)]
701    #[case::auto_increment_unsupported_type_sqlite(DatabaseBackend::Sqlite)]
702    fn test_create_table_auto_increment_pk_on_non_integer_column(#[case] backend: DatabaseBackend) {
703        // SQLite folds an auto-increment PK into the column definition
704        // (`INTEGER PRIMARY KEY AUTOINCREMENT`) and therefore SKIPS the separate
705        // PRIMARY KEY clause — but only when the PK column's type can actually
706        // carry AUTOINCREMENT. TEXT cannot, so nothing is folded into the column
707        // and the explicit PRIMARY KEY clause must survive on every backend.
708        let columns = vec![col("id", ColumnType::Simple(SimpleColumnType::Text))];
709        let constraints = vec![TableConstraint::PrimaryKey {
710            auto_increment: true,
711            columns: vec!["id".into()],
712            strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(),
713        }];
714
715        let queries = build_create_table(backend, "users", &columns, &constraints).unwrap();
716        let sql = join_queries(&queries, backend, ";\n");
717
718        assert!(
719            sql.to_uppercase().contains("PRIMARY KEY"),
720            "auto-increment PK on a type without auto-increment support must still \
721             emit an explicit PRIMARY KEY, got: {sql}"
722        );
723
724        with_settings!({ snapshot_suffix => format!("create_table_auto_increment_pk_on_non_integer_column_{:?}", backend) }, {
725            assert_snapshot!(sql);
726        });
727    }
728
729    #[rstest]
730    #[case::inline_auto_increment_postgres(DatabaseBackend::Postgres)]
731    #[case::inline_auto_increment_mysql(DatabaseBackend::MySql)]
732    #[case::inline_auto_increment_sqlite(DatabaseBackend::Sqlite)]
733    fn test_create_table_with_inline_auto_increment_primary_key(#[case] backend: DatabaseBackend) {
734        // Test that inline primary_key with auto_increment generates correct SQL
735        use vespertide_core::schema::primary_key::{PrimaryKeyDef, PrimaryKeySyntax};
736
737        let columns = vec![ColumnDef {
738            name: "id".into(),
739            r#type: ColumnType::Simple(SimpleColumnType::Integer),
740            nullable: false,
741            default: None,
742            comment: None,
743            primary_key: Some(PrimaryKeySyntax::Object(PrimaryKeyDef {
744                auto_increment: true,
745            })),
746            unique: None,
747            index: None,
748            foreign_key: None,
749        }];
750
751        let result = build_create_table(backend, "users", &columns, &[]);
752        assert!(result.is_ok());
753        let queries = result.unwrap();
754        let sql = join_queries(&queries, backend, ";\n");
755
756        // Verify auto_increment is applied correctly for each backend
757        match backend {
758            DatabaseBackend::Postgres => {
759                // sea-query 1.0.0-rc.33 renders PG auto-increment as the
760                // SQL-standard `GENERATED BY DEFAULT AS IDENTITY` (PG 10+),
761                // whereas the 1.0.0 final line used `SERIAL`. Both express an
762                // auto-incrementing PK; accept either form.
763                assert!(
764                    sql.contains("SERIAL")
765                        || sql.contains("serial")
766                        || sql.contains("IDENTITY")
767                        || sql.contains("identity"),
768                    "PostgreSQL should use SERIAL or IDENTITY for auto_increment, got: {sql}"
769                );
770            }
771            DatabaseBackend::MySql => {
772                assert!(
773                    sql.contains("AUTO_INCREMENT") || sql.contains("auto_increment"),
774                    "MySQL should use AUTO_INCREMENT for auto_increment, got: {sql}"
775                );
776            }
777            DatabaseBackend::Sqlite => {
778                assert!(
779                    sql.contains("AUTOINCREMENT") || sql.contains("autoincrement"),
780                    "SQLite should use AUTOINCREMENT for auto_increment, got: {sql}"
781                );
782            }
783        }
784
785        with_settings!({ snapshot_suffix => format!("create_table_with_inline_auto_increment_{:?}", backend) }, {
786            assert_snapshot!(sql);
787        });
788    }
789
790    /// Test creating a table with timestamp column and `NOW()` default
791    /// `SQLite` should convert `NOW()` to `CURRENT_TIMESTAMP`
792    #[rstest]
793    #[case::timestamp_now_default_postgres(DatabaseBackend::Postgres)]
794    #[case::timestamp_now_default_mysql(DatabaseBackend::MySql)]
795    #[case::timestamp_now_default_sqlite(DatabaseBackend::Sqlite)]
796    /// CREATE TABLE with `Check` + `Index` constraints exercises the
797    /// `TableConstraint::Check` and `TableConstraint::Index` arms of
798    /// `add_create_table_constraints` (both silently skip - the
799    /// constraints are emitted in separate ALTER statements outside
800    /// this builder, but the match arms still execute on every call).
801    #[rstest]
802    #[case::postgres(DatabaseBackend::Postgres)]
803    #[case::mysql(DatabaseBackend::MySql)]
804    #[case::sqlite(DatabaseBackend::Sqlite)]
805    fn test_create_table_check_and_index_constraints_are_skipped(#[case] backend: DatabaseBackend) {
806        // Mix CHECK + INDEX + PrimaryKey so the match dispatcher walks the
807        // Check and Index arms (currently no-op) alongside a real PK arm.
808        let constraints = vec![
809            TableConstraint::PrimaryKey {
810                columns: vec!["id".into()],
811                auto_increment: false,
812                strategy: vespertide_core::PrimaryKeyAdditionStrategy::default(),
813            },
814            TableConstraint::Check {
815                name: "chk_age".into(),
816                expr: "age > 0".into(),
817                strategy: vespertide_core::CheckViolationStrategy::default(),
818            },
819            TableConstraint::Index {
820                name: Some("idx_age".into()),
821                columns: vec!["age".into()],
822            },
823        ];
824        let columns = vec![
825            ColumnDef {
826                name: "id".into(),
827                r#type: ColumnType::Simple(SimpleColumnType::Integer),
828                nullable: false,
829                default: None,
830                comment: None,
831                primary_key: None,
832                unique: None,
833                index: None,
834                foreign_key: None,
835            },
836            ColumnDef {
837                name: "age".into(),
838                r#type: ColumnType::Simple(SimpleColumnType::Integer),
839                nullable: false,
840                default: None,
841                comment: None,
842                primary_key: None,
843                unique: None,
844                index: None,
845                foreign_key: None,
846            },
847        ];
848        let queries = build_create_table(backend, "users", &columns, &constraints).unwrap();
849        let sql = joined_sql(backend, &queries);
850        // The CHECK arm in `add_create_table_constraints` is a documented
851        // no-op (CHECK appears in a separate ALTER ADD CONSTRAINT statement,
852        // not in CREATE TABLE itself). We assert the dispatch ran (PK lands)
853        // and the CHECK predicate is NOT inlined in the CREATE TABLE.
854        assert!(sql.contains("PRIMARY KEY"));
855        assert!(!sql.contains("CHECK (age > 0)"));
856        // Index constraints fan out as separate CREATE INDEX statements,
857        // which is the documented behaviour of build_create_table.
858        assert!(sql.contains("CREATE INDEX") || sql.contains("idx_age"));
859    }
860
861    #[rstest]
862    #[case::postgres(DatabaseBackend::Postgres)]
863    #[case::mysql(DatabaseBackend::MySql)]
864    #[case::sqlite(DatabaseBackend::Sqlite)]
865    fn test_create_table_with_timestamp_now_default(#[case] backend: DatabaseBackend) {
866        let columns = vec![
867            ColumnDef {
868                name: "id".into(),
869                r#type: ColumnType::Simple(SimpleColumnType::BigInt),
870                nullable: false,
871                default: None,
872                comment: None,
873                primary_key: None,
874                unique: None,
875                index: None,
876                foreign_key: None,
877            },
878            ColumnDef {
879                name: "created_at".into(),
880                r#type: ColumnType::Simple(SimpleColumnType::Timestamptz),
881                nullable: false,
882                default: Some("NOW()".into()), // uppercase NOW()
883                comment: None,
884                primary_key: None,
885                unique: None,
886                index: None,
887                foreign_key: None,
888            },
889        ];
890
891        let result = build_create_table(backend, "events", &columns, &[]);
892        assert!(result.is_ok(), "build_create_table failed: {result:?}");
893        let queries = result.unwrap();
894        let sql = join_queries(&queries, backend, "\n");
895
896        // SQLite should NOT have NOW() - it should be converted to CURRENT_TIMESTAMP
897        if matches!(backend, DatabaseBackend::Sqlite) {
898            assert!(
899                !sql.contains("NOW()"),
900                "SQLite should not contain NOW(), got: {sql}"
901            );
902            assert!(
903                sql.contains("CURRENT_TIMESTAMP"),
904                "SQLite should use CURRENT_TIMESTAMP, got: {sql}"
905            );
906        }
907
908        with_settings!({ snapshot_suffix => format!("create_table_with_timestamp_now_default_{:?}", backend) }, {
909            assert_snapshot!(sql);
910        });
911    }
912}