Adding registration,

This commit is contained in:
Yaro Kasear 2025-09-15 08:28:47 -05:00
parent 637e873ccf
commit 2274b7686e
5 changed files with 203 additions and 22 deletions

View file

@ -6,26 +6,41 @@ def generate_crud_blueprint(model, service):
@bp.get('/') @bp.get('/')
def list_items(): def list_items():
items = service.list(request.args) items = service.list(request.args)
try:
return jsonify([item.as_dict() for item in items]) return jsonify([item.as_dict() for item in items])
except Exception as e:
return jsonify({"status": "error", "error": str(e)})
@bp.get('/<int:id>') @bp.get('/<int:id>')
def get_item(id): def get_item(id):
item = service.get(id, request.args) item = service.get(id, request.args)
try:
return jsonify(item.as_dict()) return jsonify(item.as_dict())
except Exception as e:
return jsonify({"status": "error", "error": str(e)})
@bp.post('/') @bp.post('/')
def create_item(): def create_item():
obj = service.create(request.json) obj = service.create(request.json)
try:
return jsonify(obj.as_dict()) return jsonify(obj.as_dict())
except Exception as e:
return jsonify({"status": "error", "error": str(e)})
@bp.patch('/<int:id>') @bp.patch('/<int:id>')
def update_item(id): def update_item(id):
obj = service.update(id, request.json) obj = service.update(id, request.json)
try:
return jsonify(obj.as_dict()) return jsonify(obj.as_dict())
except Exception as e:
return jsonify({"status": "error", "error": str(e)})
@bp.delete('/<int:id>') @bp.delete('/<int:id>')
def delete_item(id): def delete_item(id):
service.delete(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 return bp

View file

@ -1,12 +1,34 @@
from typing import Type, TypeVar, Generic, Optional from typing import Any, Type, TypeVar, Generic, Optional, Protocol, runtime_checkable, cast
from sqlalchemy.orm import Load, Session, raiseload, with_polymorphic from sqlalchemy.orm import Load, Session, raiseload, with_polymorphic, Mapper
from sqlalchemy.orm.attributes import InstrumentedAttribute 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 import inspect, text
from crudkit.core.base import Version from crudkit.core.base import Version
from crudkit.core.spec import CRUDSpec from crudkit.core.spec import CRUDSpec
from crudkit.backend import BackendInfo, make_backend_info 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): def _is_truthy(val):
return str(val).lower() in ('1', 'true', 'yes', 'on') return str(val).lower() in ('1', 'true', 'yes', 'on')
@ -25,7 +47,9 @@ class CRUDService(Generic[T]):
self.polymorphic = polymorphic self.polymorphic = polymorphic
self.supports_soft_delete = hasattr(model, 'is_deleted') self.supports_soft_delete = hasattr(model, 'is_deleted')
# Cache backend info once. If not provided, derive from session bind. # 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): def get_query(self):
if self.polymorphic: if self.polymorphic:
@ -35,7 +59,7 @@ class CRUDService(Generic[T]):
# Helper: default ORDER BY for MSSQL when paginating without explicit order # Helper: default ORDER BY for MSSQL when paginating without explicit order
def _default_order_by(self, root_alias): def _default_order_by(self, root_alias):
mapper = inspect(self.model) mapper: Mapper[Any] = cast(Mapper[Any], inspect(self.model))
cols = [] cols = []
for col in mapper.primary_key: for col in mapper.primary_key:
try: try:
@ -64,11 +88,9 @@ class CRUDService(Generic[T]):
spec.parse_includes() spec.parse_includes()
for parent_alias, relationship_attr, target_alias in spec.get_join_paths(): for parent_alias, relationship_attr, target_alias in spec.get_join_paths():
query = query.join( rel_attr = cast(InstrumentedAttribute, relationship_attr)
target_alias, target = cast(Any, target_alias)
relationship_attr.of_type(target_alias), query = query.join(target, rel_attr.of_type(target), isouter=True)
isouter=True
)
if params: if params:
root_fields, rel_field_names, root_field_names = spec.parse_fields() root_fields, rel_field_names, root_field_names = spec.parse_fields()
@ -123,11 +145,9 @@ class CRUDService(Generic[T]):
spec.parse_includes() spec.parse_includes()
for parent_alias, relationship_attr, target_alias in spec.get_join_paths(): for parent_alias, relationship_attr, target_alias in spec.get_join_paths():
query = query.join( rel_attr = cast(InstrumentedAttribute, relationship_attr)
target_alias, target = cast(Any, target_alias)
relationship_attr.of_type(target_alias), query = query.join(target, rel_attr.of_type(target), isouter=True)
isouter=True
)
if params: if params:
root_fields, rel_field_names, root_field_names = spec.parse_fields() 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: if hard or not self.supports_soft_delete:
self.session.delete(obj) self.session.delete(obj)
else: else:
obj.is_deleted = True soft = cast(_SoftDeletable, obj)
soft.is_deleted = True
self.session.commit() self.session.commit()
self._log_version("delete", obj, actor) self._log_version("delete", obj, actor)
return obj return obj

25
crudkit/integration.py Normal file
View file

@ -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)

120
crudkit/registry.py Normal file
View file

@ -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/<modelname> 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

View file

@ -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}") print(f"[render_table] url_for failed: endpoint={spec}: params={params}")
return None return None
try: try:
return url_for(spec["endpoint"], **params) return url_for('crudkit.' + spec["endpoint"], **params)
except Exception as e: except Exception as e:
print(f"[render_table] url_for failed: endpoint={spec['endpoint']} params={params} err={e}") print(f"[render_table] url_for failed: endpoint={spec['endpoint']} params={params} err={e}")
return None return None