Skip to content

Commit

Permalink
Merge pull request #35 from dabapps/attr-field-transform-value
Browse files Browse the repository at this point in the history
Add transform_value and transform_value_if_none arguments to projectors.attr and pairs.field
  • Loading branch information
j4mie authored Apr 30, 2021
2 parents 2c4940c + 8c4f733 commit 5a51ef5
Show file tree
Hide file tree
Showing 6 changed files with 74 additions and 6 deletions.
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,12 @@ project = projectors.alias(
)
```

The `projectors.attr` function takes an optional argument `transform_value`, which is a function that receives the value of the attribute and returns a new value. This is useful if the value of the attribute needs to be converted in some way during projection.

For example, imagine you have an `IntegerField` but you want the projection to include a stringified version of the integer value. In that case, you can use `projectors.attr("my_integer_field", transform_value=str)`.

By default, the `transform_value` function is only called if the value of the attribute is not `None` (so if the database value of `my_integer_field` is `NULL` then `None` would be returned, rather than the string `"None"`). If you want the `transform_value` function to _always_ be called, use `projectors.attr("my_integer_field", transform_value=str, transform_value_if_none=True)`.

Finally, the `projectors.method` function will call the given method name on the instance, returning the result under a key matching the method name. Any extra arguments passed to `projectors.method` will be passed along to the method.

### `django_readers.pairs`: "reader pairs" combining `prepare` and `project`
Expand All @@ -174,6 +180,8 @@ print(project(author))
# {'name': 'Some Author'}
```

The `pairs.field` function takes the same `transform_value` and `transform_value_if_none` arguments as `projectors.attr` (see above).

Relationships can automatically be loaded and projected, too:

```python
Expand Down
8 changes: 6 additions & 2 deletions django_readers/pairs.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
from django_readers import projectors, qs


def field(name):
return qs.include_fields(name), projectors.attr(name)
def field(name, *, transform_value=None, transform_value_if_none=False):
return qs.include_fields(name), projectors.attr(
name,
transform_value=transform_value,
transform_value_if_none=transform_value_if_none,
)


def combine(*pairs):
Expand Down
10 changes: 8 additions & 2 deletions django_readers/projectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,14 @@ def projector(instance):
return projector


def attr(name):
return wrap(name, attrgetter(name))
def attr(name, *, transform_value=None, transform_value_if_none=False):
def value_getter(instance):
value = 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):
Expand Down
4 changes: 2 additions & 2 deletions tests/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ class Owner(models.Model):


class Widget(models.Model):
name = models.CharField(max_length=100)
other = models.CharField(max_length=100)
name = models.CharField(max_length=100, null=True)
other = models.CharField(max_length=100, null=True)
owner = models.ForeignKey(Owner, null=True, on_delete=models.SET_NULL)


Expand Down
23 changes: 23 additions & 0 deletions tests/test_pairs.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.test import TestCase
from django_readers import pairs, projectors, qs
from tests.models import Category, Group, Owner, Thing, Widget
from tests.test_projectors import title_and_reverse


class PairsTestCase(TestCase):
Expand All @@ -25,6 +26,28 @@ def test_fields(self):
],
)

def test_transform_value(self):
Widget.objects.create(name="test", other="other")
Widget.objects.create(name=None, other=None)

prepare, project = pairs.combine(
pairs.field("name", transform_value=title_and_reverse),
pairs.field(
"other", transform_value=title_and_reverse, transform_value_if_none=True
),
)

queryset = prepare(Widget.objects.all())
result = [project(instance) for instance in queryset]

self.assertEqual(
result,
[
{"name": "tseT", "other": "rehtO"},
{"name": None, "other": "enoN"},
],
)

def test_forward_many_to_one_relationship(self):
group = Group.objects.create(name="test group")
owner = Owner.objects.create(name="test owner", group=group)
Expand Down
27 changes: 27 additions & 0 deletions tests/test_projectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,40 @@
from tests.models import Group, Owner, Widget


def title_and_reverse(arg):
return str(arg).title()[::-1]


class ProjectorTestCase(TestCase):
def test_attr(self):
widget = Widget.objects.create(name="test")
project = projectors.attr("name")
result = project(widget)
self.assertEqual(result, {"name": "test"})

def test_attr_transform_value(self):
widget = Widget(name="test")
project = projectors.attr("name", transform_value=title_and_reverse)
result = project(widget)
self.assertEqual(result, {"name": "tseT"})

def test_attr_transform_value_if_none(self):
widget = Widget(name=None)
project = projectors.attr("name", transform_value=title_and_reverse)
result = project(widget)
self.assertEqual(result, {"name": None})

project = projectors.attr(
"name",
transform_value=lambda value: value.upper(),
transform_value_if_none=True,
)

with self.assertRaisesMessage(
AttributeError, "'NoneType' object has no attribute 'upper'"
):
result = project(widget)

def test_combine(self):
widget = Widget.objects.create(name="test", other="other")
project = projectors.combine(
Expand Down

0 comments on commit 5a51ef5

Please sign in to comment.