diff --git a/crudkit/eager.py b/crudkit/eager.py
index f32efc2..34e7884 100644
--- a/crudkit/eager.py
+++ b/crudkit/eager.py
@@ -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.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 don’t support eagerload
+ return rel.lazy != "dynamic"
+
+def default_eager_policy(Model, expand: Sequence[str], cfg: EagerConfig | None = None) -> List[Load]:
"""
Heuristic:
- many-to-one / one-to-one: joinedload
@@ -12,31 +25,51 @@ def default_eager_policy(Model, expand: List[str]) -> List[Load]:
if not expand:
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] = []
+ seen: Set[tuple] = set()
- for path in expand:
+ for path in sorted(pruned):
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_inspect = inspect(current_model)
+ # build the chain incrementally
+ loader: Load | None = None
+ ok = True
- # first hop
- rel = current_inspect.relationships.get(parts[0])
- if not rel:
- continue # silently skip bad names
- 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:
+ for i, name in enumerate(parts):
+ rel = _rel(current_model, name)
+ if not rel or not _is_expandable(rel):
+ ok = False
break
attr = getattr(current_model, name)
- loader = loader.selectinload(attr) if rel.uselist else loader.joinedload(attr)
+ 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)
current_model = rel.mapper.class_
- opts.append(loader)
+ 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)
+ seen.add(key)
return opts
diff --git a/crudkit/html/templates/crudkit/_macros.html b/crudkit/html/templates/crudkit/_macros.html
index 4dbf0fc..bac6692 100644
--- a/crudkit/html/templates/crudkit/_macros.html
+++ b/crudkit/html/templates/crudkit/_macros.html
@@ -18,11 +18,13 @@
{%- endfor -%}
{% endmacro %}
-{% macro rows(items, fields, getp=None) -%}
+{% macro rows(items, fields, getp=None, model=None) -%}
{%- for obj in items -%}
{%- for f in fields -%}
- | {{ getp(obj, f) if getp(obj, f) is not none else '-' }} |
+ {{ getp(obj, f) if
+ getp(obj, f) is not none else '-' }} |
{%- endfor -%}
{%- else -%}
@@ -32,101 +34,133 @@
{%- endfor -%}
{%- endmacro %}
-{% macro pager(model, page, pages, per_page, sort, filters, fields_csv) -%}
-{#
-
-#}
-{% set p = page|int %}
-{% set pg = pages|int %}
-{% set prev = [1, p-1]|max %}
-{% set nxt = [pg, p+1]|min %}
-
-
+{# helper: build hx-get URL with shared params #}
+{% macro _rows_url(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 form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%}
-
-{%- endmacro %}
\ No newline at end of file
+ {# window of pages around current #}
+ {% set win = 2 %}
+ {% set left = [2, p - win]|max %}
+ {% set right = [pg - 1, p + win]|min %}
+
+
+
+ {# keep hidden pager-state in sync with one handler instead of inline click spam #}
+
+ {%- endmacro %}
+
+ {% macro form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%}
+
+ {%- endmacro %}
\ No newline at end of file
diff --git a/crudkit/html/templates/crudkit/rows.html b/crudkit/html/templates/crudkit/rows.html
index 7784328..59155b6 100644
--- a/crudkit/html/templates/crudkit/rows.html
+++ b/crudkit/html/templates/crudkit/rows.html
@@ -1,2 +1,2 @@
{% import "crudkit/_macros.html" as ui %}
-{{ ui.rows(items, fields, getp=getp) }}
+{{ ui.rows(items, fields, getp=getp, model=model) }}
diff --git a/crudkit/html/ui_fragments.py b/crudkit/html/ui_fragments.py
index a75cd59..c5de214 100644
--- a/crudkit/html/ui_fragments.py
+++ b/crudkit/html/ui_fragments.py
@@ -120,7 +120,7 @@ def make_fragments_blueprint(db_session_factory, registry: Dict[str, Any], *, na
s = session(); svc = CrudService(s, default_eager_policy)
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
diff --git a/inventory/routes/worklog.py b/inventory/routes/worklog.py
index 6e9d91f..8b5b66a 100644
--- a/inventory/routes/worklog.py
+++ b/inventory/routes/worklog.py
@@ -17,7 +17,7 @@ def list_worklog():
return render_template(
'table.html',
header=worklog_headers,
- model_name='worklog',
+ model_name='work_log',
title="Work Log",
fields = ['contact.identifier', 'work_item.identifier', 'start_time', 'end_time', 'complete', 'followup', 'analysis'],
entry_route='worklog_item',