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::collation::BUILTIN_COLLATIONS;
20use crate::ir::column_type::ColumnType;
21use crate::ir::constraint::ConstraintKind;
22use crate::ir::default_expr::DefaultExpr;
23use crate::ir::function::ReturnType;
24use crate::ir::user_type::UserTypeKind;
25use crate::plan::graph::Graph;
26
27pub use crate::ir::function::NormalizedArgTypes;
28
29/// Identifies any IR object uniquely within a [`Catalog`].
30///
31/// `Schema` carries an `Identifier` (schemas are not schema-qualified). All
32/// other variants carry [`QualifiedName`]. `Constraint` is identified by
33/// `(table, name)` because constraint names are scoped to their table.
34#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
35pub enum NodeId {
36    /// A schema (namespace).
37    Schema(Identifier),
38    /// A table.
39    Table(QualifiedName),
40    /// An index.
41    Index(QualifiedName),
42    /// A sequence.
43    Sequence(QualifiedName),
44    /// A constraint identified by its owning table and constraint name.
45    Constraint {
46        /// Owning table.
47        table: QualifiedName,
48        /// Constraint name (the `name` half of the constraint's qname).
49        name: Identifier,
50    },
51    /// A view (`CREATE VIEW`).
52    View(QualifiedName),
53    /// A materialized view (`CREATE MATERIALIZED VIEW`).
54    Mv(QualifiedName),
55    /// A user-defined type (enum, domain, or composite).
56    Type(QualifiedName),
57    /// A user-defined function — disambiguated by argument types (Decision 7).
58    Function(QualifiedName, NormalizedArgTypes),
59    /// A user-defined procedure — identified by qname only (Decision 2).
60    Procedure(QualifiedName),
61    /// An installed extension.
62    Extension(Identifier),
63    /// A trigger (qname unique within schema).
64    Trigger(QualifiedName),
65    /// A publication (not schema-qualified — publications are a per-database
66    /// global namespace).
67    Publication(Identifier),
68    /// A subscription (not schema-qualified — subscriptions are a per-database
69    /// global namespace, like publications).
70    Subscription(Identifier),
71    /// A database-global event trigger.
72    EventTrigger(Identifier),
73    /// A statistics object (`CREATE STATISTICS schema.name`).
74    Statistic(QualifiedName),
75    /// A user-defined collation (`CREATE COLLATION schema.name`).
76    ///
77    /// The variant lands in v0.3.8 so `ordering::change_node` can route
78    /// collation changes correctly; the actual graph *edges* (column /
79    /// domain / range / composite-attribute → collation) are added in a
80    /// follow-up stage.
81    Collation(QualifiedName),
82    /// A user-defined aggregate — disambiguated by argument types (aggregates
83    /// are overloadable, like functions). The aggregate depends on its state
84    /// function (`SFUNC`) and optional final function (`FINALFUNC`), both of
85    /// which are managed [`NodeId::Function`] nodes.
86    Aggregate(QualifiedName, NormalizedArgTypes),
87    /// A `CREATE CAST` — identified by `(source_type, target_type)`. Not
88    /// schema-scoped; casts are database-global objects. Depends on the
89    /// conversion function (when `WITH FUNCTION`) and on managed source/target
90    /// types (when the types are user-defined and in the managed catalog).
91    Cast(QualifiedName, QualifiedName),
92}
93
94/// Find the managed function that backs an aggregate's `sfunc`/`finalfunc`.
95///
96/// An aggregate's `sfunc` has the implied signature `(state_type, arg_types…)`
97/// and its `finalfunc` has `(state_type)`. We locate the exact managed function
98/// overload so the edge points at the right [`NodeId::Function`]:
99///
100/// 1. Filter `catalog.functions` to those whose `qname == fn_qname`.
101/// 2. If exactly one matches, use it (no signature matching needed).
102/// 3. Otherwise narrow to overloads whose IN-arg count equals the implied
103///    signature's arg count.
104/// 4. If still ambiguous, match by comparing each declared arg type to the
105///    implied types.
106///
107/// Returns `None` if no managed function matches (the closed-world check in a
108/// later task surfaces this drift; here we simply skip the edge, mirroring the
109/// event-trigger edge's behavior when its function is unmanaged).
110fn find_sfunc<'a>(
111    catalog: &'a Catalog,
112    fn_qname: &QualifiedName,
113    implied_arg_types: &[ColumnType],
114) -> Option<&'a crate::ir::function::Function> {
115    let candidates: Vec<&crate::ir::function::Function> = catalog
116        .functions
117        .iter()
118        .filter(|f| &f.qname == fn_qname)
119        .collect();
120    match candidates.as_slice() {
121        [] => None,
122        [only] => Some(only),
123        many => {
124            let by_arity: Vec<&crate::ir::function::Function> = many
125                .iter()
126                .copied()
127                .filter(|f| positional_arg_types(f).len() == implied_arg_types.len())
128                .collect();
129            match by_arity.as_slice() {
130                [] => None,
131                [only] => Some(only),
132                still_many => still_many.iter().copied().find(|f| {
133                    positional_arg_types(f)
134                        .into_iter()
135                        .eq(implied_arg_types.iter())
136                }),
137            }
138        }
139    }
140}
141
142/// Positional (IN/INOUT/VARIADIC) argument types of `f`, in declaration order —
143/// the call signature, matching how `NormalizedArgTypes` is built.
144fn positional_arg_types(f: &crate::ir::function::Function) -> Vec<&ColumnType> {
145    use crate::ir::function::ArgMode;
146    f.args
147        .iter()
148        .filter(|a| matches!(a.mode, ArgMode::In | ArgMode::InOut | ArgMode::Variadic))
149        .map(|a| &a.ty)
150        .collect()
151}
152
153/// Build the `NormalizedArgTypes` identity for an aggregate from its raw
154/// argument types. Aggregate args are always positional (`IN`), so we wrap
155/// each `ColumnType` in an `IN` [`crate::ir::function::FunctionArg`] and reuse
156/// `NormalizedArgTypes::from_args` for an identical hash to the function side.
157pub(crate) fn aggregate_arg_types(arg_types: &[ColumnType]) -> NormalizedArgTypes {
158    use crate::ir::function::{ArgMode, FunctionArg};
159    let args: Vec<FunctionArg> = arg_types
160        .iter()
161        .map(|ty| FunctionArg {
162            name: None,
163            mode: ArgMode::In,
164            ty: ty.clone(),
165            default: None,
166        })
167        .collect();
168    NormalizedArgTypes::from_args(&args)
169}
170
171/// Returns `true` iff `qname` refers to a managed collation in `catalog` —
172/// i.e., a collation we should emit a dependency edge for. Built-in
173/// `pg_catalog` collations (`C`, `POSIX`, `und-x-icu`, …) are skipped.
174fn should_add_collation_edge(qname: &QualifiedName, catalog: &Catalog) -> bool {
175    if qname.schema.as_str() == "pg_catalog" {
176        return false;
177    }
178    let name = qname.name.as_str();
179    if BUILTIN_COLLATIONS.contains(&name) {
180        return false;
181    }
182    catalog.collations.iter().any(|c| &c.qname == qname)
183}
184
185/// Build the dependency graph for `catalog`, used for create/modify ordering.
186///
187/// Topologically sorting this graph yields **dependencies first**: schemas
188/// before tables, tables before indexes, referenced tables before FKs, etc.
189#[allow(clippy::too_many_lines)] // two-phase walk of every catalog object family adding nodes + edges; one place per object.
190pub fn build_create_graph(catalog: &Catalog) -> Graph<NodeId> {
191    let mut g = Graph::new();
192
193    // Phase 1: every IR object gets a node, even if it has no edges.
194    for s in &catalog.schemas {
195        g.add_node(NodeId::Schema(s.name.clone()));
196    }
197    for t in &catalog.tables {
198        g.add_node(NodeId::Table(t.qname.clone()));
199    }
200    for i in &catalog.indexes {
201        g.add_node(NodeId::Index(i.qname.clone()));
202    }
203    for s in &catalog.sequences {
204        g.add_node(NodeId::Sequence(s.qname.clone()));
205    }
206    for t in &catalog.tables {
207        for c in &t.constraints {
208            g.add_node(NodeId::Constraint {
209                table: t.qname.clone(),
210                name: c.qname.name.clone(),
211            });
212        }
213    }
214    // Register view and MV nodes so they participate in topological ordering
215    // and body-dependency edges are rooted correctly.
216    for v in &catalog.views {
217        g.add_node(NodeId::View(v.qname.clone()));
218    }
219    for mv in &catalog.materialized_views {
220        g.add_node(NodeId::Mv(mv.qname.clone()));
221    }
222    // Register user-defined type nodes.
223    for t in &catalog.types {
224        g.add_node(NodeId::Type(t.qname.clone()));
225    }
226    // Register function and procedure nodes.
227    for f in &catalog.functions {
228        g.add_node(NodeId::Function(
229            f.qname.clone(),
230            f.arg_types_normalized.clone(),
231        ));
232    }
233    for p in &catalog.procedures {
234        g.add_node(NodeId::Procedure(p.qname.clone()));
235    }
236    // Register triggers; trigger depends on its target relation and function.
237    for t in &catalog.triggers {
238        g.add_node(NodeId::Trigger(t.qname.clone()));
239        let target_node = if catalog.tables.iter().any(|x| x.qname == t.table) {
240            NodeId::Table(t.table.clone())
241        } else if catalog.views.iter().any(|x| x.qname == t.table) {
242            NodeId::View(t.table.clone())
243        } else if catalog
244            .materialized_views
245            .iter()
246            .any(|x| x.qname == t.table)
247        {
248            NodeId::Mv(t.table.clone())
249        } else {
250            // Unresolved target — the lint rule trigger-references-unmanaged-table
251            // catches this in T9. Skip the edge so the graph builder doesn't
252            // panic on a missing target node.
253            continue;
254        };
255        g.add_edge(NodeId::Trigger(t.qname.clone()), target_node);
256        if let Some(func) = catalog
257            .functions
258            .iter()
259            .find(|f| f.qname == t.function_qname)
260        {
261            g.add_edge(
262                NodeId::Trigger(t.qname.clone()),
263                NodeId::Function(t.function_qname.clone(), func.arg_types_normalized.clone()),
264            );
265        }
266    }
267    // Register extensions; an extension with WITH SCHEMA s depends on the schema.
268    for e in &catalog.extensions {
269        g.add_node(NodeId::Extension(e.name.clone()));
270        if let Some(schema) = &e.schema {
271            g.add_edge(
272                NodeId::Extension(e.name.clone()),
273                NodeId::Schema(schema.clone()),
274            );
275        }
276    }
277    // Register publications. For Selective publications, add edges from each
278    // referenced table and schema to the publication node so publications are
279    // ordered after their dependencies. AllTables publications have no explicit
280    // edges; they are ordered by tier rule.
281    for p in &catalog.publications {
282        let pub_node = NodeId::Publication(p.name.clone());
283        g.add_node(pub_node.clone());
284        if let crate::ir::publication::PublicationScope::Selective { schemas, tables } = &p.scope {
285            for t in tables {
286                g.add_edge(pub_node.clone(), NodeId::Table(t.qname.clone()));
287            }
288            for s in schemas {
289                g.add_edge(pub_node.clone(), NodeId::Schema(s.clone()));
290            }
291        }
292    }
293    // Register subscriptions. Subscriptions cross-reference publications in
294    // a *different* cluster — no local dep edges anchor them. They are
295    // registered as isolated nodes; the tier rule in ordering.rs schedules
296    // them create-last, drop-first via sort_key.
297    for s in &catalog.subscriptions {
298        g.add_node(NodeId::Subscription(s.name.clone()));
299    }
300    // Event triggers: global nodes; edge to the function they execute so the
301    // function is created before the event trigger (and dropped after).
302    for et in &catalog.event_triggers {
303        g.add_node(NodeId::EventTrigger(et.name.clone()));
304        if let Some(func) = catalog.functions.iter().find(|f| f.qname == et.function) {
305            g.add_edge(
306                NodeId::EventTrigger(et.name.clone()),
307                NodeId::Function(et.function.clone(), func.arg_types_normalized.clone()),
308            );
309        }
310    }
311    // Register statistics; each depends on its target table (must be created
312    // after the table exists and dropped before the table is dropped).
313    for s in &catalog.statistics {
314        let stat_node = NodeId::Statistic(s.qname.clone());
315        g.add_node(stat_node.clone());
316        g.add_edge(stat_node, NodeId::Table(s.target.clone()));
317    }
318    // Register collations and wire each to its schema (collation depends on
319    // schema — CREATE COLLATION must follow CREATE SCHEMA; DROP COLLATION must
320    // precede DROP SCHEMA). Without this edge the drop ordering is only
321    // accidentally correct (via enum discriminant tie-breaking).
322    // Edges from table columns / domain / range / composite attributes
323    // → collation are added below in the relevant loops.
324    for c in &catalog.collations {
325        let coll_node = NodeId::Collation(c.qname.clone());
326        g.add_node(coll_node.clone());
327        g.add_edge(coll_node, NodeId::Schema(c.qname.schema.clone()));
328    }
329    // Register aggregates and wire each to the managed function(s) it relies on:
330    // the state function (`SFUNC`, implied signature `(state_type, arg_types…)`)
331    // and the optional final function (`FINALFUNC`, implied signature
332    // `(state_type)`). The edge direction is aggregate (dependent) → function
333    // (dependency), so the function is created before the aggregate and dropped
334    // after it — mirroring the event-trigger → function edge.
335    for agg in &catalog.aggregates {
336        let agg_node = NodeId::Aggregate(agg.qname.clone(), aggregate_arg_types(&agg.arg_types));
337        g.add_node(agg_node.clone());
338        // sfunc implied signature: (state_type, arg_types…)
339        let mut sfunc_sig = Vec::with_capacity(1 + agg.arg_types.len());
340        sfunc_sig.push(agg.state_type.clone());
341        sfunc_sig.extend(agg.arg_types.iter().cloned());
342        if let Some(sfunc) = find_sfunc(catalog, &agg.sfunc, &sfunc_sig) {
343            g.add_edge(
344                agg_node.clone(),
345                NodeId::Function(sfunc.qname.clone(), sfunc.arg_types_normalized.clone()),
346            );
347        }
348        // finalfunc implied signature: (state_type)
349        if let Some(finalfunc_qname) = &agg.finalfunc
350            && let Some(finalfunc) = find_sfunc(
351                catalog,
352                finalfunc_qname,
353                std::slice::from_ref(&agg.state_type),
354            )
355        {
356            g.add_edge(
357                agg_node,
358                NodeId::Function(
359                    finalfunc.qname.clone(),
360                    finalfunc.arg_types_normalized.clone(),
361                ),
362            );
363        }
364    }
365    // Register casts and wire each to:
366    //   1. The conversion function (only `WITH FUNCTION`): find the managed
367    //      function overload whose qname and positional arg types match the
368    //      cast's recorded `arg_types`. Skip the edge if no managed overload
369    //      matches (closed-world drift is surfaced later, not here).
370    //   2. Managed source / target types: if either type is a user-defined type
371    //      present in `catalog.types`, add a dependency edge so the type is
372    //      created before the cast and dropped after it. Built-in pg_catalog
373    //      types are absent from `catalog.types` → no edge for them.
374    for cast in &catalog.casts {
375        let cast_node = NodeId::Cast(cast.source.clone(), cast.target.clone());
376        g.add_node(cast_node.clone());
377        // Edge to conversion function (WITH FUNCTION only).
378        if let crate::ir::cast::CastMethod::Function { name, arg_types } = &cast.method
379            && let Some(func) = find_sfunc(catalog, name, arg_types)
380        {
381            g.add_edge(
382                cast_node.clone(),
383                NodeId::Function(func.qname.clone(), func.arg_types_normalized.clone()),
384            );
385        }
386        // Edge to source type if it is a managed user-defined type.
387        if catalog.types.iter().any(|t| t.qname == cast.source) {
388            g.add_edge(cast_node.clone(), NodeId::Type(cast.source.clone()));
389        }
390        // Edge to target type if it is a managed user-defined type.
391        if catalog.types.iter().any(|t| t.qname == cast.target) {
392            g.add_edge(cast_node.clone(), NodeId::Type(cast.target.clone()));
393        }
394    }
395
396    // Phase 1b.0: type → schema edges. Every user-defined type lives inside
397    // a schema and must be created after CREATE SCHEMA emits.
398    for t in &catalog.types {
399        g.add_edge(
400            NodeId::Type(t.qname.clone()),
401            NodeId::Schema(t.qname.schema.clone()),
402        );
403    }
404    // Phase 1b.0 (routines): every function/procedure depends on its schema.
405    for f in &catalog.functions {
406        let node = NodeId::Function(f.qname.clone(), f.arg_types_normalized.clone());
407        g.add_edge(node, NodeId::Schema(f.qname.schema.clone()));
408    }
409    for p in &catalog.procedures {
410        g.add_edge(
411            NodeId::Procedure(p.qname.clone()),
412            NodeId::Schema(p.qname.schema.clone()),
413        );
414    }
415
416    // Phase 1b: type → type edges from composite attributes and domain bases.
417    // These edges ensure composites/domains that reference other user-defined
418    // types are created after those types. Range types additionally depend on
419    // their subtype (when managed) and on their canonical / subtype_diff
420    // functions (when managed).
421    for ut in &catalog.types {
422        match &ut.kind {
423            UserTypeKind::Composite { attributes } => {
424                for attr in attributes {
425                    if let ColumnType::UserDefined(dep_qname) = &attr.ty {
426                        g.add_edge(
427                            NodeId::Type(ut.qname.clone()),
428                            NodeId::Type(dep_qname.clone()),
429                        );
430                    }
431                    if let Some(coll_qname) = &attr.collation
432                        && should_add_collation_edge(coll_qname, catalog)
433                    {
434                        g.add_edge(
435                            NodeId::Type(ut.qname.clone()),
436                            NodeId::Collation(coll_qname.clone()),
437                        );
438                    }
439                }
440            }
441            UserTypeKind::Domain {
442                base, collation, ..
443            } => {
444                if let ColumnType::UserDefined(base_qname) = base {
445                    g.add_edge(
446                        NodeId::Type(ut.qname.clone()),
447                        NodeId::Type(base_qname.clone()),
448                    );
449                }
450                if let Some(coll_qname) = collation
451                    && should_add_collation_edge(coll_qname, catalog)
452                {
453                    g.add_edge(
454                        NodeId::Type(ut.qname.clone()),
455                        NodeId::Collation(coll_qname.clone()),
456                    );
457                }
458            }
459            UserTypeKind::Range {
460                subtype,
461                canonical,
462                subtype_diff,
463                collation,
464                ..
465            } => {
466                // Skip built-in subtypes (pg_catalog.* are not user-managed).
467                if subtype.schema.as_str() != "pg_catalog"
468                    && catalog.types.iter().any(|t| &t.qname == subtype)
469                {
470                    g.add_edge(
471                        NodeId::Type(ut.qname.clone()),
472                        NodeId::Type(subtype.clone()),
473                    );
474                }
475                if let Some(fn_qname) = canonical
476                    && let Some(f) = catalog.functions.iter().find(|f| &f.qname == fn_qname)
477                {
478                    g.add_edge(
479                        NodeId::Type(ut.qname.clone()),
480                        NodeId::Function(f.qname.clone(), f.arg_types_normalized.clone()),
481                    );
482                }
483                if let Some(fn_qname) = subtype_diff
484                    && let Some(f) = catalog.functions.iter().find(|f| &f.qname == fn_qname)
485                {
486                    g.add_edge(
487                        NodeId::Type(ut.qname.clone()),
488                        NodeId::Function(f.qname.clone(), f.arg_types_normalized.clone()),
489                    );
490                }
491                if let Some(coll_qname) = collation
492                    && should_add_collation_edge(coll_qname, catalog)
493                {
494                    g.add_edge(
495                        NodeId::Type(ut.qname.clone()),
496                        NodeId::Collation(coll_qname.clone()),
497                    );
498                }
499            }
500            UserTypeKind::Enum { .. } => {}
501        }
502    }
503
504    // Phase 1c: table → type edges from columns with user-defined types.
505    // Tables that reference a user-defined type must be created after the type.
506    // The same loop emits table → collation edges for columns with an explicit
507    // `COLLATE` clause that points at a managed collation.
508    for t in &catalog.tables {
509        for col in &t.columns {
510            if let ColumnType::UserDefined(type_qname) = &col.ty {
511                g.add_edge(
512                    NodeId::Table(t.qname.clone()),
513                    NodeId::Type(type_qname.clone()),
514                );
515            }
516            if let Some(coll_qname) = &col.collation
517                && should_add_collation_edge(coll_qname, catalog)
518            {
519                g.add_edge(
520                    NodeId::Table(t.qname.clone()),
521                    NodeId::Collation(coll_qname.clone()),
522                );
523            }
524        }
525    }
526    // Phase 1c (routines): function/procedure → types referenced in args and
527    // return types. These ensure routines are created after their type deps.
528    for f in &catalog.functions {
529        let node = NodeId::Function(f.qname.clone(), f.arg_types_normalized.clone());
530        for arg in &f.args {
531            if let ColumnType::UserDefined(t_qname) = &arg.ty {
532                g.add_edge(node.clone(), NodeId::Type(t_qname.clone()));
533            }
534        }
535        match &f.return_type {
536            ReturnType::Scalar {
537                ty: ColumnType::UserDefined(t),
538            }
539            | ReturnType::SetOf {
540                ty: ColumnType::UserDefined(t),
541            } => {
542                g.add_edge(node.clone(), NodeId::Type(t.clone()));
543            }
544            ReturnType::Table { columns } => {
545                for col in columns {
546                    if let ColumnType::UserDefined(t) = &col.ty {
547                        g.add_edge(node.clone(), NodeId::Type(t.clone()));
548                    }
549                }
550            }
551            _ => {}
552        }
553    }
554    for p in &catalog.procedures {
555        let node = NodeId::Procedure(p.qname.clone());
556        for arg in &p.args {
557            if let ColumnType::UserDefined(t_qname) = &arg.ty {
558                g.add_edge(node.clone(), NodeId::Type(t_qname.clone()));
559            }
560        }
561    }
562
563    // Phase 2: tables depend on their schema and on any sequence used as a
564    // column default. We add the schema node implicitly via add_edge in case
565    // the caller did not declare it (defensive: source-side parsing typically
566    // does declare every referenced schema, but a hand-built Catalog might not).
567    // Partition children depend on their parent table.
568    for t in &catalog.tables {
569        g.add_edge(
570            NodeId::Table(t.qname.clone()),
571            NodeId::Schema(t.qname.schema.clone()),
572        );
573        for col in &t.columns {
574            if let Some(DefaultExpr::Sequence(seq_qname)) = &col.default {
575                g.add_edge(
576                    NodeId::Table(t.qname.clone()),
577                    NodeId::Sequence(seq_qname.clone()),
578                );
579            }
580        }
581        if let Some(po) = &t.partition_of {
582            // Partition child depends on its parent existing first.
583            g.add_edge(
584                NodeId::Table(t.qname.clone()),
585                NodeId::Table(po.parent.clone()),
586            );
587        }
588    }
589
590    // Phase 2b: views and MVs depend on objects in their body_dependencies.
591    // `body_dependencies` edges use NodeId directly (already the correct
592    // variant); we just re-register each edge into the graph.
593    for v in &catalog.views {
594        for dep in &v.body_dependencies {
595            g.add_edge(dep.from.clone(), dep.to.clone());
596        }
597    }
598    for mv in &catalog.materialized_views {
599        for dep in &mv.body_dependencies {
600            g.add_edge(dep.from.clone(), dep.to.clone());
601        }
602    }
603
604    // Phase 2c: functions and procedures body_dependencies.
605    for f in &catalog.functions {
606        for dep in &f.body_dependencies {
607            g.add_edge(dep.from.clone(), dep.to.clone());
608        }
609    }
610    for p in &catalog.procedures {
611        for dep in &p.body_dependencies {
612            g.add_edge(dep.from.clone(), dep.to.clone());
613        }
614    }
615
616    // Phase 3: indexes depend on their parent (table or MV).
617    // For `IndexParent::Mv`, we use `NodeId::Mv` so the graph correctly
618    // orders CREATE INDEX after CREATE MATERIALIZED VIEW.
619    for i in &catalog.indexes {
620        use crate::ir::index::IndexParent;
621        let parent_node = match &i.on {
622            IndexParent::Table(q) => NodeId::Table(q.clone()),
623            IndexParent::Mv(q) => NodeId::Mv(q.clone()),
624        };
625        g.add_edge(NodeId::Index(i.qname.clone()), parent_node);
626    }
627
628    // Phase 4: constraints depend on their owning table; FKs additionally
629    // depend on the referenced table.
630    //
631    // For FKs we ALSO add a direct table → referenced_table edge. Inline FKs
632    // are emitted as part of `CREATE TABLE`, so the owning table's create
633    // statement requires the referenced table to exist first. Without this
634    // edge, two-table FK cycles never produce a cycle in the graph and the
635    // planner's FK-extraction post-pass would have nothing to detect.
636    for t in &catalog.tables {
637        for c in &t.constraints {
638            let constraint_node = NodeId::Constraint {
639                table: t.qname.clone(),
640                name: c.qname.name.clone(),
641            };
642            g.add_edge(constraint_node.clone(), NodeId::Table(t.qname.clone()));
643            if let ConstraintKind::ForeignKey(fk) = &c.kind {
644                g.add_edge(constraint_node, NodeId::Table(fk.referenced_table.clone()));
645                // Self-referential FKs don't induce a real table-level cycle
646                // (table can be created first, FK satisfied at row time).
647                if fk.referenced_table != t.qname {
648                    g.add_edge(
649                        NodeId::Table(t.qname.clone()),
650                        NodeId::Table(fk.referenced_table.clone()),
651                    );
652                }
653            }
654        }
655    }
656
657    // Phase 5: an `OWNED BY` sequence depends on its owner table.
658    for s in &catalog.sequences {
659        if let Some(owner) = &s.owned_by {
660            g.add_edge(
661                NodeId::Sequence(s.qname.clone()),
662                NodeId::Table(owner.table.clone()),
663            );
664        }
665    }
666
667    g
668}
669
670/// Build the dependency graph for drop-ordering. Same edges as the create
671/// graph; the ordering is reversed at sort time.
672pub fn build_drop_graph(catalog: &Catalog) -> Graph<NodeId> {
673    build_create_graph(catalog)
674}
675
676/// Where a dependency edge came from.
677///
678/// `Structural` edges are derived from the IR shape itself (schema←table,
679/// table←index, FK references, sequence ownership). They exist in v0.1.
680///
681/// `AstExtracted` edges are derived by walking the parsed AST of an object
682/// body (view body, function body, expression-index predicate, etc.).
683/// First produced in v0.2 view sub-spec.
684///
685/// `AstDeclared` edges come from explicit `-- @pgevolve dep:` directives
686/// that close the PL/pgSQL-dynamic-SQL gap (Decision 11). First produced
687/// in v0.2 function sub-spec.
688///
689/// Ordering: `Structural < AstExtracted < AstDeclared` — structural edges
690/// are tie-broken first in the Kahn min-heap to preserve v0.1 ordering.
691#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
692pub enum DepSource {
693    /// Derived from the IR shape; v0.1 default.
694    Structural,
695    /// Walked out of a parsed body AST.
696    AstExtracted,
697    /// Declared by a `-- @pgevolve dep:` directive in source SQL.
698    AstDeclared,
699}
700
701/// An edge in the dependency graph, with provenance metadata.
702///
703/// Convention matches the existing graph: `from` depends on `to`, so `to`
704/// must be created before `from`.
705#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
706pub struct DepEdge {
707    /// Dependent node (the one that needs `to` to exist first).
708    pub from: NodeId,
709    /// Dependency target.
710    pub to: NodeId,
711    /// Provenance of this edge.
712    pub source: DepSource,
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718    use crate::ir::column::Column;
719    use crate::ir::column_type::ColumnType;
720    use crate::ir::constraint::{
721        Constraint, ConstraintKind, Deferrable, FkMatchType, ForeignKey, ReferentialAction,
722    };
723    use crate::ir::index::{
724        Index, IndexColumn, IndexColumnExpr, IndexMethod, IndexParent, NullsOrder, SortOrder,
725    };
726    use crate::ir::schema::Schema;
727    use crate::ir::sequence::{Sequence, SequenceOwner};
728    use crate::ir::table::Table;
729
730    fn id(s: &str) -> Identifier {
731        Identifier::from_unquoted(s).unwrap()
732    }
733
734    fn qn(schema: &str, name: &str) -> QualifiedName {
735        QualifiedName::new(id(schema), id(name))
736    }
737
738    fn col_id_bigint() -> Column {
739        Column {
740            name: id("id"),
741            ty: ColumnType::BigInt,
742            nullable: false,
743            default: None,
744            identity: None,
745            generated: None,
746            collation: None,
747            storage: None,
748            compression: None,
749            comment: None,
750        }
751    }
752
753    fn col_text_notnull(name: &str) -> Column {
754        Column {
755            name: id(name),
756            ty: ColumnType::Text,
757            nullable: false,
758            default: None,
759            identity: None,
760            generated: None,
761            collation: None,
762            storage: None,
763            compression: None,
764            comment: None,
765        }
766    }
767
768    fn pk(name: &str, cols: &[&str]) -> Constraint {
769        Constraint {
770            qname: qn("app", name),
771            kind: ConstraintKind::PrimaryKey {
772                columns: cols.iter().map(|c| id(c)).collect(),
773                include: vec![],
774            },
775            deferrable: Deferrable::NotDeferrable,
776            comment: None,
777        }
778    }
779
780    fn fk(name: &str, ref_table: QualifiedName) -> Constraint {
781        Constraint {
782            qname: qn("app", name),
783            kind: ConstraintKind::ForeignKey(ForeignKey {
784                columns: vec![id("ref_id")],
785                referenced_table: ref_table,
786                referenced_columns: vec![id("id")],
787                on_update: ReferentialAction::NoAction,
788                on_delete: ReferentialAction::NoAction,
789                match_type: FkMatchType::Simple,
790            }),
791            deferrable: Deferrable::NotDeferrable,
792            comment: None,
793        }
794    }
795
796    fn has_edge(g: &Graph<NodeId>, from: &NodeId, to: &NodeId) -> bool {
797        g.dependencies_of(from).any(|n| n == to)
798    }
799
800    #[test]
801    fn empty_catalog_yields_empty_graph() {
802        let g = build_create_graph(&Catalog::empty());
803        assert_eq!(g.node_count(), 0);
804    }
805
806    #[test]
807    fn every_object_appears_as_a_node() {
808        let mut c = Catalog::empty();
809        c.schemas.push(Schema::new(id("app")));
810        c.tables.push(Table {
811            qname: qn("app", "users"),
812            columns: vec![col_id_bigint()],
813            constraints: vec![pk("users_pkey", &["id"])],
814            partition_by: None,
815            partition_of: None,
816            comment: None,
817            owner: None,
818            grants: vec![],
819            rls_enabled: false,
820            rls_forced: false,
821            policies: vec![],
822            storage: crate::ir::reloptions::TableStorageOptions::default(),
823            access_method: None,
824        });
825        c.indexes.push(Index {
826            qname: qn("app", "users_idx"),
827            on: IndexParent::Table(qn("app", "users")),
828            method: IndexMethod::BTree,
829            columns: vec![IndexColumn {
830                expr: IndexColumnExpr::Column(id("id")),
831                collation: None,
832                opclass: None,
833                sort_order: SortOrder::Asc,
834                nulls_order: NullsOrder::NullsLast,
835            }],
836            include: vec![],
837            unique: false,
838            nulls_not_distinct: false,
839            predicate: None,
840            tablespace: None,
841            comment: None,
842            storage: crate::ir::reloptions::IndexStorageOptions::default(),
843        });
844        c.sequences.push(Sequence {
845            qname: qn("app", "seq1"),
846            data_type: ColumnType::BigInt,
847            start: 1,
848            increment: 1,
849            min_value: None,
850            max_value: None,
851            cache: 1,
852            cycle: false,
853            owned_by: None,
854            comment: None,
855            owner: None,
856            grants: vec![],
857        });
858
859        let g = build_create_graph(&c);
860        // schema + table + index + sequence + constraint = 5
861        assert_eq!(g.node_count(), 5);
862    }
863
864    #[test]
865    fn table_depends_on_its_schema() {
866        let mut c = Catalog::empty();
867        c.schemas.push(Schema::new(id("app")));
868        c.tables.push(Table {
869            qname: qn("app", "users"),
870            columns: vec![col_id_bigint()],
871            constraints: vec![],
872            partition_by: None,
873            partition_of: None,
874            comment: None,
875            owner: None,
876            grants: vec![],
877            rls_enabled: false,
878            rls_forced: false,
879            policies: vec![],
880            storage: crate::ir::reloptions::TableStorageOptions::default(),
881            access_method: None,
882        });
883        let g = build_create_graph(&c);
884        assert!(has_edge(
885            &g,
886            &NodeId::Table(qn("app", "users")),
887            &NodeId::Schema(id("app")),
888        ));
889    }
890
891    #[test]
892    fn index_depends_on_its_table() {
893        let mut c = Catalog::empty();
894        c.tables.push(Table {
895            qname: qn("app", "users"),
896            columns: vec![col_id_bigint()],
897            constraints: vec![],
898            partition_by: None,
899            partition_of: None,
900            comment: None,
901            owner: None,
902            grants: vec![],
903            rls_enabled: false,
904            rls_forced: false,
905            policies: vec![],
906            storage: crate::ir::reloptions::TableStorageOptions::default(),
907            access_method: None,
908        });
909        c.indexes.push(Index {
910            qname: qn("app", "users_idx"),
911            on: IndexParent::Table(qn("app", "users")),
912            method: IndexMethod::BTree,
913            columns: vec![IndexColumn {
914                expr: IndexColumnExpr::Column(id("id")),
915                collation: None,
916                opclass: None,
917                sort_order: SortOrder::Asc,
918                nulls_order: NullsOrder::NullsLast,
919            }],
920            include: vec![],
921            unique: false,
922            nulls_not_distinct: false,
923            predicate: None,
924            tablespace: None,
925            comment: None,
926            storage: crate::ir::reloptions::IndexStorageOptions::default(),
927        });
928        let g = build_create_graph(&c);
929        assert!(has_edge(
930            &g,
931            &NodeId::Index(qn("app", "users_idx")),
932            &NodeId::Table(qn("app", "users")),
933        ));
934    }
935
936    #[test]
937    fn fk_constraint_depends_on_both_endpoints() {
938        let mut c = Catalog::empty();
939        c.tables.push(Table {
940            qname: qn("app", "orgs"),
941            columns: vec![col_id_bigint()],
942            constraints: vec![pk("orgs_pkey", &["id"])],
943            partition_by: None,
944            partition_of: None,
945            comment: None,
946            owner: None,
947            grants: vec![],
948            rls_enabled: false,
949            rls_forced: false,
950            policies: vec![],
951            storage: crate::ir::reloptions::TableStorageOptions::default(),
952            access_method: None,
953        });
954        c.tables.push(Table {
955            qname: qn("app", "users"),
956            columns: vec![
957                col_id_bigint(),
958                Column {
959                    name: id("ref_id"),
960                    ty: ColumnType::BigInt,
961                    nullable: false,
962                    default: None,
963                    identity: None,
964                    generated: None,
965                    collation: None,
966                    storage: None,
967                    compression: None,
968                    comment: None,
969                },
970            ],
971            constraints: vec![fk("users_orgs_fk", qn("app", "orgs"))],
972            partition_by: None,
973            partition_of: None,
974            comment: None,
975            owner: None,
976            grants: vec![],
977            rls_enabled: false,
978            rls_forced: false,
979            policies: vec![],
980            storage: crate::ir::reloptions::TableStorageOptions::default(),
981            access_method: None,
982        });
983        let g = build_create_graph(&c);
984        let fk_node = NodeId::Constraint {
985            table: qn("app", "users"),
986            name: id("users_orgs_fk"),
987        };
988        // Owning-table edge.
989        assert!(has_edge(&g, &fk_node, &NodeId::Table(qn("app", "users"))));
990        // Referenced-table edge.
991        assert!(has_edge(&g, &fk_node, &NodeId::Table(qn("app", "orgs"))));
992    }
993
994    #[test]
995    fn table_depends_on_default_sequence() {
996        let mut c = Catalog::empty();
997        c.sequences.push(Sequence {
998            qname: qn("app", "id_seq"),
999            data_type: ColumnType::BigInt,
1000            start: 1,
1001            increment: 1,
1002            min_value: None,
1003            max_value: None,
1004            cache: 1,
1005            cycle: false,
1006            owned_by: None,
1007            comment: None,
1008            owner: None,
1009            grants: vec![],
1010        });
1011        c.tables.push(Table {
1012            qname: qn("app", "users"),
1013            columns: vec![Column {
1014                name: id("id"),
1015                ty: ColumnType::BigInt,
1016                nullable: false,
1017                default: Some(DefaultExpr::Sequence(qn("app", "id_seq"))),
1018                identity: None,
1019                generated: None,
1020                collation: None,
1021                storage: None,
1022                compression: None,
1023                comment: None,
1024            }],
1025            constraints: vec![],
1026            partition_by: None,
1027            partition_of: None,
1028            comment: None,
1029            owner: None,
1030            grants: vec![],
1031            rls_enabled: false,
1032            rls_forced: false,
1033            policies: vec![],
1034            storage: crate::ir::reloptions::TableStorageOptions::default(),
1035            access_method: None,
1036        });
1037        let g = build_create_graph(&c);
1038        assert!(has_edge(
1039            &g,
1040            &NodeId::Table(qn("app", "users")),
1041            &NodeId::Sequence(qn("app", "id_seq")),
1042        ));
1043    }
1044
1045    #[test]
1046    fn owned_sequence_depends_on_owner_table() {
1047        let mut c = Catalog::empty();
1048        c.tables.push(Table {
1049            qname: qn("app", "users"),
1050            columns: vec![col_id_bigint()],
1051            constraints: vec![],
1052            partition_by: None,
1053            partition_of: None,
1054            comment: None,
1055            owner: None,
1056            grants: vec![],
1057            rls_enabled: false,
1058            rls_forced: false,
1059            policies: vec![],
1060            storage: crate::ir::reloptions::TableStorageOptions::default(),
1061            access_method: None,
1062        });
1063        c.sequences.push(Sequence {
1064            qname: qn("app", "users_id_seq"),
1065            data_type: ColumnType::BigInt,
1066            start: 1,
1067            increment: 1,
1068            min_value: None,
1069            max_value: None,
1070            cache: 1,
1071            cycle: false,
1072            owned_by: Some(SequenceOwner {
1073                table: qn("app", "users"),
1074                column: id("id"),
1075            }),
1076            comment: None,
1077            owner: None,
1078            grants: vec![],
1079        });
1080        let g = build_create_graph(&c);
1081        assert!(has_edge(
1082            &g,
1083            &NodeId::Sequence(qn("app", "users_id_seq")),
1084            &NodeId::Table(qn("app", "users")),
1085        ));
1086    }
1087
1088    #[test]
1089    fn non_fk_constraint_depends_only_on_its_table() {
1090        let mut c = Catalog::empty();
1091        c.tables.push(Table {
1092            qname: qn("app", "users"),
1093            columns: vec![col_id_bigint()],
1094            constraints: vec![pk("users_pkey", &["id"])],
1095            partition_by: None,
1096            partition_of: None,
1097            comment: None,
1098            owner: None,
1099            grants: vec![],
1100            rls_enabled: false,
1101            rls_forced: false,
1102            policies: vec![],
1103            storage: crate::ir::reloptions::TableStorageOptions::default(),
1104            access_method: None,
1105        });
1106        let g = build_create_graph(&c);
1107        let pk_node = NodeId::Constraint {
1108            table: qn("app", "users"),
1109            name: id("users_pkey"),
1110        };
1111        let deps: Vec<&NodeId> = g.dependencies_of(&pk_node).collect();
1112        assert_eq!(deps, vec![&NodeId::Table(qn("app", "users"))]);
1113    }
1114
1115    #[test]
1116    fn drop_graph_matches_create_graph() {
1117        let mut c = Catalog::empty();
1118        c.tables.push(Table {
1119            qname: qn("app", "users"),
1120            columns: vec![col_id_bigint()],
1121            constraints: vec![pk("users_pkey", &["id"])],
1122            partition_by: None,
1123            partition_of: None,
1124            comment: None,
1125            owner: None,
1126            grants: vec![],
1127            rls_enabled: false,
1128            rls_forced: false,
1129            policies: vec![],
1130            storage: crate::ir::reloptions::TableStorageOptions::default(),
1131            access_method: None,
1132        });
1133        // Same edges; equality is structural via topological output.
1134        let cg = build_create_graph(&c);
1135        let dg = build_drop_graph(&c);
1136        assert_eq!(cg.topological_sort(), dg.topological_sort());
1137    }
1138
1139    #[test]
1140    fn fk_cycle_produces_table_level_cycle() {
1141        // Two tables with FKs to each other; each FK induces an inline-create
1142        // edge table → referenced_table, so the table subgraph cycles.
1143        let mut c = Catalog::empty();
1144        c.tables.push(Table {
1145            qname: qn("app", "a"),
1146            columns: vec![
1147                col_id_bigint(),
1148                Column {
1149                    name: id("ref_id"),
1150                    ty: ColumnType::BigInt,
1151                    nullable: false,
1152                    default: None,
1153                    identity: None,
1154                    generated: None,
1155                    collation: None,
1156                    storage: None,
1157                    compression: None,
1158                    comment: None,
1159                },
1160            ],
1161            constraints: vec![pk("a_pk", &["id"]), fk("a_to_b", qn("app", "b"))],
1162            partition_by: None,
1163            partition_of: None,
1164            comment: None,
1165            owner: None,
1166            grants: vec![],
1167            rls_enabled: false,
1168            rls_forced: false,
1169            policies: vec![],
1170            storage: crate::ir::reloptions::TableStorageOptions::default(),
1171            access_method: None,
1172        });
1173        c.tables.push(Table {
1174            qname: qn("app", "b"),
1175            columns: vec![
1176                col_id_bigint(),
1177                Column {
1178                    name: id("ref_id"),
1179                    ty: ColumnType::BigInt,
1180                    nullable: false,
1181                    default: None,
1182                    identity: None,
1183                    generated: None,
1184                    collation: None,
1185                    storage: None,
1186                    compression: None,
1187                    comment: None,
1188                },
1189            ],
1190            constraints: vec![pk("b_pk", &["id"]), fk("b_to_a", qn("app", "a"))],
1191            partition_by: None,
1192            partition_of: None,
1193            comment: None,
1194            owner: None,
1195            grants: vec![],
1196            rls_enabled: false,
1197            rls_forced: false,
1198            policies: vec![],
1199            storage: crate::ir::reloptions::TableStorageOptions::default(),
1200            access_method: None,
1201        });
1202        let g = build_create_graph(&c);
1203        let err = g.topological_sort().unwrap_err();
1204        assert!(err.nodes.contains(&NodeId::Table(qn("app", "a"))));
1205        assert!(err.nodes.contains(&NodeId::Table(qn("app", "b"))));
1206    }
1207
1208    #[test]
1209    fn self_referential_fk_does_not_cycle() {
1210        // A self-referential FK doesn't force the table to depend on itself —
1211        // the rows are inserted after the table exists.
1212        let mut c = Catalog::empty();
1213        c.tables.push(Table {
1214            qname: qn("app", "tree"),
1215            columns: vec![
1216                col_id_bigint(),
1217                Column {
1218                    name: id("ref_id"),
1219                    ty: ColumnType::BigInt,
1220                    nullable: true,
1221                    default: None,
1222                    identity: None,
1223                    generated: None,
1224                    collation: None,
1225                    storage: None,
1226                    compression: None,
1227                    comment: None,
1228                },
1229            ],
1230            constraints: vec![
1231                pk("tree_pk", &["id"]),
1232                fk("tree_parent_fk", qn("app", "tree")),
1233            ],
1234            partition_by: None,
1235            partition_of: None,
1236            comment: None,
1237            owner: None,
1238            grants: vec![],
1239            rls_enabled: false,
1240            rls_forced: false,
1241            policies: vec![],
1242            storage: crate::ir::reloptions::TableStorageOptions::default(),
1243            access_method: None,
1244        });
1245        let g = build_create_graph(&c);
1246        assert!(g.topological_sort().is_ok());
1247    }
1248
1249    #[test]
1250    fn partition_child_depends_on_parent() {
1251        // A partition child table depends on its parent table being created first.
1252        use crate::ir::partition::{PartitionBounds, PartitionBy, PartitionOf};
1253        let mut c = Catalog::empty();
1254
1255        // Parent table with PARTITION BY LIST.
1256        let parent = Table {
1257            qname: qn("app", "parent"),
1258            columns: vec![col_id_bigint(), col_text_notnull("status")],
1259            constraints: vec![pk("parent_pkey", &["id"])],
1260            partition_by: Some(PartitionBy {
1261                strategy: crate::ir::partition::PartitionStrategy::List,
1262                columns: vec![crate::ir::partition::PartitionColumn {
1263                    kind: crate::ir::partition::PartitionColumnKind::Column(id("status")),
1264                    collation: None,
1265                    opclass: None,
1266                }],
1267            }),
1268            partition_of: None,
1269            comment: None,
1270            owner: None,
1271            grants: vec![],
1272            rls_enabled: false,
1273            rls_forced: false,
1274            policies: vec![],
1275            storage: crate::ir::reloptions::TableStorageOptions::default(),
1276            access_method: None,
1277        };
1278        c.tables.push(parent);
1279
1280        // Child partition table.
1281        let child = Table {
1282            qname: qn("app", "child"),
1283            columns: vec![col_id_bigint(), col_text_notnull("status")],
1284            constraints: vec![],
1285            partition_by: None,
1286            partition_of: Some(PartitionOf {
1287                parent: qn("app", "parent"),
1288                bounds: PartitionBounds::List { values: vec![] },
1289            }),
1290            comment: None,
1291            owner: None,
1292            grants: vec![],
1293            rls_enabled: false,
1294            rls_forced: false,
1295            policies: vec![],
1296            storage: crate::ir::reloptions::TableStorageOptions::default(),
1297            access_method: None,
1298        };
1299        c.tables.push(child);
1300
1301        let g = build_create_graph(&c);
1302        assert!(
1303            has_edge(
1304                &g,
1305                &NodeId::Table(qn("app", "child")),
1306                &NodeId::Table(qn("app", "parent")),
1307            ),
1308            "expected child partition → parent table edge"
1309        );
1310    }
1311
1312    // ── User-defined type edge tests ──────────────────────────────────────────
1313
1314    use crate::ir::user_type::{CompositeAttribute, UserType, UserTypeKind};
1315
1316    fn make_enum(schema: &str, name: &str) -> UserType {
1317        UserType {
1318            qname: qn(schema, name),
1319            kind: UserTypeKind::Enum { values: vec![] },
1320            comment: None,
1321            owner: None,
1322            grants: vec![],
1323        }
1324    }
1325
1326    fn make_composite_with_attr(schema: &str, name: &str, attr_type: ColumnType) -> UserType {
1327        UserType {
1328            qname: qn(schema, name),
1329            kind: UserTypeKind::Composite {
1330                attributes: vec![CompositeAttribute {
1331                    name: id("val"),
1332                    ty: attr_type,
1333                    collation: None,
1334                }],
1335            },
1336            comment: None,
1337            owner: None,
1338            grants: vec![],
1339        }
1340    }
1341
1342    fn make_domain_over(schema: &str, name: &str, base: ColumnType) -> UserType {
1343        UserType {
1344            qname: qn(schema, name),
1345            kind: UserTypeKind::Domain {
1346                base,
1347                nullable: true,
1348                default: None,
1349                check_constraints: vec![],
1350                collation: None,
1351            },
1352            comment: None,
1353            owner: None,
1354            grants: vec![],
1355        }
1356    }
1357
1358    #[test]
1359    fn type_nodes_registered() {
1360        let mut c = Catalog::empty();
1361        c.schemas.push(crate::ir::schema::Schema::new(id("app")));
1362        c.types.push(make_enum("app", "status"));
1363        let g = build_create_graph(&c);
1364        // Type depends ONLY on its schema (no other edges for a bare enum).
1365        let deps: Vec<_> = g
1366            .dependencies_of(&NodeId::Type(qn("app", "status")))
1367            .collect();
1368        assert_eq!(deps, vec![&NodeId::Schema(id("app"))]);
1369        // Both the schema node and the type node are registered.
1370        assert_eq!(g.node_count(), 2);
1371    }
1372
1373    #[test]
1374    fn table_depends_on_user_defined_column_type() {
1375        let mut c = Catalog::empty();
1376        c.types.push(make_enum("app", "status"));
1377        c.tables.push(Table {
1378            qname: qn("app", "orders"),
1379            columns: vec![Column {
1380                name: id("status"),
1381                ty: ColumnType::UserDefined(qn("app", "status")),
1382                nullable: false,
1383                default: None,
1384                identity: None,
1385                generated: None,
1386                collation: None,
1387                storage: None,
1388                compression: None,
1389                comment: None,
1390            }],
1391            constraints: vec![],
1392            partition_by: None,
1393            partition_of: None,
1394            comment: None,
1395            owner: None,
1396            grants: vec![],
1397            rls_enabled: false,
1398            rls_forced: false,
1399            policies: vec![],
1400            storage: crate::ir::reloptions::TableStorageOptions::default(),
1401            access_method: None,
1402        });
1403        let g = build_create_graph(&c);
1404        assert!(
1405            has_edge(
1406                &g,
1407                &NodeId::Table(qn("app", "orders")),
1408                &NodeId::Type(qn("app", "status"))
1409            ),
1410            "table must depend on its user-defined column type"
1411        );
1412    }
1413
1414    #[test]
1415    fn composite_depends_on_user_defined_attribute_type() {
1416        let mut c = Catalog::empty();
1417        c.types.push(make_enum("app", "inner_t"));
1418        c.types.push(make_composite_with_attr(
1419            "app",
1420            "outer_t",
1421            ColumnType::UserDefined(qn("app", "inner_t")),
1422        ));
1423        let g = build_create_graph(&c);
1424        assert!(
1425            has_edge(
1426                &g,
1427                &NodeId::Type(qn("app", "outer_t")),
1428                &NodeId::Type(qn("app", "inner_t"))
1429            ),
1430            "composite must depend on the type of its user-defined attribute"
1431        );
1432    }
1433
1434    #[test]
1435    fn domain_depends_on_user_defined_base_type() {
1436        let mut c = Catalog::empty();
1437        c.types.push(make_enum("app", "base_t"));
1438        c.types.push(make_domain_over(
1439            "app",
1440            "derived_t",
1441            ColumnType::UserDefined(qn("app", "base_t")),
1442        ));
1443        let g = build_create_graph(&c);
1444        assert!(
1445            has_edge(
1446                &g,
1447                &NodeId::Type(qn("app", "derived_t")),
1448                &NodeId::Type(qn("app", "base_t"))
1449            ),
1450            "domain must depend on its user-defined base type"
1451        );
1452    }
1453
1454    // ── Range type edge tests ────────────────────────────────────────────────
1455
1456    fn make_range(
1457        schema: &str,
1458        name: &str,
1459        subtype: QualifiedName,
1460        canonical: Option<QualifiedName>,
1461        subtype_diff: Option<QualifiedName>,
1462    ) -> UserType {
1463        UserType {
1464            qname: qn(schema, name),
1465            kind: UserTypeKind::Range {
1466                subtype,
1467                subtype_opclass: None,
1468                collation: None,
1469                canonical,
1470                subtype_diff,
1471                multirange_type_name: None,
1472            },
1473            comment: None,
1474            owner: None,
1475            grants: vec![],
1476        }
1477    }
1478
1479    fn make_function(schema: &str, name: &str) -> crate::ir::function::Function {
1480        use crate::ir::function::{
1481            FunctionLanguage, NormalizedArgTypes, ParallelSafety, ReturnType, SecurityMode,
1482            Volatility,
1483        };
1484        use crate::parse::normalize_body::NormalizedBody;
1485        crate::ir::function::Function {
1486            qname: qn(schema, name),
1487            args: vec![],
1488            arg_types_normalized: NormalizedArgTypes::from_args(&[]),
1489            return_type: ReturnType::Scalar {
1490                ty: ColumnType::Integer,
1491            },
1492            language: FunctionLanguage::Sql,
1493            body: NormalizedBody::empty(),
1494            body_dependencies: vec![],
1495            volatility: Volatility::Volatile,
1496            strict: false,
1497            security: SecurityMode::Invoker,
1498            parallel: ParallelSafety::Unsafe,
1499            leakproof: false,
1500            cost: None,
1501            rows: None,
1502            comment: None,
1503            owner: None,
1504            grants: vec![],
1505        }
1506    }
1507
1508    #[test]
1509    fn range_with_builtin_subtype_adds_no_subtype_edge() {
1510        let mut c = Catalog::empty();
1511        c.schemas.push(crate::ir::schema::Schema::new(id("app")));
1512        c.types.push(make_range(
1513            "app",
1514            "ir",
1515            qn("pg_catalog", "int4"),
1516            None,
1517            None,
1518        ));
1519        let g = build_create_graph(&c);
1520        // Type edges: only schema; no edge to pg_catalog.int4.
1521        let deps: Vec<&NodeId> = g.dependencies_of(&NodeId::Type(qn("app", "ir"))).collect();
1522        assert_eq!(deps, vec![&NodeId::Schema(id("app"))]);
1523    }
1524
1525    #[test]
1526    fn range_with_managed_subtype_adds_type_edge() {
1527        let mut c = Catalog::empty();
1528        c.schemas.push(crate::ir::schema::Schema::new(id("app")));
1529        c.types.push(make_enum("app", "base_t"));
1530        c.types.push(make_range(
1531            "app",
1532            "myrange",
1533            qn("app", "base_t"),
1534            None,
1535            None,
1536        ));
1537        let g = build_create_graph(&c);
1538        assert!(
1539            has_edge(
1540                &g,
1541                &NodeId::Type(qn("app", "myrange")),
1542                &NodeId::Type(qn("app", "base_t"))
1543            ),
1544            "range type must depend on its managed subtype"
1545        );
1546    }
1547
1548    #[test]
1549    fn range_canonical_fn_adds_function_edge() {
1550        let mut c = Catalog::empty();
1551        c.schemas.push(crate::ir::schema::Schema::new(id("app")));
1552        let f = make_function("app", "canon_fn");
1553        let arg_types = f.arg_types_normalized.clone();
1554        c.functions.push(f);
1555        c.types.push(make_range(
1556            "app",
1557            "myrange",
1558            qn("pg_catalog", "int4"),
1559            Some(qn("app", "canon_fn")),
1560            None,
1561        ));
1562        let g = build_create_graph(&c);
1563        assert!(
1564            has_edge(
1565                &g,
1566                &NodeId::Type(qn("app", "myrange")),
1567                &NodeId::Function(qn("app", "canon_fn"), arg_types),
1568            ),
1569            "range type must depend on its canonical function"
1570        );
1571    }
1572
1573    #[test]
1574    fn range_subtype_diff_fn_adds_function_edge() {
1575        let mut c = Catalog::empty();
1576        c.schemas.push(crate::ir::schema::Schema::new(id("app")));
1577        let f = make_function("app", "diff_fn");
1578        let arg_types = f.arg_types_normalized.clone();
1579        c.functions.push(f);
1580        c.types.push(make_range(
1581            "app",
1582            "myrange",
1583            qn("pg_catalog", "int4"),
1584            None,
1585            Some(qn("app", "diff_fn")),
1586        ));
1587        let g = build_create_graph(&c);
1588        assert!(
1589            has_edge(
1590                &g,
1591                &NodeId::Type(qn("app", "myrange")),
1592                &NodeId::Function(qn("app", "diff_fn"), arg_types),
1593            ),
1594            "range type must depend on its subtype_diff function"
1595        );
1596    }
1597
1598    #[test]
1599    fn range_unmanaged_canonical_fn_adds_no_edge() {
1600        // canonical references a function that is NOT in the source catalog.
1601        // No edge should be added (the lint rule would surface this later).
1602        let mut c = Catalog::empty();
1603        c.schemas.push(crate::ir::schema::Schema::new(id("app")));
1604        c.types.push(make_range(
1605            "app",
1606            "myrange",
1607            qn("pg_catalog", "int4"),
1608            Some(qn("app", "unmanaged_fn")),
1609            None,
1610        ));
1611        let g = build_create_graph(&c);
1612        // Only the schema edge.
1613        let deps: Vec<&NodeId> = g
1614            .dependencies_of(&NodeId::Type(qn("app", "myrange")))
1615            .collect();
1616        assert_eq!(deps, vec![&NodeId::Schema(id("app"))]);
1617    }
1618
1619    // ── Collation edge tests ─────────────────────────────────────────────────
1620
1621    use crate::ir::collation::{Collation, CollationProvider};
1622
1623    fn make_collation(schema: &str, name: &str) -> Collation {
1624        Collation {
1625            qname: qn(schema, name),
1626            provider: CollationProvider::Libc,
1627            lc_collate: "C".into(),
1628            lc_ctype: "C".into(),
1629            deterministic: true,
1630            version: None,
1631            owner: None,
1632            comment: None,
1633        }
1634    }
1635
1636    fn make_table_empty(schema: &str, name: &str) -> Table {
1637        Table {
1638            qname: qn(schema, name),
1639            columns: vec![],
1640            constraints: vec![],
1641            partition_by: None,
1642            partition_of: None,
1643            comment: None,
1644            owner: None,
1645            grants: vec![],
1646            rls_enabled: false,
1647            rls_forced: false,
1648            policies: vec![],
1649            storage: crate::ir::reloptions::TableStorageOptions::default(),
1650            access_method: None,
1651        }
1652    }
1653
1654    fn col_text_with_collation(name: &str, collation: QualifiedName) -> Column {
1655        Column {
1656            name: id(name),
1657            ty: ColumnType::Text,
1658            nullable: true,
1659            default: None,
1660            identity: None,
1661            generated: None,
1662            collation: Some(collation),
1663            storage: None,
1664            compression: None,
1665            comment: None,
1666        }
1667    }
1668
1669    fn make_domain_with_collation(schema: &str, name: &str, collation: QualifiedName) -> UserType {
1670        UserType {
1671            qname: qn(schema, name),
1672            kind: UserTypeKind::Domain {
1673                base: ColumnType::Text,
1674                nullable: true,
1675                default: None,
1676                check_constraints: vec![],
1677                collation: Some(collation),
1678            },
1679            comment: None,
1680            owner: None,
1681            grants: vec![],
1682        }
1683    }
1684
1685    fn make_range_with_collation(
1686        schema: &str,
1687        name: &str,
1688        subtype: QualifiedName,
1689        collation: QualifiedName,
1690    ) -> UserType {
1691        UserType {
1692            qname: qn(schema, name),
1693            kind: UserTypeKind::Range {
1694                subtype,
1695                subtype_opclass: None,
1696                collation: Some(collation),
1697                canonical: None,
1698                subtype_diff: None,
1699                multirange_type_name: None,
1700            },
1701            comment: None,
1702            owner: None,
1703            grants: vec![],
1704        }
1705    }
1706
1707    fn make_composite_with_collated_attr(
1708        schema: &str,
1709        name: &str,
1710        collation: QualifiedName,
1711    ) -> UserType {
1712        UserType {
1713            qname: qn(schema, name),
1714            kind: UserTypeKind::Composite {
1715                attributes: vec![CompositeAttribute {
1716                    name: id("val"),
1717                    ty: ColumnType::Text,
1718                    collation: Some(collation),
1719                }],
1720            },
1721            comment: None,
1722            owner: None,
1723            grants: vec![],
1724        }
1725    }
1726
1727    #[test]
1728    fn collation_node_registered_with_schema_edge() {
1729        let mut c = Catalog::empty();
1730        c.schemas.push(crate::ir::schema::Schema::new(id("app")));
1731        c.collations.push(make_collation("app", "ci"));
1732        let g = build_create_graph(&c);
1733        // schema + collation nodes; one structural edge collation → schema.
1734        assert_eq!(g.node_count(), 2);
1735        assert!(
1736            has_edge(
1737                &g,
1738                &NodeId::Collation(qn("app", "ci")),
1739                &NodeId::Schema(id("app")),
1740            ),
1741            "collation must depend on its schema (ensures DROP COLLATION before DROP SCHEMA)"
1742        );
1743    }
1744
1745    #[test]
1746    fn column_with_managed_collation_adds_edge() {
1747        let mut cat = Catalog::empty();
1748        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1749        cat.collations.push(make_collation("app", "ci"));
1750        let mut t = make_table_empty("app", "users");
1751        t.columns
1752            .push(col_text_with_collation("email", qn("app", "ci")));
1753        cat.tables.push(t);
1754        let g = build_create_graph(&cat);
1755        assert!(
1756            has_edge(
1757                &g,
1758                &NodeId::Table(qn("app", "users")),
1759                &NodeId::Collation(qn("app", "ci")),
1760            ),
1761            "table must depend on its column's managed collation"
1762        );
1763    }
1764
1765    #[test]
1766    fn column_with_pg_catalog_collation_no_edge() {
1767        let mut cat = Catalog::empty();
1768        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1769        let mut t = make_table_empty("app", "users");
1770        t.columns
1771            .push(col_text_with_collation("email", qn("pg_catalog", "C")));
1772        cat.tables.push(t);
1773        let g = build_create_graph(&cat);
1774        assert!(
1775            !g.edges().any(|(_, to)| matches!(to, NodeId::Collation(_))),
1776            "no collation edge should be added for pg_catalog.C"
1777        );
1778    }
1779
1780    #[test]
1781    fn column_with_builtin_shortname_collation_no_edge() {
1782        // Even with no schema (or any schema), the builtin shortname `POSIX`
1783        // should be skipped because it's in BUILTIN_COLLATIONS.
1784        let mut cat = Catalog::empty();
1785        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1786        let mut t = make_table_empty("app", "users");
1787        t.columns
1788            .push(col_text_with_collation("email", qn("app", "POSIX")));
1789        cat.tables.push(t);
1790        let g = build_create_graph(&cat);
1791        assert!(!g.edges().any(|(_, to)| matches!(to, NodeId::Collation(_))),);
1792    }
1793
1794    #[test]
1795    fn column_with_unmanaged_collation_no_edge() {
1796        // Source references a collation that isn't in catalog.collations →
1797        // no edge (the lint rule surfaces the drift; here we just don't add
1798        // a phantom edge to a node that doesn't exist).
1799        let mut cat = Catalog::empty();
1800        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1801        let mut t = make_table_empty("app", "users");
1802        t.columns
1803            .push(col_text_with_collation("email", qn("app", "unmanaged")));
1804        cat.tables.push(t);
1805        let g = build_create_graph(&cat);
1806        assert!(!g.edges().any(|(_, to)| matches!(to, NodeId::Collation(_))),);
1807    }
1808
1809    #[test]
1810    fn domain_with_managed_collation_adds_edge() {
1811        let mut cat = Catalog::empty();
1812        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1813        cat.collations.push(make_collation("app", "ci"));
1814        cat.types.push(make_domain_with_collation(
1815            "app",
1816            "email_t",
1817            qn("app", "ci"),
1818        ));
1819        let g = build_create_graph(&cat);
1820        assert!(
1821            has_edge(
1822                &g,
1823                &NodeId::Type(qn("app", "email_t")),
1824                &NodeId::Collation(qn("app", "ci")),
1825            ),
1826            "domain must depend on its managed collation"
1827        );
1828    }
1829
1830    #[test]
1831    fn domain_with_pg_catalog_collation_no_edge() {
1832        let mut cat = Catalog::empty();
1833        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1834        cat.types.push(make_domain_with_collation(
1835            "app",
1836            "email_t",
1837            qn("pg_catalog", "C"),
1838        ));
1839        let g = build_create_graph(&cat);
1840        assert!(!g.edges().any(|(_, to)| matches!(to, NodeId::Collation(_))),);
1841    }
1842
1843    #[test]
1844    fn range_with_managed_collation_adds_edge() {
1845        let mut cat = Catalog::empty();
1846        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1847        cat.collations.push(make_collation("app", "ci"));
1848        cat.types.push(make_range_with_collation(
1849            "app",
1850            "textrange",
1851            qn("pg_catalog", "text"),
1852            qn("app", "ci"),
1853        ));
1854        let g = build_create_graph(&cat);
1855        assert!(
1856            has_edge(
1857                &g,
1858                &NodeId::Type(qn("app", "textrange")),
1859                &NodeId::Collation(qn("app", "ci")),
1860            ),
1861            "range type must depend on its managed collation"
1862        );
1863    }
1864
1865    #[test]
1866    fn range_with_pg_catalog_collation_no_edge() {
1867        let mut cat = Catalog::empty();
1868        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1869        cat.types.push(make_range_with_collation(
1870            "app",
1871            "textrange",
1872            qn("pg_catalog", "text"),
1873            qn("pg_catalog", "C"),
1874        ));
1875        let g = build_create_graph(&cat);
1876        assert!(!g.edges().any(|(_, to)| matches!(to, NodeId::Collation(_))),);
1877    }
1878
1879    #[test]
1880    fn composite_attribute_with_managed_collation_adds_edge() {
1881        let mut cat = Catalog::empty();
1882        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1883        cat.collations.push(make_collation("app", "ci"));
1884        cat.types.push(make_composite_with_collated_attr(
1885            "app",
1886            "addr_t",
1887            qn("app", "ci"),
1888        ));
1889        let g = build_create_graph(&cat);
1890        assert!(
1891            has_edge(
1892                &g,
1893                &NodeId::Type(qn("app", "addr_t")),
1894                &NodeId::Collation(qn("app", "ci")),
1895            ),
1896            "composite type must depend on collation of a collated attribute"
1897        );
1898    }
1899
1900    #[test]
1901    fn composite_attribute_with_pg_catalog_collation_no_edge() {
1902        let mut cat = Catalog::empty();
1903        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1904        cat.types.push(make_composite_with_collated_attr(
1905            "app",
1906            "addr_t",
1907            qn("pg_catalog", "C"),
1908        ));
1909        let g = build_create_graph(&cat);
1910        assert!(!g.edges().any(|(_, to)| matches!(to, NodeId::Collation(_))),);
1911    }
1912
1913    #[test]
1914    fn collation_ordered_before_table_using_it() {
1915        // Topological sort must place the collation before the table that
1916        // references it (since the edge is Table → Collation).
1917        let mut cat = Catalog::empty();
1918        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1919        cat.collations.push(make_collation("app", "ci"));
1920        let mut t = make_table_empty("app", "users");
1921        t.columns
1922            .push(col_text_with_collation("email", qn("app", "ci")));
1923        cat.tables.push(t);
1924        let g = build_create_graph(&cat);
1925        let order = g.topological_sort().expect("no cycle");
1926        let coll_pos = order
1927            .iter()
1928            .position(|n| n == &NodeId::Collation(qn("app", "ci")))
1929            .expect("collation in order");
1930        let table_pos = order
1931            .iter()
1932            .position(|n| n == &NodeId::Table(qn("app", "users")))
1933            .expect("table in order");
1934        assert!(
1935            coll_pos < table_pos,
1936            "collation must be created before the table that uses it"
1937        );
1938    }
1939
1940    #[test]
1941    fn type_create_ordering_respects_edges() {
1942        // derived_t depends on base_t; topological sort must put base_t first.
1943        let mut c = Catalog::empty();
1944        c.types.push(make_enum("app", "base_t"));
1945        c.types.push(make_domain_over(
1946            "app",
1947            "derived_t",
1948            ColumnType::UserDefined(qn("app", "base_t")),
1949        ));
1950        let g = build_create_graph(&c);
1951        let order = g.topological_sort().expect("no cycle expected");
1952        let base_pos = order
1953            .iter()
1954            .position(|n| n == &NodeId::Type(qn("app", "base_t")))
1955            .expect("base_t in order");
1956        let derived_pos = order
1957            .iter()
1958            .position(|n| n == &NodeId::Type(qn("app", "derived_t")))
1959            .expect("derived_t in order");
1960        assert!(base_pos < derived_pos, "base_t must come before derived_t");
1961    }
1962
1963    // ── Event trigger edge tests ─────────────────────────────────────────────
1964
1965    use crate::ir::event_trigger::{EventTrigger, EventTriggerEnabled, EventTriggerEvent};
1966
1967    fn make_event_trigger(name: &str, function: QualifiedName) -> EventTrigger {
1968        EventTrigger {
1969            name: id(name),
1970            event: EventTriggerEvent::DdlCommandEnd,
1971            tag_filter: vec![],
1972            function,
1973            enabled: EventTriggerEnabled::Enabled,
1974            owner: None,
1975            comment: None,
1976        }
1977    }
1978
1979    #[test]
1980    fn event_trigger_node_registered() {
1981        let mut c = Catalog::empty();
1982        let et = make_event_trigger("audit_et", qn("app", "audit_fn"));
1983        c.event_triggers.push(et);
1984        let g = build_create_graph(&c);
1985        assert!(
1986            g.nodes()
1987                .any(|n| n == &NodeId::EventTrigger(id("audit_et"))),
1988            "event trigger node must be registered in the graph"
1989        );
1990    }
1991
1992    #[test]
1993    fn event_trigger_depends_on_managed_function() {
1994        let mut c = Catalog::empty();
1995        let f = make_function("app", "audit_fn");
1996        let arg_types = f.arg_types_normalized.clone();
1997        c.functions.push(f);
1998        let et = make_event_trigger("audit_et", qn("app", "audit_fn"));
1999        c.event_triggers.push(et);
2000        let g = build_create_graph(&c);
2001        assert!(
2002            has_edge(
2003                &g,
2004                &NodeId::EventTrigger(id("audit_et")),
2005                &NodeId::Function(qn("app", "audit_fn"), arg_types),
2006            ),
2007            "event trigger must depend on the function it executes"
2008        );
2009    }
2010
2011    #[test]
2012    fn event_trigger_no_edge_for_unmanaged_function() {
2013        // If the function is not in the catalog, no edge is added.
2014        let mut c = Catalog::empty();
2015        let et = make_event_trigger("audit_et", qn("app", "unmanaged_fn"));
2016        c.event_triggers.push(et);
2017        let g = build_create_graph(&c);
2018        // Node exists but has no dependencies.
2019        assert!(
2020            g.nodes()
2021                .any(|n| n == &NodeId::EventTrigger(id("audit_et"))),
2022            "event trigger node must still be registered even if function is unmanaged"
2023        );
2024        assert!(
2025            g.dependencies_of(&NodeId::EventTrigger(id("audit_et")))
2026                .next()
2027                .is_none(),
2028            "no edge expected for unmanaged function reference"
2029        );
2030    }
2031
2032    // ── Aggregate edge tests ─────────────────────────────────────────────────
2033
2034    use crate::ir::aggregate::Aggregate;
2035    use crate::ir::function::{ArgMode, Function, FunctionArg};
2036
2037    /// Build a managed function with the given positional (IN) argument types.
2038    fn make_function_with_args(schema: &str, name: &str, arg_types: &[ColumnType]) -> Function {
2039        let mut f = make_function(schema, name);
2040        f.args = arg_types
2041            .iter()
2042            .map(|ty| FunctionArg {
2043                name: None,
2044                mode: ArgMode::In,
2045                ty: ty.clone(),
2046                default: None,
2047            })
2048            .collect();
2049        f.arg_types_normalized = NormalizedArgTypes::from_args(&f.args);
2050        f
2051    }
2052
2053    fn make_aggregate(
2054        schema: &str,
2055        name: &str,
2056        arg_types: Vec<ColumnType>,
2057        state_type: ColumnType,
2058        sfunc: QualifiedName,
2059        finalfunc: Option<QualifiedName>,
2060    ) -> Aggregate {
2061        Aggregate {
2062            qname: qn(schema, name),
2063            arg_types,
2064            state_type,
2065            sfunc,
2066            finalfunc,
2067            initcond: None,
2068            owner: None,
2069            comment: None,
2070        }
2071    }
2072
2073    #[test]
2074    fn aggregate_node_registered() {
2075        let mut c = Catalog::empty();
2076        c.aggregates.push(make_aggregate(
2077            "app",
2078            "my_sum",
2079            vec![ColumnType::Integer],
2080            ColumnType::BigInt,
2081            qn("app", "my_sfunc"),
2082            None,
2083        ));
2084        let g = build_create_graph(&c);
2085        let agg_node = NodeId::Aggregate(
2086            qn("app", "my_sum"),
2087            aggregate_arg_types(&[ColumnType::Integer]),
2088        );
2089        assert!(
2090            g.nodes().any(|n| n == &agg_node),
2091            "aggregate node must be registered in the graph"
2092        );
2093    }
2094
2095    #[test]
2096    fn aggregate_depends_on_managed_sfunc() {
2097        let mut c = Catalog::empty();
2098        // sfunc implied signature: (state_type, arg_types…) = (bigint, integer)
2099        let sfunc = make_function_with_args(
2100            "app",
2101            "my_sfunc",
2102            &[ColumnType::BigInt, ColumnType::Integer],
2103        );
2104        let sfunc_norm = sfunc.arg_types_normalized.clone();
2105        c.functions.push(sfunc);
2106        c.aggregates.push(make_aggregate(
2107            "app",
2108            "my_sum",
2109            vec![ColumnType::Integer],
2110            ColumnType::BigInt,
2111            qn("app", "my_sfunc"),
2112            None,
2113        ));
2114        let g = build_create_graph(&c);
2115        let agg_node = NodeId::Aggregate(
2116            qn("app", "my_sum"),
2117            aggregate_arg_types(&[ColumnType::Integer]),
2118        );
2119        assert!(
2120            has_edge(
2121                &g,
2122                &agg_node,
2123                &NodeId::Function(qn("app", "my_sfunc"), sfunc_norm),
2124            ),
2125            "aggregate must depend on its managed state function"
2126        );
2127    }
2128
2129    #[test]
2130    fn aggregate_depends_on_managed_finalfunc() {
2131        let mut c = Catalog::empty();
2132        let sfunc = make_function_with_args(
2133            "app",
2134            "my_sfunc",
2135            &[ColumnType::BigInt, ColumnType::Integer],
2136        );
2137        c.functions.push(sfunc);
2138        // finalfunc implied signature: (state_type) = (bigint)
2139        let finalfunc = make_function_with_args("app", "my_final", &[ColumnType::BigInt]);
2140        let final_norm = finalfunc.arg_types_normalized.clone();
2141        c.functions.push(finalfunc);
2142        c.aggregates.push(make_aggregate(
2143            "app",
2144            "my_sum",
2145            vec![ColumnType::Integer],
2146            ColumnType::BigInt,
2147            qn("app", "my_sfunc"),
2148            Some(qn("app", "my_final")),
2149        ));
2150        let g = build_create_graph(&c);
2151        let agg_node = NodeId::Aggregate(
2152            qn("app", "my_sum"),
2153            aggregate_arg_types(&[ColumnType::Integer]),
2154        );
2155        assert!(
2156            has_edge(
2157                &g,
2158                &agg_node,
2159                &NodeId::Function(qn("app", "my_final"), final_norm),
2160            ),
2161            "aggregate must depend on its managed final function"
2162        );
2163    }
2164
2165    #[test]
2166    fn aggregate_sfunc_overload_resolved_by_signature() {
2167        // Two overloads of `agg_sf`; only the (bigint, integer) one matches the
2168        // aggregate's implied sfunc signature.
2169        let mut c = Catalog::empty();
2170        let wrong = make_function_with_args("app", "agg_sf", &[ColumnType::Text, ColumnType::Text]);
2171        let right =
2172            make_function_with_args("app", "agg_sf", &[ColumnType::BigInt, ColumnType::Integer]);
2173        let right_norm = right.arg_types_normalized.clone();
2174        let wrong_norm = wrong.arg_types_normalized.clone();
2175        c.functions.push(wrong);
2176        c.functions.push(right);
2177        c.aggregates.push(make_aggregate(
2178            "app",
2179            "my_sum",
2180            vec![ColumnType::Integer],
2181            ColumnType::BigInt,
2182            qn("app", "agg_sf"),
2183            None,
2184        ));
2185        let g = build_create_graph(&c);
2186        let agg_node = NodeId::Aggregate(
2187            qn("app", "my_sum"),
2188            aggregate_arg_types(&[ColumnType::Integer]),
2189        );
2190        assert!(
2191            has_edge(
2192                &g,
2193                &agg_node,
2194                &NodeId::Function(qn("app", "agg_sf"), right_norm),
2195            ),
2196            "edge must point at the (bigint, integer) overload"
2197        );
2198        assert!(
2199            !has_edge(
2200                &g,
2201                &agg_node,
2202                &NodeId::Function(qn("app", "agg_sf"), wrong_norm),
2203            ),
2204            "edge must NOT point at the (text, text) overload"
2205        );
2206    }
2207
2208    #[test]
2209    fn aggregate_no_edge_for_unmanaged_sfunc() {
2210        let mut c = Catalog::empty();
2211        c.aggregates.push(make_aggregate(
2212            "app",
2213            "my_sum",
2214            vec![ColumnType::Integer],
2215            ColumnType::BigInt,
2216            qn("app", "unmanaged_sfunc"),
2217            None,
2218        ));
2219        let g = build_create_graph(&c);
2220        let agg_node = NodeId::Aggregate(
2221            qn("app", "my_sum"),
2222            aggregate_arg_types(&[ColumnType::Integer]),
2223        );
2224        assert!(
2225            g.nodes().any(|n| n == &agg_node),
2226            "aggregate node must still be registered even if sfunc is unmanaged"
2227        );
2228        assert!(
2229            g.dependencies_of(&agg_node).next().is_none(),
2230            "no edge expected for unmanaged sfunc reference"
2231        );
2232    }
2233
2234    // ── Cast edge tests ──────────────────────────────────────────────────────
2235
2236    use crate::ir::cast::{Cast, CastContext, CastMethod};
2237
2238    fn make_cast_with_function(
2239        src_schema: &str,
2240        src_name: &str,
2241        tgt_schema: &str,
2242        tgt_name: &str,
2243        fn_schema: &str,
2244        fn_name: &str,
2245        fn_arg_types: Vec<ColumnType>,
2246    ) -> Cast {
2247        Cast {
2248            source: qn(src_schema, src_name),
2249            target: qn(tgt_schema, tgt_name),
2250            method: CastMethod::Function {
2251                name: qn(fn_schema, fn_name),
2252                arg_types: fn_arg_types,
2253            },
2254            context: CastContext::Explicit,
2255            comment: None,
2256        }
2257    }
2258
2259    fn make_cast_binary(
2260        src_schema: &str,
2261        src_name: &str,
2262        tgt_schema: &str,
2263        tgt_name: &str,
2264    ) -> Cast {
2265        Cast {
2266            source: qn(src_schema, src_name),
2267            target: qn(tgt_schema, tgt_name),
2268            method: CastMethod::Binary,
2269            context: CastContext::Implicit,
2270            comment: None,
2271        }
2272    }
2273
2274    #[test]
2275    fn cast_node_registered() {
2276        let mut c = Catalog::empty();
2277        c.casts
2278            .push(make_cast_binary("app", "src_t", "app", "tgt_t"));
2279        let g = build_create_graph(&c);
2280        let cast_node = NodeId::Cast(qn("app", "src_t"), qn("app", "tgt_t"));
2281        assert!(
2282            g.nodes().any(|n| n == &cast_node),
2283            "cast node must be registered in the graph"
2284        );
2285    }
2286
2287    #[test]
2288    fn cast_depends_on_managed_function() {
2289        let mut c = Catalog::empty();
2290        // The conversion function takes (app.src_t) as a single arg.
2291        let func = make_function_with_args(
2292            "app",
2293            "src_to_tgt",
2294            &[ColumnType::UserDefined(qn("app", "src_t"))],
2295        );
2296        let func_norm = func.arg_types_normalized.clone();
2297        c.functions.push(func);
2298        c.casts.push(make_cast_with_function(
2299            "app",
2300            "src_t",
2301            "app",
2302            "tgt_t",
2303            "app",
2304            "src_to_tgt",
2305            vec![ColumnType::UserDefined(qn("app", "src_t"))],
2306        ));
2307        let g = build_create_graph(&c);
2308        let cast_node = NodeId::Cast(qn("app", "src_t"), qn("app", "tgt_t"));
2309        assert!(
2310            has_edge(
2311                &g,
2312                &cast_node,
2313                &NodeId::Function(qn("app", "src_to_tgt"), func_norm),
2314            ),
2315            "cast must depend on its managed conversion function"
2316        );
2317    }
2318
2319    #[test]
2320    fn cast_depends_on_managed_source_and_target_types() {
2321        let mut c = Catalog::empty();
2322        c.types.push(make_enum("app", "src_t"));
2323        c.types.push(make_enum("app", "tgt_t"));
2324        c.casts
2325            .push(make_cast_binary("app", "src_t", "app", "tgt_t"));
2326        let g = build_create_graph(&c);
2327        let cast_node = NodeId::Cast(qn("app", "src_t"), qn("app", "tgt_t"));
2328        assert!(
2329            has_edge(&g, &cast_node, &NodeId::Type(qn("app", "src_t"))),
2330            "cast must depend on the managed source type"
2331        );
2332        assert!(
2333            has_edge(&g, &cast_node, &NodeId::Type(qn("app", "tgt_t"))),
2334            "cast must depend on the managed target type"
2335        );
2336    }
2337
2338    #[test]
2339    fn cast_no_type_edge_for_builtin_target() {
2340        // Source is managed; target is a pg_catalog built-in — no edge for target.
2341        let mut c = Catalog::empty();
2342        c.types.push(make_enum("app", "src_t"));
2343        // Target pg_catalog.text is not in catalog.types → no type edge.
2344        let cast = Cast {
2345            source: qn("app", "src_t"),
2346            target: qn("pg_catalog", "text"),
2347            method: CastMethod::Inout,
2348            context: CastContext::Assignment,
2349            comment: None,
2350        };
2351        c.casts.push(cast);
2352        let g = build_create_graph(&c);
2353        let cast_node = NodeId::Cast(qn("app", "src_t"), qn("pg_catalog", "text"));
2354        // Source type edge present.
2355        assert!(
2356            has_edge(&g, &cast_node, &NodeId::Type(qn("app", "src_t"))),
2357            "cast must depend on the managed source type"
2358        );
2359        // No edge for the built-in target.
2360        assert!(
2361            !has_edge(&g, &cast_node, &NodeId::Type(qn("pg_catalog", "text"))),
2362            "no type edge expected for a built-in (pg_catalog) target type"
2363        );
2364    }
2365
2366    #[test]
2367    fn cast_no_edge_for_unmanaged_function() {
2368        // Conversion function not in catalog → cast node registered but no
2369        // function edge (identical behavior to aggregate unmanaged sfunc).
2370        let mut c = Catalog::empty();
2371        c.casts.push(make_cast_with_function(
2372            "app",
2373            "src_t",
2374            "pg_catalog",
2375            "text",
2376            "app",
2377            "unmanaged_fn",
2378            vec![ColumnType::UserDefined(qn("app", "src_t"))],
2379        ));
2380        let g = build_create_graph(&c);
2381        let cast_node = NodeId::Cast(qn("app", "src_t"), qn("pg_catalog", "text"));
2382        assert!(
2383            g.nodes().any(|n| n == &cast_node),
2384            "cast node must be registered even when function is unmanaged"
2385        );
2386        // The only edge would be to the function; no types are managed here.
2387        assert!(
2388            g.dependencies_of(&cast_node).next().is_none(),
2389            "no edge expected when conversion function is unmanaged"
2390        );
2391    }
2392}