Skip to content

Commit 129352d

Browse files
add support for slicing as a translation into limit and offset with ordering switch (#765)
Co-authored-by: collerek <collerek@gmail.com>
1 parent a9b9074 commit 129352d

10 files changed

Lines changed: 972 additions & 25 deletions

File tree

docs/queries/pagination-and-rows-number.md

Lines changed: 95 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,22 @@ Following methods allow you to paginate and limit number of rows in queries.
55
* `paginate(page: int) -> QuerySet`
66
* `limit(limit_count: int) -> QuerySet`
77
* `offset(offset: int) -> QuerySet`
8+
* `__getitem__(key: int | slice) -> QuerySet`
89
* `get() -> Model`
910
* `first() -> Model`
11+
* `first_or_none() -> Optional[Model]`
12+
* `last() -> Model`
13+
* `last_or_none() -> Optional[Model]`
1014

1115

1216
* `QuerysetProxy`
1317
* `QuerysetProxy.paginate(page: int)` method
1418
* `QuerysetProxy.limit(limit_count: int)` method
1519
* `QuerysetProxy.offset(offset: int)` method
20+
* `QuerysetProxy.__getitem__(key: int | slice)` method
21+
* `QuerysetProxy.first_or_none()` method
22+
* `QuerysetProxy.last()` method
23+
* `QuerysetProxy.last_or_none()` method
1624

1725
## paginate
1826

@@ -113,6 +121,41 @@ tracks = await Track.objects.offset(1).limit(1).all()
113121
Something like `Track.object.select_related("album").filter(album__name="Malibu").offset(1).limit(1).all()`
114122

115123

124+
## slicing with `__getitem__`
125+
126+
A `QuerySet` can also be sliced with Python integer or slice syntax. Each
127+
call returns a new `QuerySet` with LIMIT/OFFSET set accordingly — you still
128+
need to `await` it with `.all()` to actually run the query.
129+
130+
```python
131+
# positive bounds map directly to LIMIT/OFFSET
132+
first_ten = await Track.objects[:10].all()
133+
page_two = await Track.objects[10:20].all()
134+
single = await Track.objects[5].all() # returns a one-element list
135+
```
136+
137+
Negative indices and negative slice bounds are supported too. Internally
138+
they are translated into a reversed-order query plus an in-memory list
139+
reversal, so the caller still sees results in the original ordering:
140+
141+
```python
142+
last_five = await Track.objects[-5:].all()
143+
last_one = await Track.objects[-1].all() # one-element list
144+
tail_slice = await Track.objects[-10:-5].all()
145+
```
146+
147+
!!!note
148+
Slice shapes that would require a `COUNT(*)` round-trip to resolve — a
149+
bare `[:-N]`, or mixed positive/negative bounds like `[3:-2]` — raise
150+
`QueryDefinitionError`. If you need "all except the last N", fetch
151+
`.count()` explicitly and combine it with `.offset()`/`.limit()`.
152+
153+
A `step` other than `1` is not supported, and a non-integer / non-slice
154+
key (e.g. `objects["foo"]`) also raises `QueryDefinitionError`.
155+
156+
Each slice replaces any previous pagination state rather than composing
157+
with it — avoid chaining multiple slices on the same queryset.
158+
116159

117160
## get
118161

@@ -129,14 +172,53 @@ If no criteria is set it will return the last row in db sorted by pk.
129172

130173
## first
131174

132-
`first() -> Model`
175+
`first() -> Model`
133176

134177
Gets the first row from the db ordered by primary key column ascending.
135178

136179
!!!tip
137180
To read more about `first` visit [read/first](./read/#first)
138181

139182

183+
## first_or_none
184+
185+
`first_or_none() -> Optional[Model]`
186+
187+
Same as `first()` but returns `None` instead of raising `NoMatch` when no
188+
row matches.
189+
190+
!!!tip
191+
To read more about `first_or_none` visit [read/first_or_none](./read/#first_or_none)
192+
193+
194+
## last
195+
196+
`last() -> Model`
197+
198+
Gets the last row from the db ordered by primary key column descending.
199+
Complementary to `first()` — the default pk ordering is flipped and the top
200+
row is returned.
201+
202+
```python
203+
newest = await Track.objects.last()
204+
newest_by_name = await Track.objects.order_by("name").last()
205+
```
206+
207+
!!!tip
208+
To read more about `last` visit [read/last](./read/#last)
209+
210+
211+
## last_or_none
212+
213+
`last_or_none() -> Optional[Model]`
214+
215+
Same as `last()` but returns `None` instead of raising `NoMatch` when no
216+
row matches.
217+
218+
!!!tip
219+
To read more about `last_or_none` visit [read/last_or_none](./read/#last_or_none)
220+
221+
140222
## QuerysetProxy methods
141223

142224
When access directly the related `ManyToMany` field as well as `ReverseForeignKey`
@@ -169,4 +251,16 @@ objects from other side of the relation.
169251
!!!tip
170252
To read more about `QuerysetProxy` visit [querysetproxy][querysetproxy] section
171253

254+
### slicing with `__getitem__`
255+
256+
Works exactly the same as [slicing](./#slicing-with-__getitem__) above but on the relation
257+
side:
258+
259+
```python
260+
recent_cars = await user.cars[-3:].all()
261+
```
262+
263+
!!!tip
264+
To read more about `QuerysetProxy` visit [querysetproxy][querysetproxy] section
265+
172266
[querysetproxy]: ../relations/queryset-proxy.md

docs/queries/read.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,12 @@
33
Following methods allow you to load data from the database.
44

55
* `get(*args, **kwargs) -> Model`
6+
* `get_or_none(*args, **kwargs) -> Optional[Model]`
67
* `get_or_create(_defaults: Optional[dict[str, Any]] = None, *args, **kwargs) -> tuple[Model, bool]`
78
* `first(*args, **kwargs) -> Model`
9+
* `first_or_none(*args, **kwargs) -> Optional[Model]`
10+
* `last(*args, **kwargs) -> Model`
11+
* `last_or_none(*args, **kwargs) -> Optional[Model]`
812
* `all(*args, **kwargs) -> list[Optional[Model]]`
913
* `iterate(*args, **kwargs) -> AsyncGenerator[Model]`
1014

@@ -15,8 +19,12 @@ Following methods allow you to load data from the database.
1519

1620
* `QuerysetProxy`
1721
* `QuerysetProxy.get(*args, **kwargs)` method
22+
* `QuerysetProxy.get_or_none(*args, **kwargs)` method
1823
* `QuerysetProxy.get_or_create(_defaults: Optional[dict[str, Any]] = None, *args, **kwargs)` method
1924
* `QuerysetProxy.first(*args, **kwargs)` method
25+
* `QuerysetProxy.first_or_none(*args, **kwargs)` method
26+
* `QuerysetProxy.last(*args, **kwargs)` method
27+
* `QuerysetProxy.last_or_none(*args, **kwargs)` method
2028
* `QuerysetProxy.all(*args, **kwargs)` method
2129

2230
## get
@@ -126,6 +134,58 @@ album = await Album.objects.first()
126134
assert album.name == 'The Cat'
127135
```
128136

137+
## first_or_none
138+
139+
`first_or_none(*args, **kwargs) -> Optional[Model]`
140+
141+
Exact equivalent of `first` described above but instead of raising `NoMatch`
142+
returns `None` if no db record matching the criteria is found.
143+
144+
```python
145+
empty = await Album.objects.first_or_none()
146+
# None — no rows in the table
147+
missing = await Album.objects.first_or_none(name='The Missing')
148+
# None — no row matches the filter
149+
```
150+
151+
## last
152+
153+
`last(*args, **kwargs) -> Model`
154+
155+
Gets the last row from the db ordered by primary key column descending.
156+
Complementary to `first()` — the default pk ordering is flipped and the top
157+
row is returned. When you combine `last()` with `order_by(...)`, the user's
158+
ordering is flipped too, so `order_by("name").last()` returns the row that
159+
would sort last by name.
160+
161+
```python
162+
await Album.objects.create(name='The Cat')
163+
await Album.objects.create(name='The Dog')
164+
album = await Album.objects.last()
165+
# last row by primary_key column desc
166+
assert album.name == 'The Dog'
167+
168+
album = await Album.objects.order_by("name").last()
169+
# last row by name (alphabetical)
170+
assert album.name == 'The Dog'
171+
```
172+
173+
!!!warning
174+
Same as `first()` — raises `NoMatch` if no rows and `MultipleMatches` if
175+
somehow more than one row matches.
176+
177+
## last_or_none
178+
179+
`last_or_none(*args, **kwargs) -> Optional[Model]`
180+
181+
Exact equivalent of `last` described above but instead of raising `NoMatch`
182+
returns `None` if no db record matching the criteria is found.
183+
184+
```python
185+
empty = await Album.objects.last_or_none()
186+
# None — no rows in the table
187+
```
188+
129189
## all
130190

131191
`all(*args, **kwargs) -> list[Optional["Model"]]`
@@ -252,6 +312,30 @@ related objects from other side of the relation.
252312
!!!tip
253313
To read more about `QuerysetProxy` visit [querysetproxy][querysetproxy] section
254314

315+
### first_or_none
316+
317+
Works exactly the same as [first_or_none](./#first_or_none) function above but
318+
returns `None` instead of raising `NoMatch`, and works on the relation side.
319+
320+
!!!tip
321+
To read more about `QuerysetProxy` visit [querysetproxy][querysetproxy] section
322+
323+
### last
324+
325+
Works exactly the same as [last](./#last) function above but allows you to query
326+
related objects from other side of the relation.
327+
328+
!!!tip
329+
To read more about `QuerysetProxy` visit [querysetproxy][querysetproxy] section
330+
331+
### last_or_none
332+
333+
Works exactly the same as [last_or_none](./#last_or_none) function above but
334+
returns `None` instead of raising `NoMatch`, and works on the relation side.
335+
336+
!!!tip
337+
To read more about `QuerysetProxy` visit [querysetproxy][querysetproxy] section
338+
255339
### all
256340

257341
Works exactly the same as [all](./#all) function above but allows you to query related

ormar/queryset/actions/order_action.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import copy
12
from typing import TYPE_CHECKING, Optional
23

34
import sqlalchemy
@@ -128,6 +129,44 @@ def _split_value_into_parts(self, order_str: str) -> None:
128129
self.field_name = parts[-1]
129130
self.related_parts = parts[:-1]
130131

132+
@classmethod
133+
def from_model_defaults(cls, model_cls: type["Model"]) -> list["OrderAction"]:
134+
"""
135+
Builds the default list of ``OrderAction`` instances from a model's
136+
``OrmarConfig.orders_by`` (which always contains at least the primary
137+
key, populated by the metaclass).
138+
139+
:param model_cls: model class whose defaults should be used
140+
:type model_cls: type["Model"]
141+
:return: list of default OrderAction instances for the model
142+
:rtype: list[OrderAction]
143+
"""
144+
return [
145+
cls(order_str=str(name), model_cls=model_cls)
146+
for name in model_cls.ormar_config.orders_by
147+
]
148+
149+
def flipped(self) -> "OrderAction":
150+
"""
151+
Returns a shallow copy of this order action with the sort direction
152+
and any `NULLS FIRST`/`NULLS LAST` annotation inverted.
153+
154+
Used by reverse slicing to turn an ASC/DESC ordering into its mirror
155+
image so that ``LIMIT N`` fetches rows from the tail of the original
156+
ordering. Callers are responsible for reversing the result list in
157+
memory afterwards so the caller-visible ordering is preserved.
158+
159+
:return: new OrderAction with flipped direction and nulls ordering
160+
:rtype: OrderAction
161+
"""
162+
flipped = copy.copy(self)
163+
flipped.direction = "" if self.direction == "desc" else "desc"
164+
if self.nulls_ordering == "first":
165+
flipped.nulls_ordering = "last"
166+
elif self.nulls_ordering == "last":
167+
flipped.nulls_ordering = "first"
168+
return flipped
169+
131170
def check_if_filter_apply(self, target_model: type["Model"], alias: str) -> bool:
132171
"""
133172
Checks filter conditions to find if they apply to current join.

ormar/queryset/queries/query.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,7 @@ def _apply_default_model_sorting(self) -> None:
7979
Applies orders_by from model OrmarConfig (if provided), if it was not provided
8080
it was filled by metaclass, so it's always there and falls back to pk column
8181
"""
82-
for order_by in self.model_cls.ormar_config.orders_by:
83-
clause = ormar.OrderAction(order_str=order_by, model_cls=self.model_cls)
82+
for clause in ormar.OrderAction.from_model_defaults(self.model_cls):
8483
self.sorted_orders[clause] = clause.get_text_clause()
8584

8685
def _pagination_query_required(self) -> bool:

0 commit comments

Comments
 (0)