import uuid from fastapi import APIRouter, Query from sqlalchemy import Integer, case, cast, func, select from sqlalchemy.orm import selectinload from app.db import SessionDep from app.models import Prediction, Variant from app.schemas import VariantOut, VariantPage router = APIRouter() # Karyotype order (1..22, X, Y, MT) instead of text order, where "10" sorts before "2". _chrom = func.regexp_replace(Variant.chrom, "^chr", "", "i") CHROM_ORDER = case( (_chrom.regexp_match("^[0-9]+$"), cast(_chrom, Integer)), (_chrom == "X", 23), (_chrom == "Y", 24), (_chrom.in_(["M", "MT"]), 25), else_=26, ) @router.get("", response_model=VariantPage) async def list_variants( job_id: uuid.UUID, session: SessionDep, gene: str | None = None, impact: str | None = Query(None, pattern="^(HIGH|MODERATE|LOW|MODIFIER)$"), max_af: float | None = Query(None, ge=0, le=1), min_score: float | None = Query(None, ge=0, le=1), limit: int = Query(50, ge=1, le=500), offset: int = Query(0, ge=0), ) -> VariantPage: stmt = select(Variant).where(Variant.job_id == job_id) if gene: stmt = stmt.where(Variant.gene == gene.upper()) if impact: stmt = stmt.where(Variant.impact == impact) if max_af is not None: stmt = stmt.where((Variant.gnomad_af.is_(None)) | (Variant.gnomad_af <= max_af)) if min_score is not None: stmt = stmt.join(Prediction, Prediction.variant_id == Variant.id).where( Prediction.score >= min_score ) total = await session.scalar(select(func.count()).select_from(stmt.subquery())) rows = await session.scalars( stmt.options(selectinload(Variant.prediction)) # id breaks ties between split multiallelics at one position, keeping pages stable. .order_by(CHROM_ORDER, Variant.chrom, Variant.pos, Variant.id) .limit(limit) .offset(offset) ) return VariantPage( items=[VariantOut.model_validate(v) for v in rows], total=total or 0, limit=limit, offset=offset, )