clear-skies 1.22.31__py3-none-any.whl → 2.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of clear-skies might be problematic. Click here for more details.

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