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
925 changes: 925 additions & 0 deletions docs/database.md

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,18 @@ description = "FastAPI by the OpenTaberna Project"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"alembic>=1.17.2",
"asyncpg>=0.31.0",
"authlib>=1.6.5",
"cryptography>=46.0.3",
"fastapi>=0.124.0",
"pydantic-settings>=2.12.0",
"pydantic>=2.12.5",
"pytest-asyncio>=1.3.0",
"pytest>=9.0.2",
"python-dotenv>=1.2.1",
"python-keycloak>=5.8.1",
"ruff>=0.14.8",
"sqlalchemy[asyncio]>=2.0.44",
"uvicorn>=0.38.0",
]
22 changes: 22 additions & 0 deletions src/app/shared/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,31 @@ class Settings(BaseSettings):
database_pool_timeout: int = Field(
default=30, description="Database pool timeout in seconds"
)
database_pool_recycle: int = Field(
default=3600, description="Connection recycle time in seconds"
)
database_pool_pre_ping: bool = Field(
default=True, description="Test connections before using them"
)
database_echo: bool = Field(
default=False, description="Echo SQL queries (for debugging)"
)
database_echo_pool: bool = Field(
default=False, description="Echo connection pool events"
)
database_statement_timeout: int = Field(
default=60000, description="Statement timeout in milliseconds"
)
database_command_timeout: int = Field(
default=60, description="Command timeout in seconds"
)
database_server_settings: dict[str, str] = Field(
default_factory=lambda: {
"application_name": "OpenTaberna API",
"jit": "off", # JIT can cause issues with some queries
},
description="PostgreSQL server settings",
)

# Redis
redis_url: str = Field(
Expand Down
60 changes: 60 additions & 0 deletions src/app/shared/database/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""
Database Module

Generic database layer with async support for PostgreSQL.
Framework-agnostic, follows SOLID principles, and can be reused across APIs.

Components:
- Engine and session management
- Base repository pattern with CRUD operations
- Transaction management
- Health checks
- Migration support

Example:
>>> from app.shared.database import get_session, init_database
>>>
>>> # Initialize database
>>> await init_database()
>>>
>>> # Use session
>>> async with get_session() as session:
... result = await session.execute(select(User))
... users = result.scalars().all()
"""

from app.shared.database.engine import (
init_database,
close_database,
get_engine,
)
from app.shared.database.session import (
get_session,
get_session_dependency,
AsyncSession,
)
from app.shared.database.base import Base, TimestampMixin, SoftDeleteMixin
from app.shared.database.repository import BaseRepository
from app.shared.database.transaction import transaction
from app.shared.database.health import check_database_health

__all__ = [
# Engine
"init_database",
"close_database",
"get_engine",
# Session
"get_session",
"get_session_dependency",
"AsyncSession",
# Base
"Base",
"TimestampMixin",
"SoftDeleteMixin",
# Repository
"BaseRepository",
# Transaction
"transaction",
# Health
"check_database_health",
]
113 changes: 113 additions & 0 deletions src/app/shared/database/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
"""
Database Base Models

Declarative base for SQLAlchemy models with common fields and utilities.
"""

from datetime import datetime, UTC
from typing import Any

from sqlalchemy import DateTime, func
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column


class Base(DeclarativeBase):
"""
Base class for all database models.

Provides common functionality and timestamp fields.
All models should inherit from this class.

Example:
>>> class User(Base):
... __tablename__ = "users"
...
... id: Mapped[int] = mapped_column(primary_key=True)
... name: Mapped[str] = mapped_column(String(100))
"""

# Disable default constructor to avoid conflicts with Pydantic
__abstract__ = True

def to_dict(self) -> dict[str, Any]:
"""
Convert model instance to dictionary.

Returns:
Dictionary representation of model

Example:
>>> user = User(id=1, name="John")
>>> user.to_dict()
{"id": 1, "name": "John", "created_at": "2025-12-07T..."}
"""
return {
column.name: getattr(self, column.name) for column in self.__table__.columns
}

def __repr__(self) -> str:
"""String representation of model."""
attrs = ", ".join(
f"{k}={v!r}" for k, v in self.to_dict().items() if not k.startswith("_")
)
return f"{self.__class__.__name__}({attrs})"


class TimestampMixin:
"""
Mixin for models that need created_at and updated_at timestamps.

Example:
>>> class User(Base, TimestampMixin):
... __tablename__ = "users"
... id: Mapped[int] = mapped_column(primary_key=True)
"""

created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
doc="Timestamp when record was created",
)

updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
doc="Timestamp when record was last updated",
)


class SoftDeleteMixin:
"""
Mixin for models that support soft deletion.

Example:
>>> class User(Base, SoftDeleteMixin):
... __tablename__ = "users"
... id: Mapped[int] = mapped_column(primary_key=True)
...
>>> user.soft_delete()
>>> user.is_deleted # True
"""

deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
default=None,
doc="Timestamp when record was soft deleted",
)

@property
def is_deleted(self) -> bool:
"""Check if record is soft deleted."""
return self.deleted_at is not None

def soft_delete(self) -> None:
"""Mark record as deleted."""
self.deleted_at = datetime.now(UTC)

def restore(self) -> None:
"""Restore soft deleted record."""
self.deleted_at = None
Loading
Loading