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