clear-skies 1.22.31__py3-none-any.whl → 2.0.1__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.
Potentially problematic release.
This version of clear-skies might be problematic. Click here for more details.
- {clear_skies-1.22.31.dist-info → clear_skies-2.0.1.dist-info}/METADATA +12 -14
- clear_skies-2.0.1.dist-info/RECORD +249 -0
- {clear_skies-1.22.31.dist-info → clear_skies-2.0.1.dist-info}/WHEEL +1 -1
- clearskies/__init__.py +42 -25
- clearskies/action.py +7 -0
- clearskies/authentication/__init__.py +8 -41
- clearskies/authentication/authentication.py +46 -0
- clearskies/authentication/authorization.py +8 -9
- clearskies/authentication/authorization_pass_through.py +11 -9
- clearskies/authentication/jwks.py +133 -58
- clearskies/authentication/public.py +3 -38
- clearskies/authentication/secret_bearer.py +516 -54
- clearskies/autodoc/formats/oai3_json/__init__.py +1 -1
- clearskies/autodoc/formats/oai3_json/oai3_json.py +9 -7
- 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 +4 -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 +7 -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 +55 -20
- clearskies/backends/api_backend.py +1100 -284
- clearskies/backends/backend.py +53 -84
- clearskies/backends/cursor_backend.py +236 -186
- clearskies/backends/memory_backend.py +519 -226
- clearskies/backends/secrets_backend.py +75 -31
- clearskies/column.py +1229 -0
- clearskies/columns/__init__.py +71 -0
- clearskies/columns/audit.py +205 -0
- clearskies/columns/belongs_to_id.py +483 -0
- clearskies/columns/belongs_to_model.py +128 -0
- clearskies/columns/belongs_to_self.py +105 -0
- clearskies/columns/boolean.py +109 -0
- clearskies/columns/category_tree.py +275 -0
- clearskies/columns/category_tree_ancestors.py +51 -0
- clearskies/columns/category_tree_children.py +127 -0
- clearskies/columns/category_tree_descendants.py +48 -0
- clearskies/columns/created.py +94 -0
- clearskies/columns/created_by_authorization_data.py +116 -0
- clearskies/columns/created_by_header.py +99 -0
- clearskies/columns/created_by_ip.py +92 -0
- clearskies/columns/created_by_routing_data.py +96 -0
- clearskies/columns/created_by_user_agent.py +92 -0
- clearskies/columns/date.py +230 -0
- clearskies/columns/datetime.py +278 -0
- clearskies/columns/email.py +76 -0
- clearskies/columns/float.py +149 -0
- clearskies/columns/has_many.py +505 -0
- clearskies/columns/has_many_self.py +56 -0
- clearskies/columns/has_one.py +14 -0
- clearskies/columns/integer.py +156 -0
- clearskies/columns/json.py +122 -0
- clearskies/columns/many_to_many_ids.py +333 -0
- clearskies/columns/many_to_many_ids_with_data.py +270 -0
- clearskies/columns/many_to_many_models.py +154 -0
- clearskies/columns/many_to_many_pivots.py +133 -0
- clearskies/columns/phone.py +158 -0
- clearskies/columns/select.py +91 -0
- clearskies/columns/string.py +98 -0
- clearskies/columns/timestamp.py +160 -0
- clearskies/columns/updated.py +110 -0
- clearskies/columns/uuid.py +86 -0
- clearskies/configs/README.md +105 -0
- clearskies/configs/__init__.py +162 -0
- clearskies/configs/actions.py +43 -0
- clearskies/configs/any.py +13 -0
- clearskies/configs/any_dict.py +22 -0
- clearskies/configs/any_dict_or_callable.py +23 -0
- clearskies/configs/authentication.py +23 -0
- clearskies/configs/authorization.py +23 -0
- clearskies/configs/boolean.py +16 -0
- clearskies/configs/boolean_or_callable.py +18 -0
- clearskies/configs/callable_config.py +18 -0
- clearskies/configs/columns.py +34 -0
- clearskies/configs/conditions.py +30 -0
- clearskies/configs/config.py +24 -0
- clearskies/configs/datetime.py +18 -0
- clearskies/configs/datetime_or_callable.py +19 -0
- clearskies/configs/endpoint.py +23 -0
- clearskies/configs/endpoint_list.py +28 -0
- clearskies/configs/float.py +16 -0
- clearskies/configs/float_or_callable.py +18 -0
- clearskies/configs/integer.py +16 -0
- clearskies/configs/integer_or_callable.py +18 -0
- clearskies/configs/joins.py +30 -0
- clearskies/configs/list_any_dict.py +30 -0
- clearskies/configs/list_any_dict_or_callable.py +31 -0
- clearskies/configs/model_class.py +35 -0
- clearskies/configs/model_column.py +65 -0
- clearskies/configs/model_columns.py +56 -0
- clearskies/configs/model_destination_name.py +25 -0
- clearskies/configs/model_to_id_column.py +43 -0
- clearskies/configs/readable_model_column.py +9 -0
- clearskies/configs/readable_model_columns.py +9 -0
- clearskies/configs/schema.py +23 -0
- clearskies/configs/searchable_model_columns.py +9 -0
- clearskies/configs/security_headers.py +39 -0
- clearskies/configs/select.py +26 -0
- clearskies/configs/select_list.py +47 -0
- clearskies/configs/string.py +29 -0
- clearskies/configs/string_dict.py +32 -0
- clearskies/configs/string_list.py +32 -0
- clearskies/configs/string_list_or_callable.py +35 -0
- clearskies/configs/string_or_callable.py +18 -0
- clearskies/configs/timedelta.py +18 -0
- clearskies/configs/timezone.py +18 -0
- clearskies/configs/url.py +23 -0
- clearskies/configs/validators.py +45 -0
- clearskies/configs/writeable_model_column.py +9 -0
- clearskies/configs/writeable_model_columns.py +9 -0
- clearskies/configurable.py +76 -0
- clearskies/contexts/__init__.py +8 -8
- clearskies/contexts/cli.py +8 -41
- clearskies/contexts/context.py +91 -56
- clearskies/contexts/wsgi.py +16 -29
- clearskies/contexts/wsgi_ref.py +53 -0
- clearskies/di/__init__.py +10 -7
- clearskies/di/additional_config.py +115 -4
- clearskies/di/additional_config_auto_import.py +12 -0
- clearskies/di/di.py +742 -121
- clearskies/di/inject/__init__.py +23 -0
- clearskies/di/inject/by_class.py +21 -0
- clearskies/di/inject/by_name.py +18 -0
- clearskies/di/inject/di.py +13 -0
- clearskies/di/inject/environment.py +14 -0
- clearskies/di/inject/input_output.py +20 -0
- clearskies/di/inject/now.py +13 -0
- clearskies/di/inject/requests.py +13 -0
- clearskies/di/inject/secrets.py +14 -0
- clearskies/di/inject/utcnow.py +13 -0
- clearskies/di/inject/uuid.py +15 -0
- clearskies/di/injectable.py +29 -0
- clearskies/di/injectable_properties.py +131 -0
- clearskies/end.py +183 -0
- clearskies/endpoint.py +1310 -0
- clearskies/endpoint_group.py +310 -0
- clearskies/endpoints/__init__.py +23 -0
- clearskies/endpoints/advanced_search.py +526 -0
- clearskies/endpoints/callable.py +388 -0
- clearskies/endpoints/create.py +202 -0
- clearskies/endpoints/delete.py +139 -0
- clearskies/endpoints/get.py +275 -0
- clearskies/endpoints/health_check.py +181 -0
- clearskies/endpoints/list.py +573 -0
- clearskies/endpoints/restful_api.py +427 -0
- clearskies/endpoints/simple_search.py +286 -0
- clearskies/endpoints/update.py +190 -0
- clearskies/environment.py +5 -3
- clearskies/exceptions/__init__.py +17 -0
- clearskies/{handlers/exceptions/input_error.py → exceptions/input_errors.py} +1 -1
- clearskies/exceptions/moved_permanently.py +3 -0
- clearskies/exceptions/moved_temporarily.py +3 -0
- clearskies/exceptions/not_found.py +2 -0
- clearskies/functional/__init__.py +2 -2
- 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 +130 -142
- clearskies/input_outputs/exceptions/__init__.py +1 -1
- clearskies/input_outputs/headers.py +45 -0
- clearskies/input_outputs/input_output.py +91 -122
- clearskies/input_outputs/programmatic.py +69 -0
- clearskies/input_outputs/wsgi.py +23 -38
- clearskies/model.py +984 -183
- clearskies/parameters_to_properties.py +31 -0
- clearskies/query/__init__.py +12 -0
- clearskies/query/condition.py +223 -0
- clearskies/query/join.py +136 -0
- clearskies/query/query.py +196 -0
- clearskies/query/sort.py +27 -0
- clearskies/schema.py +82 -0
- clearskies/secrets/__init__.py +3 -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 +88 -147
- clearskies/secrets/secrets.py +8 -8
- clearskies/security_header.py +15 -0
- clearskies/security_headers/__init__.py +8 -8
- clearskies/security_headers/cache_control.py +47 -110
- clearskies/security_headers/cors.py +40 -95
- clearskies/security_headers/csp.py +76 -151
- clearskies/security_headers/hsts.py +14 -16
- clearskies/test_base.py +8 -0
- clearskies/typing.py +11 -0
- clearskies/validator.py +37 -0
- clearskies/validators/__init__.py +33 -0
- clearskies/validators/after_column.py +62 -0
- clearskies/validators/before_column.py +13 -0
- clearskies/validators/in_the_future.py +32 -0
- clearskies/validators/in_the_future_at_least.py +11 -0
- clearskies/validators/in_the_future_at_most.py +10 -0
- clearskies/validators/in_the_past.py +32 -0
- clearskies/validators/in_the_past_at_least.py +10 -0
- clearskies/validators/in_the_past_at_most.py +10 -0
- clearskies/validators/maximum_length.py +26 -0
- clearskies/validators/maximum_value.py +29 -0
- clearskies/validators/minimum_length.py +26 -0
- clearskies/validators/minimum_value.py +29 -0
- clearskies/validators/required.py +35 -0
- clearskies/validators/timedelta.py +59 -0
- clearskies/validators/unique.py +31 -0
- clear_skies-1.22.31.dist-info/RECORD +0 -214
- 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 -51
- clearskies/backends/api_get_only_backend.py +0 -48
- 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 -103
- clearskies/binding_config.py +0 -16
- clearskies/column_types/__init__.py +0 -203
- clearskies/column_types/audit.py +0 -249
- clearskies/column_types/belongs_to.py +0 -271
- clearskies/column_types/boolean.py +0 -60
- clearskies/column_types/category_tree.py +0 -304
- 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 -109
- 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 -179
- clearskies/column_types/has_one.py +0 -60
- 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/phone.py +0 -48
- clearskies/column_types/select.py +0 -11
- clearskies/column_types/string.py +0 -24
- clearskies/column_types/timestamp.py +0 -73
- clearskies/column_types/updated.py +0 -26
- clearskies/column_types/updated_micro.py +0 -26
- 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 -41
- clearskies/decorators/allow_non_json_bodies.py +0 -9
- 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 -151
- clearskies/handlers/__init__.py +0 -41
- clearskies/handlers/advanced_search.py +0 -271
- clearskies/handlers/base.py +0 -479
- clearskies/handlers/callable.py +0 -192
- 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 -206
- clearskies/handlers/simple_routing_route.py +0 -197
- clearskies/handlers/simple_search.py +0 -136
- clearskies/handlers/update.py +0 -102
- clearskies/handlers/write.py +0 -193
- clearskies/input_requirements/__init__.py +0 -78
- 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/maximum_value.py +0 -19
- clearskies/input_requirements/minimum_length.py +0 -22
- clearskies/input_requirements/minimum_value.py +0 -19
- clearskies/input_requirements/required.py +0 -23
- 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 -350
- 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.22.31.dist-info → clear_skies-2.0.1.dist-info}/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/{tests/__init__.py → input_outputs/py.typed} +0 -0
- /clearskies/{tests/simple_api/__init__.py → py.typed} +0 -0
clearskies/model.py
CHANGED
|
@@ -1,42 +1,241 @@
|
|
|
1
|
-
from
|
|
2
|
-
|
|
3
|
-
from .column_types import UUID
|
|
4
|
-
from .functional import string
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
5
3
|
import re
|
|
6
|
-
from
|
|
4
|
+
from abc import abstractmethod
|
|
5
|
+
from typing import TYPE_CHECKING, Any, Callable, Iterator, Self
|
|
6
|
+
|
|
7
|
+
from clearskies.autodoc.schema import Schema as AutoDocSchema
|
|
8
|
+
from clearskies.di import InjectableProperties, inject
|
|
9
|
+
from clearskies.functional import string
|
|
10
|
+
from clearskies.query import Condition, Join, Query, Sort
|
|
11
|
+
from clearskies.schema import Schema
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from clearskies import Column
|
|
15
|
+
from clearskies.backends import Backend
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Model(Schema, InjectableProperties):
|
|
19
|
+
"""
|
|
20
|
+
A clearskies model.
|
|
21
|
+
|
|
22
|
+
To be useable, a model class needs four things:
|
|
23
|
+
|
|
24
|
+
1. The name of the id column
|
|
25
|
+
2. A backend
|
|
26
|
+
3. A destination name (equivalent to a table name for SQL backends)
|
|
27
|
+
4. Columns
|
|
28
|
+
|
|
29
|
+
In more detail:
|
|
30
|
+
|
|
31
|
+
### Id Column Name
|
|
32
|
+
|
|
33
|
+
clearskies assumes that all models have a column that uniquely identifies each record. This id column is
|
|
34
|
+
provided where appropriate in the lifecycle of the model save process to help connect and find related records.
|
|
35
|
+
It's defined as a simple class attribute called `id_column_name`. There **MUST** be a column with the same name
|
|
36
|
+
in the column definitions. A simple approach to take is to use the Uuid column as an id column. This will
|
|
37
|
+
automatically provide a random UUID when the record is first created. If you are using auto-incrementing integers,
|
|
38
|
+
you can simply use an `Int` column type and define the column as auto-incrementing in your database.
|
|
7
39
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
40
|
+
### Backend
|
|
41
|
+
|
|
42
|
+
Every model needs a backend, which is an object that extends clearskies.Backend and is attached to the
|
|
43
|
+
`backend` attribute of the model class. clearskies comes with a variety of backends in the `clearskies.backends`
|
|
44
|
+
module that you can use, and you can also define your own or import more from additional packages.
|
|
45
|
+
|
|
46
|
+
### Destination Name
|
|
47
|
+
|
|
48
|
+
The destination name is the equivalent of a table name in other frameworks, but the name is more generic to
|
|
49
|
+
reflect the fact that clearskies is intended to work with a variety of backends - not just SQL databases.
|
|
50
|
+
The exact meaning of the destination name depends on the backend: for a cursor backend it is in fact used
|
|
51
|
+
as the table name when fetching/storing records. For the API backend it is frequently appended to a base
|
|
52
|
+
URL to reach the corect endpoint.
|
|
53
|
+
|
|
54
|
+
This is provided by a class function call `destination_name`. The base model class declares a generic method
|
|
55
|
+
for this which takes the class name, converts it from title case to snake case, and makes it plural. Hence,
|
|
56
|
+
a model class called `User` will have a default destination name of `users` and a model class of `OrderProduct`
|
|
57
|
+
will have a default destination name of `order_products`. Of course, this system isn't pefect: your backend
|
|
58
|
+
may have a different convention or you may have one of the many words in the english language that are
|
|
59
|
+
exceptions to the grammatical rules of making words plural. In this case you can simply extend the method
|
|
60
|
+
and change it according to your needs, e.g.:
|
|
61
|
+
|
|
62
|
+
```
|
|
11
63
|
from typing import Self
|
|
64
|
+
import clearskies
|
|
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
|
|
12
88
|
|
|
89
|
+
class User(clearskies.Model):
|
|
90
|
+
id_column_name = "id"
|
|
91
|
+
backend = clearskies.backends.MemoryBackend()
|
|
13
92
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
_data = None
|
|
17
|
-
_previous_data = None
|
|
18
|
-
_touched_columns = None
|
|
19
|
-
_transformed = None
|
|
20
|
-
id_column_name = "id"
|
|
93
|
+
id = clearskies.columns.Uuid()
|
|
94
|
+
name = clearskies.columns.String()
|
|
21
95
|
|
|
22
|
-
def
|
|
23
|
-
|
|
24
|
-
|
|
96
|
+
def my_application(user, users, by_type_hint: User):
|
|
97
|
+
return {
|
|
98
|
+
"all_are_user_models": isinstance(user, User) and isinstance(users, User) and isinstance(by_type_hint, User)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
cli = clearskies.contexts.Cli(my_application, classes=[User])
|
|
102
|
+
cli()
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Note that the `User` model class was provided in the `classes` list sent to the context: that's important as it
|
|
106
|
+
informs the dependency injection system that this is a class we want to provide. It's common (but not required)
|
|
107
|
+
to put all models for a clearskies application in their own separate python module and then provide those to
|
|
108
|
+
the depedency injection system via the `modules` argument to the context. So you may have a directory structure
|
|
109
|
+
like this:
|
|
110
|
+
|
|
111
|
+
```
|
|
112
|
+
├── app/
|
|
113
|
+
│ └── models/
|
|
114
|
+
│ ├── __init__.py
|
|
115
|
+
│ ├── category.py
|
|
116
|
+
│ ├── order.py
|
|
117
|
+
│ ├── product.py
|
|
118
|
+
│ ├── status.py
|
|
119
|
+
│ └── user.py
|
|
120
|
+
└── api.py
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Where `__init__.py` imports all the models:
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
from app.models.category import Category
|
|
127
|
+
from app.models.order import Order
|
|
128
|
+
from app.models.proudct import Product
|
|
129
|
+
from app.models.status import Status
|
|
130
|
+
from app.models.user import User
|
|
131
|
+
|
|
132
|
+
__all__ = ["Category", "Order", "Product", "Status", "User"]
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Then in your main application you can just import the whole `models` module into your context:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
import app.models
|
|
139
|
+
|
|
140
|
+
cli = clearskies.contexts.cli(SomeApplication, modules=[app.models])
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Adding Dependencies
|
|
144
|
+
|
|
145
|
+
The base model class extends `clearskies.di.InjectableProperties` which means that you can inject dependencies into your model
|
|
146
|
+
using the `di.inject` classes. Here's an example that demonstrates dependency injection for models:
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
import datetime
|
|
150
|
+
import clearskies
|
|
151
|
+
|
|
152
|
+
class SomeClass:
|
|
153
|
+
# Since this will be built by the DI system directly, we can declare dependencies in the __init__
|
|
154
|
+
def __init__(self, some_date):
|
|
155
|
+
self.some_date = some_date
|
|
156
|
+
|
|
157
|
+
class User(clearskies.Model):
|
|
158
|
+
id_column_name = "id"
|
|
159
|
+
backend = clearskies.backends.MemoryBackend()
|
|
160
|
+
|
|
161
|
+
utcnow = clearskies.di.inject.Utcnow()
|
|
162
|
+
some_class = clearskies.di.inject.ByClass(SomeClass)
|
|
163
|
+
|
|
164
|
+
id = clearskies.columns.Uuid()
|
|
165
|
+
name = clearskies.columns.String()
|
|
166
|
+
|
|
167
|
+
def some_date_in_the_past(self):
|
|
168
|
+
return self.some_class.some_date < self.utcnow
|
|
169
|
+
|
|
170
|
+
def my_application(user):
|
|
171
|
+
return user.some_date_in_the_past()
|
|
172
|
+
|
|
173
|
+
cli = clearskies.contexts.Cli(
|
|
174
|
+
my_application,
|
|
175
|
+
classes=[User],
|
|
176
|
+
bindings={
|
|
177
|
+
"some_date": datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1),
|
|
178
|
+
}
|
|
179
|
+
)
|
|
180
|
+
cli()
|
|
181
|
+
```
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
_previous_data: dict[str, Any] = {}
|
|
185
|
+
_data: dict[str, Any] = {}
|
|
186
|
+
_next_data: dict[str, Any] = {}
|
|
187
|
+
_transformed_data: dict[str, Any] = {}
|
|
188
|
+
_touched_columns: dict[str, bool] = {}
|
|
189
|
+
_query: Query | None = None
|
|
190
|
+
_query_executed: bool = False
|
|
191
|
+
_count: int | None = None
|
|
192
|
+
_next_page_data: dict[str, Any] | None = None
|
|
193
|
+
|
|
194
|
+
id_column_name: str = ""
|
|
195
|
+
backend: Backend = None # type: ignore
|
|
196
|
+
|
|
197
|
+
_di = inject.Di()
|
|
198
|
+
|
|
199
|
+
def __init__(self):
|
|
200
|
+
if not self.id_column_name:
|
|
201
|
+
raise ValueError(
|
|
202
|
+
f"You must define the 'id_column_name' property for every model class, but this is missing for model '{self.__class__.__name__}'"
|
|
203
|
+
)
|
|
204
|
+
if not isinstance(self.id_column_name, str):
|
|
205
|
+
raise TypeError(
|
|
206
|
+
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__}'."
|
|
207
|
+
)
|
|
208
|
+
if not self.backend:
|
|
209
|
+
raise ValueError(
|
|
210
|
+
f"You must define the 'backend' property for every model class, but this is missing for model '{self.__class__.__name__}'"
|
|
211
|
+
)
|
|
212
|
+
if not hasattr(self.backend, "documentation_pagination_parameters"):
|
|
213
|
+
raise TypeError(
|
|
214
|
+
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__}'."
|
|
215
|
+
)
|
|
216
|
+
self._previous_data = {}
|
|
25
217
|
self._data = {}
|
|
26
|
-
self.
|
|
27
|
-
self.
|
|
218
|
+
self._next_data = {}
|
|
219
|
+
self._transformed_data = {}
|
|
220
|
+
self._touched_columns = {}
|
|
221
|
+
self._query = None
|
|
222
|
+
self._query_executed = False
|
|
223
|
+
self._count = None
|
|
224
|
+
self._next_page_data = None
|
|
28
225
|
|
|
29
|
-
|
|
226
|
+
@classmethod
|
|
227
|
+
def destination_name(cls: type[Self]) -> str:
|
|
30
228
|
"""
|
|
31
|
-
Return the
|
|
229
|
+
Return the name of the destination that the model uses for data storage.
|
|
32
230
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
231
|
+
For SQL backends, this would return the table name. Other backends will use this
|
|
232
|
+
same function but interpret it in whatever way it makes sense. For instance, an
|
|
233
|
+
API backend may treat it as a URL (or URL path), an SQS backend may expect a queue
|
|
234
|
+
URL, etc...
|
|
36
235
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
"""
|
|
236
|
+
By default this takes the class name, converts from title case to snake case, and then
|
|
237
|
+
makes it plural.
|
|
238
|
+
"""
|
|
40
239
|
singular = string.camel_case_to_snake_case(cls.__name__)
|
|
41
240
|
if singular[-1] == "y":
|
|
42
241
|
return singular[:-1] + "ies"
|
|
@@ -44,103 +243,67 @@ class Model(Models):
|
|
|
44
243
|
return singular + "es"
|
|
45
244
|
return f"{singular}s"
|
|
46
245
|
|
|
47
|
-
@abstractmethod
|
|
48
|
-
def columns_configuration(self: Self):
|
|
49
|
-
"""Returns an ordered dictionary with the configuration for the columns"""
|
|
50
|
-
pass
|
|
51
|
-
|
|
52
|
-
def all_columns(self: Self):
|
|
53
|
-
default = OrderedDict([(self.id_column_name, {"class": UUID})])
|
|
54
|
-
default.update(self.columns_configuration())
|
|
55
|
-
return default
|
|
56
|
-
|
|
57
|
-
def columns(self: Self, overrides=None):
|
|
58
|
-
# no caching if we have overrides
|
|
59
|
-
if overrides is not None:
|
|
60
|
-
return self._columns.configure(self.all_columns(), self.__class__, overrides=overrides)
|
|
61
|
-
|
|
62
|
-
if self._configured_columns is None:
|
|
63
|
-
self._configured_columns = self._columns.configure(self.all_columns(), self.__class__)
|
|
64
|
-
return self._configured_columns
|
|
65
|
-
|
|
66
246
|
def supports_n_plus_one(self: Self):
|
|
67
|
-
return self.
|
|
68
|
-
|
|
69
|
-
def __getitem__(self: Self, column_name):
|
|
70
|
-
return self.__getattr__(column_name)
|
|
71
|
-
|
|
72
|
-
def __getattr__(self: Self, column_name):
|
|
73
|
-
# this should be adjusted to only return None for empty records if the column name corresponds
|
|
74
|
-
# to an actual column in the table.
|
|
75
|
-
if not self.exists:
|
|
76
|
-
return None
|
|
77
|
-
|
|
78
|
-
return self.get_transformed_from_data(column_name, self._data)
|
|
79
|
-
|
|
80
|
-
def get(self: Self, column_name, silent=False):
|
|
81
|
-
if not self.exists:
|
|
82
|
-
return None
|
|
83
|
-
|
|
84
|
-
return self.get_transformed_from_data(column_name, self._data, silent=silent)
|
|
85
|
-
|
|
86
|
-
def get_transformed_from_data(self: Self, column_name, data, cache=True, check_providers=True, silent=False):
|
|
87
|
-
if cache and column_name in self._transformed:
|
|
88
|
-
return self._transformed[column_name]
|
|
89
|
-
|
|
90
|
-
# everything in self._data came directly out of the database, but we don't want to send that off.
|
|
91
|
-
# instead, the corresponding column has an opportunity to make changes as needed. Moreover,
|
|
92
|
-
# it could be that the requested column_name doesn't even exist directly in self._data, but
|
|
93
|
-
# can be provided by a column. Therefore, we're going to do some work to fulfill the request,
|
|
94
|
-
# raise an Error if we *really* can't fulfill it, and store the results in self._transformed
|
|
95
|
-
# as a simple local cache (self._transformed is cleared during a save operation)
|
|
96
|
-
columns = self.columns()
|
|
97
|
-
value = None
|
|
98
|
-
if (column_name not in data or data[column_name] is None) and check_providers:
|
|
99
|
-
for column in columns.values():
|
|
100
|
-
if column.can_provide(column_name):
|
|
101
|
-
value = column.provide(data, column_name)
|
|
102
|
-
break
|
|
103
|
-
if column_name not in data and value is None:
|
|
104
|
-
if not silent:
|
|
105
|
-
raise KeyError(f"Unknown column '{column_name}' requested from model '{self.__class__.__name__}'")
|
|
106
|
-
return None
|
|
107
|
-
else:
|
|
108
|
-
value = (
|
|
109
|
-
self._backend.column_from_backend(self.columns()[column_name], data[column_name])
|
|
110
|
-
if column_name in self.columns()
|
|
111
|
-
else data[column_name]
|
|
112
|
-
)
|
|
247
|
+
return self.backend.supports_n_plus_one # type: ignore
|
|
113
248
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
249
|
+
def __bool__(self: Self) -> bool: # noqa: D105
|
|
250
|
+
if self._query:
|
|
251
|
+
return bool(self.__len__())
|
|
117
252
|
|
|
118
|
-
|
|
119
|
-
def exists(self: Self) -> bool:
|
|
120
|
-
return True if (self.id_column_name in self._data and self._data[self.id_column_name]) else False
|
|
253
|
+
return True if self._data else False
|
|
121
254
|
|
|
122
|
-
|
|
123
|
-
|
|
255
|
+
def get_raw_data(self: Self) -> dict[str, Any]:
|
|
256
|
+
self.no_queries()
|
|
124
257
|
return self._data
|
|
125
258
|
|
|
126
|
-
|
|
127
|
-
|
|
259
|
+
def set_raw_data(self: Self, data: dict[str, Any]) -> None:
|
|
260
|
+
self.no_queries()
|
|
128
261
|
self._data = {} if data is None else data
|
|
262
|
+
self._transformed_data = {}
|
|
129
263
|
|
|
130
|
-
def save(self: Self, data, columns=
|
|
264
|
+
def save(self: Self, data: dict[str, Any] | None = None, columns: dict[str, Column] = {}, no_data=False) -> bool:
|
|
131
265
|
"""
|
|
132
|
-
Save data to the database and update the model
|
|
266
|
+
Save data to the database and update the model.
|
|
267
|
+
|
|
268
|
+
Executes an update if the model corresponds to a record already, or an insert if not.
|
|
269
|
+
|
|
270
|
+
There are two supported flows. One is to pass in a dictionary of data to save:
|
|
271
|
+
|
|
272
|
+
```python
|
|
273
|
+
model.save({
|
|
274
|
+
"some_column": "New Value",
|
|
275
|
+
"another_column": 5,
|
|
276
|
+
})
|
|
277
|
+
```
|
|
133
278
|
|
|
134
|
-
|
|
279
|
+
And the other is to set new values on the columns attributes and then call save without data:
|
|
280
|
+
|
|
281
|
+
```python
|
|
282
|
+
model.some_column = "New Value"
|
|
283
|
+
model.another_column = 5
|
|
284
|
+
model.save()
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
You cannot combine these methods. If you set a value on a column attribute and also pass
|
|
288
|
+
in a dictionary of data to the save, then an exception will be raised.
|
|
135
289
|
"""
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
290
|
+
self.no_queries()
|
|
291
|
+
if not data and not self._next_data and not no_data:
|
|
292
|
+
raise ValueError("You have to pass in something to save, or set no_data=True in your call to save/create.")
|
|
293
|
+
if data and self._next_data:
|
|
294
|
+
raise ValueError(
|
|
295
|
+
"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."
|
|
296
|
+
)
|
|
297
|
+
if not data:
|
|
298
|
+
data = {**self._next_data}
|
|
299
|
+
self._next_data = {}
|
|
300
|
+
|
|
301
|
+
save_columns = self.get_columns()
|
|
139
302
|
if columns is not None:
|
|
140
303
|
for column in columns.values():
|
|
141
304
|
save_columns[column.name] = column
|
|
142
305
|
|
|
143
|
-
old_data = self.
|
|
306
|
+
old_data = self.get_raw_data()
|
|
144
307
|
data = self.columns_pre_save(data, save_columns)
|
|
145
308
|
data = self.pre_save(data)
|
|
146
309
|
if data is None:
|
|
@@ -148,11 +311,11 @@ class Model(Models):
|
|
|
148
311
|
|
|
149
312
|
[to_save, temporary_data] = self.columns_to_backend(data, save_columns)
|
|
150
313
|
to_save = self.to_backend(to_save, save_columns)
|
|
151
|
-
if self
|
|
152
|
-
new_data = self.
|
|
314
|
+
if self:
|
|
315
|
+
new_data = self.backend.update(self._data[self.id_column_name], to_save, self) # type: ignore
|
|
153
316
|
else:
|
|
154
|
-
new_data = self.
|
|
155
|
-
id = self.
|
|
317
|
+
new_data = self.backend.create(to_save, self) # type: ignore
|
|
318
|
+
id = self.backend.column_from_backend(save_columns[self.id_column_name], new_data[self.id_column_name]) # type: ignore
|
|
156
319
|
|
|
157
320
|
# if we had any temporary columns add them back in
|
|
158
321
|
new_data = {
|
|
@@ -163,22 +326,23 @@ class Model(Models):
|
|
|
163
326
|
data = self.columns_post_save(data, id, save_columns)
|
|
164
327
|
self.post_save(data, id)
|
|
165
328
|
|
|
166
|
-
self.
|
|
167
|
-
self.
|
|
329
|
+
self.set_raw_data(new_data)
|
|
330
|
+
self._transformed_data = {}
|
|
168
331
|
self._previous_data = old_data
|
|
169
|
-
self._touched_columns =
|
|
332
|
+
self._touched_columns = {key: True for key in data.keys()}
|
|
170
333
|
|
|
171
334
|
self.columns_save_finished(save_columns)
|
|
172
335
|
self.save_finished()
|
|
173
336
|
|
|
174
337
|
return True
|
|
175
338
|
|
|
176
|
-
def is_changing(self: Self, key, data) -> bool:
|
|
339
|
+
def is_changing(self: Self, key: str, data: dict[str, Any]) -> bool:
|
|
177
340
|
"""
|
|
178
|
-
|
|
341
|
+
Return True/False to denote if the given column is being modified by the active save operation.
|
|
179
342
|
|
|
180
343
|
Pass in the name of the column to check and the data dictionary from the save in progress
|
|
181
344
|
"""
|
|
345
|
+
self.no_queries()
|
|
182
346
|
has_old_value = key in self._data
|
|
183
347
|
has_new_value = key in data
|
|
184
348
|
|
|
@@ -187,24 +351,26 @@ class Model(Models):
|
|
|
187
351
|
if not has_old_value:
|
|
188
352
|
return True
|
|
189
353
|
|
|
190
|
-
return self
|
|
354
|
+
return getattr(self, key) != data[key]
|
|
191
355
|
|
|
192
|
-
def latest(self: Self, key, data):
|
|
356
|
+
def latest(self: Self, key: str, data: dict[str, Any]) -> Any:
|
|
193
357
|
"""
|
|
194
|
-
|
|
358
|
+
Return the 'latest' value for a column during the save operation.
|
|
195
359
|
|
|
196
|
-
|
|
360
|
+
Return either the column value from the data dictionary or the current value stored in the model
|
|
197
361
|
Basically, shorthand for the optimized version of: `data.get(key, default=getattr(self, key))` (which is
|
|
198
362
|
less than ideal because it always builds the default value, even when not necessary)
|
|
199
363
|
|
|
200
364
|
Pass in the name of the column to check and the data dictionary from the save in progress
|
|
201
365
|
"""
|
|
366
|
+
self.no_queries()
|
|
202
367
|
if key in data:
|
|
203
368
|
return data[key]
|
|
204
|
-
return self
|
|
369
|
+
return getattr(self, key)
|
|
205
370
|
|
|
206
|
-
def was_changed(self: Self, key) -> bool:
|
|
207
|
-
"""
|
|
371
|
+
def was_changed(self: Self, key: str) -> bool:
|
|
372
|
+
"""Return True/False to denote if a column was changed in the last save."""
|
|
373
|
+
self.no_queries()
|
|
208
374
|
if self._previous_data is None:
|
|
209
375
|
raise ValueError("was_changed was called before a save was finished - you must save something first")
|
|
210
376
|
if key not in self._touched_columns:
|
|
@@ -219,51 +385,56 @@ class Model(Models):
|
|
|
219
385
|
if not has_old_value:
|
|
220
386
|
return False
|
|
221
387
|
|
|
222
|
-
columns = self.
|
|
223
|
-
new_value = self.
|
|
388
|
+
columns = self.get_columns()
|
|
389
|
+
new_value = self._data[key]
|
|
224
390
|
old_value = self._previous_data[key]
|
|
225
391
|
if key not in columns:
|
|
226
392
|
return old_value != new_value
|
|
227
393
|
return not columns[key].values_match(old_value, new_value)
|
|
228
394
|
|
|
229
|
-
def previous_value(self: Self, key):
|
|
230
|
-
|
|
395
|
+
def previous_value(self: Self, key: str):
|
|
396
|
+
"""Return the value of a column from before the most recent save."""
|
|
397
|
+
self.no_queries()
|
|
398
|
+
return getattr(self.__class__, key).transform(self._previous_data.get(key))
|
|
231
399
|
|
|
232
400
|
def delete(self: Self, except_if_not_exists=True) -> bool:
|
|
233
|
-
|
|
401
|
+
"""Delete a record."""
|
|
402
|
+
self.no_queries()
|
|
403
|
+
if not self:
|
|
234
404
|
if except_if_not_exists:
|
|
235
405
|
raise ValueError("Cannot delete model that already exists")
|
|
236
406
|
return True
|
|
237
407
|
|
|
238
|
-
columns = self.
|
|
408
|
+
columns = self.get_columns()
|
|
239
409
|
self.columns_pre_delete(columns)
|
|
240
410
|
self.pre_delete()
|
|
241
411
|
|
|
242
|
-
self.
|
|
412
|
+
self.backend.delete(self._data[self.id_column_name], self) # type: ignore
|
|
243
413
|
|
|
244
414
|
self.columns_post_delete(columns)
|
|
245
415
|
self.post_delete()
|
|
246
416
|
return True
|
|
247
417
|
|
|
248
|
-
def columns_pre_save(self: Self, data, columns):
|
|
249
|
-
"""
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
)
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
418
|
+
def columns_pre_save(self: Self, data: dict[str, Any], columns) -> dict[str, Any]:
|
|
419
|
+
"""Use the column information present in the model to make any necessary changes before saving."""
|
|
420
|
+
iterate = True
|
|
421
|
+
changed = {}
|
|
422
|
+
while iterate:
|
|
423
|
+
iterate = False
|
|
424
|
+
for column in columns.values():
|
|
425
|
+
data = column.pre_save(data, self)
|
|
426
|
+
if data is None:
|
|
427
|
+
raise ValueError(
|
|
428
|
+
f"Column {column.name} of type {column.__class__.__name__} did not return any data for pre_save"
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
# if we have newly chnaged data then we want to loop through the pre-saves again
|
|
432
|
+
if data and column.name not in changed:
|
|
433
|
+
changed[column.name] = True
|
|
434
|
+
iterate = True
|
|
264
435
|
return data
|
|
265
436
|
|
|
266
|
-
def columns_to_backend(self: Self, data, columns):
|
|
437
|
+
def columns_to_backend(self: Self, data: dict[str, Any], columns) -> Any:
|
|
267
438
|
backend_data = {**data}
|
|
268
439
|
temporary_data = {}
|
|
269
440
|
for column in columns.values():
|
|
@@ -273,7 +444,7 @@ class Model(Models):
|
|
|
273
444
|
del backend_data[column.name]
|
|
274
445
|
continue
|
|
275
446
|
|
|
276
|
-
backend_data = self.
|
|
447
|
+
backend_data = self.backend.column_to_backend(column, backend_data) # type: ignore
|
|
277
448
|
if backend_data is None:
|
|
278
449
|
raise ValueError(
|
|
279
450
|
f"Column {column.name} of type {column.__class__.__name__} did not return any data for to_database"
|
|
@@ -281,44 +452,40 @@ class Model(Models):
|
|
|
281
452
|
|
|
282
453
|
return [backend_data, temporary_data]
|
|
283
454
|
|
|
284
|
-
def to_backend(self: Self, data, columns):
|
|
455
|
+
def to_backend(self: Self, data: dict[str, Any], columns) -> dict[str, Any]:
|
|
285
456
|
return data
|
|
286
457
|
|
|
287
|
-
def columns_post_save(self: Self, data, id, columns):
|
|
288
|
-
"""
|
|
458
|
+
def columns_post_save(self: Self, data: dict[str, Any], id: str | int, columns) -> dict[str, Any]:
|
|
459
|
+
"""Use the column information present in the model to make additional changes as needed after saving."""
|
|
289
460
|
for column in columns.values():
|
|
290
|
-
|
|
291
|
-
if data is None:
|
|
292
|
-
raise ValueError(
|
|
293
|
-
f"Column {column.name} of type {column.__class__.__name__} did not return any data for post_save"
|
|
294
|
-
)
|
|
461
|
+
column.post_save(data, self, id)
|
|
295
462
|
return data
|
|
296
463
|
|
|
297
|
-
def columns_save_finished(self: Self, columns):
|
|
298
|
-
"""
|
|
464
|
+
def columns_save_finished(self: Self, columns) -> None:
|
|
465
|
+
"""Call the save_finished method on all of our columns."""
|
|
299
466
|
for column in columns.values():
|
|
300
467
|
column.save_finished(self)
|
|
301
468
|
|
|
302
|
-
def post_save(self: Self, data, id):
|
|
469
|
+
def post_save(self: Self, data: dict[str, Any], id: str | int) -> None:
|
|
303
470
|
"""
|
|
304
|
-
|
|
471
|
+
Create a hook to extend so you can provide additional pre-save logic as needed.
|
|
305
472
|
|
|
306
473
|
It is passed in the data being saved as well as the id. It should take action as needed and then return
|
|
307
474
|
either the original data array or an adjusted one if appropriate.
|
|
308
475
|
"""
|
|
309
476
|
pass
|
|
310
477
|
|
|
311
|
-
def pre_save(self: Self, data):
|
|
478
|
+
def pre_save(self: Self, data: dict[str, Any]) -> dict[str, Any]:
|
|
312
479
|
"""
|
|
313
|
-
|
|
480
|
+
Create a hook to extend so you can provide additional pre-save logic as needed.
|
|
314
481
|
|
|
315
482
|
It is passed in the data being saved and it should return the same data with adjustments as needed
|
|
316
483
|
"""
|
|
317
484
|
return data
|
|
318
485
|
|
|
319
|
-
def save_finished(self: Self):
|
|
486
|
+
def save_finished(self: Self) -> None:
|
|
320
487
|
"""
|
|
321
|
-
|
|
488
|
+
Create a hook to extend so you can provide additional logic after a save operation has fully completed.
|
|
322
489
|
|
|
323
490
|
It has no retrun value and is passed no data. By the time this fires the model has already been
|
|
324
491
|
updated with the new data. You can decide on the necessary actions using the `was_changed` and
|
|
@@ -326,32 +493,666 @@ class Model(Models):
|
|
|
326
493
|
"""
|
|
327
494
|
pass
|
|
328
495
|
|
|
329
|
-
def columns_pre_delete(self: Self, columns):
|
|
330
|
-
"""
|
|
496
|
+
def columns_pre_delete(self: Self, columns: dict[str, Column]) -> None:
|
|
497
|
+
"""Use the column information present in the model to make any necessary changes before deleting."""
|
|
331
498
|
for column in columns.values():
|
|
332
499
|
column.pre_delete(self)
|
|
333
500
|
|
|
334
|
-
def pre_delete(self: Self):
|
|
335
|
-
"""
|
|
336
|
-
A hook to extend so you can provide additional pre-delete logic as needed
|
|
337
|
-
"""
|
|
501
|
+
def pre_delete(self: Self) -> None:
|
|
502
|
+
"""Create a hook to extend so you can provide additional pre-delete logic as needed."""
|
|
338
503
|
pass
|
|
339
504
|
|
|
340
|
-
def columns_post_delete(self: Self, columns):
|
|
341
|
-
"""
|
|
505
|
+
def columns_post_delete(self: Self, columns: dict[str, Column]) -> None:
|
|
506
|
+
"""Use the column information present in the model to make any necessary changes after deleting."""
|
|
342
507
|
for column in columns.values():
|
|
343
508
|
column.post_delete(self)
|
|
344
509
|
|
|
345
|
-
def post_delete(self: Self):
|
|
510
|
+
def post_delete(self: Self) -> None:
|
|
511
|
+
"""Create a hook to extend so you can provide additional post-delete logic as needed."""
|
|
512
|
+
pass
|
|
513
|
+
|
|
514
|
+
def where_for_request(
|
|
515
|
+
self: Self,
|
|
516
|
+
models: Self,
|
|
517
|
+
routing_data: dict[str, str],
|
|
518
|
+
authorization_data: dict[str, Any],
|
|
519
|
+
input_output: Any,
|
|
520
|
+
overrides: dict[str, Column] = {},
|
|
521
|
+
) -> Self:
|
|
522
|
+
"""Create a hook to automatically apply filtering whenever the model makes an appearance in a get/update/list/search handler."""
|
|
523
|
+
for column in self.get_columns(overrides=overrides).values():
|
|
524
|
+
models = column.where_for_request(models, routing_data, authorization_data, input_output) # type: ignore
|
|
525
|
+
return models
|
|
526
|
+
|
|
527
|
+
##############################################################
|
|
528
|
+
### From here down is functionality related to list/search ###
|
|
529
|
+
##############################################################
|
|
530
|
+
def has_query(self) -> bool:
|
|
531
|
+
"""Whether or not this model instance represents a query."""
|
|
532
|
+
return bool(self._query)
|
|
533
|
+
|
|
534
|
+
def get_query(self) -> Query:
|
|
535
|
+
"""Fetch the query object in the model."""
|
|
536
|
+
return self._query if self._query else Query(self.__class__)
|
|
537
|
+
|
|
538
|
+
def as_query(self) -> Self:
|
|
346
539
|
"""
|
|
347
|
-
|
|
540
|
+
Make the model queryable.
|
|
541
|
+
|
|
542
|
+
This is used to remove the ambiguity of attempting execute a query against a model object that stores a record.
|
|
543
|
+
|
|
544
|
+
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
|
|
545
|
+
subtle bugs if a developer accidentally confuses the two usages. Consider the following (partial) example:
|
|
546
|
+
|
|
547
|
+
```python
|
|
548
|
+
def some_function(models):
|
|
549
|
+
model = models.find("id=5")
|
|
550
|
+
if model:
|
|
551
|
+
models.save({"test": "example"})
|
|
552
|
+
other_record = model.find("id=6")
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
In the above example it seems likely that the intention was to use `model.save()`, not `models.save()`. Similarly, the last line
|
|
556
|
+
should be `models.find()`, not `model.find()`. To minimize these kinds of issues, clearskies won't let you execute a query against
|
|
557
|
+
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
|
|
558
|
+
get an exception from clearskies, as the models track exactly how they are being used.
|
|
559
|
+
|
|
560
|
+
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
|
|
561
|
+
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
|
|
562
|
+
inject the model class more generally. That's where the `as_query()` method comes in. It's basically just a way of telling clearskies
|
|
563
|
+
"yes, I really do want to start a query using a model that represents a record". So, for example:
|
|
564
|
+
|
|
565
|
+
```python
|
|
566
|
+
def some_function(models):
|
|
567
|
+
model = models.find("id=5")
|
|
568
|
+
more_models = model.where("test=example") # throws an exception.
|
|
569
|
+
more_models = model.as_query().where("test=example") # works as expected.
|
|
570
|
+
```
|
|
348
571
|
"""
|
|
349
|
-
|
|
572
|
+
new_model = self._di.build(self.__class__, cache=False)
|
|
573
|
+
new_model.set_query(Query(self.__class__))
|
|
574
|
+
return new_model
|
|
575
|
+
|
|
576
|
+
def set_query(self, query: Query) -> Self:
|
|
577
|
+
"""Set the query object."""
|
|
578
|
+
self._query = query
|
|
579
|
+
self._query_executed = False
|
|
580
|
+
return self
|
|
350
581
|
|
|
351
|
-
def
|
|
582
|
+
def with_query(self, query: Query) -> Self:
|
|
583
|
+
return self._di.build(self.__class__, cache=False).set_query(query)
|
|
584
|
+
|
|
585
|
+
def select(self: Self, select: str) -> Self:
|
|
352
586
|
"""
|
|
353
|
-
|
|
587
|
+
Add some additional columns to the select part of the query.
|
|
588
|
+
|
|
589
|
+
This method returns a new object with the updated query. The original model object is unmodified.
|
|
590
|
+
Multiple calls to this method add together. The following:
|
|
591
|
+
|
|
592
|
+
```python
|
|
593
|
+
models.select("column_1 column_2").select("column_3")
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
will select column_1, column_2, column_3 in the final query.
|
|
354
597
|
"""
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
598
|
+
self.no_single_model()
|
|
599
|
+
return self.with_query(self.get_query().add_select(select))
|
|
600
|
+
|
|
601
|
+
def select_all(self: Self, select_all=True) -> Self:
|
|
602
|
+
"""
|
|
603
|
+
Set whether or not to select all columns with the query.
|
|
604
|
+
|
|
605
|
+
This method returns a new object with the updated query. The original model object is unmodified.
|
|
606
|
+
"""
|
|
607
|
+
self.no_single_model()
|
|
608
|
+
return self.with_query(self.get_query().set_select_all(select_all))
|
|
609
|
+
|
|
610
|
+
def where(self: Self, where: str | Condition) -> Self:
|
|
611
|
+
"""
|
|
612
|
+
Add a condition to a query.
|
|
613
|
+
|
|
614
|
+
The `where` method (in combination with the `find` method) is typically the starting point for query records in
|
|
615
|
+
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
|
|
616
|
+
common use case. Conditions in clearskies can be built from the columns or can be constructed as SQL-like
|
|
617
|
+
string conditions, e.g. `model.where("name=Bob")` or `model.where(model.name.equals("Bob"))`. The latter
|
|
618
|
+
provides strict type-checking, while the former does not. Either way they have the same result. The list of
|
|
619
|
+
supported operators for a given column can be seen by checking the `_allowed_search_operators` attribute of the
|
|
620
|
+
column class. Most columns accept all allowed operators, which are:
|
|
621
|
+
|
|
622
|
+
- "<=>"
|
|
623
|
+
- "!="
|
|
624
|
+
- "<="
|
|
625
|
+
- ">="
|
|
626
|
+
- ">"
|
|
627
|
+
- "<"
|
|
628
|
+
- "="
|
|
629
|
+
- "in"
|
|
630
|
+
- "is not null"
|
|
631
|
+
- "is null"
|
|
632
|
+
- "like"
|
|
633
|
+
|
|
634
|
+
When working with string conditions, it is safe to inject user input into the condition. The allowed
|
|
635
|
+
format for conditions is very simple: `f"{column_name}\\s?{operator}\\s?{value}"`. This makes it possible to
|
|
636
|
+
unambiguously separate all three pieces from eachother. It's not possible to inject malicious payloads into either
|
|
637
|
+
the column names or operators because both are checked against a strict allow list (e.g. the columns declared in the
|
|
638
|
+
model or the list of allowed operators above). The value is then extracted from the leftovers, and this is
|
|
639
|
+
provided to the backend separately so it can use it appropriately (e.g. using prepared statements for the cursor
|
|
640
|
+
backend). Of course, you generally shouldn't have to inject user input into conditions very often because, most
|
|
641
|
+
often, the various list/search endpoints do this for you, but if you have to do it there are no security
|
|
642
|
+
concerns.
|
|
643
|
+
|
|
644
|
+
You can include a table name before the column name, with the two separated by a period. As always, if you do this,
|
|
645
|
+
ensure that you include a supporting join statement (via the `join` method - see it for examples).
|
|
646
|
+
|
|
647
|
+
When you call the `where` method it returns a new model object with it's query configured to include the additional
|
|
648
|
+
condition. The original model object remains unchanged. Multiple conditions are always joined with AND. There is
|
|
649
|
+
no explicit option for OR. The closest is using an IN condition.
|
|
650
|
+
|
|
651
|
+
To access the results you have to iterate over the resulting model. If you are only expecting one result
|
|
652
|
+
and want to work directly with it, then you can use `model.find(condition)` or `model.where(condition).first()`.
|
|
653
|
+
|
|
654
|
+
Example:
|
|
655
|
+
```python
|
|
656
|
+
import clearskies
|
|
657
|
+
|
|
658
|
+
class Order(clearskies.Model):
|
|
659
|
+
id_column_name = "id"
|
|
660
|
+
backend = clearskies.backends.MemoryBackend()
|
|
661
|
+
|
|
662
|
+
id = clearskies.columns.Uuid()
|
|
663
|
+
user_id = clearskies.columns.String()
|
|
664
|
+
status = clearskies.columns.Select(["Pending", "In Progress"])
|
|
665
|
+
total = clearskies.columns.Float()
|
|
666
|
+
|
|
667
|
+
def my_application(orders):
|
|
668
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
|
|
669
|
+
orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
|
|
670
|
+
orders.create({"user_id": "Jane", "status": "Pending", "total": 30})
|
|
671
|
+
|
|
672
|
+
return [order.user_id for order in orders.where("status=Pending").where(Order.total.greater_than(25))]
|
|
673
|
+
|
|
674
|
+
cli = clearskies.contexts.Cli(
|
|
675
|
+
my_application,
|
|
676
|
+
classes=[Order],
|
|
677
|
+
)
|
|
678
|
+
cli()
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
Which, if ran, returns: `["Jane"]`
|
|
682
|
+
|
|
683
|
+
"""
|
|
684
|
+
self.no_single_model()
|
|
685
|
+
return self.with_query(self.get_query().add_where(where if isinstance(where, Condition) else Condition(where)))
|
|
686
|
+
|
|
687
|
+
def join(self: Self, join: str) -> Self:
|
|
688
|
+
"""
|
|
689
|
+
Add a join clause to the query.
|
|
690
|
+
|
|
691
|
+
As with the `where` method, this expects a string which is parsed accordingly. The syntax is not as flexible as
|
|
692
|
+
SQL and expects a format of:
|
|
693
|
+
|
|
694
|
+
```
|
|
695
|
+
[left|right|inner]? join [right_table_name] ON [right_table_name].[right_column_name]=[left_table_name].[left_column_name].
|
|
696
|
+
```
|
|
697
|
+
|
|
698
|
+
This is case insensitive. Aliases are allowed. If you don't specify a join type it defaults to inner.
|
|
699
|
+
Here are two examples of valid join statements:
|
|
700
|
+
|
|
701
|
+
- `join orders on orders.user_id=users.id`
|
|
702
|
+
- `left join user_orders as orders on orders.id=users.id`
|
|
703
|
+
|
|
704
|
+
Note that joins are not strictly limited to SQL-like backends, but of course no all backends will support joining.
|
|
705
|
+
|
|
706
|
+
A basic example:
|
|
707
|
+
|
|
708
|
+
```
|
|
709
|
+
import clearskies
|
|
710
|
+
|
|
711
|
+
class User(clearskies.Model):
|
|
712
|
+
id_column_name = "id"
|
|
713
|
+
backend = clearskies.backends.MemoryBackend()
|
|
714
|
+
|
|
715
|
+
id = clearskies.columns.Uuid()
|
|
716
|
+
name = clearskies.columns.String()
|
|
717
|
+
|
|
718
|
+
class Order(clearskies.Model):
|
|
719
|
+
id_column_name = "id"
|
|
720
|
+
backend = clearskies.backends.MemoryBackend()
|
|
721
|
+
|
|
722
|
+
id = clearskies.columns.Uuid()
|
|
723
|
+
user_id = clearskies.columns.BelongsToId(User, readable_parent_columns=["id", "name"])
|
|
724
|
+
user = clearskies.columns.BelongsToModel("user_id")
|
|
725
|
+
status = clearskies.columns.Select(["Pending", "In Progress"])
|
|
726
|
+
total = clearskies.columns.Float()
|
|
727
|
+
|
|
728
|
+
def my_application(users, orders):
|
|
729
|
+
jane = users.create({"name": "Jane"})
|
|
730
|
+
another_jane = users.create({"name": "Jane"})
|
|
731
|
+
bob = users.create({"name": "Bob"})
|
|
732
|
+
|
|
733
|
+
# Jane's orders
|
|
734
|
+
orders.create({"user_id": jane.id, "status": "Pending", "total": 25})
|
|
735
|
+
orders.create({"user_id": jane.id, "status": "Pending", "total": 30})
|
|
736
|
+
orders.create({"user_id": jane.id, "status": "In Progress", "total": 35})
|
|
737
|
+
|
|
738
|
+
# Another Jane's orders
|
|
739
|
+
orders.create({"user_id": another_jane.id, "status": "Pending", "total": 15})
|
|
740
|
+
|
|
741
|
+
# Bob's orders
|
|
742
|
+
orders.create({"user_id": bob.id, "status": "Pending", "total": 28})
|
|
743
|
+
orders.create({"user_id": bob.id, "status": "In Progress", "total": 35})
|
|
744
|
+
|
|
745
|
+
# return all orders for anyone named Jane that have a status of Pending
|
|
746
|
+
return orders.join("join users on users.id=orders.user_id").where("users.name=Jane").sort_by("total", "asc").where("status=Pending")
|
|
747
|
+
|
|
748
|
+
cli = clearskies.contexts.Cli(
|
|
749
|
+
clearskies.endpoints.Callable(
|
|
750
|
+
my_application,
|
|
751
|
+
model_class=Order,
|
|
752
|
+
readable_column_names=["user", "total"],
|
|
753
|
+
),
|
|
754
|
+
classes=[Order, User],
|
|
755
|
+
)
|
|
756
|
+
cli()
|
|
757
|
+
|
|
758
|
+
```
|
|
759
|
+
"""
|
|
760
|
+
self.no_single_model()
|
|
761
|
+
return self.with_query(self.get_query().add_join(Join(join)))
|
|
762
|
+
|
|
763
|
+
def is_joined(self: Self, table_name: str, alias: str = "") -> bool:
|
|
764
|
+
"""
|
|
765
|
+
Check if a given table was already joined.
|
|
766
|
+
|
|
767
|
+
If you provide an alias then it will also verify if the table was joined with the specific alias name.
|
|
768
|
+
"""
|
|
769
|
+
for join in self.get_query().joins:
|
|
770
|
+
if join.unaliased_table_name != table_name:
|
|
771
|
+
continue
|
|
772
|
+
|
|
773
|
+
if alias and join.alias != alias:
|
|
774
|
+
continue
|
|
775
|
+
|
|
776
|
+
return True
|
|
777
|
+
return False
|
|
778
|
+
|
|
779
|
+
def group_by(self: Self, group_by_column_name: str) -> Self:
|
|
780
|
+
"""Add a group by clause to the query."""
|
|
781
|
+
self.no_single_model()
|
|
782
|
+
return self.with_query(self.get_query().set_group_by(group_by_column_name))
|
|
783
|
+
|
|
784
|
+
def sort_by(
|
|
785
|
+
self: Self,
|
|
786
|
+
primary_column_name: str,
|
|
787
|
+
primary_direction: str,
|
|
788
|
+
primary_table_name: str = "",
|
|
789
|
+
secondary_column_name: str = "",
|
|
790
|
+
secondary_direction: str = "",
|
|
791
|
+
secondary_table_name: str = "",
|
|
792
|
+
) -> Self:
|
|
793
|
+
"""
|
|
794
|
+
Add a sort by clause to the query. You can sort by up to two columns at once.
|
|
795
|
+
|
|
796
|
+
Example:
|
|
797
|
+
```
|
|
798
|
+
import clearskies
|
|
799
|
+
|
|
800
|
+
class Order(clearskies.Model):
|
|
801
|
+
id_column_name = "id"
|
|
802
|
+
backend = clearskies.backends.MemoryBackend()
|
|
803
|
+
|
|
804
|
+
id = clearskies.columns.Uuid()
|
|
805
|
+
user_id = clearskies.columns.String()
|
|
806
|
+
status = clearskies.columns.Select(["Pending", "In Progress"])
|
|
807
|
+
total = clearskies.columns.Float()
|
|
808
|
+
|
|
809
|
+
def my_application(orders):
|
|
810
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
|
|
811
|
+
orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
|
|
812
|
+
orders.create({"user_id": "Alice", "status": "Pending", "total": 30})
|
|
813
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 26})
|
|
814
|
+
|
|
815
|
+
return orders.sort_by("user_id", "asc", secondary_column_name="total", secondary_direction="desc")
|
|
816
|
+
|
|
817
|
+
cli = clearskies.contexts.Cli(
|
|
818
|
+
clearskies.endpoints.Callable(
|
|
819
|
+
my_application,
|
|
820
|
+
model_class=Order,
|
|
821
|
+
readable_column_names=["user_id", "total"],
|
|
822
|
+
),
|
|
823
|
+
classes=[Order],
|
|
824
|
+
)
|
|
825
|
+
cli()
|
|
826
|
+
```
|
|
827
|
+
"""
|
|
828
|
+
self.no_single_model()
|
|
829
|
+
sort = Sort(primary_table_name, primary_column_name, primary_direction)
|
|
830
|
+
secondary_sort = None
|
|
831
|
+
if secondary_column_name and secondary_direction:
|
|
832
|
+
secondary_sort = Sort(secondary_table_name, secondary_column_name, secondary_direction)
|
|
833
|
+
return self.with_query(self.get_query().set_sort(sort, secondary_sort))
|
|
834
|
+
|
|
835
|
+
def limit(self: Self, limit: int) -> Self:
|
|
836
|
+
"""
|
|
837
|
+
Set the number of records to return.
|
|
838
|
+
|
|
839
|
+
```
|
|
840
|
+
import clearskies
|
|
841
|
+
|
|
842
|
+
class Order(clearskies.Model):
|
|
843
|
+
id_column_name = "id"
|
|
844
|
+
backend = clearskies.backends.MemoryBackend()
|
|
845
|
+
|
|
846
|
+
id = clearskies.columns.Uuid()
|
|
847
|
+
user_id = clearskies.columns.String()
|
|
848
|
+
status = clearskies.columns.Select(["Pending", "In Progress"])
|
|
849
|
+
total = clearskies.columns.Float()
|
|
850
|
+
|
|
851
|
+
def my_application(orders):
|
|
852
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
|
|
853
|
+
orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
|
|
854
|
+
orders.create({"user_id": "Alice", "status": "Pending", "total": 30})
|
|
855
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 26})
|
|
856
|
+
|
|
857
|
+
return orders.limit(2)
|
|
858
|
+
|
|
859
|
+
cli = clearskies.contexts.Cli(
|
|
860
|
+
clearskies.endpoints.Callable(
|
|
861
|
+
my_application,
|
|
862
|
+
model_class=Order,
|
|
863
|
+
readable_column_names=["user_id", "total"],
|
|
864
|
+
),
|
|
865
|
+
classes=[Order],
|
|
866
|
+
)
|
|
867
|
+
cli()
|
|
868
|
+
```
|
|
869
|
+
"""
|
|
870
|
+
self.no_single_model()
|
|
871
|
+
return self.with_query(self.get_query().set_limit(limit))
|
|
872
|
+
|
|
873
|
+
def pagination(self: Self, **pagination_data) -> Self:
|
|
874
|
+
"""
|
|
875
|
+
Set the pagination parameter(s) for the query.
|
|
876
|
+
|
|
877
|
+
The exact details of how pagination work depend on the backend. For instance, the cursor and memory backend
|
|
878
|
+
expect to be given a `start` parameter, while an API backend will vary with the API, and the dynamodb backend
|
|
879
|
+
expects a kwarg called `cursor`. As a result, it's necessary to check the backend documentation to understand
|
|
880
|
+
how to properly set pagination. The endpoints automatically account for this because backends are required
|
|
881
|
+
to declare pagination details via the `allowed_pagination_keys` method. If you attempt to set invalid
|
|
882
|
+
pagination data via this method, clearskies will raise a ValueError.
|
|
883
|
+
|
|
884
|
+
Example:
|
|
885
|
+
```
|
|
886
|
+
import clearskies
|
|
887
|
+
|
|
888
|
+
class Order(clearskies.Model):
|
|
889
|
+
id_column_name = "id"
|
|
890
|
+
backend = clearskies.backends.MemoryBackend()
|
|
891
|
+
|
|
892
|
+
id = clearskies.columns.Uuid()
|
|
893
|
+
user_id = clearskies.columns.String()
|
|
894
|
+
status = clearskies.columns.Select(["Pending", "In Progress"])
|
|
895
|
+
total = clearskies.columns.Float()
|
|
896
|
+
|
|
897
|
+
def my_application(orders):
|
|
898
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
|
|
899
|
+
orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
|
|
900
|
+
orders.create({"user_id": "Alice", "status": "Pending", "total": 30})
|
|
901
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 26})
|
|
902
|
+
|
|
903
|
+
return orders.sort_by("total", "asc").pagination(start=2)
|
|
904
|
+
|
|
905
|
+
cli = clearskies.contexts.Cli(
|
|
906
|
+
clearskies.endpoints.Callable(
|
|
907
|
+
my_application,
|
|
908
|
+
model_class=Order,
|
|
909
|
+
readable_column_names=["user_id", "total"],
|
|
910
|
+
),
|
|
911
|
+
classes=[Order],
|
|
912
|
+
)
|
|
913
|
+
cli()
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
However, if the return line in `my_application` is switched for either of these:
|
|
917
|
+
|
|
918
|
+
```
|
|
919
|
+
return orders.sort_by("total", "asc").pagination(start="asdf")
|
|
920
|
+
return orders.sort_by("total", "asc").pagination(something_else=5)
|
|
921
|
+
```
|
|
922
|
+
|
|
923
|
+
Will result in an exception that explains exactly what is wrong.
|
|
924
|
+
|
|
925
|
+
"""
|
|
926
|
+
self.no_single_model()
|
|
927
|
+
error = self.backend.validate_pagination_data(pagination_data, str)
|
|
928
|
+
if error:
|
|
929
|
+
raise ValueError(
|
|
930
|
+
f"Invalid pagination data for model {self.__class__.__name__} with backend "
|
|
931
|
+
+ f"{self.backend.__class__.__name__}. {error}"
|
|
932
|
+
)
|
|
933
|
+
return self.with_query(self.get_query().set_pagination(pagination_data))
|
|
934
|
+
|
|
935
|
+
def find(self: Self, where: str | Condition) -> Self:
|
|
936
|
+
"""
|
|
937
|
+
Return the first model matching a given where condition.
|
|
938
|
+
|
|
939
|
+
This is just shorthand for `models.where("column=value").find()`. Example:
|
|
940
|
+
|
|
941
|
+
```python
|
|
942
|
+
import clearskies
|
|
943
|
+
|
|
944
|
+
class Order(clearskies.Model):
|
|
945
|
+
id_column_name = "id"
|
|
946
|
+
backend = clearskies.backends.MemoryBackend()
|
|
947
|
+
|
|
948
|
+
id = clearskies.columns.Uuid()
|
|
949
|
+
user_id = clearskies.columns.String()
|
|
950
|
+
status = clearskies.columns.Select(["Pending", "In Progress"])
|
|
951
|
+
total = clearskies.columns.Float()
|
|
952
|
+
|
|
953
|
+
def my_application(orders):
|
|
954
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
|
|
955
|
+
orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
|
|
956
|
+
orders.create({"user_id": "Jane", "status": "Pending", "total": 30})
|
|
957
|
+
|
|
958
|
+
jane = orders.find("user_id=Jane")
|
|
959
|
+
jane.total = 35
|
|
960
|
+
jane.save()
|
|
961
|
+
|
|
962
|
+
return {
|
|
963
|
+
"user_id": jane.user_id,
|
|
964
|
+
"total": jane.total,
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
cli = clearskies.contexts.Cli(
|
|
968
|
+
my_application,
|
|
969
|
+
classes=[Order],
|
|
970
|
+
)
|
|
971
|
+
cli()
|
|
972
|
+
```
|
|
973
|
+
"""
|
|
974
|
+
self.no_single_model()
|
|
975
|
+
return self.where(where).first()
|
|
976
|
+
|
|
977
|
+
def __len__(self: Self): # noqa: D105
|
|
978
|
+
self.no_single_model()
|
|
979
|
+
if self._count is None:
|
|
980
|
+
self._count = self.backend.count(self.get_query())
|
|
981
|
+
return self._count
|
|
982
|
+
|
|
983
|
+
def __iter__(self: Self) -> Iterator[Self]: # noqa: D105
|
|
984
|
+
self.no_single_model()
|
|
985
|
+
self._next_page_data = {}
|
|
986
|
+
raw_rows = self.backend.records(
|
|
987
|
+
self.get_query(),
|
|
988
|
+
next_page_data=self._next_page_data,
|
|
989
|
+
)
|
|
990
|
+
return iter([self.model(row) for row in raw_rows])
|
|
991
|
+
|
|
992
|
+
def paginate_all(self: Self) -> list[Self]:
|
|
993
|
+
"""
|
|
994
|
+
Loop through all available pages of results and returns a list of all models that match the query.
|
|
995
|
+
|
|
996
|
+
If you don't set a limit on a query, some backends will return all records but some backends have a
|
|
997
|
+
default maximum number of results that they will return. In the latter case, you can use `paginate_all`
|
|
998
|
+
to fetch all records by instructing clearskies to iterate over all pages. This is possible because backends
|
|
999
|
+
are required to define how pagination works in a way that clearskies can automatically understand and
|
|
1000
|
+
use. To demonstrate this, the following example sets a limit of 1 which stops the memory backend
|
|
1001
|
+
from returning everything, and then uses `paginate_all` to fetch all records. The memory backend
|
|
1002
|
+
doesn't have a default limit, so in practice the `paginate_all` is unnecessary here, but this is done
|
|
1003
|
+
for demonstration purposes.
|
|
1004
|
+
|
|
1005
|
+
```
|
|
1006
|
+
import clearskies
|
|
1007
|
+
|
|
1008
|
+
class Order(clearskies.Model):
|
|
1009
|
+
id_column_name = "id"
|
|
1010
|
+
backend = clearskies.backends.MemoryBackend()
|
|
1011
|
+
|
|
1012
|
+
id = clearskies.columns.Uuid()
|
|
1013
|
+
user_id = clearskies.columns.String()
|
|
1014
|
+
status = clearskies.columns.Select(["Pending", "In Progress"])
|
|
1015
|
+
total = clearskies.columns.Float()
|
|
1016
|
+
|
|
1017
|
+
def my_application(orders):
|
|
1018
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
|
|
1019
|
+
orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
|
|
1020
|
+
orders.create({"user_id": "Alice", "status": "Pending", "total": 30})
|
|
1021
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 26})
|
|
1022
|
+
|
|
1023
|
+
return orders.limit(1).paginate_all()
|
|
1024
|
+
|
|
1025
|
+
cli = clearskies.contexts.Cli(
|
|
1026
|
+
clearskies.endpoints.Callable(
|
|
1027
|
+
my_application,
|
|
1028
|
+
model_class=Order,
|
|
1029
|
+
readable_column_names=["user_id", "total"],
|
|
1030
|
+
),
|
|
1031
|
+
classes=[Order],
|
|
1032
|
+
)
|
|
1033
|
+
cli()
|
|
1034
|
+
```
|
|
1035
|
+
|
|
1036
|
+
NOTE: this loads up all records in memory before returning (e.g. it isn't using generators yet), so
|
|
1037
|
+
expect delays for large record sets.
|
|
1038
|
+
"""
|
|
1039
|
+
self.no_single_model()
|
|
1040
|
+
next_models = self.with_query(self.get_query())
|
|
1041
|
+
results = list(next_models.__iter__())
|
|
1042
|
+
next_page_data = next_models.next_page_data()
|
|
1043
|
+
while next_page_data:
|
|
1044
|
+
next_models = self.pagination(**next_page_data)
|
|
1045
|
+
results.extend(next_models.__iter__())
|
|
1046
|
+
next_page_data = next_models.next_page_data()
|
|
1047
|
+
return results
|
|
1048
|
+
|
|
1049
|
+
def model(self: Self, data: dict[str, Any] = {}) -> Self:
|
|
1050
|
+
"""
|
|
1051
|
+
Create a new model object and populates it with the data in `data`.
|
|
1052
|
+
|
|
1053
|
+
NOTE: the difference between this and `model.create` is that model.create() actually saves a record in the backend,
|
|
1054
|
+
while this method just creates a model object populated with the given data.
|
|
1055
|
+
"""
|
|
1056
|
+
model = self._di.build(self.__class__, cache=False)
|
|
1057
|
+
model.set_raw_data(data)
|
|
1058
|
+
return model
|
|
1059
|
+
|
|
1060
|
+
def empty(self: Self) -> Self:
|
|
1061
|
+
"""
|
|
1062
|
+
An alias for self.model({})
|
|
1063
|
+
"""
|
|
1064
|
+
return self.model({})
|
|
1065
|
+
|
|
1066
|
+
def create(self: Self, data: dict[str, Any] = {}, columns: dict[str, Column] = {}, no_data=False) -> Self:
|
|
1067
|
+
"""
|
|
1068
|
+
Create a new record in the backend using the information in `data`.
|
|
1069
|
+
|
|
1070
|
+
new_model = models.create({"column": "value"})
|
|
1071
|
+
"""
|
|
1072
|
+
empty = self.model()
|
|
1073
|
+
empty.save(data, columns=columns, no_data=no_data)
|
|
1074
|
+
return empty
|
|
1075
|
+
|
|
1076
|
+
def first(self: Self) -> Self:
|
|
1077
|
+
"""
|
|
1078
|
+
Return the first model for a given query.
|
|
1079
|
+
|
|
1080
|
+
The `where` method returns an object meant to be iterated over. If you are expecting your query to return a single
|
|
1081
|
+
record, then you can use first to turn that directly into the matching model so you don't have to iterate over it:
|
|
1082
|
+
|
|
1083
|
+
```
|
|
1084
|
+
import clearskies
|
|
1085
|
+
|
|
1086
|
+
class Order(clearskies.Model):
|
|
1087
|
+
id_column_name = "id"
|
|
1088
|
+
backend = clearskies.backends.MemoryBackend()
|
|
1089
|
+
|
|
1090
|
+
id = clearskies.columns.Uuid()
|
|
1091
|
+
user_id = clearskies.columns.String()
|
|
1092
|
+
status = clearskies.columns.Select(["Pending", "In Progress"])
|
|
1093
|
+
total = clearskies.columns.Float()
|
|
1094
|
+
|
|
1095
|
+
def my_application(orders):
|
|
1096
|
+
orders.create({"user_id": "Bob", "status": "Pending", "total": 25})
|
|
1097
|
+
orders.create({"user_id": "Alice", "status": "In Progress", "total": 15})
|
|
1098
|
+
orders.create({"user_id": "Jane", "status": "Pending", "total": 30})
|
|
1099
|
+
|
|
1100
|
+
jane = orders.where("status=Pending").where(Order.total.greater_than(25)).first()
|
|
1101
|
+
jane.total = 35
|
|
1102
|
+
jane.save()
|
|
1103
|
+
|
|
1104
|
+
return {
|
|
1105
|
+
"user_id": jane.user_id,
|
|
1106
|
+
"total": jane.total,
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
cli = clearskies.contexts.Cli(
|
|
1110
|
+
my_application,
|
|
1111
|
+
classes=[Order],
|
|
1112
|
+
)
|
|
1113
|
+
cli()
|
|
1114
|
+
|
|
1115
|
+
```
|
|
1116
|
+
"""
|
|
1117
|
+
self.no_single_model()
|
|
1118
|
+
iter = self.__iter__()
|
|
1119
|
+
try:
|
|
1120
|
+
return iter.__next__()
|
|
1121
|
+
except StopIteration:
|
|
1122
|
+
return self.model()
|
|
1123
|
+
|
|
1124
|
+
def allowed_pagination_keys(self: Self) -> list[str]:
|
|
1125
|
+
return self.backend.allowed_pagination_keys()
|
|
1126
|
+
|
|
1127
|
+
def validate_pagination_data(self, kwargs: dict[str, Any], case_mapping: Callable[[str], str]) -> str:
|
|
1128
|
+
return self.backend.validate_pagination_data(kwargs, case_mapping)
|
|
1129
|
+
|
|
1130
|
+
def next_page_data(self: Self):
|
|
1131
|
+
return self._next_page_data
|
|
1132
|
+
|
|
1133
|
+
def documentation_pagination_next_page_response(self: Self, case_mapping: Callable) -> list[Any]:
|
|
1134
|
+
return self.backend.documentation_pagination_next_page_response(case_mapping)
|
|
1135
|
+
|
|
1136
|
+
def documentation_pagination_next_page_example(self: Self, case_mapping: Callable) -> dict[str, Any]:
|
|
1137
|
+
return self.backend.documentation_pagination_next_page_example(case_mapping)
|
|
1138
|
+
|
|
1139
|
+
def documentation_pagination_parameters(self: Self, case_mapping: Callable) -> list[tuple[AutoDocSchema, str]]:
|
|
1140
|
+
return self.backend.documentation_pagination_parameters(case_mapping)
|
|
1141
|
+
|
|
1142
|
+
def no_queries(self) -> None:
|
|
1143
|
+
if self._query:
|
|
1144
|
+
raise ValueError(
|
|
1145
|
+
"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."
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
def no_single_model(self):
|
|
1149
|
+
if self._data:
|
|
1150
|
+
raise ValueError(
|
|
1151
|
+
"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."
|
|
1152
|
+
)
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
class ModelClassReference:
|
|
1156
|
+
@abstractmethod
|
|
1157
|
+
def get_model_class(self) -> type[Model]:
|
|
1158
|
+
pass
|