diff --git a/doc/_examples/intranet/projects.py b/doc/_examples/intranet/projects.py index ac50b03..68d2c7a 100644 --- a/doc/_examples/intranet/projects.py +++ b/doc/_examples/intranet/projects.py @@ -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 @@ -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( @@ -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( @@ -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, }, diff --git a/doc/changelog.rst b/doc/changelog.rst index a5bd5c7..9ac71ed 100644 --- a/doc/changelog.rst +++ b/doc/changelog.rst @@ -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 `. They filter, sort and page the resources in the diff --git a/doc/how-to/map-a-data-model.rst b/doc/how-to/map-a-data-model.rst index a623473..206ea5f 100644 --- a/doc/how-to/map-a-data-model.rst +++ b/doc/how-to/map-a-data-model.rst @@ -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 ` 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 @@ -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 ` 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 ----------------------- @@ -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 @@ -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() diff --git a/doc/reference/searches.rst b/doc/reference/searches.rst index 5bcca94..4d38756 100644 --- a/doc/reference/searches.rst +++ b/doc/reference/searches.rst @@ -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 `, 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 diff --git a/src/scim2_sqlalchemy/conversion.py b/src/scim2_sqlalchemy/conversion.py index cd06c10..b32306d 100644 --- a/src/scim2_sqlalchemy/conversion.py +++ b/src/scim2_sqlalchemy/conversion.py @@ -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] @@ -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) @@ -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 diff --git a/src/scim2_sqlalchemy/mapping.py b/src/scim2_sqlalchemy/mapping.py index f90cfe5..7a7a539 100644 --- a/src/scim2_sqlalchemy/mapping.py +++ b/src/scim2_sqlalchemy/mapping.py @@ -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 @@ -197,11 +202,13 @@ 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. @@ -209,7 +216,7 @@ class _Collection: 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 @@ -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) @@ -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 ) @@ -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, @@ -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: @@ -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 @@ -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) diff --git a/src/scim2_sqlalchemy/query.py b/src/scim2_sqlalchemy/query.py index cb4a273..0be8d16 100644 --- a/src/scim2_sqlalchemy/query.py +++ b/src/scim2_sqlalchemy/query.py @@ -107,11 +107,26 @@ def __init__( self.comparator = comparator self.scope = scope - def _path(self, attr_path: AttrPath) -> AttrPath: + def _resolve( + self, + attr_path: AttrPath, + resolve: Callable[..., AttributeBinding | None], + ) -> AttributeBinding | None: + """Bind the attribute path of a comparison or of a presence test. + + Within a value selection of a simple attribute, such as + tags[value eq "x"], value is the entry itself, and any other attribute + matches nothing, as ScimFilter.match has it. + """ if self.scope is None: - return attr_path - scope_path, _ = self.scope - return AttrPath(scope_path.attr, attr_path.attr, scope_path.uri) + return resolve(attr_path, strict=False) + scope_path, scope = self.scope + if scope.simple: + own = attr_path.attr.lower() == "value" and not attr_path.sub_attr + return scope.binding if own else None + return resolve( + AttrPath(scope_path.attr, attr_path.attr, scope_path.uri), strict=False + ) def _lookup( self, binding: AttributeBinding @@ -146,9 +161,7 @@ def _in_collection( return collection.exists(condition) def visit_comparison(self, node: Comparison) -> ColumnElement[bool]: - binding = self.filter.resolve_comparison( - self._path(node.attr_path), strict=False - ) + binding = self._resolve(node.attr_path, self.filter.resolve_comparison) if binding is None: return false() targets = self._targets(binding) @@ -181,12 +194,12 @@ def visit_comparison(self, node: Comparison) -> ColumnElement[bool]: ) def visit_present(self, node: Present) -> ColumnElement[bool]: - binding = self.filter.resolve(self._path(node.attr_path), strict=False) + binding = self._resolve(node.attr_path, self.filter.resolve) if binding is None: return false() pairs = self._lookup(binding) in_collection = pairs[0][0] is not None - if in_collection and binding.sub_field_name is None: + if in_collection and binding.sub_field_name is None and pairs[0][1] is None: return or_(*(collection.exists() for collection, _ in pairs)) # type: ignore[union-attr] if all(column is not None and column.readable for _, column in pairs): return or_( diff --git a/tests/test_many_values.py b/tests/test_many_values.py new file mode 100644 index 0000000..939fbdf --- /dev/null +++ b/tests/test_many_values.py @@ -0,0 +1,266 @@ +import datetime +import uuid + +import pytest +from scim2_models import URN +from scim2_models import ComplexAttribute +from scim2_models import Extension +from scim2_models import InvalidFilterException +from scim2_models import InvalidPathException +from scim2_models import Mutability +from scim2_models import MutabilityException +from scim2_models import SearchRequest +from scim2_models import User +from sqlalchemy import ForeignKey +from sqlalchemy import create_engine +from sqlalchemy.orm import DeclarativeBase +from sqlalchemy.orm import Mapped +from sqlalchemy.orm import mapped_column +from sqlalchemy.orm import relationship + +from scim2_sqlalchemy import Attribute +from scim2_sqlalchemy import Many +from scim2_sqlalchemy import ResourceMapping + +TAGGED = "urn:example:params:scim:schemas:extension:tagged:2.0:User" + + +class Badge(ComplexAttribute): + lines: list[str] | None = None + + +class Tagged(Extension): + __schema__ = URN(TAGGED) + + tags: list[str] | None = None + badge: Badge | None = None + + +class Base(DeclarativeBase): + pass + + +class TaggedUser(Base): + __tablename__ = "tagged_users" + + id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4) + user_name: Mapped[str] = mapped_column(unique=True) + created: Mapped[datetime.datetime] + last_modified: Mapped[datetime.datetime] + version: Mapped[int] = mapped_column() + + tags: Mapped[list["Tag"]] = relationship( + cascade="all, delete-orphan", order_by="Tag.id" + ) + + __mapper_args__ = {"version_id_col": version} + + +class Tag(Base): + __tablename__ = "tags" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("tagged_users.id")) + value: Mapped[str] + + +def tagged_mapping(tags=Tag.value, **changes): + attributes = { + "id": TaggedUser.id, + "userName": TaggedUser.user_name, + f"{TAGGED}:tags": Many(TaggedUser.tags, tags), + "meta.created": TaggedUser.created, + "meta.lastModified": TaggedUser.last_modified, + } + return ResourceMapping( + User[Tagged], + TaggedUser, + attributes | changes, + version=TaggedUser.version, + ) + + +def tagged(user_name, tags): + user = User[Tagged](user_name=user_name) + user[Tagged] = Tagged(tags=tags) + return user + + +@pytest.fixture +def factory(database_url, storage_factory): + """Return a factory of storages of tagged users, over empty tables.""" + engine = create_engine(database_url) + Base.metadata.drop_all(engine) + Base.metadata.create_all(engine) + engine.dispose() + return lambda mapping=None: storage_factory( + mappings={"User": mapping or tagged_mapping()} + ) + + +@pytest.fixture +def storage(factory, user_type): + """Return a storage of users a to e, holding tags in the order they were sent.""" + storage = factory() + for user_name, tags in [ + ("a", ["red", "Blue", "red"]), + ("b", ["green"]), + ("c", [""]), + ("d", []), + ("e", ["blue", "amber"]), + ]: + storage.create(user_type, tagged(user_name, tags)) + return storage + + +def user_names(storage, user_type, **parameters): + page = storage.search([user_type], SearchRequest(**parameters)) + return [resource.user_name for resource in page.resources] + + +def tags_of(storage, user_type, user_name): + page = storage.search( + [user_type], SearchRequest(filter=f'userName eq "{user_name}"') + ) + (user,) = page.resources + return user[Tagged] and user[Tagged].tags + + +def test_the_values_are_read_in_order_with_their_duplicates(storage, user_type): + """The storage reads back the list a client sent, and an empty list as no value.""" + assert tags_of(storage, user_type, "a") == ["red", "Blue", "red"] + assert tags_of(storage, user_type, "c") == [""] + assert tags_of(storage, user_type, "d") is None + + +def test_writing_the_resource_replaces_the_values(storage, user_type): + """The former values are deleted, and the new ones stored.""" + page = storage.search([user_type], SearchRequest(filter='userName eq "b"')) + (user,) = page.resources + user[Tagged].tags = ["violet", "green"] + + user = storage.update(user_type, user) + + assert user[Tagged].tags == ["violet", "green"] + + +@pytest.mark.parametrize( + ("scim_filter", "expected"), + [ + (f'{TAGGED}:tags eq "blue"', ["a", "e"]), + (f'{TAGGED}:tags co "EE"', ["b"]), + (f'{TAGGED}:tags ne "red"', ["b", "c", "d", "e"]), + (f"{TAGGED}:tags pr", ["a", "b", "e"]), + (f'not ({TAGGED}:tags eq "red")', ["b", "c", "d", "e"]), + (f'{TAGGED}:tags[value eq "amber"]', ["e"]), + (f'{TAGGED}:tags[value ne "red"]', ["a", "b", "c", "e"]), + (f"{TAGGED}:tags[value pr]", ["a", "b", "e"]), + (f'{TAGGED}:tags[value sw "r" and value ew "d"]', ["a"]), + (f'{TAGGED}:tags[not (value eq "green")]', ["a", "c", "e"]), + ], +) +def test_a_filter_holds_when_one_value_matches( + storage, user_type, scim_filter, expected +): + """A comparison on the attribute, or on value in a selection, compares each value, as ScimFilter.match does.""" + assert sorted(user_names(storage, user_type, filter=scim_filter)) == expected + + +def test_a_selection_on_another_attribute_matches_nothing(storage, user_type): + """A value holds no sub-attribute, so only value designates it.""" + scim_filter = f'{TAGGED}:tags[display eq "red"] or userName eq "b"' + + assert user_names(storage, user_type, filter=scim_filter) == ["b"] + + +@pytest.mark.parametrize( + ("sort_order", "expected"), + [ + ("ascending", ["c", "e", "b", "a", "d"]), + ("descending", ["d", "a", "b", "e", "c"]), + ], +) +def test_a_sort_uses_the_first_value(storage, user_type, sort_order, expected): + """Per RFC 7644 ยง3.4.2.3, a multi-valued attribute without primary sorts on its first value.""" + names = user_names( + storage, user_type, sort_by=f"{TAGGED}:tags", sort_order=sort_order + ) + + assert names == expected + + +def test_the_schema_keeps_the_values(): + """The values are written, so the attribute keeps its mutability.""" + _, extension = tagged_mapping().schemas() + + (attribute,) = extension.attributes + assert (attribute.name, attribute.multi_valued) == ("tags", True) + assert attribute.mutability == Mutability.read_write + + +def test_read_only_values_are_not_written(factory, user_type): + """A column the storage only reads makes the values read-only, and a change raises a 400.""" + mapping = tagged_mapping(tags=Attribute(Tag.value, writable=False)) + storage = factory(mapping) + _, extension = mapping.schemas() + + user = storage.create(user_type, tagged("a", None)) + user[Tagged] = Tagged(tags=["red"]) + + assert extension.attributes[0].mutability == Mutability.read_only + with pytest.raises(MutabilityException): + storage.update(user_type, user) + + +def test_write_only_values_are_never_read(factory, user_type): + """The values are never returned, and filters and sorts refuse them.""" + storage = factory(tagged_mapping(tags=Attribute(Tag.value, readable=False))) + + user = storage.create(user_type, tagged("a", ["red"])) + + assert user[Tagged] is None + with pytest.raises(InvalidFilterException): + user_names(storage, user_type, filter=f"{TAGGED}:tags pr") + with pytest.raises(InvalidFilterException): + user_names(storage, user_type, filter=f'{TAGGED}:tags[value eq "red"]') + with pytest.raises(InvalidPathException): + user_names(storage, user_type, sort_by=f"{TAGGED}:tags") + + +@pytest.mark.parametrize( + ("changes", "message"), + [ + ( + {"userName": Many(TaggedUser.tags, Tag.value)}, + "'userName' is not a multi-valued simple attribute", + ), + ( + {"emails": Many(TaggedUser.tags, Tag.value)}, + "'emails' is not a multi-valued simple attribute", + ), + ( + {f"{TAGGED}:tags": Many(TaggedUser.tags, {"value": Tag.value})}, + "tags' is not a multi-valued complex attribute", + ), + ({f"{TAGGED}:tags": Tag.value}, "map it with Many"), + ( + {f"{TAGGED}:badge.lines": Tag.value}, + "holds several values in a complex attribute", + ), + ], + ids=["single", "complex", "dict on simple", "column", "in a complex attribute"], +) +def test_many_values_are_mapped_on_a_multi_valued_simple_attribute(changes, message): + """Many with a column maps the values of a list, which a complex attribute cannot hold.""" + with pytest.raises(ValueError, match=message): + ResourceMapping( + User[Tagged], + TaggedUser, + { + "id": TaggedUser.id, + "meta.created": TaggedUser.created, + "meta.lastModified": TaggedUser.last_modified, + **changes, + }, + version=TaggedUser.version, + ) diff --git a/tests/test_mapping.py b/tests/test_mapping.py index c290c38..65edca1 100644 --- a/tests/test_mapping.py +++ b/tests/test_mapping.py @@ -106,6 +106,12 @@ def test_many_maps_a_multi_valued_complex_attribute_only(): person_mapping(userName=Many(Person.emails, {"value": Address.value})) +def test_link_maps_a_complex_attribute_only(): + """A Link gives a value and a display, which userName has no room for.""" + with pytest.raises(ValueError, match="not a complex attribute"): + person_mapping(userName=Link(Person.emails, "User")) + + def test_many_needs_the_delete_orphan_cascade(): """Without it, replacing the entries would leave the former ones behind.""" with pytest.raises(ValueError, match="delete-orphan"):