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