Repository: django/django

Issue:
# Fixed #36644 -- Enabled empty order_by() to avoid pk ordering by first()/last().

#### Trac ticket number
<!-- Replace XXXXX with the corresponding Trac ticket number, or delete the line and write "N/A" if this is a trivial PR. -->

ticket-36644

#### Branch description
Enabled calling `order_by()` without arguments to disable implicit primary key ordering in `first()`/`last()`. When a queryset is explicitly marked as unordered via `order_by()`, `first()`/`last()` now respect this and avoid adding PK ordering. Also reset default_ordering in `_combinator_query()` so that `union().first()` can still add pk ordering.

Discussion: https://github.com/django/new-features/issues/71, 


#### Checklist
- [x] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [x] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [x] I have checked the "Has patch" ticket flag in the Trac system.
- [x] I have added or updated relevant tests.
- [ ] I have added or updated relevant docs, including release notes if applicable.
- [ ] I have attached screenshots in both light and dark modes for any UI changes.


Implementation patch:
diff --git a/django/db/models/query.py b/django/db/models/query.py
index 5649a83428f7..76d0f449a67e 100644
--- a/django/db/models/query.py
+++ b/django/db/models/query.py
@@ -1158,7 +1158,7 @@ async def alatest(self, *fields):
 
     def first(self):
         """Return the first object of a query or None if no match is found."""
-        if self.ordered:
+        if self.ordered or not self.query.default_ordering:
             queryset = self
         else:
             self._check_ordering_first_last_queryset_aggregation(method="first")
@@ -1171,7 +1171,7 @@ async def afirst(self):
 
     def last(self):
         """Return the last object of a query or None if no match is found."""
-        if self.ordered:
+        if self.ordered or not self.query.default_ordering:
             queryset = self.reverse()
         else:
             self._check_ordering_first_last_queryset_aggregation(method="last")
@@ -1679,6 +1679,7 @@ def _combinator_query(self, combinator, *other_qs, all=False):
         clone = self._chain()
         # Clear limits and ordering so they can be reapplied
         clone.query.clear_ordering(force=True)
+        clone.query.default_ordering = True
         clone.query.clear_limits()
         clone.query.combined_queries = (self.query, *(qs.query for qs in other_qs))
         clone.query.combinator = combinator


Test patch:
diff --git a/tests/get_earliest_or_latest/models.py b/tests/get_earliest_or_latest/models.py
index bbf2075d368a..91725865b194 100644
--- a/tests/get_earliest_or_latest/models.py
+++ b/tests/get_earliest_or_latest/models.py
@@ -21,6 +21,14 @@ class Comment(models.Model):
     likes_count = models.PositiveIntegerField()
 
 
+class OrderedArticle(models.Model):
+    headline = models.CharField(max_length=100)
+    pub_date = models.DateField()
+
+    class Meta:
+        ordering = ["headline"]
+
+
 # Ticket #23555 - model with an intentionally broken QuerySet.__iter__ method.
 
 
diff --git a/tests/get_earliest_or_latest/tests.py b/tests/get_earliest_or_latest/tests.py
index 49c803b73a0f..793fb12bb9c1 100644
--- a/tests/get_earliest_or_latest/tests.py
+++ b/tests/get_earliest_or_latest/tests.py
@@ -1,9 +1,10 @@
 from datetime import datetime
+from unittest.mock import patch
 
 from django.db.models import Avg
 from django.test import TestCase
 
-from .models import Article, Comment, IndexErrorArticle, Person
+from .models import Article, Comment, IndexErrorArticle, OrderedArticle, Person
 
 
 class EarliestOrLatestTests(TestCase):
@@ -265,3 +266,30 @@ def test_first_last_unordered_qs_aggregation_error(self):
             qs.first()
         with self.assertRaisesMessage(TypeError, msg % "last"):
             qs.last()
+
+    def test_first_last_empty_order_by_has_no_pk_ordering(self):
+        Article.objects.create(
+            headline="Article 1",
+            pub_date=datetime(2006, 9, 10),
+            expire_date=datetime(2056, 9, 11),
+        )
+
+        qs = Article.objects.order_by()
+        with patch.object(type(qs), "order_by") as mock_order_by:
+            qs.first()
+            mock_order_by.assert_not_called()
+            qs.last()
+            mock_order_by.assert_not_called()
+
+    def test_first_last_empty_order_by_clears_default_ordering(self):
+        OrderedArticle.objects.create(
+            headline="Article 1",
+            pub_date=datetime(2006, 9, 10),
+        )
+
+        qs = OrderedArticle.objects.order_by()
+        with patch.object(type(qs), "order_by") as mock_order_by:
+            qs.first()
+            mock_order_by.assert_not_called()
+            qs.last()
+            mock_order_by.assert_not_called()
diff --git a/tests/queries/test_qs_combinators.py b/tests/queries/test_qs_combinators.py
index 2b4cd2bbbdd1..7e1e01bd4be4 100644
--- a/tests/queries/test_qs_combinators.py
+++ b/tests/queries/test_qs_combinators.py
@@ -418,6 +418,15 @@ def test_union_with_first(self):
         qs2 = base_qs.filter(name="a2")
         self.assertEqual(qs1.union(qs2).first(), a1)
 
+    @skipUnlessDBFeature("supports_slicing_ordering_in_compound")
+    def test_union_applies_default_ordering_afterward(self):
+        c = Tag.objects.create(name="C")
+        Tag.objects.create(name="B")
+        a = Tag.objects.create(name="A")
+        qs1 = Tag.objects.filter(name__in=["A", "B"])[:1]
+        qs2 = Tag.objects.filter(name__in=["C"])[:1]
+        self.assertSequenceEqual(qs1.union(qs2), [a, c])
+
     def test_union_multiple_models_with_values_list_and_order(self):
         reserved_name = ReservedName.objects.create(name="rn1", order=0)
         qs1 = Celebrity.objects.all()