From a3e676a0b0daf442f36787277fabf59269f1f4a8 Mon Sep 17 00:00:00 2001 From: Yaro Kasear Date: Tue, 26 Aug 2025 09:01:15 -0500 Subject: [PATCH] First commit. --- .gitignore | 12 +++++++ crudkit/__init__.py | 8 +++++ crudkit/blueprint.py | 81 +++++++++++++++++++++++++++++++++++++++++++ crudkit/dsl.py | 49 ++++++++++++++++++++++++++ crudkit/eager.py | 22 ++++++++++++ crudkit/mixins.py | 23 ++++++++++++ crudkit/serialize.py | 22 ++++++++++++ crudkit/service.py | 52 +++++++++++++++++++++++++++ example_app/app.py | 22 ++++++++++++ example_app/models.py | 18 ++++++++++ example_app/seed.py | 19 ++++++++++ pyproject.toml | 18 ++++++++++ readme.md | 0 13 files changed, 346 insertions(+) create mode 100644 .gitignore create mode 100644 crudkit/__init__.py create mode 100644 crudkit/blueprint.py create mode 100644 crudkit/dsl.py create mode 100644 crudkit/eager.py create mode 100644 crudkit/mixins.py create mode 100644 crudkit/serialize.py create mode 100644 crudkit/service.py create mode 100644 example_app/app.py create mode 100644 example_app/models.py create mode 100644 example_app/seed.py create mode 100644 pyproject.toml create mode 100644 readme.md 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/crudkit/__init__.py b/crudkit/__init__.py new file mode 100644 index 0000000..cf333de --- /dev/null +++ b/crudkit/__init__.py @@ -0,0 +1,8 @@ +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/blueprint.py b/crudkit/blueprint.py new file mode 100644 index 0000000..cae482e --- /dev/null +++ b/crudkit/blueprint.py @@ -0,0 +1,81 @@ +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/dsl.py b/crudkit/dsl.py new file mode 100644 index 0000000..552f850 --- /dev/null +++ b/crudkit/dsl.py @@ -0,0 +1,49 @@ +from dataclasses import dataclass, field +from typing import List, Dict, Any, Optional +from sqlalchemy import asc, desc, select, func +from sqlalchemy.orm import selectinload, joinedload, Load + +@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 build_query(Model, spec: QuerySpec, eager_policy=None): + stmt = select(Model).where(Model.deleted.is_(False)) + # filters + for raw_key, val in spec.filters.items(): + for op in FILTER_OPS: + if raw_key.endswith(op): + colname = raw_key[: -len(op)] + col = getattr(Model, colname) + stmt = stmt.where(FILTER_OPS[op](col, val)) + break + else: + stmt = stmt.where(getattr(Model, raw_key) == val) + # order_by + for key in spec.order_by: + desc_ = key.startswith("-") + col = getattr(Model, key[1:] if desc_ else key) + stmt = stmt.order_by(desc(col) if desc_ else asc(col)) + # 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 new file mode 100644 index 0000000..7a6ee8b --- /dev/null +++ b/crudkit/eager.py @@ -0,0 +1,22 @@ +from typing import List +from sqlalchemy.orm import RelationshipProperty, selectinload, joinedload, Load, class_mapper + +def default_eager_policy(Model, expand: List[str]) -> List[Load]: + """ + Heuristic: + - many-to-one or one-to-one: joinedload + - one-to-many or many-to-many: selectinload + Only for fields explicitly requested in ?expand= + """ + opts = [] + mapper = class_mapper(Model) + rels = {r.key: r for r in mapper.relationships} + for name in expand: + rel: RelationshipProperty = rels.get(name) + if not rel: + continue + if rel.uselist: + opts.append(selectinload(name)) + else: + opts.append(joinedload(name)) + return opts diff --git a/crudkit/mixins.py b/crudkit/mixins.py new file mode 100644 index 0000000..38a0e71 --- /dev/null +++ b/crudkit/mixins.py @@ -0,0 +1,23 @@ +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 new file mode 100644 index 0000000..3ba6116 --- /dev/null +++ b/crudkit/serialize.py @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..1950d06 --- /dev/null +++ b/crudkit/service.py @@ -0,0 +1,52 @@ +from sqlalchemy import func +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from .dsl import QuerySpec, build_query +from .eager import default_eager_policy + +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) + count_stmt = stmt.with_only_columns(func.count()).order_by(None) + total = self.s.execute(count_stmt).scalar_one() + if spec.page and spec.per_page: + stmt = stmt.limit(spec.per_page).offset((spec.page - 1) * spec.per_page) + 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/example_app/app.py b/example_app/app.py new file mode 100644 index 0000000..c617d9a --- /dev/null +++ b/example_app/app.py @@ -0,0 +1,22 @@ +from flask import Flask +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from .models import Base, Author, Book +from crudkit import make_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_blueprint(session_factory, registry), url_prefix="/api") + return app + +if __name__ == "__main__": + create_app().run(debug=True) diff --git a/example_app/models.py b/example_app/models.py new file mode 100644 index 0000000..68ce56a --- /dev/null +++ b/example_app/models.py @@ -0,0 +1,18 @@ +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 new file mode 100644 index 0000000..6422861 --- /dev/null +++ b/example_app/seed.py @@ -0,0 +1,19 @@ +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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..38cfc6d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "crudkit" +version = "0.1.0" +description = "A Flask API for better SQLAlchemy usage." +requires-python = ">=3.9" +dependencies = [ + "flask", + "flask_sqlalchemy", + "python-dotenv", + "Werkzeug" +] + +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = ["crudkit"] diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..e69de29