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
3 changes: 2 additions & 1 deletion doc/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ Added
records of a SQLAlchemy model, with :class:`~scim2_sqlalchemy.Attribute`,
: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``.
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``.
- :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
30 changes: 30 additions & 0 deletions doc/how-to/map-a-data-model.rst
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,36 @@ without ``value``, raises a 400 error. The linked resources are never created or
A storage serves the resource type of every :class:`~scim2_sqlalchemy.Link`. It raises a
:class:`ValueError` otherwise.

Link to several resource types
------------------------------

Map an attribute whose entries are resources of several types, such as the ``members`` of a
group holding users and groups, to a list of :class:`~scim2_sqlalchemy.Link`, one per resource
type:

.. code-block:: python

{
"members": [
Link(Team.members, "User", type="User"),
Link(Team.subteams, "Group", type="Group"),
],
}

The storage reads the entries of each Link in the order of the list. For a single-valued
attribute, it reads the first Link holding a record. It writes each entry in the Link holding
the resource of its ``value``: per :rfc:`RFC 7643 §3.1 <7643#section-3.1>`, two resources never
share an ``id``. The storage reads the direct members only, so a group may hold itself.

Give each Link a ``type`` to fill the ``type`` sub-attribute of its entries. A client may send
``type``: the storage compares it as filters compare strings, and refuses a type other than the
one of the Link with a 400 error. Filters such as ``members[type eq "Group"]`` compare it too.
Give a ``type`` to every Link of an attribute, or to none: without it, the storage does not
store the ``type`` sub-attribute.

A Link to one resource type takes a ``type`` too. ``Link(Account.teams, "Group",
type="direct")`` gives the groups of a user the type ``direct``.

Map a custom resource type
--------------------------

Expand Down
3 changes: 2 additions & 1 deletion doc/reference/errors.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ into error responses:
- Another resource holds a unique value of the resource, such as its ``userName``.
* - :class:`~scim2_models.InvalidValueException`
- The resource holds a value no mapping stores, an entry of a
:class:`~scim2_sqlalchemy.Link` without ``value``, or a link to an unknown resource.
:class:`~scim2_sqlalchemy.Link` without ``value``, a link to an unknown resource, or a
``type`` other than the one of its :class:`~scim2_sqlalchemy.Link`.
* - :class:`~scim2_models.MutabilityException`
- The resource changes an attribute the mapping only reads, or removes a value whose column
cannot be ``NULL``.
Expand Down
2 changes: 1 addition & 1 deletion doc/reference/searches.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ what a search supports:
- ``and``, ``or``, ``not``, and value selections such as ``emails[type eq "work"]``.
* - Filtered attributes
- Columns, :class:`hybrid properties <sqlalchemy.ext.hybrid.hybrid_property>`, the entries of a :class:`~scim2_sqlalchemy.Many`, and the
``value`` and the display name of a :class:`~scim2_sqlalchemy.Link`.
``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.
Expand Down
34 changes: 21 additions & 13 deletions src/scim2_sqlalchemy/conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,17 +79,19 @@ def _to_scim(
if value is not None:
Path[model](column.binding.urn).set(resource, value) # type: ignore[valid-type]

read: dict[str, tuple[bool, list[dict[str, Any]]]] = {}
for collection in collections:
endpoint = None
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 = [
_, entries = read.setdefault(collection.binding.urn, (collection.single, []))
entries.extend(
_read_entry(collection, entry, endpoint)
for entry in _related(collection, record)
]
)
for urn, (single, entries) in read.items():
if entries:
value = entries[0] if collection.single else entries
Path[model](collection.binding.urn).set(resource, value) # type: ignore[valid-type]
Path[model](urn).set(resource, entries[0] if single else entries) # type: ignore[valid-type]

resource.id = str(_read(mapping._id, record))
resource.meta = Meta(
Expand Down Expand Up @@ -132,6 +134,8 @@ def _read_entry(
if endpoint is not None:
values["$ref"] = f"{endpoint}/{value}"
values[column.binding.urn.rsplit(".", 1)[-1]] = value
if collection.link_type is not None:
values["type"] = collection.link_type
return values


Expand All @@ -158,7 +162,7 @@ def _from_scim(
continue
setattr(record, column.key, None if value is None else _as_stored(value))

for collection in mapping._collections.values():
for collection in mapping._all_collections():
if not collection.writable:
continue
if collection.owned:
Expand All @@ -182,24 +186,25 @@ def _write_entry(collection: _Collection, entry: Any) -> Any:
return record


def _link_ids(
def _link_entries(
mapping: ResourceMapping, resource: Resource[Any]
) -> dict[_Collection, list[str]]:
"""Return the identifiers each writable Link of a resource links to.
) -> list[tuple[list[_Collection], list[Any]]]:
"""Return the entries of each written attribute of a resource linking to other resources, with the collections of its Links.

A link is stored by its value, so an entry without one, such as an entry
with a $ref only, raises InvalidValueException rather than being lost.
"""
links = {}
for collection in mapping._collections.values():
links = []
for collections in mapping._collections.values():
collection = collections[0]
if collection.owned or not collection.writable:
continue
entries = _entries(collection, resource)
if any(entry.value is None for entry in entries):
raise InvalidValueException(
detail=f"'{collection.binding.urn}' holds an entry without value"
)
links[collection] = [entry.value for entry in entries]
links.append((collections, entries))
return links


Expand Down Expand Up @@ -247,7 +252,10 @@ def _check_storable(
detail=f"'{binding.urn}' cannot be removed on this server",
)
continue
if storage in (_Storage.derived, _Storage.ref) or value in (None, current):
if storage in (_Storage.derived, _Storage.ref, _Storage.link_type) or value in (
None,
current,
):
continue
if storage == _Storage.read_only:
raise MutabilityException(
Expand All @@ -264,7 +272,7 @@ def _check_enumerated(
mapping: ResourceMapping, binding: AttributeBinding, value: Any
) -> None:
"""Refuse a string that an Enum column does not store, rather than failing in the database."""
_, column = mapping._lookup(binding)
_, column = mapping._lookup(binding)[0]
if column is None or column.enumeration is None:
return
stored = column.enumeration.enums
Expand Down
Loading