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

3
.gitignore vendored
View file

@ -9,4 +9,5 @@ inventory/static/uploads/*
*.sqlite3 *.sqlite3
alembic.ini alembic.ini
alembic/ alembic/
inventory.egg-info/ inventory.egg-info/
instance/

View file

@ -46,34 +46,38 @@ T = TypeVar("T", bound=_CRUDModelProto)
# ---------------------------- utilities ---------------------------- # ---------------------------- utilities ----------------------------
def _collect_tables_from_filters(filters) -> set: def _collect_tables_from_filters(filters) -> set:
"""Walk SQLA expressions to collect Table/Alias objects that appear in filters."""
seen = set() seen = set()
stack = list(filters or [])
while stack:
node = stack.pop()
def visit(node):
if node is None:
return
tbl = getattr(node, "table", None) tbl = getattr(node, "table", None)
if tbl is not None: if tbl is not None:
cur = tbl cur = tbl
while cur is not None: while cur is not None and cur not in seen:
seen.add(cur) seen.add(cur)
cur = getattr(cur, "element", None) 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 []): # follow only the common attributes; no generic visitor
visit(f) 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 return seen
def _selectable_keys(sel) -> set[str]: def _selectable_keys(sel) -> set[str]:
@ -182,6 +186,42 @@ class CRUDService(Generic[T]):
# ---- common building blocks # ---- 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): def _apply_not_deleted(self, query, root_alias, params):
if self.supports_soft_delete and not _is_truthy((params or {}).get("include_deleted")): if self.supports_soft_delete and not _is_truthy((params or {}).get("include_deleted")):
return query.filter(getattr(root_alias, "is_deleted") == False) return query.filter(getattr(root_alias, "is_deleted") == False)
@ -274,13 +314,9 @@ class CRUDService(Generic[T]):
join_paths = tuple(spec.get_join_paths()) join_paths = tuple(spec.get_join_paths())
filter_tables = _collect_tables_from_filters(filters) filter_tables = _collect_tables_from_filters(filters)
_, proj_opts = compile_projection(self.model, req_fields) if req_fields else ([], []) _, 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() filter_tables = ()
for t in filter_tables: fkeys = set()
try:
fkeys |= _selectable_keys(t)
except Exception:
pass
return self._Plan( return self._Plan(
spec=spec, filters=filters, order_by=order_by, limit=limit, offset=offset, spec=spec, filters=filters, order_by=order_by, limit=limit, offset=offset,
@ -349,6 +385,28 @@ class CRUDService(Generic[T]):
# ---- public read ops # ---- 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( def seek_window(
self, self,
params: dict | None = None, params: dict | None = None,
@ -466,7 +524,6 @@ class CRUDService(Generic[T]):
def list(self, params=None) -> list[T]: def list(self, params=None) -> list[T]:
query, root_alias = self.get_query() query, root_alias = self.get_query()
plan = self._plan(params, root_alias) plan = self._plan(params, root_alias)
query = self._apply_not_deleted(query, root_alias, params) query = self._apply_not_deleted(query, root_alias, params)
query = self._apply_projection_load_only(query, root_alias, plan) query = self._apply_projection_load_only(query, root_alias, plan)
@ -482,8 +539,13 @@ class CRUDService(Generic[T]):
if order_by: if order_by:
query = query.order_by(*order_by) query = query.order_by(*order_by)
if plan.offset: query = query.offset(plan.offset) default_cap = getattr(current_app.config, "CRUDKIT_DEFAULT_LIST_LIMIT", 200)
if plan.limit and plan.limit > 0: query = query.limit(plan.limit) 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) query = self._apply_proj_opts(query, plan)
rows = query.all() rows = query.all()

View file

@ -1,10 +1,14 @@
from __future__ import annotations from __future__ import annotations
import os
from flask import Flask from flask import Flask
from jinja_markdown import MarkdownExtension from jinja_markdown import MarkdownExtension
from pathlib import Path
from sqlalchemy.engine import Engine from sqlalchemy.engine import Engine
from sqlalchemy import event from sqlalchemy import event
from sqlalchemy.pool import Pool from sqlalchemy.pool import Pool
from werkzeug.middleware.profiler import ProfilerMiddleware
import crudkit import crudkit
@ -85,4 +89,21 @@ def create_app(config_cls=crudkit.DevConfig) -> Flask:
except Exception: except Exception:
pass pass
return app 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 %} {% endfor %}
</ul> </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"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/github-markdown-css@5/github-markdown.min.css">
<style> <style>
textarea.auto-md { 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/marked/marked.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/dompurify/dist/purify.min.js"></script>
<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 // Initial render
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const ids = [ {% for n in items %} {{ n.id }}{% if not loop.last %}, {% endif %}{% endfor %} ]; const ids = [ {% for n in items %} {{ n.id }}{% if not loop.last %}, {% endif %}{% endfor %} ];