Skip to content

Commit bf417b5

Browse files
committed
new ordering class
1 parent 1434857 commit bf417b5

File tree

2 files changed

+146
-27
lines changed

2 files changed

+146
-27
lines changed

packages/models-library/src/models_library/rest_ordering.py

Lines changed: 99 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,41 @@
1-
from enum import Enum
2-
from typing import Annotated
1+
from typing import Annotated, Generic
32

3+
from common_library.basic_types import DEFAULT_FACTORY
44
from common_library.json_serialization import json_dumps
5-
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, field_validator
5+
from pydantic import (
6+
BaseModel,
7+
BeforeValidator,
8+
ConfigDict,
9+
Field,
10+
field_validator,
11+
)
612

713
from .basic_types import IDStr
14+
from .list_operations import OrderClause, OrderDirection, TField, check_ordering_list
815
from .rest_base import RequestParameters
9-
from .utils.common_validators import parse_json_pre_validator
16+
from .utils.common_validators import (
17+
parse_json_pre_validator,
18+
)
1019

11-
12-
class OrderDirection(str, Enum):
13-
ASC = "asc"
14-
DESC = "desc"
20+
__all__: tuple[str, ...] = ("OrderDirection",)
1521

1622

1723
class OrderBy(BaseModel):
18-
# Based on https://google.aip.dev/132#ordering
19-
field: IDStr = Field(..., description="field name identifier")
20-
direction: OrderDirection = Field(
21-
default=OrderDirection.ASC,
22-
description=(
23-
f"As [A,B,C,...] if `{OrderDirection.ASC.value}`"
24-
f" or [Z,Y,X, ...] if `{OrderDirection.DESC.value}`"
24+
# NOTE: use instead OrderClause[TField] where TField is Literal of valid fields
25+
field: Annotated[IDStr, Field(description="field name identifier")]
26+
direction: Annotated[
27+
OrderDirection,
28+
Field(
29+
description=(
30+
f"As [A,B,C,...] if `{OrderDirection.ASC.value}`"
31+
f" or [Z,Y,X, ...] if `{OrderDirection.DESC.value}`"
32+
)
2533
),
26-
)
34+
] = OrderDirection.ASC
2735

2836

2937
class _BaseOrderQueryParams(RequestParameters):
38+
# Use OrderingQueryParams instead for more flexible ordering
3039
order_by: OrderBy
3140

3241

@@ -91,30 +100,93 @@ def _check_ordering_field_and_map(cls, v):
91100
return _ordering_fields_api_to_column_map.get(v) or v
92101

93102
assert "json_schema_extra" in _OrderBy.model_config # nosec
94-
assert isinstance(_OrderBy.model_config["json_schema_extra"], dict) # nosec
95-
assert isinstance( # nosec
96-
_OrderBy.model_config["json_schema_extra"]["examples"], list
97-
)
98-
order_by_example = _OrderBy.model_config["json_schema_extra"]["examples"][0]
103+
104+
order_by_example = _OrderBy.model_json_schema()["examples"][0]
99105
order_by_example_json = json_dumps(order_by_example)
106+
100107
assert _OrderBy.model_validate(order_by_example), "Example is invalid" # nosec
101108

102109
converted_default = _OrderBy.model_validate(
103110
# NOTE: enforces ordering_fields_api_to_column_map
104111
default.model_dump()
105112
)
106113

107-
class _OrderQueryParams(_BaseOrderQueryParams):
108-
order_by: Annotated[_OrderBy, BeforeValidator(parse_json_pre_validator)] = (
114+
class _OrderJsonQueryParams(_BaseOrderQueryParams):
115+
order_by: Annotated[
116+
_OrderBy,
117+
BeforeValidator(parse_json_pre_validator),
109118
Field(
110-
default=converted_default,
111119
description=(
112120
f"Order by field (`{msg_field_options}`) and direction (`{msg_direction_options}`). "
113121
f"The default sorting order is `{json_dumps(default)}`."
114122
),
115123
examples=[order_by_example],
116124
json_schema_extra={"example_json": order_by_example_json},
117-
)
118-
)
125+
),
126+
] = converted_default
127+
128+
return _OrderJsonQueryParams
129+
130+
131+
def _parse_order_by(v):
132+
if not v:
133+
return []
134+
135+
if isinstance(v, list):
136+
v = ",".join(v)
137+
138+
if not isinstance(v, str):
139+
msg = "order_by must be a string"
140+
raise TypeError(msg)
119141

120-
return _OrderQueryParams
142+
# 1. from comma-separated string to list of OrderClause
143+
clauses = []
144+
for t in v.split(","):
145+
token = t.strip()
146+
if not token:
147+
continue
148+
if token.startswith("-"):
149+
clauses.append((token[1:], OrderDirection.DESC))
150+
elif token.startswith("+"):
151+
clauses.append((token[1:], OrderDirection.ASC))
152+
else:
153+
clauses.append((token, OrderDirection.ASC))
154+
155+
# 2. check for duplicates and conflicting directions
156+
return [
157+
{"field": field, "direction": direction}
158+
for field, direction in check_ordering_list(clauses)
159+
]
160+
161+
162+
class OrderingQueryParams(BaseModel, Generic[TField]):
163+
"""
164+
This class is designed to parse query parameters for ordering results in an API request.
165+
166+
It supports multiple ordering clauses and allows for flexible sorting options.
167+
168+
NOTE: It only parses strings and validates into list[OrderClause[TField]]
169+
where TField is a type variable representing valid field names.
170+
171+
172+
For example:
173+
174+
/my/path?order_by=field1,-field2,+field3
175+
176+
would sort by field1 ascending, field2 descending, and field3 ascending.
177+
"""
178+
179+
order_by: Annotated[
180+
list[OrderClause[TField]],
181+
BeforeValidator(_parse_order_by),
182+
Field(default_factory=list),
183+
] = DEFAULT_FACTORY
184+
185+
model_config = ConfigDict(
186+
json_schema_extra={
187+
"examples": [
188+
{"order_by": "-created_at,name,+gender"},
189+
{"order_by": ""},
190+
],
191+
}
192+
)

packages/models-library/tests/test_rest_ordering.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import pickle
2+
from typing import Literal
23

34
import pytest
45
from common_library.json_serialization import json_dumps
56
from models_library.basic_types import IDStr
67
from models_library.rest_ordering import (
78
OrderBy,
9+
OrderClause,
810
OrderDirection,
11+
OrderingQueryParams,
912
create_ordering_query_model_class,
1013
)
1114
from pydantic import (
@@ -236,3 +239,47 @@ def test_ordering_query_parse_json_pre_validator():
236239
assert error["loc"] == ("order_by",)
237240
assert error["type"] == "value_error"
238241
assert error["input"] == bad_json_value
242+
243+
244+
def test_ordering_query_params_parsing():
245+
"""Test OrderingQueryParams parsing from URL query format like ?order_by=-created_at,name,+gender"""
246+
247+
# Define allowed fields using Literal type
248+
ValidField = Literal["created_at", "name", "gender"]
249+
250+
class TestOrderingParams(OrderingQueryParams[ValidField]):
251+
pass
252+
253+
# Test parsing from comma-separated string
254+
params = TestOrderingParams.model_validate({"order_by": "-created_at,name,+gender"})
255+
256+
assert params.order_by == [
257+
OrderClause[ValidField](field="created_at", direction=OrderDirection.DESC),
258+
OrderClause[ValidField](field="name", direction=OrderDirection.ASC),
259+
OrderClause[ValidField](field="gender", direction=OrderDirection.ASC),
260+
]
261+
262+
263+
def test_ordering_query_params_validation_error_with_invalid_fields():
264+
"""Test that OrderingQueryParams raises ValidationError when invalid fields are used"""
265+
266+
# Define allowed fields using Literal type
267+
ValidField = Literal["created_at", "name"]
268+
269+
class TestOrderingParams(OrderingQueryParams[ValidField]):
270+
pass
271+
272+
# Test with invalid field should raise ValidationError
273+
with pytest.raises(ValidationError) as err_info:
274+
TestOrderingParams.model_validate(
275+
{"order_by": "-created_at,invalid_field,name"}
276+
)
277+
278+
# Verify the validation error details
279+
exc = err_info.value
280+
assert exc.error_count() == 1
281+
282+
error = exc.errors()[0]
283+
assert error["loc"] == ("order_by", 1, "field")
284+
assert error["type"] == "literal_error"
285+
assert error["input"] == "invalid_field"

0 commit comments

Comments
 (0)