refactor: migrate to DDD architecture with Dishka DI
Complete architectural refactoring from simple MVC to Clean Architecture/DDD pattern: Domain Layer: - Add entities (Post, BaseEntity) with business logic - Add value objects (Title, Content, Slug) with validation - Add repository interfaces (PostRepository) - Add domain exceptions Application Layer: - Add use cases (CreatePost, GetPost, UpdatePost, DeletePost, ListPosts, PublishPost) - Add DTOs for data transfer - Add TransactionManager interface Infrastructure Layer: - Add SQLAlchemy models and async database connection - Add SQLAlchemyPostRepository implementation - Add Dishka DI container with providers - Add error handlers and middleware Presentation Layer: - Add FastAPI routes with Dishka integration - Add Pydantic schemas - Add dependency injection using FromDishka[T] Other Changes: - Remove old flat structure (api/, common/, core/, modules/) - Add hatchling build system for package scripts - Add blog CLI command - Update AGENTS.md with new architecture docs - All 48 tests passing, mypy clean, ruff clean
This commit is contained in:
35
app/infrastructure/__init__.py
Normal file
35
app/infrastructure/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Infrastructure layer exports."""
|
||||
|
||||
from app.infrastructure.config import Settings, settings
|
||||
from app.infrastructure.database import (
|
||||
AsyncSessionLocal,
|
||||
Base,
|
||||
PostORM,
|
||||
close_db,
|
||||
engine,
|
||||
get_session,
|
||||
init_db,
|
||||
)
|
||||
from app.infrastructure.di import create_container
|
||||
from app.infrastructure.middleware import register_exception_handlers
|
||||
from app.infrastructure.repositories import SQLAlchemyPostRepository
|
||||
|
||||
__all__ = [
|
||||
# Config
|
||||
"Settings",
|
||||
"settings",
|
||||
# Database
|
||||
"Base",
|
||||
"PostORM",
|
||||
"engine",
|
||||
"AsyncSessionLocal",
|
||||
"get_session",
|
||||
"init_db",
|
||||
"close_db",
|
||||
# Repositories
|
||||
"SQLAlchemyPostRepository",
|
||||
# DI
|
||||
"create_container",
|
||||
# Middleware
|
||||
"register_exception_handlers",
|
||||
]
|
||||
5
app/infrastructure/config/__init__.py
Normal file
5
app/infrastructure/config/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Infrastructure configuration."""
|
||||
|
||||
from app.infrastructure.config.settings import Settings, settings
|
||||
|
||||
__all__ = ["Settings", "settings"]
|
||||
31
app/infrastructure/config/settings.py
Normal file
31
app/infrastructure/config/settings.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Application settings."""
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application configuration settings."""
|
||||
|
||||
# App settings
|
||||
app_name: str = "Blog API"
|
||||
debug: bool = False
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8000
|
||||
|
||||
# Database settings
|
||||
database_url: str = "sqlite:///./blog.db"
|
||||
database_echo: bool = False
|
||||
|
||||
# Security settings
|
||||
secret_key: str = "your-secret-key-change-in-production"
|
||||
access_token_expire_minutes: int = 30
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
)
|
||||
|
||||
|
||||
# Global settings instance
|
||||
settings = Settings()
|
||||
22
app/infrastructure/database/__init__.py
Normal file
22
app/infrastructure/database/__init__.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Database infrastructure."""
|
||||
|
||||
from app.infrastructure.database.connection import (
|
||||
AsyncSessionLocal,
|
||||
close_db,
|
||||
engine,
|
||||
get_session,
|
||||
get_session_context,
|
||||
init_db,
|
||||
)
|
||||
from app.infrastructure.database.models import Base, PostORM
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"PostORM",
|
||||
"engine",
|
||||
"AsyncSessionLocal",
|
||||
"get_session",
|
||||
"get_session_context",
|
||||
"init_db",
|
||||
"close_db",
|
||||
]
|
||||
70
app/infrastructure/database/connection.py
Normal file
70
app/infrastructure/database/connection.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""Database connection and session management."""
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from app.infrastructure.config import settings
|
||||
|
||||
|
||||
# Convert SQLite URL to async format if needed
|
||||
def _get_database_url() -> str:
|
||||
url = settings.database_url
|
||||
if url.startswith("sqlite:///") and not url.startswith("sqlite+aiosqlite:///"):
|
||||
return url.replace("sqlite:///", "sqlite+aiosqlite:///")
|
||||
return url
|
||||
|
||||
|
||||
# Create async engine
|
||||
engine: AsyncEngine = create_async_engine(
|
||||
_get_database_url(),
|
||||
echo=settings.database_echo,
|
||||
future=True,
|
||||
)
|
||||
|
||||
# Create session factory
|
||||
AsyncSessionLocal = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
autoflush=False,
|
||||
autocommit=False,
|
||||
)
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Get database session."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def get_session_context() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Get database session as context manager."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Initialize database tables."""
|
||||
from app.infrastructure.database.models import Base
|
||||
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
|
||||
async def close_db() -> None:
|
||||
"""Close database connections."""
|
||||
await engine.dispose()
|
||||
40
app/infrastructure/database/models.py
Normal file
40
app/infrastructure/database/models.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""SQLAlchemy ORM models."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, String, Text
|
||||
from sqlalchemy.orm import Mapped, declarative_base, mapped_column
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class PostORM(Base): # type: ignore[valid-type,misc]
|
||||
"""SQLAlchemy model for Blog Post."""
|
||||
|
||||
__tablename__ = "posts"
|
||||
|
||||
id: Mapped[str] = mapped_column(
|
||||
String(36), primary_key=True, default=lambda: str(uuid4())
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
slug: Mapped[str] = mapped_column(
|
||||
String(200), nullable=False, unique=True, index=True
|
||||
)
|
||||
author_id: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
|
||||
published: Mapped[bool] = mapped_column(
|
||||
Boolean, default=False, nullable=False, index=True
|
||||
)
|
||||
tags: Mapped[list[str]] = mapped_column(JSON, default=list)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc),
|
||||
onupdate=lambda: datetime.now(timezone.utc),
|
||||
nullable=False,
|
||||
)
|
||||
7
app/infrastructure/di/__init__.py
Normal file
7
app/infrastructure/di/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Dependency Injection using Dishka."""
|
||||
|
||||
from app.infrastructure.di.container import create_container
|
||||
|
||||
__all__ = [
|
||||
"create_container",
|
||||
]
|
||||
20
app/infrastructure/di/container.py
Normal file
20
app/infrastructure/di/container.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Dishka container setup."""
|
||||
|
||||
from dishka import AsyncContainer, make_async_container
|
||||
|
||||
from app.infrastructure.di.providers import (
|
||||
DatabaseProvider,
|
||||
RepositoryProvider,
|
||||
TransactionManagerProvider,
|
||||
UseCaseProvider,
|
||||
)
|
||||
|
||||
|
||||
def create_container() -> AsyncContainer:
|
||||
"""Create and configure Dishka container."""
|
||||
return make_async_container(
|
||||
DatabaseProvider(),
|
||||
RepositoryProvider(),
|
||||
TransactionManagerProvider(),
|
||||
UseCaseProvider(),
|
||||
)
|
||||
133
app/infrastructure/di/providers.py
Normal file
133
app/infrastructure/di/providers.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Dishka providers for dependency injection."""
|
||||
|
||||
from typing import AsyncGenerator
|
||||
|
||||
from dishka import Provider, Scope, provide
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
|
||||
|
||||
from app.application import (
|
||||
CreatePostUseCase,
|
||||
DeletePostUseCase,
|
||||
GetPostUseCase,
|
||||
ListPostsUseCase,
|
||||
PublishPostUseCase,
|
||||
UpdatePostUseCase,
|
||||
)
|
||||
from app.application.interfaces import TransactionManager
|
||||
from app.domain.repositories import PostRepository
|
||||
from app.infrastructure.database.connection import AsyncSessionLocal, engine
|
||||
from app.infrastructure.repositories.post import SQLAlchemyPostRepository
|
||||
|
||||
|
||||
class DatabaseProvider(Provider):
|
||||
"""Provider for database-related dependencies."""
|
||||
|
||||
@provide(scope=Scope.APP)
|
||||
def get_engine(self) -> AsyncEngine:
|
||||
"""Provide SQLAlchemy engine."""
|
||||
return engine
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
async def get_session(self) -> AsyncGenerator[AsyncSession, None]:
|
||||
"""Provide database session per request."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
class RepositoryProvider(Provider):
|
||||
"""Provider for repository implementations."""
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_post_repository(self, session: AsyncSession) -> PostRepository:
|
||||
"""Provide PostRepository implementation."""
|
||||
return SQLAlchemyPostRepository(session)
|
||||
|
||||
|
||||
class TransactionManagerProvider(Provider):
|
||||
"""Provider for transaction manager."""
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_transaction_manager(self, session: AsyncSession) -> TransactionManager:
|
||||
"""Provide TransactionManager implementation."""
|
||||
from app.infrastructure.di.transaction_manager import SessionTransactionManager
|
||||
|
||||
return SessionTransactionManager(session)
|
||||
|
||||
|
||||
class UseCaseProvider(Provider):
|
||||
"""Provider for use cases."""
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_create_post_use_case(
|
||||
self,
|
||||
post_repo: PostRepository,
|
||||
tx_manager: TransactionManager,
|
||||
) -> CreatePostUseCase:
|
||||
"""Provide CreatePostUseCase."""
|
||||
return CreatePostUseCase(
|
||||
post_repo=post_repo,
|
||||
tx_manager=tx_manager,
|
||||
)
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_get_post_use_case(
|
||||
self,
|
||||
post_repo: PostRepository,
|
||||
tx_manager: TransactionManager,
|
||||
) -> GetPostUseCase:
|
||||
"""Provide GetPostUseCase."""
|
||||
return GetPostUseCase(
|
||||
post_repo=post_repo,
|
||||
tx_manager=tx_manager,
|
||||
)
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_update_post_use_case(
|
||||
self,
|
||||
post_repo: PostRepository,
|
||||
tx_manager: TransactionManager,
|
||||
) -> UpdatePostUseCase:
|
||||
"""Provide UpdatePostUseCase."""
|
||||
return UpdatePostUseCase(
|
||||
post_repo=post_repo,
|
||||
tx_manager=tx_manager,
|
||||
)
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_delete_post_use_case(
|
||||
self,
|
||||
post_repo: PostRepository,
|
||||
tx_manager: TransactionManager,
|
||||
) -> DeletePostUseCase:
|
||||
"""Provide DeletePostUseCase."""
|
||||
return DeletePostUseCase(
|
||||
post_repo=post_repo,
|
||||
tx_manager=tx_manager,
|
||||
)
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_list_posts_use_case(
|
||||
self,
|
||||
post_repo: PostRepository,
|
||||
tx_manager: TransactionManager,
|
||||
) -> ListPostsUseCase:
|
||||
"""Provide ListPostsUseCase."""
|
||||
return ListPostsUseCase(
|
||||
post_repo=post_repo,
|
||||
tx_manager=tx_manager,
|
||||
)
|
||||
|
||||
@provide(scope=Scope.REQUEST)
|
||||
def get_publish_post_use_case(
|
||||
self,
|
||||
post_repo: PostRepository,
|
||||
tx_manager: TransactionManager,
|
||||
) -> PublishPostUseCase:
|
||||
"""Provide PublishPostUseCase."""
|
||||
return PublishPostUseCase(
|
||||
post_repo=post_repo,
|
||||
tx_manager=tx_manager,
|
||||
)
|
||||
24
app/infrastructure/di/transaction_manager.py
Normal file
24
app/infrastructure/di/transaction_manager.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""SQLAlchemy implementation of Transaction Manager."""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.interfaces import TransactionManager
|
||||
|
||||
|
||||
class SessionTransactionManager(TransactionManager):
|
||||
"""SQLAlchemy Session-based Transaction Manager."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
self._committed: bool = False
|
||||
|
||||
async def commit(self) -> None:
|
||||
"""Commit the current transaction."""
|
||||
if not self._committed:
|
||||
await self._session.commit()
|
||||
self._committed = True
|
||||
|
||||
async def rollback(self) -> None:
|
||||
"""Rollback the current transaction."""
|
||||
if not self._committed:
|
||||
await self._session.rollback()
|
||||
15
app/infrastructure/middleware/__init__.py
Normal file
15
app/infrastructure/middleware/__init__.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Infrastructure middleware."""
|
||||
|
||||
from app.infrastructure.middleware.error_handler import (
|
||||
domain_exception_handler,
|
||||
generic_exception_handler,
|
||||
http_exception_handler,
|
||||
register_exception_handlers,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"domain_exception_handler",
|
||||
"http_exception_handler",
|
||||
"generic_exception_handler",
|
||||
"register_exception_handlers",
|
||||
]
|
||||
93
app/infrastructure/middleware/error_handler.py
Normal file
93
app/infrastructure/middleware/error_handler.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Exception handling middleware."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.domain.exceptions import (
|
||||
AlreadyExistsException,
|
||||
DomainException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
ValidationException,
|
||||
)
|
||||
|
||||
|
||||
def get_status_code(exc: DomainException) -> int:
|
||||
"""Map domain exceptions to HTTP status codes."""
|
||||
match exc:
|
||||
case ValidationException():
|
||||
return 400
|
||||
case UnauthorizedException():
|
||||
return 401
|
||||
case ForbiddenException():
|
||||
return 403
|
||||
case NotFoundException():
|
||||
return 404
|
||||
case AlreadyExistsException():
|
||||
return 409
|
||||
case _:
|
||||
return 500
|
||||
|
||||
|
||||
async def domain_exception_handler(
|
||||
request: Request, exc: DomainException
|
||||
) -> JSONResponse:
|
||||
"""Handle domain exceptions."""
|
||||
status_code = get_status_code(exc)
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content={
|
||||
"error": exc.__class__.__name__,
|
||||
"message": exc.message,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"path": str(request.url.path),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def http_exception_handler(
|
||||
request: Request, exc: StarletteHTTPException
|
||||
) -> JSONResponse:
|
||||
"""Handle HTTP exceptions."""
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={
|
||||
"error": "HTTPException",
|
||||
"message": str(exc.detail),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"path": str(request.url.path),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||
"""Handle generic exceptions."""
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={
|
||||
"error": "InternalServerError",
|
||||
"message": "An unexpected error occurred",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"path": str(request.url.path),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
"""Register all exception handlers with FastAPI app."""
|
||||
if not isinstance(app, FastAPI):
|
||||
raise TypeError("app must be a FastAPI instance")
|
||||
|
||||
# Domain exceptions
|
||||
app.add_exception_handler(DomainException, domain_exception_handler) # type: ignore[arg-type]
|
||||
|
||||
# HTTP exceptions
|
||||
app.add_exception_handler(StarletteHTTPException, http_exception_handler) # type: ignore[arg-type]
|
||||
|
||||
# Generic exceptions (only in production)
|
||||
# In development, let FastAPI show detailed traceback
|
||||
# app.add_exception_handler(Exception, generic_exception_handler)
|
||||
5
app/infrastructure/repositories/__init__.py
Normal file
5
app/infrastructure/repositories/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Repository implementations."""
|
||||
|
||||
from app.infrastructure.repositories.post import SQLAlchemyPostRepository
|
||||
|
||||
__all__ = ["SQLAlchemyPostRepository"]
|
||||
151
app/infrastructure/repositories/post.py
Normal file
151
app/infrastructure/repositories/post.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""SQLAlchemy implementation of PostRepository."""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.entities import Post
|
||||
from app.domain.repositories import PostRepository
|
||||
from app.domain.value_objects import Content, Slug, Title
|
||||
from app.infrastructure.database.models import PostORM
|
||||
|
||||
|
||||
class SQLAlchemyPostRepository(PostRepository):
|
||||
"""SQLAlchemy implementation of Post repository."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self._session = session
|
||||
|
||||
def _to_domain(self, orm: PostORM) -> Post:
|
||||
"""Convert ORM model to domain entity."""
|
||||
return Post(
|
||||
id=UUID(orm.id),
|
||||
title=Title(orm.title),
|
||||
content=Content(orm.content),
|
||||
slug=Slug(orm.slug),
|
||||
author_id=orm.author_id,
|
||||
published=orm.published,
|
||||
tags=orm.tags or [],
|
||||
created_at=orm.created_at,
|
||||
updated_at=orm.updated_at,
|
||||
)
|
||||
|
||||
def _to_orm(self, post: Post) -> PostORM:
|
||||
"""Convert domain entity to ORM model."""
|
||||
return PostORM(
|
||||
id=str(post.id),
|
||||
title=post.title.value,
|
||||
content=post.content.value,
|
||||
slug=post.slug.value,
|
||||
author_id=post.author_id,
|
||||
published=post.published,
|
||||
tags=post.tags,
|
||||
created_at=post.created_at,
|
||||
updated_at=post.updated_at,
|
||||
)
|
||||
|
||||
async def get_by_id(self, entity_id: UUID) -> Post | None:
|
||||
"""Get post by ID."""
|
||||
result = await self._session.execute(
|
||||
select(PostORM).where(PostORM.id == str(entity_id))
|
||||
)
|
||||
orm = result.scalar_one_or_none()
|
||||
return self._to_domain(orm) if orm else None
|
||||
|
||||
async def get_all(self) -> list[Post]:
|
||||
"""Get all posts."""
|
||||
result = await self._session.execute(select(PostORM))
|
||||
orms = result.scalars().all()
|
||||
return [self._to_domain(orm) for orm in orms]
|
||||
|
||||
async def add(self, entity: Post) -> None:
|
||||
"""Add new post."""
|
||||
orm = self._to_orm(entity)
|
||||
self._session.add(orm)
|
||||
# Commit делает TransactionManager
|
||||
|
||||
async def update(self, entity: Post) -> None:
|
||||
"""Update existing post."""
|
||||
result = await self._session.execute(
|
||||
select(PostORM).where(PostORM.id == str(entity.id))
|
||||
)
|
||||
orm = result.scalar_one()
|
||||
|
||||
orm.title = entity.title.value
|
||||
orm.content = entity.content.value
|
||||
orm.slug = entity.slug.value
|
||||
orm.published = entity.published
|
||||
orm.tags = entity.tags
|
||||
orm.updated_at = entity.updated_at
|
||||
|
||||
# Commit делает TransactionManager
|
||||
|
||||
async def delete(self, entity_id: UUID) -> None:
|
||||
"""Delete post by ID."""
|
||||
result = await self._session.execute(
|
||||
select(PostORM).where(PostORM.id == str(entity_id))
|
||||
)
|
||||
orm = result.scalar_one_or_none()
|
||||
if orm:
|
||||
await self._session.delete(orm)
|
||||
|
||||
async def exists(self, entity_id: UUID) -> bool:
|
||||
"""Check if post exists."""
|
||||
result = await self._session.execute(
|
||||
select(PostORM).where(PostORM.id == str(entity_id))
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
async def get_by_slug(self, slug: str) -> Post | None:
|
||||
"""Get post by slug."""
|
||||
result = await self._session.execute(
|
||||
select(PostORM).where(PostORM.slug == slug)
|
||||
)
|
||||
orm = result.scalar_one_or_none()
|
||||
return self._to_domain(orm) if orm else None
|
||||
|
||||
async def get_by_author(self, author_id: str) -> list[Post]:
|
||||
"""Get posts by author."""
|
||||
result = await self._session.execute(
|
||||
select(PostORM).where(PostORM.author_id == author_id)
|
||||
)
|
||||
orms = result.scalars().all()
|
||||
return [self._to_domain(orm) for orm in orms]
|
||||
|
||||
async def get_published(self) -> list[Post]:
|
||||
"""Get published posts."""
|
||||
result = await self._session.execute(
|
||||
select(PostORM).where(PostORM.published.is_(True))
|
||||
)
|
||||
orms = result.scalars().all()
|
||||
return [self._to_domain(orm) for orm in orms]
|
||||
|
||||
async def get_by_tag(self, tag: str) -> list[Post]:
|
||||
"""Get posts by tag."""
|
||||
result = await self._session.execute(
|
||||
select(PostORM).where(PostORM.tags.contains([tag]))
|
||||
)
|
||||
orms = result.scalars().all()
|
||||
return [self._to_domain(orm) for orm in orms]
|
||||
|
||||
async def slug_exists(self, slug: str) -> bool:
|
||||
"""Check if slug exists."""
|
||||
result = await self._session.execute(
|
||||
select(PostORM).where(PostORM.slug == slug)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
async def search(self, query: str) -> list[Post]:
|
||||
"""Search posts."""
|
||||
search_pattern = f"%{query}%"
|
||||
result = await self._session.execute(
|
||||
select(PostORM).where(
|
||||
or_(
|
||||
PostORM.title.ilike(search_pattern),
|
||||
PostORM.content.ilike(search_pattern),
|
||||
)
|
||||
)
|
||||
)
|
||||
orms = result.scalars().all()
|
||||
return [self._to_domain(orm) for orm in orms]
|
||||
Reference in New Issue
Block a user