Skip to main content

pgevolve_core/render/
table.rs

1//! Table renderer.
2//!
3//! `render_table` emits a `CREATE TABLE` statement with inline columns and
4//! non-FK constraints (PK, UK, CHECK).  Foreign-key constraints are deliberately
5//! excluded from the inline form; callers should emit them separately via
6//! `render_add_fk` after all tables have been created.  This avoids forward-
7//! reference failures when two tables have FKs to each other.
8//!
9//! Column comments and the table comment are appended as separate
10//! `COMMENT ON ...` statements.
11
12use crate::identifier::QualifiedName;
13use crate::ir::constraint::{Constraint, ConstraintKind};
14use crate::ir::table::Table;
15use crate::plan::rewrite::sql as rewrite_sql;
16
17/// Render a `Table` as SQL.
18///
19/// Produces:
20/// 1. `CREATE TABLE <qname> (...);`  — with inline columns and all non-FK constraints.
21/// 2. Optional `COMMENT ON TABLE ... IS '...';`
22/// 3. Optional `COMMENT ON COLUMN ...` for each column that has a comment.
23///
24/// FK constraints are NOT included inline; use [`render_add_fk`] for those.
25#[must_use]
26pub fn render_table(t: &Table) -> String {
27    let mut out = String::new();
28
29    // Build a version of the table that excludes FK constraints for the
30    // inline CREATE TABLE body.
31    let table_without_fks = Table {
32        qname: t.qname.clone(),
33        columns: t.columns.clone(),
34        constraints: t
35            .constraints
36            .iter()
37            .filter(|c| !matches!(c.kind, ConstraintKind::ForeignKey(_)))
38            .cloned()
39            .collect(),
40        partition_by: None,
41        partition_of: None,
42        comment: t.comment.clone(),
43        owner: None,
44        grants: vec![],
45        rls_enabled: false,
46        rls_forced: false,
47        policies: vec![],
48        storage: crate::ir::reloptions::TableStorageOptions::default(),
49        access_method: None,
50        tablespace: None,
51    };
52
53    out.push_str(&rewrite_sql::create_table(&table_without_fks));
54    out.push('\n');
55
56    // Inline STORAGE in CREATE TABLE is PG 16+ syntax.  Emit separate
57    // ALTER TABLE … SET STORAGE statements (supported on all PG versions
58    // we target — 14–18) for each column that carries an explicit storage
59    // strategy.
60    for col in &t.columns {
61        if let Some(storage) = col.storage {
62            out.push_str(&rewrite_sql::alter_column_set_storage(
63                &t.qname, &col.name, storage,
64            ));
65            out.push('\n');
66        }
67    }
68
69    // Table comment.
70    if let Some(comment) = &t.comment {
71        out.push_str(&rewrite_sql::comment_on_table(
72            &t.qname,
73            Some(comment.as_str()),
74        ));
75        out.push('\n');
76    }
77
78    // Per-column comments.
79    for col in &t.columns {
80        if let Some(comment) = &col.comment {
81            out.push_str(&rewrite_sql::comment_on_column(
82                &t.qname,
83                &col.name,
84                Some(comment.as_str()),
85            ));
86            out.push('\n');
87        }
88    }
89
90    // Constraint comments (non-FK only; FK comments are handled by `render_add_fk`).
91    for c in &t.constraints {
92        if matches!(c.kind, ConstraintKind::ForeignKey(_)) {
93            continue;
94        }
95        if let Some(comment) = &c.comment {
96            out.push_str(&rewrite_sql::comment_on_constraint(
97                &t.qname,
98                &c.qname.name,
99                Some(comment.as_str()),
100            ));
101            out.push('\n');
102        }
103    }
104
105    out
106}
107
108/// Render a foreign-key constraint as `ALTER TABLE <table> ADD CONSTRAINT ...;`.
109///
110/// Also emits `COMMENT ON CONSTRAINT ...` when the constraint has a comment.
111#[must_use]
112pub fn render_add_fk(table_qname: &QualifiedName, c: &Constraint) -> String {
113    let mut out = String::new();
114    out.push_str(&rewrite_sql::alter_table_add_constraint(table_qname, c));
115    out.push('\n');
116    if let Some(comment) = &c.comment {
117        out.push_str(&rewrite_sql::comment_on_constraint(
118            table_qname,
119            &c.qname.name,
120            Some(comment.as_str()),
121        ));
122        out.push('\n');
123    }
124    out
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::identifier::Identifier;
131    use crate::ir::column::Column;
132    use crate::ir::column_type::ColumnType;
133    use crate::ir::constraint::{
134        Constraint, ConstraintKind, Deferrable, FkMatchType, ForeignKey, ReferentialAction,
135    };
136    use crate::ir::default_expr::{DefaultExpr, LiteralValue};
137    use crate::ir::table::Table;
138
139    fn id(s: &str) -> Identifier {
140        Identifier::from_unquoted(s).unwrap()
141    }
142
143    fn qn(schema: &str, name: &str) -> QualifiedName {
144        QualifiedName::new(id(schema), id(name))
145    }
146
147    fn col(name: &str, ty: ColumnType) -> Column {
148        Column {
149            name: id(name),
150            ty,
151            nullable: false,
152            default: None,
153            identity: None,
154            generated: None,
155            collation: None,
156            storage: None,
157            compression: None,
158            comment: None,
159        }
160    }
161
162    fn assert_pg_parseable(sql: &str) {
163        // pg_query::parse accepts multi-statement SQL; pass the whole block.
164        let r = pg_query::parse(sql);
165        assert!(r.is_ok(), "pg_query rejected SQL:\n{sql}\nerr: {r:?}");
166    }
167
168    #[test]
169    fn simple_table_renders() {
170        let t = Table {
171            qname: qn("app", "users"),
172            columns: vec![
173                col("id", ColumnType::BigInt),
174                Column {
175                    name: id("email"),
176                    ty: ColumnType::Text,
177                    nullable: true,
178                    ..col("email", ColumnType::Text)
179                },
180            ],
181            constraints: vec![Constraint {
182                qname: qn("app", "users_pkey"),
183                kind: ConstraintKind::PrimaryKey {
184                    columns: vec![id("id")],
185                    include: vec![],
186                },
187                deferrable: Deferrable::NotDeferrable,
188                comment: None,
189            }],
190            partition_by: None,
191            partition_of: None,
192            comment: None,
193            owner: None,
194            grants: vec![],
195            rls_enabled: false,
196            rls_forced: false,
197            policies: vec![],
198            storage: crate::ir::reloptions::TableStorageOptions::default(),
199            access_method: None,
200            tablespace: None,
201        };
202        let sql = render_table(&t);
203        assert!(sql.contains("CREATE TABLE app.users"));
204        assert!(sql.contains("id bigint NOT NULL"));
205        assert!(sql.contains("CONSTRAINT users_pkey PRIMARY KEY (id)"));
206        assert!(!sql.contains("FOREIGN KEY"), "FK must not appear inline");
207        assert_pg_parseable(&sql);
208    }
209
210    #[test]
211    fn fk_excluded_from_create_table() {
212        let t = Table {
213            qname: qn("app", "users"),
214            columns: vec![
215                col("id", ColumnType::BigInt),
216                col("org_id", ColumnType::BigInt),
217            ],
218            constraints: vec![
219                Constraint {
220                    qname: qn("app", "users_pkey"),
221                    kind: ConstraintKind::PrimaryKey {
222                        columns: vec![id("id")],
223                        include: vec![],
224                    },
225                    deferrable: Deferrable::NotDeferrable,
226                    comment: None,
227                },
228                Constraint {
229                    qname: qn("app", "users_org_fkey"),
230                    kind: ConstraintKind::ForeignKey(ForeignKey {
231                        columns: vec![id("org_id")],
232                        referenced_table: qn("app", "orgs"),
233                        referenced_columns: vec![id("id")],
234                        on_update: ReferentialAction::NoAction,
235                        on_delete: ReferentialAction::Cascade,
236                        match_type: FkMatchType::Simple,
237                    }),
238                    deferrable: Deferrable::NotDeferrable,
239                    comment: None,
240                },
241            ],
242            partition_by: None,
243            partition_of: None,
244            comment: None,
245            owner: None,
246            grants: vec![],
247            rls_enabled: false,
248            rls_forced: false,
249            policies: vec![],
250            storage: crate::ir::reloptions::TableStorageOptions::default(),
251            access_method: None,
252            tablespace: None,
253        };
254        let sql = render_table(&t);
255        assert!(
256            !sql.contains("FOREIGN KEY"),
257            "FK must not appear in render_table output"
258        );
259        assert_pg_parseable(&sql);
260    }
261
262    #[test]
263    fn render_add_fk_emits_alter_table() {
264        let fk = Constraint {
265            qname: qn("app", "users_org_fkey"),
266            kind: ConstraintKind::ForeignKey(ForeignKey {
267                columns: vec![id("org_id")],
268                referenced_table: qn("app", "orgs"),
269                referenced_columns: vec![id("id")],
270                on_update: ReferentialAction::NoAction,
271                on_delete: ReferentialAction::Cascade,
272                match_type: FkMatchType::Simple,
273            }),
274            deferrable: Deferrable::NotDeferrable,
275            comment: None,
276        };
277        let sql = render_add_fk(&qn("app", "users"), &fk);
278        assert!(sql.contains("ALTER TABLE app.users ADD"));
279        assert!(sql.contains("FOREIGN KEY"));
280        assert!(sql.contains("REFERENCES app.orgs"));
281        assert_pg_parseable(&sql);
282    }
283
284    #[test]
285    fn table_comment_rendered() {
286        let t = Table {
287            qname: qn("app", "orgs"),
288            columns: vec![col("id", ColumnType::BigInt)],
289            constraints: vec![],
290            partition_by: None,
291            partition_of: None,
292            comment: Some("organization records".into()),
293            owner: None,
294            grants: vec![],
295            rls_enabled: false,
296            rls_forced: false,
297            policies: vec![],
298            storage: crate::ir::reloptions::TableStorageOptions::default(),
299            access_method: None,
300            tablespace: None,
301        };
302        let sql = render_table(&t);
303        assert!(sql.contains("COMMENT ON TABLE app.orgs IS 'organization records';"));
304        assert_pg_parseable(&sql);
305    }
306
307    #[test]
308    fn column_comment_rendered() {
309        let mut c = col("email", ColumnType::Text);
310        c.comment = Some("email address".into());
311        let t = Table {
312            qname: qn("app", "users"),
313            columns: vec![c],
314            constraints: vec![],
315            partition_by: None,
316            partition_of: None,
317            comment: None,
318            owner: None,
319            grants: vec![],
320            rls_enabled: false,
321            rls_forced: false,
322            policies: vec![],
323            storage: crate::ir::reloptions::TableStorageOptions::default(),
324            access_method: None,
325            tablespace: None,
326        };
327        let sql = render_table(&t);
328        assert!(sql.contains("COMMENT ON COLUMN app.users.email IS 'email address';"));
329        assert_pg_parseable(&sql);
330    }
331
332    #[test]
333    fn column_with_default_literal() {
334        let mut c = col("active", ColumnType::Boolean);
335        c.default = Some(DefaultExpr::Literal(LiteralValue::Bool(true)));
336        c.nullable = true;
337        let t = Table {
338            qname: qn("app", "users"),
339            columns: vec![c],
340            constraints: vec![],
341            partition_by: None,
342            partition_of: None,
343            comment: None,
344            owner: None,
345            grants: vec![],
346            rls_enabled: false,
347            rls_forced: false,
348            policies: vec![],
349            storage: crate::ir::reloptions::TableStorageOptions::default(),
350            access_method: None,
351            tablespace: None,
352        };
353        let sql = render_table(&t);
354        assert!(sql.contains("DEFAULT true"));
355        assert_pg_parseable(&sql);
356    }
357
358    #[test]
359    fn nullable_column() {
360        let mut c = col("bio", ColumnType::Text);
361        c.nullable = true;
362        let t = Table {
363            qname: qn("app", "users"),
364            columns: vec![c],
365            constraints: vec![],
366            partition_by: None,
367            partition_of: None,
368            comment: None,
369            owner: None,
370            grants: vec![],
371            rls_enabled: false,
372            rls_forced: false,
373            policies: vec![],
374            storage: crate::ir::reloptions::TableStorageOptions::default(),
375            access_method: None,
376            tablespace: None,
377        };
378        let sql = render_table(&t);
379        // nullable columns must not have NOT NULL.
380        assert!(!sql.contains("NOT NULL"));
381        assert_pg_parseable(&sql);
382    }
383
384    #[test]
385    fn check_constraint_inline() {
386        use crate::ir::default_expr::NormalizedExpr;
387        let check = Constraint {
388            qname: qn("app", "users_email_check"),
389            kind: ConstraintKind::Check {
390                expression: NormalizedExpr::from_text("email ~* '^.+@.+$'"),
391                no_inherit: false,
392            },
393            deferrable: Deferrable::NotDeferrable,
394            comment: None,
395        };
396        let t = Table {
397            qname: qn("app", "users"),
398            columns: vec![col("email", ColumnType::Text)],
399            constraints: vec![check],
400            partition_by: None,
401            partition_of: None,
402            comment: None,
403            owner: None,
404            grants: vec![],
405            rls_enabled: false,
406            rls_forced: false,
407            policies: vec![],
408            storage: crate::ir::reloptions::TableStorageOptions::default(),
409            access_method: None,
410            tablespace: None,
411        };
412        let sql = render_table(&t);
413        assert!(sql.contains("CHECK"), "expected CHECK in CREATE TABLE");
414        assert_pg_parseable(&sql);
415    }
416
417    // -----------------------------------------------------------------------
418    // STORAGE / COMPRESSION in render_table
419    //
420    // Inline STORAGE in CREATE TABLE is PG 16+ syntax.  render_table must
421    // never emit it inline; instead it must emit a separate
422    // ALTER TABLE … SET STORAGE statement after the CREATE TABLE.
423    // -----------------------------------------------------------------------
424
425    #[test]
426    fn renders_storage_external_as_separate_alter_table() {
427        use crate::ir::column::StorageKind;
428        let mut c = col("doc", ColumnType::Text);
429        c.storage = Some(StorageKind::External);
430        let t = Table {
431            qname: qn("app", "users"),
432            columns: vec![c],
433            constraints: vec![],
434            partition_by: None,
435            partition_of: None,
436            comment: None,
437            owner: None,
438            grants: vec![],
439            rls_enabled: false,
440            rls_forced: false,
441            policies: vec![],
442            storage: crate::ir::reloptions::TableStorageOptions::default(),
443            access_method: None,
444            tablespace: None,
445        };
446        let sql = render_table(&t);
447        // Must NOT appear inline in the CREATE TABLE body.
448        assert!(
449            !sql.contains("doc text STORAGE EXTERNAL"),
450            "STORAGE must not be inline in CREATE TABLE, got: {sql}"
451        );
452        // Must appear as a separate ALTER TABLE statement.
453        assert!(
454            sql.contains("ALTER TABLE app.users ALTER COLUMN doc SET STORAGE EXTERNAL"),
455            "expected separate SET STORAGE statement, got: {sql}"
456        );
457        assert_pg_parseable(&sql);
458    }
459
460    #[test]
461    fn renders_inline_compression_lz4() {
462        use crate::ir::column::Compression;
463        let mut c = col("blob", ColumnType::Bytea);
464        c.compression = Some(Compression::Lz4);
465        let t = Table {
466            qname: qn("app", "users"),
467            columns: vec![c],
468            constraints: vec![],
469            partition_by: None,
470            partition_of: None,
471            comment: None,
472            owner: None,
473            grants: vec![],
474            rls_enabled: false,
475            rls_forced: false,
476            policies: vec![],
477            storage: crate::ir::reloptions::TableStorageOptions::default(),
478            access_method: None,
479            tablespace: None,
480        };
481        let sql = render_table(&t);
482        assert!(sql.contains("COMPRESSION lz4"), "got: {sql}");
483        assert_pg_parseable(&sql);
484    }
485
486    #[test]
487    fn renders_no_storage_compression_when_none() {
488        let c = col("plain", ColumnType::Text);
489        let t = Table {
490            qname: qn("app", "users"),
491            columns: vec![c],
492            constraints: vec![],
493            partition_by: None,
494            partition_of: None,
495            comment: None,
496            owner: None,
497            grants: vec![],
498            rls_enabled: false,
499            rls_forced: false,
500            policies: vec![],
501            storage: crate::ir::reloptions::TableStorageOptions::default(),
502            access_method: None,
503            tablespace: None,
504        };
505        let sql = render_table(&t);
506        assert!(!sql.contains("STORAGE"), "got: {sql}");
507        assert!(!sql.contains("COMPRESSION"), "got: {sql}");
508        assert_pg_parseable(&sql);
509    }
510}