Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions faststack_core/base/entity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Base entity classes for SQLAlchemy 2.0 declarative models.

Provides an abstract entity hierarchy with UUID primary keys, audit fields,
and soft-delete support. All classes are abstract — concrete models inherit
from the appropriate level.

Hierarchy:
Base (DeclarativeBase)
└── Entity — UUID primary key
├── AuditedEntity — created_at/by, updated_at/by
├── SoftDeleteEntity — is_deleted, deleted_at/by
└── FullAuditedEntity(AuditedEntity, SoftDeleteEntity) — all fields
"""

import uuid
from datetime import UTC, datetime

from sqlalchemy import Boolean, DateTime, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
"""SQLAlchemy 2.0 declarative base for all FastStack models."""

pass


class Entity(Base):
"""Abstract entity with a UUID primary key.

Every persistent domain object inherits from this class (directly or
through one of the audited variants).
"""

__abstract__ = True

id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)


class AuditedEntity(Entity):
"""Abstract entity that tracks creation and last-update metadata.

Fields:
created_at: UTC timestamp set automatically on insert.
updated_at: UTC timestamp set automatically on insert and update.
created_by: Optional identifier of the user who created the record.
updated_by: Optional identifier of the user who last updated the record.
"""

__abstract__ = True

created_at: Mapped[datetime] = mapped_column(DateTime, default=lambda: datetime.now(UTC))
updated_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)
)
created_by: Mapped[str | None] = mapped_column(String(255), default=None)
updated_by: Mapped[str | None] = mapped_column(String(255), default=None)


class SoftDeleteEntity(Entity):
"""Abstract entity that supports soft deletion.

Instead of removing rows, soft-deleted records are flagged so they can
be excluded from normal queries while remaining recoverable.

Fields:
is_deleted: Whether the record has been soft-deleted.
deleted_at: UTC timestamp of when the record was soft-deleted.
deleted_by: Optional identifier of the user who deleted the record.
"""

__abstract__ = True

is_deleted: Mapped[bool] = mapped_column(Boolean, default=False)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, default=None)
deleted_by: Mapped[str | None] = mapped_column(String(255), default=None)


class FullAuditedEntity(AuditedEntity, SoftDeleteEntity):
"""Abstract entity combining audit tracking and soft-delete support.

Diamond inheritance is resolved cleanly because every intermediate class
sets ``__abstract__ = True``, so SQLAlchemy never tries to map more than
one table for the shared ``Entity`` ancestor.

Includes all fields from both :class:`AuditedEntity` and
:class:`SoftDeleteEntity`.
"""

__abstract__ = True
65 changes: 65 additions & 0 deletions faststack_core/base/permissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Permission dependency factories for FastAPI routes.

These are v1 stubs that inspect ``request.state.user`` (populated by the
application's own auth middleware) and raise
:class:`~faststack_core.exceptions.domain.InsufficientPermissionsError`
when the required permission or role is missing.
"""

from collections.abc import Callable

from fastapi import Request

from faststack_core.exceptions.domain import InsufficientPermissionsError


def require_permission(permission: str) -> Callable:
"""Return a FastAPI dependency that checks *permission* on the current user.

The dependency reads ``request.state.user`` and expects it to expose a
``permissions`` attribute (any iterable supporting ``in``).

Usage::

@router.get(
"/admin",
dependencies=[Depends(require_permission("admin:read"))],
)
async def admin_endpoint(): ...
"""

async def dependency(request: Request) -> None:
user = getattr(request.state, "user", None)
if user is None:
raise InsufficientPermissionsError("Authentication required")
user_permissions = getattr(user, "permissions", [])
if permission not in user_permissions:
raise InsufficientPermissionsError(f"Missing permission: {permission}")

return dependency


def require_role(role: str) -> Callable:
"""Return a FastAPI dependency that checks *role* on the current user.

The dependency reads ``request.state.user`` and expects it to expose a
``roles`` attribute (any iterable supporting ``in``).

Usage::

@router.get(
"/admin",
dependencies=[Depends(require_role("admin"))],
)
async def admin_endpoint(): ...
"""

async def dependency(request: Request) -> None:
user = getattr(request.state, "user", None)
if user is None:
raise InsufficientPermissionsError("Authentication required")
user_roles = getattr(user, "roles", [])
if role not in user_roles:
raise InsufficientPermissionsError(f"Missing role: {role}")

return dependency
131 changes: 131 additions & 0 deletions faststack_core/base/repository.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Repository Protocol and async SQLAlchemy implementation.

Defines the contract (``Repository`` Protocol) that all repository
implementations — including in-memory fakes — must satisfy via structural
typing. Also provides ``SqlAlchemyRepository``, the production
implementation backed by an ``AsyncSession``.

See ADR-002 for the design rationale: Protocol over ABC, fakes over mocks.
"""

from __future__ import annotations

from datetime import UTC, datetime
from typing import Generic, Protocol, TypeVar, runtime_checkable
from uuid import UUID

from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession

from faststack_core.base.entity import Entity, SoftDeleteEntity
from faststack_core.exceptions.domain import NotFoundError

T = TypeVar("T", bound=Entity)


# ---------------------------------------------------------------------------
# Protocol — the contract
# ---------------------------------------------------------------------------


@runtime_checkable
class Repository(Protocol[T]):
"""Base repository contract. All methods are async.

Any class that implements these method signatures satisfies the
Protocol via structural typing — no inheritance required.
"""

async def get_by_id(self, id: UUID) -> T | None: ...

async def list(self, skip: int = 0, limit: int = 100) -> list[T]: ...

async def create(self, data: dict) -> T: ...

async def update(self, id: UUID, data: dict) -> T: ...

async def delete(self, id: UUID) -> None: ...

async def count(self) -> int: ...


@runtime_checkable
class SearchableRepository(Repository[T], Protocol):
"""Extended contract with full-text search and sorting."""

async def search(
self, query: str, fields: list[str], skip: int = 0, limit: int = 100
) -> list[T]: ...


# ---------------------------------------------------------------------------
# SQLAlchemy implementation
# ---------------------------------------------------------------------------


class SqlAlchemyRepository(Generic[T]):
"""Async SQLAlchemy implementation satisfying the ``Repository`` Protocol.

Parameters
----------
db:
An active ``AsyncSession``.
model:
The SQLAlchemy model class (a concrete subclass of ``Entity``).
"""

def __init__(self, db: AsyncSession, model: type[T]) -> None:
self.db = db
self.model = model

async def get_by_id(self, id: UUID) -> T | None:
result = await self.db.execute(
select(self.model).where(self.model.id == id) # type: ignore[attr-defined]
)
return result.scalar_one_or_none()

async def list(self, skip: int = 0, limit: int = 100) -> list[T]:
stmt = select(self.model).offset(skip).limit(limit)
result = await self.db.execute(stmt)
return list(result.scalars().all())

async def create(self, data: dict) -> T:
entity = self.model(**data)
self.db.add(entity)
await self.db.flush()
await self.db.refresh(entity)
return entity

async def update(self, id: UUID, data: dict) -> T:
entity = await self.get_by_id(id)
if not entity:
raise NotFoundError(f"{self.model.__name__} with id {id} not found")
for key, value in data.items():
setattr(entity, key, value)
await self.db.flush()
await self.db.refresh(entity)
return entity

async def delete(self, id: UUID) -> None:
entity = await self.get_by_id(id)
if not entity:
raise NotFoundError(f"{self.model.__name__} with id {id} not found")
if isinstance(entity, SoftDeleteEntity):
entity.is_deleted = True # type: ignore[attr-defined]
entity.deleted_at = datetime.now(UTC) # type: ignore[attr-defined]
await self.db.flush()
else:
await self.db.delete(entity)
await self.db.flush()

async def hard_delete(self, id: UUID) -> None:
"""Permanently remove the entity, ignoring soft-delete."""
entity = await self.get_by_id(id)
if not entity:
raise NotFoundError(f"{self.model.__name__} with id {id} not found")
await self.db.delete(entity)
await self.db.flush()

async def count(self) -> int:
result = await self.db.execute(select(func.count()).select_from(self.model))
return result.scalar_one()
95 changes: 95 additions & 0 deletions faststack_core/base/service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
"""CRUD service with async lifecycle hooks.

Provides ``CrudService`` — a generic service that wraps a ``Repository``
and exposes create/get/list/update/delete operations with before/after
hooks at each step. Users override only the hooks they need in their
generated service files.

See ADR-005 for the design rationale: hooks over method overrides.
"""

from __future__ import annotations

from typing import Any, Generic, TypeVar
from uuid import UUID

from faststack_core.base.entity import Entity
from faststack_core.base.repository import Repository
from faststack_core.exceptions.domain import NotFoundError

T = TypeVar("T", bound=Entity)


class CrudService(Generic[T]):
"""Generic async CRUD service with lifecycle hooks.

Parameters
----------
repository:
Any object satisfying the ``Repository[T]`` Protocol.

Hooks
-----
Override these ``async`` methods in your generated service to inject
custom logic. Each hook has a default no-op implementation.

- ``before_create(data)`` → transform / validate before persisting
- ``after_create(entity)`` → side-effects after creation
- ``before_update(id, data)`` → transform / validate before update
- ``after_update(entity)`` → side-effects after update
- ``before_delete(id)`` → guard / validate before deletion
- ``after_delete(id)`` → cleanup after deletion
"""

def __init__(self, repository: Repository[T]) -> None:
self.repository = repository

# ------------------------------------------------------------------
# Lifecycle hooks — override in subclasses
# ------------------------------------------------------------------

async def before_create(self, data: dict[str, Any]) -> dict[str, Any]:
return data

async def after_create(self, entity: T) -> T:
return entity

async def before_update(self, id: UUID, data: dict[str, Any]) -> dict[str, Any]:
return data

async def after_update(self, entity: T) -> T:
return entity

async def before_delete(self, id: UUID) -> None:
pass

async def after_delete(self, id: UUID) -> None:
pass

# ------------------------------------------------------------------
# CRUD operations
# ------------------------------------------------------------------

async def create(self, data: dict[str, Any]) -> T:
data = await self.before_create(data)
entity = await self.repository.create(data)
return await self.after_create(entity)

async def get(self, id: UUID) -> T:
entity = await self.repository.get_by_id(id)
if not entity:
raise NotFoundError(f"Entity with id {id} not found")
return entity

async def list(self, skip: int = 0, limit: int = 100) -> list[T]:
return await self.repository.list(skip=skip, limit=limit)

async def update(self, id: UUID, data: dict[str, Any]) -> T:
data = await self.before_update(id, data)
entity = await self.repository.update(id, data)
return await self.after_update(entity)

async def delete(self, id: UUID) -> None:
await self.before_delete(id)
await self.repository.delete(id)
await self.after_delete(id)
Loading
Loading