Adding new paging support.
This commit is contained in:
parent
ef6beb77b4
commit
b8b3f2e1b8
4 changed files with 116 additions and 51 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -10,3 +10,4 @@ inventory/static/uploads/*
|
|||
alembic.ini
|
||||
alembic/
|
||||
inventory.egg-info/
|
||||
instance/
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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 %} ];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue