Skip to main content

pgevolve_core/render/
mod.rs

1//! IR → SQL emitter.
2//!
3//! Produces source-style `CREATE` statements for each Catalog object.
4//! Used by `pgevolve dump` and by future regression-capture tooling.
5//!
6//! ## Output contract
7//!
8//! - Schemas first, then tables (with their inline constraints, excluding FKs),
9//!   then FK `ALTER TABLE ... ADD CONSTRAINT` statements (to handle FK cycles),
10//!   then standalone indexes, then sequences.
11//! - Output is deterministic: for equal `Catalog` inputs, byte-identical SQL is
12//!   produced.  Objects are emitted in their iteration order (callers wanting
13//!   canonical sort should call [`Catalog::canonicalize`] first).
14//! - Comments are emitted as `COMMENT ON ...` statements immediately after the
15//!   object they describe.
16//!
17//! ## v0.1 limitations
18//!
19//! - Views / materialized views / functions / triggers are not emitted (they
20//!   are not modelled in the v0.1 IR).
21//! - The output does NOT include pgevolve source directives (e.g.
22//!   `-- pgevolve: intent = ...`), so a directory written by `pgevolve dump`
23//!   cannot be fed directly to `pgevolve lint` or `parse_directory` without
24//!   first adding those directives.  Users running `pgevolve init` against the
25//!   dump output should add directives manually, or use a future `pgevolve
26//!   annotate` helper.
27
28pub mod index;
29pub mod schema;
30pub mod sequence;
31pub mod table;
32pub mod view;
33
34use crate::ir::catalog::Catalog;
35use crate::ir::constraint::ConstraintKind;
36
37/// Render an entire `Catalog` as a single SQL string with one `CREATE`
38/// statement per object, in dependency-correct order.
39///
40/// Order: schemas → tables (inline PK/UK/CHECK only) → FK `ALTER TABLE ADD
41/// CONSTRAINT` stmts → standalone indexes → sequences.  Each block is
42/// separated by a blank line for readability.
43#[must_use]
44pub fn render_catalog(catalog: &Catalog) -> String {
45    let mut out = String::new();
46
47    // 1. Schemas.
48    for s in &catalog.schemas {
49        out.push_str(&schema::render_schema(s));
50        out.push('\n');
51    }
52    if !catalog.schemas.is_empty() {
53        out.push('\n');
54    }
55
56    // 2. Tables (without FK constraints inline — those come after).
57    for t in &catalog.tables {
58        out.push_str(&table::render_table(t));
59        out.push('\n');
60    }
61    if !catalog.tables.is_empty() {
62        out.push('\n');
63    }
64
65    // 3. FK constraints as ALTER TABLE ADD CONSTRAINT.
66    let mut had_fk = false;
67    for t in &catalog.tables {
68        for c in &t.constraints {
69            if matches!(c.kind, ConstraintKind::ForeignKey(_)) {
70                out.push_str(&table::render_add_fk(&t.qname, c));
71                out.push('\n');
72                had_fk = true;
73            }
74        }
75    }
76    if had_fk {
77        out.push('\n');
78    }
79
80    // 4. Standalone indexes.
81    for i in &catalog.indexes {
82        out.push_str(&index::render_index(i));
83        out.push('\n');
84    }
85    if !catalog.indexes.is_empty() {
86        out.push('\n');
87    }
88
89    // 5. Sequences.
90    for s in &catalog.sequences {
91        out.push_str(&sequence::render_sequence(s));
92        out.push('\n');
93    }
94    if !catalog.sequences.is_empty() {
95        out.push('\n');
96    }
97
98    // 6. Views (after sequences — views may reference sequence defaults).
99    for v in &catalog.views {
100        out.push_str(&view::render_view(v));
101        out.push('\n');
102    }
103    if !catalog.views.is_empty() {
104        out.push('\n');
105    }
106
107    // 7. Materialized views.
108    for mv in &catalog.materialized_views {
109        out.push_str(&view::render_materialized_view(mv));
110        out.push('\n');
111    }
112
113    // Strip trailing blank line.
114    while out.ends_with("\n\n") {
115        out.pop();
116    }
117
118    out
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124    use crate::identifier::{Identifier, QualifiedName};
125    use crate::ir::catalog::Catalog;
126    use crate::ir::column::Column;
127    use crate::ir::column_type::ColumnType;
128    use crate::ir::constraint::{
129        Constraint, ConstraintKind, Deferrable, FkMatchType, ForeignKey, ReferentialAction,
130    };
131    use crate::ir::index::{
132        Index, IndexColumn, IndexColumnExpr, IndexMethod, IndexParent, NullsOrder, SortOrder,
133    };
134    use crate::ir::schema::Schema;
135    use crate::ir::sequence::Sequence;
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    #[test]
147    fn empty_catalog_renders_empty() {
148        let rendered = render_catalog(&Catalog::empty());
149        // Should be empty or only whitespace.
150        assert!(rendered.trim().is_empty());
151    }
152
153    #[test]
154    fn schema_renders_first() {
155        let mut cat = Catalog::empty();
156        cat.schemas.push(Schema::new(id("app")));
157        cat.tables.push(Table {
158            qname: qn("app", "users"),
159            columns: vec![Column {
160                name: id("id"),
161                ty: ColumnType::BigInt,
162                nullable: false,
163                default: None,
164                identity: None,
165                generated: None,
166                collation: None,
167                storage: None,
168                compression: None,
169                comment: None,
170            }],
171            constraints: vec![],
172            partition_by: None,
173            partition_of: None,
174            comment: None,
175            owner: None,
176            grants: vec![],
177            rls_enabled: false,
178            rls_forced: false,
179            policies: vec![],
180            storage: crate::ir::reloptions::TableStorageOptions::default(),
181        });
182        let rendered = render_catalog(&cat);
183        let schema_pos = rendered.find("CREATE SCHEMA").unwrap();
184        let table_pos = rendered.find("CREATE TABLE").unwrap();
185        assert!(schema_pos < table_pos, "schema must come before table");
186    }
187
188    #[test]
189    #[allow(clippy::too_many_lines)] // exhaustive catalog fixture — structural, not logic complexity
190    fn fk_rendered_after_table() {
191        let mut cat = Catalog::empty();
192        cat.schemas.push(Schema::new(id("app")));
193        cat.tables.push(Table {
194            qname: qn("app", "orgs"),
195            columns: vec![Column {
196                name: id("id"),
197                ty: ColumnType::BigInt,
198                nullable: false,
199                default: None,
200                identity: None,
201                generated: None,
202                collation: None,
203                storage: None,
204                compression: None,
205                comment: None,
206            }],
207            constraints: vec![Constraint {
208                qname: qn("app", "orgs_pkey"),
209                kind: ConstraintKind::PrimaryKey {
210                    columns: vec![id("id")],
211                    include: vec![],
212                },
213                deferrable: Deferrable::NotDeferrable,
214                comment: None,
215            }],
216            partition_by: None,
217            partition_of: None,
218            comment: None,
219            owner: None,
220            grants: vec![],
221            rls_enabled: false,
222            rls_forced: false,
223            policies: vec![],
224            storage: crate::ir::reloptions::TableStorageOptions::default(),
225        });
226        cat.tables.push(Table {
227            qname: qn("app", "users"),
228            columns: vec![
229                Column {
230                    name: id("id"),
231                    ty: ColumnType::BigInt,
232                    nullable: false,
233                    default: None,
234                    identity: None,
235                    generated: None,
236                    collation: None,
237                    storage: None,
238                    compression: None,
239                    comment: None,
240                },
241                Column {
242                    name: id("org_id"),
243                    ty: ColumnType::BigInt,
244                    nullable: false,
245                    default: None,
246                    identity: None,
247                    generated: None,
248                    collation: None,
249                    storage: None,
250                    compression: None,
251                    comment: None,
252                },
253            ],
254            constraints: vec![
255                Constraint {
256                    qname: qn("app", "users_pkey"),
257                    kind: ConstraintKind::PrimaryKey {
258                        columns: vec![id("id")],
259                        include: vec![],
260                    },
261                    deferrable: Deferrable::NotDeferrable,
262                    comment: None,
263                },
264                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::NoAction,
272                        match_type: FkMatchType::Simple,
273                    }),
274                    deferrable: Deferrable::NotDeferrable,
275                    comment: None,
276                },
277            ],
278            partition_by: None,
279            partition_of: None,
280            comment: None,
281            owner: None,
282            grants: vec![],
283            rls_enabled: false,
284            rls_forced: false,
285            policies: vec![],
286            storage: crate::ir::reloptions::TableStorageOptions::default(),
287        });
288
289        let rendered = render_catalog(&cat);
290
291        // FK must appear as ALTER TABLE, not inline in CREATE TABLE.
292        assert!(
293            rendered.contains("ALTER TABLE"),
294            "expected ALTER TABLE for FK"
295        );
296        let create_table_pos = rendered.rfind("CREATE TABLE").unwrap();
297        let alter_pos = rendered.find("ALTER TABLE").unwrap();
298        assert!(
299            create_table_pos < alter_pos,
300            "ALTER TABLE must come after last CREATE TABLE"
301        );
302
303        // FK should NOT appear inline in CREATE TABLE.
304        let create_table_end = rendered.find("ALTER TABLE").unwrap();
305        let create_table_section = &rendered[..create_table_end];
306        assert!(
307            !create_table_section.contains("FOREIGN KEY"),
308            "FK should not be inline in CREATE TABLE"
309        );
310    }
311
312    #[test]
313    fn indexes_rendered_after_tables() {
314        let mut cat = Catalog::empty();
315        cat.tables.push(Table {
316            qname: qn("app", "users"),
317            columns: vec![Column {
318                name: id("email"),
319                ty: ColumnType::Text,
320                nullable: false,
321                default: None,
322                identity: None,
323                generated: None,
324                collation: None,
325                storage: None,
326                compression: None,
327                comment: None,
328            }],
329            constraints: vec![],
330            partition_by: None,
331            partition_of: None,
332            comment: None,
333            owner: None,
334            grants: vec![],
335            rls_enabled: false,
336            rls_forced: false,
337            policies: vec![],
338            storage: crate::ir::reloptions::TableStorageOptions::default(),
339        });
340        cat.indexes.push(Index {
341            qname: qn("app", "users_email_idx"),
342            on: IndexParent::Table(qn("app", "users")),
343            method: IndexMethod::BTree,
344            columns: vec![IndexColumn {
345                expr: IndexColumnExpr::Column(id("email")),
346                collation: None,
347                opclass: None,
348                sort_order: SortOrder::Asc,
349                nulls_order: NullsOrder::NullsLast,
350            }],
351            include: vec![],
352            unique: true,
353            nulls_not_distinct: false,
354            predicate: None,
355            tablespace: None,
356            comment: None,
357            storage: crate::ir::reloptions::IndexStorageOptions::default(),
358        });
359
360        let rendered = render_catalog(&cat);
361        let table_pos = rendered.find("CREATE TABLE").unwrap();
362        let index_pos = rendered.find("CREATE UNIQUE INDEX").unwrap();
363        assert!(table_pos < index_pos, "index must come after table");
364    }
365
366    #[test]
367    fn sequences_rendered_last() {
368        let mut cat = Catalog::empty();
369        cat.sequences.push(Sequence {
370            qname: qn("app", "users_id_seq"),
371            data_type: ColumnType::BigInt,
372            start: 1,
373            increment: 1,
374            min_value: None,
375            max_value: None,
376            cache: 1,
377            cycle: false,
378            owned_by: None,
379            comment: None,
380            owner: None,
381            grants: vec![],
382        });
383        cat.tables.push(Table {
384            qname: qn("app", "users"),
385            columns: vec![Column {
386                name: id("id"),
387                ty: ColumnType::BigInt,
388                nullable: false,
389                default: None,
390                identity: None,
391                generated: None,
392                collation: None,
393                storage: None,
394                compression: None,
395                comment: None,
396            }],
397            constraints: vec![],
398            partition_by: None,
399            partition_of: None,
400            comment: None,
401            owner: None,
402            grants: vec![],
403            rls_enabled: false,
404            rls_forced: false,
405            policies: vec![],
406            storage: crate::ir::reloptions::TableStorageOptions::default(),
407        });
408
409        let rendered = render_catalog(&cat);
410        let table_pos = rendered.find("CREATE TABLE").unwrap();
411        let seq_pos = rendered.find("CREATE SEQUENCE").unwrap();
412        assert!(table_pos < seq_pos, "sequence must come after table");
413    }
414
415    #[test]
416    fn parseable_by_pg_query() {
417        let mut cat = Catalog::empty();
418        cat.schemas.push(Schema::new(id("app")));
419        cat.tables.push(Table {
420            qname: qn("app", "users"),
421            columns: vec![
422                Column {
423                    name: id("id"),
424                    ty: ColumnType::BigInt,
425                    nullable: false,
426                    default: None,
427                    identity: None,
428                    generated: None,
429                    collation: None,
430                    storage: None,
431                    compression: None,
432                    comment: None,
433                },
434                Column {
435                    name: id("email"),
436                    ty: ColumnType::Text,
437                    nullable: false,
438                    default: None,
439                    identity: None,
440                    generated: None,
441                    collation: None,
442                    storage: None,
443                    compression: None,
444                    comment: None,
445                },
446            ],
447            constraints: vec![Constraint {
448                qname: qn("app", "users_pkey"),
449                kind: ConstraintKind::PrimaryKey {
450                    columns: vec![id("id")],
451                    include: vec![],
452                },
453                deferrable: Deferrable::NotDeferrable,
454                comment: None,
455            }],
456            partition_by: None,
457            partition_of: None,
458            comment: Some("user accounts".into()),
459            owner: None,
460            grants: vec![],
461            rls_enabled: false,
462            rls_forced: false,
463            policies: vec![],
464            storage: crate::ir::reloptions::TableStorageOptions::default(),
465        });
466        cat.indexes.push(Index {
467            qname: qn("app", "users_email_idx"),
468            on: IndexParent::Table(qn("app", "users")),
469            method: IndexMethod::BTree,
470            columns: vec![IndexColumn {
471                expr: IndexColumnExpr::Column(id("email")),
472                collation: None,
473                opclass: None,
474                sort_order: SortOrder::Asc,
475                nulls_order: NullsOrder::NullsLast,
476            }],
477            include: vec![],
478            unique: true,
479            nulls_not_distinct: false,
480            predicate: None,
481            tablespace: None,
482            comment: None,
483            storage: crate::ir::reloptions::IndexStorageOptions::default(),
484        });
485
486        let rendered = render_catalog(&cat);
487
488        // The whole block must be parseable by pg_query as multi-statement SQL.
489        let r = pg_query::parse(&rendered);
490        assert!(
491            r.is_ok(),
492            "pg_query rejected rendered catalog:\n{rendered}\nerr: {r:?}"
493        );
494    }
495
496    #[test]
497    fn views_rendered_after_sequences() {
498        use crate::ir::view::View;
499        use crate::parse::normalize_body::NormalizedBody;
500
501        let mut cat = Catalog::empty();
502        cat.sequences.push(Sequence {
503            qname: qn("app", "users_id_seq"),
504            data_type: ColumnType::BigInt,
505            start: 1,
506            increment: 1,
507            min_value: None,
508            max_value: None,
509            cache: 1,
510            cycle: false,
511            owned_by: None,
512            comment: None,
513            owner: None,
514            grants: vec![],
515        });
516        cat.views.push(View {
517            qname: qn("app", "active_users"),
518            columns: vec![],
519            body_canonical: NormalizedBody::from_sql("SELECT 1").unwrap(),
520            body_dependencies: vec![],
521            security_barrier: None,
522            security_invoker: None,
523            comment: None,
524            raw_body: String::new(),
525            owner: None,
526            grants: vec![],
527        });
528
529        let rendered = render_catalog(&cat);
530        let seq_pos = rendered.find("CREATE SEQUENCE").unwrap();
531        let view_pos = rendered.find("CREATE VIEW").unwrap();
532        assert!(seq_pos < view_pos, "views must come after sequences");
533    }
534
535    #[test]
536    fn materialized_views_rendered_in_catalog() {
537        use crate::ir::view::MaterializedView;
538        use crate::parse::normalize_body::NormalizedBody;
539
540        let mut cat = Catalog::empty();
541        cat.materialized_views.push(MaterializedView {
542            qname: qn("app", "summary"),
543            columns: vec![],
544            body_canonical: NormalizedBody::from_sql("SELECT count(*) FROM app.users").unwrap(),
545            body_dependencies: vec![],
546            comment: None,
547            raw_body: String::new(),
548            owner: None,
549            grants: vec![],
550            storage: crate::ir::reloptions::MaterializedViewStorageOptions::default(),
551        });
552
553        let rendered = render_catalog(&cat);
554        assert!(
555            rendered.contains("CREATE MATERIALIZED VIEW"),
556            "expected CREATE MATERIALIZED VIEW: {rendered}"
557        );
558    }
559}