First commit.
This commit is contained in:
commit
a3e676a0b0
13 changed files with 346 additions and 0 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
**/__pycache__/
|
||||
inventory/static/uploads/*
|
||||
!inventory/static/uploads/.gitkeep
|
||||
.venv/
|
||||
.env
|
||||
*.db
|
||||
*.db-journal
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
alembic.ini
|
||||
alembic/
|
||||
*.egg-info/
|
||||
8
crudkit/__init__.py
Normal file
8
crudkit/__init__.py
Normal file
|
|
@ -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"]
|
||||
81
crudkit/blueprint.py
Normal file
81
crudkit/blueprint.py
Normal file
|
|
@ -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("/<model>/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("/<model>")
|
||||
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("/<model>/<int:id>")
|
||||
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("/<model>/<int:id>")
|
||||
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("/<model>/<int:id>")
|
||||
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("/<model>/<int:id>/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
|
||||
49
crudkit/dsl.py
Normal file
49
crudkit/dsl.py
Normal file
|
|
@ -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
|
||||
22
crudkit/eager.py
Normal file
22
crudkit/eager.py
Normal file
|
|
@ -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
|
||||
23
crudkit/mixins.py
Normal file
23
crudkit/mixins.py
Normal file
|
|
@ -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}
|
||||
22
crudkit/serialize.py
Normal file
22
crudkit/serialize.py
Normal file
|
|
@ -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
|
||||
52
crudkit/service.py
Normal file
52
crudkit/service.py
Normal file
|
|
@ -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
|
||||
22
example_app/app.py
Normal file
22
example_app/app.py
Normal file
|
|
@ -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)
|
||||
18
example_app/models.py
Normal file
18
example_app/models.py
Normal file
|
|
@ -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")
|
||||
19
example_app/seed.py
Normal file
19
example_app/seed.py
Normal file
|
|
@ -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()
|
||||
18
pyproject.toml
Normal file
18
pyproject.toml
Normal file
|
|
@ -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"]
|
||||
0
readme.md
Normal file
0
readme.md
Normal file
Loading…
Add table
Add a link
Reference in a new issue