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