Skip to content

use_query prefetching for ManyToMany and ManyToOne fields #112

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
84b6fb3
Attempt ManyToMany auto-fetching
Archmonger Nov 15, 2022
8904fd3
functioning many_to_many and many_to_one handling
Archmonger Nov 15, 2022
11b185e
better naming for todo item functions
Archmonger Nov 15, 2022
cdfacbe
fetch_options decorator
Archmonger Nov 16, 2022
e6b1237
failure tests
Archmonger Nov 16, 2022
0564716
format migrations
Archmonger Nov 16, 2022
36035fb
OrmFetch type hint on fetch_options
Archmonger Nov 16, 2022
ecb96e6
pass -> ...
Archmonger Nov 16, 2022
d1bd3f8
better variable/function names
Archmonger Nov 17, 2022
96c451f
set fetch attributes to false by default
Archmonger Nov 17, 2022
2b8d036
remove kw_only for py3.8 compatibility
Archmonger Nov 17, 2022
f73c6d9
prep _postprocess_django_query for potential API changes
Archmonger Nov 17, 2022
9259729
QueryOptions is Callable
Archmonger Nov 17, 2022
50de9c4
one test case without query_options
Archmonger Nov 17, 2022
99ac352
use_query options via type hints
Archmonger Nov 18, 2022
8bf6097
fix mypy warnings
Archmonger Nov 18, 2022
b1e55c9
Merge remote-tracking branch 'upstream/main' into use-query-manager-f…
Archmonger Nov 18, 2022
8aeef17
cleanup
Archmonger Nov 30, 2022
275c385
fix docs builds
Archmonger Nov 30, 2022
b0e2c03
better postprocessor description
Archmonger Nov 30, 2022
31c1998
remove unneeded defaults for postprocessor opts
Archmonger Dec 8, 2022
35e2d3f
postprocessor options as kwargs
Archmonger Dec 8, 2022
a88bee4
add tests for relational query
Archmonger Dec 9, 2022
93f918a
documentation
Archmonger Dec 9, 2022
17c5bd8
grammar correction
Archmonger Dec 9, 2022
a17ca7c
use_query docs
Archmonger Dec 11, 2022
834a536
move queryoptions to first arg
Archmonger Dec 11, 2022
4865e1d
fix tests
Archmonger Dec 11, 2022
10ddb70
add linenums to docs
Archmonger Dec 11, 2022
794e15e
enable linenums globally
Archmonger Dec 11, 2022
3f082fc
add changelog
Archmonger Dec 12, 2022
1baa379
misc docs cleanup
Archmonger Dec 12, 2022
b0043b1
changelog is not an rst
Archmonger Dec 12, 2022
03c0681
revert data variable name
Archmonger Dec 12, 2022
17de270
Typehints, Postprocessor returns `Any`, and `defaults` configuration …
Archmonger Dec 15, 2022
efabc16
reorganize config.py
Archmonger Dec 15, 2022
a1b8f6e
custom postprocessor docs
Archmonger Dec 28, 2022
f5ccda1
fix spelling issues
Archmonger Dec 28, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/src/features/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`"

Expand Down
2 changes: 1 addition & 1 deletion requirements/build-docs.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ mkdocs-git-revision-date-localized-plugin
mkdocs-material
mkdocs-include-markdown-plugin
linkcheckmd
mkdocs-spellcheck
mkdocs-spellcheck[all]
98 changes: 87 additions & 11 deletions src/django_idom/hooks.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,36 @@
from __future__ import annotations

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
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

from django_idom.types import IdomWebsocket, Mutation, Query, _Params, _Result
from django_idom.types import (
IdomWebsocket,
Mutation,
Query,
QueryOptions,
_Params,
_Result,
)
from django_idom.utils import _generate_obj_name


Expand Down Expand Up @@ -64,19 +83,48 @@ 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.

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:
**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}.")
Expand Down Expand Up @@ -106,8 +154,16 @@ def execute_query() -> None:
return

try:
new_data = query(*args, **kwargs)
_fetch_lazy_fields(new_data)
# Run the initial query
data = query(*args, **kwargs)

# Use a custom postprocessor, if provided
if query_options.postprocessor:
query_options.postprocessor(data, query_options)

# Use the default postprocessor
else:
_postprocess_django_query(data, query_options.postprocessor_options)
except Exception as e:
set_data(None)
set_loading(False)
Expand All @@ -119,7 +175,7 @@ def execute_query() -> None:
finally:
set_should_execute(False)

set_data(new_data)
set_data(data)
set_loading(False)
set_error(None)

Expand Down Expand Up @@ -179,19 +235,39 @@ def reset() -> None:
return Mutation(call, loading, error, reset)


def _fetch_lazy_fields(data: Any) -> None:
"""Fetch all fields within a `Model` or `QuerySet` to ensure they are not performed lazily."""
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."""

# `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:
_fetch_lazy_fields(model)
_postprocess_django_query(model, options)

# `Model` instances
elif isinstance(data, Model):
for field in data._meta.fields:
getattr(data, field.name)
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 options.get("many_to_one", False) and type(field) == ManyToOneRel:
prefetch_fields.append(f"{field.name}_set")

elif options.get("many_to_many", False) and isinstance(
field, ManyToManyField
):
prefetch_fields.append(field.name)
_postprocess_django_query(
getattr(data, field.name).get_queryset(), options
)

if prefetch_fields:
prefetch_related_objects([data], *prefetch_fields)

# Unrecognized type
else:
Expand Down
16 changes: 15 additions & 1 deletion src/django_idom/types.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -51,3 +51,17 @@ class ViewComponentIframe:
view: View | Callable
args: Sequence
kwargs: dict


@dataclass
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: Callable[[_Data, QueryOptions], None] | None = None
"""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."""
17 changes: 16 additions & 1 deletion tests/test_app/admin.py
Original file line number Diff line number Diff line change
@@ -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
76 changes: 66 additions & 10 deletions tests/test_app/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@
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
from django_idom.hooks import use_mutation, use_query
from django_idom.types import QueryOptions

from . import views

Expand Down Expand Up @@ -154,11 +155,66 @@ def authorized_user():
)


def get_items_query():
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:
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,
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:
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-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(),
)


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:
Expand All @@ -170,16 +226,16 @@ 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()


@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}")
Expand All @@ -188,12 +244,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...")
Expand Down Expand Up @@ -227,7 +283,7 @@ def on_change(event):
)


def _render_items(items, toggle_item):
def _render_todo_items(items, toggle_item):
return html.ul(
[
html.li(
Expand Down
Loading