Source code for sqlspec.data_dictionary._dependencies

"""Typed dependency graph helpers for data-dictionary DDL ordering."""

from collections import defaultdict
from enum import Enum
from typing import TYPE_CHECKING

from sqlspec.data_dictionary._types import (
    DDLResult,
    DependencyMetadata,
    ForeignKeyMetadata,
    MetadataSource,
    ObjectIdentity,
)
from sqlspec.exceptions import SQLSpecError

if TYPE_CHECKING:
    from collections.abc import Iterable, Sequence
    from typing import Literal

__all__ = (
    "DependencyCycle",
    "DependencyCycleError",
    "DependencyDirection",
    "DependencyEdge",
    "DependencyEdgeKind",
    "DependencySortResult",
    "DependencyStrength",
    "dependency_edges_from_foreign_keys",
    "dependency_edges_from_metadata",
    "sort_ddl_results",
    "sort_dependencies",
)


class DependencyEdgeKind(str, Enum):
    """Kinds of metadata dependency edges used for DDL ordering."""

    CHECK_EXPRESSION = "check_expression"
    DEFAULT_EXPRESSION = "default_expression"
    EXTENSION_OWNED = "extension_owned"
    FOREIGN_KEY = "foreign_key"
    GENERATED_EXPRESSION = "generated_expression"
    INDEX_EXPRESSION = "index_expression"
    MATERIALIZED_VIEW = "materialized_view"
    PARTITION_PARENT = "partition_parent"
    ROLE_GRANT = "role_grant"
    ROUTINE_REFERENCE = "routine_reference"
    SEQUENCE_OWNER = "sequence_owner"
    SEQUENCE_USE = "sequence_use"
    TRIGGER_TARGET = "trigger_target"
    VIEW_REFERENCE = "view_reference"


class DependencyStrength(str, Enum):
    """Strength of a dependency for ordering and diagnostics."""

    HARD = "hard"
    INFORMATIONAL = "informational"
    SOFT = "soft"


class DependencyDirection(str, Enum):
    """Relative direction for create and drop ordering."""

    FROM_BEFORE_TO = "from_before_to"
    TO_BEFORE_FROM = "to_before_from"


class DependencyEdge:
    """Typed dependency edge between two metadata objects.

    ``from_object`` is the dependent object and ``to_object`` is the prerequisite object.
    Creation order normally places ``to_object`` before ``from_object``; drop order reverses it.
    """

    __slots__ = (
        "confidence",
        "create_direction",
        "drop_direction",
        "from_object",
        "kind",
        "source",
        "strength",
        "to_object",
    )

    def __init__(
        self,
        from_object: ObjectIdentity,
        to_object: ObjectIdentity,
        kind: "DependencyEdgeKind | str",
        *,
        strength: "DependencyStrength | str" = DependencyStrength.HARD,
        create_direction: "DependencyDirection | str" = DependencyDirection.TO_BEFORE_FROM,
        drop_direction: "DependencyDirection | str" = DependencyDirection.FROM_BEFORE_TO,
        source: "MetadataSource | str" = MetadataSource.UNKNOWN,
        confidence: float = 1.0,
    ) -> None:
        if confidence < 0.0 or confidence > 1.0:
            msg = "Dependency confidence must be between 0.0 and 1.0"
            raise ValueError(msg)
        self.from_object = from_object
        self.to_object = to_object
        self.kind = _coerce_edge_kind(kind)
        self.strength = _coerce_strength(strength)
        self.create_direction = _coerce_direction(create_direction)
        self.drop_direction = _coerce_direction(drop_direction)
        self.source = _coerce_source(source)
        self.confidence = confidence

    def to_dict(self) -> "dict[str, object]":
        """Serialize the edge with stable string enum values."""
        return {
            "from": self.from_object.to_dict(),
            "to": self.to_object.to_dict(),
            "kind": self.kind.value,
            "strength": self.strength.value,
            "create_direction": self.create_direction.value,
            "drop_direction": self.drop_direction.value,
            "source": self.source.value,
            "confidence": self.confidence,
        }

    def __repr__(self) -> str:
        return (
            f"DependencyEdge(from_object={self.from_object!r}, to_object={self.to_object!r}, "
            f"kind={self.kind!r}, strength={self.strength!r}, create_direction={self.create_direction!r}, "
            f"drop_direction={self.drop_direction!r}, source={self.source!r}, confidence={self.confidence!r})"
        )

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, DependencyEdge):
            return NotImplemented
        return self.to_dict() == other.to_dict()

    def __hash__(self) -> int:
        return hash((
            self.from_object,
            self.to_object,
            self.kind,
            self.strength,
            self.create_direction,
            self.drop_direction,
            self.source,
            self.confidence,
        ))


class DependencyCycle:
    """Named dependency cycle with participating objects and edge diagnostics."""

    __slots__ = ("edges", "objects")

    def __init__(self, objects: "tuple[ObjectIdentity, ...]", edges: "tuple[DependencyEdge, ...]") -> None:
        self.objects = objects
        self.edges = edges

    def describe(self) -> str:
        """Return a compact human-readable cycle diagnostic."""
        if not self.edges:
            return " -> ".join(_format_identity(identity) for identity in self.objects)
        return "; ".join(
            f"{_format_identity(edge.from_object)} --{edge.kind.value}--> {_format_identity(edge.to_object)}"
            for edge in self.edges
        )

    def to_dict(self) -> "dict[str, object]":
        """Serialize the cycle for structured diagnostics."""
        return {
            "objects": tuple(identity.to_dict() for identity in self.objects),
            "edges": tuple(edge.to_dict() for edge in self.edges),
        }

    def __repr__(self) -> str:
        return f"DependencyCycle(objects={self.objects!r}, edges={self.edges!r})"

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, DependencyCycle):
            return NotImplemented
        return self.to_dict() == other.to_dict()

    def __hash__(self) -> int:
        return hash((self.objects, self.edges))


class DependencyCycleError(SQLSpecError):
    """Raised when dependency sorting finds one or more cycles."""

    def __init__(self, cycles: "Sequence[DependencyCycle]") -> None:
        self.cycles = tuple(cycles)
        detail = "Dependency cycle detected"
        if self.cycles:
            detail = f"{detail}: " + " | ".join(cycle.describe() for cycle in self.cycles)
        super().__init__(detail)


class DependencySortResult:
    """Result from dependency graph sorting."""

    __slots__ = ("cycles", "ordered")

    def __init__(self, ordered: "tuple[ObjectIdentity, ...]", cycles: "tuple[DependencyCycle, ...]" = ()) -> None:
        self.ordered = ordered
        self.cycles = cycles

    @property
    def is_acyclic(self) -> bool:
        """Return whether sorting completed without cycles."""
        return not self.cycles

    def raise_for_cycles(self) -> None:
        """Raise a diagnostic exception if cycles were found."""
        if self.cycles:
            raise DependencyCycleError(self.cycles)

    def to_dict(self) -> "dict[str, object]":
        """Serialize the sort result."""
        return {
            "ordered": tuple(identity.to_dict() for identity in self.ordered),
            "cycles": tuple(cycle.to_dict() for cycle in self.cycles),
        }

    def __repr__(self) -> str:
        return f"DependencySortResult(ordered={self.ordered!r}, cycles={self.cycles!r})"

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, DependencySortResult):
            return NotImplemented
        return self.to_dict() == other.to_dict()

    def __hash__(self) -> int:
        return hash((self.ordered, self.cycles))


def dependency_edges_from_foreign_keys(
    foreign_keys: "Iterable[ForeignKeyMetadata]",
    *,
    dialect: str | None = None,
    source: "MetadataSource | str" = MetadataSource.CATALOG,
) -> "tuple[DependencyEdge, ...]":
    """Convert FK metadata into typed dependency edges."""
    metadata_source = _coerce_source(source)
    edges: list[DependencyEdge] = []
    for fk in foreign_keys:
        referenced_schema = fk.referenced_schema or fk.schema
        if fk.table_name == fk.referenced_table and fk.schema == referenced_schema:
            continue
        from_object = ObjectIdentity(fk.table_name, "table", schema=fk.schema, dialect=dialect, source=metadata_source)
        to_object = ObjectIdentity(
            fk.referenced_table, "table", schema=referenced_schema, dialect=dialect, source=metadata_source
        )
        edges.append(
            DependencyEdge(
                from_object,
                to_object,
                DependencyEdgeKind.FOREIGN_KEY,
                strength=DependencyStrength.HARD,
                source=metadata_source,
            )
        )
    return tuple(edges)


def dependency_edges_from_metadata(
    dependencies: "Iterable[DependencyMetadata]",
    *,
    dialect: str | None = None,
    source: "MetadataSource | str | None" = None,
) -> "tuple[DependencyEdge, ...]":
    """Convert rich dependency metadata rows into typed dependency edges.

    Dependency query packs do not all return the same vendor column names. This
    helper accepts SQLSpec's rich ``DependencyMetadata`` wrapper and looks for
    stable attribute aliases such as ``referenced_name``, ``referenced_schema``,
    ``referenced_type``, ``kind``, ``strength``, and ``confidence``.
    """
    edges: list[DependencyEdge] = []
    for dependency in dependencies:
        edge = _dependency_edge_from_metadata(dependency, dialect=dialect, source=source)
        if edge is not None:
            edges.append(edge)
    return tuple(edges)


def sort_ddl_results(
    ddl_results: "Iterable[DDLResult]", *, order: "Literal['create', 'drop']" = "create", raise_on_cycles: bool = True
) -> "tuple[DDLResult, ...]":
    """Sort DDL result objects by their typed dependency edges.

    Args:
        ddl_results: DDL payloads to order.
        order: ``"create"`` or ``"drop"`` dependency direction.
        raise_on_cycles: Raise ``DependencyCycleError`` instead of returning a partial order.

    Returns:
        DDL results in dependency order. Results not represented in the sorted
        graph are appended in their original order.
    """
    result_tuple = tuple(ddl_results)
    result_by_identity = {result.identity: result for result in result_tuple}
    edges = tuple(edge for result in result_tuple for edge in result.dependencies)
    sort_result = sort_dependencies(result_by_identity.keys(), edges, order=order)
    if raise_on_cycles:
        sort_result.raise_for_cycles()

    ordered: list[DDLResult] = []
    seen: set[ObjectIdentity] = set()
    for identity in sort_result.ordered:
        result = result_by_identity.get(identity)
        if result is None:
            continue
        ordered.append(result)
        seen.add(identity)
    ordered.extend(result for result in result_tuple if result.identity not in seen)
    return tuple(ordered)


[docs] def sort_dependencies( objects: "Iterable[ObjectIdentity]", edges: "Iterable[DependencyEdge]", *, order: "Literal['create', 'drop']" = "create", ) -> DependencySortResult: """Sort metadata objects by typed dependency edges. Args: objects: Initial object identities to include in the graph. edges: Dependency edges. Edge endpoints are added to the graph automatically. order: ``"create"`` for dependencies before dependents, ``"drop"`` for dependents before dependencies. Returns: Ordered objects plus cycle diagnostics if cycles prevented a complete order. """ edge_tuple = tuple(edges) nodes = _dedupe_objects(( *tuple(objects), *(edge.from_object for edge in edge_tuple), *(edge.to_object for edge in edge_tuple), )) dependencies: dict[ObjectIdentity, set[ObjectIdentity]] = {node: set() for node in nodes} dependents: dict[ObjectIdentity, set[ObjectIdentity]] = defaultdict(set) for edge in edge_tuple: dependent, dependency = _edge_order(edge, order) dependencies.setdefault(dependent, set()).add(dependency) dependencies.setdefault(dependency, set()) dependents[dependency].add(dependent) ordered: list[ObjectIdentity] = [] ready = [node for node in nodes if not dependencies[node]] ready_seen = set(ready) while ready: node = ready.pop(0) ordered.append(node) for dependent in _ordered_nodes(dependents.get(node, ()), nodes): dependencies[dependent].discard(node) if not dependencies[dependent] and dependent not in ready_seen and dependent not in ordered: ready.append(dependent) ready_seen.add(dependent) unresolved = tuple(node for node in nodes if node not in ordered) if not unresolved: return DependencySortResult(tuple(ordered)) unresolved_set = set(unresolved) cycle_edges = tuple( edge for edge in edge_tuple if edge.from_object in unresolved_set and edge.to_object in unresolved_set ) return DependencySortResult(tuple(ordered), (DependencyCycle(unresolved, cycle_edges),))
def _edge_order(edge: DependencyEdge, order: "Literal['create', 'drop']") -> "tuple[ObjectIdentity, ObjectIdentity]": direction = edge.create_direction if order == "create" else edge.drop_direction if direction == DependencyDirection.TO_BEFORE_FROM: return edge.from_object, edge.to_object return edge.to_object, edge.from_object def _dedupe_objects(objects: "Iterable[ObjectIdentity]") -> "tuple[ObjectIdentity, ...]": seen: set[ObjectIdentity] = set() ordered: list[ObjectIdentity] = [] for identity in objects: if identity in seen: continue ordered.append(identity) seen.add(identity) return tuple(ordered) def _ordered_nodes( nodes: "Iterable[ObjectIdentity]", preferred_order: "Sequence[ObjectIdentity]" ) -> "tuple[ObjectIdentity, ...]": node_set = set(nodes) return tuple(node for node in preferred_order if node in node_set) def _format_identity(identity: ObjectIdentity) -> str: name = identity.name if identity.schema: name = f"{identity.schema}.{name}" if identity.catalog: name = f"{identity.catalog}.{name}" return f"{name} ({identity.object_type})" def _coerce_edge_kind(value: "DependencyEdgeKind | str") -> DependencyEdgeKind: if isinstance(value, DependencyEdgeKind): return value return DependencyEdgeKind(value) def _coerce_strength(value: "DependencyStrength | str") -> DependencyStrength: if isinstance(value, DependencyStrength): return value return DependencyStrength(value) def _coerce_direction(value: "DependencyDirection | str") -> DependencyDirection: if isinstance(value, DependencyDirection): return value return DependencyDirection(value) def _coerce_source(value: "MetadataSource | str") -> MetadataSource: if isinstance(value, MetadataSource): return value return MetadataSource(value) def _dependency_edge_from_metadata( dependency: DependencyMetadata, *, dialect: str | None, source: "MetadataSource | str | None" ) -> "DependencyEdge | None": attributes = dependency.attributes target = _dependency_target_identity(attributes, dialect=dialect, source=source or dependency.source) if target is None: return None edge_source = dependency.source if source is None else _coerce_source(source) return DependencyEdge( dependency.identity, target, _dependency_kind(attributes, dependency.identity.object_type), strength=_dependency_strength(attributes), source=edge_source, confidence=_dependency_confidence(attributes), ) def _dependency_target_identity( attributes: "dict[str, object]", *, dialect: str | None, source: "MetadataSource | str" ) -> "ObjectIdentity | None": identity = _object_identity_attribute(attributes, "referenced_identity", "target_identity", "to_identity") if identity is not None: return identity referenced_name = _string_attribute( attributes, "referenced_name", "referenced_object_name", "target_name", "to_name", "depends_on" ) if referenced_name is None: return None return ObjectIdentity( referenced_name, _string_attribute(attributes, "referenced_type", "referenced_object_type", "target_type", "to_type") or "object", catalog=_string_attribute(attributes, "referenced_catalog", "target_catalog", "to_catalog"), schema=_string_attribute(attributes, "referenced_schema", "target_schema", "to_schema"), dialect=dialect or _string_attribute(attributes, "referenced_dialect", "target_dialect", "to_dialect"), source=_coerce_source(source), ) def _object_identity_attribute(attributes: "dict[str, object]", *names: str) -> "ObjectIdentity | None": for name in names: value = attributes.get(name) if isinstance(value, ObjectIdentity): return value return None def _string_attribute(attributes: "dict[str, object]", *names: str) -> "str | None": for name in names: value = attributes.get(name) if value is None: continue return str(value) return None def _dependency_kind(attributes: "dict[str, object]", from_type: str) -> DependencyEdgeKind: raw_kind = _string_attribute(attributes, "kind", "edge_kind", "dependency_kind", "dependency_type", "type") if raw_kind is not None: normalized = raw_kind.strip().lower().replace("-", "_").replace(" ", "_") aliases = { "fk": DependencyEdgeKind.FOREIGN_KEY, "foreign_key_constraint": DependencyEdgeKind.FOREIGN_KEY, "sequence": DependencyEdgeKind.SEQUENCE_USE, "sequence_dependency": DependencyEdgeKind.SEQUENCE_USE, "view": DependencyEdgeKind.VIEW_REFERENCE, "view_dependency": DependencyEdgeKind.VIEW_REFERENCE, } if normalized in aliases: return aliases[normalized] try: return DependencyEdgeKind(normalized) except ValueError: pass normalized_type = from_type.strip().lower() if normalized_type in {"view", "materialized_view"}: return DependencyEdgeKind.VIEW_REFERENCE if normalized_type == "trigger": return DependencyEdgeKind.TRIGGER_TARGET if normalized_type in {"function", "procedure", "routine"}: return DependencyEdgeKind.ROUTINE_REFERENCE return DependencyEdgeKind.ROUTINE_REFERENCE def _dependency_strength(attributes: "dict[str, object]") -> DependencyStrength: raw_strength = _string_attribute(attributes, "strength", "dependency_strength") if raw_strength is None: return DependencyStrength.HARD return _coerce_strength(raw_strength.strip().lower().replace("-", "_").replace(" ", "_")) def _dependency_confidence(attributes: "dict[str, object]") -> float: value = attributes.get("confidence") if value is None: return 1.0 if isinstance(value, bool): return 1.0 if value else 0.0 if isinstance(value, int | float): return float(value) try: return float(str(value)) except ValueError: return 1.0