Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions doc/_examples/intranet/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from sqlalchemy.orm import relationship

from scim2_sqlalchemy import Link
from scim2_sqlalchemy import Many
from scim2_sqlalchemy import ResourceMapping
from scim2_sqlalchemy import SqlAlchemyStorage

Expand Down Expand Up @@ -76,10 +77,21 @@ class ProjectRecord(Base):

owner: Mapped[Account | None] = relationship()
members: Mapped[list[Account]] = relationship(secondary=project_members)
tags: Mapped[list["ProjectTag"]] = relationship(
cascade="all, delete-orphan", order_by="ProjectTag.id"
)

__mapper_args__ = {"version_id_col": revision}


class ProjectTag(Base):
__tablename__ = "project_tags"

id: Mapped[int] = mapped_column(primary_key=True)
project_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("projects.id"))
name: Mapped[str]


class UserReference(ComplexAttribute):
value: Annotated[str | None, Mutability.immutable] = None
ref: Annotated[Reference["User"] | None, Mutability.immutable] = Field(
Expand All @@ -94,6 +106,7 @@ class Project(Resource):
title: str | None = None
owner: UserReference | None = None
members: list[UserReference] | None = None
tags: list[str] | None = None


PROJECTS = ResourceMapping(
Expand All @@ -105,6 +118,7 @@ class Project(Resource):
"title": ProjectRecord.title,
"owner": Link(ProjectRecord.owner, "User"),
"members": Link(ProjectRecord.members, "User"),
"tags": Many(ProjectRecord.tags, ProjectTag.name),
"meta.created": ProjectRecord.created_at,
"meta.lastModified": ProjectRecord.updated_at,
},
Expand Down
4 changes: 3 additions & 1 deletion doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ Added
:class:`~scim2_sqlalchemy.Many` and :class:`~scim2_sqlalchemy.Link`. A
:class:`~scim2_sqlalchemy.Link` links to one resource, such as a ``manager``, or to
several, such as ``members``. A list of Links links to several resource types, such as the
users and the groups of a group. A Link can give its entries a ``type``.
users and the groups of a group. A Link can give its entries a ``type``. A
:class:`~scim2_sqlalchemy.Many` with a column stores a list of values, such as a list of
strings.
- :class:`~scim2_sqlalchemy.SqlAlchemyStorage` and
:class:`~scim2_sqlalchemy.AsyncSqlAlchemyStorage`, which serve the mappings to
:doc:`scim2-server <scim2_server:index>`. They filter, sort and page the resources in the
Expand Down
49 changes: 39 additions & 10 deletions doc/how-to/map-a-data-model.rst
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,40 @@ The mapping makes the password write-only:
A write-only attribute keeps its stored value when a request does not carry it, since a
resource never carries it back.

Map a list of values
--------------------

Map a multi-valued attribute holding plain values, such as a list of strings, with
:class:`~scim2_sqlalchemy.Many`. Give it the :func:`~sqlalchemy.orm.relationship` to the child
model, and the column holding the values. The projects of the intranet have tags, one per row of
a child table:

.. literalinclude:: ../_examples/intranet/projects.py
:language: python
:start-at: class ProjectTag
:end-before: class UserReference

.. code-block:: python

{"tags": Many(ProjectRecord.tags, ProjectTag.name)}

Declare the relationship with ``cascade="all, delete-orphan"``: writing the resource replaces
its values, and the :ref:`delete-orphan <sqlalchemy:cascade_delete_orphan>` cascade deletes the
former ones. A relationship without this cascade raises a :class:`ValueError`. The ``order_by``
of the relationship gives the order of the values, and the storage keeps their duplicates.

A filter such as ``tags eq "moon"`` holds when one value matches, and ``tags[value eq "moon"]``
selects the same projects. A sort on the attribute uses the first value.

A multi-valued sub-attribute of a complex attribute cannot be mapped, and raises a
:class:`ValueError`.

Map entries of a child table
----------------------------

Map a multi-valued attribute whose entries belong to the resource, such as ``emails``, with
:class:`~scim2_sqlalchemy.Many`. Give it the :func:`~sqlalchemy.orm.relationship` to the child model, and the column of
each sub-attribute:
Map a multi-valued complex attribute whose entries belong to the resource, such as ``emails``,
with :class:`~scim2_sqlalchemy.Many` too. Give it the relationship to the child model, and the
column of each sub-attribute:

.. code-block:: python

Expand All @@ -164,11 +192,9 @@ each sub-attribute:
)
}

Declare the relationship with ``cascade="all, delete-orphan"``: writing the resource replaces
its entries, and the :ref:`delete-orphan <sqlalchemy:cascade_delete_orphan>` cascade deletes the
former ones. A relationship without this cascade raises a
:class:`ValueError`. The ``order_by`` of the relationship gives the order of the entries. A sort
on the attribute uses the entry marked ``primary``, or else the first entry.
The relationship needs the same cascade as for a list of values, and its ``order_by`` gives the
order of the entries. A sort on the attribute uses the entry marked ``primary``, or else the
first entry.

Link to other resources
-----------------------
Expand Down Expand Up @@ -233,7 +259,7 @@ Map a custom resource type

Map a resource type of the application the same way as a standard one. Declare its SCIM model
first: for the steps, read :doc:`scim2_models:how-to/define-custom-models`. The intranet serves
its projects, whose owner and members are users:
its projects, whose owner and members are users, and which have tags:

.. literalinclude:: ../_examples/intranet/projects.py
:language: python
Expand Down Expand Up @@ -273,10 +299,13 @@ Create a project owned by a user:
>>> client = Client(WSGIApplication(create_storage(session, provider), provider))
>>> user = client.post("/v2/Users", json={"userName": "bjensen"}).json
>>> project = client.post(
... "/v2/Projects", json={"code": "APOLLO", "owner": {"value": user["id"]}}
... "/v2/Projects",
... json={"code": "APOLLO", "owner": {"value": user["id"]}, "tags": ["moon"]},
... ).json
>>> project["owner"]
{'value': '...', '$ref': 'http://localhost/v2/Users/...', 'display': 'bjensen'}
>>> project["tags"]
['moon']
>>> session.close()
>>> engine.dispose()

Expand Down
5 changes: 3 additions & 2 deletions doc/reference/searches.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@ what a search supports:
* - Comparison operators
- ``eq``, ``ne``, ``co``, ``sw``, ``ew``, ``gt``, ``ge``, ``lt``, ``le``, and ``pr``.
* - Logical operators
- ``and``, ``or``, ``not``, and value selections such as ``emails[type eq "work"]``.
- ``and``, ``or``, ``not``, and value selections such as ``emails[type eq "work"]``, or
``tags[value eq "moon"]`` for a list of values.
* - Filtered attributes
- Columns, :class:`hybrid properties <sqlalchemy.ext.hybrid.hybrid_property>`, the entries of a :class:`~scim2_sqlalchemy.Many`, and the
``value``, the display name and the ``type`` of a :class:`~scim2_sqlalchemy.Link`.
* - Sorted attributes
- Columns, hybrid properties, and the entries of a :class:`~scim2_sqlalchemy.Many`, on the
``primary`` entry or else the first one.
``primary`` entry or else the first one, or on the first value of a list of values.
* - Pagination
- ``startIndex`` and ``count``, or the cursors of :rfc:`9865`.
* - Resource types
Expand Down
23 changes: 13 additions & 10 deletions src/scim2_sqlalchemy/conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,10 @@ def _to_scim(
if Path[model](f"{collection.binding.urn}.$ref").resolve() is not None: # type: ignore[valid-type]
endpoint = endpoints.get(collection.resource_type) # type: ignore[arg-type]
_, entries = read.setdefault(collection.binding.urn, (collection.single, []))
entries.extend(
_read_entry(collection, entry, endpoint)
for entry in _related(collection, record)
)
for related in _related(collection, record):
entry = _read_entry(collection, related, endpoint)
if entry is not None:
entries.append(entry)
for urn, (single, entries) in read.items():
if entries:
Path[model](urn).set(resource, entries[0] if single else entries) # type: ignore[valid-type]
Expand Down Expand Up @@ -119,13 +119,13 @@ def _entries(collection: _Collection, resource: Resource[Any]) -> list[Any]:
return list(value or [])


def _read_entry(
collection: _Collection, entry: Any, endpoint: str | None
) -> dict[str, Any]:
"""Return the values of an entry, by sub-attribute name.
def _read_entry(collection: _Collection, entry: Any, endpoint: str | None) -> Any:
"""Return the values of an entry, by sub-attribute name, or the value of an entry of a simple collection.

A link gets a $ref when the endpoint of the resources it links to is known.
"""
if collection.simple:
return _read(collection.columns[None], entry)
values = {}
for sub_field_name, column in collection.columns.items():
value = _read(column, entry)
Expand Down Expand Up @@ -177,10 +177,13 @@ def _from_scim(

def _write_entry(collection: _Collection, entry: Any) -> Any:
record = collection.entry_class()
for sub_field_name, column in collection.columns.items():
if collection.simple:
setattr(record, collection.columns[None].key, _as_stored(entry))
return record
for column in collection.columns.values():
if not column.writable:
continue
value = getattr(entry, sub_field_name, None)
value = getattr(entry, column.binding.target_field_name, None)
if value is not None:
setattr(record, column.key, _as_stored(value))
return record
Expand Down
88 changes: 59 additions & 29 deletions src/scim2_sqlalchemy/mapping.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,21 +78,26 @@ def __init__(


class Many:
"""The entries of a multi-valued complex attribute, stored in a child table.
"""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. A sort uses the ``primary`` entry, or else
the first one.
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"``.
such as ``"value"`` or ``"type"``. For a simple attribute, the column
holding the values.
"""

def __init__(self, relationship: Any, attributes: Mapping[str, Any]) -> None:
def __init__(self, relationship: Any, attributes: Mapping[str, Any] | Any) -> None:
self.relationship = relationship
self.attributes = attributes

Expand Down Expand Up @@ -197,19 +202,21 @@ def stored_string(enumeration: SqlEnum, member: Enum) -> str:

@dataclass(eq=False)
class _Collection:
"""A complex attribute stored in related records.
"""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.
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, _Column] = field(default_factory=dict)
columns: dict[str | None, _Column] = field(default_factory=dict)
resource_type: str | None = None
target: "ResourceMapping | None" = None
link_type: str | None = None
Expand All @@ -231,10 +238,15 @@ def relationship_property(self) -> RelationshipProperty[Any]:
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 None for the collection named alone."""
if sub_field_name is None:
return 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)
Expand All @@ -247,6 +259,8 @@ def exists(self, condition: Any = None) -> ColumnElement[bool]:
@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
)
Expand Down Expand Up @@ -534,7 +548,7 @@ def _bind(self, mappings: Mapping[str, "ResourceMapping"]) -> None:
):
if binding is None or column is None:
continue
collection.columns[binding.sub_field_name] = _Column( # type: ignore[index]
collection.columns[binding.sub_field_name] = _Column(
binding,
column.expression,
column.key,
Expand Down Expand Up @@ -568,25 +582,18 @@ def _add(self, path: str, value: Any) -> None:
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, Many) or (
isinstance(value, list) and all(isinstance(item, Link) for item in value)
):
if not (is_complex and binding.sub_field_name is None) or (
isinstance(value, Many) and not binding.is_multivalued
):
kind = (
"a multi-valued complex" if isinstance(value, Many) else "a complex"
)
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 {kind} attribute of {self.model.__name__}"
f"{path!r} is not a complex attribute of {self.model.__name__}"
)
if isinstance(value, Many):
collections = [self._many(path, binding, value)]
else:
collections = self._links(path, binding, value)
self._collections[(binding.model, binding.field_name)] = collections
self._collections[key] = self._links(path, binding, value)
return

if binding.is_multivalued or is_complex:
Expand All @@ -611,15 +618,33 @@ def _collection(
return collection

def _many(self, path: str, binding: AttributeBinding, many: Many) -> _Collection:
"""Build the collection of a Many, with the columns of its entries."""
"""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( # type: ignore[index]
collection.columns[sub_binding.sub_field_name] = self._build_column(
f"{path}.{sub_path}", sub_binding, expression
)
return collection
Expand Down Expand Up @@ -668,6 +693,11 @@ def _build_column(
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)
Expand Down
Loading