diff --git a/apps/api/app/core/database.py b/apps/api/app/core/database.py new file mode 100644 index 000000000..84bf25731 --- /dev/null +++ b/apps/api/app/core/database.py @@ -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() diff --git a/apps/api/app/db.py b/apps/api/app/db.py new file mode 100644 index 000000000..4e0d843da --- /dev/null +++ b/apps/api/app/db.py @@ -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() diff --git a/packages/adapters/sqlalchemy_impl/__init__.py b/packages/adapters/sqlalchemy_impl/__init__.py new file mode 100644 index 000000000..60e96fbbb --- /dev/null +++ b/packages/adapters/sqlalchemy_impl/__init__.py @@ -0,0 +1 @@ +"""SQLAlchemy-based repository implementations.""" diff --git a/packages/adapters/sqlalchemy_impl/asset_library_repository.py b/packages/adapters/sqlalchemy_impl/asset_library_repository.py new file mode 100644 index 000000000..a4bc0d7a0 --- /dev/null +++ b/packages/adapters/sqlalchemy_impl/asset_library_repository.py @@ -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 diff --git a/packages/adapters/sqlalchemy_impl/asset_repository.py b/packages/adapters/sqlalchemy_impl/asset_repository.py new file mode 100644 index 000000000..f365f7928 --- /dev/null +++ b/packages/adapters/sqlalchemy_impl/asset_repository.py @@ -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 diff --git a/packages/adapters/sqlalchemy_impl/ingest_job_repository.py b/packages/adapters/sqlalchemy_impl/ingest_job_repository.py new file mode 100644 index 000000000..1ee98409c --- /dev/null +++ b/packages/adapters/sqlalchemy_impl/ingest_job_repository.py @@ -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 diff --git a/packages/adapters/sqlalchemy_impl/models.py b/packages/adapters/sqlalchemy_impl/models.py new file mode 100644 index 000000000..93a172b0a --- /dev/null +++ b/packages/adapters/sqlalchemy_impl/models.py @@ -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)) diff --git a/packages/adapters/sqlalchemy_impl/project_repository.py b/packages/adapters/sqlalchemy_impl/project_repository.py new file mode 100644 index 000000000..9a825928b --- /dev/null +++ b/packages/adapters/sqlalchemy_impl/project_repository.py @@ -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 diff --git a/tests/integration/test_sqlalchemy_repositories.py b/tests/integration/test_sqlalchemy_repositories.py new file mode 100644 index 000000000..c030a4caa --- /dev/null +++ b/tests/integration/test_sqlalchemy_repositories.py @@ -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()