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