clear-skies 1.22.31__py3-none-any.whl → 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of clear-skies might be problematic. Click here for more details.
- {clear_skies-1.22.31.dist-info → clear_skies-2.0.0.dist-info}/METADATA +11 -13
- clear_skies-2.0.0.dist-info/RECORD +248 -0
- {clear_skies-1.22.31.dist-info → clear_skies-2.0.0.dist-info}/WHEEL +1 -1
- clearskies/__init__.py +42 -25
- clearskies/action.py +7 -0
- clearskies/authentication/__init__.py +8 -41
- clearskies/authentication/authentication.py +42 -0
- clearskies/authentication/authorization.py +4 -9
- clearskies/authentication/authorization_pass_through.py +11 -9
- clearskies/authentication/jwks.py +128 -58
- clearskies/authentication/public.py +3 -38
- clearskies/authentication/secret_bearer.py +516 -54
- clearskies/autodoc/formats/oai3_json/__init__.py +1 -1
- clearskies/autodoc/formats/oai3_json/oai3_json.py +9 -7
- clearskies/autodoc/formats/oai3_json/parameter.py +6 -3
- clearskies/autodoc/formats/oai3_json/request.py +7 -5
- clearskies/autodoc/formats/oai3_json/response.py +7 -4
- clearskies/autodoc/formats/oai3_json/schema/object.py +4 -1
- clearskies/autodoc/request/__init__.py +2 -0
- clearskies/autodoc/request/header.py +4 -6
- clearskies/autodoc/request/json_body.py +4 -6
- clearskies/autodoc/request/parameter.py +8 -0
- clearskies/autodoc/request/request.py +7 -4
- clearskies/autodoc/request/url_parameter.py +4 -6
- clearskies/autodoc/request/url_path.py +4 -6
- clearskies/autodoc/schema/__init__.py +4 -2
- clearskies/autodoc/schema/array.py +5 -6
- clearskies/autodoc/schema/boolean.py +4 -10
- clearskies/autodoc/schema/date.py +0 -3
- clearskies/autodoc/schema/datetime.py +1 -4
- clearskies/autodoc/schema/double.py +0 -3
- clearskies/autodoc/schema/enum.py +4 -2
- clearskies/autodoc/schema/integer.py +4 -9
- clearskies/autodoc/schema/long.py +0 -3
- clearskies/autodoc/schema/number.py +4 -9
- clearskies/autodoc/schema/object.py +5 -7
- clearskies/autodoc/schema/password.py +0 -3
- clearskies/autodoc/schema/schema.py +11 -0
- clearskies/autodoc/schema/string.py +4 -10
- clearskies/backends/__init__.py +55 -20
- clearskies/backends/api_backend.py +1100 -284
- clearskies/backends/backend.py +40 -84
- clearskies/backends/cursor_backend.py +236 -186
- clearskies/backends/memory_backend.py +519 -226
- clearskies/backends/secrets_backend.py +75 -31
- clearskies/column.py +1232 -0
- clearskies/columns/__init__.py +71 -0
- clearskies/columns/audit.py +205 -0
- clearskies/columns/belongs_to_id.py +483 -0
- clearskies/columns/belongs_to_model.py +128 -0
- clearskies/columns/belongs_to_self.py +105 -0
- clearskies/columns/boolean.py +109 -0
- clearskies/columns/category_tree.py +275 -0
- clearskies/columns/category_tree_ancestors.py +51 -0
- clearskies/columns/category_tree_children.py +127 -0
- clearskies/columns/category_tree_descendants.py +48 -0
- clearskies/columns/created.py +94 -0
- clearskies/columns/created_by_authorization_data.py +116 -0
- clearskies/columns/created_by_header.py +99 -0
- clearskies/columns/created_by_ip.py +92 -0
- clearskies/columns/created_by_routing_data.py +96 -0
- clearskies/columns/created_by_user_agent.py +92 -0
- clearskies/columns/date.py +230 -0
- clearskies/columns/datetime.py +278 -0
- clearskies/columns/email.py +76 -0
- clearskies/columns/float.py +149 -0
- clearskies/columns/has_many.py +505 -0
- clearskies/columns/has_many_self.py +56 -0
- clearskies/columns/has_one.py +14 -0
- clearskies/columns/integer.py +156 -0
- clearskies/columns/json.py +122 -0
- clearskies/columns/many_to_many_ids.py +333 -0
- clearskies/columns/many_to_many_ids_with_data.py +270 -0
- clearskies/columns/many_to_many_models.py +154 -0
- clearskies/columns/many_to_many_pivots.py +133 -0
- clearskies/columns/phone.py +158 -0
- clearskies/columns/select.py +91 -0
- clearskies/columns/string.py +98 -0
- clearskies/columns/timestamp.py +160 -0
- clearskies/columns/updated.py +110 -0
- clearskies/columns/uuid.py +86 -0
- clearskies/configs/README.md +105 -0
- clearskies/configs/__init__.py +159 -0
- clearskies/configs/actions.py +43 -0
- clearskies/configs/any.py +13 -0
- clearskies/configs/any_dict.py +22 -0
- clearskies/configs/any_dict_or_callable.py +23 -0
- clearskies/configs/authentication.py +23 -0
- clearskies/configs/authorization.py +23 -0
- clearskies/configs/boolean.py +16 -0
- clearskies/configs/boolean_or_callable.py +18 -0
- clearskies/configs/callable_config.py +18 -0
- clearskies/configs/columns.py +34 -0
- clearskies/configs/conditions.py +30 -0
- clearskies/configs/config.py +21 -0
- clearskies/configs/datetime.py +18 -0
- clearskies/configs/datetime_or_callable.py +19 -0
- clearskies/configs/endpoint.py +23 -0
- clearskies/configs/float.py +16 -0
- clearskies/configs/float_or_callable.py +18 -0
- clearskies/configs/integer.py +16 -0
- clearskies/configs/integer_or_callable.py +18 -0
- clearskies/configs/joins.py +30 -0
- clearskies/configs/list_any_dict.py +30 -0
- clearskies/configs/list_any_dict_or_callable.py +31 -0
- clearskies/configs/model_class.py +35 -0
- clearskies/configs/model_column.py +65 -0
- clearskies/configs/model_columns.py +56 -0
- clearskies/configs/model_destination_name.py +25 -0
- clearskies/configs/model_to_id_column.py +43 -0
- clearskies/configs/readable_model_column.py +9 -0
- clearskies/configs/readable_model_columns.py +9 -0
- clearskies/configs/schema.py +23 -0
- clearskies/configs/searchable_model_columns.py +9 -0
- clearskies/configs/security_headers.py +39 -0
- clearskies/configs/select.py +26 -0
- clearskies/configs/select_list.py +47 -0
- clearskies/configs/string.py +29 -0
- clearskies/configs/string_dict.py +32 -0
- clearskies/configs/string_list.py +32 -0
- clearskies/configs/string_list_or_callable.py +35 -0
- clearskies/configs/string_or_callable.py +18 -0
- clearskies/configs/timedelta.py +18 -0
- clearskies/configs/timezone.py +18 -0
- clearskies/configs/url.py +23 -0
- clearskies/configs/validators.py +45 -0
- clearskies/configs/writeable_model_column.py +9 -0
- clearskies/configs/writeable_model_columns.py +9 -0
- clearskies/configurable.py +76 -0
- clearskies/contexts/__init__.py +8 -8
- clearskies/contexts/cli.py +5 -42
- clearskies/contexts/context.py +78 -56
- clearskies/contexts/wsgi.py +13 -30
- clearskies/contexts/wsgi_ref.py +49 -0
- clearskies/di/__init__.py +10 -7
- clearskies/di/additional_config.py +115 -4
- clearskies/di/additional_config_auto_import.py +12 -0
- clearskies/di/di.py +742 -121
- clearskies/di/inject/__init__.py +23 -0
- clearskies/di/inject/by_class.py +21 -0
- clearskies/di/inject/by_name.py +18 -0
- clearskies/di/inject/di.py +13 -0
- clearskies/di/inject/environment.py +14 -0
- clearskies/di/inject/input_output.py +20 -0
- clearskies/di/inject/now.py +13 -0
- clearskies/di/inject/requests.py +13 -0
- clearskies/di/inject/secrets.py +14 -0
- clearskies/di/inject/utcnow.py +13 -0
- clearskies/di/inject/uuid.py +15 -0
- clearskies/di/injectable.py +29 -0
- clearskies/di/injectable_properties.py +131 -0
- clearskies/end.py +183 -0
- clearskies/endpoint.py +1309 -0
- clearskies/endpoint_group.py +297 -0
- clearskies/endpoints/__init__.py +23 -0
- clearskies/endpoints/advanced_search.py +526 -0
- clearskies/endpoints/callable.py +387 -0
- clearskies/endpoints/create.py +202 -0
- clearskies/endpoints/delete.py +139 -0
- clearskies/endpoints/get.py +275 -0
- clearskies/endpoints/health_check.py +181 -0
- clearskies/endpoints/list.py +573 -0
- clearskies/endpoints/restful_api.py +427 -0
- clearskies/endpoints/simple_search.py +286 -0
- clearskies/endpoints/update.py +190 -0
- clearskies/environment.py +5 -3
- clearskies/exceptions/__init__.py +17 -0
- clearskies/{handlers/exceptions/input_error.py → exceptions/input_errors.py} +1 -1
- clearskies/exceptions/moved_permanently.py +3 -0
- clearskies/exceptions/moved_temporarily.py +3 -0
- clearskies/exceptions/not_found.py +2 -0
- clearskies/functional/__init__.py +2 -2
- clearskies/functional/routing.py +92 -0
- clearskies/functional/string.py +19 -11
- clearskies/functional/validations.py +61 -9
- clearskies/input_outputs/__init__.py +9 -7
- clearskies/input_outputs/cli.py +130 -142
- clearskies/input_outputs/exceptions/__init__.py +1 -1
- clearskies/input_outputs/headers.py +45 -0
- clearskies/input_outputs/input_output.py +91 -122
- clearskies/input_outputs/programmatic.py +69 -0
- clearskies/input_outputs/wsgi.py +23 -38
- clearskies/model.py +489 -184
- clearskies/parameters_to_properties.py +31 -0
- clearskies/query/__init__.py +12 -0
- clearskies/query/condition.py +223 -0
- clearskies/query/join.py +136 -0
- clearskies/query/query.py +196 -0
- clearskies/query/sort.py +27 -0
- clearskies/schema.py +82 -0
- clearskies/secrets/__init__.py +3 -31
- clearskies/secrets/additional_configs/mysql_connection_dynamic_producer.py +15 -4
- clearskies/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +11 -5
- clearskies/secrets/akeyless.py +88 -147
- clearskies/secrets/secrets.py +8 -8
- clearskies/security_header.py +8 -0
- clearskies/security_headers/__init__.py +8 -8
- clearskies/security_headers/cache_control.py +47 -110
- clearskies/security_headers/cors.py +40 -95
- clearskies/security_headers/csp.py +76 -151
- clearskies/security_headers/hsts.py +14 -16
- clearskies/test_base.py +8 -0
- clearskies/typing.py +11 -0
- clearskies/validator.py +25 -0
- clearskies/validators/__init__.py +33 -0
- clearskies/validators/after_column.py +62 -0
- clearskies/validators/before_column.py +13 -0
- clearskies/validators/in_the_future.py +32 -0
- clearskies/validators/in_the_future_at_least.py +11 -0
- clearskies/validators/in_the_future_at_most.py +10 -0
- clearskies/validators/in_the_past.py +32 -0
- clearskies/validators/in_the_past_at_least.py +10 -0
- clearskies/validators/in_the_past_at_most.py +10 -0
- clearskies/validators/maximum_length.py +26 -0
- clearskies/validators/maximum_value.py +29 -0
- clearskies/validators/minimum_length.py +26 -0
- clearskies/validators/minimum_value.py +29 -0
- clearskies/validators/required.py +35 -0
- clearskies/validators/timedelta.py +59 -0
- clearskies/validators/unique.py +31 -0
- clear_skies-1.22.31.dist-info/RECORD +0 -214
- clearskies/application.py +0 -29
- clearskies/authentication/auth0_jwks.py +0 -118
- clearskies/authentication/auth_exception.py +0 -2
- clearskies/authentication/jwks_jwcrypto.py +0 -51
- clearskies/backends/api_get_only_backend.py +0 -48
- clearskies/backends/example_backend.py +0 -43
- clearskies/backends/file_backend.py +0 -48
- clearskies/backends/json_backend.py +0 -7
- clearskies/backends/restful_api_advanced_search_backend.py +0 -103
- clearskies/binding_config.py +0 -16
- clearskies/column_types/__init__.py +0 -203
- clearskies/column_types/audit.py +0 -249
- clearskies/column_types/belongs_to.py +0 -271
- clearskies/column_types/boolean.py +0 -60
- clearskies/column_types/category_tree.py +0 -304
- clearskies/column_types/column.py +0 -373
- clearskies/column_types/created.py +0 -26
- clearskies/column_types/created_by_authorization_data.py +0 -26
- clearskies/column_types/created_by_header.py +0 -24
- clearskies/column_types/created_by_ip.py +0 -17
- clearskies/column_types/created_by_routing_data.py +0 -25
- clearskies/column_types/created_by_user_agent.py +0 -17
- clearskies/column_types/created_micro.py +0 -26
- clearskies/column_types/datetime.py +0 -109
- clearskies/column_types/datetime_micro.py +0 -12
- clearskies/column_types/email.py +0 -18
- clearskies/column_types/float.py +0 -43
- clearskies/column_types/has_many.py +0 -179
- clearskies/column_types/has_one.py +0 -60
- clearskies/column_types/integer.py +0 -41
- clearskies/column_types/json.py +0 -25
- clearskies/column_types/many_to_many.py +0 -278
- clearskies/column_types/many_to_many_with_data.py +0 -162
- clearskies/column_types/phone.py +0 -48
- clearskies/column_types/select.py +0 -11
- clearskies/column_types/string.py +0 -24
- clearskies/column_types/timestamp.py +0 -73
- clearskies/column_types/updated.py +0 -26
- clearskies/column_types/updated_micro.py +0 -26
- clearskies/column_types/uuid.py +0 -25
- clearskies/columns.py +0 -123
- clearskies/condition_parser.py +0 -172
- clearskies/contexts/build_context.py +0 -54
- clearskies/contexts/convert_to_application.py +0 -190
- clearskies/contexts/extract_handler.py +0 -37
- clearskies/contexts/test.py +0 -94
- clearskies/decorators/__init__.py +0 -41
- clearskies/decorators/allow_non_json_bodies.py +0 -9
- clearskies/decorators/auth0_jwks.py +0 -22
- clearskies/decorators/authorization.py +0 -10
- clearskies/decorators/binding_classes.py +0 -9
- clearskies/decorators/binding_modules.py +0 -9
- clearskies/decorators/bindings.py +0 -9
- clearskies/decorators/create.py +0 -10
- clearskies/decorators/delete.py +0 -10
- clearskies/decorators/docs.py +0 -14
- clearskies/decorators/get.py +0 -10
- clearskies/decorators/jwks.py +0 -26
- clearskies/decorators/merge.py +0 -124
- clearskies/decorators/patch.py +0 -10
- clearskies/decorators/post.py +0 -10
- clearskies/decorators/public.py +0 -11
- clearskies/decorators/response_headers.py +0 -10
- clearskies/decorators/return_raw_response.py +0 -9
- clearskies/decorators/schema.py +0 -10
- clearskies/decorators/secret_bearer.py +0 -24
- clearskies/decorators/security_headers.py +0 -10
- clearskies/di/standard_dependencies.py +0 -151
- clearskies/handlers/__init__.py +0 -41
- clearskies/handlers/advanced_search.py +0 -271
- clearskies/handlers/base.py +0 -479
- clearskies/handlers/callable.py +0 -192
- clearskies/handlers/create.py +0 -35
- clearskies/handlers/crud_by_method.py +0 -18
- clearskies/handlers/database_connector.py +0 -32
- clearskies/handlers/delete.py +0 -61
- clearskies/handlers/exceptions/__init__.py +0 -5
- clearskies/handlers/exceptions/not_found.py +0 -3
- clearskies/handlers/get.py +0 -156
- clearskies/handlers/health_check.py +0 -59
- clearskies/handlers/input_processing.py +0 -79
- clearskies/handlers/list.py +0 -530
- clearskies/handlers/mygrations.py +0 -82
- clearskies/handlers/request_method_routing.py +0 -47
- clearskies/handlers/restful_api.py +0 -218
- clearskies/handlers/routing.py +0 -62
- clearskies/handlers/schema_helper.py +0 -128
- clearskies/handlers/simple_routing.py +0 -206
- clearskies/handlers/simple_routing_route.py +0 -197
- clearskies/handlers/simple_search.py +0 -136
- clearskies/handlers/update.py +0 -102
- clearskies/handlers/write.py +0 -193
- clearskies/input_requirements/__init__.py +0 -78
- clearskies/input_requirements/after.py +0 -36
- clearskies/input_requirements/before.py +0 -36
- clearskies/input_requirements/in_the_future_at_least.py +0 -19
- clearskies/input_requirements/in_the_future_at_most.py +0 -19
- clearskies/input_requirements/in_the_past_at_least.py +0 -19
- clearskies/input_requirements/in_the_past_at_most.py +0 -19
- clearskies/input_requirements/maximum_length.py +0 -19
- clearskies/input_requirements/maximum_value.py +0 -19
- clearskies/input_requirements/minimum_length.py +0 -22
- clearskies/input_requirements/minimum_value.py +0 -19
- clearskies/input_requirements/required.py +0 -23
- clearskies/input_requirements/requirement.py +0 -25
- clearskies/input_requirements/time_delta.py +0 -38
- clearskies/input_requirements/unique.py +0 -18
- clearskies/mocks/__init__.py +0 -7
- clearskies/mocks/input_output.py +0 -124
- clearskies/mocks/models.py +0 -142
- clearskies/models.py +0 -350
- clearskies/security_headers/base.py +0 -12
- clearskies/tests/simple_api/models/__init__.py +0 -2
- clearskies/tests/simple_api/models/status.py +0 -23
- clearskies/tests/simple_api/models/user.py +0 -21
- clearskies/tests/simple_api/users_api.py +0 -64
- {clear_skies-1.22.31.dist-info → clear_skies-2.0.0.dist-info}/LICENSE +0 -0
- /clearskies/{contexts/bash.py → autodoc/py.typed} +0 -0
- /clearskies/{handlers/exceptions → exceptions}/authentication.py +0 -0
- /clearskies/{handlers/exceptions → exceptions}/authorization.py +0 -0
- /clearskies/{handlers/exceptions → exceptions}/client_error.py +0 -0
- /clearskies/{tests/__init__.py → input_outputs/py.typed} +0 -0
- /clearskies/{tests/simple_api/__init__.py → py.typed} +0 -0
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
from .backend import Backend
|
|
2
|
-
from collections import OrderedDict
|
|
3
|
-
from functools import cmp_to_key
|
|
4
1
|
import inspect
|
|
5
|
-
from
|
|
6
|
-
from
|
|
7
|
-
|
|
2
|
+
from functools import cmp_to_key
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
|
|
5
|
+
import clearskies.model
|
|
6
|
+
import clearskies.query
|
|
7
|
+
from clearskies import functional
|
|
8
|
+
from clearskies.autodoc.schema import Integer as AutoDocInteger
|
|
9
|
+
from clearskies.autodoc.schema import Schema as AutoDocSchema
|
|
10
|
+
from clearskies.backends.backend import Backend
|
|
11
|
+
from clearskies.di import InjectableProperties, inject
|
|
8
12
|
|
|
9
13
|
|
|
10
14
|
class Null:
|
|
@@ -27,11 +31,27 @@ def gentle_float_conversion(value):
|
|
|
27
31
|
return value
|
|
28
32
|
|
|
29
33
|
|
|
30
|
-
def _sort(row_a, row_b, sorts):
|
|
34
|
+
def _sort(row_a: Any, row_b: Any, sorts: list[clearskies.query.Sort], default_table_name: str) -> int:
|
|
31
35
|
for sort in sorts:
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
# so, if we've done a join then the rows will have data from all joined tables via a dict of dicts.
|
|
37
|
+
# if there wasn't a join then we'll just have the data
|
|
38
|
+
if sort.table_name in row_a and isinstance(row_a[sort.table_name], dict):
|
|
39
|
+
sort_data_a = row_a[sort.table_name]
|
|
40
|
+
elif not sort.table_name and default_table_name in row_a and isinstance(row_a[default_table_name], dict):
|
|
41
|
+
sort_data_a = row_a[default_table_name]
|
|
42
|
+
else:
|
|
43
|
+
sort_data_a = row_a
|
|
44
|
+
|
|
45
|
+
if sort.table_name in row_b and isinstance(row_b[sort.table_name], dict):
|
|
46
|
+
sort_data_b = row_b[sort.table_name]
|
|
47
|
+
elif not sort.table_name and default_table_name in row_b and isinstance(row_b[default_table_name], dict):
|
|
48
|
+
sort_data_b = row_b[default_table_name]
|
|
49
|
+
else:
|
|
50
|
+
sort_data_b = row_b
|
|
51
|
+
|
|
52
|
+
reverse = 1 if sort.direction.lower() == "asc" else -1
|
|
53
|
+
value_a = sort_data_a[sort.column_name] if sort.column_name in sort_data_a else None
|
|
54
|
+
value_b = sort_data_b[sort.column_name] if sort.column_name in sort_data_b else None
|
|
35
55
|
if value_a == value_b:
|
|
36
56
|
continue
|
|
37
57
|
if value_a is None:
|
|
@@ -41,68 +61,82 @@ def _sort(row_a, row_b, sorts):
|
|
|
41
61
|
return reverse * (1 if value_a > value_b else -1)
|
|
42
62
|
return 0
|
|
43
63
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
|
|
65
|
+
def cheating_equals(column, values, null):
|
|
66
|
+
"""
|
|
67
|
+
Cheating because this solves a very specific problem that likely is a generic issue.
|
|
68
|
+
|
|
69
|
+
The memory backend has some matching failures because boolean columns stay boolean in the
|
|
70
|
+
memory store, but the incoming search values are not converted to boolean and tend to be
|
|
71
|
+
str(1) or str(0). The issue is that save data goes through the `to_backend` flow, but search
|
|
72
|
+
data doesn't. This doesn't matter most of the time because, in practice, the backend itself
|
|
73
|
+
often does its own type conversion, but it causes problems for the memory backend. I can't
|
|
74
|
+
decide if fixing this will cause more problems than it solves, so for now I'm just cheating
|
|
75
|
+
and putting in a hack for this specific use case :shame:.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
def inner(row):
|
|
79
|
+
backend_value = row[column] if column in row else null
|
|
80
|
+
if isinstance(backend_value, bool):
|
|
81
|
+
return backend_value == bool(values[0])
|
|
82
|
+
return str(backend_value) == str(values[0])
|
|
83
|
+
|
|
84
|
+
return inner
|
|
85
|
+
|
|
60
86
|
|
|
61
87
|
class MemoryTable:
|
|
62
|
-
_table_name =
|
|
63
|
-
_column_names =
|
|
64
|
-
_rows =
|
|
65
|
-
null =
|
|
66
|
-
_id_index =
|
|
67
|
-
id_column_name =
|
|
68
|
-
_next_id =
|
|
88
|
+
_table_name: str = ""
|
|
89
|
+
_column_names: list[str] = []
|
|
90
|
+
_rows: list[dict[str, Any] | None] = []
|
|
91
|
+
null: Null = Null()
|
|
92
|
+
_id_index: dict[int | str, int] = {}
|
|
93
|
+
id_column_name: str = ""
|
|
94
|
+
_next_id: int = 1
|
|
95
|
+
_model_class: type[clearskies.model.Model] = None # type: ignore
|
|
69
96
|
|
|
70
97
|
# here be dragons. This is not a 100% drop-in replacement for the equivalent SQL operators
|
|
71
98
|
# https://codereview.stackexchange.com/questions/259198/in-memory-table-filtering-in-python
|
|
72
99
|
_operator_lambda_builders = {
|
|
73
100
|
"<=>": lambda column, values, null: lambda row: row.get(column, null) == values[0],
|
|
74
101
|
"!=": lambda column, values, null: lambda row: row.get(column, null) != values[0],
|
|
75
|
-
"<=":
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
"
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
"
|
|
102
|
+
"<=": (
|
|
103
|
+
lambda column, values, null: lambda row: gentle_float_conversion(row.get(column, null))
|
|
104
|
+
<= gentle_float_conversion(values[0])
|
|
105
|
+
),
|
|
106
|
+
">=": (
|
|
107
|
+
lambda column, values, null: lambda row: gentle_float_conversion(row.get(column, null))
|
|
108
|
+
>= gentle_float_conversion(values[0])
|
|
109
|
+
),
|
|
110
|
+
">": (
|
|
111
|
+
lambda column, values, null: lambda row: gentle_float_conversion(row.get(column, null))
|
|
112
|
+
> gentle_float_conversion(values[0])
|
|
113
|
+
),
|
|
114
|
+
"<": (
|
|
115
|
+
lambda column, values, null: lambda row: gentle_float_conversion(row.get(column, null))
|
|
116
|
+
< gentle_float_conversion(values[0])
|
|
117
|
+
),
|
|
118
|
+
"=": cheating_equals,
|
|
84
119
|
"is not null": lambda column, values, null: lambda row: (column in row and row[column] is not None),
|
|
85
120
|
"is null": lambda column, values, null: lambda row: (column not in row or row[column] is None),
|
|
86
121
|
"is not": lambda column, values, null: lambda row: row.get(column, null) != values[0],
|
|
87
122
|
"is": lambda column, values, null: lambda row: row.get(column, null) == str(values[0]),
|
|
88
|
-
"like":
|
|
123
|
+
"like": lambda column, values, null: lambda row: row.get(column, null) == str(values[0]),
|
|
89
124
|
"in": lambda column, values, null: lambda row: row.get(column, null) in values,
|
|
90
125
|
}
|
|
91
126
|
|
|
92
|
-
def __init__(self, model):
|
|
93
|
-
self.null = Null()
|
|
94
|
-
self._column_names = []
|
|
127
|
+
def __init__(self, model_class: type[clearskies.model.Model]) -> None:
|
|
95
128
|
self._rows = []
|
|
96
129
|
self._id_index = {}
|
|
97
|
-
self.id_column_name =
|
|
130
|
+
self.id_column_name = model_class.id_column_name
|
|
98
131
|
self._next_id = 1
|
|
132
|
+
self._model_class = model_class
|
|
99
133
|
|
|
100
|
-
self._table_name =
|
|
101
|
-
self._column_names
|
|
134
|
+
self._table_name = model_class.destination_name()
|
|
135
|
+
self._column_names = list(model_class.get_columns().keys())
|
|
102
136
|
if self.id_column_name not in self._column_names:
|
|
103
137
|
self._column_names.append(self.id_column_name)
|
|
104
138
|
|
|
105
|
-
def update(self, id, data):
|
|
139
|
+
def update(self, id: int | str, data: dict[str, Any]) -> dict[str, Any]:
|
|
106
140
|
if id not in self._id_index:
|
|
107
141
|
raise ValueError(f"Attempt to update non-existent record with '{self.id_column_name}' of '{id}'")
|
|
108
142
|
index = self._id_index[id]
|
|
@@ -117,16 +151,16 @@ class MemoryTable:
|
|
|
117
151
|
f"Cannot update record: column '{column_name}' does not exist in table '{self._table_name}'"
|
|
118
152
|
)
|
|
119
153
|
self._rows[index] = {
|
|
120
|
-
**self._rows[index],
|
|
154
|
+
**self._rows[index], # type: ignore
|
|
121
155
|
**data,
|
|
122
156
|
}
|
|
123
|
-
return self._rows[index]
|
|
157
|
+
return self._rows[index] # type: ignore
|
|
124
158
|
|
|
125
|
-
def create(self, data):
|
|
159
|
+
def create(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
126
160
|
for column_name in data.keys():
|
|
127
161
|
if column_name not in self._column_names:
|
|
128
162
|
raise ValueError(
|
|
129
|
-
f"Cannot create record: column '{column_name}' does not exist
|
|
163
|
+
f"Cannot create record: column '{column_name}' does not exist for model '{self._model_class.__name__}'"
|
|
130
164
|
)
|
|
131
165
|
incoming_id = data.get(self.id_column_name)
|
|
132
166
|
if not incoming_id:
|
|
@@ -134,7 +168,7 @@ class MemoryTable:
|
|
|
134
168
|
data[self.id_column_name] = incoming_id
|
|
135
169
|
self._next_id += 1
|
|
136
170
|
try:
|
|
137
|
-
incoming_as_int = int(
|
|
171
|
+
incoming_as_int = int(incoming_id)
|
|
138
172
|
if incoming_as_int >= self._next_id:
|
|
139
173
|
self._next_id = incoming_as_int + 1
|
|
140
174
|
except:
|
|
@@ -159,166 +193,403 @@ class MemoryTable:
|
|
|
159
193
|
self._rows[index] = None
|
|
160
194
|
return True
|
|
161
195
|
|
|
162
|
-
def count(self,
|
|
163
|
-
return len(self.rows(
|
|
164
|
-
|
|
165
|
-
def rows(
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
196
|
+
def count(self, query: clearskies.query.Query):
|
|
197
|
+
return len(self.rows(query, query.conditions, filter_only=True))
|
|
198
|
+
|
|
199
|
+
def rows(
|
|
200
|
+
self,
|
|
201
|
+
query: clearskies.query.Query,
|
|
202
|
+
conditions: list[clearskies.query.Condition],
|
|
203
|
+
filter_only: bool = False,
|
|
204
|
+
next_page_data: dict[str, Any] | None = None,
|
|
205
|
+
):
|
|
206
|
+
rows = [row for row in self._rows if row is not None]
|
|
207
|
+
for condition in conditions:
|
|
208
|
+
rows = list(filter(self._condition_as_filter(condition), rows))
|
|
209
|
+
rows = [*rows]
|
|
170
210
|
if filter_only:
|
|
171
211
|
return rows
|
|
172
|
-
if
|
|
173
|
-
rows = sorted(
|
|
174
|
-
|
|
212
|
+
if query.sorts:
|
|
213
|
+
rows = sorted(
|
|
214
|
+
rows,
|
|
215
|
+
key=cmp_to_key(
|
|
216
|
+
lambda row_a, row_b: _sort(row_a, row_b, query.sorts, query.model_class.destination_name())
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
if query.limit or query.pagination.get("start"):
|
|
175
220
|
number_rows = len(rows)
|
|
176
|
-
start = int(
|
|
221
|
+
start = int(query.pagination.get("start", 0))
|
|
177
222
|
if not start:
|
|
178
223
|
start = 0
|
|
179
224
|
if int(start) >= number_rows:
|
|
180
225
|
start = number_rows - 1
|
|
181
226
|
end = len(rows)
|
|
182
|
-
if
|
|
183
|
-
end = start + int(
|
|
227
|
+
if query.limit and start + int(query.limit) <= number_rows:
|
|
228
|
+
end = start + int(query.limit)
|
|
184
229
|
if end < number_rows and type(next_page_data) == dict:
|
|
185
|
-
next_page_data["start"] = start +
|
|
230
|
+
next_page_data["start"] = start + int(query.limit)
|
|
186
231
|
rows = rows[start:end]
|
|
187
232
|
return rows
|
|
188
233
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
234
|
+
@classmethod
|
|
235
|
+
def _condition_as_filter(cls, condition: clearskies.query.Condition) -> Callable:
|
|
236
|
+
column = condition.column_name
|
|
237
|
+
values = condition.values
|
|
238
|
+
return cls._operator_lambda_builders[condition.operator.lower()](column, values, cls.null)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class MemoryBackend(Backend, InjectableProperties):
|
|
242
|
+
"""
|
|
243
|
+
Store data in an in-memory store built in to clearskies.
|
|
244
|
+
|
|
245
|
+
## Usage
|
|
193
246
|
|
|
247
|
+
Since the memory backend is built into clearskies, there's no configuration necessary to make it work:
|
|
248
|
+
simply attach it to any of your models and it will manage data for you. If you want though, you can declare
|
|
249
|
+
a binding named `memory_backend_default_data` which you fill with records for your models to pre-populate
|
|
250
|
+
the memory backend. This can be helpful for tests as well as tables with fixed values.
|
|
194
251
|
|
|
195
|
-
|
|
196
|
-
_tables = None
|
|
197
|
-
_silent_on_missing_tables = False
|
|
252
|
+
### Testing
|
|
198
253
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
254
|
+
A primary use case of the memory backend is for building unit tests of your code. You can use the dependency
|
|
255
|
+
injection system to override other backends with the memory backend. You can still operate with model classes
|
|
256
|
+
in the exact same way, so this can be an easy way to mock out databases/api endpoints/etc... Of course,
|
|
257
|
+
there can be behavioral differences between the memory backend and other backends, so this isn't always perfect.
|
|
258
|
+
Hence why this works well for unit tests, but can't replace all testing, especially integration tests or
|
|
259
|
+
end-to-end tests. Here's an example of that. Note that the UserPreference model uses the cursor backend,
|
|
260
|
+
but when you run this code it will actually end up with the memory backend, so the code will run even without
|
|
261
|
+
attempting to connect to a database.
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
import clearskies
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class UserPreference(clearskies.Model):
|
|
268
|
+
id_column_name = "id"
|
|
269
|
+
backend = clearskies.backends.CursorBackend()
|
|
270
|
+
id = clearskies.columns.Uuid()
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
cli = clearskies.contexts.Cli(
|
|
274
|
+
clearskies.endpoints.Callable(
|
|
275
|
+
lambda user_preferences: user_preferences.create(no_data=True).id,
|
|
276
|
+
),
|
|
277
|
+
classes=[UserPreference],
|
|
278
|
+
class_overrides={
|
|
279
|
+
clearskies.backends.CursorBackend: clearskies.backends.MemoryBackend(),
|
|
280
|
+
},
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
if __name__ == "__main__":
|
|
284
|
+
cli()
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Note that the model requests the CursorBackend, but then we switch that for the memory backend via the
|
|
288
|
+
`class_overrides` kwarg to `clearskies.contexts.Cli`. Therefore, the above code works regardless of
|
|
289
|
+
whether or not a database is running. Since the models are used to interact with the backend
|
|
290
|
+
(rather than using the cursor directly), the above code works the same despite the change in backend.
|
|
291
|
+
|
|
292
|
+
Again, this is helpful as a quick way to manage tests - swap out a database backend (or any other backend)
|
|
293
|
+
for the memory backend, and then pre-populate records to test your application logic. Obviously, this
|
|
294
|
+
won't cach backend-specific issues (e.g. forgetting to add a column to your database, mismatches between
|
|
295
|
+
column schema and backend schema, missing indexes, etc...), which is why this helps for unit tests
|
|
296
|
+
but not for integration tests.
|
|
297
|
+
|
|
298
|
+
### Production Usage
|
|
299
|
+
|
|
300
|
+
You can use the memory backend in production if you want, although there are some important caveats to keep
|
|
301
|
+
in mind:
|
|
302
|
+
|
|
303
|
+
1. There is limited attempts at performance optimization, so you should test it before putting it under
|
|
304
|
+
substantial loads.
|
|
305
|
+
2. There's no concept of replication. If you have multiple workers, then write operations won't be
|
|
306
|
+
persisted between them.
|
|
307
|
+
|
|
308
|
+
So, while there may be some cases where this is useful in production, it's by no means a replacement for
|
|
309
|
+
more typical in-memory data stores.
|
|
310
|
+
|
|
311
|
+
### Predefined Records
|
|
312
|
+
|
|
313
|
+
You can declare a binding named `memory_backend_default_data` to seed the memory backend with records. This
|
|
314
|
+
can be helpful in testing to setup your tests, and is occassionally helpful for keeping track of data in
|
|
315
|
+
fixed, read-only tables. Here's an example:
|
|
316
|
+
|
|
317
|
+
```python
|
|
318
|
+
import clearskies
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class Owner(clearskies.Model):
|
|
322
|
+
id_column_name = "id"
|
|
323
|
+
backend = clearskies.backends.MemoryBackend()
|
|
324
|
+
|
|
325
|
+
id = clearskies.columns.Uuid()
|
|
326
|
+
name = clearskies.columns.String()
|
|
327
|
+
phone = clearskies.columns.Phone()
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class Pet(clearskies.Model):
|
|
331
|
+
id_column_name = "id"
|
|
332
|
+
backend = clearskies.backends.MemoryBackend()
|
|
333
|
+
|
|
334
|
+
id = clearskies.columns.Uuid()
|
|
335
|
+
name = clearskies.columns.String()
|
|
336
|
+
species = clearskies.columns.String()
|
|
337
|
+
owner_id = clearskies.columns.BelongsToId(Owner, readable_parent_columns=["id", "name"])
|
|
338
|
+
owner = clearskies.columns.BelongsToModel("owner_id")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
wsgi = clearskies.contexts.WsgiRef(
|
|
342
|
+
clearskies.endpoints.List(
|
|
343
|
+
model_class=Pet,
|
|
344
|
+
readable_column_names=["id", "name", "species", "owner"],
|
|
345
|
+
sortable_column_names=["id", "name", "species"],
|
|
346
|
+
default_sort_column_name="name",
|
|
347
|
+
),
|
|
348
|
+
classes=[Owner, Pet],
|
|
349
|
+
bindings={
|
|
350
|
+
"memory_backend_default_data": [
|
|
351
|
+
{
|
|
352
|
+
"model_class": Owner,
|
|
353
|
+
"records": [
|
|
354
|
+
{"id": "1-2-3-4", "name": "John Doe", "phone": "555-123-4567"},
|
|
355
|
+
{"id": "5-6-7-8", "name": "Jane Doe", "phone": "555-321-0987"},
|
|
356
|
+
],
|
|
357
|
+
},
|
|
358
|
+
{
|
|
359
|
+
"model_class": Pet,
|
|
360
|
+
"records": [
|
|
361
|
+
{"id": "a-b-c-d", "name": "Fido", "species": "Dog", "owner_id": "1-2-3-4"},
|
|
362
|
+
{"id": "e-f-g-h", "name": "Spot", "species": "Dog", "owner_id": "1-2-3-4"},
|
|
363
|
+
{
|
|
364
|
+
"id": "i-j-k-l",
|
|
365
|
+
"name": "Puss in Boots",
|
|
366
|
+
"species": "Cat",
|
|
367
|
+
"owner_id": "5-6-7-8",
|
|
368
|
+
},
|
|
369
|
+
],
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
},
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if __name__ == "__main__":
|
|
376
|
+
wsgi()
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
And if you invoke it:
|
|
380
|
+
|
|
381
|
+
```bash
|
|
382
|
+
$ curl 'http://localhost:8080' | jq
|
|
383
|
+
{
|
|
384
|
+
"status": "success",
|
|
385
|
+
"error": "",
|
|
386
|
+
"data": [
|
|
387
|
+
{
|
|
388
|
+
"id": "a-b-c-d",
|
|
389
|
+
"name": "Fido",
|
|
390
|
+
"species": "Dog",
|
|
391
|
+
"owner": {
|
|
392
|
+
"id": "1-2-3-4",
|
|
393
|
+
"name": "John Doe"
|
|
394
|
+
}
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
"id": "i-j-k-l",
|
|
398
|
+
"name": "Puss in Boots",
|
|
399
|
+
"species": "Cat",
|
|
400
|
+
"owner": {
|
|
401
|
+
"id": "5-6-7-8",
|
|
402
|
+
"name": "Jane Doe"
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
{
|
|
406
|
+
"id": "e-f-g-h",
|
|
407
|
+
"name": "Spot",
|
|
408
|
+
"species": "Dog",
|
|
409
|
+
"owner": {
|
|
410
|
+
"id": "1-2-3-4",
|
|
411
|
+
"name": "John Doe"
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
],
|
|
415
|
+
"pagination": {
|
|
416
|
+
"number_results": 3,
|
|
417
|
+
"limit": 50,
|
|
418
|
+
"next_page": {}
|
|
419
|
+
},
|
|
420
|
+
"input_errors": {}
|
|
421
|
+
}
|
|
422
|
+
```
|
|
423
|
+
"""
|
|
210
424
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
]
|
|
425
|
+
default_data = inject.ByName("memory_backend_default_data")
|
|
426
|
+
default_data_loaded = False
|
|
427
|
+
_tables: dict[str, MemoryTable] = {}
|
|
428
|
+
_silent_on_missing_tables: bool = True
|
|
214
429
|
|
|
215
|
-
def __init__(self):
|
|
216
|
-
self._tables = {}
|
|
217
|
-
self._silent_on_missing_tables =
|
|
430
|
+
def __init__(self, silent_on_missing_tables=True):
|
|
431
|
+
self.__class__._tables = {}
|
|
432
|
+
self._silent_on_missing_tables = silent_on_missing_tables
|
|
433
|
+
|
|
434
|
+
@classmethod
|
|
435
|
+
def clear_table_cache(cls):
|
|
436
|
+
cls._tables = {}
|
|
437
|
+
|
|
438
|
+
def load_default_data(self):
|
|
439
|
+
if self.default_data_loaded:
|
|
440
|
+
return
|
|
441
|
+
self.default_data_loaded = True
|
|
442
|
+
if not isinstance(self.default_data, list):
|
|
443
|
+
raise TypeError(
|
|
444
|
+
f"'memory_backend_default_data' should be populated with a list, but I received a value of type '{self.default_data.__class__.__name__}'"
|
|
445
|
+
)
|
|
446
|
+
for index, table_data in enumerate(self.default_data):
|
|
447
|
+
if "model_class" not in table_data:
|
|
448
|
+
raise TypeError(
|
|
449
|
+
f"Each entry in the 'memory_backend_default_data' list should have a key named 'model_class', but entry #{index + 1} is missing this key."
|
|
450
|
+
)
|
|
451
|
+
model_class = table_data["model_class"]
|
|
452
|
+
if not functional.validations.is_model_class(table_data["model_class"]):
|
|
453
|
+
raise TypeError(
|
|
454
|
+
f"The 'model_class' key in 'memory_backend_default_data' for entry #{index + 1} is not a model class."
|
|
455
|
+
)
|
|
456
|
+
if "records" not in table_data:
|
|
457
|
+
raise TypeError(
|
|
458
|
+
f"Each entry in the 'memory_backend_default_data' list should have a key named 'records', but entry #{index + 1} is missing this key."
|
|
459
|
+
)
|
|
460
|
+
records = table_data["records"]
|
|
461
|
+
if not isinstance(records, list):
|
|
462
|
+
raise TypeError(
|
|
463
|
+
f"The 'records' key in 'memory_backend_default_data' for entry #{index + 1} was not a list."
|
|
464
|
+
)
|
|
465
|
+
self.create_table(model_class)
|
|
466
|
+
for record in records:
|
|
467
|
+
self.create_with_model_class(record, model_class)
|
|
218
468
|
|
|
219
469
|
def silent_on_missing_tables(self, silent=True):
|
|
220
470
|
self._silent_on_missing_tables = silent
|
|
221
471
|
|
|
222
|
-
def
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
"""
|
|
227
|
-
Accepts either a model or a model class and creates a "table" for it
|
|
228
|
-
"""
|
|
229
|
-
model = self.cheez_model(model)
|
|
230
|
-
if model.table_name() in self._tables:
|
|
472
|
+
def create_table(self, model_class: type[clearskies.model.Model]):
|
|
473
|
+
self.load_default_data()
|
|
474
|
+
table_name = model_class.destination_name()
|
|
475
|
+
if table_name in self.__class__._tables:
|
|
231
476
|
return
|
|
232
|
-
self._tables[
|
|
477
|
+
self.__class__._tables[table_name] = MemoryTable(model_class)
|
|
478
|
+
|
|
479
|
+
def has_table(self, model_class: type[clearskies.model.Model]) -> bool:
|
|
480
|
+
self.load_default_data()
|
|
481
|
+
table_name = model_class.destination_name()
|
|
482
|
+
return table_name in self.__class__._tables
|
|
483
|
+
|
|
484
|
+
def get_table(self, model_class: type[clearskies.model.Model], create_if_missing=False) -> MemoryTable:
|
|
485
|
+
table_name = model_class.destination_name()
|
|
486
|
+
if table_name not in self.__class__._tables:
|
|
487
|
+
if create_if_missing:
|
|
488
|
+
self.create_table(model_class)
|
|
489
|
+
else:
|
|
490
|
+
raise ValueError(
|
|
491
|
+
f"The memory backend was asked to work with the model '{model_class.__name__}' but this model hasn't been explicitly added to the memory backend. This typically means that you are querying for records in a model but haven't created any yet."
|
|
492
|
+
)
|
|
493
|
+
return self.__class__._tables[table_name]
|
|
494
|
+
|
|
495
|
+
def create_with_model_class(
|
|
496
|
+
self, data: dict[str, Any], model_class: type[clearskies.model.Model]
|
|
497
|
+
) -> dict[str, Any]:
|
|
498
|
+
self.create_table(model_class)
|
|
499
|
+
return self.get_table(model_class).create(data)
|
|
233
500
|
|
|
234
|
-
def update(self, id, data, model):
|
|
235
|
-
self.create_table(model)
|
|
236
|
-
return self.
|
|
501
|
+
def update(self, id: int | str, data: dict[str, Any], model: clearskies.model.Model) -> dict[str, Any]:
|
|
502
|
+
self.create_table(model.__class__)
|
|
503
|
+
return self.get_table(model.__class__).update(id, data)
|
|
237
504
|
|
|
238
|
-
def create(self, data, model):
|
|
239
|
-
self.create_table(model)
|
|
240
|
-
return self.
|
|
505
|
+
def create(self, data: dict[str, Any], model: clearskies.model.Model) -> dict[str, Any]:
|
|
506
|
+
self.create_table(model.__class__)
|
|
507
|
+
return self.get_table(model.__class__).create(data)
|
|
241
508
|
|
|
242
|
-
def delete(self, id, model):
|
|
243
|
-
self.create_table(model)
|
|
244
|
-
return self.
|
|
509
|
+
def delete(self, id: int | str, model: clearskies.model.Model) -> bool:
|
|
510
|
+
self.create_table(model.__class__)
|
|
511
|
+
return self.get_table(model.__class__).delete(id)
|
|
245
512
|
|
|
246
|
-
def count(self,
|
|
247
|
-
|
|
513
|
+
def count(self, query: clearskies.query.Query) -> int:
|
|
514
|
+
self.check_query(query)
|
|
515
|
+
if not self.has_table(query.model_class):
|
|
248
516
|
if self._silent_on_missing_tables:
|
|
249
517
|
return 0
|
|
250
518
|
|
|
251
519
|
raise ValueError(
|
|
252
|
-
f"Attempt to count records
|
|
520
|
+
f"Attempt to count records for model '{query.model_class.__name__}' that hasn't yet been loaded into the MemoryBackend"
|
|
253
521
|
)
|
|
254
522
|
|
|
255
523
|
# this is easy if we have no joins, so just return early so I don't have to think about it
|
|
256
|
-
if
|
|
257
|
-
|
|
258
|
-
return self._tables[configuration["table_name"]].count(configuration, wheres)
|
|
524
|
+
if not query.joins:
|
|
525
|
+
return self.get_table(query.model_class).count(query)
|
|
259
526
|
|
|
260
527
|
# we can ignore left joins when counting
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
528
|
+
query.joins = [join for join in query.joins if join.join_type != "LEFT"]
|
|
529
|
+
return len(self.rows_with_joins(query))
|
|
530
|
+
|
|
531
|
+
def records(
|
|
532
|
+
self, query: clearskies.query.Query, next_page_data: dict[str, str | int] | None = None
|
|
533
|
+
) -> list[dict[str, Any]]:
|
|
534
|
+
self.check_query(query)
|
|
535
|
+
if not self.has_table(query.model_class):
|
|
268
536
|
if self._silent_on_missing_tables:
|
|
269
537
|
return []
|
|
270
538
|
|
|
271
539
|
raise ValueError(
|
|
272
|
-
f"Attempt to fetch records
|
|
540
|
+
f"Attempt to fetch records for model '{query.model_class.__name__} that hasn't yet been loaded into the MemoryBackend"
|
|
273
541
|
)
|
|
274
542
|
|
|
275
543
|
# this is easy if we have no joins, so just return early so I don't have to think about it
|
|
276
|
-
if
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
544
|
+
if not query.joins:
|
|
545
|
+
return self.get_table(query.model_class).rows(query, query.conditions, next_page_data=next_page_data)
|
|
546
|
+
rows = self.rows_with_joins(query)
|
|
547
|
+
|
|
548
|
+
if query.sorts:
|
|
549
|
+
default_table_name = query.model_class.destination_name()
|
|
550
|
+
rows = sorted(
|
|
551
|
+
rows, key=cmp_to_key(lambda row_a, row_b: _sort(row_a, row_b, query.sorts, default_table_name))
|
|
552
|
+
)
|
|
281
553
|
|
|
282
554
|
# currently we don't do much with selects, so just limit results down to the data from the original
|
|
283
555
|
# table.
|
|
284
|
-
rows = [row[
|
|
556
|
+
rows = [row[query.model_class.destination_name()] for row in rows]
|
|
285
557
|
|
|
286
|
-
if "
|
|
287
|
-
rows = sorted(rows, key=cmp_to_key(lambda row_a, row_b: _sort(row_a, row_b, configuration["sorts"])))
|
|
288
|
-
if "start" in configuration.get("pagination", {}) or "limit" in configuration:
|
|
558
|
+
if "start" in query.pagination or query.limit:
|
|
289
559
|
number_rows = len(rows)
|
|
290
|
-
start =
|
|
560
|
+
start = query.pagination.get("start", 0)
|
|
291
561
|
if start >= number_rows:
|
|
292
562
|
start = number_rows - 1
|
|
293
563
|
end = len(rows)
|
|
294
|
-
if
|
|
295
|
-
end = start +
|
|
564
|
+
if query.limit and start + query.limit <= number_rows:
|
|
565
|
+
end = start + query.limit
|
|
296
566
|
rows = rows[start:end]
|
|
297
567
|
if end < number_rows and type(next_page_data) == dict:
|
|
298
|
-
next_page_data["start"] = start +
|
|
568
|
+
next_page_data["start"] = start + query.limit
|
|
299
569
|
return rows
|
|
300
570
|
|
|
301
|
-
def rows_with_joins(self,
|
|
302
|
-
joins =
|
|
303
|
-
|
|
571
|
+
def rows_with_joins(self, query: clearskies.query.Query) -> list[dict[str, Any]]:
|
|
572
|
+
joins = [*query.joins]
|
|
573
|
+
conditions = [*query.conditions]
|
|
304
574
|
# quick sanity check
|
|
305
|
-
for join in
|
|
306
|
-
if join
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
575
|
+
for join in query.joins:
|
|
576
|
+
if join.unaliased_table_name not in self.__class__._tables:
|
|
577
|
+
if not self._silent_on_missing_tables:
|
|
578
|
+
raise ValueError(
|
|
579
|
+
f"Join '{join._raw_join}' refrences table '{join.unaliased_table_name}' which does not exist in MemoryBackend"
|
|
580
|
+
)
|
|
581
|
+
return []
|
|
310
582
|
|
|
311
583
|
# start with the matches in the main table
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
)
|
|
584
|
+
left_table_name = query.model_class.destination_name()
|
|
585
|
+
left_conditions = self.conditions_for_table(left_table_name, conditions, is_left=True)
|
|
586
|
+
main_rows = self.get_table(query.model_class).rows(query, left_conditions, filter_only=True)
|
|
316
587
|
# and now adjust the way data is stored in our rows list to support the joining process.
|
|
317
588
|
# we're going to go from something like: `[{row_1}, {row_2}]` to something like:
|
|
318
589
|
# [{table_1: table_1_row_1, table_2: table_2_row_1}, {table_1: table_1_row_2, table_2: table_2_row_2}]
|
|
319
590
|
# etc...
|
|
320
|
-
rows = [{
|
|
321
|
-
joined_tables = [
|
|
591
|
+
rows = [{left_table_name: row} for row in main_rows]
|
|
592
|
+
joined_tables = [left_table_name]
|
|
322
593
|
|
|
323
594
|
# and now work through our joins. The tricky part is order - we need to manage the joins in the
|
|
324
595
|
# proper order, but they may not be in the correcet order in our join list. I still don't feel like building
|
|
@@ -327,16 +598,14 @@ class MemoryBackend(Backend):
|
|
|
327
598
|
# complain (since the joins may not be a valid object graph)
|
|
328
599
|
for i in range(10):
|
|
329
600
|
for index, join in enumerate(joins):
|
|
330
|
-
|
|
331
|
-
alias = join
|
|
332
|
-
|
|
333
|
-
table_name_for_join = alias if alias else
|
|
334
|
-
if
|
|
601
|
+
left_table_name = join.left_table_name
|
|
602
|
+
alias = join.alias
|
|
603
|
+
right_table_name = join.right_table_name
|
|
604
|
+
table_name_for_join = alias if alias else right_table_name
|
|
605
|
+
if left_table_name not in joined_tables:
|
|
335
606
|
continue
|
|
336
607
|
|
|
337
|
-
join_rows = self._tables[
|
|
338
|
-
configuration, self._wheres_for_table(table_name_for_join, wheres, joined_tables), filter_only=True
|
|
339
|
-
)
|
|
608
|
+
join_rows = self.__class__._tables[join.unaliased_table_name].rows(query, [], filter_only=True)
|
|
340
609
|
|
|
341
610
|
rows = self.join_rows(rows, join_rows, join, joined_tables)
|
|
342
611
|
|
|
@@ -355,56 +624,67 @@ class MemoryBackend(Backend):
|
|
|
355
624
|
+ "joined itself. e.g.: SELECT * FROM users JOIN type ON type.id=categories.type_id"
|
|
356
625
|
)
|
|
357
626
|
|
|
627
|
+
# now apply any remaining conditions.
|
|
628
|
+
left_condition_ids = [id(condition) for condition in left_conditions]
|
|
629
|
+
for condition in [condition for condition in conditions if id(condition) not in left_condition_ids]:
|
|
630
|
+
condition_filter = MemoryTable._condition_as_filter(condition)
|
|
631
|
+
rows = list(
|
|
632
|
+
filter(lambda row: condition.table_name in row and condition_filter(row[condition.table_name]), rows)
|
|
633
|
+
)
|
|
634
|
+
|
|
358
635
|
return rows
|
|
359
636
|
|
|
360
|
-
def all_rows(self, table_name):
|
|
361
|
-
if table_name not in self._tables:
|
|
637
|
+
def all_rows(self, table_name: str) -> list[dict[str, Any]]:
|
|
638
|
+
if table_name not in self.__class__._tables:
|
|
362
639
|
if self._silent_on_missing_tables:
|
|
363
640
|
return []
|
|
364
641
|
|
|
365
642
|
raise ValueError(f"Cannot return rows for unknown table '{table_name}'")
|
|
366
|
-
return self._tables[table_name]._rows
|
|
367
|
-
|
|
368
|
-
def
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
if not key in configuration:
|
|
378
|
-
configuration[key] = [] if key[-1] == "s" else ""
|
|
379
|
-
if "pagination" not in configuration or "start" not in configuration["pagination"]:
|
|
380
|
-
configuration["pagination"] = {"start": 0}
|
|
381
|
-
return configuration
|
|
382
|
-
|
|
383
|
-
def _wheres_for_table(self, table_name, wheres, is_left=False):
|
|
643
|
+
return list(filter(None, self.__class__._tables[table_name]._rows))
|
|
644
|
+
|
|
645
|
+
def check_query(self, query: clearskies.query.Query) -> None:
|
|
646
|
+
if query.group_by:
|
|
647
|
+
raise KeyError(
|
|
648
|
+
f"MemoryBackend does not support group_by clauses in queries. You may be using the wrong backend."
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
def conditions_for_table(
|
|
652
|
+
self, table_name: str, conditions: list[clearskies.query.Condition], is_left=False
|
|
653
|
+
) -> list[clearskies.query.Condition]:
|
|
384
654
|
"""
|
|
385
|
-
|
|
655
|
+
Return only the conditions for the given table.
|
|
386
656
|
|
|
387
657
|
If you set is_left=True then it assumes this is the "default" table and so will also return conditions
|
|
388
658
|
without a table name.
|
|
389
659
|
"""
|
|
390
|
-
return [
|
|
660
|
+
return [
|
|
661
|
+
condition
|
|
662
|
+
for condition in conditions
|
|
663
|
+
if condition.table_name == table_name or (is_left and not condition.table_name)
|
|
664
|
+
]
|
|
391
665
|
|
|
392
|
-
def join_rows(
|
|
666
|
+
def join_rows(
|
|
667
|
+
self,
|
|
668
|
+
rows: list[dict[str, Any]],
|
|
669
|
+
join_rows: list[dict[str, Any]],
|
|
670
|
+
join: clearskies.query.Join,
|
|
671
|
+
joined_tables: list[str],
|
|
672
|
+
) -> list[dict[str, Any]]:
|
|
393
673
|
"""
|
|
394
|
-
|
|
674
|
+
Add the rows in `join_rows` in to the `rows` holder.
|
|
395
675
|
|
|
396
676
|
`rows` should be something like:
|
|
397
677
|
|
|
398
|
-
```
|
|
678
|
+
```python
|
|
399
679
|
[
|
|
400
680
|
{
|
|
401
|
-
|
|
402
|
-
|
|
681
|
+
"table_1": {"table_1_row_1"},
|
|
682
|
+
"table_2": {"table_2_row_1"},
|
|
403
683
|
},
|
|
404
684
|
{
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
}
|
|
685
|
+
"table_1": {"table_1_row_2"},
|
|
686
|
+
"table_2": {"table_2_row_2"},
|
|
687
|
+
},
|
|
408
688
|
]
|
|
409
689
|
```
|
|
410
690
|
|
|
@@ -414,53 +694,64 @@ class MemoryBackend(Backend):
|
|
|
414
694
|
|
|
415
695
|
which will then get merged into the rows variable properly (which it will return as a new list)
|
|
416
696
|
"""
|
|
417
|
-
join_table_name =
|
|
418
|
-
join_type =
|
|
697
|
+
join_table_name = join.alias if join.alias else join.right_table_name
|
|
698
|
+
join_type = join.join_type
|
|
699
|
+
|
|
700
|
+
#######
|
|
701
|
+
########
|
|
702
|
+
## our problem is here. When we join rows we can end up with multiple copies of the records from the left table because
|
|
703
|
+
# there can be more than one matching record in the right table. This isn't happening, and so we're not getting the
|
|
704
|
+
# proper results because the one record that is chosen to match with the left table doesn't meet the where condition
|
|
705
|
+
# that is applied at the very end. If we have multiple records that match, they all need to get retunred in the
|
|
706
|
+
# final list of rows here, so we can properly search everything.
|
|
419
707
|
|
|
420
708
|
# loop through each entry in rows, find a matching table in join_rows, and take action depending on join type
|
|
421
709
|
rows = [*rows]
|
|
422
|
-
matched_right_row_indexes =
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
710
|
+
matched_right_row_indexes = set()
|
|
711
|
+
left_table_name = join.left_table_name
|
|
712
|
+
left_column_name = join.left_column_name
|
|
713
|
+
# we're
|
|
714
|
+
for row_index in range(len(rows)):
|
|
715
|
+
row = rows[row_index]
|
|
426
716
|
matching_row = None
|
|
427
|
-
if
|
|
717
|
+
if left_table_name not in row:
|
|
428
718
|
raise ValueError("Attempted to check join data from unjoined table, which should not happen...")
|
|
429
719
|
left_value = (
|
|
430
|
-
row[
|
|
431
|
-
if (row[
|
|
720
|
+
row[left_table_name][left_column_name]
|
|
721
|
+
if (row[left_table_name] is not None and left_column_name in row[left_table_name])
|
|
432
722
|
else None
|
|
433
723
|
)
|
|
434
|
-
|
|
435
|
-
|
|
724
|
+
matching_rows = []
|
|
725
|
+
for join_index, join_row in enumerate(join_rows):
|
|
726
|
+
right_value = join_row[join.right_column_name] if join.right_column_name in join_row else None
|
|
436
727
|
# for now we are assuming the operator for the matching is `=`. This is mainly because
|
|
437
728
|
# our join parsing doesn't bother checking for the matching operator, because it is `=` in
|
|
438
729
|
# 99% of cases. We can always adjust down the line.
|
|
439
730
|
if (right_value is None and left_value is None) or (right_value == left_value):
|
|
440
|
-
|
|
441
|
-
matched_right_row_indexes.
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
# (even if it is None)
|
|
447
|
-
if join_type == "LEFT" or join_type == "OUTER":
|
|
448
|
-
rows[row_index][join_table_name] = matching_row
|
|
449
|
-
|
|
450
|
-
# for inner and right joins we delete the row if we don't have a match
|
|
451
|
-
elif join_type == "INNER" or join_type == "RIGHT":
|
|
452
|
-
if matching_row is not None:
|
|
731
|
+
matching_rows.append(join_row)
|
|
732
|
+
matched_right_row_indexes.add(right_value)
|
|
733
|
+
|
|
734
|
+
# if we have matching rows then join them in.
|
|
735
|
+
for index, matching_row in enumerate(matching_rows):
|
|
736
|
+
if not index:
|
|
453
737
|
rows[row_index][join_table_name] = matching_row
|
|
738
|
+
else:
|
|
739
|
+
rows.append({**row, **{join_table_name: matching_row}})
|
|
740
|
+
|
|
741
|
+
# if we don't have matching rows then remove them for an inner or right join
|
|
742
|
+
if not matching_rows:
|
|
743
|
+
if join_type == "LEFT" or join_type == "OUTER":
|
|
744
|
+
rows[row_index][join_table_name] = matching_row = None
|
|
454
745
|
else:
|
|
455
746
|
# we can't immediately delete the row because we're looping over the array it is in,
|
|
456
747
|
# so just mark it as None and remove it later
|
|
457
|
-
rows[row_index] = None
|
|
748
|
+
rows[row_index] = None # type: ignore
|
|
458
749
|
|
|
459
750
|
rows = [row for row in rows if row is not None]
|
|
460
751
|
|
|
461
752
|
# now for outer/right rows we add on any unmatched rows
|
|
462
753
|
if (join_type == "OUTER" or join_type == "RIGHT") and len(matched_right_row_indexes) < len(join_rows):
|
|
463
|
-
for join_index in set(
|
|
754
|
+
for join_index in set(range(len(join_rows))) - matched_right_row_indexes:
|
|
464
755
|
rows.append(
|
|
465
756
|
{
|
|
466
757
|
join_table_name: join_rows[join_index],
|
|
@@ -470,15 +761,15 @@ class MemoryBackend(Backend):
|
|
|
470
761
|
|
|
471
762
|
return rows
|
|
472
763
|
|
|
473
|
-
def
|
|
474
|
-
extra_keys = set(
|
|
764
|
+
def validate_pagination_data(self, data: dict[str, Any], case_mapping: Callable) -> str:
|
|
765
|
+
extra_keys = set(data.keys()) - set(self.allowed_pagination_keys())
|
|
475
766
|
if len(extra_keys):
|
|
476
767
|
key_name = case_mapping("start")
|
|
477
768
|
return "Invalid pagination key(s): '" + "','".join(extra_keys) + f"'. Only '{key_name}' is allowed"
|
|
478
|
-
if "start" not in
|
|
769
|
+
if "start" not in data:
|
|
479
770
|
key_name = case_mapping("start")
|
|
480
771
|
return f"You must specify '{key_name}' when setting pagination"
|
|
481
|
-
start =
|
|
772
|
+
start = data["start"]
|
|
482
773
|
try:
|
|
483
774
|
start = int(start)
|
|
484
775
|
except:
|
|
@@ -486,16 +777,18 @@ class MemoryBackend(Backend):
|
|
|
486
777
|
return f"Invalid pagination data: '{key_name}' must be a number"
|
|
487
778
|
return ""
|
|
488
779
|
|
|
489
|
-
def allowed_pagination_keys(self) ->
|
|
780
|
+
def allowed_pagination_keys(self) -> list[str]:
|
|
490
781
|
return ["start"]
|
|
491
782
|
|
|
492
|
-
def documentation_pagination_next_page_response(self, case_mapping: Callable) ->
|
|
783
|
+
def documentation_pagination_next_page_response(self, case_mapping: Callable[[str], str]) -> list[Any]:
|
|
493
784
|
return [AutoDocInteger(case_mapping("start"), example=0)]
|
|
494
785
|
|
|
495
|
-
def documentation_pagination_next_page_example(self, case_mapping: Callable) ->
|
|
786
|
+
def documentation_pagination_next_page_example(self, case_mapping: Callable[[str], str]) -> dict[str, Any]:
|
|
496
787
|
return {case_mapping("start"): 0}
|
|
497
788
|
|
|
498
|
-
def documentation_pagination_parameters(
|
|
789
|
+
def documentation_pagination_parameters(
|
|
790
|
+
self, case_mapping: Callable[[str], str]
|
|
791
|
+
) -> list[tuple[AutoDocSchema, str]]:
|
|
499
792
|
return [
|
|
500
793
|
(
|
|
501
794
|
AutoDocInteger(case_mapping("start"), example=0),
|