diff --git a/.gitignore b/.gitignore index b46b09b..22ca0cb 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ inventory/static/uploads/* *.sqlite3 alembic.ini alembic/ -inventory.egg-info/ \ No newline at end of file +inventory.egg-info/ +instance/ \ No newline at end of file diff --git a/crudkit/core/service.py b/crudkit/core/service.py index 95b56b4..e9484da 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -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() diff --git a/inventory/__init__.py b/inventory/__init__.py index c059d3b..6692858 100644 --- a/inventory/__init__.py +++ b/inventory/__init__.py @@ -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 - return app \ No newline at end of file + 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 diff --git a/inventory/templates/update_list.html b/inventory/templates/update_list.html index e0e2848..92f04b8 100644 --- a/inventory/templates/update_list.html +++ b/inventory/templates/update_list.html @@ -25,15 +25,6 @@ {% endfor %} -