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 statistics object (`CREATE STATISTICS schema.name`).
72    Statistic(QualifiedName),
73    /// A user-defined collation (`CREATE COLLATION schema.name`).
74    ///
75    /// The variant lands in v0.3.8 so `ordering::change_node` can route
76    /// collation changes correctly; the actual graph *edges* (column /
77    /// domain / range / composite-attribute → collation) are added in a
78    /// follow-up stage.
79    Collation(QualifiedName),
80}
81
82/// Returns `true` iff `qname` refers to a managed collation in `catalog` —
83/// i.e., a collation we should emit a dependency edge for. Built-in
84/// `pg_catalog` collations (`C`, `POSIX`, `und-x-icu`, …) are skipped.
85fn should_add_collation_edge(qname: &QualifiedName, catalog: &Catalog) -> bool {
86    if qname.schema.as_str() == "pg_catalog" {
87        return false;
88    }
89    let name = qname.name.as_str();
90    if BUILTIN_COLLATIONS.contains(&name) {
91        return false;
92    }
93    catalog.collations.iter().any(|c| &c.qname == qname)
94}
95
96/// Build the dependency graph for `catalog`, used for create/modify ordering.
97///
98/// Topologically sorting this graph yields **dependencies first**: schemas
99/// before tables, tables before indexes, referenced tables before FKs, etc.
100#[allow(clippy::too_many_lines)] // two-phase walk of every catalog object family adding nodes + edges; one place per object.
101pub fn build_create_graph(catalog: &Catalog) -> Graph<NodeId> {
102    let mut g = Graph::new();
103
104    // Phase 1: every IR object gets a node, even if it has no edges.
105    for s in &catalog.schemas {
106        g.add_node(NodeId::Schema(s.name.clone()));
107    }
108    for t in &catalog.tables {
109        g.add_node(NodeId::Table(t.qname.clone()));
110    }
111    for i in &catalog.indexes {
112        g.add_node(NodeId::Index(i.qname.clone()));
113    }
114    for s in &catalog.sequences {
115        g.add_node(NodeId::Sequence(s.qname.clone()));
116    }
117    for t in &catalog.tables {
118        for c in &t.constraints {
119            g.add_node(NodeId::Constraint {
120                table: t.qname.clone(),
121                name: c.qname.name.clone(),
122            });
123        }
124    }
125    // Register view and MV nodes so they participate in topological ordering
126    // and body-dependency edges are rooted correctly.
127    for v in &catalog.views {
128        g.add_node(NodeId::View(v.qname.clone()));
129    }
130    for mv in &catalog.materialized_views {
131        g.add_node(NodeId::Mv(mv.qname.clone()));
132    }
133    // Register user-defined type nodes.
134    for t in &catalog.types {
135        g.add_node(NodeId::Type(t.qname.clone()));
136    }
137    // Register function and procedure nodes.
138    for f in &catalog.functions {
139        g.add_node(NodeId::Function(
140            f.qname.clone(),
141            f.arg_types_normalized.clone(),
142        ));
143    }
144    for p in &catalog.procedures {
145        g.add_node(NodeId::Procedure(p.qname.clone()));
146    }
147    // Register triggers; trigger depends on its target relation and function.
148    for t in &catalog.triggers {
149        g.add_node(NodeId::Trigger(t.qname.clone()));
150        let target_node = if catalog.tables.iter().any(|x| x.qname == t.table) {
151            NodeId::Table(t.table.clone())
152        } else if catalog.views.iter().any(|x| x.qname == t.table) {
153            NodeId::View(t.table.clone())
154        } else if catalog
155            .materialized_views
156            .iter()
157            .any(|x| x.qname == t.table)
158        {
159            NodeId::Mv(t.table.clone())
160        } else {
161            // Unresolved target — the lint rule trigger-references-unmanaged-table
162            // catches this in T9. Skip the edge so the graph builder doesn't
163            // panic on a missing target node.
164            continue;
165        };
166        g.add_edge(NodeId::Trigger(t.qname.clone()), target_node);
167        if let Some(func) = catalog
168            .functions
169            .iter()
170            .find(|f| f.qname == t.function_qname)
171        {
172            g.add_edge(
173                NodeId::Trigger(t.qname.clone()),
174                NodeId::Function(t.function_qname.clone(), func.arg_types_normalized.clone()),
175            );
176        }
177    }
178    // Register extensions; an extension with WITH SCHEMA s depends on the schema.
179    for e in &catalog.extensions {
180        g.add_node(NodeId::Extension(e.name.clone()));
181        if let Some(schema) = &e.schema {
182            g.add_edge(
183                NodeId::Extension(e.name.clone()),
184                NodeId::Schema(schema.clone()),
185            );
186        }
187    }
188    // Register publications. For Selective publications, add edges from each
189    // referenced table and schema to the publication node so publications are
190    // ordered after their dependencies. AllTables publications have no explicit
191    // edges; they are ordered by tier rule.
192    for p in &catalog.publications {
193        let pub_node = NodeId::Publication(p.name.clone());
194        g.add_node(pub_node.clone());
195        if let crate::ir::publication::PublicationScope::Selective { schemas, tables } = &p.scope {
196            for t in tables {
197                g.add_edge(pub_node.clone(), NodeId::Table(t.qname.clone()));
198            }
199            for s in schemas {
200                g.add_edge(pub_node.clone(), NodeId::Schema(s.clone()));
201            }
202        }
203    }
204    // Register subscriptions. Subscriptions cross-reference publications in
205    // a *different* cluster — no local dep edges anchor them. They are
206    // registered as isolated nodes; the tier rule in ordering.rs schedules
207    // them create-last, drop-first via sort_key.
208    for s in &catalog.subscriptions {
209        g.add_node(NodeId::Subscription(s.name.clone()));
210    }
211    // Register statistics; each depends on its target table (must be created
212    // after the table exists and dropped before the table is dropped).
213    for s in &catalog.statistics {
214        let stat_node = NodeId::Statistic(s.qname.clone());
215        g.add_node(stat_node.clone());
216        g.add_edge(stat_node, NodeId::Table(s.target.clone()));
217    }
218    // Register collations. Edges (table/type → collation) are added below in
219    // the column / domain / range / composite-attribute loops.
220    for c in &catalog.collations {
221        g.add_node(NodeId::Collation(c.qname.clone()));
222    }
223
224    // Phase 1b.0: type → schema edges. Every user-defined type lives inside
225    // a schema and must be created after CREATE SCHEMA emits.
226    for t in &catalog.types {
227        g.add_edge(
228            NodeId::Type(t.qname.clone()),
229            NodeId::Schema(t.qname.schema.clone()),
230        );
231    }
232    // Phase 1b.0 (routines): every function/procedure depends on its schema.
233    for f in &catalog.functions {
234        let node = NodeId::Function(f.qname.clone(), f.arg_types_normalized.clone());
235        g.add_edge(node, NodeId::Schema(f.qname.schema.clone()));
236    }
237    for p in &catalog.procedures {
238        g.add_edge(
239            NodeId::Procedure(p.qname.clone()),
240            NodeId::Schema(p.qname.schema.clone()),
241        );
242    }
243
244    // Phase 1b: type → type edges from composite attributes and domain bases.
245    // These edges ensure composites/domains that reference other user-defined
246    // types are created after those types. Range types additionally depend on
247    // their subtype (when managed) and on their canonical / subtype_diff
248    // functions (when managed).
249    for ut in &catalog.types {
250        match &ut.kind {
251            UserTypeKind::Composite { attributes } => {
252                for attr in attributes {
253                    if let ColumnType::UserDefined(dep_qname) = &attr.ty {
254                        g.add_edge(
255                            NodeId::Type(ut.qname.clone()),
256                            NodeId::Type(dep_qname.clone()),
257                        );
258                    }
259                    if let Some(coll_qname) = &attr.collation
260                        && should_add_collation_edge(coll_qname, catalog)
261                    {
262                        g.add_edge(
263                            NodeId::Type(ut.qname.clone()),
264                            NodeId::Collation(coll_qname.clone()),
265                        );
266                    }
267                }
268            }
269            UserTypeKind::Domain {
270                base, collation, ..
271            } => {
272                if let ColumnType::UserDefined(base_qname) = base {
273                    g.add_edge(
274                        NodeId::Type(ut.qname.clone()),
275                        NodeId::Type(base_qname.clone()),
276                    );
277                }
278                if let Some(coll_qname) = collation
279                    && should_add_collation_edge(coll_qname, catalog)
280                {
281                    g.add_edge(
282                        NodeId::Type(ut.qname.clone()),
283                        NodeId::Collation(coll_qname.clone()),
284                    );
285                }
286            }
287            UserTypeKind::Range {
288                subtype,
289                canonical,
290                subtype_diff,
291                collation,
292                ..
293            } => {
294                // Skip built-in subtypes (pg_catalog.* are not user-managed).
295                if subtype.schema.as_str() != "pg_catalog"
296                    && catalog.types.iter().any(|t| &t.qname == subtype)
297                {
298                    g.add_edge(
299                        NodeId::Type(ut.qname.clone()),
300                        NodeId::Type(subtype.clone()),
301                    );
302                }
303                if let Some(fn_qname) = canonical
304                    && let Some(f) = catalog.functions.iter().find(|f| &f.qname == fn_qname)
305                {
306                    g.add_edge(
307                        NodeId::Type(ut.qname.clone()),
308                        NodeId::Function(f.qname.clone(), f.arg_types_normalized.clone()),
309                    );
310                }
311                if let Some(fn_qname) = subtype_diff
312                    && let Some(f) = catalog.functions.iter().find(|f| &f.qname == fn_qname)
313                {
314                    g.add_edge(
315                        NodeId::Type(ut.qname.clone()),
316                        NodeId::Function(f.qname.clone(), f.arg_types_normalized.clone()),
317                    );
318                }
319                if let Some(coll_qname) = collation
320                    && should_add_collation_edge(coll_qname, catalog)
321                {
322                    g.add_edge(
323                        NodeId::Type(ut.qname.clone()),
324                        NodeId::Collation(coll_qname.clone()),
325                    );
326                }
327            }
328            UserTypeKind::Enum { .. } => {}
329        }
330    }
331
332    // Phase 1c: table → type edges from columns with user-defined types.
333    // Tables that reference a user-defined type must be created after the type.
334    // The same loop emits table → collation edges for columns with an explicit
335    // `COLLATE` clause that points at a managed collation.
336    for t in &catalog.tables {
337        for col in &t.columns {
338            if let ColumnType::UserDefined(type_qname) = &col.ty {
339                g.add_edge(
340                    NodeId::Table(t.qname.clone()),
341                    NodeId::Type(type_qname.clone()),
342                );
343            }
344            if let Some(coll_qname) = &col.collation
345                && should_add_collation_edge(coll_qname, catalog)
346            {
347                g.add_edge(
348                    NodeId::Table(t.qname.clone()),
349                    NodeId::Collation(coll_qname.clone()),
350                );
351            }
352        }
353    }
354    // Phase 1c (routines): function/procedure → types referenced in args and
355    // return types. These ensure routines are created after their type deps.
356    for f in &catalog.functions {
357        let node = NodeId::Function(f.qname.clone(), f.arg_types_normalized.clone());
358        for arg in &f.args {
359            if let ColumnType::UserDefined(t_qname) = &arg.ty {
360                g.add_edge(node.clone(), NodeId::Type(t_qname.clone()));
361            }
362        }
363        match &f.return_type {
364            ReturnType::Scalar {
365                ty: ColumnType::UserDefined(t),
366            }
367            | ReturnType::SetOf {
368                ty: ColumnType::UserDefined(t),
369            } => {
370                g.add_edge(node.clone(), NodeId::Type(t.clone()));
371            }
372            ReturnType::Table { columns } => {
373                for col in columns {
374                    if let ColumnType::UserDefined(t) = &col.ty {
375                        g.add_edge(node.clone(), NodeId::Type(t.clone()));
376                    }
377                }
378            }
379            _ => {}
380        }
381    }
382    for p in &catalog.procedures {
383        let node = NodeId::Procedure(p.qname.clone());
384        for arg in &p.args {
385            if let ColumnType::UserDefined(t_qname) = &arg.ty {
386                g.add_edge(node.clone(), NodeId::Type(t_qname.clone()));
387            }
388        }
389    }
390
391    // Phase 2: tables depend on their schema and on any sequence used as a
392    // column default. We add the schema node implicitly via add_edge in case
393    // the caller did not declare it (defensive: source-side parsing typically
394    // does declare every referenced schema, but a hand-built Catalog might not).
395    // Partition children depend on their parent table.
396    for t in &catalog.tables {
397        g.add_edge(
398            NodeId::Table(t.qname.clone()),
399            NodeId::Schema(t.qname.schema.clone()),
400        );
401        for col in &t.columns {
402            if let Some(DefaultExpr::Sequence(seq_qname)) = &col.default {
403                g.add_edge(
404                    NodeId::Table(t.qname.clone()),
405                    NodeId::Sequence(seq_qname.clone()),
406                );
407            }
408        }
409        if let Some(po) = &t.partition_of {
410            // Partition child depends on its parent existing first.
411            g.add_edge(
412                NodeId::Table(t.qname.clone()),
413                NodeId::Table(po.parent.clone()),
414            );
415        }
416    }
417
418    // Phase 2b: views and MVs depend on objects in their body_dependencies.
419    // `body_dependencies` edges use NodeId directly (already the correct
420    // variant); we just re-register each edge into the graph.
421    for v in &catalog.views {
422        for dep in &v.body_dependencies {
423            g.add_edge(dep.from.clone(), dep.to.clone());
424        }
425    }
426    for mv in &catalog.materialized_views {
427        for dep in &mv.body_dependencies {
428            g.add_edge(dep.from.clone(), dep.to.clone());
429        }
430    }
431
432    // Phase 2c: functions and procedures body_dependencies.
433    for f in &catalog.functions {
434        for dep in &f.body_dependencies {
435            g.add_edge(dep.from.clone(), dep.to.clone());
436        }
437    }
438    for p in &catalog.procedures {
439        for dep in &p.body_dependencies {
440            g.add_edge(dep.from.clone(), dep.to.clone());
441        }
442    }
443
444    // Phase 3: indexes depend on their parent (table or MV).
445    // For `IndexParent::Mv`, we use `NodeId::Mv` so the graph correctly
446    // orders CREATE INDEX after CREATE MATERIALIZED VIEW.
447    for i in &catalog.indexes {
448        use crate::ir::index::IndexParent;
449        let parent_node = match &i.on {
450            IndexParent::Table(q) => NodeId::Table(q.clone()),
451            IndexParent::Mv(q) => NodeId::Mv(q.clone()),
452        };
453        g.add_edge(NodeId::Index(i.qname.clone()), parent_node);
454    }
455
456    // Phase 4: constraints depend on their owning table; FKs additionally
457    // depend on the referenced table.
458    //
459    // For FKs we ALSO add a direct table → referenced_table edge. Inline FKs
460    // are emitted as part of `CREATE TABLE`, so the owning table's create
461    // statement requires the referenced table to exist first. Without this
462    // edge, two-table FK cycles never produce a cycle in the graph and the
463    // planner's FK-extraction post-pass would have nothing to detect.
464    for t in &catalog.tables {
465        for c in &t.constraints {
466            let constraint_node = NodeId::Constraint {
467                table: t.qname.clone(),
468                name: c.qname.name.clone(),
469            };
470            g.add_edge(constraint_node.clone(), NodeId::Table(t.qname.clone()));
471            if let ConstraintKind::ForeignKey(fk) = &c.kind {
472                g.add_edge(constraint_node, NodeId::Table(fk.referenced_table.clone()));
473                // Self-referential FKs don't induce a real table-level cycle
474                // (table can be created first, FK satisfied at row time).
475                if fk.referenced_table != t.qname {
476                    g.add_edge(
477                        NodeId::Table(t.qname.clone()),
478                        NodeId::Table(fk.referenced_table.clone()),
479                    );
480                }
481            }
482        }
483    }
484
485    // Phase 5: an `OWNED BY` sequence depends on its owner table.
486    for s in &catalog.sequences {
487        if let Some(owner) = &s.owned_by {
488            g.add_edge(
489                NodeId::Sequence(s.qname.clone()),
490                NodeId::Table(owner.table.clone()),
491            );
492        }
493    }
494
495    g
496}
497
498/// Build the dependency graph for drop-ordering. Same edges as the create
499/// graph; the ordering is reversed at sort time.
500pub fn build_drop_graph(catalog: &Catalog) -> Graph<NodeId> {
501    build_create_graph(catalog)
502}
503
504/// Where a dependency edge came from.
505///
506/// `Structural` edges are derived from the IR shape itself (schema←table,
507/// table←index, FK references, sequence ownership). They exist in v0.1.
508///
509/// `AstExtracted` edges are derived by walking the parsed AST of an object
510/// body (view body, function body, expression-index predicate, etc.).
511/// First produced in v0.2 view sub-spec.
512///
513/// `AstDeclared` edges come from explicit `-- @pgevolve dep:` directives
514/// that close the PL/pgSQL-dynamic-SQL gap (Decision 11). First produced
515/// in v0.2 function sub-spec.
516///
517/// Ordering: `Structural < AstExtracted < AstDeclared` — structural edges
518/// are tie-broken first in the Kahn min-heap to preserve v0.1 ordering.
519#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
520pub enum DepSource {
521    /// Derived from the IR shape; v0.1 default.
522    Structural,
523    /// Walked out of a parsed body AST.
524    AstExtracted,
525    /// Declared by a `-- @pgevolve dep:` directive in source SQL.
526    AstDeclared,
527}
528
529/// An edge in the dependency graph, with provenance metadata.
530///
531/// Convention matches the existing graph: `from` depends on `to`, so `to`
532/// must be created before `from`.
533#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
534pub struct DepEdge {
535    /// Dependent node (the one that needs `to` to exist first).
536    pub from: NodeId,
537    /// Dependency target.
538    pub to: NodeId,
539    /// Provenance of this edge.
540    pub source: DepSource,
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use crate::ir::column::Column;
547    use crate::ir::column_type::ColumnType;
548    use crate::ir::constraint::{
549        Constraint, ConstraintKind, Deferrable, FkMatchType, ForeignKey, ReferentialAction,
550    };
551    use crate::ir::index::{
552        Index, IndexColumn, IndexColumnExpr, IndexMethod, IndexParent, NullsOrder, SortOrder,
553    };
554    use crate::ir::schema::Schema;
555    use crate::ir::sequence::{Sequence, SequenceOwner};
556    use crate::ir::table::Table;
557
558    fn id(s: &str) -> Identifier {
559        Identifier::from_unquoted(s).unwrap()
560    }
561
562    fn qn(schema: &str, name: &str) -> QualifiedName {
563        QualifiedName::new(id(schema), id(name))
564    }
565
566    fn col_id_bigint() -> Column {
567        Column {
568            name: id("id"),
569            ty: ColumnType::BigInt,
570            nullable: false,
571            default: None,
572            identity: None,
573            generated: None,
574            collation: None,
575            storage: None,
576            compression: None,
577            comment: None,
578        }
579    }
580
581    fn col_text_notnull(name: &str) -> Column {
582        Column {
583            name: id(name),
584            ty: ColumnType::Text,
585            nullable: false,
586            default: None,
587            identity: None,
588            generated: None,
589            collation: None,
590            storage: None,
591            compression: None,
592            comment: None,
593        }
594    }
595
596    fn pk(name: &str, cols: &[&str]) -> Constraint {
597        Constraint {
598            qname: qn("app", name),
599            kind: ConstraintKind::PrimaryKey {
600                columns: cols.iter().map(|c| id(c)).collect(),
601                include: vec![],
602            },
603            deferrable: Deferrable::NotDeferrable,
604            comment: None,
605        }
606    }
607
608    fn fk(name: &str, ref_table: QualifiedName) -> Constraint {
609        Constraint {
610            qname: qn("app", name),
611            kind: ConstraintKind::ForeignKey(ForeignKey {
612                columns: vec![id("ref_id")],
613                referenced_table: ref_table,
614                referenced_columns: vec![id("id")],
615                on_update: ReferentialAction::NoAction,
616                on_delete: ReferentialAction::NoAction,
617                match_type: FkMatchType::Simple,
618            }),
619            deferrable: Deferrable::NotDeferrable,
620            comment: None,
621        }
622    }
623
624    fn has_edge(g: &Graph<NodeId>, from: &NodeId, to: &NodeId) -> bool {
625        g.dependencies_of(from).any(|n| n == to)
626    }
627
628    #[test]
629    fn empty_catalog_yields_empty_graph() {
630        let g = build_create_graph(&Catalog::empty());
631        assert_eq!(g.node_count(), 0);
632    }
633
634    #[test]
635    fn every_object_appears_as_a_node() {
636        let mut c = Catalog::empty();
637        c.schemas.push(Schema::new(id("app")));
638        c.tables.push(Table {
639            qname: qn("app", "users"),
640            columns: vec![col_id_bigint()],
641            constraints: vec![pk("users_pkey", &["id"])],
642            partition_by: None,
643            partition_of: None,
644            comment: None,
645            owner: None,
646            grants: vec![],
647            rls_enabled: false,
648            rls_forced: false,
649            policies: vec![],
650            storage: crate::ir::reloptions::TableStorageOptions::default(),
651        });
652        c.indexes.push(Index {
653            qname: qn("app", "users_idx"),
654            on: IndexParent::Table(qn("app", "users")),
655            method: IndexMethod::BTree,
656            columns: vec![IndexColumn {
657                expr: IndexColumnExpr::Column(id("id")),
658                collation: None,
659                opclass: None,
660                sort_order: SortOrder::Asc,
661                nulls_order: NullsOrder::NullsLast,
662            }],
663            include: vec![],
664            unique: false,
665            nulls_not_distinct: false,
666            predicate: None,
667            tablespace: None,
668            comment: None,
669            storage: crate::ir::reloptions::IndexStorageOptions::default(),
670        });
671        c.sequences.push(Sequence {
672            qname: qn("app", "seq1"),
673            data_type: ColumnType::BigInt,
674            start: 1,
675            increment: 1,
676            min_value: None,
677            max_value: None,
678            cache: 1,
679            cycle: false,
680            owned_by: None,
681            comment: None,
682            owner: None,
683            grants: vec![],
684        });
685
686        let g = build_create_graph(&c);
687        // schema + table + index + sequence + constraint = 5
688        assert_eq!(g.node_count(), 5);
689    }
690
691    #[test]
692    fn table_depends_on_its_schema() {
693        let mut c = Catalog::empty();
694        c.schemas.push(Schema::new(id("app")));
695        c.tables.push(Table {
696            qname: qn("app", "users"),
697            columns: vec![col_id_bigint()],
698            constraints: vec![],
699            partition_by: None,
700            partition_of: None,
701            comment: None,
702            owner: None,
703            grants: vec![],
704            rls_enabled: false,
705            rls_forced: false,
706            policies: vec![],
707            storage: crate::ir::reloptions::TableStorageOptions::default(),
708        });
709        let g = build_create_graph(&c);
710        assert!(has_edge(
711            &g,
712            &NodeId::Table(qn("app", "users")),
713            &NodeId::Schema(id("app")),
714        ));
715    }
716
717    #[test]
718    fn index_depends_on_its_table() {
719        let mut c = Catalog::empty();
720        c.tables.push(Table {
721            qname: qn("app", "users"),
722            columns: vec![col_id_bigint()],
723            constraints: vec![],
724            partition_by: None,
725            partition_of: None,
726            comment: None,
727            owner: None,
728            grants: vec![],
729            rls_enabled: false,
730            rls_forced: false,
731            policies: vec![],
732            storage: crate::ir::reloptions::TableStorageOptions::default(),
733        });
734        c.indexes.push(Index {
735            qname: qn("app", "users_idx"),
736            on: IndexParent::Table(qn("app", "users")),
737            method: IndexMethod::BTree,
738            columns: vec![IndexColumn {
739                expr: IndexColumnExpr::Column(id("id")),
740                collation: None,
741                opclass: None,
742                sort_order: SortOrder::Asc,
743                nulls_order: NullsOrder::NullsLast,
744            }],
745            include: vec![],
746            unique: false,
747            nulls_not_distinct: false,
748            predicate: None,
749            tablespace: None,
750            comment: None,
751            storage: crate::ir::reloptions::IndexStorageOptions::default(),
752        });
753        let g = build_create_graph(&c);
754        assert!(has_edge(
755            &g,
756            &NodeId::Index(qn("app", "users_idx")),
757            &NodeId::Table(qn("app", "users")),
758        ));
759    }
760
761    #[test]
762    fn fk_constraint_depends_on_both_endpoints() {
763        let mut c = Catalog::empty();
764        c.tables.push(Table {
765            qname: qn("app", "orgs"),
766            columns: vec![col_id_bigint()],
767            constraints: vec![pk("orgs_pkey", &["id"])],
768            partition_by: None,
769            partition_of: None,
770            comment: None,
771            owner: None,
772            grants: vec![],
773            rls_enabled: false,
774            rls_forced: false,
775            policies: vec![],
776            storage: crate::ir::reloptions::TableStorageOptions::default(),
777        });
778        c.tables.push(Table {
779            qname: qn("app", "users"),
780            columns: vec![
781                col_id_bigint(),
782                Column {
783                    name: id("ref_id"),
784                    ty: ColumnType::BigInt,
785                    nullable: false,
786                    default: None,
787                    identity: None,
788                    generated: None,
789                    collation: None,
790                    storage: None,
791                    compression: None,
792                    comment: None,
793                },
794            ],
795            constraints: vec![fk("users_orgs_fk", qn("app", "orgs"))],
796            partition_by: None,
797            partition_of: None,
798            comment: None,
799            owner: None,
800            grants: vec![],
801            rls_enabled: false,
802            rls_forced: false,
803            policies: vec![],
804            storage: crate::ir::reloptions::TableStorageOptions::default(),
805        });
806        let g = build_create_graph(&c);
807        let fk_node = NodeId::Constraint {
808            table: qn("app", "users"),
809            name: id("users_orgs_fk"),
810        };
811        // Owning-table edge.
812        assert!(has_edge(&g, &fk_node, &NodeId::Table(qn("app", "users"))));
813        // Referenced-table edge.
814        assert!(has_edge(&g, &fk_node, &NodeId::Table(qn("app", "orgs"))));
815    }
816
817    #[test]
818    fn table_depends_on_default_sequence() {
819        let mut c = Catalog::empty();
820        c.sequences.push(Sequence {
821            qname: qn("app", "id_seq"),
822            data_type: ColumnType::BigInt,
823            start: 1,
824            increment: 1,
825            min_value: None,
826            max_value: None,
827            cache: 1,
828            cycle: false,
829            owned_by: None,
830            comment: None,
831            owner: None,
832            grants: vec![],
833        });
834        c.tables.push(Table {
835            qname: qn("app", "users"),
836            columns: vec![Column {
837                name: id("id"),
838                ty: ColumnType::BigInt,
839                nullable: false,
840                default: Some(DefaultExpr::Sequence(qn("app", "id_seq"))),
841                identity: None,
842                generated: None,
843                collation: None,
844                storage: None,
845                compression: None,
846                comment: None,
847            }],
848            constraints: vec![],
849            partition_by: None,
850            partition_of: None,
851            comment: None,
852            owner: None,
853            grants: vec![],
854            rls_enabled: false,
855            rls_forced: false,
856            policies: vec![],
857            storage: crate::ir::reloptions::TableStorageOptions::default(),
858        });
859        let g = build_create_graph(&c);
860        assert!(has_edge(
861            &g,
862            &NodeId::Table(qn("app", "users")),
863            &NodeId::Sequence(qn("app", "id_seq")),
864        ));
865    }
866
867    #[test]
868    fn owned_sequence_depends_on_owner_table() {
869        let mut c = Catalog::empty();
870        c.tables.push(Table {
871            qname: qn("app", "users"),
872            columns: vec![col_id_bigint()],
873            constraints: vec![],
874            partition_by: None,
875            partition_of: None,
876            comment: None,
877            owner: None,
878            grants: vec![],
879            rls_enabled: false,
880            rls_forced: false,
881            policies: vec![],
882            storage: crate::ir::reloptions::TableStorageOptions::default(),
883        });
884        c.sequences.push(Sequence {
885            qname: qn("app", "users_id_seq"),
886            data_type: ColumnType::BigInt,
887            start: 1,
888            increment: 1,
889            min_value: None,
890            max_value: None,
891            cache: 1,
892            cycle: false,
893            owned_by: Some(SequenceOwner {
894                table: qn("app", "users"),
895                column: id("id"),
896            }),
897            comment: None,
898            owner: None,
899            grants: vec![],
900        });
901        let g = build_create_graph(&c);
902        assert!(has_edge(
903            &g,
904            &NodeId::Sequence(qn("app", "users_id_seq")),
905            &NodeId::Table(qn("app", "users")),
906        ));
907    }
908
909    #[test]
910    fn non_fk_constraint_depends_only_on_its_table() {
911        let mut c = Catalog::empty();
912        c.tables.push(Table {
913            qname: qn("app", "users"),
914            columns: vec![col_id_bigint()],
915            constraints: vec![pk("users_pkey", &["id"])],
916            partition_by: None,
917            partition_of: None,
918            comment: None,
919            owner: None,
920            grants: vec![],
921            rls_enabled: false,
922            rls_forced: false,
923            policies: vec![],
924            storage: crate::ir::reloptions::TableStorageOptions::default(),
925        });
926        let g = build_create_graph(&c);
927        let pk_node = NodeId::Constraint {
928            table: qn("app", "users"),
929            name: id("users_pkey"),
930        };
931        let deps: Vec<&NodeId> = g.dependencies_of(&pk_node).collect();
932        assert_eq!(deps, vec![&NodeId::Table(qn("app", "users"))]);
933    }
934
935    #[test]
936    fn drop_graph_matches_create_graph() {
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![pk("users_pkey", &["id"])],
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        });
952        // Same edges; equality is structural via topological output.
953        let cg = build_create_graph(&c);
954        let dg = build_drop_graph(&c);
955        assert_eq!(cg.topological_sort(), dg.topological_sort());
956    }
957
958    #[test]
959    fn fk_cycle_produces_table_level_cycle() {
960        // Two tables with FKs to each other; each FK induces an inline-create
961        // edge table → referenced_table, so the table subgraph cycles.
962        let mut c = Catalog::empty();
963        c.tables.push(Table {
964            qname: qn("app", "a"),
965            columns: vec![
966                col_id_bigint(),
967                Column {
968                    name: id("ref_id"),
969                    ty: ColumnType::BigInt,
970                    nullable: false,
971                    default: None,
972                    identity: None,
973                    generated: None,
974                    collation: None,
975                    storage: None,
976                    compression: None,
977                    comment: None,
978                },
979            ],
980            constraints: vec![pk("a_pk", &["id"]), fk("a_to_b", qn("app", "b"))],
981            partition_by: None,
982            partition_of: None,
983            comment: None,
984            owner: None,
985            grants: vec![],
986            rls_enabled: false,
987            rls_forced: false,
988            policies: vec![],
989            storage: crate::ir::reloptions::TableStorageOptions::default(),
990        });
991        c.tables.push(Table {
992            qname: qn("app", "b"),
993            columns: vec![
994                col_id_bigint(),
995                Column {
996                    name: id("ref_id"),
997                    ty: ColumnType::BigInt,
998                    nullable: false,
999                    default: None,
1000                    identity: None,
1001                    generated: None,
1002                    collation: None,
1003                    storage: None,
1004                    compression: None,
1005                    comment: None,
1006                },
1007            ],
1008            constraints: vec![pk("b_pk", &["id"]), fk("b_to_a", qn("app", "a"))],
1009            partition_by: None,
1010            partition_of: None,
1011            comment: None,
1012            owner: None,
1013            grants: vec![],
1014            rls_enabled: false,
1015            rls_forced: false,
1016            policies: vec![],
1017            storage: crate::ir::reloptions::TableStorageOptions::default(),
1018        });
1019        let g = build_create_graph(&c);
1020        let err = g.topological_sort().unwrap_err();
1021        assert!(err.nodes.contains(&NodeId::Table(qn("app", "a"))));
1022        assert!(err.nodes.contains(&NodeId::Table(qn("app", "b"))));
1023    }
1024
1025    #[test]
1026    fn self_referential_fk_does_not_cycle() {
1027        // A self-referential FK doesn't force the table to depend on itself —
1028        // the rows are inserted after the table exists.
1029        let mut c = Catalog::empty();
1030        c.tables.push(Table {
1031            qname: qn("app", "tree"),
1032            columns: vec![
1033                col_id_bigint(),
1034                Column {
1035                    name: id("ref_id"),
1036                    ty: ColumnType::BigInt,
1037                    nullable: true,
1038                    default: None,
1039                    identity: None,
1040                    generated: None,
1041                    collation: None,
1042                    storage: None,
1043                    compression: None,
1044                    comment: None,
1045                },
1046            ],
1047            constraints: vec![
1048                pk("tree_pk", &["id"]),
1049                fk("tree_parent_fk", qn("app", "tree")),
1050            ],
1051            partition_by: None,
1052            partition_of: None,
1053            comment: None,
1054            owner: None,
1055            grants: vec![],
1056            rls_enabled: false,
1057            rls_forced: false,
1058            policies: vec![],
1059            storage: crate::ir::reloptions::TableStorageOptions::default(),
1060        });
1061        let g = build_create_graph(&c);
1062        assert!(g.topological_sort().is_ok());
1063    }
1064
1065    #[test]
1066    fn partition_child_depends_on_parent() {
1067        // A partition child table depends on its parent table being created first.
1068        use crate::ir::partition::{PartitionBounds, PartitionBy, PartitionOf};
1069        let mut c = Catalog::empty();
1070
1071        // Parent table with PARTITION BY LIST.
1072        let parent = Table {
1073            qname: qn("app", "parent"),
1074            columns: vec![col_id_bigint(), col_text_notnull("status")],
1075            constraints: vec![pk("parent_pkey", &["id"])],
1076            partition_by: Some(PartitionBy {
1077                strategy: crate::ir::partition::PartitionStrategy::List,
1078                columns: vec![crate::ir::partition::PartitionColumn {
1079                    kind: crate::ir::partition::PartitionColumnKind::Column(id("status")),
1080                    collation: None,
1081                    opclass: None,
1082                }],
1083            }),
1084            partition_of: None,
1085            comment: None,
1086            owner: None,
1087            grants: vec![],
1088            rls_enabled: false,
1089            rls_forced: false,
1090            policies: vec![],
1091            storage: crate::ir::reloptions::TableStorageOptions::default(),
1092        };
1093        c.tables.push(parent);
1094
1095        // Child partition table.
1096        let child = Table {
1097            qname: qn("app", "child"),
1098            columns: vec![col_id_bigint(), col_text_notnull("status")],
1099            constraints: vec![],
1100            partition_by: None,
1101            partition_of: Some(PartitionOf {
1102                parent: qn("app", "parent"),
1103                bounds: PartitionBounds::List { values: vec![] },
1104            }),
1105            comment: None,
1106            owner: None,
1107            grants: vec![],
1108            rls_enabled: false,
1109            rls_forced: false,
1110            policies: vec![],
1111            storage: crate::ir::reloptions::TableStorageOptions::default(),
1112        };
1113        c.tables.push(child);
1114
1115        let g = build_create_graph(&c);
1116        assert!(
1117            has_edge(
1118                &g,
1119                &NodeId::Table(qn("app", "child")),
1120                &NodeId::Table(qn("app", "parent")),
1121            ),
1122            "expected child partition → parent table edge"
1123        );
1124    }
1125
1126    // ── User-defined type edge tests ──────────────────────────────────────────
1127
1128    use crate::ir::user_type::{CompositeAttribute, UserType, UserTypeKind};
1129
1130    fn make_enum(schema: &str, name: &str) -> UserType {
1131        UserType {
1132            qname: qn(schema, name),
1133            kind: UserTypeKind::Enum { values: vec![] },
1134            comment: None,
1135            owner: None,
1136            grants: vec![],
1137        }
1138    }
1139
1140    fn make_composite_with_attr(schema: &str, name: &str, attr_type: ColumnType) -> UserType {
1141        UserType {
1142            qname: qn(schema, name),
1143            kind: UserTypeKind::Composite {
1144                attributes: vec![CompositeAttribute {
1145                    name: id("val"),
1146                    ty: attr_type,
1147                    collation: None,
1148                }],
1149            },
1150            comment: None,
1151            owner: None,
1152            grants: vec![],
1153        }
1154    }
1155
1156    fn make_domain_over(schema: &str, name: &str, base: ColumnType) -> UserType {
1157        UserType {
1158            qname: qn(schema, name),
1159            kind: UserTypeKind::Domain {
1160                base,
1161                nullable: true,
1162                default: None,
1163                check_constraints: vec![],
1164                collation: None,
1165            },
1166            comment: None,
1167            owner: None,
1168            grants: vec![],
1169        }
1170    }
1171
1172    #[test]
1173    fn type_nodes_registered() {
1174        let mut c = Catalog::empty();
1175        c.schemas.push(crate::ir::schema::Schema::new(id("app")));
1176        c.types.push(make_enum("app", "status"));
1177        let g = build_create_graph(&c);
1178        // Type depends ONLY on its schema (no other edges for a bare enum).
1179        let deps: Vec<_> = g
1180            .dependencies_of(&NodeId::Type(qn("app", "status")))
1181            .collect();
1182        assert_eq!(deps, vec![&NodeId::Schema(id("app"))]);
1183        // Both the schema node and the type node are registered.
1184        assert_eq!(g.node_count(), 2);
1185    }
1186
1187    #[test]
1188    fn table_depends_on_user_defined_column_type() {
1189        let mut c = Catalog::empty();
1190        c.types.push(make_enum("app", "status"));
1191        c.tables.push(Table {
1192            qname: qn("app", "orders"),
1193            columns: vec![Column {
1194                name: id("status"),
1195                ty: ColumnType::UserDefined(qn("app", "status")),
1196                nullable: false,
1197                default: None,
1198                identity: None,
1199                generated: None,
1200                collation: None,
1201                storage: None,
1202                compression: None,
1203                comment: None,
1204            }],
1205            constraints: vec![],
1206            partition_by: None,
1207            partition_of: None,
1208            comment: None,
1209            owner: None,
1210            grants: vec![],
1211            rls_enabled: false,
1212            rls_forced: false,
1213            policies: vec![],
1214            storage: crate::ir::reloptions::TableStorageOptions::default(),
1215        });
1216        let g = build_create_graph(&c);
1217        assert!(
1218            has_edge(
1219                &g,
1220                &NodeId::Table(qn("app", "orders")),
1221                &NodeId::Type(qn("app", "status"))
1222            ),
1223            "table must depend on its user-defined column type"
1224        );
1225    }
1226
1227    #[test]
1228    fn composite_depends_on_user_defined_attribute_type() {
1229        let mut c = Catalog::empty();
1230        c.types.push(make_enum("app", "inner_t"));
1231        c.types.push(make_composite_with_attr(
1232            "app",
1233            "outer_t",
1234            ColumnType::UserDefined(qn("app", "inner_t")),
1235        ));
1236        let g = build_create_graph(&c);
1237        assert!(
1238            has_edge(
1239                &g,
1240                &NodeId::Type(qn("app", "outer_t")),
1241                &NodeId::Type(qn("app", "inner_t"))
1242            ),
1243            "composite must depend on the type of its user-defined attribute"
1244        );
1245    }
1246
1247    #[test]
1248    fn domain_depends_on_user_defined_base_type() {
1249        let mut c = Catalog::empty();
1250        c.types.push(make_enum("app", "base_t"));
1251        c.types.push(make_domain_over(
1252            "app",
1253            "derived_t",
1254            ColumnType::UserDefined(qn("app", "base_t")),
1255        ));
1256        let g = build_create_graph(&c);
1257        assert!(
1258            has_edge(
1259                &g,
1260                &NodeId::Type(qn("app", "derived_t")),
1261                &NodeId::Type(qn("app", "base_t"))
1262            ),
1263            "domain must depend on its user-defined base type"
1264        );
1265    }
1266
1267    // ── Range type edge tests ────────────────────────────────────────────────
1268
1269    fn make_range(
1270        schema: &str,
1271        name: &str,
1272        subtype: QualifiedName,
1273        canonical: Option<QualifiedName>,
1274        subtype_diff: Option<QualifiedName>,
1275    ) -> UserType {
1276        UserType {
1277            qname: qn(schema, name),
1278            kind: UserTypeKind::Range {
1279                subtype,
1280                subtype_opclass: None,
1281                collation: None,
1282                canonical,
1283                subtype_diff,
1284                multirange_type_name: None,
1285            },
1286            comment: None,
1287            owner: None,
1288            grants: vec![],
1289        }
1290    }
1291
1292    fn make_function(schema: &str, name: &str) -> crate::ir::function::Function {
1293        use crate::ir::function::{
1294            FunctionLanguage, NormalizedArgTypes, ParallelSafety, ReturnType, SecurityMode,
1295            Volatility,
1296        };
1297        use crate::parse::normalize_body::NormalizedBody;
1298        crate::ir::function::Function {
1299            qname: qn(schema, name),
1300            args: vec![],
1301            arg_types_normalized: NormalizedArgTypes::from_args(&[]),
1302            return_type: ReturnType::Scalar {
1303                ty: ColumnType::Integer,
1304            },
1305            language: FunctionLanguage::Sql,
1306            body: NormalizedBody::empty(),
1307            body_dependencies: vec![],
1308            volatility: Volatility::Volatile,
1309            strict: false,
1310            security: SecurityMode::Invoker,
1311            parallel: ParallelSafety::Unsafe,
1312            leakproof: false,
1313            cost: None,
1314            rows: None,
1315            comment: None,
1316            owner: None,
1317            grants: vec![],
1318        }
1319    }
1320
1321    #[test]
1322    fn range_with_builtin_subtype_adds_no_subtype_edge() {
1323        let mut c = Catalog::empty();
1324        c.schemas.push(crate::ir::schema::Schema::new(id("app")));
1325        c.types.push(make_range(
1326            "app",
1327            "ir",
1328            qn("pg_catalog", "int4"),
1329            None,
1330            None,
1331        ));
1332        let g = build_create_graph(&c);
1333        // Type edges: only schema; no edge to pg_catalog.int4.
1334        let deps: Vec<&NodeId> = g.dependencies_of(&NodeId::Type(qn("app", "ir"))).collect();
1335        assert_eq!(deps, vec![&NodeId::Schema(id("app"))]);
1336    }
1337
1338    #[test]
1339    fn range_with_managed_subtype_adds_type_edge() {
1340        let mut c = Catalog::empty();
1341        c.schemas.push(crate::ir::schema::Schema::new(id("app")));
1342        c.types.push(make_enum("app", "base_t"));
1343        c.types.push(make_range(
1344            "app",
1345            "myrange",
1346            qn("app", "base_t"),
1347            None,
1348            None,
1349        ));
1350        let g = build_create_graph(&c);
1351        assert!(
1352            has_edge(
1353                &g,
1354                &NodeId::Type(qn("app", "myrange")),
1355                &NodeId::Type(qn("app", "base_t"))
1356            ),
1357            "range type must depend on its managed subtype"
1358        );
1359    }
1360
1361    #[test]
1362    fn range_canonical_fn_adds_function_edge() {
1363        let mut c = Catalog::empty();
1364        c.schemas.push(crate::ir::schema::Schema::new(id("app")));
1365        let f = make_function("app", "canon_fn");
1366        let arg_types = f.arg_types_normalized.clone();
1367        c.functions.push(f);
1368        c.types.push(make_range(
1369            "app",
1370            "myrange",
1371            qn("pg_catalog", "int4"),
1372            Some(qn("app", "canon_fn")),
1373            None,
1374        ));
1375        let g = build_create_graph(&c);
1376        assert!(
1377            has_edge(
1378                &g,
1379                &NodeId::Type(qn("app", "myrange")),
1380                &NodeId::Function(qn("app", "canon_fn"), arg_types),
1381            ),
1382            "range type must depend on its canonical function"
1383        );
1384    }
1385
1386    #[test]
1387    fn range_subtype_diff_fn_adds_function_edge() {
1388        let mut c = Catalog::empty();
1389        c.schemas.push(crate::ir::schema::Schema::new(id("app")));
1390        let f = make_function("app", "diff_fn");
1391        let arg_types = f.arg_types_normalized.clone();
1392        c.functions.push(f);
1393        c.types.push(make_range(
1394            "app",
1395            "myrange",
1396            qn("pg_catalog", "int4"),
1397            None,
1398            Some(qn("app", "diff_fn")),
1399        ));
1400        let g = build_create_graph(&c);
1401        assert!(
1402            has_edge(
1403                &g,
1404                &NodeId::Type(qn("app", "myrange")),
1405                &NodeId::Function(qn("app", "diff_fn"), arg_types),
1406            ),
1407            "range type must depend on its subtype_diff function"
1408        );
1409    }
1410
1411    #[test]
1412    fn range_unmanaged_canonical_fn_adds_no_edge() {
1413        // canonical references a function that is NOT in the source catalog.
1414        // No edge should be added (the lint rule would surface this later).
1415        let mut c = Catalog::empty();
1416        c.schemas.push(crate::ir::schema::Schema::new(id("app")));
1417        c.types.push(make_range(
1418            "app",
1419            "myrange",
1420            qn("pg_catalog", "int4"),
1421            Some(qn("app", "unmanaged_fn")),
1422            None,
1423        ));
1424        let g = build_create_graph(&c);
1425        // Only the schema edge.
1426        let deps: Vec<&NodeId> = g
1427            .dependencies_of(&NodeId::Type(qn("app", "myrange")))
1428            .collect();
1429        assert_eq!(deps, vec![&NodeId::Schema(id("app"))]);
1430    }
1431
1432    // ── Collation edge tests ─────────────────────────────────────────────────
1433
1434    use crate::ir::collation::{Collation, CollationProvider};
1435
1436    fn make_collation(schema: &str, name: &str) -> Collation {
1437        Collation {
1438            qname: qn(schema, name),
1439            provider: CollationProvider::Libc,
1440            lc_collate: "C".into(),
1441            lc_ctype: "C".into(),
1442            deterministic: true,
1443            version: None,
1444            owner: None,
1445            comment: None,
1446        }
1447    }
1448
1449    fn make_table_empty(schema: &str, name: &str) -> Table {
1450        Table {
1451            qname: qn(schema, name),
1452            columns: vec![],
1453            constraints: vec![],
1454            partition_by: None,
1455            partition_of: None,
1456            comment: None,
1457            owner: None,
1458            grants: vec![],
1459            rls_enabled: false,
1460            rls_forced: false,
1461            policies: vec![],
1462            storage: crate::ir::reloptions::TableStorageOptions::default(),
1463        }
1464    }
1465
1466    fn col_text_with_collation(name: &str, collation: QualifiedName) -> Column {
1467        Column {
1468            name: id(name),
1469            ty: ColumnType::Text,
1470            nullable: true,
1471            default: None,
1472            identity: None,
1473            generated: None,
1474            collation: Some(collation),
1475            storage: None,
1476            compression: None,
1477            comment: None,
1478        }
1479    }
1480
1481    fn make_domain_with_collation(schema: &str, name: &str, collation: QualifiedName) -> UserType {
1482        UserType {
1483            qname: qn(schema, name),
1484            kind: UserTypeKind::Domain {
1485                base: ColumnType::Text,
1486                nullable: true,
1487                default: None,
1488                check_constraints: vec![],
1489                collation: Some(collation),
1490            },
1491            comment: None,
1492            owner: None,
1493            grants: vec![],
1494        }
1495    }
1496
1497    fn make_range_with_collation(
1498        schema: &str,
1499        name: &str,
1500        subtype: QualifiedName,
1501        collation: QualifiedName,
1502    ) -> UserType {
1503        UserType {
1504            qname: qn(schema, name),
1505            kind: UserTypeKind::Range {
1506                subtype,
1507                subtype_opclass: None,
1508                collation: Some(collation),
1509                canonical: None,
1510                subtype_diff: None,
1511                multirange_type_name: None,
1512            },
1513            comment: None,
1514            owner: None,
1515            grants: vec![],
1516        }
1517    }
1518
1519    fn make_composite_with_collated_attr(
1520        schema: &str,
1521        name: &str,
1522        collation: QualifiedName,
1523    ) -> UserType {
1524        UserType {
1525            qname: qn(schema, name),
1526            kind: UserTypeKind::Composite {
1527                attributes: vec![CompositeAttribute {
1528                    name: id("val"),
1529                    ty: ColumnType::Text,
1530                    collation: Some(collation),
1531                }],
1532            },
1533            comment: None,
1534            owner: None,
1535            grants: vec![],
1536        }
1537    }
1538
1539    #[test]
1540    fn collation_node_registered() {
1541        let mut c = Catalog::empty();
1542        c.schemas.push(crate::ir::schema::Schema::new(id("app")));
1543        c.collations.push(make_collation("app", "ci"));
1544        let g = build_create_graph(&c);
1545        // schema + collation nodes; no edges.
1546        assert_eq!(g.node_count(), 2);
1547    }
1548
1549    #[test]
1550    fn column_with_managed_collation_adds_edge() {
1551        let mut cat = Catalog::empty();
1552        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1553        cat.collations.push(make_collation("app", "ci"));
1554        let mut t = make_table_empty("app", "users");
1555        t.columns
1556            .push(col_text_with_collation("email", qn("app", "ci")));
1557        cat.tables.push(t);
1558        let g = build_create_graph(&cat);
1559        assert!(
1560            has_edge(
1561                &g,
1562                &NodeId::Table(qn("app", "users")),
1563                &NodeId::Collation(qn("app", "ci")),
1564            ),
1565            "table must depend on its column's managed collation"
1566        );
1567    }
1568
1569    #[test]
1570    fn column_with_pg_catalog_collation_no_edge() {
1571        let mut cat = Catalog::empty();
1572        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1573        let mut t = make_table_empty("app", "users");
1574        t.columns
1575            .push(col_text_with_collation("email", qn("pg_catalog", "C")));
1576        cat.tables.push(t);
1577        let g = build_create_graph(&cat);
1578        assert!(
1579            !g.edges().any(|(_, to)| matches!(to, NodeId::Collation(_))),
1580            "no collation edge should be added for pg_catalog.C"
1581        );
1582    }
1583
1584    #[test]
1585    fn column_with_builtin_shortname_collation_no_edge() {
1586        // Even with no schema (or any schema), the builtin shortname `POSIX`
1587        // should be skipped because it's in BUILTIN_COLLATIONS.
1588        let mut cat = Catalog::empty();
1589        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1590        let mut t = make_table_empty("app", "users");
1591        t.columns
1592            .push(col_text_with_collation("email", qn("app", "POSIX")));
1593        cat.tables.push(t);
1594        let g = build_create_graph(&cat);
1595        assert!(!g.edges().any(|(_, to)| matches!(to, NodeId::Collation(_))),);
1596    }
1597
1598    #[test]
1599    fn column_with_unmanaged_collation_no_edge() {
1600        // Source references a collation that isn't in catalog.collations →
1601        // no edge (the lint rule surfaces the drift; here we just don't add
1602        // a phantom edge to a node that doesn't exist).
1603        let mut cat = Catalog::empty();
1604        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1605        let mut t = make_table_empty("app", "users");
1606        t.columns
1607            .push(col_text_with_collation("email", qn("app", "unmanaged")));
1608        cat.tables.push(t);
1609        let g = build_create_graph(&cat);
1610        assert!(!g.edges().any(|(_, to)| matches!(to, NodeId::Collation(_))),);
1611    }
1612
1613    #[test]
1614    fn domain_with_managed_collation_adds_edge() {
1615        let mut cat = Catalog::empty();
1616        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1617        cat.collations.push(make_collation("app", "ci"));
1618        cat.types.push(make_domain_with_collation(
1619            "app",
1620            "email_t",
1621            qn("app", "ci"),
1622        ));
1623        let g = build_create_graph(&cat);
1624        assert!(
1625            has_edge(
1626                &g,
1627                &NodeId::Type(qn("app", "email_t")),
1628                &NodeId::Collation(qn("app", "ci")),
1629            ),
1630            "domain must depend on its managed collation"
1631        );
1632    }
1633
1634    #[test]
1635    fn domain_with_pg_catalog_collation_no_edge() {
1636        let mut cat = Catalog::empty();
1637        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1638        cat.types.push(make_domain_with_collation(
1639            "app",
1640            "email_t",
1641            qn("pg_catalog", "C"),
1642        ));
1643        let g = build_create_graph(&cat);
1644        assert!(!g.edges().any(|(_, to)| matches!(to, NodeId::Collation(_))),);
1645    }
1646
1647    #[test]
1648    fn range_with_managed_collation_adds_edge() {
1649        let mut cat = Catalog::empty();
1650        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1651        cat.collations.push(make_collation("app", "ci"));
1652        cat.types.push(make_range_with_collation(
1653            "app",
1654            "textrange",
1655            qn("pg_catalog", "text"),
1656            qn("app", "ci"),
1657        ));
1658        let g = build_create_graph(&cat);
1659        assert!(
1660            has_edge(
1661                &g,
1662                &NodeId::Type(qn("app", "textrange")),
1663                &NodeId::Collation(qn("app", "ci")),
1664            ),
1665            "range type must depend on its managed collation"
1666        );
1667    }
1668
1669    #[test]
1670    fn range_with_pg_catalog_collation_no_edge() {
1671        let mut cat = Catalog::empty();
1672        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1673        cat.types.push(make_range_with_collation(
1674            "app",
1675            "textrange",
1676            qn("pg_catalog", "text"),
1677            qn("pg_catalog", "C"),
1678        ));
1679        let g = build_create_graph(&cat);
1680        assert!(!g.edges().any(|(_, to)| matches!(to, NodeId::Collation(_))),);
1681    }
1682
1683    #[test]
1684    fn composite_attribute_with_managed_collation_adds_edge() {
1685        let mut cat = Catalog::empty();
1686        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1687        cat.collations.push(make_collation("app", "ci"));
1688        cat.types.push(make_composite_with_collated_attr(
1689            "app",
1690            "addr_t",
1691            qn("app", "ci"),
1692        ));
1693        let g = build_create_graph(&cat);
1694        assert!(
1695            has_edge(
1696                &g,
1697                &NodeId::Type(qn("app", "addr_t")),
1698                &NodeId::Collation(qn("app", "ci")),
1699            ),
1700            "composite type must depend on collation of a collated attribute"
1701        );
1702    }
1703
1704    #[test]
1705    fn composite_attribute_with_pg_catalog_collation_no_edge() {
1706        let mut cat = Catalog::empty();
1707        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1708        cat.types.push(make_composite_with_collated_attr(
1709            "app",
1710            "addr_t",
1711            qn("pg_catalog", "C"),
1712        ));
1713        let g = build_create_graph(&cat);
1714        assert!(!g.edges().any(|(_, to)| matches!(to, NodeId::Collation(_))),);
1715    }
1716
1717    #[test]
1718    fn collation_ordered_before_table_using_it() {
1719        // Topological sort must place the collation before the table that
1720        // references it (since the edge is Table → Collation).
1721        let mut cat = Catalog::empty();
1722        cat.schemas.push(crate::ir::schema::Schema::new(id("app")));
1723        cat.collations.push(make_collation("app", "ci"));
1724        let mut t = make_table_empty("app", "users");
1725        t.columns
1726            .push(col_text_with_collation("email", qn("app", "ci")));
1727        cat.tables.push(t);
1728        let g = build_create_graph(&cat);
1729        let order = g.topological_sort().expect("no cycle");
1730        let coll_pos = order
1731            .iter()
1732            .position(|n| n == &NodeId::Collation(qn("app", "ci")))
1733            .expect("collation in order");
1734        let table_pos = order
1735            .iter()
1736            .position(|n| n == &NodeId::Table(qn("app", "users")))
1737            .expect("table in order");
1738        assert!(
1739            coll_pos < table_pos,
1740            "collation must be created before the table that uses it"
1741        );
1742    }
1743
1744    #[test]
1745    fn type_create_ordering_respects_edges() {
1746        // derived_t depends on base_t; topological sort must put base_t first.
1747        let mut c = Catalog::empty();
1748        c.types.push(make_enum("app", "base_t"));
1749        c.types.push(make_domain_over(
1750            "app",
1751            "derived_t",
1752            ColumnType::UserDefined(qn("app", "base_t")),
1753        ));
1754        let g = build_create_graph(&c);
1755        let order = g.topological_sort().expect("no cycle expected");
1756        let base_pos = order
1757            .iter()
1758            .position(|n| n == &NodeId::Type(qn("app", "base_t")))
1759            .expect("base_t in order");
1760        let derived_pos = order
1761            .iter()
1762            .position(|n| n == &NodeId::Type(qn("app", "derived_t")))
1763            .expect("derived_t in order");
1764        assert!(base_pos < derived_pos, "base_t must come before derived_t");
1765    }
1766}