diff --git a/inventory/templates/fragments/_combobox_fragment.html b/inventory/templates/fragments/_combobox_fragment.html index 798bcec..eee8ad1 100644 --- a/inventory/templates/fragments/_combobox_fragment.html +++ b/inventory/templates/fragments/_combobox_fragment.html @@ -40,7 +40,7 @@ create_url = none, edit_url = none, delete_url = none, refresh_url = none {% if refresh_url %} - {% set url = refresh_url ~ ('&' if '?' in refresh_url else '?') ~ 'view=option&limit=0' %} + {% set url = refresh_url ~ ('&' if '?' in refresh_url else '?') ~ 'view=option&limit=0&per_page=0' %}
diff --git a/inventory/templates/fragments/_dropdown_fragment.html b/inventory/templates/fragments/_dropdown_fragment.html index e3c3fae..30d3b95 100644 --- a/inventory/templates/fragments/_dropdown_fragment.html +++ b/inventory/templates/fragments/_dropdown_fragment.html @@ -49,7 +49,7 @@ {% if refresh_url %} - {% set url = refresh_url ~ ('&' if '?' in refresh_url else '?') ~ 'view=list&limit=0' %} + {% set url = refresh_url ~ ('&' if '?' in refresh_url else '?') ~ 'view=list&limit=0&per_page=0' %}
{% endif %} diff --git a/inventory/ui/blueprint.py b/inventory/ui/blueprint.py index ad881d9..b8db54b 100644 --- a/inventory/ui/blueprint.py +++ b/inventory/ui/blueprint.py @@ -1,8 +1,11 @@ +from collections import defaultdict + from flask import Blueprint, request, render_template, jsonify, abort, make_response from sqlalchemy.engine import ScalarResult from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import class_mapper, load_only, selectinload from sqlalchemy.sql import Select -from typing import Any, List, cast +from typing import Any, List, cast, Iterable, Tuple, Set, Dict from .defaults import ( default_query, default_create, default_update, default_delete, default_serialize, default_values, default_value, default_select, ensure_order_by, count_for @@ -12,6 +15,75 @@ from .. import db bp = Blueprint("ui", __name__, url_prefix="/ui") +def split_fields(Model, fields: Iterable[str]) -> Tuple[Set[str], Dict[str, Set[str]]]: + """ + Split requested fields into base model columns and relation->attr sets. + Example: ["name", "brand.name", "owner.identifier"] => + base_cols = {"name"} + rel_cols = {"brand": {"name"}, "owner": {"identifier"}} + """ + base_cols: Set[str] = set() + rel_cols: Dict[str, Set[str]] = defaultdict(set) + + for f in fields: + f = f.strip() + if not f: + continue + if "." in f: + rel, attr = f.split(".", 1) + rel_cols[rel].add(attr) + else: + base_cols.add(f) + return base_cols, rel_cols + +def _load_only_existing(Model, names: Set[str]): + """ + Return a list of mapped column attributes present on Model for load_only(...). + Skips relationships and unmapped/hybrid attributes so SQLA doesn’t scream. + """ + cols = [] + mapper = class_mapper(Model) + mapped_attr_names = set(mapper.attrs.keys()) + for n in names: + if n in mapped_attr_names: + attr = getattr(Model, n) + prop = getattr(attr, "property", None) + if prop is not None and hasattr(prop, "columns"): + cols.append(attr) + return cols + +def apply_field_loaders(stmt: Select, Model, fields: Iterable[str]) -> Select: + """ + Given a base Select[Model] and requested fields, attach loader options: + - load_only(...) for base scalar columns + - selectinload(Model.rel).options(load_only(...)) for each requested relation + Only single-depth "rel.attr" is supported, which is exactly what you’re using. + """ + base_cols, rel_cols = split_fields(Model, fields) + + # Restrict base columns if any were explicitly requested + base_only = _load_only_existing(Model, base_cols) + if base_only: + stmt = stmt.options(load_only(*base_only)) + + # Relations: selectinload each requested relation and trim its columns + for rel_name, attrs in rel_cols.items(): + if not hasattr(Model, rel_name): + continue + rel_attr = getattr(Model, rel_name) + try: + target_cls = rel_attr.property.mapper.class_ + except Exception: + continue # not a relationship; ignore + + opt = selectinload(rel_attr) + rel_only = _load_only_existing(target_cls, attrs) + if rel_only: + opt = opt.options(load_only(*rel_only)) + stmt = stmt.options(opt) + + return stmt + def _normalize(s: str) -> str: return s.replace("_", "").replace("-", "").lower() @@ -144,6 +216,18 @@ def list_items(model_name): total = count_for(db.session, stmt) else: stmt = default_select(Model, text=text, sort=sort, direction=direction) + if fields: + stmt = apply_field_loaders(stmt, Model, fields) # the helper I gave you earlier + + stmt = ensure_order_by(stmt, Model, sort=sort, direction=direction) + + if unlimited: + rows = list(db.session.execute(stmt).scalars()) + total = count_for(db.session, stmt) # uses session, not stmt.bind + else: + pagination = db.paginate(stmt, page=page, per_page=per_page, error_out=False) + rows = pagination.items + total = pagination.total stmt = ensure_order_by(stmt, Model, sort=sort, direction=direction) pagination = db.paginate( stmt, diff --git a/inventory/ui/defaults.py b/inventory/ui/defaults.py index 6d4b4be..fa250df 100644 --- a/inventory/ui/defaults.py +++ b/inventory/ui/defaults.py @@ -37,9 +37,11 @@ def infer_label_attr(Model): return a raise RuntimeError(f"No label-like mapped column on {Model.__name__} (tried {PREFERRED_LABELS})") -def count_for(stmt: Select) -> int: +def count_for(session, stmt: Select) -> int: + # strip ORDER BY for efficiency subq = stmt.order_by(None).subquery() - return stmt.bind.execute(select(func.count()).select_from(subq)).scalar_one() + count_stmt = select(func.count()).select_from(subq) + return session.execute(count_stmt).scalar_one() def ensure_order_by(stmt, Model, sort=None, direction="asc"): try: @@ -73,14 +75,22 @@ def default_select( *, text: Optional[str] = None, sort: Optional[str] = None, - direction: str = "asc" + direction: str = "asc", ) -> Select[Any]: stmt: Select[Any] = select(Model) + # search ui_search = getattr(Model, "ui_search", None) if callable(ui_search) and text: stmt = cast(Select[Any], ui_search(stmt, text)) + elif text: + # optional generic search fallback if you used this in default_query + t = f"%{text}%" + text_cols = _columns_for_text_search(Model) # your existing helper + if text_cols: + stmt = stmt.where(or_(*(col.ilike(t) for col in text_cols))) + # sorting if sort: ui_sort = getattr(Model, "ui_sort", None) if callable(ui_sort): @@ -89,7 +99,6 @@ def default_select( col = getattr(Model, sort, None) if col is not None: stmt = stmt.order_by(sa_desc(col) if direction == "desc" else sa_asc(col)) - else: ui_order_cols = getattr(Model, "ui_order_cols", ()) if ui_order_cols: @@ -101,6 +110,15 @@ def default_select( if order_cols: stmt = stmt.order_by(*order_cols) + # eagerload defaults + opts_attr = getattr(Model, "ui_eagerload", ()) + if callable(opts_attr): + opts = cast(Iterable[Any], opts_attr()) # if you prefer, pass Model in + else: + opts = cast(Iterable[Any], opts_attr) + for opt in opts: + stmt = stmt.options(opt) + return stmt def default_query(