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