feat: add PostgreSQL persistence layer
- adapters: SQLAlchemy implementations for all 4 repositories (Project, AssetLibrary, Asset, IngestJob) - models: SQLAlchemy ORM models with proper schema - database: connection config and session management - tests: SQLAlchemy repository integration test (in-memory SQLite) - all 7 integration tests passing
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class DatabaseSettings(BaseModel):
|
||||
database_url: str = "postgresql://postgres:postgres@postgres:5432/xiaoxia_saas"
|
||||
|
||||
|
||||
def get_database_settings() -> DatabaseSettings:
|
||||
return DatabaseSettings()
|
||||
@@ -0,0 +1,17 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
|
||||
from app.core.database import get_database_settings
|
||||
|
||||
settings = get_database_settings()
|
||||
engine = create_engine(settings.database_url)
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
def get_db() -> Session:
|
||||
"""Dependency for database session."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1 @@
|
||||
"""SQLAlchemy-based repository implementations."""
|
||||
@@ -0,0 +1,39 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain import AssetLibrary, AssetLibraryKind
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetLibraryModel
|
||||
|
||||
|
||||
class SQLAlchemyAssetLibraryRepository:
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def list_by_project(self, project_id: str, kind: AssetLibraryKind | None = None) -> list[AssetLibrary]:
|
||||
query = self.session.query(AssetLibraryModel).filter(AssetLibraryModel.project_id == project_id)
|
||||
if kind is not None:
|
||||
query = query.filter(AssetLibraryModel.kind == kind.value)
|
||||
models = query.all()
|
||||
return [
|
||||
AssetLibrary(
|
||||
id=m.id,
|
||||
workspace_id=m.workspace_id,
|
||||
project_id=m.project_id,
|
||||
name=m.name,
|
||||
kind=AssetLibraryKind(m.kind),
|
||||
created_at=m.created_at,
|
||||
)
|
||||
for m in models
|
||||
]
|
||||
|
||||
def create(self, library: AssetLibrary) -> AssetLibrary:
|
||||
model = AssetLibraryModel(
|
||||
id=library.id,
|
||||
workspace_id=library.workspace_id,
|
||||
project_id=library.project_id,
|
||||
name=library.name,
|
||||
kind=library.kind.value,
|
||||
created_at=library.created_at,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return library
|
||||
@@ -0,0 +1,43 @@
|
||||
import json
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain import Asset
|
||||
from packages.adapters.sqlalchemy_impl.models import AssetModel
|
||||
|
||||
|
||||
class SQLAlchemyAssetRepository:
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def list_by_library(self, library_id: str) -> list[Asset]:
|
||||
models = self.session.query(AssetModel).filter(AssetModel.library_id == library_id).all()
|
||||
return [
|
||||
Asset(
|
||||
id=m.id,
|
||||
workspace_id=m.workspace_id,
|
||||
project_id=m.project_id,
|
||||
library_id=m.library_id,
|
||||
name=m.name,
|
||||
storage_key=m.storage_key,
|
||||
mime_type=m.mime_type,
|
||||
metadata=json.loads(m.metadata_json),
|
||||
created_at=m.created_at,
|
||||
)
|
||||
for m in models
|
||||
]
|
||||
|
||||
def create(self, asset: Asset) -> Asset:
|
||||
model = AssetModel(
|
||||
id=asset.id,
|
||||
workspace_id=asset.workspace_id,
|
||||
project_id=asset.project_id,
|
||||
library_id=asset.library_id,
|
||||
name=asset.name,
|
||||
storage_key=asset.storage_key,
|
||||
mime_type=asset.mime_type,
|
||||
metadata_json=json.dumps(asset.metadata),
|
||||
created_at=asset.created_at,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return asset
|
||||
@@ -0,0 +1,54 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain import IngestJob, IngestJobStatus
|
||||
from packages.adapters.sqlalchemy_impl.models import IngestJobModel
|
||||
|
||||
|
||||
class SQLAlchemyIngestJobRepository:
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def create(self, job: IngestJob) -> IngestJob:
|
||||
model = IngestJobModel(
|
||||
id=job.id,
|
||||
workspace_id=job.workspace_id,
|
||||
project_id=job.project_id,
|
||||
library_id=job.library_id,
|
||||
storage_key=job.storage_key,
|
||||
status=job.status.value,
|
||||
error_message=job.error_message,
|
||||
result_asset_id=job.result_asset_id,
|
||||
created_at=job.created_at,
|
||||
updated_at=job.updated_at,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return job
|
||||
|
||||
def get(self, job_id: str) -> IngestJob | None:
|
||||
model = self.session.query(IngestJobModel).filter(IngestJobModel.id == job_id).first()
|
||||
if model is None:
|
||||
return None
|
||||
return IngestJob(
|
||||
id=model.id,
|
||||
workspace_id=model.workspace_id,
|
||||
project_id=model.project_id,
|
||||
library_id=model.library_id,
|
||||
storage_key=model.storage_key,
|
||||
status=IngestJobStatus(model.status),
|
||||
error_message=model.error_message,
|
||||
result_asset_id=model.result_asset_id,
|
||||
created_at=model.created_at,
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
def update(self, job: IngestJob) -> IngestJob:
|
||||
model = self.session.query(IngestJobModel).filter(IngestJobModel.id == job.id).first()
|
||||
if model is None:
|
||||
raise ValueError(f"IngestJob {job.id} not found")
|
||||
model.status = job.status.value
|
||||
model.error_message = job.error_message
|
||||
model.result_asset_id = job.result_asset_id
|
||||
model.updated_at = job.updated_at
|
||||
self.session.commit()
|
||||
return job
|
||||
@@ -0,0 +1,55 @@
|
||||
from sqlalchemy import Column, DateTime, String, Text, create_engine
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from datetime import datetime, timezone
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class ProjectModel(Base):
|
||||
__tablename__ = "projects"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
workspace_id = Column(String(32), nullable=False, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
description = Column(Text, nullable=False, default="")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class AssetLibraryModel(Base):
|
||||
__tablename__ = "asset_libraries"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
workspace_id = Column(String(32), nullable=False, index=True)
|
||||
project_id = Column(String(32), nullable=False, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
kind = Column(String(20), nullable=False)
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class AssetModel(Base):
|
||||
__tablename__ = "assets"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
workspace_id = Column(String(32), nullable=False, index=True)
|
||||
project_id = Column(String(32), nullable=False, index=True)
|
||||
library_id = Column(String(32), nullable=False, index=True)
|
||||
name = Column(String(100), nullable=False)
|
||||
storage_key = Column(String(255), nullable=False)
|
||||
mime_type = Column(String(100), nullable=False)
|
||||
metadata_json = Column(Text, nullable=False, default="{}")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class IngestJobModel(Base):
|
||||
__tablename__ = "ingest_jobs"
|
||||
|
||||
id = Column(String(32), primary_key=True)
|
||||
workspace_id = Column(String(32), nullable=False, index=True)
|
||||
project_id = Column(String(32), nullable=False, index=True)
|
||||
library_id = Column(String(32), nullable=False, index=True)
|
||||
storage_key = Column(String(255), nullable=False)
|
||||
status = Column(String(20), nullable=False, default="pending")
|
||||
error_message = Column(Text, nullable=False, default="")
|
||||
result_asset_id = Column(String(32), nullable=False, default="")
|
||||
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
@@ -0,0 +1,34 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from packages.domain import Project
|
||||
from packages.adapters.sqlalchemy_impl.models import ProjectModel
|
||||
|
||||
|
||||
class SQLAlchemyProjectRepository:
|
||||
def __init__(self, session: Session):
|
||||
self.session = session
|
||||
|
||||
def list_by_workspace(self, workspace_id: str) -> list[Project]:
|
||||
models = self.session.query(ProjectModel).filter(ProjectModel.workspace_id == workspace_id).all()
|
||||
return [
|
||||
Project(
|
||||
id=m.id,
|
||||
workspace_id=m.workspace_id,
|
||||
name=m.name,
|
||||
description=m.description,
|
||||
created_at=m.created_at,
|
||||
)
|
||||
for m in models
|
||||
]
|
||||
|
||||
def create(self, project: Project) -> Project:
|
||||
model = ProjectModel(
|
||||
id=project.id,
|
||||
workspace_id=project.workspace_id,
|
||||
name=project.name,
|
||||
description=project.description,
|
||||
created_at=project.created_at,
|
||||
)
|
||||
self.session.add(model)
|
||||
self.session.commit()
|
||||
return project
|
||||
@@ -0,0 +1,40 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from packages.adapters.sqlalchemy_impl.models import Base
|
||||
from packages.adapters.sqlalchemy_impl.project_repository import SQLAlchemyProjectRepository
|
||||
from packages.application import CreateProjectCommand, CreateProjectUseCase
|
||||
|
||||
|
||||
def test_sqlalchemy_project_repository():
|
||||
"""Test SQLAlchemy project repository with in-memory SQLite."""
|
||||
# Create in-memory SQLite database
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
Base.metadata.create_all(engine)
|
||||
SessionLocal = sessionmaker(bind=engine)
|
||||
session: Session = SessionLocal()
|
||||
|
||||
try:
|
||||
# Create repository and use case
|
||||
repository = SQLAlchemyProjectRepository(session)
|
||||
use_case = CreateProjectUseCase(repository)
|
||||
|
||||
# Create project
|
||||
project = use_case.execute(
|
||||
CreateProjectCommand(
|
||||
workspace_id="ws-1",
|
||||
name="Test Project",
|
||||
description="Test description",
|
||||
)
|
||||
)
|
||||
|
||||
assert project.name == "Test Project"
|
||||
assert project.workspace_id == "ws-1"
|
||||
|
||||
# List projects
|
||||
projects = repository.list_by_workspace("ws-1")
|
||||
assert len(projects) == 1
|
||||
assert projects[0].id == project.id
|
||||
assert projects[0].name == "Test Project"
|
||||
finally:
|
||||
session.close()
|
||||
Reference in New Issue
Block a user