Lots of fixes coming from downstream.

This commit is contained in:
Yaro Kasear 2025-08-29 09:30:26 -05:00
parent 7de7f8df86
commit 091db0b443
5 changed files with 305 additions and 98 deletions

View file

@ -74,6 +74,17 @@ def _related_predicate(Model, path_parts, op_key, value):
# wrap at this hop using the *attribute*, not the RelationshipProperty # wrap at this hop using the *attribute*, not the RelationshipProperty
return attr.any(pred) if rel.uselist else attr.has(pred) return attr.any(pred) if rel.uselist else attr.has(pred)
def split_sort_tokens(tokens):
simple, dotted = [], []
for tok in (tokens or []):
if not tok:
continue
key = tok.lstrip("-")
if ":" in key:
key = key.split(":", 1)[0]
(dotted if "." in key else simple).append(tok)
return simple, dotted
def build_query(Model, spec: QuerySpec, eager_policy=None): def build_query(Model, spec: QuerySpec, eager_policy=None):
stmt = select(Model) stmt = select(Model)
@ -102,11 +113,25 @@ def build_query(Model, spec: QuerySpec, eager_policy=None):
continue continue
stmt = stmt.where(FILTER_OPS[op_key](col, val) if op_key else (col == val)) stmt = stmt.where(FILTER_OPS[op_key](col, val) if op_key else (col == val))
# order_by simple_sorts, _ = split_sort_tokens(spec.order_by)
for key in spec.order_by:
desc_ = key.startswith("-") for token in simple_sorts:
col = getattr(Model, key[1:] if desc_ else key) direction = "asc"
stmt = stmt.order_by(desc(col) if desc_ else asc(col)) key = token
if token.startswith("-"):
direction = "desc"
key = token[1:]
if ":" in key:
key, d = key.rsplit(":", 1)
direction = "desc" if d.lower().startswith("d") else "asc"
if "." in key:
continue
col = getattr(Model, key, None)
if col is None:
continue
stmt = stmt.order_by(desc(col) if direction == "desc" else asc(col))
if not spec.order_by and spec.page and spec.per_page: if not spec.order_by and spec.page and spec.per_page:
pk_cols = inspect(Model).primary_key pk_cols = inspect(Model).primary_key

View file

@ -1,8 +1,21 @@
from typing import List from __future__ import annotations
from typing import Iterable, List, Sequence, Set
from sqlalchemy.inspection import inspect from sqlalchemy.inspection import inspect
from sqlalchemy.orm import Load, joinedload, selectinload from sqlalchemy.orm import Load, joinedload, selectinload, RelationshipProperty
def default_eager_policy(Model, expand: List[str]) -> List[Load]: class EagerConfig:
def __init__(self, strict: bool = False, max_depth: int = 4):
self.strict = strict
self.max_depth = max_depth
def _rel(cls, name: str) -> RelationshipProperty | None:
return inspect(cls).relationships.get(name)
def _is_expandable(rel: RelationshipProperty) -> bool:
# Skip dynamic or viewonly collections; they dont support eagerload
return rel.lazy != "dynamic"
def default_eager_policy(Model, expand: Sequence[str], cfg: EagerConfig | None = None) -> List[Load]:
""" """
Heuristic: Heuristic:
- many-to-one / one-to-one: joinedload - many-to-one / one-to-one: joinedload
@ -12,31 +25,51 @@ def default_eager_policy(Model, expand: List[str]) -> List[Load]:
if not expand: if not expand:
return [] return []
cfg = cfg or EagerConfig()
# normalize, dedupe, and prefer longer paths over their prefixes
raw: Set[str] = {p.strip() for p in expand if p and p.strip()}
# drop prefixes if a longer path exists (author, author.publisher -> keep only author.publisher)
pruned: Set[str] = set(raw)
for p in raw:
parts = p.split(".")
for i in range(1, len(parts)):
pruned.discard(".".join(parts[:i]))
opts: List[Load] = [] opts: List[Load] = []
seen: Set[tuple] = set()
for path in expand: for path in sorted(pruned):
parts = path.split(".") parts = path.split(".")
if len(parts) > cfg.max_depth:
if cfg.strict:
raise ValueError(f"expand path too deep: {path} (max {cfg.max_depth})")
continue
current_model = Model current_model = Model
current_inspect = inspect(current_model) # build the chain incrementally
loader: Load | None = None
ok = True
# first hop for i, name in enumerate(parts):
rel = current_inspect.relationships.get(parts[0]) rel = _rel(current_model, name)
if not rel: if not rel or not _is_expandable(rel):
continue # silently skip bad names ok = False
attr = getattr(current_model, parts[0])
loader: Load = selectinload(attr) if rel.uselist else joinedload(attr)
current_model = rel.mapper.class_
# nested hops, if any
for name in parts[1:]:
current_inspect = inspect(current_model)
rel = current_inspect.relationships.get(name)
if not rel:
break break
attr = getattr(current_model, name) attr = getattr(current_model, name)
if loader is None:
loader = selectinload(attr) if rel.uselist else joinedload(attr)
else:
loader = loader.selectinload(attr) if rel.uselist else loader.joinedload(attr) loader = loader.selectinload(attr) if rel.uselist else loader.joinedload(attr)
current_model = rel.mapper.class_ current_model = rel.mapper.class_
if not ok:
if cfg.strict:
raise ValueError(f"unknown or non-expandable relationship in expand path: {path}")
continue
key = (tuple(parts),)
if loader is not None and key not in seen:
opts.append(loader) opts.append(loader)
seen.add(key)
return opts return opts

View file

@ -32,39 +32,69 @@
{%- endfor -%} {%- endfor -%}
{%- endmacro %} {%- endmacro %}
{# helper: centralize the query string once #}
{% macro _q(model, page, per_page, sort, filters, fields_csv) -%}
/ui/{{ model }}/frag/rows
?page={{ page }}&per_page={{ per_page }}
{%- if sort %}&sort={{ sort }}{% endif -%}
{%- if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif -%}
{%- for k, v in (filters or {}).items() %}&{{ k }}={{ v|urlencode }}{% endfor -%}
{%- endmacro %}
{% macro pager(model, page, pages, per_page, sort, filters, fields_csv) -%} {% macro pager(model, page, pages, per_page, sort, filters, fields_csv) -%}
{% set p = page|int %} {% set p = page|int %}
{% set pg = pages|int %} {% set pg = pages|int %}
{% set prev = [1, p-1]|max %} {% set prev = 1 if p <= 1 else p - 1 %} {% set nxt=pg if p>= pg else p + 1 %}
{% set nxt = [pg, p+1]|min %}
<nav id="pager"> <nav class="pager-nav" aria-label="Pagination">
{% if page > 1 %} {% if p > 1 %}
<button type="button" <button type="button" class="page-btn" data-page="1"
hx-get="/ui/{{ model }}/frag/rows?page=1&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif %}{% for k,v in filters.items() %}&{{k}}={{ v|urlencode }}{% endfor %}" hx-get="{{ _q(model, 1, per_page, sort, filters, fields_csv) }}" hx-target="#rows" hx-swap="innerHTML"
hx-target="#rows" hx-swap="innerHTML">First</button> aria-label="First page">First</button>
<button type="button"
hx-get="/ui/{{ model }}/frag/rows?page={{ prev }}&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif %}{% for k,v in (filters or {}).items() %}&{{k}}={{ v|urlencode }}{% endfor %}" <button type="button" class="page-btn" data-page="{{ prev }}"
hx-target="#rows" hx-swap="innerHTML" hx-get="{{ _q(model, prev, per_page, sort, filters, fields_csv) }}" hx-target="#rows" hx-swap="innerHTML"
hx-on:click="document.querySelector('#pager-state input[name=page]').value='{{ prev }}'">Prev</button> rel="prev">Prev</button>
{% else %}
<button type="button" class="page-btn" disabled>First</button>
<button type="button" class="page-btn" disabled>Prev</button>
{% endif %} {% endif %}
<span>Page {{ page }} / {{ pages }}</span> <span aria-live="polite">Page {{ p }} / {{ pg }}</span>
{% if page < pages %} <button type="button" {% if p < pg %} <button type="button" class="page-btn" data-page="{{ p + 1 }}"
hx-get="/ui/{{ model }}/frag/rows?page={{ page+1 }}&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif %}{% for k,v in filters.items() %}&{{k}}={{ v|urlencode }}{% endfor %}" hx-get="{{ _q(model, p + 1, per_page, sort, filters, fields_csv) }}" hx-target="#rows" hx-swap="innerHTML"
hx-target="#rows" rel="next" hx-swap="innerHTML">Next</button> rel="next">Next</button>
<button type="button"
hx-get="/ui/{{ model }}/frag/rows?page={{ nxt }}&per_page={{ per_page }}{% if sort %}&sort={{ sort }}{% endif %}{% if fields_csv %}&fields_csv={{ fields_csv|urlencode }}{% endif %}{% for k,v in (filters or {}).items() %}&{{k}}={{ v|urlencode }}{% endfor %}" <button type="button" class="page-btn" data-page="{{ pg }}"
hx-target="#rows" hx-swap="innerHTML" hx-get="{{ _q(model, pg, per_page, sort, filters, fields_csv) }}" hx-target="#rows" hx-swap="innerHTML"
hx-on:click="document.querySelector('#pager-state input[name=page]').value='{{ nxt }}'">Last</button> aria-label="Last page">Last</button>
{% else %}
<button type="button" class="page-btn" disabled>Next</button>
<button type="button" class="page-btn" disabled>Last</button>
{% endif %} {% endif %}
</nav> </nav>
{%- endmacro %}
{% macro form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%} {# one tiny listener to keep #pager-state in sync for every button #}
<form action="{{ action }}" method="post" {%- if hx %} hx-{{ "patch" if obj_id else "post" }}="{{ action }}" <script>
hx-target="closest dialog, #modal-body, body" hx-swap="innerHTML" hx-disabled-elt="button[type=submit]" {%- endif (function () {
-%}> const nav = document.currentScript.previousElementSibling;
nav.addEventListener('click', function (ev) {
const btn = ev.target.closest('.page-btn');
if (!btn || btn.disabled) return;
const page = btn.getAttribute('data-page');
if (!page) return;
const inp = document.querySelector('#pager-state input[name=page]');
if (inp) inp.value = page;
}, { capture: true });
})();
</script>
{%- endmacro %}
{% macro form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%}
<form action="{{ action }}" method="post" {%- if hx %} hx-{{ "patch" if obj_id else "post" }}="{{ action }}"
hx-target="closest dialog, #modal-body, body" hx-swap="innerHTML" hx-disabled-elt="button[type=submit]" {%-
endif -%}>
{%- if csrf_token %}<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">{% endif -%} {%- if csrf_token %}<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">{% endif -%}
{%- if obj_id %}<input type="hidden" name="id" value="{{ obj_id }}">{% endif -%} {%- if obj_id %}<input type="hidden" name="id" value="{{ obj_id }}">{% endif -%}
<input type="hidden" name="fields_csv" value="{{ request.args.get('fields_csv','id,name') }}"> <input type="hidden" name="fields_csv" value="{{ request.args.get('fields_csv','id,name') }}">
@ -78,7 +108,8 @@
maxlength="{{ f.maxlength }}" {% endif %}>{{ f.value or "" }}</textarea> maxlength="{{ f.maxlength }}" {% endif %}>{{ f.value or "" }}</textarea>
{%- elif f.type == "select" -%} {%- elif f.type == "select" -%}
<select id="{{ fid }}" name="{{ f.name }}" {% if f.required %}required{% endif %}> <select id="{{ fid }}" name="{{ f.name }}" {% if f.required %}required{% endif %}>
<option value="">{{ f.placeholder or ("Choose " ~ (f.label or f.name|replace('_',' ')|title)) }}</option> <option value="">{{ f.placeholder or ("Choose " ~ (f.label or f.name|replace('_',' ')|title)) }}
</option>
{% if f.multiple %} {% if f.multiple %}
{% set selected = (f.value or [])|list %} {% set selected = (f.value or [])|list %}
{% for val, lbl in f.choices %} {% for val, lbl in f.choices %}
@ -105,5 +136,5 @@
<div class="actions"> <div class="actions">
<button type="submit">Save</button> <button type="submit">Save</button>
</div> </div>
</form> </form>
{%- endmacro %} {%- endmacro %}

View file

@ -5,6 +5,7 @@ from flask import Blueprint, request, render_template, abort, make_response
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import scoped_session from sqlalchemy.orm import scoped_session
from sqlalchemy.inspection import inspect from sqlalchemy.inspection import inspect
from sqlalchemy.sql.elements import UnaryExpression
from sqlalchemy.sql.sqltypes import Integer, Boolean, Date, DateTime, Float, Numeric from sqlalchemy.sql.sqltypes import Integer, Boolean, Date, DateTime, Float, Numeric
from ..dsl import QuerySpec from ..dsl import QuerySpec
@ -115,12 +116,12 @@ def make_fragments_blueprint(db_session_factory, registry: Dict[str, Any], *, na
page = request.args.get("page", type=int) or 1 page = request.args.get("page", type=int) or 1
per_page = request.args.get("per_page", type=int) or 20 per_page = request.args.get("per_page", type=int) or 20
expand = _collect_expand_from_paths(fields) expand = _collect_expand_from_paths(fields + ([sort.split(":")[0]] if sort else []))
spec = QuerySpec(filters=filters, order_by=[sort] if sort else [], page=page, per_page=per_page, expand=expand) spec = QuerySpec(filters=filters, order_by=[sort] if sort else [], page=page, per_page=per_page, expand=expand)
s = session(); svc = CrudService(s, default_eager_policy) s = session(); svc = CrudService(s, default_eager_policy)
rows, _ = svc.list(Model, spec) rows, _ = svc.list(Model, spec)
html = render_template("crudkit/rows.html", items=rows, fields=fields, getp=_getp) html = render_template("crudkit/rows.html", items=rows, fields=fields, getp=_getp, model=model)
return html return html
@ -134,7 +135,7 @@ def make_fragments_blueprint(db_session_factory, registry: Dict[str, Any], *, na
sort = request.args.get("sort") sort = request.args.get("sort")
fields_csv = request.args.get("fields_csv") or "id,name" fields_csv = request.args.get("fields_csv") or "id,name"
fields = _paths_from_csv(fields_csv) fields = _paths_from_csv(fields_csv)
expand = _collect_expand_from_paths(fields) expand = _collect_expand_from_paths(fields + ([sort.split(":")[0]] if sort else []))
spec = QuerySpec(filters=filters, order_by=[sort] if sort else [], page=page, per_page=per_page, expand=expand) spec = QuerySpec(filters=filters, order_by=[sort] if sort else [], page=page, per_page=per_page, expand=expand)
s = session(); svc = CrudService(s, default_eager_policy) s = session(); svc = CrudService(s, default_eager_policy)

View file

@ -1,10 +1,93 @@
from sqlalchemy import func import sqlalchemy as sa
from sqlalchemy import func, asc
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, aliased
from sqlalchemy.inspection import inspect
from sqlalchemy.sql.elements import UnaryExpression
from .dsl import QuerySpec, build_query from .dsl import QuerySpec, build_query, split_sort_tokens
from .eager import default_eager_policy from .eager import default_eager_policy
def _dedup_order_by(ordering):
seen = set()
result = []
for ob in ordering:
col = ob.element if isinstance(ob, UnaryExpression) else ob
key = f"{col}-{getattr(ob, 'modifier', '')}-{getattr(ob, 'operator', '')}"
if key in seen:
continue
seen.add(key)
result.append(ob)
return result
def _parse_sort_token(token: str):
token = token.strip()
direction = "asc"
if token.startswith('-'):
direction = "desc"
token = token[1:]
if ":" in token:
key, dirpart = token.rsplit(":", 1)
direction = "desc" if dirpart.lower().startswith("d") else "asc"
return key, direction
return token, direction
def _apply_dotted_ordering(stmt, Model, sort_tokens):
"""
stmt: a select(Model) statement
sort_tokens: list[str] like ["owner.identifier", "-brand.name"]
Returns: (stmt, alias_cache)
"""
mapper = inspect(Model)
alias_cache = {} # maps a path like "owner" or "brand" to its alias
for tok in sort_tokens:
path, direction = _parse_sort_token(tok)
parts = [p for p in path.split(".") if p]
if not parts:
continue
entity = Model
current_mapper = mapper
alias_path = []
# Walk relationships for all but the last part
for rel_name in parts[:-1]:
rel = current_mapper.relationships.get(rel_name)
if rel is None:
# invalid sort key; skip quietly or raise
# raise ValueError(f"Unknown relationship {current_mapper.class_.__name__}.{rel_name}")
entity = None
break
alias_path.append(rel_name)
key = ".".join(alias_path)
if key in alias_cache:
entity_alias = alias_cache[key]
else:
# build an alias and join
entity_alias = aliased(rel.mapper.class_)
stmt = stmt.outerjoin(entity_alias, getattr(entity, rel.key))
alias_cache[key] = entity_alias
entity = entity_alias
current_mapper = inspect(rel.mapper.class_)
if entity is None:
continue
col_name = parts[-1]
# Validate final column
if col_name not in current_mapper.columns:
# raise ValueError(f"Unknown column {current_mapper.class_.__name__}.{col_name}")
continue
col = getattr(entity, col_name) if entity is not Model else getattr(Model, col_name)
stmt = stmt.order_by(col.desc() if direction == "desc" else col.asc())
return stmt
class CrudService: class CrudService:
def __init__(self, session: Session, eager_policy=default_eager_policy): def __init__(self, session: Session, eager_policy=default_eager_policy):
self.s = session self.s = session
@ -25,10 +108,44 @@ class CrudService:
def list(self, Model, spec: QuerySpec): def list(self, Model, spec: QuerySpec):
stmt = build_query(Model, spec, self.eager_policy) stmt = build_query(Model, spec, self.eager_policy)
count_stmt = stmt.with_only_columns(func.count()).order_by(None)
total = self.s.execute(count_stmt).scalar_one() simple_sorts, dotted_sorts = split_sort_tokens(spec.order_by)
if dotted_sorts:
stmt = _apply_dotted_ordering(stmt, Model, dotted_sorts)
# count query
pk = getattr(Model, "id") # adjust if not 'id'
count_base = stmt.with_only_columns(sa.distinct(pk)).order_by(None)
total = self.s.execute(
sa.select(sa.func.count()).select_from(count_base.subquery())
).scalar_one()
if spec.page and spec.per_page: if spec.page and spec.per_page:
stmt = stmt.limit(spec.per_page).offset((spec.page - 1) * spec.per_page) offset = (spec.page - 1) * spec.per_page
stmt = stmt.limit(spec.per_page).offset(offset)
# ---- ORDER BY handling ----
mapper = inspect(Model)
pk_cols = mapper.primary_key
# Gather all clauses added so far
ordering = list(stmt._order_by_clauses)
# Append pk tie-breakers if not already present
existing_cols = {
str(ob.element if isinstance(ob, UnaryExpression) else ob)
for ob in ordering
}
for c in pk_cols:
if str(c) not in existing_cols:
ordering.append(asc(c))
# Dedup *before* applying
ordering = _dedup_order_by(ordering)
# Now wipe old order_bys and set once
stmt = stmt.order_by(None).order_by(*ordering)
rows = self.s.execute(stmt).scalars().all() rows = self.s.execute(stmt).scalars().all()
return rows, total return rows, total