Skip to content

Commit

Permalink
Merge pull request #43 from dabapps/producer-refactor
Browse files Browse the repository at this point in the history
Introduce the concept of "producers"
  • Loading branch information
j4mie authored Jul 16, 2021
2 parents b477244 + f06a67b commit e539f2c
Show file tree
Hide file tree
Showing 9 changed files with 647 additions and 525 deletions.
150 changes: 80 additions & 70 deletions README.md

Large diffs are not rendered by default.

39 changes: 17 additions & 22 deletions django_readers/pairs.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
from django.db.models import Count
from django_readers import projectors, qs
from django_readers import producers, projectors, qs


def producer_to_projector(name, pair):
prepare, produce = pair
return prepare, projectors.producer_to_projector(name, produce)


def field(name, *, transform_value=None, transform_value_if_none=False):
return qs.include_fields(name), projectors.attr(
return qs.include_fields(name), producers.attr(
name,
transform_value=transform_value,
transform_value_if_none=transform_value_if_none,
Expand All @@ -19,11 +24,6 @@ def combine(*pairs):
return qs.pipe(*prepare_fns), projectors.combine(*project_fns)


def alias(alias_or_aliases, pair):
prepare, project = pair
return prepare, projectors.alias(alias_or_aliases, project)


def prepare_only(prepare):
return prepare, projectors.noop

Expand All @@ -35,29 +35,24 @@ def project_only(project):
def field_display(name):
"""
Works with Django's get_FOO_display mechanism for fields with choices set. Given
the name of a field, calls get_<name>_display, and returns a projector that puts
the returned value under the key <name>_display.
the name of a field, returns a producer that calls get_<name>_display.
"""
return qs.include_fields(name), projectors.alias(
f"{name}_display", projectors.method(f"get_{name}_display")
)
return qs.include_fields(name), producers.method(f"get_{name}_display")


def count(name, distinct=True):
attr_name = f"{name}_count"
return (
qs.annotate(**{attr_name: Count(name, distinct=distinct)}),
projectors.attr(attr_name),
producers.attr(attr_name),
)


def has(name, distinct=True):
attr_name = f"{name}_count"
return (
qs.annotate(**{attr_name: Count(name, distinct=distinct)}),
projectors.alias(
f"has_{name}", projectors.attr(attr_name, transform_value=bool)
),
producers.attr(attr_name, transform_value=bool),
)


Expand All @@ -75,7 +70,7 @@ def order_by(*args, **kwargs):

"""
Below are pair functions which return the various queryset functions that prefetch
relationships of various types, and then project those related objects.
relationships of various types, and then produce those related objects.
There are functions for forward, reverse or many-to-many relationships, and then
a `relationship` function which selects the correct one by introspecting the
Expand All @@ -89,7 +84,7 @@ def forward_relationship(name, related_queryset, relationship_pair, to_attr=None
prepare = qs.prefetch_forward_relationship(
name, related_queryset, prepare_related_queryset, to_attr
)
return prepare, projectors.relationship(to_attr or name, project_relationship)
return prepare, producers.relationship(to_attr or name, project_relationship)


def reverse_relationship(
Expand All @@ -99,25 +94,25 @@ def reverse_relationship(
prepare = qs.prefetch_reverse_relationship(
name, related_name, related_queryset, prepare_related_queryset, to_attr
)
return prepare, projectors.relationship(to_attr or name, project_relationship)
return prepare, producers.relationship(to_attr or name, project_relationship)


def many_to_many_relationship(name, related_queryset, relationship_pair, to_attr=None):
prepare_related_queryset, project_relationship = relationship_pair
prepare = qs.prefetch_many_to_many_relationship(
name, related_queryset, prepare_related_queryset, to_attr
)
return prepare, projectors.relationship(to_attr or name, project_relationship)
return prepare, producers.relationship(to_attr or name, project_relationship)


def relationship(name, relationship_pair, to_attr=None):
prepare_related_queryset, project_relationship = relationship_pair
prepare = qs.auto_prefetch_relationship(name, prepare_related_queryset, to_attr)
return prepare, projectors.relationship(to_attr or name, project_relationship)
return prepare, producers.relationship(to_attr or name, project_relationship)


def pk_list(name, to_attr=None):
return (
qs.auto_prefetch_relationship(name, qs.include_fields("pk"), to_attr=to_attr),
projectors.pk_list(to_attr or name),
producers.pk_list(to_attr or name),
)
44 changes: 44 additions & 0 deletions django_readers/producers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
from django.core.exceptions import ObjectDoesNotExist
from django_readers.utils import map_or_apply, none_safe_attrgetter
from operator import attrgetter, methodcaller


def attr(name, *, transform_value=None, transform_value_if_none=False):
def producer(instance):
value = none_safe_attrgetter(name)(instance)
if transform_value and (value is not None or transform_value_if_none):
value = transform_value(value)
return value

return producer


method = methodcaller


def relationship(name, related_projector):
"""
Given an attribute name and a projector, return a producer which plucks
the attribute off the instance, figures out whether it represents a single
object or an iterable/queryset of objects, and applies the given projector
to the related object or objects.
"""

def producer(instance):
try:
related = none_safe_attrgetter(name)(instance)
except ObjectDoesNotExist:
return None
return map_or_apply(related, related_projector)

return producer


def pk_list(name):
"""
Given an attribute name (which should be a relationship field), return a
producer which returns a list of the PK of each item in the relationship (or
just a single PK if this is a to-one field, but this is an inefficient way of
doing it).
"""
return relationship(name, attrgetter("pk"))
87 changes: 6 additions & 81 deletions django_readers/projectors.py
Original file line number Diff line number Diff line change
@@ -1,57 +1,10 @@
from django.core.exceptions import ObjectDoesNotExist
from django_readers.utils import map_or_apply, none_safe_attrgetter
from operator import attrgetter, methodcaller


def wrap(key, value_getter):
def producer_to_projector(key, producer):
def projector(instance):
return {key: value_getter(instance)}
return {key: producer(instance)}

return projector


def attr(name, *, transform_value=None, transform_value_if_none=False):
def value_getter(instance):
value = none_safe_attrgetter(name)(instance)
if transform_value and (value is not None or transform_value_if_none):
value = transform_value(value)
return value

return wrap(name, value_getter)


def method(name, *args, **kwargs):
return wrap(name, methodcaller(name, *args, **kwargs))


def relationship(name, related_projector):
"""
Given an attribute name and a projector, return a projector which plucks
the attribute off the instance, figures out whether it represents a single
object or an iterable/queryset of objects, and applies the given projector
to the related object or objects.
"""

def value_getter(instance):
try:
related = none_safe_attrgetter(name)(instance)
except ObjectDoesNotExist:
return None
return map_or_apply(related, related_projector)

return wrap(name, value_getter)


def pk_list(name):
"""
Given an attribute name (which should be a relationship field), return a
projector which returns a list of the PK of each item in the relationship (or
just a single PK if this is a to-one field, but this is an inefficient way of
doing it).
"""
return relationship(name, attrgetter("pk"))


def combine(*projectors):
"""
Given a list of projectors as *args, return another projector which calls each
Expand All @@ -61,42 +14,14 @@ def combine(*projectors):
def combined(instance):
result = {}
for projector in projectors:
result.update(projector(instance))
projection = projector(instance)
if not isinstance(projection, dict):
raise TypeError(f"Projector {projector} did not return a dictionary")
result.update(projection)
return result

return combined


def alias(alias_or_aliases, projector):
"""
Given a projector and a dictionary of aliases {"old_key_name": "new_key_name"},
return a projector which replaces the keys in the output of the original projector
with those provided in the alias map. As a shortcut, the argument can be a single
string, in which case this will automatically alias a single-key projector without
needing to know the key name of key in the dictionary returned from the
inner projector.
"""

def aliaser(instance):
projected = projector(instance)
if isinstance(alias_or_aliases, str) and len(projected) != 1:
raise TypeError(
"A single string can only be used as an alias for projectors "
"that return a dictionary with a single key. Please use a mapping "
"to define aliases instead."
)

alias_map = (
{next(iter(projected)): alias_or_aliases}
if isinstance(alias_or_aliases, str)
else alias_or_aliases
)
for old, new in alias_map.items():
projected[new] = projected.pop(old)
return projected

return aliaser


def noop(instance):
return {}
31 changes: 23 additions & 8 deletions django_readers/specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

def process_item(item):
if isinstance(item, str):
return pairs.field(item)
item = {item: item}
if isinstance(item, dict):
return pairs.combine(
*[
relationship(name, relationship_spec)
for name, relationship_spec in item.items()
relationship_or_wrap(name, child_spec)
for name, child_spec in item.items()
]
)
return item
Expand All @@ -19,9 +19,24 @@ def process(spec):
return queries_disabled(pairs.combine(*(process_item(item) for item in spec)))


def alias(alias_or_aliases, item):
return pairs.alias(alias_or_aliases, process_item(item))


def relationship(name, relationship_spec, to_attr=None):
return pairs.relationship(name, process(relationship_spec), to_attr)
return pairs.producer_to_projector(
to_attr or name, pairs.relationship(name, process(relationship_spec), to_attr)
)


def relationship_or_wrap(name, child_spec):
if isinstance(child_spec, str):
producer_pair = pairs.field(child_spec)
elif isinstance(child_spec, list):
producer_pair = pairs.relationship(name, process(child_spec))
elif isinstance(child_spec, dict):
if len(child_spec) != 1:
raise ValueError("Aliased relationship spec must contain only one key")
relationship_name, relationship_spec = next(iter(child_spec.items()))
producer_pair = pairs.relationship(
relationship_name, process(relationship_spec)
)
else:
producer_pair = child_spec
return pairs.producer_to_projector(name, producer_pair)
Loading

0 comments on commit e539f2c

Please sign in to comment.