clear-skies 2.0.5__py3-none-any.whl → 2.0.6__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 (252) hide show
  1. {clear_skies-2.0.5.dist-info → clear_skies-2.0.6.dist-info}/METADATA +1 -1
  2. clear_skies-2.0.6.dist-info/RECORD +251 -0
  3. clearskies/__init__.py +61 -0
  4. clearskies/action.py +7 -0
  5. clearskies/authentication/__init__.py +15 -0
  6. clearskies/authentication/authentication.py +46 -0
  7. clearskies/authentication/authorization.py +16 -0
  8. clearskies/authentication/authorization_pass_through.py +20 -0
  9. clearskies/authentication/jwks.py +163 -0
  10. clearskies/authentication/public.py +5 -0
  11. clearskies/authentication/secret_bearer.py +553 -0
  12. clearskies/autodoc/__init__.py +8 -0
  13. clearskies/autodoc/formats/__init__.py +5 -0
  14. clearskies/autodoc/formats/oai3_json/__init__.py +7 -0
  15. clearskies/autodoc/formats/oai3_json/oai3_json.py +87 -0
  16. clearskies/autodoc/formats/oai3_json/oai3_schema_resolver.py +15 -0
  17. clearskies/autodoc/formats/oai3_json/parameter.py +35 -0
  18. clearskies/autodoc/formats/oai3_json/request.py +68 -0
  19. clearskies/autodoc/formats/oai3_json/response.py +28 -0
  20. clearskies/autodoc/formats/oai3_json/schema/__init__.py +11 -0
  21. clearskies/autodoc/formats/oai3_json/schema/array.py +9 -0
  22. clearskies/autodoc/formats/oai3_json/schema/default.py +13 -0
  23. clearskies/autodoc/formats/oai3_json/schema/enum.py +7 -0
  24. clearskies/autodoc/formats/oai3_json/schema/object.py +35 -0
  25. clearskies/autodoc/formats/oai3_json/test.json +1985 -0
  26. clearskies/autodoc/py.typed +0 -0
  27. clearskies/autodoc/request/__init__.py +15 -0
  28. clearskies/autodoc/request/header.py +6 -0
  29. clearskies/autodoc/request/json_body.py +6 -0
  30. clearskies/autodoc/request/parameter.py +8 -0
  31. clearskies/autodoc/request/request.py +47 -0
  32. clearskies/autodoc/request/url_parameter.py +6 -0
  33. clearskies/autodoc/request/url_path.py +6 -0
  34. clearskies/autodoc/response/__init__.py +5 -0
  35. clearskies/autodoc/response/response.py +9 -0
  36. clearskies/autodoc/schema/__init__.py +31 -0
  37. clearskies/autodoc/schema/array.py +10 -0
  38. clearskies/autodoc/schema/base64.py +8 -0
  39. clearskies/autodoc/schema/boolean.py +5 -0
  40. clearskies/autodoc/schema/date.py +5 -0
  41. clearskies/autodoc/schema/datetime.py +5 -0
  42. clearskies/autodoc/schema/double.py +5 -0
  43. clearskies/autodoc/schema/enum.py +17 -0
  44. clearskies/autodoc/schema/integer.py +6 -0
  45. clearskies/autodoc/schema/long.py +5 -0
  46. clearskies/autodoc/schema/number.py +6 -0
  47. clearskies/autodoc/schema/object.py +13 -0
  48. clearskies/autodoc/schema/password.py +5 -0
  49. clearskies/autodoc/schema/schema.py +11 -0
  50. clearskies/autodoc/schema/string.py +5 -0
  51. clearskies/backends/__init__.py +65 -0
  52. clearskies/backends/api_backend.py +1178 -0
  53. clearskies/backends/backend.py +136 -0
  54. clearskies/backends/cursor_backend.py +335 -0
  55. clearskies/backends/memory_backend.py +797 -0
  56. clearskies/backends/secrets_backend.py +106 -0
  57. clearskies/column.py +1233 -0
  58. clearskies/columns/__init__.py +71 -0
  59. clearskies/columns/audit.py +206 -0
  60. clearskies/columns/belongs_to_id.py +483 -0
  61. clearskies/columns/belongs_to_model.py +132 -0
  62. clearskies/columns/belongs_to_self.py +105 -0
  63. clearskies/columns/boolean.py +113 -0
  64. clearskies/columns/category_tree.py +275 -0
  65. clearskies/columns/category_tree_ancestors.py +51 -0
  66. clearskies/columns/category_tree_children.py +127 -0
  67. clearskies/columns/category_tree_descendants.py +48 -0
  68. clearskies/columns/created.py +95 -0
  69. clearskies/columns/created_by_authorization_data.py +116 -0
  70. clearskies/columns/created_by_header.py +99 -0
  71. clearskies/columns/created_by_ip.py +92 -0
  72. clearskies/columns/created_by_routing_data.py +97 -0
  73. clearskies/columns/created_by_user_agent.py +92 -0
  74. clearskies/columns/date.py +234 -0
  75. clearskies/columns/datetime.py +282 -0
  76. clearskies/columns/email.py +76 -0
  77. clearskies/columns/float.py +153 -0
  78. clearskies/columns/has_many.py +505 -0
  79. clearskies/columns/has_many_self.py +56 -0
  80. clearskies/columns/has_one.py +14 -0
  81. clearskies/columns/integer.py +160 -0
  82. clearskies/columns/json.py +128 -0
  83. clearskies/columns/many_to_many_ids.py +337 -0
  84. clearskies/columns/many_to_many_ids_with_data.py +274 -0
  85. clearskies/columns/many_to_many_models.py +158 -0
  86. clearskies/columns/many_to_many_pivots.py +134 -0
  87. clearskies/columns/phone.py +159 -0
  88. clearskies/columns/select.py +92 -0
  89. clearskies/columns/string.py +102 -0
  90. clearskies/columns/timestamp.py +164 -0
  91. clearskies/columns/updated.py +110 -0
  92. clearskies/columns/uuid.py +86 -0
  93. clearskies/configs/README.md +105 -0
  94. clearskies/configs/__init__.py +162 -0
  95. clearskies/configs/actions.py +43 -0
  96. clearskies/configs/any.py +13 -0
  97. clearskies/configs/any_dict.py +22 -0
  98. clearskies/configs/any_dict_or_callable.py +23 -0
  99. clearskies/configs/authentication.py +23 -0
  100. clearskies/configs/authorization.py +23 -0
  101. clearskies/configs/boolean.py +16 -0
  102. clearskies/configs/boolean_or_callable.py +18 -0
  103. clearskies/configs/callable_config.py +18 -0
  104. clearskies/configs/columns.py +34 -0
  105. clearskies/configs/conditions.py +30 -0
  106. clearskies/configs/config.py +24 -0
  107. clearskies/configs/datetime.py +18 -0
  108. clearskies/configs/datetime_or_callable.py +19 -0
  109. clearskies/configs/endpoint.py +23 -0
  110. clearskies/configs/endpoint_list.py +29 -0
  111. clearskies/configs/float.py +16 -0
  112. clearskies/configs/float_or_callable.py +18 -0
  113. clearskies/configs/integer.py +16 -0
  114. clearskies/configs/integer_or_callable.py +18 -0
  115. clearskies/configs/joins.py +30 -0
  116. clearskies/configs/list_any_dict.py +30 -0
  117. clearskies/configs/list_any_dict_or_callable.py +31 -0
  118. clearskies/configs/model_class.py +35 -0
  119. clearskies/configs/model_column.py +65 -0
  120. clearskies/configs/model_columns.py +56 -0
  121. clearskies/configs/model_destination_name.py +25 -0
  122. clearskies/configs/model_to_id_column.py +43 -0
  123. clearskies/configs/readable_model_column.py +9 -0
  124. clearskies/configs/readable_model_columns.py +9 -0
  125. clearskies/configs/schema.py +23 -0
  126. clearskies/configs/searchable_model_columns.py +9 -0
  127. clearskies/configs/security_headers.py +39 -0
  128. clearskies/configs/select.py +26 -0
  129. clearskies/configs/select_list.py +47 -0
  130. clearskies/configs/string.py +29 -0
  131. clearskies/configs/string_dict.py +32 -0
  132. clearskies/configs/string_list.py +32 -0
  133. clearskies/configs/string_list_or_callable.py +35 -0
  134. clearskies/configs/string_or_callable.py +18 -0
  135. clearskies/configs/timedelta.py +18 -0
  136. clearskies/configs/timezone.py +18 -0
  137. clearskies/configs/url.py +23 -0
  138. clearskies/configs/validators.py +45 -0
  139. clearskies/configs/writeable_model_column.py +9 -0
  140. clearskies/configs/writeable_model_columns.py +9 -0
  141. clearskies/configurable.py +76 -0
  142. clearskies/contexts/__init__.py +11 -0
  143. clearskies/contexts/cli.py +117 -0
  144. clearskies/contexts/context.py +98 -0
  145. clearskies/contexts/wsgi.py +76 -0
  146. clearskies/contexts/wsgi_ref.py +82 -0
  147. clearskies/decorators.py +33 -0
  148. clearskies/di/__init__.py +14 -0
  149. clearskies/di/additional_config.py +130 -0
  150. clearskies/di/additional_config_auto_import.py +17 -0
  151. clearskies/di/di.py +973 -0
  152. clearskies/di/inject/__init__.py +23 -0
  153. clearskies/di/inject/by_class.py +21 -0
  154. clearskies/di/inject/by_name.py +18 -0
  155. clearskies/di/inject/di.py +13 -0
  156. clearskies/di/inject/environment.py +14 -0
  157. clearskies/di/inject/input_output.py +20 -0
  158. clearskies/di/inject/now.py +13 -0
  159. clearskies/di/inject/requests.py +13 -0
  160. clearskies/di/inject/secrets.py +14 -0
  161. clearskies/di/inject/utcnow.py +13 -0
  162. clearskies/di/inject/uuid.py +15 -0
  163. clearskies/di/injectable.py +29 -0
  164. clearskies/di/injectable_properties.py +131 -0
  165. clearskies/di/test_module/__init__.py +6 -0
  166. clearskies/di/test_module/another_module/__init__.py +2 -0
  167. clearskies/di/test_module/module_class.py +5 -0
  168. clearskies/end.py +183 -0
  169. clearskies/endpoint.py +1314 -0
  170. clearskies/endpoint_group.py +336 -0
  171. clearskies/endpoints/__init__.py +25 -0
  172. clearskies/endpoints/advanced_search.py +526 -0
  173. clearskies/endpoints/callable.py +388 -0
  174. clearskies/endpoints/create.py +205 -0
  175. clearskies/endpoints/delete.py +139 -0
  176. clearskies/endpoints/get.py +271 -0
  177. clearskies/endpoints/health_check.py +183 -0
  178. clearskies/endpoints/list.py +574 -0
  179. clearskies/endpoints/restful_api.py +427 -0
  180. clearskies/endpoints/schema.py +189 -0
  181. clearskies/endpoints/simple_search.py +286 -0
  182. clearskies/endpoints/update.py +193 -0
  183. clearskies/environment.py +104 -0
  184. clearskies/exceptions/__init__.py +19 -0
  185. clearskies/exceptions/authentication.py +2 -0
  186. clearskies/exceptions/authorization.py +2 -0
  187. clearskies/exceptions/client_error.py +2 -0
  188. clearskies/exceptions/input_errors.py +4 -0
  189. clearskies/exceptions/missing_dependency.py +2 -0
  190. clearskies/exceptions/moved_permanently.py +3 -0
  191. clearskies/exceptions/moved_temporarily.py +3 -0
  192. clearskies/exceptions/not_found.py +2 -0
  193. clearskies/functional/__init__.py +7 -0
  194. clearskies/functional/routing.py +92 -0
  195. clearskies/functional/string.py +112 -0
  196. clearskies/functional/validations.py +76 -0
  197. clearskies/input_outputs/__init__.py +13 -0
  198. clearskies/input_outputs/cli.py +171 -0
  199. clearskies/input_outputs/exceptions/__init__.py +2 -0
  200. clearskies/input_outputs/exceptions/cli_input_error.py +2 -0
  201. clearskies/input_outputs/exceptions/cli_not_found.py +2 -0
  202. clearskies/input_outputs/headers.py +45 -0
  203. clearskies/input_outputs/input_output.py +138 -0
  204. clearskies/input_outputs/programmatic.py +69 -0
  205. clearskies/input_outputs/py.typed +0 -0
  206. clearskies/input_outputs/wsgi.py +77 -0
  207. clearskies/model.py +1922 -0
  208. clearskies/py.typed +0 -0
  209. clearskies/query/__init__.py +12 -0
  210. clearskies/query/condition.py +223 -0
  211. clearskies/query/join.py +136 -0
  212. clearskies/query/query.py +196 -0
  213. clearskies/query/sort.py +27 -0
  214. clearskies/schema.py +82 -0
  215. clearskies/secrets/__init__.py +6 -0
  216. clearskies/secrets/additional_configs/__init__.py +32 -0
  217. clearskies/secrets/additional_configs/mysql_connection_dynamic_producer.py +61 -0
  218. clearskies/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +160 -0
  219. clearskies/secrets/akeyless.py +182 -0
  220. clearskies/secrets/exceptions/__init__.py +1 -0
  221. clearskies/secrets/exceptions/not_found.py +2 -0
  222. clearskies/secrets/secrets.py +38 -0
  223. clearskies/security_header.py +15 -0
  224. clearskies/security_headers/__init__.py +11 -0
  225. clearskies/security_headers/cache_control.py +67 -0
  226. clearskies/security_headers/cors.py +50 -0
  227. clearskies/security_headers/csp.py +94 -0
  228. clearskies/security_headers/hsts.py +22 -0
  229. clearskies/security_headers/x_content_type_options.py +0 -0
  230. clearskies/security_headers/x_frame_options.py +0 -0
  231. clearskies/test_base.py +8 -0
  232. clearskies/typing.py +11 -0
  233. clearskies/validator.py +37 -0
  234. clearskies/validators/__init__.py +33 -0
  235. clearskies/validators/after_column.py +62 -0
  236. clearskies/validators/before_column.py +13 -0
  237. clearskies/validators/in_the_future.py +32 -0
  238. clearskies/validators/in_the_future_at_least.py +11 -0
  239. clearskies/validators/in_the_future_at_most.py +10 -0
  240. clearskies/validators/in_the_past.py +32 -0
  241. clearskies/validators/in_the_past_at_least.py +10 -0
  242. clearskies/validators/in_the_past_at_most.py +10 -0
  243. clearskies/validators/maximum_length.py +26 -0
  244. clearskies/validators/maximum_value.py +29 -0
  245. clearskies/validators/minimum_length.py +26 -0
  246. clearskies/validators/minimum_value.py +29 -0
  247. clearskies/validators/required.py +34 -0
  248. clearskies/validators/timedelta.py +59 -0
  249. clearskies/validators/unique.py +30 -0
  250. clear_skies-2.0.5.dist-info/RECORD +0 -4
  251. {clear_skies-2.0.5.dist-info → clear_skies-2.0.6.dist-info}/WHEEL +0 -0
  252. {clear_skies-2.0.5.dist-info → clear_skies-2.0.6.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,274 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Callable, Self, overload
4
+
5
+ import clearskies.decorators
6
+ import clearskies.typing
7
+ from clearskies import configs
8
+ from clearskies.columns.many_to_many_ids import ManyToManyIds
9
+
10
+ if TYPE_CHECKING:
11
+ from clearskies import Model
12
+
13
+
14
+ class ManyToManyIdsWithData(ManyToManyIds):
15
+ """
16
+ A column to represent a many-to-many relationship with information stored in the relationship itself.
17
+
18
+ This is an extention of the many-to-many column, but with one important addition: data about the
19
+ relationship is stored in the pivot table. This creates some differences, which are best
20
+ explained by example:
21
+
22
+ ```python
23
+ import clearskies
24
+
25
+
26
+ class ThingyWidgets(clearskies.Model):
27
+ id_column_name = "id"
28
+ backend = clearskies.backends.MemoryBackend()
29
+
30
+ id = clearskies.columns.Uuid()
31
+ # these could also be belongs to relationships, but the pivot model
32
+ # is rarely used directly, so I'm being lazy to avoid having to use
33
+ # model references.
34
+ thingy_id = clearskies.columns.String()
35
+ widget_id = clearskies.columns.String()
36
+ name = clearskies.columns.String()
37
+ kind = clearskies.columns.String()
38
+
39
+
40
+ class Thingy(clearskies.Model):
41
+ id_column_name = "id"
42
+ backend = clearskies.backends.MemoryBackend()
43
+
44
+ id = clearskies.columns.Uuid()
45
+ name = clearskies.columns.String()
46
+
47
+
48
+ class Widget(clearskies.Model):
49
+ id_column_name = "id"
50
+ backend = clearskies.backends.MemoryBackend()
51
+
52
+ id = clearskies.columns.Uuid()
53
+ name = clearskies.columns.String()
54
+ thingy_ids = clearskies.columns.ManyToManyIdsWithData(
55
+ related_model_class=Thingy,
56
+ pivot_model_class=ThingyWidgets,
57
+ readable_pivot_column_names=["id", "thingy_id", "widget_id", "name", "kind"],
58
+ )
59
+ thingies = clearskies.columns.ManyToManyModels("thingy_ids")
60
+ thingy_widgets = clearskies.columns.ManyToManyPivots("thingy_ids")
61
+
62
+
63
+ def my_application(widgets: Widget, thingies: Thingy):
64
+ thing_1 = thingies.create({"name": "Thing 1"})
65
+ thing_2 = thingies.create({"name": "Thing 2"})
66
+ thing_3 = thingies.create({"name": "Thing 3"})
67
+ widget = widgets.create({
68
+ "name": "Widget 1",
69
+ "thingy_ids": [
70
+ {"thingy_id": thing_1.id, "name": "Widget Thing 1", "kind": "Special"},
71
+ {"thingy_id": thing_2.id, "name": "Widget Thing 2", "kind": "Also Special"},
72
+ ],
73
+ })
74
+
75
+ return widget
76
+
77
+
78
+ cli = clearskies.contexts.Cli(
79
+ clearskies.endpoints.Callable(
80
+ my_application,
81
+ model_class=Widget,
82
+ return_records=True,
83
+ readable_column_names=["id", "name", "thingy_widgets"],
84
+ ),
85
+ classes=[Widget, Thingy, ThingyWidgets],
86
+ )
87
+
88
+ if __name__ == "__main__":
89
+ cli()
90
+ ```
91
+
92
+ As with setting ids in the ManyToManyIds class, any items left out will result in the relationship
93
+ (including all its related data) being removed. An important difference with the ManyToManyWithData
94
+ column is the way you specify which record is being connected. This is easy for the ManyToManyIds column
95
+ because all you provide is the id from the related model. When working with the ManyToManyWithData
96
+ column, you provide a dictionary for each relationship (so you can provide the data that goes in the
97
+ pivot model). To let it know what record is being connected, you therefore explicitly provide
98
+ the id from the related model in a dictionary key with the name of the related model id column in
99
+ the pivot (e.g. `{"thingy_id": id}` in the first example. However, if there are unique columns in the
100
+ related model, you can provide those instead. If you execute the above example you'll get:
101
+
102
+ ```json
103
+ {
104
+ "status": "success",
105
+ "error": "",
106
+ "data": {
107
+ "id": "c4be91a8-85a1-4e29-994a-327f59e26ec7",
108
+ "name": "Widget 1",
109
+ "thingy_widgets": [
110
+ {
111
+ "id": "3a8f6f14-9657-49d8-8844-0db3452525fe",
112
+ "thingy_id": "db292ebc-7b2b-4306-aced-8e6d073ec264",
113
+ "widget_id": "c4be91a8-85a1-4e29-994a-327f59e26ec7",
114
+ "name": "Widget Thing 1",
115
+ "kind": "Special",
116
+ },
117
+ {
118
+ "id": "480a0192-70d9-4363-a669-4a59f0b56730",
119
+ "thingy_id": "d469dbe9-556e-46f3-bc48-03f8cb8d8e44",
120
+ "widget_id": "c4be91a8-85a1-4e29-994a-327f59e26ec7",
121
+ "name": "Widget Thing 2",
122
+ "kind": "Also Special",
123
+ },
124
+ ],
125
+ },
126
+ "pagination": {},
127
+ "input_errors": {},
128
+ }
129
+ ```
130
+ """
131
+
132
+ """ The list of columns in the pivot model that can be set when saving data from an endpoint. """
133
+ setable_column_names = configs.WriteableModelColumns("pivot_model_class")
134
+
135
+ """ The list of columns in the pivot model that will be included when returning records from an endpoint. """
136
+ readable_pivot_column_names = configs.ReadableModelColumns("pivot_model_class")
137
+
138
+ """
139
+ Complicated, but probably should be false.
140
+
141
+ Sometimes you have to provide data from the related model class in your save data so that
142
+ clearskies can find the right record. Normally, this lookup column is not persisted to the
143
+ pivot table, because it is assumed to only exist in the related table. In some cases though,
144
+ you may want it in both, in which case you can set this to true.
145
+ """
146
+ persist_unique_lookup_column_to_pivot_table = configs.Boolean(default=False)
147
+
148
+ default = configs.ListAnyDict(default=None) # type: ignore
149
+ setable = configs.ListAnyDictOrCallable(default=None) # type: ignore
150
+ _descriptor_config_map = None
151
+
152
+ @clearskies.decorators.parameters_to_properties
153
+ def __init__(
154
+ self,
155
+ related_model_class,
156
+ pivot_model_class,
157
+ own_column_name_in_pivot: str = "",
158
+ related_column_name_in_pivot: str = "",
159
+ readable_related_columns: list[str] = [],
160
+ readable_pivot_column_names: list[str] = [],
161
+ setable_column_names: list[str] = [],
162
+ persist_unique_lookup_column_to_pivot_table: bool = False,
163
+ default: list[dict[str, Any]] = [],
164
+ setable: list[dict[str, Any]] | Callable[..., list[dict[str, Any]]] = [],
165
+ is_readable: bool = True,
166
+ is_writeable: bool = True,
167
+ is_temporary: bool = False,
168
+ validators: clearskies.typing.validator | list[clearskies.typing.validator] = [],
169
+ on_change_pre_save: clearskies.typing.action | list[clearskies.typing.action] = [],
170
+ on_change_post_save: clearskies.typing.action | list[clearskies.typing.action] = [],
171
+ on_change_save_finished: clearskies.typing.action | list[clearskies.typing.action] = [],
172
+ created_by_source_type: str = "",
173
+ created_by_source_key: str = "",
174
+ created_by_source_strict: bool = True,
175
+ ):
176
+ pass
177
+
178
+ @overload
179
+ def __get__(self, instance: None, cls: type[Model]) -> Self:
180
+ pass
181
+
182
+ @overload
183
+ def __get__(self, instance: Model, cls: type[Model]) -> list[Any]:
184
+ pass
185
+
186
+ def __get__(self, instance, cls):
187
+ return super().__get__(instance, cls)
188
+
189
+ def __set__(self, instance, value: list[dict[str, Any]]) -> None: # type: ignore
190
+ # this makes sure we're initialized
191
+ if "name" not in self._config: # type: ignore
192
+ instance.get_columns()
193
+
194
+ instance._next_data[self.name] = value
195
+
196
+ def post_save(self, data, model, id):
197
+ # if our incoming data is not in the data array or is None, then nothing has been set and we do not want
198
+ # to make any changes
199
+ if self.name not in data or data[self.name] is None:
200
+ return data
201
+
202
+ # figure out what ids need to be created or deleted from the pivot table.
203
+ if not model:
204
+ old_ids = set()
205
+ else:
206
+ old_ids = set(self.__get__(model, model.__class__))
207
+
208
+ # this is trickier for many-to-many-with-data compared to many-to-many. We're generally
209
+ # expecting data[self.name] to be a list of dictionaries. For each entry, we need to find
210
+ # the corresponding entry in the pivot table to decide if we need to delete, create, or update.
211
+ # However, since we have a dictionary there are a variety of ways that we can connect to
212
+ # an entry in the related table - either related id or any unique column from the related
213
+ # table. Technically we might also specify a pivot id, but we're generally trying to be
214
+ # transparent to those, so let's ignore that one.
215
+ related_column_name_in_pivot = self.related_column_name_in_pivot
216
+ own_column_name_in_pivot = self.own_column_name_in_pivot
217
+ unique_related_columns = {
218
+ column.name: column.name for column in self.related_columns.values() if column.is_unique
219
+ }
220
+ related_model = self.related_model
221
+ pivot_model = self.pivot_model
222
+ # minor cheating
223
+ if hasattr(pivot_model.backend, "create_table"):
224
+ pivot_model.backend.create_table(pivot_model)
225
+ new_ids = set()
226
+ for pivot_record in data[self.name]:
227
+ # first we need to identify which related column this belongs to.
228
+ related_column_id = None
229
+ # if they provide the related column id in the pivot data then we're good
230
+ if related_column_name_in_pivot in pivot_record:
231
+ related_column_id = pivot_record[related_column_name_in_pivot]
232
+ elif len(unique_related_columns):
233
+ for pivot_column, pivot_value in pivot_record.items():
234
+ if pivot_column not in unique_related_columns:
235
+ continue
236
+ related = related_model.find(f"{pivot_column}={pivot_value}")
237
+ related_column_id = getattr(related, related.id_column_name)
238
+ if related_column_id:
239
+ # remove this column from the data - it was used to lookup the right
240
+ # record, but mostly won't exist in the model, unless we've been instructed
241
+ # to keep it
242
+ if not self._config.get("persist_unique_lookup_column_to_pivot_table"): # type: ignore
243
+ del pivot_record[pivot_column]
244
+ break
245
+ if not related_column_id:
246
+ column_list = "'" + "', '".join(list(unique_related_columns.keys())) + "'"
247
+ raise ValueError(
248
+ f"Missing data for {self.name}: Unable to match related record for a record in the many-to-many relationship: you must provide either '{related_column_name_in_pivot}' with the id column for the related table, or a value from one of the unique columns: {column_list}"
249
+ )
250
+ pivot = (
251
+ pivot_model.where(f"{related_column_name_in_pivot}={related_column_id}")
252
+ .where(f"{own_column_name_in_pivot}={id}")
253
+ .first()
254
+ )
255
+ new_ids.add(related_column_id)
256
+ # this will either update or create accordingly
257
+ pivot.save(
258
+ {
259
+ **pivot_record,
260
+ related_column_name_in_pivot: related_column_id,
261
+ own_column_name_in_pivot: id,
262
+ }
263
+ )
264
+
265
+ # the above took care of isnerting and updating active records. Now we need to delete
266
+ # records that are no longer needed.
267
+ to_delete = old_ids - new_ids
268
+ if to_delete:
269
+ for model_to_delete in pivot_model.where(
270
+ f"{related_column_name_in_pivot} IN (" + ",".join(map(str, to_delete)) + ")"
271
+ ):
272
+ model_to_delete.delete()
273
+
274
+ return data
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import OrderedDict
4
+ from typing import TYPE_CHECKING, Any, Callable, Self, overload
5
+
6
+ import clearskies.decorators
7
+ import clearskies.typing
8
+ from clearskies import configs
9
+ from clearskies.autodoc.schema import Array as AutoDocArray
10
+ from clearskies.autodoc.schema import Object as AutoDocObject
11
+ from clearskies.column import Column
12
+ from clearskies.columns.many_to_many_ids import ManyToManyIds
13
+ from clearskies.functional import string
14
+
15
+ if TYPE_CHECKING:
16
+ from clearskies import Model
17
+
18
+
19
+ class ManyToManyModels(Column):
20
+ """
21
+ A companion for the ManyToManyIds column that returns the matching models instead of the ids.
22
+
23
+ See the example in the ManyToManyIds column to understand how to use it.
24
+ """
25
+
26
+ """ The name of the many-to-many column we are attached to. """
27
+ many_to_many_column_name = configs.ModelColumn(required=True)
28
+
29
+ is_writeable = configs.Boolean(default=False)
30
+ is_searchable = configs.Boolean(default=False)
31
+ _descriptor_config_map = None
32
+
33
+ @clearskies.decorators.parameters_to_properties
34
+ def __init__(
35
+ self,
36
+ many_to_many_column_name,
37
+ ):
38
+ pass
39
+
40
+ def finalize_configuration(self, model_class: type, name: str) -> None:
41
+ """Finalize and check the configuration."""
42
+ getattr(self.__class__, "many_to_many_column_name").set_model_class(model_class)
43
+ self.model_class = model_class
44
+ self.name = name
45
+ self.finalize_and_validate_configuration()
46
+
47
+ # finally, make sure we're really pointed at a many-to-many column
48
+ many_to_many_column = getattr(model_class, self.many_to_many_column_name)
49
+ if not isinstance(many_to_many_column, ManyToManyIds):
50
+ raise ValueError(
51
+ f"Error with configuration for {model_class.__name__}.{name}, which is a ManyToManyModels column. It needs to point to a ManyToManyIds column, and it was told to use {model_class.__name__}.{self.many_to_many_column_name}, but this is not a ManyToManyIds column."
52
+ )
53
+
54
+ @property
55
+ def pivot_model(self):
56
+ return self.di.build(self.many_to_many_column.pivot_model_class, cache=True) # type: ignore
57
+
58
+ @property
59
+ def related_models(self):
60
+ return self.di.build(self.many_to_many_column.related_model_class, cache=True) # type: ignore
61
+
62
+ @property
63
+ def related_columns(self):
64
+ return self.related_models.get_columns()
65
+
66
+ @property
67
+ def many_to_many_column(self) -> ManyToManyIds:
68
+ return getattr(self.model_class, self.many_to_many_column_name)
69
+
70
+ @overload
71
+ def __get__(self, instance: None, cls: type[Model]) -> Self:
72
+ pass
73
+
74
+ @overload
75
+ def __get__(self, instance: Model, cls: type[Model]) -> Model:
76
+ pass
77
+
78
+ def __get__(self, instance, cls):
79
+ if instance is None:
80
+ self.model_class = cls
81
+ return self
82
+
83
+ # this makes sure we're initialized
84
+ if "name" not in self._config: # type: ignore
85
+ instance.get_columns()
86
+
87
+ return self.many_to_many_column.get_related_models(instance) # type: ignore
88
+
89
+ def __set__(self, instance, value: Model | list[Model] | list[dict[str, Any]]) -> None:
90
+ # this makes sure we're initialized
91
+ if "name" not in self._config: # type: ignore
92
+ instance.get_columns()
93
+
94
+ # we allow a list of models or a model, but if it's a model it may represent a single record or a query.
95
+ # if it's a single record then we want to wrap it in a list so we can iterate over it.
96
+ if hasattr(value, "_data") and value._data:
97
+ value = []
98
+ many_to_many_column: ManyToManyIds = self.many_to_many_column # type: ignore
99
+ related_model_class = many_to_many_column.related_model_class
100
+ related_id_column_name = related_model_class.id_column_name
101
+ record_ids = []
102
+ for index, record in enumerate(value):
103
+ if isinstance(record, dict):
104
+ if not record.get(related_id_column_name):
105
+ raise KeyError(
106
+ f"A list of dictionaries was set to '{self.model_class.__name__}.{self.name}', in which case each dictionary should contain the key '{related_id_column_name}', which should be the id of an entry for the '{related_model_class.__name__}' model. However, no such key was found for entry #{index + 1}"
107
+ )
108
+ record_ids.append(record[related_id_column_name])
109
+ continue
110
+
111
+ # if we get here then the entry should be a model for our related model class
112
+ if not isinstance(record, related_model_class):
113
+ raise TypeError(
114
+ f"Models were sent to '{self.model_class.__name__}.{self.name}', in which case it should be a list of models of type {related_model_class.__name__}. However, an object of type '{record.__class__.__name__}' was found for entry #{index + 1}"
115
+ )
116
+ record_ids.append(getattr(record, related_id_column_name))
117
+ setattr(instance, self.many_to_many_column_name, record_ids)
118
+
119
+ def add_search(self, model: Model, value: str, operator: str = "", relationship_reference: str = "") -> Model:
120
+ return self.many_to_many_column.add_search( # type: ignore
121
+ model, value, operator, relationship_reference=relationship_reference
122
+ ) # type: ignore
123
+
124
+ def to_json(self, model: Model) -> dict[str, Any]:
125
+ records = []
126
+ many_to_many_column: ManyToManyIds = self.many_to_many_column # type: ignore
127
+ columns = many_to_many_column.related_columns
128
+ related_id_column_name = many_to_many_column.related_model_class.id_column_name
129
+ for related in many_to_many_column.get_related_models(model):
130
+ json = OrderedDict()
131
+ if related_id_column_name not in many_to_many_column.readable_related_column_names:
132
+ json[related_id_column_name] = columns[related_id_column_name].to_json(related)
133
+ for column_name in many_to_many_column.readable_related_column_names:
134
+ column_data = columns[column_name].to_json(related)
135
+ if type(column_data) == dict:
136
+ json = {**json, **column_data} # type: ignore
137
+ else:
138
+ json[column_name] = column_data
139
+ records.append(json)
140
+ return {self.name: records}
141
+
142
+ def documentation(self, name: str | None = None, example: str | None = None, value: str | None = None):
143
+ many_to_many_column = self.many_to_many_column # type: ignore
144
+ columns = many_to_many_column.related_columns
145
+ related_id_column_name = many_to_many_column.related_model_class.id_column_name
146
+ related_properties = [columns[related_id_column_name].documentation()]
147
+
148
+ for column_name in many_to_many_column.readable_related_column_names:
149
+ related_docs = columns[column_name].documentation()
150
+ if type(related_docs) != list:
151
+ related_docs = [related_docs]
152
+ related_properties.extend(related_docs)
153
+
154
+ related_object = AutoDocObject(
155
+ string.title_case_to_nice(many_to_many_column.related_model_class.__name__),
156
+ related_properties,
157
+ )
158
+ return AutoDocArray(name if name is not None else self.name, related_object, value=value)
@@ -0,0 +1,134 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import OrderedDict
4
+ from typing import TYPE_CHECKING, Any, Callable, Self, overload
5
+
6
+ import clearskies.decorators
7
+ import clearskies.typing
8
+ from clearskies import configs
9
+ from clearskies.autodoc.schema import Array as AutoDocArray
10
+ from clearskies.autodoc.schema import Object as AutoDocObject
11
+ from clearskies.column import Column
12
+ from clearskies.columns.many_to_many_ids import ManyToManyIds
13
+ from clearskies.functional import string
14
+
15
+ if TYPE_CHECKING:
16
+ from clearskies import Model
17
+
18
+
19
+ class ManyToManyPivots(Column):
20
+ """
21
+ A companion for the ManyToManyIds column that returns the matching pivot models instead of the ids.
22
+
23
+ See ManyToManyIdsWithData for an example of how to use it (but note that it works just the same for the
24
+ ManyToManyIds column).
25
+ """
26
+
27
+ """ The name of the many-to-many column we are attached to. """
28
+ many_to_many_column_name = configs.ModelColumn(required=True)
29
+
30
+ is_writeable = configs.Boolean(default=False)
31
+ is_searchable = configs.Boolean(default=False)
32
+ _descriptor_config_map = None
33
+
34
+ @clearskies.decorators.parameters_to_properties
35
+ def __init__(
36
+ self,
37
+ many_to_many_column_name,
38
+ ):
39
+ pass
40
+
41
+ def finalize_configuration(self, model_class: type, name: str) -> None:
42
+ """Finalize and check the configuration."""
43
+ getattr(self.__class__, "many_to_many_column_name").set_model_class(model_class)
44
+ self.model_class = model_class
45
+ self.name = name
46
+ self.finalize_and_validate_configuration()
47
+
48
+ # finally, make sure we're really pointed at a many-to-many column
49
+ many_to_many_column = getattr(model_class, self.many_to_many_column_name)
50
+ if not isinstance(many_to_many_column, ManyToManyIds):
51
+ raise ValueError(
52
+ f"Error with configuration for {model_class.__name__}.{name}, which is a ManyToManyModels column. It needs to point to a ManyToManyIds column, and it was told to use {model_class.__name__}.{self.many_to_many_column_name}, but this is not a ManyToManyIds column."
53
+ )
54
+
55
+ @property
56
+ def pivot_model(self) -> Model:
57
+ return self.di.build(self.many_to_many_column.pivot_model_class, cache=True) # type: ignore
58
+
59
+ @property
60
+ def related_models(self) -> Model:
61
+ return self.di.build(self.many_to_many_column.related_model_class, cache=True) # type: ignore
62
+
63
+ @property
64
+ def related_columns(self):
65
+ return self.related_models.get_columns()
66
+
67
+ @property
68
+ def many_to_many_column(self) -> ManyToManyIds:
69
+ return getattr(self.model_class, self.many_to_many_column_name)
70
+
71
+ @overload
72
+ def __get__(self, instance: None, cls: type[Model]) -> Self:
73
+ pass
74
+
75
+ @overload
76
+ def __get__(self, instance: Model, cls: type[Model]) -> Model:
77
+ pass
78
+
79
+ def __get__(self, instance, cls):
80
+ if instance is None:
81
+ self.model_class = cls
82
+ return self
83
+
84
+ # this makes sure we're initialized
85
+ if "name" not in self._config: # type: ignore
86
+ instance.get_columns()
87
+
88
+ many_to_many_column = self.many_to_many_column # type: ignore
89
+ own_column_name_in_pivot = many_to_many_column._config("own_column_name_in_pivot")
90
+ my_id = getattr(instance, instance.id_column_name)
91
+ return [model for model in self.pivot_model.where(f"{own_column_name_in_pivot}={my_id}")]
92
+
93
+ def __set__(self, instance, value: Model | list[Model] | list[dict[str, Any]]) -> None:
94
+ raise NotImplementedError("Saving not supported for ManyToManyPivots")
95
+
96
+ def add_search(self, model: Model, value: str, operator: str = "", relationship_reference: str = "") -> Model:
97
+ raise NotImplementedError("Searching not supported for ManyToManyPivots")
98
+
99
+ def to_json(self, model: Model) -> dict[str, Any]:
100
+ records = []
101
+ many_to_many_column = self.many_to_many_column # type: ignore
102
+ columns = many_to_many_column.pivot_columns
103
+ readable_column_names = many_to_many_column.readable_pivot_column_names
104
+ pivot_id_column_name = many_to_many_column.pivot_model_class.id_column_name
105
+ for pivot in many_to_many_column.get_pivot_models(model):
106
+ json = OrderedDict()
107
+ if pivot_id_column_name not in readable_column_names:
108
+ json[pivot_id_column_name] = columns[pivot_id_column_name].to_json(pivot)
109
+ for column_name in readable_column_names:
110
+ column_data = columns[column_name].to_json(pivot)
111
+ if type(column_data) == dict:
112
+ json = {**json, **column_data} # type: ignore
113
+ else:
114
+ json[column_name] = column_data
115
+ records.append(json)
116
+ return {self.name: records}
117
+
118
+ def documentation(self, name: str | None = None, example: str | None = None, value: str | None = None):
119
+ many_to_many_column = self.many_to_many_column # type: ignore
120
+ columns = many_to_many_column.pivot_columns
121
+ pivot_id_column_name = many_to_many_column.pivot_model_class.id_column_name
122
+ pivot_properties = [columns[pivot_id_column_name].documentation()]
123
+
124
+ for column_name in many_to_many_column.readable_pivot_column_names:
125
+ pivot_docs = columns[column_name].documentation()
126
+ if type(pivot_docs) != list:
127
+ pivot_docs = [pivot_docs]
128
+ pivot_properties.extend(pivot_docs)
129
+
130
+ pivot_object = AutoDocObject(
131
+ string.title_case_to_nice(many_to_many_column.pivot_model_class.__name__),
132
+ pivot_properties,
133
+ )
134
+ return AutoDocArray(name if name is not None else self.name, pivot_object, value=value)