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