From 08721e6fbef49de6c4f49561899929ae126eb2e0 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Tue, 2 Sep 2025 08:00:06 -0500 Subject: [PATCH 01/36] New files, new file structure, new CRUDKit. --- .gitignore | 12 - crudapi.egg-info/PKG-INFO | 9 + crudapi.egg-info/SOURCES.txt | 8 + crudapi.egg-info/dependency_links.txt | 1 + crudapi.egg-info/requires.txt | 4 + crudapi.egg-info/top_level.txt | 1 + crudkit/__init__.py | 8 - crudkit/api/__init__.py | 0 crudkit/api/flask_api.py | 0 crudkit/blueprint.py | 81 ------ crudkit/core/__init__.py | 0 crudkit/core/base.py | 0 crudkit/core/metadata.py | 0 crudkit/core/service.py | 0 crudkit/dsl.py | 147 ----------- crudkit/eager.py | 75 ------ crudkit/html/__init__.py | 3 - crudkit/html/templates/crudkit/_macros.html | 140 ---------- crudkit/html/templates/crudkit/form.html | 3 - crudkit/html/templates/crudkit/lis.html | 2 - crudkit/html/templates/crudkit/options.html | 3 - crudkit/html/templates/crudkit/pager.html | 2 - crudkit/html/templates/crudkit/row.html | 2 - crudkit/html/templates/crudkit/rows.html | 2 - crudkit/html/type_map.py | 137 ---------- crudkit/html/ui_fragments.py | 269 -------------------- crudkit/mixins.py | 23 -- crudkit/serialize.py | 22 -- crudkit/service.py | 169 ------------ crudkit/ui/__init__.py | 0 crudkit/ui/fragments.py | 0 example_app/app.py | 27 -- example_app/models.py | 18 -- example_app/seed.py | 19 -- example_app/templates/demo.html | 17 -- 35 files changed, 23 insertions(+), 1181 deletions(-) delete mode 100644 .gitignore create mode 100644 crudapi.egg-info/PKG-INFO create mode 100644 crudapi.egg-info/SOURCES.txt create mode 100644 crudapi.egg-info/dependency_links.txt create mode 100644 crudapi.egg-info/requires.txt create mode 100644 crudapi.egg-info/top_level.txt create mode 100644 crudkit/api/__init__.py create mode 100644 crudkit/api/flask_api.py delete mode 100644 crudkit/blueprint.py create mode 100644 crudkit/core/__init__.py create mode 100644 crudkit/core/base.py create mode 100644 crudkit/core/metadata.py create mode 100644 crudkit/core/service.py delete mode 100644 crudkit/dsl.py delete mode 100644 crudkit/eager.py delete mode 100644 crudkit/html/__init__.py delete mode 100644 crudkit/html/templates/crudkit/_macros.html delete mode 100644 crudkit/html/templates/crudkit/form.html delete mode 100644 crudkit/html/templates/crudkit/lis.html delete mode 100644 crudkit/html/templates/crudkit/options.html delete mode 100644 crudkit/html/templates/crudkit/pager.html delete mode 100644 crudkit/html/templates/crudkit/row.html delete mode 100644 crudkit/html/templates/crudkit/rows.html delete mode 100644 crudkit/html/type_map.py delete mode 100644 crudkit/html/ui_fragments.py delete mode 100644 crudkit/mixins.py delete mode 100644 crudkit/serialize.py delete mode 100644 crudkit/service.py create mode 100644 crudkit/ui/__init__.py create mode 100644 crudkit/ui/fragments.py delete mode 100644 example_app/app.py delete mode 100644 example_app/models.py delete mode 100644 example_app/seed.py delete mode 100644 example_app/templates/demo.html diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 3bae2c6..0000000 --- a/.gitignore +++ /dev/null @@ -1,12 +0,0 @@ -**/__pycache__/ -inventory/static/uploads/* -!inventory/static/uploads/.gitkeep -.venv/ -.env -*.db -*.db-journal -*.sqlite -*.sqlite3 -alembic.ini -alembic/ -*.egg-info/ \ No newline at end of file diff --git a/crudapi.egg-info/PKG-INFO b/crudapi.egg-info/PKG-INFO new file mode 100644 index 0000000..ab7e693 --- /dev/null +++ b/crudapi.egg-info/PKG-INFO @@ -0,0 +1,9 @@ +Metadata-Version: 2.4 +Name: crudkit +Version: 0.1.0 +Summary: A Flask API for better SQLAlchemy usage. +Requires-Python: >=3.9 +Requires-Dist: flask +Requires-Dist: flask_sqlalchemy +Requires-Dist: python-dotenv +Requires-Dist: Werkzeug diff --git a/crudapi.egg-info/SOURCES.txt b/crudapi.egg-info/SOURCES.txt new file mode 100644 index 0000000..10248da --- /dev/null +++ b/crudapi.egg-info/SOURCES.txt @@ -0,0 +1,8 @@ +pyproject.toml +crudkit/__init__.py +crudkit/mixins.py +crudkit.egg-info/PKG-INFO +crudkit.egg-info/SOURCES.txt +crudkit.egg-info/dependency_links.txt +crudkit.egg-info/requires.txt +crudkit.egg-info/top_level.txt \ No newline at end of file diff --git a/crudapi.egg-info/dependency_links.txt b/crudapi.egg-info/dependency_links.txt new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crudapi.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/crudapi.egg-info/requires.txt b/crudapi.egg-info/requires.txt new file mode 100644 index 0000000..cd67c46 --- /dev/null +++ b/crudapi.egg-info/requires.txt @@ -0,0 +1,4 @@ +flask +flask_sqlalchemy +python-dotenv +Werkzeug diff --git a/crudapi.egg-info/top_level.txt b/crudapi.egg-info/top_level.txt new file mode 100644 index 0000000..2c5bac7 --- /dev/null +++ b/crudapi.egg-info/top_level.txt @@ -0,0 +1 @@ +crudkit diff --git a/crudkit/__init__.py b/crudkit/__init__.py index cf333de..e69de29 100644 --- a/crudkit/__init__.py +++ b/crudkit/__init__.py @@ -1,8 +0,0 @@ -from .mixins import CrudMixin -from .dsl import QuerySpec -from .eager import default_eager_policy -from .service import CrudService -from .serialize import serialize -from .blueprint import make_blueprint - -__all__ = ["CrudMixin", "QuerySpec", "default_eager_policy", "CrudService", "serialize", "make_blueprint"] \ No newline at end of file diff --git a/crudkit/api/__init__.py b/crudkit/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crudkit/api/flask_api.py b/crudkit/api/flask_api.py new file mode 100644 index 0000000..e69de29 diff --git a/crudkit/blueprint.py b/crudkit/blueprint.py deleted file mode 100644 index cae482e..0000000 --- a/crudkit/blueprint.py +++ /dev/null @@ -1,81 +0,0 @@ -from flask import Blueprint, request, jsonify, abort -from sqlalchemy.orm import scoped_session -from .dsl import QuerySpec -from .service import CrudService -from .eager import default_eager_policy -from .serialize import serialize - -def make_blueprint(db_session_factory, registry): - bp = Blueprint("crud", __name__) - def session(): return scoped_session(db_session_factory)() - - @bp.get("//list") - def list_items(model): - Model = registry.get(model) or abort(404) - spec = QuerySpec( - filters=_parse_filters(request.args), - order_by=request.args.getlist("sort"), - page=request.args.get("page", type=int), - per_page=request.args.get("per_page", type=int), - expand=request.args.getlist("expand"), - fields=request.args.get("fields", type=lambda s: [x.strip() for x in s.split(",")] if s else None), - ) - s = session(); svc = CrudService(s, default_eager_policy) - rows, total = svc.list(Model, spec) - data = [serialize(r, fields=spec.fields, expand=spec.expand) for r in rows] - return jsonify({"data": data, "total": total}) - - @bp.post("/") - def create_item(model): - Model = registry.get(model) or abort(404) - payload = request.get_json() or {} - s = session(); svc = CrudService(s, default_eager_policy) - obj = svc.create(Model, payload) - s.commit() - return jsonify(serialize(obj)), 201 - - @bp.get("//") - def read_item(model, id): - Model = registry.get(model) or abort(404) - spec = QuerySpec(expand=request.args.getlist("expand"), - fields=request.args.get("fields", type=lambda s: s.split(","))) - s = session(); svc = CrudService(s, default_eager_policy) - obj = svc.get(Model, id, spec) or abort(404) - return jsonify(serialize(obj, fields=spec.fields, expand=spec.expand)) - - @bp.patch("//") - def update_item(model, id): - Model = registry.get(model) or abort(404) - s = session(); svc = CrudService(s, default_eager_policy) - obj = svc.get(Model, id, QuerySpec()) or abort(404) - payload = request.get_json() or {} - svc.update(obj, payload) - s.commit() - return jsonify(serialize(obj)) - - @bp.delete("//") - def delete_item(model, id): - Model = registry.get(model) or abort(404) - s = session(); svc = CrudService(s, default_eager_policy) - obj = svc.get(Model, id, QuerySpec()) or abort(404) - svc.soft_delete(obj) - s.commit() - return jsonify({"status": "deleted"}) - - @bp.post("///undelete") - def undelete_item(model, id): - Model = registry.get(model) or abort(404) - s = session(); svc = CrudService(s, default_eager_policy) - obj = svc.get(Model, id, QuerySpec()) or abort(404) - svc.undelete(obj) - s.commit() - return jsonify({"status": "restored"}) - return bp - -def _parse_filters(args): - out = {} - for k, v in args.items(): - if k in {"page", "per_page", "sort", "expand", "fields"}: - continue - out[k] = v - return out \ No newline at end of file diff --git a/crudkit/core/__init__.py b/crudkit/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crudkit/core/base.py b/crudkit/core/base.py new file mode 100644 index 0000000..e69de29 diff --git a/crudkit/core/metadata.py b/crudkit/core/metadata.py new file mode 100644 index 0000000..e69de29 diff --git a/crudkit/core/service.py b/crudkit/core/service.py new file mode 100644 index 0000000..e69de29 diff --git a/crudkit/dsl.py b/crudkit/dsl.py deleted file mode 100644 index 3953a83..0000000 --- a/crudkit/dsl.py +++ /dev/null @@ -1,147 +0,0 @@ -from dataclasses import dataclass, field -from typing import List, Dict, Any, Optional -from sqlalchemy import asc, desc, select, false -from sqlalchemy.inspection import inspect - -@dataclass -class QuerySpec: - filters: Dict[str, Any] = field(default_factory=dict) - order_by: List[str] = field(default_factory=list) - page: Optional[int] = None - per_page: Optional[int] = None - expand: List[str] = field(default_factory=list) - fields: Optional[List[str]] = None - -FILTER_OPS = { - "__eq": lambda c, v: c == v, - "__ne": lambda c, v: c != v, - "__lt": lambda c, v: c < v, - "__lte": lambda c, v: c <= v, - "__gt": lambda c, v: c > v, - "__gte": lambda c, v: c >= v, - "__ilike": lambda c, v: c.ilike(v), - "__in": lambda c, v: c.in_(v), - "__isnull": lambda c, v: (c.is_(None) if v else c.is_not(None)) -} - -def _split_filter_key(raw_key: str): - for op in sorted(FILTER_OPS.keys(), key=len, reverse=True): - if raw_key.endswith(op): - return raw_key[: -len(op)], op - return raw_key, None - -def _ensure_wildcards(op_key, value): - if op_key == "__ilike" and isinstance(value, str) and "%" not in value and "_" not in value: - return f"%{value}%" - return value - -def _related_predicate(Model, path_parts, op_key, value): - """ - Build EXISTS subqueries for dotted filters: - - scalar rels -> attr.has(inner_predicate) - - collection -> attr.any(inner_predicate) - """ - head, *rest = path_parts - - # class-bound relationship attribute (InstrumentedAttribute) - attr = getattr(Model, head, None) - if attr is None: - return None - - # relationship metadata if you need uselist + target model - rel = inspect(Model).relationships.get(head) - if rel is None: - return None - Target = rel.mapper.class_ - - if not rest: - # filtering directly on a relationship without a leaf column isn't supported - return None - - if len(rest) == 1: - # final hop is a column on the related model - leaf = rest[0] - col = getattr(Target, leaf, None) - if col is None: - return None - pred = FILTER_OPS[op_key](col, value) if op_key else (col == value) - else: - # recurse deeper: owner.room.area.name__ilike=... - pred = _related_predicate(Target, rest, op_key, value) - if pred is None: - return None - - # wrap at this hop using the *attribute*, not the RelationshipProperty - 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): - stmt = select(Model) - - # filter out soft-deleted rows - deleted_attr = getattr(Model, "deleted", None) - if deleted_attr is not None: - stmt = stmt.where(deleted_attr == false()) - else: - is_deleted_attr = getattr(Model, "is_deleted", None) - if is_deleted_attr is not None: - stmt = stmt.where(is_deleted_attr == false()) - - # filters - for raw_key, val in spec.filters.items(): - path, op_key = _split_filter_key(raw_key) - val = _ensure_wildcards(op_key, val) - - if "." in path: - pred = _related_predicate(Model, path.split("."), op_key, val) - if pred is not None: - stmt = stmt.where(pred) - continue - - col = getattr(Model, path, None) - if col is None: - continue - stmt = stmt.where(FILTER_OPS[op_key](col, val) if op_key else (col == val)) - - simple_sorts, _ = split_sort_tokens(spec.order_by) - - for token in simple_sorts: - direction = "asc" - 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: - pk_cols = inspect(Model).primary_key - if pk_cols: - stmt = stmt.order_by(*(asc(c) for c in pk_cols)) - - # eager loading - if eager_policy: - opts = eager_policy(Model, spec.expand) - if opts: - stmt = stmt.options(*opts) - - return stmt diff --git a/crudkit/eager.py b/crudkit/eager.py deleted file mode 100644 index 34e7884..0000000 --- a/crudkit/eager.py +++ /dev/null @@ -1,75 +0,0 @@ -from __future__ import annotations -from typing import Iterable, List, Sequence, Set -from sqlalchemy.inspection import inspect -from sqlalchemy.orm import Load, joinedload, selectinload, RelationshipProperty - -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 - - one-to-many / many-to-many: selectinload - Accepts dotted paths like "author.publisher". - """ - 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 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 - # build the chain incrementally - loader: Load | None = None - ok = True - - 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) - 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_ - - 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/__init__.py b/crudkit/html/__init__.py deleted file mode 100644 index a94f018..0000000 --- a/crudkit/html/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .ui_fragments import make_fragments_blueprint - -__all__ = ["make_fragments_blueprint"] diff --git a/crudkit/html/templates/crudkit/_macros.html b/crudkit/html/templates/crudkit/_macros.html deleted file mode 100644 index 342c731..0000000 --- a/crudkit/html/templates/crudkit/_macros.html +++ /dev/null @@ -1,140 +0,0 @@ -{% macro options(items, value_attr="id", label_path="name", getp=None) -%} - -{%- 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 -%} -{% endmacro %} - -{% macro rows(items, fields, getp=None) -%} -{%- for obj in items -%} - - {%- for f in fields -%} - {{ getp(obj, f) }} - {%- endfor -%} - -{%- else -%} - - No results. - -{%- endfor -%} -{%- 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) -%} -{% set p = page|int %} -{% set pg = pages|int %} -{% set prev = 1 if p <= 1 else p - 1 %} {% set nxt=pg if p>= pg else p + 1 %} - - - - {# one tiny listener to keep #pager-state in sync for every button #} - - {%- endmacro %} - - {% macro form(schema, action, method="POST", obj_id=None, hx=False, csrf_token=None) -%} -
    - {%- if csrf_token %}{% endif -%} - {%- if obj_id %}{% endif -%} - - - {%- for f in schema -%} -
    - {% set fid = 'f-' ~ f.name ~ '-' ~ (obj_id or 'new') %} - - {%- if f.type == "textarea" -%} - - {%- elif f.type == "select" -%} - - {%- elif f.type == "checkbox" -%} - - - {%- else -%} - - {%- endif -%} - {%- if f.help %}
    {{ f.help }}
    {% endif -%} -
    - {%- endfor -%} - -
    - -
    -
    - {%- endmacro %} \ No newline at end of file diff --git a/crudkit/html/templates/crudkit/form.html b/crudkit/html/templates/crudkit/form.html deleted file mode 100644 index 761658a..0000000 --- a/crudkit/html/templates/crudkit/form.html +++ /dev/null @@ -1,3 +0,0 @@ -{% import "crudkit/_macros.html" as ui %} -{% 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/lis.html b/crudkit/html/templates/crudkit/lis.html deleted file mode 100644 index 3ce62e7..0000000 --- a/crudkit/html/templates/crudkit/lis.html +++ /dev/null @@ -1,2 +0,0 @@ -{% import "crudkit/_macros.html" as ui %} -{{ ui.lis(items, label_path=label_path, sublabel_path=sublabel_path, getp=getp) }} diff --git a/crudkit/html/templates/crudkit/options.html b/crudkit/html/templates/crudkit/options.html deleted file mode 100644 index 0f66d2d..0000000 --- a/crudkit/html/templates/crudkit/options.html +++ /dev/null @@ -1,3 +0,0 @@ -{# Renders only rows #} -{% import "crudkit/_macros.html" as ui %} -{{ ui.options(items, value_attr=value_attr, label_path=label_path, getp=getp) }} diff --git a/crudkit/html/templates/crudkit/pager.html b/crudkit/html/templates/crudkit/pager.html deleted file mode 100644 index b3e22a3..0000000 --- a/crudkit/html/templates/crudkit/pager.html +++ /dev/null @@ -1,2 +0,0 @@ -{% import 'crudkit/_macros.html' as ui %} -{{ ui.pager(model, page, pages, per_page, sort, filters, fields_csv) }} diff --git a/crudkit/html/templates/crudkit/row.html b/crudkit/html/templates/crudkit/row.html deleted file mode 100644 index 719c0ba..0000000 --- a/crudkit/html/templates/crudkit/row.html +++ /dev/null @@ -1,2 +0,0 @@ -{% import "crudkit/_macros.html" as ui %} -{{ ui.rows([obj], fields, getp=getp) }} \ No newline at end of file diff --git a/crudkit/html/templates/crudkit/rows.html b/crudkit/html/templates/crudkit/rows.html deleted file mode 100644 index 7784328..0000000 --- a/crudkit/html/templates/crudkit/rows.html +++ /dev/null @@ -1,2 +0,0 @@ -{% import "crudkit/_macros.html" as ui %} -{{ ui.rows(items, fields, getp=getp) }} diff --git a/crudkit/html/type_map.py b/crudkit/html/type_map.py deleted file mode 100644 index 5e582c2..0000000 --- a/crudkit/html/type_map.py +++ /dev/null @@ -1,137 +0,0 @@ -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 -from sqlalchemy.sql.sqltypes import ( - String, Text, Unicode, UnicodeText, - Integer, BigInteger, SmallInteger, Float, Numeric, Boolean, - Date, DateTime, Time, JSON, Enum -) - -CANDIDATE_LABELS = ("name", "title", "label", "display_name") - -def _guess_label_attr(model_cls) -> str: - for cand in CANDIDATE_LABELS: - if hasattr(model_cls, cand): - 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)): - return "text" - if isinstance(t, (Text, UnicodeText, JSON)): - return "textarea" - if isinstance(t, (Integer, SmallInteger, BigInteger)): - return "number" - if isinstance(t, (Float, Numeric)): - return "number" - if isinstance(t, Boolean): - return "checkbox" - if isinstance(t, Date): - return "date" - if isinstance(t, DateTime): - return "datetime-local" - if isinstance(t, Time): - return "time" - if isinstance(t, Enum): - return "select" - return "text" - -def _enum_choices(col: Column) -> Optional[List[Tuple[str, str]]]: - t = col.type - if isinstance(t, Enum): - if t.enum_class: - return [(e.name, e.value) for e in t.enum_class] - if t.enums: - return [(v, v) for v in t.enums] - return None - -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: - for lc in rel.local_columns: - fk_map[lc.key] = rel - - for attr in mapper.column_attrs: - col = attr.columns[0] - name = col.key - if include and name not in include: - continue - if name in exclude: - continue - - 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) - if enum_choices: - field["type"] = "select" - field["choices"] = enum_choices - - if name in fk_map: - rel = fk_map[name] - target = rel.mapper.class_ - label_attr = _guess_label_attr(target) - rows = session.execute(select(target).limit(fk_limit)).scalars().all() - field["type"] = "select" - 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 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: - 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 deleted file mode 100644 index c2da1da..0000000 --- a/crudkit/html/ui_fragments.py +++ /dev/null @@ -1,269 +0,0 @@ -from __future__ import annotations -from typing import Any, Dict, List, Tuple -from math import ceil -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.elements import UnaryExpression -from sqlalchemy.sql.sqltypes import Integer, Boolean, Date, DateTime, Float, Numeric - -from ..dsl import QuerySpec -from ..service import CrudService -from ..eager import default_eager_policy -from .type_map import build_form_schema - -Session = None - -def make_fragments_blueprint(db_session_factory, registry: Dict[str, Any], *, name="frags"): - """ - HTML fragments for HTMX/Alpine. No base pages. Pure partials: - GET //frag/options -> - GET //frag/lis ->
  • ...
  • - GET //frag/rows -> ... + pager markup if wanted - GET //frag/form ->
    ...
    (auto-generated) - """ - global Session - if Session is None: - Session = scoped_session(db_session_factory) - - bp = Blueprint(name, __name__, template_folder="templates") - - def session(): - return Session - - @bp.teardown_app_request - def remove_session(exc=None): - Session.remove() - - def _parse_filters(args): - reserved = {"page", "per_page", "sort", "expand", "fields", "value", "label", "label_tpl", "fields_csv", "li_label", "li_sublabel"} - out = {} - for k, v in args.items(): - if k not in reserved and v != "": - out[k] = v - return out - - def _paths_from_csv(csv: str) -> List[str]: - return [p.strip() for p in csv.split(",") if p.strip()] - - def _collect_expand_from_paths(paths: List[str]) -> List[str]: - rels = set() - for p in paths: - bits = p.split(".") - if len(bits) > 1: - rels.add(bits[0]) - return list(rels) - - def _getp(obj, path: str): - cur = obj - for part in path.split("."): - 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) - value_attr = request.args.get("value", default="id") - label_path = request.args.get("label", default="name") - filters = _parse_filters(request.args) - - expand = _collect_expand_from_paths([label_path]) - spec = QuerySpec(filters=filters, order_by=[], page=None, per_page=None, expand=expand) - s = session(); svc = CrudService(s, default_eager_policy) - items, _ = svc.list(Model, spec) - - return render_template("crudkit/options.html", items=items, value_attr=value_attr, label_path=label_path, getp=_getp) - - @bp.get("//frag/lis") - def lis(model): - Model = registry.get(model) or abort(404) - label_path = request.args.get("li_label", default="name") - sublabel_path = request.args.get("li_sublabel") - filters = _parse_filters(request.args) - sort = request.args.get("sort") - page = request.args.get("page", type=int) - per_page = request.args.get("per_page", type=int) - - expand = _collect_expand_from_paths([p for p in (label_path, sublabel_path) if p]) - 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) - rows, total = svc.list(Model, spec) - pages = (ceil(total / per_page) if page and per_page else 1) - return render_template("crudkit/lis.html", items=rows, label_path=label_path, sublabel_path=sublabel_path, page=page or 1, per_page=per_page or 1, total=total, model=model, sort=sort, filters=filters, getp=_getp) - - @bp.get("//frag/rows") - def rows(model): - Model = registry.get(model) or abort(404) - fields_csv = request.args.get("fields_csv") or "id,name" - fields = _paths_from_csv(fields_csv) - filters = _parse_filters(request.args) - sort = request.args.get("sort") - page = request.args.get("page", type=int) or 1 - per_page = request.args.get("per_page", type=int) or 20 - - 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) - 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, model=model) - - return html - - @bp.get("//frag/pager") - def pager(model): - Model = registry.get(model) or abort(404) - page = request.args.get("page", type=int) or 1 - print(page) - per_page = request.args.get("per_page", type=int) or 20 - filters = _parse_filters(request.args) - sort = request.args.get("sort") - fields_csv = request.args.get("fields_csv") or "id,name" - fields = _paths_from_csv(fields_csv) - 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) - s = session(); svc = CrudService(s, default_eager_policy) - _, total = svc.list(Model, spec) - pages = max(1, ceil(total / per_page)) - - html = render_template("crudkit/pager.html", model=model, page=page, pages=pages, - per_page=per_page, sort=sort, filters=filters, fields_csv=fields_csv) - return html - - @bp.get("//frag/form") - def form(model): - Model = registry.get(model) or abort(404) - id = request.args.get("id", type=int) - include_csv = request.args.get("include") - include = [s.strip() for s in include_csv.split(",")] if include_csv else None - - s = session(); svc = CrudService(s, default_eager_policy) - obj = svc.get(Model, id) if id else None - - schema = build_form_schema(Model, s, obj=obj, include=include) - - hx = request.args.get("hx", type=int) == 1 - return render_template("crudkit/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/crudkit/mixins.py b/crudkit/mixins.py deleted file mode 100644 index 38a0e71..0000000 --- a/crudkit/mixins.py +++ /dev/null @@ -1,23 +0,0 @@ -import datetime as dt -from sqlalchemy import Column, Integer, DateTime, Boolean -from sqlalchemy.orm import declared_attr -from sqlalchemy.ext.hybrid import hybrid_property - -class CrudMixin: - id = Column(Integer, primary_key=True) - created_at = Column(DateTime, default=dt.datetime.utcnow, nullable=False) - updated_at = Column(DateTime, default=dt.datetime.utcnow, onupdate=dt.datetime.utcnow, nullable=False) - deleted = Column("deleted", Boolean, default=False, nullable=False) - version = Column(Integer, default=1, nullable=False) - - @hybrid_property - def is_deleted(self): - return self.deleted - - def mark_deleted(self): - self.deleted = True - self.version += 1 - - @declared_attr - def __mapper_args__(cls): - return {"version_id_col": cls.version} diff --git a/crudkit/serialize.py b/crudkit/serialize.py deleted file mode 100644 index 3ba6116..0000000 --- a/crudkit/serialize.py +++ /dev/null @@ -1,22 +0,0 @@ -def serialize(obj, *, fields=None, expand=None): - expand = set(expand or []) - fields = set(fields or []) - out = {} - # base columns - for col in obj.__table__.columns: - name = col.key - if fields and name not in fields: - continue - out[name] = getattr(obj, name) - # expansions - for rel in obj.__mapper__.relationships: - if rel.key not in expand: - continue - val = getattr(obj, rel.key) - if val is None: - out[rel.key] = None - elif rel.uselist: - out[rel.key] = [serialize(child) for child in val] - else: - out[rel.key] = serialize(val) - return out \ No newline at end of file diff --git a/crudkit/service.py b/crudkit/service.py deleted file mode 100644 index a62ae18..0000000 --- a/crudkit/service.py +++ /dev/null @@ -1,169 +0,0 @@ -import sqlalchemy as sa -from sqlalchemy import func, asc -from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session, aliased -from sqlalchemy.inspection import inspect -from sqlalchemy.sql.elements import UnaryExpression - -from .dsl import QuerySpec, build_query, split_sort_tokens -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: - def __init__(self, session: Session, eager_policy=default_eager_policy): - self.s = session - self.eager_policy = eager_policy - - def create(self, Model, data, *, before=None, after=None): - if before: data = before(data) or data - obj = Model(**data) - self.s.add(obj) - self.s.flush() - if after: after(obj) - return obj - - def get(self, Model, id, spec: QuerySpec | None = None): - spec = spec or QuerySpec() - stmt = build_query(Model, spec, self.eager_policy).where(Model.id == id) - return self.s.execute(stmt).scalars().first() - - def list(self, Model, spec: QuerySpec): - stmt = build_query(Model, spec, self.eager_policy) - - 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: - 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() - return rows, total - - def update(self, obj, data, *, before=None, after=None): - if obj.is_deleted: raise ValueError("Cannot update a deleted record") - if before: data = before(obj, data) or data - for k, v in data.items(): setattr(obj, k, v) - obj.version += 1 - if after: after(obj) - return obj - - def soft_delete(self, obj, *, cascade=False, guard=None): - if guard and not guard(obj): raise ValueError("Delete blocked by guard") - # optionsl FK hygiene checks go here - obj.mark_deleted() - return obj - - def undelete(self, obj): - obj.deleted = False - obj.version += 1 - return obj diff --git a/crudkit/ui/__init__.py b/crudkit/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/crudkit/ui/fragments.py b/crudkit/ui/fragments.py new file mode 100644 index 0000000..e69de29 diff --git a/example_app/app.py b/example_app/app.py deleted file mode 100644 index 4061fcd..0000000 --- a/example_app/app.py +++ /dev/null @@ -1,27 +0,0 @@ -from flask import Flask, render_template -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -from .models import Base, Author, Book -from crudkit.blueprint import make_blueprint as make_json_blueprint -from crudkit.html import make_fragments_blueprint - -engine = create_engine("sqlite:///example.db", echo=True, future=True) -SessionLocal = sessionmaker(bind=engine, expire_on_commit=False) - -def session_factory(): - return SessionLocal() - -registry = {"author": Author, "book": Book} - -def create_app(): - app = Flask(__name__) - 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__": - create_app().run(debug=True) diff --git a/example_app/models.py b/example_app/models.py deleted file mode 100644 index 68ce56a..0000000 --- a/example_app/models.py +++ /dev/null @@ -1,18 +0,0 @@ -from typing import List -from sqlalchemy import String, ForeignKey -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship -from crudkit import CrudMixin - -class Base(DeclarativeBase): - pass - -class Author(CrudMixin, Base): - __tablename__ = "author" - name: Mapped[str] = mapped_column(String(200), nullable=False) - books: Mapped[List["Book"]] = relationship(back_populates="author", cascade="all, delete-orphan") - -class Book(CrudMixin, Base): - __tablename__ = "book" - title: Mapped[str] = mapped_column(String(200), nullable=False) - author_id: Mapped[int] = mapped_column(ForeignKey("author.id"), nullable=False) - author: Mapped[Author] = relationship(back_populates="books") diff --git a/example_app/seed.py b/example_app/seed.py deleted file mode 100644 index 6422861..0000000 --- a/example_app/seed.py +++ /dev/null @@ -1,19 +0,0 @@ -from .app import SessionLocal, engine -from .models import Base, Author, Book - -def run(): - Base.metadata.create_all(engine) - s = SessionLocal() - a1 = Author(name="Ursula K. Le Guin") - a2 = Author(name="Octavia E. Butler") - s.add_all([ - a1, a2, - Book(title="The Left Hand of Darkness", author=a1), - Book(title="A Wizard of Earthsea", author=a1), - Book(title="Parable of the Sower", author=a2), - ]) - s.commit() - s.close() - -if __name__ == "__main__": - run() diff --git a/example_app/templates/demo.html b/example_app/templates/demo.html deleted file mode 100644 index 56491fb..0000000 --- a/example_app/templates/demo.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - -
    IDTitleAuthor
    - - - - - -- 2.51.2 From e8755fae4d0fb6ce682082722d3a99dda928c97c Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Tue, 2 Sep 2025 08:03:24 -0500 Subject: [PATCH 02/36] Bringing gitignore back. Whoops. --- .gitignore | 12 ++++++++++++ crudapi.egg-info/PKG-INFO | 9 --------- crudapi.egg-info/SOURCES.txt | 8 -------- crudapi.egg-info/dependency_links.txt | 1 - crudapi.egg-info/requires.txt | 4 ---- crudapi.egg-info/top_level.txt | 1 - 6 files changed, 12 insertions(+), 23 deletions(-) create mode 100644 .gitignore delete mode 100644 crudapi.egg-info/PKG-INFO delete mode 100644 crudapi.egg-info/SOURCES.txt delete mode 100644 crudapi.egg-info/dependency_links.txt delete mode 100644 crudapi.egg-info/requires.txt delete mode 100644 crudapi.egg-info/top_level.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bae2c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +**/__pycache__/ +inventory/static/uploads/* +!inventory/static/uploads/.gitkeep +.venv/ +.env +*.db +*.db-journal +*.sqlite +*.sqlite3 +alembic.ini +alembic/ +*.egg-info/ \ No newline at end of file diff --git a/crudapi.egg-info/PKG-INFO b/crudapi.egg-info/PKG-INFO deleted file mode 100644 index ab7e693..0000000 --- a/crudapi.egg-info/PKG-INFO +++ /dev/null @@ -1,9 +0,0 @@ -Metadata-Version: 2.4 -Name: crudkit -Version: 0.1.0 -Summary: A Flask API for better SQLAlchemy usage. -Requires-Python: >=3.9 -Requires-Dist: flask -Requires-Dist: flask_sqlalchemy -Requires-Dist: python-dotenv -Requires-Dist: Werkzeug diff --git a/crudapi.egg-info/SOURCES.txt b/crudapi.egg-info/SOURCES.txt deleted file mode 100644 index 10248da..0000000 --- a/crudapi.egg-info/SOURCES.txt +++ /dev/null @@ -1,8 +0,0 @@ -pyproject.toml -crudkit/__init__.py -crudkit/mixins.py -crudkit.egg-info/PKG-INFO -crudkit.egg-info/SOURCES.txt -crudkit.egg-info/dependency_links.txt -crudkit.egg-info/requires.txt -crudkit.egg-info/top_level.txt \ No newline at end of file diff --git a/crudapi.egg-info/dependency_links.txt b/crudapi.egg-info/dependency_links.txt deleted file mode 100644 index 8b13789..0000000 --- a/crudapi.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/crudapi.egg-info/requires.txt b/crudapi.egg-info/requires.txt deleted file mode 100644 index cd67c46..0000000 --- a/crudapi.egg-info/requires.txt +++ /dev/null @@ -1,4 +0,0 @@ -flask -flask_sqlalchemy -python-dotenv -Werkzeug diff --git a/crudapi.egg-info/top_level.txt b/crudapi.egg-info/top_level.txt deleted file mode 100644 index 2c5bac7..0000000 --- a/crudapi.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -crudkit -- 2.51.2 From 42e9710f48696439049f0b232ec2ba945d22b418 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Tue, 2 Sep 2025 08:46:45 -0500 Subject: [PATCH 03/36] Initial framework. --- .gitignore | 3 ++- crudkit/api/flask_api.py | 29 +++++++++++++++++++++++++++++ crudkit/core/base.py | 11 +++++++++++ crudkit/core/service.py | 33 +++++++++++++++++++++++++++++++++ crudkit/ui/fragments.py | 13 +++++++++++++ crudkit/ui/templates/field.html | 2 ++ crudkit/ui/templates/table.html | 8 ++++++++ 7 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 crudkit/ui/templates/field.html create mode 100644 crudkit/ui/templates/table.html diff --git a/.gitignore b/.gitignore index 3bae2c6..179bb33 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ inventory/static/uploads/* *.sqlite3 alembic.ini alembic/ -*.egg-info/ \ No newline at end of file +*.egg-info/ +test-app/ \ No newline at end of file diff --git a/crudkit/api/flask_api.py b/crudkit/api/flask_api.py index e69de29..94e7d3f 100644 --- a/crudkit/api/flask_api.py +++ b/crudkit/api/flask_api.py @@ -0,0 +1,29 @@ +from flask import Blueprint, jsonify, request + +def generate_crud_blueprint(model, service): + bp = Blueprint(model.__name__.lower(), __name__) + + @bp.get('/') + def list_items(): + items = service.list() + return jsonify([item.as_dict() for item in items]) + + @bp.get('/') + def get_item(id): + item = service.get(id) + return jsonify(item.as_dict()) + + @bp.post('/') + def create_item(): + obj = service.create(request.json) + return jsonify(obj.as_dict()) + + @bp.patch('/') + def update_item(id): + obj = service.update(id, request.json) + return jsonify(obj.as_dict()) + + @bp.delete('/') + def delete_item(id): + service.delete(id) + return '', 204 diff --git a/crudkit/core/base.py b/crudkit/core/base.py index e69de29..4618a85 100644 --- a/crudkit/core/base.py +++ b/crudkit/core/base.py @@ -0,0 +1,11 @@ +from sqlalchemy import Column, Integer, DateTime, func +from sqlalchemy.orm import declarative_mixin + +@declarative_mixin +class CRUDMixin: + id = Column(Integer, primary_key=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + def as_dict(self): + return {c.name: getattr(self, c.name) for c in self.__table__.columns} diff --git a/crudkit/core/service.py b/crudkit/core/service.py index e69de29..ca58dcf 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -0,0 +1,33 @@ +from typing import Type, TypeVar, Generic +from sqlalchemy.orm import Session + +T = TypeVar("T") + +class CRUDService(Generic[T]): + def __init__(self, model: Type[T], session: Session): + self.model = model + self.session = session + + def get(self, id: int) -> T: + return self.session.get(self.model, id) + + def list(self, limit=100, offset=0) -> list[T]: + return self.session.query(self.model).offset(offset).limit(limit).all() + + def create(self, data: dict) -> T: + obj = self.model(**data) + self.session.add(obj) + self.session.commit() + return obj + + def update(self, id: int, data: dict) -> T: + obj = self.get(id) + for k, v in data.items(): + setattr(obj, k, v) + self.session.commit() + return obj + + def delete(self, id: int): + obj = self.get(id) + self.session.delete(obj) + self.session.commit() diff --git a/crudkit/ui/fragments.py b/crudkit/ui/fragments.py index e69de29..db169d0 100644 --- a/crudkit/ui/fragments.py +++ b/crudkit/ui/fragments.py @@ -0,0 +1,13 @@ +from jinja2 import Environment, FileSystemLoader +import os + +FRAGMENT_PATH = os.path.join(os.path.dirname(__file__), 'templates') +env = Environment(loader=FileSystemLoader(FRAGMENT_PATH)) + +def render_field(field_name, value): + template = env.get_template('field.html') + return template.render(field_name=field_name, value=value) + +def render_table(objects): + template = env.get_template('table.html') + return template.render(objects=objects) diff --git a/crudkit/ui/templates/field.html b/crudkit/ui/templates/field.html new file mode 100644 index 0000000..d6904dc --- /dev/null +++ b/crudkit/ui/templates/field.html @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/crudkit/ui/templates/table.html b/crudkit/ui/templates/table.html new file mode 100644 index 0000000..8abd5e0 --- /dev/null +++ b/crudkit/ui/templates/table.html @@ -0,0 +1,8 @@ + + + {% for field in objects[0].__table__.columns %}{% endfor %} + + {% for obj in objects %} + {% for field in obj.__table__columns %}{% endfor %} + {% endfor %} +
    {{ field.name }}
    {{ getattr(obj, field.name) }}
    \ No newline at end of file -- 2.51.2 From 40e727f5bf78ffe716fa1a0f8cf1b3dc05487a87 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Tue, 2 Sep 2025 10:04:08 -0500 Subject: [PATCH 04/36] Used a very basic testing app to find and fix some bugs. --- .gitignore | 2 +- crudkit/api/flask_api.py | 2 ++ crudkit/ui/fragments.py | 13 +++++++++++++ crudkit/ui/templates/form.html | 6 ++++++ crudkit/ui/templates/table.html | 2 +- 5 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 crudkit/ui/templates/form.html diff --git a/.gitignore b/.gitignore index 179bb33..09da556 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,4 @@ inventory/static/uploads/* alembic.ini alembic/ *.egg-info/ -test-app/ \ No newline at end of file +test_app/ \ No newline at end of file diff --git a/crudkit/api/flask_api.py b/crudkit/api/flask_api.py index 94e7d3f..611c8c0 100644 --- a/crudkit/api/flask_api.py +++ b/crudkit/api/flask_api.py @@ -27,3 +27,5 @@ def generate_crud_blueprint(model, service): def delete_item(id): service.delete(id) return '', 204 + + return bp diff --git a/crudkit/ui/fragments.py b/crudkit/ui/fragments.py index db169d0..9ac9166 100644 --- a/crudkit/ui/fragments.py +++ b/crudkit/ui/fragments.py @@ -11,3 +11,16 @@ def render_field(field_name, value): def render_table(objects): template = env.get_template('table.html') return template.render(objects=objects) + +def render_form(model_cls, values): + template = env.get_template('form.html') + fields = [] + for col in model_cls.__table__.columns: + if col.name == 'id': + continue + if col.default or col.server_default or col.onupdate: + continue + if col.name in ('created_at', 'updated_at'): + continue + fields.append(col) + return template.render(fields=fields, values=values, render_field=render_field) diff --git a/crudkit/ui/templates/form.html b/crudkit/ui/templates/form.html new file mode 100644 index 0000000..09ad44c --- /dev/null +++ b/crudkit/ui/templates/form.html @@ -0,0 +1,6 @@ +
    + {% for field in fields %} + {{ render_field(field.name, values.get(field.name, '')) }} + {% endfor %} + +
    diff --git a/crudkit/ui/templates/table.html b/crudkit/ui/templates/table.html index 8abd5e0..5b65d61 100644 --- a/crudkit/ui/templates/table.html +++ b/crudkit/ui/templates/table.html @@ -3,6 +3,6 @@ {% for field in objects[0].__table__.columns %}{{ field.name }}{% endfor %} {% for obj in objects %} - {% for field in obj.__table__columns %}{{ getattr(obj, field.name) }}{% endfor %} + {% for field in obj.__table__.columns %}{{ obj[field.name] }}{% endfor %} {% endfor %} \ No newline at end of file -- 2.51.2 From c22ecf44ec0cdbae0465c34d83f1f270c941b4e4 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Tue, 2 Sep 2025 12:27:51 -0500 Subject: [PATCH 05/36] More changes brought through testing. --- .gitignore | 3 +- crudkit/api/flask_api.py | 2 +- crudkit/core/service.py | 17 +++++++++-- crudkit/core/spec.py | 53 +++++++++++++++++++++++++++++++++ crudkit/ui/templates/table.html | 16 ++++++---- test_app/app.py | 31 +++++++++++++++++++ test_app/db.py | 6 ++++ test_app/models.py | 18 +++++++++++ test_app/templates/index.html | 14 +++++++++ 9 files changed, 149 insertions(+), 11 deletions(-) create mode 100644 crudkit/core/spec.py create mode 100644 test_app/app.py create mode 100644 test_app/db.py create mode 100644 test_app/models.py create mode 100644 test_app/templates/index.html diff --git a/.gitignore b/.gitignore index 09da556..3bae2c6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,4 @@ inventory/static/uploads/* *.sqlite3 alembic.ini alembic/ -*.egg-info/ -test_app/ \ No newline at end of file +*.egg-info/ \ No newline at end of file diff --git a/crudkit/api/flask_api.py b/crudkit/api/flask_api.py index 611c8c0..ddb77a9 100644 --- a/crudkit/api/flask_api.py +++ b/crudkit/api/flask_api.py @@ -5,7 +5,7 @@ def generate_crud_blueprint(model, service): @bp.get('/') def list_items(): - items = service.list() + items = service.list(request.args) return jsonify([item.as_dict() for item in items]) @bp.get('/') diff --git a/crudkit/core/service.py b/crudkit/core/service.py index ca58dcf..b9d10ac 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -1,5 +1,6 @@ from typing import Type, TypeVar, Generic from sqlalchemy.orm import Session +from crudkit.core.spec import CRUDSpec T = TypeVar("T") @@ -11,8 +12,20 @@ class CRUDService(Generic[T]): def get(self, id: int) -> T: return self.session.get(self.model, id) - def list(self, limit=100, offset=0) -> list[T]: - return self.session.query(self.model).offset(offset).limit(limit).all() + def list(self, params=None) -> list[T]: + query = self.session.query(self.model) + if params: + spec = CRUDSpec(self.model, params) + filters = spec.parse_filters() + order_by = spec.parse_sort() + limit, offset = spec.parse_pagination() + + if filters: + query = query.filter(*filters) + if order_by: + query = query.order_by(*order_by) + query = query.offset(offset).limit(limit) + return query.all() def create(self, data: dict) -> T: obj = self.model(**data) diff --git a/crudkit/core/spec.py b/crudkit/core/spec.py new file mode 100644 index 0000000..a1f8292 --- /dev/null +++ b/crudkit/core/spec.py @@ -0,0 +1,53 @@ +from typing import List, Tuple +from sqlalchemy import asc, desc, or_, and_ + +OPERATORS = { + 'eq': lambda col, val: col == val, + 'lt': lambda col, val: col < val, + 'lte': lambda col, val: col <= val, + 'gt': lambda col, val: col > val, + 'gte': lambda col, val: col >= val, + 'ne': lambda col, val: col != val, + 'icontains': lambda col, val: col.ilike(f"%{val}%"), +} + +class CRUDSpec: + def __init__(self, model, params): + self.model = model + self.params = params + + def parse_filters(self): + filters = [] + for key, value in self.params.items(): + if key in ('sort', 'limit', 'offset'): + continue + if '__' in key: + field, op = key.split('__', 1) + else: + field, op = key, 'eq' + if hasattr(self.model, field): + col = getattr(self.model, field) + filters.append(OPERATORS[op](col, value)) + return filters + + def parse_sort(self): + sort_args = self.params.get('sort', '') + result = [] + for part in sort_args.split(','): + part = part.strip() + if not part: + continue + if part.startswith('-'): + field = part[1:] + order = desc + else: + field = part + order = asc + if hasattr(self.model, field): + result.append(order(getattr(self.model, field))) + return result + + def parse_pagination(self): + limit = int(self.params.get('limit', 100)) + offset = int(self.params.get('offset', 0)) + return limit, offset diff --git a/crudkit/ui/templates/table.html b/crudkit/ui/templates/table.html index 5b65d61..b4abd80 100644 --- a/crudkit/ui/templates/table.html +++ b/crudkit/ui/templates/table.html @@ -1,8 +1,12 @@ - - {% for field in objects[0].__table__.columns %}{% endfor %} - - {% for obj in objects %} - {% for field in obj.__table__.columns %}{% endfor %} - {% endfor %} + {% if objects %} + + {% for field in objects[0].__table__.columns %}{% endfor %} + + {% for obj in objects %} + {% for field in obj.__table__.columns %}{% endfor %} + {% endfor %} + {% else %} + + {% endif %}
    {{ field.name }}
    {{ obj[field.name] }}
    {{ field.name }}
    {{ obj[field.name] }}
    No data.
    \ No newline at end of file diff --git a/test_app/app.py b/test_app/app.py new file mode 100644 index 0000000..8d3f8bd --- /dev/null +++ b/test_app/app.py @@ -0,0 +1,31 @@ +from flask import Flask, render_template, request, redirect, url_for +from test_app.models import Device, User +from test_app.db import Base, engine, SessionLocal +from crudkit.core.service import CRUDService +from crudkit.api.flask_api import generate_crud_blueprint +from crudkit.ui.fragments import render_table, render_form + +app = Flask(__name__) + +Base.metadata.create_all(engine) + +session = SessionLocal() +device_service = CRUDService(Device, session) +user_service = CRUDService(User, session) + +app.register_blueprint(generate_crud_blueprint(Device, device_service), url_prefix='/api/devices') +app.register_blueprint(generate_crud_blueprint(User, user_service), url_prefix='/api/users') + +@app.route('/', methods=['GET', 'POST']) +def index(): + if request.method == 'POST': + device_service.create(request.form.to_dict()) + return redirect(url_for('index')) + + devices = device_service.list() + table = render_table(devices) + form = render_form(Device, {}) + return render_template('index.html', table=table, form=form) + +if __name__ == '__main__': + app.run(debug=True, host='127.0.0.1', port=5050) diff --git a/test_app/db.py b/test_app/db.py new file mode 100644 index 0000000..8bd38fd --- /dev/null +++ b/test_app/db.py @@ -0,0 +1,6 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base + +engine = create_engine('sqlite:///test.db', echo=True) +SessionLocal = sessionmaker(bind=engine) +Base = declarative_base() diff --git a/test_app/models.py b/test_app/models.py new file mode 100644 index 0000000..79df3e1 --- /dev/null +++ b/test_app/models.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, String, Integer, ForeignKey +from sqlalchemy.orm import relationship +from crudkit.core.base import CRUDMixin +from test_app.db import Base + +class User(CRUDMixin, Base): + __tablename__ = 'users' + name = Column(String) + email = Column(String) + supervisor_id = Column(Integer, ForeignKey('users.id')) + supervisor = relationship('User', remote_side='User.id') + +class Device(CRUDMixin, Base): + __tablename__ = 'devices' + name = Column(String) + serial = Column(String) + assigned_to_id = Column(Integer, ForeignKey('users.id')) + assigned_to = relationship('User') diff --git a/test_app/templates/index.html b/test_app/templates/index.html new file mode 100644 index 0000000..a690a2b --- /dev/null +++ b/test_app/templates/index.html @@ -0,0 +1,14 @@ + + + + Device List + + +

    Devices

    + {{ table|safe }} + + +

    Add Device

    + {{ form|safe }} + + -- 2.51.2 From 5307210a7bb4356be7701f3272f70eea6e534de9 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Tue, 2 Sep 2025 13:16:20 -0500 Subject: [PATCH 06/36] Relationship resolution! --- crudkit/core/service.py | 6 ++++ crudkit/core/spec.py | 73 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 71 insertions(+), 8 deletions(-) diff --git a/crudkit/core/service.py b/crudkit/core/service.py index b9d10ac..e82bec1 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -20,6 +20,12 @@ class CRUDService(Generic[T]): order_by = spec.parse_sort() limit, offset = spec.parse_pagination() + for parent, relationship_attr, alias in spec.get_join_paths(): + query = query.join(alias, relationship_attr.of_type(alias), isouter=True) + + for eager in spec.get_eager_loads(): + query = query.options(eager) + if filters: query = query.filter(*filters) if order_by: diff --git a/crudkit/core/spec.py b/crudkit/core/spec.py index a1f8292..5840071 100644 --- a/crudkit/core/spec.py +++ b/crudkit/core/spec.py @@ -1,5 +1,7 @@ -from typing import List, Tuple -from sqlalchemy import asc, desc, or_, and_ +from typing import List, Tuple, Set, Dict +from sqlalchemy import asc, desc +from sqlalchemy.orm import joinedload, aliased +from sqlalchemy.orm.attributes import InstrumentedAttribute OPERATORS = { 'eq': lambda col, val: col == val, @@ -15,6 +17,34 @@ class CRUDSpec: def __init__(self, model, params): self.model = model self.params = params + self.eager_paths: Set[Tuple[str, ...]] = set() + self.join_paths: List[Tuple[object, InstrumentedAttribute, object]] = [] + self.alias_map: Dict[Tuple[str, ...], object] = {} + + def _resolve_column(self, path: str): + current_model = self.model + current_alias = self.model + parts = path.split('.') + join_path = [] + + for i, attr in enumerate(parts): + if not hasattr(current_model, attr): + return None, None + attr_obj = getattr(current_model, attr) + if isinstance(attr_obj, InstrumentedAttribute): + if hasattr(attr_obj.property, 'direction'): + join_path.append(attr) + path_key = tuple(join_path) + alias = self.alias_map.get(path_key) + if not alias: + alias = aliased(attr_obj.property.mapper.class_) + self.alias_map[path_key] = alias + self.join_paths.append((current_alias, attr_obj, alias)) + current_model = attr_obj.property.mapper.class_ + current_alias = alias + else: + return getattr(current_alias, attr), tuple(join_path) if join_path else None + return None, None def parse_filters(self): filters = [] @@ -22,12 +52,17 @@ class CRUDSpec: if key in ('sort', 'limit', 'offset'): continue if '__' in key: - field, op = key.split('__', 1) + path_op = key.rsplit('__', 1) + if len(path_op) != 2: + continue + path, op = path_op else: - field, op = key, 'eq' - if hasattr(self.model, field): - col = getattr(self.model, field) + path, op = key, 'eq' + col, join_path = self._resolve_column(path) + if col and op in OPERATORS: filters.append(OPERATORS[op](col, value)) + if join_path: + self.eager_paths.add(join_path) return filters def parse_sort(self): @@ -43,11 +78,33 @@ class CRUDSpec: else: field = part order = asc - if hasattr(self.model, field): - result.append(order(getattr(self.model, field))) + col, join_path = self._resolve_column(field) + if col: + result.append(order(col)) + if join_path: + self.eager_paths.add(join_path) return result def parse_pagination(self): limit = int(self.params.get('limit', 100)) offset = int(self.params.get('offset', 0)) return limit, offset + + def get_eager_loads(self): + loads = [] + for path in self.eager_paths: + current = self.model + loader = None + for attr in path: + attr_obj = getattr(current, attr) + if loader is None: + loader = joinedload(attr_obj) + else: + loader = loader.joinedload(attr_obj) + current = attr_obj.property.mapper.class_ + if loader: + loads.append(loader) + return loads + + def get_join_paths(self): + return self.join_paths -- 2.51.2 From cfb25f098a86f6b9fc47a278052bce135b14d2a3 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Tue, 2 Sep 2025 14:05:52 -0500 Subject: [PATCH 07/36] Dropdowns for foreign_keys in fields. --- crudkit/ui/fragments.py | 52 ++++++++++++++++++++++++++++----- crudkit/ui/templates/field.html | 18 ++++++++++-- crudkit/ui/templates/form.html | 2 +- test_app/app.py | 2 +- test_app/templates/index.html | 1 - 5 files changed, 63 insertions(+), 12 deletions(-) diff --git a/crudkit/ui/fragments.py b/crudkit/ui/fragments.py index 9ac9166..ec1bd0c 100644 --- a/crudkit/ui/fragments.py +++ b/crudkit/ui/fragments.py @@ -1,26 +1,64 @@ from jinja2 import Environment, FileSystemLoader +from sqlalchemy.orm import class_mapper, RelationshipProperty import os FRAGMENT_PATH = os.path.join(os.path.dirname(__file__), 'templates') env = Environment(loader=FileSystemLoader(FRAGMENT_PATH)) -def render_field(field_name, value): +def render_field(field, value): + print(field) template = env.get_template('field.html') - return template.render(field_name=field_name, value=value) + return template.render( + field_name=field['name'], + field_label=field.get('label', field['name']), + value=value, + field_type=field.get('type', 'text'), + options=field.get('options', None) + ) + def render_table(objects): template = env.get_template('table.html') return template.render(objects=objects) -def render_form(model_cls, values): +def render_form(model_cls, values, session=None): template = env.get_template('form.html') fields = [] + fk_fields = set() + + mapper = class_mapper(model_cls) + for prop in mapper.iterate_properties: + # FK Relationship fields (many-to-one) + if isinstance(prop, RelationshipProperty) and prop.direction.name == 'MANYTOONE': + if session is None: + continue + + related_model = prop.mapper.class_ + options = session.query(related_model).all() + fields.append({ + 'name': f"{prop.key}_id", + 'label': prop.key, + 'type': 'select', + 'options': [ + {'value': getattr(obj, 'id'), 'label': str(obj)} + for obj in options + ] + }) + fk_fields.add(f"{prop.key}_id") + + # Now add basic columns — excluding FKs already covered for col in model_cls.__table__.columns: - if col.name == 'id': + if col.name in fk_fields: + continue + if col.name in ('id', 'created_at', 'updated_at'): continue if col.default or col.server_default or col.onupdate: continue - if col.name in ('created_at', 'updated_at'): - continue - fields.append(col) + fields.append({ + 'name': col.name, + 'label': col.name, + 'type': 'text', + }) + return template.render(fields=fields, values=values, render_field=render_field) + diff --git a/crudkit/ui/templates/field.html b/crudkit/ui/templates/field.html index d6904dc..28fcf7e 100644 --- a/crudkit/ui/templates/field.html +++ b/crudkit/ui/templates/field.html @@ -1,2 +1,16 @@ - - \ No newline at end of file + + +{% if field_type == 'select' %} + +{% else %} + +{% endif %} diff --git a/crudkit/ui/templates/form.html b/crudkit/ui/templates/form.html index 09ad44c..6109e25 100644 --- a/crudkit/ui/templates/form.html +++ b/crudkit/ui/templates/form.html @@ -1,6 +1,6 @@
    {% for field in fields %} - {{ render_field(field.name, values.get(field.name, '')) }} + {{ render_field(field, values.get(field.name, '')) }} {% endfor %}
    diff --git a/test_app/app.py b/test_app/app.py index 8d3f8bd..9e0a837 100644 --- a/test_app/app.py +++ b/test_app/app.py @@ -24,7 +24,7 @@ def index(): devices = device_service.list() table = render_table(devices) - form = render_form(Device, {}) + form = render_form(Device, {}, session) return render_template('index.html', table=table, form=form) if __name__ == '__main__': diff --git a/test_app/templates/index.html b/test_app/templates/index.html index a690a2b..cf80ed9 100644 --- a/test_app/templates/index.html +++ b/test_app/templates/index.html @@ -7,7 +7,6 @@

    Devices

    {{ table|safe }} -

    Add Device

    {{ form|safe }} -- 2.51.2 From 699a866a216c671592badd6cab95dea8bdde6f6d Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Tue, 2 Sep 2025 15:28:42 -0500 Subject: [PATCH 08/36] Granular replacement of fragments! --- crudkit/ui/fragments.py | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/crudkit/ui/fragments.py b/crudkit/ui/fragments.py index ec1bd0c..d30e24b 100644 --- a/crudkit/ui/fragments.py +++ b/crudkit/ui/fragments.py @@ -1,13 +1,29 @@ -from jinja2 import Environment, FileSystemLoader +from jinja2 import Environment, FileSystemLoader, ChoiceLoader from sqlalchemy.orm import class_mapper, RelationshipProperty +from flask import current_app import os -FRAGMENT_PATH = os.path.join(os.path.dirname(__file__), 'templates') -env = Environment(loader=FileSystemLoader(FRAGMENT_PATH)) +def get_env(): + app_loader = current_app.jinja_loader + + default_path = os.path.join(os.path.dirname(__file__), 'templates') + fallback_loader = FileSystemLoader(default_path) + + env = Environment(loader=ChoiceLoader([ + app_loader, + fallback_loader + ])) + return env + +def get_crudkit_template(env, name): + try: + return env.get_template(f'crudkit/{name}') + except Exception: + return env.get_template(name) def render_field(field, value): - print(field) - template = env.get_template('field.html') + env = get_env() + template = get_crudkit_template(env, 'field.html') return template.render( field_name=field['name'], field_label=field.get('label', field['name']), @@ -16,13 +32,14 @@ def render_field(field, value): options=field.get('options', None) ) - def render_table(objects): - template = env.get_template('table.html') + env = get_env() + template = get_crudkit_template(env, 'table.html') return template.render(objects=objects) def render_form(model_cls, values, session=None): - template = env.get_template('form.html') + env = get_env() + template = get_crudkit_template(env, 'form.html') fields = [] fk_fields = set() -- 2.51.2 From 59bc92d76728373cee038bbb3a32c6c1ac26080f Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Tue, 2 Sep 2025 15:32:10 -0500 Subject: [PATCH 09/36] Removing the test application. --- test_app/app.py | 31 ------------------------------- test_app/db.py | 6 ------ test_app/models.py | 18 ------------------ test_app/templates/index.html | 13 ------------- 4 files changed, 68 deletions(-) delete mode 100644 test_app/app.py delete mode 100644 test_app/db.py delete mode 100644 test_app/models.py delete mode 100644 test_app/templates/index.html diff --git a/test_app/app.py b/test_app/app.py deleted file mode 100644 index 9e0a837..0000000 --- a/test_app/app.py +++ /dev/null @@ -1,31 +0,0 @@ -from flask import Flask, render_template, request, redirect, url_for -from test_app.models import Device, User -from test_app.db import Base, engine, SessionLocal -from crudkit.core.service import CRUDService -from crudkit.api.flask_api import generate_crud_blueprint -from crudkit.ui.fragments import render_table, render_form - -app = Flask(__name__) - -Base.metadata.create_all(engine) - -session = SessionLocal() -device_service = CRUDService(Device, session) -user_service = CRUDService(User, session) - -app.register_blueprint(generate_crud_blueprint(Device, device_service), url_prefix='/api/devices') -app.register_blueprint(generate_crud_blueprint(User, user_service), url_prefix='/api/users') - -@app.route('/', methods=['GET', 'POST']) -def index(): - if request.method == 'POST': - device_service.create(request.form.to_dict()) - return redirect(url_for('index')) - - devices = device_service.list() - table = render_table(devices) - form = render_form(Device, {}, session) - return render_template('index.html', table=table, form=form) - -if __name__ == '__main__': - app.run(debug=True, host='127.0.0.1', port=5050) diff --git a/test_app/db.py b/test_app/db.py deleted file mode 100644 index 8bd38fd..0000000 --- a/test_app/db.py +++ /dev/null @@ -1,6 +0,0 @@ -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker, declarative_base - -engine = create_engine('sqlite:///test.db', echo=True) -SessionLocal = sessionmaker(bind=engine) -Base = declarative_base() diff --git a/test_app/models.py b/test_app/models.py deleted file mode 100644 index 79df3e1..0000000 --- a/test_app/models.py +++ /dev/null @@ -1,18 +0,0 @@ -from sqlalchemy import Column, String, Integer, ForeignKey -from sqlalchemy.orm import relationship -from crudkit.core.base import CRUDMixin -from test_app.db import Base - -class User(CRUDMixin, Base): - __tablename__ = 'users' - name = Column(String) - email = Column(String) - supervisor_id = Column(Integer, ForeignKey('users.id')) - supervisor = relationship('User', remote_side='User.id') - -class Device(CRUDMixin, Base): - __tablename__ = 'devices' - name = Column(String) - serial = Column(String) - assigned_to_id = Column(Integer, ForeignKey('users.id')) - assigned_to = relationship('User') diff --git a/test_app/templates/index.html b/test_app/templates/index.html deleted file mode 100644 index cf80ed9..0000000 --- a/test_app/templates/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - Device List - - -

    Devices

    - {{ table|safe }} - -

    Add Device

    - {{ form|safe }} - - -- 2.51.2 From 909f20f207c6cf859c0e8d6109f3a3b240ec9a9e Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Tue, 2 Sep 2025 16:25:52 -0500 Subject: [PATCH 10/36] Adding soft-delete support. --- crudkit/core/base.py | 6 ++++-- crudkit/core/service.py | 40 ++++++++++++++++++++++++++++++++++------ muck/models/dbref.py | 23 +++++++++++++++++++++++ 3 files changed, 61 insertions(+), 8 deletions(-) create mode 100644 muck/models/dbref.py diff --git a/crudkit/core/base.py b/crudkit/core/base.py index 4618a85..3492dcb 100644 --- a/crudkit/core/base.py +++ b/crudkit/core/base.py @@ -1,5 +1,7 @@ -from sqlalchemy import Column, Integer, DateTime, func -from sqlalchemy.orm import declarative_mixin +from sqlalchemy import Column, Integer, DateTime, Boolean, func +from sqlalchemy.orm import declarative_mixin, declarative_base + +Base = declarative_base() @declarative_mixin class CRUDMixin: diff --git a/crudkit/core/service.py b/crudkit/core/service.py index e82bec1..b240a60 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -4,17 +4,32 @@ from crudkit.core.spec import CRUDSpec T = TypeVar("T") +def _is_truthy(val): + return str(val).lower() in ('1', 'true', 'yes', 'on') + class CRUDService(Generic[T]): def __init__(self, model: Type[T], session: Session): self.model = model self.session = session + self.supports_soft_delete = hasattr(model, 'is_deleted') - def get(self, id: int) -> T: - return self.session.get(self.model, id) + def get(self, id: int, include_deleted: bool = False) -> T | None: + obj = self.session.get(self.model, id) + if obj is None: + return None + if self.supports_soft_delete and not include_deleted and obj.is_deleted: + return None + return obj def list(self, params=None) -> list[T]: query = self.session.query(self.model) + if params: + if self.supports_soft_delete: + include_deleted = False + include_deleted = _is_truthy(params.get('include_deleted')) + if not include_deleted: + query = query.filter(self.model.is_deleted == False) spec = CRUDSpec(self.model, params) filters = spec.parse_filters() order_by = spec.parse_sort() @@ -41,12 +56,25 @@ class CRUDService(Generic[T]): def update(self, id: int, data: dict) -> T: obj = self.get(id) + if not obj: + raise ValueError(f"{self.model.__name__} with ID {id} not found.") + + valid_fields = {c.name for c in self.model.__table__.columns} for k, v in data.items(): - setattr(obj, k, v) + if k in valid_fields: + setattr(obj, k, v) self.session.commit() return obj - def delete(self, id: int): - obj = self.get(id) - self.session.delete(obj) + def delete(self, id: int, hard: bool = False): + obj = self.session.get(self.model, id) + if not obj: + return None + + if hard or not self.supports_soft_delete: + self.session.delete(obj) + else: + obj.is_deleted = True + self.session.commit() + return obj diff --git a/muck/models/dbref.py b/muck/models/dbref.py new file mode 100644 index 0000000..493fcbb --- /dev/null +++ b/muck/models/dbref.py @@ -0,0 +1,23 @@ +from sqlalchemy import Column, Integer, String, ForeignKey +from sqlalchemy.orm import relationship +from crudkit.core.base import CRUDMixin, Base + +class Dbref(Base, CRUDMixin): + __tablename__ = "dbref" + + type = Column(String, nullable=False) + name = Column(String, nullable=False) + + owner_id = Column(Integer, ForeignKey("dbref.id")) + location_id = Column(Integer, ForeignKey("dbref.id")) + + owner = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[owner_id]) + location = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[location_id]) + + __mapper_args__ = { + "polymorphic_on": type, + "polymorphic_identity": "dbref" + } + + def __str__(self): + return f"#{self.id} ({self.type}): {self.name}" -- 2.51.2 From c43b17662df38855e5f503da6e6a74faa4e91f92 Mon Sep 17 00:00:00 2001 From: Conrad Nelson Date: Wed, 3 Sep 2025 10:55:25 -0500 Subject: [PATCH 11/36] Building new test application. --- crudkit/core/base.py | 15 ++++++++++++++- crudkit/core/service.py | 30 +++++++++++++++++++++++++++--- muck/models/dbref.py | 21 ++++++++++++++++++++- muck/models/exit.py | 6 ++++++ muck/models/player.py | 6 ++++++ muck/models/program.py | 6 ++++++ muck/models/room.py | 6 ++++++ muck/models/thing.py | 6 ++++++ 8 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 muck/models/exit.py create mode 100644 muck/models/player.py create mode 100644 muck/models/program.py create mode 100644 muck/models/room.py create mode 100644 muck/models/thing.py diff --git a/crudkit/core/base.py b/crudkit/core/base.py index 3492dcb..e74fef0 100644 --- a/crudkit/core/base.py +++ b/crudkit/core/base.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, DateTime, Boolean, func +from sqlalchemy import Column, Integer, DateTime, Boolean, String, JSON, func from sqlalchemy.orm import declarative_mixin, declarative_base Base = declarative_base() @@ -11,3 +11,16 @@ class CRUDMixin: def as_dict(self): return {c.name: getattr(self, c.name) for c in self.__table__.columns} + +class Version(Base): + __tablename__ = "versions" + + id = Column(Integer, primary_key=True) + model_name = Column(String, nullable=False) + object_id = Column(Integer, nullable=False) + change_type = Column(String, nullable=False) + data = Column(JSON, nullable=True) + timestamp = Column(DateTime, default=func.now()) + + actor = Column(String, nullable=True) + metadata = Column(JSON, nullable=True) diff --git a/crudkit/core/service.py b/crudkit/core/service.py index b240a60..c235213 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -1,5 +1,6 @@ from typing import Type, TypeVar, Generic from sqlalchemy.orm import Session +from crudkit.core.base import Version from crudkit.core.spec import CRUDSpec T = TypeVar("T") @@ -48,13 +49,15 @@ class CRUDService(Generic[T]): query = query.offset(offset).limit(limit) return query.all() - def create(self, data: dict) -> T: + def create(self, data: dict, actor=None) -> T: obj = self.model(**data) self.session.add(obj) self.session.commit() + + self._log_version("create", obj, actor) return obj - def update(self, id: int, data: dict) -> T: + def update(self, id: int, data: dict, actor=None) -> T: obj = self.get(id) if not obj: raise ValueError(f"{self.model.__name__} with ID {id} not found.") @@ -64,9 +67,11 @@ class CRUDService(Generic[T]): if k in valid_fields: setattr(obj, k, v) self.session.commit() + + self._log_version("update", obj, actor) return obj - def delete(self, id: int, hard: bool = False): + def delete(self, id: int, hard: bool = False, actor = False): obj = self.session.get(self.model, id) if not obj: return None @@ -77,4 +82,23 @@ class CRUDService(Generic[T]): obj.is_deleted = True self.session.commit() + + self._log_version("delete", obj, actor) return obj + + def _log_version(self, change_type: str, obj: T, actor=None, metadata: dict = {}): + try: + data = obj.as_dict() + except Exception: + data = {"error": "Failed to serialize object."} + + version = Version( + model_name=self.model.__name__, + object_id=obj.id, + change_type=change_type, + data=data, + actor=str(actor) if actor else None, + metadata=metadata + ) + self.session.add(version) + self.session.commit() diff --git a/muck/models/dbref.py b/muck/models/dbref.py index 493fcbb..9bf3c1e 100644 --- a/muck/models/dbref.py +++ b/muck/models/dbref.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, ForeignKey +from sqlalchemy import Column, Integer, String, ForeignKey, Boolean from sqlalchemy.orm import relationship from crudkit.core.base import CRUDMixin, Base @@ -7,6 +7,7 @@ class Dbref(Base, CRUDMixin): type = Column(String, nullable=False) name = Column(String, nullable=False) + is_deleted = Column(Boolean, nullable=False, default=False) owner_id = Column(Integer, ForeignKey("dbref.id")) location_id = Column(Integer, ForeignKey("dbref.id")) @@ -21,3 +22,21 @@ class Dbref(Base, CRUDMixin): def __str__(self): return f"#{self.id} ({self.type}): {self.name}" + + def is_type(self, *types: str) -> bool: + return self.type in types + + @property + def is_room(self): return self.is_type("room") + + @property + def is_thing(self): return self.is_type("thing") + + @property + def is_exit(self): return self.is_type("exit") + + @property + def is_player(self): return self.is_type("player") + + @property + def is_program(self): return self.is_type("programI ho") diff --git a/muck/models/exit.py b/muck/models/exit.py new file mode 100644 index 0000000..3ad018b --- /dev/null +++ b/muck/models/exit.py @@ -0,0 +1,6 @@ +from muck.models.dbref import Dbref + +class Exit(Dbref): + __mapper_args__ = { + "polymorphic_identity": "exit" + } diff --git a/muck/models/player.py b/muck/models/player.py new file mode 100644 index 0000000..be7694b --- /dev/null +++ b/muck/models/player.py @@ -0,0 +1,6 @@ +from muck.models.dbref import Dbref + +class Player(Dbref): + __mapper_args__ = { + "polymorphic_identity": "player" + } diff --git a/muck/models/program.py b/muck/models/program.py new file mode 100644 index 0000000..8854d47 --- /dev/null +++ b/muck/models/program.py @@ -0,0 +1,6 @@ +from muck.models.dbref import Dbref + +class Program(Dbref): + __mapper_args__ = { + "polymorphic_identity": "program" + } diff --git a/muck/models/room.py b/muck/models/room.py new file mode 100644 index 0000000..19bc508 --- /dev/null +++ b/muck/models/room.py @@ -0,0 +1,6 @@ +from muck.models.dbref import Dbref + +class Room(Dbref): + __mapper_args__ = { + "polymorphic_identity": "room" + } diff --git a/muck/models/thing.py b/muck/models/thing.py new file mode 100644 index 0000000..33af801 --- /dev/null +++ b/muck/models/thing.py @@ -0,0 +1,6 @@ +from muck.models.dbref import Dbref + +class Thing(Dbref): + __mapper_args__ = { + "polymorphic_identity": "thing" + } -- 2.51.2 From bcaff6e4df4acf76d2de381a09db7c8335f9cb80 Mon Sep 17 00:00:00 2001 From: Conrad Nelson Date: Wed, 3 Sep 2025 11:00:14 -0500 Subject: [PATCH 12/36] Improved type handling. --- muck/models/dbref.py | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/muck/models/dbref.py b/muck/models/dbref.py index 9bf3c1e..afd3081 100644 --- a/muck/models/dbref.py +++ b/muck/models/dbref.py @@ -1,11 +1,19 @@ -from sqlalchemy import Column, Integer, String, ForeignKey, Boolean +from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Enum as SQLEnum from sqlalchemy.orm import relationship from crudkit.core.base import CRUDMixin, Base +from enum import Enum + +class ObjectType(str, Enum): + ROOM = "room" + THING = "thing" + EXIT = "exit" + PLAYER = "player" + PROGRAM = "program" class Dbref(Base, CRUDMixin): __tablename__ = "dbref" - type = Column(String, nullable=False) + type = Column(SQLEnum(ObjectType, name="object_type_enum"), nullable=False) name = Column(String, nullable=False) is_deleted = Column(Boolean, nullable=False, default=False) @@ -23,20 +31,20 @@ class Dbref(Base, CRUDMixin): def __str__(self): return f"#{self.id} ({self.type}): {self.name}" - def is_type(self, *types: str) -> bool: + def is_type(self, *types: ObjectType) -> bool: return self.type in types @property - def is_room(self): return self.is_type("room") + def is_room(self): return self.is_type(ObjectType.ROOM) @property - def is_thing(self): return self.is_type("thing") + def is_thing(self): return self.is_type(ObjectType.THING) @property - def is_exit(self): return self.is_type("exit") + def is_exit(self): return self.is_type(ObjectType.EXIT) @property - def is_player(self): return self.is_type("player") + def is_player(self): return self.is_type(ObjectType.PLAYER) @property - def is_program(self): return self.is_type("programI ho") + def is_program(self): return self.is_type(ObjectType.PROGRAM) -- 2.51.2 From 009e54d568a6d53ad0723833a6af0e50358404fe Mon Sep 17 00:00:00 2001 From: Conrad Nelson Date: Wed, 3 Sep 2025 11:03:43 -0500 Subject: [PATCH 13/36] More syntactic sugar for typing. --- muck/models/exit.py | 4 ++-- muck/models/player.py | 4 ++-- muck/models/program.py | 4 ++-- muck/models/room.py | 4 ++-- muck/models/thing.py | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/muck/models/exit.py b/muck/models/exit.py index 3ad018b..93acc66 100644 --- a/muck/models/exit.py +++ b/muck/models/exit.py @@ -1,6 +1,6 @@ -from muck.models.dbref import Dbref +from muck.models.dbref import Dbref, ObjectType class Exit(Dbref): __mapper_args__ = { - "polymorphic_identity": "exit" + "polymorphic_identity": ObjectType.EXIT } diff --git a/muck/models/player.py b/muck/models/player.py index be7694b..94d6f84 100644 --- a/muck/models/player.py +++ b/muck/models/player.py @@ -1,6 +1,6 @@ -from muck.models.dbref import Dbref +from muck.models.dbref import Dbref, ObjectType class Player(Dbref): __mapper_args__ = { - "polymorphic_identity": "player" + "polymorphic_identity": ObjectType.PLAYER } diff --git a/muck/models/program.py b/muck/models/program.py index 8854d47..88e9403 100644 --- a/muck/models/program.py +++ b/muck/models/program.py @@ -1,6 +1,6 @@ -from muck.models.dbref import Dbref +from muck.models.dbref import Dbref, ObjectType class Program(Dbref): __mapper_args__ = { - "polymorphic_identity": "program" + "polymorphic_identity": ObjectType.PROGRAM } diff --git a/muck/models/room.py b/muck/models/room.py index 19bc508..4de1232 100644 --- a/muck/models/room.py +++ b/muck/models/room.py @@ -1,6 +1,6 @@ -from muck.models.dbref import Dbref +from muck.models.dbref import Dbref, ObjectType class Room(Dbref): __mapper_args__ = { - "polymorphic_identity": "room" + "polymorphic_identity": ObjectType.ROOM } diff --git a/muck/models/thing.py b/muck/models/thing.py index 33af801..3f5815a 100644 --- a/muck/models/thing.py +++ b/muck/models/thing.py @@ -1,6 +1,6 @@ -from muck.models.dbref import Dbref +from muck.models.dbref import Dbref, ObjectType class Thing(Dbref): __mapper_args__ = { - "polymorphic_identity": "thing" + "polymorphic_identity": ObjectType.THING } -- 2.51.2 From ba7428d926c5935d63285ceeaf9741bdeba39563 Mon Sep 17 00:00:00 2001 From: Conrad Nelson Date: Wed, 3 Sep 2025 13:24:13 -0500 Subject: [PATCH 14/36] More test app nonsense. --- muck/models/dbref.py | 44 ++++++++++++++++++++++++++++++++++++------ muck/models/exit.py | 12 ++++++++++++ muck/models/player.py | 20 +++++++++++++++++++ muck/models/program.py | 6 ++++++ muck/models/room.py | 12 ++++++++++++ muck/models/thing.py | 14 ++++++++++++++ 6 files changed, 102 insertions(+), 6 deletions(-) diff --git a/muck/models/dbref.py b/muck/models/dbref.py index afd3081..69d70db 100644 --- a/muck/models/dbref.py +++ b/muck/models/dbref.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Enum as SQLEnum +from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, JSON, Enum as SQLEnum, func from sqlalchemy.orm import relationship from crudkit.core.base import CRUDMixin, Base from enum import Enum @@ -10,18 +10,42 @@ class ObjectType(str, Enum): PLAYER = "player" PROGRAM = "program" +TYPE_SUFFIXES = { + ObjectType.ROOM: "R", + ObjectType.EXIT: "E", + ObjectType.PLAYER: "P", + ObjectType.PROGRAM: "F", + ObjectType.THING: "", +} + class Dbref(Base, CRUDMixin): __tablename__ = "dbref" type = Column(SQLEnum(ObjectType, name="object_type_enum"), nullable=False) name = Column(String, nullable=False) + props = Column(JSON, nullable=False, default={}) is_deleted = Column(Boolean, nullable=False, default=False) - owner_id = Column(Integer, ForeignKey("dbref.id")) - location_id = Column(Integer, ForeignKey("dbref.id")) + last_used = Column(DateTime, nullable=False, default=func.now()) + use_count = Column(Integer, nullable=False, default=0) - owner = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[owner_id]) - location = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[location_id]) + location_id = Column(Integer, ForeignKey("dbref.id"), nullable=False) + location = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[location_id], back_populates="contents") + + contents = relationship("Dbref", backref="location", foreign_keys=location_id) + exits = relationship("Exit", backref="source", foreign_keys=location_id) + + owner_id = Column(Integer, ForeignKey("dbref.id"), nullable=False) + owner = relationship("Player", remote_side=[CRUDMixin.id], foreign_keys=[owner_id]) + + creator_id = Column(Integer, ForeignKey("dbref.id"), nullable=False) + creator = relationship("Player", remote_side=[CRUDMixin.id], foreign_keys=[creator_id]) + + modifier_id = Column(Integer, ForeignKey("dbref.id"), nullable=False) + modifier = relationship("Player", remote_side=[CRUDMixin.id], foreign_keys=[modifier_id]) + + last_user_id = Column(Integer, ForeignKey("dbref.id"), nullable=False) + last_user = relationship("Player", remote_side=[CRUDMixin.id], foreign_keys=[last_user_id]) __mapper_args__ = { "polymorphic_on": type, @@ -29,11 +53,19 @@ class Dbref(Base, CRUDMixin): } def __str__(self): - return f"#{self.id} ({self.type}): {self.name}" + suffix = TYPE_SUFFIXES.get(self.type, "") + return f"#{self.id}{suffix}" + + def __repr__(self): + suffix = TYPE_SUFFIXES.get(self.type, "") + return f"" def is_type(self, *types: ObjectType) -> bool: return self.type in types + def display_type(self) -> str: + return self.type.value.upper() + @property def is_room(self): return self.is_type(ObjectType.ROOM) diff --git a/muck/models/exit.py b/muck/models/exit.py index 93acc66..a222bdc 100644 --- a/muck/models/exit.py +++ b/muck/models/exit.py @@ -1,6 +1,18 @@ +from sqlalchemy import Column, Integer, ForeignKey +from sqlalchemy.orm import relationship + +from crudkit.core.base import CRUDMixin + from muck.models.dbref import Dbref, ObjectType class Exit(Dbref): + __tablename__ = "exits" + + id = Column(Integer, ForeignKey("dbref.id"), primary_key=True) + + destination_id = Column(Integer, ForeignKey("dbref.id"), nullable=True) + destination = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[destination_id]) + __mapper_args__ = { "polymorphic_identity": ObjectType.EXIT } diff --git a/muck/models/player.py b/muck/models/player.py index 94d6f84..99a3b22 100644 --- a/muck/models/player.py +++ b/muck/models/player.py @@ -1,6 +1,26 @@ +from sqlalchemy import Column, Integer, Boolean, String, ForeignKey +from sqlalchemy.orm import relationship + +from crudkit.core.base import CRUDMixin + from muck.models.dbref import Dbref, ObjectType class Player(Dbref): + __tablename__ = "players" + + id = Column(Integer, ForeignKey("dbref.id"), primary_key=True) + + pennies = Column(Integer, nullable=False, default=0) + insert_mode = Column(Boolean, nullable=False, default=False) + block = Column(Integer, nullable=True) + password = Column(String, nullable=False) + + home_id = Column(Integer, ForeignKey("dbref.id"), nullable=False) + home = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[home_id]) + + current_program_id = Column(Integer, ForeignKey("dbref.id"), nullable=True) + current_program = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[current_program_id]) + __mapper_args__ = { "polymorphic_identity": ObjectType.PLAYER } diff --git a/muck/models/program.py b/muck/models/program.py index 88e9403..b6f375f 100644 --- a/muck/models/program.py +++ b/muck/models/program.py @@ -1,6 +1,12 @@ +from sqlalchemy import Column, Integer, ForeignKey + from muck.models.dbref import Dbref, ObjectType class Program(Dbref): + __tablename__ = "programs" + + id = Column(Integer, ForeignKey("dbref.id"), primary_key=True) + __mapper_args__ = { "polymorphic_identity": ObjectType.PROGRAM } diff --git a/muck/models/room.py b/muck/models/room.py index 4de1232..49d5469 100644 --- a/muck/models/room.py +++ b/muck/models/room.py @@ -1,6 +1,18 @@ +from sqlalchemy import Column, Integer, ForeignKey +from sqlalchemy.orm import relationship + +from crudkit.core.base import CRUDMixin + from muck.models.dbref import Dbref, ObjectType class Room(Dbref): + __tablename__ = "rooms" + + id = Column(Integer, ForeignKey("dbref.id"), primary_key=True) + + dropto_id = Column(Integer, ForeignKey("dbref.id"), nullable=True) + dropto = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[dropto_id]) + __mapper_args__ = { "polymorphic_identity": ObjectType.ROOM } diff --git a/muck/models/thing.py b/muck/models/thing.py index 3f5815a..1725208 100644 --- a/muck/models/thing.py +++ b/muck/models/thing.py @@ -1,6 +1,20 @@ +from sqlalchemy import Column, Integer, ForeignKey +from sqlalchemy.orm import relationship + +from crudkit.core.base import CRUDMixin + from muck.models.dbref import Dbref, ObjectType class Thing(Dbref): + __tablename__ = "things" + + id = Column(Integer, ForeignKey("dbref.id"), primary_key=True) + + value = Column(Integer, nullable=False, default=0) + + home_id = Column(Integer, ForeignKey("dbref.id"), nullable=False) + home = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[home_id]) + __mapper_args__ = { "polymorphic_identity": ObjectType.THING } -- 2.51.2 From c72417e5e4b8333d14c9f7ac9277ab8ddffb5f6f Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Thu, 4 Sep 2025 08:47:31 -0500 Subject: [PATCH 15/36] Fix an attribute naming issue in crudkit. SQLAlchemy reserves the name "metadata." --- crudkit/core/base.py | 2 +- crudkit/core/service.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crudkit/core/base.py b/crudkit/core/base.py index e74fef0..c7de93e 100644 --- a/crudkit/core/base.py +++ b/crudkit/core/base.py @@ -23,4 +23,4 @@ class Version(Base): timestamp = Column(DateTime, default=func.now()) actor = Column(String, nullable=True) - metadata = Column(JSON, nullable=True) + meta = Column('metadata', JSON, nullable=True) diff --git a/crudkit/core/service.py b/crudkit/core/service.py index c235213..5126a9b 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -98,7 +98,7 @@ class CRUDService(Generic[T]): change_type=change_type, data=data, actor=str(actor) if actor else None, - metadata=metadata + meta=metadata ) self.session.add(version) self.session.commit() -- 2.51.2 From d9ed6d5cd750e70024b66cc0ec7e46c177633d93 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Thu, 4 Sep 2025 14:36:00 -0500 Subject: [PATCH 16/36] Holy moley is it hard to do MUCK stuff relationally. --- muck/app.py | 14 ++++++++++++++ muck/config.py | 3 +++ muck/init.py | 39 +++++++++++++++++++++++++++++++++++++++ muck/models/__init__.py | 5 +++++ muck/models/dbref.py | 25 ++++++++++++------------- muck/models/exit.py | 9 ++++++--- muck/models/player.py | 11 ++++++----- muck/models/room.py | 7 ++++--- 8 files changed, 89 insertions(+), 24 deletions(-) create mode 100644 muck/app.py create mode 100644 muck/config.py create mode 100644 muck/init.py create mode 100644 muck/models/__init__.py diff --git a/muck/app.py b/muck/app.py new file mode 100644 index 0000000..f2134f6 --- /dev/null +++ b/muck/app.py @@ -0,0 +1,14 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, scoped_session + +from muck.models.dbref import Base +from muck.init import bootstrap_world + +engine = create_engine("sqlite:///muck.db", echo=True) +SessionLocal = scoped_session(sessionmaker(bind=engine)) + +Base.metadata.create_all(engine) + +session = SessionLocal() + +bootstrap_world(session) diff --git a/muck/config.py b/muck/config.py new file mode 100644 index 0000000..e2815ee --- /dev/null +++ b/muck/config.py @@ -0,0 +1,3 @@ +class Config: + DB_URI = "sqlite:///muck.db" + DB_TRACK_MODIFICATIONS = False \ No newline at end of file diff --git a/muck/init.py b/muck/init.py new file mode 100644 index 0000000..03eca28 --- /dev/null +++ b/muck/init.py @@ -0,0 +1,39 @@ +from muck.models.room import Room +from muck.models.player import Player + +def bootstrap_world(session): + if session.query(Room).first() or session.query(Player).first(): + print("World already initialized.") + return + + print("Bootstrapping world...") + + room_zero = Room( + id=0, + name="Room Zero", + props={"_": {"de": "You are in Room Zero. It is very dark in here."}} + ) + + the_one = Player( + id=1, + name="One", + password="potrzebie", + props={"_": {"de": "You see The One."}} + ) + + the_one.location = room_zero + the_one.home = room_zero + + the_one.creator = the_one + the_one.owner = the_one + the_one.modifier = the_one + the_one.last_user = the_one + + room_zero.owner = the_one + room_zero.creator = the_one + room_zero.modifier = the_one + room_zero.last_user = the_one + + session.add_all([room_zero, the_one]) + session.commit() + print("World initialized.") diff --git a/muck/models/__init__.py b/muck/models/__init__.py new file mode 100644 index 0000000..1ee4e02 --- /dev/null +++ b/muck/models/__init__.py @@ -0,0 +1,5 @@ +from muck.models.dbref import Dbref +from muck.models.exit import Exit +from sqlalchemy.orm import relationship + +Dbref.exits = relationship("Exit", back_populates="source", foreign_keys=[Exit.location_id]) diff --git a/muck/models/dbref.py b/muck/models/dbref.py index 69d70db..5026161 100644 --- a/muck/models/dbref.py +++ b/muck/models/dbref.py @@ -1,5 +1,5 @@ from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, DateTime, JSON, Enum as SQLEnum, func -from sqlalchemy.orm import relationship +from sqlalchemy.orm import relationship, foreign, remote from crudkit.core.base import CRUDMixin, Base from enum import Enum @@ -29,23 +29,22 @@ class Dbref(Base, CRUDMixin): last_used = Column(DateTime, nullable=False, default=func.now()) use_count = Column(Integer, nullable=False, default=0) - location_id = Column(Integer, ForeignKey("dbref.id"), nullable=False) - location = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[location_id], back_populates="contents") + location_id = Column(Integer, ForeignKey("dbref.id"), nullable=True, default=0) + location = relationship("Dbref", foreign_keys=[location_id], back_populates="contents", primaryjoin=lambda: foreign(Dbref.location_id) == remote(Dbref.id), remote_side=lambda: Dbref.id) - contents = relationship("Dbref", backref="location", foreign_keys=location_id) - exits = relationship("Exit", backref="source", foreign_keys=location_id) + contents = relationship("Dbref", foreign_keys=[location_id], back_populates="location") - owner_id = Column(Integer, ForeignKey("dbref.id"), nullable=False) - owner = relationship("Player", remote_side=[CRUDMixin.id], foreign_keys=[owner_id]) + owner_id = Column(Integer, ForeignKey("dbref.id"), nullable=True) + owner = relationship("Player", remote_side=[CRUDMixin.id], foreign_keys=[owner_id], primaryjoin=lambda: Dbref.owner_id == remote(Dbref.id), post_update=True) - creator_id = Column(Integer, ForeignKey("dbref.id"), nullable=False) - creator = relationship("Player", remote_side=[CRUDMixin.id], foreign_keys=[creator_id]) + creator_id = Column(Integer, ForeignKey("dbref.id"), nullable=True) + creator = relationship("Player", remote_side=[CRUDMixin.id], foreign_keys=[creator_id], primaryjoin=lambda: Dbref.creator_id == remote(Dbref.id), post_update=True) - modifier_id = Column(Integer, ForeignKey("dbref.id"), nullable=False) - modifier = relationship("Player", remote_side=[CRUDMixin.id], foreign_keys=[modifier_id]) + modifier_id = Column(Integer, ForeignKey("dbref.id"), nullable=True) + modifier = relationship("Player", remote_side=[CRUDMixin.id], foreign_keys=[modifier_id], primaryjoin=lambda: Dbref.modifier_id == remote(Dbref.id), post_update=True) - last_user_id = Column(Integer, ForeignKey("dbref.id"), nullable=False) - last_user = relationship("Player", remote_side=[CRUDMixin.id], foreign_keys=[last_user_id]) + last_user_id = Column(Integer, ForeignKey("dbref.id"), nullable=True) + last_user = relationship("Player", remote_side=[CRUDMixin.id], foreign_keys=[last_user_id], primaryjoin=lambda: Dbref.last_user_id == remote(Dbref.id), post_update=True) __mapper_args__ = { "polymorphic_on": type, diff --git a/muck/models/exit.py b/muck/models/exit.py index a222bdc..5523100 100644 --- a/muck/models/exit.py +++ b/muck/models/exit.py @@ -1,5 +1,5 @@ from sqlalchemy import Column, Integer, ForeignKey -from sqlalchemy.orm import relationship +from sqlalchemy.orm import relationship, foreign, remote from crudkit.core.base import CRUDMixin @@ -11,8 +11,11 @@ class Exit(Dbref): id = Column(Integer, ForeignKey("dbref.id"), primary_key=True) destination_id = Column(Integer, ForeignKey("dbref.id"), nullable=True) - destination = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[destination_id]) + destination = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[destination_id], primaryjoin=lambda: foreign(Exit.destination_id) == remote(Dbref.id)) + + source = relationship("Dbref", back_populates="exits", foreign_keys=[Dbref.location_id], remote_side=[Dbref.id]) __mapper_args__ = { - "polymorphic_identity": ObjectType.EXIT + "polymorphic_identity": ObjectType.EXIT, + "inherit_condition": id == Dbref.id } diff --git a/muck/models/player.py b/muck/models/player.py index 99a3b22..6e1db75 100644 --- a/muck/models/player.py +++ b/muck/models/player.py @@ -1,5 +1,5 @@ from sqlalchemy import Column, Integer, Boolean, String, ForeignKey -from sqlalchemy.orm import relationship +from sqlalchemy.orm import relationship, foreign, remote from crudkit.core.base import CRUDMixin @@ -15,12 +15,13 @@ class Player(Dbref): block = Column(Integer, nullable=True) password = Column(String, nullable=False) - home_id = Column(Integer, ForeignKey("dbref.id"), nullable=False) - home = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[home_id]) + home_id = Column(Integer, ForeignKey("dbref.id"), nullable=True) + home = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[home_id], primaryjoin=lambda: foreign(Player.home_id) == remote(Dbref.id)) current_program_id = Column(Integer, ForeignKey("dbref.id"), nullable=True) - current_program = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[current_program_id]) + current_program = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[current_program_id], primaryjoin=lambda: foreign(Player.current_program_id) == remote(Dbref.id)) __mapper_args__ = { - "polymorphic_identity": ObjectType.PLAYER + "polymorphic_identity": ObjectType.PLAYER, + "inherit_condition": id == Dbref.id } diff --git a/muck/models/room.py b/muck/models/room.py index 49d5469..b0e5695 100644 --- a/muck/models/room.py +++ b/muck/models/room.py @@ -1,5 +1,5 @@ from sqlalchemy import Column, Integer, ForeignKey -from sqlalchemy.orm import relationship +from sqlalchemy.orm import relationship, foreign, remote from crudkit.core.base import CRUDMixin @@ -11,8 +11,9 @@ class Room(Dbref): id = Column(Integer, ForeignKey("dbref.id"), primary_key=True) dropto_id = Column(Integer, ForeignKey("dbref.id"), nullable=True) - dropto = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[dropto_id]) + dropto = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[dropto_id], primaryjoin=lambda: foreign(Room.dropto_id) == remote(Dbref.id)) __mapper_args__ = { - "polymorphic_identity": ObjectType.ROOM + "polymorphic_identity": ObjectType.ROOM, + "inherit_condition": id == Dbref.id } -- 2.51.2 From 207e3f5b51db4ce433635ececea9990bca95345d Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Thu, 4 Sep 2025 15:05:09 -0500 Subject: [PATCH 17/36] API up and running. --- muck/app.py | 35 +++++++++++++++++++++++++++++++++-- muck/models/program.py | 3 ++- muck/models/thing.py | 9 +++++---- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/muck/app.py b/muck/app.py index f2134f6..f419bf5 100644 --- a/muck/app.py +++ b/muck/app.py @@ -1,10 +1,22 @@ +from flask import Flask + from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, scoped_session -from muck.models.dbref import Base +from crudkit.api.flask_api import generate_crud_blueprint +from crudkit.core.service import CRUDService + +from muck.models.dbref import Base, Dbref +from muck.models.exit import Exit +from muck.models.player import Player +from muck.models.program import Program +from muck.models.room import Room +from muck.models.thing import Thing from muck.init import bootstrap_world -engine = create_engine("sqlite:///muck.db", echo=True) +DATABASE_URL = "sqlite:///muck.db" + +engine = create_engine(DATABASE_URL, echo=True) SessionLocal = scoped_session(sessionmaker(bind=engine)) Base.metadata.create_all(engine) @@ -12,3 +24,22 @@ Base.metadata.create_all(engine) session = SessionLocal() bootstrap_world(session) + +app = Flask(__name__) + +dbref_service = CRUDService(Dbref, session) +exit_service = CRUDService(Exit, session) +player_service = CRUDService(Player, session) +program_service = CRUDService(Program, session) +room_service = CRUDService(Room, session) +thing_service = CRUDService(Thing, session) + +app.register_blueprint(generate_crud_blueprint(Dbref, dbref_service), url_prefix="/api/dbref") +app.register_blueprint(generate_crud_blueprint(Exit, exit_service), url_prefix="/api/exits") +app.register_blueprint(generate_crud_blueprint(Player, player_service), url_prefix="/api/players") +app.register_blueprint(generate_crud_blueprint(Program, program_service), url_prefix="/api/programs") +app.register_blueprint(generate_crud_blueprint(Room, room_service), url_prefix="/api/rooms") +app.register_blueprint(generate_crud_blueprint(Thing, thing_service), url_prefix="/api/things") + +if __name__ == "__main__": + app.run(debug=True) diff --git a/muck/models/program.py b/muck/models/program.py index b6f375f..14548cb 100644 --- a/muck/models/program.py +++ b/muck/models/program.py @@ -8,5 +8,6 @@ class Program(Dbref): id = Column(Integer, ForeignKey("dbref.id"), primary_key=True) __mapper_args__ = { - "polymorphic_identity": ObjectType.PROGRAM + "polymorphic_identity": ObjectType.PROGRAM, + "inherit_condition": id == Dbref.id } diff --git a/muck/models/thing.py b/muck/models/thing.py index 1725208..c67bc27 100644 --- a/muck/models/thing.py +++ b/muck/models/thing.py @@ -1,5 +1,5 @@ from sqlalchemy import Column, Integer, ForeignKey -from sqlalchemy.orm import relationship +from sqlalchemy.orm import relationship, foreign, remote from crudkit.core.base import CRUDMixin @@ -12,9 +12,10 @@ class Thing(Dbref): value = Column(Integer, nullable=False, default=0) - home_id = Column(Integer, ForeignKey("dbref.id"), nullable=False) - home = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[home_id]) + home_id = Column(Integer, ForeignKey("dbref.id"), nullable=True) + home = relationship("Dbref", remote_side=[CRUDMixin.id], foreign_keys=[home_id], primaryjoin=lambda: foreign(Thing.home_id) == remote(Dbref.id)) __mapper_args__ = { - "polymorphic_identity": ObjectType.THING + "polymorphic_identity": ObjectType.THING, + "inherit_condition": id == Dbref.id } -- 2.51.2 From 1cf76edf56b56ee14e7cea80ab66d23dcd08005c Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Thu, 4 Sep 2025 16:00:25 -0500 Subject: [PATCH 18/36] Um... yeah. --- crudkit/core/service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crudkit/core/service.py b/crudkit/core/service.py index 5126a9b..98b6c86 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -9,9 +9,10 @@ def _is_truthy(val): return str(val).lower() in ('1', 'true', 'yes', 'on') class CRUDService(Generic[T]): - def __init__(self, model: Type[T], session: Session): + def __init__(self, model: Type[T], session: Session, polymorphic: bool = False): self.model = model self.session = session + self.polymorphic = polymorphic self.supports_soft_delete = hasattr(model, 'is_deleted') def get(self, id: int, include_deleted: bool = False) -> T | None: -- 2.51.2 From 4ebc82c08b6d384282f7ee7f69ff251756a3b802 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Fri, 5 Sep 2025 09:36:15 -0500 Subject: [PATCH 19/36] Fixed how polymorphism affects listing. --- crudkit/core/base.py | 8 +++++++- crudkit/core/service.py | 14 +++++++++++--- muck/app.py | 4 ++-- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/crudkit/core/base.py b/crudkit/core/base.py index c7de93e..0540ec0 100644 --- a/crudkit/core/base.py +++ b/crudkit/core/base.py @@ -10,7 +10,13 @@ class CRUDMixin: updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) def as_dict(self): - return {c.name: getattr(self, c.name) for c in self.__table__.columns} + # Combine all columns from all inherited tables + result = {} + for cls in self.__class__.__mro__: + if hasattr(cls, "__table__"): + for column in cls.__table__.columns: + result[column.name] = getattr(self, column.name) + return result class Version(Base): __tablename__ = "versions" diff --git a/crudkit/core/service.py b/crudkit/core/service.py index 98b6c86..a7da4e5 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -1,5 +1,5 @@ from typing import Type, TypeVar, Generic -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, with_polymorphic from crudkit.core.base import Version from crudkit.core.spec import CRUDSpec @@ -15,8 +15,16 @@ class CRUDService(Generic[T]): self.polymorphic = polymorphic self.supports_soft_delete = hasattr(model, 'is_deleted') + def get_query(self): + if self.polymorphic: + poly_model = with_polymorphic(self.model, '*') + return self.session.query(poly_model) + else: + base_only = with_polymorphic(self.model, [], flat=True) + return self.session.query(base_only) + def get(self, id: int, include_deleted: bool = False) -> T | None: - obj = self.session.get(self.model, id) + obj = self.get_query().filter_by(id=id).first() if obj is None: return None if self.supports_soft_delete and not include_deleted and obj.is_deleted: @@ -24,7 +32,7 @@ class CRUDService(Generic[T]): return obj def list(self, params=None) -> list[T]: - query = self.session.query(self.model) + query = self.get_query() if params: if self.supports_soft_delete: diff --git a/muck/app.py b/muck/app.py index f419bf5..60d7d3f 100644 --- a/muck/app.py +++ b/muck/app.py @@ -27,7 +27,7 @@ bootstrap_world(session) app = Flask(__name__) -dbref_service = CRUDService(Dbref, session) +dbref_service = CRUDService(Dbref, session, polymorphic=True) exit_service = CRUDService(Exit, session) player_service = CRUDService(Player, session) program_service = CRUDService(Program, session) @@ -42,4 +42,4 @@ app.register_blueprint(generate_crud_blueprint(Room, room_service), url_prefix=" app.register_blueprint(generate_crud_blueprint(Thing, thing_service), url_prefix="/api/things") if __name__ == "__main__": - app.run(debug=True) + app.run(debug=True, port=5050) -- 2.51.2 From 60708164fadec2154c915657e3065d750d33580a Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Fri, 5 Sep 2025 15:18:28 -0500 Subject: [PATCH 20/36] These timestamps need to be required. --- crudkit/core/base.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crudkit/core/base.py b/crudkit/core/base.py index 0540ec0..dae5615 100644 --- a/crudkit/core/base.py +++ b/crudkit/core/base.py @@ -6,8 +6,8 @@ Base = declarative_base() @declarative_mixin class CRUDMixin: id = Column(Integer, primary_key=True) - created_at = Column(DateTime, default=func.now()) - updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + created_at = Column(DateTime, default=func.now(), nullable=False) + updated_at = Column(DateTime, default=func.now(), nullable=False, onupdate=func.now()) def as_dict(self): # Combine all columns from all inherited tables -- 2.51.2 From 7cbe200801d7cfab1e02991119a81419b4a1f8eb Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Fri, 5 Sep 2025 16:41:16 -0500 Subject: [PATCH 21/36] Fixes on dot resolution not understanding root aliases. --- crudkit/core/service.py | 38 +++++++++++++++----------- crudkit/core/spec.py | 59 +++++++++++++++++++++-------------------- 2 files changed, 53 insertions(+), 44 deletions(-) diff --git a/crudkit/core/service.py b/crudkit/core/service.py index a7da4e5..34b218a 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -18,37 +18,44 @@ class CRUDService(Generic[T]): def get_query(self): if self.polymorphic: poly_model = with_polymorphic(self.model, '*') - return self.session.query(poly_model) + return self.session.query(poly_model), poly_model else: base_only = with_polymorphic(self.model, [], flat=True) - return self.session.query(base_only) + return self.session.query(base_only), base_only def get(self, id: int, include_deleted: bool = False) -> T | None: - obj = self.get_query().filter_by(id=id).first() - if obj is None: - return None - if self.supports_soft_delete and not include_deleted and obj.is_deleted: - return None - return obj + query, root_alias = self.get_query() + + if self.supports_soft_delete and not include_deleted: + query = query.filter(getattr(root_alias, "is_deleted") == False) + + query = query.filter(getattr(root_alias, "id") == id) + + obj = query.first() + return obj or None def list(self, params=None) -> list[T]: - query = self.get_query() + query, root_alias = self.get_query() if params: if self.supports_soft_delete: - include_deleted = False include_deleted = _is_truthy(params.get('include_deleted')) if not include_deleted: - query = query.filter(self.model.is_deleted == False) - spec = CRUDSpec(self.model, params) + query = query.filter(getattr(root_alias, "is_deleted") == False) + + spec = CRUDSpec(self.model, params, root_alias) filters = spec.parse_filters() order_by = spec.parse_sort() limit, offset = spec.parse_pagination() - for parent, relationship_attr, alias in spec.get_join_paths(): - query = query.join(alias, relationship_attr.of_type(alias), isouter=True) + for parent_alias, relationship_attr, target_alias in spec.get_join_paths(): + query = query.join( + target_alias, + relationship_attr.of_type(target_alias), + isouter=True + ) - for eager in spec.get_eager_loads(): + for eager in spec.get_eager_loads(root_alias): query = query.options(eager) if filters: @@ -56,6 +63,7 @@ class CRUDService(Generic[T]): if order_by: query = query.order_by(*order_by) query = query.offset(offset).limit(limit) + return query.all() def create(self, data: dict, actor=None) -> T: diff --git a/crudkit/core/spec.py b/crudkit/core/spec.py index 5840071..26348ea 100644 --- a/crudkit/core/spec.py +++ b/crudkit/core/spec.py @@ -14,36 +14,40 @@ OPERATORS = { } class CRUDSpec: - def __init__(self, model, params): + def __init__(self, model, params, root_alias): self.model = model self.params = params + self.root_alias = root_alias self.eager_paths: Set[Tuple[str, ...]] = set() self.join_paths: List[Tuple[object, InstrumentedAttribute, object]] = [] self.alias_map: Dict[Tuple[str, ...], object] = {} def _resolve_column(self, path: str): - current_model = self.model - current_alias = self.model + current_alias = self.root_alias parts = path.split('.') - join_path = [] + join_path: list[str] = [] for i, attr in enumerate(parts): - if not hasattr(current_model, attr): + try: + attr_obj = getattr(current_alias, attr) + except AttributeError: return None, None - attr_obj = getattr(current_model, attr) - if isinstance(attr_obj, InstrumentedAttribute): - if hasattr(attr_obj.property, 'direction'): - join_path.append(attr) - path_key = tuple(join_path) - alias = self.alias_map.get(path_key) - if not alias: - alias = aliased(attr_obj.property.mapper.class_) - self.alias_map[path_key] = alias - self.join_paths.append((current_alias, attr_obj, alias)) - current_model = attr_obj.property.mapper.class_ - current_alias = alias - else: - return getattr(current_alias, attr), tuple(join_path) if join_path else None + + prop = getattr(attr_obj, "property", None) + if prop is not None and hasattr(prop, "direction"): + join_path.append(attr) + path_key = tuple(join_path) + alias = self.alias_map.get(path_key) + if not alias: + alias = aliased(prop.mapper.class_) + self.alias_map[path_key] = alias + self.join_paths.append((current_alias, attr_obj, alias)) + current_alias = alias + continue + + if isinstance(attr_obj, InstrumentedAttribute) or hasattr(attr_obj, "clauses"): + return attr_obj, tuple(join_path) if join_path else None + return None, None def parse_filters(self): @@ -90,19 +94,16 @@ class CRUDSpec: offset = int(self.params.get('offset', 0)) return limit, offset - def get_eager_loads(self): + def get_eager_loads(self, root_alias): loads = [] for path in self.eager_paths: - current = self.model + current = root_alias loader = None - for attr in path: - attr_obj = getattr(current, attr) - if loader is None: - loader = joinedload(attr_obj) - else: - loader = loader.joinedload(attr_obj) - current = attr_obj.property.mapper.class_ - if loader: + for name in path: + rel_attr = getattr(current, name) + loader = (joinedload(rel_attr) if loader is None else loader.joinedload(name)) + current = rel_attr.property.mapper.class_ + if loader is not None: loads.append(loader) return loads -- 2.51.2 From d458672c4b5832e984ae9d933d4dcae4caf2b143 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Mon, 8 Sep 2025 11:45:57 -0500 Subject: [PATCH 22/36] Config feature added. --- crudkit/__init__.py | 7 + crudkit/_sqlite.py | 14 ++ crudkit/config.py | 230 ++++++++++++++++++++++++++++++++ crudkit/engines.py | 43 ++++++ crudkit/integrations/fastapi.py | 24 ++++ crudkit/integrations/flask.py | 20 +++ 6 files changed, 338 insertions(+) create mode 100644 crudkit/_sqlite.py create mode 100644 crudkit/config.py create mode 100644 crudkit/engines.py create mode 100644 crudkit/integrations/fastapi.py create mode 100644 crudkit/integrations/flask.py diff --git a/crudkit/__init__.py b/crudkit/__init__.py index e69de29..c654971 100644 --- a/crudkit/__init__.py +++ b/crudkit/__init__.py @@ -0,0 +1,7 @@ +from .config import Config, DevConfig, TestConfig, ProdConfig, get_config, build_database_url +from .engines import CRUDKitRuntime, build_engine, build_sessionmaker + +__all__ = [ + "Config", "DevConfig", "TestConfig", "ProdConfig", "get_config", "build_database_url", + "CRUDKitRuntime", "build_engine", "build_sessionmaker" +] diff --git a/crudkit/_sqlite.py b/crudkit/_sqlite.py new file mode 100644 index 0000000..159156f --- /dev/null +++ b/crudkit/_sqlite.py @@ -0,0 +1,14 @@ +from __future__ import annotations +from sqlalchemy import event +from sqlalchemy.engine import Engine + +def apply_sqlite_pragmas(engine: Engine, pragmas: dict[str, str]) -> None: + if not str(engine.url).startswith("sqlite://"): + return + + @event.listens_for(engine, "connect") + def set_sqlite_pragma(dbapi_connection, connection_record): + cursor = dbapi_connection.cursor() + for key, value in pragmas.items(): + cursor.execute(f"PRAGMA {key}={value}") + cursor.close() diff --git a/crudkit/config.py b/crudkit/config.py new file mode 100644 index 0000000..5ffaa9e --- /dev/null +++ b/crudkit/config.py @@ -0,0 +1,230 @@ +from __future__ import annotations +import os +from typing import Dict, Any, Optional, Type +from urllib.parse import quote_plus +from pathlib import Path + +try: + from dotenv import load_dotenv +except Exception: + load_dotenv = None + +def _load_dotenv_if_present() -> None: + """ + Load .env once if present. Priority rules: + 1) CRUDKIT_DOTENV points to a file + 2) Project root's .env (two dirs up from this file) + 3) Current working directory .env + + Env already present in the process takes precedence. + """ + if load_dotenv is None: + return + + path_hint = os.getenv("CRUDKIT_DOTENV") + if path_hint: + p = Path(path_hint).resolve() + if p.exists(): + load_dotenv(dotenv_path=p, override=False) + return + + repo_env = Path(__file__).resolve().parents[2] / ".env" + if repo_env.exists(): + load_dotenv(dotenv_path=repo_env, override=False) + return + + cwd_env = Path.cwd() / ".env" + if cwd_env.exists(): + load_dotenv(dotenv_path=cwd_env, override=False) + +def build_database_url( + *, + backend: Optional[str] = None, + url: Optional[str] = None, + user: Optional[str] = None, + password: Optional[str] = None, + host: Optional[str] = None, + port: Optional[str] = None, + database: Optional[str] = None, + driver: Optional[str] = None, + dsn: Optional[str] = None, + trusted: Optional[bool] = None, + options: Optional[Dict[str, str]] = None, +) -> str: + """ + Build a SQLAlchemy URL string. If "url" is provided, it wins. + Supported: sqlite, postgresql, mysql, mssql (pyodbc) + """ + + if url: + return url + + backend = (backend or "").lower().strip() + optional = options or {} + + if backend == "sqlite": + db_path = database or "app.db" + if db_path == ":memory:": + return "sqlite:///:memory:" + return f"sqlite:///{db_path}" + + if backend in {"postgres", "postgresql"}: + driver = driver or "psycopg" + user = user or "" + password = password or "" + creds = f"{quote_plus(user)}:{quote_plus(password)}@" if user or password else "" + host = host or "localhost" + port = port or "5432" + database = database or "app" + qs = "" + if options: + qs = "?" + "&".join(f"{k}={quote_plus(v)}" for k, v in options.items()) + return f"postgresel+{driver}://{creds}{host}:{port}/{database}{qs}" + + if backend == "mysql": + driver = driver or "pymysql" + user = user or "" + password = password or "" + creds = f"{quote_plus(user)}:{quote_plus(password)}@" if user or password else "" + host = host or "localhost" + port = port or "3306" + database = database or "app" + qs = "" + if options: + qs = "?" + "&".join(f"{k}={quote_plus(v)}" for k, v in options.items()) + return f"mysql+{driver}://{creds}{host}:{port}/{database}{qs}" + + if backend in {"mssql", "sqlserver", "sqlsrv"}: + if dsn: + qs = "" + if options: + qs = "?" + "&".join(f"{k}={quote_plus(v)}" for k, v in options.items()) + return f"mssql+pyodbc://@{quote_plus(dsn)}{qs}" + + driver = driver or "ODBC Driver 18 for SQL Server" + host = host or "localhost" + port = port or "1433" + database = database or "app" + + if trusted: + base_opts = { + "driver": driver, + "Trusted_Connection": "yes", + "Encrypt": "yes", + "TrustServerCertificate": "yes", + } + base_opts.update(options) + qs = "?" + "&".join(f"{k}={quote_plus(v)}" for k, v in base_opts.items()) + return f"mssql+pyodbc://{host}:{port}/{database}{qs}" + + user = user or "" + password = password or "" + creds = f"{quote_plus(user)}:{quote_plus(password)}@" if user or password else "" + base_opts = { + "driver": driver, + "Encrypt": "yes", + "TrustServerCertificate": "yes", + } + base_opts.update(options) + qs = "?" + "&".join(f"{k}={quote_plus(v)}" for k, v in base_opts.items()) + return f"mssql+pyodbc://{creds}{host}:{port}/{database}{qs}" + + raise ValueError(f"Unsupported backend: {backend!r}") + +class Config: + """ + CRUDKit config: environment-first with sane defaults. + Designed to be subclassed by apps, but fine as-is. + """ + + _dotenv_loaded = False + + DEBUG = False + TESTING = False + SECRET_KEY = os.getenv("SECRET_KEY", "dev-not-secret") + + if not _dotenv_loaded: + _load_dotenv_if_present() + _dotenv_loaded = True + + DATABASE_URL = build_database_url( + url=os.getenv("DATABASE_URL"), + backend=os.getenv("DB_BACKEND"), + user=os.getenv("DB_USER"), + password=os.getenv("DB_PASS"), + host=os.getenv("DB_HOST"), + port=os.getenv("DB_PORT"), + database=os.getenv("DB_NAME"), + driver=os.getenv("DB_DRIVER"), + dsn=os.getenv("DB_DSN"), + trusted=bool(int(os.getenv("DB_TRUSTED", "0"))), + options=None, + ) + + SQLALCHEMY_ECHO = bool(int(os.getenv("DB_ECHO", "0"))) + POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "5")) + MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "10")) + POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "30")) + POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1000")) + POOL_PRE_PING = True + + SQLITE_PRAGMAS = { + "journal_mode": os.getenv("SQLITE_JOURNAL_MODE", "WAL"), + "foreign_keys": os.getenv("SQLITE_FOREIGN_KEYS", "ON"), + "synchronous": os.getenv("SQLITE_SYNCHRONOUS": "NORMAL"), + } + + @classmethod + def engine_kwargs(cls) -> Dict[str, Any]: + url = cls.DATABASE_URL + kwargs: Dict[str, Any] = { + "echo": cls.SQLALCHEMY_ECHO, + "pool_pre_ping": cls.POOL_PRE_PING, + "future": True, + } + + if url.startswith("sqlite://"): + kwargs["connect_args"] = {"check_same_thread": False} + + kwargs.update( + { + "pool_size": cls.POOL_SIZE, + "max_overflow": cls.MAX_OVERFLOW, + "pool_timeout": cls.POOL_TIMEOUT, + "pool_recycle": cls.POOL_RECYCLE, + } + ) + return kwargs + + @classmethod + def session_kwargs(cls) -> Dict[str, Any]: + return { + "autoflush": False, + "autocommit": False, + "expire_on_commit": False, + "future": True, + } + +class DevConfig(Config): + DEBUG = True + SQLALCHEMY_ECHO = bool(int(os.getenv("DB_ECHO", "1"))) + +class TestConfig(Config): + TESTING = True + DATABASE_URL = build_database_url(backend="sqlite", database=":memory:") + SQLALCHEMY_ECHO = False + +class ProdConfig(Config): + DEBUG = False + SQLALCHEMY_ECHO = bool(int(os.getenv("DB_ECHO", "0"))) + +def get_config(name: str | None) -> Type[Config]: + """ + Resolve config by name. None -> environment variable CRUDKIT_END or 'dev'. + """ + env = (name or os.getenv("CRUDKIT_ENV") or "dev").lower() + if env in {"prod", "production"}: + return ProdConfig + if env in {"test", "testing", "ci"}: + return TestConfig + return DevConfig diff --git a/crudkit/engines.py b/crudkit/engines.py new file mode 100644 index 0000000..25984bd --- /dev/null +++ b/crudkit/engines.py @@ -0,0 +1,43 @@ +from __future__ import annotations +from typing import Type, Optional +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from .config import Config, get_config +from ._sqlite import apply_sqlite_pragmas + +def build_engine(config_cls: Type[Config] | None = None): + config_cls = config_cls or get_config(None) + engine = create_engine(config_cls.DATABASE_URL, **config_cls.engine_kwargs()) + apply_sqlite_pragmas(engine, config_cls.SQLITE_PRAGMAS) + return engine + +def build_sessionmaker(config_cls: Type[Config] | None = None, engine=None): + config_cls = config_cls or get_config(None) + engine = engine or build_engine(config_cls) + return sessionmaker(bind=engine, **config_cls.session_kwargs()) + +class CRUDKitRuntime: + """ + Lightweight container so CRUDKit can be given either: + - prebuild engine/sessionmaker, or + - a Config to build them lazily + """ + def __init__(self, *, engine=None, session_factory=None, config: Optional[Type[Config]] = None): + if engine is None and session_factory is None and config is None: + config = get_config(None) + self._config = config + self._engine = engine or (build_engine(config) if config else None) + self._session_factory = session_factory or (build_sessionmaker(config, self._engine) if config else None) + + @property + def engine(self): + if self._engine is None and self._config: + self._engine = build_engine(self._config) + return self._engine + + @property + def session_factory(self): + if self._session_factory is None: + if self._config and self._engine: + self._session_factory = build_sessionmaker(self._config, self._engine) + return self._session_factory diff --git a/crudkit/integrations/fastapi.py b/crudkit/integrations/fastapi.py new file mode 100644 index 0000000..c62f940 --- /dev/null +++ b/crudkit/integrations/fastapi.py @@ -0,0 +1,24 @@ +from __future__ import annotations +from contextlib import contextmanager +from fastapi import Depends +from sqlalchemy.orm import Session +from ..engines import CRUDKitRuntime + +_runtime = CRUDKitRuntime() + +@contextmanager +def _session_scope(): + SessionLocal = _runtime.session_factory + session: Session = SessionLocal() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + +def get_db(): + with _session_scope() as s: + yield s diff --git a/crudkit/integrations/flask.py b/crudkit/integrations/flask.py new file mode 100644 index 0000000..3cc319b --- /dev/null +++ b/crudkit/integrations/flask.py @@ -0,0 +1,20 @@ +from __future__ import annotations +from flask import Flask +from sqlalchemy.orm import scoped_session +from ..engines import CRUDKitRuntime +from ..config import Config + +def init_app(app: Flask, *, runtime: CRUDKitRuntime | None = None, config: type[Config] | None == None): + """ + Initializes CRUDKit for a Flask app. Provies `app.extensions['crudkit']` + with a runtime (engine + session_factory). Caller manages session lifecycle. + """ + runtime = runtime or CRUDKitRuntime(config=config) + app.extensions.setdefault("crudkit", {}) + app.extensions["crudkit"]["runtime"] = runtime + + Session = runtime.session_factory + if Session is not None: + app.extensions["crudkit"]["Session"] = scoped_session(Session) + + return runtime -- 2.51.2 From a871b9c5fedc829861e68d505ead8b07cb02d080 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Mon, 8 Sep 2025 11:50:43 -0500 Subject: [PATCH 23/36] Fix a slight type in the config module. --- crudkit/config.py | 2 +- muck/app.py | 5 ++++- muck/config.py | 3 --- 3 files changed, 5 insertions(+), 5 deletions(-) delete mode 100644 muck/config.py diff --git a/crudkit/config.py b/crudkit/config.py index 5ffaa9e..16aae49 100644 --- a/crudkit/config.py +++ b/crudkit/config.py @@ -171,7 +171,7 @@ class Config: SQLITE_PRAGMAS = { "journal_mode": os.getenv("SQLITE_JOURNAL_MODE", "WAL"), "foreign_keys": os.getenv("SQLITE_FOREIGN_KEYS", "ON"), - "synchronous": os.getenv("SQLITE_SYNCHRONOUS": "NORMAL"), + "synchronous": os.getenv("SQLITE_SYNCHRONOUS", "NORMAL"), } @classmethod diff --git a/muck/app.py b/muck/app.py index 60d7d3f..ae3059a 100644 --- a/muck/app.py +++ b/muck/app.py @@ -3,8 +3,10 @@ from flask import Flask from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, scoped_session +from crudkit import ProdConfig from crudkit.api.flask_api import generate_crud_blueprint from crudkit.core.service import CRUDService +from crudkit.integrations.flask import init_app from muck.models.dbref import Base, Dbref from muck.models.exit import Exit @@ -42,4 +44,5 @@ app.register_blueprint(generate_crud_blueprint(Room, room_service), url_prefix=" app.register_blueprint(generate_crud_blueprint(Thing, thing_service), url_prefix="/api/things") if __name__ == "__main__": - app.run(debug=True, port=5050) + init_app(app, config=ProdConfig) + # app.run(debug=True, port=5050) diff --git a/muck/config.py b/muck/config.py deleted file mode 100644 index e2815ee..0000000 --- a/muck/config.py +++ /dev/null @@ -1,3 +0,0 @@ -class Config: - DB_URI = "sqlite:///muck.db" - DB_TRACK_MODIFICATIONS = False \ No newline at end of file -- 2.51.2 From f9458a429e5130d407457a47f9a3bb81cda8e04d Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Mon, 8 Sep 2025 15:06:49 -0500 Subject: [PATCH 24/36] Backend behavior and a minor fix for config implemented. --- crudkit/__init__.py | 3 +- crudkit/backend.py | 122 ++++++++++++++++++++++++++++++++++++++++ crudkit/config.py | 2 +- crudkit/core/service.py | 49 +++++++++++----- crudkit/engines.py | 7 +++ 5 files changed, 168 insertions(+), 15 deletions(-) create mode 100644 crudkit/backend.py diff --git a/crudkit/__init__.py b/crudkit/__init__.py index c654971..3959538 100644 --- a/crudkit/__init__.py +++ b/crudkit/__init__.py @@ -1,7 +1,8 @@ +from .backend import BackendInfo, make_backend_info from .config import Config, DevConfig, TestConfig, ProdConfig, get_config, build_database_url from .engines import CRUDKitRuntime, build_engine, build_sessionmaker __all__ = [ "Config", "DevConfig", "TestConfig", "ProdConfig", "get_config", "build_database_url", - "CRUDKitRuntime", "build_engine", "build_sessionmaker" + "CRUDKitRuntime", "build_engine", "build_sessionmaker", "BackendInfo", "make_backend_info" ] diff --git a/crudkit/backend.py b/crudkit/backend.py new file mode 100644 index 0000000..3232b71 --- /dev/null +++ b/crudkit/backend.py @@ -0,0 +1,122 @@ +from __future__ import annotations +from dataclasses import dataclass +from typing import Tuple, Optional, Iterable +from contextlib import contextmanager +from sqlalchemy import text, func +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session +from sqlalchemy.sql.elements import ClauseElement +from sqlalchemy.sql import Select + +@dataclass(frozen=True) +class BackendInfo: + name: str + version: Tuple[int, ...] + paramstyle: str + is_sqlite: bool + is_postgres: bool + is_mysql: bool + is_mssql: bool + + supports_returning: bool + supports_ilike: bool + requires_order_by_for_offset: bool + max_bind_params: Optional[int] + + @classmethod + def from_engine(cls, engine: Engine) -> "BackendInfo": + d = engine.dialect + name = d.name + version = tuple(getattr(d, "server_version_info", ()) or ()) + is_pg = name in {"postgresql", "postgres"} + is_my = name == "mysql" + is_sq = name == "sqlite" + is_ms = name == "mssql" + + supports_ilike = is_pg or is_my + supports_returning = is_pg or (is_sq and version >= (3, 35)) + requires_order_by_for_offset = is_ms + + max_bind_params = 999 if is_sq else None + + return cls( + name=name, + version=version, + paramstyle=d.paramstyle, + is_sqlite=is_sq, + is_postgres=is_pg, + is_mysql=is_my, + is_mssql=is_ms, + supports_returning=supports_returning, + supports_ilike=supports_ilike, + requires_order_by_for_offset=requires_order_by_for_offset, + max_bind_params=max_bind_params, + ) + +def make_backend_info(engine: Engine) -> BackendInfo: + return BackendInfo.from_engine(engine) + +def ci_like(column, value: str, backend: BackendInfo) -> ClauseElement: + """ + Portable save-insensitive LIKE. + Uses ILIKE where available, else lower() dance. + """ + pattern = f"%{value}%" + if backend.supports_ilike: + return column.ilike(pattern) + return func.lower(column).like(func.lower(text(":pattern"))).params(pattern=pattern) + +def apply_pagination(sel: Select, backend: BackendInfo, *, page: int, per_page: int, default_order_by=None) -> Select: + """ + Portable pagination. MSSQL requires ORDER BY when using OFFSET + """ + page = max(1, int(page)) + per_page = max(1, int(per_page)) + offset = (page - 1) * per_page + + if backend.requires_order_by_for_offset and not sel._order_by_clauses: + if default_order_by is None: + sel = sel.order_by(text("1")) + else: + sel = sel.order_by(default_order_by) + + return sel.limit(per_page).offset(offset) + +@contextmanager +def maybe_identify_insert(session: Session, table, backend: BackendInfo): + """ + For MSSQL tables with IDENTIFY PK when you need to insert explicit IDs. + No-op elsewhere. + """ + if not backend.is_mssql: + yield + return + + full_name = f"{table.schema}.{table.name}" if table.schema else table.name + session.execute(text(f"SET IDENTIFY_INSERT {full_name} ON")) + try: + yield + finally: + session.execute(text(f"SET IDENTITY_INSERT {full_name} OFF")) + +def chunked_in(column, values: Iterable, backend: BackendInfo, chunk_size: Optional[int] = None) -> ClauseElement: + """ + Build a safe large IN() filter respecting bund param limits. + Returns a disjunction of chunked IN clauses if needed. + """ + vals = list(values) + if not vals: + return text("1=0") + + limit = chunk_size or backend.max_bind_params or len(vals) + if len(vals) <= limit: + return column.in_(vals) + + parts = [] + for i in range(0, len(vals), limit): + parts.append(column.in_(vals[i:i + limit])) + + expr = parts[0] + for p in parts[1:]: + expr = expr | p + return expr diff --git a/crudkit/config.py b/crudkit/config.py index 16aae49..8d1195a 100644 --- a/crudkit/config.py +++ b/crudkit/config.py @@ -60,7 +60,7 @@ def build_database_url( return url backend = (backend or "").lower().strip() - optional = options or {} + options = options or {} if backend == "sqlite": db_path = database or "app.db" diff --git a/crudkit/core/service.py b/crudkit/core/service.py index 34b218a..12e5f4e 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -1,7 +1,9 @@ -from typing import Type, TypeVar, Generic +from typing import Type, TypeVar, Generic, Optional from sqlalchemy.orm import Session, with_polymorphic +from sqlalchemy import inspect, text from crudkit.core.base import Version from crudkit.core.spec import CRUDSpec +from crudkit.backend import BackendInfo, make_backend_info T = TypeVar("T") @@ -9,11 +11,20 @@ def _is_truthy(val): return str(val).lower() in ('1', 'true', 'yes', 'on') class CRUDService(Generic[T]): - def __init__(self, model: Type[T], session: Session, polymorphic: bool = False): + def __init__( + self, + model: Type[T], + session: Session, + polymorphic: bool = False, + *, + backend: Optional[BackendInfo] = None + ): self.model = model self.session = session self.polymorphic = polymorphic self.supports_soft_delete = hasattr(model, 'is_deleted') + # Cache backend info once. If not provided, derive from session bind. + self.backend = backend or make_backend_info(self.session.get_bind()) def get_query(self): if self.polymorphic: @@ -23,14 +34,22 @@ class CRUDService(Generic[T]): base_only = with_polymorphic(self.model, [], flat=True) return self.session.query(base_only), base_only + # Helper: default ORDER BY for MSSQL when paginating without explicit order + def _default_order_by(self, root_alias): + mapper = inspect(self.model) + cols = [] + for col in mapper.primary_key: + try: + cols.append(getattr(root_alias, col.key)) + except AttributeError: + cols.append(col) + return cols or [text("1")] + def get(self, id: int, include_deleted: bool = False) -> T | None: query, root_alias = self.get_query() - if self.supports_soft_delete and not include_deleted: query = query.filter(getattr(root_alias, "is_deleted") == False) - query = query.filter(getattr(root_alias, "id") == id) - obj = query.first() return obj or None @@ -60,9 +79,20 @@ class CRUDService(Generic[T]): if filters: query = query.filter(*filters) + + # MSSQL: requires ORDER BY when using OFFSET (and SQLA will use OFFSET for limit+offset). + paginating = (limit is not None) or (offset is not None and offset != 0) + if paginating and not order_by and self.backend.requires_order_by_for_offset: + order_by = self._default_order_by(root_alias) + if order_by: query = query.order_by(*order_by) - query = query.offset(offset).limit(limit) + + # Only apply offset/limit when not None. + if offset is not None and offset != 0: + query = query.offset(offset) + if limit is not None: + query = query.limit(limit) return query.all() @@ -70,7 +100,6 @@ class CRUDService(Generic[T]): obj = self.model(**data) self.session.add(obj) self.session.commit() - self._log_version("create", obj, actor) return obj @@ -78,13 +107,11 @@ class CRUDService(Generic[T]): obj = self.get(id) if not obj: raise ValueError(f"{self.model.__name__} with ID {id} not found.") - valid_fields = {c.name for c in self.model.__table__.columns} for k, v in data.items(): if k in valid_fields: setattr(obj, k, v) self.session.commit() - self._log_version("update", obj, actor) return obj @@ -92,14 +119,11 @@ class CRUDService(Generic[T]): obj = self.session.get(self.model, id) if not obj: return None - if hard or not self.supports_soft_delete: self.session.delete(obj) else: obj.is_deleted = True - self.session.commit() - self._log_version("delete", obj, actor) return obj @@ -108,7 +132,6 @@ class CRUDService(Generic[T]): data = obj.as_dict() except Exception: data = {"error": "Failed to serialize object."} - version = Version( model_name=self.model.__name__, object_id=obj.id, diff --git a/crudkit/engines.py b/crudkit/engines.py index 25984bd..b420a8d 100644 --- a/crudkit/engines.py +++ b/crudkit/engines.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import Type, Optional from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker +from .backend import make_backend_info, BackendInfo from .config import Config, get_config from ._sqlite import apply_sqlite_pragmas @@ -41,3 +42,9 @@ class CRUDKitRuntime: if self._config and self._engine: self._session_factory = build_sessionmaker(self._config, self._engine) return self._session_factory + + @property + def backend(self) -> BackendInfo: + if not hasattr(self, "_backend_info") or self._backend_info is None: + self._backend_info = make_backend_info(self.engine) + return self._backend_info -- 2.51.2 From 4cdbc44a13b22d5e1d1b06e2d82bfa3301f56e63 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Tue, 9 Sep 2025 09:21:28 -0500 Subject: [PATCH 25/36] Fix dotenv loading. --- crudkit/config.py | 44 +++++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/crudkit/config.py b/crudkit/config.py index 8d1195a..ee3f3c1 100644 --- a/crudkit/config.py +++ b/crudkit/config.py @@ -25,17 +25,27 @@ def _load_dotenv_if_present() -> None: if path_hint: p = Path(path_hint).resolve() if p.exists(): - load_dotenv(dotenv_path=p, override=False) + load_dotenv(dotenv_path=p, override=True) + os.environ["CRUDKIT_DOTENV_LOADED"] = str(p) return - repo_env = Path(__file__).resolve().parents[2] / ".env" + repo_env = Path(__file__).resolve().parents[1] / ".env" if repo_env.exists(): - load_dotenv(dotenv_path=repo_env, override=False) + load_dotenv(dotenv_path=repo_env, override=True) + os.environ["CRUDKIT_DOTENV_LOADED"] = str(repo_env) return cwd_env = Path.cwd() / ".env" if cwd_env.exists(): - load_dotenv(dotenv_path=cwd_env, override=False) + load_dotenv(dotenv_path=cwd_env, override=True) + os.environ["CRUDKIT_DOTENV_LOADED"] = str(cwd_env) + +def _getenv(name: str, default: Optional[str] = None) -> Optional[str]: + """Treat empty strings as missing. Hekos when OS env has DB_BACKEND=''.""" + val = os.getenv(name) + if val is None or val.strip() == "": + return default + return val def build_database_url( *, @@ -79,7 +89,7 @@ def build_database_url( qs = "" if options: qs = "?" + "&".join(f"{k}={quote_plus(v)}" for k, v in options.items()) - return f"postgresel+{driver}://{creds}{host}:{port}/{database}{qs}" + return f"postgresql+{driver}://{creds}{host}:{port}/{database}{qs}" if backend == "mysql": driver = driver or "pymysql" @@ -141,23 +151,23 @@ class Config: DEBUG = False TESTING = False - SECRET_KEY = os.getenv("SECRET_KEY", "dev-not-secret") + SECRET_KEY = _getenv("SECRET_KEY", "dev-not-secret") if not _dotenv_loaded: _load_dotenv_if_present() _dotenv_loaded = True DATABASE_URL = build_database_url( - url=os.getenv("DATABASE_URL"), - backend=os.getenv("DB_BACKEND"), - user=os.getenv("DB_USER"), - password=os.getenv("DB_PASS"), - host=os.getenv("DB_HOST"), - port=os.getenv("DB_PORT"), - database=os.getenv("DB_NAME"), - driver=os.getenv("DB_DRIVER"), - dsn=os.getenv("DB_DSN"), - trusted=bool(int(os.getenv("DB_TRUSTED", "0"))), + url=_getenv("DATABASE_URL"), + backend=_getenv("DB_BACKEND"), + user=_getenv("DB_USER"), + password=_getenv("DB_PASS"), + host=_getenv("DB_HOST"), + port=_getenv("DB_PORT"), + database=_getenv("DB_NAME"), + driver=_getenv("DB_DRIVER"), + dsn=_getenv("DB_DSN"), + trusted=bool(int(_getenv("DB_TRUSTED", "0"))), options=None, ) @@ -220,7 +230,7 @@ class ProdConfig(Config): def get_config(name: str | None) -> Type[Config]: """ - Resolve config by name. None -> environment variable CRUDKIT_END or 'dev'. + Resolve config by name. None -> environment variable CRUDKIT_ENV or 'dev'. """ env = (name or os.getenv("CRUDKIT_ENV") or "dev").lower() if env in {"prod", "production"}: -- 2.51.2 From e09cee0c790be44a9757c07f73dbbdb742cff10f Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Wed, 10 Sep 2025 08:14:50 -0500 Subject: [PATCH 26/36] Downstream fixes from inventory app. --- crudkit/core/base.py | 23 ++++++++++--- crudkit/core/service.py | 44 +++++++++++++++++++------ crudkit/core/spec.py | 73 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 120 insertions(+), 20 deletions(-) diff --git a/crudkit/core/base.py b/crudkit/core/base.py index dae5615..c66aaf3 100644 --- a/crudkit/core/base.py +++ b/crudkit/core/base.py @@ -9,13 +9,26 @@ class CRUDMixin: created_at = Column(DateTime, default=func.now(), nullable=False) updated_at = Column(DateTime, default=func.now(), nullable=False, onupdate=func.now()) - def as_dict(self): - # Combine all columns from all inherited tables + def as_dict(self, fields: list[str] | None = None): + """ + Serialize mapped columns. Honors projection if either: + - 'fields' is passed explicitly, or + - + """ + allowed = None + if fields: + allowed = set(fields) + else: + allowed = getattr(self, "__crudkit_root_fields__", None) result = {} for cls in self.__class__.__mro__: - if hasattr(cls, "__table__"): - for column in cls.__table__.columns: - result[column.name] = getattr(self, column.name) + if not hasattr(cls, "__table__"): + continue + for column in cls.__table__.columns: + name = column.name + if allowed is not None and name not in allowed and name != "id": + continue + result[name] = getattr(self, name) return result class Version(Base): diff --git a/crudkit/core/service.py b/crudkit/core/service.py index 12e5f4e..ab1be78 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -1,5 +1,5 @@ from typing import Type, TypeVar, Generic, Optional -from sqlalchemy.orm import Session, with_polymorphic +from sqlalchemy.orm import Load, Session, raiseload, with_polymorphic from sqlalchemy import inspect, text from crudkit.core.base import Version from crudkit.core.spec import CRUDSpec @@ -28,11 +28,9 @@ class CRUDService(Generic[T]): def get_query(self): if self.polymorphic: - poly_model = with_polymorphic(self.model, '*') - return self.session.query(poly_model), poly_model - else: - base_only = with_polymorphic(self.model, [], flat=True) - return self.session.query(base_only), base_only + poly = with_polymorphic(self.model, "*") + return self.session.query(poly), poly + return self.session.query(self.model), self.model # Helper: default ORDER BY for MSSQL when paginating without explicit order def _default_order_by(self, root_alias): @@ -62,10 +60,11 @@ class CRUDService(Generic[T]): if not include_deleted: query = query.filter(getattr(root_alias, "is_deleted") == False) - spec = CRUDSpec(self.model, params, root_alias) + spec = CRUDSpec(self.model, params or {}, root_alias) filters = spec.parse_filters() order_by = spec.parse_sort() limit, offset = spec.parse_pagination() + spec.parse_includes() for parent_alias, relationship_attr, target_alias in spec.get_join_paths(): query = query.join( @@ -74,9 +73,17 @@ class CRUDService(Generic[T]): isouter=True ) - for eager in spec.get_eager_loads(root_alias): + root_fields, rel_field_names = spec.parse_fields() + + if root_fields: + query = query.options(Load(root_alias).load_only(*root_fields)) + + for eager in spec.get_eager_loads(root_alias, fields_map=rel_field_names): query = query.options(eager) + # if root_fields or rel_field_names: + # query = query.options(Load(root_alias).raiseload("*")) + if filters: query = query.filter(*filters) @@ -91,10 +98,27 @@ class CRUDService(Generic[T]): # Only apply offset/limit when not None. if offset is not None and offset != 0: query = query.offset(offset) - if limit is not None: + if limit is not None and limit > 0: query = query.limit(limit) - return query.all() + # return query.all() + rows = query.all() + + try: + rf_names = [c.key for c in (root_fields or [])] + except NameError: + rf_names = [] + if rf_names: + allow = set(rf_names) + if "id" not in allow and hasattr(self.model, "id"): + allow.add("id") + for obj in rows: + try: + setattr(obj, "__crudkit_root_fields__", allow) + except Exception: + pass + + return rows def create(self, data: dict, actor=None) -> T: obj = self.model(**data) diff --git a/crudkit/core/spec.py b/crudkit/core/spec.py index 26348ea..f895e6e 100644 --- a/crudkit/core/spec.py +++ b/crudkit/core/spec.py @@ -1,6 +1,6 @@ -from typing import List, Tuple, Set, Dict +from typing import List, Tuple, Set, Dict, Optional from sqlalchemy import asc, desc -from sqlalchemy.orm import joinedload, aliased +from sqlalchemy.orm import aliased, selectinload from sqlalchemy.orm.attributes import InstrumentedAttribute OPERATORS = { @@ -21,6 +21,9 @@ class CRUDSpec: self.eager_paths: Set[Tuple[str, ...]] = set() self.join_paths: List[Tuple[object, InstrumentedAttribute, object]] = [] self.alias_map: Dict[Tuple[str, ...], object] = {} + self._root_fields: List[InstrumentedAttribute] = [] + self._rel_field_names: Dict[Tuple[str, ...], object] = {} + self.include_paths: Set[Tuple[str, ...]] = set() def _resolve_column(self, path: str): current_alias = self.root_alias @@ -50,6 +53,20 @@ class CRUDSpec: return None, None + def parse_includes(self): + raw = self.params.get('include') + if not raw: + return + tokens = [p.strip() for p in str(raw).split(',') if p.strip()] + for token in tokens: + _, join_path = self._resolve_column(token) + if join_path: + self.eager_paths.add(join_path) + else: + col, maybe = self._resolve_column(token + '.id') + if maybe: + self.eager_paths.add(maybe) + def parse_filters(self): filters = [] for key, value in self.params.items(): @@ -94,14 +111,60 @@ class CRUDSpec: offset = int(self.params.get('offset', 0)) return limit, offset - def get_eager_loads(self, root_alias): + def parse_fields(self): + """ + Parse ?fields=colA,colB,rel1.colC,rel1.rel2.colD + - Root fields become InstrumentedAttributes bound to root_alias. + - Related fields store attribute NAMES; we'll resolve them on the target class when building loader options. + Returns (root_fields, rel_field_names). + """ + raw = self.params.get('fields') + if not raw: + return [], {} + + if isinstance(raw, list): + tokens = [] + for chunk in raw: + tokens.extend(p.strip() for p in str(chunk).split(',') if p.strip()) + else: + tokens = [p.strip() for p in str(raw).split(',') if p.strip()] + + root_fields: List[InstrumentedAttribute] = [] + rel_field_names: Dict[Tuple[str, ...], List[str]] = {} + + for token in tokens: + col, join_path = self._resolve_column(token) + if not col: + continue + if join_path: + rel_field_names.setdefault(join_path, []).append(col.key) + self.eager_paths.add(join_path) + else: + root_fields.append(col) + + seen = set() + root_fields = [c for c in root_fields if not (c.key in seen or seen.add(c.key))] + for k, names in rel_field_names.items(): + seen2 = set() + rel_field_names[k] = [n for n in names if not (n in seen2 or seen2.add(n))] + + self._root_fields = root_fields + self._rel_field_names = rel_field_names + return root_fields, rel_field_names + + def get_eager_loads(self, root_alias, *, fields_map: Optional[Dict[Tuple[str, ...], List[str]]] = None): loads = [] for path in self.eager_paths: current = root_alias loader = None - for name in path: + for idx, name in enumerate(path): rel_attr = getattr(current, name) - loader = (joinedload(rel_attr) if loader is None else loader.joinedload(name)) + loader = (selectinload(rel_attr) if loader is None else loader.selectinload(name)) + if fields_map and idx == len(path) - 1 and path in fields_map: + target_cls = rel_attr.property.mapper.class_ + cols = [getattr(target_cls, n) for n in fields_map[path] if hasattr(target_cls, n)] + if cols: + loader = loader.load_only(*cols) current = rel_attr.property.mapper.class_ if loader is not None: loads.append(loader) -- 2.51.2 From 82062d72d6021c72744275d2ba72115933f4c463 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Wed, 10 Sep 2025 09:06:21 -0500 Subject: [PATCH 27/36] Fixing projections to include requested relationship fields. --- crudkit/config.py | 1 + crudkit/core/base.py | 41 +++++++++++++++++++++------------ crudkit/core/service.py | 37 +++++++++++++++++++++-------- crudkit/ui/fragments.py | 7 +++++- crudkit/ui/templates/table.html | 8 +++---- 5 files changed, 64 insertions(+), 30 deletions(-) diff --git a/crudkit/config.py b/crudkit/config.py index ee3f3c1..a19d4d3 100644 --- a/crudkit/config.py +++ b/crudkit/config.py @@ -1,5 +1,6 @@ from __future__ import annotations import os +import os from typing import Dict, Any, Optional, Type from urllib.parse import quote_plus from pathlib import Path diff --git a/crudkit/core/base.py b/crudkit/core/base.py index c66aaf3..2204778 100644 --- a/crudkit/core/base.py +++ b/crudkit/core/base.py @@ -11,26 +11,37 @@ class CRUDMixin: def as_dict(self, fields: list[str] | None = None): """ - Serialize mapped columns. Honors projection if either: - - 'fields' is passed explicitly, or - - + Serialize the instance. + - If 'fields' (possibly dotted) is provided, emit exactly those keys. + - Else, if '__crudkit_projection__' is set on the instance, emit those keys. + - Else, fall back to all mapped columns on this class hierarchy. + Always includes 'id' when present unless explicitly excluded. """ - allowed = None + if fields is None: + fields = getattr(self, "__crudkit_projection__", None) + if fields: - allowed = set(fields) - else: - allowed = getattr(self, "__crudkit_root_fields__", None) + out = {} + if "id" not in fields and hasattr(self, "id"): + out["id"] = getattr(self, "id") + for f in fields: + cur = self + for part in f.split("."): + if cur is None: + break + cur = getattr(cur, part, None) + out[f] = cur + return out + result = {} - for cls in self.__class__.__mro__: - if not hasattr(cls, "__table__"): - continue - for column in cls.__table__.columns: - name = column.name - if allowed is not None and name not in allowed and name != "id": - continue - result[name] = getattr(self, name) + for cls in self.__clas__.__mro__: + if hasattr(cls, "__table__"): + for column in cls.__table__.columns: + name = column.name + result[name] = getattr(self, name) return result + class Version(Base): __tablename__ = "versions" diff --git a/crudkit/core/service.py b/crudkit/core/service.py index ab1be78..f84d276 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -101,23 +101,40 @@ class CRUDService(Generic[T]): if limit is not None and limit > 0: query = query.limit(limit) - # return query.all() rows = query.all() - try: - rf_names = [c.key for c in (root_fields or [])] - except NameError: - rf_names = [] - if rf_names: - allow = set(rf_names) - if "id" not in allow and hasattr(self.model, "id"): - allow.add("id") + proj = [] + if root_fields: + proj.extend(c.key for c in root_fields) + for path, names in (rel_field_names or {}).items(): + prefix = ".".join(path) + for n in names: + proj.append(f"{prefix}.{n}") + + if proj and "id" not in proj and hasattr(self.model, "id"): + proj.insert(0, "id") + + if proj: for obj in rows: try: - setattr(obj, "__crudkit_root_fields__", allow) + setattr(obj, "__crudkit_projection__", tuple(proj)) except Exception: pass + # try: + # rf_names = [c.key for c in (root_fields or [])] + # except NameError: + # rf_names = [] + # if rf_names: + # allow = set(rf_names) + # if "id" not in allow and hasattr(self.model, "id"): + # allow.add("id") + # for obj in rows: + # try: + # setattr(obj, "__crudkit_root_fields__", allow) + # except Exception: + # pass + return rows def create(self, data: dict, actor=None) -> T: diff --git a/crudkit/ui/fragments.py b/crudkit/ui/fragments.py index d30e24b..5913e65 100644 --- a/crudkit/ui/fragments.py +++ b/crudkit/ui/fragments.py @@ -35,7 +35,12 @@ def render_field(field, value): def render_table(objects): env = get_env() template = get_crudkit_template(env, 'table.html') - return template.render(objects=objects) + if not objects: + return template.render(fields=[], rows=[]) + proj = getattr(objects[0], "__crudkit_projection__", None) + rows = [obj.as_dict(proj) for obj in objects] + fields = list(rows[0].keys()) + return template.render(fields=fields, rows=rows) def render_form(model_cls, values, session=None): env = get_env() diff --git a/crudkit/ui/templates/table.html b/crudkit/ui/templates/table.html index b4abd80..38ff48e 100644 --- a/crudkit/ui/templates/table.html +++ b/crudkit/ui/templates/table.html @@ -1,10 +1,10 @@ - {% if objects %} + {% if rows %} - {% for field in objects[0].__table__.columns %}{% endfor %} + {% for field in fields if field != "id" %}{% endfor %} - {% for obj in objects %} - {% for field in obj.__table__.columns %}{% endfor %} + {% for row in rows %} + {% for _, cell in row.items() if _ != "id" %}{% endfor %} {% endfor %} {% else %} -- 2.51.2 From f532e07a099566d71aba071ad0fafa85e4a41d77 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Thu, 11 Sep 2025 08:49:52 -0500 Subject: [PATCH 28/36] Downstream changes. --- crudkit/core/base.py | 2 +- crudkit/ui/fragments.py | 134 ++++++++++++++++++++++++++++---- crudkit/ui/templates/table.html | 26 +++++-- 3 files changed, 141 insertions(+), 21 deletions(-) diff --git a/crudkit/core/base.py b/crudkit/core/base.py index 2204778..46874fe 100644 --- a/crudkit/core/base.py +++ b/crudkit/core/base.py @@ -34,7 +34,7 @@ class CRUDMixin: return out result = {} - for cls in self.__clas__.__mro__: + for cls in self.__class__.__mro__: if hasattr(cls, "__table__"): for column in cls.__table__.columns: name = column.name diff --git a/crudkit/ui/fragments.py b/crudkit/ui/fragments.py index 5913e65..bfc8b47 100644 --- a/crudkit/ui/fragments.py +++ b/crudkit/ui/fragments.py @@ -1,19 +1,108 @@ -from jinja2 import Environment, FileSystemLoader, ChoiceLoader -from sqlalchemy.orm import class_mapper, RelationshipProperty -from flask import current_app import os -def get_env(): - app_loader = current_app.jinja_loader +from flask import current_app, url_for +from jinja2 import Environment, FileSystemLoader, ChoiceLoader +from sqlalchemy import inspect +from sqlalchemy.orm import class_mapper, RelationshipProperty +from typing import Any, Dict, List, Optional, Tuple +def get_env(): + app = current_app default_path = os.path.join(os.path.dirname(__file__), 'templates') fallback_loader = FileSystemLoader(default_path) - env = Environment(loader=ChoiceLoader([ - app_loader, - fallback_loader - ])) - return env + return app.jinja_env.overlay( + loader=ChoiceLoader([app.jinja_loader, fallback_loader]) + ) + +def _is_rel_loaded(obj, rel_name: str) -> bool: + try: + state = inspect(obj) + return state.attrs[rel_name].loaded_value is not None + except Exception: + return False + +def _deep_get_from_obj(obj, dotted: str): + cur = obj + parts = dotted.split(".") + for i, part in enumerate(parts): + if i < len(parts) - 1 and not _is_rel_loaded(cur, part): + print(f"WARNING: {cur}.{part} is not loaded!") + return None + cur = getattr(cur, part, None) + if cur is None: + return None + return cur + +def _deep_get(row: Dict[str, Any], dotted: str) -> Any: + if dotted in row: + return row[dotted] + + cur = row + for part in dotted.split('.'): + if isinstance(cur, dict) and part in cur: + cur = cur[part] + else: + return None + return cur + +def _format_value(val: Any, fmt: Optional[str]) -> Any: + if fmt is None: + return val + try: + if fmt == "yesno": + return "Yes" if bool(val) else "No" + if fmt == "date": + return val.strftime("%Y-%m-%d") if hasattr(val, "strftime") else val + if fmt == "datetime": + return val.strftime("%Y-%m-%d %H:%M") if hasattr(val, "strftime") else val + if fmt == "time": + return val.strftime("%H:%M") if hasattr(val, "strftime") else val + except Exception: + return val + return val + +def _class_for(val: Any, classes: Optional[Dict[str, str]]) -> Optional[str]: + if not classes: + return None + key = "none" if val is None else str(val).lower() + return classes.get(key, classes.get("default")) + +def _build_href(spec: Dict[str, Any], row: Dict[str, Any], obj) -> Optional[str]: + if not spec: + return None + params = {} + for k, v in (spec.get("params") or {}).items(): + if isinstance(v, str) and v.startswith("{") and v.endswith("}"): + key = v[1:-1] + val = _deep_get(row, key) + if val is None: + val = _deep_get_from_obj(obj, key) + params[k] = val + else: + params[k] = v + if any(v is None for v in params.values()): + print(f"[render_table] url_for failed: endpoint={spec}: params={params}") + return None + try: + return url_for(spec["endpoint"], **params) + except Exception as e: + print(f"[render_table] url_for failed: endpoint={spec['endpoint']} params={params} err={e}") + return None + +def _humanize(field: str) -> str: + return field.replace(".", " > ").replace("_", " ").title() + +def _normalize_columns(columns: Optional[List[Dict[str, Any]]], default_fields: List[str]) -> List[Dict[str, Any]]: + if not columns: + return [{"field": f, "label": _humanize(f)} for f in default_fields] + + norm = [] + for col in columns: + c = dict(col) + c.setdefault("label", _humanize(c["field"])) + norm.append(c) + return norm def get_crudkit_template(env, name): try: @@ -32,15 +121,32 @@ def render_field(field, value): options=field.get('options', None) ) -def render_table(objects): +def render_table(objects: List[Any], columns: Optional[List[Dict[str, Any]]] = None): env = get_env() template = get_crudkit_template(env, 'table.html') + if not objects: return template.render(fields=[], rows=[]) + proj = getattr(objects[0], "__crudkit_projection__", None) - rows = [obj.as_dict(proj) for obj in objects] - fields = list(rows[0].keys()) - return template.render(fields=fields, rows=rows) + row_dicts = [obj.as_dict(proj) for obj in objects] + + default_fields = [k for k in row_dicts[0].keys() if k != "id"] + cols = _normalize_columns(columns, default_fields) + + disp_rows = [] + for obj, rd in zip(objects, row_dicts): + cells = [] + for col in cols: + field = col["field"] + raw = _deep_get(rd, field) + text = _format_value(raw, col.get("format")) + href = _build_href(col.get("link"), rd, obj) if col.get("link") else None + cls = _class_for(raw, col.get("classes")) + cells.append({"text": text, "href": href, "class": cls}) + disp_rows.append({"id": rd.get("id"), "cells": cells}) + + return template.render(columns=cols, rows=disp_rows) def render_form(model_cls, values, session=None): env = get_env() diff --git a/crudkit/ui/templates/table.html b/crudkit/ui/templates/table.html index 38ff48e..06379cf 100644 --- a/crudkit/ui/templates/table.html +++ b/crudkit/ui/templates/table.html @@ -1,12 +1,26 @@
    {{ field.name }}{{ field }}
    {{ obj[field.name] }}
    {{ cell }}
    No data.
    + + + {% for col in columns %} + + {% endfor %} + + + {% if rows %} + {% for row in rows %} - {% for field in fields if field != "id" %}{% endfor %} + {% for cell in row.cells %} + {% if cell.href %} + + {% else %} + + {% endif %} + {% endfor %} - {% for row in rows %} - {% for _, cell in row.items() if _ != "id" %}{% endfor %} - {% endfor %} + {% endfor %} {% else %} - + {% endif %} -
    {{ col.label }}
    {{ field }}{{ cell.text if cell.text is not none else '-' }}{{ cell.text if cell.text is not none else '-' }}
    {{ cell }}
    No data.
    No data.
    \ No newline at end of file + + -- 2.51.2 From 6165feaeb77bf943a6b29e3afcb2c98776b49aaa Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Thu, 11 Sep 2025 10:06:49 -0500 Subject: [PATCH 29/36] Downstream fixes. --- crudkit/core/service.py | 6 +++++- crudkit/core/spec.py | 15 ++++++++++++--- crudkit/ui/fragments.py | 4 ++-- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/crudkit/core/service.py b/crudkit/core/service.py index f84d276..07f51ca 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -54,6 +54,9 @@ class CRUDService(Generic[T]): def list(self, params=None) -> list[T]: query, root_alias = self.get_query() + root_fields = [] + rel_field_names = {} + if params: if self.supports_soft_delete: include_deleted = _is_truthy(params.get('include_deleted')) @@ -73,7 +76,8 @@ class CRUDService(Generic[T]): isouter=True ) - root_fields, rel_field_names = spec.parse_fields() + if params: + root_fields, rel_field_names = spec.parse_fields() if root_fields: query = query.options(Load(root_alias).load_only(*root_fields)) diff --git a/crudkit/core/spec.py b/crudkit/core/spec.py index f895e6e..1b6ea31 100644 --- a/crudkit/core/spec.py +++ b/crudkit/core/spec.py @@ -157,17 +157,26 @@ class CRUDSpec: for path in self.eager_paths: current = root_alias loader = None + for idx, name in enumerate(path): rel_attr = getattr(current, name) - loader = (selectinload(rel_attr) if loader is None else loader.selectinload(name)) + + if loader is None: + loader = selectinload(rel_attr) + else: + loader = loader.selectinload(rel_attr) + + current = rel_attr.property.mapper.class_ + if fields_map and idx == len(path) - 1 and path in fields_map: - target_cls = rel_attr.property.mapper.class_ + target_cls = current cols = [getattr(target_cls, n) for n in fields_map[path] if hasattr(target_cls, n)] if cols: loader = loader.load_only(*cols) - current = rel_attr.property.mapper.class_ + if loader is not None: loads.append(loader) + return loads def get_join_paths(self): diff --git a/crudkit/ui/fragments.py b/crudkit/ui/fragments.py index bfc8b47..6a3b7a4 100644 --- a/crudkit/ui/fragments.py +++ b/crudkit/ui/fragments.py @@ -121,7 +121,7 @@ def render_field(field, value): options=field.get('options', None) ) -def render_table(objects: List[Any], columns: Optional[List[Dict[str, Any]]] = None): +def render_table(objects: List[Any], columns: Optional[List[Dict[str, Any]]] = None, **opts): env = get_env() template = get_crudkit_template(env, 'table.html') @@ -146,7 +146,7 @@ def render_table(objects: List[Any], columns: Optional[List[Dict[str, Any]]] = N cells.append({"text": text, "href": href, "class": cls}) disp_rows.append({"id": rd.get("id"), "cells": cells}) - return template.render(columns=cols, rows=disp_rows) + return template.render(columns=cols, rows=disp_rows, kwargs=opts) def render_form(model_cls, values, session=None): env = get_env() -- 2.51.2 From 637e873ccfad7ecb7b0711b7c2ef7d36b57816e0 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Thu, 11 Sep 2025 11:30:07 -0500 Subject: [PATCH 30/36] Add params support to get() and improve hybrid support. I also want a taco and will not get one. --- crudkit/api/flask_api.py | 2 +- crudkit/core/service.py | 81 +++++++++++++++++++++++++++++----------- crudkit/core/spec.py | 34 ++++++++++------- 3 files changed, 81 insertions(+), 36 deletions(-) diff --git a/crudkit/api/flask_api.py b/crudkit/api/flask_api.py index ddb77a9..46832c2 100644 --- a/crudkit/api/flask_api.py +++ b/crudkit/api/flask_api.py @@ -10,7 +10,7 @@ def generate_crud_blueprint(model, service): @bp.get('/') def get_item(id): - item = service.get(id) + item = service.get(id, request.args) return jsonify(item.as_dict()) @bp.post('/') diff --git a/crudkit/core/service.py b/crudkit/core/service.py index 07f51ca..2e1c400 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -1,5 +1,6 @@ from typing import Type, TypeVar, Generic, Optional from sqlalchemy.orm import Load, Session, raiseload, with_polymorphic +from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy import inspect, text from crudkit.core.base import Version from crudkit.core.spec import CRUDSpec @@ -43,18 +44,70 @@ class CRUDService(Generic[T]): cols.append(col) return cols or [text("1")] - def get(self, id: int, include_deleted: bool = False) -> T | None: + def get(self, id: int, params=None) -> T | None: + print(f"I AM GETTING A THING! A THINGS! {params}") query, root_alias = self.get_query() + + include_deleted = False + root_fields = [] + root_field_names = {} + rel_field_names = {} + + spec = CRUDSpec(self.model, params or {}, root_alias) + if params: + if self.supports_soft_delete: + include_deleted = _is_truthy(params.get('include_deleted')) if self.supports_soft_delete and not include_deleted: query = query.filter(getattr(root_alias, "is_deleted") == False) query = query.filter(getattr(root_alias, "id") == id) + + spec.parse_includes() + + for parent_alias, relationship_attr, target_alias in spec.get_join_paths(): + query = query.join( + target_alias, + relationship_attr.of_type(target_alias), + isouter=True + ) + + if params: + root_fields, rel_field_names, root_field_names = spec.parse_fields() + + only_cols = [c for c in root_fields if isinstance(c, InstrumentedAttribute)] + if only_cols: + query = query.options(Load(root_alias).load_only(*only_cols)) + + for eager in spec.get_eager_loads(root_alias, fields_map=rel_field_names): + query = query.options(eager) + obj = query.first() + + proj = [] + if root_field_names: + proj.extend(root_field_names) + if root_fields: + proj.extend(c.key for c in root_fields) + for path, names in (rel_field_names or {}).items(): + prefix = ".".join(path) + for n in names: + proj.append(f"{prefix}.{n}") + + if proj and "id" not in proj and hasattr(self.model, "id"): + proj.insert(0, "id") + + if proj: + try: + setattr(obj, "__crudkit_projection__", tuple(proj)) + except Exception: + pass + return obj or None def list(self, params=None) -> list[T]: query, root_alias = self.get_query() root_fields = [] + root_field_names = {} rel_field_names = {} if params: @@ -77,17 +130,15 @@ class CRUDService(Generic[T]): ) if params: - root_fields, rel_field_names = spec.parse_fields() + root_fields, rel_field_names, root_field_names = spec.parse_fields() - if root_fields: - query = query.options(Load(root_alias).load_only(*root_fields)) + only_cols = [c for c in root_fields if isinstance(c, InstrumentedAttribute)] + if only_cols: + query = query.options(Load(root_alias).load_only(*only_cols)) for eager in spec.get_eager_loads(root_alias, fields_map=rel_field_names): query = query.options(eager) - # if root_fields or rel_field_names: - # query = query.options(Load(root_alias).raiseload("*")) - if filters: query = query.filter(*filters) @@ -108,6 +159,8 @@ class CRUDService(Generic[T]): rows = query.all() proj = [] + if root_field_names: + proj.extend(root_field_names) if root_fields: proj.extend(c.key for c in root_fields) for path, names in (rel_field_names or {}).items(): @@ -125,20 +178,6 @@ class CRUDService(Generic[T]): except Exception: pass - # try: - # rf_names = [c.key for c in (root_fields or [])] - # except NameError: - # rf_names = [] - # if rf_names: - # allow = set(rf_names) - # if "id" not in allow and hasattr(self.model, "id"): - # allow.add("id") - # for obj in rows: - # try: - # setattr(obj, "__crudkit_root_fields__", allow) - # except Exception: - # pass - return rows def create(self, data: dict, actor=None) -> T: diff --git a/crudkit/core/spec.py b/crudkit/core/spec.py index 1b6ea31..9c0e53b 100644 --- a/crudkit/core/spec.py +++ b/crudkit/core/spec.py @@ -1,5 +1,6 @@ from typing import List, Tuple, Set, Dict, Optional from sqlalchemy import asc, desc +from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import aliased, selectinload from sqlalchemy.orm.attributes import InstrumentedAttribute @@ -48,7 +49,7 @@ class CRUDSpec: current_alias = alias continue - if isinstance(attr_obj, InstrumentedAttribute) or hasattr(attr_obj, "clauses"): + if isinstance(attr_obj, InstrumentedAttribute) or getattr(attr_obj, "comparator", None) is not None or hasattr(attr_obj, "clauses"): return attr_obj, tuple(join_path) if join_path else None return None, None @@ -120,7 +121,7 @@ class CRUDSpec: """ raw = self.params.get('fields') if not raw: - return [], {} + return [], {}, {} if isinstance(raw, list): tokens = [] @@ -130,6 +131,7 @@ class CRUDSpec: tokens = [p.strip() for p in str(raw).split(',') if p.strip()] root_fields: List[InstrumentedAttribute] = [] + root_field_names: list[str] = [] rel_field_names: Dict[Tuple[str, ...], List[str]] = {} for token in tokens: @@ -141,6 +143,7 @@ class CRUDSpec: self.eager_paths.add(join_path) else: root_fields.append(col) + root_field_names.append(getattr(col, "key", token)) seen = set() root_fields = [c for c in root_fields if not (c.key in seen or seen.add(c.key))] @@ -150,33 +153,36 @@ class CRUDSpec: self._root_fields = root_fields self._rel_field_names = rel_field_names - return root_fields, rel_field_names + return root_fields, rel_field_names, root_field_names - def get_eager_loads(self, root_alias, *, fields_map: Optional[Dict[Tuple[str, ...], List[str]]] = None): + def get_eager_loads(self, root_alias, *, fields_map=None): loads = [] for path in self.eager_paths: current = root_alias loader = None - for idx, name in enumerate(path): rel_attr = getattr(current, name) + loader = selectinload(rel_attr) if loader is None else loader.selectinload(rel_attr) - if loader is None: - loader = selectinload(rel_attr) - else: - loader = loader.selectinload(rel_attr) - - current = rel_attr.property.mapper.class_ + # step to target class for the next hop + target_cls = rel_attr.property.mapper.class_ + current = target_cls + # if final hop and we have a fields map, narrow columns if fields_map and idx == len(path) - 1 and path in fields_map: - target_cls = current - cols = [getattr(target_cls, n) for n in fields_map[path] if hasattr(target_cls, n)] + cols = [] + for n in fields_map[path]: + attr = getattr(target_cls, n, None) + # Only include real column attributes; skip hybrids/expressions + if isinstance(attr, InstrumentedAttribute): + cols.append(attr) + + # Only apply load_only if we have at least one real column if cols: loader = loader.load_only(*cols) if loader is not None: loads.append(loader) - return loads def get_join_paths(self): -- 2.51.2 From 2274b7686e8e044976d97cccc2b9ce7247279fc8 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Mon, 15 Sep 2025 08:28:47 -0500 Subject: [PATCH 31/36] Adding registration, --- crudkit/api/flask_api.py | 25 ++++++-- crudkit/core/service.py | 53 +++++++++++------ crudkit/integration.py | 25 ++++++++ crudkit/registry.py | 120 +++++++++++++++++++++++++++++++++++++++ crudkit/ui/fragments.py | 2 +- 5 files changed, 203 insertions(+), 22 deletions(-) create mode 100644 crudkit/integration.py create mode 100644 crudkit/registry.py diff --git a/crudkit/api/flask_api.py b/crudkit/api/flask_api.py index 46832c2..d238678 100644 --- a/crudkit/api/flask_api.py +++ b/crudkit/api/flask_api.py @@ -6,26 +6,41 @@ def generate_crud_blueprint(model, service): @bp.get('/') def list_items(): items = service.list(request.args) - return jsonify([item.as_dict() for item in items]) + try: + return jsonify([item.as_dict() for item in items]) + except Exception as e: + return jsonify({"status": "error", "error": str(e)}) @bp.get('/') def get_item(id): item = service.get(id, request.args) - return jsonify(item.as_dict()) + try: + return jsonify(item.as_dict()) + except Exception as e: + return jsonify({"status": "error", "error": str(e)}) @bp.post('/') def create_item(): obj = service.create(request.json) - return jsonify(obj.as_dict()) + try: + return jsonify(obj.as_dict()) + except Exception as e: + return jsonify({"status": "error", "error": str(e)}) @bp.patch('/') def update_item(id): obj = service.update(id, request.json) - return jsonify(obj.as_dict()) + try: + return jsonify(obj.as_dict()) + except Exception as e: + return jsonify({"status": "error", "error": str(e)}) @bp.delete('/') def delete_item(id): service.delete(id) - return '', 204 + try: + return jsonify({"status": "success"}), 204 + except Exception as e: + return jsonify({"status": "error", "error": str(e)}) return bp diff --git a/crudkit/core/service.py b/crudkit/core/service.py index 2e1c400..ed0063a 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -1,12 +1,34 @@ -from typing import Type, TypeVar, Generic, Optional -from sqlalchemy.orm import Load, Session, raiseload, with_polymorphic +from typing import Any, Type, TypeVar, Generic, Optional, Protocol, runtime_checkable, cast +from sqlalchemy.orm import Load, Session, raiseload, with_polymorphic, Mapper from sqlalchemy.orm.attributes import InstrumentedAttribute +from sqlalchemy.orm.util import AliasedClass +from sqlalchemy.engine import Engine, Connection from sqlalchemy import inspect, text from crudkit.core.base import Version from crudkit.core.spec import CRUDSpec from crudkit.backend import BackendInfo, make_backend_info -T = TypeVar("T") +@runtime_checkable +class _HasID(Protocol): + id: int + +@runtime_checkable +class _HasTable(Protocol): + __table__: Any + +@runtime_checkable +class _HasADict(Protocol): + def as_dict(self) -> dict: ... + +@runtime_checkable +class _SoftDeletable(Protocol): + is_deleted: bool + +class _CRUDModelProto(_HasID, _HasTable, _HasADict, Protocol): + """Minimal surface that our CRUD service relies on. Soft-delete is optional.""" + pass + +T = TypeVar("T", bound=_CRUDModelProto) def _is_truthy(val): return str(val).lower() in ('1', 'true', 'yes', 'on') @@ -25,7 +47,9 @@ class CRUDService(Generic[T]): self.polymorphic = polymorphic self.supports_soft_delete = hasattr(model, 'is_deleted') # Cache backend info once. If not provided, derive from session bind. - self.backend = backend or make_backend_info(self.session.get_bind()) + bind = self.session.get_bind() + eng: Engine = bind.engine if isinstance(bind, Connection) else cast(Engine, bind) + self.backend = backend or make_backend_info(eng) def get_query(self): if self.polymorphic: @@ -35,7 +59,7 @@ class CRUDService(Generic[T]): # Helper: default ORDER BY for MSSQL when paginating without explicit order def _default_order_by(self, root_alias): - mapper = inspect(self.model) + mapper: Mapper[Any] = cast(Mapper[Any], inspect(self.model)) cols = [] for col in mapper.primary_key: try: @@ -64,11 +88,9 @@ class CRUDService(Generic[T]): spec.parse_includes() for parent_alias, relationship_attr, target_alias in spec.get_join_paths(): - query = query.join( - target_alias, - relationship_attr.of_type(target_alias), - isouter=True - ) + rel_attr = cast(InstrumentedAttribute, relationship_attr) + target = cast(Any, target_alias) + query = query.join(target, rel_attr.of_type(target), isouter=True) if params: root_fields, rel_field_names, root_field_names = spec.parse_fields() @@ -123,11 +145,9 @@ class CRUDService(Generic[T]): spec.parse_includes() for parent_alias, relationship_attr, target_alias in spec.get_join_paths(): - query = query.join( - target_alias, - relationship_attr.of_type(target_alias), - isouter=True - ) + rel_attr = cast(InstrumentedAttribute, relationship_attr) + target = cast(Any, target_alias) + query = query.join(target, rel_attr.of_type(target), isouter=True) if params: root_fields, rel_field_names, root_field_names = spec.parse_fields() @@ -206,7 +226,8 @@ class CRUDService(Generic[T]): if hard or not self.supports_soft_delete: self.session.delete(obj) else: - obj.is_deleted = True + soft = cast(_SoftDeletable, obj) + soft.is_deleted = True self.session.commit() self._log_version("delete", obj, actor) return obj diff --git a/crudkit/integration.py b/crudkit/integration.py new file mode 100644 index 0000000..665e813 --- /dev/null +++ b/crudkit/integration.py @@ -0,0 +1,25 @@ +from __future__ import annotations +from typing import Type +from flask import Flask + +from crudkit.engines import CRUDKitRuntime + +from .registry import CRUDRegistry + +class CRUDKit: + def __init__(self, app: Flask, runtime: CRUDKitRuntime): + self.app = app + self.runtime = runtime + self.registry = CRUDRegistry(runtime) + + def register(self, model: Type, **kwargs): + return self.registry.register_class(self.app, model, **kwargs) + + def register_many(self, models: list[Type], **kwargs): + return self.registry.register_many(self.app, models, **kwargs) + + def get_model(self, key: str): + return self.registry.get_model(key) + + def get_service(self, model: Type): + return self.registry.get_service(model) diff --git a/crudkit/registry.py b/crudkit/registry.py new file mode 100644 index 0000000..9c66153 --- /dev/null +++ b/crudkit/registry.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Optional, Type, TypeVar, cast + +from flask import Flask +from sqlalchemy.orm import Session + +from crudkit.core.service import CRUDService +from crudkit.api.flask_api import generate_crud_blueprint +from crudkit.engines import CRUDKitRuntime + +T = TypeVar("T") + +@dataclass +class Registered: + model: Type[Any] + service: CRUDService[Any] + blueprint_name: str + url_prefix: str + +class CRUDRegistry: + """ + Binds: + - name -> model class + - model class -> CRUDService (using CRUDKitRuntime.session_factory) + - model class -> Flask blueprint (via generate_crud_blueprint) + """ + def __init__(self, runtime: CRUDKitRuntime): + self._rt = runtime + self._models_by_key: Dict[str, Type[Any]] = {} + self._services_by_model: Dict[Type[Any], CRUDService[Any]] = {} + self._bps_by_model: Dict[Type[Any], Registered] = {} + + @staticmethod + def _key(model_or_name: Type[Any] | str) -> str: + return model_or_name.lower() if isinstance(model_or_name, str) else model_or_name.__name__.lower() + + def get_model(self, key: str) -> Optional[Type[Any]]: + return self._models_by_key.get(key.lower()) + + def get_service(self, model: Type[T]) -> Optional[CRUDService[T]]: + return cast(Optional[CRUDService[T]], self._services_by_model.get(model)) + + def is_registered(self, model: Type[Any]) -> bool: + return model in self._services_by_model + + def register_class( + self, + app: Flask, + model: Type[Any], + *, + url_prefix: Optional[str] = None, + blueprint_name: Optional[str] = None, + polymorphic: bool = False, + service_kwargs: Optional[dict] = None + ) -> Registered: + """ + Register a model: + - store name -> class + - create a CRUDService bound to a Session from runtime.session_factory + - attach backend into from runtime.backend + - mount Flask blueprint at /api/ by default + Idempotent for each model. + """ + key = self._key(model) + self._models_by_key.setdefault(key, model) + + svc = self._services_by_model.get(model) + if svc is None: + SessionMaker = self._rt.session_factory + if SessionMaker is None: + raise RuntimeError("CRUDKitRuntime.session_factory is not initialized.") + session: Session = SessionMaker() + svc = CRUDService( + model, + session=session, + polymorphic=polymorphic, + backend=self._rt.backend, + **(service_kwargs or {}), + ) + self._services_by_model[model] = svc + + reg = self._bps_by_model.get(model) + if reg: + return reg + + prefix = url_prefix or f"/api/{key}" + bp_name = blueprint_name or f"crudkit.{key}" + + bp = generate_crud_blueprint(model, svc) + bp.name = bp_name + app.register_blueprint(bp, url_prefix=prefix) + + reg = Registered(model=model, service=svc, blueprint_name=bp_name, url_prefix=prefix) + self._bps_by_model[model] = reg + return reg + + def register_many( + self, + app: Flask, + models: list[Type[Any]], + *, + base_prefix: str = "/api", + polymorphic: bool = False, + service_kwargs: Optional[dict] = None, + ) -> list[Registered]: + out: list[Registered] = [] + for m in models: + key = self._key(m) + out.append( + self.register_class( + app, + m, + url_prefix=f"{base_prefix}/{key}", + polymorphic=polymorphic, + service_kwargs=service_kwargs, + ) + ) + return out diff --git a/crudkit/ui/fragments.py b/crudkit/ui/fragments.py index 6a3b7a4..4fe4208 100644 --- a/crudkit/ui/fragments.py +++ b/crudkit/ui/fragments.py @@ -85,7 +85,7 @@ def _build_href(spec: Dict[str, Any], row: Dict[str, Any], obj) -> Optional[str] print(f"[render_table] url_for failed: endpoint={spec}: params={params}") return None try: - return url_for(spec["endpoint"], **params) + return url_for('crudkit.' + spec["endpoint"], **params) except Exception as e: print(f"[render_table] url_for failed: endpoint={spec['endpoint']} params={params} err={e}") return None -- 2.51.2 From daf0684ebe6f312533dac64abce6c521740d2e6a Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Mon, 15 Sep 2025 15:29:30 -0500 Subject: [PATCH 32/36] A bunch of downstream changes. --- crudkit/__init__.py | 9 ++++ crudkit/config.py | 2 + crudkit/core/service.py | 11 ++-- crudkit/registry.py | 4 +- crudkit/ui/fragments.py | 116 +++++++++++++++++++++++++++++++++++++--- 5 files changed, 130 insertions(+), 12 deletions(-) diff --git a/crudkit/__init__.py b/crudkit/__init__.py index 3959538..e6af0be 100644 --- a/crudkit/__init__.py +++ b/crudkit/__init__.py @@ -1,8 +1,17 @@ from .backend import BackendInfo, make_backend_info from .config import Config, DevConfig, TestConfig, ProdConfig, get_config, build_database_url from .engines import CRUDKitRuntime, build_engine, build_sessionmaker +from .integration import CRUDKit __all__ = [ "Config", "DevConfig", "TestConfig", "ProdConfig", "get_config", "build_database_url", "CRUDKitRuntime", "build_engine", "build_sessionmaker", "BackendInfo", "make_backend_info" ] + +runtime = CRUDKitRuntime() +crud: CRUDKit | None = None + +def init_crud(app): + global crud + crud = CRUDKit(app, runtime) + return crud diff --git a/crudkit/config.py b/crudkit/config.py index a19d4d3..0439a3e 100644 --- a/crudkit/config.py +++ b/crudkit/config.py @@ -123,6 +123,7 @@ def build_database_url( "Trusted_Connection": "yes", "Encrypt": "yes", "TrustServerCertificate": "yes", + "MARS_Connection": "yes", } base_opts.update(options) qs = "?" + "&".join(f"{k}={quote_plus(v)}" for k, v in base_opts.items()) @@ -135,6 +136,7 @@ def build_database_url( "driver": driver, "Encrypt": "yes", "TrustServerCertificate": "yes", + "MARS_Connection": "yes", } base_opts.update(options) qs = "?" + "&".join(f"{k}={quote_plus(v)}" for k, v in base_opts.items()) diff --git a/crudkit/core/service.py b/crudkit/core/service.py index ed0063a..6c0afd6 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -1,4 +1,4 @@ -from typing import Any, Type, TypeVar, Generic, Optional, Protocol, runtime_checkable, cast +from typing import Any, Callable, Type, TypeVar, Generic, Optional, Protocol, runtime_checkable, cast from sqlalchemy.orm import Load, Session, raiseload, with_polymorphic, Mapper from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.orm.util import AliasedClass @@ -37,13 +37,13 @@ class CRUDService(Generic[T]): def __init__( self, model: Type[T], - session: Session, + session_factory: Callable[[], Session], polymorphic: bool = False, *, backend: Optional[BackendInfo] = None ): self.model = model - self.session = session + self._session_factory = session_factory self.polymorphic = polymorphic self.supports_soft_delete = hasattr(model, 'is_deleted') # Cache backend info once. If not provided, derive from session bind. @@ -51,6 +51,10 @@ class CRUDService(Generic[T]): eng: Engine = bind.engine if isinstance(bind, Connection) else cast(Engine, bind) self.backend = backend or make_backend_info(eng) + @property + def session(self) -> Session: + return self._session_factory() + def get_query(self): if self.polymorphic: poly = with_polymorphic(self.model, "*") @@ -69,7 +73,6 @@ class CRUDService(Generic[T]): return cols or [text("1")] def get(self, id: int, params=None) -> T | None: - print(f"I AM GETTING A THING! A THINGS! {params}") query, root_alias = self.get_query() include_deleted = False diff --git a/crudkit/registry.py b/crudkit/registry.py index 9c66153..fe3bfb7 100644 --- a/crudkit/registry.py +++ b/crudkit/registry.py @@ -71,10 +71,10 @@ class CRUDRegistry: SessionMaker = self._rt.session_factory if SessionMaker is None: raise RuntimeError("CRUDKitRuntime.session_factory is not initialized.") - session: Session = SessionMaker() + svc = CRUDService( model, - session=session, + session_factory=SessionMaker, polymorphic=polymorphic, backend=self._rt.backend, **(service_kwargs or {}), diff --git a/crudkit/ui/fragments.py b/crudkit/ui/fragments.py index 4fe4208..5c10f9d 100644 --- a/crudkit/ui/fragments.py +++ b/crudkit/ui/fragments.py @@ -4,6 +4,7 @@ from flask import current_app, url_for from jinja2 import Environment, FileSystemLoader, ChoiceLoader from sqlalchemy import inspect from sqlalchemy.orm import class_mapper, RelationshipProperty +from sqlalchemy.orm.attributes import NO_VALUE from typing import Any, Dict, List, Optional, Tuple def get_env(): @@ -15,10 +16,88 @@ def get_env(): loader=ChoiceLoader([app.jinja_loader, fallback_loader]) ) +def _val_from_row_or_obj(row: Dict[str, Any], obj: Any, dotted: str) -> Any: + """Best-effort deep get: try the projected row first, then the ORM object.""" + val = _deep_get(row, dotted) + if val is None: + val = _deep_get_from_obj(obj, dotted) + return val + +def _matches_simple_condition(row: Dict[str, Any], obj: Any, cond: Dict[str, Any]) -> bool: + """ + Supports: + {"field": "foo.bar", "eq": 10} + {"field": "foo", "ne": None} + {"field": "count", "gt": 0} (also lt, gte, lte) + {"field": "name", "in": ["a","b"]} + {"field": "thing", "is": None, | True | False} + {"any": [ ...subconds... ]} # OR + {"all": [ ...subconds... ]} # AND + {"not": { ...subcond... }} # NOT + """ + if "any" in cond: + return any(_matches_simple_condition(row, obj, c) for c in cond["any"]) + if "all" in cond: + return all(_matches_simple_condition(row, obj, c) for c in cond["all"]) + if "not" in cond: + return not _matches_simple_condition(row, obj, cond["not"]) + + field = cond.get("field") + val = _val_from_row_or_obj(row, obj, field) if field else None + + if "is" in cond: + target = cond["is"] + if target is None: + return val is None + if isinstance(target, bool): + return bool(val) is target + return val is target + + if "eq" in cond: + return val == cond["eq"] + if "ne" in cond: + return val != cond["ne"] + if "gt" in cond: + try: return val > cond["gt"] + except Exception: return False + if "lt" in cond: + try: return val < cond["lt"] + except Exception: return False + if "gte" in cond: + try: return val >= cond["gte"] + except Exception: return False + if "lte" in cond: + try: return val <= cond["lte"] + except Exception: return False + if "in" in cond: + try: return val in cond["in"] + except Exception: return False + + return False + +def _row_class_for(row: Dict[str, Any], obj: Any, rules: Optional[List[Dict[str, Any]]]) -> Optional[str]: + """ + rules is a list of: + {"when": , "class": "table-warning fw-semibold"} + Multiple matching rules stack classes. Later wins on duplicates by normal CSS rules. + """ + if not rules: + return None + classes = [] + for rule in rules: + when = rule.get("when") or {} + if _matches_simple_condition(row, obj, when): + cls = rule.get("class") + if cls: + classes.append(cls) + + return " ".join(dict.fromkeys(classes)) or None + def _is_rel_loaded(obj, rel_name: str) -> bool: try: state = inspect(obj) - return state.attrs[rel_name].loaded_value is not None + attr = state.attrs[rel_name] + return attr.loaded_value is not NO_VALUE except Exception: return False @@ -27,7 +106,6 @@ def _deep_get_from_obj(obj, dotted: str): parts = dotted.split(".") for i, part in enumerate(parts): if i < len(parts) - 1 and not _is_rel_loaded(cur, part): - print(f"WARNING: {cur}.{part} is not loaded!") return None cur = getattr(cur, part, None) if cur is None: @@ -82,12 +160,10 @@ def _build_href(spec: Dict[str, Any], row: Dict[str, Any], obj) -> Optional[str] else: params[k] = v if any(v is None for v in params.values()): - print(f"[render_table] url_for failed: endpoint={spec}: params={params}") return None try: return url_for('crudkit.' + spec["endpoint"], **params) except Exception as e: - print(f"[render_table] url_for failed: endpoint={spec['endpoint']} params={params} err={e}") return None def _humanize(field: str) -> str: @@ -104,6 +180,28 @@ def _normalize_columns(columns: Optional[List[Dict[str, Any]]], default_fields: norm.append(c) return norm +def _normalize_opts(opts: Dict[str, Any]) -> Dict[str, Any]: + """ + Accept either: + render_table(..., object_class='user', row_classe[...]) + or: + render_table(..., opts={'object_class': 'user', 'row_classes': [...]}) + + Returns a flat dict with top-level keys for convenience, while preserving + all original keys for the template. + """ + if not isinstance(opts, dict): + return {} + + flat = dict(opts) + + nested = flat.get("opts") + if isinstance(nested, dict): + for k, v in nested.items(): + flat.setdefault(k, v) + + return flat + def get_crudkit_template(env, name): try: return env.get_template(f'crudkit/{name}') @@ -128,12 +226,16 @@ def render_table(objects: List[Any], columns: Optional[List[Dict[str, Any]]] = N if not objects: return template.render(fields=[], rows=[]) + flat_opts = _normalize_opts(opts) + proj = getattr(objects[0], "__crudkit_projection__", None) row_dicts = [obj.as_dict(proj) for obj in objects] default_fields = [k for k in row_dicts[0].keys() if k != "id"] cols = _normalize_columns(columns, default_fields) + row_rules = (flat_opts.get("row_classes") or []) + disp_rows = [] for obj, rd in zip(objects, row_dicts): cells = [] @@ -144,9 +246,11 @@ def render_table(objects: List[Any], columns: Optional[List[Dict[str, Any]]] = N href = _build_href(col.get("link"), rd, obj) if col.get("link") else None cls = _class_for(raw, col.get("classes")) cells.append({"text": text, "href": href, "class": cls}) - disp_rows.append({"id": rd.get("id"), "cells": cells}) - return template.render(columns=cols, rows=disp_rows, kwargs=opts) + row_cls = _row_class_for(rd, obj, row_rules) + disp_rows.append({"id": rd.get("id"), "class": row_cls, "cells": cells}) + + return template.render(columns=cols, rows=disp_rows, kwargs=flat_opts) def render_form(model_cls, values, session=None): env = get_env() -- 2.51.2 From 7aefefdec6d4e59b24c543156c4544a9d418b1b1 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Tue, 16 Sep 2025 13:41:55 -0500 Subject: [PATCH 33/36] Pagination/cursor feature added. --- crudkit/api/_cursor.py | 21 +++ crudkit/api/flask_api.py | 52 +++++++- crudkit/core/service.py | 268 ++++++++++++++++++++++++++++++++++++++- crudkit/core/types.py | 16 +++ 4 files changed, 349 insertions(+), 8 deletions(-) create mode 100644 crudkit/api/_cursor.py create mode 100644 crudkit/core/types.py diff --git a/crudkit/api/_cursor.py b/crudkit/api/_cursor.py new file mode 100644 index 0000000..3b63cfd --- /dev/null +++ b/crudkit/api/_cursor.py @@ -0,0 +1,21 @@ +import base64, json +from typing import Any + +def encode_cursor(values: list[Any] | None, desc_flags: list[bool], backward: bool) -> str | None: + if not values: + return None + payload = {"v": values, "d": desc_flags, "b": backward} + return base64.urlsafe_b64encode(json.dumps(payload).encode()).decode() + +def decode_cursor(token: str | None) -> tuple[list[Any] | None, bool] | tuple[None, bool]: + if not token: + return None, False + try: + obj = json.loads(base64.urlsafe_b64decode(token.encode()).decode()) + vals = obj.get("v") + backward = bool(obj.get("b", False)) + if isinstance(vals, list): + return vals, backward + except Exception: + pass + return None, False diff --git a/crudkit/api/flask_api.py b/crudkit/api/flask_api.py index d238678..bf505b2 100644 --- a/crudkit/api/flask_api.py +++ b/crudkit/api/flask_api.py @@ -1,15 +1,59 @@ from flask import Blueprint, jsonify, request +from crudkit.api._cursor import encode_cursor, decode_cursor +from crudkit.core.service import _is_truthy + def generate_crud_blueprint(model, service): bp = Blueprint(model.__name__.lower(), __name__) @bp.get('/') def list_items(): - items = service.list(request.args) + args = request.args.to_dict(flat=True) + + # legacy detection + legacy_offset = "offset" in args or "page" in args + + # sane limit default try: - return jsonify([item.as_dict() for item in items]) - except Exception as e: - return jsonify({"status": "error", "error": str(e)}) + limit = int(args.get("limit", 50)) + except Exception: + limit = 50 + args["limit"] = limit + + if legacy_offset: + # Old behavior: honor limit/offset, same CRUDSpec goodies + items = service.list(args) + return jsonify([obj.as_dict() for obj in items]) + + # New behavior: keyset seek with cursors + key, backward = decode_cursor(args.get("cursor")) + + window = service.seek_window( + args, + key=key, + backward=backward, + include_total=_is_truthy(args.get("include_total", "1")), + ) + + desc_flags = list(window.order.desc) + body = { + "items": [obj.as_dict() for obj in window.items], + "limit": window.limit, + "next_cursor": encode_cursor(window.last_key, desc_flags, backward=False), + "prev_cursor": encode_cursor(window.first_key, desc_flags, backward=True), + "total": window.total, + } + + resp = jsonify(body) + # Optional Link header + links = [] + if body["next_cursor"]: + links.append(f'<{request.base_url}?cursor={body["next_cursor"]}&limit={window.limit}>; rel="next"') + if body["prev_cursor"]: + links.append(f'<{request.base_url}?cursor={body["prev_cursor"]}&limit={window.limit}>; rel="prev"') + if links: + resp.headers["Link"] = ", ".join(links) + return resp @bp.get('/') def get_item(id): diff --git a/crudkit/core/service.py b/crudkit/core/service.py index 6c0afd6..e7acf1d 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -1,11 +1,15 @@ -from typing import Any, Callable, Type, TypeVar, Generic, Optional, Protocol, runtime_checkable, cast -from sqlalchemy.orm import Load, Session, raiseload, with_polymorphic, Mapper +from typing import Any, Callable, Dict, Iterable, List, Tuple, Type, TypeVar, Generic, Optional, Protocol, runtime_checkable, cast +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 from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.orm.util import AliasedClass -from sqlalchemy.engine import Engine, Connection -from sqlalchemy import inspect, text +from sqlalchemy.sql import operators +from sqlalchemy.sql.elements import UnaryExpression + from crudkit.core.base import Version from crudkit.core.spec import CRUDSpec +from crudkit.core.types import OrderSpec, SeekWindow from crudkit.backend import BackendInfo, make_backend_info @runtime_checkable @@ -61,6 +65,243 @@ class CRUDService(Generic[T]): return self.session.query(poly), poly return self.session.query(self.model), self.model + def _resolve_required_includes(self, root_alias: Any, rel_field_names: Dict[Tuple[str, ...], List[str]]) -> List[Any]: + """ + For each dotted path like ("location"), -> ["label"], look up the target + model's __crudkit_field_requires__ for the terminal field and produce + selectinload options prefixed with the relationship path, e.g.: + Room.__crudkit_field_requires__['label'] = ['room_function'] + => selectinload(root.location).selectinload(Room.room_function) + """ + opts: List[Any] = [] + + root_mapper: Mapper[Any] = cast(Mapper[Any], inspect(self.model)) + + for path, names in (rel_field_names or {}).items(): + if not path: + continue + + current_alias = root_alias + current_mapper = root_mapper + rel_props: List[RelationshipProperty] = [] + + valid = True + for step in path: + rel = current_mapper.relationships.get(step) + if rel is None: + valid = False + break + rel_props.append(rel) + current_mapper = cast(Mapper[Any], inspect(rel.entity.entity)) + if not valid: + continue + + target_cls = current_mapper.class_ + + requires = getattr(target_cls, "__crudkit_field_requires__", None) + if not isinstance(requires, dict): + continue + + for field_name in names: + needed: Iterable[str] = requires.get(field_name, []) + for rel_need in needed: + loader = selectinload(getattr(root_alias, rel_props[0].key)) + for rp in rel_props[1:]: + loader = loader.selectinload(getattr(getattr(root_alias, rp.parent.class_.__name__.lower(), None) or rp.parent.class_, rp.key)) + + loader = loader.selectinload(getattr(target_cls, rel_need)) + opts.append(loader) + + return opts + + def _extract_order_spec(self, root_alias, given_order_by): + """ + SQLAlchemy 2.x only: + Normalize order_by into (cols, desc_flags). Supports plain columns and + col.asc()/col.desc() (UnaryExpression). Avoids boolean evaluation of clauses. + """ + from sqlalchemy.sql import operators + from sqlalchemy.sql.elements import UnaryExpression + + given = self._stable_order_by(root_alias, given_order_by) + + cols, desc_flags = [], [] + for ob in given: + # Unwrap column if this is a UnaryExpression produced by .asc()/.desc() + elem = getattr(ob, "element", None) + col = elem if elem is not None else ob # don't use "or" with SA expressions + + # Detect direction in SA 2.x + is_desc = False + dir_attr = getattr(ob, "_direction", None) + if dir_attr is not None: + is_desc = (dir_attr is operators.desc_op) or (getattr(dir_attr, "name", "").upper() == "DESC") + elif isinstance(ob, UnaryExpression): + op = getattr(ob, "operator", None) + is_desc = (op is operators.desc_op) or (getattr(op, "name", "").upper() == "DESC") + + cols.append(col) + desc_flags.append(bool(is_desc)) + + from crudkit.core.types import OrderSpec + return OrderSpec(cols=tuple(cols), desc=tuple(desc_flags)) + + def _key_predicate(self, spec: OrderSpec, key_vals: list[Any], backward: bool): + """ + Build lexicographic predicate for keyset seek. + For backward traversal, import comparisons. + """ + if not key_vals: + return None + conds = [] + for i, col in enumerate(spec.cols): + ties = [spec.cols[j] == key_vals[j] for j in range(i)] + is_desc = spec.desc[i] + if not backward: + op = col < key_vals[i] if is_desc else col > key_vals[i] + else: + op = col > key_vals[i] if is_desc else col < key_vals[i] + conds.append(and_(*ties, op)) + return or_(*conds) + + def _pluck_key(self, obj: Any, spec: OrderSpec) -> list[Any]: + out = [] + for c in spec.cols: + key = getattr(c, "key", None) or getattr(c, "name", None) + out.append(getattr(obj, key)) + return out + + def seek_window( + self, + params: dict | None = None, + *, + key: list[Any] | None = None, + backward: bool = False, + include_total: bool = True, + ) -> "SeekWindow[T]": + """ + Transport-agnostic keyset pagination that preserves all the goodies from `list()`: + - filters, includes, joins, field projection, eager loading, soft-delete + - deterministic ordering (user sort + PK tiebreakers) + - forward/backward seek via `key` and `backward` + Returns a SeekWindow with items, first/last keys, order spec, limit, and optional total. + """ + session = self.session + query, root_alias = self.get_query() + + spec = CRUDSpec(self.model, params or {}, root_alias) + + filters = spec.parse_filters() + order_by = spec.parse_sort() + + root_fields, rel_field_names, root_field_names = spec.parse_fields() + + for path, names in (rel_field_names or {}).items(): + if "label" in names: + rel_name = path[0] + rel_attr = getattr(root_alias, rel_name, None) + if rel_attr is not None: + query = query.options(selectinload(rel_attr)) + + # Soft delete filter + if self.supports_soft_delete and not _is_truthy(params.get("include_deleted")): + query = query.filter(getattr(root_alias, "is_deleted") == False) + + # Parse filters first + if filters: + query = query.filter(*filters) + + # Includes + joins (so relationship fields like brand.name, location.label work) + spec.parse_includes() + for parent_alias, relationship_attr, target_alias in spec.get_join_paths(): + rel_attr = cast(InstrumentedAttribute, relationship_attr) + target = cast(Any, target_alias) + query = query.join(target, rel_attr.of_type(target), isouter=True) + + # Fields/projection: load_only for root columns, eager loads for relationships + only_cols = [c for c in root_fields if isinstance(c, InstrumentedAttribute)] + if only_cols: + query = query.options(Load(root_alias).load_only(*only_cols)) + for eager in spec.get_eager_loads(root_alias, fields_map=rel_field_names): + query = query.options(eager) + + for opt in self._resolve_required_includes(root_alias, rel_field_names): + query = query.options(opt) + + # Order + limit + order_spec = self._extract_order_spec(root_alias, order_by) # SA2-only helper + limit, _ = spec.parse_pagination() + if not limit or limit <= 0: + limit = 50 # sensible default + + # Keyset predicate + if key: + pred = self._key_predicate(order_spec, key, backward) + if pred is not None: + query = query.filter(pred) + + # Apply ordering. For backward, invert SQL order then reverse in-memory for display. + if not backward: + clauses = [] + for col, is_desc in zip(order_spec.cols, order_spec.desc): + clauses.append(col.desc() if is_desc else col.asc()) + query = query.order_by(*clauses).limit(limit) + items = query.all() + else: + inv_clauses = [] + for col, is_desc in zip(order_spec.cols, order_spec.desc): + inv_clauses.append(col.asc() if is_desc else col.desc()) + query = query.order_by(*inv_clauses).limit(limit) + items = list(reversed(query.all())) + + # Tag projection so your renderer knows what fields were requested + proj = [] + if root_field_names: + proj.extend(root_field_names) + if root_fields: + proj.extend(c.key for c in root_fields) + for path, names in (rel_field_names or {}).items(): + prefix = ".".join(path) + for n in names: + proj.append(f"{prefix}.{n}") + if proj and "id" not in proj and hasattr(self.model, "id"): + proj.insert(0, "id") + if proj: + for obj in items: + try: + setattr(obj, "__crudkit_projection__", tuple(proj)) + except Exception: + pass + + # Boundary keys for cursor encoding in the API layer + first_key = self._pluck_key(items[0], order_spec) if items else None + last_key = self._pluck_key(items[-1], order_spec) if items else None + + # Optional total that’s safe under JOINs (COUNT DISTINCT ids) + total = None + if include_total: + base = self.session.query(getattr(root_alias, "id")) + if self.supports_soft_delete and not _is_truthy(params.get("include_deleted")): + base = base.filter(getattr(root_alias, "is_deleted") == False) + if filters: + base = base.filter(*filters) + # replicate the same joins used above + for parent_alias, relationship_attr, target_alias in spec.get_join_paths(): + rel_attr = cast(InstrumentedAttribute, relationship_attr) + target = cast(Any, target_alias) + base = base.join(target, rel_attr.of_type(target), isouter=True) + total = self.session.query(func.count()).select_from(base.order_by(None).distinct().subquery()).scalar() or 0 + + from crudkit.core.types import SeekWindow # avoid circulars at module top + return SeekWindow( + items=items, + limit=limit, + first_key=first_key, + last_key=last_key, + order=order_spec, + total=total, + ) + # Helper: default ORDER BY for MSSQL when paginating without explicit order def _default_order_by(self, root_alias): mapper: Mapper[Any] = cast(Mapper[Any], inspect(self.model)) @@ -72,6 +313,25 @@ class CRUDService(Generic[T]): cols.append(col) return cols or [text("1")] + def _stable_order_by(self, root_alias, given_order_by): + """ + Ensure deterministic ordering by appending PK columns as tiebreakers. + If no order is provided, fall back to default primary-key order. + """ + order_by = list(given_order_by or []) + if not order_by: + return self._default_order_by(root_alias) + + mapper: Mapper[Any] = cast(Mapper[Any], inspect(self.model)) + pk_cols = [] + for col in mapper.primary_key: + try: + pk_cols.append(getattr(root_alias, col.key)) + except ArithmeticError: + pk_cols.append(col) + + return [*order_by, *pk_cols] + def get(self, id: int, params=None) -> T | None: query, root_alias = self.get_query() diff --git a/crudkit/core/types.py b/crudkit/core/types.py new file mode 100644 index 0000000..aade874 --- /dev/null +++ b/crudkit/core/types.py @@ -0,0 +1,16 @@ +from dataclasses import dataclass +from typing import Any, Sequence + +@dataclass(frozen=True) +class OrderSpec: + cols: Sequence[Any] + desc: Sequence[bool] + +@dataclass +class SeekWindow: + items: list[Any] + limit: int + first_key: list[Any] | None + last_key: list[Any] | None + order: OrderSpec + total: int | None = None -- 2.51.2 From 93dc56b600f98c62b603582246638fc4f7190961 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Tue, 16 Sep 2025 16:02:09 -0500 Subject: [PATCH 34/36] Fixing render_form. Way more work to be done on it. --- crudkit/ui/fragments.py | 176 ++++++++++++++++++++++++++++++-- crudkit/ui/templates/form.html | 2 +- crudkit/ui/templates/table.html | 2 +- 3 files changed, 170 insertions(+), 10 deletions(-) diff --git a/crudkit/ui/fragments.py b/crudkit/ui/fragments.py index 5c10f9d..5bfcb3b 100644 --- a/crudkit/ui/fragments.py +++ b/crudkit/ui/fragments.py @@ -1,9 +1,10 @@ import os +import re from flask import current_app, url_for from jinja2 import Environment, FileSystemLoader, ChoiceLoader from sqlalchemy import inspect -from sqlalchemy.orm import class_mapper, RelationshipProperty +from sqlalchemy.orm import class_mapper, RelationshipProperty, load_only, selectinload from sqlalchemy.orm.attributes import NO_VALUE from typing import Any, Dict, List, Optional, Tuple @@ -16,6 +17,118 @@ def get_env(): loader=ChoiceLoader([app.jinja_loader, fallback_loader]) ) +class _SafeObj: + """Attribute access that returns '' for missing/None instead of exploding.""" + __slots__ = ("_obj",) + def __init__(self, obj): self._obj = obj + def __str__(self): return "" if self._obj is None else str(self._obj) + def __getattr__(self, name): + if self._obj is None: + return "" + val = getattr(self._obj, name, None) + if val is None: + return "" + return _SafeObj(val) + +def _extract_label_requirements(spec: Any) -> tuple[list[str], list[tuple[str, str]]]: + """ + From a label spec, return: + - simple_cols: ["name", "code"] + - rel_paths: [("room_function", "description"), ("owner", "last_name")] + """ + simple_cols: list[str] = [] + rel_paths: list[tuple[str, str]] = [] + + def ingest(token: str) -> None: + token = str(token).strip() + if not token: + return + if "." in token: + rel, col = token.split(".", 1) + if rel and col: + rel_paths.append((rel, col)) + else: + simple_cols.append(token) + + if spec is None or callable(spec): + return simple_cols, rel_paths + + if isinstance(spec, (list, tuple)): + for a in spec: + ingest(a) + return simple_cols, rel_paths + + if isinstance(spec, str): + # format string like "{first} {last}" or "{room_function.description} · {name}" + if "{" in spec and "}" in spec: + names = re.findall(r"{\s*([^}:\s]+)", spec) + for n in names: + ingest(n) + else: + ingest(spec) + return simple_cols, rel_paths + + return simple_cols, rel_paths + +def _attrs_from_label_spec(spec: Any) -> list[str]: + """ + Return a list of attribute names needed from the related model to compute the label. + Only simple attribute names are returned; dotted paths return just the first segment. + """ + if spec is None: + return [] + if callable(spec): + return [] + if isinstance(spec, (list, tuple)): + return [str(a).split(".", 1)[0] for a in spec] + if isinstance(spec, str): + if "{" in spec and "}" in spec: + names = re.findall(r"{\s*([^}:\s]+)", spec) + return [n.split(".", 1)[0] for n in names] + return [spec.split(".", 1)[0]] + return [] + +def _label_from_obj(obj: Any, spec: Any) -> str: + if spec is None: + return str(obj) + if callable(spec): + try: + return str(spec(obj)) + except Exception: + return str(obj) + + if isinstance(spec, (list, tuple)): + parts = [] + for a in spec: + cur = obj + for part in str(a).split("."): + cur = getattr(cur, part, None) + if cur is None: + break + parts.append("" if cur is None else str(cur)) + return " ".join(p for p in parts if p) + + if isinstance(spec, str) and "{" in spec and "}" in spec: + fields = re.findall(r"{\s*([^}:\s]+)", spec) + data: dict[str, Any] = {} + for f in fields: + root = f.split(".", 1)[0] + if root not in data: + val = getattr(obj, root, None) + data[root] = _SafeObj(val) + + try: + return spec.format(**data) + except Exception: + return str(obj) + + cur = obj + for part in str(spec).split("."): + cur = getattr(cur, part, None) + if cur is None: + return "" + return str(cur) + def _val_from_row_or_obj(row: Dict[str, Any], obj: Any, dotted: str) -> Any: """Best-effort deep get: try the projected row first, then the ORM object.""" val = _deep_get(row, dotted) @@ -252,33 +365,81 @@ def render_table(objects: List[Any], columns: Optional[List[Dict[str, Any]]] = N return template.render(columns=cols, rows=disp_rows, kwargs=flat_opts) -def render_form(model_cls, values, session=None): +def render_form(model_cls, values, session=None, *, label_specs: Optional[Dict[str, Any]] = None): env = get_env() template = get_crudkit_template(env, 'form.html') fields = [] fk_fields = set() + label_specs = label_specs or {} mapper = class_mapper(model_cls) for prop in mapper.iterate_properties: - # FK Relationship fields (many-to-one) if isinstance(prop, RelationshipProperty) and prop.direction.name == 'MANYTOONE': if session is None: continue related_model = prop.mapper.class_ - options = session.query(related_model).all() + rel_label_spec = ( + label_specs.get(prop.key) + or getattr(related_model, "__crud_label__", None) + or None + ) + + # Figure out what we must load + simple_cols, rel_paths = _extract_label_requirements(rel_label_spec) + + q = session.query(related_model) + + # id is always needed + col_attrs = [] + if hasattr(related_model, "id"): + col_attrs.append(getattr(related_model, "id")) + for name in simple_cols: + if hasattr(related_model, name): + col_attrs.append(getattr(related_model, name)) + if col_attrs: + q = q.options(load_only(*col_attrs)) + + # Load related bits minimally + for rel_name, col_name in rel_paths: + rel_prop = getattr(related_model, rel_name, None) + if rel_prop is None: + continue + # grab target class to resolve column attr + try: + target_cls = related_model.__mapper__.relationships[rel_name].mapper.class_ + col_attr = getattr(target_cls, col_name, None) + if col_attr is None: + q = q.options(selectinload(rel_prop)) + else: + q = q.options(selectinload(rel_prop).load_only(col_attr)) + except Exception: + # fallback if mapper lookup is weird + q = q.options(selectinload(rel_prop)) + + # Gentle ordering: use first simple col if any, else skip + if simple_cols: + first = simple_cols[0] + if hasattr(related_model, first): + q = q.order_by(getattr(related_model, first)) + + options = q.all() + fields.append({ 'name': f"{prop.key}_id", 'label': prop.key, 'type': 'select', 'options': [ - {'value': getattr(obj, 'id'), 'label': str(obj)} - for obj in options + { + 'value': getattr(opt, 'id'), + 'label': _label_from_obj(opt, rel_label_spec), + } + for opt in options ] }) fk_fields.add(f"{prop.key}_id") - # Now add basic columns — excluding FKs already covered + # Base columns for col in model_cls.__table__.columns: if col.name in fk_fields: continue @@ -293,4 +454,3 @@ def render_form(model_cls, values, session=None): }) return template.render(fields=fields, values=values, render_field=render_field) - diff --git a/crudkit/ui/templates/form.html b/crudkit/ui/templates/form.html index 6109e25..7ab1d50 100644 --- a/crudkit/ui/templates/form.html +++ b/crudkit/ui/templates/form.html @@ -1,6 +1,6 @@
    {% for field in fields %} - {{ render_field(field, values.get(field.name, '')) }} + {{ render_field(field, values.get(field.name, '')) | safe }} {% endfor %}
    diff --git a/crudkit/ui/templates/table.html b/crudkit/ui/templates/table.html index 06379cf..d5f302f 100644 --- a/crudkit/ui/templates/table.html +++ b/crudkit/ui/templates/table.html @@ -9,7 +9,7 @@ {% if rows %} {% for row in rows %} - + {% for cell in row.cells %} {% if cell.href %} {{ cell.text if cell.text is not none else '-' }} -- 2.51.2 From fd2284bc27a99223099f54e5b264e3a12b4bcdc2 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Wed, 17 Sep 2025 16:19:20 -0500 Subject: [PATCH 35/36] Downstream fixes. --- crudkit/ui/fragments.py | 364 ++++++++++++++++++++++++-------- crudkit/ui/templates/field.html | 27 ++- 2 files changed, 305 insertions(+), 86 deletions(-) diff --git a/crudkit/ui/fragments.py b/crudkit/ui/fragments.py index 5bfcb3b..4621df2 100644 --- a/crudkit/ui/fragments.py +++ b/crudkit/ui/fragments.py @@ -5,9 +5,17 @@ from flask import current_app, url_for from jinja2 import Environment, FileSystemLoader, ChoiceLoader from sqlalchemy import inspect from sqlalchemy.orm import class_mapper, RelationshipProperty, load_only, selectinload -from sqlalchemy.orm.attributes import NO_VALUE +from sqlalchemy.orm.base import NO_VALUE from typing import Any, Dict, List, Optional, Tuple +_ALLOWED_ATTRS = { + "class", "placeholder", "autocomplete", "inputmode", "pattern", + "min", "max", "step", "maxlength", "minlength", + "required", "readonly", "disabled", + "multiple", "size", + "id", "name", "value", +} + def get_env(): app = current_app default_path = os.path.join(os.path.dirname(__file__), 'templates') @@ -17,6 +25,29 @@ def get_env(): loader=ChoiceLoader([app.jinja_loader, fallback_loader]) ) +def _sanitize_attrs(attrs: Any) -> dict[str, Any]: + """ + Whitelist attributes; allow data-* and aria-*; render True as boolean attr. + Drop False/None and anything not whitelisted. + """ + if not isinstance(attrs, dict): + return {} + out: dict[str, Any] = {} + for k, v in attrs.items(): + if not isinstance(k, str): + continue + elif isinstance(v, str): + if len(v) > 512: + v = v[:512] + if k.startswith("data-") or k.startswith("aria-") or k in _ALLOWED_ATTRS: + if isinstance(v, bool): + if v: + out[k] = True + elif v is not None: + out[k] = str(v) + + return out + class _SafeObj: """Attribute access that returns '' for missing/None instead of exploding.""" __slots__ = ("_obj",) @@ -30,6 +61,153 @@ class _SafeObj: return "" return _SafeObj(val) +def _coerce_fk_value(values: dict | None, instance: Any, base: str): + """ + Resolve the current selection for relationship 'base': + 1) values['_id'] + 2) values['']['id'] or values[''] if scalar + 3) instance. (relationship) if it's already loaded -> use its .id + 4) instance._id if it's already loaded (column) and instance is bound + Never trigger a lazy load. Never touch the DB. + """ + # 1) explicit *_id from values + if isinstance(values, dict): + key = f"{base}_id" + if key in values: + return values.get(key) + rel = values.get(base) + if isinstance(rel, dict): + return rel.get("id") or rel.get(key) + if isinstance(rel, (int, str)): + return rel + + # 3) use loaded relationship object (safe for detached instances) + if instance is not None: + try: + state = inspect(instance) + # relationship attr present? + rel_attr = state.attrs.get(base) + if rel_attr is not None and rel_attr.loaded_value is not NO_VALUE: + rel_obj = rel_attr.loaded_value + if rel_obj is not None: + rid = getattr(rel_obj, "id", None) + if rid is not None: + return rid + # 4) use loaded fk column if the value is present and NOT expired + id_attr = state.attrs.get(f"{base}_id") + if id_attr is not None and id_attr.loaded_value is not NO_VALUE: + return id_attr.loaded_value + except Exception: + pass + + return None + +def _is_many_to_one(mapper, name: str) -> Optional[RelationshipProperty]: + try: + prop = mapper.relationships[name] + except Exception: + return None + if isinstance(prop, RelationshipProperty) and prop.direction.name == 'MANYTOONE': + return prop + return None + +def _rel_for_id_name(mapper, name: str) -> tuple[Optional[str], Optional[RelationshipProperty]]: + if name.endswith("_id"): + base = name[":-3"] + prop = _is_many_to_one(mapper, base) + return (base, prop) if prop else (None, None) + else: + prop = _is_many_to_one(mapper, name) + return (name, prop) if prop else (None, None) + +def _fk_options(session, related_model, label_spec): + simple_cols, rel_paths = _extract_label_requirements(label_spec) + q = session.query(related_model) + + col_attrs = [] + if hasattr(related_model, "id"): + col_attrs.append(getattr(related_model, "id")) + for name in simple_cols: + if hasattr(related_model, name): + col_attrs.append(getattr(related_model, name)) + if col_attrs: + q = q.options(load_only(*col_attrs)) + + for rel_name, col_name in rel_paths: + rel_prop = getattr(related_model, rel_name, None) + if rel_prop is None: + continue + try: + target_cls = related_model.__mapper__.relationships[rel_name].mapper.class_ + col_attr = getattr(target_cls, col_name, None) + if col_attr is None: + q = q.options(selectinload(rel_prop)) + else: + q = q.options(selectinload(rel_prop).load_only(col_attr)) + except Exception: + q = q.options(selectinload(rel_prop)) + + if simple_cols: + first = simple_cols[0] + if hasattr(related_model, first): + q = q.order_by(getattr(related_model, first)) + + rows = q.all() + return [ + { + 'value': getattr(opt, 'id'), + 'label': _label_from_obj(opt, label_spec), + } + for opt in rows + ] + +def _normalize_field_spec(spec, mapper, session, label_specs_model_default): + """ + Turn a user field spec into a concrete field dict the template understands. + """ + name = spec['name'] + base_rel_name, rel_prop = _rel_for_id_name(mapper, name) + + field = { + "name": name if not base_rel_name else f"{base_rel_name}_id", + "label": spec.get("label", name), + "type": spec.get("type"), + "options": spec.get("options"), + "attrs": spec.get("attrs"), + "help": spec.get("help"), + } + + if rel_prop: + if field["type"] is None: + field["type"] = "select" + if field["type"] == "select" and field.get("options") is None and session is not None: + related_model = rel_prop.mapper.class_ + label_spec = ( + spec.get("label_spec") + or label_specs_model_default.get(base_rel_name) + or getattr(related_model, "__crud_label__", None) + or "id" + ) + field["options"] = _fk_options(session, related_model, label_spec) + return field + + col = mapper.columns.get(name) + if field["type"] is None: + if col is not None and hasattr(col.type, "python_type"): + py = None + try: + py = col.type.python_type + except Exception: + pass + if py is bool: + field["type"] = "checkbox" + else: + field["type"] = "text" + else: + field["type"] = "text" + + return field + def _extract_label_requirements(spec: Any) -> tuple[list[str], list[tuple[str, str]]]: """ From a label spec, return: @@ -90,12 +268,14 @@ def _attrs_from_label_spec(spec: Any) -> list[str]: def _label_from_obj(obj: Any, spec: Any) -> str: if spec is None: - return str(obj) - if callable(spec): - try: - return str(spec(obj)) - except Exception: - return str(obj) + for attr in ("label", "name", "title", "description"): + if hasattr(obj, attr): + val = getattr(obj, attr) + if not callable(val) and val is not None: + return str(val) + if hasattr(obj, "id"): + return str(getattr(obj, "id")) + return object.__repr__(obj) if isinstance(spec, (list, tuple)): parts = [] @@ -329,7 +509,9 @@ def render_field(field, value): field_label=field.get('label', field['name']), value=value, field_type=field.get('type', 'text'), - options=field.get('options', None) + options=field.get('options', None), + attrs=_sanitize_attrs(field.get('attrs') or {}), + help=field.get('help') ) def render_table(objects: List[Any], columns: Optional[List[Dict[str, Any]]] = None, **opts): @@ -365,92 +547,108 @@ def render_table(objects: List[Any], columns: Optional[List[Dict[str, Any]]] = N return template.render(columns=cols, rows=disp_rows, kwargs=flat_opts) -def render_form(model_cls, values, session=None, *, label_specs: Optional[Dict[str, Any]] = None): +def render_form( + model_cls, + values, + session=None, + *, + fields_spec: Optional[list[dict]] = None, + label_specs: Optional[Dict[str, Any]] = None, + exclude: Optional[set[str]] = None, + overrides: Optional[Dict[str, Dict[str, Any]]] = None, + instance: Any = None, # NEW: pass the ORM object so we can read *_id +): + """ + fields_spec: list of dicts describing fields in order. Each dict supports: + - name: "first_name" | "location" | "location_id" (required) + - label: override_label + - type: "text" | "textarea" | "checkbox" | "select" | "hidden" | ... + - label_spec: for relationship selects, e.g. "{name} - {room_function.description}" + - options: prebuilt list of {"value","label"}; skips querying if provided + - attrs: dict of arbitrary HTML attributes, e.g. {"required": True, "placeholder": "Jane"} + - help: small help text under the field + label_specs: legacy per-relationship label spec fallback ({"location": "..."}). + exclude: set of field names to hide. + overrides: legacy quick overrides keyed by field name (label/type/etc.) + instance: the ORM object backing the form; used to populate *_id values + """ env = get_env() - template = get_crudkit_template(env, 'form.html') - fields = [] - fk_fields = set() + template = get_crudkit_template(env, "form.html") + exclude = exclude or set() + overrides = overrides or {} label_specs = label_specs or {} mapper = class_mapper(model_cls) - for prop in mapper.iterate_properties: - if isinstance(prop, RelationshipProperty) and prop.direction.name == 'MANYTOONE': - if session is None: + fields: list[dict] = [] + values_map = dict(values or {}) # we'll augment this with *_id selections + + if fields_spec: + # Spec-driven path + for spec in fields_spec: + if spec["name"] in exclude: continue - - related_model = prop.mapper.class_ - rel_label_spec = ( - label_specs.get(prop.key) - or getattr(related_model, "__crud_label__", None) - or None + field = _normalize_field_spec( + {**spec, **overrides.get(spec["name"], {})}, + mapper, session, label_specs ) + fields.append(field) - # Figure out what we must load - simple_cols, rel_paths = _extract_label_requirements(rel_label_spec) + # After building fields, inject current values for any M2O selects + for f in fields: + name = f.get("name") + if isinstance(name, str) and name.endswith("_id"): + base = name[:-3] + rel_prop = mapper.relationships.get(base) + if isinstance(rel_prop, RelationshipProperty) and rel_prop.direction.name == "MANYTOONE": + values_map[name] = _coerce_fk_value(values, instance, base) - q = session.query(related_model) + else: + # Auto-generate path (your original behavior) + fk_fields = set() - # id is always needed - col_attrs = [] - if hasattr(related_model, "id"): - col_attrs.append(getattr(related_model, "id")) - for name in simple_cols: - if hasattr(related_model, name): - col_attrs.append(getattr(related_model, name)) - if col_attrs: - q = q.options(load_only(*col_attrs)) - - # Load related bits minimally - for rel_name, col_name in rel_paths: - rel_prop = getattr(related_model, rel_name, None) - if rel_prop is None: + # Relationships first + for prop in mapper.iterate_properties: + if isinstance(prop, RelationshipProperty) and prop.direction.name == 'MANYTOONE': + base = prop.key + if base in exclude or f"{base}_id" in exclude: + continue + if session is None: continue - # grab target class to resolve column attr - try: - target_cls = related_model.__mapper__.relationships[rel_name].mapper.class_ - col_attr = getattr(target_cls, col_name, None) - if col_attr is None: - q = q.options(selectinload(rel_prop)) - else: - q = q.options(selectinload(rel_prop).load_only(col_attr)) - except Exception: - # fallback if mapper lookup is weird - q = q.options(selectinload(rel_prop)) - # Gentle ordering: use first simple col if any, else skip - if simple_cols: - first = simple_cols[0] - if hasattr(related_model, first): - q = q.order_by(getattr(related_model, first)) + related_model = prop.mapper.class_ + rel_label_spec = ( + label_specs.get(base) + or getattr(related_model, "__crud_label__", None) + or "id" + ) + options = _fk_options(session, related_model, rel_label_spec) + base_field = { + "name": f"{base}_id", + "label": base, + "type": "select", + "options": options, + } + field = {**base_field, **overrides.get(f"{base}_id", {})} + fields.append(field) + fk_fields.add(f"{base}_id") - options = q.all() + # NEW: set the current selection for this dropdown + values_map[f"{base}_id"] = _coerce_fk_value(values, instance, base) - fields.append({ - 'name': f"{prop.key}_id", - 'label': prop.key, - 'type': 'select', - 'options': [ - { - 'value': getattr(opt, 'id'), - 'label': _label_from_obj(opt, rel_label_spec), - } - for opt in options - ] - }) - fk_fields.add(f"{prop.key}_id") + # Then plain columns + for col in model_cls.__table__.columns: + if col.name in fk_fields or col.name in exclude: + continue + if col.name in ('id', 'created_at', 'updated_at'): + continue + if col.default or col.server_default or col.onupdate: + continue + base_field = { + "name": col.name, + "label": col.name, + "type": "checkbox" if getattr(col.type, "python_type", None) is bool else "text", + } + field = {**base_field, **overrides.get(col.name, {})} + fields.append(field) - # Base columns - for col in model_cls.__table__.columns: - if col.name in fk_fields: - continue - if col.name in ('id', 'created_at', 'updated_at'): - continue - if col.default or col.server_default or col.onupdate: - continue - fields.append({ - 'name': col.name, - 'label': col.name, - 'type': 'text', - }) - - return template.render(fields=fields, values=values, render_field=render_field) + return template.render(fields=fields, values=values_map, render_field=render_field) diff --git a/crudkit/ui/templates/field.html b/crudkit/ui/templates/field.html index 28fcf7e..c60242a 100644 --- a/crudkit/ui/templates/field.html +++ b/crudkit/ui/templates/field.html @@ -1,16 +1,37 @@ {% if field_type == 'select' %} - {% if options %} {% for opt in options %} - + {% endfor %} {% else %} {% endif %} + +{% elif field_type == 'textarea' %} + + +{% elif field_type == 'checkbox' %} + + {% else %} - + +{% endif %} + +{% if help %} +
    {{ help }}
    {% endif %} -- 2.51.2 From 4cb6a69816bbe50b45c81fdaa923ebf89d88a633 Mon Sep 17 00:00:00 2001 From: Conrad Nelson Date: Thu, 18 Sep 2025 16:20:35 -0500 Subject: [PATCH 36/36] Additional form control and presentation logic. --- crudkit/core/service.py | 46 ++++++- crudkit/ui/fragments.py | 223 +++++++++++++++++++++++++++++--- crudkit/ui/templates/field.html | 72 +++++++---- crudkit/ui/templates/form.html | 40 +++++- 4 files changed, 333 insertions(+), 48 deletions(-) diff --git a/crudkit/core/service.py b/crudkit/core/service.py index e7acf1d..1220e04 100644 --- a/crudkit/core/service.py +++ b/crudkit/core/service.py @@ -1,7 +1,7 @@ from typing import Any, Callable, Dict, Iterable, List, Tuple, Type, TypeVar, Generic, Optional, Protocol, runtime_checkable, cast 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 +from sqlalchemy.orm import Load, Session, raiseload, selectinload, with_polymorphic, Mapper, RelationshipProperty, class_mapper from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.orm.util import AliasedClass from sqlalchemy.sql import operators @@ -12,6 +12,35 @@ from crudkit.core.spec import CRUDSpec from crudkit.core.types import OrderSpec, SeekWindow from crudkit.backend import BackendInfo, make_backend_info +def _loader_options_for_fields(root_alias, model_cls, fields: list[str]) -> list[Load]: + """ + For bare MANYTOONE names in fields (e.g. "location"), selectinload the relationship + and only fetch the related PK. This is enough for preselecting - {% if options %} - - {% for opt in options %} - - {% endfor %} - {% else %} - - {% endif %} - + {% elif field_type == 'textarea' %} - + {% elif field_type == 'checkbox' %} - + + +{% elif field_type == 'hidden' %} + + +{% elif field_type == 'display' %} +
    {{ value }}
    {% else %} - + {% endif %} {% if help %} -
    {{ help }}
    +
    {{ help }}
    {% endif %} diff --git a/crudkit/ui/templates/form.html b/crudkit/ui/templates/form.html index 7ab1d50..b073fc3 100644 --- a/crudkit/ui/templates/form.html +++ b/crudkit/ui/templates/form.html @@ -1,6 +1,40 @@
    - {% for field in fields %} - {{ render_field(field, values.get(field.name, '')) | safe }} + {% macro render_row(row) %} + + {% if row.fields or row.children or row.legend %} + {% if row.legend %}{{ row.legend }}{% endif %} +
    + {% for field in row.fields %} +
    + {{ render_field(field, values.get(field.name, '')) | safe }} +
    + {% endfor %} + {% for child in row.children %} + {{ render_row(child) }} + {% endfor %} +
    + {% endif %} + {% endmacro %} + + {% if rows %} + {% for row in rows %} + {{ render_row(row) }} {% endfor %} - + {% else %} + {% for field in fields %} + {{ render_field(field, values.get(field.name, '')) | safe }} + {% endfor %} + {% endif %} + +
    -- 2.51.2