Skip to content

Commit 4c851b7

Browse files
committed
Handle serializing inherited models. #459
1 parent db90c03 commit 4c851b7

File tree

11 files changed

+602
-113
lines changed

11 files changed

+602
-113
lines changed

django_unicorn/serializer.py

Lines changed: 83 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import logging
2+
from datetime import datetime, timedelta
23
from decimal import Decimal
34
from functools import lru_cache
4-
from typing import Any, Dict, List, Optional, Tuple
5+
from typing import Any, Dict, List, Tuple
56

67
from django.core.serializers import serialize
8+
from django.core.serializers.json import DjangoJSONEncoder
79
from django.db.models import (
810
DateField,
911
DateTimeField,
@@ -18,6 +20,7 @@
1820
parse_duration,
1921
parse_time,
2022
)
23+
from django.utils.duration import duration_string
2124

2225
import orjson
2326

@@ -32,6 +35,8 @@
3235

3336
logger = logging.getLogger(__name__)
3437

38+
django_json_encoder = DjangoJSONEncoder()
39+
3540

3641
class JSONDecodeError(Exception):
3742
pass
@@ -107,6 +112,69 @@ def _get_many_to_many_field_related_names_from_meta(meta):
107112
return _get_many_to_many_field_related_names_from_meta(model._meta)
108113

109114

115+
def _get_m2m_field_serialized(model: Model, field_name) -> List:
116+
pks = []
117+
118+
try:
119+
related_descriptor = getattr(model, field_name)
120+
121+
# Get `pk` from `all` because it will re-use the cached data if the m-2-m field is prefetched
122+
# Using `values_list("pk", flat=True)` or `only()` won't use the cached prefetched values
123+
pks = [m.pk for m in related_descriptor.all()]
124+
except ValueError:
125+
# ValueError is thrown when the model doesn't have an id already set
126+
pass
127+
128+
return pks
129+
130+
131+
def _handle_inherited_models(model: Model, model_json: Dict):
132+
"""
133+
Handle if the model has a parent (i.e. the model is a subclass of another model).
134+
135+
Subclassed model's fields don't get serialized
136+
(https://docs.djangoproject.com/en/stable/topics/serialization/#inherited-models)
137+
so those fields need to be retrieved manually.
138+
"""
139+
140+
if model._meta.get_parent_list():
141+
for field in model._meta.get_fields():
142+
if (
143+
field.name not in model_json
144+
and hasattr(field, "primary_key")
145+
and not field.primary_key
146+
):
147+
if field.is_relation:
148+
# We already serialized the m2m fields above, so we can skip them, but need to handle FKs
149+
if not field.many_to_many:
150+
foreign_key_field = getattr(model, field.name)
151+
foreign_key_field_pk = getattr(
152+
foreign_key_field,
153+
"pk",
154+
getattr(foreign_key_field, "id", None),
155+
)
156+
model_json[field.name] = foreign_key_field_pk
157+
else:
158+
value = getattr(model, field.name)
159+
160+
# Explicitly handle `timedelta`, but use the DjangoJSONEncoder for everything else
161+
if isinstance(value, timedelta):
162+
value = duration_string(value)
163+
else:
164+
# Make sure the value is properly serialized
165+
value = django_json_encoder.encode(value)
166+
167+
# The DjangoJSONEncoder has extra double-quotes for strings so remove them
168+
if (
169+
isinstance(value, str)
170+
and value.startswith('"')
171+
and value.endswith('"')
172+
):
173+
value = value[1:-1]
174+
175+
model_json[field.name] = value
176+
177+
110178
def _get_model_dict(model: Model) -> dict:
111179
"""
112180
Serializes Django models. Uses the built-in Django JSON serializer, but moves the data around to
@@ -115,27 +183,29 @@ def _get_model_dict(model: Model) -> dict:
115183

116184
_parse_field_values_from_string(model)
117185

118-
# Django's `serialize` method always returns an array, so remove the brackets from the resulting string
186+
# Django's `serialize` method always returns a string of an array,
187+
# so remove the brackets from the resulting string
119188
serialized_model = serialize("json", [model])[1:-1]
189+
190+
# Convert the string into a dictionary and grab the `pk`
120191
model_json = orjson.loads(serialized_model)
121192
model_pk = model_json.get("pk")
193+
194+
# Shuffle around the serialized pieces to condense the size of the payload
122195
model_json = model_json.get("fields")
123196
model_json["pk"] = model_pk
124197

125-
for related_name in _get_many_to_many_field_related_names(model):
126-
pks = []
198+
# Set `pk` for models that subclass another model which only have `id` set
199+
if not model_pk:
200+
model_json["pk"] = model.pk or model.id
127201

128-
try:
129-
related_descriptor = getattr(model, related_name)
202+
# Add in m2m fields
203+
m2m_field_names = _get_many_to_many_field_related_names(model)
130204

131-
# Get `pk` from `all` because it will re-use the cached data if the m-2-m field is prefetched
132-
# Using `values_list("pk", flat=True)` or `only()` won't use the cached prefetched values
133-
pks = [m.pk for m in related_descriptor.all()]
134-
except ValueError:
135-
# ValueError is thrown when the model doesn't have an id already set
136-
pass
205+
for m2m_field_name in m2m_field_names:
206+
model_json[m2m_field_name] = _get_m2m_field_serialized(model, m2m_field_name)
137207

138-
model_json[related_name] = pks
208+
_handle_inherited_models(model, model_json)
139209

140210
return model_json
141211

django_unicorn/utils.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import logging
44
import pickle
55
from inspect import signature
6+
from pprint import pp
67
from typing import Dict, List, Union
78
from typing import get_type_hints as typing_get_type_hints
89

@@ -53,14 +54,23 @@ def dicts_equal(dictionary_one: Dict, dictionary_two: Dict) -> bool:
5354
Return True if all keys and values are the same between two dictionaries.
5455
"""
5556

56-
return all(
57+
is_valid = all(
5758
k in dictionary_two and dictionary_one[k] == dictionary_two[k]
5859
for k in dictionary_one
5960
) and all(
6061
k in dictionary_one and dictionary_one[k] == dictionary_two[k]
6162
for k in dictionary_two
6263
)
6364

65+
if not is_valid:
66+
print("dictionary_one:")
67+
pp(dictionary_one)
68+
print()
69+
print("dictionary_two:")
70+
pp(dictionary_two)
71+
72+
return is_valid
73+
6474

6575
def get_cacheable_component(
6676
component: "django_unicorn.views.UnicornView",
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Generated by Django 3.2.15 on 2022-11-10 04:00
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('books', '0002_author'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='author',
15+
name='id',
16+
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
17+
),
18+
migrations.AlterField(
19+
model_name='book',
20+
name='id',
21+
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
22+
),
23+
]
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Generated by Django 3.2.15 on 2022-11-10 04:00
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
6+
7+
class Migration(migrations.Migration):
8+
9+
dependencies = [
10+
('coffee', '0004_origin_taste'),
11+
]
12+
13+
operations = [
14+
migrations.CreateModel(
15+
name='NewFlavor',
16+
fields=[
17+
('flavor_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='coffee.flavor')),
18+
('new_name', models.CharField(max_length=255)),
19+
],
20+
bases=('coffee.flavor',),
21+
),
22+
migrations.AlterField(
23+
model_name='flavor',
24+
name='id',
25+
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
26+
),
27+
migrations.AlterField(
28+
model_name='origin',
29+
name='id',
30+
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
31+
),
32+
migrations.AlterField(
33+
model_name='taste',
34+
name='id',
35+
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
36+
),
37+
]

example/coffee/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,7 @@ class Taste(models.Model):
2929
class Origin(models.Model):
3030
name = models.CharField(max_length=255)
3131
flavor = models.ManyToManyField(Flavor, related_name="origins")
32+
33+
34+
class NewFlavor(Flavor):
35+
new_name = models.CharField(max_length=255)

0 commit comments

Comments
 (0)