From 506713c748950b5e6f25a8076c3ab08a3632d6c0 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Thu, 11 Sep 2025 10:26:04 -0500 Subject: [PATCH] Getting hybrid property support. --- crudkit/core/spec.py | 28 ++++++++++++++++------------ inventory/models/inventory.py | 22 +++++++++++++++++++++- inventory/routes/index.py | 4 ++-- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/crudkit/core/spec.py b/crudkit/core/spec.py index 1b6ea31..97dcacf 100644 --- a/crudkit/core/spec.py +++ b/crudkit/core/spec.py @@ -1,5 +1,6 @@ from typing import List, Tuple, Set, Dict, Optional from sqlalchemy import asc, desc +from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import aliased, selectinload from sqlalchemy.orm.attributes import InstrumentedAttribute @@ -48,7 +49,7 @@ class CRUDSpec: current_alias = alias continue - if isinstance(attr_obj, InstrumentedAttribute) or hasattr(attr_obj, "clauses"): + if isinstance(attr_obj, InstrumentedAttribute) or getattr(attr_obj, "comparator", None) is not None or hasattr(attr_obj, "clauses"): return attr_obj, tuple(join_path) if join_path else None return None, None @@ -152,31 +153,34 @@ class CRUDSpec: self._rel_field_names = rel_field_names return root_fields, rel_field_names - def get_eager_loads(self, root_alias, *, fields_map: Optional[Dict[Tuple[str, ...], List[str]]] = None): + def get_eager_loads(self, root_alias, *, fields_map=None): loads = [] for path in self.eager_paths: current = root_alias loader = None - for idx, name in enumerate(path): rel_attr = getattr(current, name) + loader = selectinload(rel_attr) if loader is None else loader.selectinload(rel_attr) - if loader is None: - loader = selectinload(rel_attr) - else: - loader = loader.selectinload(rel_attr) - - current = rel_attr.property.mapper.class_ + # step to target class for the next hop + target_cls = rel_attr.property.mapper.class_ + current = target_cls + # if final hop and we have a fields map, narrow columns if fields_map and idx == len(path) - 1 and path in fields_map: - target_cls = current - cols = [getattr(target_cls, n) for n in fields_map[path] if hasattr(target_cls, n)] + cols = [] + for n in fields_map[path]: + attr = getattr(target_cls, n, None) + # Only include real column attributes; skip hybrids/expressions + if isinstance(attr, InstrumentedAttribute): + cols.append(attr) + + # Only apply load_only if we have at least one real column if cols: loader = loader.load_only(*cols) if loader is not None: loads.append(loader) - return loads def get_join_paths(self): diff --git a/inventory/models/inventory.py b/inventory/models/inventory.py index 2af4b21..99068a3 100644 --- a/inventory/models/inventory.py +++ b/inventory/models/inventory.py @@ -1,6 +1,7 @@ from typing import List, Optional -from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, Unicode +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Unicode, case, cast, func +from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import expression as sql @@ -57,3 +58,22 @@ class Inventory(Base, CRUDMixin): parts.append(f"location={repr(self.location.identifier)}") return f"" + + @hybrid_property + def label(self): + if self.name: + return f"Name: {self.name}" + if self.barcode: + return f"Barcode: {self.barcode}" + if self.serial: + return f"Serial: {self.serial}" + return f"ID: {self.id}" + + @label.expression + def label(cls): + return case( + (cls.name.isnot(None) & (cls.name != ''), func.concat("Name: ", cls.name)), + (cls.barcode.isnot(None) & (cls.barcode != ''), func.concat("Barcode: ", cls.barcode)), + (cls.serial.isnot(None) & (cls.serial != ''), func.concat("Serial: ", cls.serial)), + else_=func.concat("ID: ", cast(cls.id, String)), + ) diff --git a/inventory/routes/index.py b/inventory/routes/index.py index 5c009bf..f272133 100644 --- a/inventory/routes/index.py +++ b/inventory/routes/index.py @@ -22,7 +22,7 @@ def init_index_routes(app): "start_time", "contact.last_name", "contact.first_name", - "work_item.name", + "work_item.label", "work_item.device_type.description" ], "sort": "start_time" @@ -32,7 +32,7 @@ def init_index_routes(app): {"field": "start_time", "label": "Start", "format": "date"}, {"field": "contact.last_name", "label": "Contact", "link": {"endpoint": "user.get_item", "params": {"id": "{contact.id}"}}}, - {"field": "work_item.name", "label": "Work Item", + {"field": "work_item.label", "label": "Work Item", "link": {"endpoint": "inventory.get_item", "params": {"id": "{work_item.id}"}}}, {"field": "work_item.device_type.description", "label": "Device Type"} ]