From 6be7bddd4f8168d17cc25104082abf4d91a80af9 Mon Sep 17 00:00:00 2001 From: gkennos Date: Mon, 18 May 2026 22:16:24 +1000 Subject: [PATCH 01/14] updated some default postgres import behaviour --- omop_alchemy/maintenance/cli.py | 32 +++++------- omop_alchemy/maintenance/load_vocab.py | 10 ++-- tests/test_load_vocab_source.py | 68 ++++++++++++++++++++++++-- 3 files changed, 79 insertions(+), 31 deletions(-) diff --git a/omop_alchemy/maintenance/cli.py b/omop_alchemy/maintenance/cli.py index d7f640f..1ad4727 100644 --- a/omop_alchemy/maintenance/cli.py +++ b/omop_alchemy/maintenance/cli.py @@ -826,8 +826,8 @@ def load_vocab_source_command( help="CSV merge strategy passed to the ORM loader. Defaults to non-destructive `upsert`; use `replace` to overwrite matching primary keys.", ), chunksize: int | None = typer.Option( - None, - help="Chunk size for fallback ORM CSV loading to reduce memory usage on large Athena files.", + 100_000, + help="Chunk size for fallback ORM CSV loading. Defaults to 100 000 rows; pass 0 to disable chunking.", ), dry_run: bool = typer.Option(False, "--dry-run"), ) -> None: @@ -895,25 +895,15 @@ def _update_progress(event: VocabularyLoadProgress) -> None: ) ) - if chunksize is None: - report = load_vocab_source( - engine, - source_path=connection_defaults.athena_source, - db_schema=connection_defaults.db_schema, - dry_run=dry_run, - merge_strategy=merge_strategy, - progress_callback=_update_progress, - ) - else: - report = load_vocab_source( - engine, - source_path=connection_defaults.athena_source, - db_schema=connection_defaults.db_schema, - dry_run=dry_run, - merge_strategy=merge_strategy, - chunksize=chunksize, - progress_callback=_update_progress, - ) + report = load_vocab_source( + engine, + source_path=connection_defaults.athena_source, + db_schema=connection_defaults.db_schema, + dry_run=dry_run, + merge_strategy=merge_strategy, + chunksize=chunksize or None, + progress_callback=_update_progress, + ) progress.update( task_id, completed=100.0, diff --git a/omop_alchemy/maintenance/load_vocab.py b/omop_alchemy/maintenance/load_vocab.py index a0ea2af..ec0cf25 100644 --- a/omop_alchemy/maintenance/load_vocab.py +++ b/omop_alchemy/maintenance/load_vocab.py @@ -150,7 +150,7 @@ def _load_vocab_model_csv( model: VocabularyModel, csv_path: Path, merge_strategy: str, - quote_mode: str = "csv", + quote_mode: str = "auto", chunksize: int | None = None, ) -> int: load_kwargs: dict[str, object] = { @@ -271,7 +271,7 @@ def load_vocab_source( db_schema: str | None = None, dry_run: bool = False, merge_strategy: str = "replace", - chunksize: int | None = None, + chunksize: int | None = 100_000, progress_callback: VocabularyLoadProgressCallback | None = None, ) -> VocabularyLoadReport: _ensure_supported_backend(engine) @@ -396,7 +396,7 @@ def load_vocab_source( row_count=None, csv_path=str(csv_path), required=required, - detail="Athena CSV would be loaded via staged ORM CSV loader using tab-delimited input and literal quote mode", + detail="Athena CSV would be loaded via staged ORM CSV loader using tab-delimited input and auto-detected quote mode", ) ) continue @@ -405,7 +405,7 @@ def load_vocab_source( "model": model, "csv_path": csv_path, "merge_strategy": merge_strategy, - "quote_mode": "literal", + "quote_mode": "auto", } if chunksize is not None: loader_kwargs["chunksize"] = chunksize @@ -465,7 +465,7 @@ def load_vocab_source( row_count=row_count, csv_path=str(csv_path), required=required, - detail="Athena CSV loaded via staged ORM CSV loader using tab-delimited input and literal quote mode", + detail="Athena CSV loaded via staged ORM CSV loader using tab-delimited input and auto-detected quote mode", ) ) if not dry_run: diff --git a/tests/test_load_vocab_source.py b/tests/test_load_vocab_source.py index a6fa1bc..6a91cb0 100644 --- a/tests/test_load_vocab_source.py +++ b/tests/test_load_vocab_source.py @@ -67,7 +67,8 @@ def fake_load_vocab_model_csv( model, csv_path, merge_strategy, - quote_mode="csv", + quote_mode="auto", + chunksize=None, ) -> int: loaded_tables.append((model.__tablename__, merge_strategy, quote_mode, csv_path)) return 1 @@ -88,7 +89,7 @@ def fake_load_vocab_model_csv( assert all(result_by_name[model.__tablename__].status == "loaded" for model in REQUIRED_VOCAB_MODELS) assert all(result_by_name[model.__tablename__].status == "skipped" for model in OPTIONAL_VOCAB_MODELS) assert all(merge_strategy == "replace" for _, merge_strategy, _, _ in loaded_tables) - assert all(quote_mode == "literal" for _, _, quote_mode, _ in loaded_tables) + assert all(quote_mode == "auto" for _, _, quote_mode, _ in loaded_tables) assert {table_name for table_name, _, _, _ in loaded_tables} == { model.__tablename__ for model in REQUIRED_VOCAB_MODELS @@ -163,6 +164,7 @@ def fake_load_vocab_source( db_schema: str | None = None, dry_run: bool = False, merge_strategy: str = "upsert", + chunksize: int | None = None, progress_callback=None, ): from omop_alchemy.maintenance.load_vocab import VocabularyLoadReport, VocabularyLoadResult @@ -302,7 +304,8 @@ def fake_load_vocab_model_csv( model, csv_path, merge_strategy, - quote_mode="csv", + quote_mode="auto", + chunksize=None, ) -> int: loaded_order.append(model.__tablename__) return 1 @@ -333,7 +336,8 @@ def fake_load_vocab_model_csv( model, csv_path, merge_strategy, - quote_mode="csv", + quote_mode="auto", + chunksize=None, ) -> int: return 1 @@ -360,7 +364,7 @@ def test_load_vocab_source_wraps_failed_table_load(monkeypatch, tmp_path): engine = sa.create_engine(f"sqlite:///{tmp_path / 'load_vocab_source_error.db'}", future=True) source_path = _build_required_athena_source(tmp_path) - def fake_load_vocab_model_csv(session, *, model, csv_path, merge_strategy, quote_mode="csv"): + def fake_load_vocab_model_csv(session, *, model, csv_path, merge_strategy, quote_mode="auto", chunksize=None): if model.__tablename__ == "domain": raise sa.exc.ProgrammingError( "COPY domain FROM STDIN", @@ -470,3 +474,57 @@ def fail_load_vocab_source(*args, **kwargs): assert result.exit_code == 1 assert "Database operation failed: ProgrammingError." in result.stdout assert "value too long for type character varying(255)" in result.stdout + + +def test_load_vocab_source_uses_csv_not_literal_quote_mode(monkeypatch, tmp_path): + """Regression: Athena load must use csv quote mode so that quoted concept_name + values are not padded with surrounding double-quote characters, which would + cause 'value too long for type character varying(255)' on CONCEPT.csv.""" + engine = sa.create_engine(f"sqlite:///{tmp_path / 'quote_mode_regression.db'}", future=True) + + # Build a tab-delimited CSV where concept_name is exactly 255 chars when + # unquoted, but would be 257 chars if the surrounding CSV quotes were kept + # as literal characters (the literal-mode bug). + source_path = tmp_path / "athena_source" + source_path.mkdir() + + long_name = "A" * 255 + for model in REQUIRED_VOCAB_MODELS: + table_name = model.__tablename__.upper() + csv_path = source_path / f"{table_name}.csv" + if table_name == "CONCEPT": + csv_path.write_text( + "concept_id\tconcept_name\tdomain_id\tvocabulary_id\t" + "concept_class_id\tstandard_concept\tconcept_code\t" + "valid_start_date\tvalid_end_date\tinvalid_reason\n" + f'4715176\t"{long_name}"\t...\t...\t...\t\t...\t20000101\t20991231\t\n', + encoding="utf-8", + ) + else: + csv_path.write_text("stub\n", encoding="utf-8") + + received_quote_modes: list[str] = [] + + def fake_load_vocab_model_csv( + session, + *, + model, + csv_path, + merge_strategy, + quote_mode="auto", + chunksize=None, + ) -> int: + received_quote_modes.append(quote_mode) + return 1 + + monkeypatch.setattr( + "omop_alchemy.maintenance.load_vocab._load_vocab_model_csv", + fake_load_vocab_model_csv, + ) + + load_vocab_source(engine, source_path=source_path) + + assert all(mode == "auto" for mode in received_quote_modes), ( + f"Expected all tables to use quote_mode='auto', got: {received_quote_modes}" + ) + assert "literal" not in received_quote_modes From a82a4201b5d4ac38f297380d95b3de52f0677ea6 Mon Sep 17 00:00:00 2001 From: georgie Date: Tue, 19 May 2026 15:07:30 +1000 Subject: [PATCH 02/14] removing notebooks that have gone stale with recent changes will add back in later --- .gitignore | 3 +- notebooks/00_select_test_fixtures.ipynb | 293 ---- notebooks/01_validate_model.ipynb | 255 ---- notebooks/02_test_load.ipynb | 476 ------- notebooks/03_basic_model_query_demo.ipynb | 1205 ----------------- notebooks/04_timeline.ipynb | 142 -- notebooks/05_concept_resolver.ipynb | 308 ----- .../ORMforResearchReadyData_APAC2023.pdf | Bin 222379 -> 0 bytes notebooks/concept_enums.py | 207 --- 9 files changed, 2 insertions(+), 2887 deletions(-) delete mode 100644 notebooks/00_select_test_fixtures.ipynb delete mode 100644 notebooks/01_validate_model.ipynb delete mode 100644 notebooks/02_test_load.ipynb delete mode 100644 notebooks/03_basic_model_query_demo.ipynb delete mode 100644 notebooks/04_timeline.ipynb delete mode 100644 notebooks/05_concept_resolver.ipynb delete mode 100644 notebooks/ORMforResearchReadyData_APAC2023.pdf delete mode 100644 notebooks/concept_enums.py diff --git a/.gitignore b/.gitignore index 3b77ef3..2532721 100644 --- a/.gitignore +++ b/.gitignore @@ -66,4 +66,5 @@ logging/ _temp/ temp/ *.dump -*.bak \ No newline at end of file +*.bak +notebooks/ \ No newline at end of file diff --git a/notebooks/00_select_test_fixtures.ipynb b/notebooks/00_select_test_fixtures.ipynb deleted file mode 100644 index 8385b37..0000000 --- a/notebooks/00_select_test_fixtures.ipynb +++ /dev/null @@ -1,293 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "7113aac3", - "metadata": {}, - "outputs": [], - "source": [ - "from omop_alchemy import get_engine_name, load_environment, TEST_PATH, ROOT_PATH\n", - "from orm_loader.helpers import get_logger\n", - "from dotenv import load_dotenv\n", - "from pathlib import Path\n", - "import os\n", - "import pandas as pd\n", - "# old enumerator classes from monolithic version of omop_alchemy - selection of cancer-relevant codes\n", - "import concept_enums\n", - "\n", - "base_path = TEST_PATH / \"fixtures\" / \"athena_source\"\n", - "load_dotenv()\n", - "source_path = Path(os.getenv('SOURCE_PATH', 'update/path/to/athena/source/as/required'))" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "d7b63035", - "metadata": {}, - "outputs": [], - "source": [ - "concept = pd.read_csv(source_path / 'CONCEPT.csv', delimiter='\\t', low_memory=False)\n", - "concept_class = pd.read_csv(source_path / 'CONCEPT_CLASS.csv', delimiter='\\t')\n", - "relationship = pd.read_csv(source_path / 'RELATIONSHIP.csv', delimiter='\\t')\n", - "domain = pd.read_csv(source_path / 'DOMAIN.csv', delimiter='\\t')\n", - "vocabulary = pd.read_csv(source_path / 'VOCABULARY.csv', delimiter='\\t')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bff8c220", - "metadata": {}, - "outputs": [], - "source": [ - "required_concepts = set(concept_class.concept_class_concept_id) | set(relationship.relationship_concept_id) | set(domain.domain_concept_id) | set(vocabulary.vocabulary_concept_id)\n", - "required_concepts_df = concept[concept.concept_id.isin(required_concepts)]\n", - "\n", - "selected = []\n", - "for d in set(domain.domain_id):\n", - " try:\n", - " c = concept[(concept.domain_id == d) & (concept.standard_concept == 'S')]\n", - " selected.append(c.sample(min(50, len(c)), random_state=1))\n", - " except ValueError:\n", - " print(f\"Not enough standard concepts in domain {d}\")\n", - " pass" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d4b273fa", - "metadata": {}, - "outputs": [], - "source": [ - "standard_concept_by_domain_df = pd.concat(selected)\n", - "\n", - "additional_test_concepts = set([x for y in \n", - " [concept_enums.__dict__[cls].member_values() \n", - " for cls in dir(concept_enums) \n", - " if hasattr(concept_enums.__dict__[cls], 'member_values')\n", - " ] \n", - " for x in y])\n", - "\n", - "additional_test_concept_df = concept[concept.concept_id.isin(additional_test_concepts)]\n", - "\n", - "metadata = concept[concept.domain_id == 'Metadata']\n", - "language = concept[concept.domain_id == 'Language']\n", - "locations = concept[(concept.concept_class_id=='Location') & (concept.standard_concept.notna())].sample(frac=0.1, replace=False)\n", - "\n", - "additional_cancer_ones = []\n", - "\n", - "for vocab, frac in {'Cancer Modifier': 1.0, 'HemOnc': 0.1, 'ICDO3': 0.05}.items():\n", - " additional_cancer_ones.append(concept[(concept.vocabulary_id == vocab) & concept.standard_concept.notna()].sample(frac=frac, replace=False))\n", - "\n", - "cancer_specific_df = pd.concat(additional_cancer_ones)\n", - "\n", - "selected_concept_df = pd.concat(\n", - " [\n", - " standard_concept_by_domain_df,\n", - " required_concepts_df,\n", - " additional_test_concept_df,\n", - " cancer_specific_df,\n", - " locations,\n", - " metadata,\n", - " language\n", - " ]\n", - ").drop_duplicates()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d40f0ebd", - "metadata": {}, - "outputs": [], - "source": [ - "selected_relationships = []\n", - "\n", - "for concept_rel in pd.read_csv(source_path / 'CONCEPT_RELATIONSHIP.csv', delimiter='\\t', low_memory=False, chunksize=100000):\n", - " filtered = concept_rel[\n", - " (concept_rel.concept_id_1.isin(selected_concept_df.concept_id)) &\n", - " (concept_rel.concept_id_2.isin(selected_concept_df.concept_id))\n", - " ]\n", - " if not filtered.empty:\n", - " selected_relationships.append(filtered)\n", - "\n", - "selected_ancestry = []\n", - "\n", - "for concept_anc in pd.read_csv(source_path / 'CONCEPT_ANCESTOR.csv', delimiter='\\t', low_memory=False, chunksize=100000):\n", - " filtered = concept_anc[\n", - " (concept_anc.ancestor_concept_id.isin(selected_concept_df.concept_id)) &\n", - " (concept_anc.descendant_concept_id.isin(selected_concept_df.concept_id))\n", - " ]\n", - " if not filtered.empty:\n", - " selected_ancestry.append(filtered)\n", - "\n", - "selected_synonyms = []\n", - "\n", - "for concept_syn in pd.read_csv(source_path / 'CONCEPT_SYNONYM.csv', delimiter='\\t', low_memory=False, chunksize=100000):\n", - " filtered = concept_syn[\n", - " (concept_syn.concept_id.isin(selected_concept_df.concept_id))\n", - " ]\n", - " if not filtered.empty:\n", - " selected_synonyms.append(filtered)\n", - "\n", - "\n", - "selected_relationship_df = pd.concat(selected_relationships)\n", - "selected_ancestry_df = pd.concat(selected_ancestry)\n", - "selected_synonyms_df = pd.concat(selected_synonyms)\n", - "\n", - "\n", - "selected_relationship_df.to_csv(base_path / 'CONCEPT_RELATIONSHIP.csv', sep='\\t', index=False)\n", - "selected_synonyms_df.to_csv(base_path / 'CONCEPT_SYNONYM.csv', sep='\\t', index=False)\n", - "selected_ancestry_df.to_csv(base_path / 'CONCEPT_ANCESTOR.csv', sep='\\t', index=False)\n", - "selected_concept_df.to_csv(base_path / 'CONCEPT.csv', sep='\\t', index=False)\n", - "domain.to_csv(base_path / 'DOMAIN.csv', sep='\\t', index=False)\n", - "vocabulary.to_csv(base_path / 'VOCABULARY.csv', sep='\\t', index=False)\n", - "relationship.to_csv(base_path / 'RELATIONSHIP.csv', sep='\\t', index=False)\n", - "concept_class.to_csv(base_path / 'CONCEPT_CLASS.csv', sep='\\t', index=False)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9c4c1353", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "796f5be8", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c9c5b8b3", - "metadata": {}, - "outputs": [], - "source": [ - "for f in [domain, vocabulary, relationship, concept_class, selected_relationship_df, selected_ancestry_df, selected_synonyms_df]:\n", - " for col in f.columns:\n", - " if 'concept_id' in col:\n", - " if len(f[~f[col].isin(selected_concept_df.concept_id)]) > 0:\n", - " raise ValueError(f\"Found concept_id in {col} not in selected concepts\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b465bc6c", - "metadata": {}, - "outputs": [], - "source": [ - "assert len(selected_relationship_df[~selected_relationship_df.relationship_id.isin(relationship.relationship_id.unique())]) == 0, \"Found relationship_id not in selected relationships\"\n", - "assert len(concept[~concept.concept_class_id.isin(concept_class.concept_class_id.unique())]) == 0, \"Found concept_class_id not in selected concepts\"\n", - "assert len(concept[~concept.domain_id.isin(domain.domain_id.unique())]) == 0, \"Found domain_id not in selected domains\"\n", - "assert len(concept[~concept.vocabulary_id.isin(vocabulary.vocabulary_id.unique())]) == 0, \"Found vocabulary_id not in selected vocabularies\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "f65cc24f", - "metadata": {}, - "outputs": [], - "source": [ - "for f in [selected_concept_df, domain, vocabulary, relationship, concept_class, selected_relationship_df, selected_ancestry_df]:\n", - " assert(len(f[f.duplicated()]) == 0), f\"Found duplicated rows in {f}\"" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "97014890", - "metadata": {}, - "outputs": [], - "source": [ - "# this is the import issue...TODO: add pk null normalisation on load\n", - "vocabulary.loc[vocabulary.vocabulary_id.isna(), 'vocabulary_id'] = 'Unknown_Vocabulary'" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "322e679f", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "4ff54924", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "8326f2a3", - "metadata": {}, - "outputs": [], - "source": [ - "metadata[metadata.concept_id==1147138]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "dc803944", - "metadata": {}, - "outputs": [], - "source": [ - "len(selected_concept_df)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "acb592e2", - "metadata": {}, - "outputs": [], - "source": [ - "os.environ.get('SOURCE_PATH')" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c6b7cfd3", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "omop-alchemy (3.13.3)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/01_validate_model.ipynb b/notebooks/01_validate_model.ipynb deleted file mode 100644 index b18e149..0000000 --- a/notebooks/01_validate_model.ipynb +++ /dev/null @@ -1,255 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "id": "3175451e", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-01-22 15:26:50,588 | INFO | sql_loader.omop_alchemy.config | Environment variables loaded from .env file\n", - "2026-01-22 15:26:50,589 | INFO | sql_loader.omop_alchemy.config | Default database engine configured\n" - ] - } - ], - "source": [ - "from orm_loader.registry import ModelRegistry, ValidationRunner, always_on_validators\n", - "from orm_loader.helpers import configure_logging, bootstrap\n", - "from omop_alchemy.cdm.specification import TABLE_LEVEL_CSV, FIELD_LEVEL_CSV\n", - "from omop_alchemy import get_engine_name, load_environment, TEST_PATH, ROOT_PATH\n", - "import sqlalchemy as sa\n", - "from sqlalchemy.orm import sessionmaker\n", - "\n", - "configure_logging()\n", - "load_environment()\n", - "\n", - "engine_string = get_engine_name()\n", - "engine = sa.create_engine(engine_string, future=True, echo=False)\n", - "registry = ModelRegistry(model_name='CDM', model_version=\"5.4\")\n", - "\n", - "registry.load_table_specs(\n", - " table_csv=TABLE_LEVEL_CSV,\n", - " field_csv=FIELD_LEVEL_CSV,\n", - ")\n", - "\n", - "registry.discover_models(\"omop_alchemy.cdm.model\")\n", - "bootstrap(engine, create=True)" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "9875dc2f", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "['concept_synonym',\n", - " 'observation_period',\n", - " 'observation',\n", - " 'payer_plan_period',\n", - " 'dose_era']" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "list(registry.known_tables())[:5]" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "4e144e8a", - "metadata": {}, - "outputs": [], - "source": [ - "validators = always_on_validators()\n", - "runner = ValidationRunner(\n", - " validators=validators,\n", - " fail_fast=False,\n", - ")\n", - "report = runner.run(registry)\n" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "75a09c70", - "metadata": {}, - "outputs": [], - "source": [ - "# report = registry.validate(engine=engine, check_domain_semantics=True)" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "9cfa9046", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "MODEL v5.4: 0 error(s), 28 warning(s), 8 info\n" - ] - } - ], - "source": [ - "print(report.summary())" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "a8fea713", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "📦 cdm_source\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: cdm_source_name) Hint: ORM primary key not marked as primary key in specification\n", - "\n", - "📦 cohort\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: cohort_definition_id) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: subject_id) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ COMPOSITE_PRIMARY_KEY Hint: Composite primary key detected\n", - "\n", - "📦 cohort_definition\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: cohort_definition_id) Hint: ORM primary key not marked as primary key in specification\n", - "\n", - "📦 concept_ancestor\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: ancestor_concept_id) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: descendant_concept_id) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ COMPOSITE_PRIMARY_KEY Hint: Composite primary key detected\n", - "\n", - "📦 concept_relationship\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: concept_id_1) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: concept_id_2) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: relationship_id) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ COMPOSITE_PRIMARY_KEY Hint: Composite primary key detected\n", - "\n", - "📦 concept_synonym\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: concept_id) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: concept_synonym_name) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: language_concept_id) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ COMPOSITE_PRIMARY_KEY Hint: Composite primary key detected\n", - "\n", - "📦 death\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: person_id) Hint: ORM primary key not marked as primary key in specification\n", - "\n", - "📦 drug_strength\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: drug_concept_id) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: ingredient_concept_id) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ COMPOSITE_PRIMARY_KEY Hint: Composite primary key detected\n", - "\n", - "📦 episode\n", - " ⚠️ FOREIGN_KEY_NOT_IN_SPEC (field: episode_parent_id) Hint: ORM defines FK but specification does not\n", - "\n", - "📦 episode_event\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: episode_event_field_concept_id) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: episode_id) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: event_id) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ COMPOSITE_PRIMARY_KEY Hint: Composite primary key detected\n", - "\n", - "📦 fact_relationship\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: domain_concept_id_1) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: domain_concept_id_2) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: fact_id_1) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: fact_id_2) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: relationship_concept_id) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ COMPOSITE_PRIMARY_KEY Hint: Composite primary key detected\n", - "\n", - "📦 relationship\n", - " ⚠️ FOREIGN_KEY_NOT_IN_SPEC (field: reverse_relationship_id) Hint: ORM defines FK but specification does not\n", - "\n", - "📦 source_to_concept_map\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: source_code) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: source_concept_id) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ PRIMARY_KEY_NOT_DECLARED_IN_SPEC (field: source_vocabulary_id) Hint: ORM primary key not marked as primary key in specification\n", - " ⚠️ COMPOSITE_PRIMARY_KEY Hint: Composite primary key detected\n" - ] - } - ], - "source": [ - "if not report.is_valid():\n", - " print(report.render_text_report())" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "6086ccff", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c827c762", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "3551f2f9", - "metadata": {}, - "outputs": [], - "source": [ - "for table, spec in registry._table_specs.items():\n", - " print(f\"{table}: {spec.is_required}\")" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "9585d76b", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2be13a79", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "omop-alchemy (3.13.3)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/02_test_load.ipynb b/notebooks/02_test_load.ipynb deleted file mode 100644 index dadd78f..0000000 --- a/notebooks/02_test_load.ipynb +++ /dev/null @@ -1,476 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "67fe4629", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-01-23 17:36:30,283 | INFO | sql_loader.omop_alchemy.config | Environment variables loaded from .env file\n", - "2026-01-23 17:36:30,283 | INFO | sql_loader.omop_alchemy.config | Default database engine configured\n" - ] - } - ], - "source": [ - "import sqlalchemy as sa\n", - "import pandas as pd\n", - "\n", - "from orm_loader.helpers import configure_logging, bootstrap, explain_sqlite_fk_error, bulk_load_context, configure_logging\n", - "from sqlalchemy.orm import sessionmaker\n", - "from sqlalchemy.exc import IntegrityError\n", - "\n", - "from random import randint, choice\n", - "import numpy as np\n", - "from orm_loader.loaders.loader_interface import ParquetLoader, PandasLoader\n", - "\n", - "from sqlalchemy.orm import Session\n", - "from omop_alchemy.cdm.model.health_system import Location, Care_Site, Provider, Visit_Detail, Visit_Occurrence\n", - "from omop_alchemy.cdm.model.clinical import Person, Condition_Occurrence, Procedure_Occurrence, Death, Specimen, Drug_Exposure, Measurement, Observation\n", - "from omop_alchemy.cdm.model.structural import Episode, Episode_Event\n", - "from omop_alchemy.cdm.model.derived import Observation_Period\n", - "from datetime import date, timedelta\n", - "from omop_alchemy import get_engine_name, load_environment, TEST_PATH, ROOT_PATH\n", - "\n", - "from omop_alchemy.cdm.model.vocabulary import (\n", - " Domain,\n", - " Vocabulary,\n", - " Concept_Class,\n", - " Relationship,\n", - " Concept,\n", - " Concept_Ancestor,\n", - " Concept_Relationship,\n", - " Concept_Synonym,\n", - " Concept_Synonym,\n", - ")\n", - "\n", - "ATHENA_INITIAL_LOAD = [\n", - " Domain,\n", - " Vocabulary,\n", - " Concept_Class,\n", - " Relationship,\n", - " Concept\n", - "]\n", - "\n", - "ATHENA_SUBSEQUENT_LOAD = [\n", - " Concept_Ancestor,\n", - " Concept_Relationship,\n", - " Concept_Synonym\n", - "]\n", - "\n", - "configure_logging()\n", - "load_environment()\n", - "\n", - "engine_string = get_engine_name()\n", - "engine = sa.create_engine(engine_string, future=True, echo=False)\n", - "bootstrap(engine, create=True)\n", - "Session = sessionmaker(bind=engine, future=True)\n", - "session = Session()\n", - "p = PandasLoader()" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "433ced72", - "metadata": {}, - "outputs": [], - "source": [ - "base_path = TEST_PATH / \"fixtures\" / \"athena_source\"\n", - "\n", - "# uncomment this line if you want to load the full athena source from env var\n", - "# instead of the minimal test fixture set for rapid access\n", - "\n", - "# base_path = Path(os.environ['SOURCE_PATH'])" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "82601899", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-01-23 17:36:33,728 | INFO | sql_loader.orm_loader.helpers.bulk | Disabled foreign key checks for bulk load\n", - "Staging table _staging_vocabulary does not exist; recreating\n", - "Staging table _staging_concept_class does not exist; recreating\n", - "Staging table _staging_relationship does not exist; recreating\n", - "Staging table _staging_concept does not exist; recreating\n", - "Found 1 rows with unexpected nulls in concept.vocabulary_id\n", - "2026-01-23 17:36:34,375 | INFO | sql_loader.orm_loader.helpers.bulk | Re-enabled foreign key checks after bulk load\n" - ] - } - ], - "source": [ - "# Initial load of core vocabulary tables - use bulk load to ensure mutual FK constraints are handled (trusted sources only)\n", - "\n", - "with bulk_load_context(session):\n", - " for model in ATHENA_INITIAL_LOAD:\n", - " _ = model.load_csv(\n", - " session,\n", - " base_path / f\"{model.__tablename__.upper()}.csv\",\n", - " dedupe=True,\n", - " merge_strategy=\"upsert\",\n", - " loader=p,\n", - " )\n", - " session.commit()" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "dcf65010", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-01-23 17:36:37,680 | INFO | sql_loader.orm_loader.helpers.bulk | Disabled foreign key checks for bulk load\n", - "Staging table _staging_concept_ancestor does not exist; recreating\n", - "Staging table _staging_concept_relationship does not exist; recreating\n", - "Staging table _staging_concept_synonym does not exist; recreating\n", - "2026-01-23 17:36:39,350 | INFO | sql_loader.orm_loader.helpers.bulk | Re-enabled foreign key checks after bulk load\n" - ] - } - ], - "source": [ - "# can still turn off FK checks for speed but mutual dependency is not an issue for this one \n", - "# has been updated to use merge strategy to handle duplicates\n", - "\n", - "with bulk_load_context(session):\n", - " for model in ATHENA_SUBSEQUENT_LOAD:\n", - " _ = model.load_csv(\n", - " session,\n", - " base_path / f\"{model.__tablename__.upper()}.csv\",\n", - " dedupe=True,\n", - " chunksize=5000,\n", - " merge_strategy=\"upsert\",\n", - " )\n", - " session.commit()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "eac7991f", - "metadata": {}, - "outputs": [], - "source": [ - "concept_by_domain = pd.DataFrame(\n", - " session.query(\n", - " *Concept.__table__.columns\n", - " )\n", - " .filter(\n", - " sa.or_(\n", - " Concept.domain_id.in_(['Gender', 'Ethnicity', 'Race', 'Visit', 'Geography', 'Provider', 'Type Concept']),\n", - " sa.and_(\n", - " Concept.domain_id == 'Condition',\n", - " Concept.vocabulary_id == 'ICDO3'\n", - " )\n", - " )\n", - " )\n", - ")\n", - "\n", - "avail_gender = list(concept_by_domain[concept_by_domain.domain_id=='Gender'].concept_id)\n", - "avail_ethnicity = list(concept_by_domain[concept_by_domain.domain_id=='Ethnicity'].concept_id)\n", - "avail_race = list(concept_by_domain[concept_by_domain.domain_id=='Race'].concept_id)\n", - "avail_place_of_service = list(concept_by_domain[concept_by_domain.domain_id=='Visit'].concept_id)\n", - "avail_country = list(concept_by_domain[concept_by_domain.concept_class_id=='Location'].concept_id)\n", - "avail_provider = list(concept_by_domain[concept_by_domain.domain_id=='Provider'].concept_id)\n", - "avail_types = list(concept_by_domain[concept_by_domain.domain_id=='Type Concept'].concept_id)\n", - "\n", - "cancers = list(concept_by_domain[(concept_by_domain.domain_id=='Condition')&(concept_by_domain.vocabulary_id=='ICDO3') & (concept_by_domain.concept_code.str.contains('/3'))].concept_id)\n", - "\n", - "staging_parents = pd.DataFrame(\n", - " session.query(\n", - " *Concept.__table__.columns\n", - " )\n", - " .join(Concept_Ancestor, Concept.concept_id==Concept_Ancestor.descendant_concept_id)\n", - " .filter(Concept_Ancestor.ancestor_concept_id==734320)\n", - " .filter(Concept_Ancestor.max_levels_of_separation==1)\n", - ")\n", - "\n", - "staging_sets = {}\n", - "\n", - "for axis in ['T', 'N', 'M', 'Stage']:\n", - " parents = list(staging_parents[staging_parents.concept_name.str.contains(axis)].concept_id)\n", - " s = pd.DataFrame(\n", - " session.query(\n", - " *Concept.__table__.columns\n", - " )\n", - " .join(Concept_Ancestor, Concept.concept_id==Concept_Ancestor.descendant_concept_id)\n", - " .filter(Concept_Ancestor.ancestor_concept_id.in_(parents))\n", - " .filter(Concept.concept_code.ilike('%8th%'))\n", - " .filter(~Concept.concept_code.ilike('%yp%'))\n", - " )\n", - " staging_sets[axis] = s" - ] - }, - { - "cell_type": "code", - "execution_count": 21, - "id": "41e86f41", - "metadata": {}, - "outputs": [], - "source": [ - "# confirming string hack to identify staging axes does work as expected\n", - "# staging_sets['Stage'].concept_code.map(lambda x: x.split('-')[-1]).value_counts()" - ] - }, - { - "cell_type": "code", - "execution_count": 22, - "id": "dc70fc6b", - "metadata": {}, - "outputs": [], - "source": [ - "# these are super-naive and brute-force ways to populate very basic test data - good enough for now - better content coming\n", - "\n", - "def populate_reference_data(session):\n", - " \n", - " loc_ids = Location.allocator(session)\n", - " cs_ids = Care_Site.allocator(session)\n", - " pro_ids = Provider.allocator(session)\n", - " \n", - " location_data = [{'location_id': loc_ids.next(), 'country_concept_id': choice(avail_country), 'city': f'City {idx}'} for idx in range(10)]\n", - " locations = [Location(**row) for row in location_data]\n", - " care_site_data = [{'care_site_id': cs_ids.next(), 'care_site_name': f'Care Site {idx}', 'location_id': choice(locations).location_id, 'place_of_service_concept_id': choice(avail_place_of_service)} for idx in range(30)]\n", - " care_sites = [Care_Site(**row) for row in care_site_data]\n", - " provider_data = [{'provider_id': pro_ids.next(), 'specialty_concept_id': choice(avail_provider), 'gender_concept_id': choice(avail_gender), 'care_site_id': choice(care_sites).care_site_id} for _ in range(50)]\n", - " providers = [Provider(**row) for row in provider_data]\n", - "\n", - " session.add_all(locations)\n", - " session.add_all(care_sites)\n", - " session.add_all(providers)\n", - " session.commit()\n", - "\n", - " return locations, care_sites, providers\n", - "\n", - "def populate_people_and_visits(session, care_sites):\n", - " \n", - " person_ids = Person.allocator(session)\n", - " visit_ids = Visit_Occurrence.allocator(session)\n", - " \n", - " person_data = [{'person_id': person_ids.next(), 'year_of_birth': randint(1950, 2020), 'month_of_birth': randint(1, 12), 'gender_concept_id':choice(avail_gender), 'race_concept_id':choice(avail_race), 'ethnicity_concept_id':choice(avail_ethnicity)} for idx in range(1000)]\n", - " people = [Person(**row) for row in person_data]\n", - "\n", - " visits = []\n", - " for person in people:\n", - " cs = choice(care_sites)\n", - " visit_num = randint(1, 3)\n", - " for v in range(visit_num):\n", - " days_delay = randint(0, 365)\n", - " visit_date = date(2020, 1, 1) + timedelta(days_delay)\n", - " visit = Visit_Occurrence(\n", - " visit_occurrence_id=visit_ids.next(),\n", - " person_id=person.person_id,\n", - " care_site_id=cs.care_site_id,\n", - " visit_concept_id=choice(avail_place_of_service),\n", - " visit_start_date=visit_date,\n", - " visit_end_date=visit_date,\n", - " )\n", - " visits.append(visit)\n", - " session.add_all(people)\n", - " session.add_all(visits)\n", - " session.commit()\n", - " return people, visits\n", - "\n", - "def populate_observation_periods(session):\n", - " op_ids = Observation_Period.allocator(session)\n", - " deaths = []\n", - " rows = (\n", - " session.query(\n", - " Visit_Occurrence.person_id,\n", - " sa.func.min(Visit_Occurrence.visit_start_date).label(\"start\"),\n", - " sa.func.max(Visit_Occurrence.visit_end_date).label(\"end\"),\n", - " Death.death_date,\n", - " Observation_Period.observation_period_id\n", - " )\n", - " .join(Death, Death.person_id==Visit_Occurrence.person_id, isouter=True)\n", - " .join(Observation_Period, Observation_Period.person_id==Visit_Occurrence.person_id, isouter=True)\n", - " .filter(Observation_Period.observation_period_id==None)\n", - " .group_by(Visit_Occurrence.person_id)\n", - " .all()\n", - " )\n", - " obs = []\n", - " for idx, r in enumerate(rows):\n", - " deceased = np.random.choice([True, False], p=[0.05, 0.95])\n", - " if deceased:\n", - " death_date = r.end + timedelta(days=randint(1, 365))\n", - " deaths.append(\n", - " Death(\n", - " person_id=r.person_id,\n", - " death_date=death_date,\n", - " death_type_concept_id=choice(avail_types),\n", - " )\n", - " )\n", - " obs_end = death_date\n", - " else:\n", - " obs_end = r.end\n", - " obs.append(\n", - " Observation_Period(\n", - " observation_period_id=op_ids.next(),\n", - " person_id=r.person_id,\n", - " observation_period_start_date=r.start,\n", - " observation_period_end_date=obs_end,\n", - " period_type_concept_id=choice(avail_types),\n", - " )\n", - " )\n", - " session.add_all(deaths)\n", - " session.add_all(obs)\n", - " session.commit()\n", - " return obs\n", - "\n", - "def populate_conditions_and_modifiers(session):\n", - " cond_ids = Condition_Occurrence.allocator(session)\n", - " meas_ids = Measurement.allocator(session)\n", - " ep_ids = Episode.allocator(session)\n", - " rows = (\n", - " session.query(\n", - " Observation_Period, Death, Condition_Occurrence\n", - " )\n", - " .join(Death, Observation_Period.person_id==Death.person_id, isouter=True)\n", - " .join(Condition_Occurrence, Observation_Period.person_id==Condition_Occurrence.person_id, isouter=True)\n", - " .all()\n", - " )\n", - " conditions = []\n", - " measurements = []\n", - " episodes = []\n", - " episode_events = []\n", - " for obs, death, condition in rows:\n", - " if condition:\n", - " continue\n", - " t = choice(list(staging_sets['T'].concept_id))\n", - " n = choice(list(staging_sets['N'].concept_id))\n", - " m = choice(list(staging_sets['M'].concept_id))\n", - " # don't worry abt overall stage for now as it should be calculated\n", - " condition_concept = choice(cancers)\n", - " condition = Condition_Occurrence(\n", - " condition_occurrence_id=cond_ids.next(),\n", - " condition_concept_id = condition_concept,\n", - " condition_start_date = obs.observation_period_start_date,\n", - " condition_type_concept_id = choice(avail_types),\n", - " person_id = obs.person_id,\n", - " condition_status_concept_id = 32902\n", - " )\n", - " conditions.append(condition)\n", - " episode = Episode(\n", - " episode_id=ep_ids.next(),\n", - " person_id=obs.person_id,\n", - " episode_concept_id=32533, # Episode of care\n", - " episode_object_concept_id=condition.condition_concept_id,\n", - " episode_start_date=condition.condition_start_date,\n", - " episode_end_date=(\n", - " death.death_date if death else obs.observation_period_end_date\n", - " ),\n", - " episode_type_concept_id=choice(avail_types), # EHR / registry / derived\n", - " )\n", - " episodes.append(episode)\n", - "\n", - " for stage in [t, n, m]:\n", - " measurement = Measurement(\n", - " person_id = obs.person_id,\n", - " measurement_id = meas_ids.next(),\n", - " measurement_concept_id = stage,\n", - " measurement_event_id = condition.condition_occurrence_id,\n", - " meas_event_field_concept_id = 1147127, # condition_occurrence.condition_occurrence_id\n", - " measurement_date = condition.condition_start_date,\n", - " measurement_type_concept_id = choice(avail_types),\n", - " value_as_number = 1\n", - " )\n", - " measurements.append(measurement)\n", - " episode_events.append(\n", - " Episode_Event(\n", - " episode_id=episode.episode_id,\n", - " event_id=measurement.measurement_id,\n", - " episode_event_field_concept_id=1147138, # measurement.measurement_id\n", - " )\n", - " )\n", - " episode_events.append(\n", - " Episode_Event(\n", - " episode_id=episode.episode_id,\n", - " event_id=condition.condition_occurrence_id,\n", - " episode_event_field_concept_id=1147127, # condition_occurrence.condition_occurrence_id\n", - " )\n", - " )\n", - " session.add_all(conditions)\n", - " session.add_all(measurements)\n", - " session.add_all(episodes)\n", - " session.add_all(episode_events)\n", - " session.commit()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "b7ccb46a", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "97d76a3f", - "metadata": {}, - "outputs": [], - "source": [ - "with Session() as sess:\n", - " populate_reference_data(sess)\n", - " sess.commit()\n", - " care_sites = sess.query(Care_Site).all()\n", - "\n", - "with Session() as sess:\n", - " populate_people_and_visits(sess, care_sites)\n", - " populate_observation_periods(sess)\n", - "\n", - "with Session() as sess:\n", - " populate_conditions_and_modifiers(sess)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e57318e0", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "a241ac28", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "omop-alchemy (3.13.3)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/03_basic_model_query_demo.ipynb b/notebooks/03_basic_model_query_demo.ipynb deleted file mode 100644 index caec0f8..0000000 --- a/notebooks/03_basic_model_query_demo.ipynb +++ /dev/null @@ -1,1205 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "766a9e4a", - "metadata": {}, - "source": [ - "This notebook is a simple demo to introduce some of the fundamental design patterns from the OMOP_Alchemy library " - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "634ae11f", - "metadata": {}, - "outputs": [], - "source": [ - "import sqlalchemy as sa\n", - "from sqlalchemy.orm import sessionmaker\n", - "from omop_alchemy.cdm.model.vocabulary import Concept, ConceptView, Domain, Vocabulary, Concept_Class\n", - "from orm_loader.helpers import configure_logging, bootstrap, bulk_load_context\n", - "from omop_alchemy import get_engine_name, load_environment, TEST_PATH, ROOT_PATH\n", - "from omop_alchemy.cdm.model.clinical import Condition_Occurrence, Condition_OccurrenceView\n", - "from omop_alchemy.cdm.model.structural import EpisodeView, Episode_EventView" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "5c3184bb", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-01-22 15:27:38,567 | INFO | sql_loader.omop_alchemy.config | Environment variables loaded from .env file\n", - "2026-01-22 15:27:38,568 | INFO | sql_loader.omop_alchemy.config | Default database engine configured\n" - ] - } - ], - "source": [ - "# this demo assumes that you have created a .env file in the ROOT_PATH with your database connection string - see .example_dotenv for details\n", - "\n", - "configure_logging()\n", - "load_environment()\n", - "engine_string = get_engine_name()\n", - "\n", - "engine = sa.create_engine(engine_string, future=True, echo=False)\n", - "bootstrap(engine, create=True)" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "fe73295d", - "metadata": {}, - "outputs": [], - "source": [ - "Session = sessionmaker(bind=engine, future=True)\n", - "session = Session()" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "8943cd87", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "c = session.query(Concept).first()\n", - "c" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "7e2c50e9", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'concept_id': 1,\n", - " 'concept_name': 'Domain',\n", - " 'domain_id': 'Metadata',\n", - " 'vocabulary_id': 'Domain',\n", - " 'concept_class_id': 'Domain',\n", - " 'concept_code': 'OMOP generated',\n", - " 'valid_start_date': datetime.date(1970, 1, 1),\n", - " 'valid_end_date': datetime.date(2099, 12, 31)}" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "c.to_dict()" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "e0939c75", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'{\"concept_class_id\": \"Domain\", \"concept_code\": \"OMOP generated\", \"concept_id\": 1, \"concept_name\": \"Domain\", \"domain_id\": \"Metadata\", \"valid_end_date\": \"2099-12-31\", \"valid_start_date\": \"1970-01-01\", \"vocabulary_id\": \"Domain\"}'" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "c.to_json()" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "dcc041a4", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[(22274, 'Neoplasm of uncertain behavior of larynx', 'S'),\n", - " (22281, 'Sickle cell-hemoglobin SS disease', 'S'),\n", - " (22288, 'Hereditary elliptocytosis', 'S'),\n", - " (22340, 'Esophageal varices without bleeding', 'S'),\n", - " (22350, 'Edema of larynx', 'S')]" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "standard_conditions = (\n", - " session.query(Concept)\n", - " .filter(\n", - " Concept.domain_id == \"Condition\",\n", - " Concept.standard_concept == \"S\",\n", - " )\n", - " .limit(5)\n", - " .all()\n", - ")\n", - "\n", - "[(c.concept_id, c.concept_name, c.standard_concept) for c in standard_conditions]\n" - ] - }, - { - "cell_type": "markdown", - "id": "b524d61d", - "metadata": {}, - "source": [ - "`Concept` is the basic class that you should be using for most ETL steps, but for introspection of relationships (including the triggering of lazy loads), `ConceptView` offers much richer expressions.\n", - "\n", - "This is separated to ensure speed of base class is maintained, while optimising the potential benefits of fully-described object relationships" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "4ae51dea", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 8, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "cv = session.query(ConceptView).first()\n", - "cv" - ] - }, - { - "cell_type": "markdown", - "id": "3df3e3fb", - "metadata": {}, - "source": [ - "`domain_id` is the actual string content of the column that was returned from the query already performed, where `cv.domain` returns a related Domain object" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "3211247e", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "('Metadata',\n", - " str,\n", - " ,\n", - " omop_alchemy.cdm.model.vocabulary.domain.Domain,\n", - " ,\n", - " omop_alchemy.cdm.model.vocabulary.vocabulary.Vocabulary)" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "cv.domain_id, type(cv.domain_id), cv.domain, type(cv.domain), cv.vocabulary, type(cv.vocabulary)" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "b51388fe", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'Hospital admission'" - ] - }, - "execution_count": 10, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# because concept ancestor and concept relationship are very large tables, ConceptView relationships have \n", - "# been set to lazy='select', these relationships will not load until accessed\n", - "\n", - "concepts = (\n", - " session.query(ConceptView)\n", - " .filter(ConceptView.vocabulary_id == 'SNOMED')\n", - " .filter(ConceptView.standard_concept == 'S')\n", - " .limit(30)\n", - ")\n", - "\n", - "concepts[0].concept_name" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "5a36bca3", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "8715 Hospital admission 5 219 361 361\n", - "9173 Inactive 5 1 7 7\n" - ] - } - ], - "source": [ - "# get details about concept dynamically - ancestors, descendants, relationships\n", - "\n", - "# because of the deferred loading strategy, these relationships will now be querying \n", - "# those tables once for every print statement in the below loop - very efficient for\n", - "# single concepts, not for sets of concepts\n", - "\n", - "for concept in concepts[:2]:\n", - " print(\n", - " concept.concept_id,\n", - " concept.concept_name,\n", - " len(concept.ancestors),\n", - " len(concept.descendants),\n", - " len(concept.incoming_relationships),\n", - " len(concept.outgoing_relationships),\n", - " )" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "id": "5a6d3413", - "metadata": {}, - "outputs": [], - "source": [ - "# when known in advance that these relationships will be needed, use joined loading to\n", - "# load them in the original query and only hit the big table once\n", - "\n", - "from sqlalchemy.orm import selectinload\n", - "\n", - "def concept_hierarchy_bundle():\n", - " return (\n", - " selectinload(ConceptView.ancestors),\n", - " selectinload(ConceptView.descendants),\n", - " )\n", - "\n", - "def concept_relationship_bundle():\n", - " return (\n", - " selectinload(ConceptView.incoming_relationships),\n", - " selectinload(ConceptView.outgoing_relationships),\n", - " )\n", - "\n", - "concepts = (\n", - " session.query(ConceptView)\n", - " .filter(ConceptView.vocabulary_id == 'SNOMED')\n", - " .filter(ConceptView.standard_concept == 'S')\n", - " .options(\n", - " *concept_hierarchy_bundle(),\n", - " *concept_relationship_bundle()\n", - " )\n", - " .limit(30)\n", - " .all()\n", - ")\n" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "id": "55633a75", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "8715 Hospital admission 5 219 361 361\n", - "9173 Inactive 5 1 7 7\n", - "9174 Obsolete 5 1 7 7\n", - "9176 Patient status determination, deceased 4 7 12 12\n", - "9177 Other 5 1 9 9\n", - "9181 Active 5 1 7 7\n", - "9189 Negative 4 1 184 184\n", - "9190 Not detected 4 3 213 213\n", - "9191 Positive 7 6 231 231\n", - "9192 Trace 6 1 20 20\n", - "22274 Neoplasm of uncertain behavior of larynx 36 45 49 49\n", - "22281 Sickle cell-hemoglobin SS disease 35 12 74 74\n", - "22288 Hereditary elliptocytosis 44 10 49 49\n", - "22340 Esophageal varices without bleeding 29 1 30 30\n", - "22350 Edema of larynx 16 9 39 39\n", - "22426 Congenital macrostomia 30 5 35 35\n", - "22492 Foreign body in pharynx 26 13 60 60\n", - "22557 Malignant tumor of submandibular gland 49 182 18 18\n", - "22665 Chronic peptic ulcer with hemorrhage AND with perforation but without obstruction 33 1 17 17\n", - "22666 Vomiting after gastrointestinal tract surgery 18 3 21 21\n", - "22722 Accessory salivary gland 33 2 17 17\n", - "22820 Tuberculosis of esophagus 36 1 26 26\n", - "22839 Overlapping malignant neoplasm of larynx 38 1 23 23\n", - "22856 Polyglandular dysfunction 6 21 65 65\n", - "22871 Neoplasm of uncertain behavior of pineal gland 44 11 36 36\n", - "22945 Horizontal overbite 22 1 20 20\n", - "22955 Perforation of esophagus 22 3 28 28\n", - "23034 Neonatal hypoglycemia 14 7 35 35\n", - "23137 Chlamydial pharyngitis 44 1 28 28\n", - "23164 Disorder of anterior pituitary 13 149 57 57\n" - ] - } - ], - "source": [ - "for concept in concepts:\n", - " print(\n", - " concept.concept_id,\n", - " concept.concept_name,\n", - " len(concept.ancestors),\n", - " len(concept.descendants),\n", - " len(concept.incoming_relationships),\n", - " len(concept.outgoing_relationships),\n", - " )" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "id": "a53f0b85", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "(36402497, 'Round cell liposarcoma of unknown primary site')" - ] - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "row = (\n", - " session.query(Condition_Occurrence, Concept)\n", - " .join(Concept, Condition_Occurrence.condition_concept_id == Concept.concept_id)\n", - " .first()\n", - ")\n", - "\n", - "row[0].condition_concept_id, row[1].concept_name" - ] - }, - { - "cell_type": "markdown", - "id": "2954093f", - "metadata": {}, - "source": [ - "we don't want to be needing to define joins every time, but equally we don't want to force the loading of relationships that are not required for simple queries.\n", - "this is why they are separated out into View classes, but they can be very useful for exploration, as well as for serialisation to downstream apis" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "id": "19cad800", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "(36402497, 'Round cell liposarcoma of unknown primary site')" - ] - }, - "execution_count": 15, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "row = (\n", - " session.query(Condition_OccurrenceView)\n", - " .first()\n", - ")\n", - "\n", - "row.condition_concept_id, row.condition_concept.concept_name" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "id": "9370cbc3", - "metadata": {}, - "outputs": [], - "source": [ - "from omop_alchemy.cdm.model.clinical import Person, PersonView\n", - "from omop_alchemy.cdm.model.health_system import Location, Provider, Care_Site" - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "id": "3b1f85f4", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 17, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "p = session.query(Person).first()\n", - "p" - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "id": "c44f77ac", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'_sa_instance_state': ,\n", - " 'ethnicity_concept_id': 38003564,\n", - " 'gender_source_value': None,\n", - " 'year_of_birth': 1976,\n", - " 'gender_source_concept_id': None,\n", - " 'race_source_value': None,\n", - " 'person_id': 1,\n", - " 'race_source_concept_id': None,\n", - " 'ethnicity_source_value': None,\n", - " 'month_of_birth': 12,\n", - " 'ethnicity_source_concept_id': None,\n", - " 'visit_occurrence_id': None,\n", - " 'day_of_birth': None,\n", - " 'location_id': None,\n", - " 'visit_detail_id': None,\n", - " 'birth_datetime': None,\n", - " 'provider_id': None,\n", - " 'gender_concept_id': 45518388,\n", - " 'care_site_id': None,\n", - " 'race_concept_id': 45456238,\n", - " 'person_source_value': None}" - ] - }, - "execution_count": 18, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# simple person class that just has the raw column data - flat, predictable, and cheap to load - no joins and no lazy relationships\n", - "p.__dict__" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "id": "e9910b9c", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 19, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# subtle in this example, but personview has actually loaded the gender concept relationship to print the label instead of the raw concept_id\n", - "pv = session.query(PersonView).first()\n", - "pv" - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "id": "b0fd6101", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "('Gender unknown', 'Ethnic category - 2001 census', 'Not Hispanic or Latino')" - ] - }, - "execution_count": 20, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "pv.gender.concept_name, pv.race.concept_name, pv.ethnicity.concept_name" - ] - }, - { - "cell_type": "code", - "execution_count": 21, - "id": "9d8e2932", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'gender_concept_id': ,\n", - " 'race_concept_id': ,\n", - " 'ethnicity_concept_id': }" - ] - }, - "execution_count": 21, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "PersonView.__expected_domains__" - ] - }, - { - "cell_type": "code", - "execution_count": 22, - "id": "4f33223a", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 22, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "p = session.query(PersonView).first()\n", - "p" - ] - }, - { - "cell_type": "code", - "execution_count": 23, - "id": "9c059b4b", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[]" - ] - }, - "execution_count": 23, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "p.domain_violations" - ] - }, - { - "cell_type": "code", - "execution_count": 24, - "id": "8580aa91", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 24, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "wrong_concept = (\n", - " session.query(Concept)\n", - " .filter(Concept.domain_id == \"Condition\")\n", - " .first()\n", - ")\n", - "wrong_concept" - ] - }, - { - "cell_type": "code", - "execution_count": 25, - "id": "930f8d2e", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[DomainRule(table='person', field='gender_concept_id', allowed_domains={'Gender'}, allowed_classes=None),\n", - " DomainRule(table='person', field='race_concept_id', allowed_domains={'Race'}, allowed_classes=None),\n", - " DomainRule(table='person', field='ethnicity_concept_id', allowed_domains={'Ethnicity'}, allowed_classes=None)]" - ] - }, - "execution_count": 25, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "PersonView.collect_domain_rules()" - ] - }, - { - "cell_type": "code", - "execution_count": 26, - "id": "192eb5ba", - "metadata": {}, - "outputs": [], - "source": [ - "p.gender_concept_id = wrong_concept.concept_id" - ] - }, - { - "cell_type": "code", - "execution_count": 27, - "id": "2ee06bb4", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "False" - ] - }, - "execution_count": 27, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "p.is_domain_valid" - ] - }, - { - "cell_type": "code", - "execution_count": 28, - "id": "feb164dd", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[\"gender_concept_id not in domain(s): ['Gender']\"]" - ] - }, - "execution_count": 28, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# we can do application-side validation of domain rules \n", - "# tbc if this can be made more efficient at scale to truly support ETL \n", - "# so that we can move it to the base class?\n", - "p.domain_violations" - ] - }, - { - "cell_type": "code", - "execution_count": 29, - "id": "a5a313da", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "50" - ] - }, - "execution_count": 29, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# age as a hybrid property\n", - "from datetime import date\n", - "pv.age" - ] - }, - { - "cell_type": "code", - "execution_count": 30, - "id": "85046519", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "44" - ] - }, - "execution_count": 30, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "pv.age_at(date(2020, 1, 1))" - ] - }, - { - "cell_type": "code", - "execution_count": 31, - "id": "efbe1fc7", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[,\n", - " ,\n", - " ,\n", - " ,\n", - " ]" - ] - }, - "execution_count": 31, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# because we are using a hybrid property, we can filter on it in queries - same logic but two execution modes\n", - "(\n", - " session.query(PersonView)\n", - " .filter(PersonView.age_at(date(2020, 1, 1)) >= 65)\n", - " .limit(5)\n", - " .all()\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 32, - "id": "b7de12c1", - "metadata": {}, - "outputs": [], - "source": [ - "# if using the base Person class, we would need to do the age calculation in the query itself\n", - "from sqlalchemy import func\n", - "on = date(2020, 1, 1)\n", - "q = (\n", - " session.query(Person)\n", - " .filter((sa.func.extract(\"year\", sa.literal(on)) - Person.year_of_birth) >= 65)\n", - " .limit(5)\n", - " .all()\n", - ")" - ] - }, - { - "cell_type": "code", - "execution_count": 33, - "id": "bc2374f3", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[, , , , ]" - ] - }, - "execution_count": 33, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# this is a trivial example in this case but in the instance of joined elements it can make a big difference in expressiveness / formalism of complex definitions\n", - "q" - ] - }, - { - "cell_type": "code", - "execution_count": 34, - "id": "54c9ec02", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[,\n", - " ,\n", - " ,\n", - " ,\n", - " ]" - ] - }, - "execution_count": 34, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "session.query(PersonView).filter(PersonView.under_observation_on(date(2020, 6, 1))).all()[:5]" - ] - }, - { - "cell_type": "code", - "execution_count": 35, - "id": "a0b86693", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[,\n", - " ,\n", - " ,\n", - " ,\n", - " ,\n", - " ,\n", - " ,\n", - " ,\n", - " ,\n", - " ]" - ] - }, - "execution_count": 35, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "cohort = (\n", - " session.query(PersonView)\n", - " .filter(\n", - " PersonView.age_at(date(2020, 1, 1)) >= 18,\n", - " PersonView.is_deceased == True,\n", - " )\n", - " .limit(10)\n", - " .all()\n", - ")\n", - "\n", - "cohort" - ] - }, - { - "cell_type": "code", - "execution_count": 36, - "id": "4f77674c", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'person_id': 1,\n", - " 'year_of_birth': 1976,\n", - " 'month_of_birth': 12,\n", - " 'gender_concept_id': 8689,\n", - " 'race_concept_id': 45456238,\n", - " 'ethnicity_concept_id': 38003564}" - ] - }, - "execution_count": 36, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "cohort[0].to_dict()" - ] - }, - { - "cell_type": "code", - "execution_count": 37, - "id": "69fff20b", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 37, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "cohort[0].death" - ] - }, - { - "cell_type": "code", - "execution_count": 38, - "id": "00c0f530", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[]" - ] - }, - "execution_count": 38, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "pv.observation_periods" - ] - }, - { - "cell_type": "code", - "execution_count": 39, - "id": "61cbed1a", - "metadata": {}, - "outputs": [], - "source": [ - "q = (\n", - " session.query(PersonView)\n", - " .filter(PersonView.first_observation_date >= date(2020, 10, 1))\n", - " .filter(PersonView.last_observation_date <= date(2021, 10, 31))\n", - ").all()\n" - ] - }, - { - "cell_type": "code", - "execution_count": 40, - "id": "07d6911c", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "96" - ] - }, - "execution_count": 40, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "len(q)" - ] - }, - { - "cell_type": "code", - "execution_count": 41, - "id": "50ada151", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 41, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "ep = session.query(EpisodeView).first()\n", - "ep" - ] - }, - { - "cell_type": "code", - "execution_count": 42, - "id": "46f0b554", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "('Disease Episode', 'Round cell liposarcoma of unknown primary site')" - ] - }, - "execution_count": 42, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "ep.episode_concept.concept_name, ep.episode_object_concept.concept_name" - ] - }, - { - "cell_type": "code", - "execution_count": 43, - "id": "34dfe21a", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[,\n", - " ,\n", - " ,\n", - " ]" - ] - }, - "execution_count": 43, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "ep.events" - ] - }, - { - "cell_type": "code", - "execution_count": 44, - "id": "ad088151", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[,\n", - " ,\n", - " ,\n", - " ]" - ] - }, - "execution_count": 44, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "events = (\n", - " session.query(Episode_EventView)\n", - " .filter(Episode_EventView.episode_id == ep.episode_id)\n", - " .all()\n", - ")\n", - "\n", - "# polymorphic relationship to clinical fact tables can be context aware and resolved dynamically\n", - "events" - ] - }, - { - "cell_type": "code", - "execution_count": 45, - "id": "87193c76", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'condition_occurrence'" - ] - }, - "execution_count": 45, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "events[0].event_table" - ] - }, - { - "cell_type": "code", - "execution_count": 46, - "id": "851aa001", - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "SELECT episode_event.episode_id, episode_event.event_id, episode_event.episode_event_field_concept_id \n", - "FROM episode_event \n", - "WHERE episode_event.episode_id = 1\n" - ] - } - ], - "source": [ - "q = session.query(Episode_EventView).filter(Episode_EventView.episode_id == ep.episode_id)\n", - "\n", - "print(q.statement.compile(compile_kwargs={\"literal_binds\": True}))" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "201386d6", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e828901e", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "omop-alchemy (3.13.3)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/04_timeline.ipynb b/notebooks/04_timeline.ipynb deleted file mode 100644 index 59e747f..0000000 --- a/notebooks/04_timeline.ipynb +++ /dev/null @@ -1,142 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "id": "8deb60a9", - "metadata": {}, - "outputs": [], - "source": [ - "import sqlalchemy as sa\n", - "from sqlalchemy.orm import sessionmaker\n", - "from omop_alchemy.cdm.model.vocabulary import Concept, ConceptView, Domain, Vocabulary, Concept_Class\n", - "from orm_loader.helpers import configure_logging, bootstrap, bulk_load_context\n", - "from omop_alchemy import get_engine_name, load_environment, TEST_PATH, ROOT_PATH\n", - "from omop_alchemy.cdm.model.extended import Person_Timeline" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "deea8749", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-01-22 15:30:52,347 | INFO | sql_loader.omop_alchemy.config | Environment variables loaded from .env file\n", - "2026-01-22 15:30:52,348 | INFO | sql_loader.omop_alchemy.config | Default database engine configured\n" - ] - } - ], - "source": [ - "\n", - "configure_logging()\n", - "load_environment()\n", - "engine_string = get_engine_name()\n", - "\n", - "engine = sa.create_engine(engine_string, future=True, echo=False)\n", - "bootstrap(engine, create=True)" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "b3e61002", - "metadata": {}, - "outputs": [], - "source": [ - "Session = sessionmaker(bind=engine, future=True)\n", - "session = Session()" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "b2e732c1", - "metadata": {}, - "outputs": [], - "source": [ - "people = session.query(Person_Timeline).limit(5).all()" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "7446ea16", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "[,\n", - " ,\n", - " ,\n", - " ]" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "people[0].timeline" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "99c17c10", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "['{\"person_id\": 2, \"concept_id\": 1635163, \"event_start\": \"2020-01-03T00:00:00\", \"event_end\": null, \"value\": {\"type\": \"numeric\", \"value\": 1.0}, \"metadata\": {\"unit_concept_id\": null}}',\n", - " '{\"person_id\": 2, \"concept_id\": 1633674, \"event_start\": \"2020-01-03T00:00:00\", \"event_end\": null, \"value\": {\"type\": \"numeric\", \"value\": 1.0}, \"metadata\": {\"unit_concept_id\": null}}',\n", - " '{\"person_id\": 2, \"concept_id\": 1634891, \"event_start\": \"2020-01-03T00:00:00\", \"event_end\": null, \"value\": {\"type\": \"numeric\", \"value\": 1.0}, \"metadata\": {\"unit_concept_id\": null}}',\n", - " '{\"condition_concept_id\": 36535612, \"condition_occurrence_id\": 2, \"condition_start_date\": \"2020-01-03\", \"condition_status_concept_id\": 32902, \"condition_type_concept_id\": 3564487, \"person_id\": 2}']" - ] - }, - "execution_count": 6, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "people[1].to_json()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "eb3b9d11", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "omop-alchemy (3.13.3)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/05_concept_resolver.ipynb b/notebooks/05_concept_resolver.ipynb deleted file mode 100644 index 80da2c0..0000000 --- a/notebooks/05_concept_resolver.ipynb +++ /dev/null @@ -1,308 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "id": "5ebb19b4", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-01-26 21:26:57,912 | INFO | sql_loader.omop_alchemy.config | Environment variables loaded from .env file\n", - "2026-01-26 21:26:57,912 | INFO | sql_loader.omop_alchemy.config | Default database engine configured\n" - ] - } - ], - "source": [ - "from orm_loader.helpers import configure_logging, bootstrap\n", - "from omop_alchemy import get_engine_name, load_environment\n", - "import sqlalchemy as sa\n", - "\n", - "configure_logging()\n", - "load_environment()\n", - "\n", - "engine_string = get_engine_name('cdm')\n", - "engine = sa.create_engine(engine_string, future=True, echo=False)\n", - "\n", - "bootstrap(engine, create=True)" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "35e8b1b7", - "metadata": {}, - "outputs": [], - "source": [ - "from omop_alchemy.cdm.model.vocabulary import Concept, Concept_Relationship\n", - "from omop_alchemy.cdm.model.clinical import Condition_Occurrence\n", - "from sqlalchemy.orm import sessionmaker" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "5921d6ac", - "metadata": {}, - "outputs": [], - "source": [ - "Session = sessionmaker(bind=engine, future=True)\n", - "session = Session()" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "c5154ea0", - "metadata": {}, - "outputs": [], - "source": [ - "from omop_alchemy.cdm.model.extended.concept_resolver import OMOPConceptResolver, ConceptValidationMixin\n", - "from orm_loader.helpers import Base\n" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "515d57fe", - "metadata": {}, - "outputs": [], - "source": [ - "related_concept = sa.alias(Concept, name='related_concept')\n", - "\n", - "q = (\n", - " sa.select(\n", - " Concept.concept_id,\n", - " Concept.standard_concept,\n", - " Concept_Relationship.relationship_id,\n", - " related_concept.c.concept_id.label('related_concept_id'),\n", - " related_concept.c.standard_concept.label('related_standard_concept'),\n", - " ).join(\n", - " Concept_Relationship, Concept.concept_id == Concept_Relationship.concept_id_1\n", - " ).join(\n", - " related_concept, Concept_Relationship.concept_id_2 == related_concept.c.concept_id\n", - " ).where(\n", - " Concept_Relationship.relationship_id == 'Subsumes'\n", - " )\n", - ").subquery()" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "1372d0dc", - "metadata": {}, - "outputs": [], - "source": [ - "class TestMapper(OMOPConceptResolver, ConceptValidationMixin, Base):\n", - " __table__ = q\n", - "\n", - " concept_id = q.c.concept_id\n", - " standard_concept = q.c.standard_concept\n", - " relationship_id = q.c.relationship_id\n", - " related_concept_id = q.c.related_concept_id\n", - " related_standard_concept = q.c.related_standard_concept" - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "dfbdb85f", - "metadata": {}, - "outputs": [], - "source": [ - "table = TestMapper.get_queryable_table(session)\n", - "cols = TestMapper.concept_id_columns()" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "d62c7f03", - "metadata": {}, - "outputs": [], - "source": [ - "violations = TestMapper.referenced_concept_violations(session)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "52f3fdde", - "metadata": {}, - "outputs": [], - "source": [] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "b0f313cb", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
Invalid Related Concept IDs
037109760
137109761
237109762
342598409
43170326
......
318137109755
318237109756
318337109757
318437109758
318537109759
\n", - "

3186 rows × 1 columns

\n", - "
" - ], - "text/plain": [ - " Invalid Related Concept IDs\n", - "0 37109760\n", - "1 37109761\n", - "2 37109762\n", - "3 42598409\n", - "4 3170326\n", - "... ...\n", - "3181 37109755\n", - "3182 37109756\n", - "3183 37109757\n", - "3184 37109758\n", - "3185 37109759\n", - "\n", - "[3186 rows x 1 columns]" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import pandas as pd\n", - "\n", - "pd.DataFrame(violations['related_concept_id'], columns=['Invalid Related Concept IDs'])" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "0da24094", - "metadata": {}, - "outputs": [], - "source": [ - "class CoT(Condition_Occurrence, OMOPConceptResolver, ConceptValidationMixin):\n", - " pass" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "a599d50b", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'condition_type_concept_id': {32544, 32545, 42539609, 45754907}}" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "CoT.referenced_concept_violations(session)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fce09d89", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "omop-alchemy (3.13.3)", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.13.3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/ORMforResearchReadyData_APAC2023.pdf b/notebooks/ORMforResearchReadyData_APAC2023.pdf deleted file mode 100644 index 54d5557f86c715d5901f29c4f14a0af85342c30e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 222379 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 TP6JX5$i|C6O)a4)iSWMwEHk^i diff --git a/notebooks/concept_enums.py b/notebooks/concept_enums.py deleted file mode 100644 index da0f4f0..0000000 --- a/notebooks/concept_enums.py +++ /dev/null @@ -1,207 +0,0 @@ -import enum - -class ConceptEnum(enum.Enum): - - @classmethod - def member_values(cls): - return [s.value for s in cls] - - @classmethod - def is_member(cls, val): - return not val or val in [s.value for s in cls] - - @classmethod - def labels(cls): - return [s.name for s in cls] - - @classmethod - def get_name(cls, val): - try: - return cls(val).name - except: - return '' - -class ModifierFields(ConceptEnum): - condition_occurrence_id = 1147127 - drug_exposure_id = 1147707 - procedure_occurrence_id = 1147082 - episode_id = 756290 - -class ModifierTables(ConceptEnum): - drug_exposure = 1147339 - episode = 35225440 - observation = 1147304 - -class TreatmentEpisode(ConceptEnum): - treatment_regimen = 32531 # Assignment to or derivation of chemo treatment regimen - treatment_cycle = 32532 # Assignment to or derivation of chemo treatment cycle - cancer_surgery = 32939 # Surgical treatment episode - radiotherapy = 32940 # Radiotherapy treatment episode - -class Modality(ConceptEnum): - chemotherapy = 35803401 - radiotherapy = 35803411 - -class DiseaseEpisodeConcepts(ConceptEnum): - episode_of_care = 32533 # Overarching disease episode - - confined = 32528 # Confined disease extent - invasive = 32677 # Invasive disease extent - metastatic = 32944 # Invasive disease extent - - stable_disease = 32948 # Stable disease dynamic - disease_progression = 32949 # Progression disease dynamic - partial_response = 32947 # Partial response disease dynamic - complete_response = 32947 # Complete response disease dynamic - -class EpisodeTypes(ConceptEnum): - ehr_defined = 32544 # Episode defined in EHR - ehr_derived = 32545 # Episode derived algorithmically from EHR - ehr_prescription = 32838 # EHR prescription - ehr_planned_dispensing = 32837 # EHR planned dispensation - ehr_encounter_record = 32827 # EHR encounter - ehr_admin_record = 32818 # EHR administration record - ehr_outpatient_note = 32834 # EHR outpatient note - rt_care_plan = 42539609 # RT care plan - -class DocumentType(ConceptEnum): - oncology_note = 706266 - -class DocumentEncoding(ConceptEnum): - UTF8 = 32678 - -class Language(ConceptEnum): - english = 4180186 - -class ConditionModifiers(ConceptEnum): - # for measurement_concept_id grouping - init_diag = 734306 # Cancer Modifier - Initial Diagnosis - tnm = 734320 # Cancer Modifier - Parent AJCC/UICC concept - mets = 36769180 # Cancer Modifier - Parent metastasis hierarchy parent - -class TreatmentModifiers(ConceptEnum): - rt_parameter = 4036397 # Radiotherapy parameter parent - rt_projection = 4124464 # Radiotherapy projection parent - rt_site = 4240671 # Radiotherapy anatomical site parent - -class TreatmentIntent(ConceptEnum): - neoadjuvant = 4161587 - adjuvant = 4191637 - curative = 4162591 - palliative = 4179711 - -class CancerProcedureTypes(ConceptEnum): - surgical_procedure = 4301351 - historical_procedure = 1340204 - rt_procedure = 1242725 # Radiotherapy procedure parent - rn_procedure = 4161415 # Radionuclide parent - rt_externalbeam = 4141448 # ebrt parent - rt_course = 37163499 # overall RT course as a procedure - used to hold intent modifier, as well as to compare intended vs. delivered treatment events - -class ProceduresByLocation(ConceptEnum): - procedure_on_lung = 4040549 - operation_on_lung = 4301352 - -class TStageConcepts(ConceptEnum): - # used to group tnm mappings into their relevant subtypes - # preferably create a concept that is the parent of all these T concepts, but for now... - t0 = 1634213 - t1 = 1635564 - t2 = 1635562 - t3 = 1634376 - t4 = 1634654 - ta = 1635114 - tx = 1635682 - tis = 1634530 - -class NStageConcepts(ConceptEnum): - # as above for n... - n0 = 1633440 - n1 = 1634434 - n2 = 1634119 - n3 = 1635320 - n4 = 1635445 - nx = 1633885 - -class MStageConcepts(ConceptEnum): - # and m... - m0 = 1635624 - m1 = 1635142 - mx = 1633547 - -class GroupStageConcepts(ConceptEnum): - # there's a pattern here - stage0 = 1633754 - stageI = 1633306 - stageII = 1634209 - stageIII = 1633650 - stageIV = 1633308 - -class ConditionConcepts(ConceptEnum): - ehr_problem_list = 32840 - resolved_condition = 32906 - confirmed_diagnosis = 32893 - - -class StageEdition(ConceptEnum): - _6th = 1634647 - _7th = 1633496 - _8th = 1634449 - -class ModifierConcepts(ConceptEnum): - grade = 35918328 - laterality = 35918306 - derived_value = 45754907 - tumor_size = 4139794 - primary_tumor = 36768229 - - -class DrugExposureConcepts(ConceptEnum): - drug_dose = 4162374 - ehr_drug_admin = 32818 - placebo = 1379408 - -class DemographyConcepts(ConceptEnum): - cob = 4155450 - language_spoken = 4052785 - postcode = 4083591 - - -class GenomicValue(ConceptEnum): - positive = 9191 - negative = 9189 - equivocal = 4172976 - -class CancerConsultTypes(ConceptEnum): - medonc = 4147722 - clinonc = 4139715 # there is no suitable radonc code? only radiotherapist? - oncology_referral = 4084352 - pall_care_referral = 4127745 - -class ProviderSpecialty(ConceptEnum): - radonc = 35621987 - medonc = 4151173 - pall_care = 4202942 - dietetitian = 4220638 - occupational_therapist = 4213188 - speech_therapist = 4010130 - haematologist = 4221826 - geneticist = 4009808 - gynaecologist = 17036 - radiation_therapist = 4143746 - medical_doctor = 4010577 - - -class WeightConcepts(ConceptEnum): - weight = 4099154 - height = 607590 - bsa = 4201235 - weight_change = 4086522 - -class WeightUnits(ConceptEnum): - lb = 8739 - pct = 4041099 - kg = 9529 - cm = 8582 - inch = 9327 - m2 = 8617 \ No newline at end of file From 2f9b34a5993fa14d4d808b061001918ffdf124f6 Mon Sep 17 00:00:00 2001 From: georgie Date: Tue, 19 May 2026 15:31:42 +1000 Subject: [PATCH 03/14] changed cli name --- .gitignore | 2 +- CHANGELOG.md | 9 +++- docs/advanced/fulltext.md | 20 ++++---- docs/getting-started/installation.md | 6 +-- docs/getting-started/maintenance.md | 68 ++++++++++++++-------------- omop_alchemy/maintenance/backup.py | 2 +- omop_alchemy/maintenance/cli.py | 10 ++-- omop_alchemy/maintenance/doctor.py | 16 +++---- omop_alchemy/maintenance/info.py | 2 +- omop_alchemy/maintenance/ui.py | 2 +- pyproject.toml | 5 +- uv.lock | 47 +------------------ 12 files changed, 76 insertions(+), 113 deletions(-) diff --git a/.gitignore b/.gitignore index 2532721..a0bec63 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,4 @@ _temp/ temp/ *.dump *.bak -notebooks/ \ No newline at end of file +notebooks/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c29534..c43b5ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,4 +91,11 @@ - set minimum versions per dependabot (dev and required deps) ## 0.6.2 -- capped maximum `orm-loader` version to avoid pulling in future breaking changes \ No newline at end of file +- capped maximum `orm-loader` version to avoid pulling in future breaking changes + +## 0.6.3 +- fix CSV quote mode for Athena vocabulary loading: switch from `literal` to `auto` to prevent quoted concept names from overflowing `VARCHAR(255)` database columns +- make `chunksize=100_000` the default for `load-vocab-source` (was `None`/disabled); pass `--chunksize 0` to disable chunking explicitly +- **breaking:** `load-vocab-source` CLI now defaults `--merge-strategy` to `replace` (was `upsert`) to match the Python API default and ensure retired concepts are purged on vocabulary refresh; pass `--merge-strategy upsert` to restore the previous behaviour +- **breaking:** CLI entry point renamed from `omop-maint` to `omop-alchemy`; update any scripts or aliases accordingly (saved `.omop-maint.toml` defaults files are unaffected) +- remove stale notebooks from repository diff --git a/docs/advanced/fulltext.md b/docs/advanced/fulltext.md index ab1c531..6cadc08 100644 --- a/docs/advanced/fulltext.md +++ b/docs/advanced/fulltext.md @@ -58,8 +58,8 @@ SELECT 'a fat cat sat on a mat and ate a fat rat'::tsvector; To enable the optional full-text sidecars in a PostgreSQL environment: ```bash -omop-maint fulltext install -omop-maint fulltext populate +omop-alchemy fulltext install +omop-alchemy fulltext populate ``` If your running Python process should use the stored sidecar columns through ORM @@ -164,28 +164,28 @@ This is the mode you want when: The maintenance CLI manages the full-text sidecars through: ```bash -omop-maint fulltext install -omop-maint fulltext populate -omop-maint fulltext drop +omop-alchemy fulltext install +omop-alchemy fulltext populate +omop-alchemy fulltext drop ``` Typical workflow: ```bash -omop-maint fulltext install -omop-maint fulltext populate +omop-alchemy fulltext install +omop-alchemy fulltext populate ``` If you later reload or update vocabulary data, refresh the stored vectors with: ```bash -omop-maint fulltext populate +omop-alchemy fulltext populate ``` If you want to remove the feature completely: ```bash -omop-maint fulltext drop +omop-alchemy fulltext drop ``` --- @@ -280,7 +280,7 @@ drop lifecycle is only meaningful on PostgreSQL. ## Operational Gotchas - treat the sidecar columns as **derived search state**, not source-of-truth data -- if you bulk-load new vocabulary rows, rerun `omop-maint fulltext populate` +- if you bulk-load new vocabulary rows, rerun `omop-alchemy fulltext populate` - if you use `reconcile-schema`, the sidecar columns and indexes are intentional database additions outside the core OMOP schema - GIN indexes can be expensive to build on large vocabularies, so plan that as a real diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index ff0f3ea..d8b7a47 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -180,14 +180,14 @@ At the database level: Typical maintenance workflow: ```bash -omop-maint fulltext install -omop-maint fulltext populate +omop-alchemy fulltext install +omop-alchemy fulltext populate ``` If you later reload vocabulary data, rerun: ```bash -omop-maint fulltext populate +omop-alchemy fulltext populate ``` For the full design and query patterns, see: diff --git a/docs/getting-started/maintenance.md b/docs/getting-started/maintenance.md index 98b4114..4f8f09a 100644 --- a/docs/getting-started/maintenance.md +++ b/docs/getting-started/maintenance.md @@ -11,7 +11,7 @@ database. ## Entrypoint ```bash -omop-maint --help +omop-alchemy --help python -m omop_alchemy.maintenance.cli --help ``` @@ -33,27 +33,27 @@ Common flags used by many commands: !!! info "Defaults file discovery" - Project-local defaults are stored in `.omop-maint.toml`. + Project-local defaults are stored in `.omop-alchemy.toml`. - the CLI looks for the nearest ancestor directory containing `pyproject.toml` - and uses `/.omop-maint.toml` - - if no ancestor project marker is found, it falls back to `./.omop-maint.toml` + and uses `/.omop-alchemy.toml` + - if no ancestor project marker is found, it falls back to `./.omop-alchemy.toml` in the current working directory - to force a fixed path, set `OMOP_MAINT_DEFAULTS_FILE` - - running `omop-maint` from outside your intended project tree may use a different + - running `omop-alchemy` from outside your intended project tree may use a different defaults file than expected ```bash -omop-maint config show -omop-maint config set-overrides --dotenv .env --engine-schema cdm --db-schema public --athena-source ./athena_source -omop-maint config clear-overrides -omop-maint config clear-overrides --db-schema +omop-alchemy config show +omop-alchemy config set-overrides --dotenv .env --engine-schema cdm --db-schema public --athena-source ./athena_source +omop-alchemy config clear-overrides +omop-alchemy config clear-overrides --db-schema ``` Resolution order: 1. explicit CLI flag -2. saved `.omop-maint.toml` default +2. saved `.omop-alchemy.toml` default 3. command fallback `engine_schema` selects the configured engine URL (`ENGINE_` or `ENGINE`). @@ -99,49 +99,49 @@ user-facing error. ### Inspect ```bash -omop-maint info -omop-maint doctor -omop-maint doctor --deep +omop-alchemy info +omop-alchemy doctor +omop-alchemy doctor --deep ``` ### Schema ```bash -omop-maint reconcile-schema -omop-maint create-missing-tables --dry-run -omop-maint create-missing-tables +omop-alchemy reconcile-schema +omop-alchemy create-missing-tables --dry-run +omop-alchemy create-missing-tables ``` ### Vocabulary ```bash -omop-maint load-vocab-source -omop-maint load-vocab-source --athena-source ./athena_source --dry-run +omop-alchemy load-vocab-source +omop-alchemy load-vocab-source --athena-source ./athena_source --dry-run ``` ### Bulk reload helpers ```bash -omop-maint foreign-keys disable -omop-maint indexes disable -omop-maint truncate-tables --scope clinical --restart-identities --yes +omop-alchemy foreign-keys disable +omop-alchemy indexes disable +omop-alchemy truncate-tables --scope clinical --restart-identities --yes ``` After ETL: ```bash -omop-maint reset-sequences -omop-maint indexes enable -omop-maint foreign-keys enable --strict -omop-maint analyze-tables --scope clinical +omop-alchemy reset-sequences +omop-alchemy indexes enable +omop-alchemy foreign-keys enable --strict +omop-alchemy analyze-tables --scope clinical ``` ### Full-text sidecars ```bash -omop-maint fulltext install -omop-maint fulltext populate -omop-maint fulltext drop +omop-alchemy fulltext install +omop-alchemy fulltext populate +omop-alchemy fulltext drop ``` For query-side usage and optional ORM metadata registration, see @@ -150,8 +150,8 @@ For query-side usage and optional ORM metadata registration, see ### Backup and restore ```bash -omop-maint backup-database --engine-schema source --output-path ./cdm.dump -omop-maint restore-database ./cdm.dump --format custom --engine-schema target +omop-alchemy backup-database --engine-schema source --output-path ./cdm.dump +omop-alchemy restore-database ./cdm.dump --format custom --engine-schema target ``` --- @@ -176,8 +176,8 @@ omop-maint restore-database ./cdm.dump --format custom --engine-schema target ## Help ```bash -omop-maint --help -omop-maint doctor --help -omop-maint fulltext --help -omop-maint config --help +omop-alchemy --help +omop-alchemy doctor --help +omop-alchemy fulltext --help +omop-alchemy config --help ``` diff --git a/omop_alchemy/maintenance/backup.py b/omop_alchemy/maintenance/backup.py index 6f32eee..a277e78 100644 --- a/omop_alchemy/maintenance/backup.py +++ b/omop_alchemy/maintenance/backup.py @@ -98,7 +98,7 @@ def _psql_path() -> str: def _default_output_path(format: BackupFormat) -> Path: timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") - return Path.cwd() / f"omop-maint-backup-{timestamp}{FORMAT_SUFFIXES[format]}" + return Path.cwd() / f"omop-alchemy-backup-{timestamp}{FORMAT_SUFFIXES[format]}" def _libpq_connection_uri(url: sa.engine.URL) -> str: diff --git a/omop_alchemy/maintenance/cli.py b/omop_alchemy/maintenance/cli.py index 1ad4727..d0de6f6 100644 --- a/omop_alchemy/maintenance/cli.py +++ b/omop_alchemy/maintenance/cli.py @@ -140,7 +140,7 @@ def _configure_cli_logging() -> None: ) if mode == "file": - log_path = defaults_path().parent / "logging" / "omop-maint.log" + log_path = defaults_path().parent / "logging" / "omop-alchemy.log" log_path.parent.mkdir(parents=True, exist_ok=True) handler: logging.Handler = logging.FileHandler(log_path, encoding="utf-8") else: @@ -822,8 +822,8 @@ def load_vocab_source_command( engine_schema: str | None = typer.Option(None, help="Engine schema selector."), db_schema: str | None = typer.Option(None, help="Database schema override. PostgreSQL only; uses search_path for ORM CSV loading."), merge_strategy: str = typer.Option( - "upsert", - help="CSV merge strategy passed to the ORM loader. Defaults to non-destructive `upsert`; use `replace` to overwrite matching primary keys.", + "replace", + help="CSV merge strategy passed to the ORM loader. Defaults to `replace` to keep the database in sync with the Athena source; use `upsert` for incremental updates.", ), chunksize: int | None = typer.Option( 100_000, @@ -851,7 +851,7 @@ def load_vocab_source_command( console.print( render_error( "No Athena vocabulary source path is configured. " - "Set it with `omop-maint config set-overrides --athena-source ` " + "Set it with `omop-alchemy config set-overrides --athena-source ` " "or pass `--athena-source`." ) ) @@ -901,7 +901,7 @@ def _update_progress(event: VocabularyLoadProgress) -> None: db_schema=connection_defaults.db_schema, dry_run=dry_run, merge_strategy=merge_strategy, - chunksize=chunksize or None, + chunksize=None if chunksize == 0 else chunksize, progress_callback=_update_progress, ) progress.update( diff --git a/omop_alchemy/maintenance/doctor.py b/omop_alchemy/maintenance/doctor.py index bc1a881..91b1cbd 100644 --- a/omop_alchemy/maintenance/doctor.py +++ b/omop_alchemy/maintenance/doctor.py @@ -63,7 +63,7 @@ def _build_recommendations( DoctorRecommendation( status="warning", summary=f"{info.missing_table_count} ORM-managed table(s) are missing from the target database.", - action="Run `omop-maint create-missing-tables` before attempting bulk operations.", + action="Run `omop-alchemy create-missing-tables` before attempting bulk operations.", ) ) @@ -72,7 +72,7 @@ def _build_recommendations( DoctorRecommendation( status="warning", summary=f"Schema reconciliation found {len(reconciliation.issues)} difference(s) against ORM metadata.", - action="Review `omop-maint reconcile-schema` output before continuing with ETL or maintenance work.", + action="Review `omop-alchemy reconcile-schema` output before continuing with ETL or maintenance work.", ) ) @@ -84,7 +84,7 @@ def _build_recommendations( DoctorRecommendation( status="warning", summary="Some PostgreSQL RI triggers are currently disabled.", - action="If loading is complete, run `omop-maint foreign-keys validate` and then `omop-maint foreign-keys enable --strict`.", + action="If loading is complete, run `omop-alchemy foreign-keys validate` and then `omop-alchemy foreign-keys enable --strict`.", ) ) @@ -96,7 +96,7 @@ def _build_recommendations( DoctorRecommendation( status="failed", summary="Foreign key validation found violating rows.", - action="Fix the reported rows, then rerun `omop-maint foreign-keys enable --strict`.", + action="Fix the reported rows, then rerun `omop-alchemy foreign-keys enable --strict`.", ) ) @@ -105,7 +105,7 @@ def _build_recommendations( DoctorRecommendation( status="warning", summary="`pg_dump` is not on PATH, so backup-database is unavailable from this machine.", - action="Install PostgreSQL client tools on the machine running `omop-maint`.", + action="Install PostgreSQL client tools on the machine running `omop-alchemy`.", ) ) @@ -118,7 +118,7 @@ def _build_recommendations( DoctorRecommendation( status="warning", summary="Neither `pg_restore` nor `psql` is on PATH, so restore-database is unavailable from this machine.", - action="Install PostgreSQL client tools on the machine running `omop-maint`.", + action="Install PostgreSQL client tools on the machine running `omop-alchemy`.", ) ) @@ -207,7 +207,7 @@ def collect_doctor_report( DoctorCheck( name="schema drift", status="skipped", - detail="Run `omop-maint doctor --deep` to reconcile ORM metadata against the target database.", + detail="Run `omop-alchemy doctor --deep` to reconcile ORM metadata against the target database.", ) ) @@ -261,7 +261,7 @@ def collect_doctor_report( DoctorCheck( name="foreign key validation", status="skipped", - detail="Run `omop-maint doctor --deep` to validate selected foreign key relationships.", + detail="Run `omop-alchemy doctor --deep` to validate selected foreign key relationships.", ) ) else: diff --git a/omop_alchemy/maintenance/info.py b/omop_alchemy/maintenance/info.py index 4ca7003..aabd11c 100644 --- a/omop_alchemy/maintenance/info.py +++ b/omop_alchemy/maintenance/info.py @@ -315,7 +315,7 @@ def collect_maintenance_info( managed_tables = select_maintenance_tables( exclude_categories=(() if vocabulary_included else (TableCategory.VOCABULARY,)) ) - cli_path = shutil.which("omop-maint") + cli_path = shutil.which("omop-alchemy") dotenv_exists = None if dotenv is None else os.path.exists(dotenv) engine_name: str | None = None diff --git a/omop_alchemy/maintenance/ui.py b/omop_alchemy/maintenance/ui.py index 6e4a7a1..3bcd4e9 100644 --- a/omop_alchemy/maintenance/ui.py +++ b/omop_alchemy/maintenance/ui.py @@ -825,7 +825,7 @@ def render_foreign_key_validation_summary( ( "All selected foreign key relationships passed validation." if not failed_tables - else "Fix the violating rows, then rerun `omop-maint foreign-keys enable --strict`." + else "Fix the violating rows, then rerun `omop-alchemy foreign-keys enable --strict`." ), ) return Panel.fit( diff --git a/pyproject.toml b/pyproject.toml index f8f6890..9325c23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "omop-alchemy" -version = "0.6.2" +version = "0.6.3" description = "SQLAlchemy-based models, validation, and utilities for the OHDSI OMOP Common Data Model" readme = "README.md" requires-python = ">=3.12" @@ -42,7 +42,6 @@ dependencies = [ [project.optional-dependencies] postgres = [ "psycopg[binary]>=3.2", - "psycopg2-binary>=2.9", ] dev = [ @@ -69,7 +68,7 @@ Repository = "https://github.com/AustralianCancerDataNetwork/OMOP_Alchemy" Issues = "https://github.com/AustralianCancerDataNetwork/OMOP_Alchemy/issues" [project.scripts] -omop-maint = "omop_alchemy.maintenance.cli:main" +omop-alchemy = "omop_alchemy.maintenance.cli:main" [build-system] diff --git a/uv.lock b/uv.lock index 861910c..87f9208 100644 --- a/uv.lock +++ b/uv.lock @@ -862,7 +862,7 @@ wheels = [ [[package]] name = "omop-alchemy" -version = "0.6.2" +version = "0.6.3" source = { editable = "." } dependencies = [ { name = "orm-loader" }, @@ -893,7 +893,6 @@ docs = [ ] postgres = [ { name = "psycopg", extra = ["binary"] }, - { name = "psycopg2-binary" }, ] [package.metadata] @@ -905,10 +904,9 @@ requires-dist = [ { name = "mkdocstrings-python", marker = "extra == 'dev'", specifier = ">=2.0.1" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8" }, { name = "myst-parser", marker = "extra == 'docs'" }, - { name = "orm-loader", specifier = ">=0.3.27,<4.0" }, + { name = "orm-loader", specifier = ">=0.3.27,<0.4.0" }, { name = "pandas", specifier = ">=2.0" }, { name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2" }, - { name = "psycopg2-binary", marker = "extra == 'postgres'", specifier = ">=2.9" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.0" }, { name = "python-dotenv", specifier = ">=1.2.2" }, @@ -1120,47 +1118,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, ] -[[package]] -name = "psycopg2-binary" -version = "2.9.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" }, - { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" }, - { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" }, - { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" }, - { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" }, - { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" }, - { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" }, - { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" }, - { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" }, - { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" }, - { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" }, - { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" }, - { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" }, - { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" }, - { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" }, - { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" }, - { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" }, - { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" }, - { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" }, - { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, - { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, - { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, - { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, - { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, - { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, - { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, - { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, - { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, - { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, - { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, -] - [[package]] name = "ptyprocess" version = "0.7.0" From a944411c95c7ff6e0bc81c85f6de2b281dad61bb Mon Sep 17 00:00:00 2001 From: gkennos Date: Tue, 19 May 2026 17:43:11 +1000 Subject: [PATCH 04/14] tests refresh: postgres integration e2e --- .github/workflows/tests.yml | 68 +++++ .gitignore | 3 + omop_alchemy/config.py | 6 +- omop_alchemy/maintenance/load_vocab.py | 1 + pyproject.toml | 7 +- tests/README.md | 43 ++- tests/conftest.py | 58 +++- tests/docker-compose.yaml | 14 + tests/fixtures/athena_source/CONCEPT.csv | 8 + .../athena_source/CONCEPT_ANCESTOR.csv | 1 + .../fixtures/athena_source/CONCEPT_CLASS.csv | 8 + .../athena_source/CONCEPT_RELATIONSHIP.csv | 1 + .../athena_source/CONCEPT_SYNONYM.csv | 1 + tests/fixtures/athena_source/DOMAIN.csv | 8 + tests/fixtures/athena_source/RELATIONSHIP.csv | 3 + tests/fixtures/athena_source/VOCABULARY.csv | 8 + tests/test_config_driver.py | 113 ++++++++ tests/test_load_vocab.py | 61 ++-- tests/test_load_vocab_postgres.py | 268 ++++++++++++++++++ tests/test_load_vocab_source.py | 11 +- uv.lock | 8 +- 21 files changed, 653 insertions(+), 46 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 tests/docker-compose.yaml create mode 100644 tests/fixtures/athena_source/CONCEPT.csv create mode 100644 tests/fixtures/athena_source/CONCEPT_ANCESTOR.csv create mode 100644 tests/fixtures/athena_source/CONCEPT_CLASS.csv create mode 100644 tests/fixtures/athena_source/CONCEPT_RELATIONSHIP.csv create mode 100644 tests/fixtures/athena_source/CONCEPT_SYNONYM.csv create mode 100644 tests/fixtures/athena_source/DOMAIN.csv create mode 100644 tests/fixtures/athena_source/RELATIONSHIP.csv create mode 100644 tests/fixtures/athena_source/VOCABULARY.csv create mode 100644 tests/test_config_driver.py create mode 100644 tests/test_load_vocab_postgres.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..a9a2018 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,68 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +jobs: + unit-and-sqlite-tests: + name: Unit & SQLite tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: pip install -e ".[dev]" + + - name: Run tests (excluding postgres) + run: pytest -m "not postgres" -q + + postgres-integration-tests: + name: PostgreSQL integration tests + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test_db + ports: + - 55432:5432 + options: >- + --health-cmd "pg_isready -U test -d test_db" + --health-interval 2s + --health-timeout 5s + --health-retries 10 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies (including postgres extra) + run: pip install -e ".[dev,postgres]" + + - name: Run postgres integration tests + run: pytest -m postgres -v + env: + PGHOST: localhost + PGPORT: 55432 + PGUSER: test + PGPASSWORD: test + PGDATABASE: test_db diff --git a/.gitignore b/.gitignore index a0bec63..bfd173e 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,9 @@ RELATIONSHIP.csv DOMAIN.csv CONCEPT_ANCESTOR.csv CONCEPT_SYNONYM.csv +# Allow committed test fixtures (minimal CSVs, not real Athena downloads) +!tests/fixtures/athena_source/ +!tests/fixtures/athena_source/*.csv data/ *.db-journal vocabulary_files/ diff --git a/omop_alchemy/config.py b/omop_alchemy/config.py index eafadb6..1cbd66f 100644 --- a/omop_alchemy/config.py +++ b/omop_alchemy/config.py @@ -10,10 +10,12 @@ logger = get_logger(__name__) +# from orm-loader 0.4.0 onwards, implicit psycopg2 dependency has been removed in favor of explicit driver modules. +# This mapping is used to provide clearer error messages when a required driver is missing. POSTGRES_DRIVER_MODULES: Mapping[str, str] = { - "postgresql": "psycopg2", - "postgresql+psycopg2": "psycopg2", + "postgresql": "psycopg", # bare URL aliased to psycopg "postgresql+psycopg": "psycopg", + "postgresql+psycopg2": "psycopg2", # retained so missing-driver message is clear } def load_environment(dotenv: str = '') -> None: diff --git a/omop_alchemy/maintenance/load_vocab.py b/omop_alchemy/maintenance/load_vocab.py index ec0cf25..5a6b91e 100644 --- a/omop_alchemy/maintenance/load_vocab.py +++ b/omop_alchemy/maintenance/load_vocab.py @@ -361,6 +361,7 @@ def load_vocab_source( for table_index, item in enumerate(load_items, start=1): model = item.model csv_path = item.csv_path + required = item.required current_model_name = model.__tablename__ current_csv_path = str(csv_path) if dry_run: diff --git a/pyproject.toml b/pyproject.toml index 9325c23..3ce0e1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ "python-dotenv>=1.2.2", "typer>=0.12", "rich>=13.0", - "orm-loader>=0.3.27,<0.4.0", + "orm-loader>=0.4.0", ] [project.optional-dependencies] @@ -75,6 +75,11 @@ omop-alchemy = "omop_alchemy.maintenance.cli:main" requires = ["setuptools>=68", "wheel"] build-backend = "setuptools.build_meta" +[tool.pytest.ini_options] +markers = [ + "postgres: marks tests that require a running PostgreSQL instance (deselect with '-m not postgres')", +] + [tool.setuptools] include-package-data = true diff --git a/tests/README.md b/tests/README.md index f2e6ec4..ed92b67 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,8 +1,41 @@ -# OMOP_Alchemy Tests +# Running the test suite -## Running Tests +## Quick start ```bash -py.test omop_alchemy # run all tests -py.test omop_alchemy test_config_and_setup.py # run specific test battery -``` \ No newline at end of file +# Unit and SQLite tests — no database required +uv run --extra dev pytest -m "not postgres" + +# PostgreSQL integration tests — requires the Docker container below +docker compose -f tests/docker-compose.yaml up -d +uv run --extra dev --extra postgres pytest -m postgres -v +``` + +## PostgreSQL integration tests + +The `postgres`-marked tests connect to a local PostgreSQL 16 container on +port **55432**. + +```bash +# Start +docker compose -f tests/docker-compose.yaml up -d + +# Run (this will run all tests) +uv run --extra dev --extra postgres pytest -m "postgres or not postgres" -v + +# Stop +docker compose -f tests/docker-compose.yaml down +``` + +## Test markers + +| Marker | Meaning | +|--------|---------| +| *(none)* | Runs on SQLite, no external dependencies | +| `postgres` | Requires the Docker container on port 55432 | + +## Fixture data + +`tests/fixtures/athena_source/` contains a minimal set of Athena vocabulary +CSVs (7 concepts) used to seed the SQLite test database. These are committed +to the repo and are sufficient for all non-postgres tests. diff --git a/tests/conftest.py b/tests/conftest.py index 3443879..3c0cdcb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import time from datetime import date from pathlib import Path @@ -7,6 +8,8 @@ import sqlalchemy.orm as so from sqlalchemy.orm import Session, sessionmaker +_PG_URL = "postgresql+psycopg://test:test@localhost:55432/test_db" + from omop_alchemy.maintenance.load_vocab import _load_vocab_model_csv from omop_alchemy.cdm.model.clinical import Condition_Occurrence, Person from omop_alchemy.cdm.model.derived import Observation_Period @@ -52,7 +55,7 @@ def _load_fixture_vocabulary(engine: sa.Engine) -> None: model=model, csv_path=csv_path, merge_strategy="upsert", - quote_mode="literal", + quote_mode="auto", ) session.commit() connection.commit() @@ -200,6 +203,59 @@ def engine(tmp_path_factory: pytest.TempPathFactory): engine.dispose() +@pytest.fixture(scope="session") +def pg_engine(): + """ + Session-scoped engine connecting to a local PostgreSQL container. + + Start the container with: + docker compose -f tests/docker-compose.yaml up -d + + The fixture retries for up to 20 seconds to allow the container to become ready. + """ + engine = sa.create_engine(_PG_URL, future=True) + for attempt in range(20): + try: + with engine.connect() as conn: + conn.execute(sa.text("SELECT 1")) + break + except Exception: + if attempt == 19: + engine.dispose() + pytest.fail( + "PostgreSQL container not available after 20 attempts. " + "Run: docker compose -f tests/docker-compose.yaml up -d" + ) + time.sleep(1) + try: + yield engine + finally: + engine.dispose() + + +@pytest.fixture +def pg_session(pg_engine): + """ + Function-scoped PostgreSQL session with a clean schema for each test. + + Drops and recreates the public schema before each test to ensure full isolation. + """ + with pg_engine.connect() as conn: + conn.execute(sa.text("DROP SCHEMA public CASCADE")) + conn.execute(sa.text("CREATE SCHEMA public")) + conn.commit() + + bootstrap(pg_engine, create=True) + + SessionLocal = sessionmaker(bind=pg_engine, future=True, expire_on_commit=False) + session = SessionLocal() + try: + yield session + finally: + session.rollback() + session.close() + + @pytest.fixture(scope="function") def session(engine) -> Session: # type: ignore """ diff --git a/tests/docker-compose.yaml b/tests/docker-compose.yaml new file mode 100644 index 0000000..7a8763d --- /dev/null +++ b/tests/docker-compose.yaml @@ -0,0 +1,14 @@ +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: test_db + ports: + - "55432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U test -d test_db"] + interval: 2s + timeout: 5s + retries: 10 diff --git a/tests/fixtures/athena_source/CONCEPT.csv b/tests/fixtures/athena_source/CONCEPT.csv new file mode 100644 index 0000000..db1e8c5 --- /dev/null +++ b/tests/fixtures/athena_source/CONCEPT.csv @@ -0,0 +1,8 @@ +concept_id concept_name domain_id vocabulary_id concept_class_id standard_concept concept_code valid_start_date valid_end_date invalid_reason +8507 MALE Gender Gender Gender S M 19700101 20991231 +8527 White Race Race Race S White 19700101 20991231 +38003564 Not Hispanic or Latino Ethnicity Ethnicity Ethnicity S Not Hispanic or Latino 19700101 20991231 +32817 EHR Type Concept Type Concept Type Concept S EHR 19700101 20991231 +201826 Type 2 diabetes mellitus Condition SNOMED Clinical Finding S 44054006 19700101 20991231 +32546 Disease Episode Episode Episode Episode S Disease Episode 19700101 20991231 +1147127 condition_occurrence.condition_occurrence_id Metadata CDM Field S condition_occurrence.condition_occurrence_id 19700101 20991231 diff --git a/tests/fixtures/athena_source/CONCEPT_ANCESTOR.csv b/tests/fixtures/athena_source/CONCEPT_ANCESTOR.csv new file mode 100644 index 0000000..4e7b1b2 --- /dev/null +++ b/tests/fixtures/athena_source/CONCEPT_ANCESTOR.csv @@ -0,0 +1 @@ +ancestor_concept_id descendant_concept_id min_levels_of_separation max_levels_of_separation diff --git a/tests/fixtures/athena_source/CONCEPT_CLASS.csv b/tests/fixtures/athena_source/CONCEPT_CLASS.csv new file mode 100644 index 0000000..0c128ae --- /dev/null +++ b/tests/fixtures/athena_source/CONCEPT_CLASS.csv @@ -0,0 +1,8 @@ +concept_class_id concept_class_name concept_class_concept_id +Clinical Finding Clinical Finding 0 +Episode Episode 0 +Ethnicity Ethnicity 0 +Field Field 0 +Gender Gender 0 +Race Race 0 +Type Concept Type Concept 0 diff --git a/tests/fixtures/athena_source/CONCEPT_RELATIONSHIP.csv b/tests/fixtures/athena_source/CONCEPT_RELATIONSHIP.csv new file mode 100644 index 0000000..89cfde0 --- /dev/null +++ b/tests/fixtures/athena_source/CONCEPT_RELATIONSHIP.csv @@ -0,0 +1 @@ +concept_id_1 concept_id_2 relationship_id valid_start_date valid_end_date invalid_reason diff --git a/tests/fixtures/athena_source/CONCEPT_SYNONYM.csv b/tests/fixtures/athena_source/CONCEPT_SYNONYM.csv new file mode 100644 index 0000000..e906770 --- /dev/null +++ b/tests/fixtures/athena_source/CONCEPT_SYNONYM.csv @@ -0,0 +1 @@ +concept_id concept_synonym_name language_concept_id diff --git a/tests/fixtures/athena_source/DOMAIN.csv b/tests/fixtures/athena_source/DOMAIN.csv new file mode 100644 index 0000000..2df5ad4 --- /dev/null +++ b/tests/fixtures/athena_source/DOMAIN.csv @@ -0,0 +1,8 @@ +domain_id domain_name domain_concept_id +Condition Condition 0 +Episode Episode 0 +Ethnicity Ethnicity 0 +Gender Gender 0 +Metadata Metadata 0 +Race Race 0 +Type Concept Type Concept 0 diff --git a/tests/fixtures/athena_source/RELATIONSHIP.csv b/tests/fixtures/athena_source/RELATIONSHIP.csv new file mode 100644 index 0000000..aa9cf9b --- /dev/null +++ b/tests/fixtures/athena_source/RELATIONSHIP.csv @@ -0,0 +1,3 @@ +relationship_id relationship_name is_hierarchical defines_ancestry reverse_relationship_id relationship_concept_id +Is a Is a 1 1 Subsumes 0 +Subsumes Subsumes 1 0 Is a 0 diff --git a/tests/fixtures/athena_source/VOCABULARY.csv b/tests/fixtures/athena_source/VOCABULARY.csv new file mode 100644 index 0000000..a51f62a --- /dev/null +++ b/tests/fixtures/athena_source/VOCABULARY.csv @@ -0,0 +1,8 @@ +vocabulary_id vocabulary_name vocabulary_reference vocabulary_version vocabulary_concept_id +CDM Common Data Model OHDSI v5.4 0 +Episode OMOP Episode OHDSI v1.0 0 +Ethnicity OMOP Ethnicity OHDSI v1.0 0 +Gender OMOP Gender OHDSI v1.0 0 +Race OMOP Race OHDSI v1.0 0 +SNOMED SNOMED-CT IHTSDO SNOMED CT 2023 0 +Type Concept OMOP Type Concept OHDSI v1.0 0 diff --git a/tests/test_config_driver.py b/tests/test_config_driver.py new file mode 100644 index 0000000..8bc6415 --- /dev/null +++ b/tests/test_config_driver.py @@ -0,0 +1,113 @@ +""" +Tests for omop_alchemy.config driver-selection logic. + +These tests do not require a database; they exercise the driver-mapping +constants, _missing_driver_message(), and create_engine_with_dependencies() +using mock exceptions to simulate missing packages. +""" +import pytest + +from omop_alchemy.config import ( + POSTGRES_DRIVER_MODULES, + _missing_driver_message, + create_engine_with_dependencies, +) + + +def _make_module_not_found(module_name: str) -> ModuleNotFoundError: + exc = ModuleNotFoundError(f"No module named '{module_name}'") + exc.name = module_name + return exc + + +# --------------------------------------------------------------------------- +# Driver-mapping constants +# --------------------------------------------------------------------------- + +def test_bare_postgresql_url_aliases_to_psycopg(): + """Bare postgresql:// now resolves to psycopg, not psycopg2.""" + assert POSTGRES_DRIVER_MODULES["postgresql"] == "psycopg" + + +def test_psycopg_driver_mapping(): + assert POSTGRES_DRIVER_MODULES["postgresql+psycopg"] == "psycopg" + + +def test_psycopg2_driver_mapping_retained_for_error_quality(): + """psycopg2 entry is kept so users get a clear error message.""" + assert POSTGRES_DRIVER_MODULES["postgresql+psycopg2"] == "psycopg2" + + +# --------------------------------------------------------------------------- +# _missing_driver_message() +# --------------------------------------------------------------------------- + +def test_missing_driver_message_for_psycopg(): + exc = _make_module_not_found("psycopg") + msg = _missing_driver_message("postgresql+psycopg://host/db", exc) + + assert msg is not None + assert "psycopg" in msg + assert "postgres" in msg.lower() + + +def test_missing_driver_message_for_bare_postgresql_url(): + """Bare postgresql:// is now aliased to psycopg; missing psycopg gives a helpful error.""" + exc = _make_module_not_found("psycopg") + msg = _missing_driver_message("postgresql://host/db", exc) + + assert msg is not None + assert "psycopg" in msg + + +def test_missing_driver_message_for_psycopg2(): + exc = _make_module_not_found("psycopg2") + msg = _missing_driver_message("postgresql+psycopg2://host/db", exc) + + assert msg is not None + assert "psycopg2" in msg + + +def test_missing_driver_message_returns_none_for_unrelated_module(): + """A ModuleNotFoundError for an unrelated package should not be intercepted.""" + exc = _make_module_not_found("pandas") + msg = _missing_driver_message("postgresql+psycopg://host/db", exc) + + assert msg is None + + +def test_missing_driver_message_returns_none_for_sqlite_url(): + exc = _make_module_not_found("psycopg") + msg = _missing_driver_message("sqlite:///test.db", exc) + + assert msg is None + + +# --------------------------------------------------------------------------- +# create_engine_with_dependencies() +# --------------------------------------------------------------------------- + +def test_sqlite_url_not_intercepted(): + """create_engine_with_dependencies should work for sqlite without wrapping errors.""" + engine = create_engine_with_dependencies("sqlite:///:memory:", future=True) + engine.dispose() + + +def test_create_engine_raises_runtime_for_missing_postgres_driver(monkeypatch): + """When psycopg is missing, create_engine_with_dependencies raises RuntimeError with install hint.""" + import sqlalchemy as sa + + def fake_create_engine(url, **kwargs): + raise ModuleNotFoundError.__new__( + ModuleNotFoundError, + ) + + exc = _make_module_not_found("psycopg") + + def raising_create_engine(url, **kwargs): + raise exc + + monkeypatch.setattr(sa, "create_engine", raising_create_engine) + + with pytest.raises(RuntimeError, match="psycopg"): + create_engine_with_dependencies("postgresql+psycopg://host/db") diff --git a/tests/test_load_vocab.py b/tests/test_load_vocab.py index 735c8a8..6d9b65e 100644 --- a/tests/test_load_vocab.py +++ b/tests/test_load_vocab.py @@ -60,8 +60,8 @@ def db_session(connection): @pytest.fixture(scope="session") def athena_vocab(connection): """ - Load a minimal, internally consistent Athena vocabulary - using the real ORM CSV loader. + Load the minimal Athena vocabulary fixture using the real ORM CSV loader. + Files follow the Athena convention: UPPERCASE table names with .csv extension. """ Session = sessionmaker(bind=connection, future=True) session = Session() @@ -73,7 +73,7 @@ def athena_vocab(connection): ) for model in ATHENA_LOAD_ORDER: - csv_path = base_path / f"{model.__tablename__}.csv" + csv_path = base_path / f"{model.__tablename__.upper()}.csv" if not csv_path.exists(): raise RuntimeError(f"Missing vocab CSV: {csv_path}") @@ -84,26 +84,22 @@ def athena_vocab(connection): yield + def test_concept_loaded(db_session, athena_vocab): - """Test concept loaded.""" - concept = db_session.get(Concept, 1) + """Test that vocabulary concepts load and are accessible by primary key.""" + # MALE (concept_id=8507) is a known row in the minimal fixture. + concept = db_session.get(Concept, 8507) assert concept is not None - assert concept.concept_name == "Domain" - assert concept.domain_id == "Metadata" + assert concept.concept_name == "MALE" + assert concept.domain_id == "Gender" + def test_concept_ancestor(db_session, athena_vocab): - """Test concept ancestor.""" - ancestors = ( - # running tests with metadata concepts so that they are definitely present - # assuming the logic to produce test db is stable - db_session.query(Concept_Ancestor) - .filter_by(descendant_concept_id=1147371) - .all() - ) - assert len(ancestors) == 2 - a = [a.ancestor_concept_id for a in ancestors] - assert 1147371 in a - assert 1147423 in a + """Test that the concept_ancestor table loads without error.""" + # Minimal fixtures have no ancestor rows; table must be accessible and empty. + count = db_session.query(Concept_Ancestor).count() + assert count == 0 + def test_all_concepts_reference_valid_domain(db_session, athena_vocab): """Test all concepts reference valid domain.""" @@ -116,15 +112,17 @@ def test_all_concepts_reference_valid_domain(db_session, athena_vocab): assert invalid == 0 + def test_relationship_vocab_loaded(db_session, athena_vocab): """Test relationship vocab loaded.""" rel = ( db_session.query(Relationship) - .filter_by(relationship_id="Has type") + .filter_by(relationship_id="Is a") .one() ) - assert rel.reverse_relationship_id == "Type of" + assert rel.reverse_relationship_id == "Subsumes" + def test_expected_domains_exist(db_session, athena_vocab): """Test expected domains exist.""" @@ -134,31 +132,34 @@ def test_expected_domains_exist(db_session, athena_vocab): } assert "Condition" in domains - assert "Procedure" in domains - assert "Drug" in domains + assert "Gender" in domains + assert "Race" in domains + def test_domains_are_consistent(db_session, athena_vocab): - """Test domains are consistent.""" + """Test concepts reference domains that exist in the domain table.""" concepts = ( db_session.query(Concept) - .filter(Concept.domain_id.in_(["Condition", "Procedure"])) + .filter(Concept.domain_id.in_(["Condition", "Gender"])) .all() ) - assert concepts + assert concepts for c in concepts: - assert c.domain_id in {"Condition", "Procedure"} + assert c.domain_id in {"Condition", "Gender"} -def test_procedure_concepts_exist(db_session, athena_vocab): - """Test procedure concepts exist.""" + +def test_condition_concepts_exist(db_session, athena_vocab): + """Test condition concepts exist.""" assert ( db_session.query(Concept) - .filter(Concept.domain_id == "Procedure") + .filter(Concept.domain_id == "Condition") .count() > 0 ) + def test_relationships_reference_valid_concepts(db_session, athena_vocab): """Test relationships reference valid concepts.""" rels = db_session.query(Concept_Relationship).limit(50).all() diff --git a/tests/test_load_vocab_postgres.py b/tests/test_load_vocab_postgres.py new file mode 100644 index 0000000..f8578ec --- /dev/null +++ b/tests/test_load_vocab_postgres.py @@ -0,0 +1,268 @@ +""" +PostgreSQL integration tests for OMOP_Alchemy vocabulary loading. + +These tests require a running PostgreSQL container. Start one with: + docker compose -f tests/docker-compose.yaml up -d + +Then run: + pytest -m postgres +""" +import shutil +from pathlib import Path + +import pytest +import sqlalchemy as sa + +from omop_alchemy.cdm.model.vocabulary import Concept +from omop_alchemy.maintenance.load_vocab import ( + REQUIRED_VOCAB_MODELS, + _load_vocab_model_csv, + load_vocab_source, +) + +_FIXTURE_SOURCE = Path(__file__).parent / "fixtures" / "athena_source" + + +def _make_concept_source( + base_dir: Path, + *, + concept_id: int, + concept_name: str, +) -> Path: + """ + Build a minimal vocabulary source where CONCEPT.csv contains exactly one + test concept with a Gender domain reference, and all other required tables + are copied from the shared fixture (which has the Gender domain row). + """ + source_path = base_dir / "athena_source" + source_path.mkdir(parents=True) + + for fname in ( + "DOMAIN.csv", + "VOCABULARY.csv", + "CONCEPT_CLASS.csv", + "RELATIONSHIP.csv", + "CONCEPT_ANCESTOR.csv", + "CONCEPT_RELATIONSHIP.csv", + "CONCEPT_SYNONYM.csv", + ): + shutil.copy(_FIXTURE_SOURCE / fname, source_path / fname) + + (source_path / "CONCEPT.csv").write_text( + "concept_id\tconcept_name\tdomain_id\tvocabulary_id\tconcept_class_id\t" + "standard_concept\tconcept_code\tvalid_start_date\tvalid_end_date\tinvalid_reason\n" + f"{concept_id}\t{concept_name}\tGender\tGender\tGender\tS\tTEST\t19700101\t20991231\t\n", + encoding="utf-8", + ) + return source_path + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +@pytest.mark.postgres +def test_end_to_end_vocab_load_on_postgres(pg_session, pg_engine): + """load_vocab_source() completes end-to-end on real Postgres via orm-loader>=0.4.0.""" + report = load_vocab_source(pg_engine, source_path=_FIXTURE_SOURCE) + + assert report.merge_strategy == "replace" + assert all(r.status == "loaded" for r in report.results if r.required) + assert all(r.status == "skipped" for r in report.results if not r.required) + + count = pg_session.execute(sa.text("SELECT COUNT(*) FROM concept")).scalar() + assert count == 7 + + +@pytest.mark.postgres +def test_quote_mode_auto_regression_on_postgres(pg_session, pg_engine, tmp_path): + """ + quote_mode='auto' strips RFC-4180 double-quotes via PostgreSQL COPY. + + Under the old quote_mode='literal' a concept_name of exactly 255 chars + wrapped in double-quotes would be stored as 257 chars and violate the + VARCHAR(255) constraint. This test would fail under literal mode. + """ + source_path = tmp_path / "athena_source" + source_path.mkdir() + + long_name = "A" * 255 # exactly at VARCHAR(255) limit when unquoted + + for model in REQUIRED_VOCAB_MODELS: + table_name = model.__tablename__.upper() + csv_path = source_path / f"{table_name}.csv" + if table_name == "CONCEPT": + # Wrap the 255-char name in double-quotes so it's 257 chars raw. + csv_path.write_text( + "concept_id\tconcept_name\tdomain_id\tvocabulary_id\t" + "concept_class_id\tstandard_concept\tconcept_code\t" + "valid_start_date\tvalid_end_date\tinvalid_reason\n" + f'1\t"{long_name}"\tGender\tGender\tGender\tS\tTEST\t19700101\t20991231\t\n', + encoding="utf-8", + ) + elif table_name == "DOMAIN": + csv_path.write_text( + "domain_id\tdomain_name\tdomain_concept_id\nGender\tGender\t0\n", + encoding="utf-8", + ) + elif table_name == "VOCABULARY": + csv_path.write_text( + "vocabulary_id\tvocabulary_name\tvocabulary_reference\t" + "vocabulary_version\tvocabulary_concept_id\n" + "Gender\tOMOP Gender\tOHDSI\tv1.0\t0\n", + encoding="utf-8", + ) + elif table_name == "CONCEPT_CLASS": + csv_path.write_text( + "concept_class_id\tconcept_class_name\tconcept_class_concept_id\n" + "Gender\tGender\t0\n", + encoding="utf-8", + ) + else: + shutil.copy(_FIXTURE_SOURCE / f"{table_name}.csv", csv_path) + + # Should not raise: literal mode would produce a 257-char value and fail. + load_vocab_source(pg_engine, source_path=source_path) + + concept_name = pg_session.execute( + sa.text("SELECT concept_name FROM concept WHERE concept_id = 1") + ).scalar() + assert concept_name is not None + assert len(concept_name) == 255, ( + f"Expected 255-char name; got {len(concept_name)}: {concept_name!r}" + ) + assert not concept_name.startswith('"'), "Surrounding quotes were not stripped" + + +@pytest.mark.postgres +def test_load_vocab_model_csv_on_postgres(pg_session): + """ + _load_vocab_model_csv loads data correctly on a real PostgreSQL session. + + orm-loader>=0.4.0 handles staging-table creation internally, so we test + the end-to-end path: CSV → staging → concept table on real Postgres. + """ + csv_path = _FIXTURE_SOURCE / "CONCEPT.csv" + + row_count = _load_vocab_model_csv( + pg_session, + model=Concept, + csv_path=csv_path, + merge_strategy="replace", + ) + pg_session.commit() + + assert row_count == 7 + count = pg_session.execute(sa.text("SELECT COUNT(*) FROM concept")).scalar() + assert count == 7 + + +@pytest.mark.postgres +def test_replace_strategy_overwrites_existing_rows(pg_session, pg_engine, tmp_path): + """merge_strategy='replace' fully replaces rows with the same PKs on second load.""" + concept_id = 99999 + source_v1 = _make_concept_source( + tmp_path / "v1", concept_id=concept_id, concept_name="name_v1" + ) + source_v2 = _make_concept_source( + tmp_path / "v2", concept_id=concept_id, concept_name="name_v2" + ) + + load_vocab_source(pg_engine, source_path=source_v1, merge_strategy="replace") + load_vocab_source(pg_engine, source_path=source_v2, merge_strategy="replace") + + name = pg_session.execute( + sa.text("SELECT concept_name FROM concept WHERE concept_id = :cid"), + {"cid": concept_id}, + ).scalar() + assert name == "name_v2", f"Expected 'name_v2' after replace, got {name!r}" + + +@pytest.mark.postgres +def test_upsert_strategy_is_non_destructive(pg_session, pg_engine, tmp_path): + """merge_strategy='upsert' preserves existing rows on second load with same PKs.""" + concept_id = 99998 + source_v1 = _make_concept_source( + tmp_path / "v1", concept_id=concept_id, concept_name="name_v1" + ) + source_v2 = _make_concept_source( + tmp_path / "v2", concept_id=concept_id, concept_name="name_v2" + ) + + load_vocab_source(pg_engine, source_path=source_v1, merge_strategy="upsert") + load_vocab_source(pg_engine, source_path=source_v2, merge_strategy="upsert") + + name = pg_session.execute( + sa.text("SELECT concept_name FROM concept WHERE concept_id = :cid"), + {"cid": concept_id}, + ).scalar() + assert name == "name_v1", ( + f"Expected 'name_v1' after upsert (existing row preserved), got {name!r}" + ) + + +@pytest.mark.postgres +def test_chunksize_forwarded_to_loader(pg_session, pg_engine, monkeypatch): + """chunksize is forwarded from load_vocab_source through to _load_vocab_model_csv.""" + from omop_alchemy.maintenance import load_vocab as _lv_module + + received_chunksizes: list[int | None] = [] + original = _lv_module._load_vocab_model_csv + + def tracking_load(session, *, model, csv_path, merge_strategy, quote_mode="auto", chunksize=None): + received_chunksizes.append(chunksize) + return original( + session, + model=model, + csv_path=csv_path, + merge_strategy=merge_strategy, + quote_mode=quote_mode, + chunksize=chunksize, + ) + + monkeypatch.setattr(_lv_module, "_load_vocab_model_csv", tracking_load) + + load_vocab_source(pg_engine, source_path=_FIXTURE_SOURCE, chunksize=500) + + assert received_chunksizes, "Expected at least one table to be loaded" + assert all(c == 500 for c in received_chunksizes), ( + f"Expected chunksize=500 for all tables, got: {received_chunksizes}" + ) + + +@pytest.mark.postgres +def test_db_schema_search_path_on_postgres(pg_engine): + """ + load_vocab_source with db_schema creates vocabulary tables in the requested + PostgreSQL schema and loads data into them correctly. + """ + schema = "vocab_test" + + with pg_engine.connect() as conn: + conn.execute(sa.text(f"DROP SCHEMA IF EXISTS {schema} CASCADE")) + conn.execute(sa.text(f"CREATE SCHEMA {schema}")) + conn.commit() + + try: + report = load_vocab_source( + pg_engine, + source_path=_FIXTURE_SOURCE, + db_schema=schema, + ) + + assert any(r.status == "loaded" for r in report.results if r.required) + + inspector = sa.inspect(pg_engine) + assert inspector.has_table("concept", schema=schema), ( + f"Expected concept table in schema '{schema}'" + ) + + with pg_engine.connect() as conn: + count = conn.execute( + sa.text(f"SELECT COUNT(*) FROM {schema}.concept") + ).scalar() + assert count == 7 + finally: + with pg_engine.connect() as conn: + conn.execute(sa.text(f"DROP SCHEMA IF EXISTS {schema} CASCADE")) + conn.commit() diff --git a/tests/test_load_vocab_source.py b/tests/test_load_vocab_source.py index 6a91cb0..42aa0ae 100644 --- a/tests/test_load_vocab_source.py +++ b/tests/test_load_vocab_source.py @@ -103,10 +103,15 @@ def test_load_vocab_source_requires_full_required_athena_fixture(tmp_path): """Test load vocab source requires full required athena fixture.""" engine = sa.create_engine(f"sqlite:///{tmp_path / 'load_vocab_source_missing_required.db'}", future=True) + # Build a source with only a subset of required models to trigger the missing-files error. + partial_source = tmp_path / "partial_athena" + partial_source.mkdir() + _write_athena_csv(partial_source, REQUIRED_VOCAB_MODELS[0].__tablename__) + with pytest.raises(RuntimeError) as exc_info: load_vocab_source( engine, - source_path=_athena_source_path(), + source_path=partial_source, ) assert "Missing required Athena vocabulary CSV files" in str(exc_info.value) @@ -163,7 +168,7 @@ def fake_load_vocab_source( source_path: str | Path, db_schema: str | None = None, dry_run: bool = False, - merge_strategy: str = "upsert", + merge_strategy: str = "replace", chunksize: int | None = None, progress_callback=None, ): @@ -235,7 +240,7 @@ def fake_load_vocab_source( assert result.exit_code == 0 assert calls["engine"] == "ENGINE" assert calls["source_path"] == expected_source_path - assert calls["merge_strategy"] == "upsert" + assert calls["merge_strategy"] == "replace" assert "load-vocab-source" in result.stdout assert "concept" in result.stdout diff --git a/uv.lock b/uv.lock index 87f9208..1f06c03 100644 --- a/uv.lock +++ b/uv.lock @@ -904,7 +904,7 @@ requires-dist = [ { name = "mkdocstrings-python", marker = "extra == 'dev'", specifier = ">=2.0.1" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8" }, { name = "myst-parser", marker = "extra == 'docs'" }, - { name = "orm-loader", specifier = ">=0.3.27,<0.4.0" }, + { name = "orm-loader", specifier = ">=0.4.0" }, { name = "pandas", specifier = ">=2.0" }, { name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, @@ -922,7 +922,7 @@ provides-extras = ["postgres", "dev", "docs"] [[package]] name = "orm-loader" -version = "0.3.27" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "chardet" }, @@ -930,9 +930,9 @@ dependencies = [ { name = "pyarrow" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/72/f5ae8aafb2868301da88c71f6ee095cac14bf4405648c935b533cf1550b6/orm_loader-0.3.27.tar.gz", hash = "sha256:51de60177bb45572329899d883414ba47ed42034a782d49bf05d0dc5d1e9f58c", size = 33014, upload-time = "2026-05-06T07:04:59.088Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/6f/cd7787ccacb6742d6c204c9b6322e2b2447616ca5f97ed98878d6d4d8920/orm_loader-0.4.0.tar.gz", hash = "sha256:08e0e260e02d42859d3e91e064c6118e845e178909cf5e38ccb185a37ac205a5", size = 38276, upload-time = "2026-05-19T06:20:40.064Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/f8/8f16b0123ea3438a084125d7450ef1250e4780edf0934f79e14a924578bc/orm_loader-0.3.27-py3-none-any.whl", hash = "sha256:7e2bbd7f6935aff1710a99d9d8f550d691307c446e75c04cb59cd67f1e64b16d", size = 44815, upload-time = "2026-05-06T07:04:57.509Z" }, + { url = "https://files.pythonhosted.org/packages/b0/0a/e014ee74e829378c54acb29ebf84fdd797d43c517072aad228a6d1f0ea2e/orm_loader-0.4.0-py3-none-any.whl", hash = "sha256:5e4680d415f264304e7fdc597303c0e71320ed18444fd7bd04bdd22939f9e780", size = 53411, upload-time = "2026-05-19T06:20:38.507Z" }, ] [[package]] From 049e9e3217e2080e0deb0c6c318e19f2dea084b0 Mon Sep 17 00:00:00 2001 From: gkennos Date: Tue, 19 May 2026 18:11:21 +1000 Subject: [PATCH 05/14] dockerhub build --- .dockerignore | 14 ++++ .github/workflows/docker-python.yml | 109 ++++++++++++++++++++++++++++ docker/docker-compose.yml | 45 +++++++++--- docker/jupyter/Dockerfile | 33 ++++----- docker/postgres/Dockerfile | 8 -- docker/postgres/custom.conf | 10 --- docker/python/.dockerignore | 6 -- docker/python/Dockerfile | 53 +++++++------- docs/getting-started/quickstart.md | 27 ++++--- 9 files changed, 216 insertions(+), 89 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/docker-python.yml delete mode 100644 docker/postgres/Dockerfile delete mode 100644 docker/postgres/custom.conf delete mode 100644 docker/python/.dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..bd0000a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.github +.pytest_cache +.venv +.vscode +__pycache__ +*.pyc +*.pyo +*.pyd +*.egg-info +_temp +docker/data +notebooks +tests diff --git a/.github/workflows/docker-python.yml b/.github/workflows/docker-python.yml new file mode 100644 index 0000000..93c5493 --- /dev/null +++ b/.github/workflows/docker-python.yml @@ -0,0 +1,109 @@ +name: Docker Python Image + +on: + pull_request: + paths: + - ".github/workflows/docker-python.yml" + - ".dockerignore" + - "docker/python/Dockerfile" + - "pyproject.toml" + - "uv.lock" + - "README.md" + - "LICENSE" + - "omop_alchemy/**" + push: + branches: + - main + tags: + - "v*" + paths: + - ".github/workflows/docker-python.yml" + - ".dockerignore" + - "docker/python/Dockerfile" + - "pyproject.toml" + - "uv.lock" + - "README.md" + - "LICENSE" + - "omop_alchemy/**" + +permissions: + contents: read + +jobs: + build-check: + name: Build check + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build python image + uses: docker/build-push-action@v6 + with: + context: . + file: docker/python/Dockerfile + build-args: | + INSTALL_DEV=true + push: false + load: false + tags: omop-alchemy-python:build-check + cache-from: type=gha,scope=docker-python + cache-to: type=gha,mode=max,scope=docker-python + + publish: + name: Publish to Docker Hub + runs-on: ubuntu-latest + if: github.event_name == 'push' + + steps: + - uses: actions/checkout@v4 + + - name: Validate Docker Hub repository variable + run: | + if [ -z "${{ vars.DOCKERHUB_REPOSITORY_PYTHON }}" ]; then + echo "Repository variable DOCKERHUB_REPOSITORY_PYTHON is not set." + echo "Example value: australiancancerdatanetwork/omop-alchemy-python" + exit 1 + fi + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: docker.io/${{ vars.DOCKERHUB_REPOSITORY_PYTHON }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,format=short + type=ref,event=tag + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build and push python image + uses: docker/build-push-action@v6 + with: + context: . + file: docker/python/Dockerfile + build-args: | + INSTALL_DEV=true + push: true + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha,scope=docker-python + cache-to: type=gha,mode=max,scope=docker-python diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index bfc1c9b..a5778f2 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -25,7 +25,12 @@ services: ports: - "5050:80" python: - build: ./python + image: omop-alchemy-python:local + build: + context: .. + dockerfile: docker/python/Dockerfile + args: + INSTALL_DEV: "true" restart: unless-stopped networks: - cava-network @@ -33,7 +38,7 @@ services: POSTGRES_USER: ${POSTGRES_USER} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_DB: ${POSTGRES_DB} - ENGINE_CDM: postgresql+psycopg2://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} + ENGINE_CDM: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} env_file: - .env depends_on: @@ -43,7 +48,7 @@ services: - ..:/workspace:rw command: tail -f /dev/null postgres: - build: ./postgres + image: postgres:18 networks: - cava-network environment: @@ -55,20 +60,34 @@ services: restart: unless-stopped volumes: - ./data:/home/data:rw - - postgres-data:/var/lib/postgresql - - ./custom.conf:/etc/postgresql/custom.conf + - postgres-data:/var/lib/postgresql/ healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 5s timeout: 5s retries: 10 command: - postgres - -c - - include_if_exists=/etc/postgresql/custom.conf + - max_wal_size=20GB + - -c + - checkpoint_timeout=30min + - -c + - wal_compression=on + - -c + - shared_buffers=6GB + - -c + - work_mem=256MB + - -c + - maintenance_work_mem=2GB + - -c + - effective_cache_size=16GB cava-jupyter-notebook: profiles: [ "jupyter"] - build: ./jupyter + image: omop-alchemy-jupyter:local + build: + context: .. + dockerfile: docker/jupyter/Dockerfile restart: unless-stopped depends_on: postgres: @@ -76,12 +95,16 @@ services: networks: - cava-network environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + ENGINE_CDM: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} JUPYTERHUB_SERVICE_PREFIX: /jupyter/ JUPYTERHUB_BASE_URL: ${HTTP_TYPE}://${HOST} env_file: - .env volumes: - - ./work:/home/jovyan/work:rw + - ..:/home/jovyan/work:rw command: - jupyter-lab - --ip=* @@ -89,6 +112,6 @@ services: - --NotebookApp.password= - --NotebookApp.base_url=/jupyter ports: - - "8888:8888" + - "8888:8888" mem_limit: 12g - shm_size: 4g \ No newline at end of file + shm_size: 4g diff --git a/docker/jupyter/Dockerfile b/docker/jupyter/Dockerfile index 7a6abc6..8d9b6c9 100644 --- a/docker/jupyter/Dockerfile +++ b/docker/jupyter/Dockerfile @@ -2,26 +2,25 @@ FROM quay.io/jupyter/minimal-notebook:python-3.13 USER root -# Force uv install location -ENV HOME=/root -ENV PATH="/root/.local/bin:${PATH}" +ENV UV_PROJECT_ENVIRONMENT=/opt/venv \ + UV_CACHE_DIR=/tmp/uv-cache \ + VIRTUAL_ENV=/opt/venv \ + PATH="/opt/venv/bin:/usr/local/bin:${PATH}" -# Install uv -RUN curl -LsSf https://astral.sh/uv/install.sh | sh +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv -# Create uv venv -RUN uv venv /opt/venv -ENV VIRTUAL_ENV=/opt/venv -ENV PATH="/opt/venv/bin:${PATH}" +WORKDIR /opt/omop-alchemy -# Install Python deps -RUN uv pip install omop-alchemy psycopg2-binary pip omop-graph -RUN /opt/venv/bin/python -m pip install ipykernel && \ - /opt/venv/bin/python -m ipykernel install \ +COPY LICENSE README.md pyproject.toml uv.lock ./ +COPY omop_alchemy ./omop_alchemy + +RUN uv sync --frozen --extra postgres \ + && /opt/venv/bin/python -m pip install ipykernel \ + && /opt/venv/bin/python -m ipykernel install \ --name uv-venv \ - --display-name "Python (uv venv)" -# Switch back to notebook user + --display-name "Python (uv venv)" \ + && chown -R jovyan:users /opt/omop-alchemy /opt/venv + USER jovyan ENV HOME=/home/jovyan -COPY ./.env /home/jovyan/work/.env -WORKDIR /home/jovyan/work \ No newline at end of file +WORKDIR /home/jovyan/work diff --git a/docker/postgres/Dockerfile b/docker/postgres/Dockerfile deleted file mode 100644 index 93f0fba..0000000 --- a/docker/postgres/Dockerfile +++ /dev/null @@ -1,8 +0,0 @@ -#FROM timescale/timescaledb-ha:pg18 -FROM postgres:18 - -# Optional: timezone / locale tweaks -ENV TZ=UTC - -# Expose is informational only -EXPOSE 5432 \ No newline at end of file diff --git a/docker/postgres/custom.conf b/docker/postgres/custom.conf deleted file mode 100644 index 9927308..0000000 --- a/docker/postgres/custom.conf +++ /dev/null @@ -1,10 +0,0 @@ -# Performance tuning for bulk loads -max_wal_size = '20GB' -checkpoint_timeout = '30min' -wal_compression = on - -# Memory -shared_buffers = '6GB' -work_mem = '256MB' -maintenance_work_mem = '2GB' -effective_cache_size = '16GB' \ No newline at end of file diff --git a/docker/python/.dockerignore b/docker/python/.dockerignore deleted file mode 100644 index 4a27e3c..0000000 --- a/docker/python/.dockerignore +++ /dev/null @@ -1,6 +0,0 @@ -.venv -__pycache__ -.git -.gitignore -.env -data \ No newline at end of file diff --git a/docker/python/Dockerfile b/docker/python/Dockerfile index 6a54075..a160ccf 100644 --- a/docker/python/Dockerfile +++ b/docker/python/Dockerfile @@ -1,47 +1,46 @@ -# ---- Stage 1: postgres tools ---- -FROM postgres:18 AS pgtools +FROM python:3.13-slim -# ---- Stage 2: python ---- -FROM python:3.13 +ARG INSTALL_DEV=false ENV PYTHONPYCACHEPREFIX=/tmp/pycache \ PYTHONUNBUFFERED=1 \ - UV_PROJECT_ENVIRONMENT=/home/vscode/.venv \ - UV_CACHE_DIR=/home/vscode/.cache/uv \ - PATH="/usr/local/bin:/home/vscode/.venv/bin:$PATH" \ + UV_PROJECT_ENVIRONMENT=/opt/venv \ + UV_CACHE_DIR=/tmp/uv-cache \ + VIRTUAL_ENV=/opt/venv \ + PATH="/opt/venv/bin:/usr/local/bin:$PATH" \ LANG=C.UTF-8 \ LC_ALL=C.UTF-8 -# system deps RUN apt-get update && apt-get install -y --no-install-recommends \ - git \ - curl \ bash \ bash-completion \ + curl \ + git \ less \ + postgresql-client \ vim \ && rm -rf /var/lib/apt/lists/* -# copy binaries from pgtools stage -COPY --from=pgtools /usr/lib/postgresql /usr/lib/postgresql -COPY --from=pgtools /usr/lib/aarch64-linux-gnu/libpq* /usr/lib/aarch64-linux-gnu/ - -RUN ln -s /usr/lib/postgresql/18/bin/psql /usr/local/bin/psql \ - && ln -s /usr/lib/postgresql/18/bin/pg_dump /usr/local/bin/pg_dump \ - && ln -s /usr/lib/postgresql/18/bin/pg_restore /usr/local/bin/pg_restore - -# ---- uv install ---- COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv -# ---- User setup ---- -RUN useradd -m -s /bin/bash vscode +RUN useradd -m -s /bin/bash omop -WORKDIR /workspace +WORKDIR /opt/omop-alchemy + +COPY LICENSE README.md pyproject.toml uv.lock ./ +COPY omop_alchemy ./omop_alchemy -# ---- Auto-activate venv ---- -RUN printf '\nif [ -f /home/vscode/.venv/bin/activate ] && [ -z "$VIRTUAL_ENV" ]; then\n . /home/vscode/.venv/bin/activate\nfi\n' >> /home/vscode/.bashrc \ - && chown vscode:vscode /home/vscode/.bashrc +RUN if [ "$INSTALL_DEV" = "true" ]; then \ + uv sync --frozen --extra postgres --extra dev; \ + else \ + uv sync --frozen --extra postgres; \ + fi \ + && chown -R omop:omop /opt/omop-alchemy /opt/venv -USER vscode +RUN printf '\nif [ -f /opt/venv/bin/activate ] && [ -z "$VIRTUAL_ENV" ]; then\n . /opt/venv/bin/activate\nfi\n' >> /home/omop/.bashrc \ + && chown omop:omop /home/omop/.bashrc + +WORKDIR /workspace +USER omop -CMD ["sleep", "infinity"] \ No newline at end of file +CMD ["sleep", "infinity"] diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index c2b67e0..03a8036 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -15,13 +15,16 @@ The goal is to provide a fast, reproducible environment for: When started with the appropriate profile, this stack runs: -- **PostgreSQL** (`cava-database`) - - Custom-built image (see `docker/postgres/Dockerfile`) +- **PostgreSQL** (`postgres`) + - Official `postgres:18` image with bulk-load-oriented runtime tuning in compose - Persistent storage via Docker volumes +- **Python workspace** (`python`) + - Local OMOP Alchemy source installed into a reusable container image + - PostgreSQL client tools included for direct `psql` / `pg_dump` access - **pgAdmin** (`pgadmin`) - - Web UI for inspecting and querying PostgreSQL + - Web UI for inspecting and querying PostgreSQL (optional) - **JupyterLab** (`cava-jupyter-notebook`, optional) - - Notebook environment wired to the same database + - Notebook environment built from the local repo and wired to the same database All services communicate on a dedicated Docker bridge network (`cava-network`). @@ -48,23 +51,27 @@ POSTGRES_DB=cava HOST=localhost HTTP_TYPE=http - -PYTHON_BIND_MOUNT=/absolute/path/to/your/code_or_data ``` These credentials are not secure and are intentionally simple for local use. ### Starting the stack -From the `docker` directory +From the `docker/` directory. + +#### Database + Python workspace + +``` +docker compose up -d +``` -#### Database + pgAdmin only +#### Database + Python workspace + pgAdmin ``` -docker compose --profile default up -d +docker compose --profile pgadmin up -d ``` -#### Database + pgAdmin + Jupyter +#### Database + Python workspace + Jupyter ``` docker compose --profile jupyter up -d From 09ee3b2411caf3efa0a6f30b3d8e5ebd24dae273 Mon Sep 17 00:00:00 2001 From: gkennos Date: Tue, 19 May 2026 22:59:00 +1000 Subject: [PATCH 06/14] upversion orm loader --- pyproject.toml | 2 +- tests/test_config_driver.py | 5 ----- tests/test_load_vocab_source.py | 4 ++-- uv.lock | 8 ++++---- 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3ce0e1c..72ca011 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ "python-dotenv>=1.2.2", "typer>=0.12", "rich>=13.0", - "orm-loader>=0.4.0", + "orm-loader>=0.4.1", ] [project.optional-dependencies] diff --git a/tests/test_config_driver.py b/tests/test_config_driver.py index 8bc6415..7d3522b 100644 --- a/tests/test_config_driver.py +++ b/tests/test_config_driver.py @@ -97,11 +97,6 @@ def test_create_engine_raises_runtime_for_missing_postgres_driver(monkeypatch): """When psycopg is missing, create_engine_with_dependencies raises RuntimeError with install hint.""" import sqlalchemy as sa - def fake_create_engine(url, **kwargs): - raise ModuleNotFoundError.__new__( - ModuleNotFoundError, - ) - exc = _make_module_not_found("psycopg") def raising_create_engine(url, **kwargs): diff --git a/tests/test_load_vocab_source.py b/tests/test_load_vocab_source.py index 42aa0ae..b947551 100644 --- a/tests/test_load_vocab_source.py +++ b/tests/test_load_vocab_source.py @@ -481,8 +481,8 @@ def fail_load_vocab_source(*args, **kwargs): assert "value too long for type character varying(255)" in result.stdout -def test_load_vocab_source_uses_csv_not_literal_quote_mode(monkeypatch, tmp_path): - """Regression: Athena load must use csv quote mode so that quoted concept_name +def test_load_vocab_source_uses_auto_not_literal_quote_mode(monkeypatch, tmp_path): + """Regression: Athena load must use auto quote mode so that quoted concept_name values are not padded with surrounding double-quote characters, which would cause 'value too long for type character varying(255)' on CONCEPT.csv.""" engine = sa.create_engine(f"sqlite:///{tmp_path / 'quote_mode_regression.db'}", future=True) diff --git a/uv.lock b/uv.lock index 1f06c03..1f9887b 100644 --- a/uv.lock +++ b/uv.lock @@ -904,7 +904,7 @@ requires-dist = [ { name = "mkdocstrings-python", marker = "extra == 'dev'", specifier = ">=2.0.1" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.8" }, { name = "myst-parser", marker = "extra == 'docs'" }, - { name = "orm-loader", specifier = ">=0.4.0" }, + { name = "orm-loader", specifier = ">=0.4.1" }, { name = "pandas", specifier = ">=2.0" }, { name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, @@ -922,7 +922,7 @@ provides-extras = ["postgres", "dev", "docs"] [[package]] name = "orm-loader" -version = "0.4.0" +version = "0.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "chardet" }, @@ -930,9 +930,9 @@ dependencies = [ { name = "pyarrow" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9f/6f/cd7787ccacb6742d6c204c9b6322e2b2447616ca5f97ed98878d6d4d8920/orm_loader-0.4.0.tar.gz", hash = "sha256:08e0e260e02d42859d3e91e064c6118e845e178909cf5e38ccb185a37ac205a5", size = 38276, upload-time = "2026-05-19T06:20:40.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/6a/007e6eef497753702d5a53444842ee6cc38bcbf7c5c422857c0671bfc727/orm_loader-0.4.1.tar.gz", hash = "sha256:434b6c3436c05bf3ad43774b46476e7f324db05a18bf34ad9f9692e4f02bcb7e", size = 39449, upload-time = "2026-05-19T12:56:29.572Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/0a/e014ee74e829378c54acb29ebf84fdd797d43c517072aad228a6d1f0ea2e/orm_loader-0.4.0-py3-none-any.whl", hash = "sha256:5e4680d415f264304e7fdc597303c0e71320ed18444fd7bd04bdd22939f9e780", size = 53411, upload-time = "2026-05-19T06:20:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/98/d7/37f82f8748a91fdb14d41f314ddc829806f596dec409196c037e59d3a5a7/orm_loader-0.4.1-py3-none-any.whl", hash = "sha256:03131b5d4b7b787ea446e110684b7256b5690313503626939b83984953174825", size = 54472, upload-time = "2026-05-19T12:56:27.959Z" }, ] [[package]] From 2d8b7148c993b1441c794268bdc12624e7c1bc0d Mon Sep 17 00:00:00 2001 From: gkennos Date: Tue, 19 May 2026 23:09:25 +1000 Subject: [PATCH 07/14] initial load path assuming empty tables --- omop_alchemy/maintenance/cli.py | 14 +++ omop_alchemy/maintenance/load_vocab.py | 14 ++- tests/test_load_vocab_postgres.py | 10 ++ tests/test_load_vocab_source.py | 137 +++++++++++++++++++++++++ 4 files changed, 172 insertions(+), 3 deletions(-) diff --git a/omop_alchemy/maintenance/cli.py b/omop_alchemy/maintenance/cli.py index d0de6f6..e331e1f 100644 --- a/omop_alchemy/maintenance/cli.py +++ b/omop_alchemy/maintenance/cli.py @@ -829,6 +829,11 @@ def load_vocab_source_command( 100_000, help="Chunk size for fallback ORM CSV loading. Defaults to 100 000 rows; pass 0 to disable chunking.", ), + initial_load: bool = typer.Option( + False, + "--initial-load", + help="Assume target vocabulary tables are empty and use the first-load fast path for a fresh Athena vocabulary load.", + ), dry_run: bool = typer.Option(False, "--dry-run"), ) -> None: connection_defaults = _resolve_connection_context( @@ -857,6 +862,14 @@ def load_vocab_source_command( ) raise typer.Exit(code=1) + if initial_load and merge_strategy != "replace": + console.print( + render_error( + "`--initial-load` cannot be combined with `--merge-strategy` values other than `replace`." + ) + ) + raise typer.Exit(code=1) + try: engine = _build_engine( dotenv=connection_defaults.dotenv, @@ -901,6 +914,7 @@ def _update_progress(event: VocabularyLoadProgress) -> None: db_schema=connection_defaults.db_schema, dry_run=dry_run, merge_strategy=merge_strategy, + initial_load=initial_load, chunksize=None if chunksize == 0 else chunksize, progress_callback=_update_progress, ) diff --git a/omop_alchemy/maintenance/load_vocab.py b/omop_alchemy/maintenance/load_vocab.py index 5a6b91e..e5db27d 100644 --- a/omop_alchemy/maintenance/load_vocab.py +++ b/omop_alchemy/maintenance/load_vocab.py @@ -271,11 +271,19 @@ def load_vocab_source( db_schema: str | None = None, dry_run: bool = False, merge_strategy: str = "replace", + initial_load: bool = False, chunksize: int | None = 100_000, progress_callback: VocabularyLoadProgressCallback | None = None, ) -> VocabularyLoadReport: _ensure_supported_backend(engine) + if initial_load and merge_strategy != "replace": + raise ValueError( + "initial_load=True cannot be combined with merge_strategy values other than 'replace'" + ) + + effective_merge_strategy = "insert_if_empty" if initial_load else merge_strategy + resolved_source_path = Path(source_path).expanduser().resolve() if not resolved_source_path.exists() or not resolved_source_path.is_dir(): raise RuntimeError( @@ -405,7 +413,7 @@ def load_vocab_source( loader_kwargs: dict[str, object] = { "model": model, "csv_path": csv_path, - "merge_strategy": merge_strategy, + "merge_strategy": effective_merge_strategy, "quote_mode": "auto", } if chunksize is not None: @@ -488,7 +496,7 @@ def load_vocab_source( raise VocabularyLoadError( "Athena vocabulary load failed for " f"table `{current_model_name or 'unknown'}` from `{current_csv_path or '-'}` " - f"using merge strategy `{merge_strategy}` on backend `{engine.dialect.name}`. " + f"using merge strategy `{effective_merge_strategy}` on backend `{engine.dialect.name}`. " f"Underlying error: {exc.__class__.__name__}: {exc}" ) from exc finally: @@ -512,7 +520,7 @@ def load_vocab_source( source_path=str(resolved_source_path), backend=engine.dialect.name, db_schema=db_schema, - merge_strategy=merge_strategy, + merge_strategy=effective_merge_strategy, created_table_count=created_table_count, sequence_reset_count=sequence_reset_count, results=tuple(results), diff --git a/tests/test_load_vocab_postgres.py b/tests/test_load_vocab_postgres.py index f8578ec..650401c 100644 --- a/tests/test_load_vocab_postgres.py +++ b/tests/test_load_vocab_postgres.py @@ -74,6 +74,16 @@ def test_end_to_end_vocab_load_on_postgres(pg_session, pg_engine): assert count == 7 +@pytest.mark.postgres +def test_initial_load_uses_insert_if_empty_on_postgres(pg_session, pg_engine): + """initial_load=True uses the empty-target insert fast path on a fresh Postgres load.""" + report = load_vocab_source(pg_engine, source_path=_FIXTURE_SOURCE, initial_load=True) + + assert report.merge_strategy == "insert_if_empty" + count = pg_session.execute(sa.text("SELECT COUNT(*) FROM concept")).scalar() + assert count == 7 + + @pytest.mark.postgres def test_quote_mode_auto_regression_on_postgres(pg_session, pg_engine, tmp_path): """ diff --git a/tests/test_load_vocab_source.py b/tests/test_load_vocab_source.py index b947551..9450b96 100644 --- a/tests/test_load_vocab_source.py +++ b/tests/test_load_vocab_source.py @@ -169,6 +169,7 @@ def fake_load_vocab_source( db_schema: str | None = None, dry_run: bool = False, merge_strategy: str = "replace", + initial_load: bool = False, chunksize: int | None = None, progress_callback=None, ): @@ -179,6 +180,7 @@ def fake_load_vocab_source( calls["db_schema"] = db_schema calls["dry_run"] = dry_run calls["merge_strategy"] = merge_strategy + calls["initial_load"] = initial_load return VocabularyLoadReport( source_path=str(source_path), backend="sqlite", @@ -241,10 +243,100 @@ def fake_load_vocab_source( assert calls["engine"] == "ENGINE" assert calls["source_path"] == expected_source_path assert calls["merge_strategy"] == "replace" + assert calls["initial_load"] is False assert "load-vocab-source" in result.stdout assert "concept" in result.stdout +def test_load_vocab_source_cli_initial_load_uses_first_load_fast_path(monkeypatch): + """CLI --initial-load forwards the fresh-load intent to load_vocab_source().""" + calls: dict[str, object] = {} + + def fake_build_engine(*, dotenv: str | None, engine_schema: str | None): + return "ENGINE" + + def fake_load_vocab_source( + engine: object, + *, + source_path: str | Path, + db_schema: str | None = None, + dry_run: bool = False, + merge_strategy: str = "replace", + initial_load: bool = False, + chunksize: int | None = None, + progress_callback=None, + ): + from omop_alchemy.maintenance.load_vocab import VocabularyLoadReport, VocabularyLoadResult + + calls["engine"] = engine + calls["merge_strategy"] = merge_strategy + calls["initial_load"] = initial_load + effective_merge_strategy = "insert_if_empty" if initial_load else merge_strategy + return VocabularyLoadReport( + source_path=str(source_path), + backend="sqlite", + db_schema=db_schema, + merge_strategy=effective_merge_strategy, + created_table_count=0, + sequence_reset_count=0, + results=( + VocabularyLoadResult( + table_name="concept", + status="planned", + row_count=None, + csv_path=str(Path(source_path) / "CONCEPT.csv"), + required=True, + detail="Athena CSV would be loaded via staged ORM CSV loader", + ), + ), + ) + + monkeypatch.setattr( + "omop_alchemy.maintenance.cli._build_engine", + fake_build_engine, + ) + monkeypatch.setattr( + "omop_alchemy.maintenance.cli.load_vocab_source", + fake_load_vocab_source, + ) + + result = runner.invoke( + app, + [ + "load-vocab-source", + "--athena-source", + str(_athena_source_path()), + "--initial-load", + "--dry-run", + ], + ) + + assert result.exit_code == 0 + assert calls["engine"] == "ENGINE" + assert calls["merge_strategy"] == "replace" + assert calls["initial_load"] is True + + +def test_load_vocab_source_cli_rejects_initial_load_with_non_replace_strategy(): + """CLI should reject combining --initial-load with a conflicting merge strategy.""" + result = runner.invoke( + app, + [ + "load-vocab-source", + "--athena-source", + str(_athena_source_path()), + "--initial-load", + "--merge-strategy", + "upsert", + "--dry-run", + ], + ) + + assert result.exit_code == 1 + assert "--initial-load" in result.stdout + assert "replace" in result.stdout + + def test_load_vocab_model_csv_passes_quote_mode(monkeypatch, tmp_path): """Test load vocab model csv passes quote mode.""" engine = sa.create_engine(f"sqlite:///{tmp_path / 'load_vocab_source_quote_mode.db'}", future=True) @@ -325,6 +417,51 @@ def fake_load_vocab_model_csv( assert loaded_order[:3] == ["domain", "concept_class", "vocabulary"] +def test_load_vocab_source_initial_load_maps_to_insert_if_empty(monkeypatch, tmp_path): + """initial_load=True maps the vocab loader onto orm-loader's insert-if-empty path.""" + engine = sa.create_engine(f"sqlite:///{tmp_path / 'load_vocab_source_initial_load.db'}", future=True) + source_path = _build_required_athena_source(tmp_path) + + received_merge_strategies: list[str] = [] + + def fake_load_vocab_model_csv( + session, + *, + model, + csv_path, + merge_strategy, + quote_mode="auto", + chunksize=None, + ) -> int: + received_merge_strategies.append(merge_strategy) + return 1 + + monkeypatch.setattr( + "omop_alchemy.maintenance.load_vocab._load_vocab_model_csv", + fake_load_vocab_model_csv, + ) + + report = load_vocab_source(engine, source_path=source_path, initial_load=True) + + assert report.merge_strategy == "insert_if_empty" + assert received_merge_strategies + assert all(strategy == "insert_if_empty" for strategy in received_merge_strategies) + + +def test_load_vocab_source_rejects_initial_load_with_non_replace_strategy(tmp_path): + """initial_load=True cannot be combined with a conflicting merge strategy.""" + engine = sa.create_engine(f"sqlite:///{tmp_path / 'load_vocab_source_initial_load_error.db'}", future=True) + source_path = _build_required_athena_source(tmp_path) + + with pytest.raises(ValueError, match="initial_load=True"): + load_vocab_source( + engine, + source_path=source_path, + initial_load=True, + merge_strategy="upsert", + ) + + def test_load_vocab_source_reports_weighted_progress(monkeypatch, tmp_path): """Test load vocab source reports weighted progress.""" engine = sa.create_engine(f"sqlite:///{tmp_path / 'load_vocab_source_progress.db'}", future=True) From d21fd740e66dfcb8405113642038072100b127fc Mon Sep 17 00:00:00 2001 From: gkennos Date: Wed, 20 May 2026 16:06:35 +1000 Subject: [PATCH 08/14] removing docker commits for now --- .dockerignore | 14 ----- docker/docker-compose.yml | 117 -------------------------------------- docker/jupyter/Dockerfile | 26 --------- docker/python/Dockerfile | 46 --------------- 4 files changed, 203 deletions(-) delete mode 100644 .dockerignore delete mode 100644 docker/docker-compose.yml delete mode 100644 docker/jupyter/Dockerfile delete mode 100644 docker/python/Dockerfile diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index bd0000a..0000000 --- a/.dockerignore +++ /dev/null @@ -1,14 +0,0 @@ -.git -.github -.pytest_cache -.venv -.vscode -__pycache__ -*.pyc -*.pyo -*.pyd -*.egg-info -_temp -docker/data -notebooks -tests diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml deleted file mode 100644 index a5778f2..0000000 --- a/docker/docker-compose.yml +++ /dev/null @@ -1,117 +0,0 @@ -volumes: - postgres-data: - name: postgres-data - pgadmin-data: - name: pgadmin-data - -networks: - cava-network: - name: cava-network - driver: bridge - -services: - pgadmin: - profiles: [ "pgadmin"] - image: dpage/pgadmin4:latest - restart: unless-stopped - networks: - - cava-network - environment: - PGADMIN_DEFAULT_EMAIL: a@b.c - PGADMIN_DEFAULT_PASSWORD: pwd - SCRIPT_NAME: /pgadmin4 - volumes: - - pgadmin-data:/var/lib/pgadmin - ports: - - "5050:80" - python: - image: omop-alchemy-python:local - build: - context: .. - dockerfile: docker/python/Dockerfile - args: - INSTALL_DEV: "true" - restart: unless-stopped - networks: - - cava-network - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} - ENGINE_CDM: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} - env_file: - - .env - depends_on: - postgres: - condition: service_healthy - volumes: - - ..:/workspace:rw - command: tail -f /dev/null - postgres: - image: postgres:18 - networks: - - cava-network - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} - env_file: - - .env - restart: unless-stopped - volumes: - - ./data:/home/data:rw - - postgres-data:/var/lib/postgresql/ - healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] - interval: 5s - timeout: 5s - retries: 10 - command: - - postgres - - -c - - max_wal_size=20GB - - -c - - checkpoint_timeout=30min - - -c - - wal_compression=on - - -c - - shared_buffers=6GB - - -c - - work_mem=256MB - - -c - - maintenance_work_mem=2GB - - -c - - effective_cache_size=16GB - cava-jupyter-notebook: - profiles: [ "jupyter"] - image: omop-alchemy-jupyter:local - build: - context: .. - dockerfile: docker/jupyter/Dockerfile - restart: unless-stopped - depends_on: - postgres: - condition: service_healthy - networks: - - cava-network - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} - ENGINE_CDM: postgresql+psycopg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB} - JUPYTERHUB_SERVICE_PREFIX: /jupyter/ - JUPYTERHUB_BASE_URL: ${HTTP_TYPE}://${HOST} - env_file: - - .env - volumes: - - ..:/home/jovyan/work:rw - command: - - jupyter-lab - - --ip=* - - --NotebookApp.token= - - --NotebookApp.password= - - --NotebookApp.base_url=/jupyter - ports: - - "8888:8888" - mem_limit: 12g - shm_size: 4g diff --git a/docker/jupyter/Dockerfile b/docker/jupyter/Dockerfile deleted file mode 100644 index 8d9b6c9..0000000 --- a/docker/jupyter/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -FROM quay.io/jupyter/minimal-notebook:python-3.13 - -USER root - -ENV UV_PROJECT_ENVIRONMENT=/opt/venv \ - UV_CACHE_DIR=/tmp/uv-cache \ - VIRTUAL_ENV=/opt/venv \ - PATH="/opt/venv/bin:/usr/local/bin:${PATH}" - -COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv - -WORKDIR /opt/omop-alchemy - -COPY LICENSE README.md pyproject.toml uv.lock ./ -COPY omop_alchemy ./omop_alchemy - -RUN uv sync --frozen --extra postgres \ - && /opt/venv/bin/python -m pip install ipykernel \ - && /opt/venv/bin/python -m ipykernel install \ - --name uv-venv \ - --display-name "Python (uv venv)" \ - && chown -R jovyan:users /opt/omop-alchemy /opt/venv - -USER jovyan -ENV HOME=/home/jovyan -WORKDIR /home/jovyan/work diff --git a/docker/python/Dockerfile b/docker/python/Dockerfile deleted file mode 100644 index a160ccf..0000000 --- a/docker/python/Dockerfile +++ /dev/null @@ -1,46 +0,0 @@ -FROM python:3.13-slim - -ARG INSTALL_DEV=false - -ENV PYTHONPYCACHEPREFIX=/tmp/pycache \ - PYTHONUNBUFFERED=1 \ - UV_PROJECT_ENVIRONMENT=/opt/venv \ - UV_CACHE_DIR=/tmp/uv-cache \ - VIRTUAL_ENV=/opt/venv \ - PATH="/opt/venv/bin:/usr/local/bin:$PATH" \ - LANG=C.UTF-8 \ - LC_ALL=C.UTF-8 - -RUN apt-get update && apt-get install -y --no-install-recommends \ - bash \ - bash-completion \ - curl \ - git \ - less \ - postgresql-client \ - vim \ - && rm -rf /var/lib/apt/lists/* - -COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv - -RUN useradd -m -s /bin/bash omop - -WORKDIR /opt/omop-alchemy - -COPY LICENSE README.md pyproject.toml uv.lock ./ -COPY omop_alchemy ./omop_alchemy - -RUN if [ "$INSTALL_DEV" = "true" ]; then \ - uv sync --frozen --extra postgres --extra dev; \ - else \ - uv sync --frozen --extra postgres; \ - fi \ - && chown -R omop:omop /opt/omop-alchemy /opt/venv - -RUN printf '\nif [ -f /opt/venv/bin/activate ] && [ -z "$VIRTUAL_ENV" ]; then\n . /opt/venv/bin/activate\nfi\n' >> /home/omop/.bashrc \ - && chown omop:omop /home/omop/.bashrc - -WORKDIR /workspace -USER omop - -CMD ["sleep", "infinity"] From ecd0d16a97d454761de59143801413bc2a368e9d Mon Sep 17 00:00:00 2001 From: gkennos Date: Wed, 20 May 2026 16:41:26 +1000 Subject: [PATCH 09/14] code review-related updates --- .github/workflows/docker-python.yml | 109 ------------------ .github/workflows/tests.yml | 11 +- .gitignore | 2 + omop_alchemy/maintenance/load_vocab.py | 3 +- tests/README.md | 2 +- tests/conftest.py | 7 +- ...mpose.yaml => example-docker-compose.yaml} | 2 + tests/test_load_vocab_postgres.py | 43 ++++--- 8 files changed, 47 insertions(+), 132 deletions(-) delete mode 100644 .github/workflows/docker-python.yml rename tests/{docker-compose.yaml => example-docker-compose.yaml} (70%) diff --git a/.github/workflows/docker-python.yml b/.github/workflows/docker-python.yml deleted file mode 100644 index 93c5493..0000000 --- a/.github/workflows/docker-python.yml +++ /dev/null @@ -1,109 +0,0 @@ -name: Docker Python Image - -on: - pull_request: - paths: - - ".github/workflows/docker-python.yml" - - ".dockerignore" - - "docker/python/Dockerfile" - - "pyproject.toml" - - "uv.lock" - - "README.md" - - "LICENSE" - - "omop_alchemy/**" - push: - branches: - - main - tags: - - "v*" - paths: - - ".github/workflows/docker-python.yml" - - ".dockerignore" - - "docker/python/Dockerfile" - - "pyproject.toml" - - "uv.lock" - - "README.md" - - "LICENSE" - - "omop_alchemy/**" - -permissions: - contents: read - -jobs: - build-check: - name: Build check - runs-on: ubuntu-latest - if: github.event_name == 'pull_request' - - steps: - - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build python image - uses: docker/build-push-action@v6 - with: - context: . - file: docker/python/Dockerfile - build-args: | - INSTALL_DEV=true - push: false - load: false - tags: omop-alchemy-python:build-check - cache-from: type=gha,scope=docker-python - cache-to: type=gha,mode=max,scope=docker-python - - publish: - name: Publish to Docker Hub - runs-on: ubuntu-latest - if: github.event_name == 'push' - - steps: - - uses: actions/checkout@v4 - - - name: Validate Docker Hub repository variable - run: | - if [ -z "${{ vars.DOCKERHUB_REPOSITORY_PYTHON }}" ]; then - echo "Repository variable DOCKERHUB_REPOSITORY_PYTHON is not set." - echo "Example value: australiancancerdatanetwork/omop-alchemy-python" - exit 1 - fi - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: docker.io/${{ vars.DOCKERHUB_REPOSITORY_PYTHON }} - tags: | - type=raw,value=latest,enable={{is_default_branch}} - type=sha,format=short - type=ref,event=tag - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - - - name: Build and push python image - uses: docker/build-push-action@v6 - with: - context: . - file: docker/python/Dockerfile - build-args: | - INSTALL_DEV=true - push: true - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha,scope=docker-python - cache-to: type=gha,mode=max,scope=docker-python diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a9a2018..e6de5ac 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,8 +29,12 @@ jobs: run: pytest -m "not postgres" -q postgres-integration-tests: - name: PostgreSQL integration tests + name: PostgreSQL integration tests (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.12", "3.13"] services: postgres: @@ -50,10 +54,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Set up Python 3.12 + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} - name: Install dependencies (including postgres extra) run: pip install -e ".[dev,postgres]" @@ -66,3 +70,4 @@ jobs: PGUSER: test PGPASSWORD: test PGDATABASE: test_db + ENGINE_CDM: postgresql+psycopg://test:test@localhost:55432/test_db diff --git a/.gitignore b/.gitignore index bfd173e..92ea8da 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,5 @@ temp/ *.dump *.bak notebooks/ +.dockerignore +docker/ \ No newline at end of file diff --git a/omop_alchemy/maintenance/load_vocab.py b/omop_alchemy/maintenance/load_vocab.py index e5db27d..f88e2a6 100644 --- a/omop_alchemy/maintenance/load_vocab.py +++ b/omop_alchemy/maintenance/load_vocab.py @@ -262,7 +262,8 @@ def _configure_loader_connection( "SQLite uses the default database namespace." ) - connection.exec_driver_sql(f"SET search_path TO {db_schema}") + quoted_schema = '"' + db_schema.replace('"', '""') + '"' + connection.exec_driver_sql(f"SET search_path TO {quoted_schema}") def load_vocab_source( engine: sa.Engine, diff --git a/tests/README.md b/tests/README.md index ed92b67..0f70491 100644 --- a/tests/README.md +++ b/tests/README.md @@ -18,7 +18,7 @@ port **55432**. ```bash # Start -docker compose -f tests/docker-compose.yaml up -d +docker compose -f tests/example-docker-compose.yaml up -d # Run (this will run all tests) uv run --extra dev --extra postgres pytest -m "postgres or not postgres" -v diff --git a/tests/conftest.py b/tests/conftest.py index 3c0cdcb..897c383 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,15 +1,13 @@ import time from datetime import date from pathlib import Path - +import os import pytest import sqlalchemy as sa from orm_loader.helpers import bootstrap import sqlalchemy.orm as so from sqlalchemy.orm import Session, sessionmaker -_PG_URL = "postgresql+psycopg://test:test@localhost:55432/test_db" - from omop_alchemy.maintenance.load_vocab import _load_vocab_model_csv from omop_alchemy.cdm.model.clinical import Condition_Occurrence, Person from omop_alchemy.cdm.model.derived import Observation_Period @@ -213,6 +211,9 @@ def pg_engine(): The fixture retries for up to 20 seconds to allow the container to become ready. """ + _PG_URL = os.getenv("ENGINE_CDM") + if not _PG_URL: + pytest.skip("No PostgreSQL engine configured. Set ENGINE_CDM environment variable.") engine = sa.create_engine(_PG_URL, future=True) for attempt in range(20): try: diff --git a/tests/docker-compose.yaml b/tests/example-docker-compose.yaml similarity index 70% rename from tests/docker-compose.yaml rename to tests/example-docker-compose.yaml index 7a8763d..9510ef3 100644 --- a/tests/docker-compose.yaml +++ b/tests/example-docker-compose.yaml @@ -1,3 +1,4 @@ +# Example docker-compose file for local testing purposes. services: postgres: image: postgres:16 @@ -5,6 +6,7 @@ services: POSTGRES_USER: test POSTGRES_PASSWORD: test POSTGRES_DB: test_db + ENGINE_CDM: postgresql+psycopg://test:test@localhost:55432/test_db ports: - "55432:5432" healthcheck: diff --git a/tests/test_load_vocab_postgres.py b/tests/test_load_vocab_postgres.py index 650401c..4a3ead1 100644 --- a/tests/test_load_vocab_postgres.py +++ b/tests/test_load_vocab_postgres.py @@ -23,6 +23,13 @@ _FIXTURE_SOURCE = Path(__file__).parent / "fixtures" / "athena_source" +def _copy_fixture_source(base_dir: Path) -> Path: + """Copy the shared Athena fixture set into an isolated per-test source dir.""" + source_path = base_dir / "athena_source" + shutil.copytree(_FIXTURE_SOURCE, source_path) + return source_path + + def _make_concept_source( base_dir: Path, *, @@ -62,9 +69,10 @@ def _make_concept_source( # --------------------------------------------------------------------------- @pytest.mark.postgres -def test_end_to_end_vocab_load_on_postgres(pg_session, pg_engine): +def test_end_to_end_vocab_load_on_postgres(pg_session, pg_engine, tmp_path): """load_vocab_source() completes end-to-end on real Postgres via orm-loader>=0.4.0.""" - report = load_vocab_source(pg_engine, source_path=_FIXTURE_SOURCE) + source_path = _copy_fixture_source(tmp_path) + report = load_vocab_source(pg_engine, source_path=source_path) assert report.merge_strategy == "replace" assert all(r.status == "loaded" for r in report.results if r.required) @@ -75,9 +83,10 @@ def test_end_to_end_vocab_load_on_postgres(pg_session, pg_engine): @pytest.mark.postgres -def test_initial_load_uses_insert_if_empty_on_postgres(pg_session, pg_engine): +def test_initial_load_uses_insert_if_empty_on_postgres(pg_session, pg_engine, tmp_path): """initial_load=True uses the empty-target insert fast path on a fresh Postgres load.""" - report = load_vocab_source(pg_engine, source_path=_FIXTURE_SOURCE, initial_load=True) + source_path = _copy_fixture_source(tmp_path) + report = load_vocab_source(pg_engine, source_path=source_path, initial_load=True) assert report.merge_strategy == "insert_if_empty" count = pg_session.execute(sa.text("SELECT COUNT(*) FROM concept")).scalar() @@ -145,14 +154,15 @@ def test_quote_mode_auto_regression_on_postgres(pg_session, pg_engine, tmp_path) @pytest.mark.postgres -def test_load_vocab_model_csv_on_postgres(pg_session): +def test_load_vocab_model_csv_on_postgres(pg_session, tmp_path): """ _load_vocab_model_csv loads data correctly on a real PostgreSQL session. orm-loader>=0.4.0 handles staging-table creation internally, so we test the end-to-end path: CSV → staging → concept table on real Postgres. """ - csv_path = _FIXTURE_SOURCE / "CONCEPT.csv" + source_path = _copy_fixture_source(tmp_path) + csv_path = source_path / "CONCEPT.csv" row_count = _load_vocab_model_csv( pg_session, @@ -212,10 +222,11 @@ def test_upsert_strategy_is_non_destructive(pg_session, pg_engine, tmp_path): @pytest.mark.postgres -def test_chunksize_forwarded_to_loader(pg_session, pg_engine, monkeypatch): +def test_chunksize_forwarded_to_loader(pg_session, pg_engine, monkeypatch, tmp_path): """chunksize is forwarded from load_vocab_source through to _load_vocab_model_csv.""" from omop_alchemy.maintenance import load_vocab as _lv_module + source_path = _copy_fixture_source(tmp_path) received_chunksizes: list[int | None] = [] original = _lv_module._load_vocab_model_csv @@ -232,7 +243,7 @@ def tracking_load(session, *, model, csv_path, merge_strategy, quote_mode="auto" monkeypatch.setattr(_lv_module, "_load_vocab_model_csv", tracking_load) - load_vocab_source(pg_engine, source_path=_FIXTURE_SOURCE, chunksize=500) + load_vocab_source(pg_engine, source_path=source_path, chunksize=500) assert received_chunksizes, "Expected at least one table to be loaded" assert all(c == 500 for c in received_chunksizes), ( @@ -241,22 +252,24 @@ def tracking_load(session, *, model, csv_path, merge_strategy, quote_mode="auto" @pytest.mark.postgres -def test_db_schema_search_path_on_postgres(pg_engine): +def test_db_schema_search_path_on_postgres(pg_engine, tmp_path): """ load_vocab_source with db_schema creates vocabulary tables in the requested PostgreSQL schema and loads data into them correctly. """ - schema = "vocab_test" + schema = 'VocabTest' + source_path = _copy_fixture_source(tmp_path) + quoted_schema = '"' + schema.replace('"', '""') + '"' with pg_engine.connect() as conn: - conn.execute(sa.text(f"DROP SCHEMA IF EXISTS {schema} CASCADE")) - conn.execute(sa.text(f"CREATE SCHEMA {schema}")) + conn.execute(sa.text(f"DROP SCHEMA IF EXISTS {quoted_schema} CASCADE")) + conn.execute(sa.text(f"CREATE SCHEMA {quoted_schema}")) conn.commit() try: report = load_vocab_source( pg_engine, - source_path=_FIXTURE_SOURCE, + source_path=source_path, db_schema=schema, ) @@ -269,10 +282,10 @@ def test_db_schema_search_path_on_postgres(pg_engine): with pg_engine.connect() as conn: count = conn.execute( - sa.text(f"SELECT COUNT(*) FROM {schema}.concept") + sa.text(f"SELECT COUNT(*) FROM {quoted_schema}.concept") ).scalar() assert count == 7 finally: with pg_engine.connect() as conn: - conn.execute(sa.text(f"DROP SCHEMA IF EXISTS {schema} CASCADE")) + conn.execute(sa.text(f"DROP SCHEMA IF EXISTS {quoted_schema} CASCADE")) conn.commit() From 874a89760fd086b2aaed18e9a43488715370fcd7 Mon Sep 17 00:00:00 2001 From: Nico Loesch Date: Thu, 21 May 2026 03:33:51 +0000 Subject: [PATCH 10/14] Have global merge strategy --- omop_alchemy/maintenance/cli.py | 20 +++----------------- omop_alchemy/maintenance/load_vocab.py | 24 +++++++++--------------- 2 files changed, 12 insertions(+), 32 deletions(-) diff --git a/omop_alchemy/maintenance/cli.py b/omop_alchemy/maintenance/cli.py index e331e1f..de2c23f 100644 --- a/omop_alchemy/maintenance/cli.py +++ b/omop_alchemy/maintenance/cli.py @@ -35,7 +35,7 @@ from .help import install_help_customizations from .info import collect_maintenance_info from .indexes import IndexAction, manage_indexes -from .load_vocab import VocabularyLoadProgress, load_vocab_source +from .load_vocab import MergeStrategy, VocabularyLoadProgress, load_vocab_source from .reconcile import reconcile_schema from .reset_sequences import reset_model_sequences from .tables import TableScope @@ -821,19 +821,14 @@ def load_vocab_source_command( dotenv: str | None = typer.Option(None, help="Optional dotenv file to load."), engine_schema: str | None = typer.Option(None, help="Engine schema selector."), db_schema: str | None = typer.Option(None, help="Database schema override. PostgreSQL only; uses search_path for ORM CSV loading."), - merge_strategy: str = typer.Option( + merge_strategy: MergeStrategy = typer.Option( "replace", - help="CSV merge strategy passed to the ORM loader. Defaults to `replace` to keep the database in sync with the Athena source; use `upsert` for incremental updates.", + help="CSV merge strategy. One of `replace` (default, keeps DB in sync), `upsert` (incremental, non-destructive), or `insert_if_empty` (fast path for a fresh empty target).", ), chunksize: int | None = typer.Option( 100_000, help="Chunk size for fallback ORM CSV loading. Defaults to 100 000 rows; pass 0 to disable chunking.", ), - initial_load: bool = typer.Option( - False, - "--initial-load", - help="Assume target vocabulary tables are empty and use the first-load fast path for a fresh Athena vocabulary load.", - ), dry_run: bool = typer.Option(False, "--dry-run"), ) -> None: connection_defaults = _resolve_connection_context( @@ -862,14 +857,6 @@ def load_vocab_source_command( ) raise typer.Exit(code=1) - if initial_load and merge_strategy != "replace": - console.print( - render_error( - "`--initial-load` cannot be combined with `--merge-strategy` values other than `replace`." - ) - ) - raise typer.Exit(code=1) - try: engine = _build_engine( dotenv=connection_defaults.dotenv, @@ -914,7 +901,6 @@ def _update_progress(event: VocabularyLoadProgress) -> None: db_schema=connection_defaults.db_schema, dry_run=dry_run, merge_strategy=merge_strategy, - initial_load=initial_load, chunksize=None if chunksize == 0 else chunksize, progress_callback=_update_progress, ) diff --git a/omop_alchemy/maintenance/load_vocab.py b/omop_alchemy/maintenance/load_vocab.py index f88e2a6..029a86d 100644 --- a/omop_alchemy/maintenance/load_vocab.py +++ b/omop_alchemy/maintenance/load_vocab.py @@ -3,7 +3,7 @@ from collections.abc import Callable from dataclasses import dataclass from pathlib import Path -from typing import TypeAlias, cast +from typing import Literal, TypeAlias, cast import sqlalchemy as sa import sqlalchemy.orm as so @@ -26,6 +26,8 @@ from .reset_sequences import reset_model_sequences from .tables import TableCategory, schema_adjusted_metadata, select_maintenance_tables +MergeStrategy: TypeAlias = Literal["replace", "upsert", "insert_if_empty"] + VocabularyModel: TypeAlias = type[CSVTableProtocol] VocabularyLoadProgressCallback: TypeAlias = Callable[["VocabularyLoadProgress"], None] @@ -48,7 +50,7 @@ class VocabularyLoadReport: source_path: str backend: str db_schema: str | None - merge_strategy: str + merge_strategy: MergeStrategy created_table_count: int sequence_reset_count: int results: tuple[VocabularyLoadResult, ...] @@ -149,7 +151,7 @@ def _load_vocab_model_csv( *, model: VocabularyModel, csv_path: Path, - merge_strategy: str, + merge_strategy: MergeStrategy, quote_mode: str = "auto", chunksize: int | None = None, ) -> int: @@ -271,20 +273,12 @@ def load_vocab_source( source_path: str | Path, db_schema: str | None = None, dry_run: bool = False, - merge_strategy: str = "replace", - initial_load: bool = False, + merge_strategy: MergeStrategy = "replace", chunksize: int | None = 100_000, progress_callback: VocabularyLoadProgressCallback | None = None, ) -> VocabularyLoadReport: _ensure_supported_backend(engine) - if initial_load and merge_strategy != "replace": - raise ValueError( - "initial_load=True cannot be combined with merge_strategy values other than 'replace'" - ) - - effective_merge_strategy = "insert_if_empty" if initial_load else merge_strategy - resolved_source_path = Path(source_path).expanduser().resolve() if not resolved_source_path.exists() or not resolved_source_path.is_dir(): raise RuntimeError( @@ -414,7 +408,7 @@ def load_vocab_source( loader_kwargs: dict[str, object] = { "model": model, "csv_path": csv_path, - "merge_strategy": effective_merge_strategy, + "merge_strategy": merge_strategy, "quote_mode": "auto", } if chunksize is not None: @@ -497,7 +491,7 @@ def load_vocab_source( raise VocabularyLoadError( "Athena vocabulary load failed for " f"table `{current_model_name or 'unknown'}` from `{current_csv_path or '-'}` " - f"using merge strategy `{effective_merge_strategy}` on backend `{engine.dialect.name}`. " + f"using merge strategy `{merge_strategy}` on backend `{engine.dialect.name}`. " f"Underlying error: {exc.__class__.__name__}: {exc}" ) from exc finally: @@ -521,7 +515,7 @@ def load_vocab_source( source_path=str(resolved_source_path), backend=engine.dialect.name, db_schema=db_schema, - merge_strategy=effective_merge_strategy, + merge_strategy=merge_strategy, created_table_count=created_table_count, sequence_reset_count=sequence_reset_count, results=tuple(results), From 06280d580fdfb8bab276635e83abdb80f5c8d83f Mon Sep 17 00:00:00 2001 From: Nico Loesch Date: Thu, 21 May 2026 04:01:20 +0000 Subject: [PATCH 11/14] In-memory fixtures with OMOP data --- tests/conftest.py | 218 +++++++++++++++++- tests/fixtures/athena_source/CONCEPT.csv | 8 - .../athena_source/CONCEPT_ANCESTOR.csv | 1 - .../fixtures/athena_source/CONCEPT_CLASS.csv | 8 - .../athena_source/CONCEPT_RELATIONSHIP.csv | 1 - .../athena_source/CONCEPT_SYNONYM.csv | 1 - tests/fixtures/athena_source/DOMAIN.csv | 8 - tests/fixtures/athena_source/RELATIONSHIP.csv | 3 - tests/fixtures/athena_source/VOCABULARY.csv | 8 - tests/test_load_vocab.py | 33 +-- tests/test_load_vocab_postgres.py | 103 +++------ tests/test_load_vocab_source.py | 140 +---------- 12 files changed, 246 insertions(+), 286 deletions(-) delete mode 100644 tests/fixtures/athena_source/CONCEPT.csv delete mode 100644 tests/fixtures/athena_source/CONCEPT_ANCESTOR.csv delete mode 100644 tests/fixtures/athena_source/CONCEPT_CLASS.csv delete mode 100644 tests/fixtures/athena_source/CONCEPT_RELATIONSHIP.csv delete mode 100644 tests/fixtures/athena_source/CONCEPT_SYNONYM.csv delete mode 100644 tests/fixtures/athena_source/DOMAIN.csv delete mode 100644 tests/fixtures/athena_source/RELATIONSHIP.csv delete mode 100644 tests/fixtures/athena_source/VOCABULARY.csv diff --git a/tests/conftest.py b/tests/conftest.py index 897c383..6c3422c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import copy import time from datetime import date from pathlib import Path @@ -8,6 +9,8 @@ import sqlalchemy.orm as so from sqlalchemy.orm import Session, sessionmaker +from typing import Any, Dict, Tuple + from omop_alchemy.maintenance.load_vocab import _load_vocab_model_csv from omop_alchemy.cdm.model.clinical import Condition_Occurrence, Person from omop_alchemy.cdm.model.derived import Observation_Period @@ -33,21 +36,143 @@ Concept_Relationship, ] - -def _athena_source_path() -> Path: - """Return the repo-local Athena fixture directory.""" - return Path(__file__).parent / "fixtures" / "athena_source" - - -def _load_fixture_vocabulary(engine: sa.Engine) -> None: - """Load required Athena vocabulary fixtures into the test database.""" - base_path = _athena_source_path() +# --------------------------------------------------------------------------- +# In-memory Athena fixture data +# --------------------------------------------------------------------------- +# Keyed by ORM __tablename__. Each value is a dict mapping column name → +# tuple of row values (one entry per row, in the same order). +# Empty tuples = table has no rows (header-only CSV). + +_ATHENA_FIXTURE_DATA: Dict[str, Dict[str, Tuple[Any, ...]]] = { + "concept_ancestor": { + "ancestor_concept_id": (), + "descendant_concept_id": (), + "min_levels_of_separation": (), + "max_levels_of_separation": (), + }, + "concept_class": { + "concept_class_id": ("Clinical Finding", "Episode", "Ethnicity", "Field", "Gender", "Race", "Type Concept"), + "concept_class_name": ("Clinical Finding", "Episode", "Ethnicity", "Field", "Gender", "Race", "Type Concept"), + "concept_class_concept_id": (0, 0, 0, 0, 0, 0, 0), + }, + "concept_relationship": { + "concept_id_1": (), + "concept_id_2": (), + "relationship_id": (), + "valid_start_date": (), + "valid_end_date": (), + "invalid_reason": (), + }, + "concept_synonym": { + "concept_id": (), + "concept_synonym_name": (), + "language_concept_id": (), + }, + "concept": { + "concept_id": (8507, 8527, 38003564, 32817, 201826, 32546, 1147127), + "concept_name": ( + "MALE", + "White", + "Not Hispanic or Latino", + "EHR", + "Type 2 diabetes mellitus", + "Disease Episode", + "condition_occurrence.condition_occurrence_id", + ), + "domain_id": ("Gender", "Race", "Ethnicity", "Type Concept", "Condition", "Episode", "Metadata"), + "vocabulary_id": ("Gender", "Race", "Ethnicity", "Type Concept", "SNOMED", "Episode", "CDM"), + "concept_class_id": ( + "Gender", + "Race", + "Ethnicity", + "Type Concept", + "Clinical Finding", + "Episode", + "Field", + ), + "standard_concept": ("S", "S", "S", "S", "S", "S", "S"), + "concept_code": ( + "M", + "White", + "Not Hispanic or Latino", + "EHR", + "44054006", + "Disease Episode", + "condition_occurrence.condition_occurrence_id", + ), + "valid_start_date": ( + "19700101", + "19700101", + "19700101", + "19700101", + "19700101", + "19700101", + "19700101", + ), + "valid_end_date": ( + "20991231", + "20991231", + "20991231", + "20991231", + "20991231", + "20991231", + "20991231", + ), + "invalid_reason": (None, None, None, None, None, None, None), + }, + "domain": { + "domain_id": ("Condition", "Episode", "Ethnicity", "Gender", "Metadata", "Race", "Type Concept"), + "domain_name": ("Condition", "Episode", "Ethnicity", "Gender", "Metadata", "Race", "Type Concept"), + "domain_concept_id": (0, 0, 0, 0, 0, 0, 0), + }, + "relationship": { + "relationship_id": ("Is a", "Subsumes"), + "relationship_name": ("Is a", "Subsumes"), + "is_hierarchical": (1, 1), + "defines_ancestry": (1, 0), + "reverse_relationship_id": ("Subsumes", "Is a"), + "relationship_concept_id": (0, 0), + }, + "vocabulary": { + "vocabulary_id": ("CDM", "Episode", "Ethnicity", "Gender", "Race", "SNOMED", "Type Concept"), + "vocabulary_name": ( + "Common Data Model", + "OMOP Episode", + "OMOP Ethnicity", + "OMOP Gender", + "OMOP Race", + "SNOMED-CT", + "OMOP Type Concept", + ), + "vocabulary_reference": ("OHDSI", "OHDSI", "OHDSI", "OHDSI", "OHDSI", "IHTSDO", "OHDSI"), + "vocabulary_version": ("v5.4", "v1.0", "v1.0", "v1.0", "v1.0", "SNOMED CT 2023", "v1.0"), + "vocabulary_concept_id": (0, 0, 0, 0, 0, 0, 0), + }, +} + + +def _write_fixture_csv(directory: Path, table_name: str, data: Dict[str, Tuple[Any, ...]]) -> Path: + """Write an in-memory fixture dict to a tab-separated CSV file.""" + path = directory / f"{table_name.upper()}.csv" + cols = list(data.keys()) + rows = list(zip(*data.values())) if cols and any(data.values()) else [] + with open(path, "w", newline="", encoding="utf-8") as f: + f.write("\t".join(cols) + "\n") + for row in rows: + f.write("\t".join("" if v is None else str(v) for v in row) + "\n") + return path + + +def _load_fixture_vocabulary(engine: sa.Engine, tmp_dir: Path) -> None: + """Write in-memory Athena fixtures to tmp_dir and load them into the test database.""" with engine.connect() as connection: SessionLocal = so.sessionmaker(bind=connection, future=True) session = SessionLocal() try: for model in ATHENA_LOAD_ORDER: - csv_path = base_path / f"{model.__tablename__.upper()}.csv" + csv_path = _write_fixture_csv( + tmp_dir, model.__tablename__, _ATHENA_FIXTURE_DATA[model.__tablename__] + ) _load_vocab_model_csv( session, model=model, @@ -189,7 +314,7 @@ def engine(tmp_path_factory: pytest.TempPathFactory): ) bootstrap(engine, create=True) - _load_fixture_vocabulary(engine) + _load_fixture_vocabulary(engine, db_dir) SessionLocal = sessionmaker(bind=engine, future=True, expire_on_commit=False) with SessionLocal() as seed_session: @@ -277,3 +402,74 @@ def session(engine) -> Session: # type: ignore session.rollback() finally: session.close() + + +# --------------------------------------------------------------------------- +# In-memory Athena vocabulary fixtures +# --------------------------------------------------------------------------- +# Each fixture returns a mutable copy of the module-level constant so tests +# can append rows without cross-contaminating other tests. + + +@pytest.fixture(scope="function") +def athena_fixtures() -> Dict[str, Dict[str, Tuple[Any, ...]]]: + """All Athena vocabulary tables as a single dict keyed by ORM table name.""" + return {k: dict(v) for k, v in _ATHENA_FIXTURE_DATA.items()} + + +@pytest.fixture(scope="function") +def athena_source_dir(tmp_path: Path) -> Path: + """Write in-memory Athena fixtures to a temp directory and return the path.""" + source = tmp_path / "athena_source" + source.mkdir() + for table_name, data in _ATHENA_FIXTURE_DATA.items(): + _write_fixture_csv(source, table_name, data) + return source + + +@pytest.fixture(scope="function") +def concept_ancestor() -> Dict[str, Tuple[Any, ...]]: + """Mutable copy of the concept_ancestor fixture data.""" + return copy.deepcopy(_ATHENA_FIXTURE_DATA["concept_ancestor"]) + + +@pytest.fixture(scope="function") +def concept_class() -> Dict[str, Tuple[Any, ...]]: + """Mutable copy of the concept_class fixture data.""" + return copy.deepcopy(_ATHENA_FIXTURE_DATA["concept_class"]) + + +@pytest.fixture(scope="function") +def concept_relationship() -> Dict[str, Tuple[Any, ...]]: + """Mutable copy of the concept_relationship fixture data.""" + return copy.deepcopy(_ATHENA_FIXTURE_DATA["concept_relationship"]) + + +@pytest.fixture(scope="function") +def concept_synonym() -> Dict[str, Tuple[Any, ...]]: + """Mutable copy of the concept_synonym fixture data.""" + return copy.deepcopy(_ATHENA_FIXTURE_DATA["concept_synonym"]) + + +@pytest.fixture(scope="function") +def concept() -> Dict[str, Tuple[Any, ...]]: + """Mutable copy of the concept fixture data.""" + return copy.deepcopy(_ATHENA_FIXTURE_DATA["concept"]) + + +@pytest.fixture(scope="function") +def domain() -> Dict[str, Tuple[Any, ...]]: + """Mutable copy of the domain fixture data.""" + return copy.deepcopy(_ATHENA_FIXTURE_DATA["domain"]) + + +@pytest.fixture(scope="function") +def relationship() -> Dict[str, Tuple[Any, ...]]: + """Mutable copy of the relationship fixture data.""" + return copy.deepcopy(_ATHENA_FIXTURE_DATA["relationship"]) + + +@pytest.fixture(scope="function") +def vocabulary() -> Dict[str, Tuple[Any, ...]]: + """Mutable copy of the vocabulary fixture data.""" + return copy.deepcopy(_ATHENA_FIXTURE_DATA["vocabulary"]) diff --git a/tests/fixtures/athena_source/CONCEPT.csv b/tests/fixtures/athena_source/CONCEPT.csv deleted file mode 100644 index db1e8c5..0000000 --- a/tests/fixtures/athena_source/CONCEPT.csv +++ /dev/null @@ -1,8 +0,0 @@ -concept_id concept_name domain_id vocabulary_id concept_class_id standard_concept concept_code valid_start_date valid_end_date invalid_reason -8507 MALE Gender Gender Gender S M 19700101 20991231 -8527 White Race Race Race S White 19700101 20991231 -38003564 Not Hispanic or Latino Ethnicity Ethnicity Ethnicity S Not Hispanic or Latino 19700101 20991231 -32817 EHR Type Concept Type Concept Type Concept S EHR 19700101 20991231 -201826 Type 2 diabetes mellitus Condition SNOMED Clinical Finding S 44054006 19700101 20991231 -32546 Disease Episode Episode Episode Episode S Disease Episode 19700101 20991231 -1147127 condition_occurrence.condition_occurrence_id Metadata CDM Field S condition_occurrence.condition_occurrence_id 19700101 20991231 diff --git a/tests/fixtures/athena_source/CONCEPT_ANCESTOR.csv b/tests/fixtures/athena_source/CONCEPT_ANCESTOR.csv deleted file mode 100644 index 4e7b1b2..0000000 --- a/tests/fixtures/athena_source/CONCEPT_ANCESTOR.csv +++ /dev/null @@ -1 +0,0 @@ -ancestor_concept_id descendant_concept_id min_levels_of_separation max_levels_of_separation diff --git a/tests/fixtures/athena_source/CONCEPT_CLASS.csv b/tests/fixtures/athena_source/CONCEPT_CLASS.csv deleted file mode 100644 index 0c128ae..0000000 --- a/tests/fixtures/athena_source/CONCEPT_CLASS.csv +++ /dev/null @@ -1,8 +0,0 @@ -concept_class_id concept_class_name concept_class_concept_id -Clinical Finding Clinical Finding 0 -Episode Episode 0 -Ethnicity Ethnicity 0 -Field Field 0 -Gender Gender 0 -Race Race 0 -Type Concept Type Concept 0 diff --git a/tests/fixtures/athena_source/CONCEPT_RELATIONSHIP.csv b/tests/fixtures/athena_source/CONCEPT_RELATIONSHIP.csv deleted file mode 100644 index 89cfde0..0000000 --- a/tests/fixtures/athena_source/CONCEPT_RELATIONSHIP.csv +++ /dev/null @@ -1 +0,0 @@ -concept_id_1 concept_id_2 relationship_id valid_start_date valid_end_date invalid_reason diff --git a/tests/fixtures/athena_source/CONCEPT_SYNONYM.csv b/tests/fixtures/athena_source/CONCEPT_SYNONYM.csv deleted file mode 100644 index e906770..0000000 --- a/tests/fixtures/athena_source/CONCEPT_SYNONYM.csv +++ /dev/null @@ -1 +0,0 @@ -concept_id concept_synonym_name language_concept_id diff --git a/tests/fixtures/athena_source/DOMAIN.csv b/tests/fixtures/athena_source/DOMAIN.csv deleted file mode 100644 index 2df5ad4..0000000 --- a/tests/fixtures/athena_source/DOMAIN.csv +++ /dev/null @@ -1,8 +0,0 @@ -domain_id domain_name domain_concept_id -Condition Condition 0 -Episode Episode 0 -Ethnicity Ethnicity 0 -Gender Gender 0 -Metadata Metadata 0 -Race Race 0 -Type Concept Type Concept 0 diff --git a/tests/fixtures/athena_source/RELATIONSHIP.csv b/tests/fixtures/athena_source/RELATIONSHIP.csv deleted file mode 100644 index aa9cf9b..0000000 --- a/tests/fixtures/athena_source/RELATIONSHIP.csv +++ /dev/null @@ -1,3 +0,0 @@ -relationship_id relationship_name is_hierarchical defines_ancestry reverse_relationship_id relationship_concept_id -Is a Is a 1 1 Subsumes 0 -Subsumes Subsumes 1 0 Is a 0 diff --git a/tests/fixtures/athena_source/VOCABULARY.csv b/tests/fixtures/athena_source/VOCABULARY.csv deleted file mode 100644 index a51f62a..0000000 --- a/tests/fixtures/athena_source/VOCABULARY.csv +++ /dev/null @@ -1,8 +0,0 @@ -vocabulary_id vocabulary_name vocabulary_reference vocabulary_version vocabulary_concept_id -CDM Common Data Model OHDSI v5.4 0 -Episode OMOP Episode OHDSI v1.0 0 -Ethnicity OMOP Ethnicity OHDSI v1.0 0 -Gender OMOP Gender OHDSI v1.0 0 -Race OMOP Race OHDSI v1.0 0 -SNOMED SNOMED-CT IHTSDO SNOMED CT 2023 0 -Type Concept OMOP Type Concept OHDSI v1.0 0 diff --git a/tests/test_load_vocab.py b/tests/test_load_vocab.py index 6d9b65e..80b75bc 100644 --- a/tests/test_load_vocab.py +++ b/tests/test_load_vocab.py @@ -1,28 +1,17 @@ import pytest -from pathlib import Path from orm_loader.helpers import bootstrap import sqlalchemy as sa from sqlalchemy.orm import sessionmaker from omop_alchemy.cdm.model.vocabulary import ( Domain, - Vocabulary, - Concept_Class, Relationship, Concept, Concept_Ancestor, Concept_Relationship, ) - -ATHENA_LOAD_ORDER = [ - Domain, - Vocabulary, - Concept_Class, - Relationship, - Concept, - Concept_Ancestor, - Concept_Relationship, -] +from pathlib import Path +from tests.conftest import ATHENA_LOAD_ORDER, _ATHENA_FIXTURE_DATA, _write_fixture_csv @pytest.fixture(scope="session") @@ -58,25 +47,19 @@ def db_session(connection): @pytest.fixture(scope="session") -def athena_vocab(connection): +def athena_vocab(connection, tmp_path_factory): """ Load the minimal Athena vocabulary fixture using the real ORM CSV loader. - Files follow the Athena convention: UPPERCASE table names with .csv extension. + + Writes in-memory fixture data to a temp directory so no static CSV files + on disk are required. """ + base_path: Path = tmp_path_factory.mktemp("athena_vocab") Session = sessionmaker(bind=connection, future=True) session = Session() - base_path = ( - Path(__file__).parent - / "fixtures" - / "athena_source" - ) - for model in ATHENA_LOAD_ORDER: - csv_path = base_path / f"{model.__tablename__.upper()}.csv" - if not csv_path.exists(): - raise RuntimeError(f"Missing vocab CSV: {csv_path}") - + csv_path = _write_fixture_csv(base_path, model.__tablename__, _ATHENA_FIXTURE_DATA[model.__tablename__]) model.load_csv(session, csv_path) session.commit() diff --git a/tests/test_load_vocab_postgres.py b/tests/test_load_vocab_postgres.py index 4a3ead1..d8a2f87 100644 --- a/tests/test_load_vocab_postgres.py +++ b/tests/test_load_vocab_postgres.py @@ -7,26 +7,24 @@ Then run: pytest -m postgres """ -import shutil from pathlib import Path -import pytest import sqlalchemy as sa from omop_alchemy.cdm.model.vocabulary import Concept from omop_alchemy.maintenance.load_vocab import ( - REQUIRED_VOCAB_MODELS, _load_vocab_model_csv, load_vocab_source, ) - -_FIXTURE_SOURCE = Path(__file__).parent / "fixtures" / "athena_source" +from tests.conftest import _ATHENA_FIXTURE_DATA, _write_fixture_csv def _copy_fixture_source(base_dir: Path) -> Path: - """Copy the shared Athena fixture set into an isolated per-test source dir.""" + """Write the shared in-memory Athena fixture set into an isolated per-test source dir.""" source_path = base_dir / "athena_source" - shutil.copytree(_FIXTURE_SOURCE, source_path) + source_path.mkdir(parents=True) + for table_name, data in _ATHENA_FIXTURE_DATA.items(): + _write_fixture_csv(source_path, table_name, data) return source_path @@ -39,28 +37,18 @@ def _make_concept_source( """ Build a minimal vocabulary source where CONCEPT.csv contains exactly one test concept with a Gender domain reference, and all other required tables - are copied from the shared fixture (which has the Gender domain row). + are written from the shared in-memory fixture. """ source_path = base_dir / "athena_source" source_path.mkdir(parents=True) - for fname in ( - "DOMAIN.csv", - "VOCABULARY.csv", - "CONCEPT_CLASS.csv", - "RELATIONSHIP.csv", - "CONCEPT_ANCESTOR.csv", - "CONCEPT_RELATIONSHIP.csv", - "CONCEPT_SYNONYM.csv", - ): - shutil.copy(_FIXTURE_SOURCE / fname, source_path / fname) - - (source_path / "CONCEPT.csv").write_text( - "concept_id\tconcept_name\tdomain_id\tvocabulary_id\tconcept_class_id\t" - "standard_concept\tconcept_code\tvalid_start_date\tvalid_end_date\tinvalid_reason\n" - f"{concept_id}\t{concept_name}\tGender\tGender\tGender\tS\tTEST\t19700101\t20991231\t\n", - encoding="utf-8", - ) + for table_name, data in _ATHENA_FIXTURE_DATA.items(): + if table_name != "concept": + _write_fixture_csv(source_path, table_name, data) + + concept_cols = list(_ATHENA_FIXTURE_DATA["concept"].keys()) + concept_row = [concept_id, concept_name, "Gender", "Gender", "Gender", "S", "TEST", "19700101", "20991231", None] + _write_fixture_csv(source_path, "concept", {col: (val,) for col, val in zip(concept_cols, concept_row)}) return source_path @@ -68,7 +56,7 @@ def _make_concept_source( # Tests # --------------------------------------------------------------------------- -@pytest.mark.postgres + def test_end_to_end_vocab_load_on_postgres(pg_session, pg_engine, tmp_path): """load_vocab_source() completes end-to-end on real Postgres via orm-loader>=0.4.0.""" source_path = _copy_fixture_source(tmp_path) @@ -82,18 +70,7 @@ def test_end_to_end_vocab_load_on_postgres(pg_session, pg_engine, tmp_path): assert count == 7 -@pytest.mark.postgres -def test_initial_load_uses_insert_if_empty_on_postgres(pg_session, pg_engine, tmp_path): - """initial_load=True uses the empty-target insert fast path on a fresh Postgres load.""" - source_path = _copy_fixture_source(tmp_path) - report = load_vocab_source(pg_engine, source_path=source_path, initial_load=True) - - assert report.merge_strategy == "insert_if_empty" - count = pg_session.execute(sa.text("SELECT COUNT(*) FROM concept")).scalar() - assert count == 7 - -@pytest.mark.postgres def test_quote_mode_auto_regression_on_postgres(pg_session, pg_engine, tmp_path): """ quote_mode='auto' strips RFC-4180 double-quotes via PostgreSQL COPY. @@ -107,38 +84,16 @@ def test_quote_mode_auto_regression_on_postgres(pg_session, pg_engine, tmp_path) long_name = "A" * 255 # exactly at VARCHAR(255) limit when unquoted - for model in REQUIRED_VOCAB_MODELS: - table_name = model.__tablename__.upper() - csv_path = source_path / f"{table_name}.csv" - if table_name == "CONCEPT": - # Wrap the 255-char name in double-quotes so it's 257 chars raw. - csv_path.write_text( - "concept_id\tconcept_name\tdomain_id\tvocabulary_id\t" - "concept_class_id\tstandard_concept\tconcept_code\t" - "valid_start_date\tvalid_end_date\tinvalid_reason\n" - f'1\t"{long_name}"\tGender\tGender\tGender\tS\tTEST\t19700101\t20991231\t\n', - encoding="utf-8", - ) - elif table_name == "DOMAIN": - csv_path.write_text( - "domain_id\tdomain_name\tdomain_concept_id\nGender\tGender\t0\n", - encoding="utf-8", - ) - elif table_name == "VOCABULARY": - csv_path.write_text( - "vocabulary_id\tvocabulary_name\tvocabulary_reference\t" - "vocabulary_version\tvocabulary_concept_id\n" - "Gender\tOMOP Gender\tOHDSI\tv1.0\t0\n", - encoding="utf-8", - ) - elif table_name == "CONCEPT_CLASS": - csv_path.write_text( - "concept_class_id\tconcept_class_name\tconcept_class_concept_id\n" - "Gender\tGender\t0\n", - encoding="utf-8", - ) - else: - shutil.copy(_FIXTURE_SOURCE / f"{table_name}.csv", csv_path) + # All tables except concept get the standard fixture data. + for table_name, data in _ATHENA_FIXTURE_DATA.items(): + if table_name != "concept": + _write_fixture_csv(source_path, table_name, data) + + # Concept gets a single row whose name is wrapped in RFC-4180 double-quotes + # so the raw file value is 257 chars. quote_mode='auto' must strip them. + concept_cols = list(_ATHENA_FIXTURE_DATA["concept"].keys()) + concept_row = [1, f'"{long_name}"', "Gender", "Gender", "Gender", "S", "TEST", "19700101", "20991231", None] + _write_fixture_csv(source_path, "concept", {col: (val,) for col, val in zip(concept_cols, concept_row)}) # Should not raise: literal mode would produce a 257-char value and fail. load_vocab_source(pg_engine, source_path=source_path) @@ -153,7 +108,7 @@ def test_quote_mode_auto_regression_on_postgres(pg_session, pg_engine, tmp_path) assert not concept_name.startswith('"'), "Surrounding quotes were not stripped" -@pytest.mark.postgres + def test_load_vocab_model_csv_on_postgres(pg_session, tmp_path): """ _load_vocab_model_csv loads data correctly on a real PostgreSQL session. @@ -177,7 +132,7 @@ def test_load_vocab_model_csv_on_postgres(pg_session, tmp_path): assert count == 7 -@pytest.mark.postgres + def test_replace_strategy_overwrites_existing_rows(pg_session, pg_engine, tmp_path): """merge_strategy='replace' fully replaces rows with the same PKs on second load.""" concept_id = 99999 @@ -198,7 +153,7 @@ def test_replace_strategy_overwrites_existing_rows(pg_session, pg_engine, tmp_pa assert name == "name_v2", f"Expected 'name_v2' after replace, got {name!r}" -@pytest.mark.postgres + def test_upsert_strategy_is_non_destructive(pg_session, pg_engine, tmp_path): """merge_strategy='upsert' preserves existing rows on second load with same PKs.""" concept_id = 99998 @@ -221,7 +176,7 @@ def test_upsert_strategy_is_non_destructive(pg_session, pg_engine, tmp_path): ) -@pytest.mark.postgres + def test_chunksize_forwarded_to_loader(pg_session, pg_engine, monkeypatch, tmp_path): """chunksize is forwarded from load_vocab_source through to _load_vocab_model_csv.""" from omop_alchemy.maintenance import load_vocab as _lv_module @@ -251,7 +206,7 @@ def tracking_load(session, *, model, csv_path, merge_strategy, quote_mode="auto" ) -@pytest.mark.postgres + def test_db_schema_search_path_on_postgres(pg_engine, tmp_path): """ load_vocab_source with db_schema creates vocabulary tables in the requested diff --git a/tests/test_load_vocab_source.py b/tests/test_load_vocab_source.py index 9450b96..fbb8a59 100644 --- a/tests/test_load_vocab_source.py +++ b/tests/test_load_vocab_source.py @@ -10,6 +10,7 @@ from omop_alchemy.maintenance.load_vocab import ( OPTIONAL_VOCAB_MODELS, REQUIRED_VOCAB_MODELS, + MergeStrategy, _load_vocab_model_csv, load_vocab_source, ) @@ -168,8 +169,7 @@ def fake_load_vocab_source( source_path: str | Path, db_schema: str | None = None, dry_run: bool = False, - merge_strategy: str = "replace", - initial_load: bool = False, + merge_strategy: MergeStrategy = "replace", chunksize: int | None = None, progress_callback=None, ): @@ -180,7 +180,6 @@ def fake_load_vocab_source( calls["db_schema"] = db_schema calls["dry_run"] = dry_run calls["merge_strategy"] = merge_strategy - calls["initial_load"] = initial_load return VocabularyLoadReport( source_path=str(source_path), backend="sqlite", @@ -243,100 +242,10 @@ def fake_load_vocab_source( assert calls["engine"] == "ENGINE" assert calls["source_path"] == expected_source_path assert calls["merge_strategy"] == "replace" - assert calls["initial_load"] is False assert "load-vocab-source" in result.stdout assert "concept" in result.stdout -def test_load_vocab_source_cli_initial_load_uses_first_load_fast_path(monkeypatch): - """CLI --initial-load forwards the fresh-load intent to load_vocab_source().""" - calls: dict[str, object] = {} - - def fake_build_engine(*, dotenv: str | None, engine_schema: str | None): - return "ENGINE" - - def fake_load_vocab_source( - engine: object, - *, - source_path: str | Path, - db_schema: str | None = None, - dry_run: bool = False, - merge_strategy: str = "replace", - initial_load: bool = False, - chunksize: int | None = None, - progress_callback=None, - ): - from omop_alchemy.maintenance.load_vocab import VocabularyLoadReport, VocabularyLoadResult - - calls["engine"] = engine - calls["merge_strategy"] = merge_strategy - calls["initial_load"] = initial_load - effective_merge_strategy = "insert_if_empty" if initial_load else merge_strategy - return VocabularyLoadReport( - source_path=str(source_path), - backend="sqlite", - db_schema=db_schema, - merge_strategy=effective_merge_strategy, - created_table_count=0, - sequence_reset_count=0, - results=( - VocabularyLoadResult( - table_name="concept", - status="planned", - row_count=None, - csv_path=str(Path(source_path) / "CONCEPT.csv"), - required=True, - detail="Athena CSV would be loaded via staged ORM CSV loader", - ), - ), - ) - - monkeypatch.setattr( - "omop_alchemy.maintenance.cli._build_engine", - fake_build_engine, - ) - monkeypatch.setattr( - "omop_alchemy.maintenance.cli.load_vocab_source", - fake_load_vocab_source, - ) - - result = runner.invoke( - app, - [ - "load-vocab-source", - "--athena-source", - str(_athena_source_path()), - "--initial-load", - "--dry-run", - ], - ) - - assert result.exit_code == 0 - assert calls["engine"] == "ENGINE" - assert calls["merge_strategy"] == "replace" - assert calls["initial_load"] is True - - -def test_load_vocab_source_cli_rejects_initial_load_with_non_replace_strategy(): - """CLI should reject combining --initial-load with a conflicting merge strategy.""" - result = runner.invoke( - app, - [ - "load-vocab-source", - "--athena-source", - str(_athena_source_path()), - "--initial-load", - "--merge-strategy", - "upsert", - "--dry-run", - ], - ) - - assert result.exit_code == 1 - assert "--initial-load" in result.stdout - assert "replace" in result.stdout - - def test_load_vocab_model_csv_passes_quote_mode(monkeypatch, tmp_path): """Test load vocab model csv passes quote mode.""" engine = sa.create_engine(f"sqlite:///{tmp_path / 'load_vocab_source_quote_mode.db'}", future=True) @@ -417,51 +326,6 @@ def fake_load_vocab_model_csv( assert loaded_order[:3] == ["domain", "concept_class", "vocabulary"] -def test_load_vocab_source_initial_load_maps_to_insert_if_empty(monkeypatch, tmp_path): - """initial_load=True maps the vocab loader onto orm-loader's insert-if-empty path.""" - engine = sa.create_engine(f"sqlite:///{tmp_path / 'load_vocab_source_initial_load.db'}", future=True) - source_path = _build_required_athena_source(tmp_path) - - received_merge_strategies: list[str] = [] - - def fake_load_vocab_model_csv( - session, - *, - model, - csv_path, - merge_strategy, - quote_mode="auto", - chunksize=None, - ) -> int: - received_merge_strategies.append(merge_strategy) - return 1 - - monkeypatch.setattr( - "omop_alchemy.maintenance.load_vocab._load_vocab_model_csv", - fake_load_vocab_model_csv, - ) - - report = load_vocab_source(engine, source_path=source_path, initial_load=True) - - assert report.merge_strategy == "insert_if_empty" - assert received_merge_strategies - assert all(strategy == "insert_if_empty" for strategy in received_merge_strategies) - - -def test_load_vocab_source_rejects_initial_load_with_non_replace_strategy(tmp_path): - """initial_load=True cannot be combined with a conflicting merge strategy.""" - engine = sa.create_engine(f"sqlite:///{tmp_path / 'load_vocab_source_initial_load_error.db'}", future=True) - source_path = _build_required_athena_source(tmp_path) - - with pytest.raises(ValueError, match="initial_load=True"): - load_vocab_source( - engine, - source_path=source_path, - initial_load=True, - merge_strategy="upsert", - ) - - def test_load_vocab_source_reports_weighted_progress(monkeypatch, tmp_path): """Test load vocab source reports weighted progress.""" engine = sa.create_engine(f"sqlite:///{tmp_path / 'load_vocab_source_progress.db'}", future=True) From d26e3669509be1ff85fffb6c741dbaa726c14abc Mon Sep 17 00:00:00 2001 From: Nico Loesch Date: Thu, 21 May 2026 04:01:45 +0000 Subject: [PATCH 12/14] quoted schema --- omop_alchemy/maintenance/tables.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/omop_alchemy/maintenance/tables.py b/omop_alchemy/maintenance/tables.py index 407bbf0..999efd0 100644 --- a/omop_alchemy/maintenance/tables.py +++ b/omop_alchemy/maintenance/tables.py @@ -67,7 +67,8 @@ def has_single_integer_primary_key(self) -> bool: def qualified_table_name(table_name: str, db_schema: str | None) -> str: if db_schema: - return f"{db_schema}.{table_name}" + quoted_schema = '"' + db_schema.replace('"', '""') + '"' + return f"{quoted_schema}.{table_name}" return table_name From 1662afc83423a3160d6358434d95fe4e552054db Mon Sep 17 00:00:00 2001 From: Nico Loesch Date: Thu, 21 May 2026 04:01:55 +0000 Subject: [PATCH 13/14] Updated CI/CD --- .github/workflows/tests.yml | 21 ++++++++------------- pyproject.toml | 3 --- tests/example-docker-compose.yaml | 16 ---------------- 3 files changed, 8 insertions(+), 32 deletions(-) delete mode 100644 tests/example-docker-compose.yaml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e6de5ac..15b6da6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,8 +6,8 @@ on: pull_request: jobs: - unit-and-sqlite-tests: - name: Unit & SQLite tests (Python ${{ matrix.python-version }}) + sqlite-tests: + name: SQLite tests (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest strategy: fail-fast: false @@ -25,11 +25,11 @@ jobs: - name: Install dependencies run: pip install -e ".[dev]" - - name: Run tests (excluding postgres) - run: pytest -m "not postgres" -q + - name: Run tests + run: pytest -q - postgres-integration-tests: - name: PostgreSQL integration tests (Python ${{ matrix.python-version }}) + postgres-tests: + name: PostgreSQL tests (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest strategy: fail-fast: false @@ -62,12 +62,7 @@ jobs: - name: Install dependencies (including postgres extra) run: pip install -e ".[dev,postgres]" - - name: Run postgres integration tests - run: pytest -m postgres -v + - name: Run tests + run: pytest -v env: - PGHOST: localhost - PGPORT: 55432 - PGUSER: test - PGPASSWORD: test - PGDATABASE: test_db ENGINE_CDM: postgresql+psycopg://test:test@localhost:55432/test_db diff --git a/pyproject.toml b/pyproject.toml index 72ca011..965ad27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,9 +76,6 @@ requires = ["setuptools>=68", "wheel"] build-backend = "setuptools.build_meta" [tool.pytest.ini_options] -markers = [ - "postgres: marks tests that require a running PostgreSQL instance (deselect with '-m not postgres')", -] [tool.setuptools] include-package-data = true diff --git a/tests/example-docker-compose.yaml b/tests/example-docker-compose.yaml deleted file mode 100644 index 9510ef3..0000000 --- a/tests/example-docker-compose.yaml +++ /dev/null @@ -1,16 +0,0 @@ -# Example docker-compose file for local testing purposes. -services: - postgres: - image: postgres:16 - environment: - POSTGRES_USER: test - POSTGRES_PASSWORD: test - POSTGRES_DB: test_db - ENGINE_CDM: postgresql+psycopg://test:test@localhost:55432/test_db - ports: - - "55432:5432" - healthcheck: - test: ["CMD-SHELL", "pg_isready -U test -d test_db"] - interval: 2s - timeout: 5s - retries: 10 From b37a48772bbed898a31e8f53a7cb0e7f797be9d3 Mon Sep 17 00:00:00 2001 From: Nico Loesch Date: Thu, 21 May 2026 04:03:47 +0000 Subject: [PATCH 14/14] Remove unused create_test_fixtures --- tests/fixtures/create_test_fixtures.py | 580 ------------------------- 1 file changed, 580 deletions(-) delete mode 100644 tests/fixtures/create_test_fixtures.py diff --git a/tests/fixtures/create_test_fixtures.py b/tests/fixtures/create_test_fixtures.py deleted file mode 100644 index f98c140..0000000 --- a/tests/fixtures/create_test_fixtures.py +++ /dev/null @@ -1,580 +0,0 @@ -from __future__ import annotations -""" -This script rebuilds the SQLite test fixture from Athena vocabulary CSVs, then exports dummy clinical tables as CSV files. - -It assumes you have a terse sample set of appropriate concepts in the Athena source, but will attempt to fall back to any available concepts if the ideal ones are not present. The generated clinical data is deterministic based on the provided random seed, but otherwise arbitrary and not meant to reflect any real patient population. -""" -import argparse -from dataclasses import dataclass -from datetime import date, timedelta -from pathlib import Path -from random import Random - -import pandas as pd -import sqlalchemy as sa -from sqlalchemy.orm import Session, sessionmaker - -from omop_alchemy.cdm.model.clinical import Condition_Occurrence, Death, Measurement, Person -from omop_alchemy.cdm.model.derived import Observation_Period -from omop_alchemy.cdm.model.health_system import Care_Site, Location, Provider, Visit_Occurrence -from omop_alchemy.cdm.model.structural import Episode, Episode_Event -from omop_alchemy.cdm.model.vocabulary import Concept, Concept_Ancestor -from omop_alchemy.maintenance.create_tables import create_missing_tables -from omop_alchemy.maintenance.load_vocab import load_vocab_source - - -ROOT = Path(__file__).resolve().parents[2] -DEFAULT_ATHENA_SOURCE = ROOT / "tests" / "fixtures" / "athena_source" -DEFAULT_DB_PATH = ROOT / "tests" / "fixtures" / "test.db" -DEFAULT_CLINICAL_CSV_DIR = ROOT / "tests" / "fixtures" / "test_clinical_csvs" - -CLINICAL_EXPORT_MODELS = ( - Location, - Care_Site, - Provider, - Person, - Visit_Occurrence, - Observation_Period, - Death, - Condition_Occurrence, - Measurement, - Episode, - Episode_Event, -) - - -@dataclass(frozen=True) -class FixtureConcepts: - genders: tuple[int, ...] - ethnicities: tuple[int, ...] - races: tuple[int, ...] - visit_concepts: tuple[int, ...] - location_concepts: tuple[int, ...] - provider_specialties: tuple[int, ...] - type_concepts: tuple[int, ...] - condition_concepts: tuple[int, ...] - stage_concepts: tuple[int, ...] - episode_concept_id: int - condition_event_field_concept_id: int - measurement_event_field_concept_id: int - - -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser( - description=( - "Rebuild the SQLite test fixture from Athena vocabulary CSVs, " - "then export dummy clinical tables as CSV files." - ) - ) - parser.add_argument( - "--athena-source", - type=Path, - default=DEFAULT_ATHENA_SOURCE, - help="Path to the curated Athena fixture directory.", - ) - parser.add_argument( - "--db-path", - type=Path, - default=DEFAULT_DB_PATH, - help="Target SQLite database path.", - ) - parser.add_argument( - "--clinical-csv-dir", - type=Path, - default=DEFAULT_CLINICAL_CSV_DIR, - help="Directory to receive exported dummy clinical CSV files.", - ) - parser.add_argument( - "--seed", - type=int, - default=54, - help="Deterministic random seed for dummy clinical data generation.", - ) - parser.add_argument( - "--person-count", - type=int, - default=24, - help="Number of dummy people to generate.", - ) - parser.add_argument( - "--force-rebuild", - action="store_true", - help="Ignore any existing fixture database and rebuild from scratch.", - ) - return parser.parse_args() - - -def _reset_outputs(db_path: Path, clinical_csv_dir: Path) -> None: - db_path.parent.mkdir(parents=True, exist_ok=True) - clinical_csv_dir.mkdir(parents=True, exist_ok=True) - - if db_path.exists(): - db_path.unlink() - - for csv_path in clinical_csv_dir.glob("*.csv"): - csv_path.unlink() - - -def _fixture_db_has_people(db_path: Path) -> bool: - if not db_path.exists(): - return False - - engine = sa.create_engine(f"sqlite:///{db_path}", future=True, echo=False) - try: - inspector = sa.inspect(engine) - if not inspector.has_table("person"): - return False - - with engine.connect() as connection: - person_count = connection.scalar(sa.text("SELECT COUNT(*) FROM person")) - return bool(person_count and int(person_count) > 0) - except Exception: - return False - finally: - engine.dispose() - - -def _missing_clinical_csv_exports(output_dir: Path) -> tuple[str, ...]: - missing: list[str] = [] - for model in CLINICAL_EXPORT_MODELS: - output_path = output_dir / f"{model.__table__.name.upper()}.csv" - if not output_path.exists(): - missing.append(output_path.name) - return tuple(missing) - - -def _concept_ids( - session: Session, - *, - domain_id: str | None = None, - concept_name: str | None = None, - vocabulary_id: str | None = None, - concept_class_id: str | None = None, - standard_only: bool = False, - limit: int | None = None, -) -> tuple[int, ...]: - stmt = sa.select(Concept.concept_id) - if domain_id is not None: - stmt = stmt.where(Concept.domain_id == domain_id) - if concept_name is not None: - stmt = stmt.where(Concept.concept_name == concept_name) - if vocabulary_id is not None: - stmt = stmt.where(Concept.vocabulary_id == vocabulary_id) - if concept_class_id is not None: - stmt = stmt.where(Concept.concept_class_id == concept_class_id) - if standard_only: - stmt = stmt.where(Concept.standard_concept == "S") - stmt = stmt.order_by(Concept.concept_id) - if limit is not None: - stmt = stmt.limit(limit) - return tuple(int(value) for value in session.scalars(stmt)) - - -def _single_concept_id(session: Session, **filters: object) -> int: - values = _concept_ids(session, limit=1, **filters) - if not values: - raise RuntimeError(f"Missing concept fixture for filters: {filters}") - return values[0] - - -def _stage_concept_ids(session: Session) -> tuple[int, ...]: - stage_root_id = 734320 - parent_stmt = ( - sa.select(Concept.concept_id) - .join( - Concept_Ancestor, - Concept.concept_id == Concept_Ancestor.descendant_concept_id, - ) - .where(Concept_Ancestor.ancestor_concept_id == stage_root_id) - .where(Concept_Ancestor.max_levels_of_separation == 1) - .where( - sa.or_( - Concept.concept_name.contains("T"), - Concept.concept_name.contains("N"), - Concept.concept_name.contains("M"), - Concept.concept_name.contains("Stage"), - ) - ) - .order_by(Concept.concept_id) - ) - parent_ids = tuple(int(value) for value in session.scalars(parent_stmt)) - - if parent_ids: - descendant_stmt = ( - sa.select(Concept.concept_id) - .join( - Concept_Ancestor, - Concept.concept_id == Concept_Ancestor.descendant_concept_id, - ) - .where(Concept_Ancestor.ancestor_concept_id.in_(parent_ids)) - .where(Concept.concept_code.ilike("%8th%")) - .where(~Concept.concept_code.ilike("%yp%")) - .order_by(Concept.concept_id) - ) - stage_ids = tuple(int(value) for value in session.scalars(descendant_stmt)) - if stage_ids: - return stage_ids - - fallback = _concept_ids(session, domain_id="Measurement", standard_only=True, limit=12) - if fallback: - return fallback - - return _concept_ids(session, domain_id="Condition", standard_only=True, limit=12) - - -def _collect_fixture_concepts(session: Session) -> FixtureConcepts: - genders = _concept_ids(session, domain_id="Gender", standard_only=True, limit=4) - ethnicities = _concept_ids(session, domain_id="Ethnicity", standard_only=True, limit=4) - races = _concept_ids(session, domain_id="Race", standard_only=True, limit=6) - visit_concepts = _concept_ids(session, domain_id="Visit", standard_only=True, limit=8) - location_concepts = _concept_ids( - session, - concept_class_id="Location", - standard_only=True, - limit=8, - ) - provider_specialties = _concept_ids(session, domain_id="Provider", standard_only=True, limit=8) - type_concepts = _concept_ids(session, domain_id="Type Concept", standard_only=True, limit=12) - condition_concepts = _concept_ids( - session, - domain_id="Condition", - vocabulary_id="ICDO3", - standard_only=True, - limit=24, - ) or _concept_ids(session, domain_id="Condition", standard_only=True, limit=24) - stage_concepts = _stage_concept_ids(session) - - if not all((genders, ethnicities, races, visit_concepts, type_concepts, condition_concepts)): - raise RuntimeError("Fixture vocabulary does not contain the minimum concepts required for dummy clinical data.") - - return FixtureConcepts( - genders=genders, - ethnicities=ethnicities, - races=races, - visit_concepts=visit_concepts, - location_concepts=location_concepts, - provider_specialties=provider_specialties, - type_concepts=type_concepts, - condition_concepts=condition_concepts, - stage_concepts=stage_concepts, - episode_concept_id=_single_concept_id( - session, - domain_id="Episode", - concept_name="Disease Episode", - ), - condition_event_field_concept_id=_single_concept_id( - session, - domain_id="Metadata", - concept_name="condition_occurrence.condition_occurrence_id", - ), - measurement_event_field_concept_id=_single_concept_id( - session, - domain_id="Metadata", - concept_name="measurement.measurement_id", - ), - ) - - -def _pick(values: tuple[int, ...], rng: Random, index: int) -> int: - if not values: - raise RuntimeError("Expected at least one fixture concept value.") - return values[(index + rng.randint(0, len(values) - 1)) % len(values)] - - -def _seed_dummy_clinical_data( - session: Session, - *, - concepts: FixtureConcepts, - rng: Random, - person_count: int, -) -> None: - locations: list[Location] = [] - care_sites: list[Care_Site] = [] - providers: list[Provider] = [] - - for index in range(1, 7): - country_concept_id = ( - _pick(concepts.location_concepts, rng, index) - if concepts.location_concepts - else None - ) - locations.append( - Location( - location_id=index, - city=f"Fixture City {index}", - state="NS", - zip=f"20{index:02d}", - country_concept_id=country_concept_id, - location_source_value=f"fixture-location-{index}", - ) - ) - - for index in range(1, 9): - location = locations[(index - 1) % len(locations)] - care_sites.append( - Care_Site( - care_site_id=index, - care_site_name=f"Fixture Care Site {index}", - location_id=location.location_id, - place_of_service_concept_id=_pick(concepts.visit_concepts, rng, index), - care_site_source_value=f"fixture-care-site-{index}", - ) - ) - - for index in range(1, 13): - care_site = care_sites[(index - 1) % len(care_sites)] - specialty = ( - _pick(concepts.provider_specialties, rng, index) - if concepts.provider_specialties - else None - ) - providers.append( - Provider( - provider_id=index, - provider_name=f"Fixture Provider {index}", - care_site_id=care_site.care_site_id, - specialty_concept_id=specialty, - gender_concept_id=_pick(concepts.genders, rng, index), - provider_source_value=f"fixture-provider-{index}", - ) - ) - - session.add_all(locations) - session.add_all(care_sites) - session.add_all(providers) - session.flush() - - people: list[Person] = [] - visits: list[Visit_Occurrence] = [] - observation_periods: list[Observation_Period] = [] - deaths: list[Death] = [] - conditions: list[Condition_Occurrence] = [] - measurements: list[Measurement] = [] - episodes: list[Episode] = [] - episode_events: list[Episode_Event] = [] - - visit_id = 1 - observation_period_id = 1 - condition_id = 1 - measurement_id = 1 - episode_id = 1 - base_date = date(2020, 1, 1) - - for person_id in range(1, person_count + 1): - location = locations[(person_id - 1) % len(locations)] - care_site = care_sites[(person_id - 1) % len(care_sites)] - provider = providers[(person_id - 1) % len(providers)] - - person = Person( - person_id=person_id, - year_of_birth=1950 + (person_id % 55), - month_of_birth=(person_id % 12) + 1, - day_of_birth=(person_id % 28) + 1, - gender_concept_id=_pick(concepts.genders, rng, person_id), - race_concept_id=_pick(concepts.races, rng, person_id), - ethnicity_concept_id=_pick(concepts.ethnicities, rng, person_id), - location_id=location.location_id, - provider_id=provider.provider_id, - care_site_id=care_site.care_site_id, - person_source_value=f"fixture-person-{person_id}", - ) - people.append(person) - - visit_count = 1 + (person_id % 3) - person_visits: list[Visit_Occurrence] = [] - for visit_index in range(visit_count): - visit_date = base_date + timedelta(days=(person_id * 9) + (visit_index * 14)) - person_visits.append( - Visit_Occurrence( - visit_occurrence_id=visit_id, - person_id=person_id, - visit_concept_id=_pick(concepts.visit_concepts, rng, visit_id), - visit_start_date=visit_date, - visit_end_date=visit_date + timedelta(days=1), - visit_type_concept_id=_pick(concepts.type_concepts, rng, visit_id), - provider_id=provider.provider_id, - care_site_id=care_site.care_site_id, - visit_source_value=f"fixture-visit-{visit_id}", - ) - ) - visit_id += 1 - - visits.extend(person_visits) - - first_visit_date = person_visits[0].visit_start_date - last_visit_date = person_visits[-1].visit_end_date - death_date = None - if person_id % 8 == 0: - death_date = last_visit_date + timedelta(days=30 + person_id) - deaths.append( - Death( - person_id=person_id, - death_date=death_date, - death_type_concept_id=_pick(concepts.type_concepts, rng, person_id), - ) - ) - - observation_periods.append( - Observation_Period( - observation_period_id=observation_period_id, - person_id=person_id, - observation_period_start_date=first_visit_date, - observation_period_end_date=death_date or last_visit_date, - period_type_concept_id=_pick(concepts.type_concepts, rng, observation_period_id), - ) - ) - observation_period_id += 1 - - primary_visit = person_visits[0] - condition = Condition_Occurrence( - condition_occurrence_id=condition_id, - person_id=person_id, - condition_concept_id=_pick(concepts.condition_concepts, rng, condition_id), - condition_start_date=primary_visit.visit_start_date, - condition_end_date=primary_visit.visit_end_date + timedelta(days=28), - condition_type_concept_id=_pick(concepts.type_concepts, rng, condition_id), - visit_occurrence_id=primary_visit.visit_occurrence_id, - provider_id=provider.provider_id, - condition_source_value=f"fixture-condition-{condition_id}", - ) - conditions.append(condition) - - episode = Episode( - episode_id=episode_id, - person_id=person_id, - episode_parent_id=(episode_id - 1) if person_id % 6 == 0 else None, - episode_concept_id=concepts.episode_concept_id, - episode_object_concept_id=condition.condition_concept_id, - episode_start_date=condition.condition_start_date, - episode_end_date=death_date or (condition.condition_end_date or condition.condition_start_date), - episode_type_concept_id=_pick(concepts.type_concepts, rng, episode_id), - episode_source_value=f"fixture-episode-{episode_id}", - ) - episodes.append(episode) - - for offset in range(3): - stage_concept_id = _pick(concepts.stage_concepts, rng, measurement_id + offset) - measurement = Measurement( - measurement_id=measurement_id, - person_id=person_id, - measurement_concept_id=stage_concept_id, - measurement_date=condition.condition_start_date + timedelta(days=offset * 7), - measurement_type_concept_id=_pick(concepts.type_concepts, rng, measurement_id), - measurement_event_id=condition.condition_occurrence_id, - meas_event_field_concept_id=concepts.condition_event_field_concept_id, - visit_occurrence_id=primary_visit.visit_occurrence_id, - provider_id=provider.provider_id, - value_as_number=float(offset + 1), - measurement_source_value=f"fixture-measurement-{measurement_id}", - ) - measurements.append(measurement) - episode_events.append( - Episode_Event( - episode_id=episode.episode_id, - event_id=measurement.measurement_id, - episode_event_field_concept_id=concepts.measurement_event_field_concept_id, - ) - ) - measurement_id += 1 - - episode_events.append( - Episode_Event( - episode_id=episode.episode_id, - event_id=condition.condition_occurrence_id, - episode_event_field_concept_id=concepts.condition_event_field_concept_id, - ) - ) - - condition_id += 1 - episode_id += 1 - - session.add_all(people) - session.add_all(visits) - session.add_all(observation_periods) - session.add_all(deaths) - session.add_all(conditions) - session.add_all(episodes) - session.add_all(measurements) - session.add_all(episode_events) - session.commit() - - -def _export_table_csvs(engine: sa.Engine, output_dir: Path) -> None: - with engine.connect() as connection: - for model in CLINICAL_EXPORT_MODELS: - table = model.__table__ - stmt = sa.select(table) - primary_keys = list(table.primary_key.columns) - if primary_keys: - stmt = stmt.order_by(*primary_keys) - frame = pd.read_sql_query(stmt, connection) - output_path = output_dir / f"{table.name.upper()}.csv" - frame.to_csv(output_path, index=False) - - -def main() -> None: - args = parse_args() - athena_source = args.athena_source.expanduser().resolve() - db_path = args.db_path.expanduser().resolve() - clinical_csv_dir = args.clinical_csv_dir.expanduser().resolve() - rng = Random(args.seed) - - clinical_csv_dir.mkdir(parents=True, exist_ok=True) - - if not args.force_rebuild and _fixture_db_has_people(db_path): - print(f"Using existing SQLite fixture at {db_path}") - missing_exports = _missing_clinical_csv_exports(clinical_csv_dir) - if not missing_exports: - print(f"Clinical CSV fixtures already present in {clinical_csv_dir}") - return - - print( - "Existing SQLite fixture is valid but some clinical CSV exports are missing: " - + ", ".join(missing_exports) - ) - engine = sa.create_engine(f"sqlite:///{db_path}", future=True, echo=False) - try: - for csv_path in clinical_csv_dir.glob("*.csv"): - csv_path.unlink() - _export_table_csvs(engine, clinical_csv_dir) - print(f"Exported clinical CSV fixtures to {clinical_csv_dir}") - return - finally: - engine.dispose() - - _reset_outputs(db_path, clinical_csv_dir) - - engine = sa.create_engine(f"sqlite:///{db_path}", future=True, echo=False) - try: - print(f"Loading Athena vocabulary from {athena_source}") - vocab_report = load_vocab_source( - engine, - source_path=athena_source, - merge_strategy="upsert", - ) - loaded_count = sum(1 for result in vocab_report.results if result.status == "loaded") - print(f"Loaded {loaded_count} vocabulary table(s)") - - creation_results = create_missing_tables(engine, vocabulary_included=False) - created_count = sum(1 for result in creation_results if result.status == "created") - print(f"Created {created_count} non-vocabulary table(s)") - - SessionLocal = sessionmaker(bind=engine, future=True, expire_on_commit=False) - with SessionLocal() as session: - concepts = _collect_fixture_concepts(session) - _seed_dummy_clinical_data( - session, - concepts=concepts, - rng=rng, - person_count=args.person_count, - ) - - _export_table_csvs(engine, clinical_csv_dir) - print(f"Wrote SQLite fixture to {db_path}") - print(f"Exported clinical CSV fixtures to {clinical_csv_dir}") - finally: - engine.dispose() - - -if __name__ == "__main__": - main()