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:
Xiaoxia AI
2026-06-15 15:29:13 +08:00
parent d5504166ff
commit e4e2595e70
9 changed files with 292 additions and 0 deletions
+9
View File
@@ -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()
+17
View File
@@ -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()