django-structured-json-field 1.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_structured_json_field-1.6.0.dist-info/METADATA +252 -0
- django_structured_json_field-1.6.0.dist-info/RECORD +48 -0
- django_structured_json_field-1.6.0.dist-info/WHEEL +5 -0
- django_structured_json_field-1.6.0.dist-info/licenses/LICENSE +21 -0
- django_structured_json_field-1.6.0.dist-info/top_level.txt +1 -0
- structured/__init__.py +13 -0
- structured/apps.py +7 -0
- structured/cache/__init__.py +10 -0
- structured/cache/cache.py +257 -0
- structured/cache/engine.py +314 -0
- structured/cache/rel_info.py +20 -0
- structured/contrib/restframework.py +71 -0
- structured/fields.py +264 -0
- structured/management/__init__.py +0 -0
- structured/management/commands/__init__.py +0 -0
- structured/management/commands/generate_schema_migration.py +165 -0
- structured/orm.py +658 -0
- structured/pydantic/__init__.py +0 -0
- structured/pydantic/conditionals.py +260 -0
- structured/pydantic/fields/__init__.py +8 -0
- structured/pydantic/fields/foreignkey.py +128 -0
- structured/pydantic/fields/queryset.py +153 -0
- structured/pydantic/fields/serializer.py +38 -0
- structured/pydantic/models.py +92 -0
- structured/schema_migrations/__init__.py +0 -0
- structured/schema_migrations/structured_json_migration.py +315 -0
- structured/settings.py +32 -0
- structured/static/js/structured-field-init.js +36 -0
- structured/templates/json-forms/widget.html +21 -0
- structured/urls.py +6 -0
- structured/utils/__init__.py +0 -0
- structured/utils/cast.py +32 -0
- structured/utils/context.py +16 -0
- structured/utils/dict.py +7 -0
- structured/utils/django.py +33 -0
- structured/utils/errors.py +31 -0
- structured/utils/getter.py +17 -0
- structured/utils/namespace.py +22 -0
- structured/utils/options.py +31 -0
- structured/utils/pydantic.py +80 -0
- structured/utils/replace.py +12 -0
- structured/utils/serializer.py +99 -0
- structured/utils/setter.py +40 -0
- structured/utils/typing.py +85 -0
- structured/views.py +76 -0
- structured/widget/__init__.py +0 -0
- structured/widget/fields.py +68 -0
- structured/widget/widgets.py +110 -0
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-structured-json-field
|
|
3
|
+
Version: 1.6.0
|
|
4
|
+
Summary: Django json field empowered by pydantic
|
|
5
|
+
Author-email: Lotrèk <gabriele@lotrek.it>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/lotrekagency/django-structured-json-field
|
|
8
|
+
Keywords: django,pydantic,django pydantic,django json,json schema,django form
|
|
9
|
+
Classifier: Environment :: Web Environment
|
|
10
|
+
Classifier: Framework :: Django
|
|
11
|
+
Classifier: Framework :: Django :: 4.2
|
|
12
|
+
Classifier: Framework :: Django :: 5.0
|
|
13
|
+
Classifier: Framework :: Django :: 5.1
|
|
14
|
+
Classifier: Framework :: Django :: 5.2
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: djangorestframework<4.0.0,>=3.14.0
|
|
27
|
+
Requires-Dist: Django>=4.2
|
|
28
|
+
Requires-Dist: pydantic>=2.12
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# Django Structured JSON Field [](https://pypi.org/project/django-structured-json-field)   [](./LICENSE) [](https://bnznamco.github.io/django-structured-field/)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
<br>
|
|
35
|
+
<br>
|
|
36
|
+
<p align="center">
|
|
37
|
+
<picture>
|
|
38
|
+
<source media="(prefers-color-scheme: dark)" srcset="docs/.vuepress/public/images/logo-dark.svg" width="200">
|
|
39
|
+
<source media="(prefers-color-scheme: light)" srcset="docs/.vuepress/public/images/logo.svg" width="200">
|
|
40
|
+
<img alt="Django Structured JSON Field logo" src="docs/.vuepress/public/images/logo.svg" width="200">
|
|
41
|
+
</picture>
|
|
42
|
+
</p>
|
|
43
|
+
<br>
|
|
44
|
+
<br>
|
|
45
|
+
<br>
|
|
46
|
+
<br>
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
This is a Django field that allows you to declare the structure of a JSON field and validate it.
|
|
51
|
+
|
|
52
|
+
## Features
|
|
53
|
+
|
|
54
|
+
- Define the structure of a JSON field using Pydantic models
|
|
55
|
+
- Validate the JSON field against the defined structure
|
|
56
|
+
- Use relationships between models inside the JSON field 🤯
|
|
57
|
+
- Django-native `prefetch_related` across JSON paths — `MyModel.objects.prefetch_related("structured_data__author__country")` Just Works 🚀
|
|
58
|
+
- Easily integrate with Django Rest Framework serializers
|
|
59
|
+
- Admin editor for the JSON field with autocomplete search for related models 👀
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
## Documentation
|
|
63
|
+
|
|
64
|
+
Check out our [documentation](https://bnznamco.github.io/django-structured-field/) for detailed guides and examples on:
|
|
65
|
+
|
|
66
|
+
- Installation and basic usage
|
|
67
|
+
- Working with relationships
|
|
68
|
+
- Prefetching relations across JSON paths
|
|
69
|
+
- Admin integration
|
|
70
|
+
- REST Framework integration
|
|
71
|
+
- Caching
|
|
72
|
+
- Settings configuration
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
## Installation
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
pip install django-structured-json-field
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Usage
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from django.db import models
|
|
86
|
+
from structured.fields import StructuredJSONField
|
|
87
|
+
from structured.pydantic.models import BaseModel
|
|
88
|
+
|
|
89
|
+
# Define this schema as you would do with a Pydantic model
|
|
90
|
+
class MySchema(BaseModel):
|
|
91
|
+
name: str
|
|
92
|
+
age: int = None
|
|
93
|
+
|
|
94
|
+
def init_data():
|
|
95
|
+
return MySchema(name='')
|
|
96
|
+
|
|
97
|
+
# Create a model with a StructuredJSONField with the schema you defined
|
|
98
|
+
class MyModel(models.Model):
|
|
99
|
+
structured_data = StructuredJSONField(schema=MySchema, default=init_data)
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Relationships
|
|
104
|
+
|
|
105
|
+
This field supports relationships between models, you can define them in your schema and they will be treated as normal django relationships. It also supports recursive schemas.
|
|
106
|
+
|
|
107
|
+
### Recursion
|
|
108
|
+
|
|
109
|
+
You can define recursive schemas by declaring the attribute type as a string:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from typing import Optional, List
|
|
113
|
+
|
|
114
|
+
class MySchema(BaseModel):
|
|
115
|
+
name: str
|
|
116
|
+
age: int = None
|
|
117
|
+
parent: Optional['MySchema'] = None
|
|
118
|
+
relateds: List['MySchema'] = []
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Foreign Keys
|
|
122
|
+
|
|
123
|
+
You can also define model relationships in your schema:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from structured.pydantic.fields import ForeignKey
|
|
127
|
+
|
|
128
|
+
class MySchema(BaseModel):
|
|
129
|
+
name: str
|
|
130
|
+
age: int = None
|
|
131
|
+
fk_field: ForeignKey['MyModel'] = None
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
This will treat the parent field as a normal django ForeignKey.
|
|
135
|
+
|
|
136
|
+
#### Tip:
|
|
137
|
+
|
|
138
|
+
You can omit the `ForeignKey` field and just use the model class as the type annotation:
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
class MySchema(BaseModel):
|
|
142
|
+
name: str
|
|
143
|
+
age: int = None
|
|
144
|
+
fk_field: MyModel = None
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
the field will still be treated as a ForeignKey if the type annotation is a subclass of django `models.Model`.
|
|
148
|
+
|
|
149
|
+
### ManyToMany
|
|
150
|
+
|
|
151
|
+
If you need a ManyToMany relationship, you can use the `QuerySet` field:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
from structured.pydantic.fields import QuerySet
|
|
155
|
+
|
|
156
|
+
class MySchema(BaseModel):
|
|
157
|
+
name: str
|
|
158
|
+
age: int = None
|
|
159
|
+
parents: QuerySet['MyModel']
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`QuerySet` fields will generate a django object manager that will allow you to query the related objects as you would do with a normal django `QuerySet`.
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
instance = MySchema(name='test', age=10, parents=MyModel.objects.all())
|
|
166
|
+
# You can filter the queryset
|
|
167
|
+
instance.parents.filter(name='test')
|
|
168
|
+
# You can count the queryset
|
|
169
|
+
instance.parents.count()
|
|
170
|
+
# You can get the first element of the queryset, etc...
|
|
171
|
+
instance.parents.first()
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
### Admin integration
|
|
175
|
+
|
|
176
|
+
The field is integrated with the Django admin, you can use the autocomplete search to select the related models. To allow the autocomplete search you need to include structured.urls in your urls.py file:
|
|
177
|
+
|
|
178
|
+
```python
|
|
179
|
+
from django.urls import path, include
|
|
180
|
+
|
|
181
|
+
urlpatterns = [
|
|
182
|
+
path('admin/', admin.site.urls),
|
|
183
|
+
path('', include('structured.urls')),
|
|
184
|
+
]
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Rest Framework integration
|
|
188
|
+
|
|
189
|
+
You can easily integrate structured fields with Django Rest Framework serializers, just use the `StructuredModelSerializer` as the base class for your serializer:
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from rest_framework import serializers
|
|
193
|
+
from structured.contrib.rest_framework import StructuredModelSerializer
|
|
194
|
+
|
|
195
|
+
class MyModelSerializer(StructuredModelSerializer):
|
|
196
|
+
class Meta:
|
|
197
|
+
model = MyModel
|
|
198
|
+
fields = '__all__'
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
Errors generated by pydantic validation will be automatically translated to DRF errors.
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
### Cache
|
|
205
|
+
|
|
206
|
+
To prevent the field from making multiple identical queries a caching technique is used. The cache is still a work in progress, please open an issue if you find any problem.
|
|
207
|
+
Actually the cache covers all the relations inside a StructuredJSONField, optimizing the queries during the serialization process.
|
|
208
|
+
|
|
209
|
+
#### Cache engine progress:
|
|
210
|
+
|
|
211
|
+
- [x] Shared cache between `ForeignKey` fields and `QuerySet` fields
|
|
212
|
+
- [x] Shared cache through nested schemas
|
|
213
|
+
- [x] Shared cache through nested lists of schemas
|
|
214
|
+
- [ ] Shared cache between all `StructuredJSONFields` in the same instance
|
|
215
|
+
- [ ] Shared cache between multiple instances of the same model
|
|
216
|
+
- [ ] Cache invalidation mechanism
|
|
217
|
+
|
|
218
|
+
## Settings
|
|
219
|
+
|
|
220
|
+
You can manage structured field behaviour modifying the `STRUCTURED_FIELD` setting in your `settings.py` file. Here a list of the available settings and their default values:
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
STRUCTURED_FIELD = {
|
|
224
|
+
'CACHE':{
|
|
225
|
+
'ENABLED': True,
|
|
226
|
+
'SHARED': False # ⚠️ EXPERIMENTAL: this enables a thread-shared cache, it's not recommended to use it in production.
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## Contributing
|
|
232
|
+
|
|
233
|
+
The project is open to contributions, just open an issue or a PR.
|
|
234
|
+
|
|
235
|
+
### Running tests
|
|
236
|
+
|
|
237
|
+
```bash
|
|
238
|
+
pip install -r requirements-dev.txt
|
|
239
|
+
make test
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Running test app
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
pip install -r requirements-dev.txt
|
|
246
|
+
python manage.py migrate
|
|
247
|
+
python manage.py runserver
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## License
|
|
251
|
+
|
|
252
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
django_structured_json_field-1.6.0.dist-info/licenses/LICENSE,sha256=2_XPsmGfwHVMSsUsHX7f2lxyLluSjm7RcRlnd7iaP3c,1074
|
|
2
|
+
structured/__init__.py,sha256=DXEWnP5eZfUwa3rzzQ_vPP3po5wQtMqyeA2vat6xIkw,227
|
|
3
|
+
structured/apps.py,sha256=pUQi2lciJdIYwUXXoP3FTrUU2DdyCjL6QinNI8__c78,136
|
|
4
|
+
structured/fields.py,sha256=Va93mv5YhfwsEJ7Bhok0Jw__ovp1k2onyWlb6H2oMpk,10334
|
|
5
|
+
structured/orm.py,sha256=8GVCRdGfjQ_EGQJqQuby9heoOSpjPXt7kw1dNDGjpV0,24513
|
|
6
|
+
structured/settings.py,sha256=YlH3fwjs8eYawv0hILH3IHUgRXdA6a41qu-chKnxCTw,1067
|
|
7
|
+
structured/urls.py,sha256=TbwHE2n6v5XaHBmo1d7OWEXtOdIQFibGFVAALZhFJVw,173
|
|
8
|
+
structured/views.py,sha256=urNvjwurbcnRhGUJ8lGJGiNWaTjbN4pPU_StsQ6AfnY,3082
|
|
9
|
+
structured/cache/__init__.py,sha256=8xQBYjkgFVH9Bytgj5OAyBCRdCweHuwk6KiJeunWX4g,262
|
|
10
|
+
structured/cache/cache.py,sha256=Y4MhFYT57e-wONZi9QymD0vsuGMn8f6jUtNbHZx4N5M,8730
|
|
11
|
+
structured/cache/engine.py,sha256=SLv4vMOFp6ScpKUg1wn0NMzC6lKvziyZtjrLpUaqers,14411
|
|
12
|
+
structured/cache/rel_info.py,sha256=K8EEhUCufKcW4O0GQKQReG3cltNmuYRHZ_bMOwCo8Ac,606
|
|
13
|
+
structured/contrib/restframework.py,sha256=A7oX2uTHg8sXeoWOicLWlE41HOvjYurLRqz3dI2KYYk,2946
|
|
14
|
+
structured/management/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
structured/management/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
16
|
+
structured/management/commands/generate_schema_migration.py,sha256=ko9S1mS0VyovjprqjUPaddB69lF2sfDTs_6F3Jd52Es,6389
|
|
17
|
+
structured/pydantic/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
18
|
+
structured/pydantic/conditionals.py,sha256=GW9ZpuSaSVMEloYg9p22i-TJ49jkeT_E51YgqO1UO8U,10423
|
|
19
|
+
structured/pydantic/models.py,sha256=9fMFmmJfRw0wB2cEC5dCPAAq2LBa7mC2EeCVjh5it5o,4079
|
|
20
|
+
structured/pydantic/fields/__init__.py,sha256=rkDcCYYMZWro8qQWcRM_c89mstFyHlmPU2tGa11rVMg,116
|
|
21
|
+
structured/pydantic/fields/foreignkey.py,sha256=_1CCBp3srU7aRPDokXiG4PPBzCVifqPDMg4zn6aPDRo,5224
|
|
22
|
+
structured/pydantic/fields/queryset.py,sha256=AfT7SUkLVWWpvkRC8uB2qEpJnMkONhxA8lGRWHK7ioM,5909
|
|
23
|
+
structured/pydantic/fields/serializer.py,sha256=vzEBPiY8D_oXBRVVyN0LwicTdCJaNgBLqadMokgk7_4,1655
|
|
24
|
+
structured/schema_migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
25
|
+
structured/schema_migrations/structured_json_migration.py,sha256=5sywecUyGdWG4C7XCUWc2h_K-evr5D6NpqhptSUuW1Y,11863
|
|
26
|
+
structured/static/js/structured-field-init.js,sha256=NmTFO8Rgog7rQJgC9CvWdzXJS3qrHSE2Xpo4cIzAZI8,1210
|
|
27
|
+
structured/templates/json-forms/widget.html,sha256=BCIGYmtHEtmOK_u_-ojR5WOKIRihuZww6rY8kr_dEEs,535
|
|
28
|
+
structured/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
29
|
+
structured/utils/cast.py,sha256=q7E6WflY_DP2kcs-Znk7Sp2xQBwYZZWrtryT2gkPKuE,1129
|
|
30
|
+
structured/utils/context.py,sha256=xwDa118xsr70eaT-zQTPBP63hmNCTeccZ8dON_fYl3s,560
|
|
31
|
+
structured/utils/dict.py,sha256=RDCZtjwpEEwjvfUjQl2bEWFKw7NxTzkXco72VeO2M9w,255
|
|
32
|
+
structured/utils/django.py,sha256=cZjXnsQ-kQa18xzQ7BwZnsq_oM39ZgJF4HgZCw_cwUA,1345
|
|
33
|
+
structured/utils/errors.py,sha256=Mh-I9n1FpWr94Ry6LcqJCqOKgSUJXB_PmYASmuL869g,1366
|
|
34
|
+
structured/utils/getter.py,sha256=NrlByyd4SrFp3aVpdA21MMs3bcJFyequsxoIkrh7ATI,639
|
|
35
|
+
structured/utils/namespace.py,sha256=Pe3DmUryy8YWkI-fAuxV9cPH6AWEaYI1u9h3p_3CZkU,892
|
|
36
|
+
structured/utils/options.py,sha256=6GJpHc2R31gKNue8TC9hDcfgJnJQvcdriyx93TWxDo0,1037
|
|
37
|
+
structured/utils/pydantic.py,sha256=M73wr0dyyAmtE-3ZG8NywfBA0xXjoip_T1xmJw9d1b4,3526
|
|
38
|
+
structured/utils/replace.py,sha256=0pHmKCZhUBLUeUIv2OD3qces2yYhNOSXoNUNPk3iCzA,436
|
|
39
|
+
structured/utils/serializer.py,sha256=NEVsQLkNP7S0kW8ze26bzjhUBWEVspCznuHG8u1F4Fk,3836
|
|
40
|
+
structured/utils/setter.py,sha256=6iF3gG8RfatxBYeubU1KweP48UVbS1LcnungnmqMqm8,1358
|
|
41
|
+
structured/utils/typing.py,sha256=1PbRc0sy_BhT6KkYAaXkvYbzs1cCu1iSykdhvBaplek,3430
|
|
42
|
+
structured/widget/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
43
|
+
structured/widget/fields.py,sha256=Woi5R0ob4-t416gHtxCurz2vtCaN9thoRUbKICJoPts,2607
|
|
44
|
+
structured/widget/widgets.py,sha256=9PYUGETpn-XbSb9bM3OYdJlUXIW9yRWBQHmSs7jOrm0,3844
|
|
45
|
+
django_structured_json_field-1.6.0.dist-info/METADATA,sha256=xC13yMMhEdLiFWgmT7sCiLaz8IyIAZoCmpOPqdLTqAI,7966
|
|
46
|
+
django_structured_json_field-1.6.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
47
|
+
django_structured_json_field-1.6.0.dist-info/top_level.txt,sha256=exTAxPKh1S71ZrY3PJ7PTnmRbnQEBRE09OPnyIpDJ28,11
|
|
48
|
+
django_structured_json_field-1.6.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) Lotrèk 2024
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
structured
|
structured/__init__.py
ADDED
structured/apps.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
from collections import defaultdict
|
|
2
|
+
from typing import Type, Union, Iterable, Dict, Any, TYPE_CHECKING
|
|
3
|
+
from django.db.models import Model as DjangoModel
|
|
4
|
+
from django.db.models.query import QuerySet as DjangoQuerySet
|
|
5
|
+
from django.db.models.signals import post_save, pre_delete
|
|
6
|
+
from pydantic import ValidationInfo, model_validator
|
|
7
|
+
import threading
|
|
8
|
+
from structured.settings import settings
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
11
|
+
from structured.pydantic.models import BaseModel
|
|
12
|
+
from structured.cache.engine import CacheEngine
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
CACHE_CONTEXT_KEY = "structured_parent_cache"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CacheEnabledModel:
|
|
19
|
+
"""
|
|
20
|
+
A model class that enables caching.
|
|
21
|
+
|
|
22
|
+
When validated via ``schema.validate_python(value, context={...})``,
|
|
23
|
+
a ``Cache`` instance under the ``CACHE_CONTEXT_KEY`` key is reused
|
|
24
|
+
instead of creating a fresh per-call cache — letting multiple
|
|
25
|
+
``StructuredJSONField``s on the same Django instance share one
|
|
26
|
+
fetch pool.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
_cache_engine: 'CacheEngine'
|
|
30
|
+
|
|
31
|
+
@model_validator(mode="wrap")
|
|
32
|
+
@classmethod
|
|
33
|
+
def build_cache(
|
|
34
|
+
cls,
|
|
35
|
+
data: Dict[str, Any],
|
|
36
|
+
handler: Any,
|
|
37
|
+
info: ValidationInfo,
|
|
38
|
+
) -> Any:
|
|
39
|
+
"""
|
|
40
|
+
Build and fetch cache for the given data.
|
|
41
|
+
|
|
42
|
+
``info`` is declared as a required positional argument with a
|
|
43
|
+
typed annotation so pydantic's signature inspection always
|
|
44
|
+
passes it. With ``info=None`` defaults, pydantic v2 sometimes
|
|
45
|
+
elects the shorter ``(cls, data, handler)`` overload and the
|
|
46
|
+
validator never sees the parent cache.
|
|
47
|
+
"""
|
|
48
|
+
parent_cache = None
|
|
49
|
+
if info is not None and info.context:
|
|
50
|
+
parent_cache = info.context.get(CACHE_CONTEXT_KEY)
|
|
51
|
+
data = cls._cache_engine.build_cache(data, parent_cache=parent_cache)
|
|
52
|
+
instance: "BaseModel" = handler(data)
|
|
53
|
+
return cls._cache_engine.fetch_cache(instance)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def get_global_cache():
|
|
57
|
+
if settings.STRUCTURED_FIELD_SHARED_CACHE:
|
|
58
|
+
return ThreadSafeCache()
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Cache(defaultdict):
|
|
63
|
+
"""
|
|
64
|
+
The cache class that stores the cache data.
|
|
65
|
+
Per-request caches do NOT connect to Django signals — they are short-lived
|
|
66
|
+
and discarded after validation. Only ThreadSafeCache (shared mode) connects
|
|
67
|
+
signals for cross-request cache invalidation.
|
|
68
|
+
The cache data is stored in the following format:
|
|
69
|
+
{
|
|
70
|
+
Model1: {
|
|
71
|
+
pk1: instance1,
|
|
72
|
+
pk2: instance2,
|
|
73
|
+
...
|
|
74
|
+
},
|
|
75
|
+
Model2: {
|
|
76
|
+
pk1: instance1,
|
|
77
|
+
pk2: instance2,
|
|
78
|
+
...
|
|
79
|
+
},
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(self) -> None:
|
|
83
|
+
super().__init__(dict)
|
|
84
|
+
|
|
85
|
+
def flush(
|
|
86
|
+
self, data: Union[DjangoModel, Iterable[DjangoModel], None] = None, **kwargs
|
|
87
|
+
) -> None:
|
|
88
|
+
"""
|
|
89
|
+
Flush the cache for the given data and/or model; with no arguments,
|
|
90
|
+
clear the whole cache. An empty iterable flushes nothing.
|
|
91
|
+
"""
|
|
92
|
+
model = kwargs.get("model", None)
|
|
93
|
+
if model is not None:
|
|
94
|
+
self._flush_model(model)
|
|
95
|
+
if data is not None:
|
|
96
|
+
self._flush_data(data)
|
|
97
|
+
if model is None and data is None:
|
|
98
|
+
self.clear()
|
|
99
|
+
|
|
100
|
+
def _flush_model(self, model: Union[str, Type[DjangoModel]]) -> None:
|
|
101
|
+
"""
|
|
102
|
+
Flush the cache for the given model.
|
|
103
|
+
"""
|
|
104
|
+
if isinstance(model, str):
|
|
105
|
+
model = next((m for m in self.keys() if m.__name__ == model), None)
|
|
106
|
+
if model and model in self:
|
|
107
|
+
del self[model]
|
|
108
|
+
|
|
109
|
+
def _flush_data(self, data: Union[DjangoModel, Iterable[DjangoModel]]) -> None:
|
|
110
|
+
"""
|
|
111
|
+
Flush the cache for the given data.
|
|
112
|
+
"""
|
|
113
|
+
if isinstance(data, DjangoModel):
|
|
114
|
+
model = data.__class__
|
|
115
|
+
if model in self and data.pk in self[model]:
|
|
116
|
+
del self[model][data.pk]
|
|
117
|
+
else:
|
|
118
|
+
for instance in data:
|
|
119
|
+
model = instance.__class__
|
|
120
|
+
if model in self and instance.pk in self[model]:
|
|
121
|
+
del self[model][instance.pk]
|
|
122
|
+
|
|
123
|
+
def set(self, data: Union[DjangoModel, Iterable[DjangoModel]]) -> None:
|
|
124
|
+
"""
|
|
125
|
+
Set the cache for the given data.
|
|
126
|
+
"""
|
|
127
|
+
if isinstance(data, DjangoModel):
|
|
128
|
+
self[data.__class__].update({data.pk: data})
|
|
129
|
+
else:
|
|
130
|
+
for instance in data:
|
|
131
|
+
self[instance.__class__].update({instance.pk: instance})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class ThreadSafeCache(Cache):
|
|
135
|
+
"""
|
|
136
|
+
A singleton cache object that allows sharing cache data between fields and instances.
|
|
137
|
+
This encapsulates the cache data inside a thread-safe singleton object.
|
|
138
|
+
Every model instance invoking a cache operation will use the same cache object,
|
|
139
|
+
allowing the cache data to be shared between them.
|
|
140
|
+
All mutation methods are protected by a threading lock for thread safety.
|
|
141
|
+
Connects to Django signals for cross-request cache invalidation.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
_instance = None
|
|
145
|
+
_lock = threading.RLock()
|
|
146
|
+
|
|
147
|
+
def __new__(cls):
|
|
148
|
+
if cls._instance is None:
|
|
149
|
+
with cls._lock:
|
|
150
|
+
if not cls._instance:
|
|
151
|
+
cls._instance = super().__new__(cls)
|
|
152
|
+
|
|
153
|
+
return cls._instance
|
|
154
|
+
|
|
155
|
+
def __init__(self) -> None:
|
|
156
|
+
if not hasattr(self, '_initialized'):
|
|
157
|
+
super().__init__()
|
|
158
|
+
self._initialized = True
|
|
159
|
+
post_save.connect(self._on_instance_save)
|
|
160
|
+
pre_delete.connect(self._on_instance_delete)
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _on_instance_save(
|
|
164
|
+
sender: Type[DjangoModel], instance: DjangoModel, **kwargs
|
|
165
|
+
) -> None:
|
|
166
|
+
"""
|
|
167
|
+
Signal handler for saving a model instance.
|
|
168
|
+
"""
|
|
169
|
+
cache = get_global_cache()
|
|
170
|
+
if cache and instance.__class__ in cache:
|
|
171
|
+
cache.set(instance)
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _on_instance_delete(
|
|
175
|
+
sender: Type[DjangoModel], instance: DjangoModel, **kwargs
|
|
176
|
+
) -> None:
|
|
177
|
+
"""
|
|
178
|
+
Signal handler for deleting a model instance.
|
|
179
|
+
"""
|
|
180
|
+
cache = get_global_cache()
|
|
181
|
+
if cache and instance.__class__ in cache:
|
|
182
|
+
cache.flush(instance)
|
|
183
|
+
|
|
184
|
+
def set(self, data: Union[DjangoModel, Iterable[DjangoModel]]) -> None:
|
|
185
|
+
with self._lock:
|
|
186
|
+
super().set(data)
|
|
187
|
+
|
|
188
|
+
def flush(
|
|
189
|
+
self, data: Union[DjangoModel, Iterable[DjangoModel], None] = None, **kwargs
|
|
190
|
+
) -> None:
|
|
191
|
+
with self._lock:
|
|
192
|
+
super().flush(data, **kwargs)
|
|
193
|
+
|
|
194
|
+
def __setitem__(self, key, value):
|
|
195
|
+
with self._lock:
|
|
196
|
+
super().__setitem__(key, value)
|
|
197
|
+
|
|
198
|
+
def __delitem__(self, key):
|
|
199
|
+
with self._lock:
|
|
200
|
+
super().__delitem__(key)
|
|
201
|
+
|
|
202
|
+
def clear(self):
|
|
203
|
+
with self._lock:
|
|
204
|
+
super().clear()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class ValueWithCache:
|
|
208
|
+
"""
|
|
209
|
+
A class that represents a cached value.
|
|
210
|
+
This is used to retrieve values from the cache during the serialization process.
|
|
211
|
+
The retrieve method fetches the needed values and returns accordingly to the field type (ForeignKey, QuerySet, etc.).
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
def __init__(
|
|
215
|
+
self,
|
|
216
|
+
cache: Cache,
|
|
217
|
+
model: Type[DjangoModel],
|
|
218
|
+
value: Union[Iterable[Union[str, int]], str, int],
|
|
219
|
+
) -> None:
|
|
220
|
+
self.cache: Cache = cache
|
|
221
|
+
self.value: Union[Iterable[Union[str, int]], str, int] = value
|
|
222
|
+
self.model: Type[DjangoModel] = model
|
|
223
|
+
|
|
224
|
+
def retrieve(self) -> Union[DjangoModel, DjangoQuerySet]:
|
|
225
|
+
"""
|
|
226
|
+
Retrieve the value from the cache or database.
|
|
227
|
+
"""
|
|
228
|
+
cache = self.cache.get(self.model)
|
|
229
|
+
if hasattr(self.value, "__iter__") and not isinstance(self.value, str):
|
|
230
|
+
pks = list(self.value)
|
|
231
|
+
if cache:
|
|
232
|
+
cached = [cache[i] for i in pks if i in cache]
|
|
233
|
+
missing_pks = [i for i in pks if i not in cache]
|
|
234
|
+
if missing_pks:
|
|
235
|
+
fetched = list(self.model._default_manager.filter(pk__in=missing_pks))
|
|
236
|
+
cached.extend(fetched)
|
|
237
|
+
# Update cache with newly fetched instances
|
|
238
|
+
for obj in fetched:
|
|
239
|
+
cache[obj.pk] = obj
|
|
240
|
+
result = cached
|
|
241
|
+
else:
|
|
242
|
+
result = list(self.model._default_manager.filter(pk__in=pks))
|
|
243
|
+
# Preserve original PK ordering
|
|
244
|
+
pk_to_obj = {obj.pk: obj for obj in result}
|
|
245
|
+
ordered = [pk_to_obj[pk] for pk in pks if pk in pk_to_obj]
|
|
246
|
+
qs = self.model._default_manager.filter(pk__in=pks)
|
|
247
|
+
setattr(qs, "_result_cache", ordered)
|
|
248
|
+
return qs
|
|
249
|
+
else:
|
|
250
|
+
if cache:
|
|
251
|
+
val = cache.get(self.value, None)
|
|
252
|
+
else:
|
|
253
|
+
val = None
|
|
254
|
+
if val is None:
|
|
255
|
+
return self.model._default_manager.filter(pk=self.value).first()
|
|
256
|
+
else:
|
|
257
|
+
return val
|