Adding new paging support.
This commit is contained in:
parent
ef6beb77b4
commit
b8b3f2e1b8
4 changed files with 116 additions and 51 deletions
|
|
@ -46,34 +46,38 @@ T = TypeVar("T", bound=_CRUDModelProto)
|
|||
# ---------------------------- utilities ----------------------------
|
||||
|
||||
def _collect_tables_from_filters(filters) -> set:
|
||||
"""Walk SQLA expressions to collect Table/Alias objects that appear in filters."""
|
||||
seen = set()
|
||||
stack = list(filters or [])
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
|
||||
def visit(node):
|
||||
if node is None:
|
||||
return
|
||||
tbl = getattr(node, "table", None)
|
||||
if tbl is not None:
|
||||
cur = tbl
|
||||
while cur is not None:
|
||||
while cur is not None and cur not in seen:
|
||||
seen.add(cur)
|
||||
cur = getattr(cur, "element", None)
|
||||
for attr in ("get_children",):
|
||||
fn = getattr(node, attr, None)
|
||||
if fn:
|
||||
for ch in fn():
|
||||
visit(ch)
|
||||
for attr in ("left", "right", "element", "clause", "clauses"):
|
||||
val = getattr(node, attr, None)
|
||||
if val is None:
|
||||
continue
|
||||
if isinstance(val, (list, tuple)):
|
||||
for v in val: visit(v)
|
||||
else:
|
||||
visit(val)
|
||||
|
||||
for f in (filters or []):
|
||||
visit(f)
|
||||
# follow only the common attributes; no generic visitor
|
||||
left = getattr(node, "left", None)
|
||||
if left is not None:
|
||||
stack.append(left)
|
||||
right = getattr(node, "right", None)
|
||||
if right is not None:
|
||||
stack.append(right)
|
||||
elem = getattr(node, "element", None)
|
||||
if elem is not None:
|
||||
stack.append(elem)
|
||||
clause = getattr(node, "clause", None)
|
||||
if clause is not None:
|
||||
stack.append(clause)
|
||||
clauses = getattr(node, "clauses", None)
|
||||
if clauses is not None:
|
||||
try:
|
||||
stack.extend(list(clauses))
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
return seen
|
||||
|
||||
def _selectable_keys(sel) -> set[str]:
|
||||
|
|
@ -182,6 +186,42 @@ class CRUDService(Generic[T]):
|
|||
|
||||
# ---- common building blocks
|
||||
|
||||
def _order_clauses(self, order_spec, invert: bool = False):
|
||||
clauses = []
|
||||
for c, is_desc in zip(order_spec.cols, order_spec.desc):
|
||||
d = not is_desc if invert else is_desc
|
||||
clauses.append(c.desc() if d else c.asc())
|
||||
return clauses
|
||||
|
||||
def _anchor_key_for_page(self, params, per_page: int, page: int):
|
||||
"""Return the keyset tuple for the last row of the previous page, or None for page 1."""
|
||||
if page <= 1:
|
||||
return None
|
||||
|
||||
query, root_alias = self.get_query()
|
||||
query = self._apply_not_deleted(query, root_alias, params)
|
||||
|
||||
plan = self._plan(params, root_alias)
|
||||
# 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)
|
||||
|
||||
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())
|
||||
|
||||
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)
|
||||
|
||||
def _apply_not_deleted(self, query, root_alias, params):
|
||||
if self.supports_soft_delete and not _is_truthy((params or {}).get("include_deleted")):
|
||||
return query.filter(getattr(root_alias, "is_deleted") == False)
|
||||
|
|
@ -274,13 +314,9 @@ class CRUDService(Generic[T]):
|
|||
join_paths = tuple(spec.get_join_paths())
|
||||
filter_tables = _collect_tables_from_filters(filters)
|
||||
_, proj_opts = compile_projection(self.model, req_fields) if req_fields else ([], [])
|
||||
# Precompute a string-key set for quick/stable membership tests
|
||||
fkeys: set[str] = set()
|
||||
for t in filter_tables:
|
||||
try:
|
||||
fkeys |= _selectable_keys(t)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
filter_tables = ()
|
||||
fkeys = set()
|
||||
|
||||
return self._Plan(
|
||||
spec=spec, filters=filters, order_by=order_by, limit=limit, offset=offset,
|
||||
|
|
@ -349,6 +385,28 @@ class CRUDService(Generic[T]):
|
|||
|
||||
# ---- public read ops
|
||||
|
||||
def page(self, params=None, *, page: int = 1, per_page: int = 50, include_total: bool = True):
|
||||
# Ensure seek_window uses `per_page`
|
||||
params = dict(params or {})
|
||||
params["limit"] = per_page
|
||||
|
||||
anchor_key = self._anchor_key_for_page(params, per_page, page)
|
||||
win = self.seek_window(params, key=anchor_key, backward=False, include_total=include_total)
|
||||
|
||||
pages = None
|
||||
if include_total and win.total is not None and per_page:
|
||||
# class ceil(total / per_page) // per_page
|
||||
pages = (win.total + per_page - 1) // per_page
|
||||
|
||||
return {
|
||||
"items": win.items,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": win.total,
|
||||
"pages": pages,
|
||||
"order": [str(c) for c in win.order.cols],
|
||||
}
|
||||
|
||||
def seek_window(
|
||||
self,
|
||||
params: dict | None = None,
|
||||
|
|
@ -466,7 +524,6 @@ class CRUDService(Generic[T]):
|
|||
|
||||
def list(self, params=None) -> list[T]:
|
||||
query, root_alias = self.get_query()
|
||||
|
||||
plan = self._plan(params, root_alias)
|
||||
query = self._apply_not_deleted(query, root_alias, params)
|
||||
query = self._apply_projection_load_only(query, root_alias, plan)
|
||||
|
|
@ -482,8 +539,13 @@ class CRUDService(Generic[T]):
|
|||
if order_by:
|
||||
query = query.order_by(*order_by)
|
||||
|
||||
if plan.offset: query = query.offset(plan.offset)
|
||||
if plan.limit and plan.limit > 0: query = query.limit(plan.limit)
|
||||
default_cap = getattr(current_app.config, "CRUDKIT_DEFAULT_LIST_LIMIT", 200)
|
||||
if plan.offset:
|
||||
query = query.offset(plan.offset)
|
||||
if plan.limit and plan.limit > 0:
|
||||
query = query.limit(plan.limit)
|
||||
elif plan.limit is None and default_cap:
|
||||
query = query.limit(default_cap)
|
||||
|
||||
query = self._apply_proj_opts(query, plan)
|
||||
rows = query.all()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue