Skip to content

Commit 8767326

Browse files
committed
fix: support the Enum columns
A column of type Enum now holds the string it stores, whether the name of the member or its value. Filters and sorts compare this string as text, so they also work on the native enumerations of PostgreSQL. A string outside the enumeration is refused with a 400 error.
1 parent d56352a commit 8767326

5 files changed

Lines changed: 227 additions & 3 deletions

File tree

‎doc/changelog.rst‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,9 @@ Added
2020
attributes a mapping stores.
2121
- Filters, sorts and uniqueness checks compare strings with the comparison key of the policy of
2222
the provider: exactly on SQLite, and approximately on PostgreSQL.
23+
- A column of type :class:`~sqlalchemy.types.Enum` holds the string it stores: the name of the
24+
member, or its value with ``values_callable``. Filters and sorts compare this string, also on
25+
the native enumerations of PostgreSQL. A string outside the enumeration is refused with
26+
``invalidValue``.
2327
- A search or a read does not load the collections that ``attributes`` and ``excludedAttributes``
2428
remove from the response, such as the members of the groups.

‎src/scim2_sqlalchemy/conversion.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import Any
77

88
from pydantic import BaseModel
9+
from scim2_models import AttributeBinding
910
from scim2_models import InvalidValueException
1011
from scim2_models import Meta
1112
from scim2_models import Mutability
@@ -42,6 +43,8 @@ def _read(column: _Column, record: Any) -> Any:
4243
value = getattr(record, column.key)
4344
if value is None or not column.readable:
4445
return None
46+
if isinstance(value, Enum) and column.enumeration is not None:
47+
return column.stored_string(column.enumeration, value)
4548
return _as_scim(value)
4649

4750

@@ -233,6 +236,7 @@ def _check_storable(
233236
if stored is not None:
234237
current = _comparable(Path(binding.urn).get(stored, strict=False))
235238
if storage == _Storage.written:
239+
_check_enumerated(mapping, binding, value)
236240
if (
237241
value is None
238242
and current is not None
@@ -256,6 +260,21 @@ def _check_storable(
256260
)
257261

258262

263+
def _check_enumerated(
264+
mapping: ResourceMapping, binding: AttributeBinding, value: Any
265+
) -> None:
266+
"""Refuse a string that an Enum column does not store, rather than failing in the database."""
267+
_, column = mapping._lookup(binding)
268+
if column is None or column.enumeration is None:
269+
return
270+
stored = column.enumeration.enums
271+
for item in value if isinstance(value, list) else [value]:
272+
if item is not None and item not in stored:
273+
raise InvalidValueException(
274+
detail=f"'{binding.urn}' cannot hold {item!r} on this server"
275+
)
276+
277+
259278
def _comparable(value: Any) -> Any:
260279
"""Return a value in a form two resources compare in.
261280

‎src/scim2_sqlalchemy/mapping.py‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from scim2_models import Schema
1818
from scim2_models import Uniqueness
1919
from sqlalchemy import ColumnElement
20+
from sqlalchemy import Enum as SqlEnum
2021
from sqlalchemy import String
2122
from sqlalchemy import cast
2223
from sqlalchemy import inspect
@@ -152,13 +153,32 @@ def nullable(self) -> bool:
152153
columns = getattr(getattr(self.expression, "property", None), "columns", None)
153154
return columns is None or bool(columns[0].nullable)
154155

156+
@property
157+
def enumeration(self) -> SqlEnum | None:
158+
"""The type of the column when it is an Enum, which stores strings."""
159+
sql_type = self.expression.type
160+
return sql_type if isinstance(sql_type, SqlEnum) else None
161+
155162
@property
156163
def compared(self) -> ColumnElement[Any]:
157-
"""The expression filters compare."""
158-
if self.textual:
164+
"""The expression filters and sorts compare.
165+
166+
An Enum column is compared as text. PostgreSQL applies no string
167+
function to its native enumerations, and sorts them in the order of
168+
their declaration.
169+
"""
170+
if self.textual or self.enumeration is not None:
159171
return cast(self.expression, String)
160172
return self.expression
161173

174+
@staticmethod
175+
def stored_string(enumeration: SqlEnum, member: Enum) -> str:
176+
"""Return the string an Enum column stores for a member: its name, or its value with values_callable."""
177+
strings: dict[Enum, str] = dict(
178+
zip(type(member), enumeration.enums, strict=False)
179+
)
180+
return strings[member]
181+
162182

163183
@dataclass(eq=False)
164184
class _Collection:

‎src/scim2_sqlalchemy/query.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ def visit_value_path(self, node: ValuePath) -> ColumnElement[bool]:
183183

184184
def _present(column: _Column) -> ColumnElement[bool]:
185185
"""Per RFC 7644 §3.4.2.2, an empty string is no value."""
186-
expression = column.expression
186+
expression = column.compared
187187
if column.is_string:
188188
return and_(expression.is_not(None), expression != "")
189189
return expression.is_not(None)

‎tests/test_enum.py‎

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import datetime
2+
import enum
3+
import uuid
4+
5+
import pytest
6+
from scim2_models import InvalidValueException
7+
from scim2_models import SearchRequest
8+
from scim2_models import User
9+
from sqlalchemy import Enum
10+
from sqlalchemy import ForeignKey
11+
from sqlalchemy import create_engine
12+
from sqlalchemy.orm import DeclarativeBase
13+
from sqlalchemy.orm import Mapped
14+
from sqlalchemy.orm import mapped_column
15+
from sqlalchemy.orm import relationship
16+
17+
from scim2_sqlalchemy import Many
18+
from scim2_sqlalchemy import ResourceMapping
19+
20+
21+
class Color(enum.Enum):
22+
Red = "Rouge"
23+
blue = "bleu"
24+
Green = "Vert"
25+
26+
27+
class Base(DeclarativeBase):
28+
pass
29+
30+
31+
class ColoredUser(Base):
32+
"""A user whose title column stores the names of the members, and whose external identifier stores their values."""
33+
34+
__tablename__ = "colored_users"
35+
36+
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
37+
user_name: Mapped[str] = mapped_column(unique=True)
38+
title: Mapped[Color | None] = mapped_column(Enum(Color, name="title_color"))
39+
external_id: Mapped[Color | None] = mapped_column(
40+
Enum(
41+
Color,
42+
name="external_color",
43+
values_callable=lambda members: [member.value for member in members],
44+
)
45+
)
46+
created: Mapped[datetime.datetime]
47+
last_modified: Mapped[datetime.datetime]
48+
version: Mapped[int] = mapped_column()
49+
50+
emails: Mapped[list["ColoredEmail"]] = relationship(
51+
cascade="all, delete-orphan", order_by="ColoredEmail.id"
52+
)
53+
54+
__mapper_args__ = {"version_id_col": version}
55+
56+
57+
class ColoredEmail(Base):
58+
__tablename__ = "colored_emails"
59+
60+
id: Mapped[int] = mapped_column(primary_key=True)
61+
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("colored_users.id"))
62+
value: Mapped[str]
63+
type: Mapped[Color | None] = mapped_column(Enum(Color, name="email_color"))
64+
65+
66+
COLORED_USERS = ResourceMapping(
67+
User,
68+
ColoredUser,
69+
{
70+
"id": ColoredUser.id,
71+
"userName": ColoredUser.user_name,
72+
"title": ColoredUser.title,
73+
"externalId": ColoredUser.external_id,
74+
"emails": Many(
75+
ColoredUser.emails,
76+
{"value": ColoredEmail.value, "type": ColoredEmail.type},
77+
),
78+
"meta.created": ColoredUser.created,
79+
"meta.lastModified": ColoredUser.last_modified,
80+
},
81+
version=ColoredUser.version,
82+
)
83+
84+
85+
@pytest.fixture
86+
def storage(database_url, storage_factory, user_type):
87+
"""Return a storage of users with enumerated columns: a with Red, b with blue, c with Green, d without."""
88+
engine = create_engine(database_url)
89+
Base.metadata.drop_all(engine)
90+
Base.metadata.create_all(engine)
91+
engine.dispose()
92+
storage = storage_factory(mappings={"User": COLORED_USERS})
93+
for user_name, title, external_id in [
94+
("a", "Red", "Rouge"),
95+
("b", "blue", "bleu"),
96+
("c", "Green", "Vert"),
97+
("d", None, None),
98+
]:
99+
emails = [{"value": f"{user_name}@example.org", "type": title}]
100+
storage.create(
101+
user_type,
102+
User(
103+
user_name=user_name,
104+
title=title,
105+
external_id=external_id,
106+
emails=emails,
107+
),
108+
)
109+
return storage
110+
111+
112+
def user_names(storage, user_type, **parameters):
113+
_, resources = storage.search([user_type], SearchRequest(**parameters))
114+
return [resource.user_name for resource in resources]
115+
116+
117+
def test_an_enumerated_column_reads_back_the_string_it_stores(storage, user_type):
118+
"""A resource holds the string the column stores: the name of the member, or its value with values_callable."""
119+
_, (user,) = storage.search([user_type], SearchRequest(filter='userName eq "a"'))
120+
121+
assert user.title == "Red"
122+
assert user.external_id == "Rouge"
123+
assert user.emails[0].type == "Red"
124+
125+
126+
@pytest.mark.parametrize(
127+
("scim_filter", "expected"),
128+
[
129+
('title eq "blue"', ["b"]),
130+
('title eq "BLUE"', ["b"]),
131+
('title eq "bleu"', []),
132+
('title gt "h"', ["a"]),
133+
('title co "e"', ["a", "b", "c"]),
134+
("title pr", ["a", "b", "c"]),
135+
('externalId eq "bleu"', ["b"]),
136+
('externalId eq "Bleu"', []),
137+
('externalId gt "S"', ["b", "c"]),
138+
('emails[type eq "green"]', ["c"]),
139+
("emails.type pr", ["a", "b", "c"]),
140+
],
141+
)
142+
def test_an_enumerated_column_is_filtered_on_the_string_it_stores(
143+
storage, user_type, scim_filter, expected
144+
):
145+
"""A filter compares the string the resource holds, as for any string column."""
146+
assert sorted(user_names(storage, user_type, filter=scim_filter)) == expected
147+
148+
149+
@pytest.mark.parametrize(
150+
("sort_by", "sort_order", "expected"),
151+
[
152+
("title", "ascending", ["b", "c", "a", "d"]),
153+
("title", "descending", ["d", "a", "c", "b"]),
154+
("externalId", "ascending", ["a", "c", "b", "d"]),
155+
],
156+
)
157+
def test_an_enumerated_column_sorts_on_the_string_it_stores(
158+
storage, user_type, sort_by, sort_order, expected
159+
):
160+
"""The order is the one of the strings, and not the order the members are declared in."""
161+
assert (
162+
user_names(storage, user_type, sort_by=sort_by, sort_order=sort_order)
163+
== expected
164+
)
165+
166+
167+
@pytest.mark.parametrize(
168+
"user",
169+
[
170+
User(user_name="e", title="Purple"),
171+
User(user_name="e", title="Rouge"),
172+
User(user_name="e", external_id="Red"),
173+
User(user_name="e", emails=[{"value": "e@example.org", "type": "Purple"}]),
174+
],
175+
)
176+
def test_an_enumerated_column_refuses_a_string_it_does_not_store(
177+
storage, user_type, user
178+
):
179+
"""A string outside the enumeration is refused with a 400, rather than failing in the database."""
180+
with pytest.raises(InvalidValueException):
181+
storage.create(user_type, user)

0 commit comments

Comments
 (0)