Lots and lots and *lots* of downstream updates.
This commit is contained in:
parent
6b56251d33
commit
8be6f917c7
7 changed files with 747 additions and 107 deletions
|
|
@ -74,18 +74,32 @@ def apply_pagination(sel: Select, backend: BackendInfo, *, page: int, per_page:
|
||||||
per_page = max(1, int(per_page))
|
per_page = max(1, int(per_page))
|
||||||
offset = (page - 1) * per_page
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
if backend.requires_order_by_for_offset and not sel._order_by_clauses:
|
if backend.requires_order_by_for_offset:
|
||||||
if default_order_by is None:
|
# Avoid private attribute if possible:
|
||||||
sel = sel.order_by(text("1"))
|
has_order = bool(getattr(sel, "_order_by_clauses", ())) # fallback for SA < 2.0.30
|
||||||
else:
|
try:
|
||||||
|
has_order = has_order or bool(sel.get_order_by())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not has_order:
|
||||||
|
if default_order_by is not None:
|
||||||
sel = sel.order_by(default_order_by)
|
sel = sel.order_by(default_order_by)
|
||||||
|
else:
|
||||||
|
# Try to find a primary key from the FROMs; fall back to a harmless literal.
|
||||||
|
try:
|
||||||
|
first_from = sel.get_final_froms()[0]
|
||||||
|
pk = next(iter(first_from.primary_key.columns))
|
||||||
|
sel = sel.order_by(pk)
|
||||||
|
except Exception:
|
||||||
|
sel = sel.order_by(text("1"))
|
||||||
|
|
||||||
return sel.limit(per_page).offset(offset)
|
return sel.limit(per_page).offset(offset)
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def maybe_identify_insert(session: Session, table, backend: BackendInfo):
|
def maybe_identify_insert(session: Session, table, backend: BackendInfo):
|
||||||
"""
|
"""
|
||||||
For MSSQL tables with IDENTIFY PK when you need to insert explicit IDs.
|
For MSSQL tables with IDENTITY PK when you need to insert explicit IDs.
|
||||||
No-op elsewhere.
|
No-op elsewhere.
|
||||||
"""
|
"""
|
||||||
if not backend.is_mssql:
|
if not backend.is_mssql:
|
||||||
|
|
@ -93,7 +107,7 @@ def maybe_identify_insert(session: Session, table, backend: BackendInfo):
|
||||||
return
|
return
|
||||||
|
|
||||||
full_name = f"{table.schema}.{table.name}" if table.schema else table.name
|
full_name = f"{table.schema}.{table.name}" if table.schema else table.name
|
||||||
session.execute(text(f"SET IDENTIFY_INSERT {full_name} ON"))
|
session.execute(text(f"SET IDENTITY_INSERT {full_name} ON"))
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -101,7 +115,7 @@ def maybe_identify_insert(session: Session, table, backend: BackendInfo):
|
||||||
|
|
||||||
def chunked_in(column, values: Iterable, backend: BackendInfo, chunk_size: Optional[int] = None) -> ClauseElement:
|
def chunked_in(column, values: Iterable, backend: BackendInfo, chunk_size: Optional[int] = None) -> ClauseElement:
|
||||||
"""
|
"""
|
||||||
Build a safe large IN() filter respecting bund param limits.
|
Build a safe large IN() filter respecting bind param limits.
|
||||||
Returns a disjunction of chunked IN clauses if needed.
|
Returns a disjunction of chunked IN clauses if needed.
|
||||||
"""
|
"""
|
||||||
vals = list(values)
|
vals = list(values)
|
||||||
|
|
@ -120,3 +134,12 @@ def chunked_in(column, values: Iterable, backend: BackendInfo, chunk_size: Optio
|
||||||
for p in parts[1:]:
|
for p in parts[1:]:
|
||||||
expr = expr | p
|
expr = expr | p
|
||||||
return expr
|
return expr
|
||||||
|
|
||||||
|
def sql_trim(expr, backend: BackendInfo):
|
||||||
|
"""
|
||||||
|
Portable TRIM. SQL Server before compat level 140 lacks TRIM().
|
||||||
|
Emit LTRIM(RTRIM(...)) there; use TRIM elsewhere
|
||||||
|
"""
|
||||||
|
if backend.is_mssql:
|
||||||
|
return func.ltrim(func.rtrim(expr))
|
||||||
|
return func.trim(expr)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,21 @@
|
||||||
from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func
|
from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func, inspect
|
||||||
from sqlalchemy.orm import declarative_mixin, declarative_base
|
from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE
|
||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
|
def _safe_get_loaded_attr(obj, name):
|
||||||
|
try:
|
||||||
|
st = inspect(obj)
|
||||||
|
attr = st.attrs.get(name)
|
||||||
|
if attr is not None:
|
||||||
|
val = attr.loaded_value
|
||||||
|
return None if val is NO_VALUE else val
|
||||||
|
if name in st.dict:
|
||||||
|
return st.dict.get(name)
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
@declarative_mixin
|
@declarative_mixin
|
||||||
class CRUDMixin:
|
class CRUDMixin:
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
from typing import Any, Callable, Dict, Iterable, List, Tuple, Type, TypeVar, Generic, Optional, Protocol, runtime_checkable, cast
|
from typing import Any, Callable, Dict, Iterable, List, Tuple, Type, TypeVar, Generic, Optional, Protocol, runtime_checkable, cast
|
||||||
from sqlalchemy import and_, func, inspect, or_, text
|
from sqlalchemy import and_, func, inspect, or_, text
|
||||||
from sqlalchemy.engine import Engine, Connection
|
from sqlalchemy.engine import Engine, Connection
|
||||||
from sqlalchemy.orm import Load, Session, raiseload, selectinload, with_polymorphic, Mapper, RelationshipProperty, class_mapper
|
from sqlalchemy.orm import Load, Session, raiseload, selectinload, with_polymorphic, Mapper, RelationshipProperty, class_mapper, ColumnProperty
|
||||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||||
|
from sqlalchemy.orm.base import NO_VALUE
|
||||||
from sqlalchemy.orm.util import AliasedClass
|
from sqlalchemy.orm.util import AliasedClass
|
||||||
from sqlalchemy.sql import operators
|
from sqlalchemy.sql import operators
|
||||||
from sqlalchemy.sql.elements import UnaryExpression
|
from sqlalchemy.sql.elements import UnaryExpression
|
||||||
|
|
@ -12,6 +13,52 @@ from crudkit.core.spec import CRUDSpec
|
||||||
from crudkit.core.types import OrderSpec, SeekWindow
|
from crudkit.core.types import OrderSpec, SeekWindow
|
||||||
from crudkit.backend import BackendInfo, make_backend_info
|
from crudkit.backend import BackendInfo, make_backend_info
|
||||||
|
|
||||||
|
def _expand_requires(model_cls, fields):
|
||||||
|
out, seen = [], set()
|
||||||
|
def add(f):
|
||||||
|
if f not in seen:
|
||||||
|
seen.add(f); out.append(f)
|
||||||
|
|
||||||
|
for f in fields:
|
||||||
|
add(f)
|
||||||
|
parts = f.split(".")
|
||||||
|
cur_cls = model_cls
|
||||||
|
prefix = []
|
||||||
|
|
||||||
|
for p in parts[:-1]:
|
||||||
|
rel = getattr(cur_cls.__mapper__.relationships, 'get', lambda _: None)(p)
|
||||||
|
if not rel:
|
||||||
|
cur_cls = None
|
||||||
|
break
|
||||||
|
cur_cls = rel.mapper.class_
|
||||||
|
prefix.append(p)
|
||||||
|
|
||||||
|
if cur_cls is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
leaf = parts[-1]
|
||||||
|
deps = (getattr(cur_cls, "__crudkit_field_requires__", {}) or {}).get(leaf)
|
||||||
|
if not deps:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pre = ".".join(prefix)
|
||||||
|
for dep in deps:
|
||||||
|
add(f"{pre + '.' if pre else ''}{dep}")
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _is_rel(model_cls, name: str) -> bool:
|
||||||
|
try:
|
||||||
|
prop = model_cls.__mapper__.relationships.get(name)
|
||||||
|
return isinstance(prop, RelationshipProperty)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _is_instrumented_column(attr) -> bool:
|
||||||
|
try:
|
||||||
|
return hasattr(attr, "property") and isinstance(attr.property, ColumnProperty)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
def _loader_options_for_fields(root_alias, model_cls, fields: list[str]) -> list[Load]:
|
def _loader_options_for_fields(root_alias, model_cls, fields: list[str]) -> list[Load]:
|
||||||
"""
|
"""
|
||||||
For bare MANYTOONE names in fields (e.g. "location"), selectinload the relationship
|
For bare MANYTOONE names in fields (e.g. "location"), selectinload the relationship
|
||||||
|
|
@ -103,43 +150,47 @@ class CRUDService(Generic[T]):
|
||||||
=> selectinload(root.location).selectinload(Room.room_function)
|
=> selectinload(root.location).selectinload(Room.room_function)
|
||||||
"""
|
"""
|
||||||
opts: List[Any] = []
|
opts: List[Any] = []
|
||||||
|
|
||||||
root_mapper: Mapper[Any] = cast(Mapper[Any], inspect(self.model))
|
root_mapper: Mapper[Any] = cast(Mapper[Any], inspect(self.model))
|
||||||
|
|
||||||
for path, names in (rel_field_names or {}).items():
|
for path, names in (rel_field_names or {}).items():
|
||||||
if not path:
|
if not path:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
current_alias = root_alias
|
|
||||||
current_mapper = root_mapper
|
current_mapper = root_mapper
|
||||||
rel_props: List[RelationshipProperty] = []
|
rel_props: List[RelationshipProperty] = []
|
||||||
|
|
||||||
valid = True
|
valid = True
|
||||||
for step in path:
|
for step in path:
|
||||||
rel = current_mapper.relationships.get(step)
|
rel = current_mapper.relationships.get(step)
|
||||||
if rel is None:
|
if not isinstance(rel, RelationshipProperty):
|
||||||
valid = False
|
valid = False
|
||||||
break
|
break
|
||||||
rel_props.append(rel)
|
rel_props.append(rel)
|
||||||
current_mapper = cast(Mapper[Any], inspect(rel.entity.entity))
|
current_mapper = cast(Mapper[Any], inspect(rel.entity.entity))
|
||||||
if not valid:
|
if not valid or not rel_props:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
target_cls = current_mapper.class_
|
first = rel_props[0]
|
||||||
|
base_loader = selectinload(getattr(root_alias, first.key))
|
||||||
|
for i in range(1, len(rel_props)):
|
||||||
|
prev_target_cls = rel_props[i - 1].mapper.class_
|
||||||
|
hop_attr = getattr(prev_target_cls, rel_props[i].key)
|
||||||
|
base_loader = base_loader.selectinload(hop_attr)
|
||||||
|
|
||||||
|
target_cls = rel_props[-1].mapper.class_
|
||||||
|
|
||||||
requires = getattr(target_cls, "__crudkit_field_requires__", None)
|
requires = getattr(target_cls, "__crudkit_field_requires__", None)
|
||||||
if not isinstance(requires, dict):
|
if not isinstance(requires, dict):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for field_name in names:
|
for field_name in names:
|
||||||
needed: Iterable[str] = requires.get(field_name, [])
|
needed: Iterable[str] = requires.get(field_name, []) or []
|
||||||
for rel_need in needed:
|
for rel_need in needed:
|
||||||
loader = selectinload(getattr(root_alias, rel_props[0].key))
|
rel_prop2 = target_cls.__mapper__.relationships.get(rel_need)
|
||||||
for rp in rel_props[1:]:
|
if not isinstance(rel_prop2, RelationshipProperty):
|
||||||
loader = loader.selectinload(getattr(getattr(root_alias, rp.parent.class_.__name__.lower(), None) or rp.parent.class_, rp.key))
|
continue
|
||||||
|
dep_attr = getattr(target_cls, rel_prop2.key)
|
||||||
loader = loader.selectinload(getattr(target_cls, rel_need))
|
opts.append(base_loader.selectinload(dep_attr))
|
||||||
opts.append(loader)
|
|
||||||
|
|
||||||
return opts
|
return opts
|
||||||
|
|
||||||
|
|
@ -215,7 +266,10 @@ class CRUDService(Generic[T]):
|
||||||
- forward/backward seek via `key` and `backward`
|
- forward/backward seek via `key` and `backward`
|
||||||
Returns a SeekWindow with items, first/last keys, order spec, limit, and optional total.
|
Returns a SeekWindow with items, first/last keys, order spec, limit, and optional total.
|
||||||
"""
|
"""
|
||||||
session = self.session
|
fields = list(params.get("fields", []))
|
||||||
|
if fields:
|
||||||
|
fields = _expand_requires(self.model, fields)
|
||||||
|
params = {**params, "fields": fields}
|
||||||
query, root_alias = self.get_query()
|
query, root_alias = self.get_query()
|
||||||
|
|
||||||
spec = CRUDSpec(self.model, params or {}, root_alias)
|
spec = CRUDSpec(self.model, params or {}, root_alias)
|
||||||
|
|
@ -225,12 +279,18 @@ class CRUDService(Generic[T]):
|
||||||
|
|
||||||
root_fields, rel_field_names, root_field_names = spec.parse_fields()
|
root_fields, rel_field_names, root_field_names = spec.parse_fields()
|
||||||
|
|
||||||
|
seen_rel_roots = set()
|
||||||
for path, names in (rel_field_names or {}).items():
|
for path, names in (rel_field_names or {}).items():
|
||||||
if "label" in names:
|
if not path:
|
||||||
|
continue
|
||||||
rel_name = path[0]
|
rel_name = path[0]
|
||||||
|
if rel_name in seen_rel_roots:
|
||||||
|
continue
|
||||||
|
if _is_rel(self.model, rel_name):
|
||||||
rel_attr = getattr(root_alias, rel_name, None)
|
rel_attr = getattr(root_alias, rel_name, None)
|
||||||
if rel_attr is not None:
|
if rel_attr is not None:
|
||||||
query = query.options(selectinload(rel_attr))
|
query = query.options(selectinload(rel_attr))
|
||||||
|
seen_rel_roots.add(rel_name)
|
||||||
|
|
||||||
# Soft delete filter
|
# Soft delete filter
|
||||||
if self.supports_soft_delete and not _is_truthy(params.get("include_deleted")):
|
if self.supports_soft_delete and not _is_truthy(params.get("include_deleted")):
|
||||||
|
|
@ -251,8 +311,8 @@ class CRUDService(Generic[T]):
|
||||||
only_cols = [c for c in root_fields if isinstance(c, InstrumentedAttribute)]
|
only_cols = [c for c in root_fields if isinstance(c, InstrumentedAttribute)]
|
||||||
if only_cols:
|
if only_cols:
|
||||||
query = query.options(Load(root_alias).load_only(*only_cols))
|
query = query.options(Load(root_alias).load_only(*only_cols))
|
||||||
for eager in spec.get_eager_loads(root_alias, fields_map=rel_field_names):
|
# for eager in spec.get_eager_loads(root_alias, fields_map=rel_field_names):
|
||||||
query = query.options(eager)
|
# query = query.options(eager)
|
||||||
|
|
||||||
for opt in self._resolve_required_includes(root_alias, rel_field_names):
|
for opt in self._resolve_required_includes(root_alias, rel_field_names):
|
||||||
query = query.options(opt)
|
query = query.options(opt)
|
||||||
|
|
@ -387,6 +447,20 @@ class CRUDService(Generic[T]):
|
||||||
if params:
|
if params:
|
||||||
root_fields, rel_field_names, root_field_names = spec.parse_fields()
|
root_fields, rel_field_names, root_field_names = spec.parse_fields()
|
||||||
|
|
||||||
|
if rel_field_names:
|
||||||
|
seen_rel_roots = set()
|
||||||
|
for path, names in rel_field_names.items():
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
rel_name = path[0]
|
||||||
|
if rel_name in seen_rel_roots:
|
||||||
|
continue
|
||||||
|
if _is_rel(self.model, rel_name):
|
||||||
|
rel_attr = getattr(root_alias, rel_name, None)
|
||||||
|
if rel_attr is not None:
|
||||||
|
query = query.options(selectinload(rel_attr))
|
||||||
|
seen_rel_roots.add(rel_name)
|
||||||
|
|
||||||
fields = (params or {}).get("fields") if isinstance(params, dict) else None
|
fields = (params or {}).get("fields") if isinstance(params, dict) else None
|
||||||
if fields:
|
if fields:
|
||||||
for opt in _loader_options_for_fields(root_alias, self.model, fields):
|
for opt in _loader_options_for_fields(root_alias, self.model, fields):
|
||||||
|
|
@ -396,8 +470,8 @@ class CRUDService(Generic[T]):
|
||||||
if only_cols:
|
if only_cols:
|
||||||
query = query.options(Load(root_alias).load_only(*only_cols))
|
query = query.options(Load(root_alias).load_only(*only_cols))
|
||||||
|
|
||||||
for eager in spec.get_eager_loads(root_alias, fields_map=rel_field_names):
|
# for eager in spec.get_eager_loads(root_alias, fields_map=rel_field_names):
|
||||||
query = query.options(eager)
|
# query = query.options(eager)
|
||||||
|
|
||||||
if params:
|
if params:
|
||||||
fields = params.get("fields") or []
|
fields = params.get("fields") or []
|
||||||
|
|
@ -454,12 +528,26 @@ class CRUDService(Generic[T]):
|
||||||
if params:
|
if params:
|
||||||
root_fields, rel_field_names, root_field_names = spec.parse_fields()
|
root_fields, rel_field_names, root_field_names = spec.parse_fields()
|
||||||
|
|
||||||
|
if rel_field_names:
|
||||||
|
seen_rel_roots = set()
|
||||||
|
for path, names in rel_field_names.items():
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
rel_name = path[0]
|
||||||
|
if rel_name in seen_rel_roots:
|
||||||
|
continue
|
||||||
|
if _is_rel(self.model, rel_name):
|
||||||
|
rel_attr = getattr(root_alias, rel_name, None)
|
||||||
|
if rel_attr is not None:
|
||||||
|
query = query.options(selectinload(rel_attr))
|
||||||
|
seen_rel_roots.add(rel_name)
|
||||||
|
|
||||||
only_cols = [c for c in root_fields if isinstance(c, InstrumentedAttribute)]
|
only_cols = [c for c in root_fields if isinstance(c, InstrumentedAttribute)]
|
||||||
if only_cols:
|
if only_cols:
|
||||||
query = query.options(Load(root_alias).load_only(*only_cols))
|
query = query.options(Load(root_alias).load_only(*only_cols))
|
||||||
|
|
||||||
for eager in spec.get_eager_loads(root_alias, fields_map=rel_field_names):
|
# for eager in spec.get_eager_loads(root_alias, fields_map=rel_field_names):
|
||||||
query = query.options(eager)
|
# query = query.options(eager)
|
||||||
|
|
||||||
if params:
|
if params:
|
||||||
fields = params.get("fields") or []
|
fields = params.get("fields") or []
|
||||||
|
|
|
||||||
236
crudkit/projection.py
Normal file
236
crudkit/projection.py
Normal file
|
|
@ -0,0 +1,236 @@
|
||||||
|
# crudkit/projection.py
|
||||||
|
from __future__ import annotations
|
||||||
|
from typing import Iterable, List, Tuple, Dict, Set
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||||
|
from sqlalchemy.orm.properties import ColumnProperty, RelationshipProperty
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
# ----------------------
|
||||||
|
# small utilities
|
||||||
|
# ----------------------
|
||||||
|
|
||||||
|
def _is_column_attr(a) -> bool:
|
||||||
|
try:
|
||||||
|
return isinstance(a, InstrumentedAttribute) and isinstance(a.property, ColumnProperty)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _is_relationship_attr(a) -> bool:
|
||||||
|
try:
|
||||||
|
return isinstance(a, InstrumentedAttribute) and isinstance(a.property, RelationshipProperty)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _split_path(field: str) -> List[str]:
|
||||||
|
return [p for p in str(field).split(".") if p]
|
||||||
|
|
||||||
|
def _model_requires_map(model_cls) -> Dict[str, List[str]]:
|
||||||
|
# apps declare per-model deps, e.g. {"label": ["first_name","last_name","title"]}
|
||||||
|
return getattr(model_cls, "__crudkit_field_requires__", {}) or {}
|
||||||
|
|
||||||
|
def _relationships_of(model_cls) -> Dict[str, RelationshipProperty]:
|
||||||
|
try:
|
||||||
|
return dict(model_cls.__mapper__.relationships)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _attr_on(model_cls, name: str):
|
||||||
|
return getattr(model_cls, name, None)
|
||||||
|
|
||||||
|
# ----------------------
|
||||||
|
# EXPAND: add required deps for leaf attributes at the correct class
|
||||||
|
# ----------------------
|
||||||
|
|
||||||
|
def _expand_requires_for_field(model_cls, pieces: List[str]) -> List[str]:
|
||||||
|
"""
|
||||||
|
Given a dotted path like ["owner","label"], walk relationships to the leaf *container* class,
|
||||||
|
pull its __crudkit_field_requires__ for that leaf attr ("label"), and yield prefixed deps:
|
||||||
|
owner.label -> ["owner.first_name", "owner.last_name", ...] if User requires so.
|
||||||
|
If leaf is a column (or has no requires), returns [].
|
||||||
|
"""
|
||||||
|
if not pieces:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# walk relationships to the leaf container (class that owns the leaf attr)
|
||||||
|
container_cls = model_cls
|
||||||
|
prefix_parts: List[str] = []
|
||||||
|
for part in pieces[:-1]:
|
||||||
|
a = _attr_on(container_cls, part)
|
||||||
|
if not _is_relationship_attr(a):
|
||||||
|
return [] # can't descend; invalid or scalar in the middle
|
||||||
|
container_cls = a.property.mapper.class_
|
||||||
|
prefix_parts.append(part)
|
||||||
|
|
||||||
|
leaf = pieces[-1]
|
||||||
|
requires = _model_requires_map(container_cls).get(leaf) or []
|
||||||
|
if not requires:
|
||||||
|
return []
|
||||||
|
|
||||||
|
prefix = ".".join(prefix_parts)
|
||||||
|
out: List[str] = []
|
||||||
|
for dep in requires:
|
||||||
|
# dep may itself be dotted relative to container (e.g. "room_function.description")
|
||||||
|
if prefix:
|
||||||
|
out.append(f"{prefix}.{dep}")
|
||||||
|
else:
|
||||||
|
out.append(dep)
|
||||||
|
return out
|
||||||
|
|
||||||
|
def _expand_requires(model_cls, fields: Iterable[str]) -> List[str]:
|
||||||
|
"""
|
||||||
|
Dedup + stable expansion of requires for all fields.
|
||||||
|
"""
|
||||||
|
seen: Set[str] = set()
|
||||||
|
out: List[str] = []
|
||||||
|
|
||||||
|
def add(f: str):
|
||||||
|
if f not in seen:
|
||||||
|
seen.add(f)
|
||||||
|
out.append(f)
|
||||||
|
|
||||||
|
# first pass: add original
|
||||||
|
queue: List[str] = []
|
||||||
|
for f in fields:
|
||||||
|
f = str(f)
|
||||||
|
if f not in seen:
|
||||||
|
seen.add(f)
|
||||||
|
out.append(f)
|
||||||
|
queue.append(f)
|
||||||
|
|
||||||
|
# BFS-ish: when we add deps, they may trigger further deps downstream
|
||||||
|
while queue:
|
||||||
|
f = queue.pop(0)
|
||||||
|
deps = _expand_requires_for_field(model_cls, _split_path(f))
|
||||||
|
for d in deps:
|
||||||
|
if d not in seen:
|
||||||
|
seen.add(d)
|
||||||
|
out.append(d)
|
||||||
|
queue.append(d)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
# ----------------------
|
||||||
|
# BUILD loader options tree with selectinload + load_only on real columns
|
||||||
|
# ----------------------
|
||||||
|
|
||||||
|
def _insert_leaf(loader_tree: dict, path: List[str]):
|
||||||
|
"""
|
||||||
|
Build nested dict structure keyed by relationship names.
|
||||||
|
Each node holds:
|
||||||
|
{
|
||||||
|
"__cols__": set(column_names_to_load_only),
|
||||||
|
"<child_rel>": { ... }
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
node = loader_tree
|
||||||
|
for rel in path[:-1]: # only relationship hops
|
||||||
|
node = node.setdefault(rel, {"__cols__": set()})
|
||||||
|
# leaf may be a column or a virtual/hybrid; only columns go to __cols__
|
||||||
|
node.setdefault("__cols__", set())
|
||||||
|
|
||||||
|
def _attach_column(loader_tree: dict, path: List[str], model_cls):
|
||||||
|
"""
|
||||||
|
If the leaf is a real column on the target class, record its name into __cols__ at that level.
|
||||||
|
"""
|
||||||
|
# descend to target class to test column-ness
|
||||||
|
container_cls = model_cls
|
||||||
|
node = loader_tree
|
||||||
|
for rel in path[:-1]:
|
||||||
|
a = _attr_on(container_cls, rel)
|
||||||
|
if not _is_relationship_attr(a):
|
||||||
|
return # invalid path, ignore
|
||||||
|
container_cls = a.property.mapper.class_
|
||||||
|
node = node.setdefault(rel, {"__cols__": set()})
|
||||||
|
|
||||||
|
leaf = path[-1]
|
||||||
|
a_leaf = _attr_on(container_cls, leaf)
|
||||||
|
node.setdefault("__cols__", set())
|
||||||
|
if _is_column_attr(a_leaf):
|
||||||
|
node["__cols__"].add(leaf)
|
||||||
|
|
||||||
|
def _build_loader_tree(model_cls, fields: Iterable[str]) -> dict:
|
||||||
|
"""
|
||||||
|
For each dotted field:
|
||||||
|
- walk relationships -> create nodes
|
||||||
|
- if leaf is a column: record it for load_only
|
||||||
|
- if leaf is not a column (hybrid/descriptor): no load_only; still ensure rel hops exist
|
||||||
|
"""
|
||||||
|
tree: Dict[str, dict] = {"__cols__": set()}
|
||||||
|
for f in fields:
|
||||||
|
parts = _split_path(f)
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
# ensure relationship nodes exist
|
||||||
|
_insert_leaf(tree, parts)
|
||||||
|
# attach column if applicable
|
||||||
|
_attach_column(tree, parts, model_cls)
|
||||||
|
return tree
|
||||||
|
|
||||||
|
def _loader_options_from_tree(model_cls, tree: dict):
|
||||||
|
"""
|
||||||
|
Convert the loader tree into SQLAlchemy loader options:
|
||||||
|
selectinload(<rel>)[.load_only(cols)] recursively
|
||||||
|
"""
|
||||||
|
opts = []
|
||||||
|
|
||||||
|
rels = _relationships_of(model_cls)
|
||||||
|
for rel_name, child in tree.items():
|
||||||
|
if rel_name == "__cols__":
|
||||||
|
continue
|
||||||
|
rel_prop = rels.get(rel_name)
|
||||||
|
if not rel_prop:
|
||||||
|
continue
|
||||||
|
rel_attr = getattr(model_cls, rel_name)
|
||||||
|
opt = selectinload(rel_attr)
|
||||||
|
|
||||||
|
# apply load_only on the related class (only real columns recorded at child["__cols__"])
|
||||||
|
cols = list(child.get("__cols__", []))
|
||||||
|
if cols:
|
||||||
|
rel_model = rel_prop.mapper.class_
|
||||||
|
# map column names to attributes
|
||||||
|
col_attrs = []
|
||||||
|
for c in cols:
|
||||||
|
a = getattr(rel_model, c, None)
|
||||||
|
if _is_column_attr(a):
|
||||||
|
col_attrs.append(a)
|
||||||
|
if col_attrs:
|
||||||
|
opt = opt.load_only(*col_attrs)
|
||||||
|
|
||||||
|
# recurse to grandchildren
|
||||||
|
sub_opts = _loader_options_from_tree(rel_prop.mapper.class_, child)
|
||||||
|
for so in sub_opts:
|
||||||
|
opt = opt.options(so)
|
||||||
|
|
||||||
|
opts.append(opt)
|
||||||
|
|
||||||
|
# root-level columns (rare in our compile; kept for completeness)
|
||||||
|
root_cols = list(tree.get("__cols__", []))
|
||||||
|
if root_cols:
|
||||||
|
# NOTE: call-site can add a root load_only(...) if desired;
|
||||||
|
# we purposely return only relationship options here to keep
|
||||||
|
# the API simple and avoid mixing Load(model_cls) contexts.
|
||||||
|
pass
|
||||||
|
|
||||||
|
return opts
|
||||||
|
|
||||||
|
# ----------------------
|
||||||
|
# PUBLIC API
|
||||||
|
# ----------------------
|
||||||
|
|
||||||
|
def compile_projection(model_cls, fields: Iterable[str]) -> Tuple[List[str], List]:
|
||||||
|
"""
|
||||||
|
Returns:
|
||||||
|
expanded_fields: List[str] # original + declared dependencies
|
||||||
|
loader_options: List[Load] # apply via query = query.options(*loader_options)
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Expands __crudkit_field_requires__ at the leaf container class for every field.
|
||||||
|
- Builds a selectinload tree; load_only only includes real columns (no hybrids).
|
||||||
|
- Safe for nested paths: e.g. "owner.label" pulls owner deps from User.__crudkit_field_requires__.
|
||||||
|
"""
|
||||||
|
fields = list(fields or [])
|
||||||
|
expanded = _expand_requires(model_cls, fields)
|
||||||
|
tree = _build_loader_tree(model_cls, expanded)
|
||||||
|
options = _loader_options_from_tree(model_cls, tree)
|
||||||
|
return expanded, options
|
||||||
|
|
@ -6,17 +6,65 @@ from flask import current_app, url_for
|
||||||
from jinja2 import Environment, FileSystemLoader, ChoiceLoader
|
from jinja2 import Environment, FileSystemLoader, ChoiceLoader
|
||||||
from sqlalchemy import inspect
|
from sqlalchemy import inspect
|
||||||
from sqlalchemy.orm import Load, RelationshipProperty, class_mapper, load_only, selectinload
|
from sqlalchemy.orm import Load, RelationshipProperty, class_mapper, load_only, selectinload
|
||||||
|
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||||
from sqlalchemy.orm.base import NO_VALUE
|
from sqlalchemy.orm.base import NO_VALUE
|
||||||
|
from sqlalchemy.orm.properties import ColumnProperty, RelationshipProperty
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
_ALLOWED_ATTRS = {
|
_ALLOWED_ATTRS = {
|
||||||
"class", "placeholder", "autocomplete", "inputmode", "pattern",
|
"class", "placeholder", "autocomplete", "inputmode", "pattern",
|
||||||
"min", "max", "step", "maxlength", "minlength",
|
"min", "max", "step", "maxlength", "minlength",
|
||||||
"required", "readonly", "disabled",
|
"required", "readonly", "disabled",
|
||||||
"multiple", "size",
|
"multiple", "size", "rows",
|
||||||
"id", "name", "value",
|
"id", "name", "value",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_SAFE_CSS_PROPS = {
|
||||||
|
# spacing / sizing
|
||||||
|
"margin","margin-top","margin-right","margin-bottom","margin-left",
|
||||||
|
"padding","padding-top","padding-right","padding-bottom","padding-left",
|
||||||
|
"width","height","min-width","min-height","max-width","max-height", "resize",
|
||||||
|
# layout
|
||||||
|
"display","flex","flex-direction","flex-wrap","justify-content","align-items","gap",
|
||||||
|
# text
|
||||||
|
"font-size","font-weight","line-height","text-align","white-space",
|
||||||
|
# colors / background
|
||||||
|
"color","background-color",
|
||||||
|
# borders / radius
|
||||||
|
"border","border-top","border-right","border-bottom","border-left",
|
||||||
|
"border-width","border-style","border-color","border-radius",
|
||||||
|
# misc (safe-ish)
|
||||||
|
"opacity","overflow","overflow-x","overflow-y",
|
||||||
|
}
|
||||||
|
|
||||||
|
_num_unit = r"-?\d+(?:\.\d+)?"
|
||||||
|
_len_unit = r"(?:px|em|rem|%)"
|
||||||
|
P_LEN = re.compile(rf"^{_num_unit}(?:{_len_unit})?$") # 12, 12px, 1.2rem, 50%
|
||||||
|
P_GAP = P_LEN
|
||||||
|
P_INT = re.compile(r"^\d+$")
|
||||||
|
P_COLOR = re.compile(
|
||||||
|
r"^(#[0-9a-fA-F]{3,8}|"
|
||||||
|
r"rgb\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*\)|"
|
||||||
|
r"rgba\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*(?:0|1|0?\.\d+)\s*\)|"
|
||||||
|
r"[a-zA-Z]+)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
_ENUMS = {
|
||||||
|
"display": {"block","inline","inline-block","flex","grid","none"},
|
||||||
|
"flex-direction": {"row","row-reverse","column","column-reverse"},
|
||||||
|
"flex-wrap": {"nowrap","wrap","wrap-reverse"},
|
||||||
|
"justify-content": {"flex-start","flex-end","center","space-between","space-around","space-evenly"},
|
||||||
|
"align-items": {"stretch","flex-start","flex-end","center","baseline"},
|
||||||
|
"text-align": {"left","right","center","justify","start","end"},
|
||||||
|
"white-space": {"normal","nowrap","pre","pre-wrap","pre-line","break-spaces"},
|
||||||
|
"border-style": {"none","solid","dashed","dotted","double","groove","ridge","inset","outset"},
|
||||||
|
"overflow": {"visible","hidden","scroll","auto","clip"},
|
||||||
|
"overflow-x": {"visible","hidden","scroll","auto","clip"},
|
||||||
|
"overflow-y": {"visible","hidden","scroll","auto","clip"},
|
||||||
|
"font-weight": {"normal","bold","bolder","lighter","100","200","300","400","500","600","700","800","900"},
|
||||||
|
"resize": {"none", "both", "horizontal", "vertical"},
|
||||||
|
}
|
||||||
|
|
||||||
def get_env():
|
def get_env():
|
||||||
app = current_app
|
app = current_app
|
||||||
default_path = os.path.join(os.path.dirname(__file__), 'templates')
|
default_path = os.path.join(os.path.dirname(__file__), 'templates')
|
||||||
|
|
@ -26,6 +74,97 @@ def get_env():
|
||||||
loader=ChoiceLoader([app.jinja_loader, fallback_loader])
|
loader=ChoiceLoader([app.jinja_loader, fallback_loader])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def expand_projection(model_cls, fields):
|
||||||
|
req = getattr(model_cls, "__crudkit_field_requires__", {}) or {}
|
||||||
|
out = set(fields)
|
||||||
|
for f in list(fields):
|
||||||
|
for dep in req.get(f, ()):
|
||||||
|
out.add(dep)
|
||||||
|
return list(out)
|
||||||
|
|
||||||
|
def _clean_css_value(prop: str, raw: str) -> str | None:
|
||||||
|
v = raw.strip()
|
||||||
|
|
||||||
|
v = v.replace("!important", "")
|
||||||
|
low = v.lower()
|
||||||
|
if any(bad in low for bad in ("url(", "expression(", "javascript:", "var(")):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if prop in {"width","height","min-width","min-height","max-width","max-height",
|
||||||
|
"margin","margin-top","margin-right","margin-bottom","margin-left",
|
||||||
|
"padding","padding-top","padding-right","padding-bottom","padding-left",
|
||||||
|
"border-width","border-top","border-right","border-bottom","border-left","border-radius",
|
||||||
|
"line-height","font-size"}:
|
||||||
|
return v if P_LEN.match(v) else None
|
||||||
|
|
||||||
|
if prop in {"gap"}:
|
||||||
|
parts = [p.strip() for p in v.split()]
|
||||||
|
if 1 <= len(parts) <= 2 and all(P_GAP.match(p) for p in parts):
|
||||||
|
return " ".join(parts)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if prop in {"color", "background-color", "border-color"}:
|
||||||
|
return v if P_COLOR.match(v) else None
|
||||||
|
|
||||||
|
if prop in _ENUMS:
|
||||||
|
return v if v.lower() in _ENUMS[prop] else None
|
||||||
|
|
||||||
|
if prop == "flex":
|
||||||
|
toks = v.split()
|
||||||
|
if len(toks) == 1 and (toks[0].isdigit() or toks[0] in {"auto", "none"}):
|
||||||
|
return v
|
||||||
|
if len(toks) == 2 and toks[0].isdigit() and (toks[1].isdigit() or toks[1] == "auto"):
|
||||||
|
return v
|
||||||
|
if len(toks) == 3 and toks[0].isdigit() and toks[1].isdigit() and (P_LEN.match(toks[2]) or toks[2] == "auto"):
|
||||||
|
return " ".join(toks)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if prop == "border":
|
||||||
|
parts = v.split()
|
||||||
|
bw = next((p for p in parts if P_LEN.match(p)), None)
|
||||||
|
bs = next((p for p in parts if p in _ENUMS["border-style"]), None)
|
||||||
|
bc = next((p for p in parts if P_COLOR.match(p)), None)
|
||||||
|
chosen = [x for x in (bw, bs, bc) if x]
|
||||||
|
return " ".join(chosen) if chosen else None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _sanitize_style(style: str | None) -> str | None:
|
||||||
|
if not style or not isinstance(style, str):
|
||||||
|
return None
|
||||||
|
safe_decls = []
|
||||||
|
for chunk in style.split(";"):
|
||||||
|
if not chunk.strip():
|
||||||
|
continue
|
||||||
|
if ":" not in chunk:
|
||||||
|
continue
|
||||||
|
prop, val = chunk.split(":", 1)
|
||||||
|
prop = prop.strip().lower()
|
||||||
|
if prop not in _SAFE_CSS_PROPS:
|
||||||
|
continue
|
||||||
|
clean = _clean_css_value(prop, val)
|
||||||
|
if clean is not None and clean != "":
|
||||||
|
safe_decls.append(f"{prop}: {clean}")
|
||||||
|
return "; ".join(safe_decls) if safe_decls else None
|
||||||
|
|
||||||
|
def _is_column_attr(attr) -> bool:
|
||||||
|
try:
|
||||||
|
return isinstance(attr, InstrumentedAttribute) and isinstance(attr.property, ColumnProperty)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _is_relationship_attr(attr) -> bool:
|
||||||
|
try:
|
||||||
|
return isinstance(attr, InstrumentedAttribute) and isinstance(attr.property, RelationshipProperty)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _get_attr_deps(model_cls, attr_name: str, extra_deps: Optional[dict] = None) -> list[str]:
|
||||||
|
"""Merge model-level and per-field declared deps for a computed attr."""
|
||||||
|
model_deps = getattr(model_cls, "__crudkit_field_requires__", {}) or {}
|
||||||
|
field_deps = (extra_deps or {})
|
||||||
|
return list(model_deps.get(attr_name, [])) + list(field_deps.get(attr_name, []))
|
||||||
|
|
||||||
def _get_loaded_attr(obj: Any, name: str) -> Any:
|
def _get_loaded_attr(obj: Any, name: str) -> Any:
|
||||||
"""
|
"""
|
||||||
Return obj.<name> only if it is already loaded.
|
Return obj.<name> only if it is already loaded.
|
||||||
|
|
@ -34,15 +173,30 @@ def _get_loaded_attr(obj: Any, name: str) -> Any:
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
st = inspect(obj)
|
st = inspect(obj)
|
||||||
|
# 1) Mapped attribute?
|
||||||
attr = st.attrs.get(name)
|
attr = st.attrs.get(name)
|
||||||
if attr is not None:
|
if attr is not None:
|
||||||
val = attr.loaded_value
|
val = attr.loaded_value
|
||||||
return None if val is NO_VALUE else val
|
return None if val is NO_VALUE else val
|
||||||
|
# 2) Already present value (e.g., eager-loaded or set on the dict)?
|
||||||
if name in st.dict:
|
if name in st.dict:
|
||||||
return st.dict.get(name)
|
return st.dict.get(name)
|
||||||
|
# 3) If object is detached or attr is not mapped, DO NOT eval hybrids
|
||||||
|
# or descriptors that could lazy-load. That would explode.
|
||||||
|
if st.session is None:
|
||||||
|
return None
|
||||||
|
# 4) As a last resort on attached instances only, try simple getattr,
|
||||||
|
# but guard against DetachedInstanceError anyway.
|
||||||
|
try:
|
||||||
return getattr(obj, name, None)
|
return getattr(obj, name, None)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
return None
|
||||||
|
except Exception:
|
||||||
|
# If we can't even inspect it, be conservative
|
||||||
|
try:
|
||||||
return getattr(obj, name, None)
|
return getattr(obj, name, None)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
def _normalize_rows_layout(layout: Optional[List[dict]]) -> Dict[str, dict]:
|
def _normalize_rows_layout(layout: Optional[List[dict]]) -> Dict[str, dict]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -182,6 +336,11 @@ def _sanitize_attrs(attrs: Any) -> dict[str, Any]:
|
||||||
elif isinstance(v, str):
|
elif isinstance(v, str):
|
||||||
if len(v) > 512:
|
if len(v) > 512:
|
||||||
v = v[:512]
|
v = v[:512]
|
||||||
|
if k == "style":
|
||||||
|
sv = _sanitize_style(v)
|
||||||
|
if sv:
|
||||||
|
out["style"] = sv
|
||||||
|
continue
|
||||||
if k.startswith("data-") or k.startswith("aria-") or k in _ALLOWED_ATTRS:
|
if k.startswith("data-") or k.startswith("aria-") or k in _ALLOWED_ATTRS:
|
||||||
if isinstance(v, bool):
|
if isinstance(v, bool):
|
||||||
if v:
|
if v:
|
||||||
|
|
@ -230,28 +389,64 @@ def _value_label_for_field(field: dict, mapper, values_map: dict, instance, sess
|
||||||
or "id"
|
or "id"
|
||||||
)
|
)
|
||||||
|
|
||||||
if rel_obj is not None and not _has_label_bits_loaded(rel_obj, label_spec) and session is not None and rid is not None:
|
if rel_obj is not None and session is not None and rid is not None:
|
||||||
mdl = rel_prop.mapper.class_
|
mdl = rel_prop.mapper.class_
|
||||||
simple_cols, rel_paths = _extract_label_requirements(label_spec)
|
|
||||||
|
# Work out exactly what the label needs (columns + rel paths),
|
||||||
|
# expanding model-level and per-field deps (for hybrids etc.)
|
||||||
|
simple_cols, rel_paths = _extract_label_requirements(
|
||||||
|
label_spec,
|
||||||
|
model_cls=mdl,
|
||||||
|
extra_deps=field.get("label_deps")
|
||||||
|
)
|
||||||
|
|
||||||
|
# If the currently-attached object doesn't have what we need, do one lean requery
|
||||||
|
if not _has_label_bits_loaded(rel_obj, label_spec):
|
||||||
q = session.query(mdl)
|
q = session.query(mdl)
|
||||||
cols = [getattr(mdl, "id")]
|
|
||||||
|
# only real columns in load_only
|
||||||
|
cols = []
|
||||||
|
id_attr = getattr(mdl, "id", None)
|
||||||
|
if _is_column_attr(id_attr):
|
||||||
|
cols.append(id_attr)
|
||||||
for c in simple_cols:
|
for c in simple_cols:
|
||||||
if hasattr(mdl, c):
|
a = getattr(mdl, c, None)
|
||||||
cols.append(getattr(mdl, c))
|
if _is_column_attr(a):
|
||||||
|
cols.append(a)
|
||||||
if cols:
|
if cols:
|
||||||
q = q.options(load_only(*cols))
|
q = q.options(load_only(*cols))
|
||||||
|
|
||||||
|
# selectinload relationships; "__all__" means just eager the relationship object
|
||||||
for rel_name, col_name in rel_paths:
|
for rel_name, col_name in rel_paths:
|
||||||
try:
|
rel_ia = getattr(mdl, rel_name, None)
|
||||||
t_rel = mdl.__mapper__.relationships[rel_name]
|
if rel_ia is None:
|
||||||
t_cls = t_rel.mapper.class_
|
continue
|
||||||
col_attr = getattr(t_cls, col_name, None)
|
opt = selectinload(rel_ia)
|
||||||
opt = selectinload(getattr(mdl, rel_name))
|
if col_name == "__all__":
|
||||||
q = q.options(opt.load_only(col_attr) if col_attr is not None else opt)
|
q = q.options(opt)
|
||||||
except Exception:
|
else:
|
||||||
q = q.options(selectinload(getattr(mdl, rel_name)))
|
t_cls = mdl.__mapper__.relationships[rel_name].mapper.class_
|
||||||
|
t_attr = getattr(t_cls, col_name, None)
|
||||||
|
q = q.options(opt.load_only(t_attr) if _is_column_attr(t_attr) else opt)
|
||||||
|
|
||||||
rel_obj = q.get(rid)
|
rel_obj = q.get(rid)
|
||||||
|
|
||||||
if rel_obj is not None:
|
if rel_obj is not None:
|
||||||
return _label_from_obj(rel_obj, label_spec)
|
try:
|
||||||
|
s = _label_from_obj(rel_obj, label_spec)
|
||||||
|
except Exception:
|
||||||
|
s = None
|
||||||
|
# If we couldn't safely render and we have a session+id, do one lean retry.
|
||||||
|
if (s is None or s == "") and session is not None and rid is not None:
|
||||||
|
mdl = rel_prop.mapper.class_
|
||||||
|
try:
|
||||||
|
rel_obj2 = session.get(mdl, rid) # attached instance
|
||||||
|
s2 = _label_from_obj(rel_obj2, label_spec)
|
||||||
|
if s2:
|
||||||
|
return s2
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return s
|
||||||
return str(rid) if rid is not None else None
|
return str(rid) if rid is not None else None
|
||||||
|
|
||||||
class _SafeObj:
|
class _SafeObj:
|
||||||
|
|
@ -333,43 +528,42 @@ def _rel_for_id_name(mapper, name: str) -> tuple[Optional[str], Optional[Relatio
|
||||||
return (name, prop) if prop else (None, None)
|
return (name, prop) if prop else (None, None)
|
||||||
|
|
||||||
def _fk_options(session, related_model, label_spec):
|
def _fk_options(session, related_model, label_spec):
|
||||||
simple_cols, rel_paths = _extract_label_requirements(label_spec)
|
simple_cols, rel_paths = _extract_label_requirements(label_spec, related_model)
|
||||||
q = session.query(related_model)
|
q = session.query(related_model)
|
||||||
|
|
||||||
col_attrs = []
|
col_attrs = []
|
||||||
if hasattr(related_model, "id"):
|
if hasattr(related_model, "id"):
|
||||||
col_attrs.append(getattr(related_model, "id"))
|
id_attr = getattr(related_model, "id")
|
||||||
|
if _is_column_attr(id_attr):
|
||||||
|
col_attrs.append(id_attr)
|
||||||
|
|
||||||
for name in simple_cols:
|
for name in simple_cols:
|
||||||
if hasattr(related_model, name):
|
attr = getattr(related_model, name, None)
|
||||||
col_attrs.append(getattr(related_model, name))
|
if _is_column_attr(attr):
|
||||||
|
col_attrs.append(attr)
|
||||||
if col_attrs:
|
if col_attrs:
|
||||||
q = q.options(load_only(*col_attrs))
|
q = q.options(load_only(*col_attrs))
|
||||||
|
|
||||||
for rel_name, col_name in rel_paths:
|
for rel_name, col_name in rel_paths:
|
||||||
rel_prop = getattr(related_model, rel_name, None)
|
rel_attr = getattr(related_model, rel_name, None)
|
||||||
if rel_prop is None:
|
if rel_attr is None:
|
||||||
continue
|
continue
|
||||||
try:
|
opt = selectinload(rel_attr)
|
||||||
|
if col_name == "__all__":
|
||||||
|
q = q.options(opt)
|
||||||
|
else:
|
||||||
target_cls = related_model.__mapper__.relationships[rel_name].mapper.class_
|
target_cls = related_model.__mapper__.relationships[rel_name].mapper.class_
|
||||||
col_attr = getattr(target_cls, col_name, None)
|
col_attr = getattr(target_cls, col_name, None)
|
||||||
if col_attr is None:
|
q = q.options(opt.load_only(col_attr) if _is_column_attr(col_attr) else opt)
|
||||||
q = q.options(selectinload(rel_prop))
|
|
||||||
else:
|
|
||||||
q = q.options(selectinload(rel_prop).load_only(col_attr))
|
|
||||||
except Exception:
|
|
||||||
q = q.options(selectinload(rel_prop))
|
|
||||||
|
|
||||||
if simple_cols:
|
if simple_cols:
|
||||||
first = simple_cols[0]
|
first = simple_cols[0]
|
||||||
if hasattr(related_model, first):
|
if hasattr(related_model, first):
|
||||||
q = q.order_by(getattr(related_model, first))
|
q = q.order_by(None).order_by(getattr(related_model, first))
|
||||||
|
|
||||||
rows = q.all()
|
rows = q.all()
|
||||||
return [
|
return [
|
||||||
{
|
{'value': getattr(opt, 'id'), 'label': _label_from_obj(opt, label_spec)}
|
||||||
'value': getattr(opt, 'id'),
|
|
||||||
'label': _label_from_obj(opt, label_spec),
|
|
||||||
}
|
|
||||||
for opt in rows
|
for opt in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -427,45 +621,67 @@ def _normalize_field_spec(spec, mapper, session, label_specs_model_default):
|
||||||
|
|
||||||
return field
|
return field
|
||||||
|
|
||||||
def _extract_label_requirements(spec: Any) -> tuple[list[str], list[tuple[str, str]]]:
|
def _extract_label_requirements(
|
||||||
|
spec: Any,
|
||||||
|
model_cls: Any = None,
|
||||||
|
extra_deps: Optional[Dict[str, List[str]]] = None
|
||||||
|
) -> tuple[list[str], list[tuple[str, str]]]:
|
||||||
"""
|
"""
|
||||||
From a label spec, return:
|
Returns:
|
||||||
- simple_cols: ["name", "code"]
|
simple_cols: ["name", "code", "label", ...] (non-dotted names; may include non-columns)
|
||||||
- rel_paths: [("room_function", "description"), ("owner", "last_name")]
|
rel_paths: [("room_function", "description"), ("brand", "__all__"), ...]
|
||||||
|
- ("rel", "__all__") means: just eager the relationship (no specific column)
|
||||||
|
Also expands dependencies declared by the model or the field (extra_deps).
|
||||||
"""
|
"""
|
||||||
simple_cols: list[str] = []
|
simple_cols: list[str] = []
|
||||||
rel_paths: list[tuple[str, str]] = []
|
rel_paths: list[tuple[str, str]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
|
||||||
def ingest(token: str) -> None:
|
def add_dep_token(token: str) -> None:
|
||||||
token = str(token).strip()
|
"""Add a concrete dependency token (column or 'rel' or 'rel.col')."""
|
||||||
if not token:
|
if not token or token in seen:
|
||||||
return
|
return
|
||||||
|
seen.add(token)
|
||||||
|
|
||||||
if "." in token:
|
if "." in token:
|
||||||
rel, col = token.split(".", 1)
|
rel, col = token.split(".", 1)
|
||||||
if rel and col:
|
if rel and col:
|
||||||
rel_paths.append((rel, col))
|
rel_paths.append((rel, col))
|
||||||
else:
|
return
|
||||||
|
|
||||||
|
# bare token: could be column, relationship, or computed
|
||||||
simple_cols.append(token)
|
simple_cols.append(token)
|
||||||
|
|
||||||
if spec is None or callable(spec):
|
# If this is not obviously a column, try pulling declared deps.
|
||||||
return simple_cols, rel_paths
|
if model_cls is not None:
|
||||||
|
attr = getattr(model_cls, token, None)
|
||||||
|
if _is_column_attr(attr):
|
||||||
|
return
|
||||||
|
# If it's a relationship, we want to eager the relationship itself.
|
||||||
|
if _is_relationship_attr(attr):
|
||||||
|
rel_paths.append((token, "__all__"))
|
||||||
|
return
|
||||||
|
# Not a column/relationship => computed (hybrid/descriptor/etc.)
|
||||||
|
for dep in _get_attr_deps(model_cls, token, extra_deps):
|
||||||
|
add_dep_token(dep)
|
||||||
|
|
||||||
if isinstance(spec, (list, tuple)):
|
def add_from_spec(piece: Any) -> None:
|
||||||
for a in spec:
|
if piece is None or callable(piece):
|
||||||
ingest(a)
|
return
|
||||||
return simple_cols, rel_paths
|
if isinstance(piece, (list, tuple)):
|
||||||
|
for a in piece:
|
||||||
if isinstance(spec, str):
|
add_from_spec(a)
|
||||||
# format string like "{first} {last}" or "{room_function.description} · {name}"
|
return
|
||||||
if "{" in spec and "}" in spec:
|
s = str(piece)
|
||||||
names = re.findall(r"{\s*([^}:\s]+)", spec)
|
if "{" in s and "}" in s:
|
||||||
for n in names:
|
for n in re.findall(r"{\s*([^}:\s]+)", s):
|
||||||
ingest(n)
|
add_dep_token(n)
|
||||||
else:
|
else:
|
||||||
ingest(spec)
|
add_dep_token(s)
|
||||||
|
|
||||||
|
add_from_spec(spec)
|
||||||
return simple_cols, rel_paths
|
return simple_cols, rel_paths
|
||||||
|
|
||||||
return simple_cols, rel_paths
|
|
||||||
|
|
||||||
def _label_from_obj(obj: Any, spec: Any) -> str:
|
def _label_from_obj(obj: Any, spec: Any) -> str:
|
||||||
if obj is None:
|
if obj is None:
|
||||||
|
|
@ -496,7 +712,10 @@ def _label_from_obj(obj: Any, spec: Any) -> str:
|
||||||
for f in fields:
|
for f in fields:
|
||||||
root = f.split(".", 1)[0]
|
root = f.split(".", 1)[0]
|
||||||
if root not in data:
|
if root not in data:
|
||||||
|
try:
|
||||||
data[root] = _SafeObj(_get_loaded_attr(obj, root))
|
data[root] = _SafeObj(_get_loaded_attr(obj, root))
|
||||||
|
except Exception:
|
||||||
|
data[root] = _SafeObj(None)
|
||||||
try:
|
try:
|
||||||
return spec.format(**data)
|
return spec.format(**data)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -633,26 +852,36 @@ def _format_value(val: Any, fmt: Optional[str]) -> Any:
|
||||||
return val
|
return val
|
||||||
return val
|
return val
|
||||||
|
|
||||||
def _has_label_bits_loaded(obj: Any, label_spec: Any) -> bool:
|
def _has_label_bits_loaded(obj, label_spec) -> bool:
|
||||||
try:
|
try:
|
||||||
st = inspect(obj)
|
st = inspect(obj)
|
||||||
except Exception:
|
except Exception:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
simple_cols, rel_paths = _extract_label_requirements(label_spec)
|
simple_cols, rel_paths = _extract_label_requirements(label_spec, type(obj))
|
||||||
for c in simple_cols:
|
|
||||||
if c not in st.dict:
|
# concrete columns on the object
|
||||||
|
for name in simple_cols:
|
||||||
|
a = getattr(type(obj), name, None)
|
||||||
|
if _is_column_attr(a) and name not in st.dict:
|
||||||
return False
|
return False
|
||||||
for rel, col in rel_paths:
|
# non-column tokens (hybrids/descriptors) are satisfied by their deps above
|
||||||
ra = st.attrs.get(rel)
|
|
||||||
if ra is None or ra.loaded_value is NO_VALUE or ra.loaded_value is None:
|
# relationships
|
||||||
|
for rel_name, col_name in rel_paths:
|
||||||
|
ra = st.attrs.get(rel_name)
|
||||||
|
if ra is None or ra.loaded_value in (NO_VALUE, None):
|
||||||
return False
|
return False
|
||||||
|
if col_name == "__all__":
|
||||||
|
continue # relationship object present is enough
|
||||||
try:
|
try:
|
||||||
t_st = inspect(ra.loaded_value)
|
t_st = inspect(ra.loaded_value)
|
||||||
if col not in t_st.dict:
|
|
||||||
return False
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
t_attr = getattr(type(ra.loaded_value), col_name, None)
|
||||||
|
if _is_column_attr(t_attr) and col_name not in t_st.dict:
|
||||||
|
return False
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _class_for(val: Any, classes: Optional[Dict[str, str]]) -> Optional[str]:
|
def _class_for(val: Any, classes: Optional[Dict[str, str]]) -> Optional[str]:
|
||||||
|
|
@ -661,6 +890,30 @@ def _class_for(val: Any, classes: Optional[Dict[str, str]]) -> Optional[str]:
|
||||||
key = "none" if val is None else str(val).lower()
|
key = "none" if val is None else str(val).lower()
|
||||||
return classes.get(key, classes.get("default"))
|
return classes.get(key, classes.get("default"))
|
||||||
|
|
||||||
|
def _format_label_from_values(spec: Any, values: dict) -> Optional[str]:
|
||||||
|
if not spec:
|
||||||
|
return None
|
||||||
|
if isinstance(spec, (list, tuple)):
|
||||||
|
parts = []
|
||||||
|
for a in spec:
|
||||||
|
v = _deep_get(values, str(a))
|
||||||
|
parts.append("" if v is None else str(v))
|
||||||
|
return " ".join(p for p in parts if p)
|
||||||
|
|
||||||
|
s = str(spec)
|
||||||
|
if "{" in s and "}" in s:
|
||||||
|
names = re.findall(r"{\s*([^}:\s]+)", s)
|
||||||
|
data = {n: _deep_get(values, n) for n in names}
|
||||||
|
# wrap for safe .format()
|
||||||
|
data = {k: ("" if v is None else v) for k, v in data.items()}
|
||||||
|
try:
|
||||||
|
return s.format(**data)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
# simple field name
|
||||||
|
v = _deep_get(values, s)
|
||||||
|
return "" if v is None else str(v)
|
||||||
|
|
||||||
def _build_href(spec: Dict[str, Any], row: Dict[str, Any], obj) -> Optional[str]:
|
def _build_href(spec: Dict[str, Any], row: Dict[str, Any], obj) -> Optional[str]:
|
||||||
if not spec:
|
if not spec:
|
||||||
return None
|
return None
|
||||||
|
|
@ -677,8 +930,9 @@ def _build_href(spec: Dict[str, Any], row: Dict[str, Any], obj) -> Optional[str]
|
||||||
if any(v is None for v in params.values()):
|
if any(v is None for v in params.values()):
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return url_for('crudkit.' + spec["endpoint"], **params)
|
return url_for(spec["endpoint"], **params)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
print(f"Cannot create endpoint for {spec['endpoint']}: {str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _humanize(field: str) -> str:
|
def _humanize(field: str) -> str:
|
||||||
|
|
@ -725,7 +979,6 @@ def get_crudkit_template(env, name):
|
||||||
|
|
||||||
def render_field(field, value):
|
def render_field(field, value):
|
||||||
env = get_env()
|
env = get_env()
|
||||||
print(field)
|
|
||||||
|
|
||||||
# 1) custom template field
|
# 1) custom template field
|
||||||
field_type = field.get('type', 'text')
|
field_type = field.get('type', 'text')
|
||||||
|
|
@ -911,9 +1164,17 @@ def render_form(
|
||||||
f["template_ctx"] = base
|
f["template_ctx"] = base
|
||||||
|
|
||||||
for f in fields:
|
for f in fields:
|
||||||
|
# existing FK label resolution
|
||||||
vl = _value_label_for_field(f, mapper, values_map, instance, session)
|
vl = _value_label_for_field(f, mapper, values_map, instance, session)
|
||||||
if vl is not None:
|
if vl is not None:
|
||||||
f["value_label"] = vl
|
f["value_label"] = vl
|
||||||
|
# NEW: if not a relationship but a label_spec is provided, format from values
|
||||||
|
elif f.get("label_spec"):
|
||||||
|
base, rel_prop = _rel_for_id_name(mapper, f["name"])
|
||||||
|
if not rel_prop: # scalar field
|
||||||
|
vl2 = _format_label_from_values(f["label_spec"], values_map)
|
||||||
|
if vl2 is not None:
|
||||||
|
f["value_label"] = vl2
|
||||||
|
|
||||||
# Build rows (supports nested layout with parents)
|
# Build rows (supports nested layout with parents)
|
||||||
rows_map = _normalize_rows_layout(layout)
|
rows_map = _normalize_rows_layout(layout)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
{# show label unless hidden/custom #}
|
{# show label unless hidden/custom #}
|
||||||
|
<!-- {{ field_name }} (field) -->
|
||||||
{% if field_type != 'hidden' and field_label %}
|
{% if field_type != 'hidden' and field_label %}
|
||||||
<label for="{{ field_name }}"
|
<label for="{{ field_name }}"
|
||||||
{% if label_attrs %}{% for k,v in label_attrs.items() %}
|
{% if label_attrs %}{% for k,v in label_attrs.items() %}
|
||||||
|
|
@ -47,6 +48,24 @@
|
||||||
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||||
{% endfor %}{% endif %}>{{ value_label if value_label else (value if value else "") }}</div>
|
{% endfor %}{% endif %}>{{ value_label if value_label else (value if value else "") }}</div>
|
||||||
|
|
||||||
|
{% elif field_type == "date" %}
|
||||||
|
<input type="date" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
|
||||||
|
{% if attrs %}{% for k,v in attrs.items() %}
|
||||||
|
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||||
|
{% endfor %}{% endif %}>
|
||||||
|
|
||||||
|
{% elif field_type == "time" %}
|
||||||
|
<input type="time" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
|
||||||
|
{% if attrs %}{% for k,v in attrs.items() %}
|
||||||
|
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||||
|
{% endfor %}{% endif %}>
|
||||||
|
|
||||||
|
{% elif field_type == "datetime" %}
|
||||||
|
<input type="datetime-local" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
|
||||||
|
{% if attrs %}{% for k,v in attrs.items() %}
|
||||||
|
{{k}}{% if v is not sameas true %}="{{ v }}"{% endif %}
|
||||||
|
{% endfor %}{% endif %}>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<input type="text" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
|
<input type="text" name="{{ field_name }}" id="{{ field_name }}" value="{{ value if value else "" }}"
|
||||||
{% if attrs %}{% for k,v in attrs.items() %}
|
{% if attrs %}{% for k,v in attrs.items() %}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<form method="POST">
|
<form method="POST">
|
||||||
{% macro render_row(row) %}
|
{% macro render_row(row) %}
|
||||||
<!-- {{ row.name }} -->
|
<!-- {{ row.name }} (row) -->
|
||||||
{% if row.fields or row.children or row.legend %}
|
{% if row.fields or row.children or row.legend %}
|
||||||
{% if row.legend %}<legend>{{ row.legend }}</legend>{% endif %}
|
{% if row.legend %}<legend>{{ row.legend }}</legend>{% endif %}
|
||||||
<fieldset
|
<fieldset
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue