Fast-Controller 0.4.0b0__tar.gz → 0.4.2b0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {fast_controller-0.4.0b0 → fast_controller-0.4.2b0}/PKG-INFO +1 -1
- {fast_controller-0.4.0b0 → fast_controller-0.4.2b0}/fast_controller/__init__.py +10 -3
- {fast_controller-0.4.0b0 → fast_controller-0.4.2b0}/fast_controller/resource.py +28 -24
- {fast_controller-0.4.0b0 → fast_controller-0.4.2b0}/fast_controller/util.py +39 -0
- {fast_controller-0.4.0b0 → fast_controller-0.4.2b0}/pyproject.toml +1 -1
- {fast_controller-0.4.0b0 → fast_controller-0.4.2b0}/tests/test_resource.py +2 -2
- {fast_controller-0.4.0b0 → fast_controller-0.4.2b0}/tests/test_util.py +70 -1
- {fast_controller-0.4.0b0 → fast_controller-0.4.2b0}/README.md +0 -0
- {fast_controller-0.4.0b0 → fast_controller-0.4.2b0}/tests/test_controller.py +0 -0
|
@@ -14,7 +14,7 @@ from sqlalchemy.orm import sessionmaker
|
|
|
14
14
|
from sqlmodel import SQLModel
|
|
15
15
|
|
|
16
16
|
from fast_controller.resource import Resource, get_field_type
|
|
17
|
-
from fast_controller.util import docstring_format, InvalidInput, expose_path_params, extract_values, inflect
|
|
17
|
+
from fast_controller.util import docstring_format, InvalidInput, expose_path_params, extract_values, inflect, to_condition_operator
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
class Action(Enum):
|
|
@@ -59,9 +59,13 @@ def _register_search_endpoint(controller, router: APIRouter, resource: type[Reso
|
|
|
59
59
|
filters: resource.get_search_schema() = Query(),
|
|
60
60
|
x_page: Optional[int] = Header(default=None, gt=0),
|
|
61
61
|
x_per_page: Optional[int] = Header(default=None, gt=0),
|
|
62
|
+
x_order: Optional[str] = Header(default=None),
|
|
63
|
+
x_duplicate: Optional[str] = Header(default=None), # TODO: move to filters
|
|
64
|
+
x_unique: Optional[str] = Header(default=None), # TODO: move to filters
|
|
62
65
|
daos: DAOFactory = controller.daos) -> list[DAOModel]:
|
|
63
66
|
"""Searches for {resource} by criteria"""
|
|
64
|
-
|
|
67
|
+
filters = {col: to_condition_operator(val) for col, val in filters.model_dump(exclude_unset=True)}
|
|
68
|
+
results = daos[resource].find(x_page, x_per_page, x_order, x_duplicate, x_unique, **filters)
|
|
65
69
|
response.headers["x-total-count"] = str(results.total)
|
|
66
70
|
response.headers["x-page"] = str(results.page)
|
|
67
71
|
response.headers["x-per-page"] = str(results.per_page)
|
|
@@ -274,12 +278,15 @@ class Controller:
|
|
|
274
278
|
def dependencies_for(self, resource: type[Resource], action: Action) -> list[Depends]:
|
|
275
279
|
return []
|
|
276
280
|
|
|
281
|
+
def get_path_for(self, resource: type[Resource]) -> str:
|
|
282
|
+
return self.prefix + resource.get_resource_path()
|
|
283
|
+
|
|
277
284
|
def register_resource(self,
|
|
278
285
|
resource: type[Resource],
|
|
279
286
|
skip: Optional[set[Action]] = frozenset(),
|
|
280
287
|
additional_endpoints: Optional[Callable] = None) -> None:
|
|
281
288
|
api_router = APIRouter(
|
|
282
|
-
prefix=self.
|
|
289
|
+
prefix=self.get_path_for(resource),
|
|
283
290
|
tags=[resource.resource_name()])
|
|
284
291
|
self._register_resource_endpoints(api_router, resource, skip)
|
|
285
292
|
if additional_endpoints:
|
|
@@ -2,26 +2,26 @@ from inspect import isclass
|
|
|
2
2
|
from typing import Any
|
|
3
3
|
|
|
4
4
|
from daomodel import DAOModel
|
|
5
|
-
from pydantic import create_model
|
|
5
|
+
from pydantic import create_model, BaseModel
|
|
6
6
|
from str_case_util import Case
|
|
7
7
|
|
|
8
8
|
from fast_controller.util import inflect
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
def either(preferred: Any, default: type[
|
|
11
|
+
def either(preferred: Any, default: type[BaseModel]) -> type[BaseModel]:
|
|
12
12
|
"""Returns the preferred type if present, otherwise the default type.
|
|
13
13
|
|
|
14
14
|
:param preferred: The type to return if not None
|
|
15
15
|
:param default: The type to return if the preferred is not a model
|
|
16
16
|
:return: either the preferred type or the default type
|
|
17
17
|
"""
|
|
18
|
-
return preferred if isclass(preferred) and issubclass(preferred,
|
|
18
|
+
return preferred if isclass(preferred) and issubclass(preferred, BaseModel) else default
|
|
19
19
|
|
|
20
20
|
|
|
21
21
|
def get_field_type(field) -> type:
|
|
22
22
|
"""Returns the equivalent type for the given field.
|
|
23
23
|
|
|
24
|
-
:param field: The Column of
|
|
24
|
+
:param field: The Column of a model
|
|
25
25
|
:return: the Python type used to represent the DB Column value
|
|
26
26
|
"""
|
|
27
27
|
return getattr(field.type, 'impl', field.type).python_type
|
|
@@ -29,11 +29,11 @@ def get_field_type(field) -> type:
|
|
|
29
29
|
|
|
30
30
|
class Resource(DAOModel):
|
|
31
31
|
__abstract__ = True
|
|
32
|
-
_default_schema: type[
|
|
33
|
-
_input_schema: type[
|
|
34
|
-
_update_schema: type[
|
|
35
|
-
_output_schema: type[
|
|
36
|
-
_detailed_output_schema: type[
|
|
32
|
+
_default_schema: type[BaseModel]
|
|
33
|
+
_input_schema: type[BaseModel]
|
|
34
|
+
_update_schema: type[BaseModel]
|
|
35
|
+
_output_schema: type[BaseModel]
|
|
36
|
+
_detailed_output_schema: type[BaseModel]
|
|
37
37
|
|
|
38
38
|
@classmethod
|
|
39
39
|
def resource_name(cls):
|
|
@@ -60,8 +60,8 @@ class Resource(DAOModel):
|
|
|
60
60
|
return True
|
|
61
61
|
|
|
62
62
|
@classmethod
|
|
63
|
-
def get_search_schema(cls) -> type[
|
|
64
|
-
"""Returns
|
|
63
|
+
def get_search_schema(cls) -> type[BaseModel]:
|
|
64
|
+
"""Returns a BaseModel representing the searchable fields"""
|
|
65
65
|
def get_field_name(field) -> str:
|
|
66
66
|
"""Constructs the field's name, optionally prepending the table name."""
|
|
67
67
|
field_name = field.name
|
|
@@ -78,53 +78,57 @@ class Resource(DAOModel):
|
|
|
78
78
|
)
|
|
79
79
|
|
|
80
80
|
@classmethod
|
|
81
|
-
def get_pk_schema(cls) -> type[
|
|
82
|
-
"""Returns
|
|
81
|
+
def get_pk_schema(cls) -> type[BaseModel]:
|
|
82
|
+
"""Returns a BaseModel representing the primary key fields"""
|
|
83
83
|
return create_model(
|
|
84
84
|
f'{cls.doc_name()}PKSchema',
|
|
85
85
|
**{field.name: (get_field_type(field), ...) for field in cls.get_pk()}
|
|
86
86
|
)
|
|
87
87
|
|
|
88
88
|
@classmethod
|
|
89
|
-
def get_base(cls) -> type[
|
|
89
|
+
def get_base(cls) -> type[BaseModel]:
|
|
90
90
|
return cls
|
|
91
91
|
|
|
92
92
|
@classmethod
|
|
93
|
-
def set_default_schema(cls, schema: type[
|
|
93
|
+
def set_default_schema(cls, schema: type[BaseModel]) -> None:
|
|
94
94
|
cls._default_schema = schema
|
|
95
95
|
|
|
96
96
|
@classmethod
|
|
97
|
-
def get_default_schema(cls) -> type[
|
|
97
|
+
def get_default_schema(cls) -> type[BaseModel]:
|
|
98
98
|
return either(cls._default_schema, cls)
|
|
99
99
|
|
|
100
100
|
@classmethod
|
|
101
|
-
def set_input_schema(cls, schema: type[
|
|
101
|
+
def set_input_schema(cls, schema: type[BaseModel]) -> None:
|
|
102
102
|
cls._input_schema = schema
|
|
103
103
|
|
|
104
104
|
@classmethod
|
|
105
|
-
def get_input_schema(cls) -> type[
|
|
105
|
+
def get_input_schema(cls) -> type[BaseModel]:
|
|
106
106
|
return either(cls._input_schema, cls.get_default_schema())
|
|
107
107
|
|
|
108
108
|
@classmethod
|
|
109
|
-
def set_update_schema(cls, schema: type[
|
|
109
|
+
def set_update_schema(cls, schema: type[BaseModel]) -> None:
|
|
110
110
|
cls._update_schema = schema
|
|
111
111
|
|
|
112
112
|
@classmethod
|
|
113
|
-
def get_update_schema(cls) -> type[
|
|
113
|
+
def get_update_schema(cls) -> type[BaseModel]:
|
|
114
114
|
return either(cls._update_schema, cls.get_input_schema())
|
|
115
115
|
|
|
116
116
|
@classmethod
|
|
117
|
-
def set_output_schema(cls, schema: type[
|
|
117
|
+
def set_output_schema(cls, schema: type[BaseModel]) -> None:
|
|
118
118
|
cls._output_schema = schema
|
|
119
119
|
|
|
120
120
|
@classmethod
|
|
121
|
-
def get_output_schema(cls) -> type[
|
|
121
|
+
def get_output_schema(cls) -> type[BaseModel]:
|
|
122
122
|
return either(cls._output_schema, cls.get_default_schema())
|
|
123
123
|
|
|
124
124
|
@classmethod
|
|
125
|
-
def set_detailed_output_schema(cls, schema: type[
|
|
125
|
+
def set_detailed_output_schema(cls, schema: type[BaseModel]) -> None:
|
|
126
126
|
cls._detailed_output_schema = schema
|
|
127
127
|
|
|
128
128
|
@classmethod
|
|
129
|
-
def get_detailed_output_schema(cls) -> type[
|
|
129
|
+
def get_detailed_output_schema(cls) -> type[BaseModel]:
|
|
130
130
|
return either(cls._detailed_output_schema, cls.get_output_schema())
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Schema(BaseModel):
|
|
134
|
+
pass
|
|
@@ -3,6 +3,7 @@ from typing import Callable, get_type_hints, Optional
|
|
|
3
3
|
from warnings import deprecated
|
|
4
4
|
|
|
5
5
|
import inflect as _inflect
|
|
6
|
+
from daomodel.search_util import *
|
|
6
7
|
from sqlmodel import SQLModel
|
|
7
8
|
|
|
8
9
|
|
|
@@ -79,3 +80,41 @@ def extract_values(kwargs: dict, field_names: list[str]) -> list:
|
|
|
79
80
|
:return: List of values in the same order as field_names
|
|
80
81
|
"""
|
|
81
82
|
return [kwargs[field] for field in field_names]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def to_condition_operator(value: str) -> ConditionOperator:
|
|
86
|
+
"""Maps a value with a potential operator prefix (e.g. 'lt:', 'contains:') to a ConditionOperator.
|
|
87
|
+
|
|
88
|
+
:param value: The query value with optional operator prefix
|
|
89
|
+
:return: The ConditionOperator defined by the prefix
|
|
90
|
+
"""
|
|
91
|
+
op, part = None, None
|
|
92
|
+
if ':' in value:
|
|
93
|
+
op, value = value.split(':', 1)
|
|
94
|
+
if '_' in op:
|
|
95
|
+
part, op = op.split('_', 1)
|
|
96
|
+
match op: # TODO: support contains, starts, and ends
|
|
97
|
+
case 'lt':
|
|
98
|
+
return LessThan(value, _part=part)
|
|
99
|
+
case 'le':
|
|
100
|
+
return LessThanEqualTo(value, _part=part)
|
|
101
|
+
case 'gt':
|
|
102
|
+
return GreaterThan(value, _part=part)
|
|
103
|
+
case 'ge':
|
|
104
|
+
return GreaterThanEqualTo(value, _part=part)
|
|
105
|
+
case 'between':
|
|
106
|
+
return Between(*value.split('|', 1), _part=part)
|
|
107
|
+
case 'anyof':
|
|
108
|
+
return AnyOf(*value.split('|'), _part=part)
|
|
109
|
+
case 'noneof':
|
|
110
|
+
return NoneOf(*value.split('|'), _part=part)
|
|
111
|
+
case 'is':
|
|
112
|
+
match value:
|
|
113
|
+
case 'set':
|
|
114
|
+
return IsSet()
|
|
115
|
+
case 'notset':
|
|
116
|
+
return NotSet()
|
|
117
|
+
case _:
|
|
118
|
+
return Equals(value, _part=part)
|
|
119
|
+
case _:
|
|
120
|
+
return Equals(value, _part=part)
|
|
@@ -33,9 +33,9 @@ def test_either(preferred: Any, default: type[SQLModel], expected: type[SQLModel
|
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def test_get_path():
|
|
36
|
-
class
|
|
36
|
+
class Class(Resource):
|
|
37
37
|
pass
|
|
38
|
-
assert
|
|
38
|
+
assert Class.get_resource_path() == "/api/classes"
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
class Author(Resource, table=True):
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import inspect
|
|
2
2
|
from unittest.mock import Mock
|
|
3
3
|
|
|
4
|
+
from daomodel.search_util import *
|
|
5
|
+
|
|
4
6
|
from fast_controller import docstring_format
|
|
5
|
-
from fast_controller.util import expose_path_params, extract_values
|
|
7
|
+
from fast_controller.util import expose_path_params, extract_values, to_condition_operator
|
|
6
8
|
|
|
7
9
|
|
|
8
10
|
@docstring_format(key="value")
|
|
@@ -182,3 +184,70 @@ def test_extract_values__different_types():
|
|
|
182
184
|
expected = ["string_value", 123, True, [1, 2, 3], None]
|
|
183
185
|
|
|
184
186
|
assert extract_values(kwargs, field_names) == expected
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_to_condition_operator__equals():
|
|
190
|
+
op = to_condition_operator("value")
|
|
191
|
+
assert isinstance(op, Equals)
|
|
192
|
+
assert op.values == ("value",)
|
|
193
|
+
|
|
194
|
+
op = to_condition_operator("is:value")
|
|
195
|
+
assert isinstance(op, Equals)
|
|
196
|
+
assert op.values == ("value",)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_to_condition_operator__lt():
|
|
200
|
+
op = to_condition_operator("lt:5")
|
|
201
|
+
assert isinstance(op, LessThan)
|
|
202
|
+
assert op.values == ("5",)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_to_condition_operator__le():
|
|
206
|
+
op = to_condition_operator("le:5")
|
|
207
|
+
assert isinstance(op, LessThanEqualTo)
|
|
208
|
+
assert op.values == ("5",)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_to_condition_operator__gt():
|
|
212
|
+
op = to_condition_operator("gt:5")
|
|
213
|
+
assert isinstance(op, GreaterThan)
|
|
214
|
+
assert op.values == ("5",)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def test_to_condition_operator__ge():
|
|
218
|
+
op = to_condition_operator("ge:5")
|
|
219
|
+
assert isinstance(op, GreaterThanEqualTo)
|
|
220
|
+
assert op.values == ("5",)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def test_to_condition_operator__between():
|
|
224
|
+
op = to_condition_operator("between:1|5")
|
|
225
|
+
assert isinstance(op, Between)
|
|
226
|
+
assert op.values == ("1", "5")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_to_condition_operator__anyof():
|
|
230
|
+
op = to_condition_operator("anyof:1|2|3")
|
|
231
|
+
assert isinstance(op, AnyOf)
|
|
232
|
+
assert op.values == ("1", "2", "3")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def test_to_condition_operator__noneof():
|
|
236
|
+
op = to_condition_operator("noneof:1|2|3")
|
|
237
|
+
assert isinstance(op, NoneOf)
|
|
238
|
+
assert op.values == ("1", "2", "3")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def test_to_condition_operator__isset():
|
|
242
|
+
assert isinstance(to_condition_operator("is:set"), IsSet)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_to_condition_operator__notset():
|
|
246
|
+
assert isinstance(to_condition_operator("is:notset"), NotSet)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_to_condition_operator__part():
|
|
250
|
+
op = to_condition_operator("part_is:value")
|
|
251
|
+
assert isinstance(op, Equals)
|
|
252
|
+
assert op.values == ("value",)
|
|
253
|
+
assert op.part == "part"
|
|
File without changes
|
|
File without changes
|