clear-skies 1.22.10__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 (368) 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.22.10.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 +8 -39
  7. clearskies/authentication/authentication.py +44 -0
  8. clearskies/authentication/authorization.py +14 -8
  9. clearskies/authentication/authorization_pass_through.py +14 -10
  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 +55 -20
  41. clearskies/backends/api_backend.py +1118 -280
  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 +115 -4
  154. clearskies/di/additional_config_auto_import.py +12 -0
  155. clearskies/di/di.py +714 -125
  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 -160
  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 +1874 -193
  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.22.10.dist-info/METADATA +0 -47
  243. clear_skies-1.22.10.dist-info/RECORD +0 -213
  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 -51
  248. clearskies/backends/api_get_only_backend.py +0 -48
  249. clearskies/backends/example_backend.py +0 -43
  250. clearskies/backends/file_backend.py +0 -48
  251. clearskies/backends/json_backend.py +0 -7
  252. clearskies/backends/restful_api_advanced_search_backend.py +0 -103
  253. clearskies/binding_config.py +0 -16
  254. clearskies/column_types/__init__.py +0 -203
  255. clearskies/column_types/audit.py +0 -249
  256. clearskies/column_types/belongs_to.py +0 -271
  257. clearskies/column_types/boolean.py +0 -60
  258. clearskies/column_types/category_tree.py +0 -304
  259. clearskies/column_types/column.py +0 -373
  260. clearskies/column_types/created.py +0 -26
  261. clearskies/column_types/created_by_authorization_data.py +0 -26
  262. clearskies/column_types/created_by_header.py +0 -24
  263. clearskies/column_types/created_by_ip.py +0 -17
  264. clearskies/column_types/created_by_routing_data.py +0 -25
  265. clearskies/column_types/created_by_user_agent.py +0 -17
  266. clearskies/column_types/created_micro.py +0 -26
  267. clearskies/column_types/datetime.py +0 -109
  268. clearskies/column_types/datetime_micro.py +0 -13
  269. clearskies/column_types/email.py +0 -18
  270. clearskies/column_types/float.py +0 -43
  271. clearskies/column_types/has_many.py +0 -179
  272. clearskies/column_types/has_one.py +0 -58
  273. clearskies/column_types/integer.py +0 -41
  274. clearskies/column_types/json.py +0 -25
  275. clearskies/column_types/many_to_many.py +0 -278
  276. clearskies/column_types/many_to_many_with_data.py +0 -162
  277. clearskies/column_types/phone.py +0 -48
  278. clearskies/column_types/select.py +0 -11
  279. clearskies/column_types/string.py +0 -24
  280. clearskies/column_types/timestamp.py +0 -73
  281. clearskies/column_types/updated.py +0 -26
  282. clearskies/column_types/updated_micro.py +0 -26
  283. clearskies/column_types/uuid.py +0 -25
  284. clearskies/columns.py +0 -123
  285. clearskies/condition_parser.py +0 -172
  286. clearskies/contexts/build_context.py +0 -54
  287. clearskies/contexts/convert_to_application.py +0 -190
  288. clearskies/contexts/extract_handler.py +0 -37
  289. clearskies/contexts/test.py +0 -94
  290. clearskies/decorators/__init__.py +0 -39
  291. clearskies/decorators/auth0_jwks.py +0 -22
  292. clearskies/decorators/authorization.py +0 -10
  293. clearskies/decorators/binding_classes.py +0 -9
  294. clearskies/decorators/binding_modules.py +0 -9
  295. clearskies/decorators/bindings.py +0 -9
  296. clearskies/decorators/create.py +0 -10
  297. clearskies/decorators/delete.py +0 -10
  298. clearskies/decorators/docs.py +0 -14
  299. clearskies/decorators/get.py +0 -10
  300. clearskies/decorators/jwks.py +0 -26
  301. clearskies/decorators/merge.py +0 -124
  302. clearskies/decorators/patch.py +0 -10
  303. clearskies/decorators/post.py +0 -10
  304. clearskies/decorators/public.py +0 -11
  305. clearskies/decorators/response_headers.py +0 -10
  306. clearskies/decorators/return_raw_response.py +0 -9
  307. clearskies/decorators/schema.py +0 -10
  308. clearskies/decorators/secret_bearer.py +0 -24
  309. clearskies/decorators/security_headers.py +0 -10
  310. clearskies/di/standard_dependencies.py +0 -151
  311. clearskies/di/test_module/__init__.py +0 -6
  312. clearskies/di/test_module/another_module/__init__.py +0 -2
  313. clearskies/di/test_module/module_class.py +0 -5
  314. clearskies/handlers/__init__.py +0 -41
  315. clearskies/handlers/advanced_search.py +0 -271
  316. clearskies/handlers/base.py +0 -479
  317. clearskies/handlers/callable.py +0 -191
  318. clearskies/handlers/create.py +0 -35
  319. clearskies/handlers/crud_by_method.py +0 -18
  320. clearskies/handlers/database_connector.py +0 -32
  321. clearskies/handlers/delete.py +0 -61
  322. clearskies/handlers/exceptions/__init__.py +0 -5
  323. clearskies/handlers/exceptions/not_found.py +0 -3
  324. clearskies/handlers/get.py +0 -156
  325. clearskies/handlers/health_check.py +0 -59
  326. clearskies/handlers/input_processing.py +0 -79
  327. clearskies/handlers/list.py +0 -530
  328. clearskies/handlers/mygrations.py +0 -82
  329. clearskies/handlers/request_method_routing.py +0 -47
  330. clearskies/handlers/restful_api.py +0 -218
  331. clearskies/handlers/routing.py +0 -62
  332. clearskies/handlers/schema_helper.py +0 -128
  333. clearskies/handlers/simple_routing.py +0 -206
  334. clearskies/handlers/simple_routing_route.py +0 -192
  335. clearskies/handlers/simple_search.py +0 -136
  336. clearskies/handlers/update.py +0 -96
  337. clearskies/handlers/write.py +0 -193
  338. clearskies/input_requirements/__init__.py +0 -78
  339. clearskies/input_requirements/after.py +0 -36
  340. clearskies/input_requirements/before.py +0 -36
  341. clearskies/input_requirements/in_the_future_at_least.py +0 -19
  342. clearskies/input_requirements/in_the_future_at_most.py +0 -19
  343. clearskies/input_requirements/in_the_past_at_least.py +0 -19
  344. clearskies/input_requirements/in_the_past_at_most.py +0 -19
  345. clearskies/input_requirements/maximum_length.py +0 -19
  346. clearskies/input_requirements/maximum_value.py +0 -19
  347. clearskies/input_requirements/minimum_length.py +0 -22
  348. clearskies/input_requirements/minimum_value.py +0 -19
  349. clearskies/input_requirements/requirement.py +0 -25
  350. clearskies/input_requirements/time_delta.py +0 -38
  351. clearskies/input_requirements/unique.py +0 -18
  352. clearskies/mocks/__init__.py +0 -7
  353. clearskies/mocks/input_output.py +0 -124
  354. clearskies/mocks/models.py +0 -142
  355. clearskies/models.py +0 -350
  356. clearskies/security_headers/base.py +0 -12
  357. clearskies/tests/simple_api/models/__init__.py +0 -2
  358. clearskies/tests/simple_api/models/status.py +0 -23
  359. clearskies/tests/simple_api/models/user.py +0 -21
  360. clearskies/tests/simple_api/users_api.py +0 -64
  361. {clear_skies-1.22.10.dist-info → clear_skies-2.0.23.dist-info/licenses}/LICENSE +0 -0
  362. /clearskies/{contexts/bash.py → autodoc/py.typed} +0 -0
  363. /clearskies/{handlers/exceptions → exceptions}/authentication.py +0 -0
  364. /clearskies/{handlers/exceptions → exceptions}/authorization.py +0 -0
  365. /clearskies/{handlers/exceptions → exceptions}/client_error.py +0 -0
  366. /clearskies/{secrets/exceptions → exceptions}/not_found.py +0 -0
  367. /clearskies/{tests/__init__.py → input_outputs/py.typed} +0 -0
  368. /clearskies/{tests/simple_api/__init__.py → py.typed} +0 -0
clearskies/model.py CHANGED
@@ -1,42 +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
- try:
9
- from typing_extensions import Self
10
- except ModuleNotFoundError:
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
+ ```
11
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
+
12
105
 
106
+ cli = clearskies.contexts.Cli(my_application, classes=[User])
107
+ cli()
108
+ ```
13
109
 
14
- class Model(Models):
15
- _configured_columns = None
16
- _data = None
17
- _previous_data = None
18
- _touched_columns = None
19
- _transformed = None
20
- id_column_name = "id"
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:
21
115
 
22
- def __init__(self: Self, backend, columns):
23
- super().__init__(backend, columns)
24
- self._transformed = {}
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 = {}
25
226
  self._data = {}
26
- self._previous_data = None
27
- 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
28
234
 
29
- def model_class(self: Self) -> type[Self]:
235
+ @classmethod
236
+ def destination_name(cls: type[Self]) -> str:
30
237
  """
31
- 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.
32
239
 
33
- This is needed by the models class
34
- """
35
- 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...
36
244
 
37
- @classmethod
38
- def table_name(cls: type[Self]) -> str:
39
- """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
+ """
40
248
  singular = string.camel_case_to_snake_case(cls.__name__)
41
249
  if singular[-1] == "y":
42
250
  return singular[:-1] + "ies"
@@ -44,103 +252,249 @@ class Model(Models):
44
252
  return singular + "es"
45
253
  return f"{singular}s"
46
254
 
47
- @abstractmethod
48
- def columns_configuration(self: Self):
49
- """Returns an ordered dictionary with the configuration for the columns"""
50
- pass
255
+ def supports_n_plus_one(self: Self):
256
+ return self.backend.supports_n_plus_one # type: ignore
51
257
 
52
- def all_columns(self: Self):
53
- default = OrderedDict([(self.id_column_name, {"class": UUID})])
54
- default.update(self.columns_configuration())
55
- return default
258
+ def __bool__(self: Self) -> bool: # noqa: D105
259
+ if self._query:
260
+ return bool(self.__len__())
56
261
 
57
- def columns(self: Self, overrides=None):
58
- # no caching if we have overrides
59
- if overrides is not None:
60
- return self._columns.configure(self.all_columns(), self.__class__, overrides=overrides)
262
+ return True if self._data else False
61
263
 
62
- if self._configured_columns is None:
63
- self._configured_columns = self._columns.configure(self.all_columns(), self.__class__)
64
- return self._configured_columns
264
+ def get_raw_data(self: Self) -> dict[str, Any]:
265
+ self.no_queries()
266
+ return self._data
65
267
 
66
- def supports_n_plus_one(self: Self):
67
- return self._backend.supports_n_plus_one
68
-
69
- def __getitem__(self: Self, column_name):
70
- return self.__getattr__(column_name)
71
-
72
- def __getattr__(self: Self, column_name):
73
- # this should be adjusted to only return None for empty records if the column name corresponds
74
- # to an actual column in the table.
75
- if not self.exists:
76
- return None
77
-
78
- return self.get_transformed_from_data(column_name, self._data)
79
-
80
- def get(self: Self, column_name, silent=False):
81
- if not self.exists:
82
- return None
83
-
84
- return self.get_transformed_from_data(column_name, self._data, silent=silent)
85
-
86
- def get_transformed_from_data(self: Self, column_name, data, cache=True, check_providers=True, silent=False):
87
- if cache and column_name in self._transformed:
88
- return self._transformed[column_name]
89
-
90
- # everything in self._data came directly out of the database, but we don't want to send that off.
91
- # instead, the corresponding column has an opportunity to make changes as needed. Moreover,
92
- # it could be that the requested column_name doesn't even exist directly in self._data, but
93
- # can be provided by a column. Therefore, we're going to do some work to fulfill the request,
94
- # raise an Error if we *really* can't fulfill it, and store the results in self._transformed
95
- # as a simple local cache (self._transformed is cleared during a save operation)
96
- columns = self.columns()
97
- value = None
98
- if (column_name not in data or data[column_name] is None) and check_providers:
99
- for column in columns.values():
100
- if column.can_provide(column_name):
101
- value = column.provide(data, column_name)
102
- break
103
- if column_name not in data and value is None:
104
- if not silent:
105
- raise KeyError(f"Unknown column '{column_name}' requested from model '{self.__class__.__name__}'")
106
- return None
107
- else:
108
- value = (
109
- self._backend.column_from_backend(self.columns()[column_name], data[column_name])
110
- if column_name in self.columns()
111
- else data[column_name]
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()
281
+ self._data = {} if data is None else data
282
+ self._transformed_data = {}
283
+
284
+ def save(self: Self, data: dict[str, Any] | None = None, columns: dict[str, Column] = {}, no_data=False) -> bool:
285
+ """
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
+ }
112
358
  )
359
+ return {"id": user.id, "name": user.name}
113
360
 
114
- if cache:
115
- self._transformed[column_name] = value
116
- return value
117
361
 
118
- @property
119
- def exists(self: Self) -> bool:
120
- return True if (self.id_column_name in self._data and self._data[self.id_column_name]) else False
362
+ cli = clearskies.contexts.Cli(
363
+ my_application,
364
+ classes=[User],
365
+ )
366
+ cli()
367
+ ```
121
368
 
122
- @property
123
- def data(self: Self):
124
- return self._data
369
+ And the other is to set new values on the columns attributes and then call save without data:
125
370
 
126
- @data.setter
127
- def data(self: Self, data) -> None:
128
- self._data = {} if data is None else data
371
+ ```python
372
+ import clearskies
129
373
 
130
- def save(self: Self, data, columns=None) -> bool:
131
- """
132
- Save data to the database and update the model!
133
374
 
134
- Executes an update if the model corresponds to a record already, or an insert if not
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()
417
+
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
+ ```
135
480
  """
136
- if not len(data):
137
- raise ValueError("You have to pass in something to save!")
138
- 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()
139
493
  if columns is not None:
140
494
  for column in columns.values():
141
495
  save_columns[column.name] = column
142
496
 
143
- old_data = self.data
497
+ old_data = self.get_raw_data()
144
498
  data = self.columns_pre_save(data, save_columns)
145
499
  data = self.pre_save(data)
146
500
  if data is None:
@@ -148,11 +502,11 @@ class Model(Models):
148
502
 
149
503
  [to_save, temporary_data] = self.columns_to_backend(data, save_columns)
150
504
  to_save = self.to_backend(to_save, save_columns)
151
- if self.exists:
152
- new_data = self._backend.update(self._data[self.id_column_name], to_save, self)
505
+ if self:
506
+ new_data = self.backend.update(self._data[self.id_column_name], to_save, self) # type: ignore
153
507
  else:
154
- new_data = self._backend.create(to_save, self)
155
- id = self._backend.column_from_backend(save_columns[self.id_column_name], new_data[self.id_column_name])
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
156
510
 
157
511
  # if we had any temporary columns add them back in
158
512
  new_data = {
@@ -163,48 +517,216 @@ class Model(Models):
163
517
  data = self.columns_post_save(data, id, save_columns)
164
518
  self.post_save(data, id)
165
519
 
166
- self.data = new_data
167
- self._transformed = {}
520
+ self.set_raw_data(new_data)
521
+ self._transformed_data = {}
168
522
  self._previous_data = old_data
169
- self._touched_columns = list(data.keys())
523
+ self._touched_columns = {key: True for key in data.keys()}
170
524
 
171
525
  self.columns_save_finished(save_columns)
172
526
  self.save_finished()
173
527
 
174
528
  return True
175
529
 
176
- def is_changing(self: Self, key, data) -> bool:
530
+ def is_changing(self: Self, key: str, data: dict[str, Any]) -> bool:
177
531
  """
178
- 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.
179
606
 
180
- Pass in the name of the column to check and the data dictionary from the save in progress
181
607
  """
608
+ self.no_queries()
182
609
  has_old_value = key in self._data
183
610
  has_new_value = key in data
184
611
 
185
612
  if not has_new_value:
186
613
  return False
614
+
187
615
  if not has_old_value:
188
616
  return True
189
617
 
190
- 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)
191
624
 
192
- def latest(self: Self, key, data):
625
+ def latest(self: Self, key: str, data: dict[str, Any]) -> Any:
193
626
  """
194
- Returns the 'latest' value for a column during the save operation
627
+ Return the 'latest' value for a column during the save operation.
195
628
 
196
- Returns either the column value from the data dictionary or the current value stored in the model
197
- Basically, shorthand for the optimized version of: `data.get(key, default=getattr(self, key))` (which is
198
- 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.
199
683
 
200
- Pass in the name of the column to check and the data dictionary from the save in progress
201
684
  """
685
+ self.no_queries()
202
686
  if key in data:
203
687
  return data[key]
204
- 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.
693
+
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`.
205
697
 
206
- def was_changed(self: Self, key) -> bool:
207
- """Returns True/False to denote if a column was changed in the last save"""
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()
208
730
  if self._previous_data is None:
209
731
  raise ValueError("was_changed was called before a save was finished - you must save something first")
210
732
  if key not in self._touched_columns:
@@ -219,51 +741,134 @@ class Model(Models):
219
741
  if not has_old_value:
220
742
  return False
221
743
 
222
- columns = self.columns()
223
- new_value = self.__getattr__(key)
744
+ columns = self.get_columns()
745
+ new_value = self._data[key]
224
746
  old_value = self._previous_data[key]
225
747
  if key not in columns:
226
748
  return old_value != new_value
227
749
  return not columns[key].values_match(old_value, new_value)
228
750
 
229
- def previous_value(self: Self, key):
230
- return self.get_transformed_from_data(key, self._previous_data, cache=False, check_providers=False, silent=True)
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))
231
792
 
232
793
  def delete(self: Self, except_if_not_exists=True) -> bool:
233
- if not self.exists:
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()
813
+
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:
234
838
  if except_if_not_exists:
235
839
  raise ValueError("Cannot delete model that already exists")
236
840
  return True
237
841
 
238
- columns = self.columns()
842
+ columns = self.get_columns()
239
843
  self.columns_pre_delete(columns)
240
844
  self.pre_delete()
241
845
 
242
- self._backend.delete(self._data[self.id_column_name], self)
846
+ self.backend.delete(self._data[self.id_column_name], self) # type: ignore
243
847
 
244
848
  self.columns_post_delete(columns)
245
849
  self.post_delete()
246
850
  return True
247
851
 
248
- def columns_pre_save(self: Self, data, columns):
249
- """Uses the column information present in the model to make any necessary changes before saving"""
250
- for column in columns.values():
251
- data = column.pre_save(data, self)
252
- if data is None:
253
- raise ValueError(
254
- f"Column {column.name} of type {column.__class__.__name__} did not return any data for pre_save"
255
- )
256
- return data
257
-
258
- def pre_save(self, data):
259
- """
260
- A hook to extend so you can provide additional pre-save logic as needed
261
-
262
- It is passed in the data being saved and it should return the same data with adjustments as needed
263
- """
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
264
869
  return data
265
870
 
266
- def columns_to_backend(self: Self, data, columns):
871
+ def columns_to_backend(self: Self, data: dict[str, Any], columns) -> Any:
267
872
  backend_data = {**data}
268
873
  temporary_data = {}
269
874
  for column in columns.values():
@@ -273,7 +878,7 @@ class Model(Models):
273
878
  del backend_data[column.name]
274
879
  continue
275
880
 
276
- backend_data = self._backend.column_to_backend(column, backend_data)
881
+ backend_data = self.backend.column_to_backend(column, backend_data) # type: ignore
277
882
  if backend_data is None:
278
883
  raise ValueError(
279
884
  f"Column {column.name} of type {column.__class__.__name__} did not return any data for to_database"
@@ -281,77 +886,1153 @@ class Model(Models):
281
886
 
282
887
  return [backend_data, temporary_data]
283
888
 
284
- def to_backend(self: Self, data, columns):
889
+ def to_backend(self: Self, data: dict[str, Any], columns) -> dict[str, Any]:
285
890
  return data
286
891
 
287
- def columns_post_save(self: Self, data, id, columns):
288
- """Uses the column information present in the model to make additional changes as needed after saving"""
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."""
289
894
  for column in columns.values():
290
- data = column.post_save(data, self, id)
291
- if data is None:
292
- raise ValueError(
293
- f"Column {column.name} of type {column.__class__.__name__} did not return any data for post_save"
294
- )
895
+ column.post_save(data, self, id)
295
896
  return data
296
897
 
297
- def columns_save_finished(self: Self, columns):
298
- """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."""
299
900
  for column in columns.values():
300
901
  column.save_finished(self)
301
902
 
302
- def post_save(self: Self, data, id):
903
+ def pre_save(self: Self, data: dict[str, Any]) -> dict[str, Any]:
303
904
  """
304
- 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.
305
970
 
306
- It is passed in the data being saved as well as the id. It should take action as needed and then return
307
- either the original data array or an adjusted one if appropriate.
308
971
  """
309
- pass
972
+ return data
310
973
 
311
- def pre_save(self: Self, data):
974
+ def post_save(self: Self, data: dict[str, Any], id: str | int) -> None:
312
975
  """
313
- 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")
314
995
 
315
- 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
+ ```
316
1030
  """
317
- return data
1031
+ pass
318
1032
 
319
- def save_finished(self: Self):
1033
+ def save_finished(self: Self) -> None:
320
1034
  """
321
- 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.
322
1036
 
323
- 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
324
1038
  updated with the new data. You can decide on the necessary actions using the `was_changed` and
325
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
+ ```
326
1086
  """
327
1087
  pass
328
1088
 
329
- def columns_pre_delete(self: Self, columns):
330
- """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."""
331
1091
  for column in columns.values():
332
1092
  column.pre_delete(self)
333
1093
 
334
- def pre_delete(self: Self):
335
- """
336
- A hook to extend so you can provide additional pre-delete logic as needed
337
- """
1094
+ def pre_delete(self: Self) -> None:
1095
+ """Create a hook to extend so you can provide additional pre-delete logic as needed."""
338
1096
  pass
339
1097
 
340
- def columns_post_delete(self: Self, columns):
341
- """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."""
342
1100
  for column in columns.values():
343
1101
  column.post_delete(self)
344
1102
 
345
- def post_delete(self: 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:
346
1130
  """
347
- 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
+ ```
348
1185
  """
349
- pass
1186
+ return model
1187
+
1188
+ ##############################################################
1189
+ ### From here down is functionality related to list/search ###
1190
+ ##############################################################
1191
+ def has_query(self) -> bool:
1192
+ """
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:
1322
+ """
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.
350
1629
 
351
- def where_for_request(self: Self, models, routing_data, authorization_data, input_output, overrides=None):
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.
352
1752
  """
353
- A hook to automatically apply filtering whenever the model makes an appearance in a get/update/list/search handler.
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:
354
1862
  """
355
- for column in self.columns(overrides=overrides).values():
356
- models = column.where_for_request(models, routing_data, authorization_data, input_output)
357
- return models
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