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)) => sql_string_literal(t),
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
843/// Render `s` as a complete single-quoted SQL string literal (doubles embedded
844/// single quotes). The single place literal escaping is defined.
845#[must_use]
846pub(crate) fn sql_string_literal(s: &str) -> String {
847    format!("'{}'", escape_sql_literal_body(s))
848}
849
850/// Double embedded single quotes for embedding inside a manually-quoted SQL
851/// literal. Prefer [`sql_string_literal`] when you control the whole literal.
852#[must_use]
853pub(crate) fn escape_sql_literal_body(s: &str) -> String {
854    s.replace('\'', "''")
855}
856
857fn render_comment(comment: Option<&str>) -> String {
858    match comment {
859        Some(t) => sql_string_literal(t),
860        None => "NULL".into(),
861    }
862}
863
864#[cfg(test)]
865mod tests {
866    use super::*;
867    use crate::identifier::Identifier;
868    use crate::ir::column_type::ColumnType;
869    use crate::ir::partition::{
870        BoundDatum, PartitionBounds, PartitionBy, PartitionColumn, PartitionColumnKind,
871        PartitionOf, PartitionStrategy,
872    };
873
874    fn id(s: &str) -> Identifier {
875        Identifier::from_unquoted(s).unwrap()
876    }
877
878    fn qn(schema: &str, name: &str) -> QualifiedName {
879        QualifiedName::new(id(schema), id(name))
880    }
881
882    fn simple_col(name: &str) -> Column {
883        Column {
884            name: id(name),
885            ty: ColumnType::Text,
886            nullable: true,
887            collation: None,
888            default: None,
889            identity: None,
890            generated: None,
891            storage: None,
892            compression: None,
893            comment: None,
894        }
895    }
896
897    fn lit(s: &str) -> crate::ir::default_expr::NormalizedExpr {
898        crate::ir::default_expr::NormalizedExpr::from_text(s)
899    }
900
901    fn empty_table(qname: QualifiedName) -> Table {
902        Table {
903            qname,
904            columns: vec![],
905            constraints: vec![],
906            partition_by: None,
907            partition_of: None,
908            comment: None,
909            owner: None,
910            grants: vec![],
911            rls_enabled: false,
912            rls_forced: false,
913            policies: vec![],
914            storage: crate::ir::reloptions::TableStorageOptions::default(),
915            access_method: None,
916            tablespace: None,
917        }
918    }
919
920    #[test]
921    fn partitioned_parent_includes_partition_by() {
922        let mut t = empty_table(qn("app", "orders"));
923        t.columns = vec![simple_col("region")];
924        t.partition_by = Some(PartitionBy {
925            strategy: PartitionStrategy::List,
926            columns: vec![PartitionColumn {
927                kind: PartitionColumnKind::Column(id("region")),
928                collation: None,
929                opclass: None,
930            }],
931        });
932        let sql = create_table(&t);
933        assert!(
934            sql.ends_with("PARTITION BY LIST (region);"),
935            "expected PARTITION BY LIST (region); at end, got: {sql}"
936        );
937        assert!(
938            sql.contains("region text"),
939            "expected column def, got: {sql}"
940        );
941    }
942
943    #[test]
944    fn child_partition_emits_partition_of_no_column_list() {
945        let mut t = empty_table(qn("app", "orders_2024"));
946        t.partition_of = Some(PartitionOf {
947            parent: qn("app", "orders"),
948            bounds: PartitionBounds::Range {
949                from: vec![BoundDatum::Literal(lit("'2024-01-01'"))],
950                to: vec![BoundDatum::Literal(lit("'2025-01-01'"))],
951            },
952        });
953        let sql = create_table(&t);
954        assert_eq!(
955            sql,
956            "CREATE TABLE app.orders_2024 PARTITION OF app.orders FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');"
957        );
958        // Must not contain a column list parenthesis block
959        assert!(
960            !sql.contains('(') || sql.contains("FOR VALUES FROM ("),
961            "should not contain a column list opening paren, got: {sql}"
962        );
963    }
964
965    // -----------------------------------------------------------------------
966    // SQL helpers: alter_column_set_storage / alter_column_set_compression
967    // -----------------------------------------------------------------------
968
969    #[test]
970    fn renders_set_storage_external() {
971        let s = alter_column_set_storage(&qn("app", "t"), &id("c"), StorageKind::External);
972        assert_eq!(
973            s.trim(),
974            "ALTER TABLE app.t ALTER COLUMN c SET STORAGE EXTERNAL;"
975        );
976    }
977
978    #[test]
979    fn renders_all_storage_variants() {
980        for (storage, expected) in [
981            (StorageKind::Plain, "PLAIN"),
982            (StorageKind::External, "EXTERNAL"),
983            (StorageKind::Extended, "EXTENDED"),
984            (StorageKind::Main, "MAIN"),
985        ] {
986            let s = alter_column_set_storage(&qn("app", "t"), &id("c"), storage);
987            assert!(s.contains(&format!("SET STORAGE {expected}")), "got: {s}");
988        }
989    }
990
991    #[test]
992    fn renders_set_compression_lz4() {
993        let s = alter_column_set_compression(&qn("app", "t"), &id("c"), Some(Compression::Lz4));
994        assert_eq!(
995            s.trim(),
996            "ALTER TABLE app.t ALTER COLUMN c SET COMPRESSION lz4;"
997        );
998    }
999
1000    #[test]
1001    fn renders_set_compression_pglz() {
1002        let s = alter_column_set_compression(&qn("app", "t"), &id("c"), Some(Compression::Pglz));
1003        assert_eq!(
1004            s.trim(),
1005            "ALTER TABLE app.t ALTER COLUMN c SET COMPRESSION pglz;"
1006        );
1007    }
1008
1009    #[test]
1010    fn renders_set_compression_default() {
1011        let s = alter_column_set_compression(&qn("app", "t"), &id("c"), None);
1012        assert_eq!(
1013            s.trim(),
1014            "ALTER TABLE app.t ALTER COLUMN c SET COMPRESSION DEFAULT;"
1015        );
1016    }
1017
1018    // -----------------------------------------------------------------------
1019    // column_def: inline COMPRESSION only (no inline STORAGE — PG 14/15 do
1020    // not support inline STORAGE; callers emit a separate SET STORAGE step)
1021    // -----------------------------------------------------------------------
1022
1023    #[test]
1024    fn column_def_never_renders_inline_storage() {
1025        // Even when `storage` is set, `column_def` must NOT emit an inline
1026        // STORAGE clause.  Callers (`create_table`, `add_column`) are
1027        // responsible for emitting a separate ALTER TABLE … SET STORAGE step.
1028        for storage in [
1029            StorageKind::Plain,
1030            StorageKind::External,
1031            StorageKind::Extended,
1032            StorageKind::Main,
1033        ] {
1034            let mut c = simple_col("doc");
1035            c.storage = Some(storage);
1036            let s = column_def(&c);
1037            assert!(
1038                !s.contains("STORAGE"),
1039                "column_def must not emit inline STORAGE (PG 14/15 do not support it), got: {s}"
1040            );
1041        }
1042    }
1043
1044    #[test]
1045    fn column_def_renders_compression_lz4() {
1046        let mut c = simple_col("blob");
1047        c.compression = Some(Compression::Lz4);
1048        let s = column_def(&c);
1049        assert!(s.contains("COMPRESSION lz4"), "got: {s}");
1050    }
1051
1052    #[test]
1053    fn column_def_renders_no_clauses_when_none() {
1054        let c = simple_col("plain");
1055        let s = column_def(&c);
1056        assert!(!s.contains("STORAGE"), "got: {s}");
1057        assert!(!s.contains("COMPRESSION"), "got: {s}");
1058    }
1059
1060    // -----------------------------------------------------------------------
1061    // create_index: WITH (...) storage clause
1062    // -----------------------------------------------------------------------
1063
1064    fn simple_index() -> Index {
1065        use crate::ir::index::{IndexColumn, IndexColumnExpr, IndexParent, NullsOrder, SortOrder};
1066        Index {
1067            qname: qn("app", "idx_test"),
1068            on: IndexParent::Table(qn("app", "t")),
1069            method: crate::ir::index::IndexMethod::BTree,
1070            columns: vec![IndexColumn {
1071                expr: IndexColumnExpr::Column(id("col1")),
1072                collation: None,
1073                opclass: None,
1074                sort_order: SortOrder::Asc,
1075                nulls_order: NullsOrder::NullsLast,
1076            }],
1077            include: vec![],
1078            unique: false,
1079            nulls_not_distinct: false,
1080            predicate: None,
1081            tablespace: None,
1082            comment: None,
1083            storage: crate::ir::reloptions::IndexStorageOptions::default(),
1084        }
1085    }
1086
1087    #[test]
1088    fn create_index_emits_with_clause_when_fillfactor_set() {
1089        let mut idx = simple_index();
1090        idx.storage = crate::ir::reloptions::IndexStorageOptions {
1091            fillfactor: Some(80),
1092            ..Default::default()
1093        };
1094        let sql = create_index(&idx, false);
1095        assert!(
1096            sql.contains("WITH (fillfactor = 80)"),
1097            "expected WITH (fillfactor = 80) in SQL, got: {sql}"
1098        );
1099    }
1100
1101    #[test]
1102    fn create_index_no_with_clause_for_default_storage() {
1103        let idx = simple_index();
1104        let sql = create_index(&idx, false);
1105        assert!(
1106            !sql.contains("WITH ("),
1107            "expected no WITH clause for default storage, got: {sql}"
1108        );
1109    }
1110
1111    // -----------------------------------------------------------------------
1112    // create_table: USING <access_method>
1113    // -----------------------------------------------------------------------
1114
1115    #[test]
1116    fn create_table_with_access_method_emits_using_clause() {
1117        let mut t = empty_table(qn("app", "events"));
1118        t.columns = vec![simple_col("id")];
1119        t.access_method = Some(Identifier::from_unquoted("columnar").unwrap());
1120        let sql = create_table(&t);
1121        // USING must appear after the closing ')' of the column list
1122        let paren_close = sql.find(')').expect("closing paren");
1123        let using_pos = sql
1124            .find(" USING columnar")
1125            .expect("USING columnar not found in SQL");
1126        assert!(
1127            using_pos > paren_close,
1128            "USING must come after the closing ')' of the column list; got: {sql}"
1129        );
1130        assert!(sql.ends_with(';'), "SQL must end with ';', got: {sql}");
1131    }
1132
1133    #[test]
1134    fn create_table_without_access_method_emits_no_using_clause() {
1135        let mut t = empty_table(qn("app", "events"));
1136        t.columns = vec![simple_col("id")];
1137        // access_method is None (the default)
1138        let sql = create_table(&t);
1139        assert!(
1140            !sql.contains("USING"),
1141            "expected no USING clause when access_method is None, got: {sql}"
1142        );
1143    }
1144
1145    // --- tablespace tests ---
1146
1147    #[test]
1148    fn alter_table_set_tablespace_with_name() {
1149        let qname = qn("app", "orders");
1150        let ts = id("fast");
1151        let sql = alter_table_set_tablespace(&qname, Some(&ts));
1152        assert_eq!(sql, "ALTER TABLE app.orders SET TABLESPACE fast;");
1153    }
1154
1155    #[test]
1156    fn alter_table_set_tablespace_none_uses_pg_default() {
1157        let qname = qn("app", "orders");
1158        let sql = alter_table_set_tablespace(&qname, None);
1159        assert_eq!(sql, "ALTER TABLE app.orders SET TABLESPACE pg_default;");
1160    }
1161
1162    #[test]
1163    fn create_table_with_tablespace_appends_clause() {
1164        let mut t = empty_table(qn("app", "events"));
1165        t.columns = vec![simple_col("id")];
1166        t.tablespace = Some(id("fast_ssd"));
1167        let sql = create_table(&t);
1168        assert!(
1169            sql.ends_with(" TABLESPACE fast_ssd;"),
1170            "expected TABLESPACE clause at end, got: {sql}"
1171        );
1172    }
1173
1174    #[test]
1175    fn create_table_without_tablespace_omits_clause() {
1176        let mut t = empty_table(qn("app", "events"));
1177        t.columns = vec![simple_col("id")];
1178        // tablespace is None (the default)
1179        let sql = create_table(&t);
1180        assert!(
1181            !sql.contains("TABLESPACE"),
1182            "expected no TABLESPACE clause when tablespace is None, got: {sql}"
1183        );
1184    }
1185}