Fixed #15648 -- Allowed QuerySet.values_list() to return a namedtuple.

This commit is contained in:
Sergey Fedoseev 2017-08-04 15:28:39 +05:00 committed by Tim Graham
parent a027447f56
commit f3c9562143
4 changed files with 101 additions and 5 deletions

View File

@ -6,8 +6,9 @@ import copy
import operator import operator
import sys import sys
import warnings import warnings
from collections import OrderedDict from collections import OrderedDict, namedtuple
from contextlib import suppress from contextlib import suppress
from functools import lru_cache
from itertools import chain from itertools import chain
from django.conf import settings from django.conf import settings
@ -137,6 +138,34 @@ class ValuesListIterable(BaseIterable):
return compiler.results_iter(tuple_expected=True) return compiler.results_iter(tuple_expected=True)
class NamedValuesListIterable(ValuesListIterable):
"""
Iterable returned by QuerySet.values_list(named=True) that yields a
namedtuple for each row.
"""
@staticmethod
@lru_cache()
def create_namedtuple_class(*names):
# Cache namedtuple() with @lru_cache() since it's too slow to be
# called for every QuerySet evaluation.
return namedtuple('Row', names)
def __iter__(self):
queryset = self.queryset
if queryset._fields:
names = queryset._fields
else:
query = queryset.query
names = list(query.extra_select)
names.extend(query.values_select)
names.extend(query.annotation_select)
tuple_class = self.create_namedtuple_class(*names)
new = tuple.__new__
for row in super().__iter__():
yield new(tuple_class, row)
class FlatValuesListIterable(BaseIterable): class FlatValuesListIterable(BaseIterable):
""" """
Iterable returned by QuerySet.values_list(flat=True) that yields single Iterable returned by QuerySet.values_list(flat=True) that yields single
@ -712,22 +741,35 @@ class QuerySet:
clone._iterable_class = ValuesIterable clone._iterable_class = ValuesIterable
return clone return clone
def values_list(self, *fields, flat=False): def values_list(self, *fields, flat=False, named=False):
if flat and named:
raise TypeError("'flat' and 'named' can't be used together.")
if flat and len(fields) > 1: if flat and len(fields) > 1:
raise TypeError("'flat' is not valid when values_list is called with more than one field.") raise TypeError("'flat' is not valid when values_list is called with more than one field.")
field_names = {f for f in fields if not hasattr(f, 'resolve_expression')}
_fields = [] _fields = []
expressions = {} expressions = {}
counter = 1
for field in fields: for field in fields:
if hasattr(field, 'resolve_expression'): if hasattr(field, 'resolve_expression'):
field_id = str(id(field)) field_id_prefix = getattr(field, 'default_alias', field.__class__.__name__.lower())
while True:
field_id = field_id_prefix + str(counter)
counter += 1
if field_id not in field_names:
break
expressions[field_id] = field expressions[field_id] = field
_fields.append(field_id) _fields.append(field_id)
else: else:
_fields.append(field) _fields.append(field)
clone = self._values(*_fields, **expressions) clone = self._values(*_fields, **expressions)
clone._iterable_class = FlatValuesListIterable if flat else ValuesListIterable clone._iterable_class = (
NamedValuesListIterable if named
else FlatValuesListIterable if flat
else ValuesListIterable
)
return clone return clone
def dates(self, field_name, kind, order='ASC'): def dates(self, field_name, kind, order='ASC'):

View File

@ -626,7 +626,7 @@ You can also refer to fields on related models with reverse relations through
``values_list()`` ``values_list()``
~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~
.. method:: values_list(*fields, flat=False) .. method:: values_list(*fields, flat=False, named=False)
This is similar to ``values()`` except that instead of returning dictionaries, This is similar to ``values()`` except that instead of returning dictionaries,
it returns tuples when iterated over. Each tuple contains the value from the it returns tuples when iterated over. Each tuple contains the value from the
@ -651,6 +651,15 @@ rather than one-tuples. An example should make the difference clearer::
It is an error to pass in ``flat`` when there is more than one field. It is an error to pass in ``flat`` when there is more than one field.
You can pass ``named=True`` to get results as a
:func:`~python:collections.namedtuple`::
>>> Entry.objects.values_list('id', 'headline', named=True)
<QuerySet [Row(id=1, headline='First entry'), ...]>
Using a named tuple may make use of the results more readable, at the expense
of a small performance penalty for transforming the results into a named tuple.
If you don't pass any values to ``values_list()``, it will return all the If you don't pass any values to ``values_list()``, it will return all the
fields in the model, in the order they were declared. fields in the model, in the order they were declared.
@ -688,6 +697,10 @@ not having any author::
Support for expressions in ``*fields`` was added. Support for expressions in ``*fields`` was added.
.. versionchanged:: 2.0
The ``named`` parameter was added.
``dates()`` ``dates()``
~~~~~~~~~~~ ~~~~~~~~~~~

View File

@ -291,6 +291,9 @@ Models
* Added support for expressions in :attr:`Meta.ordering * Added support for expressions in :attr:`Meta.ordering
<django.db.models.Options.ordering>`. <django.db.models.Options.ordering>`.
* The new ``named`` parameter of :meth:`.QuerySet.values_list` allows fetching
results as named tuples.
Pagination Pagination
~~~~~~~~~~ ~~~~~~~~~~

View File

@ -2248,6 +2248,44 @@ class ValuesQuerysetTests(TestCase):
with self.assertRaisesMessage(FieldError, msg): with self.assertRaisesMessage(FieldError, msg):
Tag.objects.values_list('name__foo') Tag.objects.values_list('name__foo')
def test_named_values_list_flat(self):
msg = "'flat' and 'named' can't be used together."
with self.assertRaisesMessage(TypeError, msg):
Number.objects.values_list('num', flat=True, named=True)
def test_named_values_list_bad_field_name(self):
msg = "Type names and field names must be valid identifiers: '1'"
with self.assertRaisesMessage(ValueError, msg):
Number.objects.extra(select={'1': 'num+1'}).values_list('1', named=True).first()
def test_named_values_list_with_fields(self):
qs = Number.objects.extra(select={'num2': 'num+1'}).annotate(Count('id'))
values = qs.values_list('num', 'num2', named=True).first()
self.assertEqual(type(values).__name__, 'Row')
self.assertEqual(values._fields, ('num', 'num2'))
self.assertEqual(values.num, 72)
self.assertEqual(values.num2, 73)
def test_named_values_list_without_fields(self):
qs = Number.objects.extra(select={'num2': 'num+1'}).annotate(Count('id'))
values = qs.values_list(named=True).first()
self.assertEqual(type(values).__name__, 'Row')
self.assertEqual(values._fields, ('num2', 'id', 'num', 'id__count'))
self.assertEqual(values.num, 72)
self.assertEqual(values.num2, 73)
self.assertEqual(values.id__count, 1)
def test_named_values_list_expression_with_default_alias(self):
expr = Count('id')
values = Number.objects.annotate(id__count1=expr).values_list(expr, 'id__count1', named=True).first()
self.assertEqual(values._fields, ('id__count2', 'id__count1'))
def test_named_values_list_expression(self):
expr = F('num') + 1
qs = Number.objects.annotate(combinedexpression1=expr).values_list(expr, 'combinedexpression1', named=True)
values = qs.first()
self.assertEqual(values._fields, ('combinedexpression2', 'combinedexpression1'))
class QuerySetSupportsPythonIdioms(TestCase): class QuerySetSupportsPythonIdioms(TestCase):