From 53cc90a74bbdd7b1a7262e0dd7db0bf819f5cce0 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Tue, 7 Oct 2025 10:28:11 -0500 Subject: [PATCH] Fix paging. --- crudkit/core/service.py | 36 ++++++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/crudkit/core/service.py b/crudkit/core/service.py index e9484da..38b7e31 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -205,22 +205,42 @@ class CRUDService(Generic[T]): # Make sure joins/filters match the real query query = self._apply_firsthop_strategies(query, root_alias, plan) if plan.filters: - query = query.filters(*plan.filters) + query = query.filter(*plan.filters) order_spec = self._extract_order_spec(root_alias, plan.order_by) - query = query.order_by(*self._order_clauses(order_spec, invert=False)) - # We only need the order-by columns for the anchor - anchor_q = self.session.query(*order_spec.cols) - # IMPORTANT: anchor_q must use the same FROMs/joins as `query` - anchor_q = anchor_q.select_from(query.subquery()) + # Inner subquery must be ordered exactly like the real query + inner = query.order_by(*self._order_clauses(order_spec, invert=False)) + + # IMPORTANT: Build subquery that actually exposes the order-by columns + # under predictable names, then select FROM that and reference subq.c[...] + subq = inner.with_entities(*order_spec.cols).subquery() + + # Map the order columns to the subquery columns by key/name + cols_on_subq = [] + for col in order_spec.cols: + key = getattr(col, "key", None) or getattr(col, "name", None) + if not key: + # Fallback, but frankly your order cols should have names + raise ValueError("Order-by column is missing a key/name") + cols_on_subq.append(getattr(subq.c, key)) + + # Now the outer anchor query orders and offsets on the subquery columns + anchor_q = ( + self.session + .query(*cols_on_subq) + .select_from(subq) + .order_by(*[ + (c.desc() if is_desc else c.asc()) + for c, is_desc in zip(cols_on_subq, order_spec.desc) + ]) + ) offset = max(0, (page - 1) * per_page - 1) row = anchor_q.offset(offset).limit(1).first() if not row: return None - # Row might be a tuple-like; turn into list for _key_predicate - return list(row) + return list(row) # tuple-like -> list for _key_predicate def _apply_not_deleted(self, query, root_alias, params): if self.supports_soft_delete and not _is_truthy((params or {}).get("include_deleted")):