diff --git a/crudkit/core/service.py b/crudkit/core/service.py index fbad3a1..d7fabc4 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -415,6 +415,8 @@ class CRUDService(Generic[T]): opt = opt.load_only(*cols) query = query.options(opt) + # inside CRUDService._apply_firsthop_strategies + # ... # NEW: if a first-hop to-one relationship’s target table is present in filter expressions, # make sure we actually JOIN it (outer) so filters don’t create a cartesian product. if plan.filter_tables: @@ -422,14 +424,19 @@ class CRUDService(Generic[T]): for rel in mapper.relationships: if rel.uselist: continue # only first-hop to-one here - target_tbl = getattr(rel.mapper.class_, "__table__", None) + target_cls = rel.mapper.class_ + target_tbl = getattr(target_cls, "__table__", None) if target_tbl is None: continue if target_tbl in plan.filter_tables: if rel.key in joined_rel_keys: continue # already joined via join_paths - query = query.join(getattr(root_alias, rel.key), isouter=True) + + # alias when joining same-entity relationships (User->User supervisor) + ta = aliased(target_cls) if target_cls is self.model else target_cls + query = query.join(getattr(root_alias, rel.key).of_type(ta), isouter=True) joined_rel_keys.add(rel.key) + if log.isEnabledFor(logging.DEBUG): info = [] for base_alias, rel_attr, target_alias in plan.join_paths: diff --git a/crudkit/ui/fragments.py b/crudkit/ui/fragments.py index ab4c3ba..03cc1c0 100644 --- a/crudkit/ui/fragments.py +++ b/crudkit/ui/fragments.py @@ -11,6 +11,8 @@ from sqlalchemy.orm.base import NO_VALUE from sqlalchemy.orm.properties import ColumnProperty, RelationshipProperty from typing import Any, Dict, List, Optional, Tuple +import crudkit + _ALLOWED_ATTRS = { "class", "placeholder", "autocomplete", "inputmode", "pattern", "min", "max", "step", "maxlength", "minlength", @@ -107,6 +109,53 @@ def register_template_globals(app=None): app.add_template_global(fn, name) installed.add(name) +def _fields_for_label_params(label_spec, related_model): + """ + Build a 'fields' list suitable for CRUDService.list() so labels render + without triggering lazy loads. Always includes 'id'. + """ + simple_cols, rel_paths = _extract_label_requirements(label_spec, related_model) + fields = set(["id"]) + for c in simple_cols: + fields.add(c) + for rel_name, col_name in rel_paths: + if col_name == "__all__": + # just ensure relationship object is present; ask for rel.id + fields.add(f"{rel_name}.id") + else: + fields.add(f"{rel_name}.{col_name}") + return list(fields) + +def _fk_options_via_service(related_model, label_spec, *, options_params: dict | None = None): + svc = crudkit.crud.get_service(related_model) + + # default to unlimited results for dropdowns + params = {"limit": 0} + if options_params: + params.update(options_params) # caller can override limit if needed + + # ensure fields needed to render the label are present (avoid lazy loads) + fields = _fields_for_label_params(label_spec, related_model) + if fields: + existing = params.get("fields") + if isinstance(existing, str): + existing = [s.strip() for s in existing.split(",") if s.strip()] + if isinstance(existing, (list, tuple)): + params["fields"] = list(dict.fromkeys(list(existing) + fields)) + else: + params["fields"] = fields + + # only set a default sort if caller didn’t supply one + if "sort" not in params: + simple_cols, _ = _extract_label_requirements(label_spec, related_model) + params["sort"] = (simple_cols[0] if simple_cols else "id") + + rows = svc.list(params) + return [ + {"value": str(r.id), "label": _label_from_obj(r, label_spec)} + for r in rows + ] + def expand_projection(model_cls, fields): req = getattr(model_cls, "__crudkit_field_requires__", {}) or {} out = set(fields) @@ -647,10 +696,12 @@ def _normalize_field_spec(spec, mapper, session, label_specs_model_default): if "label_deps" in spec: field["label_deps"] = spec["label_deps"] + opts_params = spec.get("options_params") or spec.get("options_filter") or spec.get("options_where") + if rel_prop: if field["type"] is None: field["type"] = "select" - if field["type"] == "select" and field.get("options") is None and session is not None: + if field["type"] == "select" and field.get("options") is None: related_model = rel_prop.mapper.class_ label_spec = ( spec.get("label_spec") @@ -658,7 +709,11 @@ def _normalize_field_spec(spec, mapper, session, label_specs_model_default): or getattr(related_model, "__crud_label__", None) or "id" ) - field["options"] = _fk_options(session, related_model, label_spec) + field["options"] = _fk_options_via_service( + related_model, + label_spec, + options_params=opts_params + ) return field col = mapper.columns.get(name) diff --git a/inventory/routes/entry.py b/inventory/routes/entry.py index addb6a7..fa607b3 100644 --- a/inventory/routes/entry.py +++ b/inventory/routes/entry.py @@ -105,7 +105,8 @@ def _fields_for_model(model: str): "row": "name", "wrap": {"class": "col-3"}}, {"name": "supervisor", "label": "Supervisor", "label_attrs": {"class": "form-label"}, "label_spec": "{label}", "row": "details", "wrap": {"class": "col-3"}, - "attrs": {"class": "form-control"}, "link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}}}, + "attrs": {"class": "form-control"}, "link": {"endpoint": "entry.entry", "params": {"id": "{supervisor.id}", "model": "user"}}, + "options_params": {"active__eq": True, "staff__eq": True}}, {"name": "location", "label": "Room", "label_attrs": {"class": "form-label"}, "label_spec": "{name} - {room_function.description}", "row": "details", "wrap": {"class": "col-3"}, "attrs": {"class": "form-control"}}, diff --git a/inventory/templates/crudkit/field.html b/inventory/templates/crudkit/field.html index 1a7cdb3..a5d1b6d 100644 --- a/inventory/templates/crudkit/field.html +++ b/inventory/templates/crudkit/field.html @@ -30,7 +30,21 @@ #} {% if options %} {% if value %} -{% set sel_label = (options | selectattr('value', 'equalto', value) | first)['label'] %} +{% set opts = options or [] %} +{% set selected = opts | selectattr('value', 'equalto', value) | list %} +{% if not selected %} +{# try again with string coercion to handle int vs str mismatch #} +{% set selected = opts | selectattr('value', 'equalto', value|string) | list %} +{% endif %} +{% set sel = selected[0] if selected else none %} + +{% if sel %} +{% set sel_label = sel['label'] %} +{% elif value_label %} +{% set sel_label = value_label %} +{% else %} +{% set sel_label = "-- Select --" %} +{% endif %} {% else %} {% set sel_label = "-- Select --" %} {% endif %} @@ -41,11 +55,13 @@ id="{{ field_name }}-filter" placeholder="Filter..." oninput="DropDown.utilities.filterList('{{ field_name }}')"> {% for opt in options %}
  • {{ opt['label'] }}
  • + data-value="{{ opt['value'] }}" onclick="DropDown.utilities.selectItem('{{ field_name }}', '{{ opt['value'] }}')" + id="{{ field_name }}-{{ opt['value'] }}">{{ opt['label'] }} {% endfor %} {% else %} - + {% endif %}