Skip to content

Commit 7b277b9

Browse files
authored
Merge pull request #70 from ScilifelabDataCentre/dimensions-model-normalization
SQ-845: Normalize the dimensions table to sample and scaffold FK tables
2 parents c8b8981 + b6b1179 commit 7b277b9

17 files changed

Lines changed: 942 additions & 97 deletions

File tree

docker/benchmarking.dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
FROM python:3.12-slim
22

3-
RUN apt-get update && apt-get install -y git make && rm -rf /var/lib/apt/lists/*
3+
RUN apt-get update && apt-get install -y git make tabix && rm -rf /var/lib/apt/lists/*
44

55
RUN git clone https://github.com/endast/fake-vcf.git /opt/fake-vcf
66
WORKDIR /opt/fake-vcf

docs/development/database-migrations.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ docker compose -f docker/divbase_compose.yaml down && docker compose -f docker/d
3838
# Enter the running FastAPI container
3939
docker compose -f docker/divbase_compose.yaml exec -it fastapi sh
4040

41-
# Generate migration (use descriptive names)
41+
# Generate migration (use descriptive names). The data will be prepended automatically so it does not need to be included in the name
4242
alembic revision --autogenerate -m "write_your_useful_slug_here"
4343

4444
# Exit container - if needed - see NOTE below

packages/divbase-api/src/divbase_api/crud/vcf_dimensions.py

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,34 @@
66

77
from sqlalchemy import select
88
from sqlalchemy.ext.asyncio import AsyncSession
9+
from sqlalchemy.orm import selectinload
910

10-
from divbase_api.models.vcf_dimensions import SkippedVCFDB, VCFMetadataDB
11+
from divbase_api.models.vcf_dimensions import SkippedVCFDB, VCFMetadataDB, VCFMetadataSamplesDB, VCFMetadataScaffoldsDB
1112

1213
logger = logging.getLogger(__name__)
1314

1415

1516
async def get_vcf_metadata_by_project_async(db: AsyncSession, project_id: int) -> dict:
1617
"""
1718
FOR USER INTERACTIONS WITH API ONLY
18-
Get all VCF metadata entries for a given project ID.
19+
Get all VCF metadata entries for a given project ID, including the related sample and scaffold names.
20+
21+
VCFMetadataDB has a one-to-many relationship relationship with VCFMetadataSamplesDB and with VCFMetadataScaffoldsDB.
22+
23+
Eager load with selectinload is used to minimize the number of db queries (to 3 queries). It is used instead of table joins since these child tables can have a large amount of entries
24+
per parent VCF file, which would result in a large amount of duplicated data being loaded if table joins were used and be inefficient.
25+
In this eager load, 1 query is used to load all VCFMetadataDB entries (=VCF files) for the project, 1 query is used to load all related VCFMetadataSamplesDB entries (sample names)
26+
for those VCFMetadataDB entries, and 1 query is used to load all related VCFMetadataScaffoldsDB entries (scaffold names) for those VCFMetadataDB entries.
27+
28+
Lazy load, which is not used here, would instead result in 1 + 2N queries for N VCFMetadataDB entries (=VCF files) for the project, since each VCFMetadataDB entry would be queried
29+
separately for its related sample and scaffold names.
1930
"""
20-
stmt = select(VCFMetadataDB).where(VCFMetadataDB.project_id == project_id)
31+
32+
stmt = (
33+
select(VCFMetadataDB)
34+
.where(VCFMetadataDB.project_id == project_id)
35+
.options(selectinload(VCFMetadataDB.samples), selectinload(VCFMetadataDB.scaffolds))
36+
)
2137
result = await db.execute(stmt)
2238
entries = list(result.scalars().all())
2339

@@ -28,8 +44,8 @@ async def get_vcf_metadata_by_project_async(db: AsyncSession, project_id: int) -
2844
{
2945
"vcf_file_s3_key": entry.vcf_file_s3_key,
3046
"s3_version_id": entry.s3_version_id,
31-
"samples": entry.samples,
32-
"scaffolds": entry.scaffolds,
47+
"samples": [s.sample_name for s in entry.samples],
48+
"scaffolds": [s.scaffold_name for s in entry.scaffolds],
3349
"variant_count": entry.variant_count,
3450
"sample_count": entry.sample_count,
3551
"file_size_bytes": entry.file_size_bytes,
@@ -49,3 +65,41 @@ async def get_skipped_vcfs_by_project_async(db: AsyncSession, project_id: int) -
4965
stmt = select(SkippedVCFDB).where(SkippedVCFDB.project_id == project_id)
5066
result = await db.execute(stmt)
5167
return list(result.scalars().all())
68+
69+
70+
async def get_unique_samples_by_project_async(db: AsyncSession, project_id: int) -> list[str]:
71+
"""
72+
Get unique sample names across all VCF files from a project's dimensions entries.
73+
"""
74+
75+
stmt = (
76+
select(VCFMetadataSamplesDB.sample_name)
77+
.join(VCFMetadataDB, VCFMetadataSamplesDB.vcf_metadata_id == VCFMetadataDB.id)
78+
.where(VCFMetadataDB.project_id == project_id)
79+
.distinct()
80+
.order_by(VCFMetadataSamplesDB.sample_name)
81+
)
82+
result = await db.execute(stmt)
83+
return list(result.scalars().all())
84+
85+
86+
async def get_unique_scaffolds_by_project_async(db: AsyncSession, project_id: int) -> list[str]:
87+
"""
88+
Get unique scaffold names across all VCF files for a project.
89+
"""
90+
91+
stmt = (
92+
select(VCFMetadataScaffoldsDB.scaffold_name)
93+
.join(VCFMetadataDB, VCFMetadataScaffoldsDB.vcf_metadata_id == VCFMetadataDB.id)
94+
.where(VCFMetadataDB.project_id == project_id)
95+
.distinct()
96+
.order_by(VCFMetadataScaffoldsDB.scaffold_name)
97+
)
98+
result = await db.execute(stmt)
99+
scaffolds = list(result.scalars().all())
100+
101+
# Sort scaffold names in the same way as the dimensions show CLI does when returning all dimensions data: numeric first, then alphabetic
102+
# Numeric sorting of name strings results means that 10 comes after 2 for scaffolds that have numeric names.
103+
numeric = sorted([int(s) for s in scaffolds if s.isdigit()])
104+
non_numeric = sorted([s for s in scaffolds if not s.isdigit()])
105+
return [str(n) for n in numeric] + non_numeric
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
"""2026-02-20_normalize_dimensions_model
2+
3+
Revision ID: bde261f949a1
4+
Revises: faa3d0318fda
5+
Create Date: 2026-02-20 10:03:40.815406
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
from sqlalchemy.dialects import postgresql
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "bde261f949a1"
17+
down_revision: Union[str, Sequence[str], None] = "faa3d0318fda"
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
"""Upgrade schema."""
24+
# ### commands auto generated by Alembic - please adjust! ###
25+
op.create_table(
26+
"vcf_metadata_samples",
27+
sa.Column("vcf_metadata_id", sa.Integer(), nullable=False),
28+
sa.Column("sample_name", sa.String(), nullable=False),
29+
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
30+
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
31+
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
32+
sa.ForeignKeyConstraint(["vcf_metadata_id"], ["vcf_metadata.id"], ondelete="CASCADE"),
33+
sa.PrimaryKeyConstraint("id"),
34+
)
35+
op.create_index(op.f("ix_vcf_metadata_samples_id"), "vcf_metadata_samples", ["id"], unique=False)
36+
op.create_index(op.f("ix_vcf_metadata_samples_sample_name"), "vcf_metadata_samples", ["sample_name"], unique=False)
37+
op.create_index(
38+
op.f("ix_vcf_metadata_samples_vcf_metadata_id"), "vcf_metadata_samples", ["vcf_metadata_id"], unique=False
39+
)
40+
op.create_table(
41+
"vcf_metadata_scaffolds",
42+
sa.Column("vcf_metadata_id", sa.Integer(), nullable=False),
43+
sa.Column("scaffold_name", sa.String(), nullable=False),
44+
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
45+
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
46+
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
47+
sa.ForeignKeyConstraint(["vcf_metadata_id"], ["vcf_metadata.id"], ondelete="CASCADE"),
48+
sa.PrimaryKeyConstraint("id"),
49+
)
50+
op.create_index(op.f("ix_vcf_metadata_scaffolds_id"), "vcf_metadata_scaffolds", ["id"], unique=False)
51+
op.create_index(
52+
op.f("ix_vcf_metadata_scaffolds_scaffold_name"), "vcf_metadata_scaffolds", ["scaffold_name"], unique=False
53+
)
54+
op.create_index(
55+
op.f("ix_vcf_metadata_scaffolds_vcf_metadata_id"), "vcf_metadata_scaffolds", ["vcf_metadata_id"], unique=False
56+
)
57+
op.drop_index(op.f("ix_vcf_metadata_samples"), table_name="vcf_metadata")
58+
op.drop_column(
59+
"vcf_metadata", "scaffolds"
60+
) # NOTE! this drops previous data stored in the array column. The migration does not repopulate the FK tables. Run divbase-cli dimensions update instead
61+
op.drop_column("vcf_metadata", "samples") # NOTE! same here, run divbase-cli dimensions update instead
62+
# ### end Alembic commands ###
63+
64+
65+
def downgrade() -> None:
66+
"""Downgrade schema."""
67+
# ### commands auto generated by Alembic - please adjust! ###
68+
op.add_column(
69+
"vcf_metadata", sa.Column("samples", postgresql.ARRAY(sa.VARCHAR()), autoincrement=False, nullable=False)
70+
)
71+
op.add_column(
72+
"vcf_metadata", sa.Column("scaffolds", postgresql.ARRAY(sa.VARCHAR()), autoincrement=False, nullable=False)
73+
)
74+
op.create_index(op.f("ix_vcf_metadata_samples"), "vcf_metadata", ["samples"], unique=False)
75+
op.drop_index(op.f("ix_vcf_metadata_scaffolds_vcf_metadata_id"), table_name="vcf_metadata_scaffolds")
76+
op.drop_index(op.f("ix_vcf_metadata_scaffolds_scaffold_name"), table_name="vcf_metadata_scaffolds")
77+
op.drop_index(op.f("ix_vcf_metadata_scaffolds_id"), table_name="vcf_metadata_scaffolds")
78+
op.drop_table("vcf_metadata_scaffolds")
79+
op.drop_index(op.f("ix_vcf_metadata_samples_vcf_metadata_id"), table_name="vcf_metadata_samples")
80+
op.drop_index(op.f("ix_vcf_metadata_samples_sample_name"), table_name="vcf_metadata_samples")
81+
op.drop_index(op.f("ix_vcf_metadata_samples_id"), table_name="vcf_metadata_samples")
82+
op.drop_table("vcf_metadata_samples")
83+
# ### end Alembic commands ###

packages/divbase-api/src/divbase_api/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,6 @@
2929
"ProjectVersionDB",
3030
"AnnouncementDB",
3131
"AnnouncementTarget",
32+
"VCFMetadataSamplesDB",
33+
"VCFMetadataScaffoldsDB",
3234
]

packages/divbase-api/src/divbase_api/models/vcf_dimensions.py

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from typing import TYPE_CHECKING
66

77
from sqlalchemy import BigInteger, ForeignKey, Integer, String, UniqueConstraint
8-
from sqlalchemy.dialects.postgresql import ARRAY
98
from sqlalchemy.orm import Mapped, mapped_column, relationship
109

1110
from divbase_api.models.base import BaseDBModel
@@ -34,15 +33,20 @@ class VCFMetadataDB(BaseDBModel):
3433
)
3534
s3_version_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
3635
file_size_bytes: Mapped[int] = mapped_column(BigInteger)
37-
samples: Mapped[list[str]] = mapped_column(ARRAY(String), index=True) # Sample names
38-
scaffolds: Mapped[list[str]] = mapped_column(ARRAY(String)) # Scaffold/chromosome names
3936
variant_count: Mapped[int] = mapped_column(BigInteger)
4037
sample_count: Mapped[int] = mapped_column(Integer)
4138

4239
__table_args__ = (UniqueConstraint("vcf_file_s3_key", "project_id", name="unique_vcf_per_project"),)
4340

4441
project: Mapped["ProjectDB"] = relationship("ProjectDB", back_populates="vcf_metadata")
4542

43+
samples: Mapped[list["VCFMetadataSamplesDB"]] = relationship(
44+
"VCFMetadataSamplesDB", back_populates="vcf_metadata", cascade="all, delete-orphan"
45+
)
46+
scaffolds: Mapped[list["VCFMetadataScaffoldsDB"]] = relationship(
47+
"VCFMetadataScaffoldsDB", back_populates="vcf_metadata", cascade="all, delete-orphan"
48+
)
49+
4650

4751
class SkippedVCFDB(BaseDBModel):
4852
"""
@@ -63,3 +67,35 @@ class SkippedVCFDB(BaseDBModel):
6367
__table_args__ = (UniqueConstraint("vcf_file_s3_key", "project_id", name="unique_skipped_vcf_per_project"),)
6468

6569
project: Mapped["ProjectDB"] = relationship("ProjectDB", back_populates="skipped_vcf_files")
70+
71+
72+
class VCFMetadataSamplesDB(BaseDBModel):
73+
"""
74+
DB model for one-to-many relationship between VCF file metadata and the sample names in the VCF files.
75+
"""
76+
77+
__tablename__ = "vcf_metadata_samples"
78+
79+
vcf_metadata_id: Mapped[int] = mapped_column(
80+
ForeignKey("vcf_metadata.id", ondelete="CASCADE"),
81+
index=True,
82+
)
83+
sample_name: Mapped[str] = mapped_column(String, index=True)
84+
85+
vcf_metadata: Mapped["VCFMetadataDB"] = relationship("VCFMetadataDB", back_populates="samples")
86+
87+
88+
class VCFMetadataScaffoldsDB(BaseDBModel):
89+
"""
90+
DB model for one-to-many relationship between VCF file metadata and the scaffold names in the VCF files.
91+
"""
92+
93+
__tablename__ = "vcf_metadata_scaffolds"
94+
95+
vcf_metadata_id: Mapped[int] = mapped_column(
96+
ForeignKey("vcf_metadata.id", ondelete="CASCADE"),
97+
index=True,
98+
)
99+
scaffold_name: Mapped[str] = mapped_column(String, index=True)
100+
101+
vcf_metadata: Mapped["VCFMetadataDB"] = relationship("VCFMetadataDB", back_populates="scaffolds")

packages/divbase-api/src/divbase_api/routes/queries.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
SampleMetadataQueryRequest,
3030
SampleMetadataQueryTaskResult,
3131
)
32+
from divbase_lib.exceptions import DimensionsNotUpToDateWithBucketError
3233

3334
logging.basicConfig(level=settings.api.log_level, handlers=[logging.StreamHandler(sys.stderr)])
3435

@@ -84,6 +85,8 @@ async def sample_metadata_query(
8485
except VCFDimensionsEntryMissingError:
8586
# Catch and raise anew to avoid duplications in the error message
8687
raise VCFDimensionsEntryMissingError(project_name=project.name) from None
88+
except DimensionsNotUpToDateWithBucketError as e:
89+
raise DimensionsNotUpToDateWithBucketError(str(e)) from None
8790
except celery.exceptions.TimeoutError: # type: ignore
8891
error_message = (
8992
f"The query is still being processed and has Task ID: {results.id}. \n"

packages/divbase-api/src/divbase_api/routes/vcf_dimensions.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
from divbase_api.crud.task_history import create_task_history_entry
1212
from divbase_api.crud.vcf_dimensions import (
1313
get_skipped_vcfs_by_project_async,
14+
get_unique_samples_by_project_async,
15+
get_unique_scaffolds_by_project_async,
1416
get_vcf_metadata_by_project_async,
1517
)
1618
from divbase_api.db import get_db
@@ -19,7 +21,12 @@
1921
from divbase_api.models.projects import ProjectDB, ProjectRoles
2022
from divbase_api.models.users import UserDB
2123
from divbase_api.worker.tasks import update_vcf_dimensions_task
22-
from divbase_lib.api_schemas.vcf_dimensions import DimensionsShowResult, DimensionUpdateKwargs
24+
from divbase_lib.api_schemas.vcf_dimensions import (
25+
DimensionsSamplesResult,
26+
DimensionsScaffoldsResult,
27+
DimensionsShowResult,
28+
DimensionUpdateKwargs,
29+
)
2330

2431
logger = logging.getLogger(__name__)
2532

@@ -102,3 +109,43 @@ async def update_vcf_dimensions_endpoint(
102109
)
103110

104111
return job_id
112+
113+
114+
@vcf_dimensions_router.get(
115+
"/projects/{project_name}/samples", status_code=status.HTTP_200_OK, response_model=DimensionsSamplesResult
116+
)
117+
async def list_unique_samples_endpoint(
118+
project_name: str,
119+
project_and_user_and_role: tuple[ProjectDB, UserDB, ProjectRoles] = Depends(get_project_member),
120+
db: AsyncSession = Depends(get_db),
121+
) -> DimensionsSamplesResult:
122+
"""Get all unique sample names across project VCFs."""
123+
124+
project, current_user, role = project_and_user_and_role
125+
126+
if not has_required_role(role, ProjectRoles.READ):
127+
raise AuthorizationError("You don't have permission to view VCF dimensions for this project.")
128+
129+
result = await get_unique_samples_by_project_async(db, project.id)
130+
131+
return DimensionsSamplesResult(unique_samples=result)
132+
133+
134+
@vcf_dimensions_router.get(
135+
"/projects/{project_name}/scaffolds", status_code=status.HTTP_200_OK, response_model=DimensionsScaffoldsResult
136+
)
137+
async def list_unique_scaffolds_endpoint(
138+
project_name: str,
139+
project_and_user_and_role: tuple[ProjectDB, UserDB, ProjectRoles] = Depends(get_project_member),
140+
db: AsyncSession = Depends(get_db),
141+
) -> DimensionsScaffoldsResult:
142+
"""Get all unique scaffold names across project VCFs."""
143+
144+
project, current_user, role = project_and_user_and_role
145+
146+
if not has_required_role(role, ProjectRoles.READ):
147+
raise AuthorizationError("You don't have permission to view VCF dimensions for this project.")
148+
149+
result = await get_unique_scaffolds_by_project_async(db, project.id)
150+
151+
return DimensionsScaffoldsResult(unique_scaffolds=result)

0 commit comments

Comments
 (0)