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