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