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