diff --git a/crudkit/core/service.py b/crudkit/core/service.py index fbad3a1..c4a863f 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -4,16 +4,16 @@ from collections.abc import Iterable from dataclasses import dataclass from flask import current_app from typing import Any, Callable, Type, TypeVar, Generic, Optional, Protocol, runtime_checkable, cast -from sqlalchemy import and_, func, inspect, or_, text, select, literal +from sqlalchemy import and_, func, inspect, or_, text from sqlalchemy.engine import Engine, Connection -from sqlalchemy.orm import Load, Session, with_polymorphic, Mapper, selectinload, with_loader_criteria, aliased, with_parent +from sqlalchemy.orm import Load, Session, with_polymorphic, Mapper, selectinload, with_loader_criteria from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.sql import operators, visitors from sqlalchemy.sql.elements import UnaryExpression, ColumnElement from crudkit.core import to_jsonable, deep_diff, diff_to_patch, filter_to_columns, normalize_payload from crudkit.core.base import Version -from crudkit.core.spec import CRUDSpec, CollPred +from crudkit.core.spec import CRUDSpec from crudkit.core.types import OrderSpec, SeekWindow from crudkit.backend import BackendInfo, make_backend_info from crudkit.projection import compile_projection @@ -516,89 +516,107 @@ class CRUDService(Generic[T]): return [visitors.replacement_traverse(f, {}, replace) for f in filters] def _final_filters(self, root_alias, plan): - """ - Return filters where: - - root/to-one predicates are kept as SQLAlchemy expressions. - - first-hop collection predicates (CollPred) are rebuilt into a single - EXISTS via rel.any(...) with one alias per collection table. - """ + """Return filters rebounded to our first-hop aliases, with first-hop collection + predicates rewritten to EXISTS via rel.any(...).""" filters = list(plan.filters or []) if not filters: return [] - # 1) Build a map of first-hop relationships: TABLE -> (rel_attr, target_cls) - coll_map = {} + # 1) Build alias map for first-hop targets we joined (to-one) + alias_map = {} + coll_map = {} # KEY CHANGE: table -> (rel_attr, target_cls) for base_alias, rel_attr, target_alias in plan.join_paths: if base_alias is not root_alias: continue prop = getattr(rel_attr, "property", None) - if not prop or not getattr(prop, "uselist", False): + if prop is None: continue - target_cls = prop.mapper.class_ - tbl = getattr(target_cls, "__table__", None) - if tbl is not None: - coll_map[tbl] = (rel_attr, target_cls) - # 2) Split raw filters into normal SQLA and CollPreds (by target table) - normal_filters = [] - by_table: dict[Any, list[CollPred]] = {} + # Try to capture a selectable for to-one rebinds (nice-to-have) + sel = getattr(target_alias, "selectable", None) + if sel is not None: + alias_map[frozenset(_selectable_keys(sel))] = sel + + # Always build a collection map keyed by the mapped table (no alias needed) + if bool(getattr(prop, "uselist", False)): + target_cls = prop.mapper.class_ + tbl = getattr(target_cls, "__table__", None) + if tbl is not None: + coll_map[tbl] = (rel_attr, target_cls) + print(f"STAGE 1 - alias_map = {alias_map}, coll_map={coll_map}") + + # 2) Rebind to-one columns to the exact alias objects we JOINed (if we have them) + if alias_map: + def _rebind(elem): + tbl = getattr(elem, "table", None) + if tbl is None: + return elem + keyset = frozenset(_selectable_keys(tbl)) + new_sel = alias_map.get(keyset) + if new_sel is None or new_sel is tbl: + return elem + colkey = getattr(elem, "key", None) or getattr(elem, "name", None) + if not colkey: + return elem + try: + return getattr(new_sel.c, colkey) + except Exception: + return elem + filters = [visitors.replacement_traverse(f, {}, _rebind) for f in filters] + print(f"STAGE 2 - filters = {filters}") + + # 3) If there are no collection filters, we’re done + if not coll_map: + print("STAGE 3 - No, I have determined there are no collections to handle like a bad girl.") + return filters + print("STAGE 3 - Yes, I have determined there are collections to handle like a good boy.") + + # 4) Group any filters that reference a first-hop collection TABLE + keep = [] + per_coll = {} # table -> [expr, ...] for f in filters: - if isinstance(f, CollPred): - by_table.setdefault(f.table, []).append(f) + touched_tbl = None + def _find(elem): + nonlocal touched_tbl + tbl = getattr(elem, "table", None) + if tbl is None: + return + # normalize alias -> base table + base_tbl = tbl + while getattr(base_tbl, "element", None) is not None: + base_tbl = getattr(base_tbl, "element") + if base_tbl in coll_map and touched_tbl is None: + touched_tbl = base_tbl + visitors.traverse(f, {}, {'column': _find}) + + if touched_tbl is None: + keep.append(f) else: - normal_filters.append(f) + per_coll.setdefault(touched_tbl, []).append(f) + print(f"STAGE 4 - keep = {keep}, per_coll = {per_coll}") - # 3) Rebuild each table group into ONE .any(...) using one alias - from sqlalchemy.orm import aliased - from sqlalchemy import and_ - - exists_filters = [] - for tbl, preds in by_table.items(): - if tbl not in coll_map: - # Safety: if it's not a first-hop collection, ignore or raise - continue + # 5) For each collection, remap columns to mapped class attrs and wrap with .any(and_(...)) + for tbl, exprs in per_coll.items(): rel_attr, target_cls = coll_map[tbl] - ta = aliased(target_cls) - built = [] - for p in preds: - col = getattr(ta, p.col_key) - op = p.op - val = p.value - if op == 'icontains': - built.append(col.ilike(f"%{val}%")) - elif op == 'eq': - built.append(col == val) - elif op == 'ne': - built.append(col != val) - elif op == 'in': - vs = val if isinstance(val, (list, tuple, set)) else [val] - built.append(col.in_(vs)) - elif op == 'nin': - vs = val if isinstance(val, (list, tuple, set)) else [val] - built.append(~col.in_(vs)) - elif op == 'lt': - built.append(col < val) - elif op == 'lte': - built.append(col <= val) - elif op == 'gt': - built.append(col > val) - elif op == 'gte': - built.append(col >= val) - else: - # unknown op — skip or raise - continue + def _to_model(elem): + etbl = getattr(elem, "table", None) + if etbl is not None: + # normalize alias -> base table + etbl_base = etbl + while getattr(etbl_base, "element", None) is not None: + etbl_base = getattr(etbl_base, "element") + if etbl_base is tbl: + key = getattr(elem, "key", None) or getattr(elem, "name", None) + if key and hasattr(target_cls, key): + return getattr(target_cls, key) + return elem - # enforce child soft delete inside the EXISTS - if hasattr(target_cls, "is_deleted"): - built.append(ta.is_deleted == False) + remapped = [visitors.replacement_traverse(e, {}, _to_model) for e in exprs] + keep.append(rel_attr.any(and_(*remapped))) + print(f"STAGE 5 - keep={keep}") - crit = and_(*built) if built else None - exists_filters.append(rel_attr.of_type(ta).any(crit) if crit is not None - else rel_attr.of_type(ta).any()) - - # 4) Final filter list = normal SQLA filters + all EXISTS filters - return normal_filters + exists_filters + return keep # ---- public read ops diff --git a/crudkit/core/spec.py b/crudkit/core/spec.py index bfd5f11..4ec972f 100644 --- a/crudkit/core/spec.py +++ b/crudkit/core/spec.py @@ -1,17 +1,9 @@ -from dataclasses import dataclass from typing import Any, List, Tuple, Set, Dict, Optional, Iterable from sqlalchemy import and_, asc, desc, or_ from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import aliased, selectinload from sqlalchemy.orm.attributes import InstrumentedAttribute -@dataclass(frozen=True) -class CollPred: - table: Any - col_key: str - op: str - value: Any - OPERATORS = { 'eq': lambda col, val: col == val, 'lt': lambda col, val: col < val, @@ -76,48 +68,16 @@ class CRUDSpec: exprs = [] for col, join_path in pairs: + # Track eager path for each involved relationship chain if join_path: self.eager_paths.add(join_path) - - try: - cur_cls = self.model - names = list(join_path) - last_name = names[-1] - is_collection = False - for nm in names: - rel_attr = getattr(cur_cls, nm) - prop = rel_attr.property - cur_cls = prop.mapper.class_ - is_collection = bool(getattr(getattr(self.model, last_name), "property", None) - and getattr(getattr(self.model, last_name).property, "uselist", False)) - except Exception: - is_collection = False - - if is_collection: - target_cls = cur_cls - key = getattr(col, "key", None) or getattr(col, "name", None) - if key and hasattr(target_cls, key): - target_tbl = getattr(target_cls, "__table__", None) - if target_tbl is not None: - exprs.append(CollPred(table=target_tbl, col_key=key, op=op, value=value)) - continue - exprs.append(OPERATORS[op](col, value)) if not exprs: return None - - # If any CollPred is in exprs, do NOT or_ them. Keep it single for now. - if any(isinstance(x, CollPred) for x in exprs): - # If someone used a pipe 'relA.col|relB.col' that produced multiple CollPreds, - # keep the first or raise for now (your choice). - if len(exprs) > 1: - # raise NotImplementedError("OR across collection paths not supported yet") - exprs = [next(x for x in exprs if isinstance(x, CollPred))] + if len(exprs) == 1: return exprs[0] - - # Otherwise, standard SQLA clause(s) - return exprs[0] if len(exprs) == 1 else or_(*exprs) + return or_(*exprs) def _collect_filters(self, params: dict) -> list: """ diff --git a/inventory/routes/search.py b/inventory/routes/search.py index 249f262..33d23d2 100644 --- a/inventory/routes/search.py +++ b/inventory/routes/search.py @@ -76,7 +76,7 @@ def init_search_routes(app): {"field": "updates", "format": lambda x: len(x)}, ] worklog_results = worklog_service.list({ - 'contact.label|work_item.label|updates.content__icontains': q, + 'contact.label|work_item.label__icontains': q, 'fields': [ "contact.label", "work_item.label", diff --git a/inventory/templates/search.html b/inventory/templates/search.html index 7183fbb..02859a9 100644 --- a/inventory/templates/search.html +++ b/inventory/templates/search.html @@ -26,4 +26,5 @@ {{ worklog_results | safe }} + Search including the content of work log updates is currently disabled pending a bug fix. {% endblock %}