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