Downstream patch.

This commit is contained in:
Yaro Kasear 2025-10-23 16:00:57 -05:00
parent ec82ca2394
commit 87f7108f2d
2 changed files with 66 additions and 4 deletions

View file

@ -415,6 +415,8 @@ class CRUDService(Generic[T]):
opt = opt.load_only(*cols) opt = opt.load_only(*cols)
query = query.options(opt) query = query.options(opt)
# inside CRUDService._apply_firsthop_strategies
# ...
# NEW: if a first-hop to-one relationships target table is present in filter expressions, # NEW: if a first-hop to-one relationships target table is present in filter expressions,
# make sure we actually JOIN it (outer) so filters dont create a cartesian product. # make sure we actually JOIN it (outer) so filters dont create a cartesian product.
if plan.filter_tables: if plan.filter_tables:
@ -422,14 +424,19 @@ class CRUDService(Generic[T]):
for rel in mapper.relationships: for rel in mapper.relationships:
if rel.uselist: if rel.uselist:
continue # only first-hop to-one here 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: if target_tbl is None:
continue continue
if target_tbl in plan.filter_tables: if target_tbl in plan.filter_tables:
if rel.key in joined_rel_keys: if rel.key in joined_rel_keys:
continue # already joined via join_paths 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) joined_rel_keys.add(rel.key)
if log.isEnabledFor(logging.DEBUG): if log.isEnabledFor(logging.DEBUG):
info = [] info = []
for base_alias, rel_attr, target_alias in plan.join_paths: for base_alias, rel_attr, target_alias in plan.join_paths:

View file

@ -11,6 +11,8 @@ from sqlalchemy.orm.base import NO_VALUE
from sqlalchemy.orm.properties import ColumnProperty, RelationshipProperty from sqlalchemy.orm.properties import ColumnProperty, RelationshipProperty
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
import crudkit
_ALLOWED_ATTRS = { _ALLOWED_ATTRS = {
"class", "placeholder", "autocomplete", "inputmode", "pattern", "class", "placeholder", "autocomplete", "inputmode", "pattern",
"min", "max", "step", "maxlength", "minlength", "min", "max", "step", "maxlength", "minlength",
@ -107,6 +109,53 @@ def register_template_globals(app=None):
app.add_template_global(fn, name) app.add_template_global(fn, name)
installed.add(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 didnt 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): def expand_projection(model_cls, fields):
req = getattr(model_cls, "__crudkit_field_requires__", {}) or {} req = getattr(model_cls, "__crudkit_field_requires__", {}) or {}
out = set(fields) out = set(fields)
@ -647,10 +696,12 @@ def _normalize_field_spec(spec, mapper, session, label_specs_model_default):
if "label_deps" in spec: if "label_deps" in spec:
field["label_deps"] = spec["label_deps"] 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 rel_prop:
if field["type"] is None: if field["type"] is None:
field["type"] = "select" 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_ related_model = rel_prop.mapper.class_
label_spec = ( label_spec = (
spec.get("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 getattr(related_model, "__crud_label__", None)
or "id" 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 return field
col = mapper.columns.get(name) col = mapper.columns.get(name)