Skip to main content

pgevolve_core/plan/rewrite/
sql.rs

1//! SQL rendering helpers for the rewrite pass.
2//!
3//! These functions produce canonical Postgres DDL strings from IR objects.
4//! They are used both by the non-rewriting dispatcher in [`super`] and by the
5//! online-rewrite submodules.
6//!
7//! Output is canonical (deterministic spacing, lowercase keywords, schema-qualified
8//! names) so that two equal IR inputs produce byte-identical SQL — required by
9//! the plan-id hash in spec §6.6.
10
11use super::reloptions::render_index_options;
12use crate::identifier::{Identifier, QualifiedName};
13use crate::ir::column::{
14    Column, Compression, GeneratedKind, Identity, IdentityKind, SequenceOptions, StorageKind,
15};
16use crate::ir::constraint::{
17    Constraint, ConstraintKind, Deferrable, FkMatchType, ForeignKey, ReferentialAction,
18};
19use crate::ir::default_expr::{DefaultExpr, LiteralValue};
20use crate::ir::index::{Index, IndexColumn, IndexColumnExpr, IndexMethod, NullsOrder, SortOrder};
21use crate::ir::schema::Schema;
22use crate::ir::sequence::{Sequence, SequenceOwner};
23use crate::ir::table::Table;
24
25// ---------------------------------------------------------------------------
26// Top-level statements
27// ---------------------------------------------------------------------------
28
29/// `CREATE SCHEMA name;`
30pub fn create_schema(s: &Schema) -> String {
31    format!("CREATE SCHEMA {};", s.name.render_sql())
32}
33
34/// `DROP SCHEMA name;`
35pub fn drop_schema(name: &Identifier) -> String {
36    format!("DROP SCHEMA {};", name.render_sql())
37}
38
39/// `COMMENT ON SCHEMA name IS '...';` (or `IS NULL` to clear).
40pub fn comment_on_schema(name: &Identifier, comment: Option<&str>) -> String {
41    format!(
42        "COMMENT ON SCHEMA {} IS {};",
43        name.render_sql(),
44        render_comment(comment),
45    )
46}
47
48/// `CREATE TABLE schema.name ( ... );` with inline columns and constraints.
49///
50/// When `table.partition_of` is set the column list is omitted entirely
51/// (partitions inherit their columns) and a `PARTITION OF parent FOR VALUES
52/// …` clause is emitted instead.  When `table.partition_by` is set a
53/// `PARTITION BY …` clause is appended before the trailing `;`.
54pub fn create_table(t: &Table) -> String {
55    let mut s = String::new();
56    s.push_str("CREATE TABLE ");
57    s.push_str(&t.qname.render_sql());
58
59    if let Some(po) = &t.partition_of {
60        // Child partition: no column list — columns are inherited from the
61        // parent.  Emit the PARTITION OF clause directly.
62        s.push(' ');
63        s.push_str(&crate::plan::rewrite::partitions::render_partition_of(po));
64    } else {
65        // Normal table (possibly a partitioned parent): emit the column list.
66        s.push_str(" (");
67        let mut first = true;
68        for col in &t.columns {
69            if !first {
70                s.push(',');
71            }
72            s.push('\n');
73            s.push_str("    ");
74            s.push_str(&column_def(col));
75            first = false;
76        }
77        for c in &t.constraints {
78            if !first {
79                s.push(',');
80            }
81            s.push('\n');
82            s.push_str("    ");
83            s.push_str(&inline_constraint(c));
84            first = false;
85        }
86        if !first {
87            s.push('\n');
88        }
89        s.push(')');
90    }
91
92    if let Some(pb) = &t.partition_by {
93        s.push(' ');
94        s.push_str(&crate::plan::rewrite::partitions::render_partition_by(pb));
95    }
96
97    // USING <access_method> goes after the element list / PARTITION BY clause
98    // and before any WITH (...) / TABLESPACE options (PG grammar order).
99    if let Some(am) = &t.access_method {
100        s.push_str(" USING ");
101        s.push_str(&am.render_sql());
102    }
103
104    // TABLESPACE clause — omit when None (use cluster default implicitly).
105    if let Some(ts) = &t.tablespace {
106        s.push_str(" TABLESPACE ");
107        s.push_str(&ts.render_sql());
108    }
109
110    s.push(';');
111    s
112}
113
114/// `DROP TABLE schema.name;`
115pub fn drop_table(qname: &QualifiedName) -> String {
116    format!("DROP TABLE {};", qname.render_sql())
117}
118
119/// `ALTER TABLE qname SET TABLESPACE name;`
120///
121/// When `name` is `None` the literal `pg_default` is used (the cluster default
122/// tablespace).
123pub fn alter_table_set_tablespace(qname: &QualifiedName, name: Option<&Identifier>) -> String {
124    let ts = name.map_or_else(|| "pg_default".to_owned(), Identifier::render_sql);
125    format!("ALTER TABLE {} SET TABLESPACE {};", qname.render_sql(), ts)
126}
127
128/// `COMMENT ON TABLE qname IS '...';`
129pub fn comment_on_table(qname: &QualifiedName, comment: Option<&str>) -> String {
130    format!(
131        "COMMENT ON TABLE {} IS {};",
132        qname.render_sql(),
133        render_comment(comment),
134    )
135}
136
137/// `CREATE [UNIQUE] INDEX [CONCURRENTLY] name ON table USING method (...) [INCLUDE (...)] [WHERE ...];`
138pub fn create_index(idx: &Index, concurrently: bool) -> String {
139    let mut s = String::from("CREATE ");
140    if idx.unique {
141        s.push_str("UNIQUE ");
142    }
143    s.push_str("INDEX ");
144    if concurrently {
145        s.push_str("CONCURRENTLY ");
146    }
147    s.push_str(&idx.qname.name.render_sql());
148    s.push_str(" ON ");
149    s.push_str(&idx.on.qname().render_sql());
150    s.push_str(" USING ");
151    s.push_str(index_method(idx.method));
152    s.push_str(" (");
153    s.push_str(&render_index_columns(&idx.columns));
154    s.push(')');
155    if !idx.include.is_empty() {
156        s.push_str(" INCLUDE (");
157        s.push_str(&render_idents(&idx.include));
158        s.push(')');
159    }
160    if idx.unique && idx.nulls_not_distinct {
161        s.push_str(" NULLS NOT DISTINCT");
162    }
163    if !idx.storage.is_empty() {
164        s.push_str(" WITH (");
165        s.push_str(&render_index_options(&idx.storage));
166        s.push(')');
167    }
168    if let Some(ts) = &idx.tablespace {
169        s.push_str(" TABLESPACE ");
170        s.push_str(&ts.render_sql());
171    }
172    if let Some(pred) = &idx.predicate {
173        s.push_str(" WHERE ");
174        s.push_str(&pred.canonical_text);
175    }
176    s.push(';');
177    s
178}
179
180/// `DROP INDEX [CONCURRENTLY] name;`
181pub fn drop_index(qname: &QualifiedName, concurrently: bool) -> String {
182    if concurrently {
183        format!("DROP INDEX CONCURRENTLY {};", qname.render_sql())
184    } else {
185        format!("DROP INDEX {};", qname.render_sql())
186    }
187}
188
189/// `CREATE SEQUENCE schema.name AS T [INCREMENT BY n] ...`.
190pub fn create_sequence(s: &Sequence) -> String {
191    let mut out = String::from("CREATE SEQUENCE ");
192    out.push_str(&s.qname.render_sql());
193    out.push_str(" AS ");
194    out.push_str(&s.data_type.render_sql());
195    out.push_str(&format!(" INCREMENT BY {}", s.increment));
196    if let Some(min) = s.min_value {
197        out.push_str(&format!(" MINVALUE {min}"));
198    } else {
199        out.push_str(" NO MINVALUE");
200    }
201    if let Some(max) = s.max_value {
202        out.push_str(&format!(" MAXVALUE {max}"));
203    } else {
204        out.push_str(" NO MAXVALUE");
205    }
206    out.push_str(&format!(" START WITH {}", s.start));
207    out.push_str(&format!(" CACHE {}", s.cache));
208    if s.cycle {
209        out.push_str(" CYCLE");
210    } else {
211        out.push_str(" NO CYCLE");
212    }
213    if let Some(owner) = &s.owned_by {
214        out.push_str(" OWNED BY ");
215        out.push_str(&render_owner(owner));
216    }
217    out.push(';');
218    out
219}
220
221/// `DROP SEQUENCE schema.name;`
222pub fn drop_sequence(qname: &QualifiedName) -> String {
223    format!("DROP SEQUENCE {};", qname.render_sql())
224}
225
226// ---------------------------------------------------------------------------
227// ALTER TABLE column / constraint operations
228// ---------------------------------------------------------------------------
229
230/// `ALTER TABLE qname ADD COLUMN ...;`
231pub fn alter_table_add_column(qname: &QualifiedName, c: &Column) -> String {
232    format!(
233        "ALTER TABLE {} ADD COLUMN {};",
234        qname.render_sql(),
235        column_def(c),
236    )
237}
238
239/// `ALTER TABLE qname DROP COLUMN name;`
240pub fn alter_table_drop_column(qname: &QualifiedName, name: &Identifier) -> String {
241    format!(
242        "ALTER TABLE {} DROP COLUMN {};",
243        qname.render_sql(),
244        name.render_sql(),
245    )
246}
247
248/// `ALTER TABLE qname ALTER COLUMN name TYPE T [USING expr];`
249pub fn alter_column_type(
250    qname: &QualifiedName,
251    name: &Identifier,
252    to: &crate::ir::column_type::ColumnType,
253    using: Option<&crate::ir::default_expr::NormalizedExpr>,
254) -> String {
255    let mut s = format!(
256        "ALTER TABLE {} ALTER COLUMN {} TYPE {}",
257        qname.render_sql(),
258        name.render_sql(),
259        to.render_sql(),
260    );
261    if let Some(u) = using {
262        s.push_str(" USING ");
263        s.push_str(&u.canonical_text);
264    }
265    s.push(';');
266    s
267}
268
269/// `ALTER TABLE qname ALTER COLUMN name {SET|DROP} NOT NULL;`
270pub fn alter_column_set_nullable(
271    qname: &QualifiedName,
272    name: &Identifier,
273    nullable: bool,
274) -> String {
275    let action = if nullable {
276        "DROP NOT NULL"
277    } else {
278        "SET NOT NULL"
279    };
280    format!(
281        "ALTER TABLE {} ALTER COLUMN {} {};",
282        qname.render_sql(),
283        name.render_sql(),
284        action,
285    )
286}
287
288/// `ALTER TABLE qname ALTER COLUMN name {SET DEFAULT expr|DROP DEFAULT};`
289pub fn alter_column_set_default(
290    qname: &QualifiedName,
291    name: &Identifier,
292    default: Option<&DefaultExpr>,
293) -> String {
294    match default {
295        Some(d) => format!(
296            "ALTER TABLE {} ALTER COLUMN {} SET DEFAULT {};",
297            qname.render_sql(),
298            name.render_sql(),
299            render_default_expr(d),
300        ),
301        None => format!(
302            "ALTER TABLE {} ALTER COLUMN {} DROP DEFAULT;",
303            qname.render_sql(),
304            name.render_sql(),
305        ),
306    }
307}
308
309/// `ALTER TABLE qname ALTER COLUMN name { ADD GENERATED ... AS IDENTITY | DROP IDENTITY };`
310pub fn alter_column_set_identity(
311    qname: &QualifiedName,
312    name: &Identifier,
313    identity: Option<&Identity>,
314) -> String {
315    match identity {
316        Some(id) => format!(
317            "ALTER TABLE {} ALTER COLUMN {} ADD GENERATED {} AS IDENTITY{};",
318            qname.render_sql(),
319            name.render_sql(),
320            identity_kind(id.kind),
321            render_sequence_options(&id.sequence),
322        ),
323        None => format!(
324            "ALTER TABLE {} ALTER COLUMN {} DROP IDENTITY;",
325            qname.render_sql(),
326            name.render_sql(),
327        ),
328    }
329}
330
331/// `ALTER TABLE qname ALTER COLUMN name DROP EXPRESSION;`
332///
333/// Note: Postgres has no direct `ALTER COLUMN ... ADD GENERATED ... STORED`
334/// for non-identity stored expressions. Setting a generated expression on
335/// an existing column requires drop + readd of the column. v0.1 emits
336/// `DROP EXPRESSION` for `None`; for `Some`, emits a marker statement that
337/// makes the unsupported case visible as a plan error rather than silent.
338pub fn alter_column_set_generated(
339    qname: &QualifiedName,
340    name: &Identifier,
341    generated: Option<&crate::ir::column::Generated>,
342) -> String {
343    match generated {
344        None => format!(
345            "ALTER TABLE {} ALTER COLUMN {} DROP EXPRESSION;",
346            qname.render_sql(),
347            name.render_sql(),
348        ),
349        Some(g) => {
350            // Best-effort: emit Postgres' currently-unsupported syntax so the
351            // executor surfaces a clear error rather than silently no-op'ing.
352            // The expected resolution is for the differ to produce a column
353            // recreate in this case; until then this string serves as an
354            // explicit, debuggable marker.
355            format!(
356                "ALTER TABLE {} ALTER COLUMN {} SET EXPRESSION AS ({}) {};",
357                qname.render_sql(),
358                name.render_sql(),
359                g.expression.canonical_text,
360                generated_kind(g.kind),
361            )
362        }
363    }
364}
365
366/// `ALTER TABLE qname ALTER COLUMN name SET STORAGE {PLAIN|EXTERNAL|EXTENDED|MAIN};`
367pub fn alter_column_set_storage(
368    qname: &QualifiedName,
369    name: &Identifier,
370    storage: StorageKind,
371) -> String {
372    let kw = match storage {
373        StorageKind::Plain => "PLAIN",
374        StorageKind::External => "EXTERNAL",
375        StorageKind::Extended => "EXTENDED",
376        StorageKind::Main => "MAIN",
377    };
378    format!(
379        "ALTER TABLE {} ALTER COLUMN {} SET STORAGE {};",
380        qname.render_sql(),
381        name.render_sql(),
382        kw,
383    )
384}
385
386/// `ALTER TABLE qname ALTER COLUMN name SET COMPRESSION {pglz|lz4|DEFAULT};`
387pub fn alter_column_set_compression(
388    qname: &QualifiedName,
389    name: &Identifier,
390    compression: Option<Compression>,
391) -> String {
392    let kw = match compression {
393        Some(Compression::Pglz) => "pglz",
394        Some(Compression::Lz4) => "lz4",
395        None => "DEFAULT",
396    };
397    format!(
398        "ALTER TABLE {} ALTER COLUMN {} SET COMPRESSION {};",
399        qname.render_sql(),
400        name.render_sql(),
401        kw,
402    )
403}
404
405/// `COMMENT ON COLUMN qname.col IS '...';`
406pub fn comment_on_column(qname: &QualifiedName, col: &Identifier, comment: Option<&str>) -> String {
407    format!(
408        "COMMENT ON COLUMN {}.{} IS {};",
409        qname.render_sql(),
410        col.render_sql(),
411        render_comment(comment),
412    )
413}
414
415/// `ALTER TABLE qname ADD CONSTRAINT ...;` (validated form).
416pub fn alter_table_add_constraint(qname: &QualifiedName, c: &Constraint) -> String {
417    format!(
418        "ALTER TABLE {} ADD {};",
419        qname.render_sql(),
420        constraint_def_with_name(c),
421    )
422}
423
424/// `ALTER TABLE qname ADD CONSTRAINT ... NOT VALID;`
425pub fn alter_table_add_constraint_not_valid(qname: &QualifiedName, c: &Constraint) -> String {
426    format!(
427        "ALTER TABLE {} ADD {} NOT VALID;",
428        qname.render_sql(),
429        constraint_def_with_name(c),
430    )
431}
432
433/// `ALTER TABLE qname VALIDATE CONSTRAINT name;`
434pub fn alter_table_validate_constraint(qname: &QualifiedName, cname: &Identifier) -> String {
435    format!(
436        "ALTER TABLE {} VALIDATE CONSTRAINT {};",
437        qname.render_sql(),
438        cname.render_sql(),
439    )
440}
441
442/// `ALTER TABLE qname DROP CONSTRAINT name;`
443pub fn alter_table_drop_constraint(qname: &QualifiedName, cname: &Identifier) -> String {
444    format!(
445        "ALTER TABLE {} DROP CONSTRAINT {};",
446        qname.render_sql(),
447        cname.render_sql(),
448    )
449}
450
451/// `COMMENT ON CONSTRAINT name ON qname IS '...';`
452pub fn comment_on_constraint(
453    qname: &QualifiedName,
454    cname: &Identifier,
455    comment: Option<&str>,
456) -> String {
457    format!(
458        "COMMENT ON CONSTRAINT {} ON {} IS {};",
459        cname.render_sql(),
460        qname.render_sql(),
461        render_comment(comment),
462    )
463}
464
465/// `COMMENT ON INDEX qname IS '...';`
466pub fn comment_on_index(qname: &QualifiedName, comment: Option<&str>) -> String {
467    format!(
468        "COMMENT ON INDEX {} IS {};",
469        qname.render_sql(),
470        render_comment(comment),
471    )
472}
473
474/// `COMMENT ON SEQUENCE qname IS '...';`
475pub fn comment_on_sequence(qname: &QualifiedName, comment: Option<&str>) -> String {
476    format!(
477        "COMMENT ON SEQUENCE {} IS {};",
478        qname.render_sql(),
479        render_comment(comment),
480    )
481}
482
483// ---------------------------------------------------------------------------
484// ALTER SEQUENCE field-level ops
485// ---------------------------------------------------------------------------
486
487/// `ALTER SEQUENCE qname INCREMENT BY n;`
488pub fn alter_sequence_increment(qname: &QualifiedName, n: i64) -> String {
489    format!("ALTER SEQUENCE {} INCREMENT BY {n};", qname.render_sql())
490}
491
492/// `ALTER SEQUENCE qname { MINVALUE n | NO MINVALUE };`
493pub fn alter_sequence_min_value(qname: &QualifiedName, v: Option<i64>) -> String {
494    match v {
495        Some(n) => format!("ALTER SEQUENCE {} MINVALUE {n};", qname.render_sql()),
496        None => format!("ALTER SEQUENCE {} NO MINVALUE;", qname.render_sql()),
497    }
498}
499
500/// `ALTER SEQUENCE qname { MAXVALUE n | NO MAXVALUE };`
501pub fn alter_sequence_max_value(qname: &QualifiedName, v: Option<i64>) -> String {
502    match v {
503        Some(n) => format!("ALTER SEQUENCE {} MAXVALUE {n};", qname.render_sql()),
504        None => format!("ALTER SEQUENCE {} NO MAXVALUE;", qname.render_sql()),
505    }
506}
507
508/// `ALTER SEQUENCE qname CACHE n;`
509pub fn alter_sequence_cache(qname: &QualifiedName, n: i64) -> String {
510    format!("ALTER SEQUENCE {} CACHE {n};", qname.render_sql())
511}
512
513/// `ALTER SEQUENCE qname { CYCLE | NO CYCLE };`
514pub fn alter_sequence_cycle(qname: &QualifiedName, cycle: bool) -> String {
515    let kw = if cycle { "CYCLE" } else { "NO CYCLE" };
516    format!("ALTER SEQUENCE {} {kw};", qname.render_sql())
517}
518
519/// `ALTER SEQUENCE qname AS T;`
520pub fn alter_sequence_data_type(
521    qname: &QualifiedName,
522    ty: &crate::ir::column_type::ColumnType,
523) -> String {
524    format!(
525        "ALTER SEQUENCE {} AS {};",
526        qname.render_sql(),
527        ty.render_sql(),
528    )
529}
530
531/// `ALTER SEQUENCE qname OWNED BY { table.col | NONE };`
532pub fn alter_sequence_owned_by(qname: &QualifiedName, owner: Option<&SequenceOwner>) -> String {
533    match owner {
534        Some(o) => format!(
535            "ALTER SEQUENCE {} OWNED BY {};",
536            qname.render_sql(),
537            render_owner(o),
538        ),
539        None => format!("ALTER SEQUENCE {} OWNED BY NONE;", qname.render_sql()),
540    }
541}
542
543// ---------------------------------------------------------------------------
544// Helpers — column / constraint / index / sequence sub-pieces
545// ---------------------------------------------------------------------------
546
547/// One column in `CREATE TABLE` or `ALTER TABLE ADD COLUMN`.
548pub fn column_def(c: &Column) -> String {
549    let mut s = String::new();
550    s.push_str(&c.name.render_sql());
551    s.push(' ');
552    s.push_str(&c.ty.render_sql());
553    if let Some(coll) = &c.collation {
554        s.push_str(" COLLATE ");
555        s.push_str(&coll.render_sql());
556    }
557    // COMPRESSION must appear before column constraints (including NOT NULL) —
558    // that is the order the Postgres grammar requires.
559    //
560    // NOTE: Inline `STORAGE` in `CREATE TABLE` / `ALTER TABLE ADD COLUMN` is a
561    // PG 16+ feature (added in PostgreSQL 16).  PG 14 and PG 15 only accept
562    // `ALTER TABLE … ALTER COLUMN … SET STORAGE …` (a separate post-CREATE
563    // statement).  We therefore never emit inline STORAGE here; callers that
564    // produce `CREATE TABLE` or `ADD COLUMN` steps must follow up with
565    // `alter_column_set_storage` for any column whose `storage` field is
566    // `Some(…)`.  See `emit::table::create` and `emit::table::op` for where
567    // those follow-up steps are emitted.
568    //
569    // Inline COMPRESSION is fine on all supported targets (PG 14+).
570    if let Some(compression) = c.compression {
571        let kw = match compression {
572            Compression::Pglz => "pglz",
573            Compression::Lz4 => "lz4",
574        };
575        s.push_str(" COMPRESSION ");
576        s.push_str(kw);
577    }
578    if !c.nullable {
579        s.push_str(" NOT NULL");
580    }
581    if let Some(d) = &c.default {
582        s.push_str(" DEFAULT ");
583        s.push_str(&render_default_expr(d));
584    }
585    if let Some(id) = &c.identity {
586        s.push_str(" GENERATED ");
587        s.push_str(identity_kind(id.kind));
588        s.push_str(" AS IDENTITY");
589        s.push_str(&render_sequence_options(&id.sequence));
590    }
591    if let Some(g) = &c.generated {
592        s.push_str(" GENERATED ALWAYS AS (");
593        s.push_str(&g.expression.canonical_text);
594        s.push_str(") ");
595        s.push_str(generated_kind(g.kind));
596    }
597    s
598}
599
600/// A constraint clause as it appears inline in `CREATE TABLE`.
601fn inline_constraint(c: &Constraint) -> String {
602    constraint_def_with_name(c)
603}
604
605/// `CONSTRAINT name <body>` — used for both inline and `ADD CONSTRAINT` forms.
606pub fn constraint_def_with_name(c: &Constraint) -> String {
607    let mut s = format!(
608        "CONSTRAINT {} {}",
609        c.qname.name.render_sql(),
610        constraint_body(&c.kind)
611    );
612    match c.deferrable {
613        Deferrable::NotDeferrable => {}
614        Deferrable::Deferrable {
615            initially_deferred: true,
616        } => s.push_str(" DEFERRABLE INITIALLY DEFERRED"),
617        Deferrable::Deferrable {
618            initially_deferred: false,
619        } => s.push_str(" DEFERRABLE INITIALLY IMMEDIATE"),
620    }
621    s
622}
623
624fn constraint_body(k: &ConstraintKind) -> String {
625    match k {
626        ConstraintKind::PrimaryKey { columns, include } => {
627            let mut s = format!("PRIMARY KEY ({})", render_idents(columns));
628            if !include.is_empty() {
629                s.push_str(&format!(" INCLUDE ({})", render_idents(include)));
630            }
631            s
632        }
633        ConstraintKind::Unique {
634            columns,
635            include,
636            nulls_distinct,
637        } => {
638            let mut s = String::from("UNIQUE");
639            if !nulls_distinct {
640                s.push_str(" NULLS NOT DISTINCT");
641            }
642            s.push_str(&format!(" ({})", render_idents(columns)));
643            if !include.is_empty() {
644                s.push_str(&format!(" INCLUDE ({})", render_idents(include)));
645            }
646            s
647        }
648        ConstraintKind::ForeignKey(fk) => render_fk(fk),
649        ConstraintKind::Check {
650            expression,
651            no_inherit,
652        } => {
653            let mut s = format!("CHECK ({})", expression.canonical_text);
654            if *no_inherit {
655                s.push_str(" NO INHERIT");
656            }
657            s
658        }
659    }
660}
661
662fn render_fk(fk: &ForeignKey) -> String {
663    let mut s = format!(
664        "FOREIGN KEY ({}) REFERENCES {} ({})",
665        render_idents(&fk.columns),
666        fk.referenced_table.render_sql(),
667        render_idents(&fk.referenced_columns),
668    );
669    if !matches!(fk.match_type, FkMatchType::Simple) {
670        s.push_str(" MATCH ");
671        s.push_str(match fk.match_type {
672            FkMatchType::Simple => "SIMPLE",
673            FkMatchType::Full => "FULL",
674        });
675    }
676    if !matches!(fk.on_update, ReferentialAction::NoAction) {
677        s.push_str(" ON UPDATE ");
678        s.push_str(&referential_action(&fk.on_update));
679    }
680    if !matches!(fk.on_delete, ReferentialAction::NoAction) {
681        s.push_str(" ON DELETE ");
682        s.push_str(&referential_action(&fk.on_delete));
683    }
684    s
685}
686
687fn referential_action(a: &ReferentialAction) -> String {
688    match a {
689        ReferentialAction::NoAction => "NO ACTION".into(),
690        ReferentialAction::Restrict => "RESTRICT".into(),
691        ReferentialAction::Cascade => "CASCADE".into(),
692        ReferentialAction::SetNull(cols) => {
693            if cols.is_empty() {
694                "SET NULL".into()
695            } else {
696                format!("SET NULL ({})", render_idents(cols))
697            }
698        }
699        ReferentialAction::SetDefault(cols) => {
700            if cols.is_empty() {
701                "SET DEFAULT".into()
702            } else {
703                format!("SET DEFAULT ({})", render_idents(cols))
704            }
705        }
706    }
707}
708
709fn render_index_columns(cols: &[IndexColumn]) -> String {
710    let mut parts = Vec::with_capacity(cols.len());
711    for c in cols {
712        let mut s = match &c.expr {
713            IndexColumnExpr::Column(id) => id.render_sql(),
714            IndexColumnExpr::Expression(e) => format!("({})", e.canonical_text),
715        };
716        if let Some(coll) = &c.collation {
717            s.push_str(" COLLATE ");
718            s.push_str(&coll.render_sql());
719        }
720        if let Some(opc) = &c.opclass {
721            s.push(' ');
722            s.push_str(&opc.render_sql());
723        }
724        match c.sort_order {
725            SortOrder::Asc => {} // ASC is the default; emit only DESC.
726            SortOrder::Desc => s.push_str(" DESC"),
727        }
728        match c.nulls_order {
729            NullsOrder::NullsFirst => s.push_str(" NULLS FIRST"),
730            NullsOrder::NullsLast => {} // NULLS LAST is btree default for ASC.
731        }
732        parts.push(s);
733    }
734    parts.join(", ")
735}
736
737const fn index_method(m: IndexMethod) -> &'static str {
738    match m {
739        IndexMethod::BTree => "btree",
740        IndexMethod::Hash => "hash",
741        IndexMethod::Gin => "gin",
742        IndexMethod::Gist => "gist",
743        IndexMethod::Brin => "brin",
744        IndexMethod::Spgist => "spgist",
745    }
746}
747
748const fn identity_kind(k: IdentityKind) -> &'static str {
749    match k {
750        IdentityKind::Always => "ALWAYS",
751        IdentityKind::ByDefault => "BY DEFAULT",
752    }
753}
754
755const fn generated_kind(k: GeneratedKind) -> &'static str {
756    match k {
757        GeneratedKind::Stored => "STORED",
758    }
759}
760
761fn render_sequence_options(o: &SequenceOptions) -> String {
762    // Only emit the parenthesized clause if any value differs from PG defaults.
763    let defaults = SequenceOptions {
764        start: 1,
765        increment: 1,
766        min_value: None,
767        max_value: None,
768        cache: 1,
769        cycle: false,
770    };
771    if o == &defaults {
772        return String::new();
773    }
774    let mut parts: Vec<String> = Vec::new();
775    if o.start != defaults.start {
776        parts.push(format!("START WITH {}", o.start));
777    }
778    if o.increment != defaults.increment {
779        parts.push(format!("INCREMENT BY {}", o.increment));
780    }
781    if let Some(min) = o.min_value {
782        parts.push(format!("MINVALUE {min}"));
783    }
784    if let Some(max) = o.max_value {
785        parts.push(format!("MAXVALUE {max}"));
786    }
787    if o.cache != defaults.cache {
788        parts.push(format!("CACHE {}", o.cache));
789    }
790    if o.cycle {
791        parts.push("CYCLE".into());
792    }
793    if parts.is_empty() {
794        String::new()
795    } else {
796        format!(" ({})", parts.join(" "))
797    }
798}
799
800fn render_default_expr(d: &DefaultExpr) -> String {
801    match d {
802        DefaultExpr::Literal(LiteralValue::Bool(b)) => {
803            if *b {
804                "true".into()
805            } else {
806                "false".into()
807            }
808        }
809        DefaultExpr::Literal(LiteralValue::Integer(i)) => i.to_string(),
810        DefaultExpr::Literal(LiteralValue::Float(f)) => f.to_string(),
811        DefaultExpr::Literal(LiteralValue::Text(t)) => format!("'{}'", t.replace('\'', "''")),
812        DefaultExpr::Literal(LiteralValue::Bytea(b)) => format!("'\\x{}'", hex(b)),
813        DefaultExpr::Literal(LiteralValue::Null) => "NULL".into(),
814        DefaultExpr::Sequence(q) => format!("nextval('{}')", q.render_sql()),
815        DefaultExpr::Expr(e) => e.canonical_text.clone(),
816    }
817}
818
819fn hex(bytes: &[u8]) -> String {
820    use std::fmt::Write as _;
821    let mut s = String::with_capacity(bytes.len() * 2);
822    for b in bytes {
823        let _ = write!(s, "{b:02x}");
824    }
825    s
826}
827
828fn render_idents(v: &[Identifier]) -> String {
829    let mut s = String::new();
830    for (i, id) in v.iter().enumerate() {
831        if i > 0 {
832            s.push_str(", ");
833        }
834        s.push_str(&id.render_sql());
835    }
836    s
837}
838
839fn render_owner(o: &SequenceOwner) -> String {
840    format!("{}.{}", o.table.render_sql(), o.column.render_sql())
841}
842
843fn render_comment(comment: Option<&str>) -> String {
844    match comment {
845        Some(t) => format!("'{}'", t.replace('\'', "''")),
846        None => "NULL".into(),
847    }
848}
849
850#[cfg(test)]
851mod tests {
852    use super::*;
853    use crate::identifier::Identifier;
854    use crate::ir::column_type::ColumnType;
855    use crate::ir::partition::{
856        BoundDatum, PartitionBounds, PartitionBy, PartitionColumn, PartitionColumnKind,
857        PartitionOf, PartitionStrategy,
858    };
859
860    fn id(s: &str) -> Identifier {
861        Identifier::from_unquoted(s).unwrap()
862    }
863
864    fn qn(schema: &str, name: &str) -> QualifiedName {
865        QualifiedName::new(id(schema), id(name))
866    }
867
868    fn simple_col(name: &str) -> Column {
869        Column {
870            name: id(name),
871            ty: ColumnType::Text,
872            nullable: true,
873            collation: None,
874            default: None,
875            identity: None,
876            generated: None,
877            storage: None,
878            compression: None,
879            comment: None,
880        }
881    }
882
883    fn lit(s: &str) -> crate::ir::default_expr::NormalizedExpr {
884        crate::ir::default_expr::NormalizedExpr::from_text(s)
885    }
886
887    fn empty_table(qname: QualifiedName) -> Table {
888        Table {
889            qname,
890            columns: vec![],
891            constraints: vec![],
892            partition_by: None,
893            partition_of: None,
894            comment: None,
895            owner: None,
896            grants: vec![],
897            rls_enabled: false,
898            rls_forced: false,
899            policies: vec![],
900            storage: crate::ir::reloptions::TableStorageOptions::default(),
901            access_method: None,
902            tablespace: None,
903        }
904    }
905
906    #[test]
907    fn partitioned_parent_includes_partition_by() {
908        let mut t = empty_table(qn("app", "orders"));
909        t.columns = vec![simple_col("region")];
910        t.partition_by = Some(PartitionBy {
911            strategy: PartitionStrategy::List,
912            columns: vec![PartitionColumn {
913                kind: PartitionColumnKind::Column(id("region")),
914                collation: None,
915                opclass: None,
916            }],
917        });
918        let sql = create_table(&t);
919        assert!(
920            sql.ends_with("PARTITION BY LIST (region);"),
921            "expected PARTITION BY LIST (region); at end, got: {sql}"
922        );
923        assert!(
924            sql.contains("region text"),
925            "expected column def, got: {sql}"
926        );
927    }
928
929    #[test]
930    fn child_partition_emits_partition_of_no_column_list() {
931        let mut t = empty_table(qn("app", "orders_2024"));
932        t.partition_of = Some(PartitionOf {
933            parent: qn("app", "orders"),
934            bounds: PartitionBounds::Range {
935                from: vec![BoundDatum::Literal(lit("'2024-01-01'"))],
936                to: vec![BoundDatum::Literal(lit("'2025-01-01'"))],
937            },
938        });
939        let sql = create_table(&t);
940        assert_eq!(
941            sql,
942            "CREATE TABLE app.orders_2024 PARTITION OF app.orders FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');"
943        );
944        // Must not contain a column list parenthesis block
945        assert!(
946            !sql.contains('(') || sql.contains("FOR VALUES FROM ("),
947            "should not contain a column list opening paren, got: {sql}"
948        );
949    }
950
951    // -----------------------------------------------------------------------
952    // SQL helpers: alter_column_set_storage / alter_column_set_compression
953    // -----------------------------------------------------------------------
954
955    #[test]
956    fn renders_set_storage_external() {
957        let s = alter_column_set_storage(&qn("app", "t"), &id("c"), StorageKind::External);
958        assert_eq!(
959            s.trim(),
960            "ALTER TABLE app.t ALTER COLUMN c SET STORAGE EXTERNAL;"
961        );
962    }
963
964    #[test]
965    fn renders_all_storage_variants() {
966        for (storage, expected) in [
967            (StorageKind::Plain, "PLAIN"),
968            (StorageKind::External, "EXTERNAL"),
969            (StorageKind::Extended, "EXTENDED"),
970            (StorageKind::Main, "MAIN"),
971        ] {
972            let s = alter_column_set_storage(&qn("app", "t"), &id("c"), storage);
973            assert!(s.contains(&format!("SET STORAGE {expected}")), "got: {s}");
974        }
975    }
976
977    #[test]
978    fn renders_set_compression_lz4() {
979        let s = alter_column_set_compression(&qn("app", "t"), &id("c"), Some(Compression::Lz4));
980        assert_eq!(
981            s.trim(),
982            "ALTER TABLE app.t ALTER COLUMN c SET COMPRESSION lz4;"
983        );
984    }
985
986    #[test]
987    fn renders_set_compression_pglz() {
988        let s = alter_column_set_compression(&qn("app", "t"), &id("c"), Some(Compression::Pglz));
989        assert_eq!(
990            s.trim(),
991            "ALTER TABLE app.t ALTER COLUMN c SET COMPRESSION pglz;"
992        );
993    }
994
995    #[test]
996    fn renders_set_compression_default() {
997        let s = alter_column_set_compression(&qn("app", "t"), &id("c"), None);
998        assert_eq!(
999            s.trim(),
1000            "ALTER TABLE app.t ALTER COLUMN c SET COMPRESSION DEFAULT;"
1001        );
1002    }
1003
1004    // -----------------------------------------------------------------------
1005    // column_def: inline COMPRESSION only (no inline STORAGE — PG 14/15 do
1006    // not support inline STORAGE; callers emit a separate SET STORAGE step)
1007    // -----------------------------------------------------------------------
1008
1009    #[test]
1010    fn column_def_never_renders_inline_storage() {
1011        // Even when `storage` is set, `column_def` must NOT emit an inline
1012        // STORAGE clause.  Callers (`create_table`, `add_column`) are
1013        // responsible for emitting a separate ALTER TABLE … SET STORAGE step.
1014        for storage in [
1015            StorageKind::Plain,
1016            StorageKind::External,
1017            StorageKind::Extended,
1018            StorageKind::Main,
1019        ] {
1020            let mut c = simple_col("doc");
1021            c.storage = Some(storage);
1022            let s = column_def(&c);
1023            assert!(
1024                !s.contains("STORAGE"),
1025                "column_def must not emit inline STORAGE (PG 14/15 do not support it), got: {s}"
1026            );
1027        }
1028    }
1029
1030    #[test]
1031    fn column_def_renders_compression_lz4() {
1032        let mut c = simple_col("blob");
1033        c.compression = Some(Compression::Lz4);
1034        let s = column_def(&c);
1035        assert!(s.contains("COMPRESSION lz4"), "got: {s}");
1036    }
1037
1038    #[test]
1039    fn column_def_renders_no_clauses_when_none() {
1040        let c = simple_col("plain");
1041        let s = column_def(&c);
1042        assert!(!s.contains("STORAGE"), "got: {s}");
1043        assert!(!s.contains("COMPRESSION"), "got: {s}");
1044    }
1045
1046    // -----------------------------------------------------------------------
1047    // create_index: WITH (...) storage clause
1048    // -----------------------------------------------------------------------
1049
1050    fn simple_index() -> Index {
1051        use crate::ir::index::{IndexColumn, IndexColumnExpr, IndexParent, NullsOrder, SortOrder};
1052        Index {
1053            qname: qn("app", "idx_test"),
1054            on: IndexParent::Table(qn("app", "t")),
1055            method: crate::ir::index::IndexMethod::BTree,
1056            columns: vec![IndexColumn {
1057                expr: IndexColumnExpr::Column(id("col1")),
1058                collation: None,
1059                opclass: None,
1060                sort_order: SortOrder::Asc,
1061                nulls_order: NullsOrder::NullsLast,
1062            }],
1063            include: vec![],
1064            unique: false,
1065            nulls_not_distinct: false,
1066            predicate: None,
1067            tablespace: None,
1068            comment: None,
1069            storage: crate::ir::reloptions::IndexStorageOptions::default(),
1070        }
1071    }
1072
1073    #[test]
1074    fn create_index_emits_with_clause_when_fillfactor_set() {
1075        let mut idx = simple_index();
1076        idx.storage = crate::ir::reloptions::IndexStorageOptions {
1077            fillfactor: Some(80),
1078            ..Default::default()
1079        };
1080        let sql = create_index(&idx, false);
1081        assert!(
1082            sql.contains("WITH (fillfactor = 80)"),
1083            "expected WITH (fillfactor = 80) in SQL, got: {sql}"
1084        );
1085    }
1086
1087    #[test]
1088    fn create_index_no_with_clause_for_default_storage() {
1089        let idx = simple_index();
1090        let sql = create_index(&idx, false);
1091        assert!(
1092            !sql.contains("WITH ("),
1093            "expected no WITH clause for default storage, got: {sql}"
1094        );
1095    }
1096
1097    // -----------------------------------------------------------------------
1098    // create_table: USING <access_method>
1099    // -----------------------------------------------------------------------
1100
1101    #[test]
1102    fn create_table_with_access_method_emits_using_clause() {
1103        let mut t = empty_table(qn("app", "events"));
1104        t.columns = vec![simple_col("id")];
1105        t.access_method = Some(Identifier::from_unquoted("columnar").unwrap());
1106        let sql = create_table(&t);
1107        // USING must appear after the closing ')' of the column list
1108        let paren_close = sql.find(')').expect("closing paren");
1109        let using_pos = sql
1110            .find(" USING columnar")
1111            .expect("USING columnar not found in SQL");
1112        assert!(
1113            using_pos > paren_close,
1114            "USING must come after the closing ')' of the column list; got: {sql}"
1115        );
1116        assert!(sql.ends_with(';'), "SQL must end with ';', got: {sql}");
1117    }
1118
1119    #[test]
1120    fn create_table_without_access_method_emits_no_using_clause() {
1121        let mut t = empty_table(qn("app", "events"));
1122        t.columns = vec![simple_col("id")];
1123        // access_method is None (the default)
1124        let sql = create_table(&t);
1125        assert!(
1126            !sql.contains("USING"),
1127            "expected no USING clause when access_method is None, got: {sql}"
1128        );
1129    }
1130
1131    // --- tablespace tests ---
1132
1133    #[test]
1134    fn alter_table_set_tablespace_with_name() {
1135        let qname = qn("app", "orders");
1136        let ts = id("fast");
1137        let sql = alter_table_set_tablespace(&qname, Some(&ts));
1138        assert_eq!(sql, "ALTER TABLE app.orders SET TABLESPACE fast;");
1139    }
1140
1141    #[test]
1142    fn alter_table_set_tablespace_none_uses_pg_default() {
1143        let qname = qn("app", "orders");
1144        let sql = alter_table_set_tablespace(&qname, None);
1145        assert_eq!(sql, "ALTER TABLE app.orders SET TABLESPACE pg_default;");
1146    }
1147
1148    #[test]
1149    fn create_table_with_tablespace_appends_clause() {
1150        let mut t = empty_table(qn("app", "events"));
1151        t.columns = vec![simple_col("id")];
1152        t.tablespace = Some(id("fast_ssd"));
1153        let sql = create_table(&t);
1154        assert!(
1155            sql.ends_with(" TABLESPACE fast_ssd;"),
1156            "expected TABLESPACE clause at end, got: {sql}"
1157        );
1158    }
1159
1160    #[test]
1161    fn create_table_without_tablespace_omits_clause() {
1162        let mut t = empty_table(qn("app", "events"));
1163        t.columns = vec![simple_col("id")];
1164        // tablespace is None (the default)
1165        let sql = create_table(&t);
1166        assert!(
1167            !sql.contains("TABLESPACE"),
1168            "expected no TABLESPACE clause when tablespace is None, got: {sql}"
1169        );
1170    }
1171}