diff --git a/backend/app/db/base_class.py b/backend/app/db/base_class.py index fa2b68a5..92ce9cf3 100644 --- a/backend/app/db/base_class.py +++ b/backend/app/db/base_class.py @@ -1,5 +1,45 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import String, TypeDecorator +from sqlalchemy.dialects.postgresql import UUID as PG_UUID from sqlalchemy.orm import DeclarativeBase +class GUID(TypeDecorator): + """Platform-independent UUID type. + + PostgreSQL keeps native UUID columns; SQLite test databases store canonical + UUID strings so bind/result behavior stays consistent. + """ + + impl = String + cache_ok = True + + def load_dialect_impl(self, dialect): + if dialect.name == "sqlite": + return dialect.type_descriptor(String(36)) + return dialect.type_descriptor(PG_UUID(as_uuid=True)) + + def process_bind_param(self, value, dialect): + if value is None: + return None + if dialect.name == "sqlite": + return str(value) + return value + + def process_result_value(self, value, dialect): + if value is None: + return None + if dialect.name == "sqlite" and isinstance(value, str): + return uuid.UUID(value) + return value + + class Base(DeclarativeBase): + type_annotation_map = { + uuid.UUID: GUID(), + } + pass diff --git a/backend/app/models/study_setup_config.py b/backend/app/models/study_setup_config.py index 0fb8ad27..26dda9d8 100644 --- a/backend/app/models/study_setup_config.py +++ b/backend/app/models/study_setup_config.py @@ -4,12 +4,14 @@ from typing import Optional import uuid from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint, func +from sqlalchemy import DateTime, ForeignKey, Integer, JSON, UniqueConstraint, func from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import Mapped, mapped_column from app.db.base_class import Base +JSONB_TYPE = JSON().with_variant(JSONB, "postgresql") + class StudySetupConfig(Base): __tablename__ = "study_setup_configs" @@ -18,10 +20,10 @@ class StudySetupConfig(Base): id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), nullable=False, index=True) version: Mapped[int] = mapped_column(Integer, nullable=False, default=1) - config: Mapped[dict] = mapped_column(JSONB, nullable=False) + config: Mapped[dict] = mapped_column(JSONB_TYPE, nullable=False) publish_status: Mapped[str] = mapped_column(default="DRAFT", nullable=False) - published_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True) - published_project_snapshot: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + published_config: Mapped[dict | None] = mapped_column(JSONB_TYPE, nullable=True) + published_project_snapshot: Mapped[dict | None] = mapped_column(JSONB_TYPE, nullable=True) current_branch_name: Mapped[str] = mapped_column(nullable=False, default="main") active_branch_base_version_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True diff --git a/backend/app/models/study_setup_config_version.py b/backend/app/models/study_setup_config_version.py index 52d6fca1..c74a2a25 100644 --- a/backend/app/models/study_setup_config_version.py +++ b/backend/app/models/study_setup_config_version.py @@ -4,12 +4,14 @@ from typing import Optional import uuid from datetime import datetime -from sqlalchemy import DateTime, ForeignKey, Integer, UniqueConstraint, func +from sqlalchemy import DateTime, ForeignKey, Integer, JSON, UniqueConstraint, func from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.orm import Mapped, mapped_column from app.db.base_class import Base +JSONB_TYPE = JSON().with_variant(JSONB, "postgresql") + class StudySetupConfigVersion(Base): __tablename__ = "study_setup_config_versions" @@ -35,8 +37,8 @@ class StudySetupConfigVersion(Base): merged_from_version_id: Mapped[Optional[uuid.UUID]] = mapped_column( UUID(as_uuid=True), ForeignKey("study_setup_config_versions.id", ondelete="SET NULL"), nullable=True ) - config: Mapped[dict] = mapped_column(JSONB, nullable=False) - published_project_snapshot: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + config: Mapped[dict] = mapped_column(JSONB_TYPE, nullable=False) + published_project_snapshot: Mapped[dict | None] = mapped_column(JSONB_TYPE, nullable=True) published_by: Mapped[Optional[uuid.UUID]] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) published_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())