from enum import Enum
from typing import Tuple, Union
# CardinalityLiteral = Literal["1:1", "1:N", "N:1", "M:N"]
CardinalityT = Union[str, "Cardinality"]
[docs]class Cardinality(Enum):
"""Enumeration type for cardinality relationships.
Cardinalities are comparable using numerical operators, and can be thought of as comparing "preciseness". The less
ambiguity there is for a given cardinality, the smaller it is in comparison to the others. The hierarchy is given by
``1:1 < 1:N = N:1 < M:N``. Note that ``1:N`` and ``N:1`` are considered equally precise.
Examples:
Comparing cardinalities
>>> from rics.cardinality import Cardinality
>>> Cardinality.ManyToOne
<Cardinality.ManyToOne: 'N:1'>
>>> Cardinality.OneToOne
<Cardinality.OneToOne: '1:1'>
>>> Cardinality.ManyToOne < Cardinality.OneToOne
False
"""
OneToOne = "1:1"
OneToMany = "1:N"
ManyToOne = "N:1"
ManyToMany = "M:N"
@property
def many_left(self) -> bool:
"""Many-relationship on the right, True for ``N:1`` and ``M:N``."""
return self == Cardinality.ManyToMany or self == Cardinality.ManyToOne # pragma: no cover
@property
def many_right(self) -> bool:
"""Many-relationship on the right, True for ``1:N`` and ``M:N``."""
return self == Cardinality.ManyToMany or self == Cardinality.OneToMany # pragma: no cover
@property
def one_left(self) -> bool:
"""One-relationship on the right, True for ``1:1`` and ``1:N``."""
return not self.many_left # pragma: no cover
@property
def one_right(self) -> bool:
"""One-relationship on the right, True for ``1:1`` and ``N:1``."""
return not self.many_right # pragma: no cover
@property
def inverse(self) -> "Cardinality":
"""Inverse cardinality. For symmetric cardinalities, ``self.inverse == self``.
Returns:
Inverse cardinality.
See Also:
:attr:`symmetric`
"""
if self == Cardinality.OneToMany:
return Cardinality.ManyToOne
if self == Cardinality.ManyToOne:
return Cardinality.OneToMany
return self
@property
def symmetric(self) -> bool:
"""Symmetry flag. For symmetric cardinalities, ``self.inverse == self``.
Returns:
Symmetry flag.
See Also:
:attr:`inverse`
"""
return self == Cardinality.OneToOne or self == Cardinality.ManyToMany
def __ge__(self, other: "Cardinality") -> bool:
"""Equivalent to :meth:`set.issuperset`."""
return _is_superset(self, other)
def __lt__(self, other: "Cardinality") -> bool:
return not self >= other
[docs] @classmethod
def from_counts(cls, left_count: int, right_count: int) -> "Cardinality":
"""Derive a `Cardinality` from counts.
Args:
left_count: Number of elements on the left-hand side.
right_count: Number of elements on the right-hand side.
Returns:
A :class:`Cardinality`.
Raises:
ValueError: For counts < 1.
"""
return _from_counts(left_count, right_count)
[docs] @classmethod
def parse(cls, arg: CardinalityT, strict: bool = False) -> "Cardinality":
"""Convert to cardinality.
Args:
arg: Argument to parse.
strict: If True, `arg` must match exactly when it is given as a string.
Returns:
A :class:`Cardinality`.
Raises:
ValueError: If the argument could not be converted.
"""
return arg if isinstance(arg, Cardinality) else _from_generous_string(arg, strict)
########################################################################################################################
# Supporting functions
#
# Would rather have this in a "friend module", but that's not practical (before 3.10?)
########################################################################################################################
def _parsing_failure_message(arg: str, strict: bool) -> str:
options = tuple([c.value for c in Cardinality])
alternatively = tuple([c.name for c in Cardinality])
strict_hint = "."
if strict:
try:
strict = False
Cardinality.parse(arg, strict=strict)
strict_hint = f". Hint: set {strict=} to allow this input."
except ValueError:
pass
return f"Could not convert {arg=} to Cardinality{strict_hint} Correct input {options=} or {repr(alternatively)}"
_MATRIX = (
(Cardinality.ManyToMany, Cardinality.ManyToOne),
(Cardinality.OneToMany, Cardinality.OneToOne),
)
def _is_superset(c0: Cardinality, c1: Cardinality) -> bool:
if c0 == c1:
return True
c0_i, c0_j = _pos(c0)
c1_i, c1_j = _pos(c1)
return c0_i <= c1_i and c0_j <= c1_j
def _pos(cardinality: Cardinality) -> Tuple[int, int]:
for i in range(2):
for j in range(2):
if _MATRIX[i][j] == cardinality:
return i, j
raise AssertionError("This should be impossible.")
def _from_counts(left_count: int, right_count: int) -> Cardinality:
if left_count < 1:
raise ValueError(f"{left_count=} < 1")
if right_count < 1:
raise ValueError(f"{right_count=} < 1")
one_left = left_count == 1
one_right = right_count == 1
return _MATRIX[int(one_left)][int(one_right)]
def _from_generous_string(s: str, strict: bool) -> Cardinality:
if not strict:
s = s.strip().upper().replace("-", ":", 1).replace("*", "N", 2)
if s == "N:N":
s = "M:N"
for c in Cardinality:
if c.value == s:
return c
raise ValueError(_parsing_failure_message(s, strict))