Hey,

as discussed in https://code.djangoproject.com/ticket/31096, I have
implemented a module regarding manipulating caching on Model instances
loaded from QuerySets, and hot-replaced ManyToManyField.save_form_data()
to cache the instance relations from a validated
ModelMultipleChoiceField, keeping the newly modified ManyToMany
relations cached on the validated instance when a form gets posted.

The argument for this in my case is, I have extended a ModelForm with
mixins where after saving, I log the differences between the original
and changed instance, which has the ManyToMany relations. One of the
ManyToMany relation's __str__() contains a lookup that spans over two
other related models. Hence, the logging of new/changed fields takes
tons of DB queries when the instance coming from the ModelForm has
uncached relations, much less their prefetched variants.

As the ticket is closed there, I propose my module here for starting a
debate on a better ManyToMany (and ForeignKey)relations handling, which
my module is able to do. It was said in the ticket that the module
itself is complicated, but in my humble opinion understanding the code
of how the Form, ModelChoiceField and ManyToMany fields are wired
together, is way more complicated. So all in all, it's just a question
of viewpoint.

I'll attach three modules, the one which I pasted into the ticket and
two another that will/check return relations for traversing them in the
server code. I hope to spark some debate and maybe provide something
useful for the dev community, and maybe have these modules taken into
the project.

Mind you, this all here is voluntary. I'm already using these 3 modules
in my production systems with huge speed improvements, and if nothing
else, I'll opensource them and keep using them that way.

Tests: since I have tests that are testing these modules wired up in
relation with my system, unfortunately I can't post them here. Basically
I test models and relations in my own project by evaluating the results
of these functions, expecting some wired-up results.

Cheers,
--
László Károlyi
http://linkedin.com/in/karolyi

-- 
You received this message because you are subscribed to the Google Groups 
"Django developers  (Contributions to Django itself)" group.
To unsubscribe from this group and stop receiving emails from it, send an email 
to django-developers+unsubscr...@googlegroups.com.
To view this discussion on the web visit 
https://groups.google.com/d/msgid/django-developers/4908cdd9-9d9a-b9e9-80cb-3ac260a5375f%40karolyi.hu.
from django.db.models.base import Model

from .query_cache import is_query_prefetched
from .related_field import get_reverse_fk_relations


def remote_fks_exist(obj: Model, ignores: frozenset = frozenset()) -> bool:
    """
    Return `True` if the passed Model instance `obj` is referred in
    its remote `ForeignKey` relations.
    """
    for rel_name in get_reverse_fk_relations(model=obj._meta.model):
        if rel_name in ignores:
            continue
        relation = getattr(obj, rel_name)
        if is_query_prefetched(relation=relation):
            if relation.all():
                return True
        elif relation.exists():
            return True
    return False
from typing import Iterable, Optional

from django import VERSION
from django.db.models.base import Model
from django.db.models.fields.related import ManyToManyField
from django.db.models.fields.reverse_related import ManyToOneRel
from django.db.models.manager import Manager
from django.db.models.query import QuerySet


def invalidate_onetomany(objs: Iterable[Model], prefetch_keys: Iterable[str]):
    """
    Invalidate one-to-many caches. These are remote `ForeignKey` and
    `ManyToManyField` fields fetched with `prefetch_related()`.
    """
    if VERSION[0] == 1 or VERSION[0] == 2:
        for obj in objs:
            if not hasattr(obj, '_prefetched_objects_cache'):
                continue
            for key in prefetch_keys:
                if key not in obj._prefetched_objects_cache:
                    continue
                del obj._prefetched_objects_cache[key]


def invalidate_manytoone(objs: Iterable[Model], field_names: Iterable[str]):
    """
    Invalidate many-to-one caches. These are `ForeignKey` and
    `OneToOneField` fields fetched with `select_related()` or
    `prefetch_related()`.
    """
    if VERSION[0] == 1:
        for obj in objs:
            for field_name in field_names:
                if not is_fk_cached(obj=obj, field_name=field_name):
                    continue
                del obj.__dict__[f'_{field_name}_cache']
    elif VERSION[0] == 2:
        for obj in objs:
            for field_name in field_names:
                if not is_fk_cached(obj=obj, field_name=field_name):
                    continue
                del obj._state.fields_cache[field_name]


def get_prefetch_cache_key(relation: Manager) -> str:
    'Return a key used in the prefetched cache for a relation.'
    try:
        # Works on ManyToMany
        return relation.prefetch_cache_name
    except AttributeError:
        # Is a ForeignKey (OneToMany)
        rel_field = relation.field.remote_field  # type: ManyToOneRel
        if rel_field.related_name:
            return rel_field.related_name
        if VERSION[0] == 1:
            return rel_field.name
        elif VERSION[0] == 2:
            return f'{rel_field.name}_set'


def init_prefetch_cache(obj: Model):
    'Init a prefetch cache on the model.'
    if VERSION[0] == 1 or VERSION[0] == 2:
        if hasattr(obj, '_prefetched_objects_cache'):
            return
        obj._prefetched_objects_cache = {}


def is_query_prefetched(relation: Manager) -> bool:
    'Return `True` if the relation is prefetched.'
    if VERSION[0] == 1 or VERSION[0] == 2:
        obj = relation.instance
        if not hasattr(obj, '_prefetched_objects_cache'):
            return False
        prefetch_cache_key = get_prefetch_cache_key(relation=relation)
        return prefetch_cache_key in obj._prefetched_objects_cache
    return False


def set_prefetch_cache(
        relation: Manager, queryset: QuerySet, override: bool = True):
    'Set prefetch cache on a `Model` for a relation.'
    if is_query_prefetched(relation=relation) and not override:
        return
    obj = relation.instance
    init_prefetch_cache(obj=obj)
    if VERSION[0] == 1 or VERSION[0] == 2:
        key = get_prefetch_cache_key(relation=relation)
        obj._prefetched_objects_cache[key] = queryset


def is_queryresult_loaded(qs: QuerySet) -> bool:
    'Return `True` if the query is loaded, `False` otherwise.'
    if VERSION[0] == 1 or VERSION[0] == 2:
        return qs._result_cache is not None
    return False


def set_queryresult(qs: QuerySet, result: list, override: bool = True):
    'Set result on a previously setup query.'
    if VERSION[0] == 1 or VERSION[0] == 2:
        if override or not is_queryresult_loaded(qs=qs):
            qs._result_cache = result


def get_queryresult(qs: QuerySet) -> Optional[list]:
    'Return the cached query result of the passed `QuerySet`.'
    if VERSION[0] == 1 or VERSION[0] == 2:
        return qs._result_cache


def is_fk_cached(obj: Model, field_name: str) -> bool:
    'Return `True` if the `ForeignKey` field on the object is cached.'
    if VERSION[0] == 1:
        return hasattr(obj, f'_{field_name}_cache')
    elif VERSION[0] == 2:
        if getattr(obj, '_state', None) is None or \
                getattr(obj._state, 'fields_cache', None) is None:
            return False
        return field_name in obj._state.fields_cache
    return False


def set_fk_cache(
        obj: Model, field_name: str, value: Model, override: bool = True):
    """
    Set a cache on the `obj` for a `ForeignKey` field, override when
    requested.
    """
    if is_fk_cached(obj=obj, field_name=field_name) and not override:
        return
    if VERSION[0] == 1:
        setattr(obj, f'_{field_name}_cache', value)
    elif VERSION[0] == 2:
        if getattr(obj, '_state', None) is None:
            obj._state = dict()
        if getattr(obj._state, 'fields_cache', None) is None:
            obj._state.fields_cache = dict()
        obj._state.fields_cache[field_name] = value


def del_fk_cache(obj: Model, field_name: str):
    'Delete a cached `ForeignKey` on the `Model`.'
    if not is_fk_cached(obj=obj, field_name=field_name):
        return
    if VERSION[0] == 1:
        delattr(obj, f'_{field_name}_cache')
    elif VERSION[0] == 2:
        del obj._state.fields_cache


_old_m2m_savedata = ManyToManyField.save_form_data


def _save_m2m_form_data(
        self: ManyToManyField, instance: Model, data: QuerySet):
    _old_m2m_savedata(self=self, instance=instance, data=data)
    set_prefetch_cache(
        relation=getattr(instance, self.name), queryset=data, override=True)


ManyToManyField.save_form_data = _save_m2m_form_data
from dataclasses import dataclass
from functools import lru_cache
from typing import Dict, Tuple

from django import VERSION
from django.db.models.base import Model
from django.db.models.fields.related import ForeignKey, ManyToManyField
from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel

from .query_cache import get_prefetch_cache_key


@dataclass(frozen=True)
class RemoteForeignKeyResult(object):
    """
    Result of a `ForeignKey` reverse relations lookup.
    The name used for annotation/aggregation expressions is
    `prefetch_cache_key`.
    """
    # The model on the other side
    model: Model
    # The field on the original model
    field: ForeignKey
    # The cache key
    prefetch_cache_key: str
    # Name used for annotation
    annotation_key: str


@dataclass(frozen=True)
class RemoteManyToManyResult(object):
    """
    Result of a `ManyToMany` reverse relations lookup.
    The name used for annotation/aggregation expressions is
    `prefetch_cache_key`.
    """
    # The field on the model
    m2m_field: ManyToManyField
    # The cache key
    prefetch_cache_key: str
    # The intermediate model
    through: Model
    # The model's key in the intermediate
    through_fk_mine: ForeignKey
    # Other model's key in the intermediate
    through_fk_other: ForeignKey


@lru_cache(maxsize=None)
def get_reverse_fk_relations(
        model: Model) -> Dict[str, RemoteForeignKeyResult]:
    """
    Return a `dict` of `str-RemoteForeignKeyResult` relations.
    That is, one-to-many relations where the passed model has
    `ForeignKey`s pointing to it from remote models.
    """
    result = dict()
    related_fields = [
        x for x in model._meta.get_fields()
        if x.is_relation and x.auto_created and not x.concrete]
    fk_fields = [x for x in related_fields if x.one_to_many or x.one_to_one]
    for rel_field in fk_fields:  # type: ManyToOneRel
        related_name = rel_field.related_name or f'{rel_field.name}_set'
        prefetch_cache_key = get_prefetch_cache_key(
            relation=getattr(model, related_name))
        result.update({related_name: RemoteForeignKeyResult(
            model=rel_field.related_model, field=rel_field.field,
            prefetch_cache_key=prefetch_cache_key,
            annotation_key=rel_field.related_name or rel_field.name)})
    return result


@lru_cache(maxsize=None)
def get_reverse_m2m_relations(
        model: Model) -> Dict[str, RemoteManyToManyResult]:
    """
    Return a `dict` of `str-RemoteManyToManyResult` relations.
    That is, many-to-many relations where the passed model has
    `ManyToManyField`s pointing to it from remote models.
    """
    result = dict()
    m2m_fields = [
        x for x in model._meta.get_fields()
        if x.is_relation and x.many_to_many]
    for field in m2m_fields:
        rel_field, m2m_field = (field, field.remote_field) \
            if type(field) is ManyToManyRel else \
            (field.remote_field, field)  # type: ManyToManyRel, ManyToManyField
        related_name = m2m_field.name
        prefetch_cache_key = m2m_field.name
        if type(field) is ManyToManyRel:
            related_name = rel_field.related_name or f'{rel_field.name}_set'
            prefetch_cache_key = rel_field.related_name or rel_field.name
        fk_mine, fk_other = get_m2mthrough_fk_fields(
            from_model=model, m2m_field=m2m_field)
        result.update({related_name: RemoteManyToManyResult(
            m2m_field=m2m_field, prefetch_cache_key=prefetch_cache_key,
            through=rel_field.through,
            through_fk_mine=fk_mine, through_fk_other=fk_other)})
    return result


def get_m2mthrough_fk_fields(
    m2m_field: ManyToManyField, from_model: Model
) -> Tuple[ForeignKey, ForeignKey]:
    """
    Return the two `ForeignKey` sides from a through model of a
    many-to-many relationship.
    """
    from_name, to_name = \
        (m2m_field.m2m_field_name(), m2m_field.m2m_reverse_field_name()) \
        if from_model == m2m_field.model else \
        (m2m_field.m2m_reverse_field_name(), m2m_field.m2m_field_name())
    if VERSION[0] == 1:
        m2m_model = m2m_field.rel.through
    elif VERSION[0] == 2:
        m2m_model = m2m_field.remote_field.through
    fk_from = m2m_model._meta.get_field(from_name)
    fk_to = m2m_model._meta.get_field(to_name)
    return fk_from, fk_to

Attachment: signature.asc
Description: OpenPGP digital signature

Reply via email to