Source code for scim2_sqlalchemy.mapping

from collections.abc import Iterable
from collections.abc import Iterator
from collections.abc import Mapping
from dataclasses import dataclass
from dataclasses import field
from enum import Enum
from inspect import isclass
from typing import Any

from pydantic import BaseModel
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 Resource
from scim2_models import ResponseParameters
from scim2_models import Schema
from scim2_models import Uniqueness
from sqlalchemy import ColumnElement
from sqlalchemy import Enum as SqlEnum
from sqlalchemy import String
from sqlalchemy import cast
from sqlalchemy import inspect
from sqlalchemy import literal
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import InstrumentedAttribute
from sqlalchemy.orm import Mapper
from sqlalchemy.orm import RelationshipProperty
from sqlalchemy.orm import strategy_options


class _Storage(Enum):
    """How a mapping stores an attribute.

    A derived attribute is read from another resource, such as the display of
    a member, so writing it changes nothing. A ref is the $ref of a link,
    built from its value: clients may send it, and writing it changes nothing
    either. A link type is the type a Link gives its entries: clients may send
    it, and the storage checks it against the linked resource.
    """

    written = "written"
    read_only = "read only"
    derived = "derived"
    ref = "ref"
    link_type = "link type"


def _holds_text(expression: Any) -> bool:
    try:
        return expression.type.python_type is str
    except NotImplementedError:
        return True


[docs] class Attribute: """A column holding a SCIM attribute that the storage only reads, or only writes. To store a value in another form, map the attribute to a :class:`~sqlalchemy.ext.hybrid.hybrid_property` that converts it, in Python and in SQL. :param expression: The column, or the :class:`~sqlalchemy.ext.hybrid.hybrid_property`, holding the value. :param readable: Whether the storage reads the attribute. When :data:`False`, the attribute is write-only, such as a password: the storage never returns it, nor filters or sorts on it. :param writable: Whether the storage writes the attribute. When :data:`False`, the attribute is read-only. """ def __init__( self, expression: Any, *, readable: bool = True, writable: bool = True ) -> None: self.expression = expression self.readable = readable self.writable = writable
[docs] class Many: """The entries of a multi-valued attribute, stored in a child table. The entries belong to the resource: writing the resource replaces them. The relationship needs ``cascade="all, delete-orphan"`` (:ref:`sqlalchemy:cascade_delete_orphan`). Its ``order_by`` gives the order of the entries. Without it, the order is the one the database returns. A sort uses the ``primary`` entry, or else the first one. The entries of a multi-valued simple attribute, such as a list of strings, are the values of one column of the child table. The storage keeps their order and their duplicates. :param relationship: The :func:`~sqlalchemy.orm.relationship` from the resource record to the entry records. :param attributes: The columns of the entry records, by sub-attribute name, such as ``"value"`` or ``"type"``. For a simple attribute, the column holding the values. """ def __init__(self, relationship: Any, attributes: Mapping[str, Any] | Any) -> None: self.relationship = relationship self.attributes = attributes
@dataclass(eq=False) class _Column: """A SCIM attribute stored in a column or a hybrid property. textual tells whether filters compare the column as text, as they do for an integer identifier. """ binding: AttributeBinding expression: ColumnElement[Any] key: str readable: bool writable: bool textual: bool = False @property def is_string(self) -> bool: target = self.binding.target_type return ( target is not None and SchemaAttribute.Type.from_python(target) == SchemaAttribute.Type.string ) @property def casefolded(self) -> bool: """Whether the values compare without their case.""" return self.is_string and not self.binding.case_exact @property def nullable(self) -> bool: """Whether the column can hold no value. A hybrid property is taken as nullable.""" columns = getattr(getattr(self.expression, "property", None), "columns", None) return columns is None or bool(columns[0].nullable) @property def enumeration(self) -> SqlEnum | None: """The type of the column when it is an Enum, which stores strings.""" sql_type = self.expression.type return sql_type if isinstance(sql_type, SqlEnum) else None @property def compared(self) -> ColumnElement[Any]: """The expression filters and sorts compare. An Enum column is compared as text. PostgreSQL applies no string function to its native enumerations, and sorts them in the order of their declaration. """ if self.textual or self.enumeration is not None: return cast(self.expression, String) return self.expression @staticmethod def stored_string(enumeration: SqlEnum, member: Enum) -> str: """Return the string an Enum column stores for a member: its name, or its value with values_callable.""" strings: dict[Enum, str] = dict( zip(type(member), enumeration.enums, strict=False) ) return strings[member] @dataclass(eq=False) class _Collection: """A multi-valued attribute, or a complex one, stored in related records. The entries of an owned collection belong to the resource. The others are links to the resources of another mapping, the target. A single collection holds one entry at most, such as the manager of a user. A simple collection holds the values of a multi-valued simple attribute, in the column it keeps under None. link_type is the type a Link gives its entries, and type_column the constant filters compare it as. """ binding: AttributeBinding relationship: InstrumentedAttribute[Any] columns: dict[str | None, _Column] = field(default_factory=dict) resource_type: str | None = None target: "ResourceMapping | None" = None link_type: str | None = None type_column: _Column | None = None @property def owned(self) -> bool: return self.resource_type is None @property def entry_class(self) -> type[Any]: return self.relationship_property.mapper.class_ @property def relationship_property(self) -> RelationshipProperty[Any]: return self.relationship.property # type: ignore[return-value] @property def single(self) -> bool: return not self.binding.is_multivalued @property def simple(self) -> bool: return None in self.columns def column(self, sub_field_name: str | None) -> _Column | None: """Return the column of a sub-attribute, or of the values of a simple collection named alone. A complex collection named alone has no column. """ if sub_field_name == "type" and self.type_column is not None: return self.type_column return self.columns.get(sub_field_name) def exists(self, condition: Any = None) -> ColumnElement[bool]: """Return the condition that an entry exists, and matches condition when given.""" method = self.relationship.has if self.single else self.relationship.any return method() if condition is None else method(condition) @property def writable(self) -> bool: """Whether writing the resource writes these entries.""" if self.simple and not self.columns[None].writable: return False mutability = self.binding.model.get_field_annotation( self.binding.field_name, Mutability ) return bool(mutability != Mutability.read_only)
[docs] class ResourceMapping: """How the resources of a SCIM model are stored in the records of a SQLAlchemy model. The paths are checked against the SCIM model when the mapping is created, so a mistake raises at import time rather than at the first request. :param model: The SCIM model of the resources, such as ``User[EnterpriseUser]``. :param record: The SQLAlchemy model of the records. :param attributes: The attributes, by SCIM path. A value is a column, a :class:`~sqlalchemy.ext.hybrid.hybrid_property`, an :class:`Attribute`, a :class:`Many`, a :class:`Link`, or a list of :class:`Link`. ``id``, ``meta.created`` and ``meta.lastModified`` are required. :param version: The ``version_id_col`` of the mapper of ``record``, which gives ``meta.version`` (:ref:`sqlalchemy:mapper_version_counter`). :raises ValueError: When a path is not valid for the model, or when a value does not fit the attribute of its path. """ _REQUIRED = ("id", "meta.created", "meta.lastModified") def __init__( self, model: type[Resource[Any]], record: type[Any], attributes: Mapping[str, Any], *, version: Any, ) -> None: self.model = model self.record = record self._columns: dict[tuple[type[BaseModel], str, str | None], _Column] = {} self._collections: dict[tuple[type[BaseModel], str], list[_Collection]] = {} for required in self._REQUIRED: if required not in attributes: raise ValueError(f"The mapping of {model.__name__} needs {required!r}") for path, value in attributes.items(): self._add(path, value) mapper: Mapper[Any] = inspect(record) columns = getattr(getattr(version, "property", None), "columns", [None]) if mapper.version_id_col is None or mapper.version_id_col is not columns[0]: raise ValueError( f"The version of {model.__name__} must be the version_id_col " f"of the mapper of {record.__name__}" ) self._version: InstrumentedAttribute[Any] = version self._id = self._column("id") self._id.textual = not _holds_text(self._id.expression) self._created = self._column("meta.created") self._last_modified = self._column("meta.lastModified")
[docs] def schemas(self) -> list[Schema]: """Return the schemas of the resources, restricted to what the mapping stores. They hold the mapped attributes only, and an attribute the mapping reads but never writes is ``readOnly``. Publish them, rather than the schemas of :attr:`model`, so that clients know what the server stores: build the provider of the server with :meth:`ScimProvider.from_discovery <scim2_models.ScimProvider.from_discovery>`, and pass it to the storage. :returns: The schema of the resource, then the schemas of its extensions. """ models: list[Any] = [self.model, *self.model.get_extension_models().values()] return [self._restrict(model.to_schema()) for model in models]
def _restrict(self, schema: Schema) -> Schema: attributes = [] for attribute in schema.attributes or []: restricted = self._restrict_attribute( f"{schema.id}:{attribute.name}", attribute ) if restricted is not None: attributes.append(restricted) return schema.model_copy(update={"attributes": attributes}) def _restrict_attribute(self, urn: str, attribute: Any) -> Any: """Return an attribute of a schema as the mapping stores it, or None when it stores nothing of it.""" if attribute.sub_attributes: sub_attributes = [ restricted for sub_attribute in attribute.sub_attributes if ( restricted := self._restrict_attribute( f"{urn}.{sub_attribute.name}", sub_attribute ) ) is not None ] if not sub_attributes: return None written = any( sub_attribute.mutability != Mutability.read_only for sub_attribute in sub_attributes ) return attribute.model_copy( update={ "sub_attributes": sub_attributes, "mutability": attribute.mutability if written else Mutability.read_only, } ) storage = self._storage(urn) if storage is None: return None if storage in (_Storage.written, _Storage.link_type): return attribute if storage == _Storage.ref: linked = self._linked_resource_types(urn) return attribute.model_copy(update={"reference_types": linked}) return attribute.model_copy(update={"mutability": Mutability.read_only}) def _storage(self, urn: str) -> _Storage | None: """Return how the mapping stores the attribute of a URN, or None when it does not store it.""" key = urn.lower() for column in self._columns.values(): if column.binding.urn.lower() == key: return _Storage.written if column.writable else _Storage.read_only for collection in self._all_collections(): if not collection.owned: head = collection.binding.urn.lower() if key == f"{head}.value": return _Storage.written if key in (f"{head}.display", f"{head}.displayname"): return _Storage.derived if key == f"{head}.$ref": return _Storage.ref if key == f"{head}.type" and collection.link_type is not None: return _Storage.link_type continue for column in collection.columns.values(): if column.binding.urn.lower() == key: return _Storage.written if column.writable else _Storage.read_only return None def _nullable(self, urn: str) -> bool: """Tell whether the column of an attribute can hold no value, True for an attribute without column.""" key = urn.lower() return all( column.nullable for column in self._columns.values() if column.binding.urn.lower() == key ) def _linked_resource_types(self, urn: str) -> list[str]: """Return the resource types the Links of an attribute link to, from the URN of its $ref.""" head = urn.lower().removesuffix(".$ref") return [ collection.resource_type for collection in self._all_collections() if collection.binding.urn.lower() == head and collection.resource_type is not None ] def _all_collections(self) -> Iterator[_Collection]: """Yield the collections of every attribute, each Link of an attribute being a collection.""" for collections in self._collections.values(): yield from collections def _column(self, path: str) -> _Column: binding = self._resolve(path) return self._columns[ (binding.model, binding.field_name, binding.sub_field_name) ] def _lookup( self, binding: AttributeBinding ) -> list[tuple[_Collection | None, _Column | None]]: """Return each collection holding an attribute, if any, with the column of the attribute in it. An attribute outside collections gives one pair, without collection. An attribute linking to several resource types gives a pair for each of its Links. The column is None for an attribute stored nowhere, and for a collection named alone, such as emails. """ collections = self._collections.get((binding.model, binding.field_name)) if collections is None: key = (binding.model, binding.field_name, binding.sub_field_name) return [(None, self._columns.get(key))] return [ (collection, collection.column(binding.sub_field_name)) for collection in collections ] def _sub_columns(self, binding: AttributeBinding) -> list[_Column]: """Return the mapped sub-attributes of a complex attribute holding a single value.""" return [ column for (model, field_name, sub_field_name), column in self._columns.items() if model is binding.model and field_name == binding.field_name and sub_field_name is not None ] def _string_columns(self) -> Iterator[_Column]: """Yield the columns holding strings, those of the collections included.""" for column in self._columns.values(): if column.is_string: yield column for collection in self._all_collections(): for column in collection.columns.values(): if column.is_string: yield column if collection.type_column is not None: yield collection.type_column def _unique_columns(self) -> Iterator[_Column]: """Yield the readable columns whose values no two resources share.""" for column in self._columns.values(): if column.readable and column.binding.get_annotation(Uniqueness) in ( Uniqueness.server, Uniqueness.global_, ): yield column def _returned_collections( self, parameters: ResponseParameters[Any] | None = None ) -> list[_Collection]: """Return the collections a response keeps, all of them without parameters.""" collections = list(self._all_collections()) if parameters is None: return collections paths = Path[self.model].iter_paths( # type: ignore[name-defined] include_subattributes=False, attributes=parameters.attributes, excluded_attributes=parameters.excluded_attributes, ) bindings = (path.resolve() for path in paths) kept = {binding.urn for binding in bindings if binding is not None} return [ collection for collection in collections if collection.binding.urn in kept ] def _loader_options( self, collections: Iterable[_Collection] ) -> list[strategy_options._AbstractLoad]: """Return the options loading collections along with the records. Loading them up front is what lets an asynchronous session read them. """ return [ strategy_options.selectinload(collection.relationship) for collection in collections ] def _bind(self, mappings: Mapping[str, "ResourceMapping"]) -> None: """Bind each Link to the mapping of the resources it links to. Several storages may share a mapping, as long as they link it to the same mappings. """ for collection in self._all_collections(): if collection.owned: continue target = mappings.get(collection.resource_type) # type: ignore[arg-type] if target is None: raise ValueError( f"No mapping for the resource type {collection.resource_type!r}" ) if collection.target is not None and collection.target is not target: raise ValueError( f"{collection.binding.urn!r} already links to another mapping " f"of {collection.resource_type!r}: a mapping serves storages " "sharing the same mappings" ) collection.target = target collection.columns = {} head = collection.binding.urn for binding, column in ( (self._resolve(f"{head}.value"), target._id), ( self._find(f"{head}.display") or self._find(f"{head}.displayName"), target._display(), ), ): if binding is None or column is None: continue collection.columns[binding.sub_field_name] = _Column( binding, column.expression, column.key, column.readable, False, column.textual, ) def _display(self) -> _Column | None: """Return the column a link to these resources displays.""" for path in ("displayName", "userName"): binding = self._find(path) if binding is None: continue ((_, column),) = self._lookup(binding) if column is not None and column.readable: return column return None def _find(self, path: str) -> AttributeBinding | None: return Path[self.model](path).resolve() # type: ignore[name-defined] def _resolve(self, path: str) -> AttributeBinding: binding = self._find(path) if binding is None: raise ValueError(f"{path!r} is not an attribute of {self.model.__name__}") return binding def _add(self, path: str, value: Any) -> None: binding = self._resolve(path) target = binding.target_type is_complex = isclass(target) and issubclass(target, BaseModel) key = (binding.model, binding.field_name) if isinstance(value, Many): self._collections[key] = [self._many(path, binding, value)] return if isinstance(value, Link): value = [value] if isinstance(value, list) and all(isinstance(item, Link) for item in value): if not is_complex or binding.sub_field_name is not None: raise ValueError( f"{path!r} is not a complex attribute of {self.model.__name__}" ) self._collections[key] = self._links(path, binding, value) return if binding.is_multivalued or is_complex: raise ValueError( f"{path!r} holds several values or sub-attributes: map it with Many " "or Link, or map its sub-attributes" ) self._columns[(binding.model, binding.field_name, binding.sub_field_name)] = ( self._build_column(path, binding, value) ) def _collection( self, path: str, binding: AttributeBinding, relationship: Any ) -> _Collection: """Build the collection of a relationship, holding as many records as the attribute holds entries.""" collection = _Collection(binding, relationship) if collection.relationship_property.uselist == collection.single: raise ValueError( f"{path!r} holds {'one entry' if collection.single else 'several entries'}, " f"but its relationship holds {'several' if collection.single else 'one'}" ) return collection def _many(self, path: str, binding: AttributeBinding, many: Many) -> _Collection: """Build the collection of a Many, with the columns of its entries, or the column of its values.""" target = binding.target_type is_complex = isclass(target) and issubclass(target, BaseModel) simple = not isinstance(many.attributes, Mapping) if ( binding.sub_field_name is not None or not binding.is_multivalued or is_complex == simple ): kind = "simple" if simple else "complex" raise ValueError( f"{path!r} is not a multi-valued {kind} attribute of " f"{self.model.__name__}" ) collection = self._collection(path, binding, many.relationship) if not collection.relationship_property.cascade.delete_orphan: raise ValueError( f"The relationship of {path!r} needs the delete-orphan cascade" ) if simple: collection.columns[None] = self._build_column( path, binding, many.attributes ) return collection for sub_path, expression in many.attributes.items(): sub_binding = self._resolve(f"{path}.{sub_path}") collection.columns[sub_binding.sub_field_name] = self._build_column( f"{path}.{sub_path}", sub_binding, expression ) return collection def _links( self, path: str, binding: AttributeBinding, links: list[Link] ) -> list[_Collection]: """Build the collections of the Links of an attribute, one per resource type. The type of the entries is compared, in filters, as a constant of each Link. """ resource_types = [link.resource_type for link in links] if not links or len(set(resource_types)) != len(resource_types): raise ValueError( f"{path!r} needs one Link per resource type, and at least one" ) typed = {link.type is not None for link in links} if len(typed) > 1: raise ValueError(f"Either every Link of {path!r} has a type, or none") type_binding = self._find(f"{path}.type") if typed == {True} and type_binding is None: raise ValueError(f"{path!r} has no type sub-attribute") collections = [] for link in links: collection = self._collection(path, binding, link.relationship) collection.resource_type = link.resource_type if link.type is not None: collection.link_type = link.type collection.type_column = _Column( type_binding, # type: ignore[arg-type] literal(link.type), "type", True, False, ) collections.append(collection) return collections def _build_column( self, path: str, binding: AttributeBinding, value: Any ) -> _Column: """Build the column of an attribute, and decide whether it is written. A read-only attribute, or a hybrid property without a setter, is never written. """ if binding.sub_field_name is not None and binding.target_is_multivalued: raise ValueError( f"{path!r} holds several values in a complex attribute, " "which the storage cannot map" ) attribute = value if isinstance(value, Attribute) else Attribute(value) expression = attribute.expression key = getattr(expression, "key", None) if not isinstance(key, str): raise ValueError( f"{path!r} must be mapped to a column or a hybrid property" ) writable = attribute.writable descriptor = inspect(expression.class_).all_orm_descriptors[key] mutabilities = { binding.model.get_field_annotation(binding.field_name, Mutability), binding.get_annotation(Mutability), } if Mutability.read_only in mutabilities or ( isinstance(descriptor, hybrid_property) and descriptor.fset is None ): writable = False return _Column(binding, expression, key, attribute.readable, writable)