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"