diff --git a/CHANGELOG.md b/CHANGELOG.md index a283c7d8..34ab7127 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,9 @@ Types of changes are to be listed in this order ## [Unreleased] -- Nothing (yet) +### Fixed + +- Django ORM can now be directly called within components without causing a `SynchronousOnlyOperation` exception. ## [1.1.0] - 2022-07-01 diff --git a/src/django_idom/layout.py b/src/django_idom/layout.py new file mode 100644 index 00000000..2cbc7e37 --- /dev/null +++ b/src/django_idom/layout.py @@ -0,0 +1,27 @@ +import os + +from idom.core.layout import Layout, LayoutUpdate + + +class DjangoLayout(Layout): + """Fixes Django ORM usage within components. + These issues are caused by async/sync mixed context limitations in the ORM. + Without this, `SynchronousOnlyOperation` exceptions occur when using the ORM in IDOM components. + This may be fixed in a future version, such as Django 5.0.""" + + def _create_layout_update(self, old_state) -> LayoutUpdate: + """Create a layout update, but set ALLOW ASYNC UNSAFE flags prior. + This allows the Django ORM to be used within components.""" + async_unsafe_prev = os.environ.get("DJANGO_ALLOW_ASYNC_UNSAFE", None) + + # Set ALLOW ASYNC UNSAFE to True + os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" + layout_update = super()._create_layout_update(old_state) + + # Reset async unsafe flag to the previous value + if async_unsafe_prev is not None: + os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = async_unsafe_prev + else: + os.environ.pop("DJANGO_ALLOW_ASYNC_UNSAFE") + + return layout_update diff --git a/src/django_idom/websocket/consumer.py b/src/django_idom/websocket/consumer.py index aa988afa..9a54472e 100644 --- a/src/django_idom/websocket/consumer.py +++ b/src/django_idom/websocket/consumer.py @@ -8,11 +8,12 @@ from channels.auth import login from channels.db import database_sync_to_async as convert_to_async from channels.generic.websocket import AsyncJsonWebsocketConsumer -from idom.core.layout import Layout, LayoutEvent +from idom.core.layout import LayoutEvent from idom.core.serve import serve_json_patch from django_idom.config import IDOM_REGISTERED_COMPONENTS from django_idom.hooks import IdomWebsocket, WebsocketContext +from django_idom.layout import DjangoLayout _logger = logging.getLogger(__name__) @@ -73,7 +74,7 @@ async def _run_dispatch_loop(self): self._idom_recv_queue = recv_queue = asyncio.Queue() try: await serve_json_patch( - Layout(WebsocketContext(component_instance, value=socket)), + DjangoLayout(WebsocketContext(component_instance, value=socket)), self.send_json, recv_queue.get, ) diff --git a/tests/test_app/components.py b/tests/test_app/components.py index a2491c17..361f2593 100644 --- a/tests/test_app/components.py +++ b/tests/test_app/components.py @@ -1,3 +1,5 @@ +from uuid import uuid4 + import idom import django_idom @@ -95,3 +97,19 @@ def django_js(): ), idom.html.hr(), ) + + +@idom.component +def orm_in_component(): + from .models import NamedThingy + + NamedThingy.objects.all().delete() + NamedThingy(name=f"foo-{uuid4()}").save() + model = NamedThingy.objects.all() + success = bool(model) + + return idom.html.div( + {"id": "orm-in-component", "data-success": success}, + f"orm_in_component: {model}", + idom.html.hr(), + ) diff --git a/tests/test_app/migrations/0001_initial.py b/tests/test_app/migrations/0001_initial.py new file mode 100644 index 00000000..94c016db --- /dev/null +++ b/tests/test_app/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 4.0.5 on 2022-06-26 02:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="NamedThingy", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=255)), + ], + ), + ] diff --git a/tests/test_app/models.py b/tests/test_app/models.py new file mode 100644 index 00000000..3d3981c0 --- /dev/null +++ b/tests/test_app/models.py @@ -0,0 +1,8 @@ +from django.db import models + + +class NamedThingy(models.Model): + def __str__(self): + return self.name + + name = models.CharField(max_length=255) diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html index e6960c14..b67fc634 100644 --- a/tests/test_app/templates/base.html +++ b/tests/test_app/templates/base.html @@ -1,4 +1,4 @@ -{% load static %} {% load idom %} +{% load static %} {% load idom %} {% load idom_tests %} @@ -21,6 +21,8 @@

IDOM Test Page

{% component "test_app.components.use_location" %}
{% component "test_app.components.django_css" %}
{% component "test_app.components.django_js" %}
+
{% component "test_app.components.orm_in_component" %}
+
DJANGO_ALLOW_ASYNC_UNSAFE: {% check_async_unsafe %}
diff --git a/tests/test_app/templatetags/__init__.py b/tests/test_app/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_app/templatetags/idom_tests.py b/tests/test_app/templatetags/idom_tests.py new file mode 100644 index 00000000..a93b92b2 --- /dev/null +++ b/tests/test_app/templatetags/idom_tests.py @@ -0,0 +1,11 @@ +import os + +from django import template + + +register = template.Library() + + +@register.simple_tag +def check_async_unsafe(): + return bool(os.environ.get("DJANGO_ALLOW_ASYNC_UNSAFE", None)) diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py index 8ed89efa..2541d2db 100644 --- a/tests/test_app/tests/test_components.py +++ b/tests/test_app/tests/test_components.py @@ -59,6 +59,14 @@ def test_use_location(self): element = self.driver.find_element_by_id("use-location") self.assertEqual(element.get_attribute("data-success"), "true") + def test_orm_in_component(self): + element = self.driver.find_element_by_id("orm-in-component") + self.assertEqual(element.get_attribute("data-success"), "true") + + # Make sure ASYNC_UNSAFE value was reset after component render + element = self.driver.find_element_by_id("allow-async-unsafe") + self.assertEqual(element.get_attribute("data-value"), "False") + def test_static_css(self): element = self.driver.find_element_by_css_selector("#django-css button") self.assertEqual(