Source code for scim2_sqlalchemy.generation

import hashlib
import re
import uuid
from collections.abc import Callable
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any

from scim2_models import Attribute as SchemaAttribute
from scim2_models import AttributeBinding
from scim2_models import Mutability
from scim2_models import Path
from scim2_models import Required
from scim2_models import ResourceType
from scim2_models import ScimProvider
from scim2_models import Uniqueness
from sqlalchemy import Boolean
from sqlalchemy import Column
from sqlalchemy import DateTime
from sqlalchemy import Float
from sqlalchemy import ForeignKey
from sqlalchemy import Integer
from sqlalchemy import LargeBinary
from sqlalchemy import MetaData
from sqlalchemy import String
from sqlalchemy import Table
from sqlalchemy import Uuid
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import mapped_column
from sqlalchemy.orm import relationship
from sqlalchemy.types import TypeEngine

from .mapping import Attribute
from .mapping import Link
from .mapping import Many
from .mapping import ResourceMapping

_GROUPS = "urn:ietf:params:scim:schemas:core:2.0:User:groups"
_MEMBERS = "urn:ietf:params:scim:schemas:core:2.0:Group:members"

_MAX_NAME_LENGTH = 63
"""The longest identifier PostgreSQL keeps."""

_SQL_TYPES: dict[SchemaAttribute.Type, Callable[[], TypeEngine[Any]]] = {
    SchemaAttribute.Type.string: String,
    SchemaAttribute.Type.reference: String,
    SchemaAttribute.Type.boolean: Boolean,
    SchemaAttribute.Type.integer: Integer,
    SchemaAttribute.Type.decimal: Float,
    SchemaAttribute.Type.date_time: lambda: DateTime(timezone=True),
    SchemaAttribute.Type.binary: LargeBinary,
}


def _snake(name: str) -> str:
    """Return a name in snake case, such as user_name for userName."""
    name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
    return re.sub(r"[^0-9a-zA-Z]+", "_", name).strip("_").lower()


def _fit(name: str) -> str:
    """Shorten a name longer than PostgreSQL keeps, ending it with a hash of the full name."""
    if len(name) <= _MAX_NAME_LENGTH:
        return name
    digest = hashlib.sha256(name.encode()).hexdigest()[:8]
    return f"{name[: _MAX_NAME_LENGTH - 9]}_{digest}"


def _sub_attribute(attribute: Any, name: str) -> Any:
    """Return the sub-attribute of an attribute by its name, or None."""
    return next(
        (sub for sub in attribute.sub_attributes or [] if sub.name == name), None
    )


@dataclass
class _Schema:
    """A schema of a resource type, with the prefixes of the paths and of the columns of its attributes."""

    urn: str
    attributes: list[Any]
    path_prefix: str
    column_prefix: str


@dataclass
class _Forward:
    """An attribute linking to resources, with the association table of each multi-valued link."""

    owner: str
    attribute: Any
    targets: list[str]
    keys: dict[str, str]
    tables: dict[str, Table]
    inverse_keys: dict[str, str]


class _Class:
    """The namespace of a record class being built, and the attributes of its mapping."""

    def __init__(self, name: str, table: str) -> None:
        self.name = name
        self.namespace: dict[str, Any] = {"__tablename__": table}
        self.attributes: list[tuple[str, Callable[[], Any]]] = []

    def add(self, key: str, value: Any, path: str) -> None:
        """Add a column or a relationship to the class, and refuse a name already used."""
        if key in self.namespace:
            raise ValueError(
                f"{path!r} needs the name {key!r}, which {self.name!r} already uses"
            )
        self.namespace[key] = value


class _Generator:
    """Build the tables, the record classes and the mappings of the resource types of a provider."""

    def __init__(
        self,
        provider: ScimProvider,
        metadata: MetaData,
        prefix: str,
        store: Callable[[AttributeBinding], bool | TypeEngine[Any]],
        inverses: Mapping[str, str],
    ) -> None:
        self.provider = provider
        self.metadata = metadata
        self.prefix = prefix
        self.store = store
        self.inverses = inverses
        self.decisions: dict[str, bool | TypeEngine[Any]] = {}
        self.resource_types = {str(rt.name): rt for rt in provider.resource_types}
        self.tables = {
            name: _fit(prefix + _snake(rt.endpoint or name))
            for name, rt in self.resource_types.items()
        }
        self.base: Any = type("Base", (DeclarativeBase,), {"metadata": metadata})
        self.classes: dict[str, Any] = {}
        self.schemas = {
            name: self._schemas(rt) for name, rt in self.resource_types.items()
        }
        self.forwards: dict[str, _Forward] = {}
        self.reverses: dict[str, _Forward] = {}

    def _schemas(self, resource_type: ResourceType) -> list[_Schema]:
        """Return the core schema and the extensions of a resource type."""
        model: Any = self.provider.model_for(resource_type)
        schemas = [_Schema(model.__schema__, model.to_schema().attributes, "", "")]
        for urn, extension in model.get_extension_models().items():
            schema = extension.to_schema()
            schemas.append(
                _Schema(
                    urn,
                    schema.attributes,
                    f"{urn}:",
                    f"{_snake(str(schema.name or extension.__name__))}_",
                )
            )
        return schemas

    def generate(self) -> dict[str, ResourceMapping]:
        """Return the mappings of the resource types, by name."""
        self._decide()
        for name in self.resource_types:
            self._find_forwards(name)
        self._find_inverses()
        built = {name: self._build(name) for name in self.resource_types}
        for record_class in built.values():
            self._make(record_class)
        self.base.registry.configure()
        return {
            name: ResourceMapping(
                self.provider.model_for(self.resource_types[name]),  # type: ignore[arg-type]
                self.classes[name],
                {path: build() for path, build in record_class.attributes},
                version=self.classes[name].meta_version,
            )
            for name, record_class in built.items()
        }

    def _decide(self) -> None:
        """Ask store how to keep each attribute the tables could hold, and check the answers."""
        clear = []
        for name, schemas in self.schemas.items():
            model = self.provider.model_for(self.resource_types[name])
            for schema in schemas:
                for attribute in schema.attributes:
                    urn = f"{schema.urn}:{attribute.name}"
                    if self._linked(attribute):
                        if (
                            attribute.mutability != Mutability.read_only
                            or urn in self.inverses
                        ):
                            self._ask(model, urn, column=False)
                        continue
                    if attribute.mutability == Mutability.read_only:
                        continue
                    if attribute.type != SchemaAttribute.Type.complex:
                        if self._ask_column(model, urn, attribute):
                            clear.append(urn)
                        continue
                    if not self._ask(model, urn, column=False):
                        continue
                    for sub in attribute.sub_attributes or []:
                        sub_urn = f"{urn}.{sub.name}"
                        if (
                            not sub.multi_valued
                            and sub.mutability != Mutability.read_only
                            and self._ask_column(model, sub_urn, sub)
                        ):
                            clear.append(sub_urn)
        if clear:
            raise ValueError(
                f"{', '.join(repr(urn) for urn in clear)} are write-only: store "
                "them with a column type that hashes them, such as Hashed, or "
                "not at all"
            )

    def _ask_column(self, model: Any, urn: str, attribute: Any) -> bool:
        """Ask store how to keep an attribute held by a column, and tell whether it is a write-only string kept in clear text."""
        if attribute.mutability == Mutability.write_only and not self._hashed(
            attribute
        ):
            return False
        decision = self._ask(model, urn, column=True)
        return self._hashed(attribute) and decision is True

    def _ask(self, model: Any, urn: str, *, column: bool) -> bool | TypeEngine[Any]:
        """Ask store how to keep an attribute, and refuse a column type for an attribute without its own column."""
        decision = self.store(Path(urn).resolve(model))  # type: ignore[arg-type]
        if not column and not isinstance(decision, bool):
            raise ValueError(
                f"{urn!r} has no column of its own: store it or not, without a "
                "column type"
            )
        self.decisions[urn] = decision
        return decision

    @staticmethod
    def _hashed(attribute: Any) -> bool:
        """Tell whether an attribute is a single write-only string, whose hash can be stored."""
        return bool(
            attribute.mutability == Mutability.write_only
            and attribute.type == SchemaAttribute.Type.string
            and not attribute.multi_valued
        )

    def _linked(self, attribute: Any) -> list[str]:
        """Return the served resource types an attribute links to, through the referenceTypes of its $ref."""
        if attribute.type != SchemaAttribute.Type.complex:
            return []
        ref = _sub_attribute(attribute, "$ref")
        if ref is None:
            return []
        return [name for name in ref.reference_types or [] if name in self.tables]

    def _find_forwards(self, owner: str) -> None:
        """Find the attributes of a resource type that link to resources, and add their association tables."""
        for schema in self.schemas[owner]:
            for attribute in schema.attributes:
                targets = self._linked(attribute)
                urn = f"{schema.urn}:{attribute.name}"
                if (
                    not targets
                    or attribute.mutability == Mutability.read_only
                    or self.decisions[urn] is False
                ):
                    continue
                base = _snake(schema.column_prefix + attribute.name)
                keys = {
                    target: _fit(
                        base if len(targets) == 1 else f"{base}_{_snake(target)}"
                    )
                    for target in targets
                }
                tables = {}
                if attribute.multi_valued:
                    for target in targets:
                        tables[target] = Table(
                            _fit(
                                f"{self.tables[owner]}_{base}_"
                                f"{self.tables[target].removeprefix(self.prefix)}"
                            ),
                            self.metadata,
                            Column(
                                "resource_id",
                                Uuid,
                                ForeignKey(
                                    f"{self.tables[owner]}.id", ondelete="CASCADE"
                                ),
                                primary_key=True,
                            ),
                            Column(
                                "linked_id",
                                Uuid,
                                ForeignKey(
                                    f"{self.tables[target]}.id", ondelete="CASCADE"
                                ),
                                primary_key=True,
                            ),
                        )
                self.forwards[urn] = _Forward(
                    owner, attribute, targets, keys, tables, {}
                )

    def _find_inverses(self) -> None:
        """Pair each inverse attribute with the Link of the attribute it reverses, that links to its own resource type."""
        for reverse, forward_urn in self.inverses.items():
            forward = self.forwards.get(forward_urn)
            if forward is None or self.decisions.get(reverse) is False:
                continue
            urn, _, name = reverse.rpartition(":")
            for owner, schemas in self.schemas.items():
                for schema in schemas:
                    attribute = next(
                        (a for a in schema.attributes if a.name == name), None
                    )
                    if (
                        schema.urn != urn
                        or attribute is None
                        or not attribute.multi_valued
                        or owner not in forward.tables
                        or forward.owner not in self._linked(attribute)
                    ):
                        continue
                    forward.inverse_keys[owner] = _snake(
                        schema.column_prefix + attribute.name
                    )
                    self.reverses[reverse] = forward

    def _build(self, name: str) -> _Class:
        """Return the class of a resource type, with its columns and relationships, and the attributes of its mapping."""
        record_class = _Class(name, self.tables[name])
        version = mapped_column(Integer, nullable=False)
        for key, value, path in [
            ("id", mapped_column(Uuid, primary_key=True, default=uuid.uuid4), "id"),
            ("external_id", mapped_column(String), "externalId"),
            (
                "meta_created",
                mapped_column(DateTime(timezone=True), nullable=False),
                "meta.created",
            ),
            (
                "meta_last_modified",
                mapped_column(DateTime(timezone=True), nullable=False),
                "meta.lastModified",
            ),
            ("meta_version", version, "meta.version"),
        ]:
            record_class.add(key, value, path)
            if key != "meta_version":
                record_class.attributes.append((path, self._getter(name, key)))
        record_class.namespace["__mapper_args__"] = {"version_id_col": version}

        for schema in self.schemas[name]:
            for attribute in schema.attributes:
                path = f"{schema.path_prefix}{attribute.name}"
                key = _snake(schema.column_prefix + attribute.name)
                attribute_urn = f"{schema.urn}:{attribute.name}"
                if self.decisions.get(attribute_urn) is False:
                    continue
                if attribute_urn in self.forwards:
                    self._link(name, record_class, path, self.forwards[attribute_urn])
                elif attribute_urn in self.reverses:
                    self._reverse(
                        name,
                        record_class,
                        path,
                        attribute,
                        key,
                        self.reverses[attribute_urn],
                    )
                elif attribute.mutability == Mutability.read_only:
                    continue
                elif attribute.type == SchemaAttribute.Type.complex:
                    self._complex(
                        name, record_class, path, attribute, key, attribute_urn
                    )
                elif attribute.multi_valued:
                    self._values(
                        name, record_class, path, attribute, key, attribute_urn
                    )
                else:
                    required = schema.path_prefix == "" and (
                        attribute.required == Required.true
                    )
                    mapped = self._column(
                        name,
                        record_class,
                        path,
                        attribute,
                        key,
                        attribute_urn,
                        nullable=not required,
                        unique=attribute.uniqueness
                        in (Uniqueness.server, Uniqueness.global_),
                    )
                    if mapped is not None:
                        record_class.attributes.append((path, mapped))
        return record_class

    def _make(self, record_class: _Class) -> None:
        """Create the record class from its namespace."""
        self.classes[record_class.name] = type(
            record_class.name, (self.base,), record_class.namespace
        )

    def _getter(self, name: str, key: str) -> Callable[[], Any]:
        """Return a function reading an attribute of a record class once it is created."""
        return lambda: getattr(self.classes[name], key)

    def _class(self, name: str) -> Callable[[], Any]:
        """Return a function reading a record class once it is created."""
        return lambda: self.classes[name]

    def _join(self, name: str, column: Any) -> Callable[[], Any]:
        """Return the condition joining the records of a resource type to a column of an association table."""
        return lambda: self.classes[name].id == column

    def _column(
        self,
        name: str,
        record_class: _Class,
        path: str,
        attribute: Any,
        key: str,
        urn: str,
        *,
        nullable: bool = True,
        unique: bool = False,
    ) -> Callable[[], Any] | None:
        """Add the column of a simple attribute, and return what maps it, or None when it is not stored."""
        decision = self.decisions.get(urn, False)
        if decision is False:
            return None
        key = _fit(key)
        record_class.add(
            key,
            mapped_column(
                self._type(attribute, decision),
                nullable=nullable,
                unique=unique or None,
            ),
            path,
        )
        getter = self._getter(name, key)
        if attribute.mutability != Mutability.write_only:
            return getter
        return lambda: Attribute(getter(), readable=False)

    @staticmethod
    def _type(attribute: Any, decision: bool | TypeEngine[Any]) -> TypeEngine[Any]:
        """Return the column type store gave, or the type matching the attribute."""
        if isinstance(decision, TypeEngine):
            return decision
        return _SQL_TYPES[attribute.type]()

    def _complex(
        self,
        name: str,
        record_class: _Class,
        path: str,
        attribute: Any,
        key: str,
        urn: str,
    ) -> None:
        """Add the columns of the sub-attributes of a single-valued complex attribute, or its child table if it is multi-valued."""
        if attribute.multi_valued:
            self._entries(name, record_class, path, attribute, key, urn)
            return
        for sub in attribute.sub_attributes or []:
            if sub.multi_valued or sub.mutability == Mutability.read_only:
                continue
            sub_path = f"{path}.{sub.name}"
            mapped = self._column(
                name,
                record_class,
                sub_path,
                sub,
                f"{key}_{_snake(sub.name)}",
                f"{urn}.{sub.name}",
            )
            if mapped is not None:
                record_class.attributes.append((sub_path, mapped))

    def _child(
        self, name: str, record_class: _Class, path: str, key: str
    ) -> tuple[_Class, str]:
        """Start the class of the child table of an attribute, and add the relationship to it."""
        key = _fit(key)
        child_name = _fit(f"{self.tables[name]}_{key}")
        child = _Class(child_name, child_name)
        child.add("id", mapped_column(Integer, primary_key=True), path)
        child.add(
            "resource_id",
            mapped_column(
                Uuid,
                ForeignKey(f"{self.tables[name]}.id", ondelete="CASCADE"),
                nullable=False,
            ),
            path,
        )
        record_class.add(
            key,
            relationship(
                self._class(child_name),
                cascade="all, delete-orphan",
                order_by=self._getter(child_name, "id"),
            ),
            path,
        )
        return child, key

    def _entries(
        self,
        name: str,
        record_class: _Class,
        path: str,
        attribute: Any,
        key: str,
        urn: str,
    ) -> None:
        """Add the child table of a multi-valued complex attribute, with a column for each stored sub-attribute."""
        child, key = self._child(name, record_class, path, key)
        columns = {}
        for sub in attribute.sub_attributes or []:
            if sub.multi_valued or sub.mutability == Mutability.read_only:
                continue
            mapped = self._column(
                child.name,
                child,
                f"{path}.{sub.name}",
                sub,
                _snake(sub.name),
                f"{urn}.{sub.name}",
            )
            if mapped is not None:
                columns[sub.name] = mapped
        if not columns:
            del record_class.namespace[key]
            return
        self._make(child)
        getter = self._getter(name, key)
        record_class.attributes.append(
            (
                path,
                lambda: Many(
                    getter(), {sub: mapped() for sub, mapped in columns.items()}
                ),
            )
        )

    def _values(
        self,
        name: str,
        record_class: _Class,
        path: str,
        attribute: Any,
        key: str,
        urn: str,
    ) -> None:
        """Add the child table of a multi-valued simple attribute, with a value column."""
        if attribute.mutability == Mutability.write_only:
            return
        child, key = self._child(name, record_class, path, key)
        child.add(
            "value",
            mapped_column(self._type(attribute, self.decisions[urn]), nullable=False),
            path,
        )
        self._make(child)
        getter = self._getter(name, key)
        value = self._getter(child.name, "value")
        record_class.attributes.append((path, lambda: Many(getter(), value())))

    def _link(
        self, name: str, record_class: _Class, path: str, forward: _Forward
    ) -> None:
        """Add a relationship for each resource type an attribute links to: an association table for a multi-valued attribute, a foreign key otherwise."""
        attribute = forward.attribute
        type_values = (_sub_attribute(attribute, "type") or attribute).canonical_values
        typed = bool(type_values) and all(
            target in type_values for target in forward.targets
        )
        links = []
        for target in forward.targets:
            key = forward.keys[target]
            if attribute.multi_valued:
                table = forward.tables[target]
                value = relationship(
                    self._class(target),
                    secondary=table,
                    primaryjoin=self._join(name, table.c.resource_id),
                    secondaryjoin=self._join(target, table.c.linked_id),
                    back_populates=forward.inverse_keys.get(target),
                )
            else:
                column_key = _fit(f"{key}_id")
                record_class.add(
                    column_key,
                    mapped_column(
                        Uuid,
                        ForeignKey(f"{self.tables[target]}.id", ondelete="SET NULL"),
                    ),
                    path,
                )
                # A resource linking to a resource of its own type, such as a
                # user managed by another user, may form a cycle, which the
                # unit of work only stores with a second UPDATE.
                value = relationship(
                    self._class(target),
                    foreign_keys=self._getter(name, column_key),
                    remote_side=self._getter(target, "id") if target == name else (),
                    post_update=target == name,
                )
            record_class.add(key, value, path)
            links.append((self._getter(name, key), target))
        record_class.attributes.append(
            (
                path,
                lambda: [
                    Link(getter(), target, type=target if typed else None)
                    for getter, target in links
                ],
            )
        )

    def _reverse(
        self,
        name: str,
        record_class: _Class,
        path: str,
        attribute: Any,
        key: str,
        forward: _Forward,
    ) -> None:
        """Add the relationship of an inverse attribute, which reads the association table of the attribute it reverses."""
        table = forward.tables[name]
        record_class.add(
            key,
            relationship(
                self._class(forward.owner),
                secondary=table,
                primaryjoin=self._join(name, table.c.linked_id),
                secondaryjoin=self._join(forward.owner, table.c.resource_id),
                back_populates=forward.keys[name],
            ),
            path,
        )
        type_values = (_sub_attribute(attribute, "type") or attribute).canonical_values
        link_type = "direct" if "direct" in (type_values or []) else None
        getter = self._getter(name, key)
        record_class.attributes.append(
            (path, lambda: Link(getter(), forward.owner, type=link_type))
        )


def _store_all(binding: AttributeBinding) -> bool:
    """Store each attribute with the column type matching it."""
    return True


[docs] def generate_mappings( provider: ScimProvider, metadata: MetaData, *, prefix: str = "", store: Callable[[AttributeBinding], bool | TypeEngine[Any]] = _store_all, inverses: Mapping[str, str] | None = None, ) -> dict[str, ResourceMapping]: """Generate the tables and the mappings that store the resources of a provider. Each resource type gets a table named after its endpoint, such as ``users``. A simple attribute gets a column, a complex attribute holding a single value gets a column for each sub-attribute, and an extension gets columns prefixed by the name of its schema. A multi-valued attribute gets a child table, mapped with :class:`~scim2_sqlalchemy.Many`. A complex attribute whose ``$ref`` links to served resource types gets a :class:`~scim2_sqlalchemy.Link` for each of them. A read-only attribute is not stored, except an inverse: it reads the links of the attribute it reverses, such as ``groups`` of a user, which reads the ``members`` of the groups. A multi-valued sub-attribute is not stored either, nor a write-only attribute other than a single string. A column holds ``NOT NULL`` for a required attribute of the core schema, and ``UNIQUE`` for an attribute unique on the server. The table and column names longer than 63 characters are shortened. ``store`` decides how each attribute is kept. It receives the :class:`~scim2_models.AttributeBinding` of the attribute, and returns: - :data:`True`, to store the attribute with the column type matching it; - :data:`False`, not to store it: the published schemas leave it out; - a column type, such as :class:`~scim2_sqlalchemy.Hashed`, to store it in a column of this type. Only an attribute with a column of its own takes one: a simple attribute, a sub-attribute, or the values of a multi-valued simple attribute. A write-only string, such as a password, is never stored in clear text: ``store`` gives it a column type that hashes it, or does not store it. Pass the mappings to the storage, and create the tables with :meth:`MetaData.create_all <sqlalchemy.schema.MetaData.create_all>` or a migration tool. :param provider: The provider of the server, which gives the resource types and their models. :param metadata: The :class:`~sqlalchemy.schema.MetaData` the tables are added to. :param prefix: A prefix of the names of the tables. :param store: The function deciding how each attribute is kept. By default, every attribute is stored with the column type matching it. :param inverses: The read-only attributes reading the links of another attribute, by URN, in addition to ``groups`` of a user reversing ``members`` of a group. :returns: The mappings, by resource type name. :raises ValueError: When ``store`` keeps a write-only string in clear text, when it gives a column type to an attribute without a column of its own, or when two attributes need the same column name. """ return _Generator( provider, metadata, prefix, store, {_GROUPS: _MEMBERS, **(inverses or {})}, ).generate()