diff --git a/crudkit/core/service.py b/crudkit/core/service.py index 753d90f..7f4bbcf 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -76,6 +76,20 @@ def _collect_tables_from_filters(filters) -> set: visit(f) return seen +def _selectable_keys(sel) -> set[str]: + """ + Return a set of stable string keys for a selectable/alias and its base, + so we can match when when different alias objects are used. + """ + keys: set[str] = set() + cur = sel + while cur is not None: + k = getattr(cur, "key", None) or getattr(cur, "name", None) + if isinstance(k, str) and k: + keys.add(k) + cur = getattr(cur, "element", None) + return keys + def _unwrap_ob(ob): elem = getattr(ob, "element", None) col = elem if elem is not None else ob @@ -244,6 +258,7 @@ class CRUDService(Generic[T]): collection_field_names: Any join_paths: Any filter_tables: Any + filter_table_keys: Any req_fields: Any proj_opts: Any @@ -259,12 +274,19 @@ 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 return self._Plan( spec=spec, filters=filters, order_by=order_by, limit=limit, offset=offset, root_fields=root_fields, rel_field_names=rel_field_names, root_field_names=root_field_names, collection_field_names=collection_field_names, - join_paths=join_paths, filter_tables=filter_tables, + join_paths=join_paths, filter_tables=filter_tables, filter_table_keys=fkeys, req_fields=req_fields, proj_opts=proj_opts ) @@ -278,16 +300,20 @@ class CRUDService(Generic[T]): if base_alias is not root_alias: continue prop = getattr(rel_attr, "property", None) + if log.isEnabledFor(logging.DEBUG): + try: + sel = getattr(target_alias, "selectable", None) + sel_key = getattr(sel, "key", None) or getattr(sel, "name", None) + log.debug( + "FIRST-HOP plan: rel=%s collection=%s selectable=%s filter_keys=%s", + rel_attr.key, getattr(prop, "uselist", False), sel_key, getattr(plan, "filter_table_keys", set()) + ) + except Exception: + pass is_collection = bool(getattr(prop, "uselist", False)) - sel = getattr(target_alias, "selectable", None) - sel_elem = getattr(sel, "element", None) - base_sel = sel_elem if sel_elem is not None else sel - - needed_for_filter = (sel in plan.filter_tables) or (base_sel in plan.filter_tables) - - if needed_for_filter and not is_collection: - query = query.join(rel_attr, isouter=True) + if not is_collection: + query = query.join(target_alias, rel_attr.of_type(target_alias), isouter=True) else: opt = selectinload(rel_attr) if is_collection: @@ -407,7 +433,7 @@ class CRUDService(Generic[T]): base = self._apply_not_deleted(base, root_alias, params) for _b, rel_attr, target_alias in plan.join_paths: if not bool(getattr(getattr(rel_attr, "property", None), "uselist", False)): - base = base.join(rel_attr, isouter=True) + base = base.join(target_alias, rel_attr.of_type(target_alias), isouter=True) if plan.filters: base = base.filter(*plan.filters) total = session.query(func.count()).select_from(