clear-skies 2.0.27__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.
- clear_skies-2.0.27.dist-info/METADATA +78 -0
- clear_skies-2.0.27.dist-info/RECORD +270 -0
- clear_skies-2.0.27.dist-info/WHEEL +4 -0
- clear_skies-2.0.27.dist-info/licenses/LICENSE +7 -0
- clearskies/__init__.py +69 -0
- clearskies/action.py +7 -0
- clearskies/authentication/__init__.py +15 -0
- clearskies/authentication/authentication.py +44 -0
- clearskies/authentication/authorization.py +23 -0
- clearskies/authentication/authorization_pass_through.py +22 -0
- clearskies/authentication/jwks.py +165 -0
- clearskies/authentication/public.py +5 -0
- clearskies/authentication/secret_bearer.py +551 -0
- clearskies/autodoc/__init__.py +8 -0
- clearskies/autodoc/formats/__init__.py +5 -0
- clearskies/autodoc/formats/oai3_json/__init__.py +7 -0
- clearskies/autodoc/formats/oai3_json/oai3_json.py +87 -0
- clearskies/autodoc/formats/oai3_json/oai3_schema_resolver.py +15 -0
- clearskies/autodoc/formats/oai3_json/parameter.py +35 -0
- clearskies/autodoc/formats/oai3_json/request.py +68 -0
- clearskies/autodoc/formats/oai3_json/response.py +28 -0
- clearskies/autodoc/formats/oai3_json/schema/__init__.py +11 -0
- clearskies/autodoc/formats/oai3_json/schema/array.py +9 -0
- clearskies/autodoc/formats/oai3_json/schema/default.py +13 -0
- clearskies/autodoc/formats/oai3_json/schema/enum.py +7 -0
- clearskies/autodoc/formats/oai3_json/schema/object.py +35 -0
- clearskies/autodoc/formats/oai3_json/test.json +1985 -0
- clearskies/autodoc/py.typed +0 -0
- clearskies/autodoc/request/__init__.py +15 -0
- clearskies/autodoc/request/header.py +6 -0
- clearskies/autodoc/request/json_body.py +6 -0
- clearskies/autodoc/request/parameter.py +8 -0
- clearskies/autodoc/request/request.py +47 -0
- clearskies/autodoc/request/url_parameter.py +6 -0
- clearskies/autodoc/request/url_path.py +6 -0
- clearskies/autodoc/response/__init__.py +5 -0
- clearskies/autodoc/response/response.py +9 -0
- clearskies/autodoc/schema/__init__.py +31 -0
- clearskies/autodoc/schema/array.py +10 -0
- clearskies/autodoc/schema/base64.py +8 -0
- clearskies/autodoc/schema/boolean.py +5 -0
- clearskies/autodoc/schema/date.py +5 -0
- clearskies/autodoc/schema/datetime.py +5 -0
- clearskies/autodoc/schema/double.py +5 -0
- clearskies/autodoc/schema/enum.py +17 -0
- clearskies/autodoc/schema/integer.py +6 -0
- clearskies/autodoc/schema/long.py +5 -0
- clearskies/autodoc/schema/number.py +6 -0
- clearskies/autodoc/schema/object.py +13 -0
- clearskies/autodoc/schema/password.py +5 -0
- clearskies/autodoc/schema/schema.py +11 -0
- clearskies/autodoc/schema/string.py +5 -0
- clearskies/backends/__init__.py +67 -0
- clearskies/backends/api_backend.py +1194 -0
- clearskies/backends/backend.py +137 -0
- clearskies/backends/cursor_backend.py +339 -0
- clearskies/backends/graphql_backend.py +977 -0
- clearskies/backends/memory_backend.py +794 -0
- clearskies/backends/secrets_backend.py +100 -0
- clearskies/clients/__init__.py +5 -0
- clearskies/clients/graphql_client.py +182 -0
- clearskies/column.py +1221 -0
- clearskies/columns/__init__.py +71 -0
- clearskies/columns/audit.py +306 -0
- clearskies/columns/belongs_to_id.py +478 -0
- clearskies/columns/belongs_to_model.py +145 -0
- clearskies/columns/belongs_to_self.py +109 -0
- clearskies/columns/boolean.py +110 -0
- clearskies/columns/category_tree.py +274 -0
- clearskies/columns/category_tree_ancestors.py +51 -0
- clearskies/columns/category_tree_children.py +125 -0
- clearskies/columns/category_tree_descendants.py +48 -0
- clearskies/columns/created.py +92 -0
- clearskies/columns/created_by_authorization_data.py +114 -0
- clearskies/columns/created_by_header.py +103 -0
- clearskies/columns/created_by_ip.py +90 -0
- clearskies/columns/created_by_routing_data.py +102 -0
- clearskies/columns/created_by_user_agent.py +89 -0
- clearskies/columns/date.py +232 -0
- clearskies/columns/datetime.py +284 -0
- clearskies/columns/email.py +78 -0
- clearskies/columns/float.py +149 -0
- clearskies/columns/has_many.py +552 -0
- clearskies/columns/has_many_self.py +62 -0
- clearskies/columns/has_one.py +21 -0
- clearskies/columns/integer.py +158 -0
- clearskies/columns/json.py +126 -0
- clearskies/columns/many_to_many_ids.py +335 -0
- clearskies/columns/many_to_many_ids_with_data.py +281 -0
- clearskies/columns/many_to_many_models.py +163 -0
- clearskies/columns/many_to_many_pivots.py +132 -0
- clearskies/columns/phone.py +162 -0
- clearskies/columns/select.py +95 -0
- clearskies/columns/string.py +102 -0
- clearskies/columns/timestamp.py +164 -0
- clearskies/columns/updated.py +107 -0
- clearskies/columns/uuid.py +83 -0
- clearskies/configs/README.md +105 -0
- clearskies/configs/__init__.py +170 -0
- clearskies/configs/actions.py +43 -0
- clearskies/configs/any.py +15 -0
- clearskies/configs/any_dict.py +24 -0
- clearskies/configs/any_dict_or_callable.py +25 -0
- clearskies/configs/authentication.py +23 -0
- clearskies/configs/authorization.py +23 -0
- clearskies/configs/boolean.py +18 -0
- clearskies/configs/boolean_or_callable.py +20 -0
- clearskies/configs/callable_config.py +20 -0
- clearskies/configs/columns.py +34 -0
- clearskies/configs/conditions.py +30 -0
- clearskies/configs/config.py +26 -0
- clearskies/configs/datetime.py +20 -0
- clearskies/configs/datetime_or_callable.py +21 -0
- clearskies/configs/email.py +10 -0
- clearskies/configs/email_list.py +17 -0
- clearskies/configs/email_list_or_callable.py +17 -0
- clearskies/configs/email_or_email_list_or_callable.py +59 -0
- clearskies/configs/endpoint.py +23 -0
- clearskies/configs/endpoint_list.py +29 -0
- clearskies/configs/float.py +18 -0
- clearskies/configs/float_or_callable.py +20 -0
- clearskies/configs/headers.py +28 -0
- clearskies/configs/integer.py +18 -0
- clearskies/configs/integer_or_callable.py +20 -0
- clearskies/configs/joins.py +30 -0
- clearskies/configs/list_any_dict.py +32 -0
- clearskies/configs/list_any_dict_or_callable.py +33 -0
- clearskies/configs/model_class.py +35 -0
- clearskies/configs/model_column.py +67 -0
- clearskies/configs/model_columns.py +58 -0
- clearskies/configs/model_destination_name.py +26 -0
- clearskies/configs/model_to_id_column.py +45 -0
- clearskies/configs/readable_model_column.py +11 -0
- clearskies/configs/readable_model_columns.py +11 -0
- clearskies/configs/schema.py +23 -0
- clearskies/configs/searchable_model_columns.py +11 -0
- clearskies/configs/security_headers.py +39 -0
- clearskies/configs/select.py +28 -0
- clearskies/configs/select_list.py +49 -0
- clearskies/configs/string.py +31 -0
- clearskies/configs/string_dict.py +34 -0
- clearskies/configs/string_list.py +47 -0
- clearskies/configs/string_list_or_callable.py +48 -0
- clearskies/configs/string_or_callable.py +18 -0
- clearskies/configs/timedelta.py +20 -0
- clearskies/configs/timezone.py +20 -0
- clearskies/configs/url.py +25 -0
- clearskies/configs/validators.py +45 -0
- clearskies/configs/writeable_model_column.py +11 -0
- clearskies/configs/writeable_model_columns.py +11 -0
- clearskies/configurable.py +78 -0
- clearskies/contexts/__init__.py +11 -0
- clearskies/contexts/cli.py +130 -0
- clearskies/contexts/context.py +99 -0
- clearskies/contexts/wsgi.py +79 -0
- clearskies/contexts/wsgi_ref.py +87 -0
- clearskies/cursors/__init__.py +10 -0
- clearskies/cursors/cursor.py +161 -0
- clearskies/cursors/from_environment/__init__.py +5 -0
- clearskies/cursors/from_environment/mysql.py +51 -0
- clearskies/cursors/from_environment/postgresql.py +49 -0
- clearskies/cursors/from_environment/sqlite.py +35 -0
- clearskies/cursors/mysql.py +61 -0
- clearskies/cursors/postgresql.py +61 -0
- clearskies/cursors/sqlite.py +62 -0
- clearskies/decorators.py +33 -0
- clearskies/decorators.pyi +10 -0
- clearskies/di/__init__.py +15 -0
- clearskies/di/additional_config.py +130 -0
- clearskies/di/additional_config_auto_import.py +17 -0
- clearskies/di/di.py +948 -0
- clearskies/di/inject/__init__.py +25 -0
- clearskies/di/inject/akeyless_sdk.py +16 -0
- clearskies/di/inject/by_class.py +24 -0
- clearskies/di/inject/by_name.py +22 -0
- clearskies/di/inject/di.py +16 -0
- clearskies/di/inject/environment.py +15 -0
- clearskies/di/inject/input_output.py +19 -0
- clearskies/di/inject/logger.py +16 -0
- clearskies/di/inject/now.py +16 -0
- clearskies/di/inject/requests.py +16 -0
- clearskies/di/inject/secrets.py +15 -0
- clearskies/di/inject/utcnow.py +16 -0
- clearskies/di/inject/uuid.py +16 -0
- clearskies/di/injectable.py +32 -0
- clearskies/di/injectable_properties.py +131 -0
- clearskies/end.py +219 -0
- clearskies/endpoint.py +1303 -0
- clearskies/endpoint_group.py +333 -0
- clearskies/endpoints/__init__.py +25 -0
- clearskies/endpoints/advanced_search.py +519 -0
- clearskies/endpoints/callable.py +382 -0
- clearskies/endpoints/create.py +201 -0
- clearskies/endpoints/delete.py +133 -0
- clearskies/endpoints/get.py +267 -0
- clearskies/endpoints/health_check.py +181 -0
- clearskies/endpoints/list.py +567 -0
- clearskies/endpoints/restful_api.py +417 -0
- clearskies/endpoints/schema.py +185 -0
- clearskies/endpoints/simple_search.py +279 -0
- clearskies/endpoints/update.py +188 -0
- clearskies/environment.py +106 -0
- clearskies/exceptions/__init__.py +19 -0
- clearskies/exceptions/authentication.py +2 -0
- clearskies/exceptions/authorization.py +2 -0
- clearskies/exceptions/client_error.py +2 -0
- clearskies/exceptions/input_errors.py +4 -0
- clearskies/exceptions/missing_dependency.py +2 -0
- clearskies/exceptions/moved_permanently.py +3 -0
- clearskies/exceptions/moved_temporarily.py +3 -0
- clearskies/exceptions/not_found.py +2 -0
- clearskies/functional/__init__.py +7 -0
- clearskies/functional/json.py +47 -0
- clearskies/functional/routing.py +92 -0
- clearskies/functional/string.py +112 -0
- clearskies/functional/validations.py +76 -0
- clearskies/input_outputs/__init__.py +13 -0
- clearskies/input_outputs/cli.py +157 -0
- clearskies/input_outputs/exceptions/__init__.py +7 -0
- clearskies/input_outputs/exceptions/cli_input_error.py +2 -0
- clearskies/input_outputs/exceptions/cli_not_found.py +2 -0
- clearskies/input_outputs/headers.py +54 -0
- clearskies/input_outputs/input_output.py +116 -0
- clearskies/input_outputs/programmatic.py +62 -0
- clearskies/input_outputs/py.typed +0 -0
- clearskies/input_outputs/wsgi.py +80 -0
- clearskies/loggable.py +19 -0
- clearskies/model.py +2039 -0
- clearskies/py.typed +0 -0
- clearskies/query/__init__.py +12 -0
- clearskies/query/condition.py +228 -0
- clearskies/query/join.py +136 -0
- clearskies/query/query.py +195 -0
- clearskies/query/sort.py +27 -0
- clearskies/schema.py +82 -0
- clearskies/secrets/__init__.py +7 -0
- clearskies/secrets/additional_configs/__init__.py +32 -0
- clearskies/secrets/additional_configs/mysql_connection_dynamic_producer.py +61 -0
- clearskies/secrets/additional_configs/mysql_connection_dynamic_producer_via_ssh_cert_bastion.py +160 -0
- clearskies/secrets/akeyless.py +507 -0
- clearskies/secrets/exceptions/__init__.py +7 -0
- clearskies/secrets/exceptions/not_found_error.py +2 -0
- clearskies/secrets/exceptions/permissions_error.py +2 -0
- clearskies/secrets/secrets.py +39 -0
- clearskies/security_header.py +17 -0
- clearskies/security_headers/__init__.py +11 -0
- clearskies/security_headers/cache_control.py +68 -0
- clearskies/security_headers/cors.py +51 -0
- clearskies/security_headers/csp.py +95 -0
- clearskies/security_headers/hsts.py +23 -0
- clearskies/security_headers/x_content_type_options.py +0 -0
- clearskies/security_headers/x_frame_options.py +0 -0
- clearskies/typing.py +11 -0
- clearskies/validator.py +36 -0
- clearskies/validators/__init__.py +33 -0
- clearskies/validators/after_column.py +61 -0
- clearskies/validators/before_column.py +15 -0
- clearskies/validators/in_the_future.py +29 -0
- clearskies/validators/in_the_future_at_least.py +13 -0
- clearskies/validators/in_the_future_at_most.py +12 -0
- clearskies/validators/in_the_past.py +29 -0
- clearskies/validators/in_the_past_at_least.py +12 -0
- clearskies/validators/in_the_past_at_most.py +12 -0
- clearskies/validators/maximum_length.py +25 -0
- clearskies/validators/maximum_value.py +28 -0
- clearskies/validators/minimum_length.py +25 -0
- clearskies/validators/minimum_value.py +28 -0
- clearskies/validators/required.py +32 -0
- clearskies/validators/timedelta.py +58 -0
- clearskies/validators/unique.py +28 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Callable, Self, overload
|
|
4
|
+
|
|
5
|
+
from clearskies import configs, decorators
|
|
6
|
+
from clearskies.autodoc.schema import Array as AutoDocArray
|
|
7
|
+
from clearskies.autodoc.schema import String as AutoDocString
|
|
8
|
+
from clearskies.column import Column
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from clearskies import Column, Model, typing
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ManyToManyIds(Column):
|
|
15
|
+
"""
|
|
16
|
+
A column that represents a many-to-many relationship.
|
|
17
|
+
|
|
18
|
+
This is different from belongs to/has many because with those, every child has only one parent. With a many-to-many
|
|
19
|
+
relationship, both models can have multiple relatives from the other model class. In order to support this, it's necessary
|
|
20
|
+
to have a third model (the pivot model) that records the relationships. In general this table just needs three
|
|
21
|
+
columns: it's own id, and then one column for each other model to store the id of the related records.
|
|
22
|
+
You can specify the names of these columns but it also follows the standard naming convention by default:
|
|
23
|
+
take the class name, convert it to snake case, and append `_id`.
|
|
24
|
+
|
|
25
|
+
Note, there is a variation on this (`ManyToManyIdsWithData`) where additional data is stored in the pivot table
|
|
26
|
+
to record information about the relationship.
|
|
27
|
+
|
|
28
|
+
This column is writeable. You would set it to a list of ids from the related model that denotes which
|
|
29
|
+
records it is related to.
|
|
30
|
+
|
|
31
|
+
The following example shows usage. Normally the many-to-many column exists for both related models, but in this
|
|
32
|
+
specific example it only exists for one of the models. This is done so that the example can fit in a single file
|
|
33
|
+
and therefore be easy to demonstrate. In order to have both models reference eachother, you have to use model
|
|
34
|
+
references to avoid circular imports. There are examples of doing this in the `BelongsTo` column class.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import clearskies
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ThingyToWidget(clearskies.Model):
|
|
41
|
+
id_column_name = "id"
|
|
42
|
+
backend = clearskies.backends.MemoryBackend()
|
|
43
|
+
|
|
44
|
+
id = clearskies.columns.Uuid()
|
|
45
|
+
# these could also be belongs to relationships, but the pivot model
|
|
46
|
+
# is rarely used directly, so I'm being lazy to avoid having to use
|
|
47
|
+
# model references.
|
|
48
|
+
thingy_id = clearskies.columns.String()
|
|
49
|
+
widget_id = clearskies.columns.String()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Thingy(clearskies.Model):
|
|
53
|
+
id_column_name = "id"
|
|
54
|
+
backend = clearskies.backends.MemoryBackend()
|
|
55
|
+
|
|
56
|
+
id = clearskies.columns.Uuid()
|
|
57
|
+
name = clearskies.columns.String()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Widget(clearskies.Model):
|
|
61
|
+
id_column_name = "id"
|
|
62
|
+
backend = clearskies.backends.MemoryBackend()
|
|
63
|
+
|
|
64
|
+
id = clearskies.columns.Uuid()
|
|
65
|
+
name = clearskies.columns.String()
|
|
66
|
+
thingy_ids = clearskies.columns.ManyToManyIds(
|
|
67
|
+
related_model_class=Thingy,
|
|
68
|
+
pivot_model_class=ThingyToWidget,
|
|
69
|
+
)
|
|
70
|
+
thingies = clearskies.columns.ManyToManyModels("thingy_ids")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def my_application(widgets: Widget, thingies: Thingy):
|
|
74
|
+
thing_1 = thingies.create({"name": "Thing 1"})
|
|
75
|
+
thing_2 = thingies.create({"name": "Thing 2"})
|
|
76
|
+
thing_3 = thingies.create({"name": "Thing 3"})
|
|
77
|
+
widget = widgets.create(
|
|
78
|
+
{
|
|
79
|
+
"name": "Widget 1",
|
|
80
|
+
"thingy_ids": [thing_1.id, thing_2.id],
|
|
81
|
+
}
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# remove an item by saving without it's id in place
|
|
85
|
+
widget.save({"thingy_ids": [thing.id for thing in widget.thingies if thing.id != thing_1.id]})
|
|
86
|
+
|
|
87
|
+
# add an item by saving and adding the new id
|
|
88
|
+
widget.save({"thingy_ids": [*widget.thingy_ids, thing_3.id]})
|
|
89
|
+
|
|
90
|
+
return widget.thingies
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
cli = clearskies.contexts.Cli(
|
|
94
|
+
clearskies.endpoints.Callable(
|
|
95
|
+
my_application,
|
|
96
|
+
model_class=Thingy,
|
|
97
|
+
return_records=True,
|
|
98
|
+
readable_column_names=["id", "name"],
|
|
99
|
+
),
|
|
100
|
+
classes=[Widget, Thingy, ThingyToWidget],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if __name__ == "__main__":
|
|
104
|
+
cli()
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
And when executed:
|
|
108
|
+
|
|
109
|
+
```json
|
|
110
|
+
{
|
|
111
|
+
"status": "success",
|
|
112
|
+
"error": "",
|
|
113
|
+
"data": [
|
|
114
|
+
{"id": "741bc838-c694-4624-9fc2-e9032f6cb962", "name": "Thing 2"},
|
|
115
|
+
{"id": "1808a8ef-e288-44e6-9fed-46e3b0df057f", "name": "Thing 3"},
|
|
116
|
+
],
|
|
117
|
+
"pagination": {},
|
|
118
|
+
"input_errors": {},
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Of course, you can also create or remove individual relationships by using the pivot model directly,
|
|
123
|
+
as shown in these partial code snippets:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
def add_items(thingy_to_widgets):
|
|
127
|
+
thingy_to_widgets.create({
|
|
128
|
+
"thingy_id": "some_id",
|
|
129
|
+
"widget_id": "other_id",
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def remove_item(thingy_to_widgets):
|
|
134
|
+
thingy_to_widgets.where("thingy_id=some_id").where("widget_id=other_id").first().delete()
|
|
135
|
+
```
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
""" The model class for the model that we are related to. """
|
|
139
|
+
related_model_class = configs.ModelClass(required=True)
|
|
140
|
+
|
|
141
|
+
""" The model class for the pivot table - the table used to record connections between ourselves and our related table. """
|
|
142
|
+
pivot_model_class = configs.ModelClass(required=True)
|
|
143
|
+
|
|
144
|
+
"""
|
|
145
|
+
The name of the column in the pivot table that contains the id of records from the model with this column.
|
|
146
|
+
|
|
147
|
+
A default name is created by taking the model class name, converting it to snake case, and then appending `_id`.
|
|
148
|
+
If you name your columns according to this standard then you don't have to specify this column name.
|
|
149
|
+
"""
|
|
150
|
+
own_column_name_in_pivot = configs.ModelToIdColumn(model_column_config_name="pivot_model_class")
|
|
151
|
+
|
|
152
|
+
"""
|
|
153
|
+
The name of the column in the pivot table that contains the id of records from the related table.
|
|
154
|
+
|
|
155
|
+
A default name is created by taking the name of the related model class, converting it to snake case, and then
|
|
156
|
+
appending `_id`. If you name your columns according to this standard then you don't have to specify this column
|
|
157
|
+
name.
|
|
158
|
+
"""
|
|
159
|
+
related_column_name_in_pivot = configs.ModelToIdColumn(
|
|
160
|
+
model_column_config_name="pivot_model_class", source_model_class_config_name="related_model_class"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
""" The name of the pivot table."""
|
|
164
|
+
pivot_table_name = configs.ModelDestinationName("pivot_model_class")
|
|
165
|
+
|
|
166
|
+
""" The list of columns to be loaded from the related models when we are converted to JSON. """
|
|
167
|
+
readable_related_column_names = configs.ReadableModelColumns("related_model_class")
|
|
168
|
+
|
|
169
|
+
default = configs.StringList(default=None) # type: ignore
|
|
170
|
+
setable = configs.StringListOrCallable(default=None) # type: ignore
|
|
171
|
+
is_searchable = configs.Boolean(default=False)
|
|
172
|
+
_descriptor_config_map = None
|
|
173
|
+
|
|
174
|
+
@decorators.parameters_to_properties
|
|
175
|
+
def __init__(
|
|
176
|
+
self,
|
|
177
|
+
related_model_class,
|
|
178
|
+
pivot_model_class,
|
|
179
|
+
own_column_name_in_pivot: str = "",
|
|
180
|
+
related_column_name_in_pivot: str = "",
|
|
181
|
+
readable_related_column_names: list[str] = [],
|
|
182
|
+
default: list[str] = [],
|
|
183
|
+
setable: list[str] | Callable[..., list[str]] = [],
|
|
184
|
+
is_readable: bool = True,
|
|
185
|
+
is_writeable: bool = True,
|
|
186
|
+
is_temporary: bool = False,
|
|
187
|
+
validators: typing.validator | list[typing.validator] = [],
|
|
188
|
+
on_change_pre_save: typing.action | list[typing.action] = [],
|
|
189
|
+
on_change_post_save: typing.action | list[typing.action] = [],
|
|
190
|
+
on_change_save_finished: typing.action | list[typing.action] = [],
|
|
191
|
+
created_by_source_type: str = "",
|
|
192
|
+
created_by_source_key: str = "",
|
|
193
|
+
created_by_source_strict: bool = True,
|
|
194
|
+
):
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
def finalize_configuration(self, model_class: type, name: str) -> None:
|
|
198
|
+
"""
|
|
199
|
+
Finalize and check the configuration.
|
|
200
|
+
|
|
201
|
+
This is an external trigger called by the model class when the model class is ready.
|
|
202
|
+
The reason it exists here instead of in the constructor is because some columns are tightly
|
|
203
|
+
connected to the model class, and can't validate configuration until they know what the model is.
|
|
204
|
+
Therefore, we need the model involved, and the only way for a property to know what class it is
|
|
205
|
+
in is if the parent class checks in (which is what happens here).
|
|
206
|
+
"""
|
|
207
|
+
self.model_class = model_class
|
|
208
|
+
self.name = name
|
|
209
|
+
getattr(self.__class__, "pivot_table_name").finalize_and_validate_configuration(self)
|
|
210
|
+
own_column_name_in_pivot_config = getattr(self.__class__, "own_column_name_in_pivot")
|
|
211
|
+
own_column_name_in_pivot_config.source_model_class = model_class
|
|
212
|
+
own_column_name_in_pivot_config.finalize_and_validate_configuration(self)
|
|
213
|
+
self.finalize_and_validate_configuration()
|
|
214
|
+
|
|
215
|
+
def to_backend(self, data):
|
|
216
|
+
# we can't persist our mapping data to the database directly, so remove anything here
|
|
217
|
+
# and take care of things in post_save
|
|
218
|
+
if self.name in data:
|
|
219
|
+
del data[self.name]
|
|
220
|
+
return data
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def pivot_model(self) -> Model:
|
|
224
|
+
return self.di.build(self.pivot_model_class, cache=True)
|
|
225
|
+
|
|
226
|
+
@property
|
|
227
|
+
def related_model(self) -> Model:
|
|
228
|
+
return self.di.build(self.related_model_class, cache=True)
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
def related_columns(self) -> dict[str, Column]:
|
|
232
|
+
return self.related_model.get_columns()
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def pivot_columns(self) -> dict[str, Column]:
|
|
236
|
+
return self.pivot_model.get_columns()
|
|
237
|
+
|
|
238
|
+
@overload
|
|
239
|
+
def __get__(self, instance: None, cls: type[Model]) -> Self:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
@overload
|
|
243
|
+
def __get__(self, instance: Model, cls: type[Model]) -> list[str | int]:
|
|
244
|
+
pass
|
|
245
|
+
|
|
246
|
+
def __get__(self, instance, cls):
|
|
247
|
+
if instance is None:
|
|
248
|
+
self.model_class = cls
|
|
249
|
+
return self
|
|
250
|
+
|
|
251
|
+
# this makes sure we're initialized
|
|
252
|
+
if "name" not in self._config: # type: ignore
|
|
253
|
+
instance.get_columns()
|
|
254
|
+
|
|
255
|
+
related_id_column_name = self.related_model_class.id_column_name
|
|
256
|
+
return [getattr(model, related_id_column_name) for model in self.get_related_models(instance)]
|
|
257
|
+
|
|
258
|
+
def __set__(self, instance, value: list[str | int]) -> None:
|
|
259
|
+
# this makes sure we're initialized
|
|
260
|
+
if "name" not in self._config: # type: ignore
|
|
261
|
+
instance.get_columns()
|
|
262
|
+
|
|
263
|
+
instance._next_data[self.name] = value
|
|
264
|
+
|
|
265
|
+
def get_related_models(self, model: Model) -> Model:
|
|
266
|
+
related_column_name_in_pivot = self.related_column_name_in_pivot
|
|
267
|
+
own_column_name_in_pivot = self.own_column_name_in_pivot
|
|
268
|
+
pivot_table_name = self.pivot_table_name
|
|
269
|
+
related_id_column_name = self.related_model_class.id_column_name
|
|
270
|
+
model_id = getattr(model, self.model_class.id_column_name)
|
|
271
|
+
model = self.related_model
|
|
272
|
+
join = f"JOIN {pivot_table_name} ON {pivot_table_name}.{related_column_name_in_pivot}={model.destination_name()}.{related_id_column_name}"
|
|
273
|
+
related_models = model.join(join).where(f"{pivot_table_name}.{own_column_name_in_pivot}={model_id}")
|
|
274
|
+
return related_models
|
|
275
|
+
|
|
276
|
+
def get_pivot_models(self, model: Model) -> Model:
|
|
277
|
+
return self.pivot_model.where(
|
|
278
|
+
f"{self.own_column_name_in_pivot}=" + getattr(model, self.model_class.id_column_name)
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def post_save(self, data: dict[str, Any], model: Model, id: int | str) -> None:
|
|
282
|
+
# if our incoming data is not in the data array or is None, then nothing has been set and we do not want
|
|
283
|
+
# to make any changes
|
|
284
|
+
if self.name not in data or data[self.name] is None:
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
# figure out what ids need to be created or deleted from the pivot table.
|
|
288
|
+
if not model:
|
|
289
|
+
old_ids = set()
|
|
290
|
+
else:
|
|
291
|
+
old_ids = set(self.__get__(model, model.__class__))
|
|
292
|
+
|
|
293
|
+
new_ids = set(data[self.name])
|
|
294
|
+
to_delete = old_ids - new_ids
|
|
295
|
+
to_create = new_ids - old_ids
|
|
296
|
+
pivot_model = self.pivot_model.as_query()
|
|
297
|
+
related_column_name_in_pivot = self.related_column_name_in_pivot
|
|
298
|
+
if to_delete:
|
|
299
|
+
for model_to_delete in pivot_model.where(
|
|
300
|
+
f"{related_column_name_in_pivot} IN ({','.join(map(str, to_delete))})"
|
|
301
|
+
):
|
|
302
|
+
model_to_delete.delete()
|
|
303
|
+
if to_create:
|
|
304
|
+
own_column_name_in_pivot = self.own_column_name_in_pivot
|
|
305
|
+
for id_to_create in to_create:
|
|
306
|
+
pivot_model.create(
|
|
307
|
+
{
|
|
308
|
+
related_column_name_in_pivot: id_to_create,
|
|
309
|
+
own_column_name_in_pivot: id,
|
|
310
|
+
}
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
super().post_save(data, model, id)
|
|
314
|
+
|
|
315
|
+
def add_search(self, model: Model, value: str, operator: str = "", relationship_reference: str = "") -> Model:
|
|
316
|
+
related_column_name_in_pivot = self.related_column_name_in_pivot
|
|
317
|
+
own_column_name_in_pivot = self.own_column_name_in_pivot
|
|
318
|
+
own_id_column_name = self.model_class.id_column_name
|
|
319
|
+
pivot_table_name = self.pivot_table_name
|
|
320
|
+
my_table_name = self.model_class.destination_name()
|
|
321
|
+
related_table_name = self.related_model.destination_name()
|
|
322
|
+
join_pivot = f"JOIN {pivot_table_name} ON {pivot_table_name}.{own_column_name_in_pivot}={my_table_name}.{own_id_column_name}"
|
|
323
|
+
# no reason we can't support searching by both an id or a list of ids
|
|
324
|
+
values = value if type(value) == list else [value]
|
|
325
|
+
search = " IN (" + ", ".join([str(val) for val in value]) + ")"
|
|
326
|
+
return model.join(join_pivot).where(f"{pivot_table_name}.{related_column_name_in_pivot}{search}")
|
|
327
|
+
|
|
328
|
+
def to_json(self, model: Model) -> dict[str, Any]:
|
|
329
|
+
related_id_column_name = self.related_model_class.id_column_name
|
|
330
|
+
records = [getattr(related, related_id_column_name) for related in self.get_related_models(model)]
|
|
331
|
+
return {self.name: records}
|
|
332
|
+
|
|
333
|
+
def documentation(self, name: str | None = None, example: str | None = None, value: str | None = None):
|
|
334
|
+
related_id_column_name = self.related_model_class.id_column_name
|
|
335
|
+
return AutoDocArray(name if name is not None else self.name, AutoDocString(related_id_column_name))
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Self, overload
|
|
5
|
+
|
|
6
|
+
from clearskies import configs, decorators
|
|
7
|
+
from clearskies.columns.many_to_many_ids import ManyToManyIds
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from clearskies import Model, typing
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ManyToManyIdsWithData(ManyToManyIds):
|
|
14
|
+
"""
|
|
15
|
+
A column to represent a many-to-many relationship with information stored in the relationship itself.
|
|
16
|
+
|
|
17
|
+
This is an extention of the many-to-many column, but with one important addition: data about the
|
|
18
|
+
relationship is stored in the pivot table. This creates some differences, which are best
|
|
19
|
+
explained by example:
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import clearskies
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ThingyWidgets(clearskies.Model):
|
|
26
|
+
id_column_name = "id"
|
|
27
|
+
backend = clearskies.backends.MemoryBackend()
|
|
28
|
+
|
|
29
|
+
id = clearskies.columns.Uuid()
|
|
30
|
+
# these could also be belongs to relationships, but the pivot model
|
|
31
|
+
# is rarely used directly, so I'm being lazy to avoid having to use
|
|
32
|
+
# model references.
|
|
33
|
+
thingy_id = clearskies.columns.String()
|
|
34
|
+
widget_id = clearskies.columns.String()
|
|
35
|
+
name = clearskies.columns.String()
|
|
36
|
+
kind = clearskies.columns.String()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Thingy(clearskies.Model):
|
|
40
|
+
id_column_name = "id"
|
|
41
|
+
backend = clearskies.backends.MemoryBackend()
|
|
42
|
+
|
|
43
|
+
id = clearskies.columns.Uuid()
|
|
44
|
+
name = clearskies.columns.String()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Widget(clearskies.Model):
|
|
48
|
+
id_column_name = "id"
|
|
49
|
+
backend = clearskies.backends.MemoryBackend()
|
|
50
|
+
|
|
51
|
+
id = clearskies.columns.Uuid()
|
|
52
|
+
name = clearskies.columns.String()
|
|
53
|
+
thingy_ids = clearskies.columns.ManyToManyIdsWithData(
|
|
54
|
+
related_model_class=Thingy,
|
|
55
|
+
pivot_model_class=ThingyWidgets,
|
|
56
|
+
readable_pivot_column_names=["id", "thingy_id", "widget_id", "name", "kind"],
|
|
57
|
+
)
|
|
58
|
+
thingies = clearskies.columns.ManyToManyModels("thingy_ids")
|
|
59
|
+
thingy_widgets = clearskies.columns.ManyToManyPivots("thingy_ids")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def my_application(widgets: Widget, thingies: Thingy):
|
|
63
|
+
thing_1 = thingies.create({"name": "Thing 1"})
|
|
64
|
+
thing_2 = thingies.create({"name": "Thing 2"})
|
|
65
|
+
thing_3 = thingies.create({"name": "Thing 3"})
|
|
66
|
+
widget = widgets.create(
|
|
67
|
+
{
|
|
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
|
+
|
|
76
|
+
return widget
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
cli = clearskies.contexts.Cli(
|
|
80
|
+
clearskies.endpoints.Callable(
|
|
81
|
+
my_application,
|
|
82
|
+
model_class=Widget,
|
|
83
|
+
return_records=True,
|
|
84
|
+
readable_column_names=["id", "name", "thingy_widgets"],
|
|
85
|
+
),
|
|
86
|
+
classes=[Widget, Thingy, ThingyWidgets],
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if __name__ == "__main__":
|
|
90
|
+
cli()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
As with setting ids in the ManyToManyIds class, any items left out will result in the relationship
|
|
94
|
+
(including all its related data) being removed. An important difference with the ManyToManyWithData
|
|
95
|
+
column is the way you specify which record is being connected. This is easy for the ManyToManyIds column
|
|
96
|
+
because all you provide is the id from the related model. When working with the ManyToManyWithData
|
|
97
|
+
column, you provide a dictionary for each relationship (so you can provide the data that goes in the
|
|
98
|
+
pivot model). To let it know what record is being connected, you therefore explicitly provide
|
|
99
|
+
the id from the related model in a dictionary key with the name of the related model id column in
|
|
100
|
+
the pivot (e.g. `{"thingy_id": id}` in the first example. However, if there are unique columns in the
|
|
101
|
+
related model, you can provide those instead. If you execute the above example you'll get:
|
|
102
|
+
|
|
103
|
+
```json
|
|
104
|
+
{
|
|
105
|
+
"status": "success",
|
|
106
|
+
"error": "",
|
|
107
|
+
"data": {
|
|
108
|
+
"id": "c4be91a8-85a1-4e29-994a-327f59e26ec7",
|
|
109
|
+
"name": "Widget 1",
|
|
110
|
+
"thingy_widgets": [
|
|
111
|
+
{
|
|
112
|
+
"id": "3a8f6f14-9657-49d8-8844-0db3452525fe",
|
|
113
|
+
"thingy_id": "db292ebc-7b2b-4306-aced-8e6d073ec264",
|
|
114
|
+
"widget_id": "c4be91a8-85a1-4e29-994a-327f59e26ec7",
|
|
115
|
+
"name": "Widget Thing 1",
|
|
116
|
+
"kind": "Special",
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"id": "480a0192-70d9-4363-a669-4a59f0b56730",
|
|
120
|
+
"thingy_id": "d469dbe9-556e-46f3-bc48-03f8cb8d8e44",
|
|
121
|
+
"widget_id": "c4be91a8-85a1-4e29-994a-327f59e26ec7",
|
|
122
|
+
"name": "Widget Thing 2",
|
|
123
|
+
"kind": "Also Special",
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
"pagination": {},
|
|
128
|
+
"input_errors": {},
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
""" The list of columns in the pivot model that can be set when saving data from an endpoint. """
|
|
134
|
+
setable_column_names = configs.WriteableModelColumns("pivot_model_class")
|
|
135
|
+
|
|
136
|
+
""" The list of columns in the pivot model that will be included when returning records from an endpoint. """
|
|
137
|
+
readable_pivot_column_names = configs.ReadableModelColumns("pivot_model_class")
|
|
138
|
+
|
|
139
|
+
"""
|
|
140
|
+
Complicated, but probably should be false.
|
|
141
|
+
|
|
142
|
+
Sometimes you have to provide data from the related model class in your save data so that
|
|
143
|
+
clearskies can find the right record. Normally, this lookup column is not persisted to the
|
|
144
|
+
pivot table, because it is assumed to only exist in the related table. In some cases though,
|
|
145
|
+
you may want it in both, in which case you can set this to true.
|
|
146
|
+
"""
|
|
147
|
+
persist_unique_lookup_column_to_pivot_table = configs.Boolean(default=False)
|
|
148
|
+
|
|
149
|
+
default = configs.ListAnyDict(default=None) # type: ignore
|
|
150
|
+
setable = configs.ListAnyDictOrCallable(default=None) # type: ignore
|
|
151
|
+
_descriptor_config_map = None
|
|
152
|
+
|
|
153
|
+
@decorators.parameters_to_properties
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
related_model_class,
|
|
157
|
+
pivot_model_class,
|
|
158
|
+
own_column_name_in_pivot: str = "",
|
|
159
|
+
related_column_name_in_pivot: str = "",
|
|
160
|
+
readable_related_columns: list[str] = [],
|
|
161
|
+
readable_pivot_column_names: list[str] = [],
|
|
162
|
+
setable_column_names: list[str] = [],
|
|
163
|
+
persist_unique_lookup_column_to_pivot_table: bool = False,
|
|
164
|
+
default: list[dict[str, Any]] = [],
|
|
165
|
+
setable: list[dict[str, Any]] | Callable[..., list[dict[str, Any]]] = [],
|
|
166
|
+
is_readable: bool = True,
|
|
167
|
+
is_writeable: bool = True,
|
|
168
|
+
is_temporary: bool = False,
|
|
169
|
+
validators: typing.validator | list[typing.validator] = [],
|
|
170
|
+
on_change_pre_save: typing.action | list[typing.action] = [],
|
|
171
|
+
on_change_post_save: typing.action | list[typing.action] = [],
|
|
172
|
+
on_change_save_finished: typing.action | list[typing.action] = [],
|
|
173
|
+
created_by_source_type: str = "",
|
|
174
|
+
created_by_source_key: str = "",
|
|
175
|
+
created_by_source_strict: bool = True,
|
|
176
|
+
):
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
@overload
|
|
180
|
+
def __get__(self, instance: None, cls: type[Model]) -> Self:
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
@overload
|
|
184
|
+
def __get__(self, instance: Model, cls: type[Model]) -> list[Any]:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
def __get__(self, instance, cls):
|
|
188
|
+
return super().__get__(instance, cls)
|
|
189
|
+
|
|
190
|
+
def __set__(self, instance, value: list[dict[str, Any]]) -> None: # type: ignore
|
|
191
|
+
# this makes sure we're initialized
|
|
192
|
+
if "name" not in self._config: # type: ignore
|
|
193
|
+
instance.get_columns()
|
|
194
|
+
|
|
195
|
+
instance._next_data[self.name] = value
|
|
196
|
+
|
|
197
|
+
def post_save(self, data, model, id):
|
|
198
|
+
# if our incoming data is not in the data array or is None, then nothing has been set and we do not want
|
|
199
|
+
# to make any changes
|
|
200
|
+
if self.name not in data or data[self.name] is None:
|
|
201
|
+
return data
|
|
202
|
+
|
|
203
|
+
# figure out what ids need to be created or deleted from the pivot table.
|
|
204
|
+
if not model:
|
|
205
|
+
old_ids = set()
|
|
206
|
+
else:
|
|
207
|
+
old_ids = set(self.__get__(model, model.__class__))
|
|
208
|
+
|
|
209
|
+
# this is trickier for many-to-many-with-data compared to many-to-many. We're generally
|
|
210
|
+
# expecting data[self.name] to be a list of dictionaries. For each entry, we need to find
|
|
211
|
+
# the corresponding entry in the pivot table to decide if we need to delete, create, or update.
|
|
212
|
+
# However, since we have a dictionary there are a variety of ways that we can connect to
|
|
213
|
+
# an entry in the related table - either related id or any unique column from the related
|
|
214
|
+
# table. Technically we might also specify a pivot id, but we're generally trying to be
|
|
215
|
+
# transparent to those, so let's ignore that one.
|
|
216
|
+
related_column_name_in_pivot = self.related_column_name_in_pivot
|
|
217
|
+
own_column_name_in_pivot = self.own_column_name_in_pivot
|
|
218
|
+
unique_related_columns = {
|
|
219
|
+
column.name: column.name for column in self.related_columns.values() if column.is_unique
|
|
220
|
+
}
|
|
221
|
+
related_model = self.related_model
|
|
222
|
+
pivot_model = self.pivot_model.as_query()
|
|
223
|
+
# minor cheating
|
|
224
|
+
if hasattr(pivot_model.backend, "create_table"):
|
|
225
|
+
pivot_model.backend.create_table(pivot_model)
|
|
226
|
+
new_ids = set()
|
|
227
|
+
for pivot_record in data[self.name]:
|
|
228
|
+
# first we need to identify which related column this belongs to.
|
|
229
|
+
related_column_id = None
|
|
230
|
+
|
|
231
|
+
# if the pivot record is a model, convert to dict
|
|
232
|
+
if hasattr(pivot_record, related_model.id_column_name):
|
|
233
|
+
pivot_record = pivot_record.get_columns_data()
|
|
234
|
+
|
|
235
|
+
# if they provide the related column id in the pivot data then we're good
|
|
236
|
+
if related_column_name_in_pivot in pivot_record:
|
|
237
|
+
related_column_id = pivot_record[related_column_name_in_pivot]
|
|
238
|
+
elif len(unique_related_columns):
|
|
239
|
+
for pivot_column, pivot_value in pivot_record.items():
|
|
240
|
+
if pivot_column not in unique_related_columns:
|
|
241
|
+
continue
|
|
242
|
+
related = related_model.find(f"{pivot_column}={pivot_value}")
|
|
243
|
+
related_column_id = getattr(related, related.id_column_name)
|
|
244
|
+
if related_column_id:
|
|
245
|
+
# remove this column from the data - it was used to lookup the right
|
|
246
|
+
# record, but mostly won't exist in the model, unless we've been instructed
|
|
247
|
+
# to keep it
|
|
248
|
+
if not self._config.get("persist_unique_lookup_column_to_pivot_table"): # type: ignore
|
|
249
|
+
del pivot_record[pivot_column]
|
|
250
|
+
break
|
|
251
|
+
if not related_column_id:
|
|
252
|
+
column_list = "'" + "', '".join(list(unique_related_columns.keys())) + "'"
|
|
253
|
+
raise ValueError(
|
|
254
|
+
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}"
|
|
255
|
+
)
|
|
256
|
+
pivot = (
|
|
257
|
+
pivot_model.where(f"{related_column_name_in_pivot}={related_column_id}")
|
|
258
|
+
.where(f"{own_column_name_in_pivot}={id}")
|
|
259
|
+
.first()
|
|
260
|
+
)
|
|
261
|
+
new_ids.add(related_column_id)
|
|
262
|
+
# this will either update or create accordingly
|
|
263
|
+
|
|
264
|
+
pivot.save(
|
|
265
|
+
{
|
|
266
|
+
**pivot_record,
|
|
267
|
+
related_column_name_in_pivot: related_column_id,
|
|
268
|
+
own_column_name_in_pivot: id,
|
|
269
|
+
}
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# the above took care of inserting and updating active records. Now we need to delete
|
|
273
|
+
# records that are no longer needed.
|
|
274
|
+
to_delete = old_ids - new_ids
|
|
275
|
+
if to_delete:
|
|
276
|
+
for model_to_delete in pivot_model.where(
|
|
277
|
+
f"{related_column_name_in_pivot} IN (" + ",".join(map(str, to_delete)) + ")"
|
|
278
|
+
):
|
|
279
|
+
model_to_delete.delete()
|
|
280
|
+
|
|
281
|
+
return data
|