Skip to main content

cast_core/
schema.rs

1//! Schema builder. Used in migrations: `Schema::create("users", |t| ...)`.
2//!
3//! Mirrors Laravel's `Schema::create` ergonomics + the full `Blueprint` column
4//! type surface: `t.string("name").not_null().unique()`, `t.decimal("price", 10, 2)`,
5//! `t.morphs("commentable")`, `t.remember_token()`, foreign-key constraint builders, etc.
6//!
7//! ## Dialects
8//!
9//! `Schema::new()` defaults to Postgres. For MySQL / SQLite, use `Schema::for_driver(Driver::*)`.
10//! The MigrationRunner calls `Schema::for_driver(pool.driver())` automatically so user code
11//! rarely needs to think about it.
12
13use sea_query::{
14    ColumnDef as SeaColumnDef, ColumnType, MysqlQueryBuilder, PostgresQueryBuilder,
15    SqliteQueryBuilder, Table as SeaTable,
16};
17
18use crate::pool::Driver;
19
20pub struct Schema {
21    pub statements: Vec<String>,
22    driver: Driver,
23}
24
25impl Default for Schema {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl Schema {
32    pub fn new() -> Self {
33        Self::for_driver(Driver::Postgres)
34    }
35
36    pub fn for_driver(driver: Driver) -> Self {
37        Self {
38            statements: Vec::new(),
39            driver,
40        }
41    }
42
43    pub fn driver(&self) -> Driver {
44        self.driver
45    }
46
47    /// Create a new table.
48    pub fn create<F>(&mut self, table: &str, build: F)
49    where
50        F: FnOnce(&mut Table),
51    {
52        let mut t = Table::new(table, TableMode::Create, self.driver);
53        build(&mut t);
54        self.statements.extend(t.into_statements());
55    }
56
57    /// Alter an existing table. The closure can call `add_*` / `drop_column` /
58    /// `rename_column` / index / foreign-key methods on `Table`.
59    ///
60    /// Mirrors `Schema::table('users', function (Blueprint $table) { ... })`.
61    pub fn table<F>(&mut self, table: &str, build: F)
62    where
63        F: FnOnce(&mut Table),
64    {
65        let mut t = Table::new(table, TableMode::Alter, self.driver);
66        build(&mut t);
67        self.statements.extend(t.into_statements());
68    }
69
70    pub fn drop(&mut self, table: &str) {
71        let sql = match self.driver {
72            Driver::Postgres => format!("DROP TABLE IF EXISTS {} CASCADE", table),
73            Driver::MySql | Driver::Sqlite => format!("DROP TABLE IF EXISTS {}", table),
74        };
75        self.statements.push(sql);
76    }
77
78    pub fn drop_if_exists(&mut self, table: &str) {
79        self.drop(table);
80    }
81
82    /// Rename a table. Mirrors `Schema::rename('old', 'new')`.
83    pub fn rename(&mut self, from: &str, to: &str) {
84        self.statements
85            .push(format!("ALTER TABLE {from} RENAME TO {to}"));
86    }
87
88    /// Check if a table exists (executed at apply-time as a SELECT). v0.2 will return bool.
89    pub fn has_table(&mut self, _table: &str) {
90        // Sentinel — meant for runtime use, not migration generation.
91    }
92
93    pub fn raw(&mut self, sql: impl Into<String>) {
94        self.statements.push(sql.into());
95    }
96}
97
98#[derive(Clone, Copy, PartialEq, Eq)]
99enum TableMode {
100    Create,
101    Alter,
102}
103
104/// A table definition assembled inside the build closure.
105pub struct Table {
106    name: String,
107    mode: TableMode,
108    driver: Driver,
109    columns: Vec<ColumnDef>,
110    indexes: Vec<String>,
111    foreign_keys: Vec<PendingFk>,
112    drops: Vec<String>,
113    renames: Vec<(String, String)>,
114    checks: Vec<PendingCheck>,
115    /// Composite-primary-key column list. Empty means "no composite PK"
116    /// — single-column PKs (via `t.id()`) are tracked on the column itself
117    /// and don't appear here.
118    primary_keys: Vec<String>,
119}
120
121#[derive(Clone)]
122struct PendingFk {
123    column: String,
124    ref_table: String,
125    ref_col: String,
126    on_delete: Option<String>,
127    on_update: Option<String>,
128}
129
130impl PendingFk {
131    fn constraint_name(&self, table: &str) -> String {
132        format!("fk_{}_{}", table, self.column)
133    }
134
135    fn actions(&self) -> String {
136        let mut s = String::new();
137        if let Some(action) = &self.on_delete {
138            s.push_str(&format!(" ON DELETE {action}"));
139        }
140        if let Some(action) = &self.on_update {
141            s.push_str(&format!(" ON UPDATE {action}"));
142        }
143        s
144    }
145
146    fn inline_clause(&self, table: &str) -> String {
147        format!(
148            "CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({}){}",
149            self.constraint_name(table),
150            self.column,
151            self.ref_table,
152            self.ref_col,
153            self.actions(),
154        )
155    }
156
157    fn alter_sql(&self, table: &str) -> String {
158        format!(
159            "ALTER TABLE {} ADD CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {} ({}){}",
160            table,
161            self.constraint_name(table),
162            self.column,
163            self.ref_table,
164            self.ref_col,
165            self.actions(),
166        )
167    }
168}
169
170#[derive(Clone)]
171struct PendingCheck {
172    name: String,
173    expr: String,
174}
175
176impl PendingCheck {
177    fn inline_clause(&self) -> String {
178        format!("CONSTRAINT {} CHECK ({})", self.name, self.expr)
179    }
180
181    fn alter_sql(&self, table: &str) -> String {
182        format!(
183            "ALTER TABLE {} ADD CONSTRAINT {} CHECK ({})",
184            table, self.name, self.expr
185        )
186    }
187}
188
189impl Table {
190    fn new(name: impl Into<String>, mode: TableMode, driver: Driver) -> Self {
191        Self {
192            name: name.into(),
193            mode,
194            driver,
195            columns: Vec::new(),
196            indexes: Vec::new(),
197            foreign_keys: Vec::new(),
198            drops: Vec::new(),
199            renames: Vec::new(),
200            checks: Vec::new(),
201            primary_keys: Vec::new(),
202        }
203    }
204
205    fn push_column(&mut self, name: &str, ty: ColumnType) -> &mut ColumnDef {
206        let sea_def = SeaColumnDef::new_with_type(sea_query::Alias::new(name), ty);
207        self.columns.push(ColumnDef {
208            sea_def,
209            name: name.to_string(),
210            mode: self.mode,
211        });
212        self.columns.last_mut().unwrap()
213    }
214
215    // ── identifier ───────────────────────────────────────────────────────────
216
217    /// Declare a composite PRIMARY KEY across the named columns. Mirrors
218    /// Laravel's `$table->primary(['user_id', 'role_id'])`. Use this for
219    /// pivot tables like `model_has_roles` where the row identity is the
220    /// combination of foreign keys.
221    ///
222    /// Inlines `PRIMARY KEY (col1, col2)` into the CREATE TABLE body.
223    /// On Postgres/MySQL it would otherwise need
224    /// `ALTER TABLE … ADD CONSTRAINT … PRIMARY KEY (…)`; SQLite has no
225    /// such ALTER, so inline is the only portable form.
226    ///
227    /// Don't combine with `t.id()` on the same table — that creates a
228    /// single-column `id` primary key, and adding a composite one is a
229    /// conflict the database will reject.
230    pub fn primary(&mut self, columns: &[&str]) -> &mut Self {
231        if columns.is_empty() {
232            return self;
233        }
234        self.primary_keys = columns.iter().map(|c| (*c).to_string()).collect();
235        self
236    }
237
238    pub fn id(&mut self) -> &mut ColumnDef {
239        let cd = self.push_column("id", ColumnType::BigInteger);
240        cd.sea_def.not_null().primary_key().auto_increment();
241        cd
242    }
243
244    pub fn uuid_id(&mut self) -> &mut ColumnDef {
245        let cd = self.push_column("id", ColumnType::Uuid);
246        cd.sea_def.not_null().primary_key();
247        cd
248    }
249
250    /// `ULID` placeholder — alias for `uuid_id` (we don't ship a ULID Postgres type).
251    pub fn ulid_id(&mut self) -> &mut ColumnDef {
252        self.uuid_id()
253    }
254
255    // ── numeric ──────────────────────────────────────────────────────────────
256
257    pub fn tiny_integer(&mut self, name: &str) -> &mut ColumnDef {
258        self.push_column(name, ColumnType::TinyInteger)
259    }
260
261    pub fn small_integer(&mut self, name: &str) -> &mut ColumnDef {
262        self.push_column(name, ColumnType::SmallInteger)
263    }
264
265    pub fn medium_integer(&mut self, name: &str) -> &mut ColumnDef {
266        self.push_column(name, ColumnType::Integer)
267    }
268
269    pub fn integer(&mut self, name: &str) -> &mut ColumnDef {
270        self.push_column(name, ColumnType::Integer)
271    }
272
273    pub fn big_integer(&mut self, name: &str) -> &mut ColumnDef {
274        self.push_column(name, ColumnType::BigInteger)
275    }
276
277    /// Postgres has no native unsigned types, so this is a synonym for `big_integer`
278    /// with a `>= 0` check constraint. Provided for Laravel parity.
279    pub fn unsigned_big_integer(&mut self, name: &str) -> &mut ColumnDef {
280        self.checks.push(PendingCheck {
281            name: format!("{}_{}_unsigned", self.name, name),
282            expr: format!("{} >= 0", name),
283        });
284        self.push_column(name, ColumnType::BigInteger)
285    }
286
287    pub fn unsigned_integer(&mut self, name: &str) -> &mut ColumnDef {
288        self.checks.push(PendingCheck {
289            name: format!("{}_{}_unsigned", self.name, name),
290            expr: format!("{} >= 0", name),
291        });
292        self.push_column(name, ColumnType::Integer)
293    }
294
295    pub fn decimal(&mut self, name: &str, precision: u32, scale: u32) -> &mut ColumnDef {
296        self.push_column(name, ColumnType::Decimal(Some((precision, scale))))
297    }
298
299    pub fn float(&mut self, name: &str) -> &mut ColumnDef {
300        self.push_column(name, ColumnType::Float)
301    }
302
303    pub fn double(&mut self, name: &str) -> &mut ColumnDef {
304        self.push_column(name, ColumnType::Double)
305    }
306
307    // ── string-ish ───────────────────────────────────────────────────────────
308
309    pub fn string(&mut self, name: &str) -> &mut ColumnDef {
310        self.push_column(name, ColumnType::String(sea_query::StringLen::N(255)))
311    }
312
313    /// Variable-length string with a custom max.
314    pub fn string_with_length(&mut self, name: &str, length: u32) -> &mut ColumnDef {
315        self.push_column(name, ColumnType::String(sea_query::StringLen::N(length)))
316    }
317
318    pub fn text(&mut self, name: &str) -> &mut ColumnDef {
319        self.push_column(name, ColumnType::Text)
320    }
321
322    pub fn long_text(&mut self, name: &str) -> &mut ColumnDef {
323        self.push_column(name, ColumnType::Text)
324    }
325
326    pub fn medium_text(&mut self, name: &str) -> &mut ColumnDef {
327        self.push_column(name, ColumnType::Text)
328    }
329
330    pub fn char(&mut self, name: &str, length: u32) -> &mut ColumnDef {
331        self.push_column(name, ColumnType::Char(Some(length)))
332    }
333
334    /// Laravel's `remember_token`: nullable VARCHAR(100) used by stay-logged-in cookies.
335    pub fn remember_token(&mut self) -> &mut ColumnDef {
336        let cd = self.push_column(
337            "remember_token",
338            ColumnType::String(sea_query::StringLen::N(100)),
339        );
340        cd.sea_def.null();
341        cd
342    }
343
344    // ── enum / binary ────────────────────────────────────────────────────────
345
346    /// CHECK-constrained enum column. Postgres has native ENUM types but they're
347    /// painful for migrations; this models them as `VARCHAR` + CHECK constraint.
348    pub fn enum_col(&mut self, name: &str, variants: &[&str]) -> &mut ColumnDef {
349        let list = variants
350            .iter()
351            .map(|v| format!("'{}'", v.replace('\'', "''")))
352            .collect::<Vec<_>>()
353            .join(", ");
354        self.checks.push(PendingCheck {
355            name: format!("{}_{}_enum", self.name, name),
356            expr: format!("{} IN ({})", name, list),
357        });
358        self.push_column(name, ColumnType::String(sea_query::StringLen::N(64)))
359    }
360
361    pub fn binary(&mut self, name: &str) -> &mut ColumnDef {
362        self.push_column(name, ColumnType::VarBinary(sea_query::StringLen::None))
363    }
364
365    // ── boolean ──────────────────────────────────────────────────────────────
366
367    pub fn boolean(&mut self, name: &str) -> &mut ColumnDef {
368        self.push_column(name, ColumnType::Boolean)
369    }
370
371    // ── time ─────────────────────────────────────────────────────────────────
372
373    pub fn timestamp(&mut self, name: &str) -> &mut ColumnDef {
374        self.push_column(name, ColumnType::Timestamp)
375    }
376
377    pub fn timestamp_tz(&mut self, name: &str) -> &mut ColumnDef {
378        self.push_column(name, ColumnType::TimestampWithTimeZone)
379    }
380
381    pub fn date(&mut self, name: &str) -> &mut ColumnDef {
382        self.push_column(name, ColumnType::Date)
383    }
384
385    pub fn time(&mut self, name: &str) -> &mut ColumnDef {
386        self.push_column(name, ColumnType::Time)
387    }
388
389    pub fn date_time(&mut self, name: &str) -> &mut ColumnDef {
390        self.push_column(name, ColumnType::DateTime)
391    }
392
393    pub fn year(&mut self, name: &str) -> &mut ColumnDef {
394        self.push_column(name, ColumnType::Year)
395    }
396
397    /// Adds `created_at` + `updated_at`, both `TIMESTAMPTZ NULL DEFAULT CURRENT_TIMESTAMP`.
398    pub fn timestamps(&mut self) {
399        self.push_column("created_at", ColumnType::TimestampWithTimeZone)
400            .nullable()
401            .default("CURRENT_TIMESTAMP");
402        self.push_column("updated_at", ColumnType::TimestampWithTimeZone)
403            .nullable()
404            .default("CURRENT_TIMESTAMP");
405    }
406
407    pub fn soft_deletes(&mut self) {
408        self.push_column("deleted_at", ColumnType::TimestampWithTimeZone)
409            .nullable();
410    }
411
412    // ── json / uuid / network ────────────────────────────────────────────────
413
414    pub fn json(&mut self, name: &str) -> &mut ColumnDef {
415        self.push_column(name, ColumnType::Json)
416    }
417
418    pub fn jsonb(&mut self, name: &str) -> &mut ColumnDef {
419        self.push_column(name, ColumnType::JsonBinary)
420    }
421
422    pub fn uuid(&mut self, name: &str) -> &mut ColumnDef {
423        self.push_column(name, ColumnType::Uuid)
424    }
425
426    pub fn ip_address(&mut self, name: &str) -> &mut ColumnDef {
427        // sea-query has no native INET; emit as VARCHAR(45) (max IPv6 len).
428        self.push_column(name, ColumnType::String(sea_query::StringLen::N(45)))
429    }
430
431    pub fn mac_address(&mut self, name: &str) -> &mut ColumnDef {
432        self.push_column(name, ColumnType::String(sea_query::StringLen::N(17)))
433    }
434
435    // ── polymorphic / morphs ─────────────────────────────────────────────────
436
437    /// Polymorphic FK columns: `<name>_id BIGINT` + `<name>_type VARCHAR(255)`.
438    /// Mirrors Laravel's `$table->morphs('commentable')`.
439    pub fn morphs(&mut self, name: &str) {
440        self.push_column(&format!("{name}_id"), ColumnType::BigInteger)
441            .not_null();
442        self.push_column(
443            &format!("{name}_type"),
444            ColumnType::String(sea_query::StringLen::N(255)),
445        )
446        .not_null();
447        let idx_name = format!("idx_{}_{}_type_id", self.name, name);
448        let sql = format!(
449            "CREATE INDEX {} ON {} ({}_type, {}_id)",
450            idx_name, self.name, name, name
451        );
452        self.indexes.push(sql);
453    }
454
455    pub fn nullable_morphs(&mut self, name: &str) {
456        self.push_column(&format!("{name}_id"), ColumnType::BigInteger)
457            .nullable();
458        self.push_column(
459            &format!("{name}_type"),
460            ColumnType::String(sea_query::StringLen::N(255)),
461        )
462        .nullable();
463        let idx_name = format!("idx_{}_{}_type_id", self.name, name);
464        let sql = format!(
465            "CREATE INDEX {} ON {} ({}_type, {}_id)",
466            idx_name, self.name, name, name
467        );
468        self.indexes.push(sql);
469    }
470
471    /// UUID polymorphic variant: `<name>_id UUID` + `<name>_type VARCHAR(255)`.
472    pub fn uuid_morphs(&mut self, name: &str) {
473        self.push_column(&format!("{name}_id"), ColumnType::Uuid)
474            .not_null();
475        self.push_column(
476            &format!("{name}_type"),
477            ColumnType::String(sea_query::StringLen::N(255)),
478        )
479        .not_null();
480        let idx_name = format!("idx_{}_{}_type_id", self.name, name);
481        let sql = format!(
482            "CREATE INDEX {} ON {} ({}_type, {}_id)",
483            idx_name, self.name, name, name
484        );
485        self.indexes.push(sql);
486    }
487
488    // ── foreign keys ─────────────────────────────────────────────────────────
489
490    /// Shortcut: add a `bigint` column with a FK to `references.id`. Laravel's
491    /// `$table->foreignId('user_id')->constrained()` is split here into:
492    ///
493    /// - `t.foreign_id_for("user_id", "users")` — most common pattern
494    /// - `t.big_integer("user_id")` + `t.foreign("user_id").references("id").on("users")` — explicit
495    pub fn foreign_id_for(&mut self, name: &str, references: &str) -> &mut ColumnDef {
496        self.foreign_id_for_with_action(name, references, "CASCADE", false)
497    }
498
499    /// Same as [`foreign_id_for`] but emits `ON DELETE SET NULL` and makes
500    /// the column nullable. Matches Laravel's
501    /// `$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete()`.
502    pub fn foreign_id_for_nullable(&mut self, name: &str, references: &str) -> &mut ColumnDef {
503        self.foreign_id_for_with_action(name, references, "SET NULL", true)
504    }
505
506    /// Same as [`foreign_id_for`] but emits `ON DELETE RESTRICT`. Matches
507    /// `$table->foreignId('order_id')->constrained()->restrictOnDelete()`.
508    pub fn foreign_id_for_restrict(&mut self, name: &str, references: &str) -> &mut ColumnDef {
509        self.foreign_id_for_with_action(name, references, "RESTRICT", false)
510    }
511
512    /// Same as [`foreign_id_for`] but emits no `ON DELETE` clause at all —
513    /// the database's default (usually NO ACTION) applies. Use when you need
514    /// a foreign-key column without a tied cascade policy.
515    pub fn foreign_id_for_no_action(&mut self, name: &str, references: &str) -> &mut ColumnDef {
516        let cd = {
517            self.foreign_keys.push(PendingFk {
518                column: name.to_string(),
519                ref_table: references.to_string(),
520                ref_col: "id".to_string(),
521                on_delete: None,
522                on_update: None,
523            });
524            self.push_column(name, ColumnType::BigInteger)
525        };
526        cd
527    }
528
529    fn foreign_id_for_with_action(
530        &mut self,
531        name: &str,
532        references: &str,
533        on_delete: &str,
534        nullable: bool,
535    ) -> &mut ColumnDef {
536        self.foreign_keys.push(PendingFk {
537            column: name.to_string(),
538            ref_table: references.to_string(),
539            ref_col: "id".to_string(),
540            on_delete: Some(on_delete.to_string()),
541            on_update: None,
542        });
543        let cd = self.push_column(name, ColumnType::BigInteger);
544        if nullable {
545            cd.nullable();
546        }
547        cd
548    }
549
550    /// Begin a fluent foreign-key constraint builder for `column`.
551    /// Mirrors `$table->foreign('user_id')->references('id')->on('users')`.
552    pub fn foreign(&mut self, column: &str) -> ForeignKeyBuilder<'_> {
553        ForeignKeyBuilder {
554            table: &mut self.foreign_keys,
555            column: column.to_string(),
556            ref_col: "id".to_string(),
557            ref_table: String::new(),
558            on_delete: None,
559            on_update: None,
560        }
561    }
562
563    // ── indexes ──────────────────────────────────────────────────────────────
564
565    pub fn index(&mut self, columns: &[&str]) -> &mut Self {
566        let idx_name = format!("idx_{}_{}", self.name, columns.join("_"));
567        let sql = format!(
568            "CREATE INDEX {} ON {} ({})",
569            idx_name,
570            self.name,
571            columns.join(", ")
572        );
573        self.indexes.push(sql);
574        self
575    }
576
577    pub fn unique_index(&mut self, columns: &[&str]) -> &mut Self {
578        let idx_name = format!("uq_{}_{}", self.name, columns.join("_"));
579        let sql = format!(
580            "CREATE UNIQUE INDEX {} ON {} ({})",
581            idx_name,
582            self.name,
583            columns.join(", ")
584        );
585        self.indexes.push(sql);
586        self
587    }
588
589    /// Postgres trigram / GIN indexes are common — let users emit raw `CREATE INDEX … USING …`.
590    pub fn raw_index(&mut self, sql: impl Into<String>) -> &mut Self {
591        self.indexes.push(sql.into());
592        self
593    }
594
595    // ── alter-table operations ───────────────────────────────────────────────
596
597    pub fn drop_column(&mut self, name: &str) -> &mut Self {
598        self.drops.push(format!(
599            "ALTER TABLE {} DROP COLUMN IF EXISTS {}",
600            self.name, name
601        ));
602        self
603    }
604
605    pub fn rename_column(&mut self, from: &str, to: &str) -> &mut Self {
606        self.renames.push((from.to_string(), to.to_string()));
607        self
608    }
609
610    pub fn drop_index(&mut self, name: &str) -> &mut Self {
611        self.drops.push(format!("DROP INDEX IF EXISTS {}", name));
612        self
613    }
614
615    pub fn drop_foreign(&mut self, constraint: &str) -> &mut Self {
616        self.drops.push(format!(
617            "ALTER TABLE {} DROP CONSTRAINT IF EXISTS {}",
618            self.name, constraint
619        ));
620        self
621    }
622
623    pub fn drop_unique(&mut self, name: &str) -> &mut Self {
624        self.drops.push(format!("DROP INDEX IF EXISTS {}", name));
625        self
626    }
627
628    pub fn drop_timestamps(&mut self) -> &mut Self {
629        self.drop_column("created_at").drop_column("updated_at")
630    }
631
632    pub fn drop_soft_deletes(&mut self) -> &mut Self {
633        self.drop_column("deleted_at")
634    }
635
636    // ── emit ────────────────────────────────────────────────────────────────
637
638    fn into_statements(self) -> Vec<String> {
639        let mut out = Vec::new();
640        match self.mode {
641            TableMode::Create => {
642                let mut t = SeaTable::create();
643                t.table(sea_query::Alias::new(&self.name)).if_not_exists();
644                for col in &self.columns {
645                    t.col(col.sea_def.clone());
646                }
647                let mut sql = build_per_driver(&t, self.driver);
648
649                // Inline FK + CHECK constraints + composite PK inside the
650                // CREATE TABLE body. SQLite has no `ALTER TABLE … ADD
651                // CONSTRAINT`, so emitting them post-create only works on
652                // Postgres/MySQL; inline is portable.
653                let mut inline = Vec::new();
654                if !self.primary_keys.is_empty() {
655                    inline.push(format!("PRIMARY KEY ({})", self.primary_keys.join(", ")));
656                }
657                for fk in &self.foreign_keys {
658                    inline.push(fk.inline_clause(&self.name));
659                }
660                for chk in &self.checks {
661                    inline.push(chk.inline_clause());
662                }
663                if !inline.is_empty() {
664                    let trimmed_len = sql.trim_end().len();
665                    if trimmed_len > 0 && sql.as_bytes()[trimmed_len - 1] == b')' {
666                        let injection = format!(", {}", inline.join(", "));
667                        sql.insert_str(trimmed_len - 1, &injection);
668                    } else {
669                        // Fallback: shouldn't happen for sea_query CREATE TABLE
670                        // output, but if the SQL doesn't end in `)`, fall back to
671                        // post-table ALTER statements (Postgres/MySQL only).
672                        for fk in &self.foreign_keys {
673                            out.push(fk.alter_sql(&self.name));
674                        }
675                        for chk in &self.checks {
676                            out.push(chk.alter_sql(&self.name));
677                        }
678                    }
679                }
680
681                out.push(sql);
682            }
683            TableMode::Alter => {
684                // `ALTER TABLE ... ADD COLUMN ...` per column added.
685                for col in &self.columns {
686                    let mut t = SeaTable::alter();
687                    t.table(sea_query::Alias::new(&self.name));
688                    t.add_column(col.sea_def.clone());
689                    out.push(build_alter_per_driver(&t, self.driver));
690                }
691
692                let has_constraints = !self.foreign_keys.is_empty() || !self.checks.is_empty();
693                if self.driver == Driver::Sqlite && has_constraints {
694                    tracing::warn!(
695                        table = %self.name,
696                        fks = self.foreign_keys.len(),
697                        checks = self.checks.len(),
698                        "SQLite does not support ALTER TABLE ADD CONSTRAINT; FK/CHECK additions on existing tables are skipped. Recreate the table with the constraint inline.",
699                    );
700                } else {
701                    for fk in &self.foreign_keys {
702                        out.push(fk.alter_sql(&self.name));
703                    }
704                    for chk in &self.checks {
705                        out.push(chk.alter_sql(&self.name));
706                    }
707                }
708            }
709        }
710        for (from, to) in &self.renames {
711            out.push(format!(
712                "ALTER TABLE {} RENAME COLUMN {} TO {}",
713                self.name, from, to
714            ));
715        }
716        out.extend(self.drops);
717        out.extend(self.indexes);
718        out
719    }
720}
721
722/// Fluent builder returned by `Table::foreign(col)`. Drop it (or call `.constrain()`)
723/// to commit the foreign key SQL.
724pub struct ForeignKeyBuilder<'a> {
725    table: &'a mut Vec<PendingFk>,
726    column: String,
727    ref_col: String,
728    ref_table: String,
729    on_delete: Option<String>,
730    on_update: Option<String>,
731}
732
733impl<'a> ForeignKeyBuilder<'a> {
734    /// The referenced column on the foreign table. Default: `"id"`.
735    pub fn references(mut self, column: &str) -> Self {
736        self.ref_col = column.to_string();
737        self
738    }
739
740    /// The foreign table.
741    pub fn on(mut self, table: &str) -> Self {
742        self.ref_table = table.to_string();
743        self
744    }
745
746    /// `ON DELETE CASCADE` (or `RESTRICT` / `SET NULL` / `SET DEFAULT`).
747    pub fn on_delete(mut self, action: &str) -> Self {
748        self.on_delete = Some(action.to_string());
749        self
750    }
751
752    pub fn on_update(mut self, action: &str) -> Self {
753        self.on_update = Some(action.to_string());
754        self
755    }
756
757    pub fn cascade(self) -> Self {
758        self.on_delete("CASCADE")
759    }
760
761    pub fn set_null(self) -> Self {
762        self.on_delete("SET NULL")
763    }
764
765    pub fn restrict(self) -> Self {
766        self.on_delete("RESTRICT")
767    }
768
769    /// Commit the constraint to the table. Called explicitly OR implicitly via `Drop`.
770    pub fn commit(self) {
771        // moved out by Drop; nothing else to do here
772        drop(self);
773    }
774}
775
776impl<'a> Drop for ForeignKeyBuilder<'a> {
777    fn drop(&mut self) {
778        if self.ref_table.is_empty() {
779            // No `.on(...)` was called — nothing to emit.
780            return;
781        }
782        self.table.push(PendingFk {
783            column: std::mem::take(&mut self.column),
784            ref_table: std::mem::take(&mut self.ref_table),
785            ref_col: std::mem::take(&mut self.ref_col),
786            on_delete: self.on_delete.take(),
787            on_update: self.on_update.take(),
788        });
789    }
790}
791
792pub struct ColumnDef {
793    sea_def: SeaColumnDef,
794    pub name: String,
795    #[allow(dead_code)]
796    mode: TableMode,
797}
798
799impl ColumnDef {
800    pub fn not_null(&mut self) -> &mut Self {
801        self.sea_def.not_null();
802        self
803    }
804
805    pub fn nullable(&mut self) -> &mut Self {
806        self.sea_def.null();
807        self
808    }
809
810    pub fn unique(&mut self) -> &mut Self {
811        self.sea_def.unique_key();
812        self
813    }
814
815    pub fn primary_key(&mut self) -> &mut Self {
816        self.sea_def.primary_key();
817        self
818    }
819
820    /// Set the column default. String values that look like SQL string literals
821    /// (no parens, not already quoted, not numeric, not a recognized keyword)
822    /// are auto-quoted to avoid Postgres parsing them as column references.
823    /// Use [`default_raw`](Self::default_raw) to bypass quoting entirely.
824    pub fn default(&mut self, value: impl Into<String>) -> &mut Self {
825        let v = value.into();
826        let expr = if looks_like_sql_expr(&v) {
827            v
828        } else {
829            format!("'{}'", v.replace('\'', "''"))
830        };
831        self.sea_def.default(sea_query::Expr::cust(expr));
832        self
833    }
834
835    /// Set the default to raw SQL — no quoting, no parsing. Use this when you
836    /// need to pass a specific expression (a cast like `'{}'::jsonb`, a function
837    /// reference like `gen_random_uuid()`, etc.) and don't want the auto-quoting
838    /// in [`default`](Self::default).
839    pub fn default_raw(&mut self, sql: impl Into<String>) -> &mut Self {
840        self.sea_def.default(sea_query::Expr::cust(sql.into()));
841        self
842    }
843
844    pub fn default_value<T>(&mut self, value: T) -> &mut Self
845    where
846        T: Into<sea_query::Value>,
847    {
848        self.sea_def.default(value);
849        self
850    }
851
852    /// Add an inline column comment. Postgres stores this in `COMMENT ON COLUMN`.
853    pub fn comment(&mut self, _text: impl Into<String>) -> &mut Self {
854        // sea-query's API for column comments varies per dialect — emit as a no-op
855        // until v0.2 wires up a post-emit COMMENT ON COLUMN statement.
856        self
857    }
858
859    /// Alias for `default` — Laravel uses `useCurrent()` for timestamps.
860    pub fn use_current(&mut self) -> &mut Self {
861        self.default("CURRENT_TIMESTAMP")
862    }
863}
864
865/// Heuristic: should this default value be passed to SQL verbatim, or quoted
866/// as a string literal? Returns `true` for things that already look like a SQL
867/// expression (quoted strings, numbers, function calls, known keywords).
868fn looks_like_sql_expr(value: &str) -> bool {
869    let v = value.trim();
870    if v.is_empty() {
871        return true;
872    }
873    // Already-quoted string / identifier.
874    if v.starts_with('\'') || v.starts_with('"') || v.starts_with('`') {
875        return true;
876    }
877    // Numeric literal (int or float, signed).
878    if v.parse::<f64>().is_ok() {
879        return true;
880    }
881    // Function call or any sub-expression in parens.
882    if v.contains('(') {
883        return true;
884    }
885    // Recognized keywords / time functions used as bare defaults.
886    matches!(
887        v.to_ascii_uppercase().as_str(),
888        "TRUE"
889            | "FALSE"
890            | "NULL"
891            | "CURRENT_TIMESTAMP"
892            | "CURRENT_DATE"
893            | "CURRENT_TIME"
894            | "NOW"
895            | "LOCALTIMESTAMP"
896            | "LOCALTIME"
897    )
898}
899
900// ─── per-driver SQL emission ────────────────────────────────────────────────
901
902fn build_per_driver(t: &sea_query::TableCreateStatement, driver: Driver) -> String {
903    match driver {
904        Driver::Postgres => t.build(PostgresQueryBuilder),
905        Driver::MySql => t.build(MysqlQueryBuilder),
906        Driver::Sqlite => t.build(SqliteQueryBuilder),
907    }
908}
909
910fn build_alter_per_driver(t: &sea_query::TableAlterStatement, driver: Driver) -> String {
911    match driver {
912        Driver::Postgres => t.build(PostgresQueryBuilder),
913        Driver::MySql => t.build(MysqlQueryBuilder),
914        Driver::Sqlite => t.build(SqliteQueryBuilder),
915    }
916}
917
918#[cfg(test)]
919mod tests {
920    use super::*;
921
922    fn create_stmts(driver: Driver, f: impl FnOnce(&mut Table)) -> Vec<String> {
923        let mut t = Table::new("posts", TableMode::Create, driver);
924        f(&mut t);
925        t.into_statements()
926    }
927
928    fn alter_stmts(driver: Driver, f: impl FnOnce(&mut Table)) -> Vec<String> {
929        let mut t = Table::new("posts", TableMode::Alter, driver);
930        f(&mut t);
931        t.into_statements()
932    }
933
934    #[test]
935    fn foreign_id_for_inlines_fk_in_create_on_sqlite() {
936        // Regression: previously emitted `ALTER TABLE … ADD CONSTRAINT …` which
937        // SQLite rejects with `near "CONSTRAINT": syntax error`.
938        let stmts = create_stmts(Driver::Sqlite, |t| {
939            t.id();
940            t.foreign_id_for("user_id", "users");
941        });
942        let create = stmts
943            .iter()
944            .find(|s| s.starts_with("CREATE TABLE"))
945            .unwrap();
946        assert!(
947            create.contains("FOREIGN KEY"),
948            "FK should be inline in CREATE TABLE, got: {create}"
949        );
950        assert!(
951            create.contains("REFERENCES users (id)"),
952            "FK target should be inline, got: {create}"
953        );
954        assert!(
955            create.contains("ON DELETE CASCADE"),
956            "FK action should be inline, got: {create}"
957        );
958        assert!(
959            !stmts.iter().any(|s| s.starts_with("ALTER TABLE")),
960            "no ALTER TABLE should be emitted on SQLite, got: {stmts:?}"
961        );
962    }
963
964    #[test]
965    fn foreign_id_for_inlines_fk_in_create_on_postgres() {
966        let stmts = create_stmts(Driver::Postgres, |t| {
967            t.id();
968            t.foreign_id_for("user_id", "users");
969        });
970        let create = stmts
971            .iter()
972            .find(|s| s.starts_with("CREATE TABLE"))
973            .unwrap();
974        assert!(create.contains("FOREIGN KEY"));
975        assert!(create.contains("REFERENCES users (id)"));
976        assert!(!stmts.iter().any(|s| s.starts_with("ALTER TABLE")));
977    }
978
979    #[test]
980    fn explicit_foreign_builder_inlines_in_create() {
981        let stmts = create_stmts(Driver::Sqlite, |t| {
982            t.id();
983            t.big_integer("user_id").not_null();
984            t.foreign("user_id").references("id").on("users").cascade();
985        });
986        let create = stmts
987            .iter()
988            .find(|s| s.starts_with("CREATE TABLE"))
989            .unwrap();
990        assert!(create.contains("FOREIGN KEY (user_id)"));
991        assert!(create.contains("ON DELETE CASCADE"));
992    }
993
994    #[test]
995    fn unsigned_inlines_check_constraint() {
996        let stmts = create_stmts(Driver::Sqlite, |t| {
997            t.unsigned_big_integer("balance");
998        });
999        let create = stmts
1000            .iter()
1001            .find(|s| s.starts_with("CREATE TABLE"))
1002            .unwrap();
1003        assert!(
1004            create.contains("CHECK (balance >= 0)"),
1005            "CHECK should be inline, got: {create}"
1006        );
1007        assert!(!stmts.iter().any(|s| s.starts_with("ALTER TABLE")));
1008    }
1009
1010    #[test]
1011    fn enum_col_inlines_check_constraint() {
1012        let stmts = create_stmts(Driver::Sqlite, |t| {
1013            t.enum_col("status", &["draft", "published"]);
1014        });
1015        let create = stmts
1016            .iter()
1017            .find(|s| s.starts_with("CREATE TABLE"))
1018            .unwrap();
1019        assert!(create.contains("CHECK (status IN ('draft', 'published'))"));
1020    }
1021
1022    #[test]
1023    fn alter_mode_emits_alter_table_on_postgres() {
1024        let stmts = alter_stmts(Driver::Postgres, |t| {
1025            t.foreign("user_id").references("id").on("users").cascade();
1026        });
1027        assert!(stmts
1028            .iter()
1029            .any(|s| s.contains("ALTER TABLE posts ADD CONSTRAINT")
1030                && s.contains("FOREIGN KEY (user_id)")));
1031    }
1032
1033    #[test]
1034    fn alter_mode_skips_fk_on_sqlite() {
1035        // SQLite truly doesn't support adding FK to an existing table — we warn
1036        // and skip rather than emitting invalid SQL.
1037        let stmts = alter_stmts(Driver::Sqlite, |t| {
1038            t.foreign("user_id").references("id").on("users").cascade();
1039        });
1040        assert!(
1041            !stmts.iter().any(|s| s.contains("ADD CONSTRAINT")),
1042            "no ADD CONSTRAINT on SQLite alter, got: {stmts:?}"
1043        );
1044    }
1045
1046    #[test]
1047    fn default_quotes_string_literals() {
1048        let stmts = create_stmts(Driver::Postgres, |t| {
1049            t.string("status").not_null().default("pending");
1050        });
1051        let create = &stmts[0];
1052        assert!(
1053            create.contains("DEFAULT 'pending'"),
1054            "string default should be auto-quoted, got: {create}"
1055        );
1056    }
1057
1058    #[test]
1059    fn default_preserves_already_quoted() {
1060        let stmts = create_stmts(Driver::Postgres, |t| {
1061            t.string("status").default("'pending'");
1062        });
1063        assert!(stmts[0].contains("DEFAULT 'pending'"));
1064        // Should not double-quote.
1065        assert!(!stmts[0].contains("'''"));
1066    }
1067
1068    #[test]
1069    fn default_preserves_numeric_literal() {
1070        let stmts = create_stmts(Driver::Postgres, |t| {
1071            t.integer("attempts").default("0");
1072            t.integer("max").default("3");
1073            t.float("ratio").default("1.5");
1074        });
1075        assert!(stmts[0].contains("DEFAULT 0"));
1076        assert!(stmts[0].contains("DEFAULT 3"));
1077        assert!(stmts[0].contains("DEFAULT 1.5"));
1078    }
1079
1080    #[test]
1081    fn default_preserves_boolean_keywords() {
1082        let stmts = create_stmts(Driver::Postgres, |t| {
1083            t.boolean("active").default("true");
1084            t.boolean("paid").default("false");
1085        });
1086        assert!(stmts[0].contains("DEFAULT TRUE") || stmts[0].contains("DEFAULT true"));
1087        assert!(stmts[0].contains("DEFAULT FALSE") || stmts[0].contains("DEFAULT false"));
1088    }
1089
1090    #[test]
1091    fn default_preserves_current_timestamp() {
1092        let stmts = create_stmts(Driver::Postgres, |t| {
1093            t.timestamp("ts").default("CURRENT_TIMESTAMP");
1094        });
1095        assert!(stmts[0].contains("DEFAULT CURRENT_TIMESTAMP"));
1096    }
1097
1098    #[test]
1099    fn default_preserves_function_call() {
1100        let stmts = create_stmts(Driver::Postgres, |t| {
1101            t.uuid("id").default("gen_random_uuid()");
1102        });
1103        assert!(stmts[0].contains("DEFAULT gen_random_uuid()"));
1104    }
1105
1106    #[test]
1107    fn default_escapes_embedded_quotes() {
1108        let stmts = create_stmts(Driver::Postgres, |t| {
1109            t.string("note").default("O'Reilly");
1110        });
1111        assert!(
1112            stmts[0].contains("DEFAULT 'O''Reilly'"),
1113            "embedded quote should be escaped, got: {}",
1114            stmts[0]
1115        );
1116    }
1117
1118    #[test]
1119    fn default_raw_bypasses_quoting() {
1120        let stmts = create_stmts(Driver::Postgres, |t| {
1121            t.jsonb("meta").default_raw("'{}'::jsonb");
1122        });
1123        assert!(stmts[0].contains("DEFAULT '{}'::jsonb"));
1124    }
1125}