clear-skies 1.22.30__py3-none-any.whl → 2.0.0__py3-none-any.whl

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

Potentially problematic release.


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

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