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
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Callable
|
|
5
|
+
|
|
6
|
+
from clearskies import loggable
|
|
7
|
+
from clearskies.autodoc.schema import Schema as AutoDocSchema
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from clearskies import Column, Model
|
|
11
|
+
from clearskies.query import Query
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Backend(ABC, loggable.Loggable):
|
|
15
|
+
"""
|
|
16
|
+
Connecting models to their data since 2020!.
|
|
17
|
+
|
|
18
|
+
The backend system acts as a flexible layer between models and their data sources. By changing the backend attached to a model,
|
|
19
|
+
you change where the model fetches and saves data. This might be a database, an in-memory data store, a dynamodb table,
|
|
20
|
+
an API, and more. This allows you to interact with a variety of data sources with the models acting as a standardized API.
|
|
21
|
+
Since endpoints also rely on the models for their functionality, this means that you can easily build API endpoints and
|
|
22
|
+
more for a variety of data sources with a minimal amount of code.
|
|
23
|
+
|
|
24
|
+
Of course, not all data sources support all functionality present in the model. Therefore, you do still need to have
|
|
25
|
+
a fair understanding of how your data sources work.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
supports_n_plus_one = False
|
|
29
|
+
can_count = True
|
|
30
|
+
|
|
31
|
+
@abstractmethod
|
|
32
|
+
def update(self, id: int | str, data: dict[str, Any], model: Model) -> dict[str, Any]:
|
|
33
|
+
"""Update the record with the given id with the information from the data dictionary."""
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
@abstractmethod
|
|
37
|
+
def create(self, data: dict[str, Any], model: Model) -> dict[str, Any]:
|
|
38
|
+
"""Create a record with the information from the data dictionary."""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
def delete(self, id: int | str, model: Model) -> bool:
|
|
43
|
+
"""Delete the record with the given id."""
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
def count(self, query: Query) -> int:
|
|
48
|
+
"""Return the number of records which match the given query configuration."""
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
def records(self, query: Query, next_page_data: dict[str, str | int] | None = None) -> list[dict[str, Any]]:
|
|
53
|
+
"""
|
|
54
|
+
Return a list of records that match the given query configuration.
|
|
55
|
+
|
|
56
|
+
next_page_data is used to return data to the caller. Pass in an empty dictionary, and it will be populated
|
|
57
|
+
with the data needed to return the next page of results. If it is still an empty dictionary when returned,
|
|
58
|
+
then there is no additional data.
|
|
59
|
+
"""
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
@abstractmethod
|
|
63
|
+
def validate_pagination_data(self, data: dict[str, Any], case_mapping: Callable[[str], str]) -> str:
|
|
64
|
+
"""
|
|
65
|
+
Check if the given dictionary is valid pagination data for the background.
|
|
66
|
+
|
|
67
|
+
Return a string with an error message, or an empty string if the data is valid
|
|
68
|
+
"""
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
@abstractmethod
|
|
72
|
+
def allowed_pagination_keys(self) -> list[str]:
|
|
73
|
+
"""
|
|
74
|
+
Return the list of allowed keys in the pagination kwargs for the backend.
|
|
75
|
+
|
|
76
|
+
It must always return keys in snake_case so that the auto casing system can
|
|
77
|
+
adjust on the front-end for consistency.
|
|
78
|
+
"""
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
def documentation_pagination_next_page_response(self, case_mapping: Callable) -> list[Any]:
|
|
83
|
+
"""
|
|
84
|
+
Return a list of autodoc schema objects.
|
|
85
|
+
|
|
86
|
+
It will describe the contents of the `next_page` dictionary
|
|
87
|
+
in the pagination section of the response
|
|
88
|
+
"""
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
@abstractmethod
|
|
92
|
+
def documentation_pagination_parameters(self, case_mapping: Callable) -> list[tuple[AutoDocSchema, str]]:
|
|
93
|
+
"""
|
|
94
|
+
Return a list of autodoc schema objects describing the allowed input keys to set pagination.
|
|
95
|
+
|
|
96
|
+
It should return a list of tuples, with each tuple corresponding to an input key.
|
|
97
|
+
The first element in the tuple should be the schema, and the second should be the description.
|
|
98
|
+
"""
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
@abstractmethod
|
|
102
|
+
def documentation_pagination_next_page_example(self, case_mapping: Callable) -> dict[str, Any]:
|
|
103
|
+
"""
|
|
104
|
+
Return an example for next page documentation.
|
|
105
|
+
|
|
106
|
+
Returns an example (as a simple dictionary) of what the next_page data in the pagination response
|
|
107
|
+
should look like
|
|
108
|
+
"""
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
def column_from_backend(self, column: Column, value: Any) -> Any:
|
|
112
|
+
"""
|
|
113
|
+
Manage transformations from the backend.
|
|
114
|
+
|
|
115
|
+
The idea with this (and `column_to_backend`) is that the transformations to
|
|
116
|
+
and from the backend are mostly determined by the column type - integer, string,
|
|
117
|
+
date, etc... However, there are cases where these are also backend specific: a datetime
|
|
118
|
+
column may be serialized different ways for different databases, a JSON column must be
|
|
119
|
+
serialized for a database but won't be serialized for an API call, etc... Therefore
|
|
120
|
+
we mostly just let the column handle this, but we want the backend to be in charge
|
|
121
|
+
in case it needs to make changes.
|
|
122
|
+
"""
|
|
123
|
+
return column.from_backend(value)
|
|
124
|
+
|
|
125
|
+
def column_to_backend(self, column: Column, backend_data: dict[str, Any]) -> dict[str, Any]:
|
|
126
|
+
"""
|
|
127
|
+
Manage transformations to the backend.
|
|
128
|
+
|
|
129
|
+
The idea with this (and `column_from_backend`) is that the transformations to
|
|
130
|
+
and from the backend are mostly determined by the column type - integer, string,
|
|
131
|
+
date, etc... However, there are cases where these are also backend specific: a datetime
|
|
132
|
+
column may be serialized different ways for different databases, a JSON column must be
|
|
133
|
+
serialized for a database but won't be serialized for an API call, etc... Therefore
|
|
134
|
+
we mostly just let the column handle this, but we want the backend to be in charge
|
|
135
|
+
in case it needs to make changes.
|
|
136
|
+
"""
|
|
137
|
+
return column.to_backend(backend_data)
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import TYPE_CHECKING, Any
|
|
5
|
+
|
|
6
|
+
from clearskies.autodoc.schema import Integer as AutoDocInteger
|
|
7
|
+
from clearskies.autodoc.schema import Schema as AutoDocSchema
|
|
8
|
+
from clearskies.backends.backend import Backend
|
|
9
|
+
from clearskies.di import InjectableProperties, inject
|
|
10
|
+
from clearskies.query import Condition, Query
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from clearskies import Model
|
|
14
|
+
from clearskies.di import Di
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class CursorBackend(Backend, InjectableProperties):
|
|
18
|
+
"""
|
|
19
|
+
The cursor backend connects your models to a MySQL or MariaDB database.
|
|
20
|
+
|
|
21
|
+
## Installing Dependencies
|
|
22
|
+
|
|
23
|
+
clearskies uses PyMySQL to manage the database connection and make queries. This is not installed by default,
|
|
24
|
+
but is a named extra that you can install when needed via:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install clear-skies[mysql]
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Connecting to your server
|
|
31
|
+
|
|
32
|
+
By default, database credentials are expected in environment variables:
|
|
33
|
+
|
|
34
|
+
| Name | Default | Value |
|
|
35
|
+
|-------------|---------|---------------------------------------------------------------|
|
|
36
|
+
| db_host | | The hostname where the database can be found |
|
|
37
|
+
| db_username | | The username to connect as |
|
|
38
|
+
| db_password | | The password to connect with |
|
|
39
|
+
| db_database | | The name of the database to use |
|
|
40
|
+
| db_port | 3306 | The network port to connect to |
|
|
41
|
+
| db_ssl_ca | | Path to a certificate to use: enables SSL over the connection |
|
|
42
|
+
|
|
43
|
+
However, you can fully control the credential provisioning process by declaring a dependency named `connection_details` and
|
|
44
|
+
setting it to a dictionary with the above keys, minus the `db_` prefix:
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
class ConnectionDetails(clearskies.di.AdditionalConfig):
|
|
48
|
+
provide_connection_details(self, secrets):
|
|
49
|
+
return {
|
|
50
|
+
"host": secrets.get("database_host"),
|
|
51
|
+
"username": secrets.get("db_username"),
|
|
52
|
+
"password": secrets.get("db_password"),
|
|
53
|
+
"database": secrets.get("db_database"),
|
|
54
|
+
"port": 3306,
|
|
55
|
+
"ssl_ca": "/path/to/ca",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
wsgi = clearskies.contexts.Wsgi(
|
|
59
|
+
some_application,
|
|
60
|
+
additional_configs=[ConnectionDetails()],
|
|
61
|
+
bindings={
|
|
62
|
+
"secrets": "" # some configuration here to point to your secret manager
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Similarly, some alternate credential provisioning schemes are built into clearskies. See the
|
|
68
|
+
clearskies.secrets.additional_configs module for those options.
|
|
69
|
+
|
|
70
|
+
## Connecting models to tables
|
|
71
|
+
|
|
72
|
+
The table name for your model comes from calling the `destination_name` class method of the model class. By
|
|
73
|
+
default, this takes the class name, converts it to snake case, and then pluralizes it. So, if you have a model
|
|
74
|
+
class named `UserPreference` then the cursor backend will look for a table called `user_preferences`. If this
|
|
75
|
+
isn't what you want, then you can simply override `destination_name` to return whatever table you want:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
class UserPreference(clearskies.Model):
|
|
79
|
+
@classmethod
|
|
80
|
+
def destination_name(cls):
|
|
81
|
+
return "some_other_table_name"
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Additionally, the cursor backend accepts an argument called `table_prefix` which, if provided, will be prefixed
|
|
85
|
+
to your table name. Finally, you can declare a dependency called `global_table_prefix` which will automatically
|
|
86
|
+
be added to every table name. In the following example, the table name will be `user_configuration_preferences`
|
|
87
|
+
due to:
|
|
88
|
+
|
|
89
|
+
1. The `destination_name` method sets the table name to `preferences`
|
|
90
|
+
2. The `table_prefix` argument to the CursorBackend constructor adds a prefix of `configuration_`
|
|
91
|
+
3. The `global_table_prefix` binding sets a prefix of `user_`, wihch goes before everything else.
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
import clearskies
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class UserPreference(clearskies.Model):
|
|
98
|
+
id_column_name = "id"
|
|
99
|
+
backend = clearskies.backends.CursorBackend(table_prefix="configuration_")
|
|
100
|
+
id = clearskies.columns.Uuid()
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def destination_name(cls):
|
|
104
|
+
return "preferences"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
cli = clearskies.contexts.Cli(
|
|
108
|
+
clearskies.endpoints.Callable(
|
|
109
|
+
lambda user_preferences: user_preferences.create(no_data=True).id,
|
|
110
|
+
),
|
|
111
|
+
classes=[UserPreference],
|
|
112
|
+
bindings={
|
|
113
|
+
"global_table_prefix": "user_",
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
supports_n_plus_one = True
|
|
121
|
+
global_table_prefix = inject.ByName("global_table_prefix")
|
|
122
|
+
di: Di = inject.Di() # type: ignore[assignment]
|
|
123
|
+
|
|
124
|
+
def __init__(self, cursor_dependency_name="cursor", table_prefix=""):
|
|
125
|
+
self.cursor_dependency_name = cursor_dependency_name
|
|
126
|
+
self.table_prefix = table_prefix
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def cursor(self):
|
|
130
|
+
"""
|
|
131
|
+
Lazily inject and return the database cursor instance.
|
|
132
|
+
|
|
133
|
+
Returns
|
|
134
|
+
-------
|
|
135
|
+
The cursor object used for executing database queries.
|
|
136
|
+
"""
|
|
137
|
+
if not hasattr(self, "_cursor"):
|
|
138
|
+
self._cursor = self.di.build(self.cursor_dependency_name)
|
|
139
|
+
return self._cursor
|
|
140
|
+
|
|
141
|
+
def _finalize_table_name(self, table_name):
|
|
142
|
+
self.logger.debug(f"Finalizing table name for: {table_name}")
|
|
143
|
+
table_name = f"{self.global_table_prefix}{self.table_prefix}{table_name}"
|
|
144
|
+
if "." not in table_name:
|
|
145
|
+
return f"{self.cursor.table_escape_character}{table_name}{self.cursor.table_escape_character}"
|
|
146
|
+
return (
|
|
147
|
+
self.cursor.table_escape_character
|
|
148
|
+
+ f"{self.cursor.table_escape_character}.{self.cursor.table_escape_character}".join(table_name.split("."))
|
|
149
|
+
+ self.cursor.table_escape_character
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def update(self, id: int | str, data: dict[str, Any], model: Model) -> dict[str, Any]:
|
|
153
|
+
query_parts = []
|
|
154
|
+
parameters = []
|
|
155
|
+
for key, val in data.items():
|
|
156
|
+
query_parts.append(self.cursor.column_equals_with_placeholder(key))
|
|
157
|
+
parameters.append(val)
|
|
158
|
+
updates = ", ".join(query_parts)
|
|
159
|
+
|
|
160
|
+
# update the record
|
|
161
|
+
table_name = self._finalize_table_name(model.destination_name())
|
|
162
|
+
id_equals = self.cursor.column_equals_with_placeholder(model.id_column_name)
|
|
163
|
+
self.cursor.execute(f"UPDATE {table_name} SET {updates} WHERE {id_equals}", tuple([*parameters, id]))
|
|
164
|
+
|
|
165
|
+
# and now query again to fetch the updated record.
|
|
166
|
+
return self.records(Query(model.__class__, conditions=[Condition(f"{model.id_column_name}={id}")]))[0]
|
|
167
|
+
|
|
168
|
+
def create(self, data: dict[str, Any], model: Model) -> dict[str, Any]:
|
|
169
|
+
escape = self.cursor.column_escape_character
|
|
170
|
+
columns = escape + f"{escape}, {escape}".join(data.keys()) + escape
|
|
171
|
+
placeholders = ", ".join([self.cursor.value_placeholder for i in range(len(data))])
|
|
172
|
+
|
|
173
|
+
table_name = self._finalize_table_name(model.destination_name())
|
|
174
|
+
self.cursor.execute(f"INSERT INTO {table_name} ({columns}) VALUES ({placeholders})", tuple(data.values()))
|
|
175
|
+
new_id = data.get(model.id_column_name)
|
|
176
|
+
if not new_id:
|
|
177
|
+
new_id = self.cursor.lastrowid
|
|
178
|
+
if not new_id:
|
|
179
|
+
raise ValueError("I can't figure out what the id is for a newly created record :(")
|
|
180
|
+
|
|
181
|
+
return self.records(Query(model.__class__, conditions=[Condition(f"{model.id_column_name}={new_id}")]))[0]
|
|
182
|
+
|
|
183
|
+
def delete(self, id: int | str, model: Model) -> bool:
|
|
184
|
+
table_name = self._finalize_table_name(model.destination_name())
|
|
185
|
+
id_equals = self.cursor.column_equals_with_placeholder(model.id_column_name)
|
|
186
|
+
self.cursor.execute(f"DELETE FROM {table_name} WHERE {id_equals}", (id,))
|
|
187
|
+
return True
|
|
188
|
+
|
|
189
|
+
def count(self, query: Query) -> int:
|
|
190
|
+
(sql, parameters) = self.as_count_sql(query)
|
|
191
|
+
self.cursor.execute(sql, parameters)
|
|
192
|
+
for row in self.cursor:
|
|
193
|
+
return row[0] if type(row) == tuple else row["count"]
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
def records(self, query: Query, next_page_data: dict[str, str | int] | None = None) -> list[dict[str, Any]]:
|
|
197
|
+
# I was going to get fancy and have this return an iterator, but since I'm going to load up
|
|
198
|
+
# everything into a list anyway, I may as well just return the list, right?
|
|
199
|
+
(sql, parameters) = self.as_sql(query)
|
|
200
|
+
self.cursor.execute(sql, parameters)
|
|
201
|
+
records = [row for row in self.cursor]
|
|
202
|
+
if type(next_page_data) == dict:
|
|
203
|
+
limit = query.limit
|
|
204
|
+
start = query.pagination.get("start", 0)
|
|
205
|
+
if limit and len(records) == limit:
|
|
206
|
+
next_page_data["start"] = int(start) + int(limit)
|
|
207
|
+
return records
|
|
208
|
+
|
|
209
|
+
def as_sql(self, query: Query) -> tuple[str, tuple[Any]]:
|
|
210
|
+
escape = self.cursor.column_escape_character
|
|
211
|
+
table_name = query.model_class.destination_name()
|
|
212
|
+
self.logger.debug(f"Generating SQL for table: {table_name} from model: {query.model_class.__name__}")
|
|
213
|
+
(wheres, parameters) = self.conditions_as_wheres_and_parameters(
|
|
214
|
+
query.conditions, query.model_class.destination_name()
|
|
215
|
+
)
|
|
216
|
+
select_parts = []
|
|
217
|
+
if query.select_all:
|
|
218
|
+
select_parts.append(self._finalize_table_name(table_name) + ".*")
|
|
219
|
+
if query.selects:
|
|
220
|
+
select_parts.extend(query.selects)
|
|
221
|
+
select = ", ".join(select_parts)
|
|
222
|
+
if query.joins:
|
|
223
|
+
joins = " " + " ".join([join._raw_join for join in query.joins])
|
|
224
|
+
else:
|
|
225
|
+
joins = ""
|
|
226
|
+
if query.sorts:
|
|
227
|
+
sort_parts = []
|
|
228
|
+
for sort in query.sorts:
|
|
229
|
+
prefix = self._finalize_table_name(sort.table_name) + "." if sort.table_name else ""
|
|
230
|
+
sort_parts.append(f"{prefix}{escape}{sort.column_name}{escape} {sort.direction}")
|
|
231
|
+
order_by = " ORDER BY " + ", ".join(sort_parts)
|
|
232
|
+
else:
|
|
233
|
+
order_by = ""
|
|
234
|
+
group_by = self.group_by_clause(query.group_by)
|
|
235
|
+
limit = ""
|
|
236
|
+
if query.limit:
|
|
237
|
+
start = 0
|
|
238
|
+
limit_size = int(query.limit)
|
|
239
|
+
if "start" in query.pagination:
|
|
240
|
+
start = int(query.pagination["start"])
|
|
241
|
+
limit = f" LIMIT {start}, {limit_size}"
|
|
242
|
+
|
|
243
|
+
table_name = self._finalize_table_name(table_name)
|
|
244
|
+
return (
|
|
245
|
+
f"SELECT {select} FROM {table_name}{joins}{wheres}{group_by}{order_by}{limit}".strip(),
|
|
246
|
+
parameters,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def as_count_sql(self, query: Query) -> tuple[str, tuple[Any]]:
|
|
250
|
+
escape = self.cursor.column_escape_character
|
|
251
|
+
# note that this won't work if we start including a HAVING clause
|
|
252
|
+
(wheres, parameters) = self.conditions_as_wheres_and_parameters(
|
|
253
|
+
query.conditions, query.model_class.destination_name()
|
|
254
|
+
)
|
|
255
|
+
# we also don't currently support parameters in the join clause - I'll probably need that though
|
|
256
|
+
if query.joins:
|
|
257
|
+
# We can ignore left joins because they don't change the count
|
|
258
|
+
join_sections = filter(lambda join: join.join_type != "LEFT", query.joins)
|
|
259
|
+
joins = " " + " ".join([join._raw_join for join in join_sections])
|
|
260
|
+
else:
|
|
261
|
+
joins = ""
|
|
262
|
+
table_name = self._finalize_table_name(query.model_class.destination_name())
|
|
263
|
+
if not query.group_by:
|
|
264
|
+
query_string = f"SELECT COUNT(*) AS count FROM {table_name}{joins}{wheres}"
|
|
265
|
+
else:
|
|
266
|
+
group_by = self.group_by_clause(query.group_by)
|
|
267
|
+
query_string = (
|
|
268
|
+
f"SELECT COUNT(*) AS count FROM (SELECT 1 FROM {table_name}{joins}{wheres}{group_by}) AS count_inner"
|
|
269
|
+
)
|
|
270
|
+
return (query_string, parameters)
|
|
271
|
+
|
|
272
|
+
def conditions_as_wheres_and_parameters(
|
|
273
|
+
self, conditions: list[Condition], default_table_name: str
|
|
274
|
+
) -> tuple[str, tuple[Any]]:
|
|
275
|
+
if not conditions:
|
|
276
|
+
return ("", ()) # type: ignore
|
|
277
|
+
|
|
278
|
+
parameters = []
|
|
279
|
+
where_parts = []
|
|
280
|
+
for condition in conditions:
|
|
281
|
+
parameters.extend(condition.values)
|
|
282
|
+
table = condition.table_name if condition.table_name else self._finalize_table_name(default_table_name)
|
|
283
|
+
column = condition.column_name
|
|
284
|
+
where_parts.append(
|
|
285
|
+
condition._with_placeholders(
|
|
286
|
+
f"{table}.{column}",
|
|
287
|
+
condition.operator,
|
|
288
|
+
condition.values,
|
|
289
|
+
escape=False,
|
|
290
|
+
placeholder=self.cursor.value_placeholder,
|
|
291
|
+
)
|
|
292
|
+
)
|
|
293
|
+
return (" WHERE " + " AND ".join(where_parts), tuple(parameters)) # type: ignore
|
|
294
|
+
|
|
295
|
+
def group_by_clause(self, group_by: str) -> str:
|
|
296
|
+
if not group_by:
|
|
297
|
+
return ""
|
|
298
|
+
escape = self.cursor.column_escape_character
|
|
299
|
+
if "." not in group_by:
|
|
300
|
+
return f" GROUP BY {escape}{group_by}{escape}"
|
|
301
|
+
parts = group_by.split(".", 1)
|
|
302
|
+
table = parts[0]
|
|
303
|
+
column = parts[1]
|
|
304
|
+
return f" GROUP BY {escape}{table}{escape}.{escape}{column}{escape}"
|
|
305
|
+
|
|
306
|
+
def validate_pagination_data(self, data: dict[str, Any], case_mapping: Callable) -> str:
|
|
307
|
+
extra_keys = set(data.keys()) - set(self.allowed_pagination_keys())
|
|
308
|
+
if len(extra_keys):
|
|
309
|
+
key_name = case_mapping("start")
|
|
310
|
+
return "Invalid pagination key(s): '" + "','".join(extra_keys) + f"'. Only '{key_name}' is allowed"
|
|
311
|
+
if "start" not in data:
|
|
312
|
+
key_name = case_mapping("start")
|
|
313
|
+
return f"You must specify '{key_name}' when setting pagination"
|
|
314
|
+
start = data["start"]
|
|
315
|
+
try:
|
|
316
|
+
start = int(start)
|
|
317
|
+
except:
|
|
318
|
+
key_name = case_mapping("start")
|
|
319
|
+
return f"Invalid pagination data: '{key_name}' must be a number"
|
|
320
|
+
return ""
|
|
321
|
+
|
|
322
|
+
def allowed_pagination_keys(self) -> list[str]:
|
|
323
|
+
return ["start"]
|
|
324
|
+
|
|
325
|
+
def documentation_pagination_next_page_response(self, case_mapping: Callable[[str], str]) -> list[Any]:
|
|
326
|
+
return [AutoDocInteger(case_mapping("start"), example=0)]
|
|
327
|
+
|
|
328
|
+
def documentation_pagination_next_page_example(self, case_mapping: Callable[[str], str]) -> dict[str, Any]:
|
|
329
|
+
return {case_mapping("start"): 0}
|
|
330
|
+
|
|
331
|
+
def documentation_pagination_parameters(
|
|
332
|
+
self, case_mapping: Callable[[str], str]
|
|
333
|
+
) -> list[tuple[AutoDocSchema, str]]:
|
|
334
|
+
return [
|
|
335
|
+
(
|
|
336
|
+
AutoDocInteger(case_mapping("start"), example=0),
|
|
337
|
+
"The zero-indexed record number to start listing results from",
|
|
338
|
+
)
|
|
339
|
+
]
|