Source code for algomancy_data.relations
"""Relation declarations between tables for cascade-cleanup and FK validation.
A :class:`Relation` captures a foreign-key reference from one table's column(s)
to another table's column(s). Relations are typically derived from
:class:`Column.foreign_key` declarations on user schemas via
:func:`resolve_relations_from_schemas`, but can also be constructed explicitly
to override or extend the schema-derived set.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Dict, List, Sequence, Tuple, Type
from .schema import Schema
[docs]
@dataclass(frozen=True)
class Relation:
"""A foreign-key relation between a child table and a parent table.
Used by :class:`CascadeDropTransformer` (cascade cleanup) and
:class:`ForeignKeyValidator.from_schemas` (FK violation reporting).
Typically built from ``Column.foreign_key`` declarations via
:func:`resolve_relations_from_schemas`, but can be constructed
explicitly to override or extend the schema-derived set.
"""
#: Logical name of the child table (matches ``Schema.file_name()``).
child_table: str
#: Tuple of column names on the child table that form the FK.
child_cols: Tuple[str, ...]
#: Logical name of the parent (referenced) table.
parent_table: str
#: Tuple of column names on the parent table forming the referenced key.
parent_cols: Tuple[str, ...]
#: If True, parents with zero referencing children are dropped.
parent_requires_child: bool = False
#: If True, enables partial-loss cascade when paired with a snapshot.
track_partial_loss: bool = False
@property
def key(self) -> Tuple[str, Tuple[str, ...]]:
"""Identity used when merging relations: ``(child_table, child_cols)``."""
return (self.child_table, self.child_cols)
[docs]
def resolve_relations_from_schemas(
schemas: Sequence[Type[Schema]],
) -> List[Relation]:
"""Build a list of :class:`Relation` objects from FK declarations on schemas.
Walks the ``Column.foreign_key`` declarations on each schema and groups
columns sharing the same parent table into composite FKs. Within a
single schema, all columns with ``foreign_key`` pointing at the same
parent table are collapsed into one composite relation; the referenced
parent columns are taken from each column's ``foreign_key`` tuple in
declaration order.
Args:
schemas: Iterable of ``Schema`` subclasses to walk.
Returns:
List of relations, deduplicated by ``(child_table, child_cols)``.
"""
# Group columns per (child_table, parent_table) into composite FKs.
grouped: Dict[Tuple[str, str], Dict[str, object]] = {}
for schema in schemas:
# Foreign keys are declared on flat (SINGLE) schemas via Column.
# MULTI schemas group columns per sheet and don't participate in
# cascade relations — skip them so callers can pass a mixed list.
if not schema.is_single():
continue
child_table = schema.file_name()
for col_name, col in schema.columns().items():
if col.foreign_key is None:
continue
parent_table, parent_col = col.foreign_key
key = (child_table, parent_table)
entry = grouped.setdefault(
key,
{
"child_cols": [],
"parent_cols": [],
"parent_requires_child": False,
"track_partial_loss": False,
},
)
entry["child_cols"].append(col_name)
entry["parent_cols"].append(parent_col)
if col.parent_requires_child:
entry["parent_requires_child"] = True
if col.track_partial_loss:
entry["track_partial_loss"] = True
relations: List[Relation] = []
for (child_table, parent_table), entry in grouped.items():
relations.append(
Relation(
child_table=child_table,
child_cols=tuple(entry["child_cols"]),
parent_table=parent_table,
parent_cols=tuple(entry["parent_cols"]),
parent_requires_child=bool(entry["parent_requires_child"]),
track_partial_loss=bool(entry["track_partial_loss"]),
)
)
return relations
[docs]
def merge_relations(
base: Sequence[Relation], override: Sequence[Relation]
) -> List[Relation]:
"""Merge two relation lists; ``override`` wins on matching ``(child_table, child_cols)``.
Args:
base: Base relations (typically schema-derived).
override: Override relations (typically user-supplied extras).
Returns:
Combined list. Entries from ``override`` replace entries from ``base``
with the same ``key``; remaining entries from ``override`` are appended.
"""
by_key: Dict[Tuple[str, Tuple[str, ...]], Relation] = {r.key: r for r in base}
for r in override:
by_key[r.key] = r
return list(by_key.values())