clear-skies 1.19.22__py3-none-any.whl → 2.0.23__py3-none-any.whl
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.
- clear_skies-2.0.23.dist-info/METADATA +76 -0
- clear_skies-2.0.23.dist-info/RECORD +265 -0
- {clear_skies-1.19.22.dist-info → clear_skies-2.0.23.dist-info}/WHEEL +1 -1
- clearskies/__init__.py +37 -21
- clearskies/action.py +7 -0
- clearskies/authentication/__init__.py +9 -38
- clearskies/authentication/authentication.py +44 -0
- clearskies/authentication/authorization.py +14 -8
- clearskies/authentication/authorization_pass_through.py +22 -0
- clearskies/authentication/jwks.py +135 -58
- clearskies/authentication/public.py +3 -26
- clearskies/authentication/secret_bearer.py +515 -44
- clearskies/autodoc/formats/oai3_json/__init__.py +2 -2
- clearskies/autodoc/formats/oai3_json/oai3_json.py +11 -9
- clearskies/autodoc/formats/oai3_json/parameter.py +6 -3
- clearskies/autodoc/formats/oai3_json/request.py +7 -5
- clearskies/autodoc/formats/oai3_json/response.py +7 -4
- clearskies/autodoc/formats/oai3_json/schema/object.py +10 -1
- clearskies/autodoc/request/__init__.py +2 -0
- clearskies/autodoc/request/header.py +4 -6
- clearskies/autodoc/request/json_body.py +4 -6
- clearskies/autodoc/request/parameter.py +8 -0
- clearskies/autodoc/request/request.py +16 -4
- clearskies/autodoc/request/url_parameter.py +4 -6
- clearskies/autodoc/request/url_path.py +4 -6
- clearskies/autodoc/schema/__init__.py +4 -2
- clearskies/autodoc/schema/array.py +5 -6
- clearskies/autodoc/schema/boolean.py +4 -10
- clearskies/autodoc/schema/date.py +0 -3
- clearskies/autodoc/schema/datetime.py +1 -4
- clearskies/autodoc/schema/double.py +0 -3
- clearskies/autodoc/schema/enum.py +4 -2
- clearskies/autodoc/schema/integer.py +4 -9
- clearskies/autodoc/schema/long.py +0 -3
- clearskies/autodoc/schema/number.py +4 -9
- clearskies/autodoc/schema/object.py +5 -7
- clearskies/autodoc/schema/password.py +0 -3
- clearskies/autodoc/schema/schema.py +11 -0
- clearskies/autodoc/schema/string.py +4 -10
- clearskies/backends/__init__.py +56 -17
- clearskies/backends/api_backend.py +1128 -166
- clearskies/backends/backend.py +54 -85
- clearskies/backends/cursor_backend.py +246 -191
- clearskies/backends/memory_backend.py +514 -208
- clearskies/backends/secrets_backend.py +68 -31
- clearskies/column.py +1221 -0
- clearskies/columns/__init__.py +71 -0
- clearskies/columns/audit.py +306 -0
- clearskies/columns/belongs_to_id.py +478 -0
- clearskies/columns/belongs_to_model.py +129 -0
- clearskies/columns/belongs_to_self.py +109 -0
- clearskies/columns/boolean.py +110 -0
- clearskies/columns/category_tree.py +273 -0
- clearskies/columns/category_tree_ancestors.py +51 -0
- clearskies/columns/category_tree_children.py +126 -0
- clearskies/columns/category_tree_descendants.py +48 -0
- clearskies/columns/created.py +92 -0
- clearskies/columns/created_by_authorization_data.py +114 -0
- clearskies/columns/created_by_header.py +103 -0
- clearskies/columns/created_by_ip.py +90 -0
- clearskies/columns/created_by_routing_data.py +102 -0
- clearskies/columns/created_by_user_agent.py +89 -0
- clearskies/columns/date.py +232 -0
- clearskies/columns/datetime.py +284 -0
- clearskies/columns/email.py +78 -0
- clearskies/columns/float.py +149 -0
- clearskies/columns/has_many.py +529 -0
- clearskies/columns/has_many_self.py +62 -0
- clearskies/columns/has_one.py +21 -0
- clearskies/columns/integer.py +158 -0
- clearskies/columns/json.py +126 -0
- clearskies/columns/many_to_many_ids.py +335 -0
- clearskies/columns/many_to_many_ids_with_data.py +274 -0
- clearskies/columns/many_to_many_models.py +156 -0
- clearskies/columns/many_to_many_pivots.py +132 -0
- clearskies/columns/phone.py +162 -0
- clearskies/columns/select.py +95 -0
- clearskies/columns/string.py +102 -0
- clearskies/columns/timestamp.py +164 -0
- clearskies/columns/updated.py +107 -0
- clearskies/columns/uuid.py +83 -0
- clearskies/configs/README.md +105 -0
- clearskies/configs/__init__.py +170 -0
- clearskies/configs/actions.py +43 -0
- clearskies/configs/any.py +15 -0
- clearskies/configs/any_dict.py +24 -0
- clearskies/configs/any_dict_or_callable.py +25 -0
- clearskies/configs/authentication.py +23 -0
- clearskies/configs/authorization.py +23 -0
- clearskies/configs/boolean.py +18 -0
- clearskies/configs/boolean_or_callable.py +20 -0
- clearskies/configs/callable_config.py +20 -0
- clearskies/configs/columns.py +34 -0
- clearskies/configs/conditions.py +30 -0
- clearskies/configs/config.py +26 -0
- clearskies/configs/datetime.py +20 -0
- clearskies/configs/datetime_or_callable.py +21 -0
- clearskies/configs/email.py +10 -0
- clearskies/configs/email_list.py +17 -0
- clearskies/configs/email_list_or_callable.py +17 -0
- clearskies/configs/email_or_email_list_or_callable.py +59 -0
- clearskies/configs/endpoint.py +23 -0
- clearskies/configs/endpoint_list.py +29 -0
- clearskies/configs/float.py +18 -0
- clearskies/configs/float_or_callable.py +20 -0
- clearskies/configs/headers.py +28 -0
- clearskies/configs/integer.py +18 -0
- clearskies/configs/integer_or_callable.py +20 -0
- clearskies/configs/joins.py +30 -0
- clearskies/configs/list_any_dict.py +32 -0
- clearskies/configs/list_any_dict_or_callable.py +33 -0
- clearskies/configs/model_class.py +35 -0
- clearskies/configs/model_column.py +67 -0
- clearskies/configs/model_columns.py +58 -0
- clearskies/configs/model_destination_name.py +26 -0
- clearskies/configs/model_to_id_column.py +45 -0
- clearskies/configs/readable_model_column.py +11 -0
- clearskies/configs/readable_model_columns.py +11 -0
- clearskies/configs/schema.py +23 -0
- clearskies/configs/searchable_model_columns.py +11 -0
- clearskies/configs/security_headers.py +39 -0
- clearskies/configs/select.py +28 -0
- clearskies/configs/select_list.py +49 -0
- clearskies/configs/string.py +31 -0
- clearskies/configs/string_dict.py +34 -0
- clearskies/configs/string_list.py +47 -0
- clearskies/configs/string_list_or_callable.py +48 -0
- clearskies/configs/string_or_callable.py +18 -0
- clearskies/configs/timedelta.py +20 -0
- clearskies/configs/timezone.py +20 -0
- clearskies/configs/url.py +25 -0
- clearskies/configs/validators.py +45 -0
- clearskies/configs/writeable_model_column.py +11 -0
- clearskies/configs/writeable_model_columns.py +11 -0
- clearskies/configurable.py +78 -0
- clearskies/contexts/__init__.py +8 -8
- clearskies/contexts/cli.py +129 -43
- clearskies/contexts/context.py +93 -56
- clearskies/contexts/wsgi.py +79 -33
- clearskies/contexts/wsgi_ref.py +87 -0
- clearskies/cursors/__init__.py +7 -0
- clearskies/cursors/cursor.py +166 -0
- clearskies/cursors/from_environment/__init__.py +5 -0
- clearskies/cursors/from_environment/mysql.py +51 -0
- clearskies/cursors/from_environment/postgresql.py +49 -0
- clearskies/cursors/from_environment/sqlite.py +35 -0
- clearskies/cursors/mysql.py +61 -0
- clearskies/cursors/postgresql.py +61 -0
- clearskies/cursors/sqlite.py +62 -0
- clearskies/decorators.py +33 -0
- clearskies/decorators.pyi +10 -0
- clearskies/di/__init__.py +11 -7
- clearskies/di/additional_config.py +117 -3
- clearskies/di/additional_config_auto_import.py +12 -0
- clearskies/di/di.py +717 -126
- clearskies/di/inject/__init__.py +23 -0
- clearskies/di/inject/akeyless_sdk.py +16 -0
- clearskies/di/inject/by_class.py +24 -0
- clearskies/di/inject/by_name.py +22 -0
- clearskies/di/inject/di.py +16 -0
- clearskies/di/inject/environment.py +15 -0
- clearskies/di/inject/input_output.py +19 -0
- clearskies/di/inject/now.py +16 -0
- clearskies/di/inject/requests.py +16 -0
- clearskies/di/inject/secrets.py +15 -0
- clearskies/di/inject/utcnow.py +16 -0
- clearskies/di/inject/uuid.py +16 -0
- clearskies/di/injectable.py +32 -0
- clearskies/di/injectable_properties.py +131 -0
- clearskies/end.py +219 -0
- clearskies/endpoint.py +1303 -0
- clearskies/endpoint_group.py +333 -0
- clearskies/endpoints/__init__.py +25 -0
- clearskies/endpoints/advanced_search.py +519 -0
- clearskies/endpoints/callable.py +382 -0
- clearskies/endpoints/create.py +201 -0
- clearskies/endpoints/delete.py +133 -0
- clearskies/endpoints/get.py +267 -0
- clearskies/endpoints/health_check.py +181 -0
- clearskies/endpoints/list.py +567 -0
- clearskies/endpoints/restful_api.py +417 -0
- clearskies/endpoints/schema.py +185 -0
- clearskies/endpoints/simple_search.py +279 -0
- clearskies/endpoints/update.py +188 -0
- clearskies/environment.py +7 -3
- clearskies/exceptions/__init__.py +19 -0
- clearskies/{handlers/exceptions/input_error.py → exceptions/input_errors.py} +1 -1
- clearskies/exceptions/missing_dependency.py +2 -0
- clearskies/exceptions/moved_permanently.py +3 -0
- clearskies/exceptions/moved_temporarily.py +3 -0
- clearskies/functional/__init__.py +2 -2
- clearskies/functional/json.py +47 -0
- clearskies/functional/routing.py +92 -0
- clearskies/functional/string.py +19 -11
- clearskies/functional/validations.py +61 -9
- clearskies/input_outputs/__init__.py +9 -7
- clearskies/input_outputs/cli.py +135 -152
- clearskies/input_outputs/exceptions/__init__.py +6 -1
- clearskies/input_outputs/headers.py +54 -0
- clearskies/input_outputs/input_output.py +77 -123
- clearskies/input_outputs/programmatic.py +62 -0
- clearskies/input_outputs/wsgi.py +36 -48
- clearskies/model.py +1894 -199
- clearskies/query/__init__.py +12 -0
- clearskies/query/condition.py +228 -0
- clearskies/query/join.py +136 -0
- clearskies/query/query.py +193 -0
- clearskies/query/sort.py +27 -0
- clearskies/schema.py +82 -0
- clearskies/secrets/__init__.py +4 -31
- clearskies/secrets/additional_configs/mysql_connection_dynamic_producer.py +15 -4
- clearskies/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +11 -5
- clearskies/secrets/akeyless.py +421 -155
- clearskies/secrets/exceptions/__init__.py +7 -1
- clearskies/secrets/exceptions/not_found_error.py +2 -0
- clearskies/secrets/exceptions/permissions_error.py +2 -0
- clearskies/secrets/secrets.py +12 -11
- clearskies/security_header.py +17 -0
- clearskies/security_headers/__init__.py +8 -8
- clearskies/security_headers/cache_control.py +47 -109
- clearskies/security_headers/cors.py +38 -92
- clearskies/security_headers/csp.py +76 -150
- clearskies/security_headers/hsts.py +14 -15
- clearskies/typing.py +11 -0
- clearskies/validator.py +36 -0
- clearskies/validators/__init__.py +33 -0
- clearskies/validators/after_column.py +61 -0
- clearskies/validators/before_column.py +15 -0
- clearskies/validators/in_the_future.py +29 -0
- clearskies/validators/in_the_future_at_least.py +13 -0
- clearskies/validators/in_the_future_at_most.py +12 -0
- clearskies/validators/in_the_past.py +29 -0
- clearskies/validators/in_the_past_at_least.py +12 -0
- clearskies/validators/in_the_past_at_most.py +12 -0
- clearskies/validators/maximum_length.py +25 -0
- clearskies/validators/maximum_value.py +28 -0
- clearskies/validators/minimum_length.py +25 -0
- clearskies/validators/minimum_value.py +28 -0
- clearskies/{input_requirements → validators}/required.py +18 -9
- clearskies/validators/timedelta.py +58 -0
- clearskies/validators/unique.py +28 -0
- clear_skies-1.19.22.dist-info/METADATA +0 -46
- clear_skies-1.19.22.dist-info/RECORD +0 -206
- clearskies/application.py +0 -29
- clearskies/authentication/auth0_jwks.py +0 -118
- clearskies/authentication/auth_exception.py +0 -2
- clearskies/authentication/jwks_jwcrypto.py +0 -39
- clearskies/backends/example_backend.py +0 -43
- clearskies/backends/file_backend.py +0 -48
- clearskies/backends/json_backend.py +0 -7
- clearskies/backends/restful_api_advanced_search_backend.py +0 -138
- clearskies/binding_config.py +0 -16
- clearskies/column_types/__init__.py +0 -184
- clearskies/column_types/audit.py +0 -235
- clearskies/column_types/belongs_to.py +0 -250
- clearskies/column_types/boolean.py +0 -60
- clearskies/column_types/category_tree.py +0 -226
- clearskies/column_types/column.py +0 -373
- clearskies/column_types/created.py +0 -26
- clearskies/column_types/created_by_authorization_data.py +0 -26
- clearskies/column_types/created_by_header.py +0 -24
- clearskies/column_types/created_by_ip.py +0 -17
- clearskies/column_types/created_by_routing_data.py +0 -25
- clearskies/column_types/created_by_user_agent.py +0 -17
- clearskies/column_types/created_micro.py +0 -26
- clearskies/column_types/datetime.py +0 -108
- clearskies/column_types/datetime_micro.py +0 -12
- clearskies/column_types/email.py +0 -18
- clearskies/column_types/float.py +0 -43
- clearskies/column_types/has_many.py +0 -139
- clearskies/column_types/integer.py +0 -41
- clearskies/column_types/json.py +0 -25
- clearskies/column_types/many_to_many.py +0 -278
- clearskies/column_types/many_to_many_with_data.py +0 -162
- clearskies/column_types/select.py +0 -11
- clearskies/column_types/string.py +0 -24
- clearskies/column_types/updated.py +0 -24
- clearskies/column_types/updated_micro.py +0 -24
- clearskies/column_types/uuid.py +0 -25
- clearskies/columns.py +0 -123
- clearskies/condition_parser.py +0 -172
- clearskies/contexts/build_context.py +0 -54
- clearskies/contexts/convert_to_application.py +0 -190
- clearskies/contexts/extract_handler.py +0 -37
- clearskies/contexts/test.py +0 -94
- clearskies/decorators/__init__.py +0 -39
- clearskies/decorators/auth0_jwks.py +0 -22
- clearskies/decorators/authorization.py +0 -10
- clearskies/decorators/binding_classes.py +0 -9
- clearskies/decorators/binding_modules.py +0 -9
- clearskies/decorators/bindings.py +0 -9
- clearskies/decorators/create.py +0 -10
- clearskies/decorators/delete.py +0 -10
- clearskies/decorators/docs.py +0 -14
- clearskies/decorators/get.py +0 -10
- clearskies/decorators/jwks.py +0 -26
- clearskies/decorators/merge.py +0 -124
- clearskies/decorators/patch.py +0 -10
- clearskies/decorators/post.py +0 -10
- clearskies/decorators/public.py +0 -11
- clearskies/decorators/response_headers.py +0 -10
- clearskies/decorators/return_raw_response.py +0 -9
- clearskies/decorators/schema.py +0 -10
- clearskies/decorators/secret_bearer.py +0 -24
- clearskies/decorators/security_headers.py +0 -10
- clearskies/di/standard_dependencies.py +0 -140
- clearskies/di/test_module/__init__.py +0 -6
- clearskies/di/test_module/another_module/__init__.py +0 -2
- clearskies/di/test_module/module_class.py +0 -5
- clearskies/handlers/__init__.py +0 -41
- clearskies/handlers/advanced_search.py +0 -271
- clearskies/handlers/base.py +0 -473
- clearskies/handlers/callable.py +0 -189
- clearskies/handlers/create.py +0 -35
- clearskies/handlers/crud_by_method.py +0 -18
- clearskies/handlers/database_connector.py +0 -32
- clearskies/handlers/delete.py +0 -61
- clearskies/handlers/exceptions/__init__.py +0 -5
- clearskies/handlers/exceptions/not_found.py +0 -3
- clearskies/handlers/get.py +0 -156
- clearskies/handlers/health_check.py +0 -59
- clearskies/handlers/input_processing.py +0 -79
- clearskies/handlers/list.py +0 -530
- clearskies/handlers/mygrations.py +0 -82
- clearskies/handlers/request_method_routing.py +0 -47
- clearskies/handlers/restful_api.py +0 -218
- clearskies/handlers/routing.py +0 -62
- clearskies/handlers/schema_helper.py +0 -128
- clearskies/handlers/simple_routing.py +0 -204
- clearskies/handlers/simple_routing_route.py +0 -192
- clearskies/handlers/simple_search.py +0 -136
- clearskies/handlers/update.py +0 -96
- clearskies/handlers/write.py +0 -193
- clearskies/input_requirements/__init__.py +0 -68
- clearskies/input_requirements/after.py +0 -36
- clearskies/input_requirements/before.py +0 -36
- clearskies/input_requirements/in_the_future_at_least.py +0 -19
- clearskies/input_requirements/in_the_future_at_most.py +0 -19
- clearskies/input_requirements/in_the_past_at_least.py +0 -19
- clearskies/input_requirements/in_the_past_at_most.py +0 -19
- clearskies/input_requirements/maximum_length.py +0 -19
- clearskies/input_requirements/minimum_length.py +0 -22
- clearskies/input_requirements/requirement.py +0 -25
- clearskies/input_requirements/time_delta.py +0 -38
- clearskies/input_requirements/unique.py +0 -18
- clearskies/mocks/__init__.py +0 -7
- clearskies/mocks/input_output.py +0 -124
- clearskies/mocks/models.py +0 -142
- clearskies/models.py +0 -345
- clearskies/security_headers/base.py +0 -12
- clearskies/tests/simple_api/models/__init__.py +0 -2
- clearskies/tests/simple_api/models/status.py +0 -23
- clearskies/tests/simple_api/models/user.py +0 -21
- clearskies/tests/simple_api/users_api.py +0 -64
- {clear_skies-1.19.22.dist-info → clear_skies-2.0.23.dist-info/licenses}/LICENSE +0 -0
- /clearskies/{contexts/bash.py → autodoc/py.typed} +0 -0
- /clearskies/{handlers/exceptions → exceptions}/authentication.py +0 -0
- /clearskies/{handlers/exceptions → exceptions}/authorization.py +0 -0
- /clearskies/{handlers/exceptions → exceptions}/client_error.py +0 -0
- /clearskies/{secrets/exceptions → exceptions}/not_found.py +0 -0
- /clearskies/{tests/__init__.py → input_outputs/py.typed} +0 -0
- /clearskies/{tests/simple_api/__init__.py → py.typed} +0 -0
clearskies/column.py
ADDED
|
@@ -0,0 +1,1221 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Callable, Self, overload
|
|
4
|
+
|
|
5
|
+
from clearskies import configs, configurable, decorators
|
|
6
|
+
from clearskies.autodoc.schema import String as AutoDocString
|
|
7
|
+
from clearskies.di import InjectableProperties, inject
|
|
8
|
+
from clearskies.query.condition import ParsedCondition
|
|
9
|
+
from clearskies.validator import Validator
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from clearskies import Model, Schema, typing
|
|
13
|
+
from clearskies.autodoc.schema import Schema as AutoDocSchema
|
|
14
|
+
from clearskies.query.condition import Condition
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Column(configurable.Configurable, InjectableProperties):
|
|
18
|
+
"""
|
|
19
|
+
Columns are used to build schemes and enable a variety of levels of automation with.
|
|
20
|
+
|
|
21
|
+
Columns are used to define your schemas in clearskies, especially via models. The column definitions are then used by endpoints
|
|
22
|
+
and other aspects of the clearskies framework to automate things like input validation, front-end/backend-transformations, and more.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
"""
|
|
26
|
+
The column class gets the full DI container, because it does a lot of object building itself
|
|
27
|
+
"""
|
|
28
|
+
di = inject.Di()
|
|
29
|
+
|
|
30
|
+
"""
|
|
31
|
+
A default value to set for this column.
|
|
32
|
+
|
|
33
|
+
The default is only used when creating a record for the first time, and only if
|
|
34
|
+
a value for this column has not been set.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import clearskies
|
|
38
|
+
|
|
39
|
+
class Widget(clearskies.Model):
|
|
40
|
+
id_column_name = "id"
|
|
41
|
+
backend = clearskies.backends.MemoryBackend()
|
|
42
|
+
|
|
43
|
+
id = clearskies.columns.Uuid()
|
|
44
|
+
name = clearskies.columns.String(default="Jane Doe")
|
|
45
|
+
|
|
46
|
+
cli = clearskies.contexts.Cli(
|
|
47
|
+
clearskies.endpoints.Callable(
|
|
48
|
+
lambda widgets: widgets.create(no_data=True),
|
|
49
|
+
model_class=Widget,
|
|
50
|
+
readable_column_names=["id", "name"]
|
|
51
|
+
),
|
|
52
|
+
classes=[Widget],
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
cli()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Which when invoked returns:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"status": "success",
|
|
64
|
+
"error": "",
|
|
65
|
+
"data": {
|
|
66
|
+
"id": "03806afa-b189-4729-a43c-9da5aa17bf14",
|
|
67
|
+
"name": "Jane Doe"
|
|
68
|
+
},
|
|
69
|
+
"pagination": {},
|
|
70
|
+
"input_errors": {}
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
"""
|
|
74
|
+
default = configs.string.String(default=None)
|
|
75
|
+
|
|
76
|
+
"""
|
|
77
|
+
A value to set for this column during a save operation.
|
|
78
|
+
|
|
79
|
+
Unlike the default value, a setable value is always set during a save, even on update. It will
|
|
80
|
+
even override other values, so it is intended to be used in cases where the value is always controlled
|
|
81
|
+
programmatically.
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
import clearskies
|
|
85
|
+
import datetime
|
|
86
|
+
|
|
87
|
+
class Pet(clearskies.Model):
|
|
88
|
+
id_column_name = "id"
|
|
89
|
+
backend = clearskies.backends.MemoryBackend()
|
|
90
|
+
|
|
91
|
+
id = clearskies.columns.Uuid()
|
|
92
|
+
name = clearskies.columns.String(setable="Spot")
|
|
93
|
+
date_of_birth = clearskies.columns.Date()
|
|
94
|
+
age = clearskies.columns.Integer(
|
|
95
|
+
setable=lambda data, model, now:
|
|
96
|
+
(now-dateparser.parse(model.latest("date_of_birth", data))).total_seconds()/(86400*365),
|
|
97
|
+
)
|
|
98
|
+
created = clearskies.columns.Created()
|
|
99
|
+
|
|
100
|
+
cli = clearskies.contexts.Cli(
|
|
101
|
+
clearskies.endpoints.Callable(
|
|
102
|
+
lambda pets: pets.create({"date_of_birth": "2020-05-03"}),
|
|
103
|
+
model_class=Pet,
|
|
104
|
+
readable_column_names=["id", "name", "date_of_birth", "age"]
|
|
105
|
+
),
|
|
106
|
+
classes=[Pet],
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
cli()
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Note the use of `model.latest()` above. This function returns either the column information from the data array
|
|
114
|
+
or, if not present, the latest column value from the model itself. This makes it more flexible as it works
|
|
115
|
+
well with both update and create operations.
|
|
116
|
+
|
|
117
|
+
If you then execute this it will return something like:
|
|
118
|
+
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"status": "success",
|
|
122
|
+
"error": "",
|
|
123
|
+
"data": {
|
|
124
|
+
"id": "ec4993f4-124a-44a2-8313-816d2ad51aae",
|
|
125
|
+
"name": "Spot",
|
|
126
|
+
"date_of_birth": "2020-05-03",
|
|
127
|
+
"age": 5,
|
|
128
|
+
"created": "2025-05-03T20:23:32+00:00"
|
|
129
|
+
},
|
|
130
|
+
"pagination": {},
|
|
131
|
+
"input_errors": {}
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
e.g., `date_of_birth` is `age` years behind the current time (as recorded in the `created` timestamp).
|
|
136
|
+
|
|
137
|
+
"""
|
|
138
|
+
setable = configs.string_or_callable.StringOrCallable(default=None)
|
|
139
|
+
|
|
140
|
+
"""
|
|
141
|
+
Whether or not this column can be converted to JSON and included in an API response.
|
|
142
|
+
|
|
143
|
+
If this is set to False for a column and you attempt to set that column as a readable_column in an endpoint,
|
|
144
|
+
clearskies will throw an exception.
|
|
145
|
+
"""
|
|
146
|
+
is_readable = configs.boolean.Boolean(default=True)
|
|
147
|
+
|
|
148
|
+
"""
|
|
149
|
+
Whether or not this column can be set via an API call.
|
|
150
|
+
|
|
151
|
+
If this is set to False for a column and you attempt to set the column as a writeable column in an endpoint,
|
|
152
|
+
clearskies will throw an exception.
|
|
153
|
+
"""
|
|
154
|
+
is_writeable = configs.boolean.Boolean(default=True)
|
|
155
|
+
|
|
156
|
+
"""
|
|
157
|
+
Whether or not it is possible to search by this column
|
|
158
|
+
|
|
159
|
+
If this is set to False for a column and you attempt to set the column as a searchable column in an endpoint,
|
|
160
|
+
clearskies will throw an exception.
|
|
161
|
+
"""
|
|
162
|
+
is_searchable = configs.boolean.Boolean(default=True)
|
|
163
|
+
|
|
164
|
+
"""
|
|
165
|
+
Whether or not this column is temporary. A temporary column is not persisted to the backend.
|
|
166
|
+
|
|
167
|
+
Temporary columns are useful when you want the developer or end user to set a value, but you use that value to
|
|
168
|
+
trigger additional behavior, rather than actually recording it. Temporary columns often team up with actions
|
|
169
|
+
or are used to calculate other values. For instance, in our setable example above, we had both an age and
|
|
170
|
+
a date of birth column, with the date of birth calculated from the age. This obviously results in two columns
|
|
171
|
+
with similar data. One could be marked as temporary and it will be available during the save operation, but
|
|
172
|
+
it will be skipped when saving data to the backend:
|
|
173
|
+
|
|
174
|
+
```python
|
|
175
|
+
import clearskies
|
|
176
|
+
|
|
177
|
+
class Pet(clearskies.Model):
|
|
178
|
+
id_column_name = "id"
|
|
179
|
+
backend = clearskies.backends.MemoryBackend()
|
|
180
|
+
|
|
181
|
+
id = clearskies.columns.Uuid()
|
|
182
|
+
name = clearskies.columns.String()
|
|
183
|
+
date_of_birth = clearskies.columns.Date(is_temporary=True)
|
|
184
|
+
age = clearskies.columns.Integer(
|
|
185
|
+
setable=lambda data, model, now:
|
|
186
|
+
(now-dateparser.parse(model.latest("date_of_birth", data))).total_seconds()/(86400*365),
|
|
187
|
+
)
|
|
188
|
+
created = clearskies.columns.Created()
|
|
189
|
+
|
|
190
|
+
cli = clearskies.contexts.Cli(
|
|
191
|
+
clearskies.endpoints.Callable(
|
|
192
|
+
lambda pets: pets.create({"name": "Spot", "date_of_birth": "2020-05-03"}),
|
|
193
|
+
model_class=Pet,
|
|
194
|
+
readable_column_names=["id", "age", "date_of_birth"],
|
|
195
|
+
),
|
|
196
|
+
classes=[Pet],
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
if __name__ == "__main__":
|
|
200
|
+
cli()
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Which will return:
|
|
204
|
+
|
|
205
|
+
```json
|
|
206
|
+
{
|
|
207
|
+
"status": "success",
|
|
208
|
+
"error": "",
|
|
209
|
+
"data": {
|
|
210
|
+
"id": "ee532cfa-91cf-4747-b798-3c6dcd79326e",
|
|
211
|
+
"age": 5,
|
|
212
|
+
"date_of_birth": null
|
|
213
|
+
},
|
|
214
|
+
"pagination": {},
|
|
215
|
+
"input_errors": {}
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
e.g. the date_of_birth column is empty. To be clear though, it's not just empty - clearskies made no attempt to set it.
|
|
220
|
+
If you were using an SQL database, you would not have to put a `date_of_birth` column in your table.
|
|
221
|
+
|
|
222
|
+
"""
|
|
223
|
+
is_temporary = configs.boolean.Boolean(default=False)
|
|
224
|
+
|
|
225
|
+
"""
|
|
226
|
+
Validators to use when checking the input for this column during write operations from the API.
|
|
227
|
+
|
|
228
|
+
Keep in mind that the validators are only checked when the column is exposed via a supporting endpoint.
|
|
229
|
+
You can still set whatever values you want when saving the model directly, e.g. `model.save(...)`
|
|
230
|
+
|
|
231
|
+
In the below example, we require a name that is at least 5 characters long, and the date of birth must
|
|
232
|
+
be in the past. Note that date of birth is not required, so the end-user can create a record without
|
|
233
|
+
a date of birth. In general, only the `Required` validator will reject a non-existent input.
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
import clearskies
|
|
237
|
+
|
|
238
|
+
class Pet(clearskies.Model):
|
|
239
|
+
id_column_name = "id"
|
|
240
|
+
backend = clearskies.backends.MemoryBackend()
|
|
241
|
+
|
|
242
|
+
id = clearskies.columns.Uuid()
|
|
243
|
+
name = clearskies.columns.String(validators=[
|
|
244
|
+
clearskies.validators.Required(),
|
|
245
|
+
clearskies.validators.MinimumLength(5),
|
|
246
|
+
])
|
|
247
|
+
date_of_birth = clearskies.columns.Date(validators=[
|
|
248
|
+
clearskies.validators.InThePast()
|
|
249
|
+
])
|
|
250
|
+
created = clearskies.columns.Created()
|
|
251
|
+
|
|
252
|
+
wsgi = clearskies.contexts.WsgiRef(
|
|
253
|
+
clearskies.endpoints.Create(
|
|
254
|
+
model_class=Pet,
|
|
255
|
+
writeable_column_names=["name", "date_of_birth"],
|
|
256
|
+
readable_column_names=["id", "name", "date_of_birth", "created"],
|
|
257
|
+
),
|
|
258
|
+
)
|
|
259
|
+
wsgi()
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
You can then see the result of calling the endpoint with various kinds of invalid data:
|
|
263
|
+
|
|
264
|
+
```bash
|
|
265
|
+
$ curl http://localhost:8080 -d '{"date_of_birth": "asdf"}'
|
|
266
|
+
{
|
|
267
|
+
"status": "input_errors",
|
|
268
|
+
"error": "",
|
|
269
|
+
"data": [],
|
|
270
|
+
"pagination": {},
|
|
271
|
+
"input_errors": {
|
|
272
|
+
"name": "'name' is required.",
|
|
273
|
+
"date_of_birth": "given value did not appear to be a valid date"
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
$ curl http://localhost:8080 -d '{"name":"asdf"}' | jq
|
|
278
|
+
{
|
|
279
|
+
"status": "input_errors",
|
|
280
|
+
"error": "",
|
|
281
|
+
"data": [],
|
|
282
|
+
"pagination": {},
|
|
283
|
+
"input_errors": {
|
|
284
|
+
"name": "'name' must be at least 5 characters long."
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
$ curl http://localhost:8080 -d '{"name":"Longer", "date_of_birth": "2050-01-01"}' | jq
|
|
289
|
+
{
|
|
290
|
+
"status": "input_errors",
|
|
291
|
+
"error": "",
|
|
292
|
+
"data": [],
|
|
293
|
+
"pagination": {},
|
|
294
|
+
"input_errors": {
|
|
295
|
+
"date_of_birth": "'date_of_birth' must be in the past"
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
$ curl http://localhost:8080 -d '{"name":"Long Enough"}' | jq
|
|
300
|
+
{
|
|
301
|
+
"status": "success",
|
|
302
|
+
"error": "",
|
|
303
|
+
"data": {
|
|
304
|
+
"id": "ace16b93-db91-49b3-a8f7-5dc6568d25f6",
|
|
305
|
+
"name": "Long Enough",
|
|
306
|
+
"date_of_birth": null,
|
|
307
|
+
"created": "2025-05-03T19:32:33+00:00"
|
|
308
|
+
},
|
|
309
|
+
"pagination": {},
|
|
310
|
+
"input_errors": {}
|
|
311
|
+
}
|
|
312
|
+
```
|
|
313
|
+
"""
|
|
314
|
+
validators = configs.validators.Validators(default=[])
|
|
315
|
+
|
|
316
|
+
"""
|
|
317
|
+
Actions to take during the pre-save step of the save process if the column has changed during the active save operation.
|
|
318
|
+
|
|
319
|
+
Pre-save happens before the data is persisted to the backend. Actions/callables in
|
|
320
|
+
this step must return a dictionary. The data in the dictionary will be included in the save operation.
|
|
321
|
+
Since the save hasn't completed, any data in the model itself reflects the model before the save
|
|
322
|
+
operation started. Actions in the pre-save step must **NOT** make any changes directly, but should **ONLY**
|
|
323
|
+
return modified data for the save operation. In addition, they must be idempotent - they should always return
|
|
324
|
+
the same value when called with the same data. This is because clearskies can call them more than once. If
|
|
325
|
+
a pre-save hook changes the save data, then clearskies will call all the pre-save hooks again in case this
|
|
326
|
+
new data needs to trigger further changes. Stateful changes should be reserved for the post_save or save_finished stages.
|
|
327
|
+
|
|
328
|
+
Callables and actions can request any dependencies provided by the DI system. In addition, they can request
|
|
329
|
+
two named parameters:
|
|
330
|
+
|
|
331
|
+
1. `model` - the model involved in the save operation
|
|
332
|
+
2. `data` - the new data being saved
|
|
333
|
+
|
|
334
|
+
The key here is that the defined actions will be invoked regardless of how the save happens. Whether the
|
|
335
|
+
model.save() function is called directly or the model is creatd/modified via an endpoint, your business logic
|
|
336
|
+
will always be executed. This makes for easy reusability and consistency throughout your application.
|
|
337
|
+
|
|
338
|
+
Here's an example where we want to record a timestamp anytime an order status becomes a particular value:
|
|
339
|
+
|
|
340
|
+
```python
|
|
341
|
+
import clearskies
|
|
342
|
+
|
|
343
|
+
class Order(clearskies.Model):
|
|
344
|
+
id_column_name = "id"
|
|
345
|
+
backend = clearskies.backends.MemoryBackend()
|
|
346
|
+
|
|
347
|
+
id = clearskies.columns.Uuid()
|
|
348
|
+
status = clearskies.columns.Select(
|
|
349
|
+
["Open", "On Hold", "Fulfilled"],
|
|
350
|
+
on_change_pre_save=[
|
|
351
|
+
lambda data, utcnow: {"fulfilled_at": utcnow} if data["status"] == "Fulfilled" else {},
|
|
352
|
+
],
|
|
353
|
+
)
|
|
354
|
+
fulfilled_at = clearskies.columns.Datetime()
|
|
355
|
+
|
|
356
|
+
wsgi = clearskies.contexts.WsgiRef(
|
|
357
|
+
clearskies.endpoints.Create(
|
|
358
|
+
model_class=Order,
|
|
359
|
+
writeable_column_names=["status"],
|
|
360
|
+
readable_column_names=["id", "status", "fulfilled_at"],
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
wsgi()
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
You can then see the difference depending on what you set the status to:
|
|
367
|
+
|
|
368
|
+
```bash
|
|
369
|
+
$ curl http://localhost:8080 -d '{"status":"Open"}' | jq
|
|
370
|
+
{
|
|
371
|
+
"status": "success",
|
|
372
|
+
"error": "",
|
|
373
|
+
"data": {
|
|
374
|
+
"id": "a732545f-51b3-4fd0-a6cf-576cf1b2872f",
|
|
375
|
+
"status": "Open",
|
|
376
|
+
"fulfilled_at": null
|
|
377
|
+
},
|
|
378
|
+
"pagination": {},
|
|
379
|
+
"input_errors": {}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
$ curl http://localhost:8080 -d '{"status":"Fulfilled"}' | jq
|
|
383
|
+
{
|
|
384
|
+
"status": "success",
|
|
385
|
+
"error": "",
|
|
386
|
+
"data": {
|
|
387
|
+
"id": "c288bf43-2246-48e4-b168-f40cbf5376df",
|
|
388
|
+
"status": "Fulfilled",
|
|
389
|
+
"fulfilled_at": "2025-05-04T02:32:56+00:00"
|
|
390
|
+
},
|
|
391
|
+
"pagination": {},
|
|
392
|
+
"input_errors": {}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
"""
|
|
398
|
+
on_change_pre_save = configs.actions.Actions(default=[])
|
|
399
|
+
|
|
400
|
+
"""
|
|
401
|
+
Actions to take during the post-save step of the process if the column has changed during the active save.
|
|
402
|
+
|
|
403
|
+
Post-save happens after the data is persisted to the backend but before the full save process has finished.
|
|
404
|
+
Since the data has been persisted to the backend, any data returned by the callables/actions will be ignored.
|
|
405
|
+
If you need to make data changes you'll have to execute a separate save operation.
|
|
406
|
+
Since the save hasn't finished, the model is not yet updated with the new data, and
|
|
407
|
+
any data you fetch out of the model will refelect the data in the model before the save started.
|
|
408
|
+
|
|
409
|
+
Callables and actions can request any dependencies provided by the DI system. In addition, they can request
|
|
410
|
+
three named parameters:
|
|
411
|
+
|
|
412
|
+
1. `model` - the model involved in the save operation
|
|
413
|
+
2. `data` - the new data being saved
|
|
414
|
+
3. `id` - the id of the record being saved
|
|
415
|
+
|
|
416
|
+
Here's an example of using a post-save action to record a simple audit trail when the order status changes:
|
|
417
|
+
|
|
418
|
+
```python
|
|
419
|
+
import clearskies
|
|
420
|
+
|
|
421
|
+
class Order(clearskies.Model):
|
|
422
|
+
id_column_name = "id"
|
|
423
|
+
backend = clearskies.backends.MemoryBackend()
|
|
424
|
+
|
|
425
|
+
id = clearskies.columns.Uuid()
|
|
426
|
+
status = clearskies.columns.Select(
|
|
427
|
+
["Open", "On Hold", "Fulfilled"],
|
|
428
|
+
on_change_post_save=[
|
|
429
|
+
lambda model, data, order_histories: order_histories.create({
|
|
430
|
+
"order_id": model.latest("id", data),
|
|
431
|
+
"event": "Order status changed to " + data["status"]
|
|
432
|
+
}),
|
|
433
|
+
],
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
class OrderHistory(clearskies.Model):
|
|
437
|
+
id_column_name = "id"
|
|
438
|
+
backend = clearskies.backends.MemoryBackend()
|
|
439
|
+
|
|
440
|
+
id = clearskies.columns.Uuid()
|
|
441
|
+
event = clearskies.columns.String()
|
|
442
|
+
order_id = clearskies.columns.BelongsToId(Order)
|
|
443
|
+
|
|
444
|
+
# include microseconds in the created_at time so that we can sort our example by created_at
|
|
445
|
+
# and they come out in order (since, for our test program, they will all be created in the same second).
|
|
446
|
+
created_at = clearskies.columns.Created(date_format="%Y-%m-%d %H:%M:%S.%f")
|
|
447
|
+
|
|
448
|
+
def test_post_save(orders: Order, order_histories: OrderHistory):
|
|
449
|
+
my_order = orders.create({"status": "Open"})
|
|
450
|
+
my_order.status = "On Hold"
|
|
451
|
+
my_order.save()
|
|
452
|
+
my_order.save({"status": "Open"})
|
|
453
|
+
my_order.save({"status": "Fulfilled"})
|
|
454
|
+
return order_histories.where(OrderHistory.order_id.equals(my_order.id)).sort_by("created_at", "asc")
|
|
455
|
+
|
|
456
|
+
cli = clearskies.contexts.Cli(
|
|
457
|
+
clearskies.endpoints.Callable(
|
|
458
|
+
test_post_save,
|
|
459
|
+
model_class=OrderHistory,
|
|
460
|
+
return_records=True,
|
|
461
|
+
readable_column_names=["id", "event", "created_at"],
|
|
462
|
+
),
|
|
463
|
+
classes=[Order, OrderHistory],
|
|
464
|
+
)
|
|
465
|
+
cli()
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
Note that in our `on_change_post_save` lambda function, we use `model.latest("id", data)`. We can't just use
|
|
469
|
+
`data["id"]` because `data` is a dictionary containing the information present in the save. During the create
|
|
470
|
+
operation `data["id"]` will be populated, but during the subsequent edit operations it won't be - only the status
|
|
471
|
+
column is changing. `model.latest("id", data)` is basically just short hand for: `data.get("id", model.id)`.
|
|
472
|
+
On the other hand, we can just use `data["status"]` because the `on_change` hook is attached to the status field,
|
|
473
|
+
so it will only fire when status is being changed, which means that the `status` key is guaranteed to be in
|
|
474
|
+
the dictionary when the lambda is executed.
|
|
475
|
+
|
|
476
|
+
Finally, the post-save action has a named parameter called `id`, so in this specific case we could use:
|
|
477
|
+
|
|
478
|
+
```python
|
|
479
|
+
lambda data, id, order_histories: order_histories.create("order_id": id, "event": data["status"])
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
When we execute the above script it will return something like:
|
|
483
|
+
|
|
484
|
+
```json
|
|
485
|
+
{
|
|
486
|
+
"status": "success",
|
|
487
|
+
"error": "",
|
|
488
|
+
"data": [
|
|
489
|
+
{
|
|
490
|
+
"id": "c550d714-839b-4f25-a9e1-bd7e977185ff",
|
|
491
|
+
"event": "Order status changed to Open",
|
|
492
|
+
"created_at": "2025-05-04T14:09:42.960119+00:00"
|
|
493
|
+
},
|
|
494
|
+
{
|
|
495
|
+
"id": "f393d7b0-da21-4117-a7a4-0359fab802bb",
|
|
496
|
+
"event": "Order status changed to On Hold",
|
|
497
|
+
"created_at": "2025-05-04T14:09:42.960275+00:00"
|
|
498
|
+
},
|
|
499
|
+
{
|
|
500
|
+
"id": "5b528a10-4a08-47ae-938c-fc7067603f8e",
|
|
501
|
+
"event": "Order status changed to Open",
|
|
502
|
+
"created_at": "2025-05-04T14:09:42.960395+00:00"
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
"id": "91f77a88-1c38-49f7-aa1e-7f97bd9f962f",
|
|
506
|
+
"event": "Order status changed to Fulfilled",
|
|
507
|
+
"created_at": "2025-05-04T14:09:42.960514+00:00"
|
|
508
|
+
}
|
|
509
|
+
],
|
|
510
|
+
"pagination": {},
|
|
511
|
+
"input_errors": {}
|
|
512
|
+
}
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
"""
|
|
516
|
+
on_change_post_save = configs.actions.Actions(default=[])
|
|
517
|
+
|
|
518
|
+
"""
|
|
519
|
+
Actions to take during the save-finished step of the save process if the column has changed in the save.
|
|
520
|
+
|
|
521
|
+
Save-finished happens after the save process has completely finished and the model is updated with
|
|
522
|
+
the final data. Any data returned by these actions will be ignored, since the save has already finished.
|
|
523
|
+
If you need to make data changes you'll have to execute a separate save operation.
|
|
524
|
+
|
|
525
|
+
Callables and actions can request any dependencies provided by the DI system. In addition, they can request
|
|
526
|
+
the following parameter:
|
|
527
|
+
|
|
528
|
+
1. `model` - the model involved in the save operation
|
|
529
|
+
|
|
530
|
+
Unlike pre_save and post_save, `data` is not provided because this data has already been merged into the
|
|
531
|
+
model. If you need some context from the completed save operation, use methods like `was_changed` and `previous_value`.
|
|
532
|
+
"""
|
|
533
|
+
on_change_save_finished = configs.actions.Actions(default=[])
|
|
534
|
+
|
|
535
|
+
"""
|
|
536
|
+
Use in conjunction with `created_by_source_type` to have this column automatically populated by data from an HTTP request.
|
|
537
|
+
|
|
538
|
+
So, for instance, setting `created_by_source_type` to `authorization_data` and setting this to `email`
|
|
539
|
+
will result in the email value from the authorization data being persisted into this column when the
|
|
540
|
+
record is saved.
|
|
541
|
+
|
|
542
|
+
NOTE: this is sometimes best set as a column override on an endpoint, rather than directly
|
|
543
|
+
on the model itself. The reason is because the authorization data and header information is typically
|
|
544
|
+
only available during an HTTP request, so if you set this on the model level, you'll get an error
|
|
545
|
+
if you try to make saves to the model in a context where authorization data and/or headers don't exist.
|
|
546
|
+
|
|
547
|
+
See created_by_source_type for usage examples.
|
|
548
|
+
"""
|
|
549
|
+
created_by_source_key = configs.string.String(default="")
|
|
550
|
+
|
|
551
|
+
"""
|
|
552
|
+
Use in conjunction with `created_by_source_key` to have this column automatically populated by data from ann HTTP request.
|
|
553
|
+
|
|
554
|
+
So, for instance, setting this to `authorization_data` and setting `created_by_source_key` to `email`
|
|
555
|
+
will result in the email value from the authorization data being persisted into this column when the
|
|
556
|
+
record is saved.
|
|
557
|
+
|
|
558
|
+
NOTE: this is sometimes best set as a column override on an endpoint, rather than directly
|
|
559
|
+
on the model itself. The reason is because the authorization data and header information is typically
|
|
560
|
+
only available during an HTTP request, so if you set this on the model level, you'll get an error
|
|
561
|
+
if you try to make saves to the model in a context where authorization data and/or headers don't exist.
|
|
562
|
+
|
|
563
|
+
Here's an example:
|
|
564
|
+
|
|
565
|
+
```python
|
|
566
|
+
class User(clearskies.Model):
|
|
567
|
+
id_column_name = "id"
|
|
568
|
+
backend = clearskies.backends.MemoryBackend()
|
|
569
|
+
|
|
570
|
+
id = clearskies.columns.Uuid()
|
|
571
|
+
name = clearskies.columns.String()
|
|
572
|
+
account_id = clearskies.columns.String(
|
|
573
|
+
created_by_source_type="routing_data",
|
|
574
|
+
created_by_source_key="account_id",
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
wsgi = clearskies.contexts.WsgiRef(
|
|
578
|
+
clearskies.endpoints.Create(
|
|
579
|
+
User,
|
|
580
|
+
readable_column_names=["id", "account_id", "name"],
|
|
581
|
+
writeable_column_names=["name"],
|
|
582
|
+
url="/:account_id",
|
|
583
|
+
),
|
|
584
|
+
)
|
|
585
|
+
wsgi()
|
|
586
|
+
```
|
|
587
|
+
|
|
588
|
+
Note that `created_by_source_type` is `routing_data` and `created_by_source_key` is `account_id`.
|
|
589
|
+
This means that the endpoint that creates this record must have a routing parameter named `account_id`.
|
|
590
|
+
Naturally, our endpoint has a url of `/:account_id`, and so the parameter provided by the uesr gets
|
|
591
|
+
reflected into the save.
|
|
592
|
+
|
|
593
|
+
```bash
|
|
594
|
+
$ curl http://localhost:8080/1-2-3-4 -d '{"name":"Bob"}' | jq
|
|
595
|
+
{
|
|
596
|
+
"status": "success",
|
|
597
|
+
"error": "",
|
|
598
|
+
"data": {
|
|
599
|
+
"id": "250ed725-d940-4823-aa9d-890be800404a",
|
|
600
|
+
"account_id": "1-2-3-4",
|
|
601
|
+
"name": "Bob"
|
|
602
|
+
},
|
|
603
|
+
"pagination": {},
|
|
604
|
+
"input_errors": {}
|
|
605
|
+
}
|
|
606
|
+
```
|
|
607
|
+
|
|
608
|
+
"""
|
|
609
|
+
created_by_source_type = configs.select.Select(
|
|
610
|
+
["authorization_data", "http_header", "routing_data", ""], default=""
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
"""
|
|
614
|
+
If True, and the key requested via created_by_source_key doesn't exist in the designated source, an error will be raised.
|
|
615
|
+
"""
|
|
616
|
+
created_by_source_strict = configs.boolean.Boolean(default=True)
|
|
617
|
+
|
|
618
|
+
""" The model class this column is associated with. """
|
|
619
|
+
model_class = configs.Schema()
|
|
620
|
+
|
|
621
|
+
""" The name of this column. """
|
|
622
|
+
name = configs.string.String()
|
|
623
|
+
|
|
624
|
+
"""
|
|
625
|
+
Simple flag to denote if the column is unique or not.
|
|
626
|
+
|
|
627
|
+
This is an internal cache. Use column.is_unique instead.
|
|
628
|
+
"""
|
|
629
|
+
_is_unique = False
|
|
630
|
+
|
|
631
|
+
"""
|
|
632
|
+
Specify if this column has additional functionality to solve the n+1 problem.
|
|
633
|
+
|
|
634
|
+
Relationship columns may fetch data from additional tables when outputting results, but by default they
|
|
635
|
+
end up making an additional query for every record (in order to grab related data). This is called the
|
|
636
|
+
n+1 problem - a query may fetch 10 records, and then make 10 individual additional queries to select
|
|
637
|
+
related data for each record (which obviously hampers performance). The solution to this (when using
|
|
638
|
+
sql-like backends) is to add additional joins to the original query so that the data can all be fetched
|
|
639
|
+
at once. Columns that are subject to this issue can set this flag to True and then define the
|
|
640
|
+
`configure_n_plus_one` method to add the necessary joins. This method will be called as needed.
|
|
641
|
+
"""
|
|
642
|
+
wants_n_plus_one = False
|
|
643
|
+
|
|
644
|
+
"""
|
|
645
|
+
Simple flag to denote if the column is required or not.
|
|
646
|
+
|
|
647
|
+
This is an internal cache. Use column.is_required instead.
|
|
648
|
+
"""
|
|
649
|
+
_is_required = False
|
|
650
|
+
|
|
651
|
+
"""
|
|
652
|
+
The list of allowed search operators for this column.
|
|
653
|
+
|
|
654
|
+
All the various search methods reference this list. The idea is that a column can just fill out this list
|
|
655
|
+
instead of having to override all the methods.
|
|
656
|
+
"""
|
|
657
|
+
_allowed_search_operators = ["<=>", "!=", "<=", ">=", ">", "<", "=", "in", "is not null", "is null", "like"]
|
|
658
|
+
|
|
659
|
+
"""
|
|
660
|
+
The class to use when documenting this column
|
|
661
|
+
"""
|
|
662
|
+
auto_doc_class: type[AutoDocSchema] = AutoDocString
|
|
663
|
+
|
|
664
|
+
@decorators.parameters_to_properties
|
|
665
|
+
def __init__(
|
|
666
|
+
self,
|
|
667
|
+
default: str | None = None,
|
|
668
|
+
setable: str | Callable[..., str] | None = None,
|
|
669
|
+
is_readable: bool = True,
|
|
670
|
+
is_writeable: bool = True,
|
|
671
|
+
is_searchable: bool = True,
|
|
672
|
+
is_temporary: bool = False,
|
|
673
|
+
validators: typing.validator | list[typing.validator] = [],
|
|
674
|
+
on_change_pre_save: typing.action | list[typing.action] = [],
|
|
675
|
+
on_change_post_save: typing.action | list[typing.action] = [],
|
|
676
|
+
on_change_save_finished: typing.action | list[typing.action] = [],
|
|
677
|
+
created_by_source_type: str = "",
|
|
678
|
+
created_by_source_key: str = "",
|
|
679
|
+
created_by_source_strict: bool = True,
|
|
680
|
+
):
|
|
681
|
+
pass
|
|
682
|
+
|
|
683
|
+
def get_model_columns(self):
|
|
684
|
+
"""Return the columns or the model this column is attached to."""
|
|
685
|
+
return self.model_class.get_columns()
|
|
686
|
+
|
|
687
|
+
def finalize_configuration(self, model_class: type[Schema], name: str) -> None:
|
|
688
|
+
"""
|
|
689
|
+
Finalize and check the configuration.
|
|
690
|
+
|
|
691
|
+
This is an external trigger called by the model class when the model class is ready.
|
|
692
|
+
The reason it exists here instead of in the constructor is because some columns are tightly
|
|
693
|
+
connected to the model class, and can't validate configuration until they know what the model is.
|
|
694
|
+
Therefore, we need the model involved, and the only way for a property to know what class it is
|
|
695
|
+
in is if the parent class checks in (which is what happens here).
|
|
696
|
+
"""
|
|
697
|
+
self.model_class = model_class
|
|
698
|
+
self.name = name
|
|
699
|
+
self.finalize_and_validate_configuration()
|
|
700
|
+
|
|
701
|
+
def from_backend(self, value):
|
|
702
|
+
"""
|
|
703
|
+
Take the backend representation and returns a python representation.
|
|
704
|
+
|
|
705
|
+
For instance, for an SQL date field, this will return a Python DateTime object
|
|
706
|
+
"""
|
|
707
|
+
return str(value)
|
|
708
|
+
|
|
709
|
+
def to_backend(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
710
|
+
"""
|
|
711
|
+
Make any changes needed to save the data to the backend.
|
|
712
|
+
|
|
713
|
+
This typically means formatting changes - converting DateTime objects to database
|
|
714
|
+
date strings, etc...
|
|
715
|
+
"""
|
|
716
|
+
if self.name not in data:
|
|
717
|
+
return data
|
|
718
|
+
|
|
719
|
+
return {**data, self.name: str(data[self.name])}
|
|
720
|
+
|
|
721
|
+
@overload
|
|
722
|
+
def __get__(self, instance: None, cls: type) -> Self:
|
|
723
|
+
pass
|
|
724
|
+
|
|
725
|
+
@overload
|
|
726
|
+
def __get__(self, instance: Model, cls: type):
|
|
727
|
+
pass
|
|
728
|
+
|
|
729
|
+
def __get__(self, instance, cls):
|
|
730
|
+
if instance is None:
|
|
731
|
+
# Normally this gets filled in when the model is initialized. However, the condition builders (self.equals, etc...)
|
|
732
|
+
# can be called from the class directly, before the model is initialized and everything is populated. This
|
|
733
|
+
# can cause trouble, but by filling in the model class we can give enough information for them to get the
|
|
734
|
+
# job done. They have a special flow for this, we just have to provide the model class (and the __get__
|
|
735
|
+
# function is always called, so this fixes it).
|
|
736
|
+
self.model_class = cls
|
|
737
|
+
return self
|
|
738
|
+
|
|
739
|
+
# this makes sure we're initialized
|
|
740
|
+
if "name" not in self._config: # type: ignore
|
|
741
|
+
instance.get_columns()
|
|
742
|
+
|
|
743
|
+
if self.name not in instance._data:
|
|
744
|
+
return None # type: ignore
|
|
745
|
+
|
|
746
|
+
if self.name not in instance._transformed_data:
|
|
747
|
+
instance._transformed_data[self.name] = self.from_backend(instance._data[self.name])
|
|
748
|
+
|
|
749
|
+
return instance._transformed_data[self.name]
|
|
750
|
+
|
|
751
|
+
def __set__(self, instance: Model, value) -> None:
|
|
752
|
+
# this makes sure we're initialized
|
|
753
|
+
if "name" not in self._config: # type: ignore
|
|
754
|
+
instance.get_columns()
|
|
755
|
+
|
|
756
|
+
instance._next_data[self.name] = value
|
|
757
|
+
|
|
758
|
+
def finalize_and_validate_configuration(self):
|
|
759
|
+
super().finalize_and_validate_configuration()
|
|
760
|
+
|
|
761
|
+
if self.setable is not None and self.created_by_source_type:
|
|
762
|
+
raise ValueError(
|
|
763
|
+
"You attempted to set both 'setable' and 'created_by_source_type', but these configurations are mutually exclusive. You can only set one for a given column"
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
if (self.created_by_source_type and not self.created_by_source_key) or (
|
|
767
|
+
not self.created_by_source_type and self.created_by_source_key
|
|
768
|
+
):
|
|
769
|
+
raise ValueError(
|
|
770
|
+
"You only set one of 'created_by_source_type' and 'created_by_source_key'. You have to either set both of them (which enables the 'created_by' feature of the column) or you must set neither of them."
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
@property
|
|
774
|
+
def is_unique(self) -> bool:
|
|
775
|
+
"""Return True/False to denote if this column should always have unique values."""
|
|
776
|
+
if self._is_unique is None:
|
|
777
|
+
self._is_unique = any([validator.is_unique for validator in self.validators])
|
|
778
|
+
return self._is_unique
|
|
779
|
+
|
|
780
|
+
@property
|
|
781
|
+
def is_required(self):
|
|
782
|
+
"""Return True/False to denote if this column should is required."""
|
|
783
|
+
if self._is_required is None:
|
|
784
|
+
self._is_required = any([validator.is_required for validator in self.validators])
|
|
785
|
+
return self._is_required
|
|
786
|
+
|
|
787
|
+
def additional_write_columns(self, is_create=False) -> dict[str, Self]:
|
|
788
|
+
"""
|
|
789
|
+
Return any additional columns that should be included in write operations.
|
|
790
|
+
|
|
791
|
+
Some column types, and some validation requirements, necessitate the presence of additional
|
|
792
|
+
columns in the save operation. This function adds those in so they can be included in the
|
|
793
|
+
API call.
|
|
794
|
+
"""
|
|
795
|
+
additional_write_columns: dict[str, Self] = {}
|
|
796
|
+
for validator in self.validators:
|
|
797
|
+
if not isinstance(validator, Validator):
|
|
798
|
+
continue
|
|
799
|
+
additional_write_columns = {
|
|
800
|
+
**additional_write_columns,
|
|
801
|
+
**validator.additional_write_columns(is_create=is_create), # type: ignore
|
|
802
|
+
}
|
|
803
|
+
return additional_write_columns
|
|
804
|
+
|
|
805
|
+
def to_json(self, model: Model) -> dict[str, Any]:
|
|
806
|
+
"""Grabs the column out of the model and converts it into a representation that can be turned into JSON."""
|
|
807
|
+
return {self.name: self.__get__(model, model.__class__)}
|
|
808
|
+
|
|
809
|
+
def input_errors(self, model: Model, data: dict[str, Any]) -> dict[str, Any]:
|
|
810
|
+
"""
|
|
811
|
+
Check the given dictionary of data for any possible input errors.
|
|
812
|
+
|
|
813
|
+
This accepts all the data being saved, and not just the value for this column. The reason is because
|
|
814
|
+
some input valdiation flows require more than one piece of data. For instance, a user may be asked
|
|
815
|
+
to type a specific piece of input more than once to minimize the chance of typos, or a user may
|
|
816
|
+
have to provide their password when changing security-related columns.
|
|
817
|
+
|
|
818
|
+
This also returns a dictionary, rather than an error message, so that a column can also return an error
|
|
819
|
+
message for more than one column at a time if needed.
|
|
820
|
+
|
|
821
|
+
If there are no input errors then this should simply return an empty dictionary.
|
|
822
|
+
|
|
823
|
+
This method calls `self.input_error_for_value` and then also calls all the validators attached to the
|
|
824
|
+
column so, if you're building your own column and have some specific input validation you need to do,
|
|
825
|
+
you probably want to extend `input_error_for_value` as that is the one intended checks for a column type.
|
|
826
|
+
|
|
827
|
+
Note: this is not called when you directly invoke the `save`/`create` method of a model. This is only
|
|
828
|
+
used by handlers when processing user input (e.g. API calls).
|
|
829
|
+
"""
|
|
830
|
+
if self.name in data and data[self.name]:
|
|
831
|
+
error = self.input_error_for_value(data[self.name])
|
|
832
|
+
if error:
|
|
833
|
+
return {self.name: error}
|
|
834
|
+
|
|
835
|
+
for validator in self.validators:
|
|
836
|
+
if hasattr(validator, "injectable_properties"):
|
|
837
|
+
validator.injectable_properties(self.di)
|
|
838
|
+
|
|
839
|
+
error = validator(model, self.name, data)
|
|
840
|
+
if error:
|
|
841
|
+
return {self.name: error}
|
|
842
|
+
|
|
843
|
+
return {}
|
|
844
|
+
|
|
845
|
+
def check_search_value(
|
|
846
|
+
self, value: str, operator: str | None = None, relationship_reference: str | None = None
|
|
847
|
+
) -> str:
|
|
848
|
+
"""
|
|
849
|
+
Check if the given value is an allowed value.
|
|
850
|
+
|
|
851
|
+
This is called by the search operation in the various API-related handlers to validate a search value.
|
|
852
|
+
|
|
853
|
+
Generally, this just defers to self.input_error_for_value, but it is a separate method in case you
|
|
854
|
+
need to change your input validation logic specifically when checking a search value.
|
|
855
|
+
"""
|
|
856
|
+
return self.input_error_for_value(value, operator=operator)
|
|
857
|
+
|
|
858
|
+
def input_error_for_value(self, value: str, operator: str | None = None) -> str:
|
|
859
|
+
"""
|
|
860
|
+
Check if the given value is an allowed value.
|
|
861
|
+
|
|
862
|
+
This method is intended for checks that are specific to the column type (e.g. this is where an
|
|
863
|
+
email column checks that the value is an actual email, or a datetime column checks for a valid
|
|
864
|
+
datetime). The `input_errors` method does a bit more, so in general it's easier to extend this one.
|
|
865
|
+
|
|
866
|
+
This method is passed in the value to check. It should return a string. If the data is valid,
|
|
867
|
+
then return an empty string. Otherwise return a human-readable error message.
|
|
868
|
+
|
|
869
|
+
At times an operator will be passed in. This is used when the user is searching instead of saving.
|
|
870
|
+
In this case, the check can vary depending on the operator. For instance, if it's a wildcard search
|
|
871
|
+
then an email field only has to verify the type is a string (since the user may have only entered
|
|
872
|
+
the beginning of an email address), but if it's an exact search then you would expect the value to be
|
|
873
|
+
an actual email.
|
|
874
|
+
|
|
875
|
+
Note: this is not called when you directly invoke the `save`/`create` method of a model. This is only
|
|
876
|
+
used by handlers when processing user input (e.g. API calls).
|
|
877
|
+
"""
|
|
878
|
+
return ""
|
|
879
|
+
|
|
880
|
+
def pre_save(self, data: dict[str, Any], model: Model) -> dict[str, Any]:
|
|
881
|
+
"""
|
|
882
|
+
Make any necessary changes to the data before starting the save process.
|
|
883
|
+
|
|
884
|
+
The difference between this and to_backend is that to_backend only affects
|
|
885
|
+
the data as it is going into the database, while this affects the data that will get persisted
|
|
886
|
+
in the object as well. So for instance, for a "created" field, pre_save may fill in the current
|
|
887
|
+
date with a Python DateTime object when the record is being saved, and then to_backend may
|
|
888
|
+
turn that into an SQL-compatible date string.
|
|
889
|
+
|
|
890
|
+
Note: this is called during the `pre_save` step in the lifecycle of the save process. See the
|
|
891
|
+
model class for more details.
|
|
892
|
+
"""
|
|
893
|
+
if not model and self.created_by_source_type:
|
|
894
|
+
data[self.name] = self._extract_value_from_source_type()
|
|
895
|
+
if self.setable:
|
|
896
|
+
if callable(self.setable):
|
|
897
|
+
input_output = self.di.build("input_output", cache=True)
|
|
898
|
+
data[self.name] = self.di.call_function(
|
|
899
|
+
self.setable, data=data, model=model, **input_output.get_context_for_callables()
|
|
900
|
+
)
|
|
901
|
+
else:
|
|
902
|
+
data[self.name] = self.setable
|
|
903
|
+
if not model and self.default and self.name not in data:
|
|
904
|
+
data[self.name] = self.default
|
|
905
|
+
if self.on_change_pre_save and model.is_changing(self.name, data):
|
|
906
|
+
data = self.execute_actions_with_data(self.on_change_pre_save, model, data)
|
|
907
|
+
return data
|
|
908
|
+
|
|
909
|
+
def post_save(self, data: dict[str, Any], model: Model, id: int | str) -> None:
|
|
910
|
+
"""
|
|
911
|
+
Make any changes needed after persisting data to the backend.
|
|
912
|
+
|
|
913
|
+
This lives in the `post_save` hook of the save lifecycle. See the model class for more details.
|
|
914
|
+
`data` is the data dictionary being saved. `model` is obviously the model object that initiated the
|
|
915
|
+
save.
|
|
916
|
+
|
|
917
|
+
This happens after the backend is updated but before the model is updated. Therefore, You can tell the
|
|
918
|
+
difference between a create operation and an update operation by checking if the model exists: `if model`.
|
|
919
|
+
For a create operation, the model will be empty (it evaluates to False). The opposite is true for an update
|
|
920
|
+
operation.
|
|
921
|
+
|
|
922
|
+
Any return value will be ignored. If you need to make additional changes in the backend, you
|
|
923
|
+
have to execute a new save operation.
|
|
924
|
+
"""
|
|
925
|
+
if self.on_change_post_save and model.is_changing(self.name, data):
|
|
926
|
+
self.execute_actions_with_data(
|
|
927
|
+
self.on_change_post_save,
|
|
928
|
+
model,
|
|
929
|
+
data,
|
|
930
|
+
id=id,
|
|
931
|
+
context="on_change_post_save",
|
|
932
|
+
require_dict_return_value=False,
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
def save_finished(self, model: Model) -> None:
|
|
936
|
+
"""
|
|
937
|
+
Make any necessary changes needed after a save has completely finished.
|
|
938
|
+
|
|
939
|
+
This is typically used for actions set by the developer. Column-specific behavior usually lives in
|
|
940
|
+
`pre_save` or `post_save`. See the model class for more details about the various lifecycle hooks during
|
|
941
|
+
a save.
|
|
942
|
+
"""
|
|
943
|
+
if self.on_change_save_finished and model.was_changed(self.name):
|
|
944
|
+
self.execute_actions(self.on_change_save_finished, model)
|
|
945
|
+
|
|
946
|
+
def pre_delete(self, model):
|
|
947
|
+
"""Make any changes needed to the data before starting the delete process."""
|
|
948
|
+
pass
|
|
949
|
+
|
|
950
|
+
def post_delete(self, model):
|
|
951
|
+
"""Make any changes needed to the data before finishing the delete process."""
|
|
952
|
+
pass
|
|
953
|
+
|
|
954
|
+
def _extract_value_from_source_type(self) -> Any:
|
|
955
|
+
"""For columns with `created_by_source_type` set, this fetches the appropriate value from the request."""
|
|
956
|
+
input_output = self.di.build("input_output", cache=True)
|
|
957
|
+
source_type = self.created_by_source_type
|
|
958
|
+
if source_type == "authorization_data":
|
|
959
|
+
data = input_output.authorization_data
|
|
960
|
+
elif source_type == "http_header":
|
|
961
|
+
data = input_output.request_headers
|
|
962
|
+
elif source_type == "routing_data":
|
|
963
|
+
data = input_output.routing_data
|
|
964
|
+
|
|
965
|
+
if self.created_by_source_key not in data and self.created_by_source_strict:
|
|
966
|
+
raise ValueError(
|
|
967
|
+
f"Column '{self.name}' is configured to load the key named '{self.created_by_source_key}' from "
|
|
968
|
+
+ f"the {self.created_by_source_type}', but this key was not present in the request."
|
|
969
|
+
)
|
|
970
|
+
|
|
971
|
+
return data.get(self.created_by_source_key, "N/A")
|
|
972
|
+
|
|
973
|
+
def execute_actions_with_data(
|
|
974
|
+
self,
|
|
975
|
+
actions: list[typing.action],
|
|
976
|
+
model: Model,
|
|
977
|
+
data: dict[str, Any],
|
|
978
|
+
id: int | str | None = None,
|
|
979
|
+
context: str = "on_change_pre_save",
|
|
980
|
+
require_dict_return_value: bool = True,
|
|
981
|
+
) -> dict[str, Any]:
|
|
982
|
+
"""Execute a given set of actions and expects data to be both provided and returned."""
|
|
983
|
+
input_output = self.di.build("input_output", cache=True)
|
|
984
|
+
for index, action in enumerate(actions):
|
|
985
|
+
new_data = self.di.call_function(
|
|
986
|
+
action,
|
|
987
|
+
**{
|
|
988
|
+
**input_output.get_context_for_callables(),
|
|
989
|
+
**{
|
|
990
|
+
"model": model,
|
|
991
|
+
"data": data,
|
|
992
|
+
"id": id,
|
|
993
|
+
},
|
|
994
|
+
},
|
|
995
|
+
)
|
|
996
|
+
if not isinstance(new_data, dict):
|
|
997
|
+
if require_dict_return_value:
|
|
998
|
+
raise ValueError(
|
|
999
|
+
f"Return error for action #{index + 1} in 'on_change_pre_save' for column '{self.name}' in model '{self.model_class.__name__}': this action must return a dictionary but returned an object of type '{new_data.__class__.__name__}' instead"
|
|
1000
|
+
)
|
|
1001
|
+
else:
|
|
1002
|
+
return new_data
|
|
1003
|
+
data = {
|
|
1004
|
+
**data,
|
|
1005
|
+
**new_data,
|
|
1006
|
+
}
|
|
1007
|
+
return data
|
|
1008
|
+
|
|
1009
|
+
def execute_actions(
|
|
1010
|
+
self,
|
|
1011
|
+
actions: list[typing.action],
|
|
1012
|
+
model: Model,
|
|
1013
|
+
) -> None:
|
|
1014
|
+
"""Execute a given set of actions."""
|
|
1015
|
+
input_output = self.di.build("input_output", cache=True)
|
|
1016
|
+
for action in actions:
|
|
1017
|
+
self.di.call_function(action, model=model, **input_output.get_context_for_callables())
|
|
1018
|
+
|
|
1019
|
+
def values_match(self, value_1, value_2):
|
|
1020
|
+
"""
|
|
1021
|
+
Compare two values to see if they are the same.
|
|
1022
|
+
|
|
1023
|
+
This is mainly used to compare incoming data with old data to determine if a column has changed.
|
|
1024
|
+
|
|
1025
|
+
Note that these checks shouldn't make any assumptions about whether or not data has gone through the
|
|
1026
|
+
to_backend/from_backend functions. For instance, a datetime field may find one value has a date
|
|
1027
|
+
that is formatted as a string, and the other as a DateTime object. Plan appropriately.
|
|
1028
|
+
"""
|
|
1029
|
+
return value_1 == value_2
|
|
1030
|
+
|
|
1031
|
+
def add_search(self, model: Model, value: str, operator: str = "", relationship_reference: str = "") -> Model:
|
|
1032
|
+
return model.where(self.condition(operator, value))
|
|
1033
|
+
|
|
1034
|
+
def build_condition(self, value: str, operator: str = "", column_prefix: str = ""):
|
|
1035
|
+
"""
|
|
1036
|
+
Build condition for the read (and related) handlers to turn user input into a condition.
|
|
1037
|
+
|
|
1038
|
+
Note that this may look like it is vulnerable to SQLi, but it isn't. These conditions aren't passed directly
|
|
1039
|
+
into a query. Rather, they are parsed by the condition parser before being sent into the backend.
|
|
1040
|
+
The condition parser can safely reconstruct the original pieces, and the backend can then use the data
|
|
1041
|
+
safely (and remember, the backend may not be an SQL anyway)
|
|
1042
|
+
|
|
1043
|
+
As a result, this is perfectly safe for any user input, assuming normal system flow.
|
|
1044
|
+
|
|
1045
|
+
That being said, this should probably be replaced by self.condition()...
|
|
1046
|
+
"""
|
|
1047
|
+
if not operator:
|
|
1048
|
+
operator = "="
|
|
1049
|
+
if operator.lower() == "like":
|
|
1050
|
+
return f"{column_prefix}{self.name} LIKE '%{value}%'"
|
|
1051
|
+
return f"{column_prefix}{self.name}{operator}{value}"
|
|
1052
|
+
|
|
1053
|
+
def is_allowed_operator(
|
|
1054
|
+
self,
|
|
1055
|
+
operator: str,
|
|
1056
|
+
relationship_reference: str = "",
|
|
1057
|
+
):
|
|
1058
|
+
"""Process user data to decide if the end-user is specifying an allowed operator."""
|
|
1059
|
+
return operator.lower() in self._allowed_search_operators
|
|
1060
|
+
|
|
1061
|
+
def n_plus_one_add_joins(self, model: Model, column_names: list[str] = []) -> Model:
|
|
1062
|
+
"""Add any additional joins to solve the N+1 problem."""
|
|
1063
|
+
return model
|
|
1064
|
+
|
|
1065
|
+
def n_plus_one_join_table_alias_prefix(self):
|
|
1066
|
+
"""
|
|
1067
|
+
Create a table alias to use with joins for n+1 solutions.
|
|
1068
|
+
|
|
1069
|
+
When joining tables in for n+1 solutions, you can't just do a SELECT * on the new table, because that
|
|
1070
|
+
often results in duplicate column names. A solution that generally works across the board is to select
|
|
1071
|
+
specific columns from the joined table and alias them, adding a common prefix. Then, the data from the
|
|
1072
|
+
joined table can be reconstructed automatically by finding all columns with that prefix (and then removing
|
|
1073
|
+
the prefix). This function returns that prefix for that alias.
|
|
1074
|
+
|
|
1075
|
+
Now, technically this isn't function isn't used at all by the base class, so this definition is fairly
|
|
1076
|
+
pointless. It isn't marked as an abstract method because most model columns don't need it either.
|
|
1077
|
+
Rather, this function is here mostly for documentation so it's easier to understand how to implement
|
|
1078
|
+
support for n+1 solutions when needed. See the belongs_to column for a full implementation reference.
|
|
1079
|
+
"""
|
|
1080
|
+
return "join_table_" + self.name
|
|
1081
|
+
|
|
1082
|
+
def add_join(self, model: Model) -> Model:
|
|
1083
|
+
return model
|
|
1084
|
+
|
|
1085
|
+
def where_for_request(
|
|
1086
|
+
self,
|
|
1087
|
+
model: Model,
|
|
1088
|
+
routing_data: dict[str, str],
|
|
1089
|
+
authorization_data: dict[str, Any],
|
|
1090
|
+
input_output,
|
|
1091
|
+
) -> Model:
|
|
1092
|
+
"""
|
|
1093
|
+
Create a hook to automatically apply filtering whenever the column makes an appearance in a get/update/list/search handler.
|
|
1094
|
+
|
|
1095
|
+
This hook is called by all the handlers that execute queries, so if your column needs to automatically
|
|
1096
|
+
do some filtering whenever the model shows up in an API endpoint, this is the place for it.
|
|
1097
|
+
"""
|
|
1098
|
+
return model
|
|
1099
|
+
|
|
1100
|
+
def name_for_building_condition(self) -> str:
|
|
1101
|
+
if self._config and "name" in self._config:
|
|
1102
|
+
return self.name
|
|
1103
|
+
|
|
1104
|
+
if not self._config or not self._config.get("model_class"):
|
|
1105
|
+
raise ValueError(
|
|
1106
|
+
f"A condition builder was called but the model class isn't set. This means that the __get__ method for column class {self.__class__.__name__} forgot to set `self.model_class = cls`"
|
|
1107
|
+
)
|
|
1108
|
+
|
|
1109
|
+
for attribute_name in dir(self.model_class):
|
|
1110
|
+
if id(getattr(self.model_class, attribute_name)) != id(self):
|
|
1111
|
+
continue
|
|
1112
|
+
self.name = attribute_name
|
|
1113
|
+
break
|
|
1114
|
+
|
|
1115
|
+
return self.name
|
|
1116
|
+
|
|
1117
|
+
def equals(self, value) -> Condition:
|
|
1118
|
+
name = self.name_for_building_condition()
|
|
1119
|
+
if "=" not in self._allowed_search_operators:
|
|
1120
|
+
raise ValueError(f"An 'equals search' is not allowed for '{self.model_class.__name__}.{name}'.")
|
|
1121
|
+
value = self.to_backend({name: value}).get(name)
|
|
1122
|
+
return ParsedCondition(name, "=", [value])
|
|
1123
|
+
|
|
1124
|
+
def spaceship(self, value) -> Condition:
|
|
1125
|
+
name = self.name_for_building_condition()
|
|
1126
|
+
if "<=>" not in self._allowed_search_operators:
|
|
1127
|
+
raise ValueError(f"A 'spaceship' search is not allowed for '{self.model_class.__name__}.{name}'.")
|
|
1128
|
+
value = self.to_backend({name: value}).get(name)
|
|
1129
|
+
return ParsedCondition(name, "<=>", [value])
|
|
1130
|
+
|
|
1131
|
+
def not_equals(self, value) -> Condition:
|
|
1132
|
+
name = self.name_for_building_condition()
|
|
1133
|
+
if "!=" not in self._allowed_search_operators:
|
|
1134
|
+
raise ValueError(f"A 'not equals' search is not allowed for '{self.model_class.__name__}.{name}'.")
|
|
1135
|
+
value = self.to_backend({name: value}).get(name)
|
|
1136
|
+
return ParsedCondition(name, "!=", [value])
|
|
1137
|
+
|
|
1138
|
+
def less_than_equals(self, value) -> Condition:
|
|
1139
|
+
name = self.name_for_building_condition()
|
|
1140
|
+
if "<=" not in self._allowed_search_operators:
|
|
1141
|
+
raise ValueError(f"A 'less than or equals' search is not allowed for '{self.model_class.__name__}.{name}'.")
|
|
1142
|
+
value = self.to_backend({name: value}).get(name)
|
|
1143
|
+
return ParsedCondition(name, "<=", [value])
|
|
1144
|
+
|
|
1145
|
+
def greater_than_equals(self, value) -> Condition:
|
|
1146
|
+
name = self.name_for_building_condition()
|
|
1147
|
+
if ">=" not in self._allowed_search_operators:
|
|
1148
|
+
raise ValueError(
|
|
1149
|
+
f"A 'greater than' or equals search is not allowed for '{self.model_class.__name__}.{name}'."
|
|
1150
|
+
)
|
|
1151
|
+
value = self.to_backend({name: value}).get(name)
|
|
1152
|
+
return ParsedCondition(name, ">=", [value])
|
|
1153
|
+
|
|
1154
|
+
def less_than(self, value) -> Condition:
|
|
1155
|
+
name = self.name_for_building_condition()
|
|
1156
|
+
if "<" not in self._allowed_search_operators:
|
|
1157
|
+
raise ValueError(f"A 'less than' search is not allowed for '{self.model_class.__name__}.{name}'.")
|
|
1158
|
+
value = self.to_backend({name: value}).get(name)
|
|
1159
|
+
return ParsedCondition(name, "<", [value])
|
|
1160
|
+
|
|
1161
|
+
def greater_than(self, value) -> Condition:
|
|
1162
|
+
name = self.name_for_building_condition()
|
|
1163
|
+
if ">" not in self._allowed_search_operators:
|
|
1164
|
+
raise ValueError(f"A 'greater than' search is not allowed for '{self.model_class.__name__}.{name}'.")
|
|
1165
|
+
value = self.to_backend({name: value}).get(name)
|
|
1166
|
+
return ParsedCondition(name, ">", [value])
|
|
1167
|
+
|
|
1168
|
+
def is_in(self, values) -> Condition:
|
|
1169
|
+
name = self.name_for_building_condition()
|
|
1170
|
+
if "in" not in self._allowed_search_operators:
|
|
1171
|
+
raise ValueError(f"An 'in' search is not allowed for '{self.model_class.__name__}.{name}'.")
|
|
1172
|
+
if not isinstance(values, list):
|
|
1173
|
+
raise TypeError("You must pass a list in to column.is_in")
|
|
1174
|
+
final_values = []
|
|
1175
|
+
for value in values:
|
|
1176
|
+
final_values.append(self.to_backend({name: value}).get(name))
|
|
1177
|
+
return ParsedCondition(name, "in", final_values) # type: ignore
|
|
1178
|
+
|
|
1179
|
+
def is_not_null(self) -> Condition:
|
|
1180
|
+
name = self.name_for_building_condition()
|
|
1181
|
+
if "is not null" not in self._allowed_search_operators:
|
|
1182
|
+
raise ValueError(f"An 'is not null' search is not allowed for '{self.model_class.__name__}.{name}'.")
|
|
1183
|
+
return ParsedCondition(name, "is not null", [])
|
|
1184
|
+
|
|
1185
|
+
def is_null(self) -> Condition:
|
|
1186
|
+
name = self.name_for_building_condition()
|
|
1187
|
+
if "is null" not in self._allowed_search_operators:
|
|
1188
|
+
raise ValueError(f"An 'is null' search is not allowed for '{self.model_class.__name__}.{name}'.")
|
|
1189
|
+
return ParsedCondition(name, "is null", [])
|
|
1190
|
+
|
|
1191
|
+
def like(self, value) -> Condition:
|
|
1192
|
+
name = self.name_for_building_condition()
|
|
1193
|
+
if "like" not in self._allowed_search_operators:
|
|
1194
|
+
raise ValueError(f"A 'like' search is not allowed for '{self.model_class.__name__}.{name}'.")
|
|
1195
|
+
value = self.to_backend({name: value}).get(name)
|
|
1196
|
+
return ParsedCondition(name, "like", [value])
|
|
1197
|
+
|
|
1198
|
+
def condition(self, operator: str, value) -> Condition:
|
|
1199
|
+
name = self.name_for_building_condition()
|
|
1200
|
+
operator = operator.lower()
|
|
1201
|
+
if operator not in self._allowed_search_operators:
|
|
1202
|
+
raise ValueError(f"The operator '{operator}' is not allowed for '{self.model_class.__name__}.{name}'.")
|
|
1203
|
+
|
|
1204
|
+
# the search methods are more or less identical except:
|
|
1205
|
+
if operator == "in":
|
|
1206
|
+
return self.is_in(value)
|
|
1207
|
+
|
|
1208
|
+
value = self.to_backend({name: value}).get(name)
|
|
1209
|
+
return ParsedCondition(name, operator, [value])
|
|
1210
|
+
|
|
1211
|
+
def is_allowed_search_operator(self, operator: str, relationship_reference: str = "") -> bool:
|
|
1212
|
+
return operator in self._allowed_search_operators
|
|
1213
|
+
|
|
1214
|
+
def allowed_search_operators(self, relationship_reference: str = ""):
|
|
1215
|
+
return self._allowed_search_operators
|
|
1216
|
+
|
|
1217
|
+
def join_table_alias(self) -> str:
|
|
1218
|
+
raise NotImplementedError("Ooops, I don't support joins")
|
|
1219
|
+
|
|
1220
|
+
def documentation(self, name=None, example=None, value=None) -> list[AutoDocSchema]:
|
|
1221
|
+
return [self.auto_doc_class(name if name is not None else self.name, example=example, value=value)]
|