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