#33768: MySQL ordering of nulls last/first is broken in combination with UNION
-------------------------------------+-------------------------------------
     Reporter:  Florian Apolloner    |                    Owner:  nobody
         Type:  Bug                  |                   Status:  new
    Component:  Database layer       |                  Version:  dev
  (models, ORM)                      |
     Severity:  Normal               |               Resolution:
     Keywords:                       |             Triage Stage:
                                     |  Unreviewed
    Has patch:  0                    |      Needs documentation:  0
  Needs tests:  0                    |  Patch needs improvement:  0
Easy pickings:  0                    |                    UI/UX:  0
-------------------------------------+-------------------------------------
Description changed by Florian Apolloner:

Old description:

> putting the following into test_qs_combinators.py:
> {{{
>     def test_ordering_with_null_first(self):
>         Number.objects.filter(other_num=5).update(other_num=None)
>         qs = (
>             Number.objects.filter(num__lte=1)
>             .union(Number.objects.filter(num__gte=2))
>             .order_by(F("other_num").asc(nulls_first=True))
>             .values_list("other_num", flat=True)
>         )
>         self.assertQuerysetEqual(qs, [None, 1, 2, 3, 4, 6, 7, 8, 9, 10])
>         qs = (
>             Number.objects.filter(num__lte=1)
>             .union(Number.objects.filter(num__gte=2))
>             .order_by(F("other_num").asc(nulls_last=True))
>             .values_list("other_num", flat=True)
>         )
>         self.assertQuerysetEqual(qs, [1, 2, 3, 4, 6, 7, 8, 9, 10, None])
> }}}
>
> results in:
>
> {{{
> ======================================================================
> FAIL: test_ordering_with_null_first
> (queries.test_qs_combinators.QuerySetSetOperationTests)
> ----------------------------------------------------------------------
> Traceback (most recent call last):
>   File
> "/home/florian/sources/django.git/tests/queries/test_qs_combinators.py",
> line 171, in test_ordering_with_null_first
>     self.assertQuerysetEqual(qs, [1, 2, 3, 4, 6, 7, 8, 9, 10, None])
>   File "/home/florian/sources/django.git/django/test/testcases.py", line
> 1328, in assertQuerysetEqual
>     return self.assertEqual(list(items), values, msg=msg)
> AssertionError: Lists differ: [None, 1, 2, 3, 4, 6, 7, 8, 9, 10] != [1,
> 2, 3, 4, 6, 7, 8, 9, 10, None]
>
> First differing element 0:
> None
> 1
>
> - [None, 1, 2, 3, 4, 6, 7, 8, 9, 10]
> + [1, 2, 3, 4, 6, 7, 8, 9, 10, None]
>
> ----------------------------------------------------------------------
> (0.000) UPDATE `queries_number` SET `other_num` = NULL WHERE
> `queries_number`.`other_num` = 5; args=(5,); alias=default
> (0.000) (SELECT `queries_number`.`other_num` FROM `queries_number` WHERE
> `queries_number`.`num` <= 1) UNION (SELECT `queries_number`.`other_num`
> FROM `queries_number` WHERE `queries_number`.`num` >= 2) ORDER BY (1)
> ASC; args=(1, 2); alias=default
> (0.000) (SELECT `queries_number`.`other_num` FROM `queries_number` WHERE
> `queries_number`.`num` <= 1) UNION (SELECT `queries_number`.`other_num`
> FROM `queries_number` WHERE `queries_number`.`num` >= 2) ORDER BY (1) IS
> NULL, (1) ASC; args=(1, 2); alias=default
>
> ----------------------------------------------------------------------
> }}}
>
> Due to the `UNION` we are rewriting queries to use column numbers so we
> can somewhat reliably target the columns. This breaks down when
> `nulls_first/last` is used because `(1) IS NULL` is interpreted as
> expression in mysql and not as the colname 1 being NULL. Which brings me
> back to https://twitter.com/fapolloner/status/1533512493208936450 -- I
> think that the rewriting of thise expression should happen in the
> compiler and not in `OrderBy` itself. This way we could duplicate the
> `OrderBy` into two and push them into the relevant select clauses like we
> already do in
> https://github.com/django/django/blob/49b470b9187b6be60e573fed08c8f4a87f133750/django/db/models/sql/compiler.py#L410
> for other things.
>
> Sadly it is a mess… Thoughts? Paging Adam since he was the resident MySQL
> specialist IIRC :)

New description:

 putting the following into test_qs_combinators.py:
 {{{
     def test_ordering_with_null_first(self):
         Number.objects.filter(other_num=5).update(other_num=None)
         qs = (
             Number.objects.filter(num__lte=1)
             .union(Number.objects.filter(num__gte=2))
             .order_by(F("other_num").asc(nulls_first=True))
             .values_list("other_num", flat=True)
         )
         self.assertQuerysetEqual(qs, [None, 1, 2, 3, 4, 6, 7, 8, 9, 10])
         qs = (
             Number.objects.filter(num__lte=1)
             .union(Number.objects.filter(num__gte=2))
             .order_by(F("other_num").asc(nulls_last=True))
             .values_list("other_num", flat=True)
         )
         self.assertQuerysetEqual(qs, [1, 2, 3, 4, 6, 7, 8, 9, 10, None])
 }}}

 results in:

 {{{
 ======================================================================
 FAIL: test_ordering_with_null_first
 (queries.test_qs_combinators.QuerySetSetOperationTests)
 ----------------------------------------------------------------------
 Traceback (most recent call last):
   File
 "/home/florian/sources/django.git/tests/queries/test_qs_combinators.py",
 line 171, in test_ordering_with_null_first
     self.assertQuerysetEqual(qs, [1, 2, 3, 4, 6, 7, 8, 9, 10, None])
   File "/home/florian/sources/django.git/django/test/testcases.py", line
 1328, in assertQuerysetEqual
     return self.assertEqual(list(items), values, msg=msg)
 AssertionError: Lists differ: [None, 1, 2, 3, 4, 6, 7, 8, 9, 10] != [1, 2,
 3, 4, 6, 7, 8, 9, 10, None]

 First differing element 0:
 None
 1

 - [None, 1, 2, 3, 4, 6, 7, 8, 9, 10]
 + [1, 2, 3, 4, 6, 7, 8, 9, 10, None]

 ----------------------------------------------------------------------
 (0.000) UPDATE `queries_number` SET `other_num` = NULL WHERE
 `queries_number`.`other_num` = 5; args=(5,); alias=default
 (0.000) (SELECT `queries_number`.`other_num` FROM `queries_number` WHERE
 `queries_number`.`num` <= 1) UNION (SELECT `queries_number`.`other_num`
 FROM `queries_number` WHERE `queries_number`.`num` >= 2) ORDER BY (1) ASC;
 args=(1, 2); alias=default
 (0.000) (SELECT `queries_number`.`other_num` FROM `queries_number` WHERE
 `queries_number`.`num` <= 1) UNION (SELECT `queries_number`.`other_num`
 FROM `queries_number` WHERE `queries_number`.`num` >= 2) ORDER BY (1) IS
 NULL, (1) ASC; args=(1, 2); alias=default

 ----------------------------------------------------------------------
 }}}

 Due to the `UNION` we are rewriting queries to use column numbers so we
 can somewhat reliably target the columns. This breaks down when
 `nulls_first/last` is used because `(1) IS NULL` is interpreted as
 expression in mysql and not as the colname 1 being NULL. Which brings me
 back to https://twitter.com/fapolloner/status/1533512493208936450 -- I
 think that the rewriting of this expression should happen in the compiler
 and not in `OrderBy` itself. This way we could duplicate the `OrderBy`
 into two and push them into the relevant select clauses like we already do
 in
 
https://github.com/django/django/blob/49b470b9187b6be60e573fed08c8f4a87f133750/django/db/models/sql/compiler.py#L410
 for other things.

 Sadly it is a mess… Thoughts? Paging Adam since he was the resident MySQL
 specialist IIRC :)

--

-- 
Ticket URL: <https://code.djangoproject.com/ticket/33768#comment:1>
Django <https://code.djangoproject.com/>
The Web framework for perfectionists with deadlines.

-- 
You received this message because you are subscribed to the Google Groups 
"Django updates" group.
To unsubscribe from this group and stop receiving emails from it, send an email 
to [email protected].
To view this discussion on the web visit 
https://groups.google.com/d/msgid/django-updates/0107018135681f3c-7cfdff03-bb80-4994-82e1-b3fa15bebdce-000000%40eu-central-1.amazonses.com.

Reply via email to