Skip to main content

pgevolve_core/plan/
edges.rs

1//! Dependency edge extraction from a [`Catalog`].
2//!
3//! Edges follow the convention "A depends on B" — i.e., for each edge A → B,
4//! B must be created before A. The four edge sources from spec §6.4:
5//!
6//! - schema ⟵ table ⟵ default-using-sequence
7//! - table ⟵ index
8//! - FK constraint ⟵ both endpoints (own table + referenced table)
9//! - sequence ⟵ owning table (`OWNED BY`)
10//!
11//! Both `build_create_graph` (over the source catalog) and `build_drop_graph`
12//! (over the target catalog) use the same edge logic — drop ordering is
13//! produced by reversing the topological sort, not by reversing the edges.
14
15use serde::{Deserialize, Serialize};
16
17use crate::identifier::{Identifier, QualifiedName};
18use crate::ir::catalog::Catalog;
19use crate::ir::column_type::ColumnType;
20use crate::ir::constraint::ConstraintKind;
21use crate::ir::default_expr::DefaultExpr;
22use crate::ir::function::ReturnType;
23use crate::ir::user_type::UserTypeKind;
24use crate::plan::graph::Graph;
25
26pub use crate::ir::function::NormalizedArgTypes;
27
28/// Identifies any IR object uniquely within a [`Catalog`].
29///
30/// `Schema` carries an `Identifier` (schemas are not schema-qualified). All
31/// other variants carry [`QualifiedName`]. `Constraint` is identified by
32/// `(table, name)` because constraint names are scoped to their table.
33#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
34pub enum NodeId {
35    /// A schema (namespace).
36    Schema(Identifier),
37    /// A table.
38    Table(QualifiedName),
39    /// An index.
40    Index(QualifiedName),
41    /// A sequence.
42    Sequence(QualifiedName),
43    /// A constraint identified by its owning table and constraint name.
44    Constraint {
45        /// Owning table.
46        table: QualifiedName,
47        /// Constraint name (the `name` half of the constraint's qname).
48        name: Identifier,
49    },
50    /// A view (`CREATE VIEW`).
51    View(QualifiedName),
52    /// A materialized view (`CREATE MATERIALIZED VIEW`).
53    Mv(QualifiedName),
54    /// A user-defined type (enum, domain, or composite).
55    Type(QualifiedName),
56    /// A user-defined function — disambiguated by argument types (Decision 7).
57    Function(QualifiedName, NormalizedArgTypes),
58    /// A user-defined procedure — identified by qname only (Decision 2).
59    Procedure(QualifiedName),
60    /// An installed extension.
61    Extension(Identifier),
62    /// A trigger (qname unique within schema).
63    Trigger(QualifiedName),
64    /// A publication (not schema-qualified — publications are a per-database
65    /// global namespace).
66    Publication(Identifier),
67}
68
69/// Build the dependency graph for `catalog`, used for create/modify ordering.
70///
71/// Topologically sorting this graph yields **dependencies first**: schemas
72/// before tables, tables before indexes, referenced tables before FKs, etc.
73#[allow(clippy::too_many_lines)]
74pub fn build_create_graph(catalog: &Catalog) -> Graph<NodeId> {
75    let mut g = Graph::new();
76
77    // Phase 1: every IR object gets a node, even if it has no edges.
78    for s in &catalog.schemas {
79        g.add_node(NodeId::Schema(s.name.clone()));
80    }
81    for t in &catalog.tables {
82        g.add_node(NodeId::Table(t.qname.clone()));
83    }
84    for i in &catalog.indexes {
85        g.add_node(NodeId::Index(i.qname.clone()));
86    }
87    for s in &catalog.sequences {
88        g.add_node(NodeId::Sequence(s.qname.clone()));
89    }
90    for t in &catalog.tables {
91        for c in &t.constraints {
92            g.add_node(NodeId::Constraint {
93                table: t.qname.clone(),
94                name: c.qname.name.clone(),
95            });
96        }
97    }
98    // Register view and MV nodes so they participate in topological ordering
99    // and body-dependency edges are rooted correctly.
100    for v in &catalog.views {
101        g.add_node(NodeId::View(v.qname.clone()));
102    }
103    for mv in &catalog.materialized_views {
104        g.add_node(NodeId::Mv(mv.qname.clone()));
105    }
106    // Register user-defined type nodes.
107    for t in &catalog.types {
108        g.add_node(NodeId::Type(t.qname.clone()));
109    }
110    // Register function and procedure nodes.
111    for f in &catalog.functions {
112        g.add_node(NodeId::Function(
113            f.qname.clone(),
114            f.arg_types_normalized.clone(),
115        ));
116    }
117    for p in &catalog.procedures {
118        g.add_node(NodeId::Procedure(p.qname.clone()));
119    }
120    // Register triggers; trigger depends on its target relation and function.
121    for t in &catalog.triggers {
122        g.add_node(NodeId::Trigger(t.qname.clone()));
123        let target_node = if catalog.tables.iter().any(|x| x.qname == t.table) {
124            NodeId::Table(t.table.clone())
125        } else if catalog.views.iter().any(|x| x.qname == t.table) {
126            NodeId::View(t.table.clone())
127        } else if catalog
128            .materialized_views
129            .iter()
130            .any(|x| x.qname == t.table)
131        {
132            NodeId::Mv(t.table.clone())
133        } else {
134            // Unresolved target — the lint rule trigger-references-unmanaged-table
135            // catches this in T9. Skip the edge so the graph builder doesn't
136            // panic on a missing target node.
137            continue;
138        };
139        g.add_edge(NodeId::Trigger(t.qname.clone()), target_node);
140        if let Some(func) = catalog
141            .functions
142            .iter()
143            .find(|f| f.qname == t.function_qname)
144        {
145            g.add_edge(
146                NodeId::Trigger(t.qname.clone()),
147                NodeId::Function(t.function_qname.clone(), func.arg_types_normalized.clone()),
148            );
149        }
150    }
151    // Register extensions; an extension with WITH SCHEMA s depends on the schema.
152    for e in &catalog.extensions {
153        g.add_node(NodeId::Extension(e.name.clone()));
154        if let Some(schema) = &e.schema {
155            g.add_edge(
156                NodeId::Extension(e.name.clone()),
157                NodeId::Schema(schema.clone()),
158            );
159        }
160    }
161    // Register publications. For Selective publications, add edges from each
162    // referenced table and schema to the publication node so publications are
163    // ordered after their dependencies. AllTables publications have no explicit
164    // edges; they are ordered by tier rule.
165    for p in &catalog.publications {
166        let pub_node = NodeId::Publication(p.name.clone());
167        g.add_node(pub_node.clone());
168        if let crate::ir::publication::PublicationScope::Selective { schemas, tables } = &p.scope {
169            for t in tables {
170                g.add_edge(pub_node.clone(), NodeId::Table(t.qname.clone()));
171            }
172            for s in schemas {
173                g.add_edge(pub_node.clone(), NodeId::Schema(s.clone()));
174            }
175        }
176    }
177
178    // Phase 1b.0: type → schema edges. Every user-defined type lives inside
179    // a schema and must be created after CREATE SCHEMA emits.
180    for t in &catalog.types {
181        g.add_edge(
182            NodeId::Type(t.qname.clone()),
183            NodeId::Schema(t.qname.schema.clone()),
184        );
185    }
186    // Phase 1b.0 (routines): every function/procedure depends on its schema.
187    for f in &catalog.functions {
188        let node = NodeId::Function(f.qname.clone(), f.arg_types_normalized.clone());
189        g.add_edge(node, NodeId::Schema(f.qname.schema.clone()));
190    }
191    for p in &catalog.procedures {
192        g.add_edge(
193            NodeId::Procedure(p.qname.clone()),
194            NodeId::Schema(p.qname.schema.clone()),
195        );
196    }
197
198    // Phase 1b: type → type edges from composite attributes and domain bases.
199    // These edges ensure composites/domains that reference other user-defined
200    // types are created after those types.
201    for ut in &catalog.types {
202        match &ut.kind {
203            UserTypeKind::Composite { attributes } => {
204                for attr in attributes {
205                    if let ColumnType::UserDefined(dep_qname) = &attr.ty {
206                        g.add_edge(
207                            NodeId::Type(ut.qname.clone()),
208                            NodeId::Type(dep_qname.clone()),
209                        );
210                    }
211                }
212            }
213            UserTypeKind::Domain {
214                base: ColumnType::UserDefined(base_qname),
215                ..
216            } => {
217                g.add_edge(
218                    NodeId::Type(ut.qname.clone()),
219                    NodeId::Type(base_qname.clone()),
220                );
221            }
222            _ => {}
223        }
224    }
225
226    // Phase 1c: table → type edges from columns with user-defined types.
227    // Tables that reference a user-defined type must be created after the type.
228    for t in &catalog.tables {
229        for col in &t.columns {
230            if let ColumnType::UserDefined(type_qname) = &col.ty {
231                g.add_edge(
232                    NodeId::Table(t.qname.clone()),
233                    NodeId::Type(type_qname.clone()),
234                );
235            }
236        }
237    }
238    // Phase 1c (routines): function/procedure → types referenced in args and
239    // return types. These ensure routines are created after their type deps.
240    for f in &catalog.functions {
241        let node = NodeId::Function(f.qname.clone(), f.arg_types_normalized.clone());
242        for arg in &f.args {
243            if let ColumnType::UserDefined(t_qname) = &arg.ty {
244                g.add_edge(node.clone(), NodeId::Type(t_qname.clone()));
245            }
246        }
247        match &f.return_type {
248            ReturnType::Scalar {
249                ty: ColumnType::UserDefined(t),
250            }
251            | ReturnType::SetOf {
252                ty: ColumnType::UserDefined(t),
253            } => {
254                g.add_edge(node.clone(), NodeId::Type(t.clone()));
255            }
256            ReturnType::Table { columns } => {
257                for col in columns {
258                    if let ColumnType::UserDefined(t) = &col.ty {
259                        g.add_edge(node.clone(), NodeId::Type(t.clone()));
260                    }
261                }
262            }
263            _ => {}
264        }
265    }
266    for p in &catalog.procedures {
267        let node = NodeId::Procedure(p.qname.clone());
268        for arg in &p.args {
269            if let ColumnType::UserDefined(t_qname) = &arg.ty {
270                g.add_edge(node.clone(), NodeId::Type(t_qname.clone()));
271            }
272        }
273    }
274
275    // Phase 2: tables depend on their schema and on any sequence used as a
276    // column default. We add the schema node implicitly via add_edge in case
277    // the caller did not declare it (defensive: source-side parsing typically
278    // does declare every referenced schema, but a hand-built Catalog might not).
279    // Partition children depend on their parent table.
280    for t in &catalog.tables {
281        g.add_edge(
282            NodeId::Table(t.qname.clone()),
283            NodeId::Schema(t.qname.schema.clone()),
284        );
285        for col in &t.columns {
286            if let Some(DefaultExpr::Sequence(seq_qname)) = &col.default {
287                g.add_edge(
288                    NodeId::Table(t.qname.clone()),
289                    NodeId::Sequence(seq_qname.clone()),
290                );
291            }
292        }
293        if let Some(po) = &t.partition_of {
294            // Partition child depends on its parent existing first.
295            g.add_edge(
296                NodeId::Table(t.qname.clone()),
297                NodeId::Table(po.parent.clone()),
298            );
299        }
300    }
301
302    // Phase 2b: views and MVs depend on objects in their body_dependencies.
303    // `body_dependencies` edges use NodeId directly (already the correct
304    // variant); we just re-register each edge into the graph.
305    for v in &catalog.views {
306        for dep in &v.body_dependencies {
307            g.add_edge(dep.from.clone(), dep.to.clone());
308        }
309    }
310    for mv in &catalog.materialized_views {
311        for dep in &mv.body_dependencies {
312            g.add_edge(dep.from.clone(), dep.to.clone());
313        }
314    }
315
316    // Phase 2c: functions and procedures body_dependencies.
317    for f in &catalog.functions {
318        for dep in &f.body_dependencies {
319            g.add_edge(dep.from.clone(), dep.to.clone());
320        }
321    }
322    for p in &catalog.procedures {
323        for dep in &p.body_dependencies {
324            g.add_edge(dep.from.clone(), dep.to.clone());
325        }
326    }
327
328    // Phase 3: indexes depend on their parent (table or MV).
329    // For `IndexParent::Mv`, we use `NodeId::Mv` so the graph correctly
330    // orders CREATE INDEX after CREATE MATERIALIZED VIEW.
331    for i in &catalog.indexes {
332        use crate::ir::index::IndexParent;
333        let parent_node = match &i.on {
334            IndexParent::Table(q) => NodeId::Table(q.clone()),
335            IndexParent::Mv(q) => NodeId::Mv(q.clone()),
336        };
337        g.add_edge(NodeId::Index(i.qname.clone()), parent_node);
338    }
339
340    // Phase 4: constraints depend on their owning table; FKs additionally
341    // depend on the referenced table.
342    //
343    // For FKs we ALSO add a direct table → referenced_table edge. Inline FKs
344    // are emitted as part of `CREATE TABLE`, so the owning table's create
345    // statement requires the referenced table to exist first. Without this
346    // edge, two-table FK cycles never produce a cycle in the graph and the
347    // planner's FK-extraction post-pass would have nothing to detect.
348    for t in &catalog.tables {
349        for c in &t.constraints {
350            let constraint_node = NodeId::Constraint {
351                table: t.qname.clone(),
352                name: c.qname.name.clone(),
353            };
354            g.add_edge(constraint_node.clone(), NodeId::Table(t.qname.clone()));
355            if let ConstraintKind::ForeignKey(fk) = &c.kind {
356                g.add_edge(constraint_node, NodeId::Table(fk.referenced_table.clone()));
357                // Self-referential FKs don't induce a real table-level cycle
358                // (table can be created first, FK satisfied at row time).
359                if fk.referenced_table != t.qname {
360                    g.add_edge(
361                        NodeId::Table(t.qname.clone()),
362                        NodeId::Table(fk.referenced_table.clone()),
363                    );
364                }
365            }
366        }
367    }
368
369    // Phase 5: an `OWNED BY` sequence depends on its owner table.
370    for s in &catalog.sequences {
371        if let Some(owner) = &s.owned_by {
372            g.add_edge(
373                NodeId::Sequence(s.qname.clone()),
374                NodeId::Table(owner.table.clone()),
375            );
376        }
377    }
378
379    g
380}
381
382/// Build the dependency graph for drop-ordering. Same edges as the create
383/// graph; the ordering is reversed at sort time.
384pub fn build_drop_graph(catalog: &Catalog) -> Graph<NodeId> {
385    build_create_graph(catalog)
386}
387
388/// Where a dependency edge came from.
389///
390/// `Structural` edges are derived from the IR shape itself (schema←table,
391/// table←index, FK references, sequence ownership). They exist in v0.1.
392///
393/// `AstExtracted` edges are derived by walking the parsed AST of an object
394/// body (view body, function body, expression-index predicate, etc.).
395/// First produced in v0.2 view sub-spec.
396///
397/// `AstDeclared` edges come from explicit `-- @pgevolve dep:` directives
398/// that close the PL/pgSQL-dynamic-SQL gap (Decision 11). First produced
399/// in v0.2 function sub-spec.
400///
401/// Ordering: `Structural < AstExtracted < AstDeclared` — structural edges
402/// are tie-broken first in the Kahn min-heap to preserve v0.1 ordering.
403#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
404pub enum DepSource {
405    /// Derived from the IR shape; v0.1 default.
406    Structural,
407    /// Walked out of a parsed body AST.
408    AstExtracted,
409    /// Declared by a `-- @pgevolve dep:` directive in source SQL.
410    AstDeclared,
411}
412
413/// An edge in the dependency graph, with provenance metadata.
414///
415/// Convention matches the existing graph: `from` depends on `to`, so `to`
416/// must be created before `from`.
417#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
418pub struct DepEdge {
419    /// Dependent node (the one that needs `to` to exist first).
420    pub from: NodeId,
421    /// Dependency target.
422    pub to: NodeId,
423    /// Provenance of this edge.
424    pub source: DepSource,
425}
426
427#[cfg(test)]
428mod tests {
429    use super::*;
430    use crate::ir::column::Column;
431    use crate::ir::column_type::ColumnType;
432    use crate::ir::constraint::{
433        Constraint, ConstraintKind, Deferrable, FkMatchType, ForeignKey, ReferentialAction,
434    };
435    use crate::ir::index::{
436        Index, IndexColumn, IndexColumnExpr, IndexMethod, IndexParent, NullsOrder, SortOrder,
437    };
438    use crate::ir::schema::Schema;
439    use crate::ir::sequence::{Sequence, SequenceOwner};
440    use crate::ir::table::Table;
441
442    fn id(s: &str) -> Identifier {
443        Identifier::from_unquoted(s).unwrap()
444    }
445
446    fn qn(schema: &str, name: &str) -> QualifiedName {
447        QualifiedName::new(id(schema), id(name))
448    }
449
450    fn col_id_bigint() -> Column {
451        Column {
452            name: id("id"),
453            ty: ColumnType::BigInt,
454            nullable: false,
455            default: None,
456            identity: None,
457            generated: None,
458            collation: None,
459            storage: None,
460            compression: None,
461            comment: None,
462        }
463    }
464
465    fn col_text_notnull(name: &str) -> Column {
466        Column {
467            name: id(name),
468            ty: ColumnType::Text,
469            nullable: false,
470            default: None,
471            identity: None,
472            generated: None,
473            collation: None,
474            storage: None,
475            compression: None,
476            comment: None,
477        }
478    }
479
480    fn pk(name: &str, cols: &[&str]) -> Constraint {
481        Constraint {
482            qname: qn("app", name),
483            kind: ConstraintKind::PrimaryKey {
484                columns: cols.iter().map(|c| id(c)).collect(),
485                include: vec![],
486            },
487            deferrable: Deferrable::NotDeferrable,
488            comment: None,
489        }
490    }
491
492    fn fk(name: &str, ref_table: QualifiedName) -> Constraint {
493        Constraint {
494            qname: qn("app", name),
495            kind: ConstraintKind::ForeignKey(ForeignKey {
496                columns: vec![id("ref_id")],
497                referenced_table: ref_table,
498                referenced_columns: vec![id("id")],
499                on_update: ReferentialAction::NoAction,
500                on_delete: ReferentialAction::NoAction,
501                match_type: FkMatchType::Simple,
502            }),
503            deferrable: Deferrable::NotDeferrable,
504            comment: None,
505        }
506    }
507
508    fn has_edge(g: &Graph<NodeId>, from: &NodeId, to: &NodeId) -> bool {
509        g.dependencies_of(from).any(|n| n == to)
510    }
511
512    #[test]
513    fn empty_catalog_yields_empty_graph() {
514        let g = build_create_graph(&Catalog::empty());
515        assert_eq!(g.node_count(), 0);
516    }
517
518    #[test]
519    fn every_object_appears_as_a_node() {
520        let mut c = Catalog::empty();
521        c.schemas.push(Schema::new(id("app")));
522        c.tables.push(Table {
523            qname: qn("app", "users"),
524            columns: vec![col_id_bigint()],
525            constraints: vec![pk("users_pkey", &["id"])],
526            partition_by: None,
527            partition_of: None,
528            comment: None,
529            owner: None,
530            grants: vec![],
531            rls_enabled: false,
532            rls_forced: false,
533            policies: vec![],
534            storage: crate::ir::reloptions::TableStorageOptions::default(),
535        });
536        c.indexes.push(Index {
537            qname: qn("app", "users_idx"),
538            on: IndexParent::Table(qn("app", "users")),
539            method: IndexMethod::BTree,
540            columns: vec![IndexColumn {
541                expr: IndexColumnExpr::Column(id("id")),
542                collation: None,
543                opclass: None,
544                sort_order: SortOrder::Asc,
545                nulls_order: NullsOrder::NullsLast,
546            }],
547            include: vec![],
548            unique: false,
549            nulls_not_distinct: false,
550            predicate: None,
551            tablespace: None,
552            comment: None,
553            storage: crate::ir::reloptions::IndexStorageOptions::default(),
554        });
555        c.sequences.push(Sequence {
556            qname: qn("app", "seq1"),
557            data_type: ColumnType::BigInt,
558            start: 1,
559            increment: 1,
560            min_value: None,
561            max_value: None,
562            cache: 1,
563            cycle: false,
564            owned_by: None,
565            comment: None,
566            owner: None,
567            grants: vec![],
568        });
569
570        let g = build_create_graph(&c);
571        // schema + table + index + sequence + constraint = 5
572        assert_eq!(g.node_count(), 5);
573    }
574
575    #[test]
576    fn table_depends_on_its_schema() {
577        let mut c = Catalog::empty();
578        c.schemas.push(Schema::new(id("app")));
579        c.tables.push(Table {
580            qname: qn("app", "users"),
581            columns: vec![col_id_bigint()],
582            constraints: vec![],
583            partition_by: None,
584            partition_of: None,
585            comment: None,
586            owner: None,
587            grants: vec![],
588            rls_enabled: false,
589            rls_forced: false,
590            policies: vec![],
591            storage: crate::ir::reloptions::TableStorageOptions::default(),
592        });
593        let g = build_create_graph(&c);
594        assert!(has_edge(
595            &g,
596            &NodeId::Table(qn("app", "users")),
597            &NodeId::Schema(id("app")),
598        ));
599    }
600
601    #[test]
602    fn index_depends_on_its_table() {
603        let mut c = Catalog::empty();
604        c.tables.push(Table {
605            qname: qn("app", "users"),
606            columns: vec![col_id_bigint()],
607            constraints: vec![],
608            partition_by: None,
609            partition_of: None,
610            comment: None,
611            owner: None,
612            grants: vec![],
613            rls_enabled: false,
614            rls_forced: false,
615            policies: vec![],
616            storage: crate::ir::reloptions::TableStorageOptions::default(),
617        });
618        c.indexes.push(Index {
619            qname: qn("app", "users_idx"),
620            on: IndexParent::Table(qn("app", "users")),
621            method: IndexMethod::BTree,
622            columns: vec![IndexColumn {
623                expr: IndexColumnExpr::Column(id("id")),
624                collation: None,
625                opclass: None,
626                sort_order: SortOrder::Asc,
627                nulls_order: NullsOrder::NullsLast,
628            }],
629            include: vec![],
630            unique: false,
631            nulls_not_distinct: false,
632            predicate: None,
633            tablespace: None,
634            comment: None,
635            storage: crate::ir::reloptions::IndexStorageOptions::default(),
636        });
637        let g = build_create_graph(&c);
638        assert!(has_edge(
639            &g,
640            &NodeId::Index(qn("app", "users_idx")),
641            &NodeId::Table(qn("app", "users")),
642        ));
643    }
644
645    #[test]
646    fn fk_constraint_depends_on_both_endpoints() {
647        let mut c = Catalog::empty();
648        c.tables.push(Table {
649            qname: qn("app", "orgs"),
650            columns: vec![col_id_bigint()],
651            constraints: vec![pk("orgs_pkey", &["id"])],
652            partition_by: None,
653            partition_of: None,
654            comment: None,
655            owner: None,
656            grants: vec![],
657            rls_enabled: false,
658            rls_forced: false,
659            policies: vec![],
660            storage: crate::ir::reloptions::TableStorageOptions::default(),
661        });
662        c.tables.push(Table {
663            qname: qn("app", "users"),
664            columns: vec![
665                col_id_bigint(),
666                Column {
667                    name: id("ref_id"),
668                    ty: ColumnType::BigInt,
669                    nullable: false,
670                    default: None,
671                    identity: None,
672                    generated: None,
673                    collation: None,
674                    storage: None,
675                    compression: None,
676                    comment: None,
677                },
678            ],
679            constraints: vec![fk("users_orgs_fk", qn("app", "orgs"))],
680            partition_by: None,
681            partition_of: None,
682            comment: None,
683            owner: None,
684            grants: vec![],
685            rls_enabled: false,
686            rls_forced: false,
687            policies: vec![],
688            storage: crate::ir::reloptions::TableStorageOptions::default(),
689        });
690        let g = build_create_graph(&c);
691        let fk_node = NodeId::Constraint {
692            table: qn("app", "users"),
693            name: id("users_orgs_fk"),
694        };
695        // Owning-table edge.
696        assert!(has_edge(&g, &fk_node, &NodeId::Table(qn("app", "users"))));
697        // Referenced-table edge.
698        assert!(has_edge(&g, &fk_node, &NodeId::Table(qn("app", "orgs"))));
699    }
700
701    #[test]
702    fn table_depends_on_default_sequence() {
703        let mut c = Catalog::empty();
704        c.sequences.push(Sequence {
705            qname: qn("app", "id_seq"),
706            data_type: ColumnType::BigInt,
707            start: 1,
708            increment: 1,
709            min_value: None,
710            max_value: None,
711            cache: 1,
712            cycle: false,
713            owned_by: None,
714            comment: None,
715            owner: None,
716            grants: vec![],
717        });
718        c.tables.push(Table {
719            qname: qn("app", "users"),
720            columns: vec![Column {
721                name: id("id"),
722                ty: ColumnType::BigInt,
723                nullable: false,
724                default: Some(DefaultExpr::Sequence(qn("app", "id_seq"))),
725                identity: None,
726                generated: None,
727                collation: None,
728                storage: None,
729                compression: None,
730                comment: None,
731            }],
732            constraints: vec![],
733            partition_by: None,
734            partition_of: None,
735            comment: None,
736            owner: None,
737            grants: vec![],
738            rls_enabled: false,
739            rls_forced: false,
740            policies: vec![],
741            storage: crate::ir::reloptions::TableStorageOptions::default(),
742        });
743        let g = build_create_graph(&c);
744        assert!(has_edge(
745            &g,
746            &NodeId::Table(qn("app", "users")),
747            &NodeId::Sequence(qn("app", "id_seq")),
748        ));
749    }
750
751    #[test]
752    fn owned_sequence_depends_on_owner_table() {
753        let mut c = Catalog::empty();
754        c.tables.push(Table {
755            qname: qn("app", "users"),
756            columns: vec![col_id_bigint()],
757            constraints: vec![],
758            partition_by: None,
759            partition_of: None,
760            comment: None,
761            owner: None,
762            grants: vec![],
763            rls_enabled: false,
764            rls_forced: false,
765            policies: vec![],
766            storage: crate::ir::reloptions::TableStorageOptions::default(),
767        });
768        c.sequences.push(Sequence {
769            qname: qn("app", "users_id_seq"),
770            data_type: ColumnType::BigInt,
771            start: 1,
772            increment: 1,
773            min_value: None,
774            max_value: None,
775            cache: 1,
776            cycle: false,
777            owned_by: Some(SequenceOwner {
778                table: qn("app", "users"),
779                column: id("id"),
780            }),
781            comment: None,
782            owner: None,
783            grants: vec![],
784        });
785        let g = build_create_graph(&c);
786        assert!(has_edge(
787            &g,
788            &NodeId::Sequence(qn("app", "users_id_seq")),
789            &NodeId::Table(qn("app", "users")),
790        ));
791    }
792
793    #[test]
794    fn non_fk_constraint_depends_only_on_its_table() {
795        let mut c = Catalog::empty();
796        c.tables.push(Table {
797            qname: qn("app", "users"),
798            columns: vec![col_id_bigint()],
799            constraints: vec![pk("users_pkey", &["id"])],
800            partition_by: None,
801            partition_of: None,
802            comment: None,
803            owner: None,
804            grants: vec![],
805            rls_enabled: false,
806            rls_forced: false,
807            policies: vec![],
808            storage: crate::ir::reloptions::TableStorageOptions::default(),
809        });
810        let g = build_create_graph(&c);
811        let pk_node = NodeId::Constraint {
812            table: qn("app", "users"),
813            name: id("users_pkey"),
814        };
815        let deps: Vec<&NodeId> = g.dependencies_of(&pk_node).collect();
816        assert_eq!(deps, vec![&NodeId::Table(qn("app", "users"))]);
817    }
818
819    #[test]
820    fn drop_graph_matches_create_graph() {
821        let mut c = Catalog::empty();
822        c.tables.push(Table {
823            qname: qn("app", "users"),
824            columns: vec![col_id_bigint()],
825            constraints: vec![pk("users_pkey", &["id"])],
826            partition_by: None,
827            partition_of: None,
828            comment: None,
829            owner: None,
830            grants: vec![],
831            rls_enabled: false,
832            rls_forced: false,
833            policies: vec![],
834            storage: crate::ir::reloptions::TableStorageOptions::default(),
835        });
836        // Same edges; equality is structural via topological output.
837        let cg = build_create_graph(&c);
838        let dg = build_drop_graph(&c);
839        assert_eq!(cg.topological_sort(), dg.topological_sort());
840    }
841
842    #[test]
843    fn fk_cycle_produces_table_level_cycle() {
844        // Two tables with FKs to each other; each FK induces an inline-create
845        // edge table → referenced_table, so the table subgraph cycles.
846        let mut c = Catalog::empty();
847        c.tables.push(Table {
848            qname: qn("app", "a"),
849            columns: vec![
850                col_id_bigint(),
851                Column {
852                    name: id("ref_id"),
853                    ty: ColumnType::BigInt,
854                    nullable: false,
855                    default: None,
856                    identity: None,
857                    generated: None,
858                    collation: None,
859                    storage: None,
860                    compression: None,
861                    comment: None,
862                },
863            ],
864            constraints: vec![pk("a_pk", &["id"]), fk("a_to_b", qn("app", "b"))],
865            partition_by: None,
866            partition_of: None,
867            comment: None,
868            owner: None,
869            grants: vec![],
870            rls_enabled: false,
871            rls_forced: false,
872            policies: vec![],
873            storage: crate::ir::reloptions::TableStorageOptions::default(),
874        });
875        c.tables.push(Table {
876            qname: qn("app", "b"),
877            columns: vec![
878                col_id_bigint(),
879                Column {
880                    name: id("ref_id"),
881                    ty: ColumnType::BigInt,
882                    nullable: false,
883                    default: None,
884                    identity: None,
885                    generated: None,
886                    collation: None,
887                    storage: None,
888                    compression: None,
889                    comment: None,
890                },
891            ],
892            constraints: vec![pk("b_pk", &["id"]), fk("b_to_a", qn("app", "a"))],
893            partition_by: None,
894            partition_of: None,
895            comment: None,
896            owner: None,
897            grants: vec![],
898            rls_enabled: false,
899            rls_forced: false,
900            policies: vec![],
901            storage: crate::ir::reloptions::TableStorageOptions::default(),
902        });
903        let g = build_create_graph(&c);
904        let err = g.topological_sort().unwrap_err();
905        assert!(err.nodes.contains(&NodeId::Table(qn("app", "a"))));
906        assert!(err.nodes.contains(&NodeId::Table(qn("app", "b"))));
907    }
908
909    #[test]
910    fn self_referential_fk_does_not_cycle() {
911        // A self-referential FK doesn't force the table to depend on itself —
912        // the rows are inserted after the table exists.
913        let mut c = Catalog::empty();
914        c.tables.push(Table {
915            qname: qn("app", "tree"),
916            columns: vec![
917                col_id_bigint(),
918                Column {
919                    name: id("ref_id"),
920                    ty: ColumnType::BigInt,
921                    nullable: true,
922                    default: None,
923                    identity: None,
924                    generated: None,
925                    collation: None,
926                    storage: None,
927                    compression: None,
928                    comment: None,
929                },
930            ],
931            constraints: vec![
932                pk("tree_pk", &["id"]),
933                fk("tree_parent_fk", qn("app", "tree")),
934            ],
935            partition_by: None,
936            partition_of: None,
937            comment: None,
938            owner: None,
939            grants: vec![],
940            rls_enabled: false,
941            rls_forced: false,
942            policies: vec![],
943            storage: crate::ir::reloptions::TableStorageOptions::default(),
944        });
945        let g = build_create_graph(&c);
946        assert!(g.topological_sort().is_ok());
947    }
948
949    #[test]
950    fn partition_child_depends_on_parent() {
951        // A partition child table depends on its parent table being created first.
952        use crate::ir::partition::{PartitionBounds, PartitionBy, PartitionOf};
953        let mut c = Catalog::empty();
954
955        // Parent table with PARTITION BY LIST.
956        let parent = Table {
957            qname: qn("app", "parent"),
958            columns: vec![col_id_bigint(), col_text_notnull("status")],
959            constraints: vec![pk("parent_pkey", &["id"])],
960            partition_by: Some(PartitionBy {
961                strategy: crate::ir::partition::PartitionStrategy::List,
962                columns: vec![crate::ir::partition::PartitionColumn {
963                    kind: crate::ir::partition::PartitionColumnKind::Column(id("status")),
964                    collation: None,
965                    opclass: None,
966                }],
967            }),
968            partition_of: None,
969            comment: None,
970            owner: None,
971            grants: vec![],
972            rls_enabled: false,
973            rls_forced: false,
974            policies: vec![],
975            storage: crate::ir::reloptions::TableStorageOptions::default(),
976        };
977        c.tables.push(parent);
978
979        // Child partition table.
980        let child = Table {
981            qname: qn("app", "child"),
982            columns: vec![col_id_bigint(), col_text_notnull("status")],
983            constraints: vec![],
984            partition_by: None,
985            partition_of: Some(PartitionOf {
986                parent: qn("app", "parent"),
987                bounds: PartitionBounds::List { values: vec![] },
988            }),
989            comment: None,
990            owner: None,
991            grants: vec![],
992            rls_enabled: false,
993            rls_forced: false,
994            policies: vec![],
995            storage: crate::ir::reloptions::TableStorageOptions::default(),
996        };
997        c.tables.push(child);
998
999        let g = build_create_graph(&c);
1000        assert!(
1001            has_edge(
1002                &g,
1003                &NodeId::Table(qn("app", "child")),
1004                &NodeId::Table(qn("app", "parent")),
1005            ),
1006            "expected child partition → parent table edge"
1007        );
1008    }
1009
1010    // ── User-defined type edge tests ──────────────────────────────────────────
1011
1012    use crate::ir::user_type::{CompositeAttribute, UserType, UserTypeKind};
1013
1014    fn make_enum(schema: &str, name: &str) -> UserType {
1015        UserType {
1016            qname: qn(schema, name),
1017            kind: UserTypeKind::Enum { values: vec![] },
1018            comment: None,
1019            owner: None,
1020            grants: vec![],
1021        }
1022    }
1023
1024    fn make_composite_with_attr(schema: &str, name: &str, attr_type: ColumnType) -> UserType {
1025        UserType {
1026            qname: qn(schema, name),
1027            kind: UserTypeKind::Composite {
1028                attributes: vec![CompositeAttribute {
1029                    name: id("val"),
1030                    ty: attr_type,
1031                    collation: None,
1032                }],
1033            },
1034            comment: None,
1035            owner: None,
1036            grants: vec![],
1037        }
1038    }
1039
1040    fn make_domain_over(schema: &str, name: &str, base: ColumnType) -> UserType {
1041        UserType {
1042            qname: qn(schema, name),
1043            kind: UserTypeKind::Domain {
1044                base,
1045                nullable: true,
1046                default: None,
1047                check_constraints: vec![],
1048                collation: None,
1049            },
1050            comment: None,
1051            owner: None,
1052            grants: vec![],
1053        }
1054    }
1055
1056    #[test]
1057    fn type_nodes_registered() {
1058        let mut c = Catalog::empty();
1059        c.schemas.push(crate::ir::schema::Schema::new(id("app")));
1060        c.types.push(make_enum("app", "status"));
1061        let g = build_create_graph(&c);
1062        // Type depends ONLY on its schema (no other edges for a bare enum).
1063        let deps: Vec<_> = g
1064            .dependencies_of(&NodeId::Type(qn("app", "status")))
1065            .collect();
1066        assert_eq!(deps, vec![&NodeId::Schema(id("app"))]);
1067        // Both the schema node and the type node are registered.
1068        assert_eq!(g.node_count(), 2);
1069    }
1070
1071    #[test]
1072    fn table_depends_on_user_defined_column_type() {
1073        let mut c = Catalog::empty();
1074        c.types.push(make_enum("app", "status"));
1075        c.tables.push(Table {
1076            qname: qn("app", "orders"),
1077            columns: vec![Column {
1078                name: id("status"),
1079                ty: ColumnType::UserDefined(qn("app", "status")),
1080                nullable: false,
1081                default: None,
1082                identity: None,
1083                generated: None,
1084                collation: None,
1085                storage: None,
1086                compression: None,
1087                comment: None,
1088            }],
1089            constraints: vec![],
1090            partition_by: None,
1091            partition_of: None,
1092            comment: None,
1093            owner: None,
1094            grants: vec![],
1095            rls_enabled: false,
1096            rls_forced: false,
1097            policies: vec![],
1098            storage: crate::ir::reloptions::TableStorageOptions::default(),
1099        });
1100        let g = build_create_graph(&c);
1101        assert!(
1102            has_edge(
1103                &g,
1104                &NodeId::Table(qn("app", "orders")),
1105                &NodeId::Type(qn("app", "status"))
1106            ),
1107            "table must depend on its user-defined column type"
1108        );
1109    }
1110
1111    #[test]
1112    fn composite_depends_on_user_defined_attribute_type() {
1113        let mut c = Catalog::empty();
1114        c.types.push(make_enum("app", "inner_t"));
1115        c.types.push(make_composite_with_attr(
1116            "app",
1117            "outer_t",
1118            ColumnType::UserDefined(qn("app", "inner_t")),
1119        ));
1120        let g = build_create_graph(&c);
1121        assert!(
1122            has_edge(
1123                &g,
1124                &NodeId::Type(qn("app", "outer_t")),
1125                &NodeId::Type(qn("app", "inner_t"))
1126            ),
1127            "composite must depend on the type of its user-defined attribute"
1128        );
1129    }
1130
1131    #[test]
1132    fn domain_depends_on_user_defined_base_type() {
1133        let mut c = Catalog::empty();
1134        c.types.push(make_enum("app", "base_t"));
1135        c.types.push(make_domain_over(
1136            "app",
1137            "derived_t",
1138            ColumnType::UserDefined(qn("app", "base_t")),
1139        ));
1140        let g = build_create_graph(&c);
1141        assert!(
1142            has_edge(
1143                &g,
1144                &NodeId::Type(qn("app", "derived_t")),
1145                &NodeId::Type(qn("app", "base_t"))
1146            ),
1147            "domain must depend on its user-defined base type"
1148        );
1149    }
1150
1151    #[test]
1152    fn type_create_ordering_respects_edges() {
1153        // derived_t depends on base_t; topological sort must put base_t first.
1154        let mut c = Catalog::empty();
1155        c.types.push(make_enum("app", "base_t"));
1156        c.types.push(make_domain_over(
1157            "app",
1158            "derived_t",
1159            ColumnType::UserDefined(qn("app", "base_t")),
1160        ));
1161        let g = build_create_graph(&c);
1162        let order = g.topological_sort().expect("no cycle expected");
1163        let base_pos = order
1164            .iter()
1165            .position(|n| n == &NodeId::Type(qn("app", "base_t")))
1166            .expect("base_t in order");
1167        let derived_pos = order
1168            .iter()
1169            .position(|n| n == &NodeId::Type(qn("app", "derived_t")))
1170            .expect("derived_t in order");
1171        assert!(base_pos < derived_pos, "base_t must come before derived_t");
1172    }
1173}