Additional fixes and expansions on field dependencies. Still a WIP.

This commit is contained in:
Yaro Kasear 2025-09-23 16:00:40 -05:00
parent 023acfaafe
commit 515eb27fe0
7 changed files with 419 additions and 5 deletions

View file

@ -1,8 +1,21 @@
from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func
from sqlalchemy.orm import declarative_mixin, declarative_base
from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func, inspect
from sqlalchemy.orm import declarative_mixin, declarative_base, NO_VALUE
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
class CRUDMixin:
id = Column(Integer, primary_key=True)

View file

@ -3,6 +3,7 @@ from sqlalchemy import and_, func, inspect, or_, text
from sqlalchemy.engine import Engine, Connection
from sqlalchemy.orm import Load, Session, raiseload, selectinload, with_polymorphic, Mapper, RelationshipProperty, class_mapper, ColumnProperty
from sqlalchemy.orm.attributes import InstrumentedAttribute
from sqlalchemy.orm.base import NO_VALUE
from sqlalchemy.orm.util import AliasedClass
from sqlalchemy.sql import operators
from sqlalchemy.sql.elements import UnaryExpression
@ -12,6 +13,39 @@ from crudkit.core.spec import CRUDSpec
from crudkit.core.types import OrderSpec, SeekWindow
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)
@ -232,7 +266,10 @@ class CRUDService(Generic[T]):
- forward/backward seek via `key` and `backward`
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()
spec = CRUDSpec(self.model, params or {}, root_alias)

236
crudkit/projection.py Normal file
View 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

View file

@ -19,6 +19,52 @@ _ALLOWED_ATTRS = {
"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():
app = current_app
default_path = os.path.join(os.path.dirname(__file__), 'templates')
@ -28,6 +74,79 @@ def get_env():
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)
@ -217,6 +336,11 @@ def _sanitize_attrs(attrs: Any) -> dict[str, Any]:
elif isinstance(v, str):
if len(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 isinstance(v, bool):
if v:

View file

@ -19,7 +19,7 @@ class User(Base, CRUDMixin):
__tablename__ = 'users'
__crud_label__ = "{label}"
__crudkit_field_requires__ = {
"label": ["first_name", "last_name", "title"] # whatever the hybrid touches
"label": ["first_name", "last_name", "title"]
}
first_name: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True, index=True)

View file

@ -54,7 +54,7 @@ def init_entry_routes(app):
"wrap": {"class": "h-100 w-100"}},
{"name": "notes", "type": "textarea", "label": "Notes", "row": "notes", "wrap": {"class": "col"},
"attrs": {"class": "form-control", "rows": 10}, "label_attrs": {"class": "form-label"}},
"attrs": {"class": "form-control", "rows": 10, "style": "resize: none;"}, "label_attrs": {"class": "form-label"}},
]
layout = [
{"name": "label", "order": 5},

View file

@ -33,6 +33,10 @@
</ul>
{% block header %}
{% endblock %}
<div class="d-flex">
<input type="text" id="search" class="form-control me-3">
<button type="button" class="btn btn-primary" disabled>Search</button>
</div>
</div>
</nav>
</header>