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            access_method: None,
182            tablespace: None,
183        });
184        let rendered = render_catalog(&cat);
185        let schema_pos = rendered.find("CREATE SCHEMA").unwrap();
186        let table_pos = rendered.find("CREATE TABLE").unwrap();
187        assert!(schema_pos < table_pos, "schema must come before table");
188    }
189
190    #[test]
191    #[allow(clippy::too_many_lines)] // exhaustive catalog fixture — structural, not logic complexity
192    fn fk_rendered_after_table() {
193        let mut cat = Catalog::empty();
194        cat.schemas.push(Schema::new(id("app")));
195        cat.tables.push(Table {
196            qname: qn("app", "orgs"),
197            columns: vec![Column {
198                name: id("id"),
199                ty: ColumnType::BigInt,
200                nullable: false,
201                default: None,
202                identity: None,
203                generated: None,
204                collation: None,
205                storage: None,
206                compression: None,
207                comment: None,
208            }],
209            constraints: vec![Constraint {
210                qname: qn("app", "orgs_pkey"),
211                kind: ConstraintKind::PrimaryKey {
212                    columns: vec![id("id")],
213                    include: vec![],
214                },
215                deferrable: Deferrable::NotDeferrable,
216                comment: None,
217            }],
218            partition_by: None,
219            partition_of: None,
220            comment: None,
221            owner: None,
222            grants: vec![],
223            rls_enabled: false,
224            rls_forced: false,
225            policies: vec![],
226            storage: crate::ir::reloptions::TableStorageOptions::default(),
227            access_method: None,
228            tablespace: None,
229        });
230        cat.tables.push(Table {
231            qname: qn("app", "users"),
232            columns: vec![
233                Column {
234                    name: id("id"),
235                    ty: ColumnType::BigInt,
236                    nullable: false,
237                    default: None,
238                    identity: None,
239                    generated: None,
240                    collation: None,
241                    storage: None,
242                    compression: None,
243                    comment: None,
244                },
245                Column {
246                    name: id("org_id"),
247                    ty: ColumnType::BigInt,
248                    nullable: false,
249                    default: None,
250                    identity: None,
251                    generated: None,
252                    collation: None,
253                    storage: None,
254                    compression: None,
255                    comment: None,
256                },
257            ],
258            constraints: vec![
259                Constraint {
260                    qname: qn("app", "users_pkey"),
261                    kind: ConstraintKind::PrimaryKey {
262                        columns: vec![id("id")],
263                        include: vec![],
264                    },
265                    deferrable: Deferrable::NotDeferrable,
266                    comment: None,
267                },
268                Constraint {
269                    qname: qn("app", "users_org_fkey"),
270                    kind: ConstraintKind::ForeignKey(ForeignKey {
271                        columns: vec![id("org_id")],
272                        referenced_table: qn("app", "orgs"),
273                        referenced_columns: vec![id("id")],
274                        on_update: ReferentialAction::NoAction,
275                        on_delete: ReferentialAction::NoAction,
276                        match_type: FkMatchType::Simple,
277                    }),
278                    deferrable: Deferrable::NotDeferrable,
279                    comment: None,
280                },
281            ],
282            partition_by: None,
283            partition_of: None,
284            comment: None,
285            owner: None,
286            grants: vec![],
287            rls_enabled: false,
288            rls_forced: false,
289            policies: vec![],
290            storage: crate::ir::reloptions::TableStorageOptions::default(),
291            access_method: None,
292            tablespace: None,
293        });
294
295        let rendered = render_catalog(&cat);
296
297        // FK must appear as ALTER TABLE, not inline in CREATE TABLE.
298        assert!(
299            rendered.contains("ALTER TABLE"),
300            "expected ALTER TABLE for FK"
301        );
302        let create_table_pos = rendered.rfind("CREATE TABLE").unwrap();
303        let alter_pos = rendered.find("ALTER TABLE").unwrap();
304        assert!(
305            create_table_pos < alter_pos,
306            "ALTER TABLE must come after last CREATE TABLE"
307        );
308
309        // FK should NOT appear inline in CREATE TABLE.
310        let create_table_end = rendered.find("ALTER TABLE").unwrap();
311        let create_table_section = &rendered[..create_table_end];
312        assert!(
313            !create_table_section.contains("FOREIGN KEY"),
314            "FK should not be inline in CREATE TABLE"
315        );
316    }
317
318    #[test]
319    fn indexes_rendered_after_tables() {
320        let mut cat = Catalog::empty();
321        cat.tables.push(Table {
322            qname: qn("app", "users"),
323            columns: vec![Column {
324                name: id("email"),
325                ty: ColumnType::Text,
326                nullable: false,
327                default: None,
328                identity: None,
329                generated: None,
330                collation: None,
331                storage: None,
332                compression: None,
333                comment: None,
334            }],
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            tablespace: None,
347        });
348        cat.indexes.push(Index {
349            qname: qn("app", "users_email_idx"),
350            on: IndexParent::Table(qn("app", "users")),
351            method: IndexMethod::BTree,
352            columns: vec![IndexColumn {
353                expr: IndexColumnExpr::Column(id("email")),
354                collation: None,
355                opclass: None,
356                sort_order: SortOrder::Asc,
357                nulls_order: NullsOrder::NullsLast,
358            }],
359            include: vec![],
360            unique: true,
361            nulls_not_distinct: false,
362            predicate: None,
363            tablespace: None,
364            comment: None,
365            storage: crate::ir::reloptions::IndexStorageOptions::default(),
366        });
367
368        let rendered = render_catalog(&cat);
369        let table_pos = rendered.find("CREATE TABLE").unwrap();
370        let index_pos = rendered.find("CREATE UNIQUE INDEX").unwrap();
371        assert!(table_pos < index_pos, "index must come after table");
372    }
373
374    #[test]
375    fn sequences_rendered_last() {
376        let mut cat = Catalog::empty();
377        cat.sequences.push(Sequence {
378            qname: qn("app", "users_id_seq"),
379            data_type: ColumnType::BigInt,
380            start: 1,
381            increment: 1,
382            min_value: None,
383            max_value: None,
384            cache: 1,
385            cycle: false,
386            owned_by: None,
387            comment: None,
388            owner: None,
389            grants: vec![],
390        });
391        cat.tables.push(Table {
392            qname: qn("app", "users"),
393            columns: vec![Column {
394                name: id("id"),
395                ty: ColumnType::BigInt,
396                nullable: false,
397                default: None,
398                identity: None,
399                generated: None,
400                collation: None,
401                storage: None,
402                compression: None,
403                comment: None,
404            }],
405            constraints: vec![],
406            partition_by: None,
407            partition_of: None,
408            comment: None,
409            owner: None,
410            grants: vec![],
411            rls_enabled: false,
412            rls_forced: false,
413            policies: vec![],
414            storage: crate::ir::reloptions::TableStorageOptions::default(),
415            access_method: None,
416            tablespace: None,
417        });
418
419        let rendered = render_catalog(&cat);
420        let table_pos = rendered.find("CREATE TABLE").unwrap();
421        let seq_pos = rendered.find("CREATE SEQUENCE").unwrap();
422        assert!(table_pos < seq_pos, "sequence must come after table");
423    }
424
425    #[test]
426    fn parseable_by_pg_query() {
427        let mut cat = Catalog::empty();
428        cat.schemas.push(Schema::new(id("app")));
429        cat.tables.push(Table {
430            qname: qn("app", "users"),
431            columns: vec![
432                Column {
433                    name: id("id"),
434                    ty: ColumnType::BigInt,
435                    nullable: false,
436                    default: None,
437                    identity: None,
438                    generated: None,
439                    collation: None,
440                    storage: None,
441                    compression: None,
442                    comment: None,
443                },
444                Column {
445                    name: id("email"),
446                    ty: ColumnType::Text,
447                    nullable: false,
448                    default: None,
449                    identity: None,
450                    generated: None,
451                    collation: None,
452                    storage: None,
453                    compression: None,
454                    comment: None,
455                },
456            ],
457            constraints: vec![Constraint {
458                qname: qn("app", "users_pkey"),
459                kind: ConstraintKind::PrimaryKey {
460                    columns: vec![id("id")],
461                    include: vec![],
462                },
463                deferrable: Deferrable::NotDeferrable,
464                comment: None,
465            }],
466            partition_by: None,
467            partition_of: None,
468            comment: Some("user accounts".into()),
469            owner: None,
470            grants: vec![],
471            rls_enabled: false,
472            rls_forced: false,
473            policies: vec![],
474            storage: crate::ir::reloptions::TableStorageOptions::default(),
475            access_method: None,
476            tablespace: None,
477        });
478        cat.indexes.push(Index {
479            qname: qn("app", "users_email_idx"),
480            on: IndexParent::Table(qn("app", "users")),
481            method: IndexMethod::BTree,
482            columns: vec![IndexColumn {
483                expr: IndexColumnExpr::Column(id("email")),
484                collation: None,
485                opclass: None,
486                sort_order: SortOrder::Asc,
487                nulls_order: NullsOrder::NullsLast,
488            }],
489            include: vec![],
490            unique: true,
491            nulls_not_distinct: false,
492            predicate: None,
493            tablespace: None,
494            comment: None,
495            storage: crate::ir::reloptions::IndexStorageOptions::default(),
496        });
497
498        let rendered = render_catalog(&cat);
499
500        // The whole block must be parseable by pg_query as multi-statement SQL.
501        let r = pg_query::parse(&rendered);
502        assert!(
503            r.is_ok(),
504            "pg_query rejected rendered catalog:\n{rendered}\nerr: {r:?}"
505        );
506    }
507
508    #[test]
509    fn views_rendered_after_sequences() {
510        use crate::ir::view::View;
511        use crate::parse::normalize_body::NormalizedBody;
512
513        let mut cat = Catalog::empty();
514        cat.sequences.push(Sequence {
515            qname: qn("app", "users_id_seq"),
516            data_type: ColumnType::BigInt,
517            start: 1,
518            increment: 1,
519            min_value: None,
520            max_value: None,
521            cache: 1,
522            cycle: false,
523            owned_by: None,
524            comment: None,
525            owner: None,
526            grants: vec![],
527        });
528        cat.views.push(View {
529            qname: qn("app", "active_users"),
530            columns: vec![],
531            body_canonical: NormalizedBody::from_sql("SELECT 1").unwrap(),
532            body_dependencies: vec![],
533            security_barrier: None,
534            security_invoker: None,
535            check_option: None,
536            comment: None,
537            raw_body: String::new(),
538            owner: None,
539            grants: vec![],
540        });
541
542        let rendered = render_catalog(&cat);
543        let seq_pos = rendered.find("CREATE SEQUENCE").unwrap();
544        let view_pos = rendered.find("CREATE VIEW").unwrap();
545        assert!(seq_pos < view_pos, "views must come after sequences");
546    }
547
548    #[test]
549    fn materialized_views_rendered_in_catalog() {
550        use crate::ir::view::MaterializedView;
551        use crate::parse::normalize_body::NormalizedBody;
552
553        let mut cat = Catalog::empty();
554        cat.materialized_views.push(MaterializedView {
555            qname: qn("app", "summary"),
556            columns: vec![],
557            body_canonical: NormalizedBody::from_sql("SELECT count(*) FROM app.users").unwrap(),
558            body_dependencies: vec![],
559            comment: None,
560            raw_body: String::new(),
561            owner: None,
562            grants: vec![],
563            storage: crate::ir::reloptions::MaterializedViewStorageOptions::default(),
564        });
565
566        let rendered = render_catalog(&cat);
567        assert!(
568            rendered.contains("CREATE MATERIALIZED VIEW"),
569            "expected CREATE MATERIALIZED VIEW: {rendered}"
570        );
571    }
572}