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/model.py
CHANGED
|
@@ -1,37 +1,250 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
from abc import abstractmethod
|
|
2
|
-
from
|
|
3
|
-
|
|
4
|
-
from .
|
|
5
|
-
import
|
|
6
|
-
from .
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Callable, Iterator, Self
|
|
5
|
+
|
|
6
|
+
from clearskies.di import InjectableProperties, inject
|
|
7
|
+
from clearskies.functional import string
|
|
8
|
+
from clearskies.query import Condition, Join, Query, Sort
|
|
9
|
+
from clearskies.schema import Schema
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from clearskies import Column
|
|
13
|
+
from clearskies.autodoc.schema import Schema as AutoDocSchema
|
|
14
|
+
from clearskies.backends import Backend
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Model(Schema, InjectableProperties):
|
|
18
|
+
"""
|
|
19
|
+
A clearskies model.
|
|
20
|
+
|
|
21
|
+
To be useable, a model class needs four things:
|
|
22
|
+
|
|
23
|
+
1. The name of the id column
|
|
24
|
+
2. A backend
|
|
25
|
+
3. A destination name (equivalent to a table name for SQL backends)
|
|
26
|
+
4. Columns
|
|
27
|
+
|
|
28
|
+
In more detail:
|
|
29
|
+
|
|
30
|
+
### Id Column Name
|
|
31
|
+
|
|
32
|
+
clearskies assumes that all models have a column that uniquely identifies each record. This id column is
|
|
33
|
+
provided where appropriate in the lifecycle of the model save process to help connect and find related records.
|
|
34
|
+
It's defined as a simple class attribute called `id_column_name`. There **MUST** be a column with the same name
|
|
35
|
+
in the column definitions. A simple approach to take is to use the Uuid column as an id column. This will
|
|
36
|
+
automatically provide a random UUID when the record is first created. If you are using auto-incrementing integers,
|
|
37
|
+
you can simply use an `Int` column type and define the column as auto-incrementing in your database.
|
|
38
|
+
|
|
39
|
+
### Backend
|
|
40
|
+
|
|
41
|
+
Every model needs a backend, which is an object that extends clearskies.Backend and is attached to the
|
|
42
|
+
`backend` attribute of the model class. clearskies comes with a variety of backends in the `clearskies.backends`
|
|
43
|
+
module that you can use, and you can also define your own or import more from additional packages.
|
|
44
|
+
|
|
45
|
+
### Destination Name
|
|
46
|
+
|
|
47
|
+
The destination name is the equivalent of a table name in other frameworks, but the name is more generic to
|
|
48
|
+
reflect the fact that clearskies is intended to work with a variety of backends - not just SQL databases.
|
|
49
|
+
The exact meaning of the destination name depends on the backend: for a cursor backend it is in fact used
|
|
50
|
+
as the table name when fetching/storing records. For the API backend it is frequently appended to a base
|
|
51
|
+
URL to reach the corect endpoint.
|
|
52
|
+
|
|
53
|
+
This is provided by a class function call `destination_name`. The base model class declares a generic method
|
|
54
|
+
for this which takes the class name, converts it from title case to snake case, and makes it plural. Hence,
|
|
55
|
+
a model class called `User` will have a default destination name of `users` and a model class of `OrderProduct`
|
|
56
|
+
will have a default destination name of `order_products`. Of course, this system isn't pefect: your backend
|
|
57
|
+
may have a different convention or you may have one of the many words in the english language that are
|
|
58
|
+
exceptions to the grammatical rules of making words plural. In this case you can simply extend the method
|
|
59
|
+
and change it according to your needs, e.g.:
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
from typing import Self
|
|
63
|
+
import clearskies
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Fish(clearskies.Model):
|
|
67
|
+
@classmethod
|
|
68
|
+
def destination_name(cls: type[Self]) -> str:
|
|
69
|
+
return "fish"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Columns
|
|
73
|
+
|
|
74
|
+
Finally, columns are defined by attaching attributes to your model class that extend clearskies.Column. A variety
|
|
75
|
+
are provided by default in the clearskies.columns module, and you can always create more or import them from
|
|
76
|
+
other packages.
|
|
77
|
+
|
|
78
|
+
### Fetching From the Di Container
|
|
79
|
+
|
|
80
|
+
In order to use a model in your application you need to retrieve it from the dependency injection system. Like
|
|
81
|
+
everything, you can do this by either the name or with type hinting. Models do have a special rule for
|
|
82
|
+
injection-via-name: like all classes their dependency injection name is made by converting the class name from
|
|
83
|
+
title case to snake case, but they are also available via the pluralized name. Here's a quick example of all
|
|
84
|
+
three approaches for dependency injection:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
import clearskies
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class User(clearskies.Model):
|
|
91
|
+
id_column_name = "id"
|
|
92
|
+
backend = clearskies.backends.MemoryBackend()
|
|
93
|
+
|
|
94
|
+
id = clearskies.columns.Uuid()
|
|
95
|
+
name = clearskies.columns.String()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def my_application(user, users, by_type_hint: User):
|
|
99
|
+
return {
|
|
100
|
+
"all_are_user_models": isinstance(user, User)
|
|
101
|
+
and isinstance(users, User)
|
|
102
|
+
and isinstance(by_type_hint, User)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
cli = clearskies.contexts.Cli(my_application, classes=[User])
|
|
107
|
+
cli()
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Note that the `User` model class was provided in the `classes` list sent to the context: that's important as it
|
|
111
|
+
informs the dependency injection system that this is a class we want to provide. It's common (but not required)
|
|
112
|
+
to put all models for a clearskies application in their own separate python module and then provide those to
|
|
113
|
+
the depedency injection system via the `modules` argument to the context. So you may have a directory structure
|
|
114
|
+
like this:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
├── app/
|
|
118
|
+
│ └── models/
|
|
119
|
+
│ ├── __init__.py
|
|
120
|
+
│ ├── category.py
|
|
121
|
+
│ ├── order.py
|
|
122
|
+
│ ├── product.py
|
|
123
|
+
│ ├── status.py
|
|
124
|
+
│ └── user.py
|
|
125
|
+
└── api.py
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Where `__init__.py` imports all the models:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
from app.models.category import Category
|
|
132
|
+
from app.models.order import Order
|
|
133
|
+
from app.models.proudct import Product
|
|
134
|
+
from app.models.status import Status
|
|
135
|
+
from app.models.user import User
|
|
136
|
+
|
|
137
|
+
__all__ = ["Category", "Order", "Product", "Status", "User"]
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Then in your main application you can just import the whole `models` module into your context:
|
|
141
|
+
|
|
142
|
+
```
|
|
143
|
+
import app.models
|
|
144
|
+
|
|
145
|
+
cli = clearskies.contexts.cli(SomeApplication, modules=[app.models])
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### Adding Dependencies
|
|
149
|
+
|
|
150
|
+
The base model class extends `clearskies.di.InjectableProperties` which means that you can inject dependencies into your model
|
|
151
|
+
using the `di.inject` classes. Here's an example that demonstrates dependency injection for models:
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
import datetime
|
|
155
|
+
import clearskies
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class SomeClass:
|
|
159
|
+
# Since this will be built by the DI system directly, we can declare dependencies in the __init__
|
|
160
|
+
def __init__(self, some_date):
|
|
161
|
+
self.some_date = some_date
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class User(clearskies.Model):
|
|
165
|
+
id_column_name = "id"
|
|
166
|
+
backend = clearskies.backends.MemoryBackend()
|
|
167
|
+
|
|
168
|
+
utcnow = clearskies.di.inject.Utcnow()
|
|
169
|
+
some_class = clearskies.di.inject.ByClass(SomeClass)
|
|
170
|
+
|
|
171
|
+
id = clearskies.columns.Uuid()
|
|
172
|
+
name = clearskies.columns.String()
|
|
173
|
+
|
|
174
|
+
def some_date_in_the_past(self):
|
|
175
|
+
return self.some_class.some_date < self.utcnow
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def my_application(user):
|
|
179
|
+
return user.some_date_in_the_past()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
cli = clearskies.contexts.Cli(
|
|
183
|
+
my_application,
|
|
184
|
+
classes=[User],
|
|
185
|
+
bindings={
|
|
186
|
+
"some_date": datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1),
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
cli()
|
|
190
|
+
```
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
_previous_data: dict[str, Any] = {}
|
|
194
|
+
_data: dict[str, Any] = {}
|
|
195
|
+
_next_data: dict[str, Any] = {}
|
|
196
|
+
_transformed_data: dict[str, Any] = {}
|
|
197
|
+
_touched_columns: dict[str, bool] = {}
|
|
198
|
+
_query: Query | None = None
|
|
199
|
+
_query_executed: bool = False
|
|
200
|
+
_count: int | None = None
|
|
201
|
+
_next_page_data: dict[str, Any] | None = None
|
|
202
|
+
|
|
203
|
+
id_column_name: str = ""
|
|
204
|
+
backend: Backend = None # type: ignore
|
|
205
|
+
|
|
206
|
+
_di = inject.Di()
|
|
207
|
+
|
|
208
|
+
def __init__(self):
|
|
209
|
+
if not self.id_column_name:
|
|
210
|
+
raise ValueError(
|
|
211
|
+
f"You must define the 'id_column_name' property for every model class, but this is missing for model '{self.__class__.__name__}'"
|
|
212
|
+
)
|
|
213
|
+
if not isinstance(self.id_column_name, str):
|
|
214
|
+
raise TypeError(
|
|
215
|
+
f"The 'id_column_name' property of a model must be a string that specifies the name of the id column, but that is not the case for model '{self.__class__.__name__}'."
|
|
216
|
+
)
|
|
217
|
+
if not self.backend:
|
|
218
|
+
raise ValueError(
|
|
219
|
+
f"You must define the 'backend' property for every model class, but this is missing for model '{self.__class__.__name__}'"
|
|
220
|
+
)
|
|
221
|
+
if not hasattr(self.backend, "documentation_pagination_parameters"):
|
|
222
|
+
raise TypeError(
|
|
223
|
+
f"The 'backend' property of a model must be an object that extends the clearskies.Backend class, but that is not the case for model '{self.__class__.__name__}'."
|
|
224
|
+
)
|
|
225
|
+
self._previous_data = {}
|
|
20
226
|
self._data = {}
|
|
21
|
-
self.
|
|
22
|
-
self.
|
|
227
|
+
self._next_data = {}
|
|
228
|
+
self._transformed_data = {}
|
|
229
|
+
self._touched_columns = {}
|
|
230
|
+
self._query = None
|
|
231
|
+
self._query_executed = False
|
|
232
|
+
self._count = None
|
|
233
|
+
self._next_page_data = None
|
|
23
234
|
|
|
24
|
-
|
|
235
|
+
@classmethod
|
|
236
|
+
def destination_name(cls: type[Self]) -> str:
|
|
25
237
|
"""
|
|
26
|
-
Return the
|
|
238
|
+
Return the name of the destination that the model uses for data storage.
|
|
27
239
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
240
|
+
For SQL backends, this would return the table name. Other backends will use this
|
|
241
|
+
same function but interpret it in whatever way it makes sense. For instance, an
|
|
242
|
+
API backend may treat it as a URL (or URL path), an SQS backend may expect a queue
|
|
243
|
+
URL, etc...
|
|
31
244
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"""
|
|
245
|
+
By default this takes the class name, converts from title case to snake case, and then
|
|
246
|
+
makes it plural.
|
|
247
|
+
"""
|
|
35
248
|
singular = string.camel_case_to_snake_case(cls.__name__)
|
|
36
249
|
if singular[-1] == "y":
|
|
37
250
|
return singular[:-1] + "ies"
|
|
@@ -39,161 +252,481 @@ class Model(Models):
|
|
|
39
252
|
return singular + "es"
|
|
40
253
|
return f"{singular}s"
|
|
41
254
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
"""Returns an ordered dictionary with the configuration for the columns"""
|
|
45
|
-
pass
|
|
255
|
+
def supports_n_plus_one(self: Self):
|
|
256
|
+
return self.backend.supports_n_plus_one # type: ignore
|
|
46
257
|
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return default
|
|
51
|
-
|
|
52
|
-
def columns(self, overrides=None):
|
|
53
|
-
# no caching if we have overrides
|
|
54
|
-
if overrides is not None:
|
|
55
|
-
return self._columns.configure(self.all_columns(), self.__class__, overrides=overrides)
|
|
56
|
-
|
|
57
|
-
if self._configured_columns is None:
|
|
58
|
-
self._configured_columns = self._columns.configure(self.all_columns(), self.__class__)
|
|
59
|
-
return self._configured_columns
|
|
60
|
-
|
|
61
|
-
def supports_n_plus_one(self):
|
|
62
|
-
return self._backend.supports_n_plus_one
|
|
63
|
-
|
|
64
|
-
def __getitem__(self, column_name):
|
|
65
|
-
return self.__getattr__(column_name)
|
|
66
|
-
|
|
67
|
-
def __getattr__(self, column_name):
|
|
68
|
-
# this should be adjusted to only return None for empty records if the column name corresponds
|
|
69
|
-
# to an actual column in the table.
|
|
70
|
-
if not self.exists:
|
|
71
|
-
return None
|
|
72
|
-
|
|
73
|
-
return self.get_transformed_from_data(column_name, self._data)
|
|
74
|
-
|
|
75
|
-
def get(self, column_name, silent=False):
|
|
76
|
-
if not self.exists:
|
|
77
|
-
return None
|
|
78
|
-
|
|
79
|
-
return self.get_transformed_from_data(column_name, self._data, silent=silent)
|
|
80
|
-
|
|
81
|
-
def get_transformed_from_data(self, column_name, data, cache=True, check_providers=True, silent=False):
|
|
82
|
-
if cache and column_name in self._transformed:
|
|
83
|
-
return self._transformed[column_name]
|
|
84
|
-
|
|
85
|
-
# everything in self._data came directly out of the database, but we don't want to send that off.
|
|
86
|
-
# instead, the corresponding column has an opportunity to make changes as needed. Moreover,
|
|
87
|
-
# it could be that the requested column_name doesn't even exist directly in self._data, but
|
|
88
|
-
# can be provided by a column. Therefore, we're going to do some work to fulfill the request,
|
|
89
|
-
# raise an Error if we *really* can't fulfill it, and store the results in self._transformed
|
|
90
|
-
# as a simple local cache (self._transformed is cleared during a save operation)
|
|
91
|
-
columns = self.columns()
|
|
92
|
-
value = None
|
|
93
|
-
if (column_name not in data or data[column_name] is None) and check_providers:
|
|
94
|
-
for column in columns.values():
|
|
95
|
-
if column.can_provide(column_name):
|
|
96
|
-
value = column.provide(data, column_name)
|
|
97
|
-
break
|
|
98
|
-
if column_name not in data and value is None:
|
|
99
|
-
if not silent:
|
|
100
|
-
raise KeyError(f"Unknown column '{column_name}' requested from model '{self.__class__.__name__}'")
|
|
101
|
-
return None
|
|
102
|
-
else:
|
|
103
|
-
value = (
|
|
104
|
-
self._backend.column_from_backend(self.columns()[column_name], data[column_name])
|
|
105
|
-
if column_name in self.columns()
|
|
106
|
-
else data[column_name]
|
|
107
|
-
)
|
|
258
|
+
def __bool__(self: Self) -> bool: # noqa: D105
|
|
259
|
+
if self._query:
|
|
260
|
+
return bool(self.__len__())
|
|
108
261
|
|
|
109
|
-
if
|
|
110
|
-
self._transformed[column_name] = value
|
|
111
|
-
return value
|
|
262
|
+
return True if self._data else False
|
|
112
263
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return True if (self.id_column_name in self._data and self._data[self.id_column_name]) else False
|
|
116
|
-
|
|
117
|
-
@property
|
|
118
|
-
def data(self):
|
|
264
|
+
def get_raw_data(self: Self) -> dict[str, Any]:
|
|
265
|
+
self.no_queries()
|
|
119
266
|
return self._data
|
|
120
267
|
|
|
121
|
-
|
|
122
|
-
|
|
268
|
+
def get_columns_data(self: Self, overrides: dict[str, Column] = {}, include_all=False) -> dict[str, Any]:
|
|
269
|
+
self.no_queries()
|
|
270
|
+
columns = self.get_columns(overrides=overrides).values()
|
|
271
|
+
if columns is None:
|
|
272
|
+
return {}
|
|
273
|
+
return {
|
|
274
|
+
column.name: getattr(self, column.name)
|
|
275
|
+
for column in columns
|
|
276
|
+
if column.is_readable and (column.name in self._data or include_all)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
def set_raw_data(self: Self, data: dict[str, Any]) -> None:
|
|
280
|
+
self.no_queries()
|
|
123
281
|
self._data = {} if data is None else data
|
|
282
|
+
self._transformed_data = {}
|
|
124
283
|
|
|
125
|
-
def save(self, data, columns=
|
|
284
|
+
def save(self: Self, data: dict[str, Any] | None = None, columns: dict[str, Column] = {}, no_data=False) -> bool:
|
|
126
285
|
"""
|
|
127
|
-
Save data to the database and update the
|
|
286
|
+
Save data to the database and create/update the underlying record.
|
|
287
|
+
|
|
288
|
+
### Lifecycle of a Save
|
|
289
|
+
|
|
290
|
+
Before discussing the mechanics of how to save a model, it helps to understand the full lifecycle of a save
|
|
291
|
+
operation. Of course you can ignore this lifecycle and simply use the save process to send data to a
|
|
292
|
+
backend, but then you miss out on one of the key advantages of clearskies - supporting a state machine
|
|
293
|
+
flow for defining your applications. The save process is controlled not just by the model but also by
|
|
294
|
+
the columns, with equivalent hooks for both. This creates a lot of flexibility for how to control and
|
|
295
|
+
organize an application. The overall save process looks like this:
|
|
296
|
+
|
|
297
|
+
1. The `pre_save` hook in each column is called (including the `on_change_pre_save` actions attached to the columns)
|
|
298
|
+
2. The `pre_save` hook for the model is called
|
|
299
|
+
3. The `to_backend` hook for each column is called and temporary data is removed from the save dictionary
|
|
300
|
+
4. The `to_backend` hook for the model is called
|
|
301
|
+
5. The data is persisted to the backend via a create or update call as appropriate
|
|
302
|
+
6. The `post_save` hook in each column is called (including the `on_change_post_save` actions attached to the columns)
|
|
303
|
+
7. The `post_save` hook in the model is called
|
|
304
|
+
8. Any data returned by the backend during the create/update operation is saved to the model along with the temporary data
|
|
305
|
+
9. The `save_finished` hook in each column is called (including the `on_change_save_finished` actions attached to the columns)
|
|
306
|
+
10. The `save_finished` hook in the model is called
|
|
307
|
+
|
|
308
|
+
Note that pre/post/finished hooks for all columns are called - not just the ones with data in the save.
|
|
309
|
+
Thus, any column attached to a model can always influence the save process.
|
|
310
|
+
|
|
311
|
+
From this we can see how to use these hooks. In particular:
|
|
312
|
+
|
|
313
|
+
1. The `pre_save` hook is used to modify the data before it is persisted to the backend. This means that changes
|
|
314
|
+
can be made to the data dictionary in the `pre_save` step and there will still only be a single save operation
|
|
315
|
+
with the backend. For columns, the `on_change_pre_save` methods *MUST* be stateless - they can return data to
|
|
316
|
+
change the save but should not make any changes themselves. This is because they may be called more than once
|
|
317
|
+
in a given save operation.
|
|
318
|
+
2. `to_backend` is used to modify data on its way to the backend. Consider dates: in python these are typically represented
|
|
319
|
+
by datetime objects but, to persist this to (for instance) an SQL database, it usually has to be converted to a string
|
|
320
|
+
format first. That happens in the `to_backend` method of the datetime column.
|
|
321
|
+
3. The `post_save` hook is called after the backend is updated. Therefore, if you are using auto-incrementing ids,
|
|
322
|
+
the id will only be available in ths hook. For consistency with this, clearskies doesn't directly provide the record id
|
|
323
|
+
until the `post_save` hook. If you need to make more data changes in this hook, an additional operation will
|
|
324
|
+
be required. Since the backend has already been updated, this hook does not require a return value (and anything
|
|
325
|
+
returned will be ignored).
|
|
326
|
+
4. The save finished hook happens after the save is fully completed. The backend is updated and the model has been
|
|
327
|
+
updated and the model state reflects the new backend state.
|
|
328
|
+
|
|
329
|
+
The following table summarizes some key details of these hooks:
|
|
330
|
+
|
|
331
|
+
| Name | Stateful | Return Value | Id Present | Backend Updated | Model Updated |
|
|
332
|
+
|-----------------|----------|----------------|------------|-----------------|---------------|
|
|
333
|
+
| `pre_save` | No | dict[str, Any] | No | No | No |
|
|
334
|
+
| `post_save` | Yes | None | Yes | Yes | No |
|
|
335
|
+
| `save_finished` | Yes | None | Yes | Yes | Yes |
|
|
336
|
+
|
|
337
|
+
### How to Create/Update a Model
|
|
338
|
+
|
|
339
|
+
There are two supported flows. One is to pass in a dictionary of data to save:
|
|
340
|
+
|
|
341
|
+
```python
|
|
342
|
+
import clearskies
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
class User(clearskies.Model):
|
|
346
|
+
id_column_name = "id"
|
|
347
|
+
backend = clearskies.backends.MemoryBackend()
|
|
348
|
+
|
|
349
|
+
id = clearskies.columns.Uuid()
|
|
350
|
+
name = clearskies.columns.String()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def my_application(user):
|
|
354
|
+
user.save(
|
|
355
|
+
{
|
|
356
|
+
"name": "Awesome Person",
|
|
357
|
+
}
|
|
358
|
+
)
|
|
359
|
+
return {"id": user.id, "name": user.name}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
cli = clearskies.contexts.Cli(
|
|
363
|
+
my_application,
|
|
364
|
+
classes=[User],
|
|
365
|
+
)
|
|
366
|
+
cli()
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
And the other is to set new values on the columns attributes and then call save without data:
|
|
370
|
+
|
|
371
|
+
```python
|
|
372
|
+
import clearskies
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class User(clearskies.Model):
|
|
376
|
+
id_column_name = "id"
|
|
377
|
+
backend = clearskies.backends.MemoryBackend()
|
|
378
|
+
|
|
379
|
+
id = clearskies.columns.Uuid()
|
|
380
|
+
name = clearskies.columns.String()
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def my_application(user):
|
|
384
|
+
user.name = "Awesome Person"
|
|
385
|
+
user.save()
|
|
386
|
+
return {"id": user.id, "name": user.name}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
cli = clearskies.contexts.Cli(
|
|
390
|
+
my_application,
|
|
391
|
+
classes=[User],
|
|
392
|
+
)
|
|
393
|
+
cli()
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
The primray difference is that setting attributes provides strict type checking capabilities, while passing a
|
|
397
|
+
dictionary can be done in one line. Note that you cannot combine these methods: if you set a value on a
|
|
398
|
+
column attribute and also pass in a dictionary of data to the save, then an exception will be raised.
|
|
399
|
+
In either case the save operation acts in place on the model object. The return value is always True - in
|
|
400
|
+
the event of an error an exception will be raised.
|
|
401
|
+
|
|
402
|
+
If a record already exists in the model being saved, then an update operation will be executed. Otherwise,
|
|
403
|
+
a new record will be inserted. To understand the difference yourself, you can convert a model to a boolean
|
|
404
|
+
value - it will return True if a record has been loaded and false otherwise. You can see that with this
|
|
405
|
+
example, where all the `if` statements will evaluate to `True`:
|
|
406
|
+
|
|
407
|
+
```
|
|
408
|
+
import clearskies
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
class User(clearskies.Model):
|
|
412
|
+
id_column_name = "id"
|
|
413
|
+
backend = clearskies.backends.MemoryBackend()
|
|
414
|
+
|
|
415
|
+
id = clearskies.columns.Uuid()
|
|
416
|
+
name = clearskies.columns.String()
|
|
128
417
|
|
|
129
|
-
|
|
418
|
+
|
|
419
|
+
def my_application(user):
|
|
420
|
+
if not user:
|
|
421
|
+
print("We will execute a create operation")
|
|
422
|
+
|
|
423
|
+
user.save({"name": "Test One"})
|
|
424
|
+
new_id = user.id
|
|
425
|
+
|
|
426
|
+
if user:
|
|
427
|
+
print("We will execute an update operation")
|
|
428
|
+
|
|
429
|
+
user.save({"name": "Test Two"})
|
|
430
|
+
|
|
431
|
+
final_id = user.id
|
|
432
|
+
|
|
433
|
+
if new_id == final_id:
|
|
434
|
+
print("The id did not chnage because the second save performed an update")
|
|
435
|
+
|
|
436
|
+
return {"id": user.id, "name": user.name}
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
cli = clearskies.contexts.Cli(
|
|
440
|
+
my_application,
|
|
441
|
+
classes=[User],
|
|
442
|
+
)
|
|
443
|
+
cli()
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
occassionaly, you may want to execute a save operation without actually providing any data. This may happen,
|
|
447
|
+
for instance, if you want to create a record in the database that will be filled in later, and so just need
|
|
448
|
+
an auto-generated id. By default if you call save without setting attributes on the model and without
|
|
449
|
+
providing data to the `save` call, this will raise an exception, but you can make this happen with the
|
|
450
|
+
`no_data` kwarg:
|
|
451
|
+
|
|
452
|
+
```
|
|
453
|
+
import clearskies
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
class User(clearskies.Model):
|
|
457
|
+
id_column_name = "id"
|
|
458
|
+
backend = clearskies.backends.MemoryBackend()
|
|
459
|
+
|
|
460
|
+
id = clearskies.columns.Uuid()
|
|
461
|
+
name = clearskies.columns.String()
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def my_application(user):
|
|
465
|
+
# create a record with just an id
|
|
466
|
+
user.save(no_data=True)
|
|
467
|
+
|
|
468
|
+
# and now we can set the name
|
|
469
|
+
user.save({"name": "Test"})
|
|
470
|
+
|
|
471
|
+
return {"id": user.id, "name": user.name}
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
cli = clearskies.contexts.Cli(
|
|
475
|
+
my_application,
|
|
476
|
+
classes=[User],
|
|
477
|
+
)
|
|
478
|
+
cli()
|
|
479
|
+
```
|
|
130
480
|
"""
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
481
|
+
self.no_queries()
|
|
482
|
+
if not data and not self._next_data and not no_data:
|
|
483
|
+
raise ValueError("You have to pass in something to save, or set no_data=True in your call to save/create.")
|
|
484
|
+
if data and self._next_data:
|
|
485
|
+
raise ValueError(
|
|
486
|
+
"Save data was provided to the model class by both passing in a dictionary and setting new values on the column attributes. This is not allowed. You will have to use just one method of specifying save data."
|
|
487
|
+
)
|
|
488
|
+
if not data:
|
|
489
|
+
data = {**self._next_data}
|
|
490
|
+
self._next_data = {}
|
|
491
|
+
|
|
492
|
+
save_columns = self.get_columns()
|
|
134
493
|
if columns is not None:
|
|
135
494
|
for column in columns.values():
|
|
136
495
|
save_columns[column.name] = column
|
|
137
496
|
|
|
138
|
-
old_data = self.
|
|
497
|
+
old_data = self.get_raw_data()
|
|
139
498
|
data = self.columns_pre_save(data, save_columns)
|
|
140
499
|
data = self.pre_save(data)
|
|
141
500
|
if data is None:
|
|
142
501
|
raise ValueError("pre_save forgot to return the data array!")
|
|
143
502
|
|
|
144
|
-
to_save = self.columns_to_backend(data, save_columns)
|
|
503
|
+
[to_save, temporary_data] = self.columns_to_backend(data, save_columns)
|
|
145
504
|
to_save = self.to_backend(to_save, save_columns)
|
|
146
|
-
if self
|
|
147
|
-
new_data = self.
|
|
505
|
+
if self:
|
|
506
|
+
new_data = self.backend.update(self._data[self.id_column_name], to_save, self) # type: ignore
|
|
148
507
|
else:
|
|
149
|
-
new_data = self.
|
|
150
|
-
id = self.
|
|
508
|
+
new_data = self.backend.create(to_save, self) # type: ignore
|
|
509
|
+
id = self.backend.column_from_backend(save_columns[self.id_column_name], new_data[self.id_column_name]) # type: ignore
|
|
510
|
+
|
|
511
|
+
# if we had any temporary columns add them back in
|
|
512
|
+
new_data = {
|
|
513
|
+
**temporary_data,
|
|
514
|
+
**new_data,
|
|
515
|
+
}
|
|
151
516
|
|
|
152
517
|
data = self.columns_post_save(data, id, save_columns)
|
|
153
518
|
self.post_save(data, id)
|
|
154
519
|
|
|
155
|
-
self.
|
|
156
|
-
self.
|
|
520
|
+
self.set_raw_data(new_data)
|
|
521
|
+
self._transformed_data = {}
|
|
157
522
|
self._previous_data = old_data
|
|
158
|
-
self._touched_columns =
|
|
523
|
+
self._touched_columns = {key: True for key in data.keys()}
|
|
159
524
|
|
|
160
525
|
self.columns_save_finished(save_columns)
|
|
161
526
|
self.save_finished()
|
|
162
527
|
|
|
163
528
|
return True
|
|
164
529
|
|
|
165
|
-
def is_changing(self, key, data):
|
|
530
|
+
def is_changing(self: Self, key: str, data: dict[str, Any]) -> bool:
|
|
166
531
|
"""
|
|
167
|
-
|
|
532
|
+
Return True/False to denote if the given column is being modified by the active save operation.
|
|
533
|
+
|
|
534
|
+
A column is considered to be changing if:
|
|
535
|
+
|
|
536
|
+
- During a create operation
|
|
537
|
+
- It is present in the data array, even if a null value
|
|
538
|
+
- During an update operation
|
|
539
|
+
- It is present in the data array and the value is changing
|
|
540
|
+
|
|
541
|
+
Note whether or not the value is changing is typically evaluated with a simple `=` comparison,
|
|
542
|
+
but columns can optionally implement their own custom logic.
|
|
543
|
+
|
|
544
|
+
Pass in the name of the column to check and the data dictionary from the save in progress. This only
|
|
545
|
+
returns meaningful results during a save, which typically happens in the pre-save/post-save hooks
|
|
546
|
+
(either on the model class itself or in a column). Here's an examle that extends the `pre_save` hook
|
|
547
|
+
on the model to demonstrate how `is_changing` works:
|
|
548
|
+
|
|
549
|
+
```
|
|
550
|
+
from typing import Any, Self
|
|
551
|
+
import clearskies
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
class User(clearskies.Model):
|
|
555
|
+
id_column_name = "id"
|
|
556
|
+
backend = clearskies.backends.MemoryBackend()
|
|
557
|
+
|
|
558
|
+
id = clearskies.columns.Uuid()
|
|
559
|
+
name = clearskies.columns.String()
|
|
560
|
+
age = clearskies.columns.Integer()
|
|
561
|
+
|
|
562
|
+
def pre_save(self: Self, data: dict[str, Any]) -> dict[str, Any]:
|
|
563
|
+
if self.is_changing("name", data) and self.is_changing("age", data):
|
|
564
|
+
print("My name and age have changed!")
|
|
565
|
+
elif self.is_changing("name", data):
|
|
566
|
+
print("Only my name is changing")
|
|
567
|
+
elif self.is_changing("age", data):
|
|
568
|
+
print("Only my age is changing")
|
|
569
|
+
else:
|
|
570
|
+
print("Nothing changed")
|
|
571
|
+
return data
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def my_application(users):
|
|
575
|
+
jane = users.create({"name": "Jane"})
|
|
576
|
+
jane.save({"age": 22})
|
|
577
|
+
jane.save({"name": "Anon", "age": 23})
|
|
578
|
+
jane.save({"name": "Anon", "age": 23})
|
|
579
|
+
|
|
580
|
+
return {"id": jane.id, "name": jane.name}
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
cli = clearskies.contexts.Cli(
|
|
584
|
+
my_application,
|
|
585
|
+
classes=[User],
|
|
586
|
+
)
|
|
587
|
+
cli()
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
If you run the above example it will print out:
|
|
591
|
+
|
|
592
|
+
```
|
|
593
|
+
Only my name is changing
|
|
594
|
+
Only my age is changing
|
|
595
|
+
My name and age have changed
|
|
596
|
+
Nothing changed
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
The first message is printed out when the record is created - during a create operation, any column that
|
|
600
|
+
is being set to a non-null value is considered to be changing. We then set the age, and since it changes
|
|
601
|
+
from a null value (we didn't originally set an age with the create operation, so the age was null) to a
|
|
602
|
+
non-null value, `is_changed` returns True. We perform another update operation and set both
|
|
603
|
+
name and age to new values, so both change. Finally we repeat the same save operation. This will result
|
|
604
|
+
in another update operation on the backend, but `is_changed` reflects the fact that the values haven't
|
|
605
|
+
actually changed from their previous values.
|
|
168
606
|
|
|
169
|
-
Pass in the name of the column to check and the data dictionary from the save in progress
|
|
170
607
|
"""
|
|
608
|
+
self.no_queries()
|
|
171
609
|
has_old_value = key in self._data
|
|
172
610
|
has_new_value = key in data
|
|
173
611
|
|
|
174
612
|
if not has_new_value:
|
|
175
613
|
return False
|
|
614
|
+
|
|
176
615
|
if not has_old_value:
|
|
177
616
|
return True
|
|
178
617
|
|
|
179
|
-
|
|
618
|
+
columns = self.get_columns()
|
|
619
|
+
new_value = data[key]
|
|
620
|
+
old_value = self._data[key]
|
|
621
|
+
if key not in columns:
|
|
622
|
+
return old_value != new_value
|
|
623
|
+
return not columns[key].values_match(old_value, new_value)
|
|
180
624
|
|
|
181
|
-
def latest(self, key, data):
|
|
625
|
+
def latest(self: Self, key: str, data: dict[str, Any]) -> Any:
|
|
182
626
|
"""
|
|
183
|
-
|
|
627
|
+
Return the 'latest' value for a column during the save operation.
|
|
184
628
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
629
|
+
During the pre_save and post_save hooks, the model is not yet updated with the latest data.
|
|
630
|
+
In these hooks, it's common to want the "latest" data for the model - e.g. either the column value
|
|
631
|
+
from the model or from the data dictionary (if the column is being updated in the save). This happens
|
|
632
|
+
via slightly verbose lines like: `data.get(column_name, getattr(self, column_name))`. The `latest`
|
|
633
|
+
method is just a substitue for this:
|
|
634
|
+
|
|
635
|
+
```
|
|
636
|
+
from typing import Any, Self
|
|
637
|
+
import clearskies
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
class User(clearskies.Model):
|
|
641
|
+
id_column_name = "id"
|
|
642
|
+
backend = clearskies.backends.MemoryBackend()
|
|
643
|
+
|
|
644
|
+
id = clearskies.columns.Uuid()
|
|
645
|
+
name = clearskies.columns.String()
|
|
646
|
+
age = clearskies.columns.Integer()
|
|
647
|
+
|
|
648
|
+
def pre_save(self: Self, data: dict[str, Any]) -> dict[str, Any]:
|
|
649
|
+
if not self:
|
|
650
|
+
print("Create operation in progress!")
|
|
651
|
+
else:
|
|
652
|
+
print("Update operation in progress!")
|
|
653
|
+
|
|
654
|
+
print("Latest name: " + str(self.latest("name", data)))
|
|
655
|
+
print("Latest age: " + str(self.latest("age", data)))
|
|
656
|
+
return data
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def my_application(users):
|
|
660
|
+
jane = users.create({"name": "Jane"})
|
|
661
|
+
jane.save({"age": 25})
|
|
662
|
+
return {"id": jane.id, "name": jane.name}
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
cli = clearskies.contexts.Cli(
|
|
666
|
+
my_application,
|
|
667
|
+
classes=[User],
|
|
668
|
+
)
|
|
669
|
+
cli()
|
|
670
|
+
```
|
|
671
|
+
The above example will print:
|
|
672
|
+
|
|
673
|
+
```
|
|
674
|
+
Create operation in progress!
|
|
675
|
+
Latest name: Jane
|
|
676
|
+
Latest age: None
|
|
677
|
+
Update operation in progress!
|
|
678
|
+
Latest name: Jane
|
|
679
|
+
Latest age: 25
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
e.g. `latest` returns the value in the data array (if present), the value for the column in the model, or None.
|
|
188
683
|
|
|
189
|
-
Pass in the name of the column to check and the data dictionary from the save in progress
|
|
190
684
|
"""
|
|
685
|
+
self.no_queries()
|
|
191
686
|
if key in data:
|
|
192
687
|
return data[key]
|
|
193
|
-
return self
|
|
688
|
+
return getattr(self, key)
|
|
689
|
+
|
|
690
|
+
def was_changed(self: Self, key: str) -> bool:
|
|
691
|
+
"""
|
|
692
|
+
Return True/False to denote if a column was changed in the last save.
|
|
194
693
|
|
|
195
|
-
|
|
196
|
-
|
|
694
|
+
To emphasize, the difference between this and `is_changing` is that `is_changing` is available during
|
|
695
|
+
the save prcess while `was_changed` is available after the save has finished. Otherwise, the logic for
|
|
696
|
+
deciding if a column has changed is identical as for `is_changing`.
|
|
697
|
+
|
|
698
|
+
```
|
|
699
|
+
import clearskies
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
class User(clearskies.Model):
|
|
703
|
+
id_column_name = "id"
|
|
704
|
+
backend = clearskies.backends.MemoryBackend()
|
|
705
|
+
|
|
706
|
+
id = clearskies.columns.Uuid()
|
|
707
|
+
name = clearskies.columns.String()
|
|
708
|
+
age = clearskies.columns.Integer()
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def my_application(users):
|
|
712
|
+
jane = users.create({"name": "Jane"})
|
|
713
|
+
return {
|
|
714
|
+
"name_changed": jane.was_changed("name"),
|
|
715
|
+
"age_changed": jane.was_changed("age"),
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
cli = clearskies.contexts.Cli(
|
|
720
|
+
my_application,
|
|
721
|
+
classes=[User],
|
|
722
|
+
)
|
|
723
|
+
cli()
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
In the above example the name is changed while the age is not.
|
|
727
|
+
|
|
728
|
+
"""
|
|
729
|
+
self.no_queries()
|
|
197
730
|
if self._previous_data is None:
|
|
198
731
|
raise ValueError("was_changed was called before a save was finished - you must save something first")
|
|
199
732
|
if key not in self._touched_columns:
|
|
@@ -208,136 +741,1298 @@ class Model(Models):
|
|
|
208
741
|
if not has_old_value:
|
|
209
742
|
return False
|
|
210
743
|
|
|
211
|
-
columns = self.
|
|
212
|
-
new_value = self.
|
|
744
|
+
columns = self.get_columns()
|
|
745
|
+
new_value = self._data[key]
|
|
213
746
|
old_value = self._previous_data[key]
|
|
214
747
|
if key not in columns:
|
|
215
748
|
return old_value != new_value
|
|
216
749
|
return not columns[key].values_match(old_value, new_value)
|
|
217
750
|
|
|
218
|
-
def previous_value(self, key):
|
|
219
|
-
|
|
751
|
+
def previous_value(self: Self, key: str, silent=False):
|
|
752
|
+
"""
|
|
753
|
+
Return the value of a column from before the most recent save.
|
|
754
|
+
|
|
755
|
+
```
|
|
756
|
+
import clearskies
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
class User(clearskies.Model):
|
|
760
|
+
id_column_name = "id"
|
|
761
|
+
backend = clearskies.backends.MemoryBackend()
|
|
762
|
+
|
|
763
|
+
id = clearskies.columns.Uuid()
|
|
764
|
+
name = clearskies.columns.String()
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def my_application(users):
|
|
768
|
+
jane = users.create({"name": "Jane"})
|
|
769
|
+
jane.save({"name": "Jane Doe"})
|
|
770
|
+
return {"name": jane.name, "previous_name": jane.previous_value("name")}
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
cli = clearskies.contexts.Cli(
|
|
774
|
+
my_application,
|
|
775
|
+
classes=[User],
|
|
776
|
+
)
|
|
777
|
+
cli()
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
The above example returns `{"name": "Jane Doe", "previous_name": "Jane"}`
|
|
781
|
+
|
|
782
|
+
If you request a key that is neither a column nor was present in the previous data array,
|
|
783
|
+
then you'll receive a key error. You can suppress this by setting `silent=True` in your call to
|
|
784
|
+
previous_value.
|
|
785
|
+
"""
|
|
786
|
+
self.no_queries()
|
|
787
|
+
if key not in self.get_columns() and key not in self._previous_data:
|
|
788
|
+
raise KeyError(f"Unknown previous data key: {key}")
|
|
789
|
+
if key not in self.get_columns():
|
|
790
|
+
return self._previous_data.get(key)
|
|
791
|
+
return getattr(self.__class__, key).from_backend(self._previous_data.get(key))
|
|
792
|
+
|
|
793
|
+
def delete(self: Self, except_if_not_exists=True) -> bool:
|
|
794
|
+
"""
|
|
795
|
+
Delete a record.
|
|
796
|
+
|
|
797
|
+
If you try to delete a record that doesn't exist, an exception will be thrown unless you set
|
|
798
|
+
`except_if_not_exists=False`. After the record is deleted from the backend, the model instance
|
|
799
|
+
is left unchanged and can be used to fetch the data previously stored. In the following example
|
|
800
|
+
both statements will be printed and the id and name in the "Alice" record will be returned,
|
|
801
|
+
even though the record no longer exists:
|
|
802
|
+
|
|
803
|
+
```
|
|
804
|
+
import clearskies
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
class User(clearskies.Model):
|
|
808
|
+
id_column_name = "id"
|
|
809
|
+
backend = clearskies.backends.MemoryBackend()
|
|
810
|
+
|
|
811
|
+
id = clearskies.columns.Uuid()
|
|
812
|
+
name = clearskies.columns.String()
|
|
220
813
|
|
|
221
|
-
|
|
222
|
-
|
|
814
|
+
|
|
815
|
+
def my_application(users):
|
|
816
|
+
alice = users.create({"name": "Alice"})
|
|
817
|
+
|
|
818
|
+
if users.find("name=Alice"):
|
|
819
|
+
print("Alice exists")
|
|
820
|
+
|
|
821
|
+
alice.delete()
|
|
822
|
+
|
|
823
|
+
if not users.find("name=Alice"):
|
|
824
|
+
print("No more Alice")
|
|
825
|
+
|
|
826
|
+
return {"id": alice.id, "name": alice.name}
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
cli = clearskies.contexts.Cli(
|
|
830
|
+
my_application,
|
|
831
|
+
classes=[User],
|
|
832
|
+
)
|
|
833
|
+
cli()
|
|
834
|
+
```
|
|
835
|
+
"""
|
|
836
|
+
self.no_queries()
|
|
837
|
+
if not self:
|
|
223
838
|
if except_if_not_exists:
|
|
224
839
|
raise ValueError("Cannot delete model that already exists")
|
|
225
840
|
return True
|
|
226
841
|
|
|
227
|
-
columns = self.
|
|
842
|
+
columns = self.get_columns()
|
|
228
843
|
self.columns_pre_delete(columns)
|
|
229
844
|
self.pre_delete()
|
|
230
845
|
|
|
231
|
-
self.
|
|
846
|
+
self.backend.delete(self._data[self.id_column_name], self) # type: ignore
|
|
232
847
|
|
|
233
848
|
self.columns_post_delete(columns)
|
|
234
849
|
self.post_delete()
|
|
235
850
|
return True
|
|
236
851
|
|
|
237
|
-
def columns_pre_save(self, data, columns):
|
|
238
|
-
"""
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
)
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
852
|
+
def columns_pre_save(self: Self, data: dict[str, Any], columns) -> dict[str, Any]:
|
|
853
|
+
"""Use the column information present in the model to make any necessary changes before saving."""
|
|
854
|
+
iterate = True
|
|
855
|
+
changed = {}
|
|
856
|
+
while iterate:
|
|
857
|
+
iterate = False
|
|
858
|
+
for column in columns.values():
|
|
859
|
+
data = column.pre_save(data, self)
|
|
860
|
+
if data is None:
|
|
861
|
+
raise ValueError(
|
|
862
|
+
f"Column {column.name} of type {column.__class__.__name__} did not return any data for pre_save"
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
# if we have newly chnaged data then we want to loop through the pre-saves again
|
|
866
|
+
if data and column.name not in changed:
|
|
867
|
+
changed[column.name] = True
|
|
868
|
+
iterate = True
|
|
253
869
|
return data
|
|
254
870
|
|
|
255
|
-
def columns_to_backend(self, data, columns):
|
|
871
|
+
def columns_to_backend(self: Self, data: dict[str, Any], columns) -> Any:
|
|
256
872
|
backend_data = {**data}
|
|
873
|
+
temporary_data = {}
|
|
257
874
|
for column in columns.values():
|
|
258
|
-
if column.is_temporary
|
|
259
|
-
|
|
875
|
+
if column.is_temporary:
|
|
876
|
+
if column.name in backend_data:
|
|
877
|
+
temporary_data[column.name] = backend_data[column.name]
|
|
878
|
+
del backend_data[column.name]
|
|
260
879
|
continue
|
|
261
880
|
|
|
262
|
-
backend_data = self.
|
|
881
|
+
backend_data = self.backend.column_to_backend(column, backend_data) # type: ignore
|
|
263
882
|
if backend_data is None:
|
|
264
883
|
raise ValueError(
|
|
265
884
|
f"Column {column.name} of type {column.__class__.__name__} did not return any data for to_database"
|
|
266
885
|
)
|
|
267
886
|
|
|
268
|
-
return backend_data
|
|
887
|
+
return [backend_data, temporary_data]
|
|
269
888
|
|
|
270
|
-
def to_backend(self, data, columns):
|
|
889
|
+
def to_backend(self: Self, data: dict[str, Any], columns) -> dict[str, Any]:
|
|
271
890
|
return data
|
|
272
891
|
|
|
273
|
-
def columns_post_save(self, data, id, columns):
|
|
274
|
-
"""
|
|
892
|
+
def columns_post_save(self: Self, data: dict[str, Any], id: str | int, columns) -> dict[str, Any]:
|
|
893
|
+
"""Use the column information present in the model to make additional changes as needed after saving."""
|
|
275
894
|
for column in columns.values():
|
|
276
|
-
|
|
277
|
-
if data is None:
|
|
278
|
-
raise ValueError(
|
|
279
|
-
f"Column {column.name} of type {column.__class__.__name__} did not return any data for post_save"
|
|
280
|
-
)
|
|
895
|
+
column.post_save(data, self, id)
|
|
281
896
|
return data
|
|
282
897
|
|
|
283
|
-
def columns_save_finished(self, columns):
|
|
284
|
-
"""
|
|
898
|
+
def columns_save_finished(self: Self, columns) -> None:
|
|
899
|
+
"""Call the save_finished method on all of our columns."""
|
|
285
900
|
for column in columns.values():
|
|
286
901
|
column.save_finished(self)
|
|
287
902
|
|
|
288
|
-
def
|
|
903
|
+
def pre_save(self: Self, data: dict[str, Any]) -> dict[str, Any]:
|
|
289
904
|
"""
|
|
290
|
-
|
|
905
|
+
Add a hook to add additional logic in the pre-save step of the save process.
|
|
906
|
+
|
|
907
|
+
The pre/post/finished steps of the model are directly analogous to the pre/post/finished steps for the columns.
|
|
908
|
+
|
|
909
|
+
pre-save is inteneded to be a stateless hook (e.g. you should not make changes to the backend) where you can
|
|
910
|
+
adjust the data being saved to the model. It is called before any data is persisted to the backend and
|
|
911
|
+
must return a dictionary of data that will be added to the save, potentially over-writing the save data.
|
|
912
|
+
Since pre-save happens before communicating with the backend, the record itself will not yet exist in the
|
|
913
|
+
event of a create operation, and so the id will not be-present for auto-incrementing ids. As a result, the
|
|
914
|
+
record id is not provided during the pre-save hook. See the breakdown of the save lifecycle in the `save`
|
|
915
|
+
documentation above for more details.
|
|
916
|
+
|
|
917
|
+
An here's an example of using it to set some additional data during a save:
|
|
918
|
+
|
|
919
|
+
```
|
|
920
|
+
from typing import Any, Self
|
|
921
|
+
import clearskies
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
class User(clearskies.Model):
|
|
925
|
+
id_column_name = "id"
|
|
926
|
+
backend = clearskies.backends.MemoryBackend()
|
|
927
|
+
|
|
928
|
+
id = clearskies.columns.Uuid()
|
|
929
|
+
name = clearskies.columns.String()
|
|
930
|
+
is_anonymous = clearskies.columns.Boolean()
|
|
931
|
+
|
|
932
|
+
def pre_save(self: Self, data: dict[str, Any]) -> dict[str, Any]:
|
|
933
|
+
additional_data = {}
|
|
934
|
+
|
|
935
|
+
if self.is_changing("name", data):
|
|
936
|
+
additional_data["is_anonymous"] = not bool(data["name"])
|
|
937
|
+
|
|
938
|
+
return additional_data
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def my_application(users):
|
|
942
|
+
jane = users.create({"name": "Jane"})
|
|
943
|
+
is_anonymous_after_create = jane.is_anonymous
|
|
944
|
+
|
|
945
|
+
jane.save({"name": ""})
|
|
946
|
+
is_anonymous_after_first_update = jane.is_anonymous
|
|
947
|
+
|
|
948
|
+
jane.save({"name": "Jane Doe"})
|
|
949
|
+
is_anonymous_after_last_update = jane.is_anonymous
|
|
950
|
+
|
|
951
|
+
return {
|
|
952
|
+
"is_anonymous_after_create": is_anonymous_after_create,
|
|
953
|
+
"is_anonymous_after_first_update": is_anonymous_after_first_update,
|
|
954
|
+
"is_anonymous_after_last_update": is_anonymous_after_last_update,
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
cli = clearskies.contexts.Cli(
|
|
959
|
+
my_application,
|
|
960
|
+
classes=[User],
|
|
961
|
+
)
|
|
962
|
+
cli()
|
|
963
|
+
```
|
|
964
|
+
|
|
965
|
+
In our pre-save hook we set the `is_anonymous` field to either True or False depending on whether or
|
|
966
|
+
not there is a value in the incoming `name` column. As a result, after the original create operation
|
|
967
|
+
(when the `name` is `"Jane"`, `is_anonymous` is False. We then update the name and set it to an empty
|
|
968
|
+
string, and `is_anonymous` becomes True. We then update one last time to set a name again and
|
|
969
|
+
`is_anonymous` becomes False.
|
|
291
970
|
|
|
292
|
-
It is passed in the data being saved as well as the id. It should take action as needed and then return
|
|
293
|
-
either the original data array or an adjusted one if appropriate.
|
|
294
971
|
"""
|
|
295
|
-
|
|
972
|
+
return data
|
|
296
973
|
|
|
297
|
-
def
|
|
974
|
+
def post_save(self: Self, data: dict[str, Any], id: str | int) -> None:
|
|
298
975
|
"""
|
|
299
|
-
|
|
976
|
+
Add hook to add additional logic in the post-save step of the save process.
|
|
977
|
+
|
|
978
|
+
It is passed in the data being saved as well as the id of the record. Keep in mind that the post save
|
|
979
|
+
hook happens after the backend has been updated (but before the model is updated) so if you need to make
|
|
980
|
+
any changes to the backend you must execute another save operation. Since the backend is already updated,
|
|
981
|
+
the return value from this function is ignored (it should return None):
|
|
982
|
+
|
|
983
|
+
```
|
|
984
|
+
from typing import Any, Self
|
|
985
|
+
import clearskies
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
class History(clearskies.Model):
|
|
989
|
+
id_column_name = "id"
|
|
990
|
+
backend = clearskies.backends.MemoryBackend()
|
|
991
|
+
|
|
992
|
+
id = clearskies.columns.Uuid()
|
|
993
|
+
message = clearskies.columns.String()
|
|
994
|
+
created_at = clearskies.columns.Created(date_format="%Y-%m-%d %H:%M:%S.%f")
|
|
300
995
|
|
|
301
|
-
|
|
996
|
+
|
|
997
|
+
class User(clearskies.Model):
|
|
998
|
+
id_column_name = "id"
|
|
999
|
+
backend = clearskies.backends.MemoryBackend()
|
|
1000
|
+
histories = clearskies.di.inject.ByClass(History)
|
|
1001
|
+
|
|
1002
|
+
id = clearskies.columns.Uuid()
|
|
1003
|
+
age = clearskies.columns.Integer()
|
|
1004
|
+
name = clearskies.columns.String()
|
|
1005
|
+
|
|
1006
|
+
def post_save(self: Self, data: dict[str, Any], id: str | int) -> None:
|
|
1007
|
+
if not self.is_changing("age", data):
|
|
1008
|
+
return
|
|
1009
|
+
|
|
1010
|
+
name = self.latest("name", data)
|
|
1011
|
+
age = self.latest("age", data)
|
|
1012
|
+
self.histories.create({"message": f"My name is {name} and I am {age} years old"})
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def my_application(users, histories):
|
|
1016
|
+
jane = users.create({"name": "Jane"})
|
|
1017
|
+
jane.save({"age": 25})
|
|
1018
|
+
jane.save({"age": 26})
|
|
1019
|
+
jane.save({"age": 30})
|
|
1020
|
+
|
|
1021
|
+
return [history.message for history in histories.sort_by("created_at", "ASC")]
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
cli = clearskies.contexts.Cli(
|
|
1025
|
+
my_application,
|
|
1026
|
+
classes=[User, History],
|
|
1027
|
+
)
|
|
1028
|
+
cli()
|
|
1029
|
+
```
|
|
302
1030
|
"""
|
|
303
|
-
|
|
1031
|
+
pass
|
|
304
1032
|
|
|
305
|
-
def save_finished(self):
|
|
1033
|
+
def save_finished(self: Self) -> None:
|
|
306
1034
|
"""
|
|
307
|
-
|
|
1035
|
+
Add a hook to add additional logic in the save_finished step of the save process.
|
|
308
1036
|
|
|
309
|
-
It has no
|
|
1037
|
+
It has no return value and is passed no data. By the time this fires the model has already been
|
|
310
1038
|
updated with the new data. You can decide on the necessary actions using the `was_changed` and
|
|
311
1039
|
the `previous_value` functions.
|
|
1040
|
+
|
|
1041
|
+
```
|
|
1042
|
+
from typing import Any, Self
|
|
1043
|
+
import clearskies
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
class History(clearskies.Model):
|
|
1047
|
+
id_column_name = "id"
|
|
1048
|
+
backend = clearskies.backends.MemoryBackend()
|
|
1049
|
+
|
|
1050
|
+
id = clearskies.columns.Uuid()
|
|
1051
|
+
message = clearskies.columns.String()
|
|
1052
|
+
created_at = clearskies.columns.Created(date_format="%Y-%m-%d %H:%M:%S.%f")
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
class User(clearskies.Model):
|
|
1056
|
+
id_column_name = "id"
|
|
1057
|
+
backend = clearskies.backends.MemoryBackend()
|
|
1058
|
+
histories = clearskies.di.inject.ByClass(History)
|
|
1059
|
+
|
|
1060
|
+
id = clearskies.columns.Uuid()
|
|
1061
|
+
age = clearskies.columns.Integer()
|
|
1062
|
+
name = clearskies.columns.String()
|
|
1063
|
+
|
|
1064
|
+
def save_finished(self: Self) -> None:
|
|
1065
|
+
if not self.was_changed("age"):
|
|
1066
|
+
return
|
|
1067
|
+
|
|
1068
|
+
self.histories.create({"message": f"My name is {self.name} and I am {self.age} years old"})
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def my_application(users, histories):
|
|
1072
|
+
jane = users.create({"name": "Jane"})
|
|
1073
|
+
jane.save({"age": 25})
|
|
1074
|
+
jane.save({"age": 26})
|
|
1075
|
+
jane.save({"age": 30})
|
|
1076
|
+
|
|
1077
|
+
return [history.message for history in histories.sort_by("created_at", "ASC")]
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
cli = clearskies.contexts.Cli(
|
|
1081
|
+
my_application,
|
|
1082
|
+
classes=[User, History],
|
|
1083
|
+
)
|
|
1084
|
+
cli()
|
|
1085
|
+
```
|
|
312
1086
|
"""
|
|
313
1087
|
pass
|
|
314
1088
|
|
|
315
|
-
def columns_pre_delete(self, columns):
|
|
316
|
-
"""
|
|
1089
|
+
def columns_pre_delete(self: Self, columns: dict[str, Column]) -> None:
|
|
1090
|
+
"""Use the column information present in the model to make any necessary changes before deleting."""
|
|
317
1091
|
for column in columns.values():
|
|
318
1092
|
column.pre_delete(self)
|
|
319
1093
|
|
|
320
|
-
def pre_delete(self):
|
|
321
|
-
"""
|
|
322
|
-
A hook to extend so you can provide additional pre-delete logic as needed
|
|
323
|
-
"""
|
|
1094
|
+
def pre_delete(self: Self) -> None:
|
|
1095
|
+
"""Create a hook to extend so you can provide additional pre-delete logic as needed."""
|
|
324
1096
|
pass
|
|
325
1097
|
|
|
326
|
-
def columns_post_delete(self, columns):
|
|
327
|
-
"""
|
|
1098
|
+
def columns_post_delete(self: Self, columns: dict[str, Column]) -> None:
|
|
1099
|
+
"""Use the column information present in the model to make any necessary changes after deleting."""
|
|
328
1100
|
for column in columns.values():
|
|
329
1101
|
column.post_delete(self)
|
|
330
1102
|
|
|
331
|
-
def post_delete(self):
|
|
1103
|
+
def post_delete(self: Self) -> None:
|
|
1104
|
+
"""Create a hook to extend so you can provide additional post-delete logic as needed."""
|
|
1105
|
+
pass
|
|
1106
|
+
|
|
1107
|
+
def where_for_request_all(
|
|
1108
|
+
self: Self,
|
|
1109
|
+
model: Self,
|
|
1110
|
+
input_output: Any,
|
|
1111
|
+
routing_data: dict[str, str],
|
|
1112
|
+
authorization_data: dict[str, Any],
|
|
1113
|
+
overrides: dict[str, Column] = {},
|
|
1114
|
+
) -> Self:
|
|
1115
|
+
"""Add a hook to automatically apply filtering whenever the model makes an appearance in a get/update/list/search handler."""
|
|
1116
|
+
for column in self.get_columns(overrides=overrides).values():
|
|
1117
|
+
models = column.where_for_request(model, input_output, routing_data, authorization_data) # type: ignore
|
|
1118
|
+
return self.where_for_request(
|
|
1119
|
+
model, input_output, routing_data=routing_data, authorization_data=authorization_data, overrides=overrides
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
def where_for_request(
|
|
1123
|
+
self: Self,
|
|
1124
|
+
model: Self,
|
|
1125
|
+
input_output: Any,
|
|
1126
|
+
routing_data: dict[str, str],
|
|
1127
|
+
authorization_data: dict[str, Any],
|
|
1128
|
+
overrides: dict[str, Column] = {},
|
|
1129
|
+
) -> Self:
|
|
332
1130
|
"""
|
|
333
|
-
|
|
1131
|
+
Add a hook to automatically apply filtering whenever the model makes an appearance in a get/update/list/search handler.
|
|
1132
|
+
|
|
1133
|
+
Note that this automatically affects the behavior of the various list endpoints, but won't be called when you create your
|
|
1134
|
+
own queries directly. Here's an example where the model restricts the list endpoint so that it only returns users with
|
|
1135
|
+
an age over 18:
|
|
1136
|
+
|
|
1137
|
+
```
|
|
1138
|
+
from typing import Any, Self
|
|
1139
|
+
import clearskies
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
class User(clearskies.Model):
|
|
1143
|
+
id_column_name = "id"
|
|
1144
|
+
backend = clearskies.backends.MemoryBackend()
|
|
1145
|
+
id = clearskies.columns.Uuid()
|
|
1146
|
+
name = clearskies.columns.String()
|
|
1147
|
+
age = clearskies.columns.Integer()
|
|
1148
|
+
|
|
1149
|
+
def where_for_request(
|
|
1150
|
+
self: Self,
|
|
1151
|
+
model: Self,
|
|
1152
|
+
input_output: Any,
|
|
1153
|
+
routing_data: dict[str, str],
|
|
1154
|
+
authorization_data: dict[str, Any],
|
|
1155
|
+
overrides: dict[str, clearskies.Column] = {},
|
|
1156
|
+
) -> Self:
|
|
1157
|
+
return model.where("age>=18")
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
list_users = clearskies.endpoints.List(
|
|
1161
|
+
model_class=User,
|
|
1162
|
+
readable_column_names=["id", "name", "age"],
|
|
1163
|
+
sortable_column_names=["id", "name", "age"],
|
|
1164
|
+
default_sort_column_name="name",
|
|
1165
|
+
)
|
|
1166
|
+
|
|
1167
|
+
wsgi = clearskies.contexts.WsgiRef(
|
|
1168
|
+
list_users,
|
|
1169
|
+
classes=[User],
|
|
1170
|
+
bindings={
|
|
1171
|
+
"memory_backend_default_data": [
|
|
1172
|
+
{
|
|
1173
|
+
"model_class": User,
|
|
1174
|
+
"records": [
|
|
1175
|
+
{"id": "1-2-3-4", "name": "Bob", "age": 20},
|
|
1176
|
+
{"id": "1-2-3-5", "name": "Jane", "age": 17},
|
|
1177
|
+
{"id": "1-2-3-6", "name": "Greg", "age": 22},
|
|
1178
|
+
],
|
|
1179
|
+
},
|
|
1180
|
+
]
|
|
1181
|
+
},
|
|
1182
|
+
)
|
|
1183
|
+
wsgi()
|
|
1184
|
+
```
|
|
334
1185
|
"""
|
|
335
|
-
|
|
1186
|
+
return model
|
|
336
1187
|
|
|
337
|
-
|
|
1188
|
+
##############################################################
|
|
1189
|
+
### From here down is functionality related to list/search ###
|
|
1190
|
+
##############################################################
|
|
1191
|
+
def has_query(self) -> bool:
|
|
338
1192
|
"""
|
|
339
|
-
|
|
1193
|
+
Whether or not this model instance represents a query.
|
|
1194
|
+
|
|
1195
|
+
The model class is used for both querying records and modifying individual records. As a result, each model class instance
|
|
1196
|
+
keeps track of whether it is being used to query things, or whether it represents an individual record. This distinction
|
|
1197
|
+
is not usually very important to the developer (because there's no good reason to use one model for both), but it may
|
|
1198
|
+
occassionaly be useful to tell how a given model is being used. Clearskies itself does use this to ensure that you
|
|
1199
|
+
can't accidentally use a single model instance for both purposes, mostly because when this happens it's usually a sign
|
|
1200
|
+
of a bug.
|
|
1201
|
+
|
|
1202
|
+
```
|
|
1203
|
+
import clearskies
|
|
1204
|
+
|
|
1205
|
+
|
|
1206
|
+
class User(clearskies.Model):
|
|
1207
|
+
id_column_name = "id"
|
|
1208
|
+
backend = clearskies.backends.MemoryBackend()
|
|
1209
|
+
|
|
1210
|
+
id = clearskies.columns.Uuid()
|
|
1211
|
+
name = clearskies.columns.String()
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def my_application(users):
|
|
1215
|
+
jane = users.create({"name": "Jane"})
|
|
1216
|
+
jane_instance_has_query = jane.has_query()
|
|
1217
|
+
|
|
1218
|
+
some_search = users.where("name=Jane")
|
|
1219
|
+
some_search_has_query = some_search.has_query()
|
|
1220
|
+
|
|
1221
|
+
invalid_request_error = ""
|
|
1222
|
+
try:
|
|
1223
|
+
some_search.save({"not": "valid"})
|
|
1224
|
+
except ValueError as e:
|
|
1225
|
+
invalid_request_error = str(e)
|
|
1226
|
+
|
|
1227
|
+
return {
|
|
1228
|
+
"jane_instance_has_query": jane_instance_has_query,
|
|
1229
|
+
"some_search_has_query": some_search_has_query,
|
|
1230
|
+
"invalid_request_error": invalid_request_error,
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
|
|
1234
|
+
cli = clearskies.contexts.Cli(
|
|
1235
|
+
my_application,
|
|
1236
|
+
classes=[User],
|
|
1237
|
+
)
|
|
1238
|
+
cli()
|
|
1239
|
+
```
|
|
1240
|
+
|
|
1241
|
+
Which if you run will return:
|
|
1242
|
+
|
|
1243
|
+
```
|
|
1244
|
+
{
|
|
1245
|
+
"jane_instance_has_query": false,
|
|
1246
|
+
"some_search_has_query": true,
|
|
1247
|
+
"invalid_request_error": "You attempted to save/read record data for a model being used to make a query. This is not allowed, as it is typically a sign of a bug in your application code.",
|
|
1248
|
+
}
|
|
1249
|
+
```
|
|
1250
|
+
|
|
1251
|
+
"""
|
|
1252
|
+
return bool(self._query)
|
|
1253
|
+
|
|
1254
|
+
def get_query(self) -> Query:
|
|
1255
|
+
"""Fetch the query object in the model."""
|
|
1256
|
+
return self._query if self._query else Query(self.__class__)
|
|
1257
|
+
|
|
1258
|
+
def as_query(self) -> Self:
|
|
1259
|
+
"""
|
|
1260
|
+
Make the model queryable.
|
|
1261
|
+
|
|
1262
|
+
This is used to remove the ambiguity of attempting execute a query against a model object that stores a record.
|
|
1263
|
+
|
|
1264
|
+
The reason this exists is because the model class is used both to query as well as to operate on single records, which can cause
|
|
1265
|
+
subtle bugs if a developer accidentally confuses the two usages. Consider the following (partial) example:
|
|
1266
|
+
|
|
1267
|
+
```python
|
|
1268
|
+
def some_function(models):
|
|
1269
|
+
model = models.find("id=5")
|
|
1270
|
+
if model:
|
|
1271
|
+
models.save({"test": "example"})
|
|
1272
|
+
other_record = model.find("id=6")
|
|
1273
|
+
```
|
|
1274
|
+
|
|
1275
|
+
In the above example it seems likely that the intention was to use `model.save()`, not `models.save()`. Similarly, the last line
|
|
1276
|
+
should be `models.find()`, not `model.find()`. To minimize these kinds of issues, clearskies won't let you execute a query against
|
|
1277
|
+
an individual model record, nor will it let you execute a save against a model being used to make a query. In both cases, you'll
|
|
1278
|
+
get an exception from clearskies, as the models track exactly how they are being used.
|
|
1279
|
+
|
|
1280
|
+
In some rare cases though, you may want to start a new query aginst a model that represents a single record. This is most common
|
|
1281
|
+
if you have a function that was passed an individual model, and you'd like to use it to fetch more records without having to
|
|
1282
|
+
inject the model class more generally. That's where the `as_query()` method comes in. It's basically just a way of telling clearskies
|
|
1283
|
+
"yes, I really do want to start a query using a model that represents a record". So, for example:
|
|
1284
|
+
|
|
1285
|
+
```python
|
|
1286
|
+
def some_function(models):
|
|
1287
|
+
model = models.find("id=5")
|
|
1288
|
+
more_models = model.where("test=example") # throws an exception.
|
|
1289
|
+
more_models = model.as_query().where("test=example") # works as expected.
|
|
1290
|
+
```
|
|
1291
|
+
"""
|
|
1292
|
+
new_model = self._di.build(self.__class__, cache=False)
|
|
1293
|
+
new_model.set_query(Query(self.__class__))
|
|
1294
|
+
return new_model
|
|
1295
|
+
|
|
1296
|
+
def set_query(self, query: Query) -> Self:
|
|
1297
|
+
"""Set the query object."""
|
|
1298
|
+
self._query = query
|
|
1299
|
+
self._query_executed = False
|
|
1300
|
+
return self
|
|
1301
|
+
|
|
1302
|
+
def with_query(self, query: Query) -> Self:
|
|
1303
|
+
return self._di.build(self.__class__, cache=False).set_query(query)
|
|
1304
|
+
|
|
1305
|
+
def select(self: Self, select: str) -> Self:
|
|
1306
|
+
"""
|
|
1307
|
+
Add some additional columns to the select part of the query.
|
|
1308
|
+
|
|
1309
|
+
This method returns a new object with the updated query. The original model object is unmodified.
|
|
1310
|
+
Multiple calls to this method add together. The following:
|
|
1311
|
+
|
|
1312
|
+
```python
|
|
1313
|
+
models.select("column_1 column_2").select("column_3")
|
|
1314
|
+
```
|
|
1315
|
+
|
|
1316
|
+
will select column_1, column_2, column_3 in the final query.
|
|
1317
|
+
"""
|
|
1318
|
+
self.no_single_model()
|
|
1319
|
+
return self.with_query(self.get_query().add_select(select))
|
|
1320
|
+
|
|
1321
|
+
def select_all(self: Self, select_all=True) -> Self:
|
|
340
1322
|
"""
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
1323
|
+
Set whether or not to select all columns with the query.
|
|
1324
|
+
|
|
1325
|
+
This method returns a new object with the updated query. The original model object is unmodified.
|
|
1326
|
+
"""
|
|
1327
|
+
self.no_single_model()
|
|
1328
|
+
return self.with_query(self.get_query().set_select_all(select_all))
|
|
1329
|
+
|
|
1330
|
+
def where(self: Self, where: str | Condition) -> Self:
|
|
1331
|
+
r"""
|
|
1332
|
+
Add a condition to a query.
|
|
1333
|
+
|
|
1334
|
+
The `where` method (in combination with the `find` method) is typically the starting point for query records in
|
|
1335
|
+
a model. You don't *have* to add a condition to a model in order to fetch records, but of course it's a very
|
|
1336
|
+
common use case. Conditions in clearskies can be built from the columns or can be constructed as SQL-like
|
|
1337
|
+
string conditions, e.g. `model.where("name=Bob")` or `model.where(model.name.equals("Bob"))`. The latter
|
|
1338
|
+
provides strict type-checking, while the former does not. Either way they have the same result. The list of
|
|
1339
|
+
supported operators for a given column can be seen by checking the `_allowed_search_operators` attribute of the
|
|
1340
|
+
column class. Most columns accept all allowed operators, which are:
|
|
1341
|
+
|
|
1342
|
+
- "<=>"
|
|
1343
|
+
- "!="
|
|
1344
|
+
- "<="
|
|
1345
|
+
- ">="
|
|
1346
|
+
- ">"
|
|
1347
|
+
- "<"
|
|
1348
|
+
- "="
|
|
1349
|
+
- "in"
|
|
1350
|
+
- "is not null"
|
|
1351
|
+
- "is null"
|
|
1352
|
+
- "like"
|
|
1353
|
+
|
|
1354
|
+
When working with string conditions, it is safe to inject user input into the condition. The allowed
|
|
1355
|
+
format for conditions is very simple: `f"{column_name}\\s?{operator}\\s?{value}"`. This makes it possible to
|
|
1356
|
+
unambiguously separate all three pieces from eachother. It's not possible to inject malicious payloads into either
|
|
1357
|
+
the column names or operators because both are checked against a strict allow list (e.g. the columns declared in the
|
|
1358
|
+
model or the list of allowed operators above). The value is then extracted from the leftovers, and this is
|
|
1359
|
+
provided to the backend separately so it can use it appropriately (e.g. using prepared statements for the cursor
|
|
1360
|
+
backend). Of course, you generally shouldn't have to inject user input into conditions very often because, most
|
|
1361
|
+
often, the various list/search endpoints do this for you, but if you have to do it there are no security
|
|
1362
|
+
concerns.
|
|
1363
|
+
|
|
1364
|
+
You can include a table name before the column name, with the two separated by a period. As always, if you do this,
|
|
1365
|
+
ensure that you include a supporting join statement (via the `join` method - see it for examples).
|
|
1366
|
+
|
|
1367
|
+
When you call the `where` method it returns a new model object with it's query configured to include the additional
|
|
1368
|
+
condition. The original model object remains unchanged. Multiple conditions are always joined with AND. There is
|
|
1369
|
+
no explicit option for OR. The closest is using an IN condition.
|
|
1370
|
+
|
|
1371
|
+
To access the results you have to iterate over the resulting model. If you are only expecting one result
|
|
1372
|
+
and want to work directly with it, then you can use `model.find(condition)` or `model.where(condition).first()`.
|
|
1373
|
+
|
|
1374
|
+
Example:
|
|
1375
|
+
```python
|
|
1376
|
+
import clearskies
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
class Order(clearskies.Model):
|
|
1380
|
+
id_column_name = "id"
|
|
1381
|
+
backend = clearskies.backends.MemoryBackend()
|
|
1382
|
+
|
|
1383
|
+
id = clearskies.columns.Uuid()
|
|
1384
|
+
user_id = clearskies.columns.String()
|
|
1385
|
+
status = clearskies.columns.Select(["Pending", "In Progress"])
|
|
1386
|
+
total = clearskies.columns.Float()
|
|
1387
|
+
|
|
1388
|
+
|
|
1389
|
+
def my_application(orders):
|
|
1390
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
|
|
1391
|
+
orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
|
|
1392
|
+
orders.create({"user_id": "Jane", "status": "Pending", "total": 30})
|
|
1393
|
+
|
|
1394
|
+
return [
|
|
1395
|
+
order.user_id
|
|
1396
|
+
for order in orders.where("status=Pending").where(Order.total.greater_than(25))
|
|
1397
|
+
]
|
|
1398
|
+
|
|
1399
|
+
|
|
1400
|
+
cli = clearskies.contexts.Cli(
|
|
1401
|
+
my_application,
|
|
1402
|
+
classes=[Order],
|
|
1403
|
+
)
|
|
1404
|
+
cli()
|
|
1405
|
+
```
|
|
1406
|
+
|
|
1407
|
+
Which, if ran, returns: `["Jane"]`
|
|
1408
|
+
|
|
1409
|
+
"""
|
|
1410
|
+
self.no_single_model()
|
|
1411
|
+
return self.with_query(self.get_query().add_where(where if isinstance(where, Condition) else Condition(where)))
|
|
1412
|
+
|
|
1413
|
+
def join(self: Self, join: str) -> Self:
|
|
1414
|
+
"""
|
|
1415
|
+
Add a join clause to the query.
|
|
1416
|
+
|
|
1417
|
+
As with the `where` method, this expects a string which is parsed accordingly. The syntax is not as flexible as
|
|
1418
|
+
SQL and expects a format of:
|
|
1419
|
+
|
|
1420
|
+
```
|
|
1421
|
+
[left|right|inner]? join [right_table_name] ON [right_table_name].[right_column_name]=[left_table_name].[left_column_name].
|
|
1422
|
+
```
|
|
1423
|
+
|
|
1424
|
+
This is case insensitive. Aliases are allowed. If you don't specify a join type it defaults to inner.
|
|
1425
|
+
Here are two examples of valid join statements:
|
|
1426
|
+
|
|
1427
|
+
- `join orders on orders.user_id=users.id`
|
|
1428
|
+
- `left join user_orders as orders on orders.id=users.id`
|
|
1429
|
+
|
|
1430
|
+
Note that joins are not strictly limited to SQL-like backends, but of course no all backends will support joining.
|
|
1431
|
+
|
|
1432
|
+
A basic example:
|
|
1433
|
+
|
|
1434
|
+
```
|
|
1435
|
+
import clearskies
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
class User(clearskies.Model):
|
|
1439
|
+
id_column_name = "id"
|
|
1440
|
+
backend = clearskies.backends.MemoryBackend()
|
|
1441
|
+
|
|
1442
|
+
id = clearskies.columns.Uuid()
|
|
1443
|
+
name = clearskies.columns.String()
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
class Order(clearskies.Model):
|
|
1447
|
+
id_column_name = "id"
|
|
1448
|
+
backend = clearskies.backends.MemoryBackend()
|
|
1449
|
+
|
|
1450
|
+
id = clearskies.columns.Uuid()
|
|
1451
|
+
user_id = clearskies.columns.BelongsToId(User, readable_parent_columns=["id", "name"])
|
|
1452
|
+
user = clearskies.columns.BelongsToModel("user_id")
|
|
1453
|
+
status = clearskies.columns.Select(["Pending", "In Progress"])
|
|
1454
|
+
total = clearskies.columns.Float()
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
def my_application(users, orders):
|
|
1458
|
+
jane = users.create({"name": "Jane"})
|
|
1459
|
+
another_jane = users.create({"name": "Jane"})
|
|
1460
|
+
bob = users.create({"name": "Bob"})
|
|
1461
|
+
|
|
1462
|
+
# Jane's orders
|
|
1463
|
+
orders.create({"user_id": jane.id, "status": "Pending", "total": 25})
|
|
1464
|
+
orders.create({"user_id": jane.id, "status": "Pending", "total": 30})
|
|
1465
|
+
orders.create({"user_id": jane.id, "status": "In Progress", "total": 35})
|
|
1466
|
+
|
|
1467
|
+
# Another Jane's orders
|
|
1468
|
+
orders.create({"user_id": another_jane.id, "status": "Pending", "total": 15})
|
|
1469
|
+
|
|
1470
|
+
# Bob's orders
|
|
1471
|
+
orders.create({"user_id": bob.id, "status": "Pending", "total": 28})
|
|
1472
|
+
orders.create({"user_id": bob.id, "status": "In Progress", "total": 35})
|
|
1473
|
+
|
|
1474
|
+
# return all orders for anyone named Jane that have a status of Pending
|
|
1475
|
+
return (
|
|
1476
|
+
orders.join("join users on users.id=orders.user_id")
|
|
1477
|
+
.where("users.name=Jane")
|
|
1478
|
+
.sort_by("total", "asc")
|
|
1479
|
+
.where("status=Pending")
|
|
1480
|
+
)
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
cli = clearskies.contexts.Cli(
|
|
1484
|
+
clearskies.endpoints.Callable(
|
|
1485
|
+
my_application,
|
|
1486
|
+
model_class=Order,
|
|
1487
|
+
readable_column_names=["user", "total"],
|
|
1488
|
+
),
|
|
1489
|
+
classes=[Order, User],
|
|
1490
|
+
)
|
|
1491
|
+
cli()
|
|
1492
|
+
```
|
|
1493
|
+
"""
|
|
1494
|
+
self.no_single_model()
|
|
1495
|
+
return self.with_query(self.get_query().add_join(Join(join)))
|
|
1496
|
+
|
|
1497
|
+
def is_joined(self: Self, table_name: str, alias: str = "") -> bool:
|
|
1498
|
+
"""
|
|
1499
|
+
Check if a given table was already joined.
|
|
1500
|
+
|
|
1501
|
+
If you provide an alias then it will also verify if the table was joined with the specific alias name.
|
|
1502
|
+
"""
|
|
1503
|
+
for join in self.get_query().joins:
|
|
1504
|
+
if join.unaliased_table_name != table_name:
|
|
1505
|
+
continue
|
|
1506
|
+
|
|
1507
|
+
if alias and join.alias != alias:
|
|
1508
|
+
continue
|
|
1509
|
+
|
|
1510
|
+
return True
|
|
1511
|
+
return False
|
|
1512
|
+
|
|
1513
|
+
def group_by(self: Self, group_by_column_name: str) -> Self:
|
|
1514
|
+
"""
|
|
1515
|
+
Add a group by clause to the query.
|
|
1516
|
+
|
|
1517
|
+
You just provide the name of the column to group by. Of course, not all backends support a group by clause.
|
|
1518
|
+
"""
|
|
1519
|
+
self.no_single_model()
|
|
1520
|
+
return self.with_query(self.get_query().set_group_by(group_by_column_name))
|
|
1521
|
+
|
|
1522
|
+
def sort_by(
|
|
1523
|
+
self: Self,
|
|
1524
|
+
primary_column_name: str,
|
|
1525
|
+
primary_direction: str,
|
|
1526
|
+
primary_table_name: str = "",
|
|
1527
|
+
secondary_column_name: str = "",
|
|
1528
|
+
secondary_direction: str = "",
|
|
1529
|
+
secondary_table_name: str = "",
|
|
1530
|
+
) -> Self:
|
|
1531
|
+
"""
|
|
1532
|
+
Add a sort by clause to the query. You can sort by up to two columns at once.
|
|
1533
|
+
|
|
1534
|
+
Example:
|
|
1535
|
+
```
|
|
1536
|
+
import clearskies
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
class Order(clearskies.Model):
|
|
1540
|
+
id_column_name = "id"
|
|
1541
|
+
backend = clearskies.backends.MemoryBackend()
|
|
1542
|
+
|
|
1543
|
+
id = clearskies.columns.Uuid()
|
|
1544
|
+
user_id = clearskies.columns.String()
|
|
1545
|
+
status = clearskies.columns.Select(["Pending", "In Progress"])
|
|
1546
|
+
total = clearskies.columns.Float()
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def my_application(orders):
|
|
1550
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
|
|
1551
|
+
orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
|
|
1552
|
+
orders.create({"user_id": "Alice", "status": "Pending", "total": 30})
|
|
1553
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 26})
|
|
1554
|
+
|
|
1555
|
+
return orders.sort_by(
|
|
1556
|
+
"user_id", "asc", secondary_column_name="total", secondary_direction="desc"
|
|
1557
|
+
)
|
|
1558
|
+
|
|
1559
|
+
|
|
1560
|
+
cli = clearskies.contexts.Cli(
|
|
1561
|
+
clearskies.endpoints.Callable(
|
|
1562
|
+
my_application,
|
|
1563
|
+
model_class=Order,
|
|
1564
|
+
readable_column_names=["user_id", "total"],
|
|
1565
|
+
),
|
|
1566
|
+
classes=[Order],
|
|
1567
|
+
)
|
|
1568
|
+
cli()
|
|
1569
|
+
```
|
|
1570
|
+
"""
|
|
1571
|
+
self.no_single_model()
|
|
1572
|
+
sort = Sort(primary_table_name, primary_column_name, primary_direction)
|
|
1573
|
+
secondary_sort = None
|
|
1574
|
+
if secondary_column_name and secondary_direction:
|
|
1575
|
+
secondary_sort = Sort(secondary_table_name, secondary_column_name, secondary_direction)
|
|
1576
|
+
return self.with_query(self.get_query().set_sort(sort, secondary_sort))
|
|
1577
|
+
|
|
1578
|
+
def limit(self: Self, limit: int) -> Self:
|
|
1579
|
+
"""
|
|
1580
|
+
Set the number of records to return.
|
|
1581
|
+
|
|
1582
|
+
```
|
|
1583
|
+
import clearskies
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
class Order(clearskies.Model):
|
|
1587
|
+
id_column_name = "id"
|
|
1588
|
+
backend = clearskies.backends.MemoryBackend()
|
|
1589
|
+
|
|
1590
|
+
id = clearskies.columns.Uuid()
|
|
1591
|
+
user_id = clearskies.columns.String()
|
|
1592
|
+
status = clearskies.columns.Select(["Pending", "In Progress"])
|
|
1593
|
+
total = clearskies.columns.Float()
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
def my_application(orders):
|
|
1597
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
|
|
1598
|
+
orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
|
|
1599
|
+
orders.create({"user_id": "Alice", "status": "Pending", "total": 30})
|
|
1600
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 26})
|
|
1601
|
+
|
|
1602
|
+
return orders.limit(2)
|
|
1603
|
+
|
|
1604
|
+
|
|
1605
|
+
cli = clearskies.contexts.Cli(
|
|
1606
|
+
clearskies.endpoints.Callable(
|
|
1607
|
+
my_application,
|
|
1608
|
+
model_class=Order,
|
|
1609
|
+
readable_column_names=["user_id", "total"],
|
|
1610
|
+
),
|
|
1611
|
+
classes=[Order],
|
|
1612
|
+
)
|
|
1613
|
+
cli()
|
|
1614
|
+
```
|
|
1615
|
+
"""
|
|
1616
|
+
self.no_single_model()
|
|
1617
|
+
return self.with_query(self.get_query().set_limit(limit))
|
|
1618
|
+
|
|
1619
|
+
def pagination(self: Self, **pagination_data) -> Self:
|
|
1620
|
+
"""
|
|
1621
|
+
Set the pagination parameter(s) for the query.
|
|
1622
|
+
|
|
1623
|
+
The exact details of how pagination work depend on the backend. For instance, the cursor and memory backend
|
|
1624
|
+
expect to be given a `start` parameter, while an API backend will vary with the API, and the dynamodb backend
|
|
1625
|
+
expects a kwarg called `cursor`. As a result, it's necessary to check the backend documentation to understand
|
|
1626
|
+
how to properly set pagination. The endpoints automatically account for this because backends are required
|
|
1627
|
+
to declare pagination details via the `allowed_pagination_keys` method. If you attempt to set invalid
|
|
1628
|
+
pagination data via this method, clearskies will raise a ValueError.
|
|
1629
|
+
|
|
1630
|
+
Example:
|
|
1631
|
+
```
|
|
1632
|
+
import clearskies
|
|
1633
|
+
|
|
1634
|
+
|
|
1635
|
+
class Order(clearskies.Model):
|
|
1636
|
+
id_column_name = "id"
|
|
1637
|
+
backend = clearskies.backends.MemoryBackend()
|
|
1638
|
+
|
|
1639
|
+
id = clearskies.columns.Uuid()
|
|
1640
|
+
user_id = clearskies.columns.String()
|
|
1641
|
+
status = clearskies.columns.Select(["Pending", "In Progress"])
|
|
1642
|
+
total = clearskies.columns.Float()
|
|
1643
|
+
|
|
1644
|
+
|
|
1645
|
+
def my_application(orders):
|
|
1646
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
|
|
1647
|
+
orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
|
|
1648
|
+
orders.create({"user_id": "Alice", "status": "Pending", "total": 30})
|
|
1649
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 26})
|
|
1650
|
+
|
|
1651
|
+
return orders.sort_by("total", "asc").pagination(start=2)
|
|
1652
|
+
|
|
1653
|
+
|
|
1654
|
+
cli = clearskies.contexts.Cli(
|
|
1655
|
+
clearskies.endpoints.Callable(
|
|
1656
|
+
my_application,
|
|
1657
|
+
model_class=Order,
|
|
1658
|
+
readable_column_names=["user_id", "total"],
|
|
1659
|
+
),
|
|
1660
|
+
classes=[Order],
|
|
1661
|
+
)
|
|
1662
|
+
cli()
|
|
1663
|
+
```
|
|
1664
|
+
|
|
1665
|
+
However, if the return line in `my_application` is switched for either of these:
|
|
1666
|
+
|
|
1667
|
+
```
|
|
1668
|
+
return orders.sort_by("total", "asc").pagination(start="asdf")
|
|
1669
|
+
return orders.sort_by("total", "asc").pagination(something_else=5)
|
|
1670
|
+
```
|
|
1671
|
+
|
|
1672
|
+
Will result in an exception that explains exactly what is wrong.
|
|
1673
|
+
|
|
1674
|
+
"""
|
|
1675
|
+
self.no_single_model()
|
|
1676
|
+
error = self.backend.validate_pagination_data(pagination_data, str)
|
|
1677
|
+
if error:
|
|
1678
|
+
raise ValueError(
|
|
1679
|
+
f"Invalid pagination data for model {self.__class__.__name__} with backend "
|
|
1680
|
+
+ f"{self.backend.__class__.__name__}. {error}"
|
|
1681
|
+
)
|
|
1682
|
+
return self.with_query(self.get_query().set_pagination(pagination_data))
|
|
1683
|
+
|
|
1684
|
+
def find(self: Self, where: str | Condition) -> Self:
|
|
1685
|
+
"""
|
|
1686
|
+
Return the first model matching a given where condition.
|
|
1687
|
+
|
|
1688
|
+
This is just shorthand for `models.where("column=value").find()`. Example:
|
|
1689
|
+
|
|
1690
|
+
```python
|
|
1691
|
+
import clearskies
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
class Order(clearskies.Model):
|
|
1695
|
+
id_column_name = "id"
|
|
1696
|
+
backend = clearskies.backends.MemoryBackend()
|
|
1697
|
+
|
|
1698
|
+
id = clearskies.columns.Uuid()
|
|
1699
|
+
user_id = clearskies.columns.String()
|
|
1700
|
+
status = clearskies.columns.Select(["Pending", "In Progress"])
|
|
1701
|
+
total = clearskies.columns.Float()
|
|
1702
|
+
|
|
1703
|
+
|
|
1704
|
+
def my_application(orders):
|
|
1705
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
|
|
1706
|
+
orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
|
|
1707
|
+
orders.create({"user_id": "Jane", "status": "Pending", "total": 30})
|
|
1708
|
+
|
|
1709
|
+
jane = orders.find("user_id=Jane")
|
|
1710
|
+
jane.total = 35
|
|
1711
|
+
jane.save()
|
|
1712
|
+
|
|
1713
|
+
return {
|
|
1714
|
+
"user_id": jane.user_id,
|
|
1715
|
+
"total": jane.total,
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
|
|
1719
|
+
cli = clearskies.contexts.Cli(
|
|
1720
|
+
my_application,
|
|
1721
|
+
classes=[Order],
|
|
1722
|
+
)
|
|
1723
|
+
cli()
|
|
1724
|
+
```
|
|
1725
|
+
"""
|
|
1726
|
+
self.no_single_model()
|
|
1727
|
+
return self.where(where).first()
|
|
1728
|
+
|
|
1729
|
+
def __len__(self: Self): # noqa: D105
|
|
1730
|
+
self.no_single_model()
|
|
1731
|
+
if self._count is None:
|
|
1732
|
+
self._count = self.backend.count(self.get_final_query())
|
|
1733
|
+
return self._count
|
|
1734
|
+
|
|
1735
|
+
def __iter__(self: Self) -> Iterator[Self]: # noqa: D105
|
|
1736
|
+
self.no_single_model()
|
|
1737
|
+
self._next_page_data = {}
|
|
1738
|
+
raw_rows = self.backend.records(
|
|
1739
|
+
self.get_final_query(),
|
|
1740
|
+
next_page_data=self._next_page_data,
|
|
1741
|
+
)
|
|
1742
|
+
return iter([self.model(row) for row in raw_rows])
|
|
1743
|
+
|
|
1744
|
+
def get_final_query(self) -> Query:
|
|
1745
|
+
"""
|
|
1746
|
+
Return the query to be used in a records/count operation.
|
|
1747
|
+
|
|
1748
|
+
Whenever the list of records/count is needed from the backend, this method is called
|
|
1749
|
+
by the model to get the query that is sent to the backend. As a result, you can extend
|
|
1750
|
+
this method to make any final modifications to the query. Any changes made here will
|
|
1751
|
+
therefore be applied to all usage of the model.
|
|
1752
|
+
"""
|
|
1753
|
+
return self.get_query()
|
|
1754
|
+
|
|
1755
|
+
def paginate_all(self: Self) -> list[Self]:
|
|
1756
|
+
"""
|
|
1757
|
+
Loop through all available pages of results and returns a list of all models that match the query.
|
|
1758
|
+
|
|
1759
|
+
If you don't set a limit on a query, some backends will return all records but some backends have a
|
|
1760
|
+
default maximum number of results that they will return. In the latter case, you can use `paginate_all`
|
|
1761
|
+
to fetch all records by instructing clearskies to iterate over all pages. This is possible because backends
|
|
1762
|
+
are required to define how pagination works in a way that clearskies can automatically understand and
|
|
1763
|
+
use. To demonstrate this, the following example sets a limit of 1 which stops the memory backend
|
|
1764
|
+
from returning everything, and then uses `paginate_all` to fetch all records. The memory backend
|
|
1765
|
+
doesn't have a default limit, so in practice the `paginate_all` is unnecessary here, but this is done
|
|
1766
|
+
for demonstration purposes.
|
|
1767
|
+
|
|
1768
|
+
```
|
|
1769
|
+
import clearskies
|
|
1770
|
+
|
|
1771
|
+
|
|
1772
|
+
class Order(clearskies.Model):
|
|
1773
|
+
id_column_name = "id"
|
|
1774
|
+
backend = clearskies.backends.MemoryBackend()
|
|
1775
|
+
|
|
1776
|
+
id = clearskies.columns.Uuid()
|
|
1777
|
+
user_id = clearskies.columns.String()
|
|
1778
|
+
status = clearskies.columns.Select(["Pending", "In Progress"])
|
|
1779
|
+
total = clearskies.columns.Float()
|
|
1780
|
+
|
|
1781
|
+
|
|
1782
|
+
def my_application(orders):
|
|
1783
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
|
|
1784
|
+
orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
|
|
1785
|
+
orders.create({"user_id": "Alice", "status": "Pending", "total": 30})
|
|
1786
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 26})
|
|
1787
|
+
|
|
1788
|
+
return orders.limit(1).paginate_all()
|
|
1789
|
+
|
|
1790
|
+
|
|
1791
|
+
cli = clearskies.contexts.Cli(
|
|
1792
|
+
clearskies.endpoints.Callable(
|
|
1793
|
+
my_application,
|
|
1794
|
+
model_class=Order,
|
|
1795
|
+
readable_column_names=["user_id", "total"],
|
|
1796
|
+
),
|
|
1797
|
+
classes=[Order],
|
|
1798
|
+
)
|
|
1799
|
+
cli()
|
|
1800
|
+
```
|
|
1801
|
+
|
|
1802
|
+
NOTE: this loads up all records in memory before returning (e.g. it isn't using generators yet), so
|
|
1803
|
+
expect delays for large record sets.
|
|
1804
|
+
"""
|
|
1805
|
+
self.no_single_model()
|
|
1806
|
+
next_models = self.with_query(self.get_query())
|
|
1807
|
+
results = list(next_models.__iter__())
|
|
1808
|
+
next_page_data = next_models.next_page_data()
|
|
1809
|
+
while next_page_data:
|
|
1810
|
+
next_models = self.pagination(**next_page_data)
|
|
1811
|
+
results.extend(next_models.__iter__())
|
|
1812
|
+
next_page_data = next_models.next_page_data()
|
|
1813
|
+
return results
|
|
1814
|
+
|
|
1815
|
+
def model(self: Self, data: dict[str, Any] = {}) -> Self:
|
|
1816
|
+
"""
|
|
1817
|
+
Create a new model object and populates it with the data in `data`.
|
|
1818
|
+
|
|
1819
|
+
NOTE: the difference between this and `model.create` is that model.create() actually saves a record in the backend,
|
|
1820
|
+
while this method just creates a model object populated with the given data. This can be helpful if you have record
|
|
1821
|
+
data loaded up in some alternate way and want to wrap a model around it. Calling the `model` method does not result
|
|
1822
|
+
in any interactions with the backend.
|
|
1823
|
+
|
|
1824
|
+
In the following example we create a record in the backend and then make a new model instance using `model`, which
|
|
1825
|
+
we then use to udpate the record. The returned name will be `Jane Doe`.
|
|
1826
|
+
|
|
1827
|
+
```
|
|
1828
|
+
import clearskies
|
|
1829
|
+
|
|
1830
|
+
|
|
1831
|
+
class User(clearskies.Model):
|
|
1832
|
+
id_column_name = "id"
|
|
1833
|
+
backend = clearskies.backends.MemoryBackend()
|
|
1834
|
+
|
|
1835
|
+
id = clearskies.columns.Uuid()
|
|
1836
|
+
name = clearskies.columns.String()
|
|
1837
|
+
|
|
1838
|
+
|
|
1839
|
+
def my_application(users):
|
|
1840
|
+
jane = users.create({"name": "Jane"})
|
|
1841
|
+
|
|
1842
|
+
# This effectively makes a new model instance that points to the jane record in the backend
|
|
1843
|
+
another_jane_object = users.model({"id": jane.id, "name": jane.name})
|
|
1844
|
+
# and we can perform an update operation like usual
|
|
1845
|
+
another_jane_object.save({"name": "Jane Doe"})
|
|
1846
|
+
|
|
1847
|
+
return {"id": another_jane_object.id, "name": another_jane_object.name}
|
|
1848
|
+
|
|
1849
|
+
|
|
1850
|
+
cli = clearskies.contexts.Cli(
|
|
1851
|
+
my_application,
|
|
1852
|
+
classes=[User],
|
|
1853
|
+
)
|
|
1854
|
+
cli()
|
|
1855
|
+
```
|
|
1856
|
+
"""
|
|
1857
|
+
model = self._di.build(self.__class__, cache=False)
|
|
1858
|
+
model.set_raw_data(data)
|
|
1859
|
+
return model
|
|
1860
|
+
|
|
1861
|
+
def empty(self: Self) -> Self:
|
|
1862
|
+
"""
|
|
1863
|
+
Create a an empty model instance.
|
|
1864
|
+
|
|
1865
|
+
An alias for self.model({}).
|
|
1866
|
+
|
|
1867
|
+
This just provides you a fresh, empty model instance that you can use for populating with data or creating
|
|
1868
|
+
a new record. Here's a simple exmaple. Both print statements will be printed and it will return the id
|
|
1869
|
+
for the Alice record, and then null for `blank_id`:
|
|
1870
|
+
|
|
1871
|
+
```
|
|
1872
|
+
import clearskies
|
|
1873
|
+
|
|
1874
|
+
|
|
1875
|
+
class User(clearskies.Model):
|
|
1876
|
+
id_column_name = "id"
|
|
1877
|
+
backend = clearskies.backends.MemoryBackend()
|
|
1878
|
+
|
|
1879
|
+
id = clearskies.columns.Uuid()
|
|
1880
|
+
name = clearskies.columns.String()
|
|
1881
|
+
|
|
1882
|
+
|
|
1883
|
+
def my_application(users):
|
|
1884
|
+
alice = users.create({"name": "Alice"})
|
|
1885
|
+
|
|
1886
|
+
if users.find("name=Alice"):
|
|
1887
|
+
print("Alice exists")
|
|
1888
|
+
|
|
1889
|
+
blank = alice.empty()
|
|
1890
|
+
|
|
1891
|
+
if not blank:
|
|
1892
|
+
print("Fresh instance, ready to go")
|
|
1893
|
+
|
|
1894
|
+
return {"alice_id": alice.id, "blank_id": blank.id}
|
|
1895
|
+
|
|
1896
|
+
|
|
1897
|
+
cli = clearskies.contexts.Cli(
|
|
1898
|
+
my_application,
|
|
1899
|
+
classes=[User],
|
|
1900
|
+
)
|
|
1901
|
+
cli()
|
|
1902
|
+
```
|
|
1903
|
+
"""
|
|
1904
|
+
return self.model({})
|
|
1905
|
+
|
|
1906
|
+
def create(self: Self, data: dict[str, Any] = {}, columns: dict[str, Column] = {}, no_data=False) -> Self:
|
|
1907
|
+
"""
|
|
1908
|
+
Create a new record in the backend using the information in `data`.
|
|
1909
|
+
|
|
1910
|
+
The `save` method always operates changes the model directly rather than creating a new model instance.
|
|
1911
|
+
Often, when creating a new record, you will need to both create a new (empty) model instance and save
|
|
1912
|
+
data to it. You can do this via `model.empty().save({"data": "here"})`, and this method provides a simple,
|
|
1913
|
+
unambiguous shortcut to do exactly that. So, you pass your save data to the `create` method and you will get
|
|
1914
|
+
back a new model:
|
|
1915
|
+
|
|
1916
|
+
```
|
|
1917
|
+
import clearskies
|
|
1918
|
+
|
|
1919
|
+
|
|
1920
|
+
class User(clearskies.Model):
|
|
1921
|
+
id_column_name = "id"
|
|
1922
|
+
backend = clearskies.backends.MemoryBackend()
|
|
1923
|
+
|
|
1924
|
+
id = clearskies.columns.Uuid()
|
|
1925
|
+
name = clearskies.columns.String()
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
def my_application(user):
|
|
1929
|
+
# let's create a new record
|
|
1930
|
+
user.save({"name": "Alice"})
|
|
1931
|
+
|
|
1932
|
+
# and now use `create` to both create a new record and get a new model instance
|
|
1933
|
+
bob = user.create({"name": "Bob"})
|
|
1934
|
+
|
|
1935
|
+
return {
|
|
1936
|
+
"Alice": user.name,
|
|
1937
|
+
"Bob": bob.name,
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
|
|
1941
|
+
cli = clearskies.contexts.Cli(
|
|
1942
|
+
my_application,
|
|
1943
|
+
classes=[User],
|
|
1944
|
+
)
|
|
1945
|
+
cli()
|
|
1946
|
+
```
|
|
1947
|
+
|
|
1948
|
+
Like with `save`, you can set `no_data=True` to create a record without specifying any model data.
|
|
1949
|
+
"""
|
|
1950
|
+
empty = self.model()
|
|
1951
|
+
empty.save(data, columns=columns, no_data=no_data)
|
|
1952
|
+
return empty
|
|
1953
|
+
|
|
1954
|
+
def first(self: Self) -> Self:
|
|
1955
|
+
"""
|
|
1956
|
+
Return the first model for a given query.
|
|
1957
|
+
|
|
1958
|
+
The `where` method returns an object meant to be iterated over. If you are expecting your query to return a single
|
|
1959
|
+
record, then you can use first to turn that directly into the matching model so you don't have to iterate over it:
|
|
1960
|
+
|
|
1961
|
+
```
|
|
1962
|
+
import clearskies
|
|
1963
|
+
|
|
1964
|
+
|
|
1965
|
+
class Order(clearskies.Model):
|
|
1966
|
+
id_column_name = "id"
|
|
1967
|
+
backend = clearskies.backends.MemoryBackend()
|
|
1968
|
+
|
|
1969
|
+
id = clearskies.columns.Uuid()
|
|
1970
|
+
user_id = clearskies.columns.String()
|
|
1971
|
+
status = clearskies.columns.Select(["Pending", "In Progress"])
|
|
1972
|
+
total = clearskies.columns.Float()
|
|
1973
|
+
|
|
1974
|
+
|
|
1975
|
+
def my_application(orders):
|
|
1976
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
|
|
1977
|
+
orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
|
|
1978
|
+
orders.create({"user_id": "Jane", "status": "Pending", "total": 30})
|
|
1979
|
+
|
|
1980
|
+
jane = orders.where("status=Pending").where(Order.total.greater_than(25)).first()
|
|
1981
|
+
jane.total = 35
|
|
1982
|
+
jane.save()
|
|
1983
|
+
|
|
1984
|
+
return {
|
|
1985
|
+
"user_id": jane.user_id,
|
|
1986
|
+
"total": jane.total,
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
|
|
1990
|
+
cli = clearskies.contexts.Cli(
|
|
1991
|
+
my_application,
|
|
1992
|
+
classes=[Order],
|
|
1993
|
+
)
|
|
1994
|
+
cli()
|
|
1995
|
+
```
|
|
1996
|
+
"""
|
|
1997
|
+
self.no_single_model()
|
|
1998
|
+
iter = self.__iter__()
|
|
1999
|
+
try:
|
|
2000
|
+
return iter.__next__()
|
|
2001
|
+
except StopIteration:
|
|
2002
|
+
return self.model()
|
|
2003
|
+
|
|
2004
|
+
def allowed_pagination_keys(self: Self) -> list[str]:
|
|
2005
|
+
return self.backend.allowed_pagination_keys()
|
|
2006
|
+
|
|
2007
|
+
def validate_pagination_data(self, kwargs: dict[str, Any], case_mapping: Callable[[str], str]) -> str:
|
|
2008
|
+
return self.backend.validate_pagination_data(kwargs, case_mapping)
|
|
2009
|
+
|
|
2010
|
+
def next_page_data(self: Self):
|
|
2011
|
+
return self._next_page_data
|
|
2012
|
+
|
|
2013
|
+
def documentation_pagination_next_page_response(self: Self, case_mapping: Callable) -> list[Any]:
|
|
2014
|
+
return self.backend.documentation_pagination_next_page_response(case_mapping)
|
|
2015
|
+
|
|
2016
|
+
def documentation_pagination_next_page_example(self: Self, case_mapping: Callable) -> dict[str, Any]:
|
|
2017
|
+
return self.backend.documentation_pagination_next_page_example(case_mapping)
|
|
2018
|
+
|
|
2019
|
+
def documentation_pagination_parameters(self: Self, case_mapping: Callable) -> list[tuple[AutoDocSchema, str]]:
|
|
2020
|
+
return self.backend.documentation_pagination_parameters(case_mapping)
|
|
2021
|
+
|
|
2022
|
+
def no_queries(self) -> None:
|
|
2023
|
+
if self._query:
|
|
2024
|
+
raise ValueError(
|
|
2025
|
+
"You attempted to save/read record data for a model being used to make a query. This is not allowed, as it is typically a sign of a bug in your application code."
|
|
2026
|
+
)
|
|
2027
|
+
|
|
2028
|
+
def no_single_model(self):
|
|
2029
|
+
if self._data:
|
|
2030
|
+
raise ValueError(
|
|
2031
|
+
"You have attempted to execute a query against a model that represents an individual record. This is not allowed, as it is typically a sign of a bug in your application code. If this is intentional, call model.as_query() before executing your query."
|
|
2032
|
+
)
|
|
2033
|
+
|
|
2034
|
+
|
|
2035
|
+
class ModelClassReference:
|
|
2036
|
+
@abstractmethod
|
|
2037
|
+
def get_model_class(self) -> type[Model]:
|
|
2038
|
+
pass
|