This introduces an uglt regression.

This commit is contained in:
Yaro Kasear 2025-10-20 15:20:52 -05:00
parent ce7d092be4
commit bd2daf921a
5 changed files with 136 additions and 128 deletions

View file

@ -1,22 +1,11 @@
from functools import lru_cache
from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, cast
from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func, inspect
from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE, RelationshipProperty, Mapper
from sqlalchemy import Column, Integer, DateTime, String, JSON, func, inspect
from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE, Mapper
from sqlalchemy.orm.state import InstanceState
Base = declarative_base()
from crudkit.core.meta import column_names_for_model
@lru_cache(maxsize=512)
def _column_names_for_model(cls: type) -> tuple[str, ...]:
try:
mapper = inspect(cls)
return tuple(prop.key for prop in mapper.column_attrs)
except Exception:
names: list[str] = []
for c in cls.__mro__:
if hasattr(c, "__table__"):
names.extend(col.name for col in c.__table__.columns)
return tuple(dict.fromkeys(names))
Base = declarative_base()
def _sa_state(obj: Any) -> Optional[InstanceState[Any]]:
"""Safely get SQLAlchemy InstanceState (or None)."""
@ -60,7 +49,16 @@ def _safe_get_loaded_attr(obj, name):
if attr is not None:
val = attr.loaded_value
return None if val is NO_VALUE else val
try:
# In rare cases, state.dict may be stale; reject descriptors
got = getattr(obj, name, None)
from sqlalchemy.orm.attributes import InstrumentedAttribute as _Instr
if got is not None and not isinstance(got, _Instr):
# Do not trigger load; only return if it was already present in __dict__
if hasattr(obj, "__dict__") and name in obj.__dict__:
return got
except Exception:
pass
return None
except Exception:
return None
@ -72,16 +70,10 @@ def _identity_key(obj) -> Tuple[type, Any]:
except Exception:
return (type(obj), id(obj))
def _is_collection_rel(prop: RelationshipProperty) -> bool:
try:
return prop.uselist is True
except Exception:
return False
def _serialize_simple_obj(obj) -> Dict[str, Any]:
"""Columns only (no relationships)."""
out: Dict[str, Any] = {}
for name in _column_names_for_model(type(obj)):
for name in column_names_for_model(type(obj)):
try:
out[name] = getattr(obj, name)
except Exception:
@ -153,16 +145,16 @@ def _split_field_tokens(fields: Iterable[str]) -> Tuple[List[str], Dict[str, Lis
def _deep_get_loaded(obj: Any, dotted: str) -> Any:
"""
Deep get with no lazy loads:
- For all but the final hop, use _safe_get_loaded_attr (mapped-only, no getattr).
- For the final hop, try _safe_get_loaded_attr first; if None, fall back to getattr()
to allow computed properties/hybrids that rely on already-loaded columns.
- Walk intermediate hops via _safe_get_loaded_attr only.
- Final hop: prefer _safe_get_loaded_attr; if that returns an ORM object or a collection of ORM objects,
serialize to simple dicts; else return the plain value.
"""
parts = dotted.split(".")
if not parts:
return None
cur = obj
# Traverse up to the parent of the last token safely
# Traverse up to parent of last token without lazy-loads
for part in parts[:-1]:
if cur is None:
return None
@ -171,30 +163,45 @@ def _deep_get_loaded(obj: Any, dotted: str) -> Any:
return None
last = parts[-1]
# Try safe fetch on the last hop first
val = _safe_get_loaded_attr(cur, last)
if val is not None:
return val
# Fall back to getattr for computed/hybrid attributes on an already-loaded object
try:
return getattr(cur, last, None)
except Exception:
if val is None:
# Do NOT lazy load. If it isn't loaded, treat as absent.
return None
# If the final hop is an ORM object or collection, serialize it
if _sa_mapper(val) is not None:
return _serialize_simple_obj(val)
if isinstance(val, (list, tuple)):
out = []
for v in val:
if _sa_mapper(v) is not None:
out.append(_serialize_simple_obj(v))
else:
out.append(v)
return out
# Plain scalar/computed value
return val
def _serialize_leaf(obj: Any) -> Any:
"""
Lead serialization for values we put into as_dict():
- If object has as_dict(), call as_dict() with no args (caller controls field shapes).
- Else return value as-is (Flask/JSON encoder will handle datetimes, etc., via app config).
Leaf serialization for non-dotted scalar fields:
- If it's an ORM object with as_dict(), use it.
- Else if it's an ORM object, serialize columns only.
- Else return the value as-is.
"""
if obj is None:
return None
ad = getattr(obj, "as_dict", None)
if callable(ad):
try:
return ad(None)
except Exception:
return str(obj)
if _sa_mapper(obj) is not None:
ad = getattr(obj, "as_dict", None)
if callable(ad):
try:
return ad() # no args, your default handles fields selection
except Exception:
pass
return _serialize_simple_obj(obj)
return obj
def _serialize_collection(items: Iterable[Any], requested_tails: List[str]) -> List[Dict[str, Any]]:
@ -319,8 +326,6 @@ class CRUDMixin:
if mapper is not None:
out[name] = _serialize_simple_obj(val)
continue
# If it's a collection and no subfields were requested, emit a light list
if isinstance(val, (list, tuple)):
out[name] = [_serialize_leaf(v) for v in val]
else: