Adding new paging support.

This commit is contained in:
Yaro Kasear 2025-10-07 09:52:08 -05:00
parent ef6beb77b4
commit b8b3f2e1b8
4 changed files with 116 additions and 51 deletions

1
.gitignore vendored
View file

@ -10,3 +10,4 @@ inventory/static/uploads/*
alembic.ini
alembic/
inventory.egg-info/
instance/

View file

@ -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()

View file

@ -1,10 +1,14 @@
from __future__ import annotations
import os
from flask import Flask
from jinja_markdown import MarkdownExtension
from pathlib import Path
from sqlalchemy.engine import Engine
from sqlalchemy import event
from sqlalchemy.pool import Pool
from werkzeug.middleware.profiler import ProfilerMiddleware
import crudkit
@ -85,4 +89,21 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
except Exception:
pass
if app.config.get("PROFILE", True):
# Use an absolute dir under the instance path (always writable)
inst_dir = Path(app.instance_path)
inst_dir.mkdir(parents=True, exist_ok=True)
prof_dir = inst_dir / "profiler"
prof_dir.mkdir(parents=True, exist_ok=True)
# If you're using the dev reloader, guard so you don't wrap twice
if not app.debug or os.environ.get("WERKZEUG_RUN_MAIN") == "true":
app.wsgi_app = ProfilerMiddleware(
app.wsgi_app,
sort_by=("cumtime", "tottime"),
restrictions=[50],
profile_dir=str(prof_dir), # absolute path
)
return app

View file

@ -25,15 +25,6 @@
{% endfor %}
</ul>
<div class="mt-3">
<button type="button" class="btn btn-outline-primary btn-sm" onclick="addNewUpdate()">
Add update
</button>
</div>
<input type="hidden" name="updates" id="updatesPayload">
<input type="hidden" name="delete_update_ids" id="deleteUpdatesPayload">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
<style>
textarea.auto-md {
@ -54,16 +45,6 @@
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
<script>
// Track deletions for existing notes
const deletedUpdateIds = new Set();
function addNewUpdate() {
// Create a temporary client-only id so we can manage the DOM before saving
const tempId = `new_${Date.now()}`;
const li = document.createElement('li');
li.className = 'list-group-item';
}
// Initial render
document.addEventListener('DOMContentLoaded', () => {
const ids = [ {% for n in items %} {{ n.id }}{% if not loop.last %}, {% endif %}{% endfor %} ];