From 97891961e12059838cb7754b09c7df6ffa8e5603 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Fri, 26 Sep 2025 14:59:33 -0500 Subject: [PATCH] Serialization is smarter! --- crudkit/core/base.py | 178 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 157 insertions(+), 21 deletions(-) diff --git a/crudkit/core/base.py b/crudkit/core/base.py index 5501e19..51d9b71 100644 --- a/crudkit/core/base.py +++ b/crudkit/core/base.py @@ -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.orm import declarative_mixin, declarative_base, NO_VALUE @@ -16,6 +17,99 @@ def _safe_get_loaded_attr(obj, name): except Exception: 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 class CRUDMixin: id = Column(Integer, primary_key=True) @@ -25,36 +119,78 @@ class CRUDMixin: def as_dict(self, fields: list[str] | None = None): """ 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: - out = {} - if "id" not in fields and hasattr(self, "id"): - out["id"] = getattr(self, "id") - for f in fields: - cur = self - for part in f.split("."): - if cur is None: - break - cur = getattr(cur, part, None) - out[f] = cur + Behavior: + - If 'fields' (possibly dotted) is provided, emit exactly those keys. + * 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") + except Exception: + pass + + for name in scalars: + # Try loaded value first (never lazy-load) + val = _safe_get_loaded_attr(self, name) + + # 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 - result = {} + # Fallback: all mapped columns on this class hierarchy + result: Dict[str, Any] = {} for cls in self.__class__.__mro__: if hasattr(cls, "__table__"): for column in cls.__table__.columns: name = column.name - result[name] = getattr(self, name) + try: + result[name] = getattr(self, name) + except Exception: + result[name] = None return result - class Version(Base): __tablename__ = "versions"