Serialization is smarter!
This commit is contained in:
parent
7db182041e
commit
97891961e1
1 changed files with 157 additions and 21 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
from typing import Any, Dict, Iterable, List, Tuple
|
||||||
from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func, inspect
|
from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func, inspect
|
||||||
from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE
|
from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE
|
||||||
|
|
||||||
|
|
@ -16,6 +17,99 @@ def _safe_get_loaded_attr(obj, name):
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _split_field_tokens(fields: Iterable[str]) -> Tuple[List[str], Dict[str, List[str]]]:
|
||||||
|
"""
|
||||||
|
Split requested fields into:
|
||||||
|
- scalars: ["label", "name"]
|
||||||
|
- collections: {"updates": ["id", "timestamp","content"], "owner": ["label"]}
|
||||||
|
Any dotted token "root.rest.of.path" becomes collections[root].append("rest.of.path").
|
||||||
|
Bare tokens ("foo") land in scalars.
|
||||||
|
"""
|
||||||
|
scalars: List[str] = []
|
||||||
|
groups: Dict[str, List[str]] = {}
|
||||||
|
for raw in fields:
|
||||||
|
f = str(raw).strip()
|
||||||
|
if not f:
|
||||||
|
continue
|
||||||
|
# bare token -> scalar
|
||||||
|
if "." not in f:
|
||||||
|
scalars.append(f)
|
||||||
|
continue
|
||||||
|
# dotted token -> group under root
|
||||||
|
root, tail = f.split(".", 1)
|
||||||
|
if not root or not tail:
|
||||||
|
continue
|
||||||
|
groups.setdefault(root, []).append(tail)
|
||||||
|
return scalars, groups
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
parts = dotted.split(".")
|
||||||
|
if not parts:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cur = obj
|
||||||
|
# Traverse up to the parent of the last token safely
|
||||||
|
for part in parts[:-1]:
|
||||||
|
if cur is None:
|
||||||
|
return None
|
||||||
|
cur = _safe_get_loaded_attr(cur, part)
|
||||||
|
if cur is None:
|
||||||
|
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:
|
||||||
|
return None
|
||||||
|
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
if obj is None:
|
||||||
|
return None
|
||||||
|
ad = getattr(obj, "as_dict", None)
|
||||||
|
if callable(ad):
|
||||||
|
try:
|
||||||
|
return ad(None)
|
||||||
|
except Exception:
|
||||||
|
return str(obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def _serialize_collection(items: Iterable[Any], requested_tails: List[str]) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Turn a collection of ORM objects into list[dict] with exactly requested_tails,
|
||||||
|
where each tail can be dotted again (e.g., "author.label"). We do NOT lazy-load.
|
||||||
|
"""
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
# Deduplicate while preserving order
|
||||||
|
uniq_tails = list(dict.fromkeys(requested_tails))
|
||||||
|
for child in (items or []):
|
||||||
|
row: Dict[str, Any] = {}
|
||||||
|
for tail in uniq_tails:
|
||||||
|
row[tail] = _deep_get_loaded(child, tail)
|
||||||
|
# ensure id present if exists and not already requested
|
||||||
|
try:
|
||||||
|
if "id" not in row and hasattr(child, "id"):
|
||||||
|
row["id"] = getattr(child, "id")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
@declarative_mixin
|
@declarative_mixin
|
||||||
class CRUDMixin:
|
class CRUDMixin:
|
||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
|
|
@ -25,36 +119,78 @@ class CRUDMixin:
|
||||||
def as_dict(self, fields: list[str] | None = None):
|
def as_dict(self, fields: list[str] | None = None):
|
||||||
"""
|
"""
|
||||||
Serialize the instance.
|
Serialize the instance.
|
||||||
- If 'fields' (possibly dotted) is provided, emit exactly those keys.
|
|
||||||
- Else, if '__crudkit_projection__' is set on the instance, emit those keys.
|
|
||||||
- Else, fall back to all mapped columns on this class hierarchy.
|
|
||||||
Always includes 'id' when present unless explicitly excluded.
|
|
||||||
"""
|
|
||||||
if fields is None:
|
|
||||||
fields = getattr(self, "__crudkit_projection__", None)
|
|
||||||
|
|
||||||
if fields:
|
Behavior:
|
||||||
out = {}
|
- If 'fields' (possibly dotted) is provided, emit exactly those keys.
|
||||||
if "id" not in fields and hasattr(self, "id"):
|
* Bare tokens (e.g., "label", "owner") return the current loaded value.
|
||||||
|
* Dotted tokens for one-to-many (e.g., "updates.id","updates.timestamp")
|
||||||
|
produce a single "updates" key containing a list of dicts with the requested child keys.
|
||||||
|
* Dotted tokens for many-to-one/one-to-one (e.g., "owner.label") emit the scalar under "owner.label".
|
||||||
|
- Else, if '__crudkit_projection__' is set on the instance, use that.
|
||||||
|
- Else, fall back to all mapped columns on this class hierarchy.
|
||||||
|
|
||||||
|
Always includes 'id' when present unless explicitly excluded (i.e., fields explicitly provided without id).
|
||||||
|
"""
|
||||||
|
req = fields if fields is not None else getattr(self, "__crudkit_projection__", None)
|
||||||
|
|
||||||
|
if req:
|
||||||
|
# Normalize and split into (scalars, groups of dotted by root)
|
||||||
|
req_list = [p for p in (str(x).strip() for x in req) if p]
|
||||||
|
scalars, groups = _split_field_tokens(req_list)
|
||||||
|
|
||||||
|
out: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
# Always include id unless user explicitly listed fields and included id already
|
||||||
|
if "id" not in req_list and hasattr(self, "id"):
|
||||||
|
try:
|
||||||
out["id"] = getattr(self, "id")
|
out["id"] = getattr(self, "id")
|
||||||
for f in fields:
|
except Exception:
|
||||||
cur = self
|
pass
|
||||||
for part in f.split("."):
|
|
||||||
if cur is None:
|
for name in scalars:
|
||||||
break
|
# Try loaded value first (never lazy-load)
|
||||||
cur = getattr(cur, part, None)
|
val = _safe_get_loaded_attr(self, name)
|
||||||
out[f] = cur
|
|
||||||
|
# if still None, allow a final-hop getattr for root scalars
|
||||||
|
# so hybrids / @property can compute (they won't traverse relationships).
|
||||||
|
if val is None:
|
||||||
|
try:
|
||||||
|
val = getattr(self, name)
|
||||||
|
except Exception:
|
||||||
|
val = None
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
out[name] = val
|
||||||
|
|
||||||
|
# Handle dotted groups: root -> [tails]
|
||||||
|
for root, tails in groups.items():
|
||||||
|
root_val = _safe_get_loaded_attr(self, root)
|
||||||
|
if isinstance(root_val, (list, tuple)):
|
||||||
|
# one-to-many collection
|
||||||
|
out[root] = _serialize_collection(root_val, tails)
|
||||||
|
else:
|
||||||
|
# many-to-one or scalar dotted; place each full dotted path as key
|
||||||
|
for tail in tails:
|
||||||
|
dotted = f"{root}.{tail}"
|
||||||
|
out[dotted] = _deep_get_loaded(self, dotted)
|
||||||
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
result = {}
|
# Fallback: all mapped columns on this class hierarchy
|
||||||
|
result: Dict[str, Any] = {}
|
||||||
for cls in self.__class__.__mro__:
|
for cls in self.__class__.__mro__:
|
||||||
if hasattr(cls, "__table__"):
|
if hasattr(cls, "__table__"):
|
||||||
for column in cls.__table__.columns:
|
for column in cls.__table__.columns:
|
||||||
name = column.name
|
name = column.name
|
||||||
|
try:
|
||||||
result[name] = getattr(self, name)
|
result[name] = getattr(self, name)
|
||||||
|
except Exception:
|
||||||
|
result[name] = None
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
class Version(Base):
|
class Version(Base):
|
||||||
__tablename__ = "versions"
|
__tablename__ = "versions"
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue