diff --git a/crudkit/eager.py b/crudkit/eager.py
index 7a6ee8b..f32efc2 100644
--- a/crudkit/eager.py
+++ b/crudkit/eager.py
@@ -1,22 +1,42 @@
from typing import List
-from sqlalchemy.orm import RelationshipProperty, selectinload, joinedload, Load, class_mapper
+from sqlalchemy.inspection import inspect
+from sqlalchemy.orm import Load, joinedload, selectinload
def default_eager_policy(Model, expand: List[str]) -> List[Load]:
"""
Heuristic:
- - many-to-one or one-to-one: joinedload
- - one-to-many or many-to-many: selectinload
- Only for fields explicitly requested in ?expand=
+ - many-to-one / one-to-one: joinedload
+ - one-to-many / many-to-many: selectinload
+ Accepts dotted paths like "author.publisher".
"""
- opts = []
- mapper = class_mapper(Model)
- rels = {r.key: r for r in mapper.relationships}
- for name in expand:
- rel: RelationshipProperty = rels.get(name)
+ if not expand:
+ return []
+
+ opts: List[Load] = []
+
+ for path in expand:
+ parts = path.split(".")
+ current_model = Model
+ current_inspect = inspect(current_model)
+
+ # first hop
+ rel = current_inspect.relationships.get(parts[0])
if not rel:
- continue
- if rel.uselist:
- opts.append(selectinload(name))
- else:
- opts.append(joinedload(name))
+ 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:
+ break
+ attr = getattr(current_model, name)
+ loader = loader.selectinload(attr) if rel.uselist else loader.joinedload(attr)
+ current_model = rel.mapper.class_
+
+ opts.append(loader)
+
return opts
diff --git a/crudkit/html/templates/crudkit/_macros.html b/crudkit/html/templates/crudkit/_macros.html
index 38a1a4e..f713e81 100644
--- a/crudkit/html/templates/crudkit/_macros.html
+++ b/crudkit/html/templates/crudkit/_macros.html
@@ -1,86 +1,93 @@
{% macro options(items, value_attr="id", label_path="name", getp=None) -%}
- {%- for obj in items -%}
-
- {%- endfor -%}
+{%- for obj in items -%}
+
+{%- endfor -%}
{% endmacro %}
{% macro lis(items, label_path="name", sublabel_path=None, getp=None) -%}
- {%- for obj in items -%}
-
- {{ getp(obj, label_path) }}
- {%- if sublabel_path %}
- {{ getp(obj, sublabel_path) }}
- {%- endif %}
-
- {%- else -%}
- No results.
- {%- endfor -%}
+{%- for obj in items -%}
+
+ {{ getp(obj, label_path) }}
+ {%- if sublabel_path %}
+ {{ getp(obj, sublabel_path) }}
+ {%- endif %}
+
+{%- else -%}
+No results.
+{%- endfor -%}
{% endmacro %}
{% macro rows(items, fields, getp=None) -%}
- {%- for obj in items -%}
-
- {%- for f in fields -%}
- | {{ getp(obj, f) }} |
- {%- endfor -%}
-
- {%- else -%}
- | No results. |
+{%- for obj in items -%}
+
+ {%- for f in fields -%}
+ | {{ getp(obj, f) }} |
{%- endfor -%}
+
+{%- else -%}
+
+ | No results. |
+
+{%- endfor -%}
{%- endmacro %}
{% macro pager(model, page, pages, per_page, sort, filters) -%}
-
{%- endmacro %}
{% macro form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%}
-
-{%- endmacro %}
+
+
+
+
+{%- endmacro %}
\ No newline at end of file
diff --git a/crudkit/html/templates/crudkit/form.html b/crudkit/html/templates/crudkit/form.html
index 0c2c226..5f3bfb3 100644
--- a/crudkit/html/templates/crudkit/form.html
+++ b/crudkit/html/templates/crudkit/form.html
@@ -1,9 +1,3 @@
{% import "_macros.html" as ui %}
-{# The action points at your JSON endpoints. Adjust 'crud' if you named it differently. #}
-{% if obj %}
- {% set action = url_for('crud.update_item', model=model, id=obj.id) %}
- {{ ui.form(schema, action, method="POST", obj_id=obj.id, hx=hx, csrf_token=csrf_token if csrf_token is defined else None) }}
-{% else %}
- {% set action = url_for('crud.create_item', model=model) %}
- {{ ui.form(schema, action, method="POST", obj_id=None, hx=hx, csrf_token=csrf_token if csrf_token is defined else None) }}
-{% endif %}
+{% set action = url_for('frags.save', model=model) %}
+{{ ui.form(schema, action, method="POST", obj_id=obj.id if obj else None, hx=true) }}
\ No newline at end of file
diff --git a/crudkit/html/templates/crudkit/row.html b/crudkit/html/templates/crudkit/row.html
new file mode 100644
index 0000000..a3ac629
--- /dev/null
+++ b/crudkit/html/templates/crudkit/row.html
@@ -0,0 +1,2 @@
+{% import "_macros.html" as ui %}
+{{ ui.rows([obj], fields, getp=getp) }}
\ No newline at end of file
diff --git a/crudkit/html/type_map.py b/crudkit/html/type_map.py
index c942bf8..5e582c2 100644
--- a/crudkit/html/type_map.py
+++ b/crudkit/html/type_map.py
@@ -1,5 +1,6 @@
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
+from sqlalchemy import select
from sqlalchemy.inspection import inspect
from sqlalchemy.orm import Mapper, RelationshipProperty
from sqlalchemy.sql.schema import Column
@@ -17,6 +18,9 @@ def _guess_label_attr(model_cls) -> str:
return cand
return "id"
+def _pretty(label: str) -> str:
+ return label.replace("_", " ").title()
+
def _column_input_type(col: Column) -> str:
t = col.type
if isinstance(t, (String, Unicode)):
@@ -48,45 +52,36 @@ def _enum_choices(col: Column) -> Optional[List[Tuple[str, str]]]:
return [(v, v) for v in t.enums]
return None
-def build_form_schema(
- model_cls, session, obj=None, *,
- include: Optional[List[str]] = None,
- exclude: Optional[List[str]] = None,
- fk_limit: int = 200
- ) -> List[Dict[str, Any]]:
- """
- Returns a list of field dicts:
- {name, type, required, value, placeholder, help, choices?, rel?}
- """
+def build_form_schema(model_cls, session, obj=None, *, include=None, exclude=None, fk_limit=200):
mapper: Mapper = inspect(model_cls)
include = set(include or [])
exclude = set(exclude or {"id", "created_at", "updated_at", "deleted", "version"})
+ fields = []
fields: List[Dict[str, Any]] = []
fk_map = {}
for rel in mapper.relationships:
- if rel.primaryjoin is None:
- continue
for lc in rel.local_columns:
- if any(fk.column.table is rel.entity.entity for fk in lc.foreign_keys):
- fk_map[lc.key] = rel
+ fk_map[lc.key] = rel
for attr in mapper.column_attrs:
- col: Column = attr.columns[0]
+ col = attr.columns[0]
name = col.key
if include and name not in include:
continue
if name in exclude:
continue
- field: Dict[str, Any] = {
+ field = {
"name": name,
"type": _column_input_type(col),
"required": not col.nullable,
"value": getattr(obj, name, None) if obj is not None else None,
"placeholder": "",
"help": "",
+ # default label from column name
+ "label": _pretty(name),
}
enum_choices = _enum_choices(col)
@@ -95,20 +90,48 @@ def build_form_schema(
field["choices"] = enum_choices
if name in fk_map:
- rel: RelationshipProperty = fk_map[name]
+ rel = fk_map[name]
target = rel.mapper.class_
label_attr = _guess_label_attr(target)
- q = session.query(target).limit(fk_limit)
- choices = [(getattr(row, "id"), getattr(row, label_attr)) for row in q.all()]
+ rows = session.execute(select(target).limit(fk_limit)).scalars().all()
field["type"] = "select"
- field["choices"] = choices
+ field["choices"] = [(getattr(r, "id"), getattr(r, label_attr)) for r in rows]
field["rel"] = {"target": target.__name__, "label_attr": label_attr}
+ field["label"] = _pretty(rel.key)
- if hasattr(col.type, "length") and col.type.length:
+ if getattr(col.type, "length", None):
field["maxlength"] = col.type.length
fields.append(field)
+ for rel in mapper.relationships:
+ if not rel.uselist or rel.secondary is None:
+ continue # only true many-to-many
+
+ if include and f"{rel.key}_ids" not in include:
+ continue
+
+ target = rel.mapper.class_
+ label_attr = _guess_label_attr(target)
+ choices = session.execute(select(target).limit(fk_limit)).scalars().all()
+
+ current = []
+ if obj is not None:
+ current = [getattr(x, "id") for x in getattr(obj, rel.key, []) or []]
+
+ fields.append({
+ "name": f"{rel.key}_ids", # e.g. "tags_ids"
+ "label": rel.key.replace("_"," ").title(),
+ "type": "select",
+ "multiple": True,
+ "required": False,
+ "choices": [(getattr(r,"id"), getattr(r,label_attr)) for r in choices],
+ "value": current, # list of selected IDs
+ "placeholder": f"Choose {rel.key.replace('_',' ').title()}",
+ "help": "",
+ })
+
if include:
- fields.sort(key=lambda f: list(include).index(f["name"]) if f["name"] in include else 10**9)
+ order = list(include)
+ fields.sort(key=lambda f: order.index(f["name"]) if f["name"] in include else 10**9)
return fields
diff --git a/crudkit/html/ui_fragments.py b/crudkit/html/ui_fragments.py
index 37d0743..525c6e5 100644
--- a/crudkit/html/ui_fragments.py
+++ b/crudkit/html/ui_fragments.py
@@ -1,9 +1,11 @@
from __future__ import annotations
from typing import Any, Dict, List, Tuple
from math import ceil
-from flask import Blueprint, request, render_template, abort
+from flask import Blueprint, request, render_template, abort, make_response
+from sqlalchemy import select
from sqlalchemy.orm import scoped_session
from sqlalchemy.inspection import inspect
+from sqlalchemy.sql.sqltypes import Integer, Boolean, Date, DateTime, Float, Numeric
from ..dsl import QuerySpec
from ..service import CrudService
@@ -46,6 +48,20 @@ def make_fragments_blueprint(db_session_factory, registry: Dict[str, Any], *, na
cur = getattr(cur, part, None) if cur is not None else None
return cur
+ def _extract_m2m_lists(Model, req_form) -> dict[str, list[int]]:
+ """Return {'tags': [1,2]} for any _ids fields; caller removes keys from main form."""
+ mapper = inspect(Model)
+ out = {}
+ for rel in mapper.relationships:
+ if not rel.uselist or rel.secondary is None:
+ continue
+ key = f"{rel.key}_ids"
+ ids = req_form.getlist(key)
+ if ids is None:
+ continue
+ out[rel.key] = [int(i) for i in ids if i]
+ return out
+
@bp.get("//frag/options")
def options(model):
Model = registry.get(model) or abort(404)
@@ -108,4 +124,110 @@ def make_fragments_blueprint(db_session_factory, registry: Dict[str, Any], *, na
hx = request.args.get("hx", type=int) == 1
return render_template("form.html", model=model, obj=obj, schema=schema, hx=hx)
+
+ def coerce_form_types(Model, data: dict) -> dict:
+ """Turn HTML string inputs into the Python types your columns expect."""
+ mapper = inspect(Model)
+ for attr in mapper.column_attrs:
+ col = attr.columns[0]
+ name = col.key
+ if name not in data:
+ continue
+ v = data[name]
+ if v == "":
+ data[name] = None
+ continue
+ t = col.type
+ try:
+ if isinstance(t, Boolean):
+ data[name] = v in ("1", "true", "on", "yes", True)
+ elif isinstance(t, Integer):
+ data[name] = int(v)
+ elif isinstance(t, (Float, Numeric)):
+ data[name] = float(v)
+ elif isinstance(t, DateTime):
+ from datetime import datetime
+ data[name] = datetime.fromisoformat(v)
+ elif isinstance(t, Date):
+ from datetime import date
+ data[name] = date.fromisoformat(v)
+ except Exception:
+ # Leave as string; your validator can complain later.
+ pass
+ return data
+
+ @bp.post("//frag/save")
+ def save(model):
+ Model = registry.get(model) or abort(404)
+ s = session(); svc = CrudService(s, default_eager_policy)
+
+ # grab the raw form and fields to re-render
+ raw = request.form
+ form = raw.to_dict(flat=True)
+ fields_csv = form.pop("fields_csv", "id,name")
+
+ # many-to-many lists first
+ m2m = _extract_m2m_lists(Model, raw)
+ for rel_name in list(m2m.keys()):
+ form.pop(f"{rel_name}_ids", None)
+
+ # coerce primitives for regular columns
+ form = coerce_form_types(Model, form)
+
+ id_val = form.pop("id", None)
+
+ if id_val:
+ obj = svc.get(Model, int(id_val)) or abort(404)
+ svc.update(obj, form)
+ else:
+ obj = svc.create(Model, form)
+
+ # apply many-to-many selections
+ mapper = inspect(Model)
+ for rel_name, id_list in m2m.items():
+ rel = mapper.relationships[rel_name]
+ target = rel.mapper.class_
+ selected = []
+ if id_list:
+ selected = s.execute(select(target).where(target.id.in_(id_list))).scalars().all()
+ coll = getattr(obj, rel_name)
+ coll.clear()
+ coll.extend(selected)
+
+ s.commit()
+
+ rows_html = render_template(
+ "crudkit/row.html",
+ obj=obj,
+ fields=[p.strip() for p in fields_csv.split(",") if p.strip()],
+ getp=_getp,
+ )
+ resp = make_response(rows_html)
+ if id_val:
+ resp.headers["HX-Trigger"] = '{"toast":{"level":"success","message":"Updated"}}'
+ resp.headers["HX-Retarget"] = f"#row-{obj.id}"
+ resp.headers["HX-Reswap"] = "outerHTML"
+ else:
+ resp.headers["HX-Trigger"] = '{"toast":{"level":"success","message":"Created"}}'
+ resp.headers["HX-Retarget"] = "#rows"
+ resp.headers["HX-Reswap"] = "beforeend"
+ return resp
+
+ @bp.get("/_debug//schema")
+ def debug_model(model):
+ Model = registry[model]
+ from sqlalchemy.inspection import inspect
+ m = inspect(Model)
+ return {
+ "columns": [c.key for c in m.columns],
+ "relationships": [
+ {
+ "key": r.key,
+ "target": r.mapper.class_.__name__,
+ "uselist": r.uselist,
+ "local_cols": [c.key for c in r.local_columns],
+ } for r in m.relationships
+ ],
+ }
return bp
+
diff --git a/example_app/app.py b/example_app/app.py
index b7f9909..4061fcd 100644
--- a/example_app/app.py
+++ b/example_app/app.py
@@ -18,6 +18,9 @@ def create_app():
Base.metadata.create_all(engine)
app.register_blueprint(make_json_blueprint(session_factory, registry), url_prefix="/api")
app.register_blueprint(make_fragments_blueprint(session_factory, registry), url_prefix="/ui")
+ @app.get("/demo")
+ def demo():
+ return render_template("demo.html")
return app
if __name__ == "__main__":
diff --git a/example_app/templates/demo.html b/example_app/templates/demo.html
new file mode 100644
index 0000000..56491fb
--- /dev/null
+++ b/example_app/templates/demo.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+