From 84b6fb332e994d257f7d93648726033c8a42bcd2 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 15 Nov 2022 01:41:32 -0800
Subject: [PATCH 01/37] Attempt ManyToMany auto-fetching
---
src/django_idom/hooks.py | 12 ++-
tests/test_app/admin.py | 17 +++-
tests/test_app/components.py | 44 +++++++++-
...onalchild_relationalparent_foriegnchild.py | 80 +++++++++++++++++++
tests/test_app/models.py | 17 ++++
tests/test_app/templates/base.html | 1 +
6 files changed, 167 insertions(+), 4 deletions(-)
create mode 100644 tests/test_app/migrations/0002_relationalchild_relationalparent_foriegnchild.py
diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py
index 7e73df23..0a117d87 100644
--- a/src/django_idom/hooks.py
+++ b/src/django_idom/hooks.py
@@ -1,10 +1,12 @@
from __future__ import annotations
import asyncio
+import contextlib
import logging
from typing import Any, Awaitable, Callable, DefaultDict, Sequence, Union, cast
from channels.db import database_sync_to_async as _database_sync_to_async
+from django.db.models import ManyToManyField
from django.db.models.base import Model
from django.db.models.query import QuerySet
from idom import use_callback, use_ref
@@ -190,8 +192,14 @@ def _fetch_lazy_fields(data: Any) -> None:
# `Model` instances
elif isinstance(data, Model):
- for field in data._meta.fields:
- getattr(data, field.name)
+ for field in data._meta.get_fields():
+ with contextlib.suppress(AttributeError):
+ getattr(data, field.name)
+
+ if isinstance(field, ManyToManyField):
+ mtm_field = getattr(data, field.name)
+ mtm_queryset = mtm_field.get_queryset()
+ _fetch_lazy_fields(mtm_queryset)
# Unrecognized type
else:
diff --git a/tests/test_app/admin.py b/tests/test_app/admin.py
index da45013d..5e35a515 100644
--- a/tests/test_app/admin.py
+++ b/tests/test_app/admin.py
@@ -1,7 +1,22 @@
from django.contrib import admin
-from test_app.models import TodoItem
+from test_app.models import ForiegnChild, RelationalChild, RelationalParent, TodoItem
@admin.register(TodoItem)
class TodoItemAdmin(admin.ModelAdmin):
pass
+
+
+@admin.register(RelationalChild)
+class RelationalChildAdmin(admin.ModelAdmin):
+ pass
+
+
+@admin.register(RelationalParent)
+class RelationalParentAdmin(admin.ModelAdmin):
+ pass
+
+
+@admin.register(ForiegnChild)
+class ForiegnChildAdmin(admin.ModelAdmin):
+ pass
diff --git a/tests/test_app/components.py b/tests/test_app/components.py
index 01e0abd3..5161f088 100644
--- a/tests/test_app/components.py
+++ b/tests/test_app/components.py
@@ -3,7 +3,7 @@
from django.http import HttpRequest
from django.shortcuts import render
from idom import component, hooks, html, web
-from test_app.models import TodoItem
+from test_app.models import ForiegnChild, RelationalChild, RelationalParent, TodoItem
import django_idom
from django_idom.components import view_to_component
@@ -154,6 +154,48 @@ def authorized_user():
)
+def get_relational_parent_query():
+ parent = RelationalParent.objects.first()
+ if not parent:
+ child_1 = RelationalChild.objects.create(text="ManyToMany Child 1")
+ child_2 = RelationalChild.objects.create(text="ManyToMany Child 2")
+ child_3 = RelationalChild.objects.create(text="ManyToMany Child 3")
+ child_4 = RelationalChild.objects.create(text="OneToOne Child")
+ parent = RelationalParent.objects.create(one_to_one=child_4)
+ parent.many_to_many.set((child_1, child_2, child_3))
+ parent.save()
+ return parent
+
+
+def get_foriegn_child_query():
+ child = ForiegnChild.objects.first()
+ if not child:
+ parent = RelationalParent.objects.first()
+ if not parent:
+ parent = get_relational_parent_query()
+ child = ForiegnChild.objects.create(parent=parent, text="Foriegn Child")
+ child.save()
+ return child
+
+
+@component
+def relational_query():
+ relational_parent = use_query(get_relational_parent_query)
+ foriegn_child = use_query(get_foriegn_child_query)
+
+ if not relational_parent.data or not foriegn_child.data:
+ return
+
+ return html.div(
+ {"id": "relational-obj-printout"},
+ html.div(
+ f"Relational Parent Many To Many: {relational_parent.data.many_to_many.all()}"
+ ),
+ html.div(f"Relational Parent One To One: {relational_parent.data.one_to_one}"),
+ html.div(f"Relational Child Foreign Key: {foriegn_child.data.parent}"),
+ )
+
+
def get_items_query():
return TodoItem.objects.all()
diff --git a/tests/test_app/migrations/0002_relationalchild_relationalparent_foriegnchild.py b/tests/test_app/migrations/0002_relationalchild_relationalparent_foriegnchild.py
new file mode 100644
index 00000000..0f914547
--- /dev/null
+++ b/tests/test_app/migrations/0002_relationalchild_relationalparent_foriegnchild.py
@@ -0,0 +1,80 @@
+# Generated by Django 4.1.3 on 2022-11-15 08:36
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("test_app", "0001_initial"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="RelationalChild",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("text", models.CharField(max_length=1000)),
+ ],
+ ),
+ migrations.CreateModel(
+ name="RelationalParent",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("done", models.BooleanField(default=True)),
+ (
+ "many_to_many",
+ models.ManyToManyField(
+ related_name="many_to_many", to="test_app.relationalchild"
+ ),
+ ),
+ (
+ "one_to_one",
+ models.OneToOneField(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="one_to_one",
+ to="test_app.relationalchild",
+ ),
+ ),
+ ],
+ ),
+ migrations.CreateModel(
+ name="ForiegnChild",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("text", models.CharField(max_length=1000)),
+ (
+ "parent",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ to="test_app.relationalparent",
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/tests/test_app/models.py b/tests/test_app/models.py
index 7b6fc0f8..e001c06f 100644
--- a/tests/test_app/models.py
+++ b/tests/test_app/models.py
@@ -4,3 +4,20 @@
class TodoItem(models.Model):
done = models.BooleanField() # type: ignore
text = models.CharField(max_length=1000) # type: ignore
+
+
+class RelationalChild(models.Model):
+ text = models.CharField(max_length=1000) # type: ignore
+
+
+class RelationalParent(models.Model):
+ done = models.BooleanField(default=True) # type: ignore
+ many_to_many = models.ManyToManyField(RelationalChild, related_name="many_to_many") # type: ignore
+ one_to_one = models.OneToOneField( # type: ignore
+ RelationalChild, related_name="one_to_one", on_delete=models.SET_NULL, null=True
+ )
+
+
+class ForiegnChild(models.Model):
+ text = models.CharField(max_length=1000) # type: ignore
+ parent = models.ForeignKey(RelationalParent, on_delete=models.CASCADE) # type: ignore
diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html
index 812f1bda..ec99aa1f 100644
--- a/tests/test_app/templates/base.html
+++ b/tests/test_app/templates/base.html
@@ -32,6 +32,7 @@
IDOM Test Page
{% component "test_app.components.django_js" %}
{% component "test_app.components.unauthorized_user" %}
{% component "test_app.components.authorized_user" %}
+ {% component "test_app.components.relational_query" %}
{% component "test_app.components.todo_list" %}
{% component "test_app.components.view_to_component_sync_func" %}
{% component "test_app.components.view_to_component_async_func" %}
From 8904fd3c0d74f519f792f3c3217829b6a9fd2cd9 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 15 Nov 2022 14:34:36 -0800
Subject: [PATCH 02/37] functioning many_to_many and many_to_one handling
---
src/django_idom/hooks.py | 29 +++++++++++++++++++++--------
tests/test_app/components.py | 4 ++++
2 files changed, 25 insertions(+), 8 deletions(-)
diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py
index 0a117d87..ad07301f 100644
--- a/src/django_idom/hooks.py
+++ b/src/django_idom/hooks.py
@@ -6,8 +6,9 @@
from typing import Any, Awaitable, Callable, DefaultDict, Sequence, Union, cast
from channels.db import database_sync_to_async as _database_sync_to_async
-from django.db.models import ManyToManyField
+from django.db.models import ManyToManyField, prefetch_related_objects
from django.db.models.base import Model
+from django.db.models.fields.reverse_related import ManyToOneRel
from django.db.models.query import QuerySet
from idom import use_callback, use_ref
from idom.backend.types import Location
@@ -69,6 +70,8 @@ def use_websocket() -> IdomWebsocket:
def use_query(
query: Callable[_Params, _Result | None],
*args: _Params.args,
+ many_to_many: bool = True,
+ many_to_one: bool = True,
**kwargs: _Params.kwargs,
) -> Query[_Result | None]:
"""Hook to fetch a Django ORM query.
@@ -109,7 +112,7 @@ def execute_query() -> None:
try:
new_data = query(*args, **kwargs)
- _fetch_lazy_fields(new_data)
+ _fetch_lazy_fields(new_data, many_to_many, many_to_one)
except Exception as e:
set_data(None)
set_loading(False)
@@ -181,25 +184,35 @@ def reset() -> None:
return Mutation(call, loading, error, reset)
-def _fetch_lazy_fields(data: Any) -> None:
+def _fetch_lazy_fields(data: Any, many_to_many, many_to_one) -> None:
"""Fetch all fields within a `Model` or `QuerySet` to ensure they are not performed lazily."""
# `QuerySet`, which is effectively a list of `Model` instances
# https://github.com/typeddjango/django-stubs/issues/704
if isinstance(data, QuerySet): # type: ignore[misc]
for model in data:
- _fetch_lazy_fields(model)
+ _fetch_lazy_fields(model, many_to_many, many_to_one)
# `Model` instances
elif isinstance(data, Model):
+ prefetch_fields = []
for field in data._meta.get_fields():
+ # `ForeignKey` relationships will cause an `AttributeError`
+ # This is handled within the `ManyToOneRel` conditional below.
with contextlib.suppress(AttributeError):
getattr(data, field.name)
- if isinstance(field, ManyToManyField):
- mtm_field = getattr(data, field.name)
- mtm_queryset = mtm_field.get_queryset()
- _fetch_lazy_fields(mtm_queryset)
+ if many_to_one and type(field) == ManyToOneRel:
+ prefetch_fields.append(f"{field.name}_set")
+
+ elif many_to_many and isinstance(field, ManyToManyField):
+ prefetch_fields.append(field.name)
+ _fetch_lazy_fields(
+ getattr(data, field.name).get_queryset(), many_to_many, many_to_one
+ )
+
+ if prefetch_fields:
+ prefetch_related_objects([data], *prefetch_fields)
# Unrecognized type
else:
diff --git a/tests/test_app/components.py b/tests/test_app/components.py
index 5161f088..e927be6b 100644
--- a/tests/test_app/components.py
+++ b/tests/test_app/components.py
@@ -192,7 +192,11 @@ def relational_query():
f"Relational Parent Many To Many: {relational_parent.data.many_to_many.all()}"
),
html.div(f"Relational Parent One To One: {relational_parent.data.one_to_one}"),
+ html.div(
+ f"Relational Parent's Children: {relational_parent.data.foriegnchild_set.all()}"
+ ),
html.div(f"Relational Child Foreign Key: {foriegn_child.data.parent}"),
+ html.hr(),
)
From 11b185e3fa09fcfc96358adad4209ccde67f94d5 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Tue, 15 Nov 2022 14:35:00 -0800
Subject: [PATCH 03/37] better naming for todo item functions
---
tests/test_app/components.py | 18 +++++++++---------
1 file changed, 9 insertions(+), 9 deletions(-)
diff --git a/tests/test_app/components.py b/tests/test_app/components.py
index e927be6b..528288fc 100644
--- a/tests/test_app/components.py
+++ b/tests/test_app/components.py
@@ -200,11 +200,11 @@ def relational_query():
)
-def get_items_query():
+def get_todo_query():
return TodoItem.objects.all()
-def add_item_mutation(text: str):
+def add_todo_mutation(text: str):
existing = TodoItem.objects.filter(text=text).first()
if existing:
if existing.done:
@@ -216,7 +216,7 @@ def add_item_mutation(text: str):
TodoItem(text=text, done=False).save()
-def toggle_item_mutation(item: TodoItem):
+def toggle_todo_mutation(item: TodoItem):
item.done = not item.done
item.save()
@@ -224,8 +224,8 @@ def toggle_item_mutation(item: TodoItem):
@component
def todo_list():
input_value, set_input_value = hooks.use_state("")
- items = use_query(get_items_query)
- toggle_item = use_mutation(toggle_item_mutation)
+ items = use_query(get_todo_query)
+ toggle_item = use_mutation(toggle_todo_mutation)
if items.error:
rendered_items = html.h2(f"Error when loading - {items.error}")
@@ -234,12 +234,12 @@ def todo_list():
else:
rendered_items = html._(
html.h3("Not Done"),
- _render_items([i for i in items.data if not i.done], toggle_item),
+ _render_todo_items([i for i in items.data if not i.done], toggle_item),
html.h3("Done"),
- _render_items([i for i in items.data if i.done], toggle_item),
+ _render_todo_items([i for i in items.data if i.done], toggle_item),
)
- add_item = use_mutation(add_item_mutation, refetch=get_items_query)
+ add_item = use_mutation(add_todo_mutation, refetch=get_todo_query)
if add_item.loading:
mutation_status = html.h2("Working...")
@@ -273,7 +273,7 @@ def on_change(event):
)
-def _render_items(items, toggle_item):
+def _render_todo_items(items, toggle_item):
return html.ul(
[
html.li(
From cdfacbe531fed2a0ce325627aad441e476bc92e1 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Wed, 16 Nov 2022 00:07:51 -0800
Subject: [PATCH 04/37] fetch_options decorator
---
src/django_idom/hooks.py | 57 +++++++++++++++++++++++++++++-----------
src/django_idom/types.py | 22 +++++++++++++++-
src/django_idom/utils.py | 40 +++++++++++++++++++++++++++-
3 files changed, 101 insertions(+), 18 deletions(-)
diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py
index ad07301f..58114969 100644
--- a/src/django_idom/hooks.py
+++ b/src/django_idom/hooks.py
@@ -14,7 +14,7 @@
from idom.backend.types import Location
from idom.core.hooks import Context, create_context, use_context, use_effect, use_state
-from django_idom.types import IdomWebsocket, Mutation, Query, _Params, _Result
+from django_idom.types import IdomWebsocket, Mutation, OrmFetch, Query, _Params, _Result
from django_idom.utils import _generate_obj_name
@@ -68,10 +68,8 @@ def use_websocket() -> IdomWebsocket:
def use_query(
- query: Callable[_Params, _Result | None],
+ query: Callable[_Params, _Result | None] | OrmFetch,
*args: _Params.args,
- many_to_many: bool = True,
- many_to_one: bool = True,
**kwargs: _Params.kwargs,
) -> Query[_Result | None]:
"""Hook to fetch a Django ORM query.
@@ -90,6 +88,7 @@ def use_query(
data, set_data = use_state(cast(Union[_Result, None], None))
loading, set_loading = use_state(True)
error, set_error = use_state(cast(Union[Exception, None], None))
+ fetch_cls = query if isinstance(query, OrmFetch) else OrmFetch(query)
@use_callback
def refetch() -> None:
@@ -101,8 +100,8 @@ def refetch() -> None:
def add_refetch_callback() -> Callable[[], None]:
# By tracking callbacks globally, any usage of the query function will be re-run
# if the user has told a mutation to refetch it.
- _REFETCH_CALLBACKS[query].add(refetch)
- return lambda: _REFETCH_CALLBACKS[query].remove(refetch)
+ _REFETCH_CALLBACKS[fetch_cls.func].add(refetch)
+ return lambda: _REFETCH_CALLBACKS[fetch_cls.func].remove(refetch)
@use_effect(dependencies=None)
@database_sync_to_async
@@ -111,8 +110,16 @@ def execute_query() -> None:
return
try:
- new_data = query(*args, **kwargs)
- _fetch_lazy_fields(new_data, many_to_many, many_to_one)
+ # Run the initial query
+ fetch_cls.data = fetch_cls.func(*args, **kwargs)
+
+ # Use a custom evaluator, if provided
+ if fetch_cls.evaluator:
+ fetch_cls.evaluator(fetch_cls)
+
+ # Evaluate lazy Django fields
+ else:
+ _evaluate_django_query(fetch_cls)
except Exception as e:
set_data(None)
set_loading(False)
@@ -124,7 +131,7 @@ def execute_query() -> None:
finally:
set_should_execute(False)
- set_data(new_data)
+ set_data(fetch_cls.data)
set_loading(False)
set_error(None)
@@ -184,14 +191,23 @@ def reset() -> None:
return Mutation(call, loading, error, reset)
-def _fetch_lazy_fields(data: Any, many_to_many, many_to_one) -> None:
- """Fetch all fields within a `Model` or `QuerySet` to ensure they are not performed lazily."""
+def _evaluate_django_query(fetch_cls: OrmFetch) -> None:
+ """Recursively fetch all fields within a `Model` or `QuerySet` to ensure they are not performed lazily.
+
+ Some behaviors can be modified through `OrmFetch` attributes."""
+ data = fetch_cls.data
# `QuerySet`, which is effectively a list of `Model` instances
# https://github.com/typeddjango/django-stubs/issues/704
if isinstance(data, QuerySet): # type: ignore[misc]
for model in data:
- _fetch_lazy_fields(model, many_to_many, many_to_one)
+ _evaluate_django_query(
+ OrmFetch(
+ func=fetch_cls.func,
+ data=model,
+ options=fetch_cls.options,
+ )
+ )
# `Model` instances
elif isinstance(data, Model):
@@ -202,13 +218,22 @@ def _fetch_lazy_fields(data: Any, many_to_many, many_to_one) -> None:
with contextlib.suppress(AttributeError):
getattr(data, field.name)
- if many_to_one and type(field) == ManyToOneRel:
+ if (
+ fetch_cls.options.get("many_to_one", None)
+ and type(field) == ManyToOneRel
+ ):
prefetch_fields.append(f"{field.name}_set")
- elif many_to_many and isinstance(field, ManyToManyField):
+ elif fetch_cls.options.get("many_to_many", None) and isinstance(
+ field, ManyToManyField
+ ):
prefetch_fields.append(field.name)
- _fetch_lazy_fields(
- getattr(data, field.name).get_queryset(), many_to_many, many_to_one
+ _evaluate_django_query(
+ OrmFetch(
+ func=fetch_cls.func,
+ data=getattr(data, field.name).get_queryset(),
+ options=fetch_cls.options,
+ )
)
if prefetch_fields:
diff --git a/src/django_idom/types.py b/src/django_idom/types.py
index a13bbe8f..8f351ed5 100644
--- a/src/django_idom/types.py
+++ b/src/django_idom/types.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from typing import Any, Awaitable, Callable, Generic, Optional, Sequence, TypeVar, Union
from django.db.models.base import Model
@@ -51,3 +51,23 @@ class ViewComponentIframe:
view: View | Callable
args: Sequence
kwargs: dict
+
+
+@dataclass
+class OrmFetch:
+ """A Django ORM fetch."""
+
+ func: Callable
+ """Callable that fetches ORM objects."""
+
+ data: Any | None = None
+ """The results of a fetch operation."""
+
+ options: dict[str, Any] = field(
+ default_factory=lambda: {"many_to_many": True, "many_to_one": True}
+ )
+ """Configuration values usable by the `fetch_handler`."""
+
+ evaluator: Callable[[OrmFetch], None] | None = None
+ """A post-processing callable that can read/modify the `OrmFetch` object. If unset, the default fetch
+ handler is used to prevent lazy loading of Django fields."""
diff --git a/src/django_idom/utils.py b/src/django_idom/utils.py
index 8394589e..fa95cb4c 100644
--- a/src/django_idom/utils.py
+++ b/src/django_idom/utils.py
@@ -7,7 +7,7 @@
from fnmatch import fnmatch
from importlib import import_module
from inspect import iscoroutinefunction
-from typing import Any, Callable, Sequence
+from typing import Any, Callable, Sequence, overload
from channels.db import database_sync_to_async
from django.http import HttpRequest, HttpResponse
@@ -16,6 +16,7 @@
from django.views import View
from django_idom.config import IDOM_REGISTERED_COMPONENTS
+from django_idom.types import OrmFetch, _Params, _Result
_logger = logging.getLogger(__name__)
@@ -199,3 +200,40 @@ def _generate_obj_name(object: Any) -> str | None:
if hasattr(object, "__class__"):
return f"{object.__module__}.{object.__class__.__name__}"
return None
+
+
+@overload
+def fetch_options(
+ query_func: None = ...,
+ evaluator: Callable[[OrmFetch], None] | None = None,
+ **options,
+) -> Callable[[Callable[_Params, _Result]], Callable[_Params, _Result]]:
+ pass
+
+
+@overload
+def fetch_options(
+ query_func: Callable[_Params, _Result],
+ evaluator: Callable[[OrmFetch], None] | None = None,
+ **options,
+) -> Callable[_Params, _Result]:
+ ...
+
+
+def fetch_options(
+ query_func: Callable[_Params, _Result] | None = None,
+ evaluator: Callable[[OrmFetch], None] | None = None,
+ **options,
+) -> Callable[_Params, _Result] | Callable[
+ [Callable[_Params, _Result]], Callable[_Params, _Result]
+]:
+ def decorator(query_func: Callable[_Params, _Result]):
+ if not query_func:
+ raise ValueError("A query function must be provided to `fetch_options`")
+
+ fetch_options = OrmFetch(func=query_func, evaluator=evaluator)
+ if options:
+ fetch_options.options.update(options)
+ return fetch_options
+
+ return decorator(query_func) if query_func else decorator
From e6b12378f3432ad103960deda2d6ab4e99e4fd0d Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Wed, 16 Nov 2022 00:33:27 -0800
Subject: [PATCH 05/37] failure tests
---
tests/test_app/components.py | 74 +++++++++++++++++++++++-------
tests/test_app/templates/base.html | 2 +
2 files changed, 60 insertions(+), 16 deletions(-)
diff --git a/tests/test_app/components.py b/tests/test_app/components.py
index 528288fc..f2672593 100644
--- a/tests/test_app/components.py
+++ b/tests/test_app/components.py
@@ -8,6 +8,7 @@
import django_idom
from django_idom.components import view_to_component
from django_idom.hooks import use_mutation, use_query
+from django_idom.utils import fetch_options
from . import views
@@ -154,19 +155,21 @@ def authorized_user():
)
-def get_relational_parent_query():
- parent = RelationalParent.objects.first()
- if not parent:
- child_1 = RelationalChild.objects.create(text="ManyToMany Child 1")
- child_2 = RelationalChild.objects.create(text="ManyToMany Child 2")
- child_3 = RelationalChild.objects.create(text="ManyToMany Child 3")
- child_4 = RelationalChild.objects.create(text="OneToOne Child")
- parent = RelationalParent.objects.create(one_to_one=child_4)
- parent.many_to_many.set((child_1, child_2, child_3))
- parent.save()
+def create_relational_parent() -> RelationalParent:
+ child_1 = RelationalChild.objects.create(text="ManyToMany Child 1")
+ child_2 = RelationalChild.objects.create(text="ManyToMany Child 2")
+ child_3 = RelationalChild.objects.create(text="ManyToMany Child 3")
+ child_4 = RelationalChild.objects.create(text="OneToOne Child")
+ parent = RelationalParent.objects.create(one_to_one=child_4)
+ parent.many_to_many.set((child_1, child_2, child_3))
+ parent.save()
return parent
+def get_relational_parent_query():
+ return RelationalParent.objects.first() or create_relational_parent()
+
+
def get_foriegn_child_query():
child = ForiegnChild.objects.first()
if not child:
@@ -186,17 +189,56 @@ def relational_query():
if not relational_parent.data or not foriegn_child.data:
return
+ mtm = relational_parent.data.many_to_many.all()
+ oto = relational_parent.data.one_to_one
+ mto = relational_parent.data.foriegnchild_set.all()
+ fk = foriegn_child.data.parent
+
return html.div(
- {"id": "relational-obj-printout"},
+ {
+ "id": "relational-query",
+ "data-success": bool(mtm) and bool(oto) and bool(mto) and bool(fk),
+ },
+ html.div(f"Relational Parent Many To Many: {mtm}"),
+ html.div(f"Relational Parent One To One: {oto}"),
+ html.div(f"Relational Parent Many to One: {mto}"),
+ html.div(f"Relational Child Foreign Key: {fk}"),
+ html.hr(),
+ )
+
+
+@fetch_options(many_to_many=False, many_to_one=False)
+def get_relational_parent_query_fail():
+ return RelationalParent.objects.first() or create_relational_parent()
+
+
+@component
+def relational_query_fail_mtm():
+ relational_parent = use_query(get_relational_parent_query_fail)
+
+ if not relational_parent.data:
+ return
+
+ return html.div(
+ {"id": "relational-query-fail-mtm"},
html.div(
- f"Relational Parent Many To Many: {relational_parent.data.many_to_many.all()}"
+ f"Relational Parent Many To Many Fail: {relational_parent.data.many_to_many.all()}"
),
- html.div(f"Relational Parent One To One: {relational_parent.data.one_to_one}"),
+ )
+
+
+@component
+def relational_query_fail_mto():
+ relational_parent = use_query(get_relational_parent_query_fail)
+
+ if not relational_parent.data:
+ return
+
+ return html.div(
+ {"id": "relational-query-fail-mto"},
html.div(
- f"Relational Parent's Children: {relational_parent.data.foriegnchild_set.all()}"
+ f"Relational Parent Many to One: {relational_parent.data.foriegnchild_set.all()}"
),
- html.div(f"Relational Child Foreign Key: {foriegn_child.data.parent}"),
- html.hr(),
)
diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html
index ec99aa1f..393408f7 100644
--- a/tests/test_app/templates/base.html
+++ b/tests/test_app/templates/base.html
@@ -32,6 +32,8 @@ IDOM Test Page
{% component "test_app.components.django_js" %}
{% component "test_app.components.unauthorized_user" %}
{% component "test_app.components.authorized_user" %}
+ {% component "test_app.components.relational_query_fail_mtm" %}
+ {% component "test_app.components.relational_query_fail_mto" %}
{% component "test_app.components.relational_query" %}
{% component "test_app.components.todo_list" %}
{% component "test_app.components.view_to_component_sync_func" %}
From 056471685f48f64ba680a40be1833adb7d2b0ad1 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Wed, 16 Nov 2022 00:47:35 -0800
Subject: [PATCH 06/37] format migrations
---
.../0002_relationalchild_relationalparent_foriegnchild.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/test_app/migrations/0002_relationalchild_relationalparent_foriegnchild.py b/tests/test_app/migrations/0002_relationalchild_relationalparent_foriegnchild.py
index 0f914547..3ee897b0 100644
--- a/tests/test_app/migrations/0002_relationalchild_relationalparent_foriegnchild.py
+++ b/tests/test_app/migrations/0002_relationalchild_relationalparent_foriegnchild.py
@@ -1,7 +1,7 @@
# Generated by Django 4.1.3 on 2022-11-15 08:36
-from django.db import migrations, models
import django.db.models.deletion
+from django.db import migrations, models
class Migration(migrations.Migration):
From 36035fbd3d550e763dfd532696268868dd3e3365 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Wed, 16 Nov 2022 01:52:31 -0800
Subject: [PATCH 07/37] OrmFetch type hint on fetch_options
---
src/django_idom/utils.py | 8 +++-----
1 file changed, 3 insertions(+), 5 deletions(-)
diff --git a/src/django_idom/utils.py b/src/django_idom/utils.py
index fa95cb4c..e82ef10b 100644
--- a/src/django_idom/utils.py
+++ b/src/django_idom/utils.py
@@ -207,7 +207,7 @@ def fetch_options(
query_func: None = ...,
evaluator: Callable[[OrmFetch], None] | None = None,
**options,
-) -> Callable[[Callable[_Params, _Result]], Callable[_Params, _Result]]:
+) -> Callable[[Callable[_Params, _Result]], OrmFetch]:
pass
@@ -216,7 +216,7 @@ def fetch_options(
query_func: Callable[_Params, _Result],
evaluator: Callable[[OrmFetch], None] | None = None,
**options,
-) -> Callable[_Params, _Result]:
+) -> OrmFetch:
...
@@ -224,9 +224,7 @@ def fetch_options(
query_func: Callable[_Params, _Result] | None = None,
evaluator: Callable[[OrmFetch], None] | None = None,
**options,
-) -> Callable[_Params, _Result] | Callable[
- [Callable[_Params, _Result]], Callable[_Params, _Result]
-]:
+) -> OrmFetch | Callable[[Callable[_Params, _Result]], OrmFetch]:
def decorator(query_func: Callable[_Params, _Result]):
if not query_func:
raise ValueError("A query function must be provided to `fetch_options`")
From ecb96e68997ad96fa6a5ddae48aaedafdb8a64a8 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Wed, 16 Nov 2022 01:53:35 -0800
Subject: [PATCH 08/37] pass -> ...
---
src/django_idom/utils.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/django_idom/utils.py b/src/django_idom/utils.py
index e82ef10b..3dec5b41 100644
--- a/src/django_idom/utils.py
+++ b/src/django_idom/utils.py
@@ -208,7 +208,7 @@ def fetch_options(
evaluator: Callable[[OrmFetch], None] | None = None,
**options,
) -> Callable[[Callable[_Params, _Result]], OrmFetch]:
- pass
+ ...
@overload
From d1bd3f8dbebf0eca05e1d7ededcab8ad6ad240fa Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Wed, 16 Nov 2022 19:03:00 -0800
Subject: [PATCH 09/37] better variable/function names
---
src/django_idom/hooks.py | 61 +++++++++++++++++++++---------------
src/django_idom/types.py | 21 +++++++------
src/django_idom/utils.py | 28 ++++++++---------
tests/test_app/components.py | 4 +--
4 files changed, 62 insertions(+), 52 deletions(-)
diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py
index 58114969..4ca1525c 100644
--- a/src/django_idom/hooks.py
+++ b/src/django_idom/hooks.py
@@ -14,7 +14,14 @@
from idom.backend.types import Location
from idom.core.hooks import Context, create_context, use_context, use_effect, use_state
-from django_idom.types import IdomWebsocket, Mutation, OrmFetch, Query, _Params, _Result
+from django_idom.types import (
+ IdomWebsocket,
+ Mutation,
+ QueryOptions,
+ Query,
+ _Params,
+ _Result,
+)
from django_idom.utils import _generate_obj_name
@@ -68,7 +75,7 @@ def use_websocket() -> IdomWebsocket:
def use_query(
- query: Callable[_Params, _Result | None] | OrmFetch,
+ query: Callable[_Params, _Result | None] | QueryOptions,
*args: _Params.args,
**kwargs: _Params.kwargs,
) -> Query[_Result | None]:
@@ -88,7 +95,9 @@ def use_query(
data, set_data = use_state(cast(Union[_Result, None], None))
loading, set_loading = use_state(True)
error, set_error = use_state(cast(Union[Exception, None], None))
- fetch_cls = query if isinstance(query, OrmFetch) else OrmFetch(query)
+ query_options = (
+ query if isinstance(query, QueryOptions) else QueryOptions(func=query)
+ )
@use_callback
def refetch() -> None:
@@ -100,8 +109,8 @@ def refetch() -> None:
def add_refetch_callback() -> Callable[[], None]:
# By tracking callbacks globally, any usage of the query function will be re-run
# if the user has told a mutation to refetch it.
- _REFETCH_CALLBACKS[fetch_cls.func].add(refetch)
- return lambda: _REFETCH_CALLBACKS[fetch_cls.func].remove(refetch)
+ _REFETCH_CALLBACKS[query_options.func].add(refetch)
+ return lambda: _REFETCH_CALLBACKS[query_options.func].remove(refetch)
@use_effect(dependencies=None)
@database_sync_to_async
@@ -111,15 +120,15 @@ def execute_query() -> None:
try:
# Run the initial query
- fetch_cls.data = fetch_cls.func(*args, **kwargs)
+ query_options._data = query_options.func(*args, **kwargs)
- # Use a custom evaluator, if provided
- if fetch_cls.evaluator:
- fetch_cls.evaluator(fetch_cls)
+ # Use a custom postprocessor, if provided
+ if query_options.postprocessor:
+ query_options.postprocessor(query_options)
- # Evaluate lazy Django fields
+ # Use the default postprocessor
else:
- _evaluate_django_query(fetch_cls)
+ _postprocess_django_query(query_options)
except Exception as e:
set_data(None)
set_loading(False)
@@ -131,7 +140,7 @@ def execute_query() -> None:
finally:
set_should_execute(False)
- set_data(fetch_cls.data)
+ set_data(query_options._data)
set_loading(False)
set_error(None)
@@ -191,21 +200,21 @@ def reset() -> None:
return Mutation(call, loading, error, reset)
-def _evaluate_django_query(fetch_cls: OrmFetch) -> None:
+def _postprocess_django_query(fetch_cls: QueryOptions) -> None:
"""Recursively fetch all fields within a `Model` or `QuerySet` to ensure they are not performed lazily.
Some behaviors can be modified through `OrmFetch` attributes."""
- data = fetch_cls.data
+ data = fetch_cls._data
# `QuerySet`, which is effectively a list of `Model` instances
# https://github.com/typeddjango/django-stubs/issues/704
if isinstance(data, QuerySet): # type: ignore[misc]
for model in data:
- _evaluate_django_query(
- OrmFetch(
+ _postprocess_django_query(
+ QueryOptions(
func=fetch_cls.func,
- data=model,
- options=fetch_cls.options,
+ _data=model,
+ postprocessor_options=fetch_cls.postprocessor_options,
)
)
@@ -219,20 +228,20 @@ def _evaluate_django_query(fetch_cls: OrmFetch) -> None:
getattr(data, field.name)
if (
- fetch_cls.options.get("many_to_one", None)
+ fetch_cls.postprocessor_options.get("many_to_one", None)
and type(field) == ManyToOneRel
):
prefetch_fields.append(f"{field.name}_set")
- elif fetch_cls.options.get("many_to_many", None) and isinstance(
- field, ManyToManyField
- ):
+ elif fetch_cls.postprocessor_options.get(
+ "many_to_many", None
+ ) and isinstance(field, ManyToManyField):
prefetch_fields.append(field.name)
- _evaluate_django_query(
- OrmFetch(
+ _postprocess_django_query(
+ QueryOptions(
func=fetch_cls.func,
- data=getattr(data, field.name).get_queryset(),
- options=fetch_cls.options,
+ _data=getattr(data, field.name).get_queryset(),
+ postprocessor_options=fetch_cls.postprocessor_options,
)
)
diff --git a/src/django_idom/types.py b/src/django_idom/types.py
index 8f351ed5..08980cec 100644
--- a/src/django_idom/types.py
+++ b/src/django_idom/types.py
@@ -53,21 +53,22 @@ class ViewComponentIframe:
kwargs: dict
-@dataclass
-class OrmFetch:
- """A Django ORM fetch."""
+@dataclass(kw_only=True)
+class QueryOptions:
+ """A Django ORM query function, alongside some configuration values."""
func: Callable
- """Callable that fetches ORM objects."""
+ """Callable that fetches ORM object(s)."""
- data: Any | None = None
- """The results of a fetch operation."""
+ _data: Any = None
+ """The results of a fetch operation.
+ This is only intended to be set automatically by the `use_query` hook."""
- options: dict[str, Any] = field(
+ postprocessor_options: dict[str, Any] = field(
default_factory=lambda: {"many_to_many": True, "many_to_one": True}
)
- """Configuration values usable by the `fetch_handler`."""
+ """Configuration values usable by the `postprocessor`."""
- evaluator: Callable[[OrmFetch], None] | None = None
- """A post-processing callable that can read/modify the `OrmFetch` object. If unset, the default fetch
+ postprocessor: Callable[[QueryOptions], None] | None = None
+ """A post processing callable that can read/modify the `OrmFetch` object. If unset, the default fetch
handler is used to prevent lazy loading of Django fields."""
diff --git a/src/django_idom/utils.py b/src/django_idom/utils.py
index 3dec5b41..5115ae10 100644
--- a/src/django_idom/utils.py
+++ b/src/django_idom/utils.py
@@ -16,7 +16,7 @@
from django.views import View
from django_idom.config import IDOM_REGISTERED_COMPONENTS
-from django_idom.types import OrmFetch, _Params, _Result
+from django_idom.types import QueryOptions, _Params, _Result
_logger = logging.getLogger(__name__)
@@ -203,35 +203,35 @@ def _generate_obj_name(object: Any) -> str | None:
@overload
-def fetch_options(
+def query_options(
query_func: None = ...,
- evaluator: Callable[[OrmFetch], None] | None = None,
+ evaluator: Callable[[QueryOptions], None] | None = None,
**options,
-) -> Callable[[Callable[_Params, _Result]], OrmFetch]:
+) -> Callable[[Callable[_Params, _Result]], QueryOptions]:
...
@overload
-def fetch_options(
+def query_options(
query_func: Callable[_Params, _Result],
- evaluator: Callable[[OrmFetch], None] | None = None,
+ evaluator: Callable[[QueryOptions], None] | None = None,
**options,
-) -> OrmFetch:
+) -> QueryOptions:
...
-def fetch_options(
+def query_options(
query_func: Callable[_Params, _Result] | None = None,
- evaluator: Callable[[OrmFetch], None] | None = None,
+ evaluator: Callable[[QueryOptions], None] | None = None,
**options,
-) -> OrmFetch | Callable[[Callable[_Params, _Result]], OrmFetch]:
+) -> QueryOptions | Callable[[Callable[_Params, _Result]], QueryOptions]:
def decorator(query_func: Callable[_Params, _Result]):
if not query_func:
- raise ValueError("A query function must be provided to `fetch_options`")
+ raise ValueError("A query function must be provided to `query_options`")
- fetch_options = OrmFetch(func=query_func, evaluator=evaluator)
+ query_options = QueryOptions(func=query_func, postprocessor=evaluator)
if options:
- fetch_options.options.update(options)
- return fetch_options
+ query_options.postprocessor_options.update(options)
+ return query_options
return decorator(query_func) if query_func else decorator
diff --git a/tests/test_app/components.py b/tests/test_app/components.py
index f2672593..c8a89308 100644
--- a/tests/test_app/components.py
+++ b/tests/test_app/components.py
@@ -8,7 +8,7 @@
import django_idom
from django_idom.components import view_to_component
from django_idom.hooks import use_mutation, use_query
-from django_idom.utils import fetch_options
+from django_idom.utils import query_options
from . import views
@@ -207,7 +207,7 @@ def relational_query():
)
-@fetch_options(many_to_many=False, many_to_one=False)
+@query_options(many_to_many=False, many_to_one=False)
def get_relational_parent_query_fail():
return RelationalParent.objects.first() or create_relational_parent()
From 96c451f00b74c2c57a775669b7ff8d25a867c2d9 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Wed, 16 Nov 2022 19:30:43 -0800
Subject: [PATCH 10/37] set fetch attributes to false by default
---
src/django_idom/hooks.py | 4 ++--
src/django_idom/types.py | 7 ++++--
tests/test_app/components.py | 37 ++----------------------------
tests/test_app/templates/base.html | 2 --
4 files changed, 9 insertions(+), 41 deletions(-)
diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py
index 4ca1525c..00afe6fa 100644
--- a/src/django_idom/hooks.py
+++ b/src/django_idom/hooks.py
@@ -17,8 +17,8 @@
from django_idom.types import (
IdomWebsocket,
Mutation,
- QueryOptions,
Query,
+ QueryOptions,
_Params,
_Result,
)
@@ -203,7 +203,7 @@ def reset() -> None:
def _postprocess_django_query(fetch_cls: QueryOptions) -> None:
"""Recursively fetch all fields within a `Model` or `QuerySet` to ensure they are not performed lazily.
- Some behaviors can be modified through `OrmFetch` attributes."""
+ Some behaviors can be modified through `query_options` attributes."""
data = fetch_cls._data
# `QuerySet`, which is effectively a list of `Model` instances
diff --git a/src/django_idom/types.py b/src/django_idom/types.py
index 08980cec..42de894c 100644
--- a/src/django_idom/types.py
+++ b/src/django_idom/types.py
@@ -57,6 +57,9 @@ class ViewComponentIframe:
class QueryOptions:
"""A Django ORM query function, alongside some configuration values."""
+ def __call__(self, *args, **kwargs):
+ return self.func(*args, **kwargs)
+
func: Callable
"""Callable that fetches ORM object(s)."""
@@ -65,10 +68,10 @@ class QueryOptions:
This is only intended to be set automatically by the `use_query` hook."""
postprocessor_options: dict[str, Any] = field(
- default_factory=lambda: {"many_to_many": True, "many_to_one": True}
+ default_factory=lambda: {"many_to_many": False, "many_to_one": False}
)
"""Configuration values usable by the `postprocessor`."""
postprocessor: Callable[[QueryOptions], None] | None = None
- """A post processing callable that can read/modify the `OrmFetch` object. If unset, the default fetch
+ """A post processing callable that can read/modify the `QueryOptions` object. If unset, the default fetch
handler is used to prevent lazy loading of Django fields."""
diff --git a/tests/test_app/components.py b/tests/test_app/components.py
index c8a89308..db896662 100644
--- a/tests/test_app/components.py
+++ b/tests/test_app/components.py
@@ -166,10 +166,12 @@ def create_relational_parent() -> RelationalParent:
return parent
+@query_options(many_to_many=True, many_to_one=True)
def get_relational_parent_query():
return RelationalParent.objects.first() or create_relational_parent()
+@query_options(many_to_many=True, many_to_one=True)
def get_foriegn_child_query():
child = ForiegnChild.objects.first()
if not child:
@@ -207,41 +209,6 @@ def relational_query():
)
-@query_options(many_to_many=False, many_to_one=False)
-def get_relational_parent_query_fail():
- return RelationalParent.objects.first() or create_relational_parent()
-
-
-@component
-def relational_query_fail_mtm():
- relational_parent = use_query(get_relational_parent_query_fail)
-
- if not relational_parent.data:
- return
-
- return html.div(
- {"id": "relational-query-fail-mtm"},
- html.div(
- f"Relational Parent Many To Many Fail: {relational_parent.data.many_to_many.all()}"
- ),
- )
-
-
-@component
-def relational_query_fail_mto():
- relational_parent = use_query(get_relational_parent_query_fail)
-
- if not relational_parent.data:
- return
-
- return html.div(
- {"id": "relational-query-fail-mto"},
- html.div(
- f"Relational Parent Many to One: {relational_parent.data.foriegnchild_set.all()}"
- ),
- )
-
-
def get_todo_query():
return TodoItem.objects.all()
diff --git a/tests/test_app/templates/base.html b/tests/test_app/templates/base.html
index 393408f7..ec99aa1f 100644
--- a/tests/test_app/templates/base.html
+++ b/tests/test_app/templates/base.html
@@ -32,8 +32,6 @@ IDOM Test Page
{% component "test_app.components.django_js" %}
{% component "test_app.components.unauthorized_user" %}
{% component "test_app.components.authorized_user" %}
- {% component "test_app.components.relational_query_fail_mtm" %}
- {% component "test_app.components.relational_query_fail_mto" %}
{% component "test_app.components.relational_query" %}
{% component "test_app.components.todo_list" %}
{% component "test_app.components.view_to_component_sync_func" %}
From 2b8d036ea8fe83dee82427a9217fdb7ea8e8588a Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Wed, 16 Nov 2022 19:38:10 -0800
Subject: [PATCH 11/37] remove kw_only for py3.8 compatibility
---
src/django_idom/types.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/django_idom/types.py b/src/django_idom/types.py
index 42de894c..b2014d19 100644
--- a/src/django_idom/types.py
+++ b/src/django_idom/types.py
@@ -53,7 +53,7 @@ class ViewComponentIframe:
kwargs: dict
-@dataclass(kw_only=True)
+@dataclass
class QueryOptions:
"""A Django ORM query function, alongside some configuration values."""
From f73c6d9601a96b5aa7a31eeed6d433fe9b2ab5ac Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Thu, 17 Nov 2022 00:37:30 -0800
Subject: [PATCH 12/37] prep _postprocess_django_query for potential API
changes
---
src/django_idom/hooks.py | 38 ++++++++++++--------------------------
src/django_idom/types.py | 4 ----
2 files changed, 12 insertions(+), 30 deletions(-)
diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py
index 00afe6fa..16bc9cdd 100644
--- a/src/django_idom/hooks.py
+++ b/src/django_idom/hooks.py
@@ -120,7 +120,7 @@ def execute_query() -> None:
try:
# Run the initial query
- query_options._data = query_options.func(*args, **kwargs)
+ data = query_options.func(*args, **kwargs)
# Use a custom postprocessor, if provided
if query_options.postprocessor:
@@ -128,7 +128,7 @@ def execute_query() -> None:
# Use the default postprocessor
else:
- _postprocess_django_query(query_options)
+ _postprocess_django_query(data, query_options.postprocessor_options)
except Exception as e:
set_data(None)
set_loading(False)
@@ -140,7 +140,7 @@ def execute_query() -> None:
finally:
set_should_execute(False)
- set_data(query_options._data)
+ set_data(data)
set_loading(False)
set_error(None)
@@ -200,49 +200,35 @@ def reset() -> None:
return Mutation(call, loading, error, reset)
-def _postprocess_django_query(fetch_cls: QueryOptions) -> None:
+def _postprocess_django_query(data: QuerySet | Model, options: dict[str, Any]) -> None:
"""Recursively fetch all fields within a `Model` or `QuerySet` to ensure they are not performed lazily.
Some behaviors can be modified through `query_options` attributes."""
- data = fetch_cls._data
- # `QuerySet`, which is effectively a list of `Model` instances
+ # `QuerySet`, which is an iterable of `Model`/`QuerySet` instances
# https://github.com/typeddjango/django-stubs/issues/704
if isinstance(data, QuerySet): # type: ignore[misc]
for model in data:
- _postprocess_django_query(
- QueryOptions(
- func=fetch_cls.func,
- _data=model,
- postprocessor_options=fetch_cls.postprocessor_options,
- )
- )
+ _postprocess_django_query(model, options)
# `Model` instances
elif isinstance(data, Model):
- prefetch_fields = []
+ prefetch_fields: list[str] = []
for field in data._meta.get_fields():
# `ForeignKey` relationships will cause an `AttributeError`
# This is handled within the `ManyToOneRel` conditional below.
with contextlib.suppress(AttributeError):
getattr(data, field.name)
- if (
- fetch_cls.postprocessor_options.get("many_to_one", None)
- and type(field) == ManyToOneRel
- ):
+ if options.get("many_to_one", None) and type(field) == ManyToOneRel:
prefetch_fields.append(f"{field.name}_set")
- elif fetch_cls.postprocessor_options.get(
- "many_to_many", None
- ) and isinstance(field, ManyToManyField):
+ elif options.get("many_to_many", None) and isinstance(
+ field, ManyToManyField
+ ):
prefetch_fields.append(field.name)
_postprocess_django_query(
- QueryOptions(
- func=fetch_cls.func,
- _data=getattr(data, field.name).get_queryset(),
- postprocessor_options=fetch_cls.postprocessor_options,
- )
+ getattr(data, field.name).get_queryset(), options
)
if prefetch_fields:
diff --git a/src/django_idom/types.py b/src/django_idom/types.py
index b2014d19..3281aa59 100644
--- a/src/django_idom/types.py
+++ b/src/django_idom/types.py
@@ -63,10 +63,6 @@ def __call__(self, *args, **kwargs):
func: Callable
"""Callable that fetches ORM object(s)."""
- _data: Any = None
- """The results of a fetch operation.
- This is only intended to be set automatically by the `use_query` hook."""
-
postprocessor_options: dict[str, Any] = field(
default_factory=lambda: {"many_to_many": False, "many_to_one": False}
)
From 92597295e27ec9fcbf445b08e30b0509f67dc70b Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Thu, 17 Nov 2022 02:11:38 -0800
Subject: [PATCH 13/37] QueryOptions is Callable
---
src/django_idom/hooks.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py
index 16bc9cdd..0c7fd09c 100644
--- a/src/django_idom/hooks.py
+++ b/src/django_idom/hooks.py
@@ -75,7 +75,7 @@ def use_websocket() -> IdomWebsocket:
def use_query(
- query: Callable[_Params, _Result | None] | QueryOptions,
+ query: Callable[_Params, _Result | None],
*args: _Params.args,
**kwargs: _Params.kwargs,
) -> Query[_Result | None]:
From 50de9c40f61565b3c54856470291b2b522043f82 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Thu, 17 Nov 2022 02:13:40 -0800
Subject: [PATCH 14/37] one test case without query_options
---
tests/test_app/components.py | 1 -
1 file changed, 1 deletion(-)
diff --git a/tests/test_app/components.py b/tests/test_app/components.py
index db896662..eebe7d40 100644
--- a/tests/test_app/components.py
+++ b/tests/test_app/components.py
@@ -171,7 +171,6 @@ def get_relational_parent_query():
return RelationalParent.objects.first() or create_relational_parent()
-@query_options(many_to_many=True, many_to_one=True)
def get_foriegn_child_query():
child = ForiegnChild.objects.first()
if not child:
From 99ac3521d2bd9513f41bbdad41d16e0c8df2a3a0 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Thu, 17 Nov 2022 17:22:43 -0800
Subject: [PATCH 15/37] use_query options via type hints
---
docs/src/features/components.md | 2 +-
src/django_idom/hooks.py | 48 ++++++++++++++++++++++++++++-----
src/django_idom/types.py | 6 -----
src/django_idom/utils.py | 35 ------------------------
tests/test_app/components.py | 8 +++---
5 files changed, 47 insertions(+), 52 deletions(-)
diff --git a/docs/src/features/components.md b/docs/src/features/components.md
index 019f03b7..e7b7398d 100644
--- a/docs/src/features/components.md
+++ b/docs/src/features/components.md
@@ -39,7 +39,7 @@ Convert any Django view into a IDOM component by using this decorator. Compatibl
| Type | Description |
| --- | --- |
- | `_ViewComponentConstructor` | A function that takes `request: HttpRequest | None, *args: Any, key: Key | None, **kwargs: Any` and returns an IDOM component. |
+ | `_ViewComponentConstructor` | A function that takes `request, *args, key, **kwargs` and returns an IDOM component. All parameters are directly provided to your view, besides `key` which is used by IDOM. |
??? Warning "Potential information exposure when using `compatibility = True`"
diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py
index 0c7fd09c..503e2774 100644
--- a/src/django_idom/hooks.py
+++ b/src/django_idom/hooks.py
@@ -3,7 +3,16 @@
import asyncio
import contextlib
import logging
-from typing import Any, Awaitable, Callable, DefaultDict, Sequence, Union, cast
+from typing import (
+ Any,
+ Awaitable,
+ Callable,
+ DefaultDict,
+ Sequence,
+ Union,
+ cast,
+ overload,
+)
from channels.db import database_sync_to_async as _database_sync_to_async
from django.db.models import ManyToManyField, prefetch_related_objects
@@ -74,10 +83,30 @@ def use_websocket() -> IdomWebsocket:
return websocket
+@overload
+def use_query(
+ query: Callable[_Params, _Result | None],
+ options: QueryOptions,
+ /,
+ *args: _Params.args,
+ **kwargs: _Params.kwargs,
+) -> Query[_Result | None]:
+ ...
+
+
+@overload
def use_query(
query: Callable[_Params, _Result | None],
+ /,
*args: _Params.args,
**kwargs: _Params.kwargs,
+) -> Query[_Result | None]:
+ ...
+
+
+def use_query(
+ *args: Any,
+ **kwargs: Any,
) -> Query[_Result | None]:
"""Hook to fetch a Django ORM query.
@@ -87,6 +116,14 @@ def use_query(
Keyword Args:
**kwargs: Keyword arguments to pass into `query`."""
+ query = args[0]
+ if len(args) > 1 and isinstance(args[1], QueryOptions):
+ query_options = args[1]
+ args = args[2:]
+ else:
+ query_options = QueryOptions()
+ args = args[1:]
+
query_ref = use_ref(query)
if query_ref.current is not query:
raise ValueError(f"Query function changed from {query_ref.current} to {query}.")
@@ -95,9 +132,6 @@ def use_query(
data, set_data = use_state(cast(Union[_Result, None], None))
loading, set_loading = use_state(True)
error, set_error = use_state(cast(Union[Exception, None], None))
- query_options = (
- query if isinstance(query, QueryOptions) else QueryOptions(func=query)
- )
@use_callback
def refetch() -> None:
@@ -109,8 +143,8 @@ def refetch() -> None:
def add_refetch_callback() -> Callable[[], None]:
# By tracking callbacks globally, any usage of the query function will be re-run
# if the user has told a mutation to refetch it.
- _REFETCH_CALLBACKS[query_options.func].add(refetch)
- return lambda: _REFETCH_CALLBACKS[query_options.func].remove(refetch)
+ _REFETCH_CALLBACKS[query].add(refetch)
+ return lambda: _REFETCH_CALLBACKS[query].remove(refetch)
@use_effect(dependencies=None)
@database_sync_to_async
@@ -120,7 +154,7 @@ def execute_query() -> None:
try:
# Run the initial query
- data = query_options.func(*args, **kwargs)
+ data = query(*args, **kwargs)
# Use a custom postprocessor, if provided
if query_options.postprocessor:
diff --git a/src/django_idom/types.py b/src/django_idom/types.py
index 3281aa59..7999d390 100644
--- a/src/django_idom/types.py
+++ b/src/django_idom/types.py
@@ -57,12 +57,6 @@ class ViewComponentIframe:
class QueryOptions:
"""A Django ORM query function, alongside some configuration values."""
- def __call__(self, *args, **kwargs):
- return self.func(*args, **kwargs)
-
- func: Callable
- """Callable that fetches ORM object(s)."""
-
postprocessor_options: dict[str, Any] = field(
default_factory=lambda: {"many_to_many": False, "many_to_one": False}
)
diff --git a/src/django_idom/utils.py b/src/django_idom/utils.py
index 5115ae10..e1418c9e 100644
--- a/src/django_idom/utils.py
+++ b/src/django_idom/utils.py
@@ -200,38 +200,3 @@ def _generate_obj_name(object: Any) -> str | None:
if hasattr(object, "__class__"):
return f"{object.__module__}.{object.__class__.__name__}"
return None
-
-
-@overload
-def query_options(
- query_func: None = ...,
- evaluator: Callable[[QueryOptions], None] | None = None,
- **options,
-) -> Callable[[Callable[_Params, _Result]], QueryOptions]:
- ...
-
-
-@overload
-def query_options(
- query_func: Callable[_Params, _Result],
- evaluator: Callable[[QueryOptions], None] | None = None,
- **options,
-) -> QueryOptions:
- ...
-
-
-def query_options(
- query_func: Callable[_Params, _Result] | None = None,
- evaluator: Callable[[QueryOptions], None] | None = None,
- **options,
-) -> QueryOptions | Callable[[Callable[_Params, _Result]], QueryOptions]:
- def decorator(query_func: Callable[_Params, _Result]):
- if not query_func:
- raise ValueError("A query function must be provided to `query_options`")
-
- query_options = QueryOptions(func=query_func, postprocessor=evaluator)
- if options:
- query_options.postprocessor_options.update(options)
- return query_options
-
- return decorator(query_func) if query_func else decorator
diff --git a/tests/test_app/components.py b/tests/test_app/components.py
index eebe7d40..8d533c91 100644
--- a/tests/test_app/components.py
+++ b/tests/test_app/components.py
@@ -8,7 +8,7 @@
import django_idom
from django_idom.components import view_to_component
from django_idom.hooks import use_mutation, use_query
-from django_idom.utils import query_options
+from django_idom.types import QueryOptions
from . import views
@@ -166,7 +166,6 @@ def create_relational_parent() -> RelationalParent:
return parent
-@query_options(many_to_many=True, many_to_one=True)
def get_relational_parent_query():
return RelationalParent.objects.first() or create_relational_parent()
@@ -184,7 +183,10 @@ def get_foriegn_child_query():
@component
def relational_query():
- relational_parent = use_query(get_relational_parent_query)
+ relational_parent = use_query(
+ get_relational_parent_query,
+ QueryOptions(postprocessor_options={"many_to_many": True, "many_to_one": True}),
+ )
foriegn_child = use_query(get_foriegn_child_query)
if not relational_parent.data or not foriegn_child.data:
From 8bf6097227535741be98dbea670280b141044f4e Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Thu, 17 Nov 2022 17:27:48 -0800
Subject: [PATCH 16/37] fix mypy warnings
---
src/django_idom/types.py | 2 +-
src/django_idom/utils.py | 3 +--
2 files changed, 2 insertions(+), 3 deletions(-)
diff --git a/src/django_idom/types.py b/src/django_idom/types.py
index 7999d390..2e94cff0 100644
--- a/src/django_idom/types.py
+++ b/src/django_idom/types.py
@@ -55,7 +55,7 @@ class ViewComponentIframe:
@dataclass
class QueryOptions:
- """A Django ORM query function, alongside some configuration values."""
+ """Configuration options that can be provided to `use_query`."""
postprocessor_options: dict[str, Any] = field(
default_factory=lambda: {"many_to_many": False, "many_to_one": False}
diff --git a/src/django_idom/utils.py b/src/django_idom/utils.py
index e1418c9e..8394589e 100644
--- a/src/django_idom/utils.py
+++ b/src/django_idom/utils.py
@@ -7,7 +7,7 @@
from fnmatch import fnmatch
from importlib import import_module
from inspect import iscoroutinefunction
-from typing import Any, Callable, Sequence, overload
+from typing import Any, Callable, Sequence
from channels.db import database_sync_to_async
from django.http import HttpRequest, HttpResponse
@@ -16,7 +16,6 @@
from django.views import View
from django_idom.config import IDOM_REGISTERED_COMPONENTS
-from django_idom.types import QueryOptions, _Params, _Result
_logger = logging.getLogger(__name__)
From 8aeef17c7272a17935ccf3d4d2016ee7612757cf Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Wed, 30 Nov 2022 01:43:38 -0800
Subject: [PATCH 17/37] cleanup
---
src/django_idom/hooks.py | 3 ++-
src/django_idom/types.py | 2 +-
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py
index 503e2774..3e36fb18 100644
--- a/src/django_idom/hooks.py
+++ b/src/django_idom/hooks.py
@@ -112,6 +112,7 @@ def use_query(
Args:
query: A callable that returns a Django `Model` or `QuerySet`.
+ options: A `QueryOptions` object that can modify how the query is excuted.
*args: Positional arguments to pass into `query`.
Keyword Args:
@@ -158,7 +159,7 @@ def execute_query() -> None:
# Use a custom postprocessor, if provided
if query_options.postprocessor:
- query_options.postprocessor(query_options)
+ query_options.postprocessor(data, query_options)
# Use the default postprocessor
else:
diff --git a/src/django_idom/types.py b/src/django_idom/types.py
index 2e94cff0..982411a1 100644
--- a/src/django_idom/types.py
+++ b/src/django_idom/types.py
@@ -62,6 +62,6 @@ class QueryOptions:
)
"""Configuration values usable by the `postprocessor`."""
- postprocessor: Callable[[QueryOptions], None] | None = None
+ postprocessor: Callable[[_Data, QueryOptions], None] | None = None
"""A post processing callable that can read/modify the `QueryOptions` object. If unset, the default fetch
handler is used to prevent lazy loading of Django fields."""
From 275c385bd05c78dcbfbdce4d8cf9fce805598de2 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Wed, 30 Nov 2022 01:56:18 -0800
Subject: [PATCH 18/37] fix docs builds
---
requirements/build-docs.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/requirements/build-docs.txt b/requirements/build-docs.txt
index fbaebe2d..304cc367 100644
--- a/requirements/build-docs.txt
+++ b/requirements/build-docs.txt
@@ -3,4 +3,4 @@ mkdocs-git-revision-date-localized-plugin
mkdocs-material
mkdocs-include-markdown-plugin
linkcheckmd
-mkdocs-spellcheck
+mkdocs-spellcheck[all]
From b0e2c03d1ccece2bad0a69abfda2c5ed5c43bcff Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Wed, 30 Nov 2022 14:39:09 -0800
Subject: [PATCH 19/37] better postprocessor description
---
src/django_idom/types.py | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/django_idom/types.py b/src/django_idom/types.py
index 982411a1..d2d087b2 100644
--- a/src/django_idom/types.py
+++ b/src/django_idom/types.py
@@ -63,5 +63,7 @@ class QueryOptions:
"""Configuration values usable by the `postprocessor`."""
postprocessor: Callable[[_Data, QueryOptions], None] | None = None
- """A post processing callable that can read/modify the `QueryOptions` object. If unset, the default fetch
- handler is used to prevent lazy loading of Django fields."""
+ """A post processing callable that can read/modify the query `data` and the `QueryOptions` object.
+
+ If unset, the default handler is used. This handler can be configured via `postprocessor_options`
+ to recursively fetch all fields to ensure queries are not performed lazily."""
From 31c1998a349171f855a441a823f6943f744a5a8c Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Thu, 8 Dec 2022 03:35:43 -0800
Subject: [PATCH 20/37] remove unneeded defaults for postprocessor opts
---
src/django_idom/hooks.py | 4 ++--
src/django_idom/types.py | 4 +---
2 files changed, 3 insertions(+), 5 deletions(-)
diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py
index 3e36fb18..70abcb70 100644
--- a/src/django_idom/hooks.py
+++ b/src/django_idom/hooks.py
@@ -255,10 +255,10 @@ def _postprocess_django_query(data: QuerySet | Model, options: dict[str, Any]) -
with contextlib.suppress(AttributeError):
getattr(data, field.name)
- if options.get("many_to_one", None) and type(field) == ManyToOneRel:
+ if options.get("many_to_one", False) and type(field) == ManyToOneRel:
prefetch_fields.append(f"{field.name}_set")
- elif options.get("many_to_many", None) and isinstance(
+ elif options.get("many_to_many", False) and isinstance(
field, ManyToManyField
):
prefetch_fields.append(field.name)
diff --git a/src/django_idom/types.py b/src/django_idom/types.py
index d2d087b2..2976e1af 100644
--- a/src/django_idom/types.py
+++ b/src/django_idom/types.py
@@ -57,9 +57,7 @@ class ViewComponentIframe:
class QueryOptions:
"""Configuration options that can be provided to `use_query`."""
- postprocessor_options: dict[str, Any] = field(
- default_factory=lambda: {"many_to_many": False, "many_to_one": False}
- )
+ postprocessor_options: dict[str, Any] = field(default_factory=lambda: {})
"""Configuration values usable by the `postprocessor`."""
postprocessor: Callable[[_Data, QueryOptions], None] | None = None
From 35e2d3f3795bb0d49d3686ef3b2774267896369e Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Thu, 8 Dec 2022 15:39:52 -0800
Subject: [PATCH 21/37] postprocessor options as kwargs
---
src/django_idom/hooks.py | 32 ++++++++++++++++++++++----------
src/django_idom/types.py | 30 +++++++++++++++++++++++++-----
2 files changed, 47 insertions(+), 15 deletions(-)
diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py
index 70abcb70..0db92cff 100644
--- a/src/django_idom/hooks.py
+++ b/src/django_idom/hooks.py
@@ -159,11 +159,11 @@ def execute_query() -> None:
# Use a custom postprocessor, if provided
if query_options.postprocessor:
- query_options.postprocessor(data, query_options)
+ query_options.postprocessor(data, **query_options.postprocessor_options)
# Use the default postprocessor
else:
- _postprocess_django_query(data, query_options.postprocessor_options)
+ _postprocess_django_query(data, **query_options.postprocessor_options)
except Exception as e:
set_data(None)
set_loading(False)
@@ -235,7 +235,9 @@ def reset() -> None:
return Mutation(call, loading, error, reset)
-def _postprocess_django_query(data: QuerySet | Model, options: dict[str, Any]) -> None:
+def _postprocess_django_query(
+ data: QuerySet | Model, /, many_to_many: bool = False, many_to_one: bool = False
+) -> None:
"""Recursively fetch all fields within a `Model` or `QuerySet` to ensure they are not performed lazily.
Some behaviors can be modified through `query_options` attributes."""
@@ -244,7 +246,11 @@ def _postprocess_django_query(data: QuerySet | Model, options: dict[str, Any]) -
# https://github.com/typeddjango/django-stubs/issues/704
if isinstance(data, QuerySet): # type: ignore[misc]
for model in data:
- _postprocess_django_query(model, options)
+ _postprocess_django_query(
+ model,
+ many_to_many=many_to_many,
+ many_to_one=many_to_one,
+ )
# `Model` instances
elif isinstance(data, Model):
@@ -255,15 +261,15 @@ def _postprocess_django_query(data: QuerySet | Model, options: dict[str, Any]) -
with contextlib.suppress(AttributeError):
getattr(data, field.name)
- if options.get("many_to_one", False) and type(field) == ManyToOneRel:
+ if many_to_one and type(field) == ManyToOneRel:
prefetch_fields.append(f"{field.name}_set")
- elif options.get("many_to_many", False) and isinstance(
- field, ManyToManyField
- ):
+ elif many_to_many and isinstance(field, ManyToManyField):
prefetch_fields.append(field.name)
_postprocess_django_query(
- getattr(data, field.name).get_queryset(), options
+ getattr(data, field.name).get_queryset(),
+ many_to_many=many_to_many,
+ many_to_one=many_to_one,
)
if prefetch_fields:
@@ -271,4 +277,10 @@ def _postprocess_django_query(data: QuerySet | Model, options: dict[str, Any]) -
# Unrecognized type
else:
- raise ValueError(f"Expected a Model or QuerySet, got {data!r}")
+ raise TypeError(
+ f"Django query postprocessor expected a Model or QuerySet, got {data!r}.\n"
+ "One of the following may have occurred:\n"
+ " - You are using a non-Django ORM.\n"
+ " - You are attempting to use `use_query` to fetch a non-ORM data.\n\n"
+ "If these situations seem correct, you may want to consider disabling the postprocessor via `QueryOptions`."
+ )
diff --git a/src/django_idom/types.py b/src/django_idom/types.py
index 2976e1af..b2be4bee 100644
--- a/src/django_idom/types.py
+++ b/src/django_idom/types.py
@@ -1,7 +1,17 @@
from __future__ import annotations
from dataclasses import dataclass, field
-from typing import Any, Awaitable, Callable, Generic, Optional, Sequence, TypeVar, Union
+from typing import (
+ Any,
+ Awaitable,
+ Callable,
+ Generic,
+ Optional,
+ Protocol,
+ Sequence,
+ TypeVar,
+ Union,
+)
from django.db.models.base import Model
from django.db.models.query import QuerySet
@@ -53,6 +63,11 @@ class ViewComponentIframe:
kwargs: dict
+class Postprocessor(Protocol):
+ def __call__(self, data: Any, **kwargs: Any) -> None:
+ ...
+
+
@dataclass
class QueryOptions:
"""Configuration options that can be provided to `use_query`."""
@@ -60,8 +75,13 @@ class QueryOptions:
postprocessor_options: dict[str, Any] = field(default_factory=lambda: {})
"""Configuration values usable by the `postprocessor`."""
- postprocessor: Callable[[_Data, QueryOptions], None] | None = None
- """A post processing callable that can read/modify the query `data` and the `QueryOptions` object.
+ postprocessor: Postprocessor | None = None
+ """A post processing callable that can read/modify the query `data`.
+
+ `postprocessor_options` are provided to this `postprocessor` as keyword arguments.
+
+ If `None`, the default postprocessor is used.
- If unset, the default handler is used. This handler can be configured via `postprocessor_options`
- to recursively fetch all fields to ensure queries are not performed lazily."""
+ This default Django query postprocessor prevents Django's lazy query execution, and
+ additionally can be configured via `postprocessor_options` to recursively fetch
+ `many_to_many` and `many_to_one` fields."""
From a88bee418577cd059db0d52c116d9599d993231a Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Fri, 9 Dec 2022 00:59:18 -0800
Subject: [PATCH 22/37] add tests for relational query
---
src/django_idom/hooks.py | 2 +-
tests/test_app/tests/test_components.py | 3 +++
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py
index 0db92cff..895b1a2e 100644
--- a/src/django_idom/hooks.py
+++ b/src/django_idom/hooks.py
@@ -112,7 +112,7 @@ def use_query(
Args:
query: A callable that returns a Django `Model` or `QuerySet`.
- options: A `QueryOptions` object that can modify how the query is excuted.
+ options: An optional `QueryOptions` object that can modify how the query is excuted.
*args: Positional arguments to pass into `query`.
Keyword Args:
diff --git a/tests/test_app/tests/test_components.py b/tests/test_app/tests/test_components.py
index 83bfedcd..7ded5a44 100644
--- a/tests/test_app/tests/test_components.py
+++ b/tests/test_app/tests/test_components.py
@@ -91,6 +91,9 @@ def test_authorized_user(self):
)
self.page.wait_for_selector("#authorized-user")
+ def test_relational_query(self):
+ self.page.locator("#relational-query[data-success=true]").wait_for()
+
def test_use_query_and_mutation(self):
todo_input = self.page.wait_for_selector("#todo-input")
From 93f918ac90922810918fcc49da14240dde607dcf Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Fri, 9 Dec 2022 03:44:12 -0800
Subject: [PATCH 23/37] documentation
---
docs/src/dictionary.txt | 2 +
docs/src/features/hooks.md | 85 ++++++++++++++++++++++++++++++------
src/django_idom/hooks.py | 12 ++---
src/django_idom/types.py | 10 ++---
tests/test_app/components.py | 2 +-
5 files changed, 85 insertions(+), 26 deletions(-)
diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt
index 3e4179a1..b573ba8f 100644
--- a/docs/src/dictionary.txt
+++ b/docs/src/dictionary.txt
@@ -22,3 +22,5 @@ unstyled
py
idom
asgi
+postfixed
+postprocessing
diff --git a/docs/src/features/hooks.md b/docs/src/features/hooks.md
index ed132b89..5ad9e2a6 100644
--- a/docs/src/features/hooks.md
+++ b/docs/src/features/hooks.md
@@ -54,6 +54,7 @@ The function you provide into this hook must return either a `Model` or `QuerySe
| Name | Type | Description | Default |
| --- | --- | --- | --- |
| `query` | `Callable[_Params, _Result | None]` | A callable that returns a Django `Model` or `QuerySet`. | N/A |
+ | `options` | `QueryOptions | None` | An optional `QueryOptions` object that can modify how the query is executed. | None |
| `*args` | `_Params.args` | Positional arguments to pass into `query`. | N/A |
| `**kwargs` | `_Params.kwargs` | Keyword arguments to pass into `query`. | N/A |
@@ -67,19 +68,83 @@ The function you provide into this hook must return either a `Model` or `QuerySe
Due to Django's ORM design, database queries must be deferred using hooks. Otherwise, you will see a `SynchronousOnlyOperation` exception.
- This may be resolved in a future version of Django containing an asynchronous ORM.
+ This compatibility may be resolved in a future version of Django containing an asynchronous ORM. However, it is still best practice to always perform ORM calls in the background via `use_query`.
-??? question "Why does the example `get_items` function return a `Model` or `QuerySet`?"
+??? question "Can this hook be used for things other than the Django ORM?"
- This was a technical design decision to [based on Apollo](https://www.apollographql.com/docs/react/data/mutations/#usemutation-api), but ultimately helps avoid Django's `SynchronousOnlyOperation` exceptions.
+ If you...
- The `use_query` hook ensures the provided `Model` or `QuerySet` executes all [deferred](https://docs.djangoproject.com/en/dev/ref/models/instances/#django.db.models.Model.get_deferred_fields)/[lazy queries](https://docs.djangoproject.com/en/dev/topics/db/queries/#querysets-are-lazy) safely prior to reaching your components.
+ 1. Want to use this hook to defer IO intensive tasks to be computed in the background
+ 2. Want to to utilize `use_query` with a different ORM
+
+ ... then you can disable all postprocessing behavior by modifying the `postprocessor` parameter in `QueryOptions`.
+
+ ```python
+ from django_idom.types import QueryOptions
+ from django_idom.hooks import use_query
+
+ def io_intensive_operation():
+ """This is an example function call that does something IO intensive, but can
+ potentially fail to execute."""
+ ...
+
+ @component
+ def todo_list():
+ query = use_query(
+ io_intensive_operation,
+ QueryOptions(
+ # By setting the postprocessor to a function that takes one argument
+ # and returns None, we can disable postprocessing behavior.
+ postprocessor=lambda data: None,
+ ),
+ )
+
+ if query.loading or query.error:
+ return None
+
+ return str(query.data)
+ ```
+
+??? question "Can this hook automatically fetch `ManyToMany` fields or `ForeignKey` relationships?"
+
+ By default, automatic recursive fetching of `ManyToMany` or `ForeignKey` fields is disabled for performance reasons.
+
+ If you wish you enable this feature, you can modify the `postprocessor_kwargs` parameter in `QueryOptions`.
-??? question "What is an "ORM"?"
+ ```python
+ from example_project.my_app.models import MyModel
+ from django_idom.types import QueryOptions
+ from django_idom.hooks import use_query
+
+ def model_with_relationships():
+ """This is an example function that gets `MyModel` that has a ManyToMany field, and
+ additionally other models that have formed a ForeignKey association to `MyModel`.
+
+ ManyToMany Field: `many_to_many_field`
+ ForeignKey Field: `foreign_key_field_set`
+ """
+ return MyModel.objects.get(id=1)
+
+ @component
+ def todo_list():
+ query = use_query(
+ io_intensive_operation,
+ QueryOptions(postprocessor_kwargs={"many_to_many": True, "many_to_one": True}),
+ )
+
+ if query.loading or query.error:
+ return None
+
+ return f"{query.data.many_to_many_field} {query.data.foriegn_key_field_set}"
+ ```
- A Python **Object Relational Mapper** is an API for your code to access a database.
+ Please note that due Django's ORM design, the field name to access foreign keys is [always be postfixed with `_set`](https://docs.djangoproject.com/en/dev/topics/db/examples/many_to_one/).
- See the [Django ORM documentation](https://docs.djangoproject.com/en/dev/topics/db/queries/) for more information.
+??? question "Why does the example `get_items` function return a `Model` or `QuerySet`?"
+
+ This was a technical design decision to [based on Apollo](https://www.apollographql.com/docs/react/data/mutations/#usemutation-api), but ultimately helps avoid Django's `SynchronousOnlyOperation` exceptions.
+
+ The `use_query` hook ensures the provided `Model` or `QuerySet` executes all [deferred](https://docs.djangoproject.com/en/dev/ref/models/instances/#django.db.models.Model.get_deferred_fields)/[lazy queries](https://docs.djangoproject.com/en/dev/topics/db/queries/#querysets-are-lazy) safely prior to reaching your components.
## Use Mutation
@@ -244,12 +309,6 @@ The function you provide into this hook will have no return value.
However, even when resolved it is best practice to perform ORM queries within the `use_query` in order to handle `loading` and `error` states.
-??? question "What is an "ORM"?"
-
- A Python **Object Relational Mapper** is an API for your code to access a database.
-
- See the [Django ORM documentation](https://docs.djangoproject.com/en/dev/topics/db/queries/) for more information.
-
## Use Websocket
You can fetch the Django Channels [websocket](https://channels.readthedocs.io/en/stable/topics/consumers.html#asyncjsonwebsocketconsumer) at any time by using `use_websocket`.
diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py
index 895b1a2e..a619486e 100644
--- a/src/django_idom/hooks.py
+++ b/src/django_idom/hooks.py
@@ -112,7 +112,7 @@ def use_query(
Args:
query: A callable that returns a Django `Model` or `QuerySet`.
- options: An optional `QueryOptions` object that can modify how the query is excuted.
+ options: An optional `QueryOptions` object that can modify how the query is executed.
*args: Positional arguments to pass into `query`.
Keyword Args:
@@ -159,11 +159,11 @@ def execute_query() -> None:
# Use a custom postprocessor, if provided
if query_options.postprocessor:
- query_options.postprocessor(data, **query_options.postprocessor_options)
+ query_options.postprocessor(data, **query_options.postprocessor_kwargs)
# Use the default postprocessor
else:
- _postprocess_django_query(data, **query_options.postprocessor_options)
+ postprocess_django_query(data, **query_options.postprocessor_kwargs)
except Exception as e:
set_data(None)
set_loading(False)
@@ -235,7 +235,7 @@ def reset() -> None:
return Mutation(call, loading, error, reset)
-def _postprocess_django_query(
+def postprocess_django_query(
data: QuerySet | Model, /, many_to_many: bool = False, many_to_one: bool = False
) -> None:
"""Recursively fetch all fields within a `Model` or `QuerySet` to ensure they are not performed lazily.
@@ -246,7 +246,7 @@ def _postprocess_django_query(
# https://github.com/typeddjango/django-stubs/issues/704
if isinstance(data, QuerySet): # type: ignore[misc]
for model in data:
- _postprocess_django_query(
+ postprocess_django_query(
model,
many_to_many=many_to_many,
many_to_one=many_to_one,
@@ -266,7 +266,7 @@ def _postprocess_django_query(
elif many_to_many and isinstance(field, ManyToManyField):
prefetch_fields.append(field.name)
- _postprocess_django_query(
+ postprocess_django_query(
getattr(data, field.name).get_queryset(),
many_to_many=many_to_many,
many_to_one=many_to_one,
diff --git a/src/django_idom/types.py b/src/django_idom/types.py
index b2be4bee..a1d3c9d2 100644
--- a/src/django_idom/types.py
+++ b/src/django_idom/types.py
@@ -72,16 +72,14 @@ def __call__(self, data: Any, **kwargs: Any) -> None:
class QueryOptions:
"""Configuration options that can be provided to `use_query`."""
- postprocessor_options: dict[str, Any] = field(default_factory=lambda: {})
- """Configuration values usable by the `postprocessor`."""
-
postprocessor: Postprocessor | None = None
"""A post processing callable that can read/modify the query `data`.
- `postprocessor_options` are provided to this `postprocessor` as keyword arguments.
-
If `None`, the default postprocessor is used.
This default Django query postprocessor prevents Django's lazy query execution, and
- additionally can be configured via `postprocessor_options` to recursively fetch
+ additionally can be configured via `postprocessor_kwargs` to recursively fetch
`many_to_many` and `many_to_one` fields."""
+
+ postprocessor_kwargs: dict[str, Any] = field(default_factory=lambda: {})
+ """Keyworded arguments directly passed into the `postprocessor` for configuration."""
diff --git a/tests/test_app/components.py b/tests/test_app/components.py
index 8d533c91..3b8f0bc9 100644
--- a/tests/test_app/components.py
+++ b/tests/test_app/components.py
@@ -185,7 +185,7 @@ def get_foriegn_child_query():
def relational_query():
relational_parent = use_query(
get_relational_parent_query,
- QueryOptions(postprocessor_options={"many_to_many": True, "many_to_one": True}),
+ QueryOptions(postprocessor_kwargs={"many_to_many": True, "many_to_one": True}),
)
foriegn_child = use_query(get_foriegn_child_query)
From 17c5bd81c9b776b62f507710f34986577208b323 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Fri, 9 Dec 2022 15:28:55 -0800
Subject: [PATCH 24/37] grammar correction
---
src/django_idom/hooks.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py
index a619486e..016d0a44 100644
--- a/src/django_idom/hooks.py
+++ b/src/django_idom/hooks.py
@@ -281,6 +281,6 @@ def postprocess_django_query(
f"Django query postprocessor expected a Model or QuerySet, got {data!r}.\n"
"One of the following may have occurred:\n"
" - You are using a non-Django ORM.\n"
- " - You are attempting to use `use_query` to fetch a non-ORM data.\n\n"
+ " - You are attempting to use `use_query` to fetch non-ORM data.\n\n"
"If these situations seem correct, you may want to consider disabling the postprocessor via `QueryOptions`."
)
From a17ca7c52c6464bca2d41f6570f99d2d16d15355 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Sat, 10 Dec 2022 16:25:27 -0800
Subject: [PATCH 25/37] use_query docs
---
docs/includes/orm.md | 13 +++
docs/src/dictionary.txt | 1 +
docs/src/features/hooks.md | 179 +++++++++++++++++++++++------------
src/django_idom/hooks.py | 60 +-----------
src/django_idom/utils.py | 55 +++++++++++
tests/test_app/components.py | 6 +-
6 files changed, 189 insertions(+), 125 deletions(-)
create mode 100644 docs/includes/orm.md
diff --git a/docs/includes/orm.md b/docs/includes/orm.md
new file mode 100644
index 00000000..2a87878d
--- /dev/null
+++ b/docs/includes/orm.md
@@ -0,0 +1,13 @@
+
+
+Due to Django's ORM design, database queries must be deferred using hooks. Otherwise, you will see a `SynchronousOnlyOperation` exception.
+
+These `SynchronousOnlyOperation` exceptions may be resolved in a future version of Django containing an asynchronous ORM. However, it is best practice to always perform ORM calls in the background via hooks.
+
+
+
+
+
+By default, automatic recursive fetching of `ManyToMany` or `ForeignKey` fields is enabled within the default `QueryOptions.postprocessor`. This is needed to prevent `SynchronousOnlyOperation` exceptions when accessing these fields within your IDOM components.
+
+
diff --git a/docs/src/dictionary.txt b/docs/src/dictionary.txt
index b573ba8f..7892a120 100644
--- a/docs/src/dictionary.txt
+++ b/docs/src/dictionary.txt
@@ -6,6 +6,7 @@ changelog
async
pre
prefetch
+prefetching
preloader
whitespace
refetch
diff --git a/docs/src/features/hooks.md b/docs/src/features/hooks.md
index 5ad9e2a6..a8d281d9 100644
--- a/docs/src/features/hooks.md
+++ b/docs/src/features/hooks.md
@@ -64,87 +64,120 @@ The function you provide into this hook must return either a `Model` or `QuerySe
| --- | --- |
| `Query[_Result | None]` | An object containing `loading`/`error` states, your `data` (if the query has successfully executed), and a `refetch` callable that can be used to re-run the query. |
-??? question "Can I make ORM calls without hooks?"
+??? question "How can I provide arguments to my query function?"
+
+ `*args` and `**kwargs` can be provided to your query function via `use_query` parameters.
+
+ === "components.py"
- Due to Django's ORM design, database queries must be deferred using hooks. Otherwise, you will see a `SynchronousOnlyOperation` exception.
+ ```python
+ from idom import component
+ from django_idom.hooks import use_query
- This compatibility may be resolved in a future version of Django containing an asynchronous ORM. However, it is still best practice to always perform ORM calls in the background via `use_query`.
+ def example_query(value:int, other_value:bool = False):
+ ...
+
+ @component
+ def my_component():
+ query = use_query(
+ example_query,
+ 123,
+ other_value=True,
+ )
+
+ ...
+ ```
+
+??? question "Why does the example `get_items` function return `TodoItem.objects.all()`?"
+
+ This was a technical design decision to based on [Apollo's `useQuery` hook](https://www.apollographql.com/docs/react/data/queries/), but ultimately helps avoid Django's `SynchronousOnlyOperation` exceptions.
+
+ The `use_query` hook ensures the provided `Model` or `QuerySet` executes all [deferred](https://docs.djangoproject.com/en/dev/ref/models/instances/#django.db.models.Model.get_deferred_fields)/[lazy queries](https://docs.djangoproject.com/en/dev/topics/db/queries/#querysets-are-lazy) safely prior to reaching your components.
??? question "Can this hook be used for things other than the Django ORM?"
- If you...
+ {% include-markdown "../../includes/orm.md" start="" end="" %}
+
+ However, if you...
1. Want to use this hook to defer IO intensive tasks to be computed in the background
2. Want to to utilize `use_query` with a different ORM
- ... then you can disable all postprocessing behavior by modifying the `postprocessor` parameter in `QueryOptions`.
+ ... then you can disable all postprocessing behavior by modifying the `QueryOptions.postprocessor` parameter. In the example below, we will set the `postprocessor` to a function that takes one argument and returns nothing.
- ```python
- from django_idom.types import QueryOptions
- from django_idom.hooks import use_query
+ === "components.py"
- def io_intensive_operation():
- """This is an example function call that does something IO intensive, but can
- potentially fail to execute."""
- ...
+ ```python
+ from idom import component
+ from django_idom.types import QueryOptions
+ from django_idom.hooks import use_query
- @component
- def todo_list():
- query = use_query(
- io_intensive_operation,
- QueryOptions(
- # By setting the postprocessor to a function that takes one argument
- # and returns None, we can disable postprocessing behavior.
- postprocessor=lambda data: None,
- ),
- )
+ def execute_io_intensive_operation():
+ """This is an example query function that does something IO intensive."""
+ pass
- if query.loading or query.error:
- return None
+ @component
+ def todo_list():
+ query = use_query(
+ execute_io_intensive_operation,
+ QueryOptions(
+ # By setting the postprocessor to a function that takes one argument
+ # and returns None, we can disable postprocessing behavior.
+ postprocessor=lambda data: None,
+ ),
+ )
- return str(query.data)
- ```
+ if query.loading or query.error:
+ return None
-??? question "Can this hook automatically fetch `ManyToMany` fields or `ForeignKey` relationships?"
+ return str(query.data)
+ ```
- By default, automatic recursive fetching of `ManyToMany` or `ForeignKey` fields is disabled for performance reasons.
+??? question "How can I prevent this hook from recursively fetching `ManyToMany` fields or `ForeignKey` relationships?"
- If you wish you enable this feature, you can modify the `postprocessor_kwargs` parameter in `QueryOptions`.
+ {% include-markdown "../../includes/orm.md" start="" end="" %}
- ```python
- from example_project.my_app.models import MyModel
- from django_idom.types import QueryOptions
- from django_idom.hooks import use_query
+ However, if you have deep nested trees of relational data, this may not be a desirable behavior. You may prefer to manually fetch these relational fields using a second `use_query` hook.
- def model_with_relationships():
- """This is an example function that gets `MyModel` that has a ManyToMany field, and
- additionally other models that have formed a ForeignKey association to `MyModel`.
+ You can disable the prefetching behavior of the default `postprocessor` (located at `django_idom.utils.django_query_postprocessor`) via the `QueryOptions.postprocessor_kwargs` parameter.
- ManyToMany Field: `many_to_many_field`
- ForeignKey Field: `foreign_key_field_set`
- """
- return MyModel.objects.get(id=1)
+ === "components.py"
- @component
- def todo_list():
- query = use_query(
- io_intensive_operation,
- QueryOptions(postprocessor_kwargs={"many_to_many": True, "many_to_one": True}),
- )
+ ```python
+ from example_project.my_app.models import MyModel
+ from idom import component
+ from django_idom.types import QueryOptions
+ from django_idom.hooks import use_query
- if query.loading or query.error:
- return None
+ def get_model_with_relationships():
+ """This is an example query function that gets `MyModel` which has a ManyToMany field, and
+ additionally other models that have formed a ForeignKey association to `MyModel`.
- return f"{query.data.many_to_many_field} {query.data.foriegn_key_field_set}"
- ```
+ ManyToMany Field: `many_to_many_field`
+ ForeignKey Field: `foreign_key_field_set`
+ """
+ return MyModel.objects.get(id=1)
- Please note that due Django's ORM design, the field name to access foreign keys is [always be postfixed with `_set`](https://docs.djangoproject.com/en/dev/topics/db/examples/many_to_one/).
+ @component
+ def todo_list():
+ query = use_query(
+ get_model_with_relationships,
+ QueryOptions(postprocessor_kwargs={"many_to_many": False, "many_to_one": False}),
+ )
+
+ if query.loading or query.error:
+ return None
-??? question "Why does the example `get_items` function return a `Model` or `QuerySet`?"
+ # By disabling `many_to_many` and `many_to_one`, accessing these fields will now
+ # generate a `SynchronousOnlyOperation` exception
+ return f"{query.data.many_to_many_field} {query.data.foriegn_key_field_set}"
+ ```
- This was a technical design decision to [based on Apollo](https://www.apollographql.com/docs/react/data/mutations/#usemutation-api), but ultimately helps avoid Django's `SynchronousOnlyOperation` exceptions.
+ _Note: In Django's ORM design, the field name to access foreign keys is [always be postfixed with `_set`](https://docs.djangoproject.com/en/dev/topics/db/examples/many_to_one/)._
- The `use_query` hook ensures the provided `Model` or `QuerySet` executes all [deferred](https://docs.djangoproject.com/en/dev/ref/models/instances/#django.db.models.Model.get_deferred_fields)/[lazy queries](https://docs.djangoproject.com/en/dev/topics/db/queries/#querysets-are-lazy) safely prior to reaching your components.
+??? question "Can I make ORM calls without hooks?"
+
+ {% include-markdown "../../includes/orm.md" start="" end="" %}
## Use Mutation
@@ -202,6 +235,28 @@ The function you provide into this hook will have no return value.
| --- | --- |
| `Mutation[_Params]` | An object containing `loading`/`error` states, a `reset` callable that will set `loading`/`error` states to defaults, and a `execute` callable that will run the query. |
+??? question "How can I provide arguments to my mutation function?"
+
+ `*args` and `**kwargs` can be provided to your mutation function via `mutation.execute` parameters.
+
+ === "components.py"
+
+ ```python
+ from idom import component
+ from django_idom.hooks import use_mutation
+
+ def example_mutation(value:int, other_value:bool = False):
+ ...
+
+ @component
+ def my_component():
+ mutation = use_mutation(example_mutation)
+
+ mutation.execute(123, other_value=True)
+
+ ...
+ ```
+
??? question "Can `use_mutation` trigger a refetch of `use_query`?"
Yes, `use_mutation` can queue a refetch of a `use_query` via the `refetch=...` argument.
@@ -225,11 +280,14 @@ The function you provide into this hook will have no return value.
@component
def todo_list():
+ item_query = use_query(get_items)
+ item_mutation = use_mutation(add_item, refetch=get_items)
+
def submit_event(event):
if event["key"] == "Enter":
item_mutation.execute(text=event["target"]["value"])
- item_query = use_query(get_items)
+ # Handle all possible query states
if item_query.loading:
rendered_items = html.h2("Loading...")
elif item_query.error:
@@ -237,7 +295,7 @@ The function you provide into this hook will have no return value.
else:
rendered_items = html.ul(html.li(item, key=item) for item in item_query.data)
- item_mutation = use_mutation(add_item, refetch=get_items)
+ # Handle all possible mutation states
if item_mutation.loading:
mutation_status = html.h2("Adding...")
elif item_mutation.error:
@@ -275,6 +333,8 @@ The function you provide into this hook will have no return value.
@component
def todo_list():
+ item_mutation = use_mutation(add_item)
+
def reset_event(event):
item_mutation.reset()
@@ -282,7 +342,6 @@ The function you provide into this hook will have no return value.
if event["key"] == "Enter":
item_mutation.execute(text=event["target"]["value"])
- item_mutation = use_mutation(add_item)
if item_mutation.loading:
mutation_status = html.h2("Adding...")
elif item_mutation.error:
@@ -303,11 +362,7 @@ The function you provide into this hook will have no return value.
??? question "Can I make ORM calls without hooks?"
- Due to Django's ORM design, database queries must be deferred using hooks. Otherwise, you will see a `SynchronousOnlyOperation` exception.
-
- This may be resolved in a future version of Django containing an asynchronous ORM.
-
- However, even when resolved it is best practice to perform ORM queries within the `use_query` in order to handle `loading` and `error` states.
+ {% include-markdown "../../includes/orm.md" start="" end="" %}
## Use Websocket
diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py
index 016d0a44..d2df288c 100644
--- a/src/django_idom/hooks.py
+++ b/src/django_idom/hooks.py
@@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
-import contextlib
import logging
from typing import (
Any,
@@ -15,10 +14,6 @@
)
from channels.db import database_sync_to_async as _database_sync_to_async
-from django.db.models import ManyToManyField, prefetch_related_objects
-from django.db.models.base import Model
-from django.db.models.fields.reverse_related import ManyToOneRel
-from django.db.models.query import QuerySet
from idom import use_callback, use_ref
from idom.backend.types import Location
from idom.core.hooks import Context, create_context, use_context, use_effect, use_state
@@ -31,7 +26,7 @@
_Params,
_Result,
)
-from django_idom.utils import _generate_obj_name
+from django_idom.utils import _generate_obj_name, django_query_postprocessor
_logger = logging.getLogger(__name__)
@@ -163,7 +158,7 @@ def execute_query() -> None:
# Use the default postprocessor
else:
- postprocess_django_query(data, **query_options.postprocessor_kwargs)
+ django_query_postprocessor(data, **query_options.postprocessor_kwargs)
except Exception as e:
set_data(None)
set_loading(False)
@@ -233,54 +228,3 @@ def reset() -> None:
set_error(None)
return Mutation(call, loading, error, reset)
-
-
-def postprocess_django_query(
- data: QuerySet | Model, /, many_to_many: bool = False, many_to_one: bool = False
-) -> None:
- """Recursively fetch all fields within a `Model` or `QuerySet` to ensure they are not performed lazily.
-
- Some behaviors can be modified through `query_options` attributes."""
-
- # `QuerySet`, which is an iterable of `Model`/`QuerySet` instances
- # https://github.com/typeddjango/django-stubs/issues/704
- if isinstance(data, QuerySet): # type: ignore[misc]
- for model in data:
- postprocess_django_query(
- model,
- many_to_many=many_to_many,
- many_to_one=many_to_one,
- )
-
- # `Model` instances
- elif isinstance(data, Model):
- prefetch_fields: list[str] = []
- for field in data._meta.get_fields():
- # `ForeignKey` relationships will cause an `AttributeError`
- # This is handled within the `ManyToOneRel` conditional below.
- with contextlib.suppress(AttributeError):
- getattr(data, field.name)
-
- if many_to_one and type(field) == ManyToOneRel:
- prefetch_fields.append(f"{field.name}_set")
-
- elif many_to_many and isinstance(field, ManyToManyField):
- prefetch_fields.append(field.name)
- postprocess_django_query(
- getattr(data, field.name).get_queryset(),
- many_to_many=many_to_many,
- many_to_one=many_to_one,
- )
-
- if prefetch_fields:
- prefetch_related_objects([data], *prefetch_fields)
-
- # Unrecognized type
- else:
- raise TypeError(
- f"Django query postprocessor expected a Model or QuerySet, got {data!r}.\n"
- "One of the following may have occurred:\n"
- " - You are using a non-Django ORM.\n"
- " - You are attempting to use `use_query` to fetch non-ORM data.\n\n"
- "If these situations seem correct, you may want to consider disabling the postprocessor via `QueryOptions`."
- )
diff --git a/src/django_idom/utils.py b/src/django_idom/utils.py
index 8394589e..a7ee5f8c 100644
--- a/src/django_idom/utils.py
+++ b/src/django_idom/utils.py
@@ -10,6 +10,10 @@
from typing import Any, Callable, Sequence
from channels.db import database_sync_to_async
+from django.db.models import ManyToManyField, prefetch_related_objects
+from django.db.models.base import Model
+from django.db.models.fields.reverse_related import ManyToOneRel
+from django.db.models.query import QuerySet
from django.http import HttpRequest, HttpResponse
from django.template import engines
from django.utils.encoding import smart_str
@@ -199,3 +203,54 @@ def _generate_obj_name(object: Any) -> str | None:
if hasattr(object, "__class__"):
return f"{object.__module__}.{object.__class__.__name__}"
return None
+
+
+def django_query_postprocessor(
+ data: QuerySet | Model, /, many_to_many: bool = True, many_to_one: bool = True
+) -> None:
+ """Recursively fetch all fields within a `Model` or `QuerySet` to ensure they are not performed lazily.
+
+ Some behaviors can be modified through `query_options` attributes."""
+
+ # `QuerySet`, which is an iterable of `Model`/`QuerySet` instances
+ # https://github.com/typeddjango/django-stubs/issues/704
+ if isinstance(data, QuerySet): # type: ignore[misc]
+ for model in data:
+ django_query_postprocessor(
+ model,
+ many_to_many=many_to_many,
+ many_to_one=many_to_one,
+ )
+
+ # `Model` instances
+ elif isinstance(data, Model):
+ prefetch_fields: list[str] = []
+ for field in data._meta.get_fields():
+ # `ForeignKey` relationships will cause an `AttributeError`
+ # This is handled within the `ManyToOneRel` conditional below.
+ with contextlib.suppress(AttributeError):
+ getattr(data, field.name)
+
+ if many_to_one and type(field) == ManyToOneRel:
+ prefetch_fields.append(f"{field.name}_set")
+
+ elif many_to_many and isinstance(field, ManyToManyField):
+ prefetch_fields.append(field.name)
+ django_query_postprocessor(
+ getattr(data, field.name).get_queryset(),
+ many_to_many=many_to_many,
+ many_to_one=many_to_one,
+ )
+
+ if prefetch_fields:
+ prefetch_related_objects([data], *prefetch_fields)
+
+ # Unrecognized type
+ else:
+ raise TypeError(
+ f"Django query postprocessor expected a Model or QuerySet, got {data!r}.\n"
+ "One of the following may have occurred:\n"
+ " - You are using a non-Django ORM.\n"
+ " - You are attempting to use `use_query` to fetch non-ORM data.\n\n"
+ "If these situations seem correct, you may want to consider disabling the postprocessor via `QueryOptions`."
+ )
diff --git a/tests/test_app/components.py b/tests/test_app/components.py
index 3b8f0bc9..086047c9 100644
--- a/tests/test_app/components.py
+++ b/tests/test_app/components.py
@@ -8,7 +8,6 @@
import django_idom
from django_idom.components import view_to_component
from django_idom.hooks import use_mutation, use_query
-from django_idom.types import QueryOptions
from . import views
@@ -183,10 +182,7 @@ def get_foriegn_child_query():
@component
def relational_query():
- relational_parent = use_query(
- get_relational_parent_query,
- QueryOptions(postprocessor_kwargs={"many_to_many": True, "many_to_one": True}),
- )
+ relational_parent = use_query(get_relational_parent_query)
foriegn_child = use_query(get_foriegn_child_query)
if not relational_parent.data or not foriegn_child.data:
From 834a53681118fd4875fafe6f697675c54e1da41e Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Sat, 10 Dec 2022 16:31:27 -0800
Subject: [PATCH 26/37] move queryoptions to first arg
---
docs/src/features/hooks.md | 12 +++++-------
src/django_idom/hooks.py | 13 ++++++++-----
2 files changed, 13 insertions(+), 12 deletions(-)
diff --git a/docs/src/features/hooks.md b/docs/src/features/hooks.md
index a8d281d9..4fe039a1 100644
--- a/docs/src/features/hooks.md
+++ b/docs/src/features/hooks.md
@@ -53,8 +53,8 @@ The function you provide into this hook must return either a `Model` or `QuerySe
| Name | Type | Description | Default |
| --- | --- | --- | --- |
- | `query` | `Callable[_Params, _Result | None]` | A callable that returns a Django `Model` or `QuerySet`. | N/A |
| `options` | `QueryOptions | None` | An optional `QueryOptions` object that can modify how the query is executed. | None |
+ | `query` | `Callable[_Params, _Result | None]` | A callable that returns a Django `Model` or `QuerySet`. | N/A |
| `*args` | `_Params.args` | Positional arguments to pass into `query`. | N/A |
| `**kwargs` | `_Params.kwargs` | Keyword arguments to pass into `query`. | N/A |
@@ -118,13 +118,11 @@ The function you provide into this hook must return either a `Model` or `QuerySe
@component
def todo_list():
+ # By setting the postprocessor to a function that takes one argument
+ # and returns None, we can disable postprocessing behavior.
query = use_query(
+ QueryOptions(postprocessor=lambda data: None),
execute_io_intensive_operation,
- QueryOptions(
- # By setting the postprocessor to a function that takes one argument
- # and returns None, we can disable postprocessing behavior.
- postprocessor=lambda data: None,
- ),
)
if query.loading or query.error:
@@ -161,8 +159,8 @@ The function you provide into this hook must return either a `Model` or `QuerySe
@component
def todo_list():
query = use_query(
- get_model_with_relationships,
QueryOptions(postprocessor_kwargs={"many_to_many": False, "many_to_one": False}),
+ get_model_with_relationships,
)
if query.loading or query.error:
diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py
index d2df288c..20455a57 100644
--- a/src/django_idom/hooks.py
+++ b/src/django_idom/hooks.py
@@ -80,8 +80,8 @@ def use_websocket() -> IdomWebsocket:
@overload
def use_query(
- query: Callable[_Params, _Result | None],
options: QueryOptions,
+ query: Callable[_Params, _Result | None],
/,
*args: _Params.args,
**kwargs: _Params.kwargs,
@@ -106,18 +106,21 @@ def use_query(
"""Hook to fetch a Django ORM query.
Args:
- query: A callable that returns a Django `Model` or `QuerySet`.
options: An optional `QueryOptions` object that can modify how the query is executed.
+ query: A callable that returns a Django `Model` or `QuerySet`.
*args: Positional arguments to pass into `query`.
Keyword Args:
**kwargs: Keyword arguments to pass into `query`."""
- query = args[0]
- if len(args) > 1 and isinstance(args[1], QueryOptions):
- query_options = args[1]
+
+ if isinstance(args[0], QueryOptions):
+ query_options = args[0]
+ query = args[1]
args = args[2:]
+
else:
query_options = QueryOptions()
+ query = args[0]
args = args[1:]
query_ref = use_ref(query)
From 4865e1d5c186b1de5836ab015e75128f954ff104 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Sat, 10 Dec 2022 18:57:23 -0800
Subject: [PATCH 27/37] fix tests
---
tests/test_app/components.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/test_app/components.py b/tests/test_app/components.py
index 086047c9..72581aed 100644
--- a/tests/test_app/components.py
+++ b/tests/test_app/components.py
@@ -182,8 +182,8 @@ def get_foriegn_child_query():
@component
def relational_query():
- relational_parent = use_query(get_relational_parent_query)
foriegn_child = use_query(get_foriegn_child_query)
+ relational_parent = use_query(get_relational_parent_query)
if not relational_parent.data or not foriegn_child.data:
return
From 10ddb70f87b8bd51fdefd864fbcf26f9ae68f272 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Sat, 10 Dec 2022 19:12:12 -0800
Subject: [PATCH 28/37] add linenums to docs
---
docs/src/features/components.md | 4 ++--
docs/src/features/hooks.md | 8 ++++----
2 files changed, 6 insertions(+), 6 deletions(-)
diff --git a/docs/src/features/components.md b/docs/src/features/components.md
index e7b7398d..a85e2630 100644
--- a/docs/src/features/components.md
+++ b/docs/src/features/components.md
@@ -51,7 +51,7 @@ Convert any Django view into a IDOM component by using this decorator. Compatibl
=== "Function Based View"
- ```python
+ ```python linenums="1"
...
@view_to_component(compatibility=True)
@@ -62,7 +62,7 @@ Convert any Django view into a IDOM component by using this decorator. Compatibl
=== "Class Based View"
- ```python
+ ```python linenums="1"
...
@view_to_component(compatibility=True)
diff --git a/docs/src/features/hooks.md b/docs/src/features/hooks.md
index 4fe039a1..56b50462 100644
--- a/docs/src/features/hooks.md
+++ b/docs/src/features/hooks.md
@@ -70,7 +70,7 @@ The function you provide into this hook must return either a `Model` or `QuerySe
=== "components.py"
- ```python
+ ```python linenums="1"
from idom import component
from django_idom.hooks import use_query
@@ -107,7 +107,7 @@ The function you provide into this hook must return either a `Model` or `QuerySe
=== "components.py"
- ```python
+ ```python linenums="1"
from idom import component
from django_idom.types import QueryOptions
from django_idom.hooks import use_query
@@ -141,7 +141,7 @@ The function you provide into this hook must return either a `Model` or `QuerySe
=== "components.py"
- ```python
+ ```python linenums="1"
from example_project.my_app.models import MyModel
from idom import component
from django_idom.types import QueryOptions
@@ -239,7 +239,7 @@ The function you provide into this hook will have no return value.
=== "components.py"
- ```python
+ ```python linenums="1"
from idom import component
from django_idom.hooks import use_mutation
From 794e15e1ee6cf01467e2e404a8a9e14ebe4f7ad3 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Sun, 11 Dec 2022 15:57:04 -0800
Subject: [PATCH 29/37] enable linenums globally
---
README.md | 4 +--
docs/includes/examples.md | 2 +-
docs/src/features/components.md | 32 ++++++++++++------------
docs/src/features/decorators.md | 12 ++++-----
docs/src/features/hooks.md | 26 +++++++++----------
docs/src/features/settings.md | 2 +-
docs/src/features/templatetag.md | 8 +++---
docs/src/getting-started/installation.md | 8 +++---
docs/src/getting-started/render-view.md | 4 +--
mkdocs.yml | 3 ++-
10 files changed, 51 insertions(+), 50 deletions(-)
diff --git a/README.md b/README.md
index 4281bb27..f45f1242 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ You will need a file to define your [IDOM](https://github.com/idom-team/idom) co
-```python linenums="1"
+```python
from idom import component, html
@component
@@ -51,7 +51,7 @@ Additionally, you can pass in keyword arguments into your component function. Fo
-```jinja linenums="1"
+```jinja
{% load idom %}
diff --git a/docs/includes/examples.md b/docs/includes/examples.md
index 0edbf273..6db558c0 100644
--- a/docs/includes/examples.md
+++ b/docs/includes/examples.md
@@ -1,6 +1,6 @@
-```python linenums="1"
+```python
from django.db import models
class TodoItem(models.Model):
diff --git a/docs/src/features/components.md b/docs/src/features/components.md
index a85e2630..5a8c3ba1 100644
--- a/docs/src/features/components.md
+++ b/docs/src/features/components.md
@@ -8,7 +8,7 @@ Convert any Django view into a IDOM component by using this decorator. Compatibl
=== "components.py"
- ```python linenums="1"
+ ```python
from idom import component, html
from django.http import HttpResponse
from django_idom.components import view_to_component
@@ -51,7 +51,7 @@ Convert any Django view into a IDOM component by using this decorator. Compatibl
=== "Function Based View"
- ```python linenums="1"
+ ```python
...
@view_to_component(compatibility=True)
@@ -62,7 +62,7 @@ Convert any Django view into a IDOM component by using this decorator. Compatibl
=== "Class Based View"
- ```python linenums="1"
+ ```python
...
@view_to_component(compatibility=True)
@@ -88,7 +88,7 @@ Convert any Django view into a IDOM component by using this decorator. Compatibl
=== "components.py"
- ```python linenums="1"
+ ```python
from idom import component, html
from django.http import HttpResponse
from django.views import View
@@ -112,7 +112,7 @@ Convert any Django view into a IDOM component by using this decorator. Compatibl
=== "components.py"
- ```python linenums="1"
+ ```python
from idom import component, html
from django.http import HttpResponse
from django_idom.components import view_to_component
@@ -135,7 +135,7 @@ Convert any Django view into a IDOM component by using this decorator. Compatibl
=== "components.py"
- ```python linenums="1"
+ ```python
from idom import component, html
from django.http import HttpResponse, HttpRequest
from django_idom.components import view_to_component
@@ -164,7 +164,7 @@ Convert any Django view into a IDOM component by using this decorator. Compatibl
=== "components.py"
- ```python linenums="1"
+ ```python
from idom import component, html
from django.http import HttpResponse
from django_idom.components import view_to_component
@@ -200,7 +200,7 @@ Convert any Django view into a IDOM component by using this decorator. Compatibl
=== "components.py"
- ```python linenums="1"
+ ```python
from idom import component, html
from django.http import HttpResponse
from django_idom.components import view_to_component
@@ -230,7 +230,7 @@ Convert any Django view into a IDOM component by using this decorator. Compatibl
=== "components.py"
- ```python linenums="1"
+ ```python
from idom import component, html
from django.http import HttpResponse
from django_idom.components import view_to_component
@@ -258,7 +258,7 @@ Convert any Django view into a IDOM component by using this decorator. Compatibl
=== "components.py"
- ```python linenums="1"
+ ```python
from idom import component, html
from django.http import HttpResponse
from django_idom.components import view_to_component
@@ -285,7 +285,7 @@ Allows you to defer loading a CSS stylesheet until a component begins rendering.
=== "components.py"
- ```python linenums="1"
+ ```python
from idom import component, html
from django_idom.components import django_css
@@ -322,7 +322,7 @@ Allows you to defer loading a CSS stylesheet until a component begins rendering.
Here's an example on what you should avoid doing for Django static files:
- ```python linenums="1"
+ ```python
from idom import component, html
from django.templatetags.static import static
@@ -340,7 +340,7 @@ Allows you to defer loading a CSS stylesheet until a component begins rendering.
For external CSS, substitute `django_css` with `html.link`.
- ```python linenums="1"
+ ```python
from idom import component, html
@component
@@ -363,7 +363,7 @@ Allows you to defer loading JavaScript until a component begins rendering. This
=== "components.py"
- ```python linenums="1"
+ ```python
from idom import component, html
from django_idom.components import django_js
@@ -400,7 +400,7 @@ Allows you to defer loading JavaScript until a component begins rendering. This
Here's an example on what you should avoid doing for Django static files:
- ```python linenums="1"
+ ```python
from idom import component, html
from django.templatetags.static import static
@@ -418,7 +418,7 @@ Allows you to defer loading JavaScript until a component begins rendering. This
For external JavaScript, substitute `django_js` with `html.script`.
- ```python linenums="1"
+ ```python
from idom import component, html
@component
diff --git a/docs/src/features/decorators.md b/docs/src/features/decorators.md
index 663dfb52..e643ec77 100644
--- a/docs/src/features/decorators.md
+++ b/docs/src/features/decorators.md
@@ -14,7 +14,7 @@ This decorator can be used with or without parentheses.
=== "components.py"
- ```python linenums="1"
+ ```python
from django_idom.decorators import auth_required
from django_idom.hooks import use_websocket
from idom import component, html
@@ -48,7 +48,7 @@ This decorator can be used with or without parentheses.
=== "components.py"
- ```python linenums="1"
+ ```python
from django_idom.decorators import auth_required
from idom import component, html
@@ -68,7 +68,7 @@ This decorator can be used with or without parentheses.
=== "components.py"
- ```python linenums="1"
+ ```python
from django_idom.decorators import auth_required
from django_idom.hooks import use_websocket
from idom import component, html
@@ -85,7 +85,7 @@ This decorator can be used with or without parentheses.
=== "components.py"
- ```python linenums="1"
+ ```python
from django_idom.decorators import auth_required
from django_idom.hooks import use_websocket
from idom import component, html
@@ -105,7 +105,7 @@ This decorator can be used with or without parentheses.
=== "models.py"
- ```python linenums="1"
+ ```python
from django.contrib.auth.models import AbstractBaseUser
class CustomUserModel(AbstractBaseUser):
@@ -118,7 +118,7 @@ This decorator can be used with or without parentheses.
=== "components.py"
- ```python linenums="1"
+ ```python
from django_idom.decorators import auth_required
from django_idom.hooks import use_websocket
from idom import component, html
diff --git a/docs/src/features/hooks.md b/docs/src/features/hooks.md
index 56b50462..f0145303 100644
--- a/docs/src/features/hooks.md
+++ b/docs/src/features/hooks.md
@@ -16,7 +16,7 @@ The function you provide into this hook must return either a `Model` or `QuerySe
=== "components.py"
- ```python linenums="1"
+ ```python
from example_project.my_app.models import TodoItem
from idom import component, html
from django_idom.hooks import use_query
@@ -40,7 +40,7 @@ The function you provide into this hook must return either a `Model` or `QuerySe
=== "models.py"
- ```python linenums="1"
+ ```python
from django.db import models
class TodoItem(models.Model):
@@ -70,7 +70,7 @@ The function you provide into this hook must return either a `Model` or `QuerySe
=== "components.py"
- ```python linenums="1"
+ ```python
from idom import component
from django_idom.hooks import use_query
@@ -107,7 +107,7 @@ The function you provide into this hook must return either a `Model` or `QuerySe
=== "components.py"
- ```python linenums="1"
+ ```python
from idom import component
from django_idom.types import QueryOptions
from django_idom.hooks import use_query
@@ -141,7 +141,7 @@ The function you provide into this hook must return either a `Model` or `QuerySe
=== "components.py"
- ```python linenums="1"
+ ```python
from example_project.my_app.models import MyModel
from idom import component
from django_idom.types import QueryOptions
@@ -185,7 +185,7 @@ The function you provide into this hook will have no return value.
=== "components.py"
- ```python linenums="1"
+ ```python
from example_project.my_app.models import TodoItem
from idom import component, html
from django_idom.hooks import use_mutation
@@ -239,7 +239,7 @@ The function you provide into this hook will have no return value.
=== "components.py"
- ```python linenums="1"
+ ```python
from idom import component
from django_idom.hooks import use_mutation
@@ -265,7 +265,7 @@ The function you provide into this hook will have no return value.
=== "components.py"
- ```python linenums="1"
+ ```python
from example_project.my_app.models import TodoItem
from idom import component, html
from django_idom.hooks import use_mutation
@@ -321,7 +321,7 @@ The function you provide into this hook will have no return value.
=== "components.py"
- ```python linenums="1"
+ ```python
from example_project.my_app.models import TodoItem
from idom import component, html
from django_idom.hooks import use_mutation
@@ -368,7 +368,7 @@ You can fetch the Django Channels [websocket](https://channels.readthedocs.io/en
=== "components.py"
- ```python linenums="1"
+ ```python
from idom import component, html
from django_idom.hooks import use_websocket
@@ -396,7 +396,7 @@ This is a shortcut that returns the Websocket's [`scope`](https://channels.readt
=== "components.py"
- ```python linenums="1"
+ ```python
from idom import component, html
from django_idom.hooks import use_scope
@@ -426,7 +426,7 @@ You can expect this hook to provide strings such as `/idom/my_path`.
=== "components.py"
- ```python linenums="1"
+ ```python
from idom import component, html
from django_idom.hooks import use_location
@@ -462,7 +462,7 @@ You can expect this hook to provide strings such as `http://example.com`.
=== "components.py"
- ```python linenums="1"
+ ```python
from idom import component, html
from django_idom.hooks import use_origin
diff --git a/docs/src/features/settings.md b/docs/src/features/settings.md
index bbe248e4..b7d00c9c 100644
--- a/docs/src/features/settings.md
+++ b/docs/src/features/settings.md
@@ -8,7 +8,7 @@
- ```python linenums="1"
+ ```python
# If "idom" cache is not configured, then we will use "default" instead
CACHES = {
"idom": {"BACKEND": ...},
diff --git a/docs/src/features/templatetag.md b/docs/src/features/templatetag.md
index 9482f573..6aa58023 100644
--- a/docs/src/features/templatetag.md
+++ b/docs/src/features/templatetag.md
@@ -20,7 +20,7 @@
=== "my-template.html"
- ```jinja linenums="1"
+ ```jinja
{% component dont_do_this recipient="World" %}
@@ -30,7 +30,7 @@
=== "views.py"
- ```python linenums="1"
+ ```python
def example_view():
context_vars = {"dont_do_this": "example_project.my_app.components.hello_world"}
return render(request, "my-template.html", context_vars)
@@ -48,7 +48,7 @@
=== "my-template.html"
- ```jinja linenums="1"
+ ```jinja
...
{% component "example.components.my_component" class="my-html-class" key=123 %}
...
@@ -79,7 +79,7 @@
=== "my-template.html"
- ```jinja linenums="1"
+ ```jinja
{% load idom %}
diff --git a/docs/src/getting-started/installation.md b/docs/src/getting-started/installation.md
index 9b3b61ef..1224df32 100644
--- a/docs/src/getting-started/installation.md
+++ b/docs/src/getting-started/installation.md
@@ -18,7 +18,7 @@ In your settings you will need to add `django_idom` to [`INSTALLED_APPS`](https:
=== "settings.py"
- ```python linenums="1"
+ ```python
INSTALLED_APPS = [
"django_idom",
...
@@ -35,7 +35,7 @@ In your settings you will need to add `django_idom` to [`INSTALLED_APPS`](https:
=== "settings.py"
- ```python linenums="1"
+ ```python
INSTALLED_APPS = [
"daphne",
...
@@ -55,7 +55,7 @@ Add IDOM HTTP paths to your `urlpatterns`.
=== "urls.py"
- ```python linenums="1"
+ ```python
from django.urls import include, path
urlpatterns = [
@@ -70,7 +70,7 @@ Register IDOM's Websocket using `IDOM_WEBSOCKET_PATH`.
=== "asgi.py"
- ```python linenums="1"
+ ```python
import os
from django.core.asgi import get_asgi_application
diff --git a/docs/src/getting-started/render-view.md b/docs/src/getting-started/render-view.md
index 6319381a..6098680b 100644
--- a/docs/src/getting-started/render-view.md
+++ b/docs/src/getting-started/render-view.md
@@ -12,7 +12,7 @@ In this example, we will create a view that renders `my-template.html` (_from th
=== "views.py"
- ```python linenums="1"
+ ```python
from django.shortcuts import render
def index(request):
@@ -23,7 +23,7 @@ We will add this new view into your [`urls.py`](https://docs.djangoproject.com/e
=== "urls.py"
- ```python linenums="1"
+ ```python
from django.urls import path
from example_project.my_app import views
diff --git a/mkdocs.yml b/mkdocs.yml
index eb4c169f..f51bc051 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -53,7 +53,8 @@ markdown_extensions:
emoji_generator: !!python/name:materialx.emoji.to_svg
- pymdownx.tabbed:
alternate_style: true
- - pymdownx.highlight
+ - pymdownx.highlight:
+ linenums: true
- pymdownx.superfences
- pymdownx.details
- pymdownx.inlinehilite
From 3f082fcb14d064e94fe6ca882480dca055406e3e Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Mon, 12 Dec 2022 03:22:59 -0800
Subject: [PATCH 30/37] add changelog
---
CHANGELOG.md | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9e0b8d57..5797fdca 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,7 +22,17 @@ Using the following categories, list your changes in this order:
## [Unreleased]
-- Nothing (yet)
+### Added
+
+- Add `options: QueryOptions` parameter to `use_query` to allow for configuration of this hook.
+
+### Changed
+
+- By default, `use_query` will recursively prefetch all many-to-many or many-to-one relationships to prevent `SynchronousOnlyOperation` exceptions.
+
+### Removed
+
+- `django_idom.hooks._fetch_lazy_fields` has been deleted. The equivalent replacement is `django_idom.utils.django_query_postprocessor`.
## [2.1.0] - 2022-11-01
From 1baa379da130e6a3768073746de75958cc5a1d4b Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Mon, 12 Dec 2022 03:24:21 -0800
Subject: [PATCH 31/37] misc docs cleanup
---
docs/src/features/components.md | 8 ++++----
docs/src/features/hooks.md | 8 ++++----
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/docs/src/features/components.md b/docs/src/features/components.md
index 5a8c3ba1..9f444d8d 100644
--- a/docs/src/features/components.md
+++ b/docs/src/features/components.md
@@ -196,8 +196,6 @@ Convert any Django view into a IDOM component by using this decorator. Compatibl
In these scenarios, you may want to rely on best-fit parsing by setting the `strict_parsing` parameter to `False`.
- Note that best-fit parsing is designed to be similar to how web browsers would handle non-standard or broken HTML.
-
=== "components.py"
```python
@@ -216,7 +214,7 @@ Convert any Django view into a IDOM component by using this decorator. Compatibl
)
```
-
+ _Note: Best-fit parsing is designed to be similar to how web browsers would handle non-standard or broken HTML._
---
@@ -226,7 +224,7 @@ Convert any Django view into a IDOM component by using this decorator. Compatibl
Any view can be rendered within compatibility mode. However, the `transforms`, `strict_parsing`, `request`, `args`, and `kwargs` arguments do not apply to compatibility mode.
- Please note that by default the iframe is unstyled, and thus won't look pretty until you add some CSS.
+
=== "components.py"
@@ -246,6 +244,8 @@ Convert any Django view into a IDOM component by using this decorator. Compatibl
)
```
+ _Note: By default the `compatibility` iframe is unstyled, and thus won't look pretty until you add some CSS._
+
---
**`transforms`**
diff --git a/docs/src/features/hooks.md b/docs/src/features/hooks.md
index f0145303..277a5223 100644
--- a/docs/src/features/hooks.md
+++ b/docs/src/features/hooks.md
@@ -74,7 +74,7 @@ The function you provide into this hook must return either a `Model` or `QuerySe
from idom import component
from django_idom.hooks import use_query
- def example_query(value:int, other_value:bool = False):
+ def example_query(value: int, other_value: bool = False):
...
@component
@@ -103,7 +103,7 @@ The function you provide into this hook must return either a `Model` or `QuerySe
1. Want to use this hook to defer IO intensive tasks to be computed in the background
2. Want to to utilize `use_query` with a different ORM
- ... then you can disable all postprocessing behavior by modifying the `QueryOptions.postprocessor` parameter. In the example below, we will set the `postprocessor` to a function that takes one argument and returns nothing.
+ ... then you can disable all postprocessing behavior by modifying the `QueryOptions.postprocessor` parameter. In the example below, we will set the `postprocessor` to a function that takes one argument and returns `None`.
=== "components.py"
@@ -135,7 +135,7 @@ The function you provide into this hook must return either a `Model` or `QuerySe
{% include-markdown "../../includes/orm.md" start="" end="" %}
- However, if you have deep nested trees of relational data, this may not be a desirable behavior. You may prefer to manually fetch these relational fields using a second `use_query` hook.
+ However, if you have deep nested trees of relational data, this may not be a desirable behavior. In these scenarios, you may prefer to manually fetch these relational fields using a second `use_query` hook.
You can disable the prefetching behavior of the default `postprocessor` (located at `django_idom.utils.django_query_postprocessor`) via the `QueryOptions.postprocessor_kwargs` parameter.
@@ -243,7 +243,7 @@ The function you provide into this hook will have no return value.
from idom import component
from django_idom.hooks import use_mutation
- def example_mutation(value:int, other_value:bool = False):
+ def example_mutation(value: int, other_value: bool = False):
...
@component
From b0043b1172408fe8090e6b11db85dfc192eb33be Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Mon, 12 Dec 2022 03:30:28 -0800
Subject: [PATCH 32/37] changelog is not an rst
---
.github/pull_request_template.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 33212937..95ad9c16 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -7,5 +7,5 @@ A summary of the changes.
Please update this checklist as you complete each item:
- [ ] Tests have been included for all bug fixes or added functionality.
-- [ ] The `changelog.rst` has been updated with any significant changes, if necessary.
+- [ ] The changelog has been updated with any significant changes, if necessary.
- [ ] GitHub Issues which may be closed by this PR have been linked.
From 03c0681df6099938a7bd3660ea037113da836a11 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Mon, 12 Dec 2022 15:06:44 -0800
Subject: [PATCH 33/37] revert data variable name
---
src/django_idom/hooks.py | 12 ++++++++----
1 file changed, 8 insertions(+), 4 deletions(-)
diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py
index 20455a57..07d3817e 100644
--- a/src/django_idom/hooks.py
+++ b/src/django_idom/hooks.py
@@ -153,15 +153,19 @@ def execute_query() -> None:
try:
# Run the initial query
- data = query(*args, **kwargs)
+ new_data = query(*args, **kwargs)
# Use a custom postprocessor, if provided
if query_options.postprocessor:
- query_options.postprocessor(data, **query_options.postprocessor_kwargs)
+ query_options.postprocessor(
+ new_data, **query_options.postprocessor_kwargs
+ )
# Use the default postprocessor
else:
- django_query_postprocessor(data, **query_options.postprocessor_kwargs)
+ django_query_postprocessor(
+ new_data, **query_options.postprocessor_kwargs
+ )
except Exception as e:
set_data(None)
set_loading(False)
@@ -173,7 +177,7 @@ def execute_query() -> None:
finally:
set_should_execute(False)
- set_data(data)
+ set_data(new_data)
set_loading(False)
set_error(None)
From 17de2703c35804b769dcbb6492d28ff66fd45346 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Thu, 15 Dec 2022 03:10:09 -0800
Subject: [PATCH 34/37] Typehints, Postprocessor returns `Any`, and `defaults`
configuration value for postprocessor
---
docs/src/features/hooks.md | 6 ++----
docs/src/features/settings.md | 3 +++
src/django_idom/config.py | 6 +++++-
src/django_idom/defaults.py | 16 ++++++++++++++++
src/django_idom/hooks.py | 10 ++--------
src/django_idom/types.py | 12 +++++++++---
src/django_idom/utils.py | 10 ++++++----
7 files changed, 43 insertions(+), 20 deletions(-)
create mode 100644 src/django_idom/defaults.py
diff --git a/docs/src/features/hooks.md b/docs/src/features/hooks.md
index 277a5223..b6f5aaaa 100644
--- a/docs/src/features/hooks.md
+++ b/docs/src/features/hooks.md
@@ -103,7 +103,7 @@ The function you provide into this hook must return either a `Model` or `QuerySe
1. Want to use this hook to defer IO intensive tasks to be computed in the background
2. Want to to utilize `use_query` with a different ORM
- ... then you can disable all postprocessing behavior by modifying the `QueryOptions.postprocessor` parameter. In the example below, we will set the `postprocessor` to a function that takes one argument and returns `None`.
+ ... then you can disable all postprocessing behavior by modifying the `QueryOptions.postprocessor` parameter. In the example below, we will set the `postprocessor` to `None`.
=== "components.py"
@@ -118,10 +118,8 @@ The function you provide into this hook must return either a `Model` or `QuerySe
@component
def todo_list():
- # By setting the postprocessor to a function that takes one argument
- # and returns None, we can disable postprocessing behavior.
query = use_query(
- QueryOptions(postprocessor=lambda data: None),
+ QueryOptions(postprocessor=None),
execute_io_intensive_operation,
)
diff --git a/docs/src/features/settings.md b/docs/src/features/settings.md
index b7d00c9c..4eb83fe1 100644
--- a/docs/src/features/settings.md
+++ b/docs/src/features/settings.md
@@ -20,6 +20,9 @@
# The URL for IDOM to serve websockets
IDOM_WEBSOCKET_URL = "idom/"
+
+ # Dotted path to the default postprocessor function, or `None`
+ IDOM_DEFAULT_QUERY_POSTPROCESSOR = "example_project.utils.my_postprocessor"
```
diff --git a/src/django_idom/config.py b/src/django_idom/config.py
index b87fb7e9..57826795 100644
--- a/src/django_idom/config.py
+++ b/src/django_idom/config.py
@@ -1,10 +1,13 @@
+from __future__ import annotations
+
from typing import Dict
from django.conf import settings
from django.core.cache import DEFAULT_CACHE_ALIAS, BaseCache, caches
from idom.core.types import ComponentConstructor
-from django_idom.types import ViewComponentIframe
+from django_idom.defaults import _DEFAULT_QUERY_POSTPROCESSOR
+from django_idom.types import Postprocessor, ViewComponentIframe
IDOM_REGISTERED_COMPONENTS: Dict[str, ComponentConstructor] = {}
@@ -14,6 +17,7 @@
IDOM_WS_MAX_RECONNECT_TIMEOUT = getattr(
settings, "IDOM_WS_MAX_RECONNECT_TIMEOUT", 604800
)
+IDOM_DEFAULT_QUERY_POSTPROCESSOR: Postprocessor | None = _DEFAULT_QUERY_POSTPROCESSOR
# Determine if using Django caching or LRU cache
IDOM_CACHE: BaseCache = (
diff --git a/src/django_idom/defaults.py b/src/django_idom/defaults.py
new file mode 100644
index 00000000..bc0530bb
--- /dev/null
+++ b/src/django_idom/defaults.py
@@ -0,0 +1,16 @@
+from __future__ import annotations
+
+from typing import Any, Callable
+
+from django.conf import settings
+
+from django_idom.utils import _import_dotted_path
+
+
+_DEFAULT_QUERY_POSTPROCESSOR: Callable[..., Any] | None = _import_dotted_path(
+ getattr(
+ settings,
+ "IDOM_DEFAULT_QUERY_POSTPROCESSOR",
+ "django_idom.utils.django_query_postprocessor",
+ )
+)
diff --git a/src/django_idom/hooks.py b/src/django_idom/hooks.py
index 07d3817e..4455f779 100644
--- a/src/django_idom/hooks.py
+++ b/src/django_idom/hooks.py
@@ -26,7 +26,7 @@
_Params,
_Result,
)
-from django_idom.utils import _generate_obj_name, django_query_postprocessor
+from django_idom.utils import _generate_obj_name
_logger = logging.getLogger(__name__)
@@ -155,17 +155,11 @@ def execute_query() -> None:
# Run the initial query
new_data = query(*args, **kwargs)
- # Use a custom postprocessor, if provided
if query_options.postprocessor:
- query_options.postprocessor(
+ new_data = query_options.postprocessor(
new_data, **query_options.postprocessor_kwargs
)
- # Use the default postprocessor
- else:
- django_query_postprocessor(
- new_data, **query_options.postprocessor_kwargs
- )
except Exception as e:
set_data(None)
set_loading(False)
diff --git a/src/django_idom/types.py b/src/django_idom/types.py
index a1d3c9d2..7705c98b 100644
--- a/src/django_idom/types.py
+++ b/src/django_idom/types.py
@@ -18,6 +18,8 @@
from django.views.generic import View
from typing_extensions import ParamSpec
+from django_idom.defaults import _DEFAULT_QUERY_POSTPROCESSOR
+
__all__ = ["_Result", "_Params", "_Data", "IdomWebsocket", "Query", "Mutation"]
@@ -64,7 +66,7 @@ class ViewComponentIframe:
class Postprocessor(Protocol):
- def __call__(self, data: Any, **kwargs: Any) -> None:
+ def __call__(self, data: Any) -> Any:
...
@@ -72,8 +74,12 @@ def __call__(self, data: Any, **kwargs: Any) -> None:
class QueryOptions:
"""Configuration options that can be provided to `use_query`."""
- postprocessor: Postprocessor | None = None
- """A post processing callable that can read/modify the query `data`.
+ postprocessor: Postprocessor | None = _DEFAULT_QUERY_POSTPROCESSOR
+ """A callable that can modify the query `data` after the query has been executed.
+
+ The first argument of postprocessor must be the query `data`. All proceeding arguments
+ are optional `postprocessor_kwargs` (see below). This postprocessor function must return
+ the modified `data`.
If `None`, the default postprocessor is used.
diff --git a/src/django_idom/utils.py b/src/django_idom/utils.py
index a7ee5f8c..0b0d2003 100644
--- a/src/django_idom/utils.py
+++ b/src/django_idom/utils.py
@@ -19,8 +19,6 @@
from django.utils.encoding import smart_str
from django.views import View
-from django_idom.config import IDOM_REGISTERED_COMPONENTS
-
_logger = logging.getLogger(__name__)
_component_tag = r"(?Pcomponent)"
@@ -77,6 +75,8 @@ async def render_view(
def _register_component(dotted_path: str) -> None:
+ from django_idom.config import IDOM_REGISTERED_COMPONENTS
+
if dotted_path in IDOM_REGISTERED_COMPONENTS:
return
@@ -206,8 +206,8 @@ def _generate_obj_name(object: Any) -> str | None:
def django_query_postprocessor(
- data: QuerySet | Model, /, many_to_many: bool = True, many_to_one: bool = True
-) -> None:
+ data: QuerySet | Model, many_to_many: bool = True, many_to_one: bool = True
+) -> QuerySet | Model:
"""Recursively fetch all fields within a `Model` or `QuerySet` to ensure they are not performed lazily.
Some behaviors can be modified through `query_options` attributes."""
@@ -254,3 +254,5 @@ def django_query_postprocessor(
" - You are attempting to use `use_query` to fetch non-ORM data.\n\n"
"If these situations seem correct, you may want to consider disabling the postprocessor via `QueryOptions`."
)
+
+ return data
From efabc16f42828f87ddebaae91ab8657fdb739148 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Thu, 15 Dec 2022 05:09:53 -0800
Subject: [PATCH 35/37] reorganize config.py
---
src/django_idom/config.py | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/src/django_idom/config.py b/src/django_idom/config.py
index 57826795..0e1d4cd7 100644
--- a/src/django_idom/config.py
+++ b/src/django_idom/config.py
@@ -12,16 +12,19 @@
IDOM_REGISTERED_COMPONENTS: Dict[str, ComponentConstructor] = {}
IDOM_VIEW_COMPONENT_IFRAMES: Dict[str, ViewComponentIframe] = {}
-
-IDOM_WEBSOCKET_URL = getattr(settings, "IDOM_WEBSOCKET_URL", "idom/")
+IDOM_WEBSOCKET_URL = getattr(
+ settings,
+ "IDOM_WEBSOCKET_URL",
+ "idom/",
+)
IDOM_WS_MAX_RECONNECT_TIMEOUT = getattr(
- settings, "IDOM_WS_MAX_RECONNECT_TIMEOUT", 604800
+ settings,
+ "IDOM_WS_MAX_RECONNECT_TIMEOUT",
+ 604800,
)
-IDOM_DEFAULT_QUERY_POSTPROCESSOR: Postprocessor | None = _DEFAULT_QUERY_POSTPROCESSOR
-
-# Determine if using Django caching or LRU cache
IDOM_CACHE: BaseCache = (
caches["idom"]
if "idom" in getattr(settings, "CACHES", {})
else caches[DEFAULT_CACHE_ALIAS]
)
+IDOM_DEFAULT_QUERY_POSTPROCESSOR: Postprocessor | None = _DEFAULT_QUERY_POSTPROCESSOR
From a1b8f6e30aef9e0c5f68e29fc509d1ddc4745f55 Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Wed, 28 Dec 2022 00:38:34 -0800
Subject: [PATCH 36/37] custom postprocessor docs
---
docs/src/features/hooks.md | 41 +++++++++++++++++++++++++++++++++++++-
1 file changed, 40 insertions(+), 1 deletion(-)
diff --git a/docs/src/features/hooks.md b/docs/src/features/hooks.md
index b6f5aaaa..c27187df 100644
--- a/docs/src/features/hooks.md
+++ b/docs/src/features/hooks.md
@@ -103,7 +103,7 @@ The function you provide into this hook must return either a `Model` or `QuerySe
1. Want to use this hook to defer IO intensive tasks to be computed in the background
2. Want to to utilize `use_query` with a different ORM
- ... then you can disable all postprocessing behavior by modifying the `QueryOptions.postprocessor` parameter. In the example below, we will set the `postprocessor` to `None`.
+ ... then you can disable all postprocessing behavior by modifying the `QueryOptions.postprocessor` parameter. In the example below, we will set the `postprocessor` to `None` to disable postprocessing behavior.
=== "components.py"
@@ -129,6 +129,45 @@ The function you provide into this hook must return either a `Model` or `QuerySe
return str(query.data)
```
+ If you wish to create a custom postprocessor, you will need to create a callable.
+
+ The first argument of postprocessor must be the query `data`. All proceeding arguments
+ are optional `postprocessor_kwargs` (see below). This postprocessor function must return
+ the modified `data`.
+
+ === "components.py"
+
+ ```python
+ from idom import component
+ from django_idom.types import QueryOptions
+ from django_idom.hooks import use_query
+
+ def my_postprocessor(data, example_kwarg=True):
+ if example_kwarg:
+ return data
+
+ return dict(data)
+
+ def execute_io_intensive_operation():
+ """This is an example query function that does something IO intensive."""
+ pass
+
+ @component
+ def todo_list():
+ query = use_query(
+ QueryOptions(
+ postprocessor=my_postprocessor,
+ postprocessor_kwargs={"example_kwarg": False},
+ ),
+ execute_io_intensive_operation,
+ )
+
+ if query.loading or query.error:
+ return None
+
+ return str(query.data)
+ ```
+
??? question "How can I prevent this hook from recursively fetching `ManyToMany` fields or `ForeignKey` relationships?"
{% include-markdown "../../includes/orm.md" start="" end="" %}
From f5ccda1746abc78e36dfca9aa74f5c006e4b44dc Mon Sep 17 00:00:00 2001
From: Archmonger <16909269+Archmonger@users.noreply.github.com>
Date: Wed, 28 Dec 2022 00:42:41 -0800
Subject: [PATCH 37/37] fix spelling issues
---
.github/workflows/test-docs.yml | 2 +-
docs/src/features/hooks.md | 6 +++---
2 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/test-docs.yml b/.github/workflows/test-docs.yml
index 3bbacf4f..5b5bcf6b 100644
--- a/.github/workflows/test-docs.yml
+++ b/.github/workflows/test-docs.yml
@@ -22,4 +22,4 @@ jobs:
python-version: 3.x
- run: pip install -r requirements/build-docs.txt
- run: linkcheckMarkdown docs/ -v -r
- - run: mkdocs build --verbose --strict
+ - run: mkdocs build --strict
diff --git a/docs/src/features/hooks.md b/docs/src/features/hooks.md
index c27187df..07040d0b 100644
--- a/docs/src/features/hooks.md
+++ b/docs/src/features/hooks.md
@@ -129,10 +129,10 @@ The function you provide into this hook must return either a `Model` or `QuerySe
return str(query.data)
```
- If you wish to create a custom postprocessor, you will need to create a callable.
+ If you wish to create a custom `postprocessor`, you will need to create a callable.
- The first argument of postprocessor must be the query `data`. All proceeding arguments
- are optional `postprocessor_kwargs` (see below). This postprocessor function must return
+ The first argument of `postprocessor` must be the query `data`. All proceeding arguments
+ are optional `postprocessor_kwargs` (see below). This `postprocessor` must return
the modified `data`.
=== "components.py"