diff --git a/django/db/models/query.py b/django/db/models/query.py index 8d9ee8373e8..a690ba4b72b 100644 --- a/django/db/models/query.py +++ b/django/db/models/query.py @@ -6,8 +6,9 @@ import copy import operator import sys import warnings -from collections import OrderedDict +from collections import OrderedDict, namedtuple from contextlib import suppress +from functools import lru_cache from itertools import chain from django.conf import settings @@ -137,6 +138,34 @@ class ValuesListIterable(BaseIterable): 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): """ Iterable returned by QuerySet.values_list(flat=True) that yields single @@ -712,22 +741,35 @@ class QuerySet: clone._iterable_class = ValuesIterable 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: 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 = [] expressions = {} + counter = 1 for field in fields: 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 _fields.append(field_id) else: _fields.append(field) 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 def dates(self, field_name, kind, order='ASC'): diff --git a/docs/ref/models/querysets.txt b/docs/ref/models/querysets.txt index 5973f2b5a3a..6d66b05957b 100644 --- a/docs/ref/models/querysets.txt +++ b/docs/ref/models/querysets.txt @@ -626,7 +626,7 @@ You can also refer to fields on related models with reverse relations through ``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, 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. +You can pass ``named=True`` to get results as a +:func:`~python:collections.namedtuple`:: + + >>> Entry.objects.values_list('id', 'headline', named=True) + + +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 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. +.. versionchanged:: 2.0 + + The ``named`` parameter was added. + ``dates()`` ~~~~~~~~~~~ diff --git a/docs/releases/2.0.txt b/docs/releases/2.0.txt index c8ec169e3c8..7b2df87fcf0 100644 --- a/docs/releases/2.0.txt +++ b/docs/releases/2.0.txt @@ -291,6 +291,9 @@ Models * Added support for expressions in :attr:`Meta.ordering `. +* The new ``named`` parameter of :meth:`.QuerySet.values_list` allows fetching + results as named tuples. + Pagination ~~~~~~~~~~ diff --git a/tests/queries/tests.py b/tests/queries/tests.py index 1c6dd8bcc57..668b07361ac 100644 --- a/tests/queries/tests.py +++ b/tests/queries/tests.py @@ -2248,6 +2248,44 @@ class ValuesQuerysetTests(TestCase): with self.assertRaisesMessage(FieldError, msg): 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):