Opened 5 years ago

Closed 5 years ago

#30667 closed Bug (duplicate)

Running last() on a related object with a F() ordering mutates model ordering.

Reported by: Mikail Owned by: nobody
Component: Database layer (models, ORM) Version:
Severity: Normal Keywords:
Cc: Triage Stage: Unreviewed
Has patch: no Needs documentation: no
Needs tests: no Patch needs improvement: no
Easy pickings: no UI/UX: no

Description

Hi,

I have ran into a weird issue when working with a O2M relation, where one would use the meta field ordering with F(...).

We can take this following code to illustrate as an example:

from django.db import models
from django.db.models import F


class Item(models.Model):
    pass


class ItemValue(models.Model):
    sort_order = models.IntegerField(unique=True, null=True)
    item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name="items")

    class Meta:
        ordering = (F("sort_order").asc(nulls_last=True), "id")

In Item we have a relation to the items. The most important part here, is the ordering with a F(...).

Now, if another part of the code (a view, a test, ...) runs last() on the items, it will trigger some kind of switch in the ORM, which will make it always run F(...) in DESC.

Here is an example:

item = Item.objects.create()
ItemValue.objects.bulk_create([
    ItemValue(item=self.item, sort_order=0),
    ItemValue(item=self.item),
    ItemValue(item=self.item),
])

sort_order = item.items.all()[0].sort_order  # is 0

item.items.last()

sort_order = item.items.all()[0].sort_order # is None

item.items.last()

sort_order = item.items.all()[0].sort_order # is 0

The biggest issue with this, is if this is ran into a view or a test, the ordering will always be reversed until we restart the django worker, or if we run item.items.last() again to flip the switch.

Here is a full example:

# models
from django.db import models
from django.db.models import F


class Item(models.Model):
    pass


class ItemValue(models.Model):
    sort_order = models.IntegerField(unique=True, null=True)
    item = models.ForeignKey(Item, on_delete=models.CASCADE, related_name="items")

    class Meta:
        ordering = (F("sort_order").asc(nulls_last=True), "id")


# test
class TestItemValue(TestCase):
    def setUp(self) -> None:
        self.item = Item.objects.create()
        ItemValue.objects.bulk_create([
            ItemValue(item=self.item, sort_order=0),
            ItemValue(item=self.item),
            ItemValue(item=self.item),
        ])

    def test_valid(self):
        self.assertEqual(self.item.items.all()[0].sort_order, 0)

    def test_switch(self):
        self.item.items.last()

        with CaptureQueriesContext(connection) as ctx:
            sort_order = self.item.items.all()[0].sort_order
            sql = ctx[0]['sql']
            order_by = sql[sql.find("ORDER BY"):]
            self.assertEqual(sort_order, 0, order_by)

This is an example of the switch in action during a .all():
https://i.imgur.com/lVH1pOs.gif

Change History (1)

comment:1 by Mariusz Felisiak, 5 years ago

Resolution: duplicate
Status: newclosed
Summary: Running last() on a related object with a F() ordering breaks all() ordering (global side effect)Running last() on a related object with a F() ordering mutates model ordering.
Version: 2.2

Thanks for this report, it is a duplicate of #30501 because last() uses QuerySet.reverse(). Patch will appear in Django 3.0 (see f8b8b00f0197e52f7a0986fae51462be174dbaea).

Note: See TracTickets for help on using tickets.
Back to Top