Skip to content

Commit 9c0f941

Browse files
author
Travis Wrightsman
authored
feat(grz-db,grzctl): add PostgreSQL support (#459)
- add unit tests with postgres - fix migrations to work with both SQLite and PostgreSQL
1 parent 6eac517 commit 9c0f941

15 files changed

Lines changed: 452 additions & 73 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,9 @@ jobs:
249249
with:
250250
lfs: true
251251

252+
- name: Install PostgreSQL
253+
run: sudo apt-get --update install postgresql
254+
252255
- name: Download grz-check binary artifact
253256
uses: actions/download-artifact@v4
254257
with:

packages/grz-db/pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ dependencies = [
1818
"alembic[tz] >=1.16.1",
1919
"cryptography >=45.0.3",
2020
"sqlmodel >=0.0.27",
21-
"grz-pydantic-models >=2.4,<3"
21+
"grz-pydantic-models >=2.4,<3",
22+
"psycopg[binary] ~=3.2"
2223
]
2324

2425
[tool.uv.sources]

packages/grz-db/src/grz_db/migrations/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,6 @@ One can also look at the generated schema for a newly initialized database:
3030
```
3131
sqlite3 submission.db.sqlite .schema
3232
```
33+
34+
For PostgreSQL, Enums must be explicitly created if not added as part of a `create_table` operation (e.g. `add_column`).
35+
See the existing migrations for hints.

packages/grz-db/src/grz_db/migrations/versions/9bf36a91c87e_add_reporting_columns.py

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,35 +22,34 @@
2222
def upgrade() -> None:
2323
"""Upgrade schema."""
2424
op.add_column("submissions", sa.Column("submission_date", sa.Date(), nullable=True))
25+
submission_type_enum = sa.Enum("initial", "followup", "addition", "correction", "test", name="submissiontype")
26+
submission_type_enum.create(op.get_bind())
2527
op.add_column(
2628
"submissions",
27-
sa.Column(
28-
"submission_type", sa.Enum("initial", "followup", "addition", "correction", "test", name="submissiontype")
29-
),
29+
sa.Column("submission_type", submission_type_enum),
3030
)
3131
op.add_column("submissions", sa.Column("submitter_id", AutoString()))
3232
op.add_column("submissions", sa.Column("data_node_id", AutoString()))
33-
op.add_column(
34-
"submissions", sa.Column("disease_type", sa.Enum("oncological", "rare", "hereditary", name="diseasetype"))
33+
disease_type_enum = sa.Enum("oncological", "rare", "hereditary", name="diseasetype")
34+
disease_type_enum.create(op.get_bind())
35+
op.add_column("submissions", sa.Column("disease_type", disease_type_enum))
36+
library_type_enum = sa.Enum(
37+
"panel",
38+
"panel_lr",
39+
"wes",
40+
"wes_lr",
41+
"wgs",
42+
"wgs_lr",
43+
"wxs",
44+
"wxs_lr",
45+
"other",
46+
"unknown",
47+
name="librarytype",
3548
)
49+
library_type_enum.create(op.get_bind())
3650
op.add_column(
3751
"submissions",
38-
sa.Column(
39-
"library_type",
40-
sa.Enum(
41-
"panel",
42-
"panel_lr",
43-
"wes",
44-
"wes_lr",
45-
"wgs",
46-
"wgs_lr",
47-
"wxs",
48-
"wxs_lr",
49-
"other",
50-
"unknown",
51-
name="librarytype",
52-
),
53-
),
52+
sa.Column("library_type", library_type_enum),
5453
)
5554
op.add_column("submissions", sa.Column("basic_qc_passed", sa.Boolean()))
5655
op.add_column("submissions", sa.Column("consented", sa.Boolean()))

packages/grz-db/src/grz_db/migrations/versions/fb3df229a77b_initial_quartalsbericht_support.py

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -51,24 +51,47 @@ def upgrade() -> None:
5151
sa.Column("research_consent_missing_justifications", AutoString(), nullable=True),
5252
)
5353

54+
# explicitly create enums if needed (PostgreSQL)
55+
genomic_study_type_enum = sa.Enum("single", "duo", "trio", name="genomicstudytype")
56+
genomic_study_type_enum.create(op.get_bind())
57+
genomic_study_subtype_enum = sa.Enum("tumor_only", "tumor_germline", "germline_only", name="genomicstudysubtype")
58+
genomic_study_subtype_enum.create(op.get_bind())
59+
coverage_type_enum = sa.Enum(
60+
"GKV", "PKV", "BG", "SEL", "SOZ", "GPV", "PPV", "BEI", "SKT", "UNK", name="coveragetype"
61+
)
62+
coverage_type_enum.create(op.get_bind())
63+
5464
# we have to batch alter because SQLite doesn't support ALTER on column types
5565
with op.batch_alter_table("submissions") as batch_op:
5666
# will need to be repopulated in donors table before reporting, so just drop
5767
batch_op.drop_column("library_type")
5868
# modified/extra submission table columns
59-
batch_op.add_column(sa.Column("genomic_study_type", sa.Enum("single", "duo", "trio", name="genomicstudytype")))
69+
batch_op.add_column(sa.Column("genomic_study_type", genomic_study_type_enum))
6070
batch_op.add_column(
6171
sa.Column(
6272
"genomic_study_subtype",
63-
sa.Enum("tumor_only", "tumor_germline", "germline_only", name="genomicstudysubtype"),
73+
genomic_study_subtype_enum,
6474
),
6575
)
66-
batch_op.add_column(
67-
sa.Column(
68-
"coverage_type",
69-
sa.Enum("GKV", "PKV", "BG", "SEL", "SOZ", "GPV", "PPV", "BEI", "SKT", "UNK", name="coveragetype"),
70-
)
71-
)
76+
batch_op.add_column(sa.Column("coverage_type", coverage_type_enum))
77+
78+
# already should exist so don't need to re-create, just define
79+
# but must be postgresql.ENUM until fixed in sqlalchemy 2.1
80+
# see https://github.com/sqlalchemy/alembic/issues/1347
81+
library_type_enum = sa.dialects.postgresql.ENUM(
82+
"panel",
83+
"panel_lr",
84+
"wes",
85+
"wes_lr",
86+
"wgs",
87+
"wgs_lr",
88+
"wxs",
89+
"wxs_lr",
90+
"other",
91+
"unknown",
92+
name="librarytype",
93+
create_type=False,
94+
)
7295

7396
# new detailed QC results table
7497
op.create_table(
@@ -90,19 +113,7 @@ def upgrade() -> None:
90113
),
91114
sa.Column(
92115
"library_type",
93-
sa.Enum(
94-
"panel",
95-
"panel_lr",
96-
"wes",
97-
"wes_lr",
98-
"wgs",
99-
"wgs_lr",
100-
"wxs",
101-
"wxs_lr",
102-
"other",
103-
"unknown",
104-
name="librarytype",
105-
),
116+
library_type_enum,
106117
nullable=False,
107118
),
108119
sa.Column("percent_bases_above_quality_threshold_minimum_quality", sa.Float(), nullable=False),

packages/grz-db/src/grz_db/models/submission.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
Tan,
2626
)
2727
from pydantic import ConfigDict, field_serializer, field_validator
28-
from sqlalchemy import JSON, Column
28+
from sqlalchemy import JSON, Column, Enum
2929
from sqlalchemy import func as sqlfn
3030
from sqlalchemy.exc import IntegrityError
3131
from sqlalchemy.orm import selectinload
@@ -292,7 +292,11 @@ class Donor(SQLModel, table=True):
292292

293293
submission_id: str = Field(foreign_key="submissions.id", primary_key=True)
294294
pseudonym: str = Field(primary_key=True)
295-
relation: Relation
295+
# use values_callable so enum value is stored instead of name.
296+
# SQLite stores string of value instead of name because it doesn't have
297+
# native Enum support, so this keeps things consistent with other SQL
298+
# server implementations.
299+
relation: Relation = Field(sa_column=Column(Enum(Relation, values_callable=lambda e: [x.value for x in e])))
296300
library_types: set[LibraryType] = Field(sa_column=Column(SemicolonSeparatedStringSet))
297301
sequence_types: set[SequenceType] = Field(sa_column=Column(SemicolonSeparatedStringSet))
298302
sequence_subtypes: set[SequenceSubtype] = Field(sa_column=Column(SemicolonSeparatedStringSet))

packages/grz-db/uv.lock

Lines changed: 54 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/grzctl/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ dependencies = [
1919
"jsonschema >=4.23.0,<5",
2020
"pyyaml >=6.0.2,<7",
2121
"tqdm >=4.66.5,<5",
22+
"psycopg ~=3.2",
2223
"pydantic >=2.12,<3",
2324
"pydantic-settings >=2.11.0,<3",
2425
"platformdirs >=4.3.6,<5",
@@ -76,6 +77,8 @@ test = [
7677
"pytest-mock",
7778
"pytest-cov",
7879
"pytest-sugar",
80+
"pytest-postgresql ~=7.0.2",
81+
"psycopg",
7982
"moto[s3]",
8083
"numpy",
8184
"grz-common",

packages/grzctl/tests/cli/conftest.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,26 @@
1+
import shutil
12
from pathlib import Path
23

34
import click.testing
45
import cryptography.hazmat.primitives.serialization as cryptser
56
import grzctl.cli
7+
import psycopg
68
import pytest
79
import yaml
810
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
911
from grzctl.models.config import DbConfig
1012

1113

12-
@pytest.fixture
13-
def blank_database_config(tmp_path: Path) -> DbConfig:
14+
@pytest.fixture(
15+
params=[
16+
"sqlite",
17+
pytest.param(
18+
"postgresql",
19+
marks=pytest.mark.skipif(condition=shutil.which("pg_config") is None, reason="postgresql not detected"),
20+
),
21+
]
22+
)
23+
def blank_database_config(request: pytest.FixtureRequest, tmp_path: Path) -> DbConfig:
1424
private_key = Ed25519PrivateKey.generate()
1525
private_key_path = tmp_path / "alice.sec"
1626
with open(private_key_path, "wb") as private_key_file:
@@ -31,9 +41,14 @@ def blank_database_config(tmp_path: Path) -> DbConfig:
3141
# add the comment too
3242
public_key_file.write(b" alice")
3343

44+
database_url = "sqlite:///" + str((tmp_path / "submission.db.sqlite").resolve())
45+
if request.param == "postgresql":
46+
postgresql: psycopg.Connection = request.getfixturevalue("postgresql")
47+
database_url = f"postgresql+psycopg://{postgresql.info.user}:@{postgresql.info.host}:{postgresql.info.port}/{postgresql.info.dbname}"
48+
3449
return DbConfig(
3550
db={
36-
"database_url": "sqlite:///" + str((tmp_path / "submission.db.sqlite").resolve()),
51+
"database_url": database_url,
3752
"author": {
3853
"name": "alice",
3954
"private_key_path": str(private_key_path.resolve()),

0 commit comments

Comments
 (0)