Getting hybrid property support.

This commit is contained in:
Yaro Kasear 2025-09-11 10:26:04 -05:00
parent b68dbfc7ae
commit 506713c748
3 changed files with 39 additions and 15 deletions

View file

@ -1,5 +1,6 @@
from typing import List, Tuple, Set, Dict, Optional from typing import List, Tuple, Set, Dict, Optional
from sqlalchemy import asc, desc from sqlalchemy import asc, desc
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import aliased, selectinload from sqlalchemy.orm import aliased, selectinload
from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.orm.attributes import InstrumentedAttribute
@ -48,7 +49,7 @@ class CRUDSpec:
current_alias = alias current_alias = alias
continue 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 attr_obj, tuple(join_path) if join_path else None
return None, None return None, None
@ -152,31 +153,34 @@ class CRUDSpec:
self._rel_field_names = rel_field_names self._rel_field_names = rel_field_names
return root_fields, 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 = [] loads = []
for path in self.eager_paths: for path in self.eager_paths:
current = root_alias current = root_alias
loader = None loader = None
for idx, name in enumerate(path): for idx, name in enumerate(path):
rel_attr = getattr(current, name) rel_attr = getattr(current, name)
loader = selectinload(rel_attr) if loader is None else loader.selectinload(rel_attr)
if loader is None: # step to target class for the next hop
loader = selectinload(rel_attr) target_cls = rel_attr.property.mapper.class_
else: current = target_cls
loader = loader.selectinload(rel_attr)
current = rel_attr.property.mapper.class_
# if final hop and we have a fields map, narrow columns
if fields_map and idx == len(path) - 1 and path in fields_map: if fields_map and idx == len(path) - 1 and path in fields_map:
target_cls = current cols = []
cols = [getattr(target_cls, n) for n in fields_map[path] if hasattr(target_cls, n)] 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: if cols:
loader = loader.load_only(*cols) loader = loader.load_only(*cols)
if loader is not None: if loader is not None:
loads.append(loader) loads.append(loader)
return loads return loads
def get_join_paths(self): def get_join_paths(self):

View file

@ -1,6 +1,7 @@
from typing import List, Optional 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.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import expression as sql from sqlalchemy.sql import expression as sql
@ -57,3 +58,22 @@ class Inventory(Base, CRUDMixin):
parts.append(f"location={repr(self.location.identifier)}") parts.append(f"location={repr(self.location.identifier)}")
return f"<Inventory({', '.join(parts)})>" return f"<Inventory({', '.join(parts)})>"
@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)),
)

View file

@ -22,7 +22,7 @@ def init_index_routes(app):
"start_time", "start_time",
"contact.last_name", "contact.last_name",
"contact.first_name", "contact.first_name",
"work_item.name", "work_item.label",
"work_item.device_type.description" "work_item.device_type.description"
], ],
"sort": "start_time" "sort": "start_time"
@ -32,7 +32,7 @@ def init_index_routes(app):
{"field": "start_time", "label": "Start", "format": "date"}, {"field": "start_time", "label": "Start", "format": "date"},
{"field": "contact.last_name", "label": "Contact", {"field": "contact.last_name", "label": "Contact",
"link": {"endpoint": "user.get_item", "params": {"id": "{contact.id}"}}}, "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}"}}}, "link": {"endpoint": "inventory.get_item", "params": {"id": "{work_item.id}"}}},
{"field": "work_item.device_type.description", "label": "Device Type"} {"field": "work_item.device_type.description", "label": "Device Type"}
] ]