From d565dda0f3c7146af733c38641bb7e5ce542ce81 Mon Sep 17 00:00:00 2001 From: Nico Loesch Date: Wed, 7 Oct 2026 22:51:11 +0000 Subject: [PATCH 1/6] Move RelationshipClass/RelationshipMapping onto the extension schema Both tables now inherit CDMExtensionTableBase instead of CDMTableBase, and their predicate_kind enum lives in EXTENSION_SCHEMA instead of Role.PRIMARY. RelationshipMapping's FK to relationship_class becomes a plain qualified string (there's no Role.EXTENSION; role_fk() is deliberately Role-only). relationship_classification() claims and ensures the extension schema itself when it resolves its own engine; a caller injecting its own engine/connection is responsible for including that claim, same convention as the existing staging_schema_claim(). Tests building their own scoped engine via scoped_test_schema()/ create_engines() now also claim a fresh, test-isolated extension schema alongside their existing staging claim, since schema_claims aren't inherited across separate create_engine() calls on the same resolved. --- src/omop_graph/cli.py | 1 + src/omop_graph/extensions/omop_alchemy.py | 36 ++++++++-------- tests/test_oaklib_schema_awareness.py | 51 ++++++++++++++++++++--- tests/test_relationship_classification.py | 36 ++++++++++++---- tests/test_vocab_split_connection.py | 40 ++++++++++++++---- 5 files changed, 122 insertions(+), 42 deletions(-) diff --git a/src/omop_graph/cli.py b/src/omop_graph/cli.py index 9684482..009931d 100644 --- a/src/omop_graph/cli.py +++ b/src/omop_graph/cli.py @@ -233,6 +233,7 @@ def _load_relationship_classification( RelationshipMapping.__table__, RelationshipClass.__table__, ] + ensure_schema(engine, physical_schema_of(engine, schema_tag=EXTENSION_SCHEMA)) with open_connection(engine) as connection: Base.metadata.drop_all(bind=connection, tables=tables_to_drop, checkfirst=True) # type: ignore create_extension_tables(connection, resolved=resolved) diff --git a/src/omop_graph/extensions/omop_alchemy.py b/src/omop_graph/extensions/omop_alchemy.py index 9fdd154..582ac5c 100644 --- a/src/omop_graph/extensions/omop_alchemy.py +++ b/src/omop_graph/extensions/omop_alchemy.py @@ -1,14 +1,14 @@ # Extension to omop-alchemy package import sqlalchemy as sa import sqlalchemy.orm as so -from orm_loader.helpers import Base, create_tables +from orm_loader.helpers import create_tables from omop_alchemy.cdm.base import ( ReferenceTable, - cdm_table, - CDMTableBase, + CDMExtensionTableBase, merge_table_args, role_fk, ) +from omop_alchemy.config import CDM_EXTENSION_SCHEMA from oa_configurator import ResolvedCDMDatabase, Role from enum import Enum @@ -24,25 +24,22 @@ class PredicateKind(Enum): ATTRIBUTE = "Attribute" -@cdm_table -class RelationshipClass(ReferenceTable, CDMTableBase, Base): +class RelationshipClass(ReferenceTable, CDMExtensionTableBase): """ Extensions table: Defines the semantic categories (Parent) and entity types (Child). """ __tablename__ = "relationship_class" - # Must match RelationshipMapping's schema, or SQLAlchemy silently builds a second, unlinked Table object. - __table_args__ = merge_table_args({"schema": Role.PRIMARY.value}) predicate_kind: so.Mapped[PredicateKind] = so.mapped_column( sa.Enum( PredicateKind, values_callable=lambda obj: [ e.value for e in obj ], - # Must match __table_args__'s schema to ensure that schema_translate_map - # routes the enum correctly. - schema=Role.PRIMARY.value, + # Must match the table's own schema to ensure that + # schema_translate_map routes the enum correctly. + schema=CDM_EXTENSION_SCHEMA, ), primary_key=True, ) @@ -54,8 +51,7 @@ class RelationshipClass(ReferenceTable, CDMTableBase, Base): inference: so.Mapped[str] = so.mapped_column(sa.String(40), nullable=False) -@cdm_table -class RelationshipMapping(ReferenceTable, CDMTableBase, Base): +class RelationshipMapping(ReferenceTable, CDMExtensionTableBase): """ Extensions table: Maps standard OMOP relationship_ids to their parent (predicate_kind - one of PredicateKind) and more fine-grained subclasses . @@ -73,9 +69,9 @@ class RelationshipMapping(ReferenceTable, CDMTableBase, Base): values_callable=lambda obj: [ e.value for e in obj ], - # Must match __table_args__'s schema to ensure that schema_translate_map - # routes the enum correctly. - schema=Role.PRIMARY.value, + # Must match the table's own schema to ensure that + # schema_translate_map routes the enum correctly. + schema=CDM_EXTENSION_SCHEMA, ), primary_key=True, ) @@ -83,17 +79,19 @@ class RelationshipMapping(ReferenceTable, CDMTableBase, Base): sa.String(20), primary_key=True ) - # Must match RelationshipClass's schema, or SQLAlchemy silently builds a second, unlinked Table object. + # relationship_class lives in the same fixed extension schema + # (CDMExtensionTableBase's default). The target is a plain qualified + # string rather than role_fk() since there is no Role.EXTENSION. __table_args__ = merge_table_args( sa.ForeignKeyConstraint( ["predicate_kind", "predicate_subkind"], [ - role_fk(Role.PRIMARY, "relationship_class.predicate_kind"), - role_fk(Role.PRIMARY, "relationship_class.predicate_subkind"), + f"{CDM_EXTENSION_SCHEMA}.relationship_class.predicate_kind", + f"{CDM_EXTENSION_SCHEMA}.relationship_class.predicate_subkind", ], name="fk_rel_mapping_to_rel_class", ), - {"schema": Role.PRIMARY.value}, + {"schema": CDM_EXTENSION_SCHEMA}, ) diff --git a/tests/test_oaklib_schema_awareness.py b/tests/test_oaklib_schema_awareness.py index 1afe5ce..8865e0b 100644 --- a/tests/test_oaklib_schema_awareness.py +++ b/tests/test_oaklib_schema_awareness.py @@ -11,16 +11,19 @@ from __future__ import annotations +from contextlib import contextmanager from datetime import date +from typing import Iterator import pytest import sqlalchemy as sa import sqlalchemy.orm from oaklib import get_adapter -from oa_configurator import Resolver, Role -from oa_configurator.testing import scoped_test_schema +from oa_configurator import Resolver, Role, SchemaClaim +from oa_configurator.testing import isolated_test_schema, scoped_test_schema from omop_alchemy.cdm.model.vocabulary import Concept, Concept_Class, Domain, Vocabulary +from omop_alchemy.config import CDM_EXTENSION_SCHEMA from omop_alchemy.cross_database import cdm_sessionmaker from orm_loader.backends import staging_schema_claim from orm_loader.helpers import Base, bulk_load_context @@ -88,6 +91,19 @@ def _seed_one_concept(engine: sa.Engine, *, concept_id: int, name: str) -> None: session.commit() +@contextmanager +def _extension_schema_claim(engine: sa.Engine, *, prefix: str) -> Iterator[SchemaClaim]: + """A SchemaClaim for a fresh, test-isolated physical extension schema. + + RelationshipClass/RelationshipMapping live in CDM_EXTENSION_SCHEMA, so + every scoped_test_schema() call that will create_all() them needs + this claimed too -- unlike primary/vocab/results, scoped_test_schema() + doesn't scope non-Role tags on its own. + """ + with isolated_test_schema(engine, prefix=f"{prefix}_ext") as physical_schema: + yield SchemaClaim(schema_tag=CDM_EXTENSION_SCHEMA, physical_schema=physical_schema, reserved=True) + + def _populate(scoped, *, name: str) -> None: Base.metadata.create_all(bind=scoped.engine, checkfirst=True) _seed_one_concept(scoped.engine, concept_id=_CONCEPT_ID, name=name) @@ -99,7 +115,12 @@ def _populate(scoped, *, name: str) -> None: def test_get_adapter_resolves_the_slug_as_the_database_name( pg_db, monkeypatch, descriptor, expected_name ) -> None: - with scoped_test_schema(pg_db.resolved, prefix="oaklib_get_adapter", schema_claims=[staging_schema_claim()]) as scoped: + with ( + _extension_schema_claim(pg_db.committing_engine, prefix="oaklib_get_adapter") as ext_claim, + scoped_test_schema( + pg_db.resolved, prefix="oaklib_get_adapter", schema_claims=[staging_schema_claim(), ext_claim] + ) as scoped, + ): _populate(scoped, name="Adapter concept") def fake_resolve_cdm_database(name): @@ -125,7 +146,12 @@ def test_resolve_cdm_database_resolves_a_named_entry(pg_db) -> None: def test_resolved_path_reuses_one_engine_without_a_vocab_split(pg_db) -> None: - with scoped_test_schema(pg_db.resolved, prefix="oaklib_resolved", schema_claims=[staging_schema_claim()]) as scoped: + with ( + _extension_schema_claim(pg_db.committing_engine, prefix="oaklib_resolved") as ext_claim, + scoped_test_schema( + pg_db.resolved, prefix="oaklib_resolved", schema_claims=[staging_schema_claim(), ext_claim] + ) as scoped, + ): _populate(scoped, name="Resolved-path concept") adapter = OMOPAlchemyImplementation(resolved=scoped.resolved) @@ -147,7 +173,15 @@ def test_two_connection_entries_for_one_database_collapse_to_one_engine(pg_db) - connections={"oaklib_split_vocab": resolver.config.connections[pg_db.resolved.connection.name]}, databases={name: resolver.config.databases[name].model_copy(update={"vocab_connection": "oaklib_split_vocab"})}, ) - with scoped_test_schema(resolver.resolve_database(name), prefix="oaklib_split", resolver=resolver, schema_claims=[staging_schema_claim()]) as scoped: + with ( + _extension_schema_claim(pg_db.committing_engine, prefix="oaklib_split") as ext_claim, + scoped_test_schema( + resolver.resolve_database(name), + prefix="oaklib_split", + resolver=resolver, + schema_claims=[staging_schema_claim(), ext_claim], + ) as scoped, + ): assert scoped.resolved.connection.name != scoped.resolved.vocab_connection.name assert scoped.resolved.foreign_key_can_span(Role.PRIMARY, Role.VOCAB) _populate(scoped, name="Split-wiring concept") @@ -159,7 +193,12 @@ def test_two_connection_entries_for_one_database_collapse_to_one_engine(pg_db) - def test_kg_injection_path_uses_the_injected_knowledge_graph(pg_db) -> None: - with scoped_test_schema(pg_db.resolved, prefix="oaklib_kg", schema_claims=[staging_schema_claim()]) as scoped: + with ( + _extension_schema_claim(pg_db.committing_engine, prefix="oaklib_kg") as ext_claim, + scoped_test_schema( + pg_db.resolved, prefix="oaklib_kg", schema_claims=[staging_schema_claim(), ext_claim] + ) as scoped, + ): _populate(scoped, name="Injected concept") kg = KnowledgeGraph( cdm_sessionmaker(scoped.resolved, primary=scoped.engine, vocab=scoped.engine) diff --git a/tests/test_relationship_classification.py b/tests/test_relationship_classification.py index af1bb27..7a80879 100644 --- a/tests/test_relationship_classification.py +++ b/tests/test_relationship_classification.py @@ -12,20 +12,30 @@ import logging import sqlalchemy as sa -from oa_configurator import Role -from oa_configurator.testing import scoped_test_schema +from oa_configurator import SchemaClaim +from oa_configurator.testing import isolated_test_schema, scoped_test_schema from orm_loader.backends import staging_schema_claim from orm_loader.helpers import Base +from omop_alchemy.config import EXTENSION_SCHEMA from omop_graph.cli import relationship_classification from omop_graph.extensions.omop_alchemy import RelationshipClass, RelationshipMapping from fixtures.mock_cdm import seed_relationship_vocabulary def test_relationship_classification_respects_the_configured_schema(pg_db): - with scoped_test_schema(pg_db.resolved, prefix="relclass_routing", schema_claims=[staging_schema_claim()]) as scoped: - schema = scoped.schemas[Role.PRIMARY] + with ( + isolated_test_schema(pg_db.committing_engine, prefix="relclass_routing_ext") as extension_schema, + scoped_test_schema( + pg_db.resolved, + prefix="relclass_routing", + schema_claims=[ + staging_schema_claim(), + SchemaClaim(schema_tag=EXTENSION_SCHEMA, physical_schema=extension_schema, reserved=True), + ], + ) as scoped, + ): Base.metadata.create_all(bind=scoped.engine, checkfirst=True) seed_relationship_vocabulary(scoped.engine) @@ -43,18 +53,28 @@ def test_relationship_classification_respects_the_configured_schema(pg_db): "SELECT n.nspname FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace " "WHERE t.typname = 'predicatekind' AND n.nspname = :schema" ), - {"schema": schema}, + {"schema": extension_schema}, ).scalar() assert n_class and n_class > 0 assert n_mapping and n_mapping > 0 - assert sa.inspect(scoped.engine).has_table("relationship_class", schema=schema) - assert enum_schema == schema + assert sa.inspect(scoped.engine).has_table("relationship_class", schema=extension_schema) + assert enum_schema == extension_schema def test_relationship_classification_is_idempotent(pg_db): """Re-running against the same schema, the real-world redeploy case the DROP TABLE/enum-drop cleanup exists for, must not fail.""" - with scoped_test_schema(pg_db.resolved, prefix="relclass_idempotent", schema_claims=[staging_schema_claim()]) as scoped: + with ( + isolated_test_schema(pg_db.committing_engine, prefix="relclass_idempotent_ext") as extension_schema, + scoped_test_schema( + pg_db.resolved, + prefix="relclass_idempotent", + schema_claims=[ + staging_schema_claim(), + SchemaClaim(schema_tag=EXTENSION_SCHEMA, physical_schema=extension_schema, reserved=True), + ], + ) as scoped, + ): Base.metadata.create_all(bind=scoped.engine, checkfirst=True) seed_relationship_vocabulary(scoped.engine) diff --git a/tests/test_vocab_split_connection.py b/tests/test_vocab_split_connection.py index 1de901c..af6b7a9 100644 --- a/tests/test_vocab_split_connection.py +++ b/tests/test_vocab_split_connection.py @@ -19,8 +19,14 @@ import sqlalchemy as sa import sqlalchemy.orm as so -from oa_configurator import ResolvedCDMDatabase, Resolver, Role -from oa_configurator.testing import isolated_test_database, resolve_with_role_schemas, scoped_test_schema +from oa_configurator import ResolvedCDMDatabase, Resolver, Role, SchemaClaim +from oa_configurator.testing import ( + isolated_test_database, + isolated_test_schema, + resolve_with_role_schemas, + scoped_test_schema, +) +from orm_loader.backends import staging_schema_claim from orm_loader.config import OrmLoaderConfig from orm_loader.helpers import Base, bulk_load_context @@ -32,6 +38,7 @@ Relationship, Vocabulary, ) +from omop_alchemy.config import CDM_EXTENSION_SCHEMA from omop_alchemy.cross_database import cdm_sessionmaker from omop_graph.cli import relationship_classification @@ -70,7 +77,7 @@ sa.Enum( PredicateKind, values_callable=lambda obj: [e.value for e in obj], - schema=Role.PRIMARY.value, + schema=CDM_EXTENSION_SCHEMA, ), primary_key=True, ), @@ -79,7 +86,7 @@ sa.Column("semantics", sa.String(40), nullable=False), sa.Column("inference", sa.String(40), nullable=False), # Must match RelationshipClass's schema, or SQLAlchemy silently builds a second, unlinked Table object. - schema=Role.PRIMARY.value, + schema=CDM_EXTENSION_SCHEMA, ) _shadow_relationship_mapping = sa.Table( "relationship_mapping", @@ -90,12 +97,12 @@ sa.Enum( PredicateKind, values_callable=lambda obj: [e.value for e in obj], - schema=Role.PRIMARY.value, + schema=CDM_EXTENSION_SCHEMA, ), primary_key=True, ), sa.Column("predicate_subkind", sa.String(20), primary_key=True), - schema=Role.PRIMARY.value, + schema=CDM_EXTENSION_SCHEMA, ) @@ -103,6 +110,10 @@ class _Engines(NamedTuple): primary: sa.Engine vocab: sa.Engine resolved: ResolvedCDMDatabase + extension_schema_claim: SchemaClaim + """Reusable claim for any later engine built from .resolved (e.g. the + staging engine in test_relationship_classification_succeeds_on_a_genuinely_split_vocab_connection): + schema_claims aren't inherited across separate create_engine() calls.""" @pytest.fixture() @@ -113,7 +124,11 @@ def split_engines() -> Iterator[_Engines]: with ( isolated_test_database(OmopGraphConfig, "test_cdm_db_pg") as primary_db, isolated_test_database(OrmLoaderConfig, "test_orm_db_pg") as vocab_db, + # RelationshipClass/RelationshipMapping live in the extension schema, + # on the same physical connection as primary. + isolated_test_schema(primary_db.committing_engine, prefix="vocab_split_ext") as extension_schema, ): + ext_claim = SchemaClaim(schema_tag=CDM_EXTENSION_SCHEMA, physical_schema=extension_schema, reserved=True) name = primary_db.resolved.name resolver = Resolver.from_active_config() resolver = resolver.with_overrides( @@ -124,8 +139,10 @@ def split_engines() -> Iterator[_Engines]: } ) split = resolver.resolve_database(name) - with scoped_test_schema(split, prefix="vocab_split", resolver=resolver) as scoped: - primary_engine, vocab_engine = scoped.resolved.create_engines() + with scoped_test_schema( + split, prefix="vocab_split", resolver=resolver, schema_claims=[ext_claim] + ) as scoped: + primary_engine, vocab_engine = scoped.resolved.create_engines(schema_claims=[ext_claim]) # Vocab tables are created through the vocab server's own resolved: # create_all on the split vocab engine also emits the PRIMARY-tagged # predicatekind type, whose schema only exists on the primary server. @@ -138,7 +155,12 @@ def split_engines() -> Iterator[_Engines]: _shadow_metadata.create_all(primary_engine) Base.metadata.create_all(vocab_setup_engine, tables=_VOCAB_TABLES, checkfirst=True) _seed(primary_engine, vocab_setup_engine) - yield _Engines(primary=primary_engine, vocab=vocab_engine, resolved=scoped.resolved) + yield _Engines( + primary=primary_engine, + vocab=vocab_engine, + resolved=scoped.resolved, + extension_schema_claim=ext_claim, + ) finally: vocab_setup_engine.dispose() primary_engine.dispose() From 1623ddc4f9db8932fce5b9df23a1fb3bf43d5ff0 Mon Sep 17 00:00:00 2001 From: Nico Loesch Date: Wed, 7 Oct 2026 23:48:22 +0000 Subject: [PATCH 2/6] Claim the extension schema everywhere Base.metadata.create_all() runs Verified against a real Postgres connection: Base.metadata.create_all() creates every custom dialect-level type registered anywhere on the shared Base.metadata (e.g. RelationshipClass's predicate_kind enum), regardless of its own tables= filter. Once RelationshipClass/RelationshipMapping moved to the extension schema, every test building its own engine via scoped_test_schema()/create_engines()/create_engine() needed that schema tag claimed and physically created too, not just the tables it actually asked for. Adds a shared extension_schema_claim() test helper (fixtures/helpers.py): a fresh, test-isolated physical schema per test, claimed under a fixed owner ("omop_graph") so sequential test files claiming the same tag on the same connection don't collide on ownership, and with register_claims=False so none of them persist a provenance row that could drift against a sibling test's own differently-named schema. test_schema_provenance_guard.py is the one exception: it deliberately exercises relationship_classification()'s own internal claim (the engine=None path in cli.py), so it claims the same literal schema name and owner that path auto-derives, with real registration enabled. --- tests/fixtures/helpers.py | 46 +++++++++++++++++++- tests/fixtures/mock_cdm.py | 11 +++-- tests/test_concept_queries.py | 9 +++- tests/test_fulltext_vocab_schema_postgres.py | 24 ++++++++-- tests/test_oaklib_schema_awareness.py | 40 +++++++---------- tests/test_relationship_classification.py | 23 +++++----- tests/test_schema_provenance_guard.py | 20 ++++++++- tests/test_vocab_split_connection.py | 23 ++++++---- 8 files changed, 138 insertions(+), 58 deletions(-) diff --git a/tests/fixtures/helpers.py b/tests/fixtures/helpers.py index 7de6513..e885129 100644 --- a/tests/fixtures/helpers.py +++ b/tests/fixtures/helpers.py @@ -2,15 +2,19 @@ from __future__ import annotations -from typing import cast +from contextlib import contextmanager +from typing import Generator, cast import sqlalchemy as sa +from oa_configurator import SchemaClaim +from oa_configurator.testing import isolated_test_schema from omop_alchemy.cdm.model.vocabulary import ( Concept, Concept_Class, Domain, Vocabulary ) +from omop_alchemy.config import EXTENSION_SCHEMA VOCAB_TABLES = cast( "tuple[sa.Table, ...]", @@ -21,3 +25,43 @@ needing more tables (e.g. Relationship, Concept_Relationship) extends this tuple rather than redefining its own copy of the shared core. """ + + +EXTENSION_SCHEMA_TEST_OWNER = "omop_graph" +"""Fixed owner for every EXTENSION_SCHEMA claim in this suite, matching +relationship_classification()'s own internal claim (cli.py, engine=None path): +that one leaves owner unset, so oa-configurator auto-derives it from the +top-level package of its own call site, "omop_graph". + +create_engine() derives owner from the immediate caller's own top-level +module whenever a SchemaClaim doesn't set one explicitly, and a test +file is its own top-level module -- so two test files claiming the same +schema_tag without this override would each get a different, auto-derived +owner, and SchemaOwnershipError fires for the *tag* existing under a +different owner on the same connection (checked across the whole shared +test database, not scoped per test run). Matching the literal value +production itself falls back to lets every test file's own claim, and +relationship_classification()'s internal one, coexist as "the same owner +reclaiming" instead of conflicting -- each still gets its own physical_schema, +scoped independently by SchemaDriftError's own database_config_name key. +""" + + +@contextmanager +def extension_schema_claim(engine: sa.Engine, *, prefix: str) -> Generator[SchemaClaim, None, None]: + """A SchemaClaim for a fresh, test-isolated physical extension schema. + + Needed before any Base.metadata.create_all() call reachable from a test, + even one scoped via tables=: a dialect-level custom type (e.g. + RelationshipClass's predicate_kind) is created metadata-wide, not + scoped to tables=, so it's attempted regardless of which tables the + call actually asked for. scoped_test_schema() doesn't scope the + extension tag on its own (only Role tags), so this must be claimed + and physically created separately. + """ + with isolated_test_schema(engine, prefix=f"{prefix}_ext") as physical_schema: + yield SchemaClaim( + schema_tag=EXTENSION_SCHEMA, + physical_schema=physical_schema, + owner=EXTENSION_SCHEMA_TEST_OWNER, + ) diff --git a/tests/fixtures/mock_cdm.py b/tests/fixtures/mock_cdm.py index 60c5648..21d982c 100644 --- a/tests/fixtures/mock_cdm.py +++ b/tests/fixtures/mock_cdm.py @@ -26,6 +26,8 @@ ) from omop_graph.graph.kg import KnowledgeGraph +from fixtures.helpers import extension_schema_claim + PARENT_CANCER_ID = 443392 CONCEPT_META_ID = 0 LANGUAGE_CONCEPT_ID = 1 @@ -34,9 +36,12 @@ @pytest.fixture(scope="module") def mock_cdm_engine() -> Iterator[sa.Engine]: with isolated_test_database(OmopGraphConfig, "test_cdm_db_pg") as db: - with scoped_test_schema(db.resolved, prefix="mock_cdm") as scoped: - _create_mock_cdm_tables(scoped.engine) - yield scoped.engine + with extension_schema_claim(db.committing_engine, prefix="mock_cdm") as ext_claim: + with scoped_test_schema( + db.resolved, prefix="mock_cdm", schema_claims=[ext_claim], register_claims=False + ) as scoped: + _create_mock_cdm_tables(scoped.engine) + yield scoped.engine @pytest.fixture(scope="module") diff --git a/tests/test_concept_queries.py b/tests/test_concept_queries.py index cada934..65ad171 100644 --- a/tests/test_concept_queries.py +++ b/tests/test_concept_queries.py @@ -23,14 +23,19 @@ q_entities, ) -from fixtures.helpers import VOCAB_TABLES +from fixtures.helpers import VOCAB_TABLES, extension_schema_claim _META_CONCEPT_ID = 0 @pytest.fixture() def concept_engine(pg_db) -> Iterator[sa.Engine]: - with scoped_test_schema(pg_db.resolved, prefix="concept_queries") as scoped: + with ( + extension_schema_claim(pg_db.committing_engine, prefix="concept_queries") as ext_claim, + scoped_test_schema( + pg_db.resolved, prefix="concept_queries", schema_claims=[ext_claim], register_claims=False + ) as scoped, + ): engine = scoped.engine Base.metadata.create_all(engine, tables=VOCAB_TABLES, checkfirst=True) diff --git a/tests/test_fulltext_vocab_schema_postgres.py b/tests/test_fulltext_vocab_schema_postgres.py index b9f0065..ca35a51 100644 --- a/tests/test_fulltext_vocab_schema_postgres.py +++ b/tests/test_fulltext_vocab_schema_postgres.py @@ -17,13 +17,22 @@ from omop_graph.graph.queries import q_concept_name_fulltext -from fixtures.helpers import VOCAB_TABLES +from fixtures.helpers import VOCAB_TABLES, extension_schema_claim pytestmark = [pytest.mark.postgresql, pytest.mark.db_dialect] def test_fulltext_query_finds_the_tsvector_column_in_the_vocab_schema(pg_db): - with scoped_test_schema(pg_db.resolved, prefix="fulltext", split_roles=[Role.VOCAB]) as scoped: + with ( + extension_schema_claim(pg_db.committing_engine, prefix="fulltext_a") as ext_claim, + scoped_test_schema( + pg_db.resolved, + prefix="fulltext", + split_roles=[Role.VOCAB], + schema_claims=[ext_claim], + register_claims=False, + ) as scoped, + ): engine = scoped.engine Base.metadata.create_all(bind=engine, tables=VOCAB_TABLES, checkfirst=True) @@ -50,7 +59,16 @@ def test_fulltext_query_finds_the_tsvector_column_in_the_vocab_schema(pg_db): def test_fulltext_query_still_raises_when_the_column_is_genuinely_absent(pg_db): - with scoped_test_schema(pg_db.resolved, prefix="fulltext", split_roles=[Role.VOCAB]) as scoped: + with ( + extension_schema_claim(pg_db.committing_engine, prefix="fulltext_b") as ext_claim, + scoped_test_schema( + pg_db.resolved, + prefix="fulltext", + split_roles=[Role.VOCAB], + schema_claims=[ext_claim], + register_claims=False, + ) as scoped, + ): Base.metadata.create_all(bind=scoped.engine, tables=VOCAB_TABLES, checkfirst=True) with pytest.raises(FullTextError): diff --git a/tests/test_oaklib_schema_awareness.py b/tests/test_oaklib_schema_awareness.py index 8865e0b..09e2408 100644 --- a/tests/test_oaklib_schema_awareness.py +++ b/tests/test_oaklib_schema_awareness.py @@ -11,19 +11,16 @@ from __future__ import annotations -from contextlib import contextmanager from datetime import date -from typing import Iterator import pytest import sqlalchemy as sa import sqlalchemy.orm from oaklib import get_adapter -from oa_configurator import Resolver, Role, SchemaClaim -from oa_configurator.testing import isolated_test_schema, scoped_test_schema +from oa_configurator import Resolver, Role +from oa_configurator.testing import scoped_test_schema from omop_alchemy.cdm.model.vocabulary import Concept, Concept_Class, Domain, Vocabulary -from omop_alchemy.config import CDM_EXTENSION_SCHEMA from omop_alchemy.cross_database import cdm_sessionmaker from orm_loader.backends import staging_schema_claim from orm_loader.helpers import Base, bulk_load_context @@ -36,6 +33,8 @@ from omop_graph.oaklib_interface.omop_implementation import OMOPAlchemyImplementation from fixtures.mock_cdm import seed_relationship_vocabulary +from fixtures.helpers import extension_schema_claim + _META_CONCEPT_ID = 0 _CONCEPT_ID = 1001 _TODAY = date(2020, 1, 1) @@ -91,19 +90,6 @@ def _seed_one_concept(engine: sa.Engine, *, concept_id: int, name: str) -> None: session.commit() -@contextmanager -def _extension_schema_claim(engine: sa.Engine, *, prefix: str) -> Iterator[SchemaClaim]: - """A SchemaClaim for a fresh, test-isolated physical extension schema. - - RelationshipClass/RelationshipMapping live in CDM_EXTENSION_SCHEMA, so - every scoped_test_schema() call that will create_all() them needs - this claimed too -- unlike primary/vocab/results, scoped_test_schema() - doesn't scope non-Role tags on its own. - """ - with isolated_test_schema(engine, prefix=f"{prefix}_ext") as physical_schema: - yield SchemaClaim(schema_tag=CDM_EXTENSION_SCHEMA, physical_schema=physical_schema, reserved=True) - - def _populate(scoped, *, name: str) -> None: Base.metadata.create_all(bind=scoped.engine, checkfirst=True) _seed_one_concept(scoped.engine, concept_id=_CONCEPT_ID, name=name) @@ -116,9 +102,10 @@ def test_get_adapter_resolves_the_slug_as_the_database_name( pg_db, monkeypatch, descriptor, expected_name ) -> None: with ( - _extension_schema_claim(pg_db.committing_engine, prefix="oaklib_get_adapter") as ext_claim, + extension_schema_claim(pg_db.committing_engine, prefix="oaklib_get_adapter") as ext_claim, scoped_test_schema( - pg_db.resolved, prefix="oaklib_get_adapter", schema_claims=[staging_schema_claim(), ext_claim] + pg_db.resolved, prefix="oaklib_get_adapter", schema_claims=[staging_schema_claim(), ext_claim], + register_claims=False, ) as scoped, ): _populate(scoped, name="Adapter concept") @@ -147,9 +134,10 @@ def test_resolve_cdm_database_resolves_a_named_entry(pg_db) -> None: def test_resolved_path_reuses_one_engine_without_a_vocab_split(pg_db) -> None: with ( - _extension_schema_claim(pg_db.committing_engine, prefix="oaklib_resolved") as ext_claim, + extension_schema_claim(pg_db.committing_engine, prefix="oaklib_resolved") as ext_claim, scoped_test_schema( - pg_db.resolved, prefix="oaklib_resolved", schema_claims=[staging_schema_claim(), ext_claim] + pg_db.resolved, prefix="oaklib_resolved", schema_claims=[staging_schema_claim(), ext_claim], + register_claims=False, ) as scoped, ): _populate(scoped, name="Resolved-path concept") @@ -174,12 +162,13 @@ def test_two_connection_entries_for_one_database_collapse_to_one_engine(pg_db) - databases={name: resolver.config.databases[name].model_copy(update={"vocab_connection": "oaklib_split_vocab"})}, ) with ( - _extension_schema_claim(pg_db.committing_engine, prefix="oaklib_split") as ext_claim, + extension_schema_claim(pg_db.committing_engine, prefix="oaklib_split") as ext_claim, scoped_test_schema( resolver.resolve_database(name), prefix="oaklib_split", resolver=resolver, schema_claims=[staging_schema_claim(), ext_claim], + register_claims=False, ) as scoped, ): assert scoped.resolved.connection.name != scoped.resolved.vocab_connection.name @@ -194,9 +183,10 @@ def test_two_connection_entries_for_one_database_collapse_to_one_engine(pg_db) - def test_kg_injection_path_uses_the_injected_knowledge_graph(pg_db) -> None: with ( - _extension_schema_claim(pg_db.committing_engine, prefix="oaklib_kg") as ext_claim, + extension_schema_claim(pg_db.committing_engine, prefix="oaklib_kg") as ext_claim, scoped_test_schema( - pg_db.resolved, prefix="oaklib_kg", schema_claims=[staging_schema_claim(), ext_claim] + pg_db.resolved, prefix="oaklib_kg", schema_claims=[staging_schema_claim(), ext_claim], + register_claims=False, ) as scoped, ): _populate(scoped, name="Injected concept") diff --git a/tests/test_relationship_classification.py b/tests/test_relationship_classification.py index 7a80879..0e5301d 100644 --- a/tests/test_relationship_classification.py +++ b/tests/test_relationship_classification.py @@ -12,30 +12,29 @@ import logging import sqlalchemy as sa -from oa_configurator import SchemaClaim -from oa_configurator.testing import isolated_test_schema, scoped_test_schema +from oa_configurator.testing import scoped_test_schema from orm_loader.backends import staging_schema_claim from orm_loader.helpers import Base -from omop_alchemy.config import EXTENSION_SCHEMA from omop_graph.cli import relationship_classification from omop_graph.extensions.omop_alchemy import RelationshipClass, RelationshipMapping from fixtures.mock_cdm import seed_relationship_vocabulary +from fixtures.helpers import extension_schema_claim + def test_relationship_classification_respects_the_configured_schema(pg_db): with ( - isolated_test_schema(pg_db.committing_engine, prefix="relclass_routing_ext") as extension_schema, + extension_schema_claim(pg_db.committing_engine, prefix="relclass_routing") as ext_claim, scoped_test_schema( pg_db.resolved, prefix="relclass_routing", - schema_claims=[ - staging_schema_claim(), - SchemaClaim(schema_tag=EXTENSION_SCHEMA, physical_schema=extension_schema, reserved=True), - ], + schema_claims=[staging_schema_claim(), ext_claim], + register_claims=False, ) as scoped, ): + extension_schema = ext_claim.physical_schema Base.metadata.create_all(bind=scoped.engine, checkfirst=True) seed_relationship_vocabulary(scoped.engine) @@ -65,14 +64,12 @@ def test_relationship_classification_is_idempotent(pg_db): """Re-running against the same schema, the real-world redeploy case the DROP TABLE/enum-drop cleanup exists for, must not fail.""" with ( - isolated_test_schema(pg_db.committing_engine, prefix="relclass_idempotent_ext") as extension_schema, + extension_schema_claim(pg_db.committing_engine, prefix="relclass_idempotent") as ext_claim, scoped_test_schema( pg_db.resolved, prefix="relclass_idempotent", - schema_claims=[ - staging_schema_claim(), - SchemaClaim(schema_tag=EXTENSION_SCHEMA, physical_schema=extension_schema, reserved=True), - ], + schema_claims=[staging_schema_claim(), ext_claim], + register_claims=False, ) as scoped, ): Base.metadata.create_all(bind=scoped.engine, checkfirst=True) diff --git a/tests/test_schema_provenance_guard.py b/tests/test_schema_provenance_guard.py index 98acaa3..00228c6 100644 --- a/tests/test_schema_provenance_guard.py +++ b/tests/test_schema_provenance_guard.py @@ -16,7 +16,7 @@ from __future__ import annotations import pytest -from oa_configurator import Role, SchemaDriftError +from oa_configurator import Role, SchemaClaim, SchemaDriftError, ensure_schema from oa_configurator.testing import ( guarded_resolver, reset_schema_registry_rows, @@ -26,6 +26,7 @@ from orm_loader.helpers import Base +from omop_alchemy.config import CDM_EXTENSION_SCHEMA, CDM_EXTENSION_SCHEMA_OWNER from omop_graph import cli as omop_graph_cli from fixtures.mock_cdm import seed_relationship_vocabulary @@ -39,7 +40,22 @@ def test_relationship_classification_guard_fires_on_reconfigured_schema( resolver = guarded_resolver(pg_db.resolved) resolved = resolver.resolve_database(pg_db.resolved.name) - with scoped_test_schema(resolved, prefix="graph_guard_a", resolver=resolver) as scoped_a: + # Literal CDM_EXTENSION_SCHEMA, not a test-isolated name: relationship_classification() + # below runs its own resolved-only path, which resolves its engine via + # create_cdm_engines() and claims the same literal name and owner + # internally (matching production, like MAINTENANCE_SCHEMA) -- a + # test-isolated name or owner here would silently diverge from what + # that path actually uses. + extension_claim = SchemaClaim( + schema_tag=CDM_EXTENSION_SCHEMA, + physical_schema=CDM_EXTENSION_SCHEMA, + reserved=True, + owner=CDM_EXTENSION_SCHEMA_OWNER, + ) + with scoped_test_schema( + resolved, prefix="graph_guard_a", resolver=resolver, schema_claims=[extension_claim] + ) as scoped_a: + ensure_schema(scoped_a.engine, CDM_EXTENSION_SCHEMA) Base.metadata.create_all(bind=scoped_a.engine, checkfirst=True) seed_relationship_vocabulary(scoped_a.engine) monkeypatch.setattr(omop_graph_cli, "resolve_cdm_database", lambda: scoped_a.resolved) diff --git a/tests/test_vocab_split_connection.py b/tests/test_vocab_split_connection.py index af6b7a9..d0a9b62 100644 --- a/tests/test_vocab_split_connection.py +++ b/tests/test_vocab_split_connection.py @@ -22,7 +22,6 @@ from oa_configurator import ResolvedCDMDatabase, Resolver, Role, SchemaClaim from oa_configurator.testing import ( isolated_test_database, - isolated_test_schema, resolve_with_role_schemas, scoped_test_schema, ) @@ -50,7 +49,7 @@ ) from omop_graph.graph.kg import KnowledgeGraph -from fixtures.helpers import VOCAB_TABLES +from fixtures.helpers import VOCAB_TABLES, extension_schema_claim pytestmark = [pytest.mark.postgresql, pytest.mark.db_dialect] @@ -126,9 +125,8 @@ def split_engines() -> Iterator[_Engines]: isolated_test_database(OrmLoaderConfig, "test_orm_db_pg") as vocab_db, # RelationshipClass/RelationshipMapping live in the extension schema, # on the same physical connection as primary. - isolated_test_schema(primary_db.committing_engine, prefix="vocab_split_ext") as extension_schema, + extension_schema_claim(primary_db.committing_engine, prefix="vocab_split") as ext_claim, ): - ext_claim = SchemaClaim(schema_tag=CDM_EXTENSION_SCHEMA, physical_schema=extension_schema, reserved=True) name = primary_db.resolved.name resolver = Resolver.from_active_config() resolver = resolver.with_overrides( @@ -140,17 +138,24 @@ def split_engines() -> Iterator[_Engines]: ) split = resolver.resolve_database(name) with scoped_test_schema( - split, prefix="vocab_split", resolver=resolver, schema_claims=[ext_claim] + split, prefix="vocab_split", resolver=resolver, schema_claims=[ext_claim], register_claims=False ) as scoped: - primary_engine, vocab_engine = scoped.resolved.create_engines(schema_claims=[ext_claim]) + primary_engine, vocab_engine = scoped.resolved.create_engines( + schema_claims=[ext_claim], register_claims=False + ) # Vocab tables are created through the vocab server's own resolved: - # create_all on the split vocab engine also emits the PRIMARY-tagged - # predicatekind type, whose schema only exists on the primary server. + # create_all on the split vocab engine also emits the extension-tagged + # predicatekind type (metadata-wide, not scoped to tables=), whose + # schema only exists on the primary server -- co-locate it with + # vocab_schema here since nothing on this engine touches its content. vocab_schema = scoped.schemas[Role.VOCAB] vocab_only = resolve_with_role_schemas( vocab_db.resolved, {role: vocab_schema for role in vocab_db.resolved.schema_tags()} ) - vocab_setup_engine, _ = vocab_only.create_engines() + vocab_setup_engine, _ = vocab_only.create_engines( + schema_claims=[SchemaClaim(schema_tag=CDM_EXTENSION_SCHEMA, physical_schema=vocab_schema)], + register_claims=False, + ) try: _shadow_metadata.create_all(primary_engine) Base.metadata.create_all(vocab_setup_engine, tables=_VOCAB_TABLES, checkfirst=True) From 6757ac8a031ed68811ff5cd740622811f4c16e4e Mon Sep 17 00:00:00 2001 From: Nico Loesch Date: Thu, 8 Oct 2026 03:04:13 +0000 Subject: [PATCH 3/6] Renamed to cdm_extension for clearer disambiguation --- src/omop_graph/cli.py | 1 - src/omop_graph/extensions/omop_alchemy.py | 9 +++--- tests/fixtures/helpers.py | 30 ++++---------------- tests/fixtures/mock_cdm.py | 4 +-- tests/test_concept_queries.py | 4 +-- tests/test_fulltext_vocab_schema_postgres.py | 6 ++-- tests/test_oaklib_schema_awareness.py | 10 +++---- tests/test_relationship_classification.py | 6 ++-- tests/test_vocab_split_connection.py | 8 +++--- 9 files changed, 29 insertions(+), 49 deletions(-) diff --git a/src/omop_graph/cli.py b/src/omop_graph/cli.py index 009931d..9684482 100644 --- a/src/omop_graph/cli.py +++ b/src/omop_graph/cli.py @@ -233,7 +233,6 @@ def _load_relationship_classification( RelationshipMapping.__table__, RelationshipClass.__table__, ] - ensure_schema(engine, physical_schema_of(engine, schema_tag=EXTENSION_SCHEMA)) with open_connection(engine) as connection: Base.metadata.drop_all(bind=connection, tables=tables_to_drop, checkfirst=True) # type: ignore create_extension_tables(connection, resolved=resolved) diff --git a/src/omop_graph/extensions/omop_alchemy.py b/src/omop_graph/extensions/omop_alchemy.py index 582ac5c..d085b1c 100644 --- a/src/omop_graph/extensions/omop_alchemy.py +++ b/src/omop_graph/extensions/omop_alchemy.py @@ -37,7 +37,7 @@ class RelationshipClass(ReferenceTable, CDMExtensionTableBase): values_callable=lambda obj: [ e.value for e in obj ], - # Must match the table's own schema to ensure that + # Must match CDMExtensionTableBase's own schema to ensure that # schema_translate_map routes the enum correctly. schema=CDM_EXTENSION_SCHEMA, ), @@ -69,7 +69,7 @@ class RelationshipMapping(ReferenceTable, CDMExtensionTableBase): values_callable=lambda obj: [ e.value for e in obj ], - # Must match the table's own schema to ensure that + # Must match CDMExtensionTableBase's own schema to ensure that # schema_translate_map routes the enum correctly. schema=CDM_EXTENSION_SCHEMA, ), @@ -79,9 +79,8 @@ class RelationshipMapping(ReferenceTable, CDMExtensionTableBase): sa.String(20), primary_key=True ) - # relationship_class lives in the same fixed extension schema - # (CDMExtensionTableBase's default). The target is a plain qualified - # string rather than role_fk() since there is no Role.EXTENSION. + # Qualified string to RelationshipClass as it lives in the same + # fixed extension schema (CDMExtensionTableBase's default). __table_args__ = merge_table_args( sa.ForeignKeyConstraint( ["predicate_kind", "predicate_subkind"], diff --git a/tests/fixtures/helpers.py b/tests/fixtures/helpers.py index e885129..600305e 100644 --- a/tests/fixtures/helpers.py +++ b/tests/fixtures/helpers.py @@ -14,7 +14,7 @@ Domain, Vocabulary ) -from omop_alchemy.config import EXTENSION_SCHEMA +from omop_alchemy.config import CDM_EXTENSION_SCHEMA, CDM_EXTENSION_SCHEMA_OWNER VOCAB_TABLES = cast( "tuple[sa.Table, ...]", @@ -27,30 +27,12 @@ """ -EXTENSION_SCHEMA_TEST_OWNER = "omop_graph" -"""Fixed owner for every EXTENSION_SCHEMA claim in this suite, matching -relationship_classification()'s own internal claim (cli.py, engine=None path): -that one leaves owner unset, so oa-configurator auto-derives it from the -top-level package of its own call site, "omop_graph". - -create_engine() derives owner from the immediate caller's own top-level -module whenever a SchemaClaim doesn't set one explicitly, and a test -file is its own top-level module -- so two test files claiming the same -schema_tag without this override would each get a different, auto-derived -owner, and SchemaOwnershipError fires for the *tag* existing under a -different owner on the same connection (checked across the whole shared -test database, not scoped per test run). Matching the literal value -production itself falls back to lets every test file's own claim, and -relationship_classification()'s internal one, coexist as "the same owner -reclaiming" instead of conflicting -- each still gets its own physical_schema, -scoped independently by SchemaDriftError's own database_config_name key. -""" - - @contextmanager -def extension_schema_claim(engine: sa.Engine, *, prefix: str) -> Generator[SchemaClaim, None, None]: +def cdm_extension_schema_claim(engine: sa.Engine, *, prefix: str) -> Generator[SchemaClaim, None, None]: """A SchemaClaim for a fresh, test-isolated physical extension schema. + Notes + ----- Needed before any Base.metadata.create_all() call reachable from a test, even one scoped via tables=: a dialect-level custom type (e.g. RelationshipClass's predicate_kind) is created metadata-wide, not @@ -61,7 +43,7 @@ def extension_schema_claim(engine: sa.Engine, *, prefix: str) -> Generator[Schem """ with isolated_test_schema(engine, prefix=f"{prefix}_ext") as physical_schema: yield SchemaClaim( - schema_tag=EXTENSION_SCHEMA, + schema_tag=CDM_EXTENSION_SCHEMA, physical_schema=physical_schema, - owner=EXTENSION_SCHEMA_TEST_OWNER, + owner=CDM_EXTENSION_SCHEMA_OWNER, ) diff --git a/tests/fixtures/mock_cdm.py b/tests/fixtures/mock_cdm.py index 21d982c..7430655 100644 --- a/tests/fixtures/mock_cdm.py +++ b/tests/fixtures/mock_cdm.py @@ -26,7 +26,7 @@ ) from omop_graph.graph.kg import KnowledgeGraph -from fixtures.helpers import extension_schema_claim +from fixtures.helpers import cdm_extension_schema_claim PARENT_CANCER_ID = 443392 CONCEPT_META_ID = 0 @@ -36,7 +36,7 @@ @pytest.fixture(scope="module") def mock_cdm_engine() -> Iterator[sa.Engine]: with isolated_test_database(OmopGraphConfig, "test_cdm_db_pg") as db: - with extension_schema_claim(db.committing_engine, prefix="mock_cdm") as ext_claim: + with cdm_extension_schema_claim(db.committing_engine, prefix="mock_cdm") as ext_claim: with scoped_test_schema( db.resolved, prefix="mock_cdm", schema_claims=[ext_claim], register_claims=False ) as scoped: diff --git a/tests/test_concept_queries.py b/tests/test_concept_queries.py index 65ad171..2054274 100644 --- a/tests/test_concept_queries.py +++ b/tests/test_concept_queries.py @@ -23,7 +23,7 @@ q_entities, ) -from fixtures.helpers import VOCAB_TABLES, extension_schema_claim +from fixtures.helpers import VOCAB_TABLES, cdm_extension_schema_claim _META_CONCEPT_ID = 0 @@ -31,7 +31,7 @@ @pytest.fixture() def concept_engine(pg_db) -> Iterator[sa.Engine]: with ( - extension_schema_claim(pg_db.committing_engine, prefix="concept_queries") as ext_claim, + cdm_extension_schema_claim(pg_db.committing_engine, prefix="concept_queries") as ext_claim, scoped_test_schema( pg_db.resolved, prefix="concept_queries", schema_claims=[ext_claim], register_claims=False ) as scoped, diff --git a/tests/test_fulltext_vocab_schema_postgres.py b/tests/test_fulltext_vocab_schema_postgres.py index ca35a51..9a62c84 100644 --- a/tests/test_fulltext_vocab_schema_postgres.py +++ b/tests/test_fulltext_vocab_schema_postgres.py @@ -17,14 +17,14 @@ from omop_graph.graph.queries import q_concept_name_fulltext -from fixtures.helpers import VOCAB_TABLES, extension_schema_claim +from fixtures.helpers import VOCAB_TABLES, cdm_extension_schema_claim pytestmark = [pytest.mark.postgresql, pytest.mark.db_dialect] def test_fulltext_query_finds_the_tsvector_column_in_the_vocab_schema(pg_db): with ( - extension_schema_claim(pg_db.committing_engine, prefix="fulltext_a") as ext_claim, + cdm_extension_schema_claim(pg_db.committing_engine, prefix="fulltext_a") as ext_claim, scoped_test_schema( pg_db.resolved, prefix="fulltext", @@ -60,7 +60,7 @@ def test_fulltext_query_finds_the_tsvector_column_in_the_vocab_schema(pg_db): def test_fulltext_query_still_raises_when_the_column_is_genuinely_absent(pg_db): with ( - extension_schema_claim(pg_db.committing_engine, prefix="fulltext_b") as ext_claim, + cdm_extension_schema_claim(pg_db.committing_engine, prefix="fulltext_b") as ext_claim, scoped_test_schema( pg_db.resolved, prefix="fulltext", diff --git a/tests/test_oaklib_schema_awareness.py b/tests/test_oaklib_schema_awareness.py index 09e2408..582d416 100644 --- a/tests/test_oaklib_schema_awareness.py +++ b/tests/test_oaklib_schema_awareness.py @@ -33,7 +33,7 @@ from omop_graph.oaklib_interface.omop_implementation import OMOPAlchemyImplementation from fixtures.mock_cdm import seed_relationship_vocabulary -from fixtures.helpers import extension_schema_claim +from fixtures.helpers import cdm_extension_schema_claim _META_CONCEPT_ID = 0 _CONCEPT_ID = 1001 @@ -102,7 +102,7 @@ def test_get_adapter_resolves_the_slug_as_the_database_name( pg_db, monkeypatch, descriptor, expected_name ) -> None: with ( - extension_schema_claim(pg_db.committing_engine, prefix="oaklib_get_adapter") as ext_claim, + cdm_extension_schema_claim(pg_db.committing_engine, prefix="oaklib_get_adapter") as ext_claim, scoped_test_schema( pg_db.resolved, prefix="oaklib_get_adapter", schema_claims=[staging_schema_claim(), ext_claim], register_claims=False, @@ -134,7 +134,7 @@ def test_resolve_cdm_database_resolves_a_named_entry(pg_db) -> None: def test_resolved_path_reuses_one_engine_without_a_vocab_split(pg_db) -> None: with ( - extension_schema_claim(pg_db.committing_engine, prefix="oaklib_resolved") as ext_claim, + cdm_extension_schema_claim(pg_db.committing_engine, prefix="oaklib_resolved") as ext_claim, scoped_test_schema( pg_db.resolved, prefix="oaklib_resolved", schema_claims=[staging_schema_claim(), ext_claim], register_claims=False, @@ -162,7 +162,7 @@ def test_two_connection_entries_for_one_database_collapse_to_one_engine(pg_db) - databases={name: resolver.config.databases[name].model_copy(update={"vocab_connection": "oaklib_split_vocab"})}, ) with ( - extension_schema_claim(pg_db.committing_engine, prefix="oaklib_split") as ext_claim, + cdm_extension_schema_claim(pg_db.committing_engine, prefix="oaklib_split") as ext_claim, scoped_test_schema( resolver.resolve_database(name), prefix="oaklib_split", @@ -183,7 +183,7 @@ def test_two_connection_entries_for_one_database_collapse_to_one_engine(pg_db) - def test_kg_injection_path_uses_the_injected_knowledge_graph(pg_db) -> None: with ( - extension_schema_claim(pg_db.committing_engine, prefix="oaklib_kg") as ext_claim, + cdm_extension_schema_claim(pg_db.committing_engine, prefix="oaklib_kg") as ext_claim, scoped_test_schema( pg_db.resolved, prefix="oaklib_kg", schema_claims=[staging_schema_claim(), ext_claim], register_claims=False, diff --git a/tests/test_relationship_classification.py b/tests/test_relationship_classification.py index 0e5301d..413222e 100644 --- a/tests/test_relationship_classification.py +++ b/tests/test_relationship_classification.py @@ -21,12 +21,12 @@ from omop_graph.extensions.omop_alchemy import RelationshipClass, RelationshipMapping from fixtures.mock_cdm import seed_relationship_vocabulary -from fixtures.helpers import extension_schema_claim +from fixtures.helpers import cdm_extension_schema_claim def test_relationship_classification_respects_the_configured_schema(pg_db): with ( - extension_schema_claim(pg_db.committing_engine, prefix="relclass_routing") as ext_claim, + cdm_extension_schema_claim(pg_db.committing_engine, prefix="relclass_routing") as ext_claim, scoped_test_schema( pg_db.resolved, prefix="relclass_routing", @@ -64,7 +64,7 @@ def test_relationship_classification_is_idempotent(pg_db): """Re-running against the same schema, the real-world redeploy case the DROP TABLE/enum-drop cleanup exists for, must not fail.""" with ( - extension_schema_claim(pg_db.committing_engine, prefix="relclass_idempotent") as ext_claim, + cdm_extension_schema_claim(pg_db.committing_engine, prefix="relclass_idempotent") as ext_claim, scoped_test_schema( pg_db.resolved, prefix="relclass_idempotent", diff --git a/tests/test_vocab_split_connection.py b/tests/test_vocab_split_connection.py index d0a9b62..67c0180 100644 --- a/tests/test_vocab_split_connection.py +++ b/tests/test_vocab_split_connection.py @@ -49,7 +49,7 @@ ) from omop_graph.graph.kg import KnowledgeGraph -from fixtures.helpers import VOCAB_TABLES, extension_schema_claim +from fixtures.helpers import VOCAB_TABLES, cdm_extension_schema_claim pytestmark = [pytest.mark.postgresql, pytest.mark.db_dialect] @@ -109,7 +109,7 @@ class _Engines(NamedTuple): primary: sa.Engine vocab: sa.Engine resolved: ResolvedCDMDatabase - extension_schema_claim: SchemaClaim + cdm_extension_schema_claim: SchemaClaim """Reusable claim for any later engine built from .resolved (e.g. the staging engine in test_relationship_classification_succeeds_on_a_genuinely_split_vocab_connection): schema_claims aren't inherited across separate create_engine() calls.""" @@ -125,7 +125,7 @@ def split_engines() -> Iterator[_Engines]: isolated_test_database(OrmLoaderConfig, "test_orm_db_pg") as vocab_db, # RelationshipClass/RelationshipMapping live in the extension schema, # on the same physical connection as primary. - extension_schema_claim(primary_db.committing_engine, prefix="vocab_split") as ext_claim, + cdm_extension_schema_claim(primary_db.committing_engine, prefix="vocab_split") as ext_claim, ): name = primary_db.resolved.name resolver = Resolver.from_active_config() @@ -164,7 +164,7 @@ def split_engines() -> Iterator[_Engines]: primary=primary_engine, vocab=vocab_engine, resolved=scoped.resolved, - extension_schema_claim=ext_claim, + cdm_extension_schema_claim=ext_claim, ) finally: vocab_setup_engine.dispose() From 67839d3c6904bc4bda5bd7958affb32db18038c0 Mon Sep 17 00:00:00 2001 From: Nico Loesch Date: Tue, 13 Oct 2026 00:23:05 +0000 Subject: [PATCH 4/6] Fix post-rebase API breaks and extension-schema test isolation main's own cli.py refactor (independent of this branch) replaced relationship_classification()'s engine= injection with a resolved-only interface that always builds its engine via open_cdm_sessions() -> create_cdm_engines(), which had no way to accept extra schema_claims. That meant the extension tag always resolved to the literal CDM_EXTENSION_SCHEMA default, with no way to point it at a test-isolated physical schema. relationship_classification()/open_cdm_sessions()/ _create_cdm_sessions() now accept and forward a schema_claims parameter, paired with the same addition on omop-alchemy's create_cdm_engines(). Every affected test now passes its own ext_claim through, and resets the shared CDM_EXTENSION_SCHEMA registry row around it (register_claims=True is the default, needed so relationship_classification() can actually create the schema it writes into) so one test's physical schema doesn't leak into whichever test claims the tag next. Also: the plain SQLite mock engine in fixtures/mock_cdm.py needed a cdm_extension entry in its hand-built schema_translate_map now that RelationshipClass/RelationshipMapping live there instead of Role.PRIMARY. --- src/omop_graph/cli.py | 11 +++++-- src/omop_graph/db/session.py | 21 ++++++++---- tests/fixtures/mock_cdm.py | 3 +- tests/test_oaklib_schema_awareness.py | 37 ++++++++++++++------- tests/test_relationship_classification.py | 40 +++++++++++++++-------- tests/test_vocab_split_connection.py | 24 +++++++++++--- 6 files changed, 96 insertions(+), 40 deletions(-) diff --git a/src/omop_graph/cli.py b/src/omop_graph/cli.py index 9684482..a934406 100644 --- a/src/omop_graph/cli.py +++ b/src/omop_graph/cli.py @@ -2,14 +2,14 @@ import tempfile from importlib import resources from pathlib import Path -from typing import Annotated, Optional, cast +from typing import Annotated, Iterable, Optional, cast import pandas as pd import sqlalchemy as sa import typer import sqlalchemy.orm as so -from oa_configurator import ResolvedCDMDatabase, open_connection +from oa_configurator import ResolvedCDMDatabase, SchemaClaim, open_connection from orm_loader.backends import STAGING_SCHEMA, resolve_backend from orm_loader.helpers import bulk_load_context @@ -99,6 +99,7 @@ def relationship_classification( pred_class_dir: Optional[str] = None, *, resolved: ResolvedCDMDatabase | None = None, + schema_claims: Iterable[SchemaClaim] = (), ) -> None: """Load pre-classified predicates into the database. @@ -112,10 +113,14 @@ def relationship_classification( Database to load into. Defaults to the active config's CDM database. When the vocabulary has its own database, ``RelationshipMapping`` is created without its FK to ``relationship.relationship_id``. + schema_claims : Iterable[SchemaClaim], optional + Forwarded to ``open_cdm_sessions()``/``create_cdm_engines()``, e.g. + a test's own test-isolated CDM_EXTENSION_SCHEMA claim overriding + the literal default this would otherwise always resolve to. """ if resolved is None: resolved = resolve_cdm_database() - with open_cdm_sessions(resolved) as cdm_sessions: + with open_cdm_sessions(resolved, schema_claims=schema_claims) as cdm_sessions: with cdm_sessions() as session: engine = session.get_bind(RelationshipMapping).engine relationship_ids = set( diff --git a/src/omop_graph/db/session.py b/src/omop_graph/db/session.py index 826f088..9abbcfb 100644 --- a/src/omop_graph/db/session.py +++ b/src/omop_graph/db/session.py @@ -3,11 +3,11 @@ from __future__ import annotations from typing import Optional -from collections.abc import Generator +from collections.abc import Generator, Iterable from contextlib import contextmanager import sqlalchemy.orm as so from sqlalchemy.engine import Engine -from oa_configurator import ResolvedCDMDatabase, Resolver +from oa_configurator import ResolvedCDMDatabase, Resolver, SchemaClaim from omop_alchemy.config import create_cdm_engines from omop_alchemy.cross_database import CDMSession, cdm_sessionmaker from sqlalchemy.orm import sessionmaker @@ -62,24 +62,33 @@ def cdm_session_factory( def _create_cdm_sessions( - resolved: ResolvedCDMDatabase, *, register_claims: bool + resolved: ResolvedCDMDatabase, + *, + schema_claims: Iterable[SchemaClaim] = (), + register_claims: bool, ) -> tuple[sessionmaker[CDMSession], Engine, Engine]: """Build routed sessions and their owned engine pair through omop-alchemy.""" primary, vocab = create_cdm_engines( - resolved, register_claims=register_claims + resolved, schema_claims=schema_claims, register_claims=register_claims ) return cdm_sessionmaker(resolved, primary=primary, vocab=vocab), primary, vocab @contextmanager def open_cdm_sessions( - resolved: ResolvedCDMDatabase, *, register_claims: bool = True + resolved: ResolvedCDMDatabase, + *, + schema_claims: Iterable[SchemaClaim] = (), + register_claims: bool = True, ) -> Generator[so.sessionmaker[CDMSession], None, None]: """Yield a routed session factory on ``create_cdm_engines(resolved)``, disposing both engines on exit. Parameters ---------- resolved : ResolvedCDMDatabase + schema_claims : Iterable[SchemaClaim], optional + As for :func:`create_cdm_engines`, e.g. a test's own test-isolated + CDM_EXTENSION_SCHEMA claim. register_claims : bool, optional As for :func:`create_cdm_engines`. @@ -88,7 +97,7 @@ def open_cdm_sessions( sqlalchemy.orm.sessionmaker[CDMSession] """ cdm_sessions, primary, vocab = _create_cdm_sessions( - resolved, register_claims=register_claims + resolved, schema_claims=schema_claims, register_claims=register_claims ) try: yield cdm_sessions diff --git a/tests/fixtures/mock_cdm.py b/tests/fixtures/mock_cdm.py index 7430655..724a488 100644 --- a/tests/fixtures/mock_cdm.py +++ b/tests/fixtures/mock_cdm.py @@ -17,6 +17,7 @@ from omop_alchemy.cdm.model.vocabulary.domain import Domain from omop_alchemy.cdm.model.vocabulary.relationship import Relationship from omop_alchemy.cdm.model.vocabulary.vocabulary import Vocabulary +from omop_alchemy.config import CDM_EXTENSION_SCHEMA from omop_graph.config import OmopGraphConfig from omop_graph.extensions.omop_alchemy import ( @@ -47,7 +48,7 @@ def mock_cdm_engine() -> Iterator[sa.Engine]: @pytest.fixture(scope="module") def mock_cdm_engine_sqlite() -> Iterator[sa.Engine]: engine = sa.create_engine("sqlite+pysqlite:///:memory:").execution_options( - schema_translate_map={"primary": None, "vocab": None} + schema_translate_map={"primary": None, "vocab": None, CDM_EXTENSION_SCHEMA: None} ) _create_mock_cdm_tables(engine) try: diff --git a/tests/test_oaklib_schema_awareness.py b/tests/test_oaklib_schema_awareness.py index 582d416..0248ab8 100644 --- a/tests/test_oaklib_schema_awareness.py +++ b/tests/test_oaklib_schema_awareness.py @@ -18,9 +18,10 @@ import sqlalchemy.orm from oaklib import get_adapter -from oa_configurator import Resolver, Role -from oa_configurator.testing import scoped_test_schema +from oa_configurator import Resolver, Role, SchemaClaim +from oa_configurator.testing import reset_schema_registry_rows, scoped_test_schema from omop_alchemy.cdm.model.vocabulary import Concept, Concept_Class, Domain, Vocabulary +from omop_alchemy.config import CDM_EXTENSION_SCHEMA from omop_alchemy.cross_database import cdm_sessionmaker from orm_loader.backends import staging_schema_claim from orm_loader.helpers import Base, bulk_load_context @@ -90,17 +91,26 @@ def _seed_one_concept(engine: sa.Engine, *, concept_id: int, name: str) -> None: session.commit() -def _populate(scoped, *, name: str) -> None: +def _populate(scoped, *, name: str, ext_claim: SchemaClaim) -> None: + """Seed one concept plus the relationship vocabulary/classification. + + relationship_classification() registers ext_claim (register_claims=True, + the default, needed so it can create the schema it writes into), so + every caller resets the registry's shared CDM_EXTENSION_SCHEMA row + around this (via cleanup_after_test) to avoid leaving it pointed at + this test's own physical schema for whichever test runs next. + """ Base.metadata.create_all(bind=scoped.engine, checkfirst=True) _seed_one_concept(scoped.engine, concept_id=_CONCEPT_ID, name=name) seed_relationship_vocabulary(scoped.engine) - relationship_classification(resolved=scoped.resolved) + relationship_classification(resolved=scoped.resolved, schema_claims=[ext_claim]) @pytest.mark.parametrize(("descriptor", "expected_name"), [("omop:named_db", "named_db"), ("omop:", None)]) def test_get_adapter_resolves_the_slug_as_the_database_name( - pg_db, monkeypatch, descriptor, expected_name + pg_db, monkeypatch, descriptor, expected_name, cleanup_after_test ) -> None: + reset_schema_registry_rows(cleanup_after_test, pg_db.committing_engine, schema_tags=[CDM_EXTENSION_SCHEMA]) with ( cdm_extension_schema_claim(pg_db.committing_engine, prefix="oaklib_get_adapter") as ext_claim, scoped_test_schema( @@ -108,7 +118,7 @@ def test_get_adapter_resolves_the_slug_as_the_database_name( register_claims=False, ) as scoped, ): - _populate(scoped, name="Adapter concept") + _populate(scoped, name="Adapter concept", ext_claim=ext_claim) def fake_resolve_cdm_database(name): assert name == expected_name @@ -132,7 +142,8 @@ def test_resolve_cdm_database_resolves_a_named_entry(pg_db) -> None: assert resolve_cdm_database(pg_db.resolved.name).name == pg_db.resolved.name -def test_resolved_path_reuses_one_engine_without_a_vocab_split(pg_db) -> None: +def test_resolved_path_reuses_one_engine_without_a_vocab_split(pg_db, cleanup_after_test) -> None: + reset_schema_registry_rows(cleanup_after_test, pg_db.committing_engine, schema_tags=[CDM_EXTENSION_SCHEMA]) with ( cdm_extension_schema_claim(pg_db.committing_engine, prefix="oaklib_resolved") as ext_claim, scoped_test_schema( @@ -140,7 +151,7 @@ def test_resolved_path_reuses_one_engine_without_a_vocab_split(pg_db) -> None: register_claims=False, ) as scoped, ): - _populate(scoped, name="Resolved-path concept") + _populate(scoped, name="Resolved-path concept", ext_claim=ext_claim) adapter = OMOPAlchemyImplementation(resolved=scoped.resolved) @@ -150,11 +161,12 @@ def test_resolved_path_reuses_one_engine_without_a_vocab_split(pg_db) -> None: assert adapter.label(f"OMOP:{_CONCEPT_ID}") == "Resolved-path concept" -def test_two_connection_entries_for_one_database_collapse_to_one_engine(pg_db) -> None: +def test_two_connection_entries_for_one_database_collapse_to_one_engine(pg_db, cleanup_after_test) -> None: """A second connection entry naming the same physical database is not a split, so the adapter gets one engine for both roles and the cross-schema foreign key stays creatable. Deciding this by config identity instead would open a second pool to a database already in use and drop that key.""" + reset_schema_registry_rows(cleanup_after_test, pg_db.committing_engine, schema_tags=[CDM_EXTENSION_SCHEMA]) name = pg_db.resolved.name resolver = Resolver.from_active_config() resolver = resolver.with_overrides( @@ -173,7 +185,7 @@ def test_two_connection_entries_for_one_database_collapse_to_one_engine(pg_db) - ): assert scoped.resolved.connection.name != scoped.resolved.vocab_connection.name assert scoped.resolved.foreign_key_can_span(Role.PRIMARY, Role.VOCAB) - _populate(scoped, name="Split-wiring concept") + _populate(scoped, name="Split-wiring concept", ext_claim=ext_claim) adapter = OMOPAlchemyImplementation(resolved=scoped.resolved) @@ -181,7 +193,8 @@ def test_two_connection_entries_for_one_database_collapse_to_one_engine(pg_db) - assert adapter.label(f"OMOP:{_CONCEPT_ID}") == "Split-wiring concept" -def test_kg_injection_path_uses_the_injected_knowledge_graph(pg_db) -> None: +def test_kg_injection_path_uses_the_injected_knowledge_graph(pg_db, cleanup_after_test) -> None: + reset_schema_registry_rows(cleanup_after_test, pg_db.committing_engine, schema_tags=[CDM_EXTENSION_SCHEMA]) with ( cdm_extension_schema_claim(pg_db.committing_engine, prefix="oaklib_kg") as ext_claim, scoped_test_schema( @@ -189,7 +202,7 @@ def test_kg_injection_path_uses_the_injected_knowledge_graph(pg_db) -> None: register_claims=False, ) as scoped, ): - _populate(scoped, name="Injected concept") + _populate(scoped, name="Injected concept", ext_claim=ext_claim) kg = KnowledgeGraph( cdm_sessionmaker(scoped.resolved, primary=scoped.engine, vocab=scoped.engine) ) diff --git a/tests/test_relationship_classification.py b/tests/test_relationship_classification.py index 413222e..d9183db 100644 --- a/tests/test_relationship_classification.py +++ b/tests/test_relationship_classification.py @@ -12,11 +12,12 @@ import logging import sqlalchemy as sa -from oa_configurator.testing import scoped_test_schema +from oa_configurator.testing import reset_schema_registry_rows, scoped_test_schema from orm_loader.backends import staging_schema_claim from orm_loader.helpers import Base +from omop_alchemy.config import CDM_EXTENSION_SCHEMA from omop_graph.cli import relationship_classification from omop_graph.extensions.omop_alchemy import RelationshipClass, RelationshipMapping from fixtures.mock_cdm import seed_relationship_vocabulary @@ -24,7 +25,12 @@ from fixtures.helpers import cdm_extension_schema_claim -def test_relationship_classification_respects_the_configured_schema(pg_db): +def test_relationship_classification_respects_the_configured_schema(pg_db, cleanup_after_test): + # relationship_classification() below registers its own claim (register_claims=True, + # the default, needed so it can actually create the schema it writes into) under the + # same owner the literal production default uses, so this leaves the shared registry + # row for CDM_EXTENSION_SCHEMA pointing at this test's own physical schema unless reset. + reset_schema_registry_rows(cleanup_after_test, pg_db.committing_engine, schema_tags=[CDM_EXTENSION_SCHEMA]) with ( cdm_extension_schema_claim(pg_db.committing_engine, prefix="relclass_routing") as ext_claim, scoped_test_schema( @@ -38,7 +44,7 @@ def test_relationship_classification_respects_the_configured_schema(pg_db): Base.metadata.create_all(bind=scoped.engine, checkfirst=True) seed_relationship_vocabulary(scoped.engine) - relationship_classification(resolved=scoped.resolved) + relationship_classification(resolved=scoped.resolved, schema_claims=[ext_claim]) with scoped.engine.connect() as conn: n_class = conn.execute( @@ -60,9 +66,10 @@ def test_relationship_classification_respects_the_configured_schema(pg_db): assert enum_schema == extension_schema -def test_relationship_classification_is_idempotent(pg_db): +def test_relationship_classification_is_idempotent(pg_db, cleanup_after_test): """Re-running against the same schema, the real-world redeploy case the DROP TABLE/enum-drop cleanup exists for, must not fail.""" + reset_schema_registry_rows(cleanup_after_test, pg_db.committing_engine, schema_tags=[CDM_EXTENSION_SCHEMA]) with ( cdm_extension_schema_claim(pg_db.committing_engine, prefix="relclass_idempotent") as ext_claim, scoped_test_schema( @@ -75,8 +82,8 @@ def test_relationship_classification_is_idempotent(pg_db): Base.metadata.create_all(bind=scoped.engine, checkfirst=True) seed_relationship_vocabulary(scoped.engine) - relationship_classification(resolved=scoped.resolved) - relationship_classification(resolved=scoped.resolved) + relationship_classification(resolved=scoped.resolved, schema_claims=[ext_claim]) + relationship_classification(resolved=scoped.resolved, schema_claims=[ext_claim]) with scoped.engine.connect() as conn: n_class = conn.execute( @@ -86,7 +93,7 @@ def test_relationship_classification_is_idempotent(pg_db): def test_relationship_classification_warns_and_drops_unknown_ids_colocated( - pg_db, tmp_path, caplog + pg_db, tmp_path, caplog, cleanup_after_test ): (tmp_path / "predicate_classification.csv").write_text( "class,subclass,description,semantics,inference\n" @@ -98,16 +105,23 @@ def test_relationship_classification_warns_and_drops_unknown_ids_colocated( "Identity,mapping,unknown_rel,Unknown,,,\n" ) - with scoped_test_schema( - pg_db.resolved, - prefix="relclass_unknown", - schema_claims=[staging_schema_claim()], - ) as scoped: + reset_schema_registry_rows(cleanup_after_test, pg_db.committing_engine, schema_tags=[CDM_EXTENSION_SCHEMA]) + with ( + cdm_extension_schema_claim(pg_db.committing_engine, prefix="relclass_unknown") as ext_claim, + scoped_test_schema( + pg_db.resolved, + prefix="relclass_unknown", + schema_claims=[staging_schema_claim(), ext_claim], + register_claims=False, + ) as scoped, + ): Base.metadata.create_all(bind=scoped.engine, checkfirst=True) seed_relationship_vocabulary(scoped.engine) with caplog.at_level(logging.WARNING, logger="omop_graph.cli"): - relationship_classification(str(tmp_path), resolved=scoped.resolved) + relationship_classification( + str(tmp_path), resolved=scoped.resolved, schema_claims=[ext_claim] + ) with scoped.engine.connect() as connection: relationship_ids = set( diff --git a/tests/test_vocab_split_connection.py b/tests/test_vocab_split_connection.py index 67c0180..d7fc776 100644 --- a/tests/test_vocab_split_connection.py +++ b/tests/test_vocab_split_connection.py @@ -22,10 +22,10 @@ from oa_configurator import ResolvedCDMDatabase, Resolver, Role, SchemaClaim from oa_configurator.testing import ( isolated_test_database, + reset_schema_registry_rows, resolve_with_role_schemas, scoped_test_schema, ) -from orm_loader.backends import staging_schema_claim from orm_loader.config import OrmLoaderConfig from orm_loader.helpers import Base, bulk_load_context @@ -37,7 +37,7 @@ Relationship, Vocabulary, ) -from omop_alchemy.config import CDM_EXTENSION_SCHEMA +from omop_alchemy.config import CDM_EXTENSION_SCHEMA, CDM_EXTENSION_SCHEMA_OWNER from omop_alchemy.cross_database import cdm_sessionmaker from omop_graph.cli import relationship_classification @@ -153,7 +153,13 @@ def split_engines() -> Iterator[_Engines]: vocab_db.resolved, {role: vocab_schema for role in vocab_db.resolved.schema_tags()} ) vocab_setup_engine, _ = vocab_only.create_engines( - schema_claims=[SchemaClaim(schema_tag=CDM_EXTENSION_SCHEMA, physical_schema=vocab_schema)], + schema_claims=[ + SchemaClaim( + schema_tag=CDM_EXTENSION_SCHEMA, + physical_schema=vocab_schema, + owner=CDM_EXTENSION_SCHEMA_OWNER, + ) + ], register_claims=False, ) try: @@ -405,7 +411,7 @@ def test_relationships_invert_swaps_subjects_and_objects(split_engines: _Engines def test_relationship_classification_succeeds_on_a_genuinely_split_vocab_connection( - split_engines: _Engines, tmp_path + split_engines: _Engines, tmp_path, cleanup_after_test ) -> None: """relationship_classification() creates RelationshipMapping without its FK to the vocabulary. A relationship ID the vocabulary lacks is warned @@ -420,7 +426,15 @@ def test_relationship_classification_succeeds_on_a_genuinely_split_vocab_connect "Identity,mapping,unknown_rel,Unknown,,,\n" ) - relationship_classification(str(tmp_path), resolved=split_engines.resolved) + # relationship_classification() below registers its own claim + # (register_claims=True, the default), so reset the shared registry row + # for CDM_EXTENSION_SCHEMA around it to avoid leaving it pointed at this + # test's own physical schema for whichever test runs next. + reset_schema_registry_rows(cleanup_after_test, split_engines.primary, schema_tags=[CDM_EXTENSION_SCHEMA]) + relationship_classification( + str(tmp_path), resolved=split_engines.resolved, + schema_claims=[split_engines.cdm_extension_schema_claim], + ) with so.Session(split_engines.primary) as session: relationship_ids = set( From f57ed579d0e226b91123ddad49e11cf9e379e34c Mon Sep 17 00:00:00 2001 From: Nico Loesch Date: Tue, 13 Oct 2026 04:01:27 +0000 Subject: [PATCH 5/6] Cleanup of extension schema and schema claims --- src/omop_graph/cli.py | 11 +-- src/omop_graph/db/session.py | 43 ++++++----- tests/fixtures/mock_cdm.py | 17 ++--- tests/test_oaklib_schema_awareness.py | 90 ++++++++--------------- tests/test_relationship_classification.py | 68 ++++++++--------- tests/test_vocab_split_connection.py | 40 ++++------ 6 files changed, 110 insertions(+), 159 deletions(-) diff --git a/src/omop_graph/cli.py b/src/omop_graph/cli.py index a934406..9684482 100644 --- a/src/omop_graph/cli.py +++ b/src/omop_graph/cli.py @@ -2,14 +2,14 @@ import tempfile from importlib import resources from pathlib import Path -from typing import Annotated, Iterable, Optional, cast +from typing import Annotated, Optional, cast import pandas as pd import sqlalchemy as sa import typer import sqlalchemy.orm as so -from oa_configurator import ResolvedCDMDatabase, SchemaClaim, open_connection +from oa_configurator import ResolvedCDMDatabase, open_connection from orm_loader.backends import STAGING_SCHEMA, resolve_backend from orm_loader.helpers import bulk_load_context @@ -99,7 +99,6 @@ def relationship_classification( pred_class_dir: Optional[str] = None, *, resolved: ResolvedCDMDatabase | None = None, - schema_claims: Iterable[SchemaClaim] = (), ) -> None: """Load pre-classified predicates into the database. @@ -113,14 +112,10 @@ def relationship_classification( Database to load into. Defaults to the active config's CDM database. When the vocabulary has its own database, ``RelationshipMapping`` is created without its FK to ``relationship.relationship_id``. - schema_claims : Iterable[SchemaClaim], optional - Forwarded to ``open_cdm_sessions()``/``create_cdm_engines()``, e.g. - a test's own test-isolated CDM_EXTENSION_SCHEMA claim overriding - the literal default this would otherwise always resolve to. """ if resolved is None: resolved = resolve_cdm_database() - with open_cdm_sessions(resolved, schema_claims=schema_claims) as cdm_sessions: + with open_cdm_sessions(resolved) as cdm_sessions: with cdm_sessions() as session: engine = session.get_bind(RelationshipMapping).engine relationship_ids = set( diff --git a/src/omop_graph/db/session.py b/src/omop_graph/db/session.py index 9abbcfb..2b3c578 100644 --- a/src/omop_graph/db/session.py +++ b/src/omop_graph/db/session.py @@ -3,13 +3,14 @@ from __future__ import annotations from typing import Optional -from collections.abc import Generator, Iterable +from collections.abc import Generator from contextlib import contextmanager import sqlalchemy.orm as so from sqlalchemy.engine import Engine -from oa_configurator import ResolvedCDMDatabase, Resolver, SchemaClaim -from omop_alchemy.config import create_cdm_engines +from oa_configurator import ResolvedCDMDatabase, Resolver +from omop_alchemy.config import CDM_EXTENSION_SCHEMA_CLAIM from omop_alchemy.cross_database import CDMSession, cdm_sessionmaker +from orm_loader.backends import staging_schema_claim from sqlalchemy.orm import sessionmaker from omop_graph.config import OmopGraphConfig @@ -48,9 +49,9 @@ def cdm_session_factory( ) -> sessionmaker[CDMSession]: """Read-only routed session factory on a CDM database from the active config. - Builds the engine pair with ``create_cdm_engines()``, checking its schema - claims without registering them. The engines live as long as the process, - so this suits scripts that run once. + Builds the engine pair directly, checking its schema claims without + registering them. The engines live as long as the process, so this + suits scripts that run once. Parameters ---------- @@ -64,12 +65,20 @@ def cdm_session_factory( def _create_cdm_sessions( resolved: ResolvedCDMDatabase, *, - schema_claims: Iterable[SchemaClaim] = (), register_claims: bool, ) -> tuple[sessionmaker[CDMSession], Engine, Engine]: - """Build routed sessions and their owned engine pair through omop-alchemy.""" - primary, vocab = create_cdm_engines( - resolved, schema_claims=schema_claims, register_claims=register_claims + """Build routed sessions and their owned engine pair. + + Notes + ----- + Claims the following additional schemas: + - staging schema: for CSV bulk-loading of RelationshipClass/RelationshipMapping + - CDM_EXTENSION_SCHEMA: for the tables those two live in + """ + + primary, vocab = resolved.create_engines( + schema_claims=[staging_schema_claim(), CDM_EXTENSION_SCHEMA_CLAIM], + register_claims=register_claims, ) return cdm_sessionmaker(resolved, primary=primary, vocab=vocab), primary, vocab @@ -77,28 +86,18 @@ def _create_cdm_sessions( @contextmanager def open_cdm_sessions( resolved: ResolvedCDMDatabase, - *, - schema_claims: Iterable[SchemaClaim] = (), - register_claims: bool = True, ) -> Generator[so.sessionmaker[CDMSession], None, None]: - """Yield a routed session factory on ``create_cdm_engines(resolved)``, disposing both engines on exit. + """Yield a routed session factory, disposing both engines on exit. Parameters ---------- resolved : ResolvedCDMDatabase - schema_claims : Iterable[SchemaClaim], optional - As for :func:`create_cdm_engines`, e.g. a test's own test-isolated - CDM_EXTENSION_SCHEMA claim. - register_claims : bool, optional - As for :func:`create_cdm_engines`. Yields ------ sqlalchemy.orm.sessionmaker[CDMSession] """ - cdm_sessions, primary, vocab = _create_cdm_sessions( - resolved, schema_claims=schema_claims, register_claims=register_claims - ) + cdm_sessions, primary, vocab = _create_cdm_sessions(resolved, register_claims=True) try: yield cdm_sessions finally: diff --git a/tests/fixtures/mock_cdm.py b/tests/fixtures/mock_cdm.py index 724a488..8477fe5 100644 --- a/tests/fixtures/mock_cdm.py +++ b/tests/fixtures/mock_cdm.py @@ -7,6 +7,7 @@ import sqlalchemy as sa from sqlalchemy.orm import Session, sessionmaker +from oa_configurator import Dialect from oa_configurator.testing import isolated_test_database, scoped_test_schema from orm_loader.helpers import Base, bulk_load_context from omop_alchemy.cdm.model.vocabulary.concept import Concept @@ -17,7 +18,7 @@ from omop_alchemy.cdm.model.vocabulary.domain import Domain from omop_alchemy.cdm.model.vocabulary.relationship import Relationship from omop_alchemy.cdm.model.vocabulary.vocabulary import Vocabulary -from omop_alchemy.config import CDM_EXTENSION_SCHEMA +from omop_alchemy.config import CDM_EXTENSION_SCHEMA_CLAIM from omop_graph.config import OmopGraphConfig from omop_graph.extensions.omop_alchemy import ( @@ -47,14 +48,12 @@ def mock_cdm_engine() -> Iterator[sa.Engine]: @pytest.fixture(scope="module") def mock_cdm_engine_sqlite() -> Iterator[sa.Engine]: - engine = sa.create_engine("sqlite+pysqlite:///:memory:").execution_options( - schema_translate_map={"primary": None, "vocab": None, CDM_EXTENSION_SCHEMA: None} - ) - _create_mock_cdm_tables(engine) - try: - yield engine - finally: - engine.dispose() + with isolated_test_database( + OmopGraphConfig, "test_cdm_db_sqlite", dialect=Dialect.SQLITE, + schema_claims=[CDM_EXTENSION_SCHEMA_CLAIM], + ) as db: + _create_mock_cdm_tables(db.committing_engine) + yield db.committing_engine def _create_mock_cdm_tables(engine: sa.Engine) -> None: diff --git a/tests/test_oaklib_schema_awareness.py b/tests/test_oaklib_schema_awareness.py index 0248ab8..fbe397c 100644 --- a/tests/test_oaklib_schema_awareness.py +++ b/tests/test_oaklib_schema_awareness.py @@ -18,10 +18,9 @@ import sqlalchemy.orm from oaklib import get_adapter -from oa_configurator import Resolver, Role, SchemaClaim -from oa_configurator.testing import reset_schema_registry_rows, scoped_test_schema +from oa_configurator import Resolver, Role +from oa_configurator.testing import scoped_test_schema from omop_alchemy.cdm.model.vocabulary import Concept, Concept_Class, Domain, Vocabulary -from omop_alchemy.config import CDM_EXTENSION_SCHEMA from omop_alchemy.cross_database import cdm_sessionmaker from orm_loader.backends import staging_schema_claim from orm_loader.helpers import Base, bulk_load_context @@ -34,8 +33,6 @@ from omop_graph.oaklib_interface.omop_implementation import OMOPAlchemyImplementation from fixtures.mock_cdm import seed_relationship_vocabulary -from fixtures.helpers import cdm_extension_schema_claim - _META_CONCEPT_ID = 0 _CONCEPT_ID = 1001 _TODAY = date(2020, 1, 1) @@ -91,34 +88,23 @@ def _seed_one_concept(engine: sa.Engine, *, concept_id: int, name: str) -> None: session.commit() -def _populate(scoped, *, name: str, ext_claim: SchemaClaim) -> None: - """Seed one concept plus the relationship vocabulary/classification. - - relationship_classification() registers ext_claim (register_claims=True, - the default, needed so it can create the schema it writes into), so - every caller resets the registry's shared CDM_EXTENSION_SCHEMA row - around this (via cleanup_after_test) to avoid leaving it pointed at - this test's own physical schema for whichever test runs next. - """ +def _populate(scoped, *, name: str) -> None: + """Seed one concept plus the relationship vocabulary/classification.""" Base.metadata.create_all(bind=scoped.engine, checkfirst=True) _seed_one_concept(scoped.engine, concept_id=_CONCEPT_ID, name=name) seed_relationship_vocabulary(scoped.engine) - relationship_classification(resolved=scoped.resolved, schema_claims=[ext_claim]) + relationship_classification(resolved=scoped.resolved) @pytest.mark.parametrize(("descriptor", "expected_name"), [("omop:named_db", "named_db"), ("omop:", None)]) def test_get_adapter_resolves_the_slug_as_the_database_name( - pg_db, monkeypatch, descriptor, expected_name, cleanup_after_test + pg_db, monkeypatch, descriptor, expected_name ) -> None: - reset_schema_registry_rows(cleanup_after_test, pg_db.committing_engine, schema_tags=[CDM_EXTENSION_SCHEMA]) - with ( - cdm_extension_schema_claim(pg_db.committing_engine, prefix="oaklib_get_adapter") as ext_claim, - scoped_test_schema( - pg_db.resolved, prefix="oaklib_get_adapter", schema_claims=[staging_schema_claim(), ext_claim], - register_claims=False, - ) as scoped, - ): - _populate(scoped, name="Adapter concept", ext_claim=ext_claim) + with scoped_test_schema( + pg_db.resolved, prefix="oaklib_get_adapter", schema_claims=[staging_schema_claim()], + register_claims=False, + ) as scoped: + _populate(scoped, name="Adapter concept") def fake_resolve_cdm_database(name): assert name == expected_name @@ -142,16 +128,12 @@ def test_resolve_cdm_database_resolves_a_named_entry(pg_db) -> None: assert resolve_cdm_database(pg_db.resolved.name).name == pg_db.resolved.name -def test_resolved_path_reuses_one_engine_without_a_vocab_split(pg_db, cleanup_after_test) -> None: - reset_schema_registry_rows(cleanup_after_test, pg_db.committing_engine, schema_tags=[CDM_EXTENSION_SCHEMA]) - with ( - cdm_extension_schema_claim(pg_db.committing_engine, prefix="oaklib_resolved") as ext_claim, - scoped_test_schema( - pg_db.resolved, prefix="oaklib_resolved", schema_claims=[staging_schema_claim(), ext_claim], - register_claims=False, - ) as scoped, - ): - _populate(scoped, name="Resolved-path concept", ext_claim=ext_claim) +def test_resolved_path_reuses_one_engine_without_a_vocab_split(pg_db) -> None: + with scoped_test_schema( + pg_db.resolved, prefix="oaklib_resolved", schema_claims=[staging_schema_claim()], + register_claims=False, + ) as scoped: + _populate(scoped, name="Resolved-path concept") adapter = OMOPAlchemyImplementation(resolved=scoped.resolved) @@ -161,31 +143,27 @@ def test_resolved_path_reuses_one_engine_without_a_vocab_split(pg_db, cleanup_af assert adapter.label(f"OMOP:{_CONCEPT_ID}") == "Resolved-path concept" -def test_two_connection_entries_for_one_database_collapse_to_one_engine(pg_db, cleanup_after_test) -> None: +def test_two_connection_entries_for_one_database_collapse_to_one_engine(pg_db) -> None: """A second connection entry naming the same physical database is not a split, so the adapter gets one engine for both roles and the cross-schema foreign key stays creatable. Deciding this by config identity instead would open a second pool to a database already in use and drop that key.""" - reset_schema_registry_rows(cleanup_after_test, pg_db.committing_engine, schema_tags=[CDM_EXTENSION_SCHEMA]) name = pg_db.resolved.name resolver = Resolver.from_active_config() resolver = resolver.with_overrides( connections={"oaklib_split_vocab": resolver.config.connections[pg_db.resolved.connection.name]}, databases={name: resolver.config.databases[name].model_copy(update={"vocab_connection": "oaklib_split_vocab"})}, ) - with ( - cdm_extension_schema_claim(pg_db.committing_engine, prefix="oaklib_split") as ext_claim, - scoped_test_schema( - resolver.resolve_database(name), - prefix="oaklib_split", - resolver=resolver, - schema_claims=[staging_schema_claim(), ext_claim], - register_claims=False, - ) as scoped, - ): + with scoped_test_schema( + resolver.resolve_database(name), + prefix="oaklib_split", + resolver=resolver, + schema_claims=[staging_schema_claim()], + register_claims=False, + ) as scoped: assert scoped.resolved.connection.name != scoped.resolved.vocab_connection.name assert scoped.resolved.foreign_key_can_span(Role.PRIMARY, Role.VOCAB) - _populate(scoped, name="Split-wiring concept", ext_claim=ext_claim) + _populate(scoped, name="Split-wiring concept") adapter = OMOPAlchemyImplementation(resolved=scoped.resolved) @@ -193,16 +171,12 @@ def test_two_connection_entries_for_one_database_collapse_to_one_engine(pg_db, c assert adapter.label(f"OMOP:{_CONCEPT_ID}") == "Split-wiring concept" -def test_kg_injection_path_uses_the_injected_knowledge_graph(pg_db, cleanup_after_test) -> None: - reset_schema_registry_rows(cleanup_after_test, pg_db.committing_engine, schema_tags=[CDM_EXTENSION_SCHEMA]) - with ( - cdm_extension_schema_claim(pg_db.committing_engine, prefix="oaklib_kg") as ext_claim, - scoped_test_schema( - pg_db.resolved, prefix="oaklib_kg", schema_claims=[staging_schema_claim(), ext_claim], - register_claims=False, - ) as scoped, - ): - _populate(scoped, name="Injected concept", ext_claim=ext_claim) +def test_kg_injection_path_uses_the_injected_knowledge_graph(pg_db) -> None: + with scoped_test_schema( + pg_db.resolved, prefix="oaklib_kg", schema_claims=[staging_schema_claim()], + register_claims=False, + ) as scoped: + _populate(scoped, name="Injected concept") kg = KnowledgeGraph( cdm_sessionmaker(scoped.resolved, primary=scoped.engine, vocab=scoped.engine) ) diff --git a/tests/test_relationship_classification.py b/tests/test_relationship_classification.py index d9183db..8f4601d 100644 --- a/tests/test_relationship_classification.py +++ b/tests/test_relationship_classification.py @@ -12,25 +12,25 @@ import logging import sqlalchemy as sa -from oa_configurator.testing import reset_schema_registry_rows, scoped_test_schema +import sqlalchemy.orm as so +from oa_configurator.testing import scoped_test_schema from orm_loader.backends import staging_schema_claim from orm_loader.helpers import Base -from omop_alchemy.config import CDM_EXTENSION_SCHEMA -from omop_graph.cli import relationship_classification +from omop_alchemy.cdm.model.vocabulary.relationship import Relationship +from omop_graph.cli import _load_relationship_classification, relationship_classification from omop_graph.extensions.omop_alchemy import RelationshipClass, RelationshipMapping from fixtures.mock_cdm import seed_relationship_vocabulary from fixtures.helpers import cdm_extension_schema_claim -def test_relationship_classification_respects_the_configured_schema(pg_db, cleanup_after_test): - # relationship_classification() below registers its own claim (register_claims=True, - # the default, needed so it can actually create the schema it writes into) under the - # same owner the literal production default uses, so this leaves the shared registry - # row for CDM_EXTENSION_SCHEMA pointing at this test's own physical schema unless reset. - reset_schema_registry_rows(cleanup_after_test, pg_db.committing_engine, schema_tags=[CDM_EXTENSION_SCHEMA]) +def test_relationship_classification_respects_the_configured_schema(pg_db): + """The loading body creates RelationshipClass/RelationshipMapping (and the + predicatekind enum) wherever its engine resolves CDM_EXTENSION_SCHEMA to, + not a hardcoded literal schema name. + """ with ( cdm_extension_schema_claim(pg_db.committing_engine, prefix="relclass_routing") as ext_claim, scoped_test_schema( @@ -44,7 +44,11 @@ def test_relationship_classification_respects_the_configured_schema(pg_db, clean Base.metadata.create_all(bind=scoped.engine, checkfirst=True) seed_relationship_vocabulary(scoped.engine) - relationship_classification(resolved=scoped.resolved, schema_claims=[ext_claim]) + with so.Session(scoped.engine) as session: + relationship_ids = set(session.scalars(sa.select(Relationship.relationship_id)).all()) + _load_relationship_classification( + None, resolved=scoped.resolved, engine=scoped.engine, relationship_ids=relationship_ids + ) with scoped.engine.connect() as conn: n_class = conn.execute( @@ -66,24 +70,20 @@ def test_relationship_classification_respects_the_configured_schema(pg_db, clean assert enum_schema == extension_schema -def test_relationship_classification_is_idempotent(pg_db, cleanup_after_test): +def test_relationship_classification_is_idempotent(pg_db): """Re-running against the same schema, the real-world redeploy case the DROP TABLE/enum-drop cleanup exists for, must not fail.""" - reset_schema_registry_rows(cleanup_after_test, pg_db.committing_engine, schema_tags=[CDM_EXTENSION_SCHEMA]) - with ( - cdm_extension_schema_claim(pg_db.committing_engine, prefix="relclass_idempotent") as ext_claim, - scoped_test_schema( - pg_db.resolved, - prefix="relclass_idempotent", - schema_claims=[staging_schema_claim(), ext_claim], - register_claims=False, - ) as scoped, - ): + with scoped_test_schema( + pg_db.resolved, + prefix="relclass_idempotent", + schema_claims=[staging_schema_claim()], + register_claims=False, + ) as scoped: Base.metadata.create_all(bind=scoped.engine, checkfirst=True) seed_relationship_vocabulary(scoped.engine) - relationship_classification(resolved=scoped.resolved, schema_claims=[ext_claim]) - relationship_classification(resolved=scoped.resolved, schema_claims=[ext_claim]) + relationship_classification(resolved=scoped.resolved) + relationship_classification(resolved=scoped.resolved) with scoped.engine.connect() as conn: n_class = conn.execute( @@ -93,7 +93,7 @@ def test_relationship_classification_is_idempotent(pg_db, cleanup_after_test): def test_relationship_classification_warns_and_drops_unknown_ids_colocated( - pg_db, tmp_path, caplog, cleanup_after_test + pg_db, tmp_path, caplog ): (tmp_path / "predicate_classification.csv").write_text( "class,subclass,description,semantics,inference\n" @@ -105,23 +105,17 @@ def test_relationship_classification_warns_and_drops_unknown_ids_colocated( "Identity,mapping,unknown_rel,Unknown,,,\n" ) - reset_schema_registry_rows(cleanup_after_test, pg_db.committing_engine, schema_tags=[CDM_EXTENSION_SCHEMA]) - with ( - cdm_extension_schema_claim(pg_db.committing_engine, prefix="relclass_unknown") as ext_claim, - scoped_test_schema( - pg_db.resolved, - prefix="relclass_unknown", - schema_claims=[staging_schema_claim(), ext_claim], - register_claims=False, - ) as scoped, - ): + with scoped_test_schema( + pg_db.resolved, + prefix="relclass_unknown", + schema_claims=[staging_schema_claim()], + register_claims=False, + ) as scoped: Base.metadata.create_all(bind=scoped.engine, checkfirst=True) seed_relationship_vocabulary(scoped.engine) with caplog.at_level(logging.WARNING, logger="omop_graph.cli"): - relationship_classification( - str(tmp_path), resolved=scoped.resolved, schema_claims=[ext_claim] - ) + relationship_classification(str(tmp_path), resolved=scoped.resolved) with scoped.engine.connect() as connection: relationship_ids = set( diff --git a/tests/test_vocab_split_connection.py b/tests/test_vocab_split_connection.py index d7fc776..dc8f0f8 100644 --- a/tests/test_vocab_split_connection.py +++ b/tests/test_vocab_split_connection.py @@ -22,10 +22,10 @@ from oa_configurator import ResolvedCDMDatabase, Resolver, Role, SchemaClaim from oa_configurator.testing import ( isolated_test_database, - reset_schema_registry_rows, resolve_with_role_schemas, scoped_test_schema, ) +from orm_loader.backends import staging_schema_claim from orm_loader.config import OrmLoaderConfig from orm_loader.helpers import Base, bulk_load_context @@ -40,7 +40,7 @@ from omop_alchemy.config import CDM_EXTENSION_SCHEMA, CDM_EXTENSION_SCHEMA_OWNER from omop_alchemy.cross_database import cdm_sessionmaker -from omop_graph.cli import relationship_classification +from omop_graph.cli import _load_relationship_classification from omop_graph.config import OmopGraphConfig from omop_graph.extensions.omop_alchemy import ( PredicateKind, @@ -109,10 +109,6 @@ class _Engines(NamedTuple): primary: sa.Engine vocab: sa.Engine resolved: ResolvedCDMDatabase - cdm_extension_schema_claim: SchemaClaim - """Reusable claim for any later engine built from .resolved (e.g. the - staging engine in test_relationship_classification_succeeds_on_a_genuinely_split_vocab_connection): - schema_claims aren't inherited across separate create_engine() calls.""" @pytest.fixture() @@ -141,13 +137,11 @@ def split_engines() -> Iterator[_Engines]: split, prefix="vocab_split", resolver=resolver, schema_claims=[ext_claim], register_claims=False ) as scoped: primary_engine, vocab_engine = scoped.resolved.create_engines( - schema_claims=[ext_claim], register_claims=False + schema_claims=[staging_schema_claim(), ext_claim], register_claims=False ) - # Vocab tables are created through the vocab server's own resolved: - # create_all on the split vocab engine also emits the extension-tagged - # predicatekind type (metadata-wide, not scoped to tables=), whose - # schema only exists on the primary server -- co-locate it with - # vocab_schema here since nothing on this engine touches its content. + # create_all() on the vocab engine also emits the extension-tagged + # predicatekind type; co-locate it with vocab_schema since its own + # schema only exists on the primary server. vocab_schema = scoped.schemas[Role.VOCAB] vocab_only = resolve_with_role_schemas( vocab_db.resolved, {role: vocab_schema for role in vocab_db.resolved.schema_tags()} @@ -170,7 +164,6 @@ def split_engines() -> Iterator[_Engines]: primary=primary_engine, vocab=vocab_engine, resolved=scoped.resolved, - cdm_extension_schema_claim=ext_claim, ) finally: vocab_setup_engine.dispose() @@ -411,11 +404,11 @@ def test_relationships_invert_swaps_subjects_and_objects(split_engines: _Engines def test_relationship_classification_succeeds_on_a_genuinely_split_vocab_connection( - split_engines: _Engines, tmp_path, cleanup_after_test + split_engines: _Engines, tmp_path ) -> None: - """relationship_classification() creates RelationshipMapping without its - FK to the vocabulary. A relationship ID the vocabulary lacks is warned - about and dropped.""" + """The loading body creates RelationshipMapping without its FK to the + vocabulary when they're on separate connections. A relationship ID the + vocabulary lacks is warned about and dropped.""" (tmp_path / "predicate_classification.csv").write_text( "class,subclass,description,semantics,inference\n" "Identity,mapping,Identity mapping,identity,none\n" @@ -426,14 +419,11 @@ def test_relationship_classification_succeeds_on_a_genuinely_split_vocab_connect "Identity,mapping,unknown_rel,Unknown,,,\n" ) - # relationship_classification() below registers its own claim - # (register_claims=True, the default), so reset the shared registry row - # for CDM_EXTENSION_SCHEMA around it to avoid leaving it pointed at this - # test's own physical schema for whichever test runs next. - reset_schema_registry_rows(cleanup_after_test, split_engines.primary, schema_tags=[CDM_EXTENSION_SCHEMA]) - relationship_classification( - str(tmp_path), resolved=split_engines.resolved, - schema_claims=[split_engines.cdm_extension_schema_claim], + with so.Session(split_engines.vocab) as session: + relationship_ids = set(session.scalars(sa.select(Relationship.relationship_id)).all()) + _load_relationship_classification( + str(tmp_path), resolved=split_engines.resolved, engine=split_engines.primary, + relationship_ids=relationship_ids, ) with so.Session(split_engines.primary) as session: From 6b1e17a824c1f4783c19cf636f008e0c5a497eb0 Mon Sep 17 00:00:00 2001 From: Nico Loesch Date: Sat, 7 Nov 2026 19:32:52 +0000 Subject: [PATCH 6/6] Rename extension tables to auxiliary tables Avoids colliding with OHDSI's own Extension/Expansion mechanism (working-group-governed table sets like Oncology/Imaging/GIS); these are package-owned add-ons, not OHDSI-recognized extensions. Also threads physical_schemas through relationship_classification()'s DuckDB-affected calls: the auxiliary schema is a caller-claimed tag, not one of resolved's own configured Role tags, so a dialect that cannot cross schemas needs its physical name explicitly to classify the FK to vocab as feasible or deferred. Fixes the same gap in an ad hoc SQLite fixture's hardcoded schema_translate_map. Co-Authored-By: Claude Sonnet 5 --- src/omop_graph/cli.py | 33 ++++++++++++++++---- src/omop_graph/db/session.py | 6 ++-- src/omop_graph/extensions/omop_alchemy.py | 32 +++++++++---------- tests/fixtures/helpers.py | 12 +++---- tests/fixtures/mock_cdm.py | 10 +++--- tests/test_concept_queries.py | 6 ++-- tests/test_edges_cross_domain_regression.py | 7 ++++- tests/test_fulltext_vocab_schema_postgres.py | 10 +++--- tests/test_oaklib_schema_awareness.py | 2 +- tests/test_relationship_classification.py | 16 +++++----- tests/test_schema_provenance_guard.py | 16 +++++----- tests/test_vocab_split_connection.py | 26 +++++++-------- 12 files changed, 101 insertions(+), 75 deletions(-) diff --git a/src/omop_graph/cli.py b/src/omop_graph/cli.py index dff286e..11c0a88 100644 --- a/src/omop_graph/cli.py +++ b/src/omop_graph/cli.py @@ -9,7 +9,12 @@ import typer import sqlalchemy.orm as so -from oa_configurator import ResolvedCDMDatabase, open_connection +from oa_configurator import ( + ResolvedCDMDatabase, + claimed_schema_tags, + open_connection, + physical_schema_of, +) from orm_loader.backends import STAGING_SCHEMA, resolve_backend from orm_loader.helpers.metadata import Base @@ -20,7 +25,7 @@ from omop_graph.extensions.omop_alchemy import ( RelationshipClass, RelationshipMapping, - create_extension_tables, + create_auxiliary_tables, ) from omop_graph.cli_utils import populate_test_data @@ -134,7 +139,7 @@ def _load_relationship_classification( engine: sa.Engine, relationship_ids: set[str], ) -> None: - """Body of :func:`relationship_classification`, on *engine*, which hosts the extension tables.""" + """Body of :func:`relationship_classification`, on *engine*, which hosts the auxiliary tables.""" pred_class_dir_pl = ( Path(pred_class_dir) if pred_class_dir else packaged_predicate_csv_dir() ) @@ -206,7 +211,19 @@ def _load_relationship_classification( subset=["relationship_id", "predicate_kind", "predicate_subkind"] ) - loader_backend = resolve_backend(engine, staging_schema_tag=STAGING_SCHEMA) + # The auxiliary schema is a caller-claimed tag, not one of resolved's own + # configured Role tags, so a dialect that cannot cross schemas (DuckDB) + # needs its physical name explicitly to classify the FK to vocab as + # feasible or deferred. Same pattern as + # MaintenanceContext._foreign_keys_creatable()/create_tables(): derive it + # from every tag claimed on the engine, not just the one in play here. + physical_schemas = { + tag: physical_schema_of(engine, schema_tag=tag) for tag in claimed_schema_tags(engine) + } + + loader_backend = resolve_backend( + engine, staging_schema_tag=STAGING_SCHEMA, physical_schemas=physical_schemas + ) drop_staging_sql = ( sa.text( @@ -233,7 +250,7 @@ def _load_relationship_classification( ] with open_connection(engine) as connection: Base.metadata.drop_all(bind=connection, tables=tables_to_drop, checkfirst=True) # type: ignore - create_extension_tables(connection, resolved=resolved) + create_auxiliary_tables(connection, resolved=resolved) with tempfile.TemporaryDirectory() as tmp_dir, so.Session(engine) as session: for model, df in zip( @@ -253,12 +270,16 @@ def _load_relationship_classification( merge_strategy="replace", staging_schema_tag=STAGING_SCHEMA, resolved=resolved, + physical_schemas=physical_schemas, ) session.commit() for model in (RelationshipClass, RelationshipMapping): model.validate_foreign_keys( # ty: ignore[invalid-argument-type] - session, staging_schema_tag=STAGING_SCHEMA, resolved=resolved + session, + staging_schema_tag=STAGING_SCHEMA, + resolved=resolved, + physical_schemas=physical_schemas, ) diff --git a/src/omop_graph/db/session.py b/src/omop_graph/db/session.py index 2b3c578..6ed980e 100644 --- a/src/omop_graph/db/session.py +++ b/src/omop_graph/db/session.py @@ -8,7 +8,7 @@ import sqlalchemy.orm as so from sqlalchemy.engine import Engine from oa_configurator import ResolvedCDMDatabase, Resolver -from omop_alchemy.config import CDM_EXTENSION_SCHEMA_CLAIM +from omop_alchemy.config import CDM_AUXILIARY_SCHEMA_CLAIM from omop_alchemy.cross_database import CDMSession, cdm_sessionmaker from orm_loader.backends import staging_schema_claim from sqlalchemy.orm import sessionmaker @@ -73,11 +73,11 @@ def _create_cdm_sessions( ----- Claims the following additional schemas: - staging schema: for CSV bulk-loading of RelationshipClass/RelationshipMapping - - CDM_EXTENSION_SCHEMA: for the tables those two live in + - CDM_AUXILIARY_SCHEMA: for the tables those two live in """ primary, vocab = resolved.create_engines( - schema_claims=[staging_schema_claim(), CDM_EXTENSION_SCHEMA_CLAIM], + schema_claims=[staging_schema_claim(), CDM_AUXILIARY_SCHEMA_CLAIM], register_claims=register_claims, ) return cdm_sessionmaker(resolved, primary=primary, vocab=vocab), primary, vocab diff --git a/src/omop_graph/extensions/omop_alchemy.py b/src/omop_graph/extensions/omop_alchemy.py index 1f1f37c..4d4e934 100644 --- a/src/omop_graph/extensions/omop_alchemy.py +++ b/src/omop_graph/extensions/omop_alchemy.py @@ -4,11 +4,11 @@ from orm_loader.helpers import create_tables from omop_alchemy.cdm.base import ( ReferenceTable, - CDMExtensionTableBase, + CDMAuxiliaryTableBase, merge_table_args, role_fk, ) -from omop_alchemy.config import CDM_EXTENSION_SCHEMA +from omop_alchemy.config import CDM_AUXILIARY_SCHEMA from oa_configurator import ResolvedCDMDatabase, Role from enum import Enum @@ -45,17 +45,17 @@ def _predicate_kind_enum(*, schema: str) -> sa.Enum: ) -class RelationshipClass(ReferenceTable, CDMExtensionTableBase): +class RelationshipClass(ReferenceTable, CDMAuxiliaryTableBase): """ - Extensions table: Defines the semantic categories (Parent) + Auxiliary table: Defines the semantic categories (Parent) and entity types (Child). """ __tablename__ = "relationship_class" predicate_kind: so.Mapped[PredicateKind] = so.mapped_column( - # Must match CDMExtensionTableBase's own schema to ensure that + # Must match CDMAuxiliaryTableBase's own schema to ensure that # schema_translate_map routes the enum correctly. - _predicate_kind_enum(schema=CDM_EXTENSION_SCHEMA), primary_key=True + _predicate_kind_enum(schema=CDM_AUXILIARY_SCHEMA), primary_key=True ) predicate_subkind: so.Mapped[str] = so.mapped_column( sa.String(20), primary_key=True @@ -65,9 +65,9 @@ class RelationshipClass(ReferenceTable, CDMExtensionTableBase): inference: so.Mapped[str] = so.mapped_column(sa.String(40), nullable=False) -class RelationshipMapping(ReferenceTable, CDMExtensionTableBase): +class RelationshipMapping(ReferenceTable, CDMAuxiliaryTableBase): """ - Extensions table: Maps standard OMOP relationship_ids to + Auxiliary table: Maps standard OMOP relationship_ids to their parent (predicate_kind - one of PredicateKind) and more fine-grained subclasses . """ @@ -78,35 +78,35 @@ class RelationshipMapping(ReferenceTable, CDMExtensionTableBase): primary_key=True, ) predicate_kind: so.Mapped[PredicateKind] = so.mapped_column( - # Must match CDMExtensionTableBase's own schema to ensure that + # Must match CDMAuxiliaryTableBase's own schema to ensure that # schema_translate_map routes the enum correctly. - _predicate_kind_enum(schema=CDM_EXTENSION_SCHEMA), primary_key=True + _predicate_kind_enum(schema=CDM_AUXILIARY_SCHEMA), primary_key=True ) predicate_subkind: so.Mapped[str] = so.mapped_column( sa.String(20), primary_key=True ) # Qualified string to RelationshipClass as it lives in the same - # fixed extension schema (CDMExtensionTableBase's default). + # fixed auxiliary schema (CDMAuxiliaryTableBase's default). __table_args__ = merge_table_args( sa.ForeignKeyConstraint( ["predicate_kind", "predicate_subkind"], [ - f"{CDM_EXTENSION_SCHEMA}.relationship_class.predicate_kind", - f"{CDM_EXTENSION_SCHEMA}.relationship_class.predicate_subkind", + f"{CDM_AUXILIARY_SCHEMA}.relationship_class.predicate_kind", + f"{CDM_AUXILIARY_SCHEMA}.relationship_class.predicate_subkind", ], name="fk_rel_mapping_to_rel_class", ), - {"schema": CDM_EXTENSION_SCHEMA}, + {"schema": CDM_AUXILIARY_SCHEMA}, ) -def create_extension_tables( +def create_auxiliary_tables( connection: sa.Connection, *, resolved: ResolvedCDMDatabase, ) -> None: - """Create the extension tables as they can physically exist on *resolved*. + """Create the auxiliary tables as they can physically exist on *resolved*. Goes through ``orm_loader``'s ``create_tables``: on a split deployment the foreign key to the vocabulary is left out, since no dialect can express a diff --git a/tests/fixtures/helpers.py b/tests/fixtures/helpers.py index 600305e..b9a5175 100644 --- a/tests/fixtures/helpers.py +++ b/tests/fixtures/helpers.py @@ -14,7 +14,7 @@ Domain, Vocabulary ) -from omop_alchemy.config import CDM_EXTENSION_SCHEMA, CDM_EXTENSION_SCHEMA_OWNER +from omop_alchemy.config import CDM_AUXILIARY_SCHEMA, CDM_AUXILIARY_SCHEMA_OWNER VOCAB_TABLES = cast( "tuple[sa.Table, ...]", @@ -28,8 +28,8 @@ @contextmanager -def cdm_extension_schema_claim(engine: sa.Engine, *, prefix: str) -> Generator[SchemaClaim, None, None]: - """A SchemaClaim for a fresh, test-isolated physical extension schema. +def cdm_auxiliary_schema_claim(engine: sa.Engine, *, prefix: str) -> Generator[SchemaClaim, None, None]: + """A SchemaClaim for a fresh, test-isolated physical auxiliary schema. Notes ----- @@ -38,12 +38,12 @@ def cdm_extension_schema_claim(engine: sa.Engine, *, prefix: str) -> Generator[S RelationshipClass's predicate_kind) is created metadata-wide, not scoped to tables=, so it's attempted regardless of which tables the call actually asked for. scoped_test_schema() doesn't scope the - extension tag on its own (only Role tags), so this must be claimed + auxiliary tag on its own (only Role tags), so this must be claimed and physically created separately. """ with isolated_test_schema(engine, prefix=f"{prefix}_ext") as physical_schema: yield SchemaClaim( - schema_tag=CDM_EXTENSION_SCHEMA, + schema_tag=CDM_AUXILIARY_SCHEMA, physical_schema=physical_schema, - owner=CDM_EXTENSION_SCHEMA_OWNER, + owner=CDM_AUXILIARY_SCHEMA_OWNER, ) diff --git a/tests/fixtures/mock_cdm.py b/tests/fixtures/mock_cdm.py index 316d3ab..2e8e83b 100644 --- a/tests/fixtures/mock_cdm.py +++ b/tests/fixtures/mock_cdm.py @@ -19,7 +19,7 @@ from omop_alchemy.cdm.model.vocabulary.domain import Domain from omop_alchemy.cdm.model.vocabulary.relationship import Relationship from omop_alchemy.cdm.model.vocabulary.vocabulary import Vocabulary -from omop_alchemy.config import CDM_EXTENSION_SCHEMA_CLAIM +from omop_alchemy.config import CDM_AUXILIARY_SCHEMA_CLAIM from omop_graph.config import OmopGraphConfig from omop_graph.extensions.omop_alchemy import ( @@ -29,7 +29,7 @@ ) from omop_graph.graph.kg import KnowledgeGraph -from fixtures.helpers import cdm_extension_schema_claim +from fixtures.helpers import cdm_auxiliary_schema_claim PARENT_CANCER_ID = 443392 CONCEPT_META_ID = 0 @@ -39,9 +39,9 @@ @pytest.fixture(scope="module") def mock_cdm_engine() -> Iterator[sa.Engine]: with isolated_test_database(OmopGraphConfig, "test_cdm_db_pg") as db: - with cdm_extension_schema_claim(db.committing_engine, prefix="mock_cdm") as ext_claim: + with cdm_auxiliary_schema_claim(db.committing_engine, prefix="mock_cdm") as aux_claim: with scoped_test_schema( - db.resolved, prefix="mock_cdm", schema_claims=[ext_claim], register_claims=False + db.resolved, prefix="mock_cdm", schema_claims=[aux_claim], register_claims=False ) as scoped: _create_mock_cdm_tables(scoped.engine) yield scoped.engine @@ -51,7 +51,7 @@ def mock_cdm_engine() -> Iterator[sa.Engine]: def mock_cdm_engine_sqlite() -> Iterator[sa.Engine]: with isolated_test_database( OmopGraphConfig, "test_cdm_db_sqlite", dialect=Dialect.SQLITE, - schema_claims=[CDM_EXTENSION_SCHEMA_CLAIM], + schema_claims=[CDM_AUXILIARY_SCHEMA_CLAIM], ) as db: _create_mock_cdm_tables(db.committing_engine) yield db.committing_engine diff --git a/tests/test_concept_queries.py b/tests/test_concept_queries.py index 1c9dee1..ee9383a 100644 --- a/tests/test_concept_queries.py +++ b/tests/test_concept_queries.py @@ -24,7 +24,7 @@ q_entities, ) -from fixtures.helpers import VOCAB_TABLES, cdm_extension_schema_claim +from fixtures.helpers import VOCAB_TABLES, cdm_auxiliary_schema_claim _META_CONCEPT_ID = 0 @@ -32,9 +32,9 @@ @pytest.fixture() def concept_engine(pg_db) -> Iterator[sa.Engine]: with ( - cdm_extension_schema_claim(pg_db.committing_engine, prefix="concept_queries") as ext_claim, + cdm_auxiliary_schema_claim(pg_db.committing_engine, prefix="concept_queries") as aux_claim, scoped_test_schema( - pg_db.resolved, prefix="concept_queries", schema_claims=[ext_claim], register_claims=False + pg_db.resolved, prefix="concept_queries", schema_claims=[aux_claim], register_claims=False ) as scoped, ): engine = scoped.engine diff --git a/tests/test_edges_cross_domain_regression.py b/tests/test_edges_cross_domain_regression.py index 97c1435..551dcb9 100644 --- a/tests/test_edges_cross_domain_regression.py +++ b/tests/test_edges_cross_domain_regression.py @@ -22,6 +22,7 @@ from orm_loader.helpers import Base from omop_alchemy.cdm.model.vocabulary.concept import Concept from omop_alchemy.cdm.model.vocabulary.concept_relationship import Concept_Relationship +from omop_alchemy.config import CDM_AUXILIARY_SCHEMA_CLAIM from omop_graph.extensions.omop_alchemy import ( PredicateKind, @@ -54,7 +55,11 @@ @pytest.fixture(scope="module") def procedure_kg() -> Iterator[KnowledgeGraph]: engine = sa.create_engine("sqlite+pysqlite:///:memory:").execution_options( - schema_translate_map={"primary": None, "vocab": None} + schema_translate_map={ + "primary": None, + "vocab": None, + CDM_AUXILIARY_SCHEMA_CLAIM.schema_tag: None, + } ) tables = cast( list[sa.Table], diff --git a/tests/test_fulltext_vocab_schema_postgres.py b/tests/test_fulltext_vocab_schema_postgres.py index 9a62c84..59fe4fc 100644 --- a/tests/test_fulltext_vocab_schema_postgres.py +++ b/tests/test_fulltext_vocab_schema_postgres.py @@ -17,19 +17,19 @@ from omop_graph.graph.queries import q_concept_name_fulltext -from fixtures.helpers import VOCAB_TABLES, cdm_extension_schema_claim +from fixtures.helpers import VOCAB_TABLES, cdm_auxiliary_schema_claim pytestmark = [pytest.mark.postgresql, pytest.mark.db_dialect] def test_fulltext_query_finds_the_tsvector_column_in_the_vocab_schema(pg_db): with ( - cdm_extension_schema_claim(pg_db.committing_engine, prefix="fulltext_a") as ext_claim, + cdm_auxiliary_schema_claim(pg_db.committing_engine, prefix="fulltext_a") as aux_claim, scoped_test_schema( pg_db.resolved, prefix="fulltext", split_roles=[Role.VOCAB], - schema_claims=[ext_claim], + schema_claims=[aux_claim], register_claims=False, ) as scoped, ): @@ -60,12 +60,12 @@ def test_fulltext_query_finds_the_tsvector_column_in_the_vocab_schema(pg_db): def test_fulltext_query_still_raises_when_the_column_is_genuinely_absent(pg_db): with ( - cdm_extension_schema_claim(pg_db.committing_engine, prefix="fulltext_b") as ext_claim, + cdm_auxiliary_schema_claim(pg_db.committing_engine, prefix="fulltext_b") as aux_claim, scoped_test_schema( pg_db.resolved, prefix="fulltext", split_roles=[Role.VOCAB], - schema_claims=[ext_claim], + schema_claims=[aux_claim], register_claims=False, ) as scoped, ): diff --git a/tests/test_oaklib_schema_awareness.py b/tests/test_oaklib_schema_awareness.py index e275ed2..3cc0a1c 100644 --- a/tests/test_oaklib_schema_awareness.py +++ b/tests/test_oaklib_schema_awareness.py @@ -120,7 +120,7 @@ def fake_resolve_cdm_database(name): def _one_engine_for_both_roles(kg: KnowledgeGraph) -> bool: - """Do the graph's sessions send vocabulary and extension tables to one engine?""" + """Do the graph's sessions send vocabulary and auxiliary tables to one engine?""" with kg.session_factory() as session: return session.get_bind(Concept) is session.get_bind(RelationshipMapping) diff --git a/tests/test_relationship_classification.py b/tests/test_relationship_classification.py index 8f4601d..1882552 100644 --- a/tests/test_relationship_classification.py +++ b/tests/test_relationship_classification.py @@ -23,24 +23,24 @@ from omop_graph.extensions.omop_alchemy import RelationshipClass, RelationshipMapping from fixtures.mock_cdm import seed_relationship_vocabulary -from fixtures.helpers import cdm_extension_schema_claim +from fixtures.helpers import cdm_auxiliary_schema_claim def test_relationship_classification_respects_the_configured_schema(pg_db): """The loading body creates RelationshipClass/RelationshipMapping (and the - predicatekind enum) wherever its engine resolves CDM_EXTENSION_SCHEMA to, + predicatekind enum) wherever its engine resolves CDM_AUXILIARY_SCHEMA to, not a hardcoded literal schema name. """ with ( - cdm_extension_schema_claim(pg_db.committing_engine, prefix="relclass_routing") as ext_claim, + cdm_auxiliary_schema_claim(pg_db.committing_engine, prefix="relclass_routing") as aux_claim, scoped_test_schema( pg_db.resolved, prefix="relclass_routing", - schema_claims=[staging_schema_claim(), ext_claim], + schema_claims=[staging_schema_claim(), aux_claim], register_claims=False, ) as scoped, ): - extension_schema = ext_claim.physical_schema + auxiliary_schema = aux_claim.physical_schema Base.metadata.create_all(bind=scoped.engine, checkfirst=True) seed_relationship_vocabulary(scoped.engine) @@ -62,12 +62,12 @@ def test_relationship_classification_respects_the_configured_schema(pg_db): "SELECT n.nspname FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace " "WHERE t.typname = 'predicatekind' AND n.nspname = :schema" ), - {"schema": extension_schema}, + {"schema": auxiliary_schema}, ).scalar() assert n_class and n_class > 0 assert n_mapping and n_mapping > 0 - assert sa.inspect(scoped.engine).has_table("relationship_class", schema=extension_schema) - assert enum_schema == extension_schema + assert sa.inspect(scoped.engine).has_table("relationship_class", schema=auxiliary_schema) + assert enum_schema == auxiliary_schema def test_relationship_classification_is_idempotent(pg_db): diff --git a/tests/test_schema_provenance_guard.py b/tests/test_schema_provenance_guard.py index 00228c6..b759c00 100644 --- a/tests/test_schema_provenance_guard.py +++ b/tests/test_schema_provenance_guard.py @@ -26,7 +26,7 @@ from orm_loader.helpers import Base -from omop_alchemy.config import CDM_EXTENSION_SCHEMA, CDM_EXTENSION_SCHEMA_OWNER +from omop_alchemy.config import CDM_AUXILIARY_SCHEMA, CDM_AUXILIARY_SCHEMA_OWNER from omop_graph import cli as omop_graph_cli from fixtures.mock_cdm import seed_relationship_vocabulary @@ -40,22 +40,22 @@ def test_relationship_classification_guard_fires_on_reconfigured_schema( resolver = guarded_resolver(pg_db.resolved) resolved = resolver.resolve_database(pg_db.resolved.name) - # Literal CDM_EXTENSION_SCHEMA, not a test-isolated name: relationship_classification() + # Literal CDM_AUXILIARY_SCHEMA, not a test-isolated name: relationship_classification() # below runs its own resolved-only path, which resolves its engine via # create_cdm_engines() and claims the same literal name and owner # internally (matching production, like MAINTENANCE_SCHEMA) -- a # test-isolated name or owner here would silently diverge from what # that path actually uses. - extension_claim = SchemaClaim( - schema_tag=CDM_EXTENSION_SCHEMA, - physical_schema=CDM_EXTENSION_SCHEMA, + auxiliary_claim = SchemaClaim( + schema_tag=CDM_AUXILIARY_SCHEMA, + physical_schema=CDM_AUXILIARY_SCHEMA, reserved=True, - owner=CDM_EXTENSION_SCHEMA_OWNER, + owner=CDM_AUXILIARY_SCHEMA_OWNER, ) with scoped_test_schema( - resolved, prefix="graph_guard_a", resolver=resolver, schema_claims=[extension_claim] + resolved, prefix="graph_guard_a", resolver=resolver, schema_claims=[auxiliary_claim] ) as scoped_a: - ensure_schema(scoped_a.engine, CDM_EXTENSION_SCHEMA) + ensure_schema(scoped_a.engine, CDM_AUXILIARY_SCHEMA) Base.metadata.create_all(bind=scoped_a.engine, checkfirst=True) seed_relationship_vocabulary(scoped_a.engine) monkeypatch.setattr(omop_graph_cli, "resolve_cdm_database", lambda: scoped_a.resolved) diff --git a/tests/test_vocab_split_connection.py b/tests/test_vocab_split_connection.py index e2efb65..4c7d1a1 100644 --- a/tests/test_vocab_split_connection.py +++ b/tests/test_vocab_split_connection.py @@ -38,7 +38,7 @@ Relationship, Vocabulary, ) -from omop_alchemy.config import CDM_EXTENSION_SCHEMA, CDM_EXTENSION_SCHEMA_OWNER +from omop_alchemy.config import CDM_AUXILIARY_SCHEMA, CDM_AUXILIARY_SCHEMA_OWNER from omop_alchemy.cross_database import cdm_sessionmaker from omop_graph.cli import _load_relationship_classification @@ -50,7 +50,7 @@ ) from omop_graph.graph.kg import KnowledgeGraph -from fixtures.helpers import VOCAB_TABLES, cdm_extension_schema_claim +from fixtures.helpers import VOCAB_TABLES, cdm_auxiliary_schema_claim pytestmark = [pytest.mark.postgresql, pytest.mark.db_dialect] @@ -77,7 +77,7 @@ sa.Enum( PredicateKind, values_callable=lambda obj: [e.value for e in obj], - schema=CDM_EXTENSION_SCHEMA, + schema=CDM_AUXILIARY_SCHEMA, ), primary_key=True, ), @@ -86,7 +86,7 @@ sa.Column("semantics", sa.String(40), nullable=False), sa.Column("inference", sa.String(40), nullable=False), # Must match RelationshipClass's schema, or SQLAlchemy silently builds a second, unlinked Table object. - schema=CDM_EXTENSION_SCHEMA, + schema=CDM_AUXILIARY_SCHEMA, ) _shadow_relationship_mapping = sa.Table( "relationship_mapping", @@ -97,12 +97,12 @@ sa.Enum( PredicateKind, values_callable=lambda obj: [e.value for e in obj], - schema=CDM_EXTENSION_SCHEMA, + schema=CDM_AUXILIARY_SCHEMA, ), primary_key=True, ), sa.Column("predicate_subkind", sa.String(20), primary_key=True), - schema=CDM_EXTENSION_SCHEMA, + schema=CDM_AUXILIARY_SCHEMA, ) @@ -120,9 +120,9 @@ def split_engines() -> Iterator[_Engines]: with ( isolated_test_database(OmopGraphConfig, "test_cdm_db_pg") as primary_db, isolated_test_database(OrmLoaderConfig, "test_orm_db_pg") as vocab_db, - # RelationshipClass/RelationshipMapping live in the extension schema, + # RelationshipClass/RelationshipMapping live in the auxiliary schema, # on the same physical connection as primary. - cdm_extension_schema_claim(primary_db.committing_engine, prefix="vocab_split") as ext_claim, + cdm_auxiliary_schema_claim(primary_db.committing_engine, prefix="vocab_split") as aux_claim, ): name = primary_db.resolved.name resolver = Resolver.from_active_config() @@ -135,12 +135,12 @@ def split_engines() -> Iterator[_Engines]: ) split = resolver.resolve_database(name) with scoped_test_schema( - split, prefix="vocab_split", resolver=resolver, schema_claims=[ext_claim], register_claims=False + split, prefix="vocab_split", resolver=resolver, schema_claims=[aux_claim], register_claims=False ) as scoped: primary_engine, vocab_engine = scoped.resolved.create_engines( - schema_claims=[staging_schema_claim(), ext_claim], register_claims=False + schema_claims=[staging_schema_claim(), aux_claim], register_claims=False ) - # create_all() on the vocab engine also emits the extension-tagged + # create_all() on the vocab engine also emits the auxiliary-tagged # predicatekind type; co-locate it with vocab_schema since its own # schema only exists on the primary server. vocab_schema = scoped.schemas[Role.VOCAB] @@ -150,9 +150,9 @@ def split_engines() -> Iterator[_Engines]: vocab_setup_engine, _ = vocab_only.create_engines( schema_claims=[ SchemaClaim( - schema_tag=CDM_EXTENSION_SCHEMA, + schema_tag=CDM_AUXILIARY_SCHEMA, physical_schema=vocab_schema, - owner=CDM_EXTENSION_SCHEMA_OWNER, + owner=CDM_AUXILIARY_SCHEMA_OWNER, ) ], register_claims=False,