Skip to content

Commit 1686bfa

Browse files
committed
Add better support for Django QuerySets.
1 parent bc729c2 commit 1686bfa

File tree

12 files changed

+364
-64
lines changed

12 files changed

+364
-64
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
from .types import *
12
from .unicorn_view import *
23
from .updaters import *

django_unicorn/components/types.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from typing import Generic, Iterator, TypeVar
2+
3+
from django.db.models import Model, QuerySet
4+
5+
6+
M = TypeVar("M", bound=Model)
7+
8+
9+
class QueryType(Generic[M], QuerySet):
10+
def __iter__(self) -> Iterator[M]:
11+
...

django_unicorn/static/js/component.js

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,6 @@ export class Component {
224224
this.keyEls.push(element);
225225
}
226226

227-
console.log(element.actions);
228-
229227
element.actions.forEach((action) => {
230228
if (this.actionEvents[action.eventType]) {
231229
this.actionEvents[action.eventType].push({ action, element });

django_unicorn/views/action_parsers/utils.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from typing import Any
22

3+
from django.db.models import QuerySet
4+
35
from django_unicorn.components import UnicornView
46
from django_unicorn.decorators import timed
57

@@ -79,6 +81,15 @@ class TestView(UnicornView):
7981
# TODO: Check for iterable instad of list? `from collections.abc import Iterable`
8082
property_name_part = int(property_name_part)
8183

84+
if idx == len(property_name_parts) - 1:
85+
component_or_field[property_name_part] = property_value
86+
data_or_dict[property_name_part] = property_value
87+
else:
88+
component_or_field = component_or_field[property_name_part]
89+
data_or_dict = data_or_dict[property_name_part]
90+
elif isinstance(component_or_field, QuerySet):
91+
property_name_part = int(property_name_part)
92+
8293
if idx == len(property_name_parts) - 1:
8394
component_or_field[property_name_part] = property_value
8495
data_or_dict[property_name_part] = property_value

django_unicorn/views/utils.py

Lines changed: 109 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,30 @@
1+
import logging
12
from dataclasses import is_dataclass
23
from typing import Any, Union
34

4-
from django.db.models import Model
5+
from django.db.models import Model, QuerySet
56

6-
from django_unicorn.components import UnicornField, UnicornView
7+
from django_unicorn.components import QueryType, UnicornField, UnicornView
78
from django_unicorn.decorators import timed
89
from django_unicorn.utils import get_type_hints
910

1011

12+
try:
13+
from typing import get_args, get_origin
14+
except ImportError:
15+
# Fallback to dunder methods for older versions of Python
16+
def get_args(type_hint):
17+
if hasattr(type_hint, "__args__"):
18+
return type_hint.__args__
19+
20+
def get_origin(type_hint):
21+
if hasattr(type_hint, "__origin__"):
22+
return type_hint.__origin__
23+
24+
25+
logger = logging.getLogger(__name__)
26+
27+
1128
@timed
1229
def set_property_from_data(
1330
component_or_field: Union[UnicornView, UnicornField, Model], name: str, value: Any,
@@ -29,6 +46,8 @@ def set_property_from_data(
2946
# Re-get the field since it might have been set in `_is_component_field_model_or_unicorn_field`
3047
field = getattr(component_or_field, name)
3148

49+
type_hints = get_type_hints(component_or_field)
50+
3251
if isinstance(value, dict):
3352
for key in value.keys():
3453
key_value = value[key]
@@ -37,18 +56,20 @@ def set_property_from_data(
3756
set_property_from_data(field, field.name, value)
3857
else:
3958
type_hints = get_type_hints(component_or_field)
59+
type_hint = type_hints.get(name)
4060

41-
if name in type_hints:
42-
# Construct the specified type by passing the value in
43-
# Usually the value will be a string (because it is coming from JSON)
44-
# and basic types can be constructed by passing in a string,
45-
# i.e. int("1") or float("1.1")
46-
47-
if is_dataclass(type_hints[name]):
48-
value = type_hints[name](**value)
61+
if _is_queryset(field, type_hint, value):
62+
value = _create_queryset(field, type_hint, value)
63+
elif type_hint:
64+
if is_dataclass(type_hint):
65+
value = type_hint(**value)
4966
else:
67+
# Construct the specified type by passing the value in
68+
# Usually the value will be a string (because it is coming from JSON)
69+
# and basic types can be constructed by passing in a string,
70+
# i.e. int("1") or float("1.1")
5071
try:
51-
value = type_hints[name](value)
72+
value = type_hint(value)
5273
except TypeError:
5374
# Ignore this exception because some type-hints can't be instantiated like this (e.g. `List[]`)
5475
pass
@@ -107,3 +128,80 @@ def _is_component_field_model_or_unicorn_field(
107128
pass
108129

109130
return is_subclass_of_model or is_subclass_of_unicorn_field
131+
132+
133+
def _is_queryset(field, type_hint, value):
134+
"""
135+
Determines whether a field is a `QuerySet` or not based on the current instance of the
136+
component or the type hint.
137+
"""
138+
139+
return (
140+
isinstance(field, QuerySet)
141+
or (type_hint and get_origin(type_hint) is QueryType)
142+
) and isinstance(value, list)
143+
144+
145+
def _create_queryset(field, type_hint, value) -> QuerySet:
146+
"""
147+
Create a queryset based on the `value`. If needed, the queryset will be created based on the `QueryType`.
148+
149+
For example, all of these ways fields are equivalent:
150+
151+
```
152+
class TestComponent(UnicornView):
153+
queryset_with_empty_list: QueryType[SomeModel] = []
154+
queryset_with_none: QueryType[SomeModel] = None
155+
queryset_with_empty_queryset: QueryType[SomeModel] = SomeModel.objects.none()
156+
queryset_with_no_typehint = SomeModel.objects.none()
157+
```
158+
159+
Params:
160+
field: Field of the component.
161+
type_hint: The optional type hint for the field.
162+
value: JSON.
163+
"""
164+
165+
# Get original queryset, update it with dictionary data and then
166+
# re-set the queryset; this is required because otherwise the
167+
# property changes type from a queryset to the serialized queryset
168+
# (which is an array of dictionaries)
169+
queryset = field
170+
model_type = None
171+
172+
if type_hint and not isinstance(queryset, QuerySet):
173+
type_arguments = get_args(type_hint)
174+
175+
if type_arguments:
176+
# First type argument should be the type of the model
177+
queryset = type_arguments[0].objects.none()
178+
model_type = type_arguments[0]
179+
180+
if not model_type and not isinstance(queryset, QuerySet):
181+
raise Exception(f"Getting Django Model type failed for type: {type(queryset)}")
182+
183+
if not model_type:
184+
# Assume that `queryset` is _actually_ a QuerySet so grab the
185+
# `model` attribute in that case
186+
model_type = queryset.model
187+
188+
for model_value in value:
189+
model_found = False
190+
191+
# The following portion uses the internal `_result_cache` QuerySet API which
192+
# is private and could potentially change in the future, but not sure how
193+
# else to change internal models or append a new model to a QuerySet (probably
194+
# because it isn't really allowed)
195+
if queryset._result_cache is None:
196+
# Explicitly set `_result_cache` to an empty list
197+
queryset._result_cache = []
198+
199+
for (idx, model) in enumerate(queryset._result_cache):
200+
if model.pk == model_value.get("pk"):
201+
queryset._result_cache[idx] = model_type(**model_value)
202+
model_found = True
203+
204+
if not model_found:
205+
queryset._result_cache.append(model_type(**model_value))
206+
207+
return queryset
Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,26 @@
1-
from typing import List
2-
3-
from django_unicorn.components import UnicornView
1+
from django_unicorn.components import QueryType, UnicornView
42
from example.coffee.models import Flavor
53

64

75
class ModelsView(UnicornView):
8-
# class attributes get stored on the class, so django-unicorn handles clearing
9-
# this with `_resettable_attributes_cache` in components.py
106
flavor: Flavor = Flavor()
11-
flavors: List[Flavor] = []
7+
flavors: QueryType[Flavor] = Flavor.objects.none()
128

139
def mount(self):
14-
self.flavors = list(Flavor.objects.all().order_by("-id")[:2])
10+
self.refresh_flavors()
11+
12+
def refresh_flavors(self):
13+
self.flavors = Flavor.objects.all().order_by("-id")[:2]
1514

1615
def save_flavor(self):
1716
self.flavor.save()
1817
self.flavor = Flavor()
18+
self.refresh_flavors()
1919

20-
def save(self, flavors_idx):
21-
flavor_data = self.flavors[flavors_idx]
22-
print("call save for idx", flavors_idx)
23-
flavor = Flavor(**flavor_data)
24-
flavor.save()
20+
def save(self, flavor_idx: int):
21+
flavor_data = self.flavors[flavor_idx]
22+
flavor_data.save()
2523

26-
def save_specific(self, flavor: Flavor):
27-
flavor.save()
28-
print("call save on flavor", flavor)
24+
def delete(self, flavor_to_delete: Flavor):
25+
flavor_to_delete.delete()
26+
self.refresh_flavors()

example/unicorn/templates/unicorn/models.html

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
}
66
</style>
77

8-
<h3>Current flavors</h3>
9-
108
<div>
119
<h3>Using unicorn:model with Django models</h3>
1210

@@ -46,7 +44,7 @@ <h4>{{ flavor.pk }}</h4>
4644
{{ flavor.decimal_value }}
4745
<br />
4846

49-
<button unicorn:click="save({{ forloop.counter0 }})">save(flavors_idx)</button>
47+
<button unicorn:click="save({{ forloop.counter0 }})">save(flavor_idx)</button>
5048
<button unicorn:click="delete({{ flavor.pk }})">delete(flavor.pk)</button>
5149
</div>
5250
{% endfor %}

tests/views/action_parsers/utils/__init__.py

Whitespace-only changes.
Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,20 @@
11
from datetime import datetime
22
from typing import List
33

4+
import pytest
5+
46
from django_unicorn.components import UnicornView
57
from django_unicorn.views.action_parsers.utils import set_property_value
6-
from django_unicorn.views.utils import set_property_from_data
8+
from example.coffee.models import Flavor
79

810

911
class FakeComponent(UnicornView):
1012
string = "property_view"
1113
integer = 99
1214
datetime = datetime(2020, 1, 1)
1315
array: List[str] = []
14-
15-
16-
def test_set_property_from_data_str():
17-
component = FakeComponent(component_name="test", component_id="12345678")
18-
assert "property_view" == component.string
19-
20-
set_property_from_data(component, "string", "property_view_updated")
21-
22-
assert "property_view_updated" == component.string
16+
model = Flavor(name="initial-flavor")
17+
queryset = Flavor.objects.none()
2318

2419

2520
def test_set_property_value_str():
@@ -36,15 +31,6 @@ def test_set_property_value_str():
3631
assert "property_view_updated" == component.string
3732

3833

39-
def test_set_property_from_data_int():
40-
component = FakeComponent(component_name="test", component_id="12345678")
41-
assert 99 == component.integer
42-
43-
set_property_from_data(component, "integer", 100)
44-
45-
assert 100 == component.integer
46-
47-
4834
def test_set_property_value_int():
4935
component = FakeComponent(component_name="test", component_id="12345678")
5036
assert 99 == component.integer
@@ -54,33 +40,47 @@ def test_set_property_value_int():
5440
assert 100 == component.integer
5541

5642

57-
def test_set_property_from_data_datetime():
43+
def test_set_property_value_datetime():
5844
component = FakeComponent(component_name="test", component_id="12345678")
5945
assert datetime(2020, 1, 1) == component.datetime
6046

61-
set_property_from_data(component, "datetime", datetime(2020, 1, 2))
47+
set_property_value(
48+
component, "datetime", datetime(2020, 1, 2), {"datetime": datetime(2020, 1, 2)}
49+
)
6250

6351
assert datetime(2020, 1, 2) == component.datetime
6452

6553

66-
def test_set_property_from_data_list():
67-
"""
68-
Prevent attempting to instantiate `List[]` type-hint doesn't throw TypeError
69-
"""
54+
def test_set_property_value_model():
7055
component = FakeComponent(component_name="test", component_id="12345678")
71-
assert component.array == []
56+
assert "initial-flavor" == component.model.name
7257

73-
set_property_from_data(component, "array", ["string"])
58+
set_property_value(
59+
component,
60+
"model",
61+
Flavor(name="test-flavor"),
62+
{"model": {"name": "test-flavor"}},
63+
)
7464

75-
assert ["string"] == component.array
65+
assert "test-flavor" == component.model.name
7666

7767

78-
def test_set_property_value_datetime():
68+
@pytest.mark.django_db
69+
def test_set_property_value_queryset():
7970
component = FakeComponent(component_name="test", component_id="12345678")
80-
assert datetime(2020, 1, 1) == component.datetime
71+
assert len(component.queryset) == 0
72+
73+
flavor_one = Flavor(name="test-flavor-one")
74+
flavor_one.save()
75+
flavor_two = Flavor(name="test-flavor-two")
76+
flavor_two.save()
77+
queryset = Flavor.objects.all()[:2]
8178

8279
set_property_value(
83-
component, "datetime", datetime(2020, 1, 2), {"datetime": datetime(2020, 1, 2)}
80+
component,
81+
"queryset",
82+
queryset,
83+
{"queryset": [{"name": "test-flavor-one"}, {"name": "test-flavor-two"}]},
8484
)
8585

86-
assert datetime(2020, 1, 2) == component.datetime
86+
assert len(queryset) == 2

tests/views/message/test_db_input.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
def test_message_db_input_update(client):
1313
flavor = Flavor(id=1, name="Enzymatic-Flowery")
1414
flavor.save()
15-
data = {"flavors": [{"pk": flavor.pk, "title": flavor.name}]}
15+
data = {"flavors": [{"pk": flavor.pk, "name": flavor.name}]}
1616

1717
message = {
1818
"actionQueue": [

0 commit comments

Comments
 (0)