Skip to content

Commit d04e59a

Browse files
committed
feat: link an attribute to several resource types
An attribute can map to a list of Links, one per resource type, so a group can hold users and groups. A Link can give its entries a type, which clients may send and filters compare.
1 parent ff1b898 commit d04e59a

12 files changed

Lines changed: 626 additions & 122 deletions

File tree

‎doc/changelog.rst‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ Added
1010
records of a SQLAlchemy model, with :class:`~scim2_sqlalchemy.Attribute`,
1111
:class:`~scim2_sqlalchemy.Many` and :class:`~scim2_sqlalchemy.Link`. A
1212
:class:`~scim2_sqlalchemy.Link` links to one resource, such as a ``manager``, or to
13-
several, such as ``members``.
13+
several, such as ``members``. A list of Links links to several resource types, such as the
14+
users and the groups of a group. A Link can give its entries a ``type``.
1415
- :class:`~scim2_sqlalchemy.SqlAlchemyStorage` and
1516
:class:`~scim2_sqlalchemy.AsyncSqlAlchemyStorage`, which serve the mappings to
1617
:doc:`scim2-server <scim2_server:index>`. They filter, sort and page the resources in the

‎doc/how-to/map-a-data-model.rst‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,36 @@ without ``value``, raises a 400 error. The linked resources are never created or
198198
A storage serves the resource type of every :class:`~scim2_sqlalchemy.Link`. It raises a
199199
:class:`ValueError` otherwise.
200200

201+
Link to several resource types
202+
------------------------------
203+
204+
Map an attribute whose entries are resources of several types, such as the ``members`` of a
205+
group holding users and groups, to a list of :class:`~scim2_sqlalchemy.Link`, one per resource
206+
type:
207+
208+
.. code-block:: python
209+
210+
{
211+
"members": [
212+
Link(Team.members, "User", type="User"),
213+
Link(Team.subteams, "Group", type="Group"),
214+
],
215+
}
216+
217+
The storage reads the entries of each Link in the order of the list. For a single-valued
218+
attribute, it reads the first Link holding a record. It writes each entry in the Link holding
219+
the resource of its ``value``: per :rfc:`RFC 7643 §3.1 <7643#section-3.1>`, two resources never
220+
share an ``id``. The storage reads the direct members only, so a group may hold itself.
221+
222+
Give each Link a ``type`` to fill the ``type`` sub-attribute of its entries. A client may send
223+
``type``: the storage compares it as filters compare strings, and refuses a type other than the
224+
one of the Link with a 400 error. Filters such as ``members[type eq "Group"]`` compare it too.
225+
Give a ``type`` to every Link of an attribute, or to none: without it, the storage does not
226+
store the ``type`` sub-attribute.
227+
228+
A Link to one resource type takes a ``type`` too. ``Link(Account.teams, "Group",
229+
type="direct")`` gives the groups of a user the type ``direct``.
230+
201231
Map a custom resource type
202232
--------------------------
203233

‎doc/reference/errors.rst‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ into error responses:
1818
- Another resource holds a unique value of the resource, such as its ``userName``.
1919
* - :class:`~scim2_models.InvalidValueException`
2020
- The resource holds a value no mapping stores, an entry of a
21-
:class:`~scim2_sqlalchemy.Link` without ``value``, or a link to an unknown resource.
21+
:class:`~scim2_sqlalchemy.Link` without ``value``, a link to an unknown resource, or a
22+
``type`` other than the one of its :class:`~scim2_sqlalchemy.Link`.
2223
* - :class:`~scim2_models.MutabilityException`
2324
- The resource changes an attribute the mapping only reads, or removes a value whose column
2425
cannot be ``NULL``.

‎doc/reference/searches.rst‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ what a search supports:
1515
- ``and``, ``or``, ``not``, and value selections such as ``emails[type eq "work"]``.
1616
* - Filtered attributes
1717
- Columns, :class:`hybrid properties <sqlalchemy.ext.hybrid.hybrid_property>`, the entries of a :class:`~scim2_sqlalchemy.Many`, and the
18-
``value`` and the display name of a :class:`~scim2_sqlalchemy.Link`.
18+
``value``, the display name and the ``type`` of a :class:`~scim2_sqlalchemy.Link`.
1919
* - Sorted attributes
2020
- Columns, hybrid properties, and the entries of a :class:`~scim2_sqlalchemy.Many`, on the
2121
``primary`` entry or else the first one.

‎src/scim2_sqlalchemy/conversion.py‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -79,17 +79,19 @@ def _to_scim(
7979
if value is not None:
8080
Path[model](column.binding.urn).set(resource, value) # type: ignore[valid-type]
8181

82+
read: dict[str, tuple[bool, list[dict[str, Any]]]] = {}
8283
for collection in collections:
8384
endpoint = None
8485
if Path[model](f"{collection.binding.urn}.$ref").resolve() is not None: # type: ignore[valid-type]
8586
endpoint = endpoints.get(collection.resource_type) # type: ignore[arg-type]
86-
entries = [
87+
_, entries = read.setdefault(collection.binding.urn, (collection.single, []))
88+
entries.extend(
8789
_read_entry(collection, entry, endpoint)
8890
for entry in _related(collection, record)
89-
]
91+
)
92+
for urn, (single, entries) in read.items():
9093
if entries:
91-
value = entries[0] if collection.single else entries
92-
Path[model](collection.binding.urn).set(resource, value) # type: ignore[valid-type]
94+
Path[model](urn).set(resource, entries[0] if single else entries) # type: ignore[valid-type]
9395

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

137141

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

161-
for collection in mapping._collections.values():
165+
for collection in mapping._all_collections():
162166
if not collection.writable:
163167
continue
164168
if collection.owned:
@@ -182,24 +186,25 @@ def _write_entry(collection: _Collection, entry: Any) -> Any:
182186
return record
183187

184188

185-
def _link_ids(
189+
def _link_entries(
186190
mapping: ResourceMapping, resource: Resource[Any]
187-
) -> dict[_Collection, list[str]]:
188-
"""Return the identifiers each writable Link of a resource links to.
191+
) -> list[tuple[list[_Collection], list[Any]]]:
192+
"""Return the entries of each written attribute of a resource linking to other resources, with the collections of its Links.
189193
190194
A link is stored by its value, so an entry without one, such as an entry
191195
with a $ref only, raises InvalidValueException rather than being lost.
192196
"""
193-
links = {}
194-
for collection in mapping._collections.values():
197+
links = []
198+
for collections in mapping._collections.values():
199+
collection = collections[0]
195200
if collection.owned or not collection.writable:
196201
continue
197202
entries = _entries(collection, resource)
198203
if any(entry.value is None for entry in entries):
199204
raise InvalidValueException(
200205
detail=f"'{collection.binding.urn}' holds an entry without value"
201206
)
202-
links[collection] = [entry.value for entry in entries]
207+
links.append((collections, entries))
203208
return links
204209

205210

@@ -247,7 +252,10 @@ def _check_storable(
247252
detail=f"'{binding.urn}' cannot be removed on this server",
248253
)
249254
continue
250-
if storage in (_Storage.derived, _Storage.ref) or value in (None, current):
255+
if storage in (_Storage.derived, _Storage.ref, _Storage.link_type) or value in (
256+
None,
257+
current,
258+
):
251259
continue
252260
if storage == _Storage.read_only:
253261
raise MutabilityException(
@@ -264,7 +272,7 @@ def _check_enumerated(
264272
mapping: ResourceMapping, binding: AttributeBinding, value: Any
265273
) -> None:
266274
"""Refuse a string that an Enum column does not store, rather than failing in the database."""
267-
_, column = mapping._lookup(binding)
275+
_, column = mapping._lookup(binding)[0]
268276
if column is None or column.enumeration is None:
269277
return
270278
stored = column.enumeration.enums

0 commit comments

Comments
 (0)