Skip to content

Commit 9737dee

Browse files
fix(Top): allow order_by=None to inherit existing ordering (#1242)
When chaining dj.Top restrictions, the second Top can now inherit the ordering from the first by using order_by=None: (Table & dj.Top(100, "score DESC")) & dj.Top(10, order_by=None) # Returns top 10 of top 100, ordered by score descending This fixes an issue where preview (to_arrays with limit) would override user-specified ordering with the default KEY ordering. Changes: - Top class now accepts order_by=None to mean "inherit" - Added Top.merge() method for combining Tops with compatible ordering - restrict() now merges Tops when order_by=None or identical - Creates subquery only when orderings differ Merge behavior: - limit = min(existing_limit, new_limit) - offset = existing_offset + new_offset - order_by = preserved from existing Top Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 9f28b10 commit 9737dee

File tree

4 files changed

+207
-15
lines changed

4 files changed

+207
-15
lines changed

src/datajoint/condition.py

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -107,32 +107,73 @@ class Top:
107107
----------
108108
limit : int, optional
109109
Maximum number of rows to return. Default 1.
110-
order_by : str or list[str], optional
111-
Attributes to order by. ``"KEY"`` for primary key. Default ``"KEY"``.
110+
order_by : str or list[str] or None, optional
111+
Attributes to order by. ``"KEY"`` for primary key order.
112+
``None`` means inherit ordering from an existing Top (or default to KEY).
113+
Default ``"KEY"``.
112114
offset : int, optional
113115
Number of rows to skip. Default 0.
116+
117+
Examples
118+
--------
119+
>>> query & dj.Top(5) # Top 5 by primary key
120+
>>> query & dj.Top(10, 'score DESC') # Top 10 by score descending
121+
>>> query & dj.Top(10, order_by=None) # Top 10, inherit existing order
122+
>>> query & dj.Top(5, offset=10) # Skip 10, take 5
114123
"""
115124

116125
limit: int | None = 1
117-
order_by: str | list[str] = "KEY"
126+
order_by: str | list[str] | None = "KEY"
118127
offset: int = 0
119128

120129
def __post_init__(self) -> None:
121-
self.order_by = self.order_by or ["KEY"]
122130
self.offset = self.offset or 0
123131

124132
if self.limit is not None and not isinstance(self.limit, int):
125133
raise TypeError("Top limit must be an integer")
126-
if not isinstance(self.order_by, (str, collections.abc.Sequence)) or not all(
127-
isinstance(r, str) for r in self.order_by
128-
):
129-
raise TypeError("Top order_by attributes must all be strings")
134+
if self.order_by is not None:
135+
if not isinstance(self.order_by, (str, collections.abc.Sequence)) or not all(
136+
isinstance(r, str) for r in self.order_by
137+
):
138+
raise TypeError("Top order_by attributes must all be strings")
139+
if isinstance(self.order_by, str):
140+
self.order_by = [self.order_by]
130141
if not isinstance(self.offset, int):
131142
raise TypeError("The offset argument must be an integer")
132143
if self.offset and self.limit is None:
133144
self.limit = 999999999999 # arbitrary large number to allow query
134-
if isinstance(self.order_by, str):
135-
self.order_by = [self.order_by]
145+
146+
def merge(self, other: "Top") -> "Top":
147+
"""
148+
Merge another Top into this one (when other inherits ordering).
149+
150+
Used when ``other.order_by`` is None or matches ``self.order_by``.
151+
152+
Parameters
153+
----------
154+
other : Top
155+
The Top to merge. Its order_by should be None or equal to self.order_by.
156+
157+
Returns
158+
-------
159+
Top
160+
New Top with merged limit/offset and preserved ordering.
161+
"""
162+
# Compute effective limit (minimum of defined limits)
163+
if self.limit is None and other.limit is None:
164+
new_limit = None
165+
elif self.limit is None:
166+
new_limit = other.limit
167+
elif other.limit is None:
168+
new_limit = self.limit
169+
else:
170+
new_limit = min(self.limit, other.limit)
171+
172+
return Top(
173+
limit=new_limit,
174+
order_by=self.order_by, # preserve existing ordering
175+
offset=self.offset + other.offset, # offsets add
176+
)
136177

137178

138179
class Not:

src/datajoint/expression.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,9 @@ def where_clause(self):
132132
def sorting_clauses(self):
133133
if not self._top:
134134
return ""
135-
clause = ", ".join(_wrap_attributes(_flatten_attribute_list(self.primary_key, self._top.order_by)))
135+
# Default to KEY ordering if order_by is None (inherit with no existing order)
136+
order_by = self._top.order_by if self._top.order_by is not None else ["KEY"]
137+
clause = ", ".join(_wrap_attributes(_flatten_attribute_list(self.primary_key, order_by)))
136138
if clause:
137139
clause = f" ORDER BY {clause}"
138140
if self._top.limit is not None:
@@ -216,10 +218,18 @@ def restrict(self, restriction, semantic_check=True):
216218
"""
217219
attributes = set()
218220
if isinstance(restriction, Top):
219-
result = (
220-
self.make_subquery() if self._top and not self._top.__eq__(restriction) else copy.copy(self)
221-
) # make subquery to avoid overwriting existing Top
222-
result._top = restriction
221+
if self._top is None:
222+
# No existing Top — apply new one directly
223+
result = copy.copy(self)
224+
result._top = restriction
225+
elif restriction.order_by is None or restriction.order_by == self._top.order_by:
226+
# Merge: new Top inherits or matches existing ordering
227+
result = copy.copy(self)
228+
result._top = self._top.merge(restriction)
229+
else:
230+
# Different ordering — need subquery
231+
result = self.make_subquery()
232+
result._top = restriction
223233
return result
224234
new_condition = make_condition(self, restriction, attributes, semantic_check=semantic_check)
225235
if new_condition is True:

tests/integration/test_relational_operand.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,3 +627,49 @@ def test_top_errors(self, schema_simp_pop):
627627
assert "TypeError: Top limit must be an integer" == str(err3.exconly())
628628
assert "TypeError: Top order_by attributes must all be strings" == str(err4.exconly())
629629
assert "TypeError: The offset argument must be an integer" == str(err5.exconly())
630+
631+
def test_top_inherit_order(self, schema_simp_pop):
632+
"""Test that dj.Top(order_by=None) inherits existing ordering."""
633+
# First Top sets descending order, second Top inherits it
634+
query = L() & dj.Top(10, "id_l desc") & dj.Top(3, order_by=None)
635+
result = query.to_dicts()
636+
assert len(result) == 3
637+
# Should be top 3 by descending id_l (L has ids 0-29)
638+
assert result[0]["id_l"] > result[1]["id_l"] > result[2]["id_l"]
639+
assert [r["id_l"] for r in result] == [29, 28, 27]
640+
641+
def test_top_merge_identical_order(self, schema_simp_pop):
642+
"""Test that Tops with identical order_by are merged."""
643+
# Both Tops specify same ordering - should merge
644+
query = L() & dj.Top(10, "id_l desc") & dj.Top(5, "id_l desc")
645+
result = query.to_dicts()
646+
# Merged limit is min(10, 5) = 5
647+
assert len(result) == 5
648+
assert [r["id_l"] for r in result] == [29, 28, 27, 26, 25]
649+
650+
def test_top_merge_offsets_add(self, schema_simp_pop):
651+
"""Test that offsets are added when merging Tops."""
652+
# First Top: offset 2, second Top: offset 3, inherited order
653+
query = L() & dj.Top(10, "id_l desc", offset=2) & dj.Top(3, order_by=None, offset=3)
654+
result = query.to_dicts()
655+
# Total offset = 2 + 3 = 5, so starts at 6th element (id_l=24)
656+
assert len(result) == 3
657+
assert [r["id_l"] for r in result] == [24, 23, 22]
658+
659+
def test_preview_respects_order(self, schema_simp_pop):
660+
"""Test that preview (to_arrays with limit) respects Top ordering (issue #1242)."""
661+
# Apply descending order with no limit (None = unlimited)
662+
query = L() & dj.Top(None, order_by="id_l desc")
663+
# Preview should respect the ordering (single attr returns array directly)
664+
id_l = query.to_arrays("id_l", limit=5)
665+
assert list(id_l) == [29, 28, 27, 26, 25]
666+
667+
def test_top_different_order_subquery(self, schema_simp_pop):
668+
"""Test that different orderings create subquery."""
669+
# First Top: descending, second Top: ascending - cannot merge
670+
query = L() & dj.Top(10, "id_l desc") & dj.Top(3, "id_l asc")
671+
result = query.to_dicts()
672+
# Second Top reorders the result of first Top
673+
# First Top gives ids 29-20, second Top takes lowest 3 of those
674+
assert len(result) == 3
675+
assert [r["id_l"] for r in result] == [20, 21, 22]

tests/unit/test_condition.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Unit tests for condition.py - Top class and merge logic."""
2+
3+
import pytest
4+
from datajoint.condition import Top
5+
6+
7+
class TestTopMerge:
8+
"""Tests for Top.merge() method."""
9+
10+
def test_merge_inherits_order(self):
11+
"""When other.order_by is None, ordering is inherited."""
12+
top1 = Top(limit=10, order_by="score desc")
13+
top2 = Top(limit=5, order_by=None)
14+
merged = top1.merge(top2)
15+
assert merged.order_by == ["score desc"]
16+
assert merged.limit == 5
17+
assert merged.offset == 0
18+
19+
def test_merge_limits_take_min(self):
20+
"""Merged limit is minimum of both."""
21+
top1 = Top(limit=10, order_by="id")
22+
top2 = Top(limit=3, order_by=None)
23+
merged = top1.merge(top2)
24+
assert merged.limit == 3
25+
26+
# Reverse order
27+
top1 = Top(limit=3, order_by="id")
28+
top2 = Top(limit=10, order_by=None)
29+
merged = top1.merge(top2)
30+
assert merged.limit == 3
31+
32+
def test_merge_none_limit_preserved(self):
33+
"""None limit (unlimited) is handled correctly."""
34+
top1 = Top(limit=None, order_by="id")
35+
top2 = Top(limit=5, order_by=None)
36+
merged = top1.merge(top2)
37+
assert merged.limit == 5
38+
39+
top1 = Top(limit=5, order_by="id")
40+
top2 = Top(limit=None, order_by=None)
41+
merged = top1.merge(top2)
42+
assert merged.limit == 5
43+
44+
top1 = Top(limit=None, order_by="id")
45+
top2 = Top(limit=None, order_by=None)
46+
merged = top1.merge(top2)
47+
assert merged.limit is None
48+
49+
def test_merge_offsets_add(self):
50+
"""Offsets are added together."""
51+
top1 = Top(limit=10, order_by="id", offset=5)
52+
top2 = Top(limit=3, order_by=None, offset=2)
53+
merged = top1.merge(top2)
54+
assert merged.offset == 7
55+
56+
def test_merge_preserves_existing_order(self):
57+
"""Merged Top preserves first Top's ordering."""
58+
top1 = Top(limit=10, order_by=["col1 desc", "col2 asc"])
59+
top2 = Top(limit=5, order_by=None)
60+
merged = top1.merge(top2)
61+
assert merged.order_by == ["col1 desc", "col2 asc"]
62+
63+
64+
class TestTopValidation:
65+
"""Tests for Top validation."""
66+
67+
def test_order_by_none_allowed(self):
68+
"""order_by=None is valid (means inherit)."""
69+
top = Top(limit=5, order_by=None)
70+
assert top.order_by is None
71+
72+
def test_order_by_string_converted_to_list(self):
73+
"""Single string order_by is converted to list."""
74+
top = Top(order_by="id desc")
75+
assert top.order_by == ["id desc"]
76+
77+
def test_order_by_list_preserved(self):
78+
"""List order_by is preserved."""
79+
top = Top(order_by=["col1", "col2 desc"])
80+
assert top.order_by == ["col1", "col2 desc"]
81+
82+
def test_invalid_limit_type_raises(self):
83+
"""Non-integer limit raises TypeError."""
84+
with pytest.raises(TypeError):
85+
Top(limit="5")
86+
87+
def test_invalid_order_by_type_raises(self):
88+
"""Non-string order_by raises TypeError."""
89+
with pytest.raises(TypeError):
90+
Top(order_by=123)
91+
92+
def test_invalid_offset_type_raises(self):
93+
"""Non-integer offset raises TypeError."""
94+
with pytest.raises(TypeError):
95+
Top(offset="1")

0 commit comments

Comments
 (0)