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    };
50
51    out.push_str(&rewrite_sql::create_table(&table_without_fks));
52    out.push('\n');
53
54    // Table comment.
55    if let Some(comment) = &t.comment {
56        out.push_str(&rewrite_sql::comment_on_table(
57            &t.qname,
58            Some(comment.as_str()),
59        ));
60        out.push('\n');
61    }
62
63    // Per-column comments.
64    for col in &t.columns {
65        if let Some(comment) = &col.comment {
66            out.push_str(&rewrite_sql::comment_on_column(
67                &t.qname,
68                &col.name,
69                Some(comment.as_str()),
70            ));
71            out.push('\n');
72        }
73    }
74
75    // Constraint comments (non-FK only; FK comments are handled by `render_add_fk`).
76    for c in &t.constraints {
77        if matches!(c.kind, ConstraintKind::ForeignKey(_)) {
78            continue;
79        }
80        if let Some(comment) = &c.comment {
81            out.push_str(&rewrite_sql::comment_on_constraint(
82                &t.qname,
83                &c.qname.name,
84                Some(comment.as_str()),
85            ));
86            out.push('\n');
87        }
88    }
89
90    out
91}
92
93/// Render a foreign-key constraint as `ALTER TABLE <table> ADD CONSTRAINT ...;`.
94///
95/// Also emits `COMMENT ON CONSTRAINT ...` when the constraint has a comment.
96#[must_use]
97pub fn render_add_fk(table_qname: &QualifiedName, c: &Constraint) -> String {
98    let mut out = String::new();
99    out.push_str(&rewrite_sql::alter_table_add_constraint(table_qname, c));
100    out.push('\n');
101    if let Some(comment) = &c.comment {
102        out.push_str(&rewrite_sql::comment_on_constraint(
103            table_qname,
104            &c.qname.name,
105            Some(comment.as_str()),
106        ));
107        out.push('\n');
108    }
109    out
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use crate::identifier::Identifier;
116    use crate::ir::column::Column;
117    use crate::ir::column_type::ColumnType;
118    use crate::ir::constraint::{
119        Constraint, ConstraintKind, Deferrable, FkMatchType, ForeignKey, ReferentialAction,
120    };
121    use crate::ir::default_expr::{DefaultExpr, LiteralValue};
122    use crate::ir::table::Table;
123
124    fn id(s: &str) -> Identifier {
125        Identifier::from_unquoted(s).unwrap()
126    }
127
128    fn qn(schema: &str, name: &str) -> QualifiedName {
129        QualifiedName::new(id(schema), id(name))
130    }
131
132    fn col(name: &str, ty: ColumnType) -> Column {
133        Column {
134            name: id(name),
135            ty,
136            nullable: false,
137            default: None,
138            identity: None,
139            generated: None,
140            collation: None,
141            storage: None,
142            compression: None,
143            comment: None,
144        }
145    }
146
147    fn assert_pg_parseable(sql: &str) {
148        // pg_query::parse accepts multi-statement SQL; pass the whole block.
149        let r = pg_query::parse(sql);
150        assert!(r.is_ok(), "pg_query rejected SQL:\n{sql}\nerr: {r:?}");
151    }
152
153    #[test]
154    fn simple_table_renders() {
155        let t = Table {
156            qname: qn("app", "users"),
157            columns: vec![
158                col("id", ColumnType::BigInt),
159                Column {
160                    name: id("email"),
161                    ty: ColumnType::Text,
162                    nullable: true,
163                    ..col("email", ColumnType::Text)
164                },
165            ],
166            constraints: vec![Constraint {
167                qname: qn("app", "users_pkey"),
168                kind: ConstraintKind::PrimaryKey {
169                    columns: vec![id("id")],
170                    include: vec![],
171                },
172                deferrable: Deferrable::NotDeferrable,
173                comment: None,
174            }],
175            partition_by: None,
176            partition_of: None,
177            comment: None,
178            owner: None,
179            grants: vec![],
180            rls_enabled: false,
181            rls_forced: false,
182            policies: vec![],
183            storage: crate::ir::reloptions::TableStorageOptions::default(),
184        };
185        let sql = render_table(&t);
186        assert!(sql.contains("CREATE TABLE app.users"));
187        assert!(sql.contains("id bigint NOT NULL"));
188        assert!(sql.contains("CONSTRAINT users_pkey PRIMARY KEY (id)"));
189        assert!(!sql.contains("FOREIGN KEY"), "FK must not appear inline");
190        assert_pg_parseable(&sql);
191    }
192
193    #[test]
194    fn fk_excluded_from_create_table() {
195        let t = Table {
196            qname: qn("app", "users"),
197            columns: vec![
198                col("id", ColumnType::BigInt),
199                col("org_id", ColumnType::BigInt),
200            ],
201            constraints: vec![
202                Constraint {
203                    qname: qn("app", "users_pkey"),
204                    kind: ConstraintKind::PrimaryKey {
205                        columns: vec![id("id")],
206                        include: vec![],
207                    },
208                    deferrable: Deferrable::NotDeferrable,
209                    comment: None,
210                },
211                Constraint {
212                    qname: qn("app", "users_org_fkey"),
213                    kind: ConstraintKind::ForeignKey(ForeignKey {
214                        columns: vec![id("org_id")],
215                        referenced_table: qn("app", "orgs"),
216                        referenced_columns: vec![id("id")],
217                        on_update: ReferentialAction::NoAction,
218                        on_delete: ReferentialAction::Cascade,
219                        match_type: FkMatchType::Simple,
220                    }),
221                    deferrable: Deferrable::NotDeferrable,
222                    comment: None,
223                },
224            ],
225            partition_by: None,
226            partition_of: None,
227            comment: None,
228            owner: None,
229            grants: vec![],
230            rls_enabled: false,
231            rls_forced: false,
232            policies: vec![],
233            storage: crate::ir::reloptions::TableStorageOptions::default(),
234        };
235        let sql = render_table(&t);
236        assert!(
237            !sql.contains("FOREIGN KEY"),
238            "FK must not appear in render_table output"
239        );
240        assert_pg_parseable(&sql);
241    }
242
243    #[test]
244    fn render_add_fk_emits_alter_table() {
245        let fk = Constraint {
246            qname: qn("app", "users_org_fkey"),
247            kind: ConstraintKind::ForeignKey(ForeignKey {
248                columns: vec![id("org_id")],
249                referenced_table: qn("app", "orgs"),
250                referenced_columns: vec![id("id")],
251                on_update: ReferentialAction::NoAction,
252                on_delete: ReferentialAction::Cascade,
253                match_type: FkMatchType::Simple,
254            }),
255            deferrable: Deferrable::NotDeferrable,
256            comment: None,
257        };
258        let sql = render_add_fk(&qn("app", "users"), &fk);
259        assert!(sql.contains("ALTER TABLE app.users ADD"));
260        assert!(sql.contains("FOREIGN KEY"));
261        assert!(sql.contains("REFERENCES app.orgs"));
262        assert_pg_parseable(&sql);
263    }
264
265    #[test]
266    fn table_comment_rendered() {
267        let t = Table {
268            qname: qn("app", "orgs"),
269            columns: vec![col("id", ColumnType::BigInt)],
270            constraints: vec![],
271            partition_by: None,
272            partition_of: None,
273            comment: Some("organization records".into()),
274            owner: None,
275            grants: vec![],
276            rls_enabled: false,
277            rls_forced: false,
278            policies: vec![],
279            storage: crate::ir::reloptions::TableStorageOptions::default(),
280        };
281        let sql = render_table(&t);
282        assert!(sql.contains("COMMENT ON TABLE app.orgs IS 'organization records';"));
283        assert_pg_parseable(&sql);
284    }
285
286    #[test]
287    fn column_comment_rendered() {
288        let mut c = col("email", ColumnType::Text);
289        c.comment = Some("email address".into());
290        let t = Table {
291            qname: qn("app", "users"),
292            columns: vec![c],
293            constraints: vec![],
294            partition_by: None,
295            partition_of: None,
296            comment: None,
297            owner: None,
298            grants: vec![],
299            rls_enabled: false,
300            rls_forced: false,
301            policies: vec![],
302            storage: crate::ir::reloptions::TableStorageOptions::default(),
303        };
304        let sql = render_table(&t);
305        assert!(sql.contains("COMMENT ON COLUMN app.users.email IS 'email address';"));
306        assert_pg_parseable(&sql);
307    }
308
309    #[test]
310    fn column_with_default_literal() {
311        let mut c = col("active", ColumnType::Boolean);
312        c.default = Some(DefaultExpr::Literal(LiteralValue::Bool(true)));
313        c.nullable = true;
314        let t = Table {
315            qname: qn("app", "users"),
316            columns: vec![c],
317            constraints: vec![],
318            partition_by: None,
319            partition_of: None,
320            comment: None,
321            owner: None,
322            grants: vec![],
323            rls_enabled: false,
324            rls_forced: false,
325            policies: vec![],
326            storage: crate::ir::reloptions::TableStorageOptions::default(),
327        };
328        let sql = render_table(&t);
329        assert!(sql.contains("DEFAULT true"));
330        assert_pg_parseable(&sql);
331    }
332
333    #[test]
334    fn nullable_column() {
335        let mut c = col("bio", ColumnType::Text);
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        };
351        let sql = render_table(&t);
352        // nullable columns must not have NOT NULL.
353        assert!(!sql.contains("NOT NULL"));
354        assert_pg_parseable(&sql);
355    }
356
357    #[test]
358    fn check_constraint_inline() {
359        use crate::ir::default_expr::NormalizedExpr;
360        let check = Constraint {
361            qname: qn("app", "users_email_check"),
362            kind: ConstraintKind::Check {
363                expression: NormalizedExpr::from_text("email ~* '^.+@.+$'"),
364                no_inherit: false,
365            },
366            deferrable: Deferrable::NotDeferrable,
367            comment: None,
368        };
369        let t = Table {
370            qname: qn("app", "users"),
371            columns: vec![col("email", ColumnType::Text)],
372            constraints: vec![check],
373            partition_by: None,
374            partition_of: None,
375            comment: None,
376            owner: None,
377            grants: vec![],
378            rls_enabled: false,
379            rls_forced: false,
380            policies: vec![],
381            storage: crate::ir::reloptions::TableStorageOptions::default(),
382        };
383        let sql = render_table(&t);
384        assert!(sql.contains("CHECK"), "expected CHECK in CREATE TABLE");
385        assert_pg_parseable(&sql);
386    }
387
388    // -----------------------------------------------------------------------
389    // Inline STORAGE / COMPRESSION in CREATE TABLE
390    // -----------------------------------------------------------------------
391
392    #[test]
393    fn renders_inline_storage_external() {
394        use crate::ir::column::StorageKind;
395        let mut c = col("doc", ColumnType::Text);
396        c.storage = Some(StorageKind::External);
397        let t = Table {
398            qname: qn("app", "users"),
399            columns: vec![c],
400            constraints: vec![],
401            partition_by: None,
402            partition_of: None,
403            comment: None,
404            owner: None,
405            grants: vec![],
406            rls_enabled: false,
407            rls_forced: false,
408            policies: vec![],
409            storage: crate::ir::reloptions::TableStorageOptions::default(),
410        };
411        let sql = render_table(&t);
412        assert!(sql.contains("STORAGE EXTERNAL"), "got: {sql}");
413        assert_pg_parseable(&sql);
414    }
415
416    #[test]
417    fn renders_inline_compression_lz4() {
418        use crate::ir::column::Compression;
419        let mut c = col("blob", ColumnType::Bytea);
420        c.compression = Some(Compression::Lz4);
421        let t = Table {
422            qname: qn("app", "users"),
423            columns: vec![c],
424            constraints: vec![],
425            partition_by: None,
426            partition_of: None,
427            comment: None,
428            owner: None,
429            grants: vec![],
430            rls_enabled: false,
431            rls_forced: false,
432            policies: vec![],
433            storage: crate::ir::reloptions::TableStorageOptions::default(),
434        };
435        let sql = render_table(&t);
436        assert!(sql.contains("COMPRESSION lz4"), "got: {sql}");
437        assert_pg_parseable(&sql);
438    }
439
440    #[test]
441    fn renders_no_storage_compression_when_none() {
442        let c = col("plain", ColumnType::Text);
443        let t = Table {
444            qname: qn("app", "users"),
445            columns: vec![c],
446            constraints: vec![],
447            partition_by: None,
448            partition_of: None,
449            comment: None,
450            owner: None,
451            grants: vec![],
452            rls_enabled: false,
453            rls_forced: false,
454            policies: vec![],
455            storage: crate::ir::reloptions::TableStorageOptions::default(),
456        };
457        let sql = render_table(&t);
458        assert!(!sql.contains("STORAGE"), "got: {sql}");
459        assert!(!sql.contains("COMPRESSION"), "got: {sql}");
460        assert_pg_parseable(&sql);
461    }
462}