From 6be7bddd4f8168d17cc25104082abf4d91a80af9 Mon Sep 17 00:00:00 2001 From: gkennos Date: Mon, 18 May 2026 22:16:24 +1000 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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()