Compare commits
2 commits
a3f2c794f5
...
515eb27fe0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
515eb27fe0 | ||
|
|
023acfaafe |
8 changed files with 419 additions and 6 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ bp_listing = Blueprint("listing", __name__)
|
|||
def init_listing_routes(app):
|
||||
@bp_listing.get("/listing/<model>")
|
||||
def show_list(model):
|
||||
page_num = int(request.args.get("page", 1))
|
||||
if model.lower() not in {"inventory", "user", "worklog"}:
|
||||
abort(404)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue