From 8fbc1cab38960fcbb8e8a45d916f4e2103f9e7d5 Mon Sep 17 00:00:00 2001 From: gkennos Date: Fri, 28 Aug 2026 11:16:49 +1000 Subject: [PATCH 01/30] generic event projection contracts --- .gitignore | 3 +- docs/advanced/timelines.md | 12 +- docs/toolkit/analytics.md | 116 +++++-- docs/toolkit/core.md | 64 ++-- docs/toolkit/episodes.md | 128 +++++--- docs/toolkit/index.md | 65 ++-- docs/toolkit/integrations.md | 11 +- docs/toolkit/query-contracts.md | 189 +++++++++++ mkdocs.yml | 9 +- omop_alchemy/cdm/base/cdm_constants.py | 2 + omop_alchemy/toolkit/core/__init__.py | 4 + .../toolkit/core/concepts/__init__.py | 2 + omop_alchemy/toolkit/core/concepts/runtime.py | 70 ++++ omop_alchemy/toolkit/core/events/__init__.py | 25 ++ omop_alchemy/toolkit/core/events/contracts.py | 87 +++++ .../toolkit/core/timeline/__init__.py | 7 +- .../toolkit/core/timeline/event_timeline.py | 57 +++- .../toolkit/episodes/derivation/__init__.py | 32 +- .../toolkit/episodes/derivation/contracts.py | 184 +++++++++++ tests/fixtures/__init__.py | 1 + tests/fixtures/query_contract_cases.py | 172 ++++++++++ tests/test_query_builder_contracts.py | 306 ++++++++++++++++++ 22 files changed, 1385 insertions(+), 161 deletions(-) create mode 100644 docs/toolkit/query-contracts.md create mode 100644 omop_alchemy/toolkit/core/concepts/runtime.py create mode 100644 omop_alchemy/toolkit/core/events/__init__.py create mode 100644 omop_alchemy/toolkit/core/events/contracts.py create mode 100644 omop_alchemy/toolkit/episodes/derivation/contracts.py create mode 100644 tests/fixtures/__init__.py create mode 100644 tests/fixtures/query_contract_cases.py create mode 100644 tests/test_query_builder_contracts.py diff --git a/.gitignore b/.gitignore index 3e87184..e6640e9 100644 --- a/.gitignore +++ b/.gitignore @@ -74,4 +74,5 @@ notebooks/ .dockerignore docker/ tests/test_meds* -site/ \ No newline at end of file +site/ +_design/ \ No newline at end of file diff --git a/docs/advanced/timelines.md b/docs/advanced/timelines.md index c18a16d..5267fef 100644 --- a/docs/advanced/timelines.md +++ b/docs/advanced/timelines.md @@ -39,9 +39,11 @@ instance at class level. ## The `ClinicalEvent` mixin -`ClinicalEvent` is a mixin that adds timeline behaviour to any CDM ORM class. It reads -`_mapping` to implement `event_time`, `event_value`, `event_metadata`, `to_dict`, and -`to_json`. +`ClinicalEvent` is a mixin that adds timeline behaviour to any CDM ORM class. It implements +the shared `toolkit.core.events.ClinicalEventRow` identity and projection fields, then reads +`_mapping` to add `event_time`, `event_value`, `event_metadata`, `to_dict`, and `to_json`. +The shared core contract keeps timeline events and future SQL event projections aligned +without making `core.timeline` import the higher-level episode package. ::: omop_alchemy.toolkit.core.timeline.event_timeline.ClinicalEvent @@ -97,10 +99,14 @@ class and set `_mapping`: ```python from omop_alchemy.toolkit.core.timeline.event_timeline import ClinicalEvent, EventMapping +from omop_alchemy.cdm.base import ModifierFieldConcepts from omop_alchemy.cdm.model.clinical import Procedure_Occurrence class Procedure_Event(Procedure_Occurrence, ClinicalEvent): _mapping = EventMapping( + event_id_field="procedure_occurrence_id", + event_field_concept_id=ModifierFieldConcepts.PROCEDURE_OCCURRENCE, + event_source_table="procedure_occurrence", concept_field="procedure_concept_id", start_date_field="procedure_date", start_datetime_field="procedure_datetime", diff --git a/docs/toolkit/analytics.md b/docs/toolkit/analytics.md index 9e0da51..ff8d1dc 100644 --- a/docs/toolkit/analytics.md +++ b/docs/toolkit/analytics.md @@ -1,27 +1,55 @@ -# analytics +# Clinical analytics -Clinical-domain logic lives beside the concept sets and policies that give it meaning. -The domain-neutral `core` and `episodes` packages provide the underlying resolution and -traversal mechanisms. +Analytics packages combine domain-neutral retrieval with governed concept sets and clinical interpretation. This is where a procedure becomes evidence of radiotherapy, a series of measurements becomes a weight trajectory, or percentage weight loss becomes a severity grade. -## oncology +## Oncology -| API | Capability | -|---|---| -| `OncologyEpisode` | Classifies episode purpose and modality; traverses events linked to the episode and its direct children. | -| `structural_modalities` / `concept_modalities` | Preserve every evidenced modality so mixed treatment and SACT classification disagreements remain visible. | -| `structural_modality` / `concept_modality` | Select one deterministic modality in radiotherapy, surgery, diagnostic/staging, SACT priority order. | -| `OncologyProcedure` / `OncologyDrugExposure` | Add governed `is_radiotherapy`, `is_surgery`, `is_diagnostic_staging`, and `is_sact` questions to CDM facts. | -| `RTDoseSummary.from_procedures(...)` | Constructs one radiotherapy summary; `summarize_rt_procedures_by(...)` groups before construction. | -| `SACTDoseSummary.from_exposures(...)` | Constructs one SACT summary; `summarize_sact_exposures_by(...)` groups before construction. | -| `OncologyEpisodeEvent` | Resolves oncology-aware facts while retaining episode-event diagnostics. | +`OncologyEpisode` is the main entry point for an episode-centred oncology analysis. Keep the object attached to its SQLAlchemy session while accessing properties that traverse related events or resolve vocabulary-backed concept groups: -Governed membership has two access modes: +```python +from sqlalchemy.orm import Session -| Access form | Behaviour | -|---|---| -| Loaded instance property | Expands once per vocabulary identity, then uses cached O(1) membership. Initial classification requires a live session; a cached result remains the snapshot computed while the instance was attached. | -| Class-level hybrid expression | Emits a database subquery for each query and does not use the Python expansion cache. | +from omop_alchemy.toolkit.analytics.oncology import OncologyEpisode + +with Session(engine) as session: + episode = session.get(OncologyEpisode, episode_id) + if episode is None: + raise LookupError(f"Unknown episode: {episode_id}") + + treatment_episodes = episode.child_treatment_episodes + modalities = episode.structural_modalities + sact = episode.sact_dose_summaries_by_drug_concept + radiotherapy = episode.rt_dose_summaries_by_site +``` + +The episode includes events linked directly to it and events linked to its direct children. This supports a regimen whose drug exposures or procedures are recorded against cycle-level child episodes without flattening the episode hierarchy itself. + +### Modality evidence + +An episode can contain evidence for more than one treatment modality. `structural_modalities` and `concept_modalities` therefore return sets rather than forcing the record into a single label: + +- structural evidence treats any linked drug exposure as SACT evidence and uses governed concepts for radiotherapy, surgery, and diagnostic or staging procedures; +- concept evidence requires drug exposures to belong to the governed SACT concept set as well. + +Comparing the two sets makes source-structure and terminology disagreements visible: + +```python +structural = episode.structural_modalities +governed = episode.concept_modalities + +if structural != governed: + review_episode_modality(episode.episode_id, structural, governed) +``` + +When a caller needs one value, `structural_modality` and `concept_modality` apply a deterministic order: radiotherapy, surgery, diagnostic or staging, then SACT. This is a stable tie-break for mixed evidence, not a statement of clinical importance. Use the plural properties when mixed treatment matters to the analysis. + +### Treatment summaries + +`sact_exposures` contains linked exposures whose concepts belong to the governed SACT set. `sact_dose_summaries_by_drug_concept` groups them by drug concept; `sact_dose_summary` provides an all-SACT summary. The summary keeps source units and carries a `DoseEvaluability` result. Mixed units or missing quantities remain visible instead of being presented as a valid combined dose. + +`rt_procedures` applies the governed radiotherapy procedure set. Site-grouped and whole-episode summaries expose dates, procedure and modifier concepts, counts, quantities, and dose evaluability. OMOP Procedure Occurrence does not provide a universal radiotherapy dose model, so these summaries preserve the available evidence for a site-specific policy rather than inferring one. + +`OncologyProcedure` and `OncologyDrugExposure` expose the same governed classifications on individual facts. `OncologyEpisodeEvent` retains resolution diagnostics when a linked event cannot be loaded. ::: omop_alchemy.toolkit.analytics.oncology.OncologyEpisode options: @@ -60,15 +88,26 @@ Governed membership has two access modes: members: - from_exposures -## body_metrics +## Body metrics + +`WeightTrajectoryMixin` turns an episode's weight measurements and the person's height measurements into a normalised longitudinal view. Weight is converted to kilograms, height to centimetres, and measurements with missing or unrecognised units are excluded from calculations. + +An episode that includes the mixin can produce a compact, tabular summary: + +```python +summary = episode.weight_trajectory_summary() + +print(summary["baseline_weight_kg"]) +print(summary["latest_weight_kg"]) +print(summary["pct_change_from_baseline"]) +print(summary["pct_change_from_baseline_evaluable"]) +``` -| API | Capability | -|---|---| -| `MeasurementReading.from_measurement(...)` | Reduces an OMOP measurement to the fields used by calculations and records its resolution source. | -| `MeasurementSeriesMixin` | Resolves normalized measurement series for an episode. | -| `WeightTrajectoryMixin` | Exposes normalized weight and height, BMI, BSA, windowed change, trajectories, and a dict-shaped typed summary. | -| `WeightChange` | Represents percentage change and whether it was evaluable; unevaluable change has `pct_change=None`. | -| `WeightTrajectorySummary` | Types the DataFrame- and JSON-friendly mapping returned by `weight_trajectory_summary()`. | +The baseline is the first normalised weight in the resolved episode series and the latest weight is the last. Percentage change is negative for weight loss. A result separates its value from evaluability so that missing evidence is not confused with zero change. + +`pct_change_over(days)` compares the latest reading with the earliest reading inside the requested look-back period. `pct_change_trajectory()` returns every normalised point relative to baseline. `sustained_loss()` asks whether the final consecutive readings all meet a configurable loss threshold. These are deliberately distinct questions; choose the one that matches the analysis rather than treating them as interchangeable summaries of weight loss. + +Body-metric defaults resolve governed measurement and unit concepts. A deployment that uses local concepts can supply its own `BodyMetricRules` on the episode class. ::: omop_alchemy.toolkit.analytics.body_metrics.MeasurementReading options: @@ -91,12 +130,23 @@ Governed membership has two access modes: - sustained_loss - weight_trajectory_summary -## adverse_events +## Adverse events + +The adverse-event functions apply grading policy to an already calculated percentage change and, where available, BMI: + +```python +from omop_alchemy.toolkit.analytics.adverse_events import ( + critical_weight_loss_grade, +) + +grade = critical_weight_loss_grade( + pct_change=-8.2, + bmi=21.4, +) +``` + +`martin_weight_loss_grade()` applies the BMI-adjusted Martin et al. matrix. `ctcae_weight_loss_grade()` applies the CTCAE v5.0 physiological percentage-loss thresholds but does not infer intervention qualifiers such as hospitalisation, tube feeding, or parenteral nutrition. `critical_weight_loss_grade()` uses the Martin matrix when both percentage change and BMI are available and otherwise falls back to the percentage-only CTCAE-style grade. -| API | Policy | -|---|---| -| `ctcae_weight_loss_grade(...)` | Grades percentage weight loss against CTCAE-style bins. | -| `martin_weight_loss_grade(...)` | Applies the Martin et al. BMI-adjusted matrix. | -| `critical_weight_loss_grade(...)` | Uses the Martin matrix when BMI is available and otherwise falls back to CTCAE-style bins. | +All three return `None` when the inputs needed by that policy are unavailable. They do not retrieve measurements or choose a baseline; those decisions remain in the body-metric layer. ::: omop_alchemy.toolkit.analytics.adverse_events diff --git a/docs/toolkit/core.md b/docs/toolkit/core.md index 7df27de..9029d6a 100644 --- a/docs/toolkit/core.md +++ b/docs/toolkit/core.md @@ -1,14 +1,10 @@ -# core +# Core services -Foundational services with no clinical-domain assumptions. A concept resolver behaves -the same whether it is mapping tumour morphology or procedures; a patient timeline is -the same object whatever populates it. Domain-specific concept sets, thresholds, and -grading rules belong in [`analytics`](analytics.md), not here. +The core package handles problems that have the same meaning in every clinical domain: resolving a source term to an OMOP concept, identifying an event across CDM tables, arranging events on a timeline, and converting measurements to comparable units. -## Concept resolution +## Resolve source data to concepts -Turns a declarative description of *which* concepts belong in a lookup into a runtime -resolver that maps free text and source codes to OMOP concept IDs. +Suppose an intake system supplies the text `Adenocarcinoma of lung` rather than an OMOP concept ID. A resolver limits the eligible vocabulary rows and applies the same text normalisation when it builds its lookup and when it handles an incoming value: ```python from omop_alchemy.toolkit.core.concepts import make_concept_resolver @@ -18,23 +14,55 @@ resolver = make_concept_resolver( name="condition lookup", domain_id="Condition", ) + concept_id = resolver.lookup("Adenocarcinoma of lung") ``` +Creating the resolver reads the vocabulary tables, so create it once for a mapping workflow and reuse it. `LookupSpec`, `LookupIndex`, and `ConceptResolver` expose the individual stages when you need to control indexed fields, normalisation, or resolver lifetime. `ConceptResolverRegistry` provides lazy construction and caching when an application maintains several lookups. + +Concept groups answer the complementary question: whether a known concept belongs to a governed set. A resolved group supports both in-memory membership and a SQLAlchemy expression derived from the same specification, so filtering loaded objects and filtering in SQL do not require separate definitions. + +Configuration-driven concept sets use `RuntimeConceptSetSpec`. It records exact and ancestral inclusions and exclusions without touching the database; see [Runtime concept sets](query-contracts.md#runtime-concept-sets) for the set semantics and current execution boundary. + ::: omop_alchemy.toolkit.core.concepts -## Patient timelines +## Identify events across CDM tables + +A Measurement and a Procedure Occurrence may have the same numeric primary key. Code that combines tables must therefore carry the source table as part of event identity: + +```python +from omop_alchemy.toolkit.core.events import ClinicalEventIdentity + +measurement = ClinicalEventIdentity("measurement", 7) +procedure = ClinicalEventIdentity("procedure_occurrence", 7) + +assert measurement != procedure +``` + +`ClinicalEventColumn` defines the common labels used when heterogeneous event tables are projected into one result. The required shape includes the person, table-scoped event identity, event date and datetime, clinical concept, and OMOP Field concept that identifies the source ID column. Optional labels cover numeric values, value concepts, and units. + +These contracts describe result shape and identity; they do not execute a query. The [query contracts](query-contracts.md) explain how this shape participates in episode attachment. + +## Work with a patient timeline + +The timeline adapter presents conditions, measurements, and drug exposures as a single ordered sequence while retaining each row's source identity and value semantics. Use it when an application needs to display or serialise a patient's chronology rather than build a set-based analytical query. + +The timeline has a dedicated guide with session requirements, event mappings, and extension points: [Patient timelines](../advanced/timelines.md). -Projects a person's clinical rows — conditions, measurements, drug exposures — into a -single time-ordered event stream. This has its own dedicated page, since it predates the -rest of the toolkit reorg: see [Patient Timelines](../advanced/timelines.md). +## Convert body measurements + +Body-size calculations require weights and heights to use consistent units. The default conversion rules use the unit concept recorded on each measurement: + +```python +from omop_alchemy.toolkit.core.units import default_body_unit_conversion_rules + +rules = default_body_unit_conversion_rules() +weight_kg = rules.normalize_weight_kg(180.0, rules.units.lb) +height_cm = rules.normalize_height_cm(70.0, rules.units.inch) +``` -## Unit conversion +An unknown unit, a missing unit, or a missing value produces `None`; values are never passed through as though they were already normalised. Deployments with local unit concepts can construct `BodyUnitConversionRules` with their own `BodySizeUnitConcepts` mapping. -Converts measurement values to canonical units. Kilograms, pounds, centimetres, and -inches mean the same thing in every clinical domain, so the conversion rules live here -rather than with any one domain's measurement logic — see -[`analytics.body_metrics`](analytics.md#body_metrics) for where those domain-specific -measurements are resolved and normalised using these rules. +Clinical choices built on those conversions, including which measurements constitute baseline weight and how change is graded, belong to [`analytics.body_metrics`](analytics.md#body-metrics) and [`analytics.adverse_events`](analytics.md#adverse-events). ::: omop_alchemy.toolkit.core.units diff --git a/docs/toolkit/episodes.md b/docs/toolkit/episodes.md index ef287af..82cb381 100644 --- a/docs/toolkit/episodes.md +++ b/docs/toolkit/episodes.md @@ -1,72 +1,106 @@ -# episodes +# Episodes -Domain-neutral machinery for building episodes and retrieving what belongs to them. A -drug episode behaves the same whether the drug is a cytotoxic agent or an antibiotic, so -everything here takes concept filters and grouping keys as parameters rather than -assuming a clinical specialty. Domain-specific episode classes — for example -`OncologyEpisode` — compose these pieces with their own concept sets and live in -[`analytics`](analytics.md). +Episode APIs answer two related questions: how episodes relate to one another, and which clinical facts belong to an episode. They do not assume a specialty. Oncology-specific episode types compose these APIs with governed oncology concepts in the [analytics package](analytics.md#oncology). -## derivation +## Retrieve facts from an episode -How episodes are constructed and related to one another — building episode queries and -resolving parent/child hierarchy, written against the raw `Episode`/`Episode_Event` -tables rather than any materialised view. +For a treatment episode, a common first task is to retrieve its linked drug exposures and group them by drug concept: -Not yet populated. The equivalent built against materialised-view subclasses lives in -`omop-constructs`. - -## handling +```python +from omop_alchemy.cdm.model.structural import EpisodeView +from omop_alchemy.toolkit.episodes.handling import DrugEpisodeMixin -What is inside an episode once it exists. -**Linked drug exposures.** `DrugEpisodeMixin` adds retrieval and grouped summaries to -any episode view: +class TreatmentEpisode(DrugEpisodeMixin, EpisodeView): + _drug_concept_ids = treatment_drug_concept_ids -```python -from omop_alchemy.toolkit.episodes.handling import DrugEpisodeMixin -class MyEpisode(DrugEpisodeMixin, EpisodeView): - _drug_concept_ids = my_concept_ids +episode = session.get(TreatmentEpisode, episode_id) +if episode is None: + raise LookupError(f"Unknown episode: {episode_id}") -episode.drug_exposures # resolved Drug_Exposure rows -episode.drug_exposure_summaries_by() # grouped by drug concept by default +exposures = episode.drug_exposures +summaries = episode.drug_exposure_summaries_by() ``` -Construct a summary for an already selected set of rows through the summary -type itself: +`drug_exposures` uses explicit `Episode_Event` links by default. `_drug_concept_ids` limits the rows to the concepts meaningful for this episode type, and `drug_exposure_summaries_by()` groups the selected rows by `drug_concept_id`. Pass a key function when another grouping, such as ingredient or regimen member, is more useful. -```python -from omop_alchemy.toolkit.episodes.handling import DrugExposureSummary +If rows have already been selected elsewhere, construct or group summaries directly: -summary = DrugExposureSummary.from_exposures(exposures, group_key="regimen-a") +```python +from omop_alchemy.toolkit.episodes.handling import ( + DrugExposureSummary, + summarize_drug_exposures_by, +) + +regimen_summary = DrugExposureSummary.from_exposures( + regimen_exposures, + group_key="regimen-a", +) + +by_drug = summarize_drug_exposures_by( + regimen_exposures, + key=lambda exposure: exposure.drug_concept_id, +) ``` -Dose quantities are frequently not comparable across agents, because source units and -quantities arrive unnormalised. `DoseEvaluability` carries that judgement alongside the -number, so a summary that cannot be interpreted as a dose says so rather than presenting -a misleading total. +The generic summary reports counts, dates, source units, concepts, and a raw quantity total. A total is only clinically comparable when the source quantities have compatible meaning and units. Domain-specific summaries can attach `DoseEvaluability` to make that judgement explicit, as the oncology SACT and radiotherapy summaries do. + +## Explicit links and date windows + +An `Episode_Event` row is the strongest statement that a fact belongs to an episode, so linked facts are always retained. Some datasets do not populate these links consistently. A caller can opt into bounded date-window retrieval for drug exposures by setting `_include_window_drug_exposures = True` on its mixin class. + +Window retrieval must be paired with a meaningful concept filter. Without one, every same-person drug exposure inside the dates is eligible. The generic helper deliberately defaults to explicit links only. + +`episode_attachment_window()` provides a related bounded window for episode-attributable facts. Its lower bound is a configurable number of days before the episode start. Its upper bound is the recorded episode end, or a finite fallback after the start when the episode is open-ended. The finite fallback prevents an incomplete episode from absorbing the rest of a person's record. -**Explicit links versus admitted-by-window.** Facts linked through `Episode_Event` are -always honoured. `episode_attachment_window` computes the bounded, date-based fallback -window used when a caller opts in to admitting same-person facts that weren't explicitly -linked. +## Understand unresolved episode links -**Resolution diagnostics.** `Episode_EventView.resolved_event` already resolves an -`Episode_Event` link best-effort, returning `None` on failure. `ResolvedEpisodeEvent` -extends it to explain *why* — a miscoded field concept, a target class not yet -registered, or a genuinely dangling reference: +`Episode_EventView.resolved_event` returns the linked ORM row when the field concept and target row can be resolved, otherwise `None`. `ResolvedEpisodeEvent` preserves that behaviour and adds a diagnostic that distinguishes three cases: + +- the field concept is not a recognised `ModifierFieldConcepts` value; +- the field concept is recognised but no ORM target class is registered for it; or +- the target row does not exist. ```python from omop_alchemy.toolkit.episodes.handling import ResolvedEpisodeEvent -ee = session.get(ResolvedEpisodeEvent, (episode_id, event_id, field_concept_id)) -ee.resolved_event # the resolved row, or None -ee.event_resolution_diagnostics # [] if resolved cleanly, otherwise why not +link = session.get( + ResolvedEpisodeEvent, + (episode_id, event_id, field_concept_id), +) + +if link is not None and link.resolved_event is None: + for diagnostic in link.event_resolution_diagnostics: + logger.warning("%s: %s", diagnostic.kind, diagnostic.message) ``` -Mix `ResolvedEpisodeEventMixin` into an episode view to reach diagnostics through -ordinary `episode.episode_events` traversal instead of a direct query — this is how -`OncologyEpisode` gets diagnostics on oncology-aware event resolution for free. +Use `ResolvedEpisodeEventMixin` on an episode view when diagnostics should be available through `episode.episode_events` rather than through a separate query. ::: omop_alchemy.toolkit.episodes.handling + +## Describe episode attachment policy + +The derivation package provides declarative types for code that assigns events to episodes. The types keep four choices visible: whether explicit links take precedence, whether fallback may return one or several episodes, which side of an anchor date is preferred, and how candidates within that preference are ranked. + +For example, the following policy honours a valid explicit link and otherwise chooses one episode. Episodes that had started by the event date are considered before future episodes, and the nearest start date wins within that group: + +```python +from omop_alchemy.toolkit.episodes.derivation import ( + EpisodeAttachmentPolicy, + TemporalRankingSpec, + TemporalSelectionPolicy, + TemporalSidePreference, +) + +attachment = EpisodeAttachmentPolicy.explicit_first_ranked +ranking = TemporalRankingSpec( + policy=TemporalSelectionPolicy.nearest, + stable_id_column="episode_id", + side_preference=TemporalSidePreference.on_or_before_anchor, +) +``` + +These objects state query semantics but do not build or execute SQL. See [Query contracts](query-contracts.md) for the complete event shape, attachment examples, boundaries, repeated-observation selection, and the distinction between absolute-nearest and already-started-first ranking. + +::: omop_alchemy.toolkit.episodes.derivation diff --git a/docs/toolkit/index.md b/docs/toolkit/index.md index 145568c..2d17029 100644 --- a/docs/toolkit/index.md +++ b/docs/toolkit/index.md @@ -1,55 +1,48 @@ # Toolkit -`omop_alchemy.cdm` gives you the OMOP CDM schema as SQLAlchemy models. The toolkit is -what you build with them: vocabulary resolution, patient timelines, episode traversal, -domain analytics, and outbound export. - -!!! warning "Experimental" - The toolkit is newer and less battle-tested than the CDM models it sits on top of. - Module paths below an area (anything past `toolkit..`) may still move; - the area itself is the stable import surface. The CDM models themselves are not - affected by anything here. - -## Four tiers - -Each tier may depend only on the tiers before it in this list — `core` knows nothing of -episodes or clinical domains, and nothing depends on `integrations`. - -| Tier | Answers | Assumes a clinical domain? | -|---|---|---| -| [`core`](core.md) | Resolve concepts, build a patient timeline, convert units | No | -| [`episodes`](episodes.md) | Build episodes, retrieve what belongs to one | No | -| [`analytics`](analytics.md) | What does this value mean clinically? | Yes, one subpackage per domain | -| [`integrations`](integrations.md) | Export to an external data standard | No | - -## A worked example - -Querying an oncology episode pulls together all four tiers without you having to think -about the seams between them: concept resolution and timelines from `core`, drug -retrieval from `episodes`, oncology classification from `analytics`. +The toolkit turns OMOP rows into objects that answer clinical questions. For example, given the ID of an oncology episode, you can inspect its treatment modality, linked drug exposures, radiotherapy dose, and weight-loss assessment while the episode remains attached to a SQLAlchemy session: ```python from sqlalchemy.orm import Session + from omop_alchemy.toolkit.analytics.oncology import OncologyEpisode with Session(engine) as session: episode = session.get(OncologyEpisode, episode_id) + if episode is None: + raise LookupError(f"Unknown episode: {episode_id}") - episode.structural_modality # OncologyModality.SACT, .RADIOTHERAPY, ... - episode.drug_exposures # linked Drug_Exposure rows - episode.rt_dose_summary # RTDoseSummary, if any RT was given - episode.critical_weight_loss_grade # graded against Martin/CTCAE criteria + modalities = episode.structural_modalities + drug_exposures = episode.sact_exposures + radiotherapy = episode.rt_dose_summary + weight_loss = episode.critical_weight_loss_summary() ``` -## Import surface +This is still ordinary SQLAlchemy. `OncologyEpisode` is mapped to the OMOP episode view, and properties may load related rows or resolve governed vocabulary sets through the active session. The toolkit adds interpretation and reusable retrieval rules; it does not replace the CDM models or hide when database access is required. -Import from the area subpackage — `omop_alchemy.toolkit..` — not from a -specific module beneath it: +## Where to begin + +Choose the part of the toolkit that matches the question you are asking: + +| If you need to… | Start with | +|---|---| +| Resolve incoming text or source codes to OMOP concepts, compare measurements in common units, or represent events from several CDM tables consistently | [`core`](core.md) | +| Traverse episode relationships, retrieve episode-linked facts, or state how an event should be attached to an episode | [`episodes`](episodes.md) | +| Apply a clinical interpretation such as oncology modality, dose summarisation, body-metric analysis, or weight-loss grading | [`analytics`](analytics.md) | +| Check the availability and expectations of outbound data-standard integrations | [`integrations`](integrations.md) | + +The dependency direction follows the same order. `episodes` can use `core`; `analytics` can use both; `integrations` can use the whole toolkit. Lower layers never import a clinical specialty or an export format. This keeps general concepts such as event identity and unit conversion independent of the analyses that use them. + +## Public imports + +Import from the documented area package rather than from a file beneath it: ```python from omop_alchemy.toolkit.core.concepts import make_concept_resolver from omop_alchemy.toolkit.analytics.oncology import OncologyEpisode ``` -Each area's `__init__.py` re-exports its public names, and that's the part of the path -that stays stable. Files beneath it are free to move. +The area packages re-export their public API. Module names below those packages are implementation details and may change without providing a compatibility import. + +!!! warning "Toolkit stability" + Toolkit area packages are less stable than `omop_alchemy.cdm`. Treat the documented area import paths as the compatibility boundary, pin the package version in deployed applications, and review release notes before upgrading. Changes in the toolkit do not alter the CDM model API. diff --git a/docs/toolkit/integrations.md b/docs/toolkit/integrations.md index 84605d2..87418f8 100644 --- a/docs/toolkit/integrations.md +++ b/docs/toolkit/integrations.md @@ -1,11 +1,6 @@ -# integrations - -Export to external data standards. Integrations sit at the outer edge of the toolkit — -one may use anything in `core`, `episodes`, or `analytics`, and nothing in those tiers -depends on an integration, so adding or changing an export format cannot affect the -clinical logic beneath it. Each integration brings its own heavyweight dependencies and -is gated behind an optional extra, so installing omop-alchemy does not pull in formats -you are not exporting to. +# Integrations + +Export to external data standards. Integrations sit at the outer edge of the toolkit — one may use anything in `core`, `episodes`, or `analytics`, and nothing in those tiers depends on an integration, so adding or changing an export format cannot affect the clinical logic beneath it. Each integration brings its own heavyweight dependencies and is gated behind an optional extra, so installing omop-alchemy does not pull in formats you are not exporting to. ## meds_standard diff --git a/docs/toolkit/query-contracts.md b/docs/toolkit/query-contracts.md new file mode 100644 index 0000000..2699e84 --- /dev/null +++ b/docs/toolkit/query-contracts.md @@ -0,0 +1,189 @@ +# Query contracts + +Consider a procedure recorded on the same day as two overlapping treatment episodes. The procedure may already have a valid `Episode_Event` link, or it may need to be assigned from dates alone. A reliable query has to answer several questions explicitly: what identifies the procedure, whether an explicit link takes precedence, whether fallback may attach it to one or both episodes, and how equally plausible candidates are ordered. + +The contracts on this page provide a common vocabulary for those decisions. They are small, immutable values that can be shared by query-building code, configuration, and tests without opening a database connection. + +!!! note "Declarative API" + These contracts describe result shape and selection policy. They do not currently construct or execute SQL. Code that consumes them remains responsible for applying the declared policy to a query. + +## Start with event identity + +OMOP primary keys are scoped to their source tables. These three rows represent three different events even though they all use event ID 7: + +| Source table | Event ID | Person | Date | +|---|---:|---:|---| +| Measurement | 7 | 101 | 20 January 2026 | +| Procedure Occurrence | 7 | 101 | 20 January 2026 | +| Observation | 7 | 202 | 20 January 2026 | + +`ClinicalEventIdentity` keeps the table and numeric ID together: + +```python +from omop_alchemy.toolkit.core.events import ClinicalEventIdentity + +measurement = ClinicalEventIdentity("measurement", 7) +procedure = ClinicalEventIdentity("procedure_occurrence", 7) +observation = ClinicalEventIdentity("observation", 7) + +assert len({measurement, procedure, observation}) == 3 +``` + +A cross-table projection needs more than an identity. `CANONICAL_EVENT_REQUIRED_COLUMNS` defines the labels a consumer can rely on: + +| Column | Meaning | +|---|---| +| `person_id` | Person who owns the source event | +| `event_id` | Primary key in the source table | +| `event_source_table` | Table that scopes `event_id` | +| `event_field_concept_id` | OMOP Field concept naming the source table's ID column | +| `event_date` | Date used for temporal selection | +| `event_datetime` | Source datetime when one is available | +| `event_concept_id` | Primary clinical concept carried by the event | + +Numeric value, value concept, and unit labels are available through `CANONICAL_EVENT_OPTIONAL_COLUMNS` when a source table supports them. + +The Field concept is not interchangeable with the event's clinical concept. For example, a Procedure Occurrence projection uses the Field concept for `procedure_occurrence.procedure_occurrence_id` as its discriminator and the row's `procedure_concept_id` as its clinical concept. + +## Attach an event to an episode + +A complete attachment key adds the episode ID to the table-scoped event identity: + +```python +from omop_alchemy.toolkit.episodes.derivation import EpisodeAttachmentIdentity + +attachment = EpisodeAttachmentIdentity.from_event( + procedure, + episode_id=1002, +) + +assert attachment.event == procedure +``` + +Before accepting an explicit link, the query must confirm that the Field concept names the event's actual source table and that the event and episode belong to the same person. A source mismatch is a `discriminator_mismatch`; a person mismatch is a `person_mismatch`. A link to a missing source row is a `dangling_event`. + +Once valid, an explicit link takes precedence under either explicit-first policy. Suppose Procedure Occurrence 7 is linked to episode 1002, while its date also falls inside the windows of episodes 1001 and 1002. The result is only `(procedure_occurrence, 7, 1002)`: fallback must not add episode 1001 or duplicate episode 1002. + +`EpisodeAttachmentPolicy` controls what happens when no valid explicit link exists: + +| Policy | Fallback behaviour | +|---|---| +| `explicit_only` | Leave the event unattached | +| `explicit_first_ranked` | Select one date-eligible episode using a separate ranking specification | +| `explicit_first_all_in_window` | Retain every date-eligible episode | + +Choosing between ranked and all-in-window fallback is a statement about result grain. Ranked fallback produces at most one episode per event. All-in-window fallback intentionally allows one event to appear against several overlapping episodes. + +## Rank fallback candidates + +Ranking has two independent parts: which side of the anchor date should be considered first, and how candidates on that side should be ordered. Keeping them separate supports both symmetric nearest-date matching and the common preference for an episode that had already started when the event occurred. + +For an event on 20 January, consider these episode starts: + +| Episode | Start date | Absolute distance | State on 20 January | +|---|---|---:|---| +| 1001 | 15 January | 5 days | Already started | +| 1003 | 21 January | 1 day | Not yet started | + +A side-neutral nearest policy selects episode 1003: + +```python +from omop_alchemy.toolkit.episodes.derivation import ( + TemporalRankingSpec, + TemporalSelectionPolicy, +) + +absolute_nearest = TemporalRankingSpec( + policy=TemporalSelectionPolicy.nearest, + stable_id_column="episode_id", +) +``` + +If the analysis should prefer an episode that was underway when the event happened, apply a side preference before distance: + +```python +from omop_alchemy.toolkit.episodes.derivation import TemporalSidePreference + +already_started_first = TemporalRankingSpec( + policy=TemporalSelectionPolicy.nearest, + stable_id_column="episode_id", + side_preference=TemporalSidePreference.on_or_before_anchor, +) +``` + +This policy selects episode 1001. Absolute distance still orders episodes within the preferred side; it simply does not allow a closer future episode to outrank every episode that had already started. `on_or_after_anchor` expresses the corresponding future-first rule. + +`earliest` and `latest` are available when chronological position, rather than distance from the anchor, defines the result. Every policy ends with the named stable ID column in ascending order. If episodes 1001 and 1002 are otherwise tied, 1001 wins consistently rather than relying on database return order. + +### Date boundaries + +Lower and upper bounds are inclusive by default and can be changed independently through `include_lower_bound` and `include_upper_bound`. For an episode starting 15 January 2026 with a 90-day prior window, 17 October 2025 lies exactly on the lower boundary and is included under the default. If the episode ends on 5 February, that date is included while 6 February is not. + +Boundary choices belong in the ranking specification rather than being hidden in a comparison operator. This is especially important when two systems use similar-looking windows but disagree at exactly 90 or 180 days. + +## Select one repeated observation + +Repeated observations need the same explicit treatment of direction, grouping, and ties. For an anchor date of 20 January, suppose a person has these rows: + +| Observation ID | Date | Value | +|---:|---|---| +| 21 | 1 January | earlier | +| 22 | 20 January | anchor-a | +| 23 | 20 January | anchor-b | +| 24 | 21 January | after-anchor | + +The following specification chooses the latest observation on or before the anchor, grouping rows by person and observation concept: + +```python +from omop_alchemy.toolkit.episodes.derivation import ( + ObservationSelectionPolicy, + ObservationSelectionSpec, +) + +selection = ObservationSelectionSpec( + policy=ObservationSelectionPolicy.latest_on_or_before_anchor, + partition_by=("person_id", "observation_concept_id"), + stable_id_column="observation_id", + include_anchor_date=True, +) +``` + +Observation 24 is after the anchor and is therefore excluded. Observations 22 and 23 tie on date, so the stable ID selects 22. That tie-break creates reproducible output; it does not claim that one same-day clinical value is more correct. If every same-day value is meaningful, retain them by choosing a result grain that includes the observation ID instead of reducing the group to one row. + +Add `episode_id` or another field to `partition_by` when selection must occur separately within those groups. The partition is part of the clinical meaning of the result, not merely an optimisation detail. + +## Runtime concept sets + +Applications often receive concept selection as configuration rather than as a compile-time governed unit. `RuntimeConceptSetSpec` records four inputs: exact concepts and descendants to include, and exact concepts and descendants to exclude. + +```python +from omop_alchemy.toolkit.core.concepts import RuntimeConceptSetSpec + +concepts = RuntimeConceptSetSpec( + include_ancestor_ids=(100,), + include_exact_ids=(900,), + exclude_ancestor_ids=(400,), + exclude_exact_ids=(901,), + require_standard=True, + include_classification=False, +) +``` + +The intended set is: + +```text +(descendants of 100 OR exact concept 900) +AND NOT (descendants of 400 OR exact concept 901) +``` + +Exclusion wins when a concept is reached from both sides. With no inclusion, the set matches nothing. IDs are sorted and deduplicated when the specification is created. + +`require_standard` and `include_classification` use the same vocabulary as `ConceptFilter` and `ConceptGroupSpec`; consuming SQL should delegate to the existing normalised OMOP standardness expressions. The specification does not decide whether a numeric ID is valid in a particular vocabulary. Validate configuration and local-concept policy at the boundary where those rules are known. + +Constructing the specification performs no hierarchy expansion and no database access. A consumer must translate it into predicates over `concept_ancestor` and, when standardness filtering is requested, `concept`. + +## API reference + +::: omop_alchemy.toolkit.core.events + +::: omop_alchemy.toolkit.episodes.derivation diff --git a/mkdocs.yml b/mkdocs.yml index cebc5db..668bb02 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -134,10 +134,11 @@ nav: - Toolkit: - Overview: toolkit/index.md - - core: toolkit/core.md - - episodes: toolkit/episodes.md - - analytics: toolkit/analytics.md - - integrations: toolkit/integrations.md + - Core services: toolkit/core.md + - Episodes: toolkit/episodes.md + - Query contracts: toolkit/query-contracts.md + - Clinical analytics: toolkit/analytics.md + - Integrations: toolkit/integrations.md - OMOP-Specific Validation: - Overview: validation/index.md diff --git a/omop_alchemy/cdm/base/cdm_constants.py b/omop_alchemy/cdm/base/cdm_constants.py index 63dea95..27a0187 100644 --- a/omop_alchemy/cdm/base/cdm_constants.py +++ b/omop_alchemy/cdm/base/cdm_constants.py @@ -1,5 +1,7 @@ class ModifierFieldConcepts: CONDITION_OCCURRENCE = 1147127 + MEASUREMENT = 1147138 + OBSERVATION = 1147165 PROCEDURE_OCCURRENCE = 1147082 DRUG_EXPOSURE = 1147707 EPISODE = 756290 diff --git a/omop_alchemy/toolkit/core/__init__.py b/omop_alchemy/toolkit/core/__init__.py index 2a94bbe..2c3972e 100644 --- a/omop_alchemy/toolkit/core/__init__.py +++ b/omop_alchemy/toolkit/core/__init__.py @@ -9,6 +9,10 @@ Map free text and source codes to OMOP concept IDs, and hold the normalisation rules that make those mappings reproducible. +``events`` + Canonical cross-table event identities and projection row shapes shared by + timelines and episode builders. + ``timeline`` Project heterogeneous clinical rows into a single ordered sequence of events for one person. diff --git a/omop_alchemy/toolkit/core/concepts/__init__.py b/omop_alchemy/toolkit/core/concepts/__init__.py index 6880357..06d9dd9 100644 --- a/omop_alchemy/toolkit/core/concepts/__init__.py +++ b/omop_alchemy/toolkit/core/concepts/__init__.py @@ -99,6 +99,7 @@ concept_group_registry, resolve_concept_group, ) +from .runtime import RuntimeConceptSetSpec __all__ = [ "DEFAULT_MAX_CACHE_BYTES", @@ -111,6 +112,7 @@ "LookupSpec", "OMOPConceptSource", "ResolvedConceptGroup", + "RuntimeConceptSetSpec", "build_concept_group", "clear_concept_group_cache", "clear_vocabulary_identity", diff --git a/omop_alchemy/toolkit/core/concepts/runtime.py b/omop_alchemy/toolkit/core/concepts/runtime.py new file mode 100644 index 0000000..9bdd58c --- /dev/null +++ b/omop_alchemy/toolkit/core/concepts/runtime.py @@ -0,0 +1,70 @@ +"""Declarative runtime concept-set inputs for database-side predicates. + +``ConceptGroupSpec`` is the right contract for governed omop-semantics units. +``RuntimeConceptSetSpec`` complements it for IDs supplied by configuration at +runtime. It records intent without expanding vocabulary hierarchies or touching +a database; a later query builder renders the corresponding SQL predicate. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + + +def _normalise_concept_ids(values: Iterable[int]) -> tuple[int, ...]: + """Return stable, duplicate-free inputs without imposing vocabulary policy.""" + return tuple(sorted(set(values))) + + +@dataclass(frozen=True, slots=True) +class RuntimeConceptSetSpec: + """Runtime include/exclude inputs for a database-side concept predicate. + + The intended expression is:: + + (included ancestor descendants OR included exact IDs) + AND NOT (excluded ancestor descendants OR excluded exact IDs) + + Exclusion therefore wins if the same concept is reached by both sides. + Empty inclusions describe an always-false set. Constructing the spec is + side-effect free and preserves no session-bound vocabulary objects. + + ``require_standard`` and ``include_classification`` deliberately match + ``ConceptFilter`` and ``ConceptGroupSpec``. A future renderer delegates to + the existing normalised ``Concept`` flag expressions rather than defining + another interpretation of OMOP's single-character standardness flags. + + IDs are sorted and deduplicated only. Validity rules for configuration or a + local vocabulary belong at those boundaries, not in this generic spec. + """ + + include_ancestor_ids: tuple[int, ...] = () + include_exact_ids: tuple[int, ...] = () + exclude_ancestor_ids: tuple[int, ...] = () + exclude_exact_ids: tuple[int, ...] = () + require_standard: bool = False + include_classification: bool = True + + def __post_init__(self) -> None: + for field_name in ( + "include_ancestor_ids", + "include_exact_ids", + "exclude_ancestor_ids", + "exclude_exact_ids", + ): + object.__setattr__( + self, + field_name, + _normalise_concept_ids(getattr(self, field_name)), + ) + + @property + def has_inclusions(self) -> bool: + """Whether the future predicate can match at least one configured input.""" + return bool(self.include_ancestor_ids or self.include_exact_ids) + + @property + def requires_concept_join(self) -> bool: + """Whether standardness filtering requires the Concept table.""" + return self.require_standard diff --git a/omop_alchemy/toolkit/core/events/__init__.py b/omop_alchemy/toolkit/core/events/__init__.py new file mode 100644 index 0000000..f9296fd --- /dev/null +++ b/omop_alchemy/toolkit/core/events/__init__.py @@ -0,0 +1,25 @@ +"""Canonical, domain-neutral clinical-event identities and row shapes. + +Event tables use different native column names, but cross-table analytical +queries need one stable vocabulary. These contracts are shared by timeline +adapters in ``core`` and episode query builders in the higher ``episodes`` +tier. They are declarative and perform no database work. +""" + +from .contracts import ( + CANONICAL_EVENT_OPTIONAL_COLUMNS, + CANONICAL_EVENT_REQUIRED_COLUMNS, + ClinicalEventColumn, + ClinicalEventIdentity, + ClinicalEventRow, + ValuedClinicalEventRow, +) + +__all__ = [ + "CANONICAL_EVENT_OPTIONAL_COLUMNS", + "CANONICAL_EVENT_REQUIRED_COLUMNS", + "ClinicalEventColumn", + "ClinicalEventIdentity", + "ClinicalEventRow", + "ValuedClinicalEventRow", +] diff --git a/omop_alchemy/toolkit/core/events/contracts.py b/omop_alchemy/toolkit/core/events/contracts.py new file mode 100644 index 0000000..2466628 --- /dev/null +++ b/omop_alchemy/toolkit/core/events/contracts.py @@ -0,0 +1,87 @@ +"""Side-effect-free contracts for canonical cross-table clinical events.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime +from enum import StrEnum +from typing import Protocol, runtime_checkable + + +class ClinicalEventColumn(StrEnum): + """Canonical labels emitted by a cross-table clinical-event projection.""" + + person_id = "person_id" + event_id = "event_id" + event_date = "event_date" + event_datetime = "event_datetime" + event_concept_id = "event_concept_id" + event_field_concept_id = "event_field_concept_id" + event_source_table = "event_source_table" + value_as_number = "value_as_number" + value_as_concept_id = "value_as_concept_id" + unit_concept_id = "unit_concept_id" + + +CANONICAL_EVENT_REQUIRED_COLUMNS: tuple[ClinicalEventColumn, ...] = ( + ClinicalEventColumn.person_id, + ClinicalEventColumn.event_id, + ClinicalEventColumn.event_date, + ClinicalEventColumn.event_datetime, + ClinicalEventColumn.event_concept_id, + ClinicalEventColumn.event_field_concept_id, + ClinicalEventColumn.event_source_table, +) +"""Columns every canonical clinical-event projection must expose.""" + + +CANONICAL_EVENT_OPTIONAL_COLUMNS: tuple[ClinicalEventColumn, ...] = ( + ClinicalEventColumn.value_as_number, + ClinicalEventColumn.value_as_concept_id, + ClinicalEventColumn.unit_concept_id, +) +"""Nullable value columns a projection may add when its source supports them.""" + + +@runtime_checkable +class ClinicalEventRow(Protocol): + """Value-level view of the required canonical event projection. + + SQLAlchemy ``Row`` objects and small dataclasses can both satisfy this + protocol. It describes the output consumed by downstream tools; it does not + require a session-bound ORM entity. + """ + + person_id: int + event_id: int + event_date: date + event_datetime: datetime | None + event_concept_id: int + event_field_concept_id: int + event_source_table: str + + +@runtime_checkable +class ValuedClinicalEventRow(ClinicalEventRow, Protocol): + """Canonical event row extended with nullable value and unit fields.""" + + value_as_number: float | None + value_as_concept_id: int | None + unit_concept_id: int | None + + +@dataclass(frozen=True, order=True, slots=True) +class ClinicalEventIdentity: + """Cross-table event identity. + + OMOP event IDs are unique only within their source table. A Measurement and + a Procedure Occurrence may legitimately have the same numeric ID, so the + table is a mandatory part of identity. + """ + + event_source_table: str + event_id: int + + def __post_init__(self) -> None: + if not self.event_source_table.strip(): + raise ValueError("event_source_table must not be empty") diff --git a/omop_alchemy/toolkit/core/timeline/__init__.py b/omop_alchemy/toolkit/core/timeline/__init__.py index 7cb044e..ec44e49 100644 --- a/omop_alchemy/toolkit/core/timeline/__init__.py +++ b/omop_alchemy/toolkit/core/timeline/__init__.py @@ -6,9 +6,10 @@ does that reconciliation and presents the result as a single list of events sorted by time. -Each event exposes a canonical time and value regardless of which table it -came from, so callers can iterate a patient's history without special- -casing per table:: +Each event implements the canonical row identity from ``toolkit.core.events`` +and adds timeline-specific interval, value, metadata, and serialisation +behaviour, so callers can iterate a patient's history without special-casing +per table:: from omop_alchemy.toolkit.core.timeline import Person_Timeline diff --git a/omop_alchemy/toolkit/core/timeline/event_timeline.py b/omop_alchemy/toolkit/core/timeline/event_timeline.py index 16fce08..f35a07b 100644 --- a/omop_alchemy/toolkit/core/timeline/event_timeline.py +++ b/omop_alchemy/toolkit/core/timeline/event_timeline.py @@ -1,5 +1,6 @@ from omop_alchemy.cdm.model.clinical import Measurement, Person, Condition_Occurrence, Drug_Exposure +from omop_alchemy.cdm.base import ModifierFieldConcepts from sqlalchemy.orm import object_session from sqlalchemy import select from datetime import datetime, time, date @@ -8,6 +9,8 @@ from dataclasses import dataclass from typing import Protocol, Union, Literal +from omop_alchemy.toolkit.core.events import ClinicalEventRow + TemporalKind = Literal["point", "interval"] @@ -40,14 +43,11 @@ def kind(self) -> TemporalKind: return "interval" if self.end is not None else "point" -class ClinicalEventProtocol(Protocol): +class ClinicalEventProtocol(ClinicalEventRow, Protocol): """ Interface for ORM rows that can be projected into a patient timeline. """ - @property - def person_id(self) -> int: ... - """Primary clinical concept driving the event""" @property def concept_id(self) -> int: ... @@ -77,6 +77,9 @@ def to_json(self) -> str: ... @dataclass class EventMapping: + event_id_field: str + event_field_concept_id: int + event_source_table: str concept_field: str start_date_field: str start_datetime_field: Optional[str] = None @@ -95,9 +98,41 @@ class ClinicalEvent: _mapping: EventMapping + @property + def event_id(self) -> int: + """Primary key value within the mapped source table.""" + return getattr(self, self._mapping.event_id_field) + @property def concept_id(self) -> int: return getattr(self, self._mapping.concept_field) + + @property + def event_concept_id(self) -> int: + """Canonical projection name for the timeline event's clinical concept.""" + return self.concept_id + + @property + def event_source_table(self) -> str: + """OMOP source table that scopes ``event_id``.""" + return self._mapping.event_source_table + + @property + def event_field_concept_id(self) -> int: + """OMOP Field concept identifying the source event ID column.""" + return self._mapping.event_field_concept_id + + @property + def event_date(self) -> date: + """Date component used by canonical event projections.""" + value = getattr(self, self._mapping.start_date_field) + return value.date() if isinstance(value, datetime) else value + + @property + def event_datetime(self) -> datetime | None: + """Source datetime when the event table carries one, otherwise ``None``.""" + field = self._mapping.start_datetime_field + return getattr(self, field) if field else None def event_value(self) -> EventValue: @@ -176,6 +211,9 @@ def to_dict(self: ClinicalEventProtocol) -> dict[str, Any]: return { "person_id": self.person_id, + "event_id": self.event_id, + "event_source_table": self.event_source_table, + "event_field_concept_id": self.event_field_concept_id, "concept_id": self.concept_id, "event_start": et.start.isoformat(), "event_end": et.end.isoformat() if et.end else None, @@ -193,6 +231,9 @@ def to_json(self: ClinicalEventProtocol) -> str: class Condition_Event(Condition_Occurrence, ClinicalEvent): _mapping = EventMapping( + event_id_field="condition_occurrence_id", + event_field_concept_id=ModifierFieldConcepts.CONDITION_OCCURRENCE, + event_source_table="condition_occurrence", concept_field="condition_concept_id", start_date_field="condition_start_date", start_datetime_field="condition_start_datetime", @@ -204,6 +245,9 @@ class Condition_Event(Condition_Occurrence, ClinicalEvent): class Measurement_Event(ClinicalEvent, Measurement): _mapping = EventMapping( + event_id_field="measurement_id", + event_field_concept_id=ModifierFieldConcepts.MEASUREMENT, + event_source_table="measurement", concept_field="measurement_concept_id", start_date_field="measurement_date", start_datetime_field="measurement_datetime", @@ -224,6 +268,9 @@ def event_metadata(self) -> dict[str, Optional[int]]: class Drug_Exposure_Event(Drug_Exposure, ClinicalEvent): _mapping = EventMapping( + event_id_field="drug_exposure_id", + event_field_concept_id=ModifierFieldConcepts.DRUG_EXPOSURE, + event_source_table="drug_exposure", concept_field="drug_concept_id", start_date_field="drug_exposure_start_date", start_datetime_field="drug_exposure_start_datetime", @@ -270,4 +317,4 @@ def timeline(self) -> list[ClinicalEvent]: ) def to_json(self) -> list[str]: # ty: ignore[invalid-method-override] - return [e.to_json() for e in self.timeline] # ty: ignore[invalid-argument-type] \ No newline at end of file + return [e.to_json() for e in self.timeline] # ty: ignore[invalid-argument-type] diff --git a/omop_alchemy/toolkit/episodes/derivation/__init__.py b/omop_alchemy/toolkit/episodes/derivation/__init__.py index 8ae225c..ed64bd3 100644 --- a/omop_alchemy/toolkit/episodes/derivation/__init__.py +++ b/omop_alchemy/toolkit/episodes/derivation/__init__.py @@ -9,7 +9,33 @@ date windows relating one episode to another, written against the raw ``Episode``/``Episode_Event`` tables rather than any materialised view. -Not yet populated in this package. The equivalent built against -materialised-view subclasses lives in ``omop-constructs``; nothing has -moved into ``toolkit`` yet. +The public contracts in this area define episode-attachment identities and +policies used by query builders. Shared clinical-event row names and identities +live in ``toolkit.core.events``. All are declarative and perform no database +work, so downstream packages can agree on semantics before changing clinical +queries. """ + +from .contracts import ( + AttachmentDiagnosticCode, + EpisodeAttachmentDiagnostic, + EpisodeAttachmentIdentity, + EpisodeAttachmentPolicy, + ObservationSelectionPolicy, + ObservationSelectionSpec, + TemporalRankingSpec, + TemporalSelectionPolicy, + TemporalSidePreference, +) + +__all__ = [ + "AttachmentDiagnosticCode", + "EpisodeAttachmentDiagnostic", + "EpisodeAttachmentIdentity", + "EpisodeAttachmentPolicy", + "ObservationSelectionPolicy", + "ObservationSelectionSpec", + "TemporalRankingSpec", + "TemporalSelectionPolicy", + "TemporalSidePreference", +] diff --git a/omop_alchemy/toolkit/episodes/derivation/contracts.py b/omop_alchemy/toolkit/episodes/derivation/contracts.py new file mode 100644 index 0000000..7e41f00 --- /dev/null +++ b/omop_alchemy/toolkit/episodes/derivation/contracts.py @@ -0,0 +1,184 @@ +"""Side-effect-free contracts for episode attachment and temporal SQL builders. + +The shared clinical-event row and identity contracts live in +``toolkit.core.events`` so timelines and episode builders can consume them +without reversing the toolkit import layers. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + +from omop_alchemy.toolkit.core.events import ClinicalEventIdentity + + +@dataclass(frozen=True, order=True, slots=True) +class EpisodeAttachmentIdentity: + """Unique identity of one event attached to one episode.""" + + event_source_table: str + event_id: int + episode_id: int + + def __post_init__(self) -> None: + # Keep the directly constructed form as safe as ``from_event``: downstream + # result sets use this key for deduplication, so an empty source would erase + # the boundary that protects against cross-table ID collisions. + ClinicalEventIdentity(self.event_source_table, self.event_id) + + @classmethod + def from_event( + cls, + event: ClinicalEventIdentity, + *, + episode_id: int, + ) -> EpisodeAttachmentIdentity: + """Add an episode to an already canonical cross-table event identity.""" + return cls( + event_source_table=event.event_source_table, + event_id=event.event_id, + episode_id=episode_id, + ) + + @property + def event(self) -> ClinicalEventIdentity: + """The event portion of this attachment identity.""" + return ClinicalEventIdentity(self.event_source_table, self.event_id) + + +class EpisodeAttachmentPolicy(StrEnum): + """Named precedence and fallback-cardinality policies. + + A valid explicit link always wins in the two explicit-first policies. The + difference is what happens to an event that has no valid explicit link. + """ + + explicit_only = "explicit_only" + explicit_first_ranked = "explicit_first_ranked" + explicit_first_all_in_window = "explicit_first_all_in_window" + + @property + def uses_fallback(self) -> bool: + """Whether unlinked events may be attached by a date window.""" + return self is not EpisodeAttachmentPolicy.explicit_only + + @property + def permits_fallback_fanout(self) -> bool: + """Whether one fallback event may attach to several episodes.""" + return self is EpisodeAttachmentPolicy.explicit_first_all_in_window + + @property + def requires_fallback_ranking(self) -> bool: + """Whether fallback needs a separate temporal ranking specification.""" + return self is EpisodeAttachmentPolicy.explicit_first_ranked + + +class AttachmentDiagnosticCode(StrEnum): + """Stable categories for explaining rejected or ambiguous attachment rows.""" + + discriminator_mismatch = "discriminator_mismatch" + person_mismatch = "person_mismatch" + dangling_event = "dangling_event" + no_candidate_episode = "no_candidate_episode" + ambiguous_fallback = "ambiguous_fallback" + + +@dataclass(frozen=True, slots=True) +class EpisodeAttachmentDiagnostic: + """Advisory result explaining why an attachment needs review.""" + + code: AttachmentDiagnosticCode + event: ClinicalEventIdentity + message: str + episode_id: int | None = None + + +class TemporalSelectionPolicy(StrEnum): + """How one row is selected from several temporal candidates.""" + + nearest = "nearest" + earliest = "earliest" + latest = "latest" + + +class TemporalSidePreference(StrEnum): + """Which side of an anchor is preferred before temporal ranking. + + Candidate dates are ranked relative to a caller-supplied anchor. For event + attachment where the event is the anchor and episode starts are candidates, + ``on_or_before_anchor`` prefers an episode that had already started. + """ + + none = "none" + on_or_before_anchor = "on_or_before_anchor" + on_or_after_anchor = "on_or_after_anchor" + + +@dataclass(frozen=True, slots=True) +class TemporalRankingSpec: + """Temporal ranking and boundary contract for future SQL builders. + + A side preference, when present, is applied before the selection policy. + ``nearest`` then means the smallest absolute distance within that tier. + All policies use the named stable ID column as their final ascending + tie-breaker, so the same source rows cannot alternate across executions. + """ + + policy: TemporalSelectionPolicy + stable_id_column: str + side_preference: TemporalSidePreference = TemporalSidePreference.none + include_lower_bound: bool = True + include_upper_bound: bool = True + + def __post_init__(self) -> None: + if not self.stable_id_column.strip(): + raise ValueError("stable_id_column must not be empty") + + @property + def uses_absolute_distance(self) -> bool: + """Whether ranking uses absolute distance after any side-preference tier.""" + return self.policy is TemporalSelectionPolicy.nearest + + @property + def has_side_preference(self) -> bool: + """Whether candidates are tiered by their side of the anchor first.""" + return self.side_preference is not TemporalSidePreference.none + + +class ObservationSelectionPolicy(StrEnum): + """Supported deterministic choices for repeated longitudinal observations.""" + + earliest = "earliest" + latest = "latest" + latest_on_or_before_anchor = "latest_on_or_before_anchor" + + +@dataclass(frozen=True, slots=True) +class ObservationSelectionSpec: + """Partition and tie-break contract for repeated-observation selection. + + The default partition describes one observation concept for one person. + Callers may add an episode or another grouping field when their declared + result grain requires it. + """ + + policy: ObservationSelectionPolicy + partition_by: tuple[str, ...] = ("person_id", "observation_concept_id") + stable_id_column: str = "observation_id" + include_anchor_date: bool = True + + def __post_init__(self) -> None: + if not self.partition_by: + raise ValueError("partition_by must contain at least one column") + if any(not name.strip() for name in self.partition_by): + raise ValueError("partition_by column names must not be empty") + if len(set(self.partition_by)) != len(self.partition_by): + raise ValueError("partition_by column names must be unique") + if not self.stable_id_column.strip(): + raise ValueError("stable_id_column must not be empty") + + @property + def requires_anchor(self) -> bool: + """Whether selection requires a caller-supplied anchor date.""" + return self.policy is ObservationSelectionPolicy.latest_on_or_before_anchor diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000..9d65cdb --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1 @@ +"""Small, reviewable datasets shared by semantic contract tests.""" diff --git a/tests/fixtures/query_contract_cases.py b/tests/fixtures/query_contract_cases.py new file mode 100644 index 0000000..574a1dc --- /dev/null +++ b/tests/fixtures/query_contract_cases.py @@ -0,0 +1,172 @@ +"""Counterexamples that clinical query builders must satisfy. + +The IDs are intentionally small and collide across event tables. Dates are +chosen so precedence, boundaries, and ties can be verified by inspection. +This module contains data only; it does not encode the expected algorithm. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date + +from omop_alchemy.cdm.base import ModifierFieldConcepts +from omop_alchemy.toolkit.core.events import ClinicalEventIdentity + +MEASUREMENT_FIELD_CONCEPT_ID = ModifierFieldConcepts.MEASUREMENT +OBSERVATION_FIELD_CONCEPT_ID = ModifierFieldConcepts.OBSERVATION +PROCEDURE_FIELD_CONCEPT_ID = ModifierFieldConcepts.PROCEDURE_OCCURRENCE + + +@dataclass(frozen=True, slots=True) +class EventCase: + identity: ClinicalEventIdentity + person_id: int + event_date: date + event_field_concept_id: int + + +@dataclass(frozen=True, slots=True) +class EpisodeCase: + episode_id: int + person_id: int + start_date: date + end_date: date | None + + +@dataclass(frozen=True, slots=True) +class ExplicitLinkCase: + event: ClinicalEventIdentity + episode_id: int + episode_event_field_concept_id: int + + +@dataclass(frozen=True, slots=True) +class ObservationCase: + observation_id: int + person_id: int + observation_concept_id: int + observation_date: date + value: str + + +# Numeric event ID 7 exists in two event tables for the same person. A third +# event with ID 7 belongs to another person. Numeric ID alone cannot identify +# any of these rows safely. +COLLIDING_EVENTS = ( + EventCase( + ClinicalEventIdentity("measurement", 7), + person_id=101, + event_date=date(2026, 1, 20), + event_field_concept_id=MEASUREMENT_FIELD_CONCEPT_ID, + ), + EventCase( + ClinicalEventIdentity("procedure_occurrence", 7), + person_id=101, + event_date=date(2026, 1, 20), + event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, + ), + EventCase( + ClinicalEventIdentity("observation", 7), + person_id=202, + event_date=date(2026, 1, 20), + event_field_concept_id=OBSERVATION_FIELD_CONCEPT_ID, + ), +) + + +# Episodes 1001 and 1002 overlap. Their start dates are equally distant from +# 20 January, so nearest selection must use episode_id as its final tie-break. +OVERLAPPING_EPISODES = ( + EpisodeCase(1001, person_id=101, start_date=date(2026, 1, 15), end_date=date(2026, 2, 5)), + EpisodeCase(1002, person_id=101, start_date=date(2026, 1, 25), end_date=date(2026, 2, 20)), + EpisodeCase(2001, person_id=202, start_date=date(2026, 1, 10), end_date=None), +) + + +# For the 20 January event, episode 1003 is technically closer but has not +# started. A side-neutral nearest policy selects 1003; a policy that prefers +# already-started episodes selects 1001. +DIRECTIONAL_PREFERENCE_EPISODES = ( + EpisodeCase(1001, person_id=101, start_date=date(2026, 1, 15), end_date=date(2026, 2, 5)), + EpisodeCase(1003, person_id=101, start_date=date(2026, 1, 21), end_date=date(2026, 2, 28)), +) + + +# These future-only candidates reproduce the signed-distance pitfall in the +# current omop-constructs visit ranking. With diff = event - episode start, +# ascending signed values choose 4002 even though 4001 is closer. +CONSTRUCTS_FUTURE_VISIT_EPISODES = ( + EpisodeCase(4001, person_id=101, start_date=date(2026, 8, 1), end_date=None), + EpisodeCase(4002, person_id=101, start_date=date(2026, 9, 1), end_date=None), +) + +# Current omop-constructs uses abs(diff_days) < 180 for its first visit tier. +# This start is exactly 180 days before the event and therefore exposes the +# strict-boundary behaviour. +CONSTRUCTS_EXACT_180_DAY_EPISODE = EpisodeCase( + 4003, + person_id=101, + start_date=date(2025, 7, 24), + end_date=None, +) + + +VALID_EXPLICIT_LINK = ExplicitLinkCase( + event=ClinicalEventIdentity("procedure_occurrence", 7), + episode_id=1002, + episode_event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, +) + +WRONG_DISCRIMINATOR_LINK = ExplicitLinkCase( + event=ClinicalEventIdentity("procedure_occurrence", 7), + episode_id=1001, + episode_event_field_concept_id=MEASUREMENT_FIELD_CONCEPT_ID, +) + +CROSS_PERSON_LINK = ExplicitLinkCase( + event=ClinicalEventIdentity("observation", 7), + episode_id=1001, + episode_event_field_concept_id=OBSERVATION_FIELD_CONCEPT_ID, +) + + +# With a 90-day prior window and episode 1001 starting on 15 January, 17 +# October is the inclusive lower boundary and 16 October is outside it. +BOUNDARY_EVENTS = ( + EventCase( + ClinicalEventIdentity("measurement", 8), + person_id=101, + event_date=date(2025, 10, 17), + event_field_concept_id=MEASUREMENT_FIELD_CONCEPT_ID, + ), + EventCase( + ClinicalEventIdentity("measurement", 9), + person_id=101, + event_date=date(2025, 10, 16), + event_field_concept_id=MEASUREMENT_FIELD_CONCEPT_ID, + ), + EventCase( + ClinicalEventIdentity("measurement", 10), + person_id=101, + event_date=date(2026, 2, 5), + event_field_concept_id=MEASUREMENT_FIELD_CONCEPT_ID, + ), + EventCase( + ClinicalEventIdentity("measurement", 11), + person_id=101, + event_date=date(2026, 2, 6), + event_field_concept_id=MEASUREMENT_FIELD_CONCEPT_ID, + ), +) + + +# Two observations occur on the anchor date. Stable ascending observation_id +# makes 22 the deterministic winner for latest-on-or-before-anchor. +OBSERVATION_ANCHOR_DATE = date(2026, 1, 20) +REPEATED_OBSERVATIONS = ( + ObservationCase(21, 101, 900_001, date(2026, 1, 1), "earlier"), + ObservationCase(22, 101, 900_001, OBSERVATION_ANCHOR_DATE, "anchor-a"), + ObservationCase(23, 101, 900_001, OBSERVATION_ANCHOR_DATE, "anchor-b"), + ObservationCase(24, 101, 900_001, date(2026, 1, 21), "after-anchor"), +) diff --git a/tests/test_query_builder_contracts.py b/tests/test_query_builder_contracts.py new file mode 100644 index 0000000..feb63ff --- /dev/null +++ b/tests/test_query_builder_contracts.py @@ -0,0 +1,306 @@ +"""Query contracts and reusable counterexamples for SQL builders.""" + +from __future__ import annotations + +from datetime import date, timedelta + +import pytest +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql, sqlite + +from omop_alchemy.cdm.base import ModifierFieldConcepts +from omop_alchemy.toolkit.core.concepts import ( + RuntimeConceptSetSpec, +) +from omop_alchemy.toolkit.core.events import ( + CANONICAL_EVENT_OPTIONAL_COLUMNS, + CANONICAL_EVENT_REQUIRED_COLUMNS, + ClinicalEventColumn, + ClinicalEventIdentity, + ClinicalEventRow, +) +from omop_alchemy.toolkit.core.timeline import Measurement_Event +from omop_alchemy.toolkit.episodes.derivation import ( + EpisodeAttachmentIdentity, + EpisodeAttachmentPolicy, + ObservationSelectionPolicy, + ObservationSelectionSpec, + TemporalRankingSpec, + TemporalSelectionPolicy, + TemporalSidePreference, +) +from tests.fixtures.query_contract_cases import ( + BOUNDARY_EVENTS, + COLLIDING_EVENTS, + CONSTRUCTS_EXACT_180_DAY_EPISODE, + CONSTRUCTS_FUTURE_VISIT_EPISODES, + CROSS_PERSON_LINK, + DIRECTIONAL_PREFERENCE_EPISODES, + OBSERVATION_ANCHOR_DATE, + OVERLAPPING_EPISODES, + REPEATED_OBSERVATIONS, + VALID_EXPLICIT_LINK, + WRONG_DISCRIMINATOR_LINK, +) + + +def test_canonical_event_shape_has_unique_stable_names(): + all_columns = CANONICAL_EVENT_REQUIRED_COLUMNS + CANONICAL_EVENT_OPTIONAL_COLUMNS + + assert len(all_columns) == len(set(all_columns)) + assert tuple(str(column) for column in CANONICAL_EVENT_REQUIRED_COLUMNS) == ( + "person_id", + "event_id", + "event_date", + "event_datetime", + "event_concept_id", + "event_field_concept_id", + "event_source_table", + ) + + +def test_source_table_is_required_to_distinguish_colliding_event_ids(): + identities = {case.identity for case in COLLIDING_EVENTS} + + assert {case.identity.event_id for case in COLLIDING_EVENTS} == {7} + assert len(identities) == 3 + assert ClinicalEventIdentity("measurement", 7) != ClinicalEventIdentity( + "procedure_occurrence", 7 + ) + + +def test_timeline_event_implements_the_shared_core_event_contract(): + event = Measurement_Event( + measurement_id=7, + person_id=101, + measurement_concept_id=900_001, + measurement_date=date(2026, 1, 20), + measurement_datetime=None, + ) + + assert isinstance(event, ClinicalEventRow) + assert event.event_id == 7 + assert event.event_source_table == "measurement" + assert event.event_field_concept_id == ModifierFieldConcepts.MEASUREMENT + assert event.event_concept_id == 900_001 + assert event.event_date == date(2026, 1, 20) + assert event.event_datetime is None + + +def test_attachment_identity_keeps_the_event_source_and_episode(): + event = ClinicalEventIdentity("procedure_occurrence", 7) + + first = EpisodeAttachmentIdentity.from_event(event, episode_id=1001) + second = EpisodeAttachmentIdentity.from_event(event, episode_id=1002) + + assert first.event == event + assert first != second + + +@pytest.mark.parametrize("identity_type", [ClinicalEventIdentity, EpisodeAttachmentIdentity]) +def test_event_source_table_cannot_be_empty(identity_type): + args = ("", 7) if identity_type is ClinicalEventIdentity else ("", 7, 1001) + with pytest.raises(ValueError, match="event_source_table"): + identity_type(*args) + + +@pytest.mark.parametrize( + ("policy", "uses_fallback", "permits_fanout"), + [ + (EpisodeAttachmentPolicy.explicit_only, False, False), + (EpisodeAttachmentPolicy.explicit_first_ranked, True, False), + (EpisodeAttachmentPolicy.explicit_first_all_in_window, True, True), + ], +) +def test_attachment_policies_state_precedence_and_cardinality( + policy: EpisodeAttachmentPolicy, + uses_fallback: bool, + permits_fanout: bool, +): + assert policy.uses_fallback is uses_fallback + assert policy.permits_fallback_fanout is permits_fanout + assert policy.requires_fallback_ranking is ( + policy is EpisodeAttachmentPolicy.explicit_first_ranked + ) + + +def test_counterexample_links_cover_valid_discriminator_and_person_failures(): + event_by_identity = {case.identity: case for case in COLLIDING_EVENTS} + episode_by_id = {case.episode_id: case for case in OVERLAPPING_EPISODES} + + valid_event = event_by_identity[VALID_EXPLICIT_LINK.event] + assert valid_event.event_field_concept_id == VALID_EXPLICIT_LINK.episode_event_field_concept_id + assert valid_event.person_id == episode_by_id[VALID_EXPLICIT_LINK.episode_id].person_id + + wrong_event = event_by_identity[WRONG_DISCRIMINATOR_LINK.event] + assert ( + wrong_event.event_field_concept_id + != WRONG_DISCRIMINATOR_LINK.episode_event_field_concept_id + ) + + cross_person_event = event_by_identity[CROSS_PERSON_LINK.event] + assert cross_person_event.person_id != episode_by_id[CROSS_PERSON_LINK.episode_id].person_id + + +def test_nearest_temporal_contract_uses_absolute_distance_and_stable_id(): + spec = TemporalRankingSpec( + policy=TemporalSelectionPolicy.nearest, + stable_id_column="episode_id", + ) + event_date = date(2026, 1, 20) + ranked = sorted( + OVERLAPPING_EPISODES[:2], + key=lambda episode: ( + abs((event_date - episode.start_date).days), + episode.episode_id, + ), + ) + + assert spec.uses_absolute_distance + assert [episode.episode_id for episode in ranked] == [1001, 1002] + + +def test_started_episode_preference_can_override_absolute_nearest(): + anchor = date(2026, 1, 20) + neutral = TemporalRankingSpec( + policy=TemporalSelectionPolicy.nearest, + stable_id_column="episode_id", + ) + started_first = TemporalRankingSpec( + policy=TemporalSelectionPolicy.nearest, + stable_id_column="episode_id", + side_preference=TemporalSidePreference.on_or_before_anchor, + ) + + absolute = sorted( + DIRECTIONAL_PREFERENCE_EPISODES, + key=lambda episode: (abs((episode.start_date - anchor).days), episode.episode_id), + ) + directional = sorted( + DIRECTIONAL_PREFERENCE_EPISODES, + key=lambda episode: ( + episode.start_date > anchor, + abs((episode.start_date - anchor).days), + episode.episode_id, + ), + ) + + assert not neutral.has_side_preference + assert started_first.has_side_preference + assert absolute[0].episode_id == 1003 + assert directional[0].episode_id == 1001 + + +def test_constructs_visit_fixture_exposes_signed_future_distance_pitfall(): + event_date = date(2026, 1, 20) + signed = sorted( + CONSTRUCTS_FUTURE_VISIT_EPISODES, + key=lambda episode: (event_date - episode.start_date).days, + ) + absolute = sorted( + CONSTRUCTS_FUTURE_VISIT_EPISODES, + key=lambda episode: abs((event_date - episode.start_date).days), + ) + + assert signed[0].episode_id == 4002 + assert absolute[0].episode_id == 4001 + + +def test_constructs_visit_fixture_records_strict_180_day_boundary(): + event_date = date(2026, 1, 20) + distance = abs((event_date - CONSTRUCTS_EXACT_180_DAY_EPISODE.start_date).days) + + assert distance == 180 + assert not distance < 180 + + +def test_boundary_fixture_makes_closed_window_expectations_visible(): + spec = TemporalRankingSpec( + policy=TemporalSelectionPolicy.nearest, + stable_id_column="event_id", + ) + episode = OVERLAPPING_EPISODES[0] + lower = episode.start_date - timedelta(days=90) + upper = episode.end_date + assert upper is not None + included = [ + event.identity.event_id + for event in BOUNDARY_EVENTS + if lower <= event.event_date <= upper + ] + + assert spec.include_lower_bound + assert spec.include_upper_bound + assert included == [8, 10] + + +def test_observation_as_of_contract_excludes_future_and_breaks_ties_by_id(): + spec = ObservationSelectionSpec( + policy=ObservationSelectionPolicy.latest_on_or_before_anchor + ) + candidates = [ + row for row in REPEATED_OBSERVATIONS if row.observation_date <= OBSERVATION_ANCHOR_DATE + ] + selected = sorted( + candidates, + key=lambda row: (-row.observation_date.toordinal(), row.observation_id), + )[0] + + assert spec.requires_anchor + assert selected.observation_id == 22 + + +def test_runtime_concept_set_is_normalised_without_database_access(): + spec = RuntimeConceptSetSpec( + include_ancestor_ids=(300, 100, 300), + include_exact_ids=(900,), + exclude_ancestor_ids=(400,), + exclude_exact_ids=(901, 901), + require_standard=True, + include_classification=False, + ) + + assert spec.include_ancestor_ids == (100, 300) + assert spec.exclude_exact_ids == (901,) + assert spec.has_inclusions + assert spec.requires_concept_join + + +def test_runtime_concept_set_does_not_invent_concept_id_validity_policy(): + spec = RuntimeConceptSetSpec(include_exact_ids=(0, -1, 0)) + + assert spec.include_exact_ids == (-1, 0) + + +def _projection_contract_select() -> sa.Select: + """Minimal selectable proving the canonical labels compile on supported dialects.""" + event = sa.table( + "research_event", + sa.column("person_id", sa.Integer), + sa.column("event_id", sa.Integer), + sa.column("event_date", sa.Date), + sa.column("event_concept_id", sa.Integer), + ) + return sa.select( + event.c.person_id.label(ClinicalEventColumn.person_id), + event.c.event_id.label(ClinicalEventColumn.event_id), + event.c.event_date.label(ClinicalEventColumn.event_date), + sa.cast(sa.null(), sa.DateTime).label(ClinicalEventColumn.event_datetime), + event.c.event_concept_id.label(ClinicalEventColumn.event_concept_id), + sa.literal(ModifierFieldConcepts.MEASUREMENT).label( + ClinicalEventColumn.event_field_concept_id + ), + sa.literal("measurement").label(ClinicalEventColumn.event_source_table), + ) + + +@pytest.mark.parametrize("dialect", [sqlite.dialect(), postgresql.dialect()]) +def test_required_projection_contract_compiles_without_execution(dialect): + statement = _projection_contract_select() + compiled = str(statement.compile(dialect=dialect, compile_kwargs={"literal_binds": True})) + + assert tuple(statement.selected_columns.keys()) == tuple( + str(column) for column in CANONICAL_EVENT_REQUIRED_COLUMNS + ) + assert "event_field_concept_id" in compiled + assert "event_source_table" in compiled From 2ca6199d6ff9aec92c79e4f238fc568e0f84c7c1 Mon Sep 17 00:00:00 2001 From: gkennos Date: Fri, 28 Aug 2026 15:00:56 +1000 Subject: [PATCH 02/30] core projections and predicates --- docs/advanced/timelines.md | 2 +- docs/toolkit/core.md | 22 +- docs/toolkit/episodes.md | 25 +- docs/toolkit/query-contracts.md | 106 ++++++++- .../cdm/model/clinical/measurement.py | 13 +- .../cdm/model/clinical/observation.py | 13 +- omop_alchemy/cdm/query.py | 2 +- .../toolkit/core/concepts/__init__.py | 8 +- omop_alchemy/toolkit/core/concepts/groups.py | 36 +-- omop_alchemy/toolkit/core/concepts/runtime.py | 87 ++++++- omop_alchemy/toolkit/core/events/__init__.py | 18 +- .../toolkit/core/events/projections.py | 195 ++++++++++++++++ .../toolkit/episodes/derivation/__init__.py | 47 +++- .../toolkit/episodes/derivation/contracts.py | 21 +- .../episodes/derivation/observations.py | 97 ++++++++ .../toolkit/episodes/derivation/structure.py | 125 ++++++++++ .../toolkit/episodes/derivation/temporal.py | 217 ++++++++++++++++++ tests/test_episode_structure_queries.py | 87 +++++++ tests/test_event_projections.py | 111 +++++++++ tests/test_query_builder_contracts.py | 2 +- tests/test_runtime_concept_queries.py | 198 ++++++++++++++++ tests/test_temporal_queries.py | 188 +++++++++++++++ 22 files changed, 1562 insertions(+), 58 deletions(-) create mode 100644 omop_alchemy/toolkit/core/events/projections.py create mode 100644 omop_alchemy/toolkit/episodes/derivation/observations.py create mode 100644 omop_alchemy/toolkit/episodes/derivation/structure.py create mode 100644 omop_alchemy/toolkit/episodes/derivation/temporal.py create mode 100644 tests/test_episode_structure_queries.py create mode 100644 tests/test_event_projections.py create mode 100644 tests/test_runtime_concept_queries.py create mode 100644 tests/test_temporal_queries.py diff --git a/docs/advanced/timelines.md b/docs/advanced/timelines.md index 5267fef..ad729c5 100644 --- a/docs/advanced/timelines.md +++ b/docs/advanced/timelines.md @@ -42,7 +42,7 @@ instance at class level. `ClinicalEvent` is a mixin that adds timeline behaviour to any CDM ORM class. It implements the shared `toolkit.core.events.ClinicalEventRow` identity and projection fields, then reads `_mapping` to add `event_time`, `event_value`, `event_metadata`, `to_dict`, and `to_json`. -The shared core contract keeps timeline events and future SQL event projections aligned +The shared core contract keeps timeline events and SQL event projections aligned without making `core.timeline` import the higher-level episode package. ::: omop_alchemy.toolkit.core.timeline.event_timeline.ClinicalEvent diff --git a/docs/toolkit/core.md b/docs/toolkit/core.md index 9029d6a..48f171a 100644 --- a/docs/toolkit/core.md +++ b/docs/toolkit/core.md @@ -41,7 +41,27 @@ assert measurement != procedure `ClinicalEventColumn` defines the common labels used when heterogeneous event tables are projected into one result. The required shape includes the person, table-scoped event identity, event date and datetime, clinical concept, and OMOP Field concept that identifies the source ID column. Optional labels cover numeric values, value concepts, and units. -These contracts describe result shape and identity; they do not execute a query. The [query contracts](query-contracts.md) explain how this shape participates in episode attachment. +`canonical_event_union()` turns supported event models into that shared shape. Measurement and Observation retain their value and unit columns; sources without those fields receive typed nulls so every branch of the union remains compatible: + +```python +from omop_alchemy.cdm.model import ( + Measurement, + Observation, + Procedure_Occurrence, +) +from omop_alchemy.toolkit.core.events import canonical_event_union + +events = canonical_event_union( + Measurement, + Observation, + Procedure_Occurrence, +) + +for event in session.execute(events).mappings(): + print(event["event_source_table"], event["event_id"], event["event_date"]) +``` + +The projection derives its ID, clinical concept, date, source table, and Field concept from `ModifierTargetMixin` metadata. `UnsupportedClinicalEventModelError` is raised before SQL execution when that metadata is incomplete. The [query contracts](query-contracts.md) explain how the projected shape participates in episode attachment. ## Work with a patient timeline diff --git a/docs/toolkit/episodes.md b/docs/toolkit/episodes.md index 82cb381..dbb3608 100644 --- a/docs/toolkit/episodes.md +++ b/docs/toolkit/episodes.md @@ -79,6 +79,29 @@ Use `ResolvedEpisodeEventMixin` on an episode view when diagnostics should be av ::: omop_alchemy.toolkit.episodes.handling +## Traverse an episode hierarchy + +For a parent episode, `episode_descendants()` returns a recursive CTE containing the root at depth zero and each descendant at its distance from that root: + +```python +from sqlalchemy import select + +from omop_alchemy.toolkit.episodes.derivation import episode_descendants + +hierarchy = episode_descendants(root_episode_id=episode_id) +statement = select( + hierarchy.c.episode_id, + hierarchy.c.episode_parent_id, + hierarchy.c.depth, +).order_by(hierarchy.c.depth, hierarchy.c.episode_id) + +rows = session.execute(statement).mappings().all() +``` + +Traversal follows parent IDs only within the same person and stops at a configurable maximum depth, which bounds malformed cyclic data. Set `include_root=False` when only descendants are needed. `direct_episode_relationship_projection()` provides a non-recursive parent-child result for callers that need one level only. + +`episode_event_hierarchy_projection()` joins the hierarchy to `Episode_Event` and retains the root episode, the episode that owns the link, and its depth. This lets a caller include child-linked evidence without encoding a specialty-specific number of child levels. + ## Describe episode attachment policy The derivation package provides declarative types for code that assigns events to episodes. The types keep four choices visible: whether explicit links take precedence, whether fallback may return one or several episodes, which side of an anchor date is preferred, and how candidates within that preference are ranked. @@ -101,6 +124,6 @@ ranking = TemporalRankingSpec( ) ``` -These objects state query semantics but do not build or execute SQL. See [Query contracts](query-contracts.md) for the complete event shape, attachment examples, boundaries, repeated-observation selection, and the distinction between absolute-nearest and already-started-first ranking. +The policy objects do not perform attachment themselves. `episode_window_predicate()`, `temporal_order_expressions()`, and `temporal_row_number()` build the portable SQL pieces needed by an attachment query on PostgreSQL or SQLite. A caller still owns the joins that validate explicit links and apply the chosen fallback cardinality. See [Query contracts](query-contracts.md) for the complete event shape, attachment examples, boundaries, repeated-observation selection, and the distinction between absolute-nearest and already-started-first ranking. ::: omop_alchemy.toolkit.episodes.derivation diff --git a/docs/toolkit/query-contracts.md b/docs/toolkit/query-contracts.md index 2699e84..c5379e1 100644 --- a/docs/toolkit/query-contracts.md +++ b/docs/toolkit/query-contracts.md @@ -2,10 +2,10 @@ Consider a procedure recorded on the same day as two overlapping treatment episodes. The procedure may already have a valid `Episode_Event` link, or it may need to be assigned from dates alone. A reliable query has to answer several questions explicitly: what identifies the procedure, whether an explicit link takes precedence, whether fallback may attach it to one or both episodes, and how equally plausible candidates are ordered. -The contracts on this page provide a common vocabulary for those decisions. They are small, immutable values that can be shared by query-building code, configuration, and tests without opening a database connection. +The contracts on this page provide a common vocabulary for those decisions. They are small, immutable values that can be shared by query-building code, configuration, and tests. Projection and predicate helpers translate the contracts into SQLAlchemy statements without opening a database connection. -!!! note "Declarative API" - These contracts describe result shape and selection policy. They do not currently construct or execute SQL. Code that consumes them remains responsible for applying the declared policy to a query. +!!! note "Construction and execution" + Creating a contract, statement, CTE, or predicate is side-effect free. Database access begins only when the resulting statement is executed through a connection or session. The toolkit supplies projection, hierarchy, temporal, concept-set, and observation builders; complete explicit-first event attachment remains the caller's query until a dedicated attachment builder is available. ## Start with event identity @@ -45,6 +45,21 @@ Numeric value, value concept, and unit labels are available through `CANONICAL_E The Field concept is not interchangeable with the event's clinical concept. For example, a Procedure Occurrence projection uses the Field concept for `procedure_occurrence.procedure_occurrence_id` as its discriminator and the row's `procedure_concept_id` as its clinical concept. +Build one shared event stream by passing the source models to `canonical_event_union()`: + +```python +from omop_alchemy.cdm.model import Measurement, Observation, Procedure_Occurrence +from omop_alchemy.toolkit.core.events import canonical_event_union + +events = canonical_event_union( + Measurement, + Observation, + Procedure_Occurrence, +).subquery("clinical_events") +``` + +All branches expose the same labels. The source table and Field concept are literals derived from model metadata, so they remain available after the tables are combined. + ## Attach an event to an episode A complete attachment key adds the episode ID to the table-scoped event identity: @@ -113,6 +128,22 @@ already_started_first = TemporalRankingSpec( This policy selects episode 1001. Absolute distance still orders episodes within the preferred side; it simply does not allow a closer future episode to outrank every episode that had already started. `on_or_after_anchor` expresses the corresponding future-first rule. +Apply the policy to SQLAlchemy columns with `temporal_order_expressions()`: + +```python +from omop_alchemy.cdm.model.structural import Episode +from omop_alchemy.toolkit.episodes.derivation import temporal_order_expressions + +ordering = temporal_order_expressions( + Episode.episode_start_date, + events.c.event_date, + Episode.episode_id, + already_started_first, +) + +candidate_episodes = candidate_episodes.order_by(*ordering) +``` + `earliest` and `latest` are available when chronological position, rather than distance from the anchor, defines the result. Every policy ends with the named stable ID column in ascending order. If episodes 1001 and 1002 are otherwise tied, 1001 wins consistently rather than relying on database return order. ### Date boundaries @@ -121,6 +152,19 @@ Lower and upper bounds are inclusive by default and can be changed independently Boundary choices belong in the ranking specification rather than being hidden in a comparison operator. This is especially important when two systems use similar-looking windows but disagree at exactly 90 or 180 days. +`episode_window_predicate()` uses the same finite defaults as the in-memory episode window. It honours a recorded episode end and substitutes a bounded post-start end only when the end is missing: + +```python +from omop_alchemy.toolkit.episodes.derivation import episode_window_predicate + +inside_episode_window = episode_window_predicate( + events.c.event_date, + Episode.episode_start_date, + Episode.episode_end_date, + ranking=already_started_first, +) +``` + ## Select one repeated observation Repeated observations need the same explicit treatment of direction, grouping, and ties. For an anchor date of 20 January, suppose a person has these rows: @@ -148,6 +192,25 @@ selection = ObservationSelectionSpec( ) ``` +Use `ranked_observation_select()` to apply the anchor filter before calculating row numbers: + +```python +from datetime import date + +from sqlalchemy import literal, select + +from omop_alchemy.cdm.model import Observation +from omop_alchemy.toolkit.episodes.derivation import ranked_observation_select + +ranked = ranked_observation_select( + Observation.__table__, + selection, + anchor_date=literal(date(2026, 1, 20)), +).subquery("ranked_observations") + +selected = select(ranked).where(ranked.c.observation_rank == 1) +``` + Observation 24 is after the anchor and is therefore excluded. Observations 22 and 23 tie on date, so the stable ID selects 22. That tie-break creates reproducible output; it does not claim that one same-day clinical value is more correct. If every same-day value is meaningful, retain them by choosing a result grain that includes the observation ID instead of reducing the group to one row. Add `episode_id` or another field to `partition_by` when selection must occur separately within those groups. The partition is part of the clinical meaning of the result, not merely an optimisation detail. @@ -178,9 +241,42 @@ AND NOT (descendants of 400 OR exact concept 901) Exclusion wins when a concept is reached from both sides. With no inclusion, the set matches nothing. IDs are sorted and deduplicated when the specification is created. -`require_standard` and `include_classification` use the same vocabulary as `ConceptFilter` and `ConceptGroupSpec`; consuming SQL should delegate to the existing normalised OMOP standardness expressions. The specification does not decide whether a numeric ID is valid in a particular vocabulary. Validate configuration and local-concept policy at the boundary where those rules are known. +`require_standard` and `include_classification` use the same vocabulary as `ConceptFilter` and `ConceptGroupSpec`; predicate rendering delegates to the existing normalised OMOP standardness expressions. The specification does not decide whether a numeric ID is valid in a particular vocabulary. Validate configuration and local-concept policy at the boundary where those rules are known. + +`runtime_concept_predicate()` translates the specification into database-side `concept_ancestor` and `concept` predicates: + +```python +from sqlalchemy import select + +from omop_alchemy.cdm.model import Procedure_Occurrence +from omop_alchemy.toolkit.core.concepts import runtime_concept_predicate + +matching_procedures = select(Procedure_Occurrence).where( + runtime_concept_predicate( + Procedure_Occurrence.procedure_concept_id, + concepts, + ) +) +``` + +Constructing the specification or predicate performs no hierarchy expansion and no database access. Descendants are resolved by the database when the surrounding statement is executed. + +Some applications compose positive and negative rules independently rather than collecting them into one runtime set. `descendant_concept_select()` provides the lower-level hierarchy operation for that case and returns each matching descendant once: + +```python +from omop_alchemy.toolkit.core.concepts import descendant_concept_select + +matching_procedures = select(Procedure_Occurrence).where( + Procedure_Occurrence.procedure_concept_id.in_( + descendant_concept_select((100, 200)) + ), + Procedure_Occurrence.procedure_concept_id.not_in( + descendant_concept_select((400,)) + ), +) +``` -Constructing the specification performs no hierarchy expansion and no database access. A consumer must translate it into predicates over `concept_ancestor` and, when standardness filtering is requested, `concept`. +Use `RuntimeConceptSetSpec` when the inclusions and exclusions form one configured set with exclusion precedence. Use `descendant_concept_select()` when the surrounding query or rule model owns how separate predicates are combined. ## API reference diff --git a/omop_alchemy/cdm/model/clinical/measurement.py b/omop_alchemy/cdm/model/clinical/measurement.py index aa94954..8d2bb87 100644 --- a/omop_alchemy/cdm/model/clinical/measurement.py +++ b/omop_alchemy/cdm/model/clinical/measurement.py @@ -8,6 +8,8 @@ from orm_loader.helpers import Base from omop_alchemy.cdm.base import ( CDMTableBase, + ModifierFieldConcepts, + ModifierTargetMixin, cdm_table, ValueMixin, merge_table_args, @@ -15,8 +17,13 @@ ) @cdm_table -class Measurement(Base, CDMTableBase, ValueMixin): +class Measurement(Base, CDMTableBase, ValueMixin, ModifierTargetMixin): __tablename__ = "measurement" + __event_id_col__ = "measurement_id" + __concept_id_col__ = "measurement_concept_id" + __start_date_col__ = "measurement_date" + __end_date_col__ = "measurement_date" + __type_concept_id_col__ = "measurement_type_concept_id" __table_args__ = merge_table_args( omop_index(__tablename__, "person_id", cluster=True), omop_index(__tablename__, "measurement_concept_id"), @@ -58,3 +65,7 @@ def modifier_of_event_id(self) -> Optional[int]: @hybrid_property def modifier_of_field_concept_id(self) -> Optional[int]: return self.meas_event_field_concept_id + + @classmethod + def modifier_field_concept_id(cls) -> int: + return ModifierFieldConcepts.MEASUREMENT diff --git a/omop_alchemy/cdm/model/clinical/observation.py b/omop_alchemy/cdm/model/clinical/observation.py index e71bab4..b924ca1 100644 --- a/omop_alchemy/cdm/model/clinical/observation.py +++ b/omop_alchemy/cdm/model/clinical/observation.py @@ -8,6 +8,8 @@ from orm_loader.helpers import Base from omop_alchemy.cdm.base import ( CDMTableBase, + ModifierFieldConcepts, + ModifierTargetMixin, cdm_table, ValueMixin, merge_table_args, @@ -15,8 +17,13 @@ ) @cdm_table -class Observation(Base, CDMTableBase, ValueMixin): +class Observation(Base, CDMTableBase, ValueMixin, ModifierTargetMixin): __tablename__ = "observation" + __event_id_col__ = "observation_id" + __concept_id_col__ = "observation_concept_id" + __start_date_col__ = "observation_date" + __end_date_col__ = "observation_date" + __type_concept_id_col__ = "observation_type_concept_id" __table_args__ = merge_table_args( omop_index(__tablename__, "person_id", cluster=True), omop_index(__tablename__, "observation_concept_id"), @@ -53,3 +60,7 @@ def modifier_of_event_id(self) -> Optional[int]: @hybrid_property def modifier_of_field_concept_id(self) -> Optional[int]: return self.obs_event_field_concept_id + + @classmethod + def modifier_field_concept_id(cls) -> int: + return ModifierFieldConcepts.OBSERVATION diff --git a/omop_alchemy/cdm/query.py b/omop_alchemy/cdm/query.py index 724e705..834d632 100644 --- a/omop_alchemy/cdm/query.py +++ b/omop_alchemy/cdm/query.py @@ -59,7 +59,7 @@ def __post_init__(self) -> None: ) def apply(self, query: sa.Select) -> sa.Select: - """Apply filter constraints to a Select already targeting Concept.""" + """Apply filter constraints to a Select whose FROM clause includes Concept.""" if self.concept_ids is not None: query = query.where(Concept.concept_id.in_(self.concept_ids)) diff --git a/omop_alchemy/toolkit/core/concepts/__init__.py b/omop_alchemy/toolkit/core/concepts/__init__.py index 06d9dd9..3eacd2c 100644 --- a/omop_alchemy/toolkit/core/concepts/__init__.py +++ b/omop_alchemy/toolkit/core/concepts/__init__.py @@ -99,7 +99,11 @@ concept_group_registry, resolve_concept_group, ) -from .runtime import RuntimeConceptSetSpec +from .runtime import ( + RuntimeConceptSetSpec, + descendant_concept_select, + runtime_concept_predicate, +) __all__ = [ "DEFAULT_MAX_CACHE_BYTES", @@ -124,6 +128,8 @@ "normalize_default", "register_vocabulary_identity", "resolve_concept_group", + "descendant_concept_select", + "runtime_concept_predicate", "site_to_NOS", "strip_uicc", ] diff --git a/omop_alchemy/toolkit/core/concepts/groups.py b/omop_alchemy/toolkit/core/concepts/groups.py index f3fed7a..3cd9a54 100644 --- a/omop_alchemy/toolkit/core/concepts/groups.py +++ b/omop_alchemy/toolkit/core/concepts/groups.py @@ -23,12 +23,12 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import Any, Iterable +from typing import Any import sqlalchemy as sa import sqlalchemy.orm as so -from omop_alchemy.cdm.model import Concept_Ancestor +from .runtime import descendant_concept_select @dataclass(frozen=True) @@ -107,7 +107,7 @@ def expression_for( if parents and self.include_descendants: expr = column.in_( - _descendant_select( + descendant_concept_select( parents, require_standard=self.require_standard, include_classification=self.include_classification, @@ -116,7 +116,7 @@ def expression_for( excluded = self.excluded_parent_ids() if excluded: expr = expr & column.not_in( - _descendant_select( + descendant_concept_select( excluded, require_standard=self.require_standard, include_classification=self.include_classification, @@ -134,30 +134,6 @@ def expression_for( return sa.or_(*clauses) -def _descendant_select( - parents: Iterable[int], - *, - require_standard: bool, - include_classification: bool = True, -) -> sa.Select: - stmt = sa.select(Concept_Ancestor.descendant_concept_id).where( - Concept_Ancestor.ancestor_concept_id.in_(tuple(parents)) - ) - if require_standard: - from omop_alchemy.cdm.model.vocabulary import Concept - - standardness = ( - sa.or_(Concept.is_standard_expr(), Concept.is_classification_expr()) - if include_classification - else Concept.is_standard_expr() - ) - stmt = stmt.join( - Concept, - Concept.concept_id == Concept_Ancestor.descendant_concept_id, - ).where(standardness) - return stmt - - @dataclass(frozen=True) class ResolvedConceptGroup: """A governed group expanded against one vocabulary. @@ -217,7 +193,7 @@ def build_concept_group( if parents: if spec.include_descendants: - stmt = _descendant_select( + stmt = descendant_concept_select( parents, require_standard=spec.require_standard, include_classification=spec.include_classification, @@ -225,7 +201,7 @@ def build_concept_group( ids |= set(session.execute(stmt).scalars().all()) excluded = spec.excluded_parent_ids() if excluded: - stmt = _descendant_select( + stmt = descendant_concept_select( excluded, require_standard=spec.require_standard, include_classification=spec.include_classification, diff --git a/omop_alchemy/toolkit/core/concepts/runtime.py b/omop_alchemy/toolkit/core/concepts/runtime.py index 9bdd58c..dc839e4 100644 --- a/omop_alchemy/toolkit/core/concepts/runtime.py +++ b/omop_alchemy/toolkit/core/concepts/runtime.py @@ -3,13 +3,18 @@ ``ConceptGroupSpec`` is the right contract for governed omop-semantics units. ``RuntimeConceptSetSpec`` complements it for IDs supplied by configuration at runtime. It records intent without expanding vocabulary hierarchies or touching -a database; a later query builder renders the corresponding SQL predicate. +a database; ``runtime_concept_predicate`` renders the corresponding SQL. """ from __future__ import annotations from dataclasses import dataclass -from typing import Iterable +from typing import Any, Iterable + +import sqlalchemy as sa + +from omop_alchemy.cdm.model.vocabulary import Concept, Concept_Ancestor +from omop_alchemy.cdm.query import ConceptFilter def _normalise_concept_ids(values: Iterable[int]) -> tuple[int, ...]: @@ -31,7 +36,7 @@ class RuntimeConceptSetSpec: side-effect free and preserves no session-bound vocabulary objects. ``require_standard`` and ``include_classification`` deliberately match - ``ConceptFilter`` and ``ConceptGroupSpec``. A future renderer delegates to + ``ConceptFilter`` and ``ConceptGroupSpec``. Predicate rendering delegates to the existing normalised ``Concept`` flag expressions rather than defining another interpretation of OMOP's single-character standardness flags. @@ -61,10 +66,76 @@ def __post_init__(self) -> None: @property def has_inclusions(self) -> bool: - """Whether the future predicate can match at least one configured input.""" + """Whether the predicate can match at least one configured input.""" return bool(self.include_ancestor_ids or self.include_exact_ids) - @property - def requires_concept_join(self) -> bool: - """Whether standardness filtering requires the Concept table.""" - return self.require_standard + +def descendant_concept_select( + ancestor_ids: Iterable[int], + *, + require_standard: bool = False, + include_classification: bool = True, +) -> sa.Select[Any]: + """Select each descendant ID once for the supplied ancestors.""" + statement = ( + sa.select(Concept_Ancestor.descendant_concept_id) + .where(Concept_Ancestor.ancestor_concept_id.in_(tuple(ancestor_ids))) + .distinct() + ) + if not require_standard: + return statement + + statement = statement.join( + Concept, + Concept.concept_id == Concept_Ancestor.descendant_concept_id, + ) + return ConceptFilter( + require_standard=True, + include_classification=include_classification, + ).apply(statement) + + +def _concept_set_side( + column: sa.SQLColumnExpression[Any], + *, + ancestor_ids: tuple[int, ...], + exact_ids: tuple[int, ...], +) -> sa.ColumnElement[bool]: + clauses: list[sa.ColumnElement[bool]] = [] + if ancestor_ids: + clauses.append(column.in_(descendant_concept_select(ancestor_ids))) + if exact_ids: + clauses.append(column.in_(exact_ids)) + return sa.or_(*clauses) if clauses else sa.false() + + +def runtime_concept_predicate( + column: sa.SQLColumnExpression[Any], + spec: RuntimeConceptSetSpec, +) -> sa.ColumnElement[bool]: + """Render runtime concept membership entirely as database predicates.""" + included = _concept_set_side( + column, + ancestor_ids=spec.include_ancestor_ids, + exact_ids=spec.include_exact_ids, + ) + if not spec.has_inclusions: + return sa.false() + + excluded = _concept_set_side( + column, + ancestor_ids=spec.exclude_ancestor_ids, + exact_ids=spec.exclude_exact_ids, + ) + predicate = sa.and_(included, sa.not_(excluded)) + + if spec.require_standard: + standard_concept_ids = ConceptFilter( + require_standard=True, + include_classification=spec.include_classification, + ).apply(sa.select(Concept.concept_id)) + predicate = sa.and_( + predicate, + column.in_(standard_concept_ids), + ) + return predicate diff --git a/omop_alchemy/toolkit/core/events/__init__.py b/omop_alchemy/toolkit/core/events/__init__.py index f9296fd..6956a35 100644 --- a/omop_alchemy/toolkit/core/events/__init__.py +++ b/omop_alchemy/toolkit/core/events/__init__.py @@ -1,9 +1,9 @@ """Canonical, domain-neutral clinical-event identities and row shapes. Event tables use different native column names, but cross-table analytical -queries need one stable vocabulary. These contracts are shared by timeline -adapters in ``core`` and episode query builders in the higher ``episodes`` -tier. They are declarative and perform no database work. +queries need one stable vocabulary. This area provides both the shared row +contracts and SQLAlchemy projections. Building a projection is side-effect free; +the database is accessed only when a caller executes the returned statement. """ from .contracts import ( @@ -14,12 +14,24 @@ ClinicalEventRow, ValuedClinicalEventRow, ) +from .projections import ( + ClinicalEventModelSpec, + UnsupportedClinicalEventModelError, + canonical_event_projection, + canonical_event_union, + clinical_event_model_spec, +) __all__ = [ "CANONICAL_EVENT_OPTIONAL_COLUMNS", "CANONICAL_EVENT_REQUIRED_COLUMNS", "ClinicalEventColumn", "ClinicalEventIdentity", + "ClinicalEventModelSpec", "ClinicalEventRow", "ValuedClinicalEventRow", + "UnsupportedClinicalEventModelError", + "canonical_event_projection", + "canonical_event_union", + "clinical_event_model_spec", ] diff --git a/omop_alchemy/toolkit/core/events/projections.py b/omop_alchemy/toolkit/core/events/projections.py new file mode 100644 index 0000000..76e599a --- /dev/null +++ b/omop_alchemy/toolkit/core/events/projections.py @@ -0,0 +1,195 @@ +"""SQLAlchemy projections for a consistent cross-table clinical-event shape.""" + +from __future__ import annotations + +from collections.abc import Iterator +from dataclasses import dataclass +from typing import Any + +import sqlalchemy as sa + +from omop_alchemy.cdm.base import ModifierTargetMixin + +from .contracts import ClinicalEventColumn + + +class UnsupportedClinicalEventModelError(TypeError): + """Raised when a model cannot provide a canonical clinical-event projection.""" + + def __init__(self, model: object, reason: str) -> None: + self.model = model + self.reason = reason + name = getattr(model, "__name__", repr(model)) + super().__init__(f"{name} is not a supported clinical-event model: {reason}") + + +@dataclass(frozen=True, slots=True) +class ClinicalEventModelSpec: + """Resolved model metadata used to build a canonical event projection.""" + + event_id_column: str + event_concept_id_column: str + event_date_column: str + event_datetime_column: str | None + event_field_concept_id: int + event_source_table: str + + +def _subclasses(model: type[Any]) -> Iterator[type[Any]]: + for subclass in model.__subclasses__(): + yield subclass + yield from _subclasses(subclass) + + +def _metadata_candidate(model: type[Any]) -> type[ModifierTargetMixin] | None: + candidates = [model, *_subclasses(model)] + supported: list[type[ModifierTargetMixin]] = [] + for candidate in candidates: + if not issubclass(candidate, ModifierTargetMixin): + continue + required_names = ( + "__event_id_col__", + "__concept_id_col__", + "__start_date_col__", + ) + if any(not getattr(candidate, name, None) for name in required_names): + continue + try: + candidate.modifier_field_concept_id() + except NotImplementedError: + continue + supported.append(candidate) + + if not supported: + return None + supported.sort( + key=lambda candidate: ( + candidate is not model, + -len(candidate.mro()), + f"{candidate.__module__}.{candidate.__qualname__}", + ) + ) + return supported[0] + + +def _datetime_column_name(model: type[Any], date_column_name: str) -> str | None: + if date_column_name.endswith("_date"): + candidate = f"{date_column_name[:-5]}_datetime" + if hasattr(model, candidate): + return candidate + return None + + +def clinical_event_model_spec(model: type[Any]) -> ClinicalEventModelSpec: + """Resolve the event metadata for an ORM model without accessing a database.""" + if not isinstance(model, type) or not hasattr(model, "__table__"): + raise UnsupportedClinicalEventModelError(model, "expected a mapped ORM model class") + + metadata_model = _metadata_candidate(model) + if metadata_model is None: + raise UnsupportedClinicalEventModelError( + model, + "no complete ModifierTargetMixin metadata is available", + ) + + event_id_column = metadata_model.__event_id_col__ + event_concept_id_column = metadata_model.__concept_id_col__ + event_date_column = metadata_model.__start_date_col__ + required_columns = ( + event_id_column, + event_concept_id_column, + event_date_column, + "person_id", + ) + missing = tuple(name for name in required_columns if not hasattr(model, name)) + if missing: + raise UnsupportedClinicalEventModelError( + model, + f"missing required columns: {', '.join(missing)}", + ) + + try: + field_concept_id = metadata_model.modifier_field_concept_id() + except NotImplementedError as error: + raise UnsupportedClinicalEventModelError( + model, + "modifier Field concept is not defined", + ) from error + + return ClinicalEventModelSpec( + event_id_column=event_id_column, + event_concept_id_column=event_concept_id_column, + event_date_column=event_date_column, + event_datetime_column=_datetime_column_name(model, event_date_column), + event_field_concept_id=field_concept_id, + event_source_table=metadata_model.modifier_target_table(), + ) + + +def _nullable_column( + model: type[Any], + name: ClinicalEventColumn, + sql_type: sa.types.TypeEngine[Any], +) -> sa.ColumnElement[Any]: + column = getattr(model, str(name), None) + if column is None: + return sa.cast(sa.null(), sql_type).label(str(name)) + return column.label(str(name)) + + +def canonical_event_projection( + model: type[Any], + *, + include_values: bool = True, +) -> sa.Select[Any]: + """Project one supported OMOP event model to canonical event columns.""" + spec = clinical_event_model_spec(model) + event_datetime = ( + getattr(model, spec.event_datetime_column) + if spec.event_datetime_column is not None + else sa.cast(sa.null(), sa.DateTime) + ) + columns: list[sa.ColumnElement[Any]] = [ + model.person_id.label(str(ClinicalEventColumn.person_id)), + getattr(model, spec.event_id_column).label(str(ClinicalEventColumn.event_id)), + getattr(model, spec.event_date_column).label(str(ClinicalEventColumn.event_date)), + event_datetime.label(str(ClinicalEventColumn.event_datetime)), + getattr(model, spec.event_concept_id_column).label( + str(ClinicalEventColumn.event_concept_id) + ), + sa.literal(spec.event_field_concept_id).label( + str(ClinicalEventColumn.event_field_concept_id) + ), + sa.literal(spec.event_source_table).label( + str(ClinicalEventColumn.event_source_table) + ), + ] + if include_values: + columns.extend( + ( + _nullable_column(model, ClinicalEventColumn.value_as_number, sa.Float()), + _nullable_column( + model, + ClinicalEventColumn.value_as_concept_id, + sa.Integer(), + ), + _nullable_column(model, ClinicalEventColumn.unit_concept_id, sa.Integer()), + ) + ) + return sa.select(*columns) + + +def canonical_event_union( + *models: type[Any], + include_values: bool = True, +) -> sa.Select[Any] | sa.CompoundSelect[Any]: + """Combine supported event models into one canonical ``UNION ALL`` query.""" + if not models: + raise ValueError("canonical_event_union requires at least one model") + projections = [ + canonical_event_projection(model, include_values=include_values) + for model in models + ] + if len(projections) == 1: + return projections[0] + return sa.union_all(*projections) diff --git a/omop_alchemy/toolkit/episodes/derivation/__init__.py b/omop_alchemy/toolkit/episodes/derivation/__init__.py index ed64bd3..0491ab4 100644 --- a/omop_alchemy/toolkit/episodes/derivation/__init__.py +++ b/omop_alchemy/toolkit/episodes/derivation/__init__.py @@ -11,13 +11,14 @@ The public contracts in this area define episode-attachment identities and policies used by query builders. Shared clinical-event row names and identities -live in ``toolkit.core.events``. All are declarative and perform no database -work, so downstream packages can agree on semantics before changing clinical -queries. +live in ``toolkit.core.events``. Projection and ranking helpers return SQLAlchemy +statements or expressions without executing them. """ from .contracts import ( + CANONICAL_EPISODE_COLUMNS, AttachmentDiagnosticCode, + EpisodeColumn, EpisodeAttachmentDiagnostic, EpisodeAttachmentIdentity, EpisodeAttachmentPolicy, @@ -27,9 +28,33 @@ TemporalSelectionPolicy, TemporalSidePreference, ) +from .observations import ( + observation_eligibility_predicate, + observation_order_expressions, + observation_row_number, + ranked_observation_select, +) +from .structure import ( + canonical_episode_projection, + direct_episode_relationship_projection, + episode_descendants, + episode_event_hierarchy_projection, +) +from .temporal import ( + absolute_day_delta, + bounded_temporal_predicate, + episode_window_bounds, + episode_window_predicate, + shift_date, + signed_day_delta, + temporal_order_expressions, + temporal_row_number, +) __all__ = [ "AttachmentDiagnosticCode", + "CANONICAL_EPISODE_COLUMNS", + "EpisodeColumn", "EpisodeAttachmentDiagnostic", "EpisodeAttachmentIdentity", "EpisodeAttachmentPolicy", @@ -38,4 +63,20 @@ "TemporalRankingSpec", "TemporalSelectionPolicy", "TemporalSidePreference", + "absolute_day_delta", + "bounded_temporal_predicate", + "canonical_episode_projection", + "direct_episode_relationship_projection", + "episode_descendants", + "episode_event_hierarchy_projection", + "episode_window_bounds", + "episode_window_predicate", + "observation_eligibility_predicate", + "observation_order_expressions", + "observation_row_number", + "ranked_observation_select", + "shift_date", + "signed_day_delta", + "temporal_order_expressions", + "temporal_row_number", ] diff --git a/omop_alchemy/toolkit/episodes/derivation/contracts.py b/omop_alchemy/toolkit/episodes/derivation/contracts.py index 7e41f00..1c15f15 100644 --- a/omop_alchemy/toolkit/episodes/derivation/contracts.py +++ b/omop_alchemy/toolkit/episodes/derivation/contracts.py @@ -13,6 +13,25 @@ from omop_alchemy.toolkit.core.events import ClinicalEventIdentity +class EpisodeColumn(StrEnum): + """Canonical labels emitted by an episode projection.""" + + episode_id = "episode_id" + person_id = "person_id" + episode_parent_id = "episode_parent_id" + episode_start_date = "episode_start_date" + episode_start_datetime = "episode_start_datetime" + episode_end_date = "episode_end_date" + episode_end_datetime = "episode_end_datetime" + episode_concept_id = "episode_concept_id" + episode_object_concept_id = "episode_object_concept_id" + episode_type_concept_id = "episode_type_concept_id" + + +CANONICAL_EPISODE_COLUMNS: tuple[EpisodeColumn, ...] = tuple(EpisodeColumn) +"""Columns exposed by the canonical episode projection.""" + + @dataclass(frozen=True, order=True, slots=True) class EpisodeAttachmentIdentity: """Unique identity of one event attached to one episode.""" @@ -117,7 +136,7 @@ class TemporalSidePreference(StrEnum): @dataclass(frozen=True, slots=True) class TemporalRankingSpec: - """Temporal ranking and boundary contract for future SQL builders. + """Temporal ranking and boundary contract for SQL builders. A side preference, when present, is applied before the selection policy. ``nearest`` then means the smallest absolute distance within that tier. diff --git a/omop_alchemy/toolkit/episodes/derivation/observations.py b/omop_alchemy/toolkit/episodes/derivation/observations.py new file mode 100644 index 0000000..7d7dcac --- /dev/null +++ b/omop_alchemy/toolkit/episodes/derivation/observations.py @@ -0,0 +1,97 @@ +"""Reusable SQL selection for repeated longitudinal observations.""" + +from __future__ import annotations + +from typing import Any + +import sqlalchemy as sa + +from .contracts import ObservationSelectionPolicy, ObservationSelectionSpec + + +def observation_eligibility_predicate( + observation_date: sa.ColumnElement[Any], + spec: ObservationSelectionSpec, + *, + anchor_date: sa.ColumnElement[Any] | None = None, +) -> sa.ColumnElement[bool]: + """Return the date predicate required by an observation selection policy.""" + if not spec.requires_anchor: + return sa.true() + if anchor_date is None: + raise ValueError(f"{spec.policy} requires anchor_date") + return ( + observation_date <= anchor_date + if spec.include_anchor_date + else observation_date < anchor_date + ) + + +def observation_order_expressions( + observation_date: sa.ColumnElement[Any], + stable_id: sa.ColumnElement[Any], + spec: ObservationSelectionSpec, +) -> tuple[sa.ColumnElement[Any], ...]: + """Build date and stable-ID ordering for repeated observations.""" + if spec.policy is ObservationSelectionPolicy.earliest: + date_order = observation_date.asc() + elif spec.policy in ( + ObservationSelectionPolicy.latest, + ObservationSelectionPolicy.latest_on_or_before_anchor, + ): + date_order = observation_date.desc() + else: # pragma: no cover - StrEnum construction prevents unknown policies + raise ValueError(f"Unsupported observation selection policy: {spec.policy}") + return date_order, stable_id.asc() + + +def observation_row_number( + columns: Any, + *, + observation_date_column: str, + spec: ObservationSelectionSpec, + label: str = "observation_rank", +) -> sa.ColumnElement[int]: + """Return a deterministic row number using the declared observation grain.""" + try: + observation_date = columns[observation_date_column] + stable_id = columns[spec.stable_id_column] + partition_by = tuple(columns[name] for name in spec.partition_by) + except KeyError as error: + raise ValueError(f"Observation selection column is missing: {error.args[0]}") from error + return sa.func.row_number().over( + partition_by=partition_by, + order_by=observation_order_expressions(observation_date, stable_id, spec), + ).label(label) + + +def ranked_observation_select( + source: sa.FromClause, + spec: ObservationSelectionSpec, + *, + observation_date_column: str = "observation_date", + anchor_date: sa.ColumnElement[Any] | None = None, + rank_label: str = "observation_rank", +) -> sa.Select[Any]: + """Select source columns with deterministic observation rank and eligibility.""" + columns = source.c + try: + observation_date = columns[observation_date_column] + except KeyError as error: + raise ValueError( + f"Observation selection column is missing: {observation_date_column}" + ) from error + + rank = observation_row_number( + columns, + observation_date_column=observation_date_column, + spec=spec, + label=rank_label, + ) + return sa.select(*columns, rank).where( + observation_eligibility_predicate( + observation_date, + spec, + anchor_date=anchor_date, + ) + ) diff --git a/omop_alchemy/toolkit/episodes/derivation/structure.py b/omop_alchemy/toolkit/episodes/derivation/structure.py new file mode 100644 index 0000000..10ed659 --- /dev/null +++ b/omop_alchemy/toolkit/episodes/derivation/structure.py @@ -0,0 +1,125 @@ +"""Domain-neutral projections over episode hierarchies and linked events.""" + +from __future__ import annotations + +from typing import Any + +import sqlalchemy as sa + +from omop_alchemy.cdm.model.structural import Episode, Episode_Event + +from .contracts import CANONICAL_EPISODE_COLUMNS + + +def canonical_episode_projection( + episode_model: type[Episode] = Episode, +) -> sa.Select[Any]: + """Project the stable fields needed to identify and interpret an episode.""" + return sa.select( + *( + getattr(episode_model, str(column)).label(str(column)) + for column in CANONICAL_EPISODE_COLUMNS + ) + ) + + +def direct_episode_relationship_projection( + episode_model: type[Episode] = Episode, +) -> sa.Select[Any]: + """Select direct parent-child pairs with a depth of one.""" + episodes = episode_model.__table__ + parent = episodes.alias("parent_episode") + child = episodes.alias("child_episode") + return sa.select( + parent.c.episode_id.label("root_episode_id"), + child.c.episode_id.label("episode_id"), + child.c.episode_parent_id.label("episode_parent_id"), + child.c.person_id.label("person_id"), + sa.literal(1).label("depth"), + ).select_from( + parent.join( + child, + sa.and_( + child.c.episode_parent_id == parent.c.episode_id, + child.c.person_id == parent.c.person_id, + ), + ) + ) + + +def episode_descendants( + *, + root_episode_id: int | sa.ColumnElement[Any] | None = None, + episode_model: type[Episode] = Episode, + include_root: bool = True, + max_depth: int = 100, + name: str = "episode_descendants", +) -> sa.CTE: + """Build a recursive root-to-descendant projection with bounded depth.""" + if max_depth < 0: + raise ValueError("max_depth must be non-negative") + + episodes = episode_model.__table__ + seed = sa.select( + episodes.c.episode_id.label("root_episode_id"), + episodes.c.episode_id.label("episode_id"), + episodes.c.episode_parent_id.label("episode_parent_id"), + episodes.c.person_id.label("person_id"), + sa.literal(0).label("depth"), + ) + if root_episode_id is not None: + seed = seed.where(episodes.c.episode_id == root_episode_id) + + hierarchy = seed.cte(f"{name}_walk", recursive=True) + child = episodes.alias(f"{name}_child") + hierarchy = hierarchy.union_all( + sa.select( + hierarchy.c.root_episode_id, + child.c.episode_id, + child.c.episode_parent_id, + child.c.person_id, + (hierarchy.c.depth + 1).label("depth"), + ) + .select_from( + hierarchy.join( + child, + sa.and_( + child.c.episode_parent_id == hierarchy.c.episode_id, + child.c.person_id == hierarchy.c.person_id, + ), + ) + ) + .where(hierarchy.c.depth < max_depth) + ) + if include_root: + return hierarchy + return sa.select(*hierarchy.c).where(hierarchy.c.depth > 0).cte(name) + + +def episode_event_hierarchy_projection( + *, + root_episode_id: int | sa.ColumnElement[Any] | None = None, + episode_model: type[Episode] = Episode, + episode_event_model: type[Episode_Event] = Episode_Event, + include_root: bool = True, + max_depth: int = 100, +) -> sa.Select[Any]: + """Select linked events with their root episode, owning episode, and depth.""" + hierarchy = episode_descendants( + root_episode_id=root_episode_id, + episode_model=episode_model, + include_root=include_root, + max_depth=max_depth, + name="episode_event_descendants", + ) + event = episode_event_model.__table__ + return sa.select( + hierarchy.c.root_episode_id, + hierarchy.c.episode_id.label("linked_episode_id"), + hierarchy.c.person_id, + hierarchy.c.depth.label("episode_depth"), + event.c.event_id, + event.c.episode_event_field_concept_id.label("event_field_concept_id"), + ).select_from( + hierarchy.join(event, event.c.episode_id == hierarchy.c.episode_id) + ) diff --git a/omop_alchemy/toolkit/episodes/derivation/temporal.py b/omop_alchemy/toolkit/episodes/derivation/temporal.py new file mode 100644 index 0000000..6a1a68b --- /dev/null +++ b/omop_alchemy/toolkit/episodes/derivation/temporal.py @@ -0,0 +1,217 @@ +"""Portable SQL expressions for bounded windows and deterministic date ranking.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +import sqlalchemy as sa +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.sql.compiler import SQLCompiler +from sqlalchemy.sql.functions import FunctionElement + +from omop_alchemy.toolkit.episodes.handling.event_windowing import ( + DEFAULT_EPISODE_OPEN_END_FALLBACK_DAYS, + DEFAULT_EPISODE_WINDOW_DAYS_PRIOR, +) + +from .contracts import ( + TemporalRankingSpec, + TemporalSelectionPolicy, + TemporalSidePreference, +) + + +class _SignedDayDelta(FunctionElement[int]): + type = sa.Integer() + inherit_cache = True + + +@compiles(_SignedDayDelta) +@compiles(_SignedDayDelta, "postgresql") +def _compile_signed_day_delta( + element: _SignedDayDelta, + compiler: SQLCompiler, + **kwargs: Any, +) -> str: + candidate, anchor = list(element.clauses) + return ( + f"(CAST({compiler.process(candidate, **kwargs)} AS DATE) - " + f"CAST({compiler.process(anchor, **kwargs)} AS DATE))" + ) + + +@compiles(_SignedDayDelta, "sqlite") +def _compile_sqlite_signed_day_delta( + element: _SignedDayDelta, + compiler: SQLCompiler, + **kwargs: Any, +) -> str: + candidate, anchor = list(element.clauses) + return ( + "CAST((julianday(date(" + f"{compiler.process(candidate, **kwargs)})) - julianday(date(" + f"{compiler.process(anchor, **kwargs)}))) AS INTEGER)" + ) + + +class _ShiftDate(FunctionElement[Any]): + type = sa.Date() + inherit_cache = True + + +@compiles(_ShiftDate) +@compiles(_ShiftDate, "postgresql") +def _compile_shift_date( + element: _ShiftDate, + compiler: SQLCompiler, + **kwargs: Any, +) -> str: + value, days = list(element.clauses) + return ( + f"(CAST({compiler.process(value, **kwargs)} AS DATE) + " + f"CAST({compiler.process(days, **kwargs)} AS INTEGER))" + ) + + +@compiles(_ShiftDate, "sqlite") +def _compile_sqlite_shift_date( + element: _ShiftDate, + compiler: SQLCompiler, + **kwargs: Any, +) -> str: + value, days = list(element.clauses) + return ( + f"date({compiler.process(value, **kwargs)}, " + f"printf('%+d days', {compiler.process(days, **kwargs)}))" + ) + + +def signed_day_delta( + candidate_date: sa.ColumnElement[Any], + anchor_date: sa.ColumnElement[Any], +) -> sa.ColumnElement[int]: + """Return candidate minus anchor in whole calendar days.""" + return _SignedDayDelta(candidate_date, anchor_date) + + +def absolute_day_delta( + candidate_date: sa.ColumnElement[Any], + anchor_date: sa.ColumnElement[Any], +) -> sa.ColumnElement[int]: + """Return absolute calendar-day distance between candidate and anchor.""" + return sa.func.abs(signed_day_delta(candidate_date, anchor_date)) + + +def shift_date( + value: sa.ColumnElement[Any], + *, + days: int, +) -> sa.ColumnElement[Any]: + """Shift a date by a fixed number of days on PostgreSQL or SQLite.""" + return _ShiftDate(value, sa.literal(days)) + + +def bounded_temporal_predicate( + value: sa.ColumnElement[Any], + lower_bound: sa.ColumnElement[Any], + upper_bound: sa.ColumnElement[Any], + *, + include_lower_bound: bool = True, + include_upper_bound: bool = True, +) -> sa.ColumnElement[bool]: + """Test a value against independently open or closed temporal bounds.""" + lower = value >= lower_bound if include_lower_bound else value > lower_bound + upper = value <= upper_bound if include_upper_bound else value < upper_bound + return sa.and_(lower, upper) + + +def episode_window_bounds( + episode_start_date: sa.ColumnElement[Any], + episode_end_date: sa.ColumnElement[Any], + *, + days_prior: int = DEFAULT_EPISODE_WINDOW_DAYS_PRIOR, + open_end_fallback_days: int = DEFAULT_EPISODE_OPEN_END_FALLBACK_DAYS, +) -> tuple[sa.ColumnElement[Any], sa.ColumnElement[Any]]: + """Build the bounded SQL interval used for date-admitted episode facts.""" + if days_prior < 0: + raise ValueError("days_prior must be non-negative") + if open_end_fallback_days < 0: + raise ValueError("open_end_fallback_days must be non-negative") + lower = shift_date(episode_start_date, days=-days_prior) + upper = sa.func.coalesce( + episode_end_date, + shift_date(episode_start_date, days=open_end_fallback_days), + ) + return lower, upper + + +def episode_window_predicate( + event_date: sa.ColumnElement[Any], + episode_start_date: sa.ColumnElement[Any], + episode_end_date: sa.ColumnElement[Any], + *, + ranking: TemporalRankingSpec | None = None, + days_prior: int = DEFAULT_EPISODE_WINDOW_DAYS_PRIOR, + open_end_fallback_days: int = DEFAULT_EPISODE_OPEN_END_FALLBACK_DAYS, +) -> sa.ColumnElement[bool]: + """Test whether an event lies inside a bounded episode-relative window.""" + lower, upper = episode_window_bounds( + episode_start_date, + episode_end_date, + days_prior=days_prior, + open_end_fallback_days=open_end_fallback_days, + ) + return bounded_temporal_predicate( + event_date, + lower, + upper, + include_lower_bound=ranking.include_lower_bound if ranking else True, + include_upper_bound=ranking.include_upper_bound if ranking else True, + ) + + +def temporal_order_expressions( + candidate_date: sa.ColumnElement[Any], + anchor_date: sa.ColumnElement[Any], + stable_id: sa.ColumnElement[Any], + ranking: TemporalRankingSpec, +) -> tuple[sa.ColumnElement[Any], ...]: + """Build deterministic ordering for a temporal ranking contract.""" + order: list[sa.ColumnElement[Any]] = [] + if ranking.side_preference is TemporalSidePreference.on_or_before_anchor: + order.append(sa.case((candidate_date <= anchor_date, 0), else_=1).asc()) + elif ranking.side_preference is TemporalSidePreference.on_or_after_anchor: + order.append(sa.case((candidate_date >= anchor_date, 0), else_=1).asc()) + + if ranking.policy is TemporalSelectionPolicy.nearest: + order.append(absolute_day_delta(candidate_date, anchor_date).asc()) + elif ranking.policy is TemporalSelectionPolicy.earliest: + order.append(candidate_date.asc()) + elif ranking.policy is TemporalSelectionPolicy.latest: + order.append(candidate_date.desc()) + else: # pragma: no cover - StrEnum construction prevents unknown policies + raise ValueError(f"Unsupported temporal selection policy: {ranking.policy}") + order.append(stable_id.asc()) + return tuple(order) + + +def temporal_row_number( + candidate_date: sa.ColumnElement[Any], + anchor_date: sa.ColumnElement[Any], + stable_id: sa.ColumnElement[Any], + ranking: TemporalRankingSpec, + *, + partition_by: Iterable[sa.ColumnElement[Any]] = (), + label: str = "temporal_rank", +) -> sa.ColumnElement[int]: + """Return a deterministic row number for temporal candidates.""" + return sa.func.row_number().over( + partition_by=tuple(partition_by), + order_by=temporal_order_expressions( + candidate_date, + anchor_date, + stable_id, + ranking, + ), + ).label(label) diff --git a/tests/test_episode_structure_queries.py b/tests/test_episode_structure_queries.py new file mode 100644 index 0000000..3db377e --- /dev/null +++ b/tests/test_episode_structure_queries.py @@ -0,0 +1,87 @@ +"""Canonical episode and hierarchy projections.""" + +from __future__ import annotations + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql, sqlite + +from omop_alchemy.toolkit.episodes.derivation import ( + CANONICAL_EPISODE_COLUMNS, + canonical_episode_projection, + direct_episode_relationship_projection, + episode_descendants, + episode_event_hierarchy_projection, +) + + +def test_canonical_episode_projection_has_stable_fields(): + statement = canonical_episode_projection() + + assert tuple(statement.selected_columns.keys()) == tuple( + map(str, CANONICAL_EPISODE_COLUMNS) + ) + + +def test_direct_episode_relationships_preserve_person_and_depth(session): + rows = session.execute( + direct_episode_relationship_projection().where( + sa.column("root_episode_id") == 100 + ) + ).mappings().all() + + assert rows == [ + { + "root_episode_id": 100, + "episode_id": 101, + "episode_parent_id": 100, + "person_id": 1, + "depth": 1, + } + ] + + +def test_recursive_episode_projection_includes_root_and_descendants(session): + hierarchy = episode_descendants(root_episode_id=100) + rows = session.execute( + sa.select(hierarchy.c.episode_id, hierarchy.c.depth).order_by( + hierarchy.c.depth, + hierarchy.c.episode_id, + ) + ).all() + + assert rows == [(100, 0), (101, 1)] + + +def test_recursive_episode_projection_can_exclude_root(session): + hierarchy = episode_descendants(root_episode_id=100, include_root=False) + + assert session.execute( + sa.select(hierarchy.c.episode_id, hierarchy.c.depth) + ).all() == [(101, 1)] + + +def test_episode_event_projection_carries_owning_depth(session): + rows = session.execute( + episode_event_hierarchy_projection(root_episode_id=100) + ).mappings().all() + + assert rows == [ + { + "root_episode_id": 100, + "linked_episode_id": 101, + "person_id": 1, + "episode_depth": 1, + "event_id": 1, + "event_field_concept_id": 1147127, + } + ] + + +def test_recursive_episode_projection_compiles_on_supported_dialects(): + hierarchy = episode_descendants(root_episode_id=100) + statement = sa.select(*hierarchy.c) + + for dialect in (sqlite.dialect(), postgresql.dialect()): + compiled = str(statement.compile(dialect=dialect)) + assert "WITH RECURSIVE" in compiled + assert "episode_parent_id" in compiled diff --git a/tests/test_event_projections.py b/tests/test_event_projections.py new file mode 100644 index 0000000..4055917 --- /dev/null +++ b/tests/test_event_projections.py @@ -0,0 +1,111 @@ +"""Canonical clinical-event projection behaviour.""" + +from __future__ import annotations + +import pytest +from sqlalchemy.dialects import postgresql, sqlite + +from omop_alchemy.cdm.base import ModifierFieldConcepts +from omop_alchemy.cdm.model import ( + Condition_Occurrence, + Device_Exposure, + Drug_Exposure, + Measurement, + Observation, + Procedure_Occurrence, +) +from omop_alchemy.cdm.model.structural import Episode_EventView +from omop_alchemy.toolkit.core.events import ( + CANONICAL_EVENT_OPTIONAL_COLUMNS, + CANONICAL_EVENT_REQUIRED_COLUMNS, + UnsupportedClinicalEventModelError, + canonical_event_projection, + canonical_event_union, + clinical_event_model_spec, +) + + +@pytest.mark.parametrize( + ("model", "source_table", "field_concept_id"), + [ + ( + Condition_Occurrence, + "condition_occurrence", + ModifierFieldConcepts.CONDITION_OCCURRENCE, + ), + (Drug_Exposure, "drug_exposure", ModifierFieldConcepts.DRUG_EXPOSURE), + (Measurement, "measurement", ModifierFieldConcepts.MEASUREMENT), + (Observation, "observation", ModifierFieldConcepts.OBSERVATION), + ( + Procedure_Occurrence, + "procedure_occurrence", + ModifierFieldConcepts.PROCEDURE_OCCURRENCE, + ), + ], +) +def test_projection_resolves_source_metadata( + model, + source_table: str, + field_concept_id: int, +): + spec = clinical_event_model_spec(model) + statement = canonical_event_projection(model) + + assert spec.event_source_table == source_table + assert spec.event_field_concept_id == field_concept_id + assert tuple(statement.selected_columns.keys()) == tuple( + map(str, CANONICAL_EVENT_REQUIRED_COLUMNS + CANONICAL_EVENT_OPTIONAL_COLUMNS) + ) + + +@pytest.mark.parametrize("dialect", [sqlite.dialect(), postgresql.dialect()]) +def test_projection_compiles_discriminator_and_source_as_literals(dialect): + statement = canonical_event_projection(Measurement) + compiled = str( + statement.compile(dialect=dialect, compile_kwargs={"literal_binds": True}) + ) + + assert str(ModifierFieldConcepts.MEASUREMENT) in compiled + assert "'measurement'" in compiled + assert "measurement.value_as_number AS value_as_number" in compiled + + +def test_non_value_event_projects_typed_null_value_columns(): + compiled = str( + canonical_event_projection(Procedure_Occurrence).compile( + dialect=postgresql.dialect(), + compile_kwargs={"literal_binds": True}, + ) + ) + + assert "CAST(NULL AS FLOAT) AS value_as_number" in compiled + assert "CAST(NULL AS INTEGER) AS value_as_concept_id" in compiled + + +def test_projection_union_preserves_one_shared_shape(): + statement = canonical_event_union( + Measurement, + Observation, + Procedure_Occurrence, + ) + compiled = str(statement.compile(dialect=sqlite.dialect())) + + assert tuple(statement.selected_columns.keys()) == tuple( + map(str, CANONICAL_EVENT_REQUIRED_COLUMNS + CANONICAL_EVENT_OPTIONAL_COLUMNS) + ) + assert compiled.count("UNION ALL") == 2 + + +def test_incomplete_modifier_target_has_a_typed_error(): + with pytest.raises( + UnsupportedClinicalEventModelError, + match="no complete ModifierTargetMixin metadata", + ): + canonical_event_projection(Device_Exposure) + + +def test_measurement_and_observation_are_registered_episode_event_targets(): + targets = Episode_EventView.resolved_event_target_classes() + + assert targets[ModifierFieldConcepts.MEASUREMENT] is Measurement + assert targets[ModifierFieldConcepts.OBSERVATION] is Observation diff --git a/tests/test_query_builder_contracts.py b/tests/test_query_builder_contracts.py index feb63ff..5c6a9c7 100644 --- a/tests/test_query_builder_contracts.py +++ b/tests/test_query_builder_contracts.py @@ -263,7 +263,7 @@ def test_runtime_concept_set_is_normalised_without_database_access(): assert spec.include_ancestor_ids == (100, 300) assert spec.exclude_exact_ids == (901,) assert spec.has_inclusions - assert spec.requires_concept_join + assert spec.require_standard def test_runtime_concept_set_does_not_invent_concept_id_validity_policy(): diff --git a/tests/test_runtime_concept_queries.py b/tests/test_runtime_concept_queries.py new file mode 100644 index 0000000..d1dca55 --- /dev/null +++ b/tests/test_runtime_concept_queries.py @@ -0,0 +1,198 @@ +"""Database-side runtime concept hierarchy predicates.""" + +from __future__ import annotations + +from datetime import date + +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql, sqlite + +from omop_alchemy.cdm.model.vocabulary import Concept, Concept_Ancestor +from omop_alchemy.toolkit.core.concepts import ( + RuntimeConceptSetSpec, + descendant_concept_select, + runtime_concept_predicate, +) + + +def _add_runtime_hierarchy(session) -> None: + concepts = ( + (990_000, "runtime root", "S"), + (990_001, "included standard", "S"), + (990_002, "included classification", "C"), + (990_003, "excluded standard", "S"), + (990_010, "runtime exclusion root", "S"), + ) + session.add_all( + Concept( + concept_id=concept_id, + concept_name=name, + domain_id="Condition", + vocabulary_id="SNOMED", + concept_class_id="Clinical Finding", + standard_concept=standardness, + concept_code=str(concept_id), + valid_start_date=date(1970, 1, 1), + valid_end_date=date(2099, 12, 31), + ) + for concept_id, name, standardness in concepts + ) + session.flush() + session.add_all( + ( + Concept_Ancestor( + ancestor_concept_id=990_000, + descendant_concept_id=990_001, + min_levels_of_separation=1, + max_levels_of_separation=1, + ), + Concept_Ancestor( + ancestor_concept_id=990_000, + descendant_concept_id=990_002, + min_levels_of_separation=1, + max_levels_of_separation=1, + ), + Concept_Ancestor( + ancestor_concept_id=990_000, + descendant_concept_id=990_003, + min_levels_of_separation=1, + max_levels_of_separation=1, + ), + Concept_Ancestor( + ancestor_concept_id=990_010, + descendant_concept_id=990_003, + min_levels_of_separation=1, + max_levels_of_separation=1, + ), + ) + ) + session.flush() + + +def test_runtime_hierarchy_applies_union_then_exclusion_in_database(session): + _add_runtime_hierarchy(session) + spec = RuntimeConceptSetSpec( + include_ancestor_ids=(990_000,), + include_exact_ids=(8507,), + exclude_ancestor_ids=(990_010,), + exclude_exact_ids=(990_001,), + ) + + statement = sa.select(Concept.concept_id).where( + runtime_concept_predicate(Concept.concept_id, spec) + ) + assert session.scalars(statement.order_by(Concept.concept_id)).all() == [ + 8507, + 990_002, + ] + + +def test_runtime_hierarchy_reuses_standardness_policy(session): + _add_runtime_hierarchy(session) + standard_only = RuntimeConceptSetSpec( + include_ancestor_ids=(990_000,), + require_standard=True, + include_classification=False, + ) + with_classification = RuntimeConceptSetSpec( + include_ancestor_ids=(990_000,), + require_standard=True, + include_classification=True, + ) + + standard_only_statement = sa.select(Concept.concept_id).where( + runtime_concept_predicate(Concept.concept_id, standard_only) + ) + with_classification_statement = sa.select(Concept.concept_id).where( + runtime_concept_predicate(Concept.concept_id, with_classification) + ) + + assert session.scalars(standard_only_statement).all() == [990_001, 990_003] + assert set(session.scalars(with_classification_statement)) == { + 990_001, + 990_002, + 990_003, + } + + +def test_runtime_hierarchy_with_no_inclusions_is_always_false(session): + statement = sa.select(Concept.concept_id).where( + runtime_concept_predicate(Concept.concept_id, RuntimeConceptSetSpec()) + ) + + assert session.scalars(statement).all() == [] + + +def test_runtime_hierarchy_compiles_to_concept_ancestor_subqueries(): + statement = sa.select(Concept.concept_id).where( + runtime_concept_predicate( + Concept.concept_id, + RuntimeConceptSetSpec( + include_ancestor_ids=(100,), + exclude_ancestor_ids=(400,), + require_standard=True, + ), + ) + ) + + for dialect in (sqlite.dialect(), postgresql.dialect()): + compiled = str(statement.compile(dialect=dialect)) + assert "concept_ancestor" in compiled + assert "standard_concept" in compiled + + +def test_descendant_select_returns_overlapping_descendant_once(session): + _add_runtime_hierarchy(session) + + descendants = session.scalars(descendant_concept_select((990_000, 990_010))).all() + + assert descendants.count(990_003) == 1 + + +def test_exact_runtime_id_does_not_depend_on_a_concept_row(session): + configured_id = -1 + statement = sa.select(sa.literal(configured_id)).where( + runtime_concept_predicate( + sa.literal(configured_id), + RuntimeConceptSetSpec(include_exact_ids=(configured_id,)), + ) + ) + + assert session.scalar(statement) == configured_id + + +def test_exclusion_wins_over_an_exact_inclusion(session): + _add_runtime_hierarchy(session) + spec = RuntimeConceptSetSpec( + include_exact_ids=(990_003,), + exclude_ancestor_ids=(990_010,), + ) + statement = sa.select(Concept.concept_id).where( + runtime_concept_predicate(Concept.concept_id, spec) + ) + + assert session.scalars(statement).all() == [] + + +def test_standardness_applies_to_exact_inclusions(session): + _add_runtime_hierarchy(session) + standard_only = RuntimeConceptSetSpec( + include_exact_ids=(990_002,), + require_standard=True, + include_classification=False, + ) + with_classification = RuntimeConceptSetSpec( + include_exact_ids=(990_002,), + require_standard=True, + include_classification=True, + ) + + standard_only_statement = sa.select(Concept.concept_id).where( + runtime_concept_predicate(Concept.concept_id, standard_only) + ) + with_classification_statement = sa.select(Concept.concept_id).where( + runtime_concept_predicate(Concept.concept_id, with_classification) + ) + + assert session.scalars(standard_only_statement).all() == [] + assert session.scalars(with_classification_statement).all() == [990_002] diff --git a/tests/test_temporal_queries.py b/tests/test_temporal_queries.py new file mode 100644 index 0000000..a662612 --- /dev/null +++ b/tests/test_temporal_queries.py @@ -0,0 +1,188 @@ +"""Portable temporal and repeated-observation SQL expressions.""" + +from __future__ import annotations + +from datetime import date + +import pytest +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql, sqlite + +from omop_alchemy.toolkit.episodes.derivation import ( + ObservationSelectionPolicy, + ObservationSelectionSpec, + TemporalRankingSpec, + TemporalSelectionPolicy, + TemporalSidePreference, + absolute_day_delta, + bounded_temporal_predicate, + episode_window_bounds, + ranked_observation_select, + signed_day_delta, + temporal_order_expressions, +) + + +def _temporal_candidates() -> sa.CTE: + return sa.union_all( + sa.select( + sa.literal(1001).label("episode_id"), + sa.literal(date(2026, 1, 15)).label("start_date"), + ), + sa.select( + sa.literal(1003).label("episode_id"), + sa.literal(date(2026, 1, 21)).label("start_date"), + ), + ).cte("temporal_candidates") + + +@pytest.mark.parametrize("dialect", [sqlite.dialect(), postgresql.dialect()]) +def test_day_delta_compiles_for_supported_dialects(dialect): + statement = sa.select( + signed_day_delta(sa.literal(date(2026, 1, 21)), sa.literal(date(2026, 1, 20))), + absolute_day_delta( + sa.literal(date(2026, 1, 15)), + sa.literal(date(2026, 1, 20)), + ), + ) + + compiled = str(statement.compile(dialect=dialect)) + assert "julianday" in compiled if dialect.name == "sqlite" else "CAST" in compiled + + +def test_day_delta_executes_as_signed_calendar_days(session): + values = session.execute( + sa.select( + signed_day_delta( + sa.literal(date(2026, 1, 21)), + sa.literal(date(2026, 1, 20)), + ), + signed_day_delta( + sa.literal(date(2026, 1, 15)), + sa.literal(date(2026, 1, 20)), + ), + ) + ).one() + + assert tuple(values) == (1, -5) + + +def test_side_preference_is_applied_before_absolute_distance(session): + candidates = _temporal_candidates() + anchor = sa.literal(date(2026, 1, 20)) + neutral = TemporalRankingSpec( + policy=TemporalSelectionPolicy.nearest, + stable_id_column="episode_id", + ) + started_first = TemporalRankingSpec( + policy=TemporalSelectionPolicy.nearest, + stable_id_column="episode_id", + side_preference=TemporalSidePreference.on_or_before_anchor, + ) + + def first_id(spec: TemporalRankingSpec) -> int: + statement = sa.select(candidates.c.episode_id).order_by( + *temporal_order_expressions( + candidates.c.start_date, + anchor, + candidates.c.episode_id, + spec, + ) + ) + value = session.scalar(statement.limit(1)) + assert value is not None + return value + + assert first_id(neutral) == 1003 + assert first_id(started_first) == 1001 + + +def test_episode_window_bounds_are_finite_and_boundary_policy_is_explicit(session): + start = sa.literal(date(2026, 1, 15)) + end = sa.literal(None, type_=sa.Date()) + lower, upper = episode_window_bounds( + start, + end, + days_prior=90, + open_end_fallback_days=30, + ) + values = session.execute(sa.select(lower, upper)).one() + + assert tuple(values) == (date(2025, 10, 17), date(2026, 2, 14)) + closed = bounded_temporal_predicate( + sa.literal(date(2025, 10, 17)), + lower, + upper, + ) + open_lower = bounded_temporal_predicate( + sa.literal(date(2025, 10, 17)), + lower, + upper, + include_lower_bound=False, + ) + assert session.scalar(sa.select(closed)) is True + assert session.scalar(sa.select(open_lower)) is False + + +def _observation_source() -> sa.CTE: + rows = ( + (21, date(2026, 1, 1)), + (22, date(2026, 1, 20)), + (23, date(2026, 1, 20)), + (24, date(2026, 1, 21)), + ) + return sa.union_all( + *( + sa.select( + sa.literal(101).label("person_id"), + sa.literal(900_001).label("observation_concept_id"), + sa.literal(observation_id).label("observation_id"), + sa.literal(observation_date).label("observation_date"), + ) + for observation_id, observation_date in rows + ) + ).cte("observations") + + +def test_as_of_observation_selection_filters_before_ranking(session): + source = _observation_source() + spec = ObservationSelectionSpec( + policy=ObservationSelectionPolicy.latest_on_or_before_anchor + ) + ranked = ranked_observation_select( + source, + spec, + anchor_date=sa.literal(date(2026, 1, 20)), + ).subquery() + selected = session.scalar( + sa.select(ranked.c.observation_id).where(ranked.c.observation_rank == 1) + ) + + assert selected == 22 + + +def test_as_of_observation_selection_can_exclude_the_anchor_date(session): + source = _observation_source() + spec = ObservationSelectionSpec( + policy=ObservationSelectionPolicy.latest_on_or_before_anchor, + include_anchor_date=False, + ) + ranked = ranked_observation_select( + source, + spec, + anchor_date=sa.literal(date(2026, 1, 20)), + ).subquery() + + assert session.scalar( + sa.select(ranked.c.observation_id).where(ranked.c.observation_rank == 1) + ) == 21 + + +def test_as_of_observation_selection_requires_an_anchor(): + with pytest.raises(ValueError, match="requires anchor_date"): + ranked_observation_select( + _observation_source(), + ObservationSelectionSpec( + policy=ObservationSelectionPolicy.latest_on_or_before_anchor + ), + ) From 53f4d760bd486ce5ab3584009dd73a1ec552a064 Mon Sep 17 00:00:00 2001 From: gkennos Date: Fri, 28 Aug 2026 15:53:57 +1000 Subject: [PATCH 03/30] attachment and mapping --- docs/toolkit/core.md | 26 + docs/toolkit/episodes.md | 2 +- docs/toolkit/query-contracts.md | 33 +- .../toolkit/core/concepts/__init__.py | 12 + .../toolkit/core/concepts/relationships.py | 135 +++++ .../toolkit/episodes/derivation/__init__.py | 18 +- .../episodes/derivation/attachments.py | 482 ++++++++++++++++++ .../toolkit/episodes/derivation/contracts.py | 26 + tests/test_concept_mapping_queries.py | 209 ++++++++ tests/test_episode_attachment_queries.py | 304 +++++++++++ 10 files changed, 1242 insertions(+), 5 deletions(-) create mode 100644 omop_alchemy/toolkit/core/concepts/relationships.py create mode 100644 omop_alchemy/toolkit/episodes/derivation/attachments.py create mode 100644 tests/test_concept_mapping_queries.py create mode 100644 tests/test_episode_attachment_queries.py diff --git a/docs/toolkit/core.md b/docs/toolkit/core.md index 48f171a..4a4caec 100644 --- a/docs/toolkit/core.md +++ b/docs/toolkit/core.md @@ -24,6 +24,32 @@ Concept groups answer the complementary question: whether a known concept belong Configuration-driven concept sets use `RuntimeConceptSetSpec`. It records exact and ancestral inclusions and exclusions without touching the database; see [Runtime concept sets](query-contracts.md#runtime-concept-sets) for the set semantics and current execution boundary. +## Resolve concepts to standard concepts + +Most source concepts resolve to one standard concept. Some source concepts represent several clinical meanings, however, and OMOP maps those to several standard concepts. `standard_concept_mapping_select()` therefore returns one row per valid `Maps to` relationship rather than choosing one target: + +```python +from datetime import date + +from omop_alchemy.toolkit.core.concepts import ( + StandardConceptMappingSpec, + standard_concept_mapping_select, +) + +mapping_query = standard_concept_mapping_select( + StandardConceptMappingSpec( + source_concept_ids=(source_concept_id,), + valid_on=date(2026, 1, 1), + ) +) + +mapping_rows = session.execute(mapping_query).mappings().all() +``` + +Each row carries the source and standard concept identifiers, vocabularies, codes, and names alongside the relationship validity dates. Invalid relationships, invalid targets, non-standard targets, and other relationship types are excluded. A standard concept's `Maps to` self-map is returned normally. + +Supplying `valid_on` makes the relationship and target date ranges part of the query, which is useful when a result must be reproducible against a dated vocabulary release. The query does not follow replacement relationships or `Maps to value`: those relationships answer different questions and should be handled by purpose-specific queries when a toolkit consumer needs them. + ::: omop_alchemy.toolkit.core.concepts ## Identify events across CDM tables diff --git a/docs/toolkit/episodes.md b/docs/toolkit/episodes.md index dbb3608..f1354ab 100644 --- a/docs/toolkit/episodes.md +++ b/docs/toolkit/episodes.md @@ -124,6 +124,6 @@ ranking = TemporalRankingSpec( ) ``` -The policy objects do not perform attachment themselves. `episode_window_predicate()`, `temporal_order_expressions()`, and `temporal_row_number()` build the portable SQL pieces needed by an attachment query on PostgreSQL or SQLite. A caller still owns the joins that validate explicit links and apply the chosen fallback cardinality. See [Query contracts](query-contracts.md) for the complete event shape, attachment examples, boundaries, repeated-observation selection, and the distinction between absolute-nearest and already-started-first ranking. +Pass those choices to `episode_attachment_queries()` with a canonical event projection. The builder validates explicit links by event ID, Field-concept discriminator, episode ID, and person; suppresses fallback only after a valid link; and returns deterministic attachments plus optional diagnostics. `episode_window_predicate()`, `temporal_order_expressions()`, and `temporal_row_number()` remain available when a query needs the individual portable SQL pieces. See [Query contracts](query-contracts.md) for the complete result shape, attachment example, boundaries, repeated-observation selection, and the distinction between absolute-nearest and already-started-first ranking. ::: omop_alchemy.toolkit.episodes.derivation diff --git a/docs/toolkit/query-contracts.md b/docs/toolkit/query-contracts.md index c5379e1..6d63bca 100644 --- a/docs/toolkit/query-contracts.md +++ b/docs/toolkit/query-contracts.md @@ -5,7 +5,7 @@ Consider a procedure recorded on the same day as two overlapping treatment episo The contracts on this page provide a common vocabulary for those decisions. They are small, immutable values that can be shared by query-building code, configuration, and tests. Projection and predicate helpers translate the contracts into SQLAlchemy statements without opening a database connection. !!! note "Construction and execution" - Creating a contract, statement, CTE, or predicate is side-effect free. Database access begins only when the resulting statement is executed through a connection or session. The toolkit supplies projection, hierarchy, temporal, concept-set, and observation builders; complete explicit-first event attachment remains the caller's query until a dedicated attachment builder is available. + Creating a contract, statement, CTE, or predicate is side-effect free. Database access begins only when the resulting statement is executed through a connection or session. The toolkit supplies projection, attachment, hierarchy, temporal, concept-set, mapping, and observation builders. ## Start with event identity @@ -75,7 +75,7 @@ attachment = EpisodeAttachmentIdentity.from_event( assert attachment.event == procedure ``` -Before accepting an explicit link, the query must confirm that the Field concept names the event's actual source table and that the event and episode belong to the same person. A source mismatch is a `discriminator_mismatch`; a person mismatch is a `person_mismatch`. A link to a missing source row is a `dangling_event`. +Before accepting an explicit link, the query must confirm that the Field concept names the event's actual source table and that the event and episode belong to the same person. A source mismatch is a `discriminator_mismatch`; a person mismatch is a `person_mismatch`. A link to a missing source row is a `dangling_event`, but absence from an arbitrary event projection is not enough to establish that condition because the projection may intentionally be filtered. Diagnose dangling links from an unfiltered source table through the episode-event resolution APIs. Once valid, an explicit link takes precedence under either explicit-first policy. Suppose Procedure Occurrence 7 is linked to episode 1002, while its date also falls inside the windows of episodes 1001 and 1002. The result is only `(procedure_occurrence, 7, 1002)`: fallback must not add episode 1001 or duplicate episode 1002. @@ -89,6 +89,35 @@ Once valid, an explicit link takes precedence under either explicit-first policy Choosing between ranked and all-in-window fallback is a statement about result grain. Ranked fallback produces at most one episode per event. All-in-window fallback intentionally allows one event to appear against several overlapping episodes. +`episode_attachment_queries()` applies the complete precedence rule. It accepts a canonical event statement or one supported event model, validates explicit links against `Episode_Event`, and applies fallback only to events that have no valid explicit link: + +```python +from omop_alchemy.toolkit.episodes.derivation import ( + EpisodeAttachmentPolicy, + TemporalRankingSpec, + TemporalSelectionPolicy, + episode_attachment_queries, +) + +attachment_queries = episode_attachment_queries( + events, + policy=EpisodeAttachmentPolicy.explicit_first_ranked, + ranking=TemporalRankingSpec( + policy=TemporalSelectionPolicy.nearest, + stable_id_column="episode_id", + ), + include_diagnostics=True, +) + +attachments = session.execute(attachment_queries.attachments).mappings().all() +assert attachment_queries.diagnostics is not None +diagnostics = session.execute(attachment_queries.diagnostics).mappings().all() +``` + +The attachment result preserves the event projection and adds `episode_id` and `attachment_method`. Its uniqueness key is `(event_source_table, event_id, episode_id)`. A valid explicit link may legitimately connect an event to more than one episode; each relationship remains a separate attachment under that key. + +Diagnostics are advisory rows and do not change the attachments. They identify discriminator and person mismatches, fallback ambiguity, and events for which no valid explicit link or fallback candidate exists. A discriminator mismatch is reported relative to a particular projected event: an `Episode_Event` row with event ID 7 and the Measurement Field concept is a valid link for Measurement 7 but a rejected candidate for Procedure Occurrence 7. + ## Rank fallback candidates Ranking has two independent parts: which side of the anchor date should be considered first, and how candidates on that side should be ordered. Keeping them separate supports both symmetric nearest-date matching and the common preference for an episode that had already started when the event occurred. diff --git a/omop_alchemy/toolkit/core/concepts/__init__.py b/omop_alchemy/toolkit/core/concepts/__init__.py index 3eacd2c..7000786 100644 --- a/omop_alchemy/toolkit/core/concepts/__init__.py +++ b/omop_alchemy/toolkit/core/concepts/__init__.py @@ -99,6 +99,13 @@ concept_group_registry, resolve_concept_group, ) +from .relationships import ( + STANDARD_CONCEPT_MAPPING_COLUMNS, + STANDARD_CONCEPT_MAPPING_UNIQUENESS, + StandardConceptMappingColumn, + StandardConceptMappingSpec, + standard_concept_mapping_select, +) from .runtime import ( RuntimeConceptSetSpec, descendant_concept_select, @@ -108,8 +115,12 @@ __all__ = [ "DEFAULT_MAX_CACHE_BYTES", "CacheStats", + "STANDARD_CONCEPT_MAPPING_COLUMNS", + "STANDARD_CONCEPT_MAPPING_UNIQUENESS", "ConceptGroupRegistry", "ConceptGroupSpec", + "StandardConceptMappingColumn", + "StandardConceptMappingSpec", "ConceptResolver", "ConceptResolverRegistry", "LookupIndex", @@ -123,6 +134,7 @@ "compose_normalizers", "concept_group_cache_stats", "concept_group_registry", + "standard_concept_mapping_select", "make_concept_resolver", "make_stage", "normalize_default", diff --git a/omop_alchemy/toolkit/core/concepts/relationships.py b/omop_alchemy/toolkit/core/concepts/relationships.py new file mode 100644 index 0000000..7ba6ab8 --- /dev/null +++ b/omop_alchemy/toolkit/core/concepts/relationships.py @@ -0,0 +1,135 @@ +"""Queries for resolving OMOP concepts to their standard representations.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from enum import StrEnum +from typing import Any + +import sqlalchemy as sa +import sqlalchemy.orm as so + +from omop_alchemy.cdm.model.vocabulary import Concept, Concept_Relationship + + +class StandardConceptMappingColumn(StrEnum): + """Stable labels emitted by :func:`standard_concept_mapping_select`.""" + + source_concept_id = "source_concept_id" + source_vocabulary_id = "source_vocabulary_id" + source_concept_code = "source_concept_code" + source_concept_name = "source_concept_name" + standard_concept_id = "standard_concept_id" + standard_vocabulary_id = "standard_vocabulary_id" + standard_concept_code = "standard_concept_code" + standard_concept_name = "standard_concept_name" + relationship_valid_start_date = "relationship_valid_start_date" + relationship_valid_end_date = "relationship_valid_end_date" + + +STANDARD_CONCEPT_MAPPING_COLUMNS: tuple[StandardConceptMappingColumn, ...] = tuple( + StandardConceptMappingColumn +) +"""Columns exposed by a standard concept mapping query.""" + + +STANDARD_CONCEPT_MAPPING_UNIQUENESS: tuple[StandardConceptMappingColumn, ...] = ( + StandardConceptMappingColumn.source_concept_id, + StandardConceptMappingColumn.standard_concept_id, +) +"""Executable uniqueness key of a standard concept mapping result.""" + + +@dataclass(frozen=True, slots=True) +class StandardConceptMappingSpec: + """Select valid ``Maps to`` relationships for the requested source concepts. + + An empty ``source_concept_ids`` tuple selects every source. ``valid_on`` can + be supplied when a reproducible historical vocabulary view is required; it + applies to both the relationship and its standard target. Invalid + relationships and targets are always excluded. + """ + + source_concept_ids: tuple[int, ...] = () + valid_on: date | None = None + + def __post_init__(self) -> None: + object.__setattr__( + self, + "source_concept_ids", + tuple(sorted(set(self.source_concept_ids))), + ) + + +def standard_concept_mapping_select( + spec: StandardConceptMappingSpec, +) -> sa.Select[Any]: + """Select each valid, single-hop ``Maps to`` standard-concept mapping. + + Results remain relational because OMOP permits one source concept to map to + more than one standard concept. Standard-concept self-maps are returned as + ordinary rows; no recursive traversal is required. + """ + source = so.aliased(Concept, name="mapping_source_concept") + standard = so.aliased(Concept, name="mapping_standard_concept") + relationship = so.aliased( + Concept_Relationship, + name="standard_mapping_relationship", + ) + + statement = ( + sa.select( + source.concept_id.label( + str(StandardConceptMappingColumn.source_concept_id) + ), + source.vocabulary_id.label( + str(StandardConceptMappingColumn.source_vocabulary_id) + ), + source.concept_code.label( + str(StandardConceptMappingColumn.source_concept_code) + ), + source.concept_name.label( + str(StandardConceptMappingColumn.source_concept_name) + ), + standard.concept_id.label( + str(StandardConceptMappingColumn.standard_concept_id) + ), + standard.vocabulary_id.label( + str(StandardConceptMappingColumn.standard_vocabulary_id) + ), + standard.concept_code.label( + str(StandardConceptMappingColumn.standard_concept_code) + ), + standard.concept_name.label( + str(StandardConceptMappingColumn.standard_concept_name) + ), + relationship.valid_start_date.label( + str(StandardConceptMappingColumn.relationship_valid_start_date) + ), + relationship.valid_end_date.label( + str(StandardConceptMappingColumn.relationship_valid_end_date) + ), + ) + .select_from(source) + .join(relationship, relationship.concept_id_1 == source.concept_id) + .join(standard, standard.concept_id == relationship.concept_id_2) + .where( + relationship.relationship_id == "Maps to", + relationship.is_valid_expr(), + standard.is_standard_expr(), + standard.is_valid_expr(), + ) + ) + + if spec.source_concept_ids: + statement = statement.where(source.concept_id.in_(spec.source_concept_ids)) + if spec.valid_on is not None: + valid_on = sa.literal(spec.valid_on) + statement = statement.where( + relationship.valid_start_date <= valid_on, + relationship.valid_end_date >= valid_on, + standard.valid_start_date <= valid_on, + standard.valid_end_date >= valid_on, + ) + return statement diff --git a/omop_alchemy/toolkit/episodes/derivation/__init__.py b/omop_alchemy/toolkit/episodes/derivation/__init__.py index 0491ab4..a59ba7b 100644 --- a/omop_alchemy/toolkit/episodes/derivation/__init__.py +++ b/omop_alchemy/toolkit/episodes/derivation/__init__.py @@ -11,16 +11,24 @@ The public contracts in this area define episode-attachment identities and policies used by query builders. Shared clinical-event row names and identities -live in ``toolkit.core.events``. Projection and ranking helpers return SQLAlchemy -statements or expressions without executing them. +live in ``toolkit.core.events``. Projection, attachment, and ranking helpers +return SQLAlchemy statements or expressions without executing them. """ +from .attachments import ( + EpisodeAttachmentQueries, + InvalidAttachmentSourceError, + episode_attachment_queries, +) from .contracts import ( + CANONICAL_ATTACHMENT_DIAGNOSTIC_COLUMNS, CANONICAL_EPISODE_COLUMNS, AttachmentDiagnosticCode, + AttachmentDiagnosticColumn, EpisodeColumn, EpisodeAttachmentDiagnostic, EpisodeAttachmentIdentity, + EpisodeAttachmentMethod, EpisodeAttachmentPolicy, ObservationSelectionPolicy, ObservationSelectionSpec, @@ -53,11 +61,16 @@ __all__ = [ "AttachmentDiagnosticCode", + "AttachmentDiagnosticColumn", + "CANONICAL_ATTACHMENT_DIAGNOSTIC_COLUMNS", "CANONICAL_EPISODE_COLUMNS", "EpisodeColumn", "EpisodeAttachmentDiagnostic", "EpisodeAttachmentIdentity", + "EpisodeAttachmentMethod", "EpisodeAttachmentPolicy", + "EpisodeAttachmentQueries", + "InvalidAttachmentSourceError", "ObservationSelectionPolicy", "ObservationSelectionSpec", "TemporalRankingSpec", @@ -67,6 +80,7 @@ "bounded_temporal_predicate", "canonical_episode_projection", "direct_episode_relationship_projection", + "episode_attachment_queries", "episode_descendants", "episode_event_hierarchy_projection", "episode_window_bounds", diff --git a/omop_alchemy/toolkit/episodes/derivation/attachments.py b/omop_alchemy/toolkit/episodes/derivation/attachments.py new file mode 100644 index 0000000..40b2cfe --- /dev/null +++ b/omop_alchemy/toolkit/episodes/derivation/attachments.py @@ -0,0 +1,482 @@ +"""Explicit-first event-to-episode attachment queries.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, cast + +import sqlalchemy as sa +from sqlalchemy.sql.selectable import FromClause, SelectBase + +from omop_alchemy.cdm.model.structural import Episode, Episode_Event +from omop_alchemy.toolkit.core.events import ( + CANONICAL_EVENT_REQUIRED_COLUMNS, + ClinicalEventColumn, + canonical_event_projection, +) +from omop_alchemy.toolkit.episodes.handling.event_windowing import ( + DEFAULT_EPISODE_OPEN_END_FALLBACK_DAYS, + DEFAULT_EPISODE_WINDOW_DAYS_PRIOR, +) + +from .contracts import ( + CANONICAL_ATTACHMENT_DIAGNOSTIC_COLUMNS, + AttachmentDiagnosticCode, + AttachmentDiagnosticColumn, + EpisodeAttachmentMethod, + EpisodeAttachmentPolicy, + EpisodeColumn, + TemporalRankingSpec, +) +from .structure import canonical_episode_projection +from .temporal import episode_window_predicate, temporal_row_number + + +ATTACHMENT_EPISODE_ID = "episode_id" +ATTACHMENT_METHOD = "attachment_method" +_FALLBACK_RANK = "_fallback_rank" +_FALLBACK_CANDIDATE_COUNT = "_fallback_candidate_count" +_IDENTITY_RANK = "_attachment_identity_rank" + + +class InvalidAttachmentSourceError(ValueError): + """Raised when an attachment input does not expose its required columns.""" + + +@dataclass(frozen=True, slots=True) +class EpisodeAttachmentQueries: + """Attachment results and, when requested, their advisory diagnostics. + + ``attachments`` preserves the input event columns and appends ``episode_id`` + and ``attachment_method``. Its executable uniqueness key is + ``(event_source_table, event_id, episode_id)``. + + ``diagnostics`` is ``None`` unless requested. Diagnostics explain rejected + explicit links and fallback outcomes; they do not alter attachment rows. + """ + + attachments: sa.Select[Any] + diagnostics: sa.Select[Any] | None = None + + +def _as_from_clause( + source: FromClause | SelectBase, + *, + name: str, +) -> FromClause: + if isinstance(source, SelectBase): + return source.subquery(name) + if isinstance(source, FromClause): + return source + raise TypeError(f"{name} must be a SQLAlchemy Select or FromClause") + + +def _event_source(source: type[Any] | FromClause | SelectBase) -> FromClause: + if isinstance(source, type): + return canonical_event_projection(source).subquery("attachment_events") + return _as_from_clause(source, name="attachment_events") + + +def _episode_source( + source: type[Episode] | FromClause | SelectBase, +) -> FromClause: + if isinstance(source, type): + model = cast(type[Episode], source) + return canonical_episode_projection(model).subquery("attachment_episodes") + return _as_from_clause(source, name="attachment_episodes") + + +def _episode_event_source( + source: type[Episode_Event] | FromClause | SelectBase, +) -> FromClause: + if isinstance(source, type): + model = cast(type[Episode_Event], source) + return model.__table__ + return _as_from_clause(source, name="attachment_episode_events") + + +def _require_columns( + source: FromClause, + required: tuple[str, ...], + *, + role: str, +) -> None: + missing = tuple(name for name in required if name not in source.c) + if missing: + raise InvalidAttachmentSourceError( + f"{role} is missing required columns: {', '.join(missing)}" + ) + + +def _same_event(left: FromClause, right: FromClause) -> sa.ColumnElement[bool]: + return sa.and_( + left.c[str(ClinicalEventColumn.event_source_table)] + == right.c[str(ClinicalEventColumn.event_source_table)], + left.c[str(ClinicalEventColumn.event_id)] + == right.c[str(ClinicalEventColumn.event_id)], + ) + + +def _not_exists_for_event( + source: FromClause, + keys: FromClause, +) -> sa.ColumnElement[bool]: + return sa.not_( + sa.exists(sa.select(1).select_from(keys).where(_same_event(source, keys))) + ) + + +def _attachment_diagnostics( + events: FromClause, + episodes: FromClause, + episode_events: FromClause, + valid_explicit: FromClause, + fallback_candidates: FromClause | None, + *, + policy: EpisodeAttachmentPolicy, +) -> sa.Select[Any]: + event_id = str(ClinicalEventColumn.event_id) + source_table = str(ClinicalEventColumn.event_source_table) + event_field = str(ClinicalEventColumn.event_field_concept_id) + episode_id = str(EpisodeColumn.episode_id) + person_id = str(ClinicalEventColumn.person_id) + link_field = "episode_event_field_concept_id" + + def diagnostic_literals( + code: AttachmentDiagnosticCode, + *, + linked_field: sa.ColumnElement[Any], + linked_episode_id: sa.ColumnElement[Any], + candidate_count: sa.ColumnElement[Any], + message: str, + ) -> tuple[sa.ColumnElement[Any], ...]: + return ( + sa.literal(str(code)).label( + str(AttachmentDiagnosticColumn.diagnostic_code) + ), + events.c[source_table].label(source_table), + events.c[event_id].label(event_id), + events.c[event_field].label(event_field), + linked_field.label( + str(AttachmentDiagnosticColumn.linked_event_field_concept_id) + ), + linked_episode_id.label(episode_id), + candidate_count.label(str(AttachmentDiagnosticColumn.candidate_count)), + sa.literal(message).label(str(AttachmentDiagnosticColumn.message)), + ) + + null_integer = sa.cast(sa.null(), sa.Integer()) + discriminator_mismatches = ( + sa.select( + *diagnostic_literals( + AttachmentDiagnosticCode.discriminator_mismatch, + linked_field=episode_events.c[link_field], + linked_episode_id=episode_events.c[episode_id], + candidate_count=null_integer, + message="explicit link discriminator does not identify this event source", + ) + ) + .select_from( + events.join( + episode_events, + events.c[event_id] == episode_events.c[event_id], + ).join( + episodes, + episodes.c[episode_id] == episode_events.c[episode_id], + ) + ) + .where(events.c[event_field] != episode_events.c[link_field]) + ) + + person_mismatches = ( + sa.select( + *diagnostic_literals( + AttachmentDiagnosticCode.person_mismatch, + linked_field=episode_events.c[link_field], + linked_episode_id=episode_events.c[episode_id], + candidate_count=null_integer, + message="explicit link connects an event and episode belonging to different people", + ) + ) + .select_from( + events.join( + episode_events, + sa.and_( + events.c[event_id] == episode_events.c[event_id], + events.c[event_field] == episode_events.c[link_field], + ), + ).join( + episodes, + episodes.c[episode_id] == episode_events.c[episode_id], + ) + ) + .where(events.c[person_id] != episodes.c[person_id]) + ) + + valid_keys = ( + sa.select( + valid_explicit.c[source_table], + valid_explicit.c[event_id], + ) + .distinct() + .cte("valid_explicit_event_keys") + ) + diagnostic_branches: list[sa.Select[Any]] = [ + discriminator_mismatches, + person_mismatches, + ] + + if fallback_candidates is not None: + candidate_keys = ( + sa.select( + fallback_candidates.c[source_table], + fallback_candidates.c[event_id], + ) + .distinct() + .cte("fallback_candidate_event_keys") + ) + ambiguous = ( + sa.select( + *diagnostic_literals( + AttachmentDiagnosticCode.ambiguous_fallback, + linked_field=null_integer, + linked_episode_id=null_integer, + candidate_count=fallback_candidates.c[_FALLBACK_CANDIDATE_COUNT], + message="more than one episode is eligible for fallback attachment", + ) + ) + .select_from( + events.join( + fallback_candidates, _same_event(events, fallback_candidates) + ) + ) + .where(fallback_candidates.c[_FALLBACK_CANDIDATE_COUNT] > 1) + .distinct() + ) + diagnostic_branches.append(ambiguous) + else: + candidate_keys = None + + no_candidate_conditions = [_not_exists_for_event(events, valid_keys)] + if candidate_keys is not None: + no_candidate_conditions.append(_not_exists_for_event(events, candidate_keys)) + no_candidate_message = ( + "event has no valid explicit link" + if policy is EpisodeAttachmentPolicy.explicit_only + else "event has no valid explicit link or fallback episode in the configured window" + ) + no_candidate = sa.select( + *diagnostic_literals( + AttachmentDiagnosticCode.no_candidate_episode, + linked_field=null_integer, + linked_episode_id=null_integer, + candidate_count=sa.literal(0), + message=no_candidate_message, + ) + ).where(*no_candidate_conditions) + diagnostic_branches.append(no_candidate) + + combined = sa.union_all(*diagnostic_branches).subquery("attachment_diagnostics") + return sa.select( + *(combined.c[str(column)] for column in CANONICAL_ATTACHMENT_DIAGNOSTIC_COLUMNS) + ).distinct() + + +def episode_attachment_queries( + events: type[Any] | FromClause | SelectBase, + *, + policy: EpisodeAttachmentPolicy, + episodes: type[Episode] | FromClause | SelectBase = Episode, + episode_events: type[Episode_Event] | FromClause | SelectBase = Episode_Event, + ranking: TemporalRankingSpec | None = None, + days_prior: int = DEFAULT_EPISODE_WINDOW_DAYS_PRIOR, + open_end_fallback_days: int = DEFAULT_EPISODE_OPEN_END_FALLBACK_DAYS, + include_diagnostics: bool = False, +) -> EpisodeAttachmentQueries: + """Build explicit-first attachments from canonical event and episode inputs. + + Explicit links are valid only when their event ID, Field-concept + discriminator, episode ID, and person all agree. An invalid explicit link + never suppresses fallback. Ranked fallback requires a ranking specification; + all-in-window fallback retains every eligible episode. + """ + if policy.requires_fallback_ranking and ranking is None: + raise ValueError("explicit_first_ranked requires a temporal ranking") + + event_source = _event_source(events) + episode_source = _episode_source(episodes) + link_source = _episode_event_source(episode_events) + event_names = tuple(column.key for column in event_source.c) + + _require_columns( + event_source, + tuple(str(column) for column in CANONICAL_EVENT_REQUIRED_COLUMNS), + role="events", + ) + _require_columns( + episode_source, + ( + str(EpisodeColumn.episode_id), + str(EpisodeColumn.person_id), + str(EpisodeColumn.episode_start_date), + str(EpisodeColumn.episode_end_date), + ), + role="episodes", + ) + _require_columns( + link_source, + ("episode_id", "event_id", "episode_event_field_concept_id"), + role="episode_events", + ) + for reserved in (ATTACHMENT_EPISODE_ID, ATTACHMENT_METHOD): + if reserved in event_names: + raise InvalidAttachmentSourceError( + f"events already contains reserved attachment column {reserved!r}" + ) + + event_id = str(ClinicalEventColumn.event_id) + event_date = str(ClinicalEventColumn.event_date) + event_field = str(ClinicalEventColumn.event_field_concept_id) + source_table = str(ClinicalEventColumn.event_source_table) + person_id = str(ClinicalEventColumn.person_id) + episode_id = str(EpisodeColumn.episode_id) + episode_person_id = str(EpisodeColumn.person_id) + episode_start = str(EpisodeColumn.episode_start_date) + episode_end = str(EpisodeColumn.episode_end_date) + + valid_explicit = ( + sa.select( + *(event_source.c[name] for name in event_names), + episode_source.c[episode_id].label(ATTACHMENT_EPISODE_ID), + sa.literal(str(EpisodeAttachmentMethod.explicit)).label(ATTACHMENT_METHOD), + ) + .select_from( + event_source.join( + link_source, + sa.and_( + event_source.c[event_id] == link_source.c[event_id], + event_source.c[event_field] + == link_source.c["episode_event_field_concept_id"], + ), + ).join( + episode_source, + sa.and_( + episode_source.c[episode_id] == link_source.c[episode_id], + episode_source.c[episode_person_id] == event_source.c[person_id], + ), + ) + ) + .distinct() + .cte("valid_explicit_attachments") + ) + + attachment_names = (*event_names, ATTACHMENT_EPISODE_ID, ATTACHMENT_METHOD) + explicit_select = sa.select(*(valid_explicit.c[name] for name in attachment_names)) + fallback_candidates: FromClause | None = None + attachment_branches: list[sa.Select[Any]] = [explicit_select] + + if policy.uses_fallback: + valid_keys = ( + sa.select( + valid_explicit.c[source_table], + valid_explicit.c[event_id], + ) + .distinct() + .cte("valid_explicit_event_keys_for_fallback") + ) + fallback_columns: list[sa.ColumnElement[Any]] = [ + *(event_source.c[name] for name in event_names), + episode_source.c[episode_id].label(ATTACHMENT_EPISODE_ID), + sa.literal(str(EpisodeAttachmentMethod.fallback)).label(ATTACHMENT_METHOD), + sa.func.count() + .over(partition_by=(event_source.c[source_table], event_source.c[event_id])) + .label(_FALLBACK_CANDIDATE_COUNT), + ] + if policy.requires_fallback_ranking: + assert ranking is not None # validated above + if ranking.stable_id_column not in episode_source.c: + raise InvalidAttachmentSourceError( + "episodes is missing temporal stable ID column: " + f"{ranking.stable_id_column}" + ) + fallback_columns.append( + temporal_row_number( + episode_source.c[episode_start], + event_source.c[event_date], + episode_source.c[ranking.stable_id_column], + ranking, + partition_by=( + event_source.c[source_table], + event_source.c[event_id], + ), + label=_FALLBACK_RANK, + ) + ) + + fallback_candidates = ( + sa.select(*fallback_columns) + .select_from( + event_source.join( + episode_source, + sa.and_( + event_source.c[person_id] + == episode_source.c[episode_person_id], + episode_window_predicate( + event_source.c[event_date], + episode_source.c[episode_start], + episode_source.c[episode_end], + ranking=ranking, + days_prior=days_prior, + open_end_fallback_days=open_end_fallback_days, + ), + ), + ) + ) + .where(_not_exists_for_event(event_source, valid_keys)) + .cte("fallback_attachment_candidates") + ) + selected_fallback = sa.select( + *(fallback_candidates.c[name] for name in attachment_names) + ) + if policy.requires_fallback_ranking: + selected_fallback = selected_fallback.where( + fallback_candidates.c[_FALLBACK_RANK] == 1 + ) + attachment_branches.append(selected_fallback) + + combined = sa.union_all(*attachment_branches).cte("combined_episode_attachments") + ranked_attachments = sa.select( + *(combined.c[name] for name in attachment_names), + sa.func.row_number() + .over( + partition_by=( + combined.c[source_table], + combined.c[event_id], + combined.c[ATTACHMENT_EPISODE_ID], + ), + order_by=sa.case( + ( + combined.c[ATTACHMENT_METHOD] + == str(EpisodeAttachmentMethod.explicit), + 0, + ), + else_=1, + ), + ) + .label(_IDENTITY_RANK), + ).cte("deduplicated_episode_attachments") + attachments = sa.select( + *(ranked_attachments.c[name] for name in attachment_names) + ).where(ranked_attachments.c[_IDENTITY_RANK] == 1) + + diagnostics = None + if include_diagnostics: + diagnostics = _attachment_diagnostics( + event_source, + episode_source, + link_source, + valid_explicit, + fallback_candidates, + policy=policy, + ) + return EpisodeAttachmentQueries(attachments=attachments, diagnostics=diagnostics) diff --git a/omop_alchemy/toolkit/episodes/derivation/contracts.py b/omop_alchemy/toolkit/episodes/derivation/contracts.py index 1c15f15..1e3b1aa 100644 --- a/omop_alchemy/toolkit/episodes/derivation/contracts.py +++ b/omop_alchemy/toolkit/episodes/derivation/contracts.py @@ -93,6 +93,13 @@ def requires_fallback_ranking(self) -> bool: return self is EpisodeAttachmentPolicy.explicit_first_ranked +class EpisodeAttachmentMethod(StrEnum): + """How an event-to-episode attachment was established.""" + + explicit = "explicit" + fallback = "fallback" + + class AttachmentDiagnosticCode(StrEnum): """Stable categories for explaining rejected or ambiguous attachment rows.""" @@ -103,6 +110,25 @@ class AttachmentDiagnosticCode(StrEnum): ambiguous_fallback = "ambiguous_fallback" +class AttachmentDiagnosticColumn(StrEnum): + """Stable labels emitted by an attachment diagnostics query.""" + + diagnostic_code = "diagnostic_code" + event_source_table = "event_source_table" + event_id = "event_id" + event_field_concept_id = "event_field_concept_id" + linked_event_field_concept_id = "linked_event_field_concept_id" + episode_id = "episode_id" + candidate_count = "candidate_count" + message = "message" + + +CANONICAL_ATTACHMENT_DIAGNOSTIC_COLUMNS: tuple[AttachmentDiagnosticColumn, ...] = tuple( + AttachmentDiagnosticColumn +) +"""Columns exposed by an attachment diagnostics query.""" + + @dataclass(frozen=True, slots=True) class EpisodeAttachmentDiagnostic: """Advisory result explaining why an attachment needs review.""" diff --git a/tests/test_concept_mapping_queries.py b/tests/test_concept_mapping_queries.py new file mode 100644 index 0000000..59d83f5 --- /dev/null +++ b/tests/test_concept_mapping_queries.py @@ -0,0 +1,209 @@ +"""Standard concept mapping query contracts.""" + +from __future__ import annotations + +from datetime import date + +import pytest +from sqlalchemy.dialects import postgresql, sqlite + +from omop_alchemy.cdm.model.vocabulary import Concept, Concept_Relationship +from omop_alchemy.toolkit.core.concepts import ( + STANDARD_CONCEPT_MAPPING_UNIQUENESS, + StandardConceptMappingSpec, + standard_concept_mapping_select, +) + + +SOURCE_ID = 991_000 +STANDARD_TARGET_ID = 991_001 +SECOND_STANDARD_TARGET_ID = 991_002 +NON_STANDARD_TARGET_ID = 991_003 +INVALID_TARGET_ID = 991_004 +STANDARD_SOURCE_ID = 991_005 + + +def _concept( + concept_id: int, + name: str, + *, + standard_concept: str | None, + invalid_reason: str | None = None, +) -> Concept: + return Concept( + concept_id=concept_id, + concept_name=name, + domain_id="Condition", + vocabulary_id="SNOMED", + concept_class_id="Clinical Finding", + standard_concept=standard_concept, + concept_code=f"code-{concept_id}", + valid_start_date=date(2000, 1, 1), + valid_end_date=date(2099, 12, 31), + invalid_reason=invalid_reason, + ) + + +def _relationship( + source_id: int, + target_id: int, + relationship_id: str = "Maps to", + *, + valid_end_date: date = date(2099, 12, 31), + invalid_reason: str | None = None, +) -> Concept_Relationship: + return Concept_Relationship( + concept_id_1=source_id, + concept_id_2=target_id, + relationship_id=relationship_id, + valid_start_date=date(2000, 1, 1), + valid_end_date=valid_end_date, + invalid_reason=invalid_reason, + ) + + +def _add_vocabulary_rows(session) -> None: + session.add_all( + ( + _concept(SOURCE_ID, "source", standard_concept=None), + _concept(STANDARD_TARGET_ID, "first standard", standard_concept="S"), + _concept( + SECOND_STANDARD_TARGET_ID, + "second standard", + standard_concept="S", + ), + _concept(NON_STANDARD_TARGET_ID, "non-standard", standard_concept=None), + _concept( + INVALID_TARGET_ID, + "invalid standard", + standard_concept="S", + invalid_reason="D", + ), + _concept(STANDARD_SOURCE_ID, "standard source", standard_concept="S"), + ) + ) + session.flush() + session.add_all( + ( + _relationship(SOURCE_ID, STANDARD_TARGET_ID), + _relationship(SOURCE_ID, SECOND_STANDARD_TARGET_ID), + _relationship(SOURCE_ID, NON_STANDARD_TARGET_ID), + _relationship(SOURCE_ID, INVALID_TARGET_ID), + _relationship( + SOURCE_ID, + STANDARD_SOURCE_ID, + valid_end_date=date(2020, 12, 31), + invalid_reason="U", + ), + _relationship(SOURCE_ID, STANDARD_TARGET_ID, "Maps to value"), + _relationship(STANDARD_SOURCE_ID, STANDARD_SOURCE_ID), + ) + ) + session.flush() + + +def test_mapping_preserves_multiple_standard_targets(session): + _add_vocabulary_rows(session) + + rows = ( + session.execute( + standard_concept_mapping_select( + StandardConceptMappingSpec(source_concept_ids=(SOURCE_ID,)) + ) + ) + .mappings() + .all() + ) + + assert {row["standard_concept_id"] for row in rows} == { + STANDARD_TARGET_ID, + SECOND_STANDARD_TARGET_ID, + } + assert rows[0]["source_concept_name"] == "source" + assert rows[0]["source_vocabulary_id"] == "SNOMED" + assert rows[0]["standard_vocabulary_id"] == "SNOMED" + + +def test_mapping_excludes_other_relationships_and_invalid_or_non_standard_targets( + session, +): + _add_vocabulary_rows(session) + + rows = ( + session.execute( + standard_concept_mapping_select( + StandardConceptMappingSpec(source_concept_ids=(SOURCE_ID,)) + ) + ) + .mappings() + .all() + ) + + assert len(rows) == 2 + + +def test_mapping_returns_standard_concept_self_map(session): + _add_vocabulary_rows(session) + + row = ( + session.execute( + standard_concept_mapping_select( + StandardConceptMappingSpec(source_concept_ids=(STANDARD_SOURCE_ID,)) + ) + ) + .mappings() + .one() + ) + + assert row["source_concept_id"] == STANDARD_SOURCE_ID + assert row["standard_concept_id"] == STANDARD_SOURCE_ID + + +def test_mapping_can_apply_reproducible_validity_date(session): + _add_vocabulary_rows(session) + + rows = ( + session.execute( + standard_concept_mapping_select( + StandardConceptMappingSpec( + source_concept_ids=(SOURCE_ID,), + valid_on=date(2026, 1, 1), + ) + ) + ) + .mappings() + .all() + ) + + assert {row["standard_concept_id"] for row in rows} == { + STANDARD_TARGET_ID, + SECOND_STANDARD_TARGET_ID, + } + + +def test_mapping_output_honours_its_documented_uniqueness_key(session): + _add_vocabulary_rows(session) + rows = ( + session.execute(standard_concept_mapping_select(StandardConceptMappingSpec())) + .mappings() + .all() + ) + key_names = tuple(str(column) for column in STANDARD_CONCEPT_MAPPING_UNIQUENESS) + keys = {tuple(row[name] for name in key_names) for row in rows} + + assert len(keys) == len(rows) + + +@pytest.mark.parametrize("dialect", [sqlite.dialect(), postgresql.dialect()]) +def test_mapping_query_compiles_on_supported_dialects(dialect): + statement = standard_concept_mapping_select( + StandardConceptMappingSpec( + source_concept_ids=(SOURCE_ID,), + valid_on=date(2026, 1, 1), + ) + ) + + compiled = str(statement.compile(dialect=dialect)) + assert "concept_relationship" in compiled + assert "mapping_source_concept" in compiled + assert "mapping_standard_concept" in compiled diff --git a/tests/test_episode_attachment_queries.py b/tests/test_episode_attachment_queries.py new file mode 100644 index 0000000..f9bb450 --- /dev/null +++ b/tests/test_episode_attachment_queries.py @@ -0,0 +1,304 @@ +"""Explicit-first event-to-episode attachment queries.""" + +from __future__ import annotations + +from datetime import date + +import pytest +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql, sqlite + +from omop_alchemy.cdm.model import Procedure_Occurrence +from omop_alchemy.toolkit.core.events import ClinicalEventIdentity +from omop_alchemy.toolkit.episodes.derivation import ( + AttachmentDiagnosticCode, + EpisodeAttachmentPolicy, + TemporalRankingSpec, + TemporalSelectionPolicy, + TemporalSidePreference, + episode_attachment_queries, +) +from tests.fixtures.query_contract_cases import ( + COLLIDING_EVENTS, + CROSS_PERSON_LINK, + DIRECTIONAL_PREFERENCE_EPISODES, + OVERLAPPING_EPISODES, + VALID_EXPLICIT_LINK, + WRONG_DISCRIMINATOR_LINK, + EpisodeCase, + EventCase, + ExplicitLinkCase, + PROCEDURE_FIELD_CONCEPT_ID, +) + + +def _event_source(*events: EventCase) -> sa.CTE: + return sa.union_all( + *( + sa.select( + sa.literal(event.person_id).label("person_id"), + sa.literal(event.identity.event_id).label("event_id"), + sa.literal(event.event_date).label("event_date"), + sa.cast(sa.null(), sa.DateTime()).label("event_datetime"), + sa.literal(900_001).label("event_concept_id"), + sa.literal(event.event_field_concept_id).label( + "event_field_concept_id" + ), + sa.literal(event.identity.event_source_table).label( + "event_source_table" + ), + ) + for event in events + ) + ).cte("events") + + +def _episode_source(*episodes: EpisodeCase) -> sa.CTE: + return sa.union_all( + *( + sa.select( + sa.literal(episode.episode_id).label("episode_id"), + sa.literal(episode.person_id).label("person_id"), + sa.literal(episode.start_date).label("episode_start_date"), + sa.literal(episode.end_date, type_=sa.Date()).label("episode_end_date"), + ) + for episode in episodes + ) + ).cte("episodes") + + +def _link_source(*links: ExplicitLinkCase) -> sa.CTE: + return sa.union_all( + *( + sa.select( + sa.literal(link.episode_id).label("episode_id"), + sa.literal(link.event.event_id).label("event_id"), + sa.literal(link.episode_event_field_concept_id).label( + "episode_event_field_concept_id" + ), + ) + for link in links + ) + ).cte("episode_events") + + +def _empty_link_source() -> sa.CTE: + return ( + sa.select( + sa.cast(sa.null(), sa.Integer()).label("episode_id"), + sa.cast(sa.null(), sa.Integer()).label("event_id"), + sa.cast(sa.null(), sa.Integer()).label("episode_event_field_concept_id"), + ) + .where(sa.false()) + .cte("episode_events") + ) + + +def _nearest(*, started_first: bool = False) -> TemporalRankingSpec: + return TemporalRankingSpec( + policy=TemporalSelectionPolicy.nearest, + stable_id_column="episode_id", + side_preference=( + TemporalSidePreference.on_or_before_anchor + if started_first + else TemporalSidePreference.none + ), + ) + + +def test_valid_explicit_links_suppress_ranked_fallback_with_colliding_ids(session): + unlinked = EventCase( + identity=ClinicalEventIdentity("procedure_occurrence", 8), + person_id=101, + event_date=date(2026, 1, 20), + event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, + ) + sources = episode_attachment_queries( + _event_source(*COLLIDING_EVENTS, unlinked), + episodes=_episode_source(*OVERLAPPING_EPISODES), + episode_events=_link_source( + VALID_EXPLICIT_LINK, + WRONG_DISCRIMINATOR_LINK, + CROSS_PERSON_LINK, + ), + policy=EpisodeAttachmentPolicy.explicit_first_ranked, + ranking=_nearest(), + ) + + rows = session.execute(sources.attachments).mappings().all() + identities = { + (row["event_source_table"], row["event_id"], row["episode_id"]) for row in rows + } + + assert len(identities) == len(rows) + assert ("measurement", 7, 1001) in identities + assert ("procedure_occurrence", 7, 1002) in identities + assert ("procedure_occurrence", 7, 1001) not in identities + assert ("observation", 7, 2001) in identities + assert ("procedure_occurrence", 8, 1001) in identities + + +def test_invalid_explicit_link_does_not_suppress_fallback(session): + event = COLLIDING_EVENTS[2] + sources = episode_attachment_queries( + _event_source(event), + episodes=_episode_source(*OVERLAPPING_EPISODES), + episode_events=_link_source(CROSS_PERSON_LINK), + policy=EpisodeAttachmentPolicy.explicit_first_ranked, + ranking=_nearest(), + ) + + assert session.execute(sources.attachments).mappings().one()["episode_id"] == 2001 + + +def test_explicit_only_returns_valid_links_without_fallback(session): + queries = episode_attachment_queries( + _event_source(*COLLIDING_EVENTS), + episodes=_episode_source(*OVERLAPPING_EPISODES), + episode_events=_link_source( + VALID_EXPLICIT_LINK, + WRONG_DISCRIMINATOR_LINK, + CROSS_PERSON_LINK, + ), + policy=EpisodeAttachmentPolicy.explicit_only, + ) + + rows = session.execute(queries.attachments).mappings().all() + + assert queries.diagnostics is None + assert { + (row["event_source_table"], row["event_id"], row["episode_id"]) for row in rows + } == { + ("measurement", 7, 1001), + ("procedure_occurrence", 7, 1002), + } + + +def test_side_preference_is_applied_to_ranked_fallback(session): + event = EventCase( + identity=ClinicalEventIdentity("procedure_occurrence", 8), + person_id=101, + event_date=date(2026, 1, 20), + event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, + ) + + def selected_episode(ranking: TemporalRankingSpec) -> int: + queries = episode_attachment_queries( + _event_source(event), + episodes=_episode_source(*DIRECTIONAL_PREFERENCE_EPISODES), + episode_events=_empty_link_source(), + policy=EpisodeAttachmentPolicy.explicit_first_ranked, + ranking=ranking, + ) + attachments = queries.attachments.subquery() + value = session.scalar(sa.select(attachments.c.episode_id)) + assert value is not None + return value + + assert selected_episode(_nearest()) == 1003 + assert selected_episode(_nearest(started_first=True)) == 1001 + + +def test_all_in_window_fallback_retains_each_eligible_episode(session): + event = EventCase( + identity=ClinicalEventIdentity("procedure_occurrence", 8), + person_id=101, + event_date=date(2026, 1, 20), + event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, + ) + queries = episode_attachment_queries( + _event_source(event), + episodes=_episode_source(*OVERLAPPING_EPISODES[:2]), + episode_events=_empty_link_source(), + policy=EpisodeAttachmentPolicy.explicit_first_all_in_window, + ) + attachments = queries.attachments.subquery() + + assert set(session.scalars(sa.select(attachments.c.episode_id))) == { + 1001, + 1002, + } + + +def test_diagnostics_explain_rejected_and_ambiguous_rows(session): + ambiguous = EventCase( + identity=ClinicalEventIdentity("procedure_occurrence", 8), + person_id=101, + event_date=date(2026, 1, 20), + event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, + ) + unlinked = EventCase( + identity=ClinicalEventIdentity("procedure_occurrence", 9), + person_id=303, + event_date=date(2026, 1, 20), + event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, + ) + queries = episode_attachment_queries( + _event_source(*COLLIDING_EVENTS, ambiguous, unlinked), + episodes=_episode_source(*OVERLAPPING_EPISODES), + episode_events=_link_source( + VALID_EXPLICIT_LINK, + WRONG_DISCRIMINATOR_LINK, + CROSS_PERSON_LINK, + ), + policy=EpisodeAttachmentPolicy.explicit_first_ranked, + ranking=_nearest(), + include_diagnostics=True, + ) + assert queries.diagnostics is not None + + rows = session.execute(queries.diagnostics).mappings().all() + codes = {row["diagnostic_code"] for row in rows} + ambiguous_rows = [ + row + for row in rows + if row["diagnostic_code"] == str(AttachmentDiagnosticCode.ambiguous_fallback) + and row["event_id"] == 8 + ] + + assert str(AttachmentDiagnosticCode.discriminator_mismatch) in codes + assert str(AttachmentDiagnosticCode.person_mismatch) in codes + assert str(AttachmentDiagnosticCode.no_candidate_episode) in codes + assert len(ambiguous_rows) == 1 + assert ambiguous_rows[0]["candidate_count"] == 2 + + +def test_ranked_policy_requires_a_ranking_contract(): + with pytest.raises(ValueError, match="requires a temporal ranking"): + episode_attachment_queries( + _event_source(COLLIDING_EVENTS[0]), + episodes=_episode_source(OVERLAPPING_EPISODES[0]), + episode_events=_link_source(WRONG_DISCRIMINATOR_LINK), + policy=EpisodeAttachmentPolicy.explicit_first_ranked, + ) + + +@pytest.mark.parametrize("dialect", [sqlite.dialect(), postgresql.dialect()]) +def test_attachment_and_diagnostics_compile_on_supported_dialects(dialect): + queries = episode_attachment_queries( + _event_source(*COLLIDING_EVENTS), + episodes=_episode_source(*OVERLAPPING_EPISODES), + episode_events=_link_source( + VALID_EXPLICIT_LINK, + WRONG_DISCRIMINATOR_LINK, + CROSS_PERSON_LINK, + ), + policy=EpisodeAttachmentPolicy.explicit_first_ranked, + ranking=_nearest(), + include_diagnostics=True, + ) + + str(queries.attachments.compile(dialect=dialect)) + assert queries.diagnostics is not None + str(queries.diagnostics.compile(dialect=dialect)) + + +def test_attachment_builder_accepts_a_supported_event_model(): + queries = episode_attachment_queries( + Procedure_Occurrence, + policy=EpisodeAttachmentPolicy.explicit_only, + ) + + assert "procedure_occurrence" in str( + queries.attachments.compile(dialect=postgresql.dialect()) + ) From 4b2c93f1d4cae19c7a30036bfcb9373871fcdb4f Mon Sep 17 00:00:00 2001 From: gkennos Date: Mon, 31 Aug 2026 09:19:23 +1000 Subject: [PATCH 04/30] materialised view centralisation --- docs/toolkit/materialized-views.md | 98 ++++++ mkdocs.yml | 1 + omop_alchemy/toolkit/core/__init__.py | 3 + .../toolkit/core/materialization/__init__.py | 58 ++++ .../toolkit/core/materialization/contracts.py | 125 ++++++++ .../toolkit/core/materialization/ddl.py | 160 +++++++++ .../toolkit/core/materialization/lifecycle.py | 303 ++++++++++++++++++ tests/test_materialized_view_lifecycle.py | 198 ++++++++++++ ...st_materialized_view_lifecycle_postgres.py | 104 ++++++ 9 files changed, 1050 insertions(+) create mode 100644 docs/toolkit/materialized-views.md create mode 100644 omop_alchemy/toolkit/core/materialization/__init__.py create mode 100644 omop_alchemy/toolkit/core/materialization/contracts.py create mode 100644 omop_alchemy/toolkit/core/materialization/ddl.py create mode 100644 omop_alchemy/toolkit/core/materialization/lifecycle.py create mode 100644 tests/test_materialized_view_lifecycle.py create mode 100644 tests/test_materialized_view_lifecycle_postgres.py diff --git a/docs/toolkit/materialized-views.md b/docs/toolkit/materialized-views.md new file mode 100644 index 0000000..ca8a01d --- /dev/null +++ b/docs/toolkit/materialized-views.md @@ -0,0 +1,98 @@ +# Materialized views + +Materialized views are useful when an analytical query is expensive but its results can be refreshed on a controlled schedule. The lifecycle helpers in OMOP Alchemy keep every operation tied to an explicit PostgreSQL schema and view name. This prevents a connection's search path from deciding which object is created, refreshed, indexed, or dropped. + +The following view stores one row for each event selected by an application query: + +```python +import sqlalchemy as sa + +from omop_alchemy.toolkit.core.materialization import ( + MaterializedViewIndex, + MaterializedViewSpec, + MaterializedViewTarget, + create_materialized_view, + create_materialized_view_indexes, +) + +event_query = sa.select( + events.c.person_id, + events.c.event_id, + events.c.event_date, +) + +event_view = MaterializedViewSpec( + target=MaterializedViewTarget( + schema="reporting", + name="clinical_events", + ), + selectable=event_query, + logical_identity=("person_id", "event_id"), + indexes=( + MaterializedViewIndex( + name="clinical_events_identity_uq", + columns=("person_id", "event_id"), + unique=True, + ), + ), +) + +with engine.begin() as connection: + create_materialized_view(connection, event_view) + create_materialized_view_indexes(connection, event_view) +``` + +The schema and view name are separate identifiers and are quoted by the PostgreSQL dialect. Index columns and index names are quoted in the same way. Applications should pass identifiers as plain strings; they should not add SQL quoting themselves. + +Creation fails when the view or index name already exists. This makes a stale definition or incompatible existing index visible to the caller. A registry that has separately checked the existing object may opt into idempotent PostgreSQL DDL with `if_not_exists=True`. + +## Refresh a populated view + +A normal refresh replaces the contents while holding the PostgreSQL lock associated with `REFRESH MATERIALIZED VIEW`: + +```python +from omop_alchemy.toolkit.core.materialization import refresh_materialized_view + +with engine.begin() as connection: + refresh_materialized_view(connection, event_view) +``` + +A concurrent refresh permits reads to continue, but PostgreSQL requires an eligible unique index on the populated view. Setting `concurrently=True` does not simply add SQL syntax. The helper first checks that the specification declares a simple unique index and then inspects PostgreSQL to confirm that an eligible unique index exists: + +```python +with engine.begin() as connection: + refresh_materialized_view( + connection, + event_view, + concurrently=True, + ) +``` + +If either check fails, `ConcurrentRefreshNotEligibleError` is raised before the refresh statement is executed. The declaration does not substitute for creating the index, and an undeclared database index does not substitute for recording the operational requirement in the specification. + +## Drop one qualified target + +Dropping uses the same `MaterializedViewTarget` as creation and refresh: + +```python +from omop_alchemy.toolkit.core.materialization import drop_materialized_view + +with engine.begin() as connection: + drop_materialized_view(connection, event_view) +``` + +The default is `DROP MATERIALIZED VIEW IF EXISTS` without `CASCADE`. Set `if_exists=False` when absence should be an error, or `cascade=True` when the caller has deliberately accounted for dependent database objects. + +Database failures are raised as `MaterializationError`. Its `failure` attribute records the operation, qualified target, optional index name, reason, and original exception. The original database exception is also retained as the exception cause. Lifecycle helpers never print an error and continue within an aborted transaction. + +## Identity, indexes, and dependencies + +`logical_identity` describes the complete output columns that distinguish rows in the materialized view. Construction fails if an identity or index refers to a column that the selectable does not expose. The identity is descriptive until a unique index or another database constraint enforces it, so applications should test its uniqueness against representative data before deployment. + +`dependencies` records other qualified materialized views that must already exist. It is metadata for an application-owned registry or deployment planner; the single-view lifecycle helpers do not create, refresh, or drop dependencies automatically. This keeps orchestration policy, dependency order, and command-line behaviour in the system that owns the collection of views. + +The DDL elements `CreateMaterializedView`, `CreateMaterializedViewIndex`, `RefreshMaterializedView`, and `DropMaterializedView` can be compiled with the PostgreSQL dialect when a deployment tool needs to inspect or record SQL without executing it. + +## API reference + +::: omop_alchemy.toolkit.core.materialization diff --git a/mkdocs.yml b/mkdocs.yml index 668bb02..1b73759 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -135,6 +135,7 @@ nav: - Toolkit: - Overview: toolkit/index.md - Core services: toolkit/core.md + - Materialized views: toolkit/materialized-views.md - Episodes: toolkit/episodes.md - Query contracts: toolkit/query-contracts.md - Clinical analytics: toolkit/analytics.md diff --git a/omop_alchemy/toolkit/core/__init__.py b/omop_alchemy/toolkit/core/__init__.py index 2c3972e..1efe65a 100644 --- a/omop_alchemy/toolkit/core/__init__.py +++ b/omop_alchemy/toolkit/core/__init__.py @@ -13,6 +13,9 @@ Canonical cross-table event identities and projection row shapes shared by timelines and episode builders. +``materialization`` + Qualified PostgreSQL materialized-view definitions and lifecycle helpers. + ``timeline`` Project heterogeneous clinical rows into a single ordered sequence of events for one person. diff --git a/omop_alchemy/toolkit/core/materialization/__init__.py b/omop_alchemy/toolkit/core/materialization/__init__.py new file mode 100644 index 0000000..bb7767e --- /dev/null +++ b/omop_alchemy/toolkit/core/materialization/__init__.py @@ -0,0 +1,58 @@ +"""PostgreSQL materialized-view definitions and lifecycle helpers. + +This package owns the mechanics of targeting and operating on one materialized +view. Applications retain responsibility for dependency ordering, deployment +policy, and registry or command-line orchestration. +""" + +from .contracts import ( + MaterializedSelectable, + MaterializedViewIndex, + MaterializedViewSpec, + MaterializedViewTarget, +) +from .ddl import ( + CreateMaterializedView, + CreateMaterializedViewIndex, + DropMaterializedView, + RefreshMaterializedView, + render_materialized_view_target, +) +from .lifecycle import ( + ConcurrentRefreshNotEligibleError, + MaterializationError, + MaterializationFailure, + MaterializationOperation, + MaterializationOutcome, + UnsupportedMaterializationDialectError, + create_materialized_view, + create_materialized_view_index, + create_materialized_view_indexes, + drop_materialized_view, + materialized_view_has_eligible_unique_index, + refresh_materialized_view, +) + +__all__ = [ + "ConcurrentRefreshNotEligibleError", + "CreateMaterializedView", + "CreateMaterializedViewIndex", + "DropMaterializedView", + "MaterializationError", + "MaterializationFailure", + "MaterializationOperation", + "MaterializationOutcome", + "MaterializedSelectable", + "MaterializedViewIndex", + "MaterializedViewSpec", + "MaterializedViewTarget", + "RefreshMaterializedView", + "UnsupportedMaterializationDialectError", + "create_materialized_view", + "create_materialized_view_index", + "create_materialized_view_indexes", + "drop_materialized_view", + "materialized_view_has_eligible_unique_index", + "refresh_materialized_view", + "render_materialized_view_target", +] diff --git a/omop_alchemy/toolkit/core/materialization/contracts.py b/omop_alchemy/toolkit/core/materialization/contracts.py new file mode 100644 index 0000000..629f69b --- /dev/null +++ b/omop_alchemy/toolkit/core/materialization/contracts.py @@ -0,0 +1,125 @@ +"""Side-effect-free contracts for PostgreSQL materialized views.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + +from sqlalchemy.sql.selectable import SelectBase + + +def _require_identifier(value: str, *, field_name: str) -> None: + if not value.strip(): + raise ValueError(f"{field_name} must not be empty") + + +def _require_distinct(values: tuple[str, ...], *, field_name: str) -> None: + if len(values) != len(set(values)): + raise ValueError(f"{field_name} must not contain duplicates") + + +@dataclass(frozen=True, slots=True) +class MaterializedViewTarget: + """The schema and name that identify one materialized view.""" + + schema: str + name: str + + def __post_init__(self) -> None: + _require_identifier(self.schema, field_name="schema") + _require_identifier(self.name, field_name="name") + + +@dataclass(frozen=True, slots=True) +class MaterializedViewIndex: + """A simple column index required by a materialized view. + + Only column indexes are represented. This keeps concurrent-refresh + eligibility explicit: PostgreSQL requires a unique index without a + predicate or expressions that includes every row in the view. + """ + + name: str + columns: tuple[str, ...] + unique: bool = False + + def __post_init__(self) -> None: + _require_identifier(self.name, field_name="index name") + if not self.columns: + raise ValueError("index columns must not be empty") + for column in self.columns: + _require_identifier(column, field_name="index column") + _require_distinct(self.columns, field_name="index columns") + + +@runtime_checkable +class MaterializedSelectable(Protocol): + """Definition consumed by materialized-view lifecycle helpers.""" + + @property + def target(self) -> MaterializedViewTarget: ... + + @property + def selectable(self) -> SelectBase: ... + + @property + def logical_identity(self) -> tuple[str, ...]: ... + + @property + def dependencies(self) -> tuple[MaterializedViewTarget, ...]: ... + + @property + def indexes(self) -> tuple[MaterializedViewIndex, ...]: ... + + +@dataclass(frozen=True, slots=True) +class MaterializedViewSpec: + """Immutable materialized-view definition with executable row identity. + + Dependencies are metadata for an owning registry or deployment tool. The + lifecycle helpers deliberately operate on one view at a time and do not + infer orchestration from them. + """ + + target: MaterializedViewTarget + selectable: SelectBase + logical_identity: tuple[str, ...] + dependencies: tuple[MaterializedViewTarget, ...] = () + indexes: tuple[MaterializedViewIndex, ...] = () + + def __post_init__(self) -> None: + if not self.logical_identity: + raise ValueError("logical_identity must not be empty") + for column in self.logical_identity: + _require_identifier(column, field_name="logical identity column") + _require_distinct( + self.logical_identity, + field_name="logical_identity", + ) + + output_columns = set(self.selectable.selected_columns.keys()) + unknown_identity = sorted(set(self.logical_identity) - output_columns) + if unknown_identity: + raise ValueError( + f"logical_identity columns are not selected: {unknown_identity}" + ) + + index_names = tuple(index.name for index in self.indexes) + _require_distinct(index_names, field_name="index names") + for index in self.indexes: + unknown_index_columns = sorted(set(index.columns) - output_columns) + if unknown_index_columns: + raise ValueError( + f"index {index.name!r} columns are not selected: " + f"{unknown_index_columns}" + ) + + if self.target in self.dependencies: + raise ValueError("a materialized view cannot depend on itself") + if len(self.dependencies) != len(set(self.dependencies)): + raise ValueError("dependencies must not contain duplicates") + + @property + def concurrent_refresh_indexes(self) -> tuple[MaterializedViewIndex, ...]: + """Declared indexes whose shape can support concurrent refresh.""" + return tuple(index for index in self.indexes if index.unique) diff --git a/omop_alchemy/toolkit/core/materialization/ddl.py b/omop_alchemy/toolkit/core/materialization/ddl.py new file mode 100644 index 0000000..a616154 --- /dev/null +++ b/omop_alchemy/toolkit/core/materialization/ddl.py @@ -0,0 +1,160 @@ +"""PostgreSQL DDL for schema-qualified materialized views.""" + +from __future__ import annotations + +from typing import Any + +from sqlalchemy.engine import Dialect +from sqlalchemy.ext.compiler import compiles +from sqlalchemy.schema import DDLElement +from sqlalchemy.sql.compiler import DDLCompiler, IdentifierPreparer +from sqlalchemy.sql.selectable import SelectBase + +from .contracts import MaterializedViewIndex, MaterializedViewTarget + + +def _quote_identifier(preparer: IdentifierPreparer, value: str) -> str: + return preparer.quote_identifier(value) + + +def _qualified_target( + preparer: IdentifierPreparer, + target: MaterializedViewTarget, +) -> str: + return ".".join( + ( + _quote_identifier(preparer, target.schema), + _quote_identifier(preparer, target.name), + ) + ) + + +def render_materialized_view_target( + target: MaterializedViewTarget, + dialect: Dialect, +) -> str: + """Render a fully quoted materialized-view identifier for ``dialect``.""" + return _qualified_target(dialect.identifier_preparer, target) + + +class CreateMaterializedView(DDLElement): + """Create one PostgreSQL materialized view from a selectable.""" + + inherit_cache = False + + def __init__( + self, + target: MaterializedViewTarget, + selectable: SelectBase, + *, + with_data: bool = True, + if_not_exists: bool = False, + ) -> None: + self.view_target = target + self.selectable = selectable + self.with_data = with_data + self.if_not_exists = if_not_exists + + +class RefreshMaterializedView(DDLElement): + """Refresh one PostgreSQL materialized view.""" + + inherit_cache = False + + def __init__( + self, + target: MaterializedViewTarget, + *, + concurrently: bool = False, + ) -> None: + self.view_target = target + self.concurrently = concurrently + + +class DropMaterializedView(DDLElement): + """Drop one PostgreSQL materialized view.""" + + inherit_cache = False + + def __init__( + self, + target: MaterializedViewTarget, + *, + if_exists: bool = True, + cascade: bool = False, + ) -> None: + self.view_target = target + self.if_exists = if_exists + self.cascade = cascade + + +class CreateMaterializedViewIndex(DDLElement): + """Create one declared simple index on a materialized view.""" + + inherit_cache = False + + def __init__( + self, + target: MaterializedViewTarget, + index: MaterializedViewIndex, + *, + if_not_exists: bool = False, + ) -> None: + self.view_target = target + self.index = index + self.if_not_exists = if_not_exists + + +@compiles(CreateMaterializedView, "postgresql") +def _compile_create_materialized_view( + element: CreateMaterializedView, + compiler: DDLCompiler, + **_: Any, +) -> str: + target = _qualified_target(compiler.preparer, element.view_target) + selectable = compiler.sql_compiler.process( + element.selectable, + literal_binds=True, + ) + existence = " IF NOT EXISTS" if element.if_not_exists else "" + population = "WITH DATA" if element.with_data else "WITH NO DATA" + return f"CREATE MATERIALIZED VIEW{existence} {target} AS {selectable} {population}" + + +@compiles(RefreshMaterializedView, "postgresql") +def _compile_refresh_materialized_view( + element: RefreshMaterializedView, + compiler: DDLCompiler, + **_: Any, +) -> str: + target = _qualified_target(compiler.preparer, element.view_target) + concurrency = " CONCURRENTLY" if element.concurrently else "" + return f"REFRESH MATERIALIZED VIEW{concurrency} {target}" + + +@compiles(DropMaterializedView, "postgresql") +def _compile_drop_materialized_view( + element: DropMaterializedView, + compiler: DDLCompiler, + **_: Any, +) -> str: + target = _qualified_target(compiler.preparer, element.view_target) + existence = " IF EXISTS" if element.if_exists else "" + cascade = " CASCADE" if element.cascade else "" + return f"DROP MATERIALIZED VIEW{existence} {target}{cascade}" + + +@compiles(CreateMaterializedViewIndex, "postgresql") +def _compile_create_materialized_view_index( + element: CreateMaterializedViewIndex, + compiler: DDLCompiler, + **_: Any, +) -> str: + target = _qualified_target(compiler.preparer, element.view_target) + index_name = _quote_identifier(compiler.preparer, element.index.name) + columns = ", ".join( + _quote_identifier(compiler.preparer, column) for column in element.index.columns + ) + uniqueness = "UNIQUE " if element.index.unique else "" + existence = " IF NOT EXISTS" if element.if_not_exists else "" + return f"CREATE {uniqueness}INDEX{existence} {index_name} ON {target} ({columns})" diff --git a/omop_alchemy/toolkit/core/materialization/lifecycle.py b/omop_alchemy/toolkit/core/materialization/lifecycle.py new file mode 100644 index 0000000..1f0ba67 --- /dev/null +++ b/omop_alchemy/toolkit/core/materialization/lifecycle.py @@ -0,0 +1,303 @@ +"""Execution helpers for one PostgreSQL materialized view at a time.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +import sqlalchemy as sa + +from .contracts import ( + MaterializedSelectable, + MaterializedViewIndex, + MaterializedViewTarget, +) +from .ddl import ( + CreateMaterializedView, + CreateMaterializedViewIndex, + DropMaterializedView, + RefreshMaterializedView, +) + + +class MaterializationOperation(StrEnum): + """Lifecycle operation recorded in outcomes and failures.""" + + create = "create" + create_index = "create_index" + inspect_indexes = "inspect_indexes" + refresh = "refresh" + drop = "drop" + + +@dataclass(frozen=True, slots=True) +class MaterializationOutcome: + """A successfully executed materialized-view operation.""" + + operation: MaterializationOperation + target: MaterializedViewTarget + index_name: str | None = None + + +@dataclass(frozen=True, slots=True) +class MaterializationFailure: + """Structured context for a failed materialized-view operation.""" + + operation: MaterializationOperation + target: MaterializedViewTarget + reason: str + index_name: str | None = None + cause: BaseException | None = None + + +class MaterializationError(RuntimeError): + """Base exception carrying structured materialization failure context.""" + + def __init__(self, failure: MaterializationFailure) -> None: + self.failure = failure + super().__init__( + f"Could not {failure.operation} materialized view " + f"{failure.target.schema}.{failure.target.name}: {failure.reason}" + ) + + +class UnsupportedMaterializationDialectError(MaterializationError): + """Raised before execution when a connection is not PostgreSQL.""" + + +class ConcurrentRefreshNotEligibleError(MaterializationError): + """Raised before refresh when no eligible unique index is available.""" + + +_ELIGIBLE_UNIQUE_INDEX_SQL = sa.text( + """ + SELECT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_class AS materialized_view + JOIN pg_catalog.pg_namespace AS namespace + ON namespace.oid = materialized_view.relnamespace + JOIN pg_catalog.pg_index AS index_definition + ON index_definition.indrelid = materialized_view.oid + JOIN pg_catalog.pg_class AS index_relation + ON index_relation.oid = index_definition.indexrelid + WHERE namespace.nspname = :schema + AND materialized_view.relname = :name + AND index_relation.relname IN :index_names + AND materialized_view.relkind = 'm' + AND index_definition.indisunique + AND index_definition.indisvalid + AND index_definition.indisready + AND index_definition.indpred IS NULL + AND index_definition.indexprs IS NULL + ) + """ +).bindparams(sa.bindparam("index_names", expanding=True)) + + +def _require_postgresql( + connection: sa.Connection, + *, + operation: MaterializationOperation, + target: MaterializedViewTarget, +) -> None: + if connection.dialect.name == "postgresql": + return + failure = MaterializationFailure( + operation=operation, + target=target, + reason=( + "materialized-view lifecycle operations require PostgreSQL; " + f"received {connection.dialect.name!r}" + ), + ) + raise UnsupportedMaterializationDialectError(failure) + + +def _execute( + connection: sa.Connection, + statement: Any, + *, + operation: MaterializationOperation, + target: MaterializedViewTarget, + index_name: str | None = None, +) -> MaterializationOutcome: + _require_postgresql(connection, operation=operation, target=target) + try: + connection.execute(statement) + except Exception as error: + failure = MaterializationFailure( + operation=operation, + target=target, + index_name=index_name, + reason=str(error), + cause=error, + ) + raise MaterializationError(failure) from error + return MaterializationOutcome( + operation=operation, + target=target, + index_name=index_name, + ) + + +def create_materialized_view( + connection: sa.Connection, + materialized: MaterializedSelectable, + *, + with_data: bool = True, + if_not_exists: bool = False, +) -> MaterializationOutcome: + """Create a materialized view at its declared qualified target.""" + return _execute( + connection, + CreateMaterializedView( + materialized.target, + materialized.selectable, + with_data=with_data, + if_not_exists=if_not_exists, + ), + operation=MaterializationOperation.create, + target=materialized.target, + ) + + +def create_materialized_view_index( + connection: sa.Connection, + target: MaterializedViewTarget, + index: MaterializedViewIndex, + *, + if_not_exists: bool = False, +) -> MaterializationOutcome: + """Create one declared index against the same qualified view target.""" + return _execute( + connection, + CreateMaterializedViewIndex( + target, + index, + if_not_exists=if_not_exists, + ), + operation=MaterializationOperation.create_index, + target=target, + index_name=index.name, + ) + + +def create_materialized_view_indexes( + connection: sa.Connection, + materialized: MaterializedSelectable, + *, + if_not_exists: bool = False, +) -> tuple[MaterializationOutcome, ...]: + """Create every index declared by a materialized selectable.""" + return tuple( + create_materialized_view_index( + connection, + materialized.target, + index, + if_not_exists=if_not_exists, + ) + for index in materialized.indexes + ) + + +def materialized_view_has_eligible_unique_index( + connection: sa.Connection, + materialized: MaterializedSelectable, +) -> bool: + """Confirm that a declared unique index is eligible in PostgreSQL.""" + operation = MaterializationOperation.inspect_indexes + target = materialized.target + _require_postgresql(connection, operation=operation, target=target) + index_names = tuple(index.name for index in materialized.indexes if index.unique) + if not index_names: + return False + try: + return bool( + connection.execute( + _ELIGIBLE_UNIQUE_INDEX_SQL, + { + "schema": target.schema, + "name": target.name, + "index_names": index_names, + }, + ).scalar_one() + ) + except Exception as error: + failure = MaterializationFailure( + operation=operation, + target=target, + reason=str(error), + cause=error, + ) + raise MaterializationError(failure) from error + + +def _require_concurrent_refresh_eligibility( + connection: sa.Connection, + materialized: MaterializedSelectable, +) -> None: + if not any(index.unique for index in materialized.indexes): + raise ConcurrentRefreshNotEligibleError( + MaterializationFailure( + operation=MaterializationOperation.refresh, + target=materialized.target, + reason="no simple unique index is declared", + ) + ) + if not materialized_view_has_eligible_unique_index( + connection, + materialized, + ): + raise ConcurrentRefreshNotEligibleError( + MaterializationFailure( + operation=MaterializationOperation.refresh, + target=materialized.target, + reason="PostgreSQL has no eligible unique index on the target", + ) + ) + + +def refresh_materialized_view( + connection: sa.Connection, + materialized: MaterializedSelectable, + *, + concurrently: bool = False, +) -> MaterializationOutcome: + """Refresh a materialized view after any concurrent-refresh preflight.""" + _require_postgresql( + connection, + operation=MaterializationOperation.refresh, + target=materialized.target, + ) + if concurrently: + _require_concurrent_refresh_eligibility(connection, materialized) + return _execute( + connection, + RefreshMaterializedView( + materialized.target, + concurrently=concurrently, + ), + operation=MaterializationOperation.refresh, + target=materialized.target, + ) + + +def drop_materialized_view( + connection: sa.Connection, + materialized: MaterializedSelectable, + *, + if_exists: bool = True, + cascade: bool = False, +) -> MaterializationOutcome: + """Drop one materialized view, preserving the original database failure.""" + return _execute( + connection, + DropMaterializedView( + materialized.target, + if_exists=if_exists, + cascade=cascade, + ), + operation=MaterializationOperation.drop, + target=materialized.target, + ) diff --git a/tests/test_materialized_view_lifecycle.py b/tests/test_materialized_view_lifecycle.py new file mode 100644 index 0000000..e72b310 --- /dev/null +++ b/tests/test_materialized_view_lifecycle.py @@ -0,0 +1,198 @@ +"""Materialized-view contracts, DDL, and lifecycle preflight tests.""" + +from __future__ import annotations + +from typing import Any, cast + +import pytest +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql, sqlite +from sqlalchemy.sql.elements import TextClause + +from omop_alchemy.toolkit.core.materialization import ( + ConcurrentRefreshNotEligibleError, + CreateMaterializedView, + CreateMaterializedViewIndex, + DropMaterializedView, + MaterializationError, + MaterializationOperation, + MaterializedSelectable, + MaterializedViewIndex, + MaterializedViewSpec, + MaterializedViewTarget, + RefreshMaterializedView, + UnsupportedMaterializationDialectError, + drop_materialized_view, + refresh_materialized_view, + render_materialized_view_target, +) + + +def _spec(*, unique_index: bool = True) -> MaterializedViewSpec: + indexes = ( + MaterializedViewIndex( + name="person events identity", + columns=("person_id", "event_id"), + unique=unique_index, + ), + ) + return MaterializedViewSpec( + target=MaterializedViewTarget( + schema="analysis space", + name='select"events', + ), + selectable=sa.select( + sa.literal(1).label("person_id"), + sa.literal(7).label("event_id"), + ), + logical_identity=("person_id", "event_id"), + indexes=indexes, + ) + + +def test_materialized_view_spec_implements_public_protocol(): + assert isinstance(_spec(), MaterializedSelectable) + + +def test_materialized_view_spec_validates_identity_and_index_columns(): + target = MaterializedViewTarget(schema="reporting", name="events") + selectable = sa.select(sa.literal(1).label("event_id")) + + with pytest.raises(ValueError, match="logical_identity columns"): + MaterializedViewSpec( + target=target, + selectable=selectable, + logical_identity=("person_id",), + ) + with pytest.raises(ValueError, match="index .* columns"): + MaterializedViewSpec( + target=target, + selectable=selectable, + logical_identity=("event_id",), + indexes=( + MaterializedViewIndex( + name="bad_index", + columns=("missing_column",), + ), + ), + ) + + +def test_qualified_target_is_always_schema_qualified_and_quoted(): + rendered = render_materialized_view_target( + _spec().target, + postgresql.dialect(), + ) + + assert rendered == '"analysis space"."select""events"' + + +def test_all_ddl_uses_the_same_qualified_target(): + spec = _spec() + dialect = postgresql.dialect() + qualified = render_materialized_view_target(spec.target, dialect) + statements = ( + CreateMaterializedView(spec.target, spec.selectable), + CreateMaterializedViewIndex(spec.target, spec.indexes[0]), + RefreshMaterializedView(spec.target, concurrently=True), + DropMaterializedView(spec.target, cascade=True), + ) + + compiled = tuple( + str(statement.compile(dialect=dialect)) for statement in statements + ) + + assert all(qualified in sql for sql in compiled) + assert compiled[0].startswith("CREATE MATERIALIZED VIEW ") + assert "IF NOT EXISTS" not in compiled[0] + assert "CREATE UNIQUE INDEX " in compiled[1] + assert "IF NOT EXISTS" not in compiled[1] + assert '"person_id", "event_id"' in compiled[1] + assert compiled[2].startswith("REFRESH MATERIALIZED VIEW CONCURRENTLY") + assert compiled[3].endswith(" CASCADE") + + +class _ScalarResult: + def __init__(self, value: bool) -> None: + self.value = value + + def scalar_one(self) -> bool: + return self.value + + +class _RecordingConnection: + def __init__( + self, + *, + dialect: sa.engine.Dialect | None = None, + eligible_index: bool = False, + error: Exception | None = None, + ) -> None: + self.dialect = dialect or postgresql.dialect() + self.eligible_index = eligible_index + self.error = error + self.statements: list[Any] = [] + + def execute(self, statement: Any, *_: Any, **__: Any) -> _ScalarResult: + self.statements.append(statement) + if self.error is not None: + raise self.error + return _ScalarResult(self.eligible_index) + + +def test_concurrent_refresh_without_declared_unique_index_executes_nothing(): + connection = _RecordingConnection() + + with pytest.raises(ConcurrentRefreshNotEligibleError) as error: + refresh_materialized_view( + cast(sa.Connection, connection), + _spec(unique_index=False), + concurrently=True, + ) + + assert connection.statements == [] + assert error.value.failure.operation is MaterializationOperation.refresh + assert "no simple unique index" in error.value.failure.reason + + +def test_concurrent_refresh_requires_the_declared_index_to_exist_in_postgres(): + connection = _RecordingConnection(eligible_index=False) + + with pytest.raises(ConcurrentRefreshNotEligibleError) as error: + refresh_materialized_view( + cast(sa.Connection, connection), + _spec(), + concurrently=True, + ) + + assert len(connection.statements) == 1 + assert isinstance(connection.statements[0], TextClause) + assert "PostgreSQL has no eligible unique index" in error.value.failure.reason + + +def test_drop_failure_retains_the_original_exception(): + original = RuntimeError("database transaction is aborted") + connection = _RecordingConnection(error=original) + + with pytest.raises(MaterializationError) as error: + drop_materialized_view( + cast(sa.Connection, connection), + _spec(), + ) + + assert error.value.failure.operation is MaterializationOperation.drop + assert error.value.failure.cause is original + assert error.value.__cause__ is original + assert len(connection.statements) == 1 + + +def test_lifecycle_rejects_non_postgresql_connections_before_execution(): + connection = _RecordingConnection(dialect=sqlite.dialect()) + + with pytest.raises(UnsupportedMaterializationDialectError): + drop_materialized_view( + cast(sa.Connection, connection), + _spec(), + ) + + assert connection.statements == [] diff --git a/tests/test_materialized_view_lifecycle_postgres.py b/tests/test_materialized_view_lifecycle_postgres.py new file mode 100644 index 0000000..e736a32 --- /dev/null +++ b/tests/test_materialized_view_lifecycle_postgres.py @@ -0,0 +1,104 @@ +"""PostgreSQL integration coverage for qualified materialized-view lifecycle.""" + +from __future__ import annotations + +import pytest +import sqlalchemy as sa + +from omop_alchemy.toolkit.core.materialization import ( + MaterializedViewIndex, + MaterializedViewSpec, + MaterializedViewTarget, + create_materialized_view, + create_materialized_view_indexes, + drop_materialized_view, + materialized_view_has_eligible_unique_index, + refresh_materialized_view, +) + + +LEFT_SCHEMA = "mv_lifecycle_left" +RIGHT_SCHEMA = "mv_lifecycle_right" +VIEW_NAME = "shared name" + + +def _spec(schema: str, value: str) -> MaterializedViewSpec: + return MaterializedViewSpec( + target=MaterializedViewTarget(schema=schema, name=VIEW_NAME), + selectable=sa.select( + sa.literal(1).label("row_id"), + sa.literal(value).label("payload"), + ), + logical_identity=("row_id",), + indexes=( + MaterializedViewIndex( + name="shared_name_row_id_uq", + columns=("row_id",), + unique=True, + ), + ), + ) + + +def _view_exists(connection: sa.Connection, target: MaterializedViewTarget) -> bool: + return bool( + connection.execute( + sa.text( + """ + SELECT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_matviews + WHERE schemaname = :schema + AND matviewname = :name + ) + """ + ), + {"schema": target.schema, "name": target.name}, + ).scalar_one() + ) + + +@pytest.mark.requires_database("test_cdm_db") +def test_lifecycle_is_scoped_to_the_requested_schema(pg_engine): + left = _spec(LEFT_SCHEMA, "left") + right = _spec(RIGHT_SCHEMA, "right") + + try: + with pg_engine.begin() as connection: + connection.execute( + sa.text(f'DROP SCHEMA IF EXISTS "{LEFT_SCHEMA}" CASCADE') + ) + connection.execute( + sa.text(f'DROP SCHEMA IF EXISTS "{RIGHT_SCHEMA}" CASCADE') + ) + connection.execute(sa.text(f'CREATE SCHEMA "{LEFT_SCHEMA}"')) + connection.execute(sa.text(f'CREATE SCHEMA "{RIGHT_SCHEMA}"')) + + create_materialized_view(connection, left) + create_materialized_view(connection, right) + create_materialized_view_indexes(connection, left) + create_materialized_view_indexes(connection, right) + + with pg_engine.begin() as connection: + assert materialized_view_has_eligible_unique_index( + connection, + left, + ) + assert materialized_view_has_eligible_unique_index( + connection, + right, + ) + + refresh_materialized_view(connection, left, concurrently=True) + drop_materialized_view(connection, left) + + assert not _view_exists(connection, left.target) + assert _view_exists(connection, right.target) + finally: + with pg_engine.begin() as connection: + connection.execute( + sa.text(f'DROP SCHEMA IF EXISTS "{LEFT_SCHEMA}" CASCADE') + ) + connection.execute( + sa.text(f'DROP SCHEMA IF EXISTS "{RIGHT_SCHEMA}" CASCADE') + ) From 8d4dc547dbda5e3b396c0c7a3d91e514dde8000f Mon Sep 17 00:00:00 2001 From: gkennos Date: Mon, 31 Aug 2026 11:02:00 +1000 Subject: [PATCH 05/30] cleanup and consolidation of public episode contracts --- docs/advanced/timelines.md | 17 +-- docs/toolkit/core.md | 4 +- docs/toolkit/episodes.md | 19 ++- docs/toolkit/materialized-views.md | 2 + docs/toolkit/query-contracts.md | 33 +++-- omop_alchemy/cdm/model/clinical/__init__.py | 36 +++-- .../model/clinical/clinical_event_union.py | 15 ++- .../cdm/model/clinical/measurement.py | 123 +++++++++++++++--- .../cdm/model/clinical/observation.py | 121 ++++++++++++++--- omop_alchemy/toolkit/core/concepts/runtime.py | 42 +++--- .../toolkit/core/events/projections.py | 91 +++++++------ .../toolkit/core/materialization/lifecycle.py | 13 +- .../toolkit/core/timeline/event_timeline.py | 123 ++++++++++-------- .../toolkit/episodes/derivation/__init__.py | 4 + .../toolkit/episodes/derivation/_ranking.py | 25 ++++ .../episodes/derivation/attachments.py | 73 ++++++----- .../toolkit/episodes/derivation/contracts.py | 60 ++++++++- .../episodes/derivation/observations.py | 10 +- .../toolkit/episodes/derivation/structure.py | 26 +++- .../toolkit/episodes/derivation/temporal.py | 36 ++--- tests/fixtures/query_contract_cases.py | 27 +++- .../test_clinical_event_union_deprecation.py | 24 ++++ tests/test_episode_attachment_queries.py | 123 ++++++++++++++++-- tests/test_episode_structure_queries.py | 71 +++++++++- tests/test_event_projections.py | 84 +++++++++++- tests/test_query_builder_contracts.py | 52 +++++--- tests/test_runtime_concept_queries.py | 4 +- tests/test_temporal_queries.py | 73 ++++++++++- 28 files changed, 1017 insertions(+), 314 deletions(-) create mode 100644 omop_alchemy/toolkit/episodes/derivation/_ranking.py create mode 100644 tests/test_clinical_event_union_deprecation.py diff --git a/docs/advanced/timelines.md b/docs/advanced/timelines.md index ad729c5..5213458 100644 --- a/docs/advanced/timelines.md +++ b/docs/advanced/timelines.md @@ -29,9 +29,7 @@ The value associated with a clinical event — numeric, concept, string, or none ### `EventMapping` -Declares which ORM fields supply the concept, start/end datetimes, and value for a -particular CDM table. Subclasses of `ClinicalEvent` set `_mapping` to an `EventMapping` -instance at class level. +Declares which ORM fields supply the concept, start/end datetimes, and value for a particular CDM table. `EventMapping.from_model()` derives event identity, source, concept, and start fields from the same stable metadata used by canonical SQL projections. Timeline classes add only their end, value, and display-specific fields. ::: omop_alchemy.toolkit.core.timeline.event_timeline.EventMapping @@ -94,21 +92,12 @@ with Session(engine) as session: ## Extending to new tables -To add a new CDM table to the timeline, subclass both `ClinicalEvent` and the target ORM -class and set `_mapping`: +To add a supported CDM table to the timeline, subclass both `ClinicalEvent` and the target ORM class and build `_mapping` from its Core metadata: ```python from omop_alchemy.toolkit.core.timeline.event_timeline import ClinicalEvent, EventMapping -from omop_alchemy.cdm.base import ModifierFieldConcepts from omop_alchemy.cdm.model.clinical import Procedure_Occurrence class Procedure_Event(Procedure_Occurrence, ClinicalEvent): - _mapping = EventMapping( - event_id_field="procedure_occurrence_id", - event_field_concept_id=ModifierFieldConcepts.PROCEDURE_OCCURRENCE, - event_source_table="procedure_occurrence", - concept_field="procedure_concept_id", - start_date_field="procedure_date", - start_datetime_field="procedure_datetime", - ) + _mapping = EventMapping.from_model(Procedure_Occurrence) ``` diff --git a/docs/toolkit/core.md b/docs/toolkit/core.md index 4a4caec..436383e 100644 --- a/docs/toolkit/core.md +++ b/docs/toolkit/core.md @@ -87,7 +87,9 @@ for event in session.execute(events).mappings(): print(event["event_source_table"], event["event_id"], event["event_date"]) ``` -The projection derives its ID, clinical concept, date, source table, and Field concept from `ModifierTargetMixin` metadata. `UnsupportedClinicalEventModelError` is raised before SQL execution when that metadata is incomplete. The [query contracts](query-contracts.md) explain how the projected shape participates in episode attachment. +The projection resolves its ID, clinical concept, date, source table, and Field concept through stable CDM event metadata. Bare `Measurement` and `Observation` classes remain lightweight mappings for ETL, while `MeasurementView` and `ObservationView` provide analytical reference context, domain validation, and episode-event resolution. Importing analytics modules cannot change the metadata used for a Core projection. `UnsupportedClinicalEventModelError` is raised before SQL execution when no supported CDM definition exists. + +The [query contracts](query-contracts.md) explain how the canonical shape participates in episode attachment. ## Work with a patient timeline diff --git a/docs/toolkit/episodes.md b/docs/toolkit/episodes.md index f1354ab..dc3f462 100644 --- a/docs/toolkit/episodes.md +++ b/docs/toolkit/episodes.md @@ -56,7 +56,7 @@ Window retrieval must be paired with a meaningful concept filter. Without one, e ## Understand unresolved episode links -`Episode_EventView.resolved_event` returns the linked ORM row when the field concept and target row can be resolved, otherwise `None`. `ResolvedEpisodeEvent` preserves that behaviour and adds a diagnostic that distinguishes three cases: +`Episode_EventView.resolved_event` returns the linked analytical ORM row when the field concept and target row can be resolved, otherwise `None`. Measurement and Observation links resolve to `MeasurementView` and `ObservationView`; their bare table mappings remain available for lightweight ETL. `ResolvedEpisodeEvent` preserves the resolution behaviour and adds a diagnostic that distinguishes three cases: - the field concept is not a recognised `ModifierFieldConcepts` value; - the field concept is recognised but no ORM target class is registered for it; or @@ -81,6 +81,14 @@ Use `ResolvedEpisodeEventMixin` on an episode view when diagnostics should be av ## Traverse an episode hierarchy +`canonical_episode_projection()` selects the stable structural fields without joining vocabulary tables. Pass `include_concept_label=True` when a result intended for inspection or presentation also needs `episode_concept_name`. The vocabulary lookup is optional: an episode remains in the result with a null label when its concept is zero, absent, or unavailable in a subset vocabulary. + +```python +from omop_alchemy.toolkit.episodes.derivation import canonical_episode_projection + +episodes = canonical_episode_projection(include_concept_label=True) +``` + For a parent episode, `episode_descendants()` returns a recursive CTE containing the root at depth zero and each descendant at its distance from that root: ```python @@ -111,12 +119,17 @@ For example, the following policy honours a valid explicit link and otherwise ch ```python from omop_alchemy.toolkit.episodes.derivation import ( EpisodeAttachmentPolicy, + EpisodeWindowSpec, TemporalRankingSpec, TemporalSelectionPolicy, TemporalSidePreference, ) attachment = EpisodeAttachmentPolicy.explicit_first_ranked +window = EpisodeWindowSpec( + days_prior=90, + open_end_fallback_days=365, +) ranking = TemporalRankingSpec( policy=TemporalSelectionPolicy.nearest, stable_id_column="episode_id", @@ -124,6 +137,8 @@ ranking = TemporalRankingSpec( ) ``` -Pass those choices to `episode_attachment_queries()` with a canonical event projection. The builder validates explicit links by event ID, Field-concept discriminator, episode ID, and person; suppresses fallback only after a valid link; and returns deterministic attachments plus optional diagnostics. `episode_window_predicate()`, `temporal_order_expressions()`, and `temporal_row_number()` remain available when a query needs the individual portable SQL pieces. See [Query contracts](query-contracts.md) for the complete result shape, attachment example, boundaries, repeated-observation selection, and the distinction between absolute-nearest and already-started-first ranking. +Pass the policy, window, and—only for ranked fallback—ranking to `episode_attachment_queries()` with a canonical event projection. The builder validates explicit links by event ID, Field-concept discriminator, episode ID, and person; suppresses fallback only after a valid link; and returns deterministic attachments plus optional diagnostics. All-in-window fallback accepts the window but rejects a ranking because it deliberately retains every admitted episode. `episode_window_predicate()`, `temporal_order_expressions()`, and `temporal_row_number()` remain available when a query needs the individual portable SQL pieces. Date arithmetic is implemented for PostgreSQL and SQLite; compiling it for another dialect fails rather than assuming PostgreSQL syntax. + +See [Query contracts](query-contracts.md) for the complete result shape, attachment example, boundaries, repeated-observation selection, and the distinction between absolute-nearest and already-started-first ranking. ::: omop_alchemy.toolkit.episodes.derivation diff --git a/docs/toolkit/materialized-views.md b/docs/toolkit/materialized-views.md index ca8a01d..b0fa56c 100644 --- a/docs/toolkit/materialized-views.md +++ b/docs/toolkit/materialized-views.md @@ -85,6 +85,8 @@ The default is `DROP MATERIALIZED VIEW IF EXISTS` without `CASCADE`. Set `if_exi Database failures are raised as `MaterializationError`. Its `failure` attribute records the operation, qualified target, optional index name, reason, and original exception. The original database exception is also retained as the exception cause. Lifecycle helpers never print an error and continue within an aborted transaction. +The `engine.begin()` context used in these examples rolls the transaction back automatically when an exception leaves the block. If an application manages a `Connection` transaction manually, it must roll back after a database error before attempting another statement on that connection. Catching `MaterializationError` does not make an aborted PostgreSQL transaction usable again. + ## Identity, indexes, and dependencies `logical_identity` describes the complete output columns that distinguish rows in the materialized view. Construction fails if an identity or index refers to a column that the selectable does not expose. The identity is descriptive until a unique index or another database constraint enforces it, so applications should test its uniqueness against representative data before deployment. diff --git a/docs/toolkit/query-contracts.md b/docs/toolkit/query-contracts.md index 6d63bca..3102426 100644 --- a/docs/toolkit/query-contracts.md +++ b/docs/toolkit/query-contracts.md @@ -75,7 +75,9 @@ attachment = EpisodeAttachmentIdentity.from_event( assert attachment.event == procedure ``` -Before accepting an explicit link, the query must confirm that the Field concept names the event's actual source table and that the event and episode belong to the same person. A source mismatch is a `discriminator_mismatch`; a person mismatch is a `person_mismatch`. A link to a missing source row is a `dangling_event`, but absence from an arbitrary event projection is not enough to establish that condition because the projection may intentionally be filtered. Diagnose dangling links from an unfiltered source table through the episode-event resolution APIs. +Before accepting an explicit link, the query confirms that the event ID and Field concept identify a row in the supplied projection and that the event and episode belong to the same person. A link carrying another table's Field concept is outside that projection's scope. This is important because event IDs are unique only within their OMOP table: a Procedure Occurrence 7 link says nothing about Measurement 7, even when both numbers happen to be present. + +Attachment diagnostics therefore do not infer a discriminator error or missing target from non-matching rows. An arbitrary event projection may intentionally be filtered, so absence from it does not prove anything about the underlying OMOP table. `ResolvedEpisodeEvent.event_resolution_diagnostics` checks the target table named by the Field concept and reports unsupported fields or `dangling_event` when the row truly does not exist. A link that names an existing row but is clinically incorrect cannot be identified from the linkage columns alone. Once valid, an explicit link takes precedence under either explicit-first policy. Suppose Procedure Occurrence 7 is linked to episode 1002, while its date also falls inside the windows of episodes 1001 and 1002. The result is only `(procedure_occurrence, 7, 1002)`: fallback must not add episode 1001 or duplicate episode 1002. @@ -94,6 +96,7 @@ Choosing between ranked and all-in-window fallback is a statement about result g ```python from omop_alchemy.toolkit.episodes.derivation import ( EpisodeAttachmentPolicy, + EpisodeAttachmentDiagnostic, TemporalRankingSpec, TemporalSelectionPolicy, episode_attachment_queries, @@ -112,11 +115,15 @@ attachment_queries = episode_attachment_queries( attachments = session.execute(attachment_queries.attachments).mappings().all() assert attachment_queries.diagnostics is not None diagnostics = session.execute(attachment_queries.diagnostics).mappings().all() +typed_diagnostics = [ + EpisodeAttachmentDiagnostic.from_mapping(row) + for row in diagnostics +] ``` The attachment result preserves the event projection and adds `episode_id` and `attachment_method`. Its uniqueness key is `(event_source_table, event_id, episode_id)`. A valid explicit link may legitimately connect an event to more than one episode; each relationship remains a separate attachment under that key. -Diagnostics are advisory rows and do not change the attachments. They identify discriminator and person mismatches, fallback ambiguity, and events for which no valid explicit link or fallback candidate exists. A discriminator mismatch is reported relative to a particular projected event: an `Episode_Event` row with event ID 7 and the Measurement Field concept is a valid link for Measurement 7 but a rejected candidate for Procedure Occurrence 7. +Diagnostics are advisory rows and do not change the attachments. They identify cross-person links, fallback ambiguity, and events for which no valid explicit link or fallback candidate exists. `EpisodeAttachmentDiagnostic.from_mapping()` converts a raw SQLAlchemy mapping into a typed value carrying the event identity, projected and linked Field concepts, episode, candidate count, and message. ## Rank fallback candidates @@ -177,20 +184,30 @@ candidate_episodes = candidate_episodes.order_by(*ordering) ### Date boundaries -Lower and upper bounds are inclusive by default and can be changed independently through `include_lower_bound` and `include_upper_bound`. For an episode starting 15 January 2026 with a 90-day prior window, 17 October 2025 lies exactly on the lower boundary and is included under the default. If the episode ends on 5 February, that date is included while 6 February is not. +Lower and upper bounds are inclusive by default and can be changed independently through `EpisodeWindowSpec`. For an episode starting 15 January 2026 with a 90-day prior window, 17 October 2025 lies exactly on the lower boundary and is included under the default. If the episode ends on 5 February, that date is included while 6 February is not. -Boundary choices belong in the ranking specification rather than being hidden in a comparison operator. This is especially important when two systems use similar-looking windows but disagree at exactly 90 or 180 days. +Window admission and candidate ranking are separate decisions. `EpisodeWindowSpec` owns the finite interval and its open or closed boundaries. `TemporalRankingSpec` is accepted only by ranked fallback and describes how already-admitted candidates are ordered. `episode_window_predicate()` uses the same finite defaults as the in-memory episode window. It honours a recorded episode end and substitutes a bounded post-start end only when the end is missing: ```python -from omop_alchemy.toolkit.episodes.derivation import episode_window_predicate +from omop_alchemy.toolkit.episodes.derivation import ( + EpisodeWindowSpec, + episode_window_predicate, +) + +window = EpisodeWindowSpec( + days_prior=90, + open_end_fallback_days=365, + include_lower_bound=True, + include_upper_bound=True, +) inside_episode_window = episode_window_predicate( events.c.event_date, Episode.episode_start_date, Episode.episode_end_date, - ranking=already_started_first, + window=window, ) ``` @@ -270,7 +287,7 @@ AND NOT (descendants of 400 OR exact concept 901) Exclusion wins when a concept is reached from both sides. With no inclusion, the set matches nothing. IDs are sorted and deduplicated when the specification is created. -`require_standard` and `include_classification` use the same vocabulary as `ConceptFilter` and `ConceptGroupSpec`; predicate rendering delegates to the existing normalised OMOP standardness expressions. The specification does not decide whether a numeric ID is valid in a particular vocabulary. Validate configuration and local-concept policy at the boundary where those rules are known. +`require_standard` and `include_classification` apply while expanding ancestor descendants. Exact IDs are explicit configuration and are not removed if the deployed vocabulary is temporarily out of step with the configuration source, including after a concept has been de-standardised. The specification does not decide whether an exact numeric ID is present, active, standard, or classification in a particular vocabulary; validate and report those expectations at the configuration boundary without changing membership silently. `runtime_concept_predicate()` translates the specification into database-side `concept_ancestor` and `concept` predicates: @@ -288,7 +305,7 @@ matching_procedures = select(Procedure_Occurrence).where( ) ``` -Constructing the specification or predicate performs no hierarchy expansion and no database access. Descendants are resolved by the database when the surrounding statement is executed. +Constructing the specification or predicate performs no hierarchy expansion and no database access. Descendants are resolved by the database when the surrounding statement is executed. Exact IDs are rendered as parameters, so very large externally supplied lists should be staged as rows and joined rather than pushed through a single `IN` predicate. Some applications compose positive and negative rules independently rather than collecting them into one runtime set. `descendant_concept_select()` provides the lower-level hierarchy operation for that case and returns each matching descendant once: diff --git a/omop_alchemy/cdm/model/clinical/__init__.py b/omop_alchemy/cdm/model/clinical/__init__.py index 9fe1046..a0cbd5a 100644 --- a/omop_alchemy/cdm/model/clinical/__init__.py +++ b/omop_alchemy/cdm/model/clinical/__init__.py @@ -1,24 +1,40 @@ -from .condition_occurrence import Condition_Occurrence, Condition_OccurrenceContext, Condition_OccurrenceView -from .measurement import Measurement -from .observation import Observation +from .condition_occurrence import ( + Condition_Occurrence, + Condition_OccurrenceContext, + Condition_OccurrenceView, +) +from .drug_exposure import Drug_Exposure, Drug_ExposureContext, Drug_ExposureView +from .measurement import Measurement, MeasurementContext, MeasurementView +from .observation import Observation, ObservationContext, ObservationView from .person import Person, PersonView -from .drug_exposure import Drug_Exposure -from .procedure_occurrence import Procedure_Occurrence +from .procedure_occurrence import ( + Procedure_Occurrence, + Procedure_OccurrenceContext, + Procedure_OccurrenceView, +) from .device_exposure import Device_Exposure from .death import Death from .specimen import Specimen __all__ = [ - "Condition_Occurrence", - "Condition_OccurrenceContext", + "Condition_Occurrence", + "Condition_OccurrenceContext", "Condition_OccurrenceView", + "Drug_Exposure", + "Drug_ExposureContext", + "Drug_ExposureView", "Measurement", + "MeasurementContext", + "MeasurementView", "Observation", + "ObservationContext", + "ObservationView", "Person", - "Drug_Exposure", "Procedure_Occurrence", + "Procedure_OccurrenceContext", + "Procedure_OccurrenceView", "Device_Exposure", "Death", "Specimen", - "PersonView" -] \ No newline at end of file + "PersonView", +] diff --git a/omop_alchemy/cdm/model/clinical/clinical_event_union.py b/omop_alchemy/cdm/model/clinical/clinical_event_union.py index 4c03a15..2cda850 100644 --- a/omop_alchemy/cdm/model/clinical/clinical_event_union.py +++ b/omop_alchemy/cdm/model/clinical/clinical_event_union.py @@ -1,9 +1,19 @@ +import warnings + from sqlalchemy import select, union_all, literal from .condition_occurrence import Condition_Occurrence -#from .drug_exposure import Drug_Exposure +# from .drug_exposure import Drug_Exposure from orm_loader.helpers import Base +warnings.warn( + "omop_alchemy.cdm.model.clinical.clinical_event_union is deprecated; " + "use omop_alchemy.toolkit.core.events.canonical_event_union instead. " + "The compatibility module will be removed in omop-alchemy 2.0.", + DeprecationWarning, + stacklevel=2, +) + clinical_event_union = union_all( select( literal("condition").label("domain"), @@ -30,6 +40,7 @@ # procedure... ).subquery("clinical_event") + class ClinicalEventView(Base): __table__ = clinical_event_union __mapper_args__ = { @@ -37,4 +48,4 @@ class ClinicalEventView(Base): clinical_event_union.c.domain, clinical_event_union.c.event_id, ] - } \ No newline at end of file + } diff --git a/omop_alchemy/cdm/model/clinical/measurement.py b/omop_alchemy/cdm/model/clinical/measurement.py index 8d2bb87..25b725b 100644 --- a/omop_alchemy/cdm/model/clinical/measurement.py +++ b/omop_alchemy/cdm/model/clinical/measurement.py @@ -3,27 +3,31 @@ import sqlalchemy as sa import sqlalchemy.orm as so from sqlalchemy.ext.hybrid import hybrid_property -from typing import Optional +from typing import Optional, TYPE_CHECKING from datetime import date, datetime from orm_loader.helpers import Base from omop_alchemy.cdm.base import ( CDMTableBase, + DomainValidationMixin, + ExpectedDomain, ModifierFieldConcepts, ModifierTargetMixin, + ReferenceContext, cdm_table, ValueMixin, merge_table_args, omop_index, ) +if TYPE_CHECKING: + from ..health_system import Provider, Visit_Detail, Visit_Occurrence + from ..vocabulary import Concept + from .person import Person + + @cdm_table -class Measurement(Base, CDMTableBase, ValueMixin, ModifierTargetMixin): +class Measurement(Base, CDMTableBase, ValueMixin): __tablename__ = "measurement" - __event_id_col__ = "measurement_id" - __concept_id_col__ = "measurement_concept_id" - __start_date_col__ = "measurement_date" - __end_date_col__ = "measurement_date" - __type_concept_id_col__ = "measurement_type_concept_id" __table_args__ = merge_table_args( omop_index(__tablename__, "person_id", cluster=True), omop_index(__tablename__, "measurement_concept_id"), @@ -33,30 +37,53 @@ class Measurement(Base, CDMTableBase, ValueMixin, ModifierTargetMixin): ) measurement_id: so.Mapped[int] = so.mapped_column(primary_key=True) - person_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey("person.person_id"), nullable=False) - measurement_concept_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey("concept.concept_id"), nullable=False) + person_id: so.Mapped[int] = so.mapped_column( + sa.ForeignKey("person.person_id"), nullable=False + ) + measurement_concept_id: so.Mapped[int] = so.mapped_column( + sa.ForeignKey("concept.concept_id"), nullable=False + ) measurement_date: so.Mapped[date] = so.mapped_column(nullable=False) measurement_datetime: so.Mapped[Optional[datetime]] measurement_time: so.Mapped[Optional[str]] - measurement_type_concept_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey("concept.concept_id"), nullable=False) - operator_concept_id: so.Mapped[Optional[int]] = so.mapped_column(sa.ForeignKey("concept.concept_id")) - unit_concept_id: so.Mapped[Optional[int]] = so.mapped_column(sa.ForeignKey("concept.concept_id")) + measurement_type_concept_id: so.Mapped[int] = so.mapped_column( + sa.ForeignKey("concept.concept_id"), nullable=False + ) + operator_concept_id: so.Mapped[Optional[int]] = so.mapped_column( + sa.ForeignKey("concept.concept_id") + ) + unit_concept_id: so.Mapped[Optional[int]] = so.mapped_column( + sa.ForeignKey("concept.concept_id") + ) range_low: so.Mapped[Optional[float]] range_high: so.Mapped[Optional[float]] - provider_id: so.Mapped[Optional[int]] = so.mapped_column(sa.ForeignKey("provider.provider_id")) - visit_occurrence_id: so.Mapped[Optional[int]] = so.mapped_column(sa.ForeignKey("visit_occurrence.visit_occurrence_id")) - visit_detail_id: so.Mapped[Optional[int]] = so.mapped_column(sa.ForeignKey("visit_detail.visit_detail_id")) + provider_id: so.Mapped[Optional[int]] = so.mapped_column( + sa.ForeignKey("provider.provider_id") + ) + visit_occurrence_id: so.Mapped[Optional[int]] = so.mapped_column( + sa.ForeignKey("visit_occurrence.visit_occurrence_id") + ) + visit_detail_id: so.Mapped[Optional[int]] = so.mapped_column( + sa.ForeignKey("visit_detail.visit_detail_id") + ) measurement_source_value: so.Mapped[Optional[str]] - measurement_source_concept_id: so.Mapped[Optional[int]] = so.mapped_column(sa.ForeignKey("concept.concept_id")) + measurement_source_concept_id: so.Mapped[Optional[int]] = so.mapped_column( + sa.ForeignKey("concept.concept_id") + ) unit_source_value: so.Mapped[Optional[str]] - unit_source_concept_id: so.Mapped[Optional[int]] = so.mapped_column(sa.ForeignKey("concept.concept_id")) + unit_source_concept_id: so.Mapped[Optional[int]] = so.mapped_column( + sa.ForeignKey("concept.concept_id") + ) value_source_value: so.Mapped[Optional[str]] measurement_event_id: so.Mapped[Optional[int]] - meas_event_field_concept_id: so.Mapped[Optional[int]] = so.mapped_column(sa.ForeignKey("concept.concept_id"), doc="Identifies which OMOP table measurement_event_id refers to",) + meas_event_field_concept_id: so.Mapped[Optional[int]] = so.mapped_column( + sa.ForeignKey("concept.concept_id"), + doc="Identifies which OMOP table measurement_event_id refers to", + ) @hybrid_property def modifier_of_event_id(self) -> Optional[int]: @@ -66,6 +93,66 @@ def modifier_of_event_id(self) -> Optional[int]: def modifier_of_field_concept_id(self) -> Optional[int]: return self.meas_event_field_concept_id + +class MeasurementContext(ReferenceContext): + """Read-only analytical relationships for a Measurement row.""" + + person: so.Mapped["Person"] = ReferenceContext._reference_relationship( + target="Person", local_fk="person_id", remote_pk="person_id" + ) # type: ignore[assignment] + measurement_concept: so.Mapped["Concept"] = ( + ReferenceContext._reference_relationship( + target="Concept", local_fk="measurement_concept_id", remote_pk="concept_id" + ) + ) # type: ignore[assignment] + measurement_type_concept: so.Mapped["Concept"] = ( + ReferenceContext._reference_relationship( + target="Concept", + local_fk="measurement_type_concept_id", + remote_pk="concept_id", + ) + ) # type: ignore[assignment] + provider: so.Mapped[Optional["Provider"]] = ( + ReferenceContext._reference_relationship( + target="Provider", local_fk="provider_id", remote_pk="provider_id" + ) + ) # type: ignore[assignment] + visit_occurrence: so.Mapped[Optional["Visit_Occurrence"]] = ( + ReferenceContext._reference_relationship( + target="Visit_Occurrence", + local_fk="visit_occurrence_id", + remote_pk="visit_occurrence_id", + ) + ) # type: ignore[assignment] + visit_detail: so.Mapped[Optional["Visit_Detail"]] = ( + ReferenceContext._reference_relationship( + target="Visit_Detail", + local_fk="visit_detail_id", + remote_pk="visit_detail_id", + ) + ) # type: ignore[assignment] + + +class MeasurementView( + Measurement, + MeasurementContext, + DomainValidationMixin, + ModifierTargetMixin, +): + """Analytical Measurement mapping with event metadata and reference context.""" + + __tablename__ = "measurement" + __mapper_args__ = {"concrete": False} + __event_id_col__ = "measurement_id" + __concept_id_col__ = "measurement_concept_id" + __start_date_col__ = "measurement_date" + __end_date_col__ = "measurement_date" + __type_concept_id_col__ = "measurement_type_concept_id" + __expected_domains__ = { + "measurement_concept_id": ExpectedDomain("Measurement"), + "measurement_type_concept_id": ExpectedDomain("Type Concept"), + } + @classmethod def modifier_field_concept_id(cls) -> int: return ModifierFieldConcepts.MEASUREMENT diff --git a/omop_alchemy/cdm/model/clinical/observation.py b/omop_alchemy/cdm/model/clinical/observation.py index b924ca1..e9b0fa5 100644 --- a/omop_alchemy/cdm/model/clinical/observation.py +++ b/omop_alchemy/cdm/model/clinical/observation.py @@ -3,27 +3,31 @@ import sqlalchemy as sa import sqlalchemy.orm as so from sqlalchemy.ext.hybrid import hybrid_property -from typing import Optional +from typing import Optional, TYPE_CHECKING from datetime import date, datetime from orm_loader.helpers import Base from omop_alchemy.cdm.base import ( CDMTableBase, + DomainValidationMixin, + ExpectedDomain, ModifierFieldConcepts, ModifierTargetMixin, + ReferenceContext, cdm_table, ValueMixin, merge_table_args, omop_index, ) +if TYPE_CHECKING: + from ..health_system import Provider, Visit_Detail, Visit_Occurrence + from ..vocabulary import Concept + from .person import Person + + @cdm_table -class Observation(Base, CDMTableBase, ValueMixin, ModifierTargetMixin): +class Observation(Base, CDMTableBase, ValueMixin): __tablename__ = "observation" - __event_id_col__ = "observation_id" - __concept_id_col__ = "observation_concept_id" - __start_date_col__ = "observation_date" - __end_date_col__ = "observation_date" - __type_concept_id_col__ = "observation_type_concept_id" __table_args__ = merge_table_args( omop_index(__tablename__, "person_id", cluster=True), omop_index(__tablename__, "observation_concept_id"), @@ -32,26 +36,46 @@ class Observation(Base, CDMTableBase, ValueMixin, ModifierTargetMixin): ) observation_id: so.Mapped[int] = so.mapped_column(primary_key=True) - person_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey("person.person_id"), nullable=False) - observation_concept_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey("concept.concept_id"), nullable=False) + person_id: so.Mapped[int] = so.mapped_column( + sa.ForeignKey("person.person_id"), nullable=False + ) + observation_concept_id: so.Mapped[int] = so.mapped_column( + sa.ForeignKey("concept.concept_id"), nullable=False + ) observation_date: so.Mapped[date] = so.mapped_column(nullable=False) observation_datetime: so.Mapped[Optional[datetime]] - observation_type_concept_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey("concept.concept_id"), nullable=False) - #value_as_number: so.Mapped[Optional[float]] + observation_type_concept_id: so.Mapped[int] = so.mapped_column( + sa.ForeignKey("concept.concept_id"), nullable=False + ) + # value_as_number: so.Mapped[Optional[float]] value_as_string: so.Mapped[Optional[str]] - #value_as_concept_id: so.Mapped[Optional[int]] = so.mapped_column(sa.ForeignKey("concept.concept_id")) - qualifier_concept_id: so.Mapped[Optional[int]] = so.mapped_column(sa.ForeignKey("concept.concept_id")) - unit_concept_id: so.Mapped[Optional[int]] = so.mapped_column(sa.ForeignKey("concept.concept_id")) - provider_id: so.Mapped[Optional[int]] = so.mapped_column(sa.ForeignKey("provider.provider_id")) - visit_occurrence_id: so.Mapped[Optional[int]] = so.mapped_column(sa.ForeignKey("visit_occurrence.visit_occurrence_id")) - visit_detail_id: so.Mapped[Optional[int]] = so.mapped_column(sa.ForeignKey("visit_detail.visit_detail_id")) + # value_as_concept_id: so.Mapped[Optional[int]] = so.mapped_column(sa.ForeignKey("concept.concept_id")) + qualifier_concept_id: so.Mapped[Optional[int]] = so.mapped_column( + sa.ForeignKey("concept.concept_id") + ) + unit_concept_id: so.Mapped[Optional[int]] = so.mapped_column( + sa.ForeignKey("concept.concept_id") + ) + provider_id: so.Mapped[Optional[int]] = so.mapped_column( + sa.ForeignKey("provider.provider_id") + ) + visit_occurrence_id: so.Mapped[Optional[int]] = so.mapped_column( + sa.ForeignKey("visit_occurrence.visit_occurrence_id") + ) + visit_detail_id: so.Mapped[Optional[int]] = so.mapped_column( + sa.ForeignKey("visit_detail.visit_detail_id") + ) observation_source_value: so.Mapped[Optional[str]] - observation_source_concept_id: so.Mapped[Optional[int]] = so.mapped_column(sa.ForeignKey("concept.concept_id")) + observation_source_concept_id: so.Mapped[Optional[int]] = so.mapped_column( + sa.ForeignKey("concept.concept_id") + ) unit_source_value: so.Mapped[Optional[str]] qualifier_source_value: so.Mapped[Optional[str]] value_source_value: so.Mapped[Optional[str]] observation_event_id: so.Mapped[Optional[int]] - obs_event_field_concept_id: so.Mapped[Optional[int]] = so.mapped_column(sa.ForeignKey("concept.concept_id")) + obs_event_field_concept_id: so.Mapped[Optional[int]] = so.mapped_column( + sa.ForeignKey("concept.concept_id") + ) @hybrid_property def modifier_of_event_id(self) -> Optional[int]: @@ -61,6 +85,65 @@ def modifier_of_event_id(self) -> Optional[int]: def modifier_of_field_concept_id(self) -> Optional[int]: return self.obs_event_field_concept_id + +class ObservationContext(ReferenceContext): + """Read-only analytical relationships for an Observation row.""" + + person: so.Mapped["Person"] = ReferenceContext._reference_relationship( + target="Person", local_fk="person_id", remote_pk="person_id" + ) # type: ignore[assignment] + observation_concept: so.Mapped["Concept"] = ( + ReferenceContext._reference_relationship( + target="Concept", local_fk="observation_concept_id", remote_pk="concept_id" + ) + ) # type: ignore[assignment] + observation_type_concept: so.Mapped["Concept"] = ( + ReferenceContext._reference_relationship( + target="Concept", + local_fk="observation_type_concept_id", + remote_pk="concept_id", + ) + ) # type: ignore[assignment] + provider: so.Mapped[Optional["Provider"]] = ( + ReferenceContext._reference_relationship( + target="Provider", local_fk="provider_id", remote_pk="provider_id" + ) + ) # type: ignore[assignment] + visit_occurrence: so.Mapped[Optional["Visit_Occurrence"]] = ( + ReferenceContext._reference_relationship( + target="Visit_Occurrence", + local_fk="visit_occurrence_id", + remote_pk="visit_occurrence_id", + ) + ) # type: ignore[assignment] + visit_detail: so.Mapped[Optional["Visit_Detail"]] = ( + ReferenceContext._reference_relationship( + target="Visit_Detail", + local_fk="visit_detail_id", + remote_pk="visit_detail_id", + ) + ) # type: ignore[assignment] + + +class ObservationView( + Observation, + ObservationContext, + DomainValidationMixin, + ModifierTargetMixin, +): + """Analytical Observation mapping with event metadata and reference context.""" + + __tablename__ = "observation" + __mapper_args__ = {"concrete": False} + __event_id_col__ = "observation_id" + __concept_id_col__ = "observation_concept_id" + __start_date_col__ = "observation_date" + __end_date_col__ = "observation_date" + __type_concept_id_col__ = "observation_type_concept_id" + __expected_domains__ = { + "observation_type_concept_id": ExpectedDomain("Type Concept"), + } + @classmethod def modifier_field_concept_id(cls) -> int: return ModifierFieldConcepts.OBSERVATION diff --git a/omop_alchemy/toolkit/core/concepts/runtime.py b/omop_alchemy/toolkit/core/concepts/runtime.py index dc839e4..0bf81ba 100644 --- a/omop_alchemy/toolkit/core/concepts/runtime.py +++ b/omop_alchemy/toolkit/core/concepts/runtime.py @@ -36,9 +36,11 @@ class RuntimeConceptSetSpec: side-effect free and preserves no session-bound vocabulary objects. ``require_standard`` and ``include_classification`` deliberately match - ``ConceptFilter`` and ``ConceptGroupSpec``. Predicate rendering delegates to - the existing normalised ``Concept`` flag expressions rather than defining - another interpretation of OMOP's single-character standardness flags. + ``ConceptFilter`` and ``ConceptGroupSpec`` for descendant expansion. + Exact IDs remain explicit inclusions even when the deployed vocabulary has + de-standardised one of them; configuration validation may report that drift + without silently changing set membership. Descendant rendering delegates to + the existing normalised ``Concept`` flag expressions. IDs are sorted and deduplicated only. Validity rules for configuration or a local vocabulary belong at those boundaries, not in this generic spec. @@ -100,10 +102,20 @@ def _concept_set_side( *, ancestor_ids: tuple[int, ...], exact_ids: tuple[int, ...], + require_standard: bool, + include_classification: bool, ) -> sa.ColumnElement[bool]: clauses: list[sa.ColumnElement[bool]] = [] if ancestor_ids: - clauses.append(column.in_(descendant_concept_select(ancestor_ids))) + clauses.append( + column.in_( + descendant_concept_select( + ancestor_ids, + require_standard=require_standard, + include_classification=include_classification, + ) + ) + ) if exact_ids: clauses.append(column.in_(exact_ids)) return sa.or_(*clauses) if clauses else sa.false() @@ -114,28 +126,22 @@ def runtime_concept_predicate( spec: RuntimeConceptSetSpec, ) -> sa.ColumnElement[bool]: """Render runtime concept membership entirely as database predicates.""" + if not spec.has_inclusions: + return sa.false() + included = _concept_set_side( column, ancestor_ids=spec.include_ancestor_ids, exact_ids=spec.include_exact_ids, + require_standard=spec.require_standard, + include_classification=spec.include_classification, ) - if not spec.has_inclusions: - return sa.false() excluded = _concept_set_side( column, ancestor_ids=spec.exclude_ancestor_ids, exact_ids=spec.exclude_exact_ids, + require_standard=spec.require_standard, + include_classification=spec.include_classification, ) - predicate = sa.and_(included, sa.not_(excluded)) - - if spec.require_standard: - standard_concept_ids = ConceptFilter( - require_standard=True, - include_classification=spec.include_classification, - ).apply(sa.select(Concept.concept_id)) - predicate = sa.and_( - predicate, - column.in_(standard_concept_ids), - ) - return predicate + return sa.and_(included, sa.not_(excluded)) diff --git a/omop_alchemy/toolkit/core/events/projections.py b/omop_alchemy/toolkit/core/events/projections.py index 76e599a..b510654 100644 --- a/omop_alchemy/toolkit/core/events/projections.py +++ b/omop_alchemy/toolkit/core/events/projections.py @@ -2,13 +2,24 @@ from __future__ import annotations -from collections.abc import Iterator from dataclasses import dataclass from typing import Any import sqlalchemy as sa from omop_alchemy.cdm.base import ModifierTargetMixin +from omop_alchemy.cdm.model.clinical import ( + Condition_Occurrence, + Condition_OccurrenceView, + Drug_Exposure, + Drug_ExposureView, + Measurement, + MeasurementView, + Observation, + ObservationView, + Procedure_Occurrence, + Procedure_OccurrenceView, +) from .contracts import ClinicalEventColumn @@ -35,41 +46,39 @@ class ClinicalEventModelSpec: event_source_table: str -def _subclasses(model: type[Any]) -> Iterator[type[Any]]: - for subclass in model.__subclasses__(): - yield subclass - yield from _subclasses(subclass) +_EVENT_METADATA_BY_TABLE: dict[str, type[ModifierTargetMixin]] = { + Condition_Occurrence.__tablename__: Condition_OccurrenceView, + Drug_Exposure.__tablename__: Drug_ExposureView, + Measurement.__tablename__: MeasurementView, + Observation.__tablename__: ObservationView, + Procedure_Occurrence.__tablename__: Procedure_OccurrenceView, +} +"""Stable CDM event metadata, independent of imported analytical subclasses.""" + + +def _has_complete_event_metadata(model: type[Any]) -> bool: + if not issubclass(model, ModifierTargetMixin): + return False + if any( + not getattr(model, name, None) + for name in ("__event_id_col__", "__concept_id_col__", "__start_date_col__") + ): + return False + try: + model.modifier_field_concept_id() + except NotImplementedError: + return False + return True def _metadata_candidate(model: type[Any]) -> type[ModifierTargetMixin] | None: - candidates = [model, *_subclasses(model)] - supported: list[type[ModifierTargetMixin]] = [] - for candidate in candidates: - if not issubclass(candidate, ModifierTargetMixin): - continue - required_names = ( - "__event_id_col__", - "__concept_id_col__", - "__start_date_col__", - ) - if any(not getattr(candidate, name, None) for name in required_names): - continue - try: - candidate.modifier_field_concept_id() - except NotImplementedError: - continue - supported.append(candidate) - - if not supported: - return None - supported.sort( - key=lambda candidate: ( - candidate is not model, - -len(candidate.mro()), - f"{candidate.__module__}.{candidate.__qualname__}", - ) - ) - return supported[0] + # An explicitly supplied event view owns its metadata. Bare CDM tables use the + # registered CDM view for that table; unrelated subclasses are never discovered + # by walking Python's import-dependent subclass graph. + if _has_complete_event_metadata(model): + return model + table_name = getattr(model, "__tablename__", None) + return _EVENT_METADATA_BY_TABLE.get(table_name) def _datetime_column_name(model: type[Any], date_column_name: str) -> str | None: @@ -83,7 +92,9 @@ def _datetime_column_name(model: type[Any], date_column_name: str) -> str | None def clinical_event_model_spec(model: type[Any]) -> ClinicalEventModelSpec: """Resolve the event metadata for an ORM model without accessing a database.""" if not isinstance(model, type) or not hasattr(model, "__table__"): - raise UnsupportedClinicalEventModelError(model, "expected a mapped ORM model class") + raise UnsupportedClinicalEventModelError( + model, "expected a mapped ORM model class" + ) metadata_model = _metadata_candidate(model) if metadata_model is None: @@ -152,7 +163,9 @@ def canonical_event_projection( columns: list[sa.ColumnElement[Any]] = [ model.person_id.label(str(ClinicalEventColumn.person_id)), getattr(model, spec.event_id_column).label(str(ClinicalEventColumn.event_id)), - getattr(model, spec.event_date_column).label(str(ClinicalEventColumn.event_date)), + getattr(model, spec.event_date_column).label( + str(ClinicalEventColumn.event_date) + ), event_datetime.label(str(ClinicalEventColumn.event_datetime)), getattr(model, spec.event_concept_id_column).label( str(ClinicalEventColumn.event_concept_id) @@ -167,13 +180,17 @@ def canonical_event_projection( if include_values: columns.extend( ( - _nullable_column(model, ClinicalEventColumn.value_as_number, sa.Float()), + _nullable_column( + model, ClinicalEventColumn.value_as_number, sa.Float() + ), _nullable_column( model, ClinicalEventColumn.value_as_concept_id, sa.Integer(), ), - _nullable_column(model, ClinicalEventColumn.unit_concept_id, sa.Integer()), + _nullable_column( + model, ClinicalEventColumn.unit_concept_id, sa.Integer() + ), ) ) return sa.select(*columns) diff --git a/omop_alchemy/toolkit/core/materialization/lifecycle.py b/omop_alchemy/toolkit/core/materialization/lifecycle.py index 1f0ba67..809f347 100644 --- a/omop_alchemy/toolkit/core/materialization/lifecycle.py +++ b/omop_alchemy/toolkit/core/materialization/lifecycle.py @@ -265,12 +265,15 @@ def refresh_materialized_view( concurrently: bool = False, ) -> MaterializationOutcome: """Refresh a materialized view after any concurrent-refresh preflight.""" - _require_postgresql( - connection, - operation=MaterializationOperation.refresh, - target=materialized.target, - ) if concurrently: + # Concurrent refresh performs catalog inspection before execution, so it + # needs the dialect guard before preflight. Ordinary refresh reaches the + # same guard once through _execute(). + _require_postgresql( + connection, + operation=MaterializationOperation.refresh, + target=materialized.target, + ) _require_concurrent_refresh_eligibility(connection, materialized) return _execute( connection, diff --git a/omop_alchemy/toolkit/core/timeline/event_timeline.py b/omop_alchemy/toolkit/core/timeline/event_timeline.py index f35a07b..a6149e0 100644 --- a/omop_alchemy/toolkit/core/timeline/event_timeline.py +++ b/omop_alchemy/toolkit/core/timeline/event_timeline.py @@ -1,21 +1,25 @@ - -from omop_alchemy.cdm.model.clinical import Measurement, Person, Condition_Occurrence, Drug_Exposure -from omop_alchemy.cdm.base import ModifierFieldConcepts +from omop_alchemy.cdm.model.clinical import ( + Measurement, + Person, + Condition_Occurrence, + Drug_Exposure, +) from sqlalchemy.orm import object_session from sqlalchemy import select from datetime import datetime, time, date -from typing import Optional, Mapping, Any, List +from typing import Optional, Mapping, Any, List import json from dataclasses import dataclass from typing import Protocol, Union, Literal -from omop_alchemy.toolkit.core.events import ClinicalEventRow +from omop_alchemy.toolkit.core.events import ClinicalEventRow, clinical_event_model_spec TemporalKind = Literal["point", "interval"] EventValueType = Literal["numeric", "concept", "string", "none"] + @dataclass(frozen=True) class EventValue: type: EventValueType @@ -35,13 +39,14 @@ class EventTime: """ Canonical temporal representation for an event. """ + start: datetime end: Optional[datetime] = None @property def kind(self) -> TemporalKind: return "interval" if self.end is not None else "point" - + class ClinicalEventProtocol(ClinicalEventRow, Protocol): """ @@ -49,6 +54,7 @@ class ClinicalEventProtocol(ClinicalEventRow, Protocol): """ """Primary clinical concept driving the event""" + @property def concept_id(self) -> int: ... @@ -56,19 +62,21 @@ def concept_id(self) -> int: ... Canonical event time. Handles date vs datetime, start-only vs start+end. """ + @property - def event_time(self) -> EventTime:... + def event_time(self) -> EventTime: ... """ Numeric or categorical values associated with the event. Used for feature construction (e.g. value_as_number). """ - def event_value(self) -> EventValue: ... + def event_value(self) -> EventValue: ... """ Non-feature metadata (units, source value, modifiers). """ + def event_metadata(self) -> Mapping[str, Any]: ... def to_dict(self) -> dict[str, Any]: ... @@ -87,6 +95,30 @@ class EventMapping: end_datetime_field: Optional[str] = None value_fields: Optional[List[str]] = None + @classmethod + def from_model( + cls, + model: type[Any], + *, + end_date_field: str | None = None, + end_datetime_field: str | None = None, + value_fields: list[str] | None = None, + ) -> "EventMapping": + """Build shared event fields from the canonical Core metadata definition.""" + spec = clinical_event_model_spec(model) + return cls( + event_id_field=spec.event_id_column, + event_field_concept_id=spec.event_field_concept_id, + event_source_table=spec.event_source_table, + concept_field=spec.event_concept_id_column, + start_date_field=spec.event_date_column, + start_datetime_field=spec.event_datetime_column, + end_date_field=end_date_field, + end_datetime_field=end_datetime_field, + value_fields=value_fields, + ) + + def _as_datetime(d: date | datetime | None, *, end: bool = False) -> datetime | None: if d is None: return None @@ -94,8 +126,8 @@ def _as_datetime(d: date | datetime | None, *, end: bool = False) -> datetime | return d return datetime.combine(d, time.max if end else time.min) + class ClinicalEvent: - _mapping: EventMapping @property @@ -133,7 +165,7 @@ def event_datetime(self) -> datetime | None: """Source datetime when the event table carries one, otherwise ``None``.""" field = self._mapping.start_datetime_field return getattr(self, field) if field else None - + def event_value(self) -> EventValue: fields = self._mapping.value_fields or [] @@ -143,17 +175,22 @@ def event_value(self) -> EventValue: if value is None: continue - if 'concept' in field.lower() and 'number' not in field.lower() and isinstance(value, int) and value != 0: + if ( + "concept" in field.lower() + and "number" not in field.lower() + and isinstance(value, int) + and value != 0 + ): return EventValue(type="concept", value=value) - - if 'number' in field.lower() and isinstance(value, (int, float)): + + if "number" in field.lower() and isinstance(value, (int, float)): return EventValue(type="numeric", value=value) - if 'string' in field.lower() and isinstance(value, str) and value.strip(): + if "string" in field.lower() and isinstance(value, str) and value.strip(): return EventValue(type="string", value=value) return EventValue(type="none", value=None) - + @property def event_time(self) -> EventTime: m = self._mapping @@ -163,7 +200,9 @@ def event_time(self) -> EventTime: if start is None: start = _as_datetime(getattr(self, m.start_date_field), end=False) if start is None: - raise ValueError(f"{self.__class__.__name__}: could not resolve event start time") + raise ValueError( + f"{self.__class__.__name__}: could not resolve event start time" + ) end = None if m.end_datetime_field: end = getattr(self, m.end_datetime_field) @@ -172,10 +211,9 @@ def event_time(self) -> EventTime: if end is not None and end <= start: end = None return EventTime(start=start, end=end) - + def event_metadata(self) -> Mapping[str, Any]: return {} - def __repr__(self: ClinicalEventProtocol) -> str: # ty: ignore[invalid-method-override] et = self.event_time @@ -204,7 +242,7 @@ def __repr__(self: ClinicalEventProtocol) -> str: # ty: ignore[invalid-method-o f"time={time_str} " f"value={value_str}>" ) - + def to_dict(self: ClinicalEventProtocol) -> dict[str, Any]: et = self.event_time ev = self.event_value() @@ -228,29 +266,17 @@ def to_json(self: ClinicalEventProtocol) -> str: return json.dumps(self.to_dict(), ensure_ascii=False) - class Condition_Event(Condition_Occurrence, ClinicalEvent): - _mapping = EventMapping( - event_id_field="condition_occurrence_id", - event_field_concept_id=ModifierFieldConcepts.CONDITION_OCCURRENCE, - event_source_table="condition_occurrence", - concept_field="condition_concept_id", - start_date_field="condition_start_date", - start_datetime_field="condition_start_datetime", + _mapping = EventMapping.from_model( + Condition_Occurrence, end_date_field="condition_end_date", end_datetime_field="condition_end_datetime", ) class Measurement_Event(ClinicalEvent, Measurement): - - _mapping = EventMapping( - event_id_field="measurement_id", - event_field_concept_id=ModifierFieldConcepts.MEASUREMENT, - event_source_table="measurement", - concept_field="measurement_concept_id", - start_date_field="measurement_date", - start_datetime_field="measurement_datetime", + _mapping = EventMapping.from_model( + Measurement, value_fields=[ "value_as_concept_id", "value_as_number", @@ -259,26 +285,18 @@ class Measurement_Event(ClinicalEvent, Measurement): ) def event_metadata(self) -> dict[str, Optional[int]]: - metadata = { - "unit_concept_id": self.unit_concept_id - } + metadata = {"unit_concept_id": self.unit_concept_id} return metadata class Drug_Exposure_Event(Drug_Exposure, ClinicalEvent): - - _mapping = EventMapping( - event_id_field="drug_exposure_id", - event_field_concept_id=ModifierFieldConcepts.DRUG_EXPOSURE, - event_source_table="drug_exposure", - concept_field="drug_concept_id", - start_date_field="drug_exposure_start_date", - start_datetime_field="drug_exposure_start_datetime", + _mapping = EventMapping.from_model( + Drug_Exposure, end_date_field="drug_exposure_end_date", end_datetime_field="drug_exposure_end_datetime", value_fields=["quantity"], ) - + def event_metadata(self) -> Mapping[str, Any]: metadata = { "route_source_value": self.route_source_value, @@ -288,10 +306,8 @@ def event_metadata(self) -> Mapping[str, Any]: class Person_Timeline(Person): - EVENT_TABLES = (Measurement_Event, Condition_Event, Drug_Exposure_Event) - @property def events(self) -> list[ClinicalEvent]: session = object_session(self) @@ -301,20 +317,17 @@ def events(self) -> list[ClinicalEvent]: events: list[ClinicalEvent] = [] for EventCls in self.EVENT_TABLES: - stmt = ( - select(EventCls) - .where(EventCls.person_id == self.person_id) - ) + stmt = select(EventCls).where(EventCls.person_id == self.person_id) events.extend(session.execute(stmt).scalars()) return events - + @property def timeline(self) -> list[ClinicalEvent]: return sorted( self.events, key=lambda e: e.event_time.start, ) - + def to_json(self) -> list[str]: # ty: ignore[invalid-method-override] return [e.to_json() for e in self.timeline] # ty: ignore[invalid-argument-type] diff --git a/omop_alchemy/toolkit/episodes/derivation/__init__.py b/omop_alchemy/toolkit/episodes/derivation/__init__.py index a59ba7b..fe930f9 100644 --- a/omop_alchemy/toolkit/episodes/derivation/__init__.py +++ b/omop_alchemy/toolkit/episodes/derivation/__init__.py @@ -23,6 +23,7 @@ from .contracts import ( CANONICAL_ATTACHMENT_DIAGNOSTIC_COLUMNS, CANONICAL_EPISODE_COLUMNS, + CANONICAL_EPISODE_OPTIONAL_COLUMNS, AttachmentDiagnosticCode, AttachmentDiagnosticColumn, EpisodeColumn, @@ -30,6 +31,7 @@ EpisodeAttachmentIdentity, EpisodeAttachmentMethod, EpisodeAttachmentPolicy, + EpisodeWindowSpec, ObservationSelectionPolicy, ObservationSelectionSpec, TemporalRankingSpec, @@ -64,12 +66,14 @@ "AttachmentDiagnosticColumn", "CANONICAL_ATTACHMENT_DIAGNOSTIC_COLUMNS", "CANONICAL_EPISODE_COLUMNS", + "CANONICAL_EPISODE_OPTIONAL_COLUMNS", "EpisodeColumn", "EpisodeAttachmentDiagnostic", "EpisodeAttachmentIdentity", "EpisodeAttachmentMethod", "EpisodeAttachmentPolicy", "EpisodeAttachmentQueries", + "EpisodeWindowSpec", "InvalidAttachmentSourceError", "ObservationSelectionPolicy", "ObservationSelectionSpec", diff --git a/omop_alchemy/toolkit/episodes/derivation/_ranking.py b/omop_alchemy/toolkit/episodes/derivation/_ranking.py new file mode 100644 index 0000000..af2cfce --- /dev/null +++ b/omop_alchemy/toolkit/episodes/derivation/_ranking.py @@ -0,0 +1,25 @@ +"""Shared internal construction for deterministic SQL window ranks.""" + +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +import sqlalchemy as sa + + +def deterministic_row_number( + *, + partition_by: Iterable[sa.ColumnElement[Any]], + order_by: Iterable[sa.ColumnElement[Any]], + label: str, +) -> sa.ColumnElement[int]: + """Build the row rank shared by temporal and observation selectors.""" + return ( + sa.func.row_number() + .over( + partition_by=tuple(partition_by), + order_by=tuple(order_by), + ) + .label(label) + ) diff --git a/omop_alchemy/toolkit/episodes/derivation/attachments.py b/omop_alchemy/toolkit/episodes/derivation/attachments.py index 40b2cfe..d0ee8e9 100644 --- a/omop_alchemy/toolkit/episodes/derivation/attachments.py +++ b/omop_alchemy/toolkit/episodes/derivation/attachments.py @@ -14,11 +14,6 @@ ClinicalEventColumn, canonical_event_projection, ) -from omop_alchemy.toolkit.episodes.handling.event_windowing import ( - DEFAULT_EPISODE_OPEN_END_FALLBACK_DAYS, - DEFAULT_EPISODE_WINDOW_DAYS_PRIOR, -) - from .contracts import ( CANONICAL_ATTACHMENT_DIAGNOSTIC_COLUMNS, AttachmentDiagnosticCode, @@ -26,6 +21,7 @@ EpisodeAttachmentMethod, EpisodeAttachmentPolicy, EpisodeColumn, + EpisodeWindowSpec, TemporalRankingSpec, ) from .structure import canonical_episode_projection @@ -166,28 +162,9 @@ def diagnostic_literals( ) null_integer = sa.cast(sa.null(), sa.Integer()) - discriminator_mismatches = ( - sa.select( - *diagnostic_literals( - AttachmentDiagnosticCode.discriminator_mismatch, - linked_field=episode_events.c[link_field], - linked_episode_id=episode_events.c[episode_id], - candidate_count=null_integer, - message="explicit link discriminator does not identify this event source", - ) - ) - .select_from( - events.join( - episode_events, - events.c[event_id] == episode_events.c[event_id], - ).join( - episodes, - episodes.c[episode_id] == episode_events.c[episode_id], - ) - ) - .where(events.c[event_field] != episode_events.c[link_field]) - ) - + # A discriminator-correct link is still rejected when it crosses people; + # this protects downstream episode grains from attaching another person's + # otherwise valid event row. person_mismatches = ( sa.select( *diagnostic_literals( @@ -213,6 +190,8 @@ def diagnostic_literals( .where(events.c[person_id] != episodes.c[person_id]) ) + # These keys distinguish events that already have authoritative links from + # those whose absence or fallback outcome still needs explanation. valid_keys = ( sa.select( valid_explicit.c[source_table], @@ -221,12 +200,12 @@ def diagnostic_literals( .distinct() .cte("valid_explicit_event_keys") ) - diagnostic_branches: list[sa.Select[Any]] = [ - discriminator_mismatches, - person_mismatches, - ] + diagnostic_branches: list[sa.Select[Any]] = [person_mismatches] if fallback_candidates is not None: + # Candidate counts are calculated before ranked fallback reduces the + # result. A selected row can therefore still explain that another + # eligible episode existed and was resolved by policy. candidate_keys = ( sa.select( fallback_candidates.c[source_table], @@ -257,6 +236,8 @@ def diagnostic_literals( else: candidate_keys = None + # The final diagnostic is event-relative: no valid explicit relationship + # and, where fallback is enabled, no episode admitted by the window. no_candidate_conditions = [_not_exists_for_event(events, valid_keys)] if candidate_keys is not None: no_candidate_conditions.append(_not_exists_for_event(events, candidate_keys)) @@ -289,8 +270,7 @@ def episode_attachment_queries( episodes: type[Episode] | FromClause | SelectBase = Episode, episode_events: type[Episode_Event] | FromClause | SelectBase = Episode_Event, ranking: TemporalRankingSpec | None = None, - days_prior: int = DEFAULT_EPISODE_WINDOW_DAYS_PRIOR, - open_end_fallback_days: int = DEFAULT_EPISODE_OPEN_END_FALLBACK_DAYS, + window: EpisodeWindowSpec = EpisodeWindowSpec(), include_diagnostics: bool = False, ) -> EpisodeAttachmentQueries: """Build explicit-first attachments from canonical event and episode inputs. @@ -302,6 +282,8 @@ def episode_attachment_queries( """ if policy.requires_fallback_ranking and ranking is None: raise ValueError("explicit_first_ranked requires a temporal ranking") + if not policy.requires_fallback_ranking and ranking is not None: + raise ValueError(f"{policy} does not use a temporal ranking") event_source = _event_source(events) episode_source = _episode_source(episodes) @@ -344,6 +326,9 @@ def episode_attachment_queries( episode_start = str(EpisodeColumn.episode_start_date) episode_end = str(EpisodeColumn.episode_end_date) + # stage 1 of episode resolution accepts an explicit link only when ID, Field + # discriminator, episode, and person agree. This is the sole point where an + # explicit link becomes authoritative enough to suppress date-based fallback. valid_explicit = ( sa.select( *(event_source.c[name] for name in event_names), @@ -376,6 +361,9 @@ def episode_attachment_queries( attachment_branches: list[sa.Select[Any]] = [explicit_select] if policy.uses_fallback: + # episode resolution stage 2 records the complete table-scoped identity + # of every valid explicit event. The anti-existence check below must use + # both columns: event_id alone is never a cross-table identity in OMOP. valid_keys = ( sa.select( valid_explicit.c[source_table], @@ -399,6 +387,9 @@ def episode_attachment_queries( "episodes is missing temporal stable ID column: " f"{ranking.stable_id_column}" ) + # episode resolution stage 3 ranks only after window admission. A side + # preference is a deliberate clinical policy tier; the stable episode + # ID prevents tied dates from depending on database row order. fallback_columns.append( temporal_row_number( episode_source.c[episode_start], @@ -413,6 +404,9 @@ def episode_attachment_queries( ) ) + # All fallback policies share the same finite episode-relative window. + # The policy decides whether every admitted episode survives or exactly + # one ranked candidate is retained. fallback_candidates = ( sa.select(*fallback_columns) .select_from( @@ -425,9 +419,7 @@ def episode_attachment_queries( event_source.c[event_date], episode_source.c[episode_start], episode_source.c[episode_end], - ranking=ranking, - days_prior=days_prior, - open_end_fallback_days=open_end_fallback_days, + window=window, ), ), ) @@ -444,6 +436,10 @@ def episode_attachment_queries( ) attachment_branches.append(selected_fallback) + # The builder accepts arbitrary selectables as inputs, so their source keys + # are not necessarily database constraints. Enforce the documented output + # identity here and prefer explicit provenance if a custom input manages to + # present the same attachment through more than one branch. combined = sa.union_all(*attachment_branches).cte("combined_episode_attachments") ranked_attachments = sa.select( *(combined.c[name] for name in attachment_names), @@ -471,6 +467,11 @@ def episode_attachment_queries( diagnostics = None if include_diagnostics: + # Diagnostics explain rejected links and fallback outcomes without + # changing attachment rows. Non-matching discriminators and missing + # source rows are intentionally out of scope: this builder may receive + # a filtered projection and cannot infer absence from an OMOP table. + # ResolvedEpisodeEvent owns complete target-table resolution. diagnostics = _attachment_diagnostics( event_source, episode_source, diff --git a/omop_alchemy/toolkit/episodes/derivation/contracts.py b/omop_alchemy/toolkit/episodes/derivation/contracts.py index 1e3b1aa..9e7fcbf 100644 --- a/omop_alchemy/toolkit/episodes/derivation/contracts.py +++ b/omop_alchemy/toolkit/episodes/derivation/contracts.py @@ -9,8 +9,13 @@ from dataclasses import dataclass from enum import StrEnum +from typing import Any, Mapping from omop_alchemy.toolkit.core.events import ClinicalEventIdentity +from omop_alchemy.toolkit.episodes.handling.event_windowing import ( + DEFAULT_EPISODE_OPEN_END_FALLBACK_DAYS, + DEFAULT_EPISODE_WINDOW_DAYS_PRIOR, +) class EpisodeColumn(StrEnum): @@ -26,11 +31,21 @@ class EpisodeColumn(StrEnum): episode_concept_id = "episode_concept_id" episode_object_concept_id = "episode_object_concept_id" episode_type_concept_id = "episode_type_concept_id" + episode_concept_name = "episode_concept_name" -CANONICAL_EPISODE_COLUMNS: tuple[EpisodeColumn, ...] = tuple(EpisodeColumn) +CANONICAL_EPISODE_COLUMNS: tuple[EpisodeColumn, ...] = tuple( + column + for column in EpisodeColumn + if column is not EpisodeColumn.episode_concept_name +) """Columns exposed by the canonical episode projection.""" +CANONICAL_EPISODE_OPTIONAL_COLUMNS: tuple[EpisodeColumn, ...] = ( + EpisodeColumn.episode_concept_name, +) +"""Opt-in descriptive columns exposed by an enriched episode projection.""" + @dataclass(frozen=True, order=True, slots=True) class EpisodeAttachmentIdentity: @@ -103,9 +118,7 @@ class EpisodeAttachmentMethod(StrEnum): class AttachmentDiagnosticCode(StrEnum): """Stable categories for explaining rejected or ambiguous attachment rows.""" - discriminator_mismatch = "discriminator_mismatch" person_mismatch = "person_mismatch" - dangling_event = "dangling_event" no_candidate_episode = "no_candidate_episode" ambiguous_fallback = "ambiguous_fallback" @@ -131,12 +144,47 @@ class AttachmentDiagnosticColumn(StrEnum): @dataclass(frozen=True, slots=True) class EpisodeAttachmentDiagnostic: - """Advisory result explaining why an attachment needs review.""" + """Typed advisory result returned by an attachment diagnostics query.""" code: AttachmentDiagnosticCode event: ClinicalEventIdentity + event_field_concept_id: int message: str + linked_event_field_concept_id: int | None = None episode_id: int | None = None + candidate_count: int | None = None + + @classmethod + def from_mapping(cls, row: Mapping[str, Any]) -> "EpisodeAttachmentDiagnostic": + """Convert one SQLAlchemy mapping result without leaking column-name handling.""" + return cls( + code=AttachmentDiagnosticCode(row["diagnostic_code"]), + event=ClinicalEventIdentity( + event_source_table=row["event_source_table"], + event_id=row["event_id"], + ), + event_field_concept_id=row["event_field_concept_id"], + linked_event_field_concept_id=row["linked_event_field_concept_id"], + episode_id=row["episode_id"], + candidate_count=row["candidate_count"], + message=row["message"], + ) + + +@dataclass(frozen=True, slots=True) +class EpisodeWindowSpec: + """Finite episode-relative window used to admit fallback event candidates.""" + + days_prior: int = DEFAULT_EPISODE_WINDOW_DAYS_PRIOR + open_end_fallback_days: int = DEFAULT_EPISODE_OPEN_END_FALLBACK_DAYS + include_lower_bound: bool = True + include_upper_bound: bool = True + + def __post_init__(self) -> None: + if self.days_prior < 0: + raise ValueError("days_prior must be non-negative") + if self.open_end_fallback_days < 0: + raise ValueError("open_end_fallback_days must be non-negative") class TemporalSelectionPolicy(StrEnum): @@ -162,7 +210,7 @@ class TemporalSidePreference(StrEnum): @dataclass(frozen=True, slots=True) class TemporalRankingSpec: - """Temporal ranking and boundary contract for SQL builders. + """Temporal ranking contract for SQL builders. A side preference, when present, is applied before the selection policy. ``nearest`` then means the smallest absolute distance within that tier. @@ -173,8 +221,6 @@ class TemporalRankingSpec: policy: TemporalSelectionPolicy stable_id_column: str side_preference: TemporalSidePreference = TemporalSidePreference.none - include_lower_bound: bool = True - include_upper_bound: bool = True def __post_init__(self) -> None: if not self.stable_id_column.strip(): diff --git a/omop_alchemy/toolkit/episodes/derivation/observations.py b/omop_alchemy/toolkit/episodes/derivation/observations.py index 7d7dcac..5e2831b 100644 --- a/omop_alchemy/toolkit/episodes/derivation/observations.py +++ b/omop_alchemy/toolkit/episodes/derivation/observations.py @@ -6,6 +6,7 @@ import sqlalchemy as sa +from ._ranking import deterministic_row_number from .contracts import ObservationSelectionPolicy, ObservationSelectionSpec @@ -58,11 +59,14 @@ def observation_row_number( stable_id = columns[spec.stable_id_column] partition_by = tuple(columns[name] for name in spec.partition_by) except KeyError as error: - raise ValueError(f"Observation selection column is missing: {error.args[0]}") from error - return sa.func.row_number().over( + raise ValueError( + f"Observation selection column is missing: {error.args[0]}" + ) from error + return deterministic_row_number( partition_by=partition_by, order_by=observation_order_expressions(observation_date, stable_id, spec), - ).label(label) + label=label, + ) def ranked_observation_select( diff --git a/omop_alchemy/toolkit/episodes/derivation/structure.py b/omop_alchemy/toolkit/episodes/derivation/structure.py index 10ed659..2710e8e 100644 --- a/omop_alchemy/toolkit/episodes/derivation/structure.py +++ b/omop_alchemy/toolkit/episodes/derivation/structure.py @@ -5,21 +5,37 @@ from typing import Any import sqlalchemy as sa +import sqlalchemy.orm as so from omop_alchemy.cdm.model.structural import Episode, Episode_Event +from omop_alchemy.cdm.model.vocabulary import Concept -from .contracts import CANONICAL_EPISODE_COLUMNS +from .contracts import CANONICAL_EPISODE_COLUMNS, EpisodeColumn def canonical_episode_projection( episode_model: type[Episode] = Episode, + *, + include_concept_label: bool = False, ) -> sa.Select[Any]: - """Project the stable fields needed to identify and interpret an episode.""" - return sa.select( + """Project stable episode fields, optionally including its concept name.""" + columns = [ *( getattr(episode_model, str(column)).label(str(column)) for column in CANONICAL_EPISODE_COLUMNS ) + ] + if not include_concept_label: + return sa.select(*columns) + + episode_concept = so.aliased(Concept, name="episode_projection_concept") + columns.append( + episode_concept.concept_name.label(str(EpisodeColumn.episode_concept_name)) + ) + return sa.select(*columns).join( + episode_concept, + episode_concept.concept_id == episode_model.episode_concept_id, + isouter=True, ) @@ -120,6 +136,4 @@ def episode_event_hierarchy_projection( hierarchy.c.depth.label("episode_depth"), event.c.event_id, event.c.episode_event_field_concept_id.label("event_field_concept_id"), - ).select_from( - hierarchy.join(event, event.c.episode_id == hierarchy.c.episode_id) - ) + ).select_from(hierarchy.join(event, event.c.episode_id == hierarchy.c.episode_id)) diff --git a/omop_alchemy/toolkit/episodes/derivation/temporal.py b/omop_alchemy/toolkit/episodes/derivation/temporal.py index 6a1a68b..232abc7 100644 --- a/omop_alchemy/toolkit/episodes/derivation/temporal.py +++ b/omop_alchemy/toolkit/episodes/derivation/temporal.py @@ -10,12 +10,9 @@ from sqlalchemy.sql.compiler import SQLCompiler from sqlalchemy.sql.functions import FunctionElement -from omop_alchemy.toolkit.episodes.handling.event_windowing import ( - DEFAULT_EPISODE_OPEN_END_FALLBACK_DAYS, - DEFAULT_EPISODE_WINDOW_DAYS_PRIOR, -) - +from ._ranking import deterministic_row_number from .contracts import ( + EpisodeWindowSpec, TemporalRankingSpec, TemporalSelectionPolicy, TemporalSidePreference, @@ -27,7 +24,6 @@ class _SignedDayDelta(FunctionElement[int]): inherit_cache = True -@compiles(_SignedDayDelta) @compiles(_SignedDayDelta, "postgresql") def _compile_signed_day_delta( element: _SignedDayDelta, @@ -60,7 +56,6 @@ class _ShiftDate(FunctionElement[Any]): inherit_cache = True -@compiles(_ShiftDate) @compiles(_ShiftDate, "postgresql") def _compile_shift_date( element: _ShiftDate, @@ -130,18 +125,13 @@ def episode_window_bounds( episode_start_date: sa.ColumnElement[Any], episode_end_date: sa.ColumnElement[Any], *, - days_prior: int = DEFAULT_EPISODE_WINDOW_DAYS_PRIOR, - open_end_fallback_days: int = DEFAULT_EPISODE_OPEN_END_FALLBACK_DAYS, + window: EpisodeWindowSpec = EpisodeWindowSpec(), ) -> tuple[sa.ColumnElement[Any], sa.ColumnElement[Any]]: """Build the bounded SQL interval used for date-admitted episode facts.""" - if days_prior < 0: - raise ValueError("days_prior must be non-negative") - if open_end_fallback_days < 0: - raise ValueError("open_end_fallback_days must be non-negative") - lower = shift_date(episode_start_date, days=-days_prior) + lower = shift_date(episode_start_date, days=-window.days_prior) upper = sa.func.coalesce( episode_end_date, - shift_date(episode_start_date, days=open_end_fallback_days), + shift_date(episode_start_date, days=window.open_end_fallback_days), ) return lower, upper @@ -151,23 +141,20 @@ def episode_window_predicate( episode_start_date: sa.ColumnElement[Any], episode_end_date: sa.ColumnElement[Any], *, - ranking: TemporalRankingSpec | None = None, - days_prior: int = DEFAULT_EPISODE_WINDOW_DAYS_PRIOR, - open_end_fallback_days: int = DEFAULT_EPISODE_OPEN_END_FALLBACK_DAYS, + window: EpisodeWindowSpec = EpisodeWindowSpec(), ) -> sa.ColumnElement[bool]: """Test whether an event lies inside a bounded episode-relative window.""" lower, upper = episode_window_bounds( episode_start_date, episode_end_date, - days_prior=days_prior, - open_end_fallback_days=open_end_fallback_days, + window=window, ) return bounded_temporal_predicate( event_date, lower, upper, - include_lower_bound=ranking.include_lower_bound if ranking else True, - include_upper_bound=ranking.include_upper_bound if ranking else True, + include_lower_bound=window.include_lower_bound, + include_upper_bound=window.include_upper_bound, ) @@ -206,7 +193,7 @@ def temporal_row_number( label: str = "temporal_rank", ) -> sa.ColumnElement[int]: """Return a deterministic row number for temporal candidates.""" - return sa.func.row_number().over( + return deterministic_row_number( partition_by=tuple(partition_by), order_by=temporal_order_expressions( candidate_date, @@ -214,4 +201,5 @@ def temporal_row_number( stable_id, ranking, ), - ).label(label) + label=label, + ) diff --git a/tests/fixtures/query_contract_cases.py b/tests/fixtures/query_contract_cases.py index 574a1dc..e00ee20 100644 --- a/tests/fixtures/query_contract_cases.py +++ b/tests/fixtures/query_contract_cases.py @@ -16,6 +16,7 @@ MEASUREMENT_FIELD_CONCEPT_ID = ModifierFieldConcepts.MEASUREMENT OBSERVATION_FIELD_CONCEPT_ID = ModifierFieldConcepts.OBSERVATION PROCEDURE_FIELD_CONCEPT_ID = ModifierFieldConcepts.PROCEDURE_OCCURRENCE +DRUG_FIELD_CONCEPT_ID = ModifierFieldConcepts.DRUG_EXPOSURE @dataclass(frozen=True, slots=True) @@ -78,8 +79,12 @@ class ObservationCase: # Episodes 1001 and 1002 overlap. Their start dates are equally distant from # 20 January, so nearest selection must use episode_id as its final tie-break. OVERLAPPING_EPISODES = ( - EpisodeCase(1001, person_id=101, start_date=date(2026, 1, 15), end_date=date(2026, 2, 5)), - EpisodeCase(1002, person_id=101, start_date=date(2026, 1, 25), end_date=date(2026, 2, 20)), + EpisodeCase( + 1001, person_id=101, start_date=date(2026, 1, 15), end_date=date(2026, 2, 5) + ), + EpisodeCase( + 1002, person_id=101, start_date=date(2026, 1, 25), end_date=date(2026, 2, 20) + ), EpisodeCase(2001, person_id=202, start_date=date(2026, 1, 10), end_date=None), ) @@ -88,8 +93,12 @@ class ObservationCase: # started. A side-neutral nearest policy selects 1003; a policy that prefers # already-started episodes selects 1001. DIRECTIONAL_PREFERENCE_EPISODES = ( - EpisodeCase(1001, person_id=101, start_date=date(2026, 1, 15), end_date=date(2026, 2, 5)), - EpisodeCase(1003, person_id=101, start_date=date(2026, 1, 21), end_date=date(2026, 2, 28)), + EpisodeCase( + 1001, person_id=101, start_date=date(2026, 1, 15), end_date=date(2026, 2, 5) + ), + EpisodeCase( + 1003, person_id=101, start_date=date(2026, 1, 21), end_date=date(2026, 2, 28) + ), ) @@ -118,12 +127,18 @@ class ObservationCase: episode_event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, ) -WRONG_DISCRIMINATOR_LINK = ExplicitLinkCase( - event=ClinicalEventIdentity("procedure_occurrence", 7), +COLLIDING_VALID_LINK = ExplicitLinkCase( + event=ClinicalEventIdentity("measurement", 7), episode_id=1001, episode_event_field_concept_id=MEASUREMENT_FIELD_CONCEPT_ID, ) +OUT_OF_SCOPE_LINK = ExplicitLinkCase( + event=ClinicalEventIdentity("drug_exposure", 7), + episode_id=1001, + episode_event_field_concept_id=DRUG_FIELD_CONCEPT_ID, +) + CROSS_PERSON_LINK = ExplicitLinkCase( event=ClinicalEventIdentity("observation", 7), episode_id=1001, diff --git a/tests/test_clinical_event_union_deprecation.py b/tests/test_clinical_event_union_deprecation.py new file mode 100644 index 0000000..4254c2a --- /dev/null +++ b/tests/test_clinical_event_union_deprecation.py @@ -0,0 +1,24 @@ +"""Compatibility warning for the superseded mapped clinical-event prototype.""" + +from __future__ import annotations + +import subprocess +import sys + + +def test_clinical_event_union_module_warns_on_direct_import(): + result = subprocess.run( + [ + sys.executable, + "-W", + "always::DeprecationWarning", + "-c", + "import omop_alchemy.cdm.model.clinical.clinical_event_union", + ], + capture_output=True, + check=True, + text=True, + ) + + assert "canonical_event_union" in result.stderr + assert "removed in omop-alchemy 2.0" in result.stderr diff --git a/tests/test_episode_attachment_queries.py b/tests/test_episode_attachment_queries.py index f9bb450..0f666e5 100644 --- a/tests/test_episode_attachment_queries.py +++ b/tests/test_episode_attachment_queries.py @@ -12,7 +12,9 @@ from omop_alchemy.toolkit.core.events import ClinicalEventIdentity from omop_alchemy.toolkit.episodes.derivation import ( AttachmentDiagnosticCode, + EpisodeAttachmentDiagnostic, EpisodeAttachmentPolicy, + EpisodeWindowSpec, TemporalRankingSpec, TemporalSelectionPolicy, TemporalSidePreference, @@ -23,8 +25,9 @@ CROSS_PERSON_LINK, DIRECTIONAL_PREFERENCE_EPISODES, OVERLAPPING_EPISODES, + OUT_OF_SCOPE_LINK, VALID_EXPLICIT_LINK, - WRONG_DISCRIMINATOR_LINK, + COLLIDING_VALID_LINK, EpisodeCase, EventCase, ExplicitLinkCase, @@ -118,7 +121,7 @@ def test_valid_explicit_links_suppress_ranked_fallback_with_colliding_ids(sessio episodes=_episode_source(*OVERLAPPING_EPISODES), episode_events=_link_source( VALID_EXPLICIT_LINK, - WRONG_DISCRIMINATOR_LINK, + COLLIDING_VALID_LINK, CROSS_PERSON_LINK, ), policy=EpisodeAttachmentPolicy.explicit_first_ranked, @@ -151,13 +154,27 @@ def test_invalid_explicit_link_does_not_suppress_fallback(session): assert session.execute(sources.attachments).mappings().one()["episode_id"] == 2001 +def test_foreign_discriminator_link_is_out_of_scope_for_a_single_model(session): + queries = episode_attachment_queries( + _event_source(COLLIDING_EVENTS[0]), + episodes=_episode_source(*OVERLAPPING_EPISODES[:2]), + episode_events=_link_source(COLLIDING_VALID_LINK, VALID_EXPLICIT_LINK), + policy=EpisodeAttachmentPolicy.explicit_only, + include_diagnostics=True, + ) + + assert session.execute(queries.attachments).mappings().one()["episode_id"] == 1001 + assert queries.diagnostics is not None + assert session.execute(queries.diagnostics).all() == [] + + def test_explicit_only_returns_valid_links_without_fallback(session): queries = episode_attachment_queries( _event_source(*COLLIDING_EVENTS), episodes=_episode_source(*OVERLAPPING_EPISODES), episode_events=_link_source( VALID_EXPLICIT_LINK, - WRONG_DISCRIMINATOR_LINK, + COLLIDING_VALID_LINK, CROSS_PERSON_LINK, ), policy=EpisodeAttachmentPolicy.explicit_only, @@ -220,7 +237,43 @@ def test_all_in_window_fallback_retains_each_eligible_episode(session): } -def test_diagnostics_explain_rejected_and_ambiguous_rows(session): +def test_all_in_window_uses_a_window_contract_without_ranking(session): + boundary_event = EventCase( + identity=ClinicalEventIdentity("procedure_occurrence", 8), + person_id=101, + event_date=date(2025, 10, 17), + event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, + ) + queries = episode_attachment_queries( + _event_source(boundary_event), + episodes=_episode_source(OVERLAPPING_EPISODES[0]), + episode_events=_empty_link_source(), + policy=EpisodeAttachmentPolicy.explicit_first_all_in_window, + window=EpisodeWindowSpec(include_lower_bound=False), + ) + + assert session.execute(queries.attachments).all() == [] + + +@pytest.mark.parametrize( + "policy", + [ + EpisodeAttachmentPolicy.explicit_only, + EpisodeAttachmentPolicy.explicit_first_all_in_window, + ], +) +def test_non_ranked_attachment_policies_reject_ranking(policy): + with pytest.raises(ValueError, match="does not use a temporal ranking"): + episode_attachment_queries( + _event_source(COLLIDING_EVENTS[0]), + episodes=_episode_source(OVERLAPPING_EPISODES[0]), + episode_events=_empty_link_source(), + policy=policy, + ranking=_nearest(), + ) + + +def test_diagnostics_explain_person_mismatches_and_fallback_outcomes(session): ambiguous = EventCase( identity=ClinicalEventIdentity("procedure_occurrence", 8), person_id=101, @@ -238,7 +291,8 @@ def test_diagnostics_explain_rejected_and_ambiguous_rows(session): episodes=_episode_source(*OVERLAPPING_EPISODES), episode_events=_link_source( VALID_EXPLICIT_LINK, - WRONG_DISCRIMINATOR_LINK, + COLLIDING_VALID_LINK, + OUT_OF_SCOPE_LINK, CROSS_PERSON_LINK, ), policy=EpisodeAttachmentPolicy.explicit_first_ranked, @@ -256,11 +310,23 @@ def test_diagnostics_explain_rejected_and_ambiguous_rows(session): and row["event_id"] == 8 ] - assert str(AttachmentDiagnosticCode.discriminator_mismatch) in codes assert str(AttachmentDiagnosticCode.person_mismatch) in codes assert str(AttachmentDiagnosticCode.no_candidate_episode) in codes assert len(ambiguous_rows) == 1 assert ambiguous_rows[0]["candidate_count"] == 2 + person_rows = [ + row + for row in rows + if row["diagnostic_code"] == str(AttachmentDiagnosticCode.person_mismatch) + ] + typed = EpisodeAttachmentDiagnostic.from_mapping(person_rows[0]) + assert typed.code is AttachmentDiagnosticCode.person_mismatch + assert typed.event.event_id == 7 + assert ( + typed.linked_event_field_concept_id + == CROSS_PERSON_LINK.episode_event_field_concept_id + ) + assert typed.episode_id == CROSS_PERSON_LINK.episode_id def test_ranked_policy_requires_a_ranking_contract(): @@ -268,7 +334,7 @@ def test_ranked_policy_requires_a_ranking_contract(): episode_attachment_queries( _event_source(COLLIDING_EVENTS[0]), episodes=_episode_source(OVERLAPPING_EPISODES[0]), - episode_events=_link_source(WRONG_DISCRIMINATOR_LINK), + episode_events=_link_source(OUT_OF_SCOPE_LINK), policy=EpisodeAttachmentPolicy.explicit_first_ranked, ) @@ -280,7 +346,7 @@ def test_attachment_and_diagnostics_compile_on_supported_dialects(dialect): episodes=_episode_source(*OVERLAPPING_EPISODES), episode_events=_link_source( VALID_EXPLICIT_LINK, - WRONG_DISCRIMINATOR_LINK, + COLLIDING_VALID_LINK, CROSS_PERSON_LINK, ), policy=EpisodeAttachmentPolicy.explicit_first_ranked, @@ -302,3 +368,44 @@ def test_attachment_builder_accepts_a_supported_event_model(): assert "procedure_occurrence" in str( queries.attachments.compile(dialect=postgresql.dialect()) ) + + +@pytest.mark.requires_database("test_cdm_db") +def test_postgresql_executes_collision_and_stable_tie_contracts(pg_session): + unlinked = EventCase( + identity=ClinicalEventIdentity("procedure_occurrence", 8), + person_id=101, + event_date=date(2026, 1, 20), + event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, + ) + queries = episode_attachment_queries( + _event_source(*COLLIDING_EVENTS[:2], unlinked), + episodes=_episode_source(*OVERLAPPING_EPISODES[:2]), + episode_events=_link_source(VALID_EXPLICIT_LINK, COLLIDING_VALID_LINK), + policy=EpisodeAttachmentPolicy.explicit_first_ranked, + ranking=_nearest(), + include_diagnostics=True, + ) + + rows = pg_session.execute(queries.attachments).mappings().all() + assert { + (row["event_source_table"], row["event_id"], row["episode_id"]) for row in rows + } == { + ("measurement", 7, 1001), + ("procedure_occurrence", 7, 1002), + ("procedure_occurrence", 8, 1001), + } + + single_model = episode_attachment_queries( + _event_source(COLLIDING_EVENTS[0]), + episodes=_episode_source(*OVERLAPPING_EPISODES[:2]), + episode_events=_link_source(COLLIDING_VALID_LINK, VALID_EXPLICIT_LINK), + policy=EpisodeAttachmentPolicy.explicit_only, + include_diagnostics=True, + ) + assert ( + pg_session.execute(single_model.attachments).mappings().one()["episode_id"] + == 1001 + ) + assert single_model.diagnostics is not None + assert pg_session.execute(single_model.diagnostics).all() == [] diff --git a/tests/test_episode_structure_queries.py b/tests/test_episode_structure_queries.py index 3db377e..7ef8a12 100644 --- a/tests/test_episode_structure_queries.py +++ b/tests/test_episode_structure_queries.py @@ -2,11 +2,15 @@ from __future__ import annotations +from datetime import date + import sqlalchemy as sa from sqlalchemy.dialects import postgresql, sqlite +from omop_alchemy.cdm.model.structural import Episode from omop_alchemy.toolkit.episodes.derivation import ( CANONICAL_EPISODE_COLUMNS, + CANONICAL_EPISODE_OPTIONAL_COLUMNS, canonical_episode_projection, direct_episode_relationship_projection, episode_descendants, @@ -22,12 +26,63 @@ def test_canonical_episode_projection_has_stable_fields(): ) +def test_canonical_episode_projection_can_include_the_episode_concept_name(session): + source = session.get(Episode, 100) + assert source is not None + session.add( + Episode( + episode_id=102, + person_id=source.person_id, + episode_start_date=date(2020, 2, 1), + episode_end_date=None, + episode_concept_id=0, + episode_object_concept_id=source.episode_object_concept_id, + episode_type_concept_id=source.episode_type_concept_id, + ) + ) + session.flush() + + unlabelled = canonical_episode_projection().where( + sa.column("episode_id").in_((100, 102)) + ) + labelled = canonical_episode_projection(include_concept_label=True).where( + sa.column("episode_id").in_((100, 102)) + ) + unlabelled_rows = session.execute(unlabelled).mappings().all() + labelled_rows = session.execute(labelled).mappings().all() + + assert tuple(labelled.selected_columns.keys()) == tuple( + map(str, CANONICAL_EPISODE_COLUMNS + CANONICAL_EPISODE_OPTIONAL_COLUMNS) + ) + assert ( + {row["episode_id"] for row in labelled_rows} + == {row["episode_id"] for row in unlabelled_rows} + == {100, 102} + ) + labels = {row["episode_id"]: row["episode_concept_name"] for row in labelled_rows} + assert labels == {100: "Disease Episode", 102: None} + + +def test_episode_concept_label_projection_compiles_on_supported_dialects(): + for dialect in (sqlite.dialect(), postgresql.dialect()): + compiled = str( + canonical_episode_projection(include_concept_label=True).compile( + dialect=dialect + ) + ) + assert "LEFT OUTER JOIN" in compiled + + def test_direct_episode_relationships_preserve_person_and_depth(session): - rows = session.execute( - direct_episode_relationship_projection().where( - sa.column("root_episode_id") == 100 + rows = ( + session.execute( + direct_episode_relationship_projection().where( + sa.column("root_episode_id") == 100 + ) ) - ).mappings().all() + .mappings() + .all() + ) assert rows == [ { @@ -61,9 +116,11 @@ def test_recursive_episode_projection_can_exclude_root(session): def test_episode_event_projection_carries_owning_depth(session): - rows = session.execute( - episode_event_hierarchy_projection(root_episode_id=100) - ).mappings().all() + rows = ( + session.execute(episode_event_hierarchy_projection(root_episode_id=100)) + .mappings() + .all() + ) assert rows == [ { diff --git a/tests/test_event_projections.py b/tests/test_event_projections.py index 4055917..8357846 100644 --- a/tests/test_event_projections.py +++ b/tests/test_event_projections.py @@ -2,6 +2,10 @@ from __future__ import annotations +import subprocess +import sys +import textwrap + import pytest from sqlalchemy.dialects import postgresql, sqlite @@ -14,6 +18,14 @@ Observation, Procedure_Occurrence, ) +from omop_alchemy.cdm.model.clinical import ( + Condition_OccurrenceView, + Drug_ExposureView, + MeasurementView, + ObservationView, + Procedure_OccurrenceView, +) +from omop_alchemy.cdm.base import ModifierTargetMixin from omop_alchemy.cdm.model.structural import Episode_EventView from omop_alchemy.toolkit.core.events import ( CANONICAL_EVENT_OPTIONAL_COLUMNS, @@ -104,8 +116,74 @@ def test_incomplete_modifier_target_has_a_typed_error(): canonical_event_projection(Device_Exposure) -def test_measurement_and_observation_are_registered_episode_event_targets(): +def test_all_core_event_views_are_registered_episode_event_targets(): targets = Episode_EventView.resolved_event_target_classes() - assert targets[ModifierFieldConcepts.MEASUREMENT] is Measurement - assert targets[ModifierFieldConcepts.OBSERVATION] is Observation + expected = { + ModifierFieldConcepts.CONDITION_OCCURRENCE: Condition_OccurrenceView, + ModifierFieldConcepts.DRUG_EXPOSURE: Drug_ExposureView, + ModifierFieldConcepts.MEASUREMENT: MeasurementView, + ModifierFieldConcepts.OBSERVATION: ObservationView, + ModifierFieldConcepts.PROCEDURE_OCCURRENCE: Procedure_OccurrenceView, + } + + assert {field: targets[field] for field in expected} == expected + assert not issubclass(Measurement, ModifierTargetMixin) + assert not issubclass(Observation, ModifierTargetMixin) + + +def test_registered_event_views_have_distinct_field_concepts(): + views = ( + Condition_OccurrenceView, + Drug_ExposureView, + MeasurementView, + ObservationView, + Procedure_OccurrenceView, + ) + field_concepts = tuple(view.modifier_field_concept_id() for view in views) + + assert len(field_concepts) == len(set(field_concepts)) == 5 + + +def test_analytics_import_preserves_all_core_metadata_and_compiled_projections(): + code = textwrap.dedent( + """ + from sqlalchemy.dialects import postgresql, sqlite + from omop_alchemy.cdm.model import ( + Condition_Occurrence, + Drug_Exposure, + Measurement, + Observation, + Procedure_Occurrence, + ) + from omop_alchemy.toolkit.core.events import ( + canonical_event_projection, + clinical_event_model_spec, + ) + + models = ( + Condition_Occurrence, + Drug_Exposure, + Measurement, + Observation, + Procedure_Occurrence, + ) + + def snapshot(): + return tuple( + ( + model.__name__, + clinical_event_model_spec(model), + str(canonical_event_projection(model).compile(dialect=sqlite.dialect())), + str(canonical_event_projection(model).compile(dialect=postgresql.dialect())), + ) + for model in models + ) + + before = snapshot() + import omop_alchemy.toolkit.analytics.oncology + assert snapshot() == before + """ + ) + + subprocess.run([sys.executable, "-c", code], check=True) diff --git a/tests/test_query_builder_contracts.py b/tests/test_query_builder_contracts.py index 5c6a9c7..a2c5090 100644 --- a/tests/test_query_builder_contracts.py +++ b/tests/test_query_builder_contracts.py @@ -23,6 +23,7 @@ from omop_alchemy.toolkit.episodes.derivation import ( EpisodeAttachmentIdentity, EpisodeAttachmentPolicy, + EpisodeWindowSpec, ObservationSelectionPolicy, ObservationSelectionSpec, TemporalRankingSpec, @@ -40,7 +41,8 @@ OVERLAPPING_EPISODES, REPEATED_OBSERVATIONS, VALID_EXPLICIT_LINK, - WRONG_DISCRIMINATOR_LINK, + COLLIDING_VALID_LINK, + OUT_OF_SCOPE_LINK, ) @@ -97,7 +99,9 @@ def test_attachment_identity_keeps_the_event_source_and_episode(): assert first != second -@pytest.mark.parametrize("identity_type", [ClinicalEventIdentity, EpisodeAttachmentIdentity]) +@pytest.mark.parametrize( + "identity_type", [ClinicalEventIdentity, EpisodeAttachmentIdentity] +) def test_event_source_table_cannot_be_empty(identity_type): args = ("", 7) if identity_type is ClinicalEventIdentity else ("", 7, 1001) with pytest.raises(ValueError, match="event_source_table"): @@ -124,22 +128,34 @@ def test_attachment_policies_state_precedence_and_cardinality( ) -def test_counterexample_links_cover_valid_discriminator_and_person_failures(): +def test_counterexample_links_cover_valid_out_of_scope_and_person_cases(): event_by_identity = {case.identity: case for case in COLLIDING_EVENTS} episode_by_id = {case.episode_id: case for case in OVERLAPPING_EPISODES} valid_event = event_by_identity[VALID_EXPLICIT_LINK.event] - assert valid_event.event_field_concept_id == VALID_EXPLICIT_LINK.episode_event_field_concept_id - assert valid_event.person_id == episode_by_id[VALID_EXPLICIT_LINK.episode_id].person_id + assert ( + valid_event.event_field_concept_id + == VALID_EXPLICIT_LINK.episode_event_field_concept_id + ) + assert ( + valid_event.person_id == episode_by_id[VALID_EXPLICIT_LINK.episode_id].person_id + ) - wrong_event = event_by_identity[WRONG_DISCRIMINATOR_LINK.event] + colliding_event = event_by_identity[COLLIDING_VALID_LINK.event] assert ( - wrong_event.event_field_concept_id - != WRONG_DISCRIMINATOR_LINK.episode_event_field_concept_id + colliding_event.event_field_concept_id + == COLLIDING_VALID_LINK.episode_event_field_concept_id + ) + assert all( + event.event_field_concept_id != OUT_OF_SCOPE_LINK.episode_event_field_concept_id + for event in COLLIDING_EVENTS ) cross_person_event = event_by_identity[CROSS_PERSON_LINK.event] - assert cross_person_event.person_id != episode_by_id[CROSS_PERSON_LINK.episode_id].person_id + assert ( + cross_person_event.person_id + != episode_by_id[CROSS_PERSON_LINK.episode_id].person_id + ) def test_nearest_temporal_contract_uses_absolute_distance_and_stable_id(): @@ -174,7 +190,10 @@ def test_started_episode_preference_can_override_absolute_nearest(): absolute = sorted( DIRECTIONAL_PREFERENCE_EPISODES, - key=lambda episode: (abs((episode.start_date - anchor).days), episode.episode_id), + key=lambda episode: ( + abs((episode.start_date - anchor).days), + episode.episode_id, + ), ) directional = sorted( DIRECTIONAL_PREFERENCE_EPISODES, @@ -215,10 +234,7 @@ def test_constructs_visit_fixture_records_strict_180_day_boundary(): def test_boundary_fixture_makes_closed_window_expectations_visible(): - spec = TemporalRankingSpec( - policy=TemporalSelectionPolicy.nearest, - stable_id_column="event_id", - ) + spec = EpisodeWindowSpec() episode = OVERLAPPING_EPISODES[0] lower = episode.start_date - timedelta(days=90) upper = episode.end_date @@ -239,7 +255,9 @@ def test_observation_as_of_contract_excludes_future_and_breaks_ties_by_id(): policy=ObservationSelectionPolicy.latest_on_or_before_anchor ) candidates = [ - row for row in REPEATED_OBSERVATIONS if row.observation_date <= OBSERVATION_ANCHOR_DATE + row + for row in REPEATED_OBSERVATIONS + if row.observation_date <= OBSERVATION_ANCHOR_DATE ] selected = sorted( candidates, @@ -297,7 +315,9 @@ def _projection_contract_select() -> sa.Select: @pytest.mark.parametrize("dialect", [sqlite.dialect(), postgresql.dialect()]) def test_required_projection_contract_compiles_without_execution(dialect): statement = _projection_contract_select() - compiled = str(statement.compile(dialect=dialect, compile_kwargs={"literal_binds": True})) + compiled = str( + statement.compile(dialect=dialect, compile_kwargs={"literal_binds": True}) + ) assert tuple(statement.selected_columns.keys()) == tuple( str(column) for column in CANONICAL_EVENT_REQUIRED_COLUMNS diff --git a/tests/test_runtime_concept_queries.py b/tests/test_runtime_concept_queries.py index d1dca55..60b0ff7 100644 --- a/tests/test_runtime_concept_queries.py +++ b/tests/test_runtime_concept_queries.py @@ -174,7 +174,7 @@ def test_exclusion_wins_over_an_exact_inclusion(session): assert session.scalars(statement).all() == [] -def test_standardness_applies_to_exact_inclusions(session): +def test_exact_inclusions_are_not_removed_by_descendant_standardness_policy(session): _add_runtime_hierarchy(session) standard_only = RuntimeConceptSetSpec( include_exact_ids=(990_002,), @@ -194,5 +194,5 @@ def test_standardness_applies_to_exact_inclusions(session): runtime_concept_predicate(Concept.concept_id, with_classification) ) - assert session.scalars(standard_only_statement).all() == [] + assert session.scalars(standard_only_statement).all() == [990_002] assert session.scalars(with_classification_statement).all() == [990_002] diff --git a/tests/test_temporal_queries.py b/tests/test_temporal_queries.py index a662612..f6d48ba 100644 --- a/tests/test_temporal_queries.py +++ b/tests/test_temporal_queries.py @@ -6,11 +6,13 @@ import pytest import sqlalchemy as sa -from sqlalchemy.dialects import postgresql, sqlite +from sqlalchemy.dialects import mysql, postgresql, sqlite +from sqlalchemy.exc import UnsupportedCompilationError from omop_alchemy.toolkit.episodes.derivation import ( ObservationSelectionPolicy, ObservationSelectionSpec, + EpisodeWindowSpec, TemporalRankingSpec, TemporalSelectionPolicy, TemporalSidePreference, @@ -47,7 +49,29 @@ def test_day_delta_compiles_for_supported_dialects(dialect): ) compiled = str(statement.compile(dialect=dialect)) - assert "julianday" in compiled if dialect.name == "sqlite" else "CAST" in compiled + if dialect.name == "sqlite": + assert "julianday" in compiled + else: + assert "CAST" in compiled + + +def test_day_delta_rejects_an_unsupported_dialect(): + statement = sa.select( + signed_day_delta(sa.literal(date(2026, 1, 21)), sa.literal(date(2026, 1, 20))) + ) + + with pytest.raises(UnsupportedCompilationError): + statement.compile(dialect=mysql.dialect()) + + +def test_episode_window_rejects_an_unsupported_dialect(): + lower, upper = episode_window_bounds( + sa.literal(date(2026, 1, 15)), + sa.literal(None, type_=sa.Date()), + ) + + with pytest.raises(UnsupportedCompilationError): + sa.select(lower, upper).compile(dialect=mysql.dialect()) def test_day_delta_executes_as_signed_calendar_days(session): @@ -103,8 +127,7 @@ def test_episode_window_bounds_are_finite_and_boundary_policy_is_explicit(sessio lower, upper = episode_window_bounds( start, end, - days_prior=90, - open_end_fallback_days=30, + window=EpisodeWindowSpec(days_prior=90, open_end_fallback_days=30), ) values = session.execute(sa.select(lower, upper)).one() @@ -173,9 +196,12 @@ def test_as_of_observation_selection_can_exclude_the_anchor_date(session): anchor_date=sa.literal(date(2026, 1, 20)), ).subquery() - assert session.scalar( - sa.select(ranked.c.observation_id).where(ranked.c.observation_rank == 1) - ) == 21 + assert ( + session.scalar( + sa.select(ranked.c.observation_id).where(ranked.c.observation_rank == 1) + ) + == 21 + ) def test_as_of_observation_selection_requires_an_anchor(): @@ -186,3 +212,36 @@ def test_as_of_observation_selection_requires_an_anchor(): policy=ObservationSelectionPolicy.latest_on_or_before_anchor ), ) + + +@pytest.mark.requires_database("test_cdm_db") +def test_postgresql_executes_boundary_and_side_preference_contracts(pg_session): + start = sa.literal(date(2026, 1, 15)) + end = sa.literal(date(2026, 2, 5)) + lower, upper = episode_window_bounds(start, end) + boundary = bounded_temporal_predicate( + sa.literal(date(2025, 10, 17)), + lower, + upper, + ) + assert pg_session.scalar(sa.select(boundary)) is True + + candidates = _temporal_candidates() + ranking = TemporalRankingSpec( + policy=TemporalSelectionPolicy.nearest, + stable_id_column="episode_id", + side_preference=TemporalSidePreference.on_or_before_anchor, + ) + selected = pg_session.scalar( + sa.select(candidates.c.episode_id) + .order_by( + *temporal_order_expressions( + candidates.c.start_date, + sa.literal(date(2026, 1, 20)), + candidates.c.episode_id, + ranking, + ) + ) + .limit(1) + ) + assert selected == 1001 From 401dcb1ddbc7f6b16d21403d7f2d2552f2f4e27e Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Mon, 31 Aug 2026 11:58:23 +1000 Subject: [PATCH 06/30] added figures to documentation --- docs/toolkit/analytics.md | 16 ++++++++++++++++ docs/toolkit/core.md | 10 ++++++++++ docs/toolkit/episodes.md | 13 ++++++++++++- docs/toolkit/materialized-views.md | 13 +++++++++++++ docs/toolkit/query-contracts.md | 18 ++++++++++++++++++ 5 files changed, 69 insertions(+), 1 deletion(-) diff --git a/docs/toolkit/analytics.md b/docs/toolkit/analytics.md index ff8d1dc..e2f8f66 100644 --- a/docs/toolkit/analytics.md +++ b/docs/toolkit/analytics.md @@ -24,6 +24,15 @@ with Session(engine) as session: The episode includes events linked directly to it and events linked to its direct children. This supports a regimen whose drug exposures or procedures are recorded against cycle-level child episodes without flattening the episode hierarchy itself. +```mermaid +flowchart TD + OE["OncologyEpisode"] --> Direct["Events linked directly to this episode"] + OE --> Children["child_treatment_episodes"] + Children --> ChildEvents["Events linked to those children
(e.g. cycle-level drug exposures)"] + Direct --> Pool["Evidence pool for
modalities, dose summaries, weight loss"] + ChildEvents --> Pool +``` + ### Modality evidence An episode can contain evidence for more than one treatment modality. `structural_modalities` and `concept_modalities` therefore return sets rather than forcing the record into a single label: @@ -43,6 +52,13 @@ if structural != governed: When a caller needs one value, `structural_modality` and `concept_modality` apply a deterministic order: radiotherapy, surgery, diagnostic or staging, then SACT. This is a stable tie-break for mixed evidence, not a statement of clinical importance. Use the plural properties when mixed treatment matters to the analysis. +```mermaid +flowchart LR + RT["Radiotherapy"] --> SUR["Surgery"] --> DX["Diagnostic / Staging"] --> SACT["SACT"] +``` + +The single-value properties return the first modality in this order for which the episode has evidence. + ### Treatment summaries `sact_exposures` contains linked exposures whose concepts belong to the governed SACT set. `sact_dose_summaries_by_drug_concept` groups them by drug concept; `sact_dose_summary` provides an all-SACT summary. The summary keeps source units and carries a `DoseEvaluability` result. Mixed units or missing quantities remain visible instead of being presented as a valid combined dose. diff --git a/docs/toolkit/core.md b/docs/toolkit/core.md index 436383e..2df03c9 100644 --- a/docs/toolkit/core.md +++ b/docs/toolkit/core.md @@ -89,6 +89,16 @@ for event in session.execute(events).mappings(): The projection resolves its ID, clinical concept, date, source table, and Field concept through stable CDM event metadata. Bare `Measurement` and `Observation` classes remain lightweight mappings for ETL, while `MeasurementView` and `ObservationView` provide analytical reference context, domain validation, and episode-event resolution. Importing analytics modules cannot change the metadata used for a Core projection. `UnsupportedClinicalEventModelError` is raised before SQL execution when no supported CDM definition exists. +```mermaid +flowchart LR + M["Measurement
measurement_id"] --> U["canonical_event_union()"] + O["Observation
observation_id"] --> U + P["Procedure_Occurrence
procedure_occurrence_id"] --> U + U --> S["canonical shape
person_id · event_id · event_source_table
event_field_concept_id · event_date · event_concept_id
(+ value / value_concept / unit where supported)"] +``` + +Each source model contributes its own ID column and Field concept; branches without a value or unit receive typed nulls so the union stays one consistent shape regardless of which models are combined. + The [query contracts](query-contracts.md) explain how the canonical shape participates in episode attachment. ## Work with a patient timeline diff --git a/docs/toolkit/episodes.md b/docs/toolkit/episodes.md index dc3f462..ccd5758 100644 --- a/docs/toolkit/episodes.md +++ b/docs/toolkit/episodes.md @@ -108,7 +108,18 @@ rows = session.execute(statement).mappings().all() Traversal follows parent IDs only within the same person and stops at a configurable maximum depth, which bounds malformed cyclic data. Set `include_root=False` when only descendants are needed. `direct_episode_relationship_projection()` provides a non-recursive parent-child result for callers that need one level only. -`episode_event_hierarchy_projection()` joins the hierarchy to `Episode_Event` and retains the root episode, the episode that owns the link, and its depth. This lets a caller include child-linked evidence without encoding a specialty-specific number of child levels. +`episode_event_hierarchy_projection()` joins the hierarchy to `Episode_Event` and retains the root episode, the episode that owns the link, and its depth. This lets a caller include child-linked evidence without encoding a specialty-specific number of child levels: + +```mermaid +flowchart TD + Root["Episode 1000 (root)
depth 0"] --> C1["Episode 1001
depth 1"] + Root --> C2["Episode 1002
depth 1"] + C1 --> GC1["Episode 1010
depth 2"] + C2 --> GC2["Episode 1011
depth 2"] + Ev(["Procedure_Occurrence 7
Episode_Event link"]) -. "joined via
episode_event_hierarchy_projection()" .-> GC1 +``` + +The link at depth 2 is visible to code operating on the root at depth 0, without the caller having to know how many levels separate them. ## Describe episode attachment policy diff --git a/docs/toolkit/materialized-views.md b/docs/toolkit/materialized-views.md index b0fa56c..64a0313 100644 --- a/docs/toolkit/materialized-views.md +++ b/docs/toolkit/materialized-views.md @@ -70,6 +70,19 @@ with engine.begin() as connection: If either check fails, `ConcurrentRefreshNotEligibleError` is raised before the refresh statement is executed. The declaration does not substitute for creating the index, and an undeclared database index does not substitute for recording the operational requirement in the specification. +```mermaid +stateDiagram-v2 + [*] --> Absent + Absent --> Populated: create_materialized_view() + Populated --> Populated: refresh_materialized_view() + Populated --> Absent: drop_materialized_view() + + state eligibility <> + Populated --> eligibility: refresh(concurrently=True) + eligibility --> Populated: declared unique index
confirmed in the database + eligibility --> [*]: ConcurrentRefreshNotEligibleError +``` + ## Drop one qualified target Dropping uses the same `MaterializedViewTarget` as creation and refresh: diff --git a/docs/toolkit/query-contracts.md b/docs/toolkit/query-contracts.md index 3102426..202356f 100644 --- a/docs/toolkit/query-contracts.md +++ b/docs/toolkit/query-contracts.md @@ -89,6 +89,16 @@ Once valid, an explicit link takes precedence under either explicit-first policy | `explicit_first_ranked` | Select one date-eligible episode using a separate ranking specification | | `explicit_first_all_in_window` | Retain every date-eligible episode | +```mermaid +flowchart TD + Start["Event from canonical projection"] --> Valid{"Valid explicit
Episode_Event link?"} + Valid -- "yes" --> Explicit["Attach via the explicit link
(precedence; fallback is not applied)"] + Valid -- "no" --> Policy{"EpisodeAttachmentPolicy"} + Policy -- "explicit_only" --> Unattached["Leave unattached"] + Policy -- "explicit_first_ranked" --> Ranked["Rank date-eligible episodes
(TemporalRankingSpec)"] --> One["Attach to one episode"] + Policy -- "explicit_first_all_in_window" --> Window["Every date-eligible episode
in the window"] --> Many["Attach to each eligible episode"] +``` + Choosing between ranked and all-in-window fallback is a statement about result grain. Ranked fallback produces at most one episode per event. All-in-window fallback intentionally allows one event to appear against several overlapping episodes. `episode_attachment_queries()` applies the complete precedence rule. It accepts a canonical event statement or one supported event model, validates explicit links against `Episode_Event`, and applies fallback only to events that have no valid explicit link: @@ -162,6 +172,14 @@ already_started_first = TemporalRankingSpec( ) ``` +```mermaid +flowchart TD + A["Event on 20 January"] --> N{"policy = nearest"} + A --> S{"policy = nearest,
side_preference =
on_or_before_anchor"} + N -->|"closest by absolute distance"| E1003["Episode 1003
starts 21 Jan · 1 day away"] + S -->|"already-started side considered first"| E1001["Episode 1001
starts 15 Jan · 5 days away"] +``` + This policy selects episode 1001. Absolute distance still orders episodes within the preferred side; it simply does not allow a closer future episode to outrank every episode that had already started. `on_or_after_anchor` expresses the corresponding future-first rule. Apply the policy to SQLAlchemy columns with `temporal_order_expressions()`: From 39e6e2d850682fe94ad0a14cc97e5caeefed211a Mon Sep 17 00:00:00 2001 From: gkennos Date: Mon, 31 Aug 2026 13:41:03 +1000 Subject: [PATCH 07/30] fixing timeline resolvers --- docs/advanced/timelines.md | 7 +- omop_alchemy/cdm/base/cdm_constants.py | 1 + omop_alchemy/cdm/model/clinical/__init__.py | 4 +- .../cdm/model/clinical/device_exposure.py | 93 +++++++++++- .../cdm/model/clinical/drug_exposure.py | 1 - .../body_metrics/weight_trajectory.py | 31 +++- omop_alchemy/toolkit/core/concepts/lookup.py | 83 ++++++----- .../toolkit/core/concepts/relationships.py | 4 + omop_alchemy/toolkit/core/concepts/runtime.py | 6 + .../toolkit/core/events/projections.py | 30 +++- .../toolkit/core/materialization/ddl.py | 7 + .../toolkit/core/materialization/lifecycle.py | 13 ++ .../toolkit/core/timeline/__init__.py | 4 +- .../toolkit/core/timeline/event_timeline.py | 60 +++++--- .../episodes/derivation/attachments.py | 52 ++++--- .../episodes/derivation/observations.py | 19 ++- .../toolkit/episodes/derivation/structure.py | 138 +++++++++++++----- .../toolkit/episodes/derivation/temporal.py | 24 ++- .../episodes/handling/exposure_series.py | 6 + tests/test_episode_attachment_queries.py | 36 +++++ tests/test_episode_structure_queries.py | 63 +++++++- tests/test_event_projections.py | 28 +++- tests/test_event_timeline.py | 64 ++++++++ 23 files changed, 639 insertions(+), 135 deletions(-) create mode 100644 tests/test_event_timeline.py diff --git a/docs/advanced/timelines.md b/docs/advanced/timelines.md index 5213458..3c37fa9 100644 --- a/docs/advanced/timelines.md +++ b/docs/advanced/timelines.md @@ -49,13 +49,14 @@ without making `core.timeline` import the higher-level episode package. ## Concrete event classes -Three CDM tables are pre-wired with `EventMapping`s: +Four CDM tables are pre-wired with `EventMapping`s: | Class | CDM table | Concept field | Value fields | |-------|-----------|---------------|--------------| | `Condition_Event` | `condition_occurrence` | `condition_concept_id` | — | | `Measurement_Event` | `measurement` | `measurement_concept_id` | `value_as_number`, `value_as_concept_id`, `value_as_string` | | `Drug_Exposure_Event` | `drug_exposure` | `drug_concept_id` | `quantity` | +| `Observation_Event` | `observation` | `observation_concept_id` | `value_as_concept_id`, `value_as_number`, `value_as_string` | ::: omop_alchemy.toolkit.core.timeline.event_timeline.Condition_Event @@ -63,6 +64,8 @@ Three CDM tables are pre-wired with `EventMapping`s: ::: omop_alchemy.toolkit.core.timeline.event_timeline.Drug_Exposure_Event +::: omop_alchemy.toolkit.core.timeline.event_timeline.Observation_Event + --- ## `Person_Timeline` @@ -98,6 +101,6 @@ To add a supported CDM table to the timeline, subclass both `ClinicalEvent` and from omop_alchemy.toolkit.core.timeline.event_timeline import ClinicalEvent, EventMapping from omop_alchemy.cdm.model.clinical import Procedure_Occurrence -class Procedure_Event(Procedure_Occurrence, ClinicalEvent): +class Procedure_Event(ClinicalEvent, Procedure_Occurrence): _mapping = EventMapping.from_model(Procedure_Occurrence) ``` diff --git a/omop_alchemy/cdm/base/cdm_constants.py b/omop_alchemy/cdm/base/cdm_constants.py index 27a0187..139bbe8 100644 --- a/omop_alchemy/cdm/base/cdm_constants.py +++ b/omop_alchemy/cdm/base/cdm_constants.py @@ -4,4 +4,5 @@ class ModifierFieldConcepts: OBSERVATION = 1147165 PROCEDURE_OCCURRENCE = 1147082 DRUG_EXPOSURE = 1147707 + DEVICE_EXPOSURE = 1147693 EPISODE = 756290 diff --git a/omop_alchemy/cdm/model/clinical/__init__.py b/omop_alchemy/cdm/model/clinical/__init__.py index a0cbd5a..c3033b2 100644 --- a/omop_alchemy/cdm/model/clinical/__init__.py +++ b/omop_alchemy/cdm/model/clinical/__init__.py @@ -12,7 +12,7 @@ Procedure_OccurrenceContext, Procedure_OccurrenceView, ) -from .device_exposure import Device_Exposure +from .device_exposure import Device_Exposure, Device_ExposureContext, Device_ExposureView from .death import Death from .specimen import Specimen @@ -34,6 +34,8 @@ "Procedure_OccurrenceContext", "Procedure_OccurrenceView", "Device_Exposure", + "Device_ExposureContext", + "Device_ExposureView", "Death", "Specimen", "PersonView", diff --git a/omop_alchemy/cdm/model/clinical/device_exposure.py b/omop_alchemy/cdm/model/clinical/device_exposure.py index c4862b2..2a5175b 100644 --- a/omop_alchemy/cdm/model/clinical/device_exposure.py +++ b/omop_alchemy/cdm/model/clinical/device_exposure.py @@ -1,6 +1,6 @@ import sqlalchemy as sa import sqlalchemy.orm as so -from typing import Optional +from typing import Optional, TYPE_CHECKING from datetime import date, datetime from orm_loader.helpers import Base @@ -9,6 +9,10 @@ HealthSystemContext, FactTable, CDMTableBase, + DomainValidationMixin, + ExpectedDomain, + ModifierFieldConcepts, + ReferenceContext, cdm_table, required_concept_fk, optional_concept_fk, @@ -18,12 +22,16 @@ omop_index, ) +if TYPE_CHECKING: + from ..health_system import Provider, Visit_Detail, Visit_Occurrence + from ..vocabulary import Concept + from .person import Person + @cdm_table class Device_Exposure( PersonScoped, CDMTableBase, FactTable, - ModifierTargetMixin, HealthSystemContext, Base, ): @@ -52,3 +60,84 @@ class Device_Exposure( quantity: so.Mapped[Optional[int]] = optional_int() device_source_value: so.Mapped[Optional[str]] = so.mapped_column(sa.String(50)) unit_source_value: so.Mapped[Optional[str]] = so.mapped_column(sa.String(50)) + + +class Device_ExposureContext(ReferenceContext): + """Read-only analytical relationships for a Device Exposure row.""" + + person: so.Mapped["Person"] = ReferenceContext._reference_relationship( + target="Person", local_fk="person_id", remote_pk="person_id" + ) # type: ignore[assignment] + device_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship( + target="Concept", local_fk="device_concept_id", remote_pk="concept_id" + ) # type: ignore[assignment] + device_type_concept: so.Mapped["Concept"] = ( + ReferenceContext._reference_relationship( + target="Concept", + local_fk="device_type_concept_id", + remote_pk="concept_id", + ) + ) # type: ignore[assignment] + device_source_concept: so.Mapped[Optional["Concept"]] = ( + ReferenceContext._reference_relationship( + target="Concept", + local_fk="device_source_concept_id", + remote_pk="concept_id", + ) + ) # type: ignore[assignment] + unit_concept: so.Mapped[Optional["Concept"]] = ( + ReferenceContext._reference_relationship( + target="Concept", local_fk="unit_concept_id", remote_pk="concept_id" + ) + ) # type: ignore[assignment] + unit_source_concept: so.Mapped[Optional["Concept"]] = ( + ReferenceContext._reference_relationship( + target="Concept", + local_fk="unit_source_concept_id", + remote_pk="concept_id", + ) + ) # type: ignore[assignment] + provider: so.Mapped[Optional["Provider"]] = ( + ReferenceContext._reference_relationship( + target="Provider", local_fk="provider_id", remote_pk="provider_id" + ) + ) # type: ignore[assignment] + visit_occurrence: so.Mapped[Optional["Visit_Occurrence"]] = ( + ReferenceContext._reference_relationship( + target="Visit_Occurrence", + local_fk="visit_occurrence_id", + remote_pk="visit_occurrence_id", + ) + ) # type: ignore[assignment] + visit_detail: so.Mapped[Optional["Visit_Detail"]] = ( + ReferenceContext._reference_relationship( + target="Visit_Detail", + local_fk="visit_detail_id", + remote_pk="visit_detail_id", + ) + ) # type: ignore[assignment] + + +class Device_ExposureView( + Device_Exposure, + Device_ExposureContext, + DomainValidationMixin, + ModifierTargetMixin, +): + """Analytical Device Exposure mapping with event metadata and references.""" + + __tablename__ = "device_exposure" + __mapper_args__ = {"concrete": False} + __event_id_col__ = "device_exposure_id" + __concept_id_col__ = "device_concept_id" + __start_date_col__ = "device_exposure_start_date" + __end_date_col__ = "device_exposure_end_date" + __type_concept_id_col__ = "device_type_concept_id" + __expected_domains__ = { + "device_concept_id": ExpectedDomain("Device"), + "device_type_concept_id": ExpectedDomain("Type Concept"), + } + + @classmethod + def modifier_field_concept_id(cls) -> int: + return ModifierFieldConcepts.DEVICE_EXPOSURE diff --git a/omop_alchemy/cdm/model/clinical/drug_exposure.py b/omop_alchemy/cdm/model/clinical/drug_exposure.py index ea2d9aa..f27ba4f 100644 --- a/omop_alchemy/cdm/model/clinical/drug_exposure.py +++ b/omop_alchemy/cdm/model/clinical/drug_exposure.py @@ -27,7 +27,6 @@ class Drug_Exposure( PersonScoped, CDMTableBase, FactTable, - ModifierTargetMixin, HealthSystemContext, Base, ): diff --git a/omop_alchemy/toolkit/analytics/body_metrics/weight_trajectory.py b/omop_alchemy/toolkit/analytics/body_metrics/weight_trajectory.py index eb2ffd9..b5794ab 100644 --- a/omop_alchemy/toolkit/analytics/body_metrics/weight_trajectory.py +++ b/omop_alchemy/toolkit/analytics/body_metrics/weight_trajectory.py @@ -65,6 +65,7 @@ def normalize_weight_readings( readings: list[MeasurementReading], rules: BodyMetricRules, ) -> list[MeasurementReading]: + """Convert supported weight units to kilograms and drop invalid values.""" normalized: list[MeasurementReading] = [] for reading in readings: kg = rules.normalize_weight_kg(reading.value, reading.unit_concept_id) @@ -78,6 +79,7 @@ def normalize_height_readings( readings: list[MeasurementReading], rules: BodyMetricRules, ) -> list[MeasurementReading]: + """Convert supported height units to centimetres and drop invalid values.""" normalized: list[MeasurementReading] = [] for reading in readings: cm = rules.normalize_height_cm(reading.value, reading.unit_concept_id) @@ -125,15 +127,20 @@ def _raw_height_series(self) -> list[MeasurementReading]: @cached_property def weight_readings(self) -> list[MeasurementReading]: """Weight readings normalized to kg.""" - return normalize_weight_readings(self._raw_weight_series, self.body_metric_rules()) + return normalize_weight_readings( + self._raw_weight_series, self.body_metric_rules() + ) @cached_property def height_readings_cm(self) -> list[MeasurementReading]: """Height readings normalized to cm and resolved without an episode date window.""" - return normalize_height_readings(self._raw_height_series, self.body_metric_rules()) + return normalize_height_readings( + self._raw_height_series, self.body_metric_rules() + ) @property def height_m(self) -> Optional[float]: + """Return the first normalized height as metres, when available.""" readings = self.height_readings_cm if not readings or readings[0].value is None: return None @@ -141,14 +148,17 @@ def height_m(self) -> Optional[float]: @property def baseline_weight(self) -> Optional[MeasurementReading]: + """Return the earliest normalized weight reading in the series.""" return self.weight_readings[0] if self.weight_readings else None @property def latest_weight(self) -> Optional[MeasurementReading]: + """Return the latest normalized weight reading in the series.""" return self.weight_readings[-1] if self.weight_readings else None @property def baseline_bmi(self) -> Optional[float]: + """Calculate BMI from the baseline weight and first available height.""" baseline = self.baseline_weight return self.body_metric_rules().bmi( baseline.value if baseline else None, @@ -157,6 +167,7 @@ def baseline_bmi(self) -> Optional[float]: @property def baseline_bsa_mosteller_m2(self) -> Optional[float]: + """Calculate Mosteller BSA from baseline weight and first height.""" baseline = self.baseline_weight height = self.height_readings_cm[0] if self.height_readings_cm else None return self.body_metric_rules().bsa_mosteller_m2( @@ -168,6 +179,7 @@ def pct_change_from_baseline( self, as_of: Optional[MeasurementReading] = None, ) -> WeightChange: + """Compare a target reading with baseline, preserving non-evaluability.""" baseline = self.baseline_weight target = as_of or self.latest_weight if ( @@ -183,6 +195,7 @@ def pct_change_from_baseline( return WeightChange(pct_change=pct, evaluable=True, reference=baseline) def pct_change_over(self, days: int) -> WeightChange: + """Compare latest weight with the earliest reading in the time window.""" readings = self.weight_readings if len(readings) < 2: return WeightChange.not_evaluable() @@ -197,10 +210,15 @@ def pct_change_over(self, days: int) -> WeightChange: or earliest_in_window.measurement_id == latest.measurement_id ): return WeightChange.not_evaluable() - pct = 100.0 * (latest.value - earliest_in_window.value) / earliest_in_window.value - return WeightChange(pct_change=pct, evaluable=True, reference=earliest_in_window) + pct = ( + 100.0 * (latest.value - earliest_in_window.value) / earliest_in_window.value + ) + return WeightChange( + pct_change=pct, evaluable=True, reference=earliest_in_window + ) def pct_change_trajectory(self) -> list[WeightTrajectoryPoint]: + """Return each valid reading's percentage change from baseline.""" baseline = self.baseline_weight if baseline is None or baseline.value is None or baseline.value <= 0: return [] @@ -225,8 +243,10 @@ def sustained_loss( threshold_pct: float = 5.0, min_consecutive: int = 2, ) -> Optional[bool]: + """Test whether recent readings sustain the requested loss threshold.""" baseline = self.baseline_weight - # the baseline is the first reading, so it cannot count towards a loss against itself + # Baseline is the first reading, so it cannot count toward a loss + # against itself; insufficient post-baseline data remains unknown. post_baseline = self.weight_readings[1:] if ( baseline is None @@ -245,6 +265,7 @@ def sustained_loss( ) def weight_trajectory_summary(self) -> WeightTrajectorySummary: + """Return the stable summary contract used by analytics consumers.""" baseline = self.baseline_weight latest = self.latest_weight change_from_baseline = self.pct_change_from_baseline() diff --git a/omop_alchemy/toolkit/core/concepts/lookup.py b/omop_alchemy/toolkit/core/concepts/lookup.py index 92e3e88..df4e5ed 100644 --- a/omop_alchemy/toolkit/core/concepts/lookup.py +++ b/omop_alchemy/toolkit/core/concepts/lookup.py @@ -1,3 +1,12 @@ +"""Build and resolve scoped text-to-OMOP concept lookup indexes. + +The database-facing source materialises a deliberately bounded vocabulary +selection once; the runtime resolver then performs only in-memory +normalisation and correction. Keeping those responsibilities separate is +important for bulk ETL, where a resolver must not reopen relationships or +expand vocabulary hierarchies per row. +""" + from typing import Iterable, Callable from dataclasses import dataclass from functools import cached_property @@ -9,16 +18,9 @@ from omop_alchemy.cdm.model.vocabulary import Concept, Concept_Synonym, Concept_Ancestor from omop_alchemy.cdm.query import ConceptFilter -""" -Class definitions for vocabulary handling and mapping. - -This is somewhat redundant with some of omop-graph but -mutual dependency is awkward and this is a thin layer so -it's not worth over-engineering separation at this stage. -""" - Normaliser = Callable[[str], str] + @dataclass(frozen=True) class LookupIndex: """ @@ -43,24 +45,27 @@ class LookupIndex: Notes ----- The mapping may contain multiple textual representations pointing to the - same concept ID (e.g. name + code + synonym). + same concept ID (e.g. name + code + synonym). """ + name: str unknown: int | None mapping: dict[str, int] def lookup(self, term: str | None) -> int | None: + """Resolve an already-normalized key, returning the configured fallback.""" if term is None: term = "" return self.mapping.get(term, self.unknown) def __contains__(self, item: str | int) -> bool: + """Test membership by indexed key or by reachable concept ID.""" if isinstance(item, str): return item in self.mapping if isinstance(item, int): return item in self.mapping.values() return False - + def __repr__(self) -> str: return ( f" str: @property def all_concepts(self) -> set[int]: + """Return the concept IDs represented by this materialized index.""" return set(self.mapping.values()) - + @dataclass(frozen=True) class LookupSpec: @@ -92,12 +98,12 @@ class LookupSpec: Attributes ---------- name: - Stable identifier for this lookup specification. + Stable identifier for this lookup specification. unknown: Concept ID to return for unmatched terms. Set to None to preserve nulls, or to a sentinel concept ID to force closed-world behaviour. domain_id: - Optional OMOP domain filter + Optional OMOP domain filter concept_class_id: Optional list of OMOP concept_class_id values to restrict the lookup vocabulary_id: @@ -144,6 +150,7 @@ class LookupSpec: build-time (this spec) and runtime (ConceptResolver) to make lookup behaviour explicit and testable. """ + name: str unknown: int | None = 0 domain_id: str | None = None @@ -168,8 +175,8 @@ class OMOPConceptSource: and higher-level vocabulary indexing logic. - Used exclusively to builds a query based on provided parameters (adds - filter for each non-None parameter, and joins to Concept_Ancestor + Used exclusively to builds a query based on provided parameters (adds + filter for each non-None parameter, and joins to Concept_Ancestor if parents are specified). """ @@ -203,7 +210,7 @@ def fetch_synonyms( for r in rows if r.concept_synonym_name ] - + @staticmethod def fetch_concepts( session: so.Session, @@ -290,7 +297,7 @@ def fetch_concepts( ) for r in rows ] - + @staticmethod def descendants( session: so.Session, @@ -298,6 +305,7 @@ def descendants( *, include_non_standard: bool = False, ) -> list[int]: + """Return descendant IDs using the source's standardness policy.""" rows = OMOPConceptSource.fetch_concepts( session, parents=parents, @@ -305,13 +313,20 @@ def descendants( require_standard=not include_non_standard, ) return list({r.concept_id for r in rows}) - @staticmethod def build_lookup( session: so.Session, spec: LookupSpec, ) -> LookupIndex: + """Materialize one scoped lookup and its optional synonym keys. + + The returned index is intentionally detached from the session. If + multiple selected representations normalize to the same key, the + later materialized assignment wins; callers that need collision-free + semantics should narrow the ``LookupSpec`` rather than rely on row + ordering. + """ rows = OMOPConceptSource.fetch_concepts( session, domain_id=spec.domain_id, @@ -335,17 +350,14 @@ def build_lookup( m[spec.normalizer(r.concept_code)] = r.concept_id if spec.include_synonyms: - for cid, syn in OMOPConceptSource.fetch_synonyms( - session, concept_ids=ids - ): + for cid, syn in OMOPConceptSource.fetch_synonyms(session, concept_ids=ids): if syn: m[spec.normalizer(syn)] = cid return LookupIndex(name=spec.name, unknown=spec.unknown, mapping=m) - -class ConceptResolver: +class ConceptResolver: """ Runtime resolver for mapping free-text terms to OMOP concept IDs. @@ -399,6 +411,7 @@ class ConceptResolver: 123456 """ + def __init__( self, index: LookupIndex, @@ -406,11 +419,13 @@ def __init__( normalizer: Normaliser | None = None, corrections: list[Callable[[str], str]] | None = None, ): + """Bind a materialized index to runtime normalization and corrections.""" self.index = index self._normalizer = normalizer or normalize_default self._corrections = corrections or [] def lookup(self, term: str | None) -> int | None: + """Resolve a term, trying direct lookup before ordered corrections.""" if not term: return self.index.unknown @@ -428,11 +443,13 @@ def lookup(self, term: str | None) -> int | None: return self.index.unknown def lookup_exact(self, term: str | None) -> int | None: + """Resolve only the normalized input, bypassing correction functions.""" if not term: return self.index.unknown return self.index.mapping.get(self._normalizer(term), self.index.unknown) def __contains__(self, item: str | int) -> bool: + """Test corrected text membership or direct concept-ID membership.""" if isinstance(item, int): return item in self.all_concepts if isinstance(item, str): @@ -502,7 +519,7 @@ def make_concept_resolver( name: Stable identifier for this lookup specification, used in logging and debugging. unknown: - Concept ID to return for unmatched terms. Set to None to preserve nulls, or to + Concept ID to return for unmatched terms. Set to None to preserve nulls, or to a sentinel concept ID to force closed-world behaviour. domain_id: Optional OMOP domain filter for the concepts to include in the lookup. @@ -524,29 +541,29 @@ def make_concept_resolver( text can still be resolved forward through "Maps to" / "Concept replaced by", whereas filtering it out here discards the term entirely. code_filter: - Optional substring filter applied to concept_code (ILIKE-based). + Optional substring filter applied to concept_code (ILIKE-based). Useful for coarse scoping (e.g. AJCC-only codes). parents: - Optional list of ancestor concept IDs from which to expand the lookup + Optional list of ancestor concept IDs from which to expand the lookup via the Concept_Ancestor table. include_non_standard_descendants: - If True, includes non-standard concepts when expanding from parents. Has no + If True, includes non-standard concepts when expanding from parents. Has no effect if `parents` is None. include_synonyms: If True, include Concept_Synonym entries in the lookup keys. include: - Tuple of ConceptRow attribute names to index as keys (e.g. ("concept_name", + Tuple of ConceptRow attribute names to index as keys (e.g. ("concept_name", "concept_code")). This controls which textual fields become resolvable inputs. build_normalizer: - Normalisation function applied to all indexed strings at build time. This should - match (or be compatible with) the normaliser used at resolution time by + Normalisation function applied to all indexed strings at build time. This should + match (or be compatible with) the normaliser used at resolution time by ConceptResolver. runtime_normalizer: - Optional normalisation function applied to input terms at lookup time. Defaults to - ``normalize_default``. This should be compatible with the normaliser used when + Optional normalisation function applied to input terms at lookup time. Defaults to + ``normalize_default``. This should be compatible with the normaliser used when constructing the LookupIndex. corrections: - Optional ordered list of correction functions applied to the raw input term prior + Optional ordered list of correction functions applied to the raw input term prior to normalisation and lookup """ diff --git a/omop_alchemy/toolkit/core/concepts/relationships.py b/omop_alchemy/toolkit/core/concepts/relationships.py index 7ba6ab8..426344a 100644 --- a/omop_alchemy/toolkit/core/concepts/relationships.py +++ b/omop_alchemy/toolkit/core/concepts/relationships.py @@ -78,6 +78,8 @@ def standard_concept_mapping_select( name="standard_mapping_relationship", ) + # Separate aliases preserve both sides of the mapping in the result and + # prevent source predicates from accidentally being applied to the target. statement = ( sa.select( source.concept_id.label( @@ -125,6 +127,8 @@ def standard_concept_mapping_select( if spec.source_concept_ids: statement = statement.where(source.concept_id.in_(spec.source_concept_ids)) if spec.valid_on is not None: + # A historical mapping is valid only when both the relationship and + # the target concept existed at the requested date. valid_on = sa.literal(spec.valid_on) statement = statement.where( relationship.valid_start_date <= valid_on, diff --git a/omop_alchemy/toolkit/core/concepts/runtime.py b/omop_alchemy/toolkit/core/concepts/runtime.py index 0bf81ba..d86cb16 100644 --- a/omop_alchemy/toolkit/core/concepts/runtime.py +++ b/omop_alchemy/toolkit/core/concepts/runtime.py @@ -85,6 +85,8 @@ def descendant_concept_select( .distinct() ) if not require_standard: + # Exact descendant expansion can remain a cheap ancestor-table query; + # join the concept table only when OMOP standardness is requested. return statement statement = statement.join( @@ -117,6 +119,8 @@ def _concept_set_side( ) ) if exact_ids: + # Exact IDs are explicit configuration and intentionally bypass the + # descendant standardness filter; validation belongs at the config edge. clauses.append(column.in_(exact_ids)) return sa.or_(*clauses) if clauses else sa.false() @@ -144,4 +148,6 @@ def runtime_concept_predicate( require_standard=spec.require_standard, include_classification=spec.include_classification, ) + # Exclusion is evaluated after inclusion so an explicit exclusion always + # wins over a descendant or exact inclusion. return sa.and_(included, sa.not_(excluded)) diff --git a/omop_alchemy/toolkit/core/events/projections.py b/omop_alchemy/toolkit/core/events/projections.py index b510654..2aeff54 100644 --- a/omop_alchemy/toolkit/core/events/projections.py +++ b/omop_alchemy/toolkit/core/events/projections.py @@ -11,6 +11,8 @@ from omop_alchemy.cdm.model.clinical import ( Condition_Occurrence, Condition_OccurrenceView, + Device_Exposure, + Device_ExposureView, Drug_Exposure, Drug_ExposureView, Measurement, @@ -48,15 +50,22 @@ class ClinicalEventModelSpec: _EVENT_METADATA_BY_TABLE: dict[str, type[ModifierTargetMixin]] = { Condition_Occurrence.__tablename__: Condition_OccurrenceView, + Device_Exposure.__tablename__: Device_ExposureView, Drug_Exposure.__tablename__: Drug_ExposureView, Measurement.__tablename__: MeasurementView, Observation.__tablename__: ObservationView, Procedure_Occurrence.__tablename__: Procedure_OccurrenceView, } +# This registry is intentionally explicit. Projection behavior must not depend +# on which analytical subclasses happen to have been imported or registered by +# SQLAlchemy in the current process. """Stable CDM event metadata, independent of imported analytical subclasses.""" def _has_complete_event_metadata(model: type[Any]) -> bool: + # A model is eligible to own metadata only when the complete modifier + # contract is present. Partial class attributes would produce a projection + # whose labels look valid while pointing at the wrong source columns. if not issubclass(model, ModifierTargetMixin): return False if any( @@ -77,11 +86,16 @@ def _metadata_candidate(model: type[Any]) -> type[ModifierTargetMixin] | None: # by walking Python's import-dependent subclass graph. if _has_complete_event_metadata(model): return model + # Lean CDM models intentionally do not carry modifier metadata. Resolve + # their table through the configured analytical view without changing the + # class used to read scalar event rows. table_name = getattr(model, "__tablename__", None) - return _EVENT_METADATA_BY_TABLE.get(table_name) + return _EVENT_METADATA_BY_TABLE.get(str(table_name)) def _datetime_column_name(model: type[Any], date_column_name: str) -> str | None: + # Datetime is optional in OMOP event tables. Derive the conventional name + # only when the mapped model actually exposes that column. if date_column_name.endswith("_date"): candidate = f"{date_column_name[:-5]}_datetime" if hasattr(model, candidate): @@ -91,6 +105,8 @@ def _datetime_column_name(model: type[Any], date_column_name: str) -> str | None def clinical_event_model_spec(model: type[Any]) -> ClinicalEventModelSpec: """Resolve the event metadata for an ORM model without accessing a database.""" + # Resolve metadata before building SQL so unsupported models fail at query + # construction, rather than producing a partially shaped union at runtime. if not isinstance(model, type) or not hasattr(model, "__table__"): raise UnsupportedClinicalEventModelError( model, "expected a mapped ORM model class" @@ -112,6 +128,8 @@ def clinical_event_model_spec(model: type[Any]) -> ClinicalEventModelSpec: event_date_column, "person_id", ) + # Metadata may come from a sibling view, so validate the physical source + # model separately before using the view's canonical field-concept marker. missing = tuple(name for name in required_columns if not hasattr(model, name)) if missing: raise UnsupportedClinicalEventModelError( @@ -144,6 +162,8 @@ def _nullable_column( ) -> sa.ColumnElement[Any]: column = getattr(model, str(name), None) if column is None: + # Unions need the same column positions across event tables. A typed + # NULL preserves that shape when a source has no corresponding value. return sa.cast(sa.null(), sql_type).label(str(name)) return column.label(str(name)) @@ -155,6 +175,9 @@ def canonical_event_projection( ) -> sa.Select[Any]: """Project one supported OMOP event model to canonical event columns.""" spec = clinical_event_model_spec(model) + # The output deliberately uses canonical labels rather than source names; + # downstream attachment, timeline, and union code should not branch on the + # particular OMOP event table being projected. event_datetime = ( getattr(model, spec.event_datetime_column) if spec.event_datetime_column is not None @@ -178,6 +201,8 @@ def canonical_event_projection( ), ] if include_values: + # Value fields are optional but occupy fixed positions when requested, + # allowing heterogeneous event projections to be combined with UNION ALL. columns.extend( ( _nullable_column( @@ -207,6 +232,9 @@ def canonical_event_union( canonical_event_projection(model, include_values=include_values) for model in models ] + # Keep one-model calls as Select objects while combining multiple models + # with UNION ALL; callers can therefore use the same canonical columns in + # either case without deduplicating clinically distinct rows. if len(projections) == 1: return projections[0] return sa.union_all(*projections) diff --git a/omop_alchemy/toolkit/core/materialization/ddl.py b/omop_alchemy/toolkit/core/materialization/ddl.py index a616154..8d473e7 100644 --- a/omop_alchemy/toolkit/core/materialization/ddl.py +++ b/omop_alchemy/toolkit/core/materialization/ddl.py @@ -14,6 +14,7 @@ def _quote_identifier(preparer: IdentifierPreparer, value: str) -> str: + """Quote one user-declared identifier with the active dialect rules.""" return preparer.quote_identifier(value) @@ -21,6 +22,7 @@ def _qualified_target( preparer: IdentifierPreparer, target: MaterializedViewTarget, ) -> str: + """Render schema and object name without allowing identifier ambiguity.""" return ".".join( ( _quote_identifier(preparer, target.schema), @@ -112,6 +114,8 @@ def _compile_create_materialized_view( **_: Any, ) -> str: target = _qualified_target(compiler.preparer, element.view_target) + # DDL must be executable as one standalone statement, so selectable + # literals are rendered into the CREATE text rather than bound parameters. selectable = compiler.sql_compiler.process( element.selectable, literal_binds=True, @@ -151,6 +155,9 @@ def _compile_create_materialized_view_index( **_: Any, ) -> str: target = _qualified_target(compiler.preparer, element.view_target) + # MaterializedViewIndex intentionally accepts simple column names only; + # quoting each declared name keeps reserved or mixed-case columns safe and + # avoids treating arbitrary SQL fragments as index expressions. index_name = _quote_identifier(compiler.preparer, element.index.name) columns = ", ".join( _quote_identifier(compiler.preparer, column) for column in element.index.columns diff --git a/omop_alchemy/toolkit/core/materialization/lifecycle.py b/omop_alchemy/toolkit/core/materialization/lifecycle.py index 809f347..6215345 100644 --- a/omop_alchemy/toolkit/core/materialization/lifecycle.py +++ b/omop_alchemy/toolkit/core/materialization/lifecycle.py @@ -71,6 +71,9 @@ class ConcurrentRefreshNotEligibleError(MaterializationError): _ELIGIBLE_UNIQUE_INDEX_SQL = sa.text( + # Concurrent refresh requires a valid, ready, unconditional unique index. + # The catalog query deliberately excludes partial and expression indexes; + # the declaration model supports the simple-column contract we can verify. """ SELECT EXISTS ( SELECT 1 @@ -101,6 +104,9 @@ def _require_postgresql( operation: MaterializationOperation, target: MaterializedViewTarget, ) -> None: + # These lifecycle statements are PostgreSQL-specific. Failing before + # execution keeps SQLite test connections from appearing to support a + # weaker, semantically different implementation. if connection.dialect.name == "postgresql": return failure = MaterializationFailure( @@ -122,6 +128,9 @@ def _execute( target: MaterializedViewTarget, index_name: str | None = None, ) -> MaterializationOutcome: + # Centralizing execution preserves the original DB exception in + # MaterializationFailure.cause while exposing stable operation/target + # context to callers. _require_postgresql(connection, operation=operation, target=target) try: connection.execute(statement) @@ -211,6 +220,8 @@ def materialized_view_has_eligible_unique_index( _require_postgresql(connection, operation=operation, target=target) index_names = tuple(index.name for index in materialized.indexes if index.unique) if not index_names: + # Avoid a catalog query when the declaration already proves that + # concurrent refresh cannot be eligible. return False try: return bool( @@ -238,6 +249,8 @@ def _require_concurrent_refresh_eligibility( materialized: MaterializedSelectable, ) -> None: if not any(index.unique for index in materialized.indexes): + # This fast path gives a declaration-level error and avoids asking the + # database to inspect a target that cannot satisfy the contract. raise ConcurrentRefreshNotEligibleError( MaterializationFailure( operation=MaterializationOperation.refresh, diff --git a/omop_alchemy/toolkit/core/timeline/__init__.py b/omop_alchemy/toolkit/core/timeline/__init__.py index ec44e49..d31d1c7 100644 --- a/omop_alchemy/toolkit/core/timeline/__init__.py +++ b/omop_alchemy/toolkit/core/timeline/__init__.py @@ -1,6 +1,6 @@ """Project a person's clinical records into one ordered event sequence. -Conditions, measurements, and drug exposures live in separate CDM tables +Conditions, measurements, drug exposures, and observations live in separate CDM tables with differently named date and value columns. Answering "what happened to this patient, in order" means reconciling them. ``Person_Timeline`` does that reconciliation and presents the result as a single list of @@ -30,6 +30,7 @@ EventTime, EventValue, Measurement_Event, + Observation_Event, Person_Timeline, ) @@ -42,5 +43,6 @@ "EventTime", "EventValue", "Measurement_Event", + "Observation_Event", "Person_Timeline", ] diff --git a/omop_alchemy/toolkit/core/timeline/event_timeline.py b/omop_alchemy/toolkit/core/timeline/event_timeline.py index a6149e0..599a5b0 100644 --- a/omop_alchemy/toolkit/core/timeline/event_timeline.py +++ b/omop_alchemy/toolkit/core/timeline/event_timeline.py @@ -3,11 +3,12 @@ Person, Condition_Occurrence, Drug_Exposure, + Observation, ) from sqlalchemy.orm import object_session from sqlalchemy import select from datetime import datetime, time, date -from typing import Optional, Mapping, Any, List +from typing import Optional, Mapping, Any, List, cast import json from dataclasses import dataclass from typing import Protocol, Union, Literal @@ -215,9 +216,13 @@ def event_time(self) -> EventTime: def event_metadata(self) -> Mapping[str, Any]: return {} - def __repr__(self: ClinicalEventProtocol) -> str: # ty: ignore[invalid-method-override] - et = self.event_time - ev = self.event_value() + def __repr__(self) -> str: + # The ORM event subclasses provide the row fields described by the + # protocol; the base class keeps that relationship structural so it + # does not need to inherit from a mapped row class. + event = cast(ClinicalEventProtocol, self) + et = event.event_time + ev = event.event_value() # time string if et.end is None: @@ -236,37 +241,38 @@ def __repr__(self: ClinicalEventProtocol) -> str: # ty: ignore[invalid-method-o value_str = "∅" return ( - f"<{self.__class__.__name__} " - f"person={self.person_id} " - f"concept={self.concept_id} " + f"<{event.__class__.__name__} " + f"person={event.person_id} " + f"concept={event.concept_id} " f"time={time_str} " f"value={value_str}>" ) - def to_dict(self: ClinicalEventProtocol) -> dict[str, Any]: - et = self.event_time - ev = self.event_value() + def to_dict(self) -> dict[str, Any]: + event = cast(ClinicalEventProtocol, self) + et = event.event_time + ev = event.event_value() return { - "person_id": self.person_id, - "event_id": self.event_id, - "event_source_table": self.event_source_table, - "event_field_concept_id": self.event_field_concept_id, - "concept_id": self.concept_id, + "person_id": event.person_id, + "event_id": event.event_id, + "event_source_table": event.event_source_table, + "event_field_concept_id": event.event_field_concept_id, + "event_concept_id": event.concept_id, "event_start": et.start.isoformat(), "event_end": et.end.isoformat() if et.end else None, "value": { "type": ev.type, "value": ev.value, }, - "metadata": dict(self.event_metadata() or {}), + "metadata": dict(event.event_metadata() or {}), } - def to_json(self: ClinicalEventProtocol) -> str: + def to_json(self) -> str: return json.dumps(self.to_dict(), ensure_ascii=False) -class Condition_Event(Condition_Occurrence, ClinicalEvent): +class Condition_Event(ClinicalEvent, Condition_Occurrence): _mapping = EventMapping.from_model( Condition_Occurrence, end_date_field="condition_end_date", @@ -289,7 +295,7 @@ def event_metadata(self) -> dict[str, Optional[int]]: return metadata -class Drug_Exposure_Event(Drug_Exposure, ClinicalEvent): +class Drug_Exposure_Event(ClinicalEvent, Drug_Exposure): _mapping = EventMapping.from_model( Drug_Exposure, end_date_field="drug_exposure_end_date", @@ -305,8 +311,20 @@ def event_metadata(self) -> Mapping[str, Any]: return metadata +class Observation_Event(ClinicalEvent, Observation): + _mapping = EventMapping.from_model( + Observation, + value_fields=["value_as_concept_id", "value_as_number", "value_as_string"], + ) + + class Person_Timeline(Person): - EVENT_TABLES = (Measurement_Event, Condition_Event, Drug_Exposure_Event) + EVENT_TABLES = ( + Measurement_Event, + Condition_Event, + Drug_Exposure_Event, + Observation_Event, + ) @property def events(self) -> list[ClinicalEvent]: @@ -330,4 +348,4 @@ def timeline(self) -> list[ClinicalEvent]: ) def to_json(self) -> list[str]: # ty: ignore[invalid-method-override] - return [e.to_json() for e in self.timeline] # ty: ignore[invalid-argument-type] + return [e.to_json() for e in self.timeline] diff --git a/omop_alchemy/toolkit/episodes/derivation/attachments.py b/omop_alchemy/toolkit/episodes/derivation/attachments.py index d0ee8e9..47b268e 100644 --- a/omop_alchemy/toolkit/episodes/derivation/attachments.py +++ b/omop_alchemy/toolkit/episodes/derivation/attachments.py @@ -127,6 +127,7 @@ def _attachment_diagnostics( episodes: FromClause, episode_events: FromClause, valid_explicit: FromClause, + valid_explicit_event_keys: FromClause, fallback_candidates: FromClause | None, *, policy: EpisodeAttachmentPolicy, @@ -190,16 +191,6 @@ def diagnostic_literals( .where(events.c[person_id] != episodes.c[person_id]) ) - # These keys distinguish events that already have authoritative links from - # those whose absence or fallback outcome still needs explanation. - valid_keys = ( - sa.select( - valid_explicit.c[source_table], - valid_explicit.c[event_id], - ) - .distinct() - .cte("valid_explicit_event_keys") - ) diagnostic_branches: list[sa.Select[Any]] = [person_mismatches] if fallback_candidates is not None: @@ -232,13 +223,14 @@ def diagnostic_literals( .where(fallback_candidates.c[_FALLBACK_CANDIDATE_COUNT] > 1) .distinct() ) - diagnostic_branches.append(ambiguous) + if not policy.permits_fallback_fanout: + diagnostic_branches.append(ambiguous) else: candidate_keys = None # The final diagnostic is event-relative: no valid explicit relationship # and, where fallback is enabled, no episode admitted by the window. - no_candidate_conditions = [_not_exists_for_event(events, valid_keys)] + no_candidate_conditions = [_not_exists_for_event(events, valid_explicit_event_keys)] if candidate_keys is not None: no_candidate_conditions.append(_not_exists_for_event(events, candidate_keys)) no_candidate_message = ( @@ -326,8 +318,8 @@ def episode_attachment_queries( episode_start = str(EpisodeColumn.episode_start_date) episode_end = str(EpisodeColumn.episode_end_date) - # stage 1 of episode resolution accepts an explicit link only when ID, Field - # discriminator, episode, and person agree. This is the sole point where an + # stage 1 of episode resolution accepts an explicit link only when ID, Field + # discriminator, episode, and person agree. This is the sole point where an # explicit link becomes authoritative enough to suppress date-based fallback. valid_explicit = ( sa.select( @@ -355,23 +347,28 @@ def episode_attachment_queries( .cte("valid_explicit_attachments") ) + # These keys distinguish events that already have authoritative links from + # those whose absence or fallback outcome still needs explanation. Build the + # relation once so both fallback suppression and diagnostics reference the + # same CTE rather than constructing duplicate DISTINCT projections. + valid_explicit_event_keys = ( + sa.select( + valid_explicit.c[source_table], + valid_explicit.c[event_id], + ) + .distinct() + .cte("valid_explicit_event_keys") + ) + attachment_names = (*event_names, ATTACHMENT_EPISODE_ID, ATTACHMENT_METHOD) explicit_select = sa.select(*(valid_explicit.c[name] for name in attachment_names)) fallback_candidates: FromClause | None = None attachment_branches: list[sa.Select[Any]] = [explicit_select] if policy.uses_fallback: - # episode resolution stage 2 records the complete table-scoped identity - # of every valid explicit event. The anti-existence check below must use + # episode resolution stage 2 records the complete table-scoped identity + # of every valid explicit event. The anti-existence check below must use # both columns: event_id alone is never a cross-table identity in OMOP. - valid_keys = ( - sa.select( - valid_explicit.c[source_table], - valid_explicit.c[event_id], - ) - .distinct() - .cte("valid_explicit_event_keys_for_fallback") - ) fallback_columns: list[sa.ColumnElement[Any]] = [ *(event_source.c[name] for name in event_names), episode_source.c[episode_id].label(ATTACHMENT_EPISODE_ID), @@ -387,8 +384,8 @@ def episode_attachment_queries( "episodes is missing temporal stable ID column: " f"{ranking.stable_id_column}" ) - # episode resolution stage 3 ranks only after window admission. A side - # preference is a deliberate clinical policy tier; the stable episode + # episode resolution stage 3 ranks only after window admission. A side + # preference is a deliberate clinical policy tier; the stable episode # ID prevents tied dates from depending on database row order. fallback_columns.append( temporal_row_number( @@ -424,7 +421,7 @@ def episode_attachment_queries( ), ) ) - .where(_not_exists_for_event(event_source, valid_keys)) + .where(_not_exists_for_event(event_source, valid_explicit_event_keys)) .cte("fallback_attachment_candidates") ) selected_fallback = sa.select( @@ -477,6 +474,7 @@ def episode_attachment_queries( episode_source, link_source, valid_explicit, + valid_explicit_event_keys, fallback_candidates, policy=policy, ) diff --git a/omop_alchemy/toolkit/episodes/derivation/observations.py b/omop_alchemy/toolkit/episodes/derivation/observations.py index 5e2831b..c151fc0 100644 --- a/omop_alchemy/toolkit/episodes/derivation/observations.py +++ b/omop_alchemy/toolkit/episodes/derivation/observations.py @@ -18,8 +18,13 @@ def observation_eligibility_predicate( ) -> sa.ColumnElement[bool]: """Return the date predicate required by an observation selection policy.""" if not spec.requires_anchor: + # Unanchored policies intentionally keep every source row eligible; + # callers can reuse the same ranking builder for episode and person + # level observations without inventing a sentinel anchor date. return sa.true() if anchor_date is None: + # Missing anchor input is a configuration error, not an instruction to + # widen the selection and risk returning an unrelated observation. raise ValueError(f"{spec.policy} requires anchor_date") return ( observation_date <= anchor_date @@ -53,7 +58,11 @@ def observation_row_number( spec: ObservationSelectionSpec, label: str = "observation_rank", ) -> sa.ColumnElement[int]: - """Return a deterministic row number using the declared observation grain.""" + """Return a deterministic row number using the declared observation grain. + + The caller supplies column names rather than mapped attributes so this + helper works against raw OMOP sources and projected/aliased selects alike. + """ try: observation_date = columns[observation_date_column] stable_id = columns[spec.stable_id_column] @@ -77,7 +86,13 @@ def ranked_observation_select( anchor_date: sa.ColumnElement[Any] | None = None, rank_label: str = "observation_rank", ) -> sa.Select[Any]: - """Select source columns with deterministic observation rank and eligibility.""" + """Select source columns with deterministic rank and eligibility. + + Eligibility is applied in the same select that computes the window rank, + keeping out-of-window rows from competing for rank one. The source's + existing columns are preserved and the rank is appended for downstream + projection or filtering. + """ columns = source.c try: observation_date = columns[observation_date_column] diff --git a/omop_alchemy/toolkit/episodes/derivation/structure.py b/omop_alchemy/toolkit/episodes/derivation/structure.py index 2710e8e..a717e60 100644 --- a/omop_alchemy/toolkit/episodes/derivation/structure.py +++ b/omop_alchemy/toolkit/episodes/derivation/structure.py @@ -2,10 +2,11 @@ from __future__ import annotations -from typing import Any +from typing import Any, cast import sqlalchemy as sa import sqlalchemy.orm as so +from sqlalchemy.sql.selectable import FromClause, SelectBase from omop_alchemy.cdm.model.structural import Episode, Episode_Event from omop_alchemy.cdm.model.vocabulary import Concept @@ -13,51 +14,109 @@ from .contracts import CANONICAL_EPISODE_COLUMNS, EpisodeColumn +EpisodeSource = type[Episode] | FromClause | SelectBase +EpisodeEventSource = type[Episode_Event] | FromClause | SelectBase +# Hierarchy builders deliberately accept both mapped tables and pre-shaped +# selectables. This keeps filtering/aliasing at the caller boundary instead of +# forcing recursive queries to rediscover or override that source definition. + + +def _as_from_clause( + source: FromClause | SelectBase, + *, + name: str, +) -> FromClause: + # Recursive joins and column lookup need a FromClause. Wrapping a Select + # once also gives it a stable name for readable SQL and repeated aliases. + if isinstance(source, SelectBase): + return source.subquery(name) + if isinstance(source, FromClause): + return source + raise TypeError(f"{name} must be a SQLAlchemy Select or FromClause") + + +def _episode_source(source: EpisodeSource, *, name: str) -> FromClause: + # Mapped classes contribute only their table here; relationship-bearing ORM + # behavior is intentionally kept out of these SQL-only hierarchy builders. + if isinstance(source, type): + return cast(FromClause, getattr(source, "__table__")) + return _as_from_clause(source, name=name) + + +def _episode_event_source( + source: EpisodeEventSource, + *, + name: str, +) -> FromClause: + # Episode_Event is normalized separately because callers may supply a + # filtered link source while the hierarchy itself remains episode-relative. + if isinstance(source, type): + return cast(FromClause, getattr(source, "__table__")) + return _as_from_clause(source, name=name) + + def canonical_episode_projection( - episode_model: type[Episode] = Episode, + episode_model: EpisodeSource = Episode, *, include_concept_label: bool = False, ) -> sa.Select[Any]: """Project stable episode fields, optionally including its concept name.""" + episodes = _episode_source(episode_model, name="episode_projection") + # Every output label comes from the shared contract so projected episodes + # can be consumed by attachments and hierarchy helpers without table-specific + # column knowledge. columns = [ *( - getattr(episode_model, str(column)).label(str(column)) + episodes.c[str(column)].label(str(column)) for column in CANONICAL_EPISODE_COLUMNS ) ] if not include_concept_label: - return sa.select(*columns) + return sa.select(*columns).select_from(episodes) + # Keep the episode row when vocabulary data is absent; labels are an + # optional presentation enrichment, not a filter on structural episodes. episode_concept = so.aliased(Concept, name="episode_projection_concept") columns.append( episode_concept.concept_name.label(str(EpisodeColumn.episode_concept_name)) ) - return sa.select(*columns).join( - episode_concept, - episode_concept.concept_id == episode_model.episode_concept_id, - isouter=True, + return ( + sa.select(*columns) + .select_from(episodes) + .join( + episode_concept, + episode_concept.concept_id + == episodes.c[str(EpisodeColumn.episode_concept_id)], + isouter=True, + ) ) def direct_episode_relationship_projection( - episode_model: type[Episode] = Episode, + episode_model: EpisodeSource = Episode, ) -> sa.Select[Any]: """Select direct parent-child pairs with a depth of one.""" - episodes = episode_model.__table__ + episodes = _episode_source(episode_model, name="episode_relationships") + # Episode IDs are joined within person so a malformed or imported dataset + # cannot connect two patients merely because their IDs collide. parent = episodes.alias("parent_episode") child = episodes.alias("child_episode") return sa.select( - parent.c.episode_id.label("root_episode_id"), - child.c.episode_id.label("episode_id"), - child.c.episode_parent_id.label("episode_parent_id"), - child.c.person_id.label("person_id"), + parent.c[str(EpisodeColumn.episode_id)].label("root_episode_id"), + child.c[str(EpisodeColumn.episode_id)].label(str(EpisodeColumn.episode_id)), + child.c[str(EpisodeColumn.episode_parent_id)].label( + str(EpisodeColumn.episode_parent_id) + ), + child.c[str(EpisodeColumn.person_id)].label(str(EpisodeColumn.person_id)), sa.literal(1).label("depth"), ).select_from( parent.join( child, sa.and_( - child.c.episode_parent_id == parent.c.episode_id, - child.c.person_id == parent.c.person_id, + child.c[str(EpisodeColumn.episode_parent_id)] + == parent.c[str(EpisodeColumn.episode_id)], + child.c[str(EpisodeColumn.person_id)] + == parent.c[str(EpisodeColumn.person_id)], ), ) ) @@ -66,7 +125,7 @@ def direct_episode_relationship_projection( def episode_descendants( *, root_episode_id: int | sa.ColumnElement[Any] | None = None, - episode_model: type[Episode] = Episode, + episode_model: EpisodeSource = Episode, include_root: bool = True, max_depth: int = 100, name: str = "episode_descendants", @@ -75,33 +134,38 @@ def episode_descendants( if max_depth < 0: raise ValueError("max_depth must be non-negative") - episodes = episode_model.__table__ + episodes = _episode_source(episode_model, name=f"{name}_source") + episode_id = str(EpisodeColumn.episode_id) + episode_parent_id = str(EpisodeColumn.episode_parent_id) + person_id = str(EpisodeColumn.person_id) + # The seed establishes the requested root and depth zero; the recursive + # branch walks only same-person parent links and is bounded for cyclic data. seed = sa.select( - episodes.c.episode_id.label("root_episode_id"), - episodes.c.episode_id.label("episode_id"), - episodes.c.episode_parent_id.label("episode_parent_id"), - episodes.c.person_id.label("person_id"), + episodes.c[episode_id].label("root_episode_id"), + episodes.c[episode_id].label(episode_id), + episodes.c[episode_parent_id].label(episode_parent_id), + episodes.c[person_id].label(person_id), sa.literal(0).label("depth"), ) if root_episode_id is not None: - seed = seed.where(episodes.c.episode_id == root_episode_id) + seed = seed.where(episodes.c[episode_id] == root_episode_id) hierarchy = seed.cte(f"{name}_walk", recursive=True) child = episodes.alias(f"{name}_child") hierarchy = hierarchy.union_all( sa.select( hierarchy.c.root_episode_id, - child.c.episode_id, - child.c.episode_parent_id, - child.c.person_id, + child.c[episode_id], + child.c[episode_parent_id], + child.c[person_id], (hierarchy.c.depth + 1).label("depth"), ) .select_from( hierarchy.join( child, sa.and_( - child.c.episode_parent_id == hierarchy.c.episode_id, - child.c.person_id == hierarchy.c.person_id, + child.c[episode_parent_id] == hierarchy.c[episode_id], + child.c[person_id] == hierarchy.c[person_id], ), ) ) @@ -109,14 +173,16 @@ def episode_descendants( ) if include_root: return hierarchy + # Preserve the same CTE shape while removing only the seed rows from the + # public result, so downstream joins can use one stable hierarchy contract. return sa.select(*hierarchy.c).where(hierarchy.c.depth > 0).cte(name) def episode_event_hierarchy_projection( *, root_episode_id: int | sa.ColumnElement[Any] | None = None, - episode_model: type[Episode] = Episode, - episode_event_model: type[Episode_Event] = Episode_Event, + episode_model: EpisodeSource = Episode, + episode_event_model: EpisodeEventSource = Episode_Event, include_root: bool = True, max_depth: int = 100, ) -> sa.Select[Any]: @@ -128,12 +194,18 @@ def episode_event_hierarchy_projection( max_depth=max_depth, name="episode_event_descendants", ) - event = episode_event_model.__table__ + event = _episode_event_source( + episode_event_model, + name="episode_event_hierarchy_events", + ) + # Keep both the root and owning episode: child-linked events should remain + # attributable to their direct owner while still supporting root-level + # episode analyses. return sa.select( hierarchy.c.root_episode_id, hierarchy.c.episode_id.label("linked_episode_id"), hierarchy.c.person_id, hierarchy.c.depth.label("episode_depth"), - event.c.event_id, - event.c.episode_event_field_concept_id.label("event_field_concept_id"), + event.c["event_id"], + event.c["episode_event_field_concept_id"].label("event_field_concept_id"), ).select_from(hierarchy.join(event, event.c.episode_id == hierarchy.c.episode_id)) diff --git a/omop_alchemy/toolkit/episodes/derivation/temporal.py b/omop_alchemy/toolkit/episodes/derivation/temporal.py index 232abc7..857209e 100644 --- a/omop_alchemy/toolkit/episodes/derivation/temporal.py +++ b/omop_alchemy/toolkit/episodes/derivation/temporal.py @@ -20,6 +20,8 @@ class _SignedDayDelta(FunctionElement[int]): + """Dialect-specific whole-calendar-day difference used by ranking rules.""" + type = sa.Integer() inherit_cache = True @@ -30,6 +32,9 @@ def _compile_signed_day_delta( compiler: SQLCompiler, **kwargs: Any, ) -> str: + # PostgreSQL dates subtract to an integer; casting first makes timestamp + # inputs obey the toolkit's calendar-day contract instead of elapsed-time + # semantics. candidate, anchor = list(element.clauses) return ( f"(CAST({compiler.process(candidate, **kwargs)} AS DATE) - " @@ -43,6 +48,8 @@ def _compile_sqlite_signed_day_delta( compiler: SQLCompiler, **kwargs: Any, ) -> str: + # SQLite has no date subtraction operator. Truncate both operands before + # julianday arithmetic so this stays equivalent to PostgreSQL for dates. candidate, anchor = list(element.clauses) return ( "CAST((julianday(date(" @@ -52,6 +59,8 @@ def _compile_sqlite_signed_day_delta( class _ShiftDate(FunctionElement[Any]): + """Dialect-specific date shift used to construct episode window bounds.""" + type = sa.Date() inherit_cache = True @@ -62,6 +71,8 @@ def _compile_shift_date( compiler: SQLCompiler, **kwargs: Any, ) -> str: + # Keep the date cast on the value and integer cast on the offset explicit; + # this avoids PostgreSQL choosing timestamp/interval semantics implicitly. value, days = list(element.clauses) return ( f"(CAST({compiler.process(value, **kwargs)} AS DATE) + " @@ -75,6 +86,9 @@ def _compile_sqlite_shift_date( compiler: SQLCompiler, **kwargs: Any, ) -> str: + # SQLite's portable equivalent is its date modifier syntax. The printf + # form keeps the offset bound as a SQL expression rather than interpolating + # it into generated SQL. value, days = list(element.clauses) return ( f"date({compiler.process(value, **kwargs)}, " @@ -127,7 +141,12 @@ def episode_window_bounds( *, window: EpisodeWindowSpec = EpisodeWindowSpec(), ) -> tuple[sa.ColumnElement[Any], sa.ColumnElement[Any]]: - """Build the bounded SQL interval used for date-admitted episode facts.""" + """Build the bounded SQL interval used for date-admitted episode facts. + + Closed episodes use their recorded end date. Open episodes use the + configured fallback horizon so an absent end date cannot silently produce + an unbounded join. + """ lower = shift_date(episode_start_date, days=-window.days_prior) upper = sa.func.coalesce( episode_end_date, @@ -166,6 +185,8 @@ def temporal_order_expressions( ) -> tuple[sa.ColumnElement[Any], ...]: """Build deterministic ordering for a temporal ranking contract.""" order: list[sa.ColumnElement[Any]] = [] + # Side preference is a priority tier, not an additional date filter: the + # ranking contract may still select the nearest row on the other side. if ranking.side_preference is TemporalSidePreference.on_or_before_anchor: order.append(sa.case((candidate_date <= anchor_date, 0), else_=1).asc()) elif ranking.side_preference is TemporalSidePreference.on_or_after_anchor: @@ -179,6 +200,7 @@ def temporal_order_expressions( order.append(candidate_date.desc()) else: # pragma: no cover - StrEnum construction prevents unknown policies raise ValueError(f"Unsupported temporal selection policy: {ranking.policy}") + # A stable source ID makes equal-date/equal-distance candidates reproducible. order.append(stable_id.asc()) return tuple(order) diff --git a/omop_alchemy/toolkit/episodes/handling/exposure_series.py b/omop_alchemy/toolkit/episodes/handling/exposure_series.py index a7354a6..508bbce 100644 --- a/omop_alchemy/toolkit/episodes/handling/exposure_series.py +++ b/omop_alchemy/toolkit/episodes/handling/exposure_series.py @@ -9,6 +9,7 @@ def _episode_date_bounds(episode): + """Return the closed fallback window used only by opt-in recovery.""" start = episode.episode_start_date end = episode.episode_end_date or start return start, end @@ -33,6 +34,8 @@ def resolve_drug_exposure_series( seen_ids: set[int] = set() exposures: list[Drug_Exposure] = [] + # Explicit Episode_Event links are authoritative. They remain usable + # without a session and are not replaced by a broader date-based guess. for event in episode.events: if not isinstance(event, Drug_Exposure): continue @@ -43,6 +46,9 @@ def resolve_drug_exposure_series( session = object_session(episode) if include_window and session is not None: + # Date fallback is deliberately opt-in because same-person, same-date + # exposure is not sufficient evidence of episode membership. The ID + # set prevents an explicitly linked row from being returned twice. start, end = _episode_date_bounds(episode) stmt = select(Drug_Exposure).where( Drug_Exposure.person_id == episode.person_id, diff --git a/tests/test_episode_attachment_queries.py b/tests/test_episode_attachment_queries.py index 0f666e5..1af5b02 100644 --- a/tests/test_episode_attachment_queries.py +++ b/tests/test_episode_attachment_queries.py @@ -255,6 +255,25 @@ def test_all_in_window_uses_a_window_contract_without_ranking(session): assert session.execute(queries.attachments).all() == [] +def test_all_in_window_diagnostics_do_not_report_intended_fanout(session): + event = EventCase( + identity=ClinicalEventIdentity("procedure_occurrence", 8), + person_id=101, + event_date=date(2026, 1, 20), + event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, + ) + queries = episode_attachment_queries( + _event_source(event), + episodes=_episode_source(*OVERLAPPING_EPISODES[:2]), + episode_events=_empty_link_source(), + policy=EpisodeAttachmentPolicy.explicit_first_all_in_window, + include_diagnostics=True, + ) + + assert queries.diagnostics is not None + assert session.execute(queries.diagnostics).mappings().all() == [] + + @pytest.mark.parametrize( "policy", [ @@ -329,6 +348,23 @@ def test_diagnostics_explain_person_mismatches_and_fallback_outcomes(session): assert typed.episode_id == CROSS_PERSON_LINK.episode_id +def test_diagnostics_and_fallback_share_the_explicit_event_key_cte(): + queries = episode_attachment_queries( + _event_source(COLLIDING_EVENTS[0]), + episodes=_episode_source(*OVERLAPPING_EPISODES), + episode_events=_empty_link_source(), + policy=EpisodeAttachmentPolicy.explicit_first_ranked, + ranking=_nearest(), + include_diagnostics=True, + ) + + assert queries.diagnostics is not None + compiled = str(queries.diagnostics.compile(dialect=postgresql.dialect())) + + assert "valid_explicit_event_keys_for_fallback" not in compiled + assert compiled.count("valid_explicit_event_keys AS") == 1 + + def test_ranked_policy_requires_a_ranking_contract(): with pytest.raises(ValueError, match="requires a temporal ranking"): episode_attachment_queries( diff --git a/tests/test_episode_structure_queries.py b/tests/test_episode_structure_queries.py index 7ef8a12..5b246c6 100644 --- a/tests/test_episode_structure_queries.py +++ b/tests/test_episode_structure_queries.py @@ -7,7 +7,7 @@ import sqlalchemy as sa from sqlalchemy.dialects import postgresql, sqlite -from omop_alchemy.cdm.model.structural import Episode +from omop_alchemy.cdm.model.structural import Episode, Episode_Event from omop_alchemy.toolkit.episodes.derivation import ( CANONICAL_EPISODE_COLUMNS, CANONICAL_EPISODE_OPTIONAL_COLUMNS, @@ -63,6 +63,67 @@ def test_canonical_episode_projection_can_include_the_episode_concept_name(sessi assert labels == {100: "Disease Episode", 102: None} +def test_hierarchy_projections_accept_a_filtered_projected_episode_source(session): + source = canonical_episode_projection().where(Episode.person_id == 1) + + direct_rows = ( + session.execute(direct_episode_relationship_projection(episode_model=source)) + .mappings() + .all() + ) + assert direct_rows == [ + { + "root_episode_id": 100, + "episode_id": 101, + "episode_parent_id": 100, + "person_id": 1, + "depth": 1, + } + ] + + hierarchy = episode_descendants( + root_episode_id=100, + episode_model=source, + ) + rows = session.execute( + sa.select(hierarchy.c.episode_id, hierarchy.c.depth).order_by( + hierarchy.c.depth, + hierarchy.c.episode_id, + ) + ).all() + assert rows == [(100, 0), (101, 1)] + + +def test_episode_event_projection_accepts_projected_episode_and_event_sources( + session, +): + episode_source = canonical_episode_projection().where(Episode.person_id == 1) + event_source = sa.select(Episode_Event.__table__).subquery("episode_events") + + rows = ( + session.execute( + episode_event_hierarchy_projection( + root_episode_id=100, + episode_model=episode_source, + episode_event_model=event_source, + ) + ) + .mappings() + .all() + ) + + assert rows == [ + { + "root_episode_id": 100, + "linked_episode_id": 101, + "person_id": 1, + "episode_depth": 1, + "event_id": 1, + "event_field_concept_id": 1147127, + } + ] + + def test_episode_concept_label_projection_compiles_on_supported_dialects(): for dialect in (sqlite.dialect(), postgresql.dialect()): compiled = str( diff --git a/tests/test_event_projections.py b/tests/test_event_projections.py index 8357846..003ae0d 100644 --- a/tests/test_event_projections.py +++ b/tests/test_event_projections.py @@ -16,10 +16,12 @@ Drug_Exposure, Measurement, Observation, + Person, Procedure_Occurrence, ) from omop_alchemy.cdm.model.clinical import ( Condition_OccurrenceView, + Device_ExposureView, Drug_ExposureView, MeasurementView, ObservationView, @@ -45,6 +47,11 @@ "condition_occurrence", ModifierFieldConcepts.CONDITION_OCCURRENCE, ), + ( + Device_Exposure, + "device_exposure", + ModifierFieldConcepts.DEVICE_EXPOSURE, + ), (Drug_Exposure, "drug_exposure", ModifierFieldConcepts.DRUG_EXPOSURE), (Measurement, "measurement", ModifierFieldConcepts.MEASUREMENT), (Observation, "observation", ModifierFieldConcepts.OBSERVATION), @@ -113,7 +120,7 @@ def test_incomplete_modifier_target_has_a_typed_error(): UnsupportedClinicalEventModelError, match="no complete ModifierTargetMixin metadata", ): - canonical_event_projection(Device_Exposure) + canonical_event_projection(Person) def test_all_core_event_views_are_registered_episode_event_targets(): @@ -121,6 +128,7 @@ def test_all_core_event_views_are_registered_episode_event_targets(): expected = { ModifierFieldConcepts.CONDITION_OCCURRENCE: Condition_OccurrenceView, + ModifierFieldConcepts.DEVICE_EXPOSURE: Device_ExposureView, ModifierFieldConcepts.DRUG_EXPOSURE: Drug_ExposureView, ModifierFieldConcepts.MEASUREMENT: MeasurementView, ModifierFieldConcepts.OBSERVATION: ObservationView, @@ -128,13 +136,23 @@ def test_all_core_event_views_are_registered_episode_event_targets(): } assert {field: targets[field] for field in expected} == expected - assert not issubclass(Measurement, ModifierTargetMixin) - assert not issubclass(Observation, ModifierTargetMixin) + assert all( + not issubclass(model, ModifierTargetMixin) + for model in ( + Condition_Occurrence, + Device_Exposure, + Drug_Exposure, + Measurement, + Observation, + Procedure_Occurrence, + ) + ) def test_registered_event_views_have_distinct_field_concepts(): views = ( Condition_OccurrenceView, + Device_ExposureView, Drug_ExposureView, MeasurementView, ObservationView, @@ -142,7 +160,7 @@ def test_registered_event_views_have_distinct_field_concepts(): ) field_concepts = tuple(view.modifier_field_concept_id() for view in views) - assert len(field_concepts) == len(set(field_concepts)) == 5 + assert len(field_concepts) == len(set(field_concepts)) == 6 def test_analytics_import_preserves_all_core_metadata_and_compiled_projections(): @@ -151,6 +169,7 @@ def test_analytics_import_preserves_all_core_metadata_and_compiled_projections() from sqlalchemy.dialects import postgresql, sqlite from omop_alchemy.cdm.model import ( Condition_Occurrence, + Device_Exposure, Drug_Exposure, Measurement, Observation, @@ -163,6 +182,7 @@ def test_analytics_import_preserves_all_core_metadata_and_compiled_projections() models = ( Condition_Occurrence, + Device_Exposure, Drug_Exposure, Measurement, Observation, diff --git a/tests/test_event_timeline.py b/tests/test_event_timeline.py new file mode 100644 index 0000000..3eea7be --- /dev/null +++ b/tests/test_event_timeline.py @@ -0,0 +1,64 @@ +"""Behavioural coverage for the lightweight clinical event timeline.""" + +from __future__ import annotations + +from datetime import date, datetime +import json + +import sqlalchemy as sa +from sqlalchemy.dialects import sqlite + +from omop_alchemy.cdm.base import ModifierFieldConcepts +from omop_alchemy.toolkit.core.events import ClinicalEventRow +from omop_alchemy.toolkit.core.timeline import ( + ClinicalEvent, + Condition_Event, + Drug_Exposure_Event, + Observation_Event, + Person_Timeline, +) + + +def test_observation_event_implements_the_timeline_contract(): + event = Observation_Event( + observation_id=7, + person_id=101, + observation_concept_id=900_001, + observation_date=date(2026, 1, 20), + observation_datetime=datetime(2026, 1, 20, 9, 30), + observation_type_concept_id=32817, + value_as_string="family history", + ) + + assert isinstance(event, ClinicalEventRow) + assert event.event_id == 7 + assert event.event_source_table == "observation" + assert event.event_field_concept_id == ModifierFieldConcepts.OBSERVATION + assert event.event_concept_id == 900_001 + assert event.event_date == date(2026, 1, 20) + assert event.event_time.start == datetime(2026, 1, 20, 9, 30) + assert event.event_time.end is None + assert event.event_value().type == "string" + assert event.event_value().value == "family history" + + payload = json.loads(event.to_json()) + assert payload["event_id"] == 7 + assert payload["event_source_table"] == "observation" + assert payload["event_concept_id"] == 900_001 + assert "concept_id" not in payload + + +def test_observation_event_is_part_of_person_timeline_and_compiles(): + assert Observation_Event in Person_Timeline.EVENT_TABLES + + statement = sa.select(Observation_Event).where(Observation_Event.person_id == 101) + compiled = str(statement.compile(dialect=sqlite.dialect())) + + assert "observation" in compiled + assert "person_id" in compiled + + +def test_all_timeline_events_use_clinical_event_behaviour(): + assert Condition_Event.to_json is ClinicalEvent.to_json + assert Drug_Exposure_Event.to_json is ClinicalEvent.to_json + assert Observation_Event.to_json is ClinicalEvent.to_json From 053ae6f0dd417d875aab75904351518699c13776 Mon Sep 17 00:00:00 2001 From: gkennos Date: Mon, 31 Aug 2026 14:28:46 +1000 Subject: [PATCH 08/30] metadata cleanup for projections --- docs/toolkit/core.md | 4 +- docs/toolkit/episodes.md | 2 +- .../cdm/model/clinical/event_metadata.py | 48 ++++++++ omop_alchemy/cdm/model/structural/__init__.py | 2 - .../cdm/model/structural/episode_event.py | 105 ++++++------------ .../toolkit/core/events/projections.py | 31 +----- .../toolkit/core/timeline/event_timeline.py | 7 +- tests/test_episodes_basic.py | 37 ++---- tests/test_event_projections.py | 23 ++-- tests/test_event_timeline.py | 15 +++ 10 files changed, 135 insertions(+), 139 deletions(-) create mode 100644 omop_alchemy/cdm/model/clinical/event_metadata.py diff --git a/docs/toolkit/core.md b/docs/toolkit/core.md index 2df03c9..fd420d7 100644 --- a/docs/toolkit/core.md +++ b/docs/toolkit/core.md @@ -87,7 +87,7 @@ for event in session.execute(events).mappings(): print(event["event_source_table"], event["event_id"], event["event_date"]) ``` -The projection resolves its ID, clinical concept, date, source table, and Field concept through stable CDM event metadata. Bare `Measurement` and `Observation` classes remain lightweight mappings for ETL, while `MeasurementView` and `ObservationView` provide analytical reference context, domain validation, and episode-event resolution. Importing analytics modules cannot change the metadata used for a Core projection. `UnsupportedClinicalEventModelError` is raised before SQL execution when no supported CDM definition exists. +The projection resolves its ID, clinical concept, date, source table, and Field concept through stable CDM event metadata shared with episode-event resolution. Bare `Measurement`, `Observation`, and `Device_Exposure` classes remain lightweight mappings for ETL, while their analytical views provide reference context, domain validation, and episode-event resolution. Importing analytics modules cannot change either the Core projection metadata or the default resolution target. `UnsupportedClinicalEventModelError` is raised before SQL execution when no supported CDM definition exists. ```mermaid flowchart LR @@ -103,7 +103,7 @@ The [query contracts](query-contracts.md) explain how the canonical shape partic ## Work with a patient timeline -The timeline adapter presents conditions, measurements, and drug exposures as a single ordered sequence while retaining each row's source identity and value semantics. Use it when an application needs to display or serialise a patient's chronology rather than build a set-based analytical query. +The timeline adapter presents conditions, measurements, observations, and drug exposures as a single ordered sequence while retaining each row's source identity and value semantics. Use it when an application needs to display or serialise a patient's chronology rather than build a set-based analytical query. The timeline has a dedicated guide with session requirements, event mappings, and extension points: [Patient timelines](../advanced/timelines.md). diff --git a/docs/toolkit/episodes.md b/docs/toolkit/episodes.md index ccd5758..20c71b1 100644 --- a/docs/toolkit/episodes.md +++ b/docs/toolkit/episodes.md @@ -56,7 +56,7 @@ Window retrieval must be paired with a meaningful concept filter. Without one, e ## Understand unresolved episode links -`Episode_EventView.resolved_event` returns the linked analytical ORM row when the field concept and target row can be resolved, otherwise `None`. Measurement and Observation links resolve to `MeasurementView` and `ObservationView`; their bare table mappings remain available for lightweight ETL. `ResolvedEpisodeEvent` preserves the resolution behaviour and adds a diagnostic that distinguishes three cases: +`Episode_EventView.resolved_event` returns the linked analytical ORM row when the field concept and target row can be resolved, otherwise `None`. Measurement, Observation, and Device Exposure links resolve to `MeasurementView`, `ObservationView`, and `Device_ExposureView`; their bare table mappings remain available for lightweight ETL. The target map uses the same stable CDM metadata as canonical event projections, so importing domain-specific analytics cannot change the default target. `ResolvedEpisodeEvent` preserves the resolution behaviour and adds a diagnostic that distinguishes three cases: - the field concept is not a recognised `ModifierFieldConcepts` value; - the field concept is recognised but no ORM target class is registered for it; or diff --git a/omop_alchemy/cdm/model/clinical/event_metadata.py b/omop_alchemy/cdm/model/clinical/event_metadata.py new file mode 100644 index 0000000..6fa4ed5 --- /dev/null +++ b/omop_alchemy/cdm/model/clinical/event_metadata.py @@ -0,0 +1,48 @@ +"""Stable metadata for CDM tables that participate in clinical-event APIs.""" + +from __future__ import annotations + +from types import MappingProxyType +from typing import Any, Mapping + +from omop_alchemy.cdm.base import ModifierTargetMixin + +from .condition_occurrence import Condition_Occurrence, Condition_OccurrenceView +from .device_exposure import Device_Exposure, Device_ExposureView +from .drug_exposure import Drug_Exposure, Drug_ExposureView +from .measurement import Measurement, MeasurementView +from .observation import Observation, ObservationView +from .procedure_occurrence import Procedure_Occurrence, Procedure_OccurrenceView + + +# Keep one explicit supported event set. Both lookup shapes are derived from it +# so projection and episode-resolution support cannot drift independently. +_CLINICAL_EVENT_TARGETS: tuple[tuple[type[Any], type[ModifierTargetMixin]], ...] = ( + (Condition_Occurrence, Condition_OccurrenceView), + (Device_Exposure, Device_ExposureView), + (Drug_Exposure, Drug_ExposureView), + (Measurement, MeasurementView), + (Observation, ObservationView), + (Procedure_Occurrence, Procedure_OccurrenceView), +) + +CLINICAL_EVENT_TARGETS_BY_TABLE: Mapping[str, type[ModifierTargetMixin]] = ( + MappingProxyType( + {source.__tablename__: target for source, target in _CLINICAL_EVENT_TARGETS} + ) +) +CLINICAL_EVENT_TARGETS_BY_FIELD_CONCEPT_ID: Mapping[int, type[ModifierTargetMixin]] = ( + MappingProxyType( + { + target.modifier_field_concept_id(): target + for _, target in _CLINICAL_EVENT_TARGETS + } + ) +) + + +def clinical_event_target_for_table( + table_name: str, +) -> type[ModifierTargetMixin] | None: + """Return the analytical target that owns metadata for a bare CDM table.""" + return CLINICAL_EVENT_TARGETS_BY_TABLE.get(table_name) diff --git a/omop_alchemy/cdm/model/structural/__init__.py b/omop_alchemy/cdm/model/structural/__init__.py index 2002a44..6d65f77 100644 --- a/omop_alchemy/cdm/model/structural/__init__.py +++ b/omop_alchemy/cdm/model/structural/__init__.py @@ -3,7 +3,6 @@ Episode_Event, Episode_EventContext, Episode_EventView, - clear_episode_event_target_class_cache, ) from .fact_relationship import Fact_Relationship @@ -14,6 +13,5 @@ "Episode_Event", "Episode_EventContext", "Episode_EventView", - "clear_episode_event_target_class_cache", "Fact_Relationship", ] diff --git a/omop_alchemy/cdm/model/structural/episode_event.py b/omop_alchemy/cdm/model/structural/episode_event.py index 3720c4d..fcb7d64 100644 --- a/omop_alchemy/cdm/model/structural/episode_event.py +++ b/omop_alchemy/cdm/model/structural/episode_event.py @@ -1,78 +1,25 @@ import sqlalchemy as sa import sqlalchemy.orm as so -from sqlalchemy import event -from sqlalchemy.orm import Mapper from typing import TYPE_CHECKING, Any, Type -from functools import cached_property, cache +from functools import cached_property from orm_loader.helpers import Base from omop_alchemy.cdm.base import ( cdm_table, CDMTableBase, - MODEL_MODULE_PREFIX, ReferenceContext, DomainValidationMixin, ExpectedDomain, - ModifierTargetMixin, merge_table_args, omop_index, ) +from omop_alchemy.cdm.model.clinical.event_metadata import ( + CLINICAL_EVENT_TARGETS_BY_FIELD_CONCEPT_ID, +) if TYPE_CHECKING: from ..vocabulary import Concept from .episode import Episode -@cache -def _modifier_target_classes_by_field_concept_id() -> dict[int, Type[Any]]: - """ - Default field-concept -> ORM target class map, CDM classes only. - - Scans every registered mapper, so iteration order is otherwise at the - mercy of import order. Sorting makes the result deterministic, and - restricting to ``cdm.model`` (the same ``MODEL_MODULE_PREFIX`` that - ``@cdm_table`` itself classifies tables by) excludes toolkit/domain - subclasses (e.g. oncology-aware views) that also implement - ``ModifierTargetMixin`` -- those are reached only through an explicit - override such as ``OncologyEpisodeEvent.resolved_event_target_classes``, - never by accident here. - """ - classes: dict[int, Type[Any]] = {} - mappers = sorted( - Base.registry.mappers, - key=lambda mapper: f"{mapper.class_.__module__}.{mapper.class_.__qualname__}", - ) - for mapper in mappers: - cls = mapper.class_ - if not issubclass(cls, ModifierTargetMixin): - continue - if not cls.__module__.startswith(MODEL_MODULE_PREFIX): - continue - try: - field_concept_id = cls.modifier_field_concept_id() - except NotImplementedError: - continue - if field_concept_id not in classes: - classes[field_concept_id] = cls - return classes - -@event.listens_for(Mapper, "after_mapper_constructed") -def _invalidate_target_class_cache( - mapper: Mapper[Any], - class_: type[Any], -) -> None: - _modifier_target_classes_by_field_concept_id.cache_clear() - - -def clear_episode_event_target_class_cache() -> None: - """ - Clear cached episode_event field-concept target mappings. - - Invalidation is automatic: ``after_mapper_constructed`` clears the cache - whenever a new mapper is configured, so a ``ModifierTargetMixin`` subclass - imported late is picked up without intervention. This remains as an escape - hatch for callers that build target classes outside the mapper lifecycle. - """ - _modifier_target_classes_by_field_concept_id.cache_clear() - @cdm_table class Episode_Event(CDMTableBase, Base): @@ -82,16 +29,30 @@ class Episode_Event(CDMTableBase, Base): omop_index(__tablename__, "episode_event_field_concept_id"), ) - episode_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey("episode.episode_id"),nullable=False,primary_key=True) - event_id: so.Mapped[int] = so.mapped_column(nullable=False,primary_key=True) - episode_event_field_concept_id: so.Mapped[int] = so.mapped_column(sa.ForeignKey("concept.concept_id"),nullable=False,primary_key=True) + episode_id: so.Mapped[int] = so.mapped_column( + sa.ForeignKey("episode.episode_id"), nullable=False, primary_key=True + ) + event_id: so.Mapped[int] = so.mapped_column(nullable=False, primary_key=True) + episode_event_field_concept_id: so.Mapped[int] = so.mapped_column( + sa.ForeignKey("concept.concept_id"), nullable=False, primary_key=True + ) def __repr__(self) -> str: return f"" - + + class Episode_EventContext(ReferenceContext): - episode: so.Mapped["Episode"] = ReferenceContext._reference_relationship(target="Episode",local_fk="episode_id",remote_pk="episode_id",) # type: ignore[assignment] - event_field: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept",local_fk="episode_event_field_concept_id",remote_pk="concept_id",) # type: ignore[assignment] + episode: so.Mapped["Episode"] = ReferenceContext._reference_relationship( + target="Episode", + local_fk="episode_id", + remote_pk="episode_id", + ) # type: ignore[assignment] + event_field: so.Mapped["Concept"] = ReferenceContext._reference_relationship( + target="Concept", + local_fk="episode_event_field_concept_id", + remote_pk="concept_id", + ) # type: ignore[assignment] + class Episode_EventView(Episode_Event, Episode_EventContext, DomainValidationMixin): """ @@ -112,8 +73,11 @@ class Episode_EventView(Episode_Event, Episode_EventContext, DomainValidationMix def resolved_event_target_classes(cls) -> dict[int, Type[Any]]: """ Map episode_event field concepts to ORM classes that can receive them. + + A fresh mapping lets domain-specific subclasses override selected + targets without mutating the stable Core registry. """ - return _modifier_target_classes_by_field_concept_id() + return dict(CLINICAL_EVENT_TARGETS_BY_FIELD_CONCEPT_ID) @property def event_table(self) -> str | None: @@ -148,24 +112,21 @@ def __repr__(self): f"{target.__class__.__name__}#{self.event_id}>" ) return f"" - + @property def episode_start_datetime(self): - return ( - self.episode.episode_start_datetime - if self.episode else None - ) - + return self.episode.episode_start_datetime if self.episode else None + @property def resolved_event_id_column(self) -> str | None: """ Name of the ID column on the resolved event table. Derived from episode_event_field_concept_id metadata. - + Example: 'condition_occurrence.condition_occurrence_id' resolves to 'condition_occurrence_id' """ if self.event_field and "." in self.event_field.concept_name: return self.event_field.concept_name.split(".", 1)[1] - return None \ No newline at end of file + return None diff --git a/omop_alchemy/toolkit/core/events/projections.py b/omop_alchemy/toolkit/core/events/projections.py index 2aeff54..e6ba86f 100644 --- a/omop_alchemy/toolkit/core/events/projections.py +++ b/omop_alchemy/toolkit/core/events/projections.py @@ -8,19 +8,8 @@ import sqlalchemy as sa from omop_alchemy.cdm.base import ModifierTargetMixin -from omop_alchemy.cdm.model.clinical import ( - Condition_Occurrence, - Condition_OccurrenceView, - Device_Exposure, - Device_ExposureView, - Drug_Exposure, - Drug_ExposureView, - Measurement, - MeasurementView, - Observation, - ObservationView, - Procedure_Occurrence, - Procedure_OccurrenceView, +from omop_alchemy.cdm.model.clinical.event_metadata import ( + clinical_event_target_for_table, ) from .contracts import ClinicalEventColumn @@ -48,20 +37,6 @@ class ClinicalEventModelSpec: event_source_table: str -_EVENT_METADATA_BY_TABLE: dict[str, type[ModifierTargetMixin]] = { - Condition_Occurrence.__tablename__: Condition_OccurrenceView, - Device_Exposure.__tablename__: Device_ExposureView, - Drug_Exposure.__tablename__: Drug_ExposureView, - Measurement.__tablename__: MeasurementView, - Observation.__tablename__: ObservationView, - Procedure_Occurrence.__tablename__: Procedure_OccurrenceView, -} -# This registry is intentionally explicit. Projection behavior must not depend -# on which analytical subclasses happen to have been imported or registered by -# SQLAlchemy in the current process. -"""Stable CDM event metadata, independent of imported analytical subclasses.""" - - def _has_complete_event_metadata(model: type[Any]) -> bool: # A model is eligible to own metadata only when the complete modifier # contract is present. Partial class attributes would produce a projection @@ -90,7 +65,7 @@ def _metadata_candidate(model: type[Any]) -> type[ModifierTargetMixin] | None: # their table through the configured analytical view without changing the # class used to read scalar event rows. table_name = getattr(model, "__tablename__", None) - return _EVENT_METADATA_BY_TABLE.get(str(table_name)) + return clinical_event_target_for_table(str(table_name)) def _datetime_column_name(model: type[Any], date_column_name: str) -> str | None: diff --git a/omop_alchemy/toolkit/core/timeline/event_timeline.py b/omop_alchemy/toolkit/core/timeline/event_timeline.py index 599a5b0..e49976a 100644 --- a/omop_alchemy/toolkit/core/timeline/event_timeline.py +++ b/omop_alchemy/toolkit/core/timeline/event_timeline.py @@ -184,7 +184,12 @@ def event_value(self) -> EventValue: ): return EventValue(type="concept", value=value) - if "number" in field.lower() and isinstance(value, (int, float)): + # OMOP quantity fields are numeric even though their names do not + # contain ``number``. Keep the mapping semantic here so callers do + # not need table-specific special cases when serialising timelines. + if ( + "number" in field.lower() or field.lower() == "quantity" + ) and isinstance(value, (int, float)): return EventValue(type="numeric", value=value) if "string" in field.lower() and isinstance(value, str) and value.strip(): diff --git a/tests/test_episodes_basic.py b/tests/test_episodes_basic.py index 7d3e7b9..b723085 100644 --- a/tests/test_episodes_basic.py +++ b/tests/test_episodes_basic.py @@ -3,7 +3,6 @@ EpisodeView, Episode_Event, Episode_EventView, - clear_episode_event_target_class_cache, ) from omop_alchemy.toolkit.episodes.handling import ( DEFAULT_EPISODE_OPEN_END_FALLBACK_DAYS, @@ -30,7 +29,9 @@ def test_episode_view_expected_domains(): assert "episode_object_concept_id" in cls.__expected_domains__ assert "episode_type_concept_id" in cls.__expected_domains__ - assert cls.__expected_domains__["episode_concept_id"].domains == frozenset({"Episode"}) + assert cls.__expected_domains__["episode_concept_id"].domains == frozenset( + {"Episode"} + ) def test_episode_reference_context(session): @@ -46,11 +47,7 @@ def test_episode_reference_context(session): def test_episode_has_episode_events(session): """Test episode has episode events.""" - ep = ( - session.query(EpisodeView) - .filter(EpisodeView.episode_events.any()) - .first() - ) + ep = session.query(EpisodeView).filter(EpisodeView.episode_events.any()).first() assert ep is not None assert len(ep.episode_events) > 0 @@ -73,11 +70,7 @@ def test_resolved_episode_event_mixin_targets_diagnostic_rows(session): def test_episode_event_resolves_target(session): """Test episode event resolves target.""" - ep = ( - session.query(EpisodeView) - .filter(EpisodeView.episode_events.any()) - .first() - ) + ep = session.query(EpisodeView).filter(EpisodeView.episode_events.any()).first() ee = ep.episode_events[0] target = ee.resolved_event @@ -185,28 +178,21 @@ def test_episode_event_resolution_reports_dangling_target(session): assert diagnostics[0].kind == "dangling_event" -def test_episode_event_target_class_cache_can_be_cleared(): - """The resolver map is memoized but explicitly invalidatable.""" - clear_episode_event_target_class_cache() +def test_episode_event_target_classes_are_isolated_from_caller_mutation(): + """Resolver overrides cannot mutate the stable Core target registry.""" first = Episode_EventView.resolved_event_target_classes() second = Episode_EventView.resolved_event_target_classes() - assert first is second - - clear_episode_event_target_class_cache() + assert first is not second + first.clear() third = Episode_EventView.resolved_event_target_classes() - assert third is not first - assert third == first + assert third == second def test_episode_view_events_property(session): """Test episode view events property.""" - ep = ( - session.query(EpisodeView) - .filter(EpisodeView.episode_events.any()) - .first() - ) + ep = session.query(EpisodeView).filter(EpisodeView.episode_events.any()).first() events = ep.events @@ -221,7 +207,6 @@ def test_episode_view_events_property(session): assert hasattr(target, "person_id") - def test_episode_parent_relationship(session): """Test episode parent relationship.""" child = ( diff --git a/tests/test_event_projections.py b/tests/test_event_projections.py index 003ae0d..a10ccda 100644 --- a/tests/test_event_projections.py +++ b/tests/test_event_projections.py @@ -179,6 +179,7 @@ def test_analytics_import_preserves_all_core_metadata_and_compiled_projections() canonical_event_projection, clinical_event_model_spec, ) + from omop_alchemy.cdm.model.structural import Episode_EventView models = ( Condition_Occurrence, @@ -190,14 +191,22 @@ def test_analytics_import_preserves_all_core_metadata_and_compiled_projections() ) def snapshot(): - return tuple( - ( - model.__name__, - clinical_event_model_spec(model), - str(canonical_event_projection(model).compile(dialect=sqlite.dialect())), - str(canonical_event_projection(model).compile(dialect=postgresql.dialect())), + return ( + tuple( + ( + model.__name__, + clinical_event_model_spec(model), + str(canonical_event_projection(model).compile(dialect=sqlite.dialect())), + str(canonical_event_projection(model).compile(dialect=postgresql.dialect())), + ) + for model in models + ), + tuple( + sorted( + (field, target.__name__) + for field, target in Episode_EventView.resolved_event_target_classes().items() + ) ) - for model in models ) before = snapshot() diff --git a/tests/test_event_timeline.py b/tests/test_event_timeline.py index 3eea7be..fa7240d 100644 --- a/tests/test_event_timeline.py +++ b/tests/test_event_timeline.py @@ -58,6 +58,21 @@ def test_observation_event_is_part_of_person_timeline_and_compiles(): assert "person_id" in compiled +def test_drug_exposure_quantity_is_a_numeric_timeline_value(): + event = Drug_Exposure_Event( + drug_exposure_id=8, + person_id=101, + drug_concept_id=900_002, + drug_exposure_start_date=date(2026, 1, 21), + drug_type_concept_id=32817, + quantity=12.5, + ) + + assert event.event_value().type == "numeric" + assert event.event_value().value == 12.5 + assert event.to_dict()["value"] == {"type": "numeric", "value": 12.5} + + def test_all_timeline_events_use_clinical_event_behaviour(): assert Condition_Event.to_json is ClinicalEvent.to_json assert Drug_Exposure_Event.to_json is ClinicalEvent.to_json From 81cf0071b1081e004db99cbc6f373fe2ec275d61 Mon Sep 17 00:00:00 2001 From: gkennos Date: Mon, 31 Aug 2026 14:29:24 +1000 Subject: [PATCH 09/30] metadata cleanup for projections --- omop_alchemy/cdm/model/structural/__init__.py | 2 ++ omop_alchemy/cdm/model/structural/episode_event.py | 4 ++++ tests/test_episodes_basic.py | 2 ++ 3 files changed, 8 insertions(+) diff --git a/omop_alchemy/cdm/model/structural/__init__.py b/omop_alchemy/cdm/model/structural/__init__.py index 6d65f77..2002a44 100644 --- a/omop_alchemy/cdm/model/structural/__init__.py +++ b/omop_alchemy/cdm/model/structural/__init__.py @@ -3,6 +3,7 @@ Episode_Event, Episode_EventContext, Episode_EventView, + clear_episode_event_target_class_cache, ) from .fact_relationship import Fact_Relationship @@ -13,5 +14,6 @@ "Episode_Event", "Episode_EventContext", "Episode_EventView", + "clear_episode_event_target_class_cache", "Fact_Relationship", ] diff --git a/omop_alchemy/cdm/model/structural/episode_event.py b/omop_alchemy/cdm/model/structural/episode_event.py index fcb7d64..121f35d 100644 --- a/omop_alchemy/cdm/model/structural/episode_event.py +++ b/omop_alchemy/cdm/model/structural/episode_event.py @@ -21,6 +21,10 @@ from .episode import Episode +def clear_episode_event_target_class_cache() -> None: + """Retain the former cache hook; stable event metadata needs no invalidation.""" + + @cdm_table class Episode_Event(CDMTableBase, Base): __tablename__ = "episode_event" diff --git a/tests/test_episodes_basic.py b/tests/test_episodes_basic.py index b723085..9bf3ed8 100644 --- a/tests/test_episodes_basic.py +++ b/tests/test_episodes_basic.py @@ -3,6 +3,7 @@ EpisodeView, Episode_Event, Episode_EventView, + clear_episode_event_target_class_cache, ) from omop_alchemy.toolkit.episodes.handling import ( DEFAULT_EPISODE_OPEN_END_FALLBACK_DAYS, @@ -185,6 +186,7 @@ def test_episode_event_target_classes_are_isolated_from_caller_mutation(): assert first is not second first.clear() + clear_episode_event_target_class_cache() third = Episode_EventView.resolved_event_target_classes() assert third == second From 49a94b034c97a1c3eedd09fbda943fa6cf594d90 Mon Sep 17 00:00:00 2001 From: gkennos Date: Tue, 1 Sep 2026 06:31:37 +1000 Subject: [PATCH 10/30] mat view cleanup --- .github/CONTRIBUTING.md | 6 + docs/api/architecture.md | 8 + docs/getting-started/installation.md | 2 +- docs/toolkit/core.md | 8 + docs/toolkit/materialized-views.md | 156 +++++---- omop_alchemy/toolkit/core/__init__.py | 7 +- .../toolkit/core/materialization/__init__.py | 58 ---- .../toolkit/core/materialization/contracts.py | 125 ------- .../toolkit/core/materialization/ddl.py | 167 --------- .../toolkit/core/materialization/lifecycle.py | 319 ------------------ tests/test_materialization_ownership.py | 44 +++ tests/test_materialized_view_lifecycle.py | 198 ----------- ...st_materialized_view_lifecycle_postgres.py | 104 ------ 13 files changed, 146 insertions(+), 1056 deletions(-) delete mode 100644 omop_alchemy/toolkit/core/materialization/__init__.py delete mode 100644 omop_alchemy/toolkit/core/materialization/contracts.py delete mode 100644 omop_alchemy/toolkit/core/materialization/ddl.py delete mode 100644 omop_alchemy/toolkit/core/materialization/lifecycle.py create mode 100644 tests/test_materialization_ownership.py delete mode 100644 tests/test_materialized_view_lifecycle.py delete mode 100644 tests/test_materialized_view_lifecycle_postgres.py diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index e4c513d..63ffdc4 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -8,6 +8,12 @@ uv run pytest -q uv run ruff check . ``` +## Ownership boundaries + +Before adding general database or ORM infrastructure, check whether it belongs in a lower-level dependency. `orm-loader` owns domain-independent loading, serialization, and materialized-view lifecycle mechanics. OMOP Alchemy owns OMOP table models, clinical semantics, and the OMOP-specific selectables and row grains that consumers can pass to that infrastructure. + +Do not add materialized-view DDL, lifecycle helpers, or orchestration to `omop_alchemy`. A consuming application owns its view registry, dependency and rebuild policy, and command-line or deployment workflow. The ownership test in `tests/test_materialization_ownership.py` protects this boundary. + ## Opening a pull request 1. Apply **exactly one** label before merging: diff --git a/docs/api/architecture.md b/docs/api/architecture.md index aee5eb4..dfbdd94 100644 --- a/docs/api/architecture.md +++ b/docs/api/architecture.md @@ -22,6 +22,7 @@ flowchart TD L0a["CSVLoadableTableInterface"] L0b["SerialisableTableInterface"] L0c["Bulk load & casting helpers"] + L0d["Materialized-view lifecycle"] end subgraph L1["cdm.base"] @@ -68,6 +69,7 @@ This layer provides: * bulk inserts * type casting * serialization helpers +* generic materialized-view definition and lifecycle operations It is deliberately domain-agnostic. @@ -77,6 +79,12 @@ Examples: * [CSVLoadableTableInterface](https://australiancancerdatanetwork.github.io/orm-loader/loaders/) * [SerialisableTableInterface](https://australiancancerdatanetwork.github.io/orm-loader/tables/serialisable_table/) +* [Materialized views](https://australiancancerdatanetwork.github.io/orm-loader/tables/mat_view/) + +OMOP Alchemy may supply an OMOP-specific selectable and its logical row +identity to this layer, but it does not implement database DDL or refresh +mechanics. Applications own collections of materialized views, dependency +policy, and deployment commands. #### cdm.base (L1) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 18800b3..52e2106 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -87,7 +87,7 @@ This is supported for postgres only. Use engine_with_replica_role when: -* Creating / refreshing materialized views +* Running schema-level operations that may open independent sessions * Running schema-level operations that might trigger independent sessions * Using tooling that opens its own connections diff --git a/docs/toolkit/core.md b/docs/toolkit/core.md index fd420d7..9010a3a 100644 --- a/docs/toolkit/core.md +++ b/docs/toolkit/core.md @@ -2,6 +2,14 @@ The core package handles problems that have the same meaning in every clinical domain: resolving a source term to an OMOP concept, identifying an event across CDM tables, arranging events on a timeline, and converting measurements to comparable units. +Generic database lifecycle operations do not belong in the clinical toolkit. Use +[`orm-loader`](https://australiancancerdatanetwork.github.io/orm-loader/tables/mat_view/) +to define, create, refresh, index, and drop materialized views. OMOP Alchemy owns +the OMOP-specific query and row-grain decisions supplied to that infrastructure; +an application such as `omop-constructs` owns its registry, dependency policy, +and deployment orchestration. See [Materialized views](materialized-views.md) for +the integration boundary. + ## Resolve source data to concepts Suppose an intake system supplies the text `Adenocarcinoma of lung` rather than an OMOP concept ID. A resolver limits the eligible vocabulary rows and applies the same text normalisation when it builds its lookup and when it handles an incoming value: diff --git a/docs/toolkit/materialized-views.md b/docs/toolkit/materialized-views.md index 64a0313..c49732e 100644 --- a/docs/toolkit/materialized-views.md +++ b/docs/toolkit/materialized-views.md @@ -1,113 +1,109 @@ # Materialized views -Materialized views are useful when an analytical query is expensive but its results can be refreshed on a controlled schedule. The lifecycle helpers in OMOP Alchemy keep every operation tied to an explicit PostgreSQL schema and view name. This prevents a connection's search path from deciding which object is created, refreshed, indexed, or dropped. +Materialized-view definitions and database lifecycle operations are provided by +[`orm-loader`](https://australiancancerdatanetwork.github.io/orm-loader/tables/mat_view/). +OMOP Alchemy supplies OMOP models and query-building primitives that applications +can use in those definitions; it does not provide a second DDL or refresh API. -The following view stores one row for each event selected by an application query: +The supported deployment contract is PostgreSQL with unqualified materialized +views resolving to the `public` schema. Omit the `schema` argument when calling +the lifecycle methods. Qualified non-`public` schemas are not currently part of +the supported contract. + +## Define a view over an OMOP query + +Use the public `orm_loader.materialized_views` module. A definition states the +view name, its SQLAlchemy selectable, the complete logical row identity, any +dependencies, and any indexes that should be created with the view: ```python import sqlalchemy as sa -from omop_alchemy.toolkit.core.materialization import ( +from orm_loader.materialized_views import ( MaterializedViewIndex, - MaterializedViewSpec, - MaterializedViewTarget, - create_materialized_view, - create_materialized_view_indexes, + MaterializedViewMixin, ) + event_query = sa.select( events.c.person_id, events.c.event_id, events.c.event_date, ) -event_view = MaterializedViewSpec( - target=MaterializedViewTarget( - schema="reporting", - name="clinical_events", - ), - selectable=event_query, - logical_identity=("person_id", "event_id"), - indexes=( + +class ClinicalEventsMV(MaterializedViewMixin): + __mv_name__ = "clinical_events" + __mv_select__ = event_query + __mv_logical_identity__ = ("person_id", "event_id") + __mv_dependencies__ = ("measurement", "observation") + __mv_indexes__ = ( MaterializedViewIndex( name="clinical_events_identity_uq", columns=("person_id", "event_id"), unique=True, ), - ), -) - -with engine.begin() as connection: - create_materialized_view(connection, event_view) - create_materialized_view_indexes(connection, event_view) -``` - -The schema and view name are separate identifiers and are quoted by the PostgreSQL dialect. Index columns and index names are quoted in the same way. Applications should pass identifiers as plain strings; they should not add SQL quoting themselves. - -Creation fails when the view or index name already exists. This makes a stale definition or incompatible existing index visible to the caller. A registry that has separately checked the existing object may opt into idempotent PostgreSQL DDL with `if_not_exists=True`. - -## Refresh a populated view - -A normal refresh replaces the contents while holding the PostgreSQL lock associated with `REFRESH MATERIALIZED VIEW`: - -```python -from omop_alchemy.toolkit.core.materialization import refresh_materialized_view - -with engine.begin() as connection: - refresh_materialized_view(connection, event_view) -``` - -A concurrent refresh permits reads to continue, but PostgreSQL requires an eligible unique index on the populated view. Setting `concurrently=True` does not simply add SQL syntax. The helper first checks that the specification declares a simple unique index and then inspects PostgreSQL to confirm that an eligible unique index exists: - -```python -with engine.begin() as connection: - refresh_materialized_view( - connection, - event_view, - concurrently=True, ) ``` -If either check fails, `ConcurrentRefreshNotEligibleError` is raised before the refresh statement is executed. The declaration does not substitute for creating the index, and an undeclared database index does not substitute for recording the operational requirement in the specification. - -```mermaid -stateDiagram-v2 - [*] --> Absent - Absent --> Populated: create_materialized_view() - Populated --> Populated: refresh_materialized_view() - Populated --> Absent: drop_materialized_view() +`__mv_logical_identity__` documents the complete grain and is validated against +the selectable, but it is not itself a database constraint. Test that identity +against representative data and declare a matching unique index when the view +must support concurrent refresh. - state eligibility <> - Populated --> eligibility: refresh(concurrently=True) - eligibility --> Populated: declared unique index
confirmed in the database - eligibility --> [*]: ConcurrentRefreshNotEligibleError -``` +`__mv_dependencies__` records tables or materialized views that the definition +depends on. The registry owner decides which dependencies are managed views and +uses that metadata to determine refresh order. -## Drop one qualified target +## Create, refresh, and drop -Dropping uses the same `MaterializedViewTarget` as creation and refresh: +The class methods accept either a SQLAlchemy `Engine` or `Connection`. Passing +an engine lets `orm-loader` manage the transaction. Passing a connection keeps +the operation inside the caller's transaction: ```python -from omop_alchemy.toolkit.core.materialization import drop_materialized_view +ClinicalEventsMV.create_mv(engine) +ClinicalEventsMV.refresh_mv(engine) +ClinicalEventsMV.drop_mv(engine) with engine.begin() as connection: - drop_materialized_view(connection, event_view) + ClinicalEventsMV.create_mv(connection) ``` -The default is `DROP MATERIALIZED VIEW IF EXISTS` without `CASCADE`. Set `if_exists=False` when absence should be an error, or `cascade=True` when the caller has deliberately accounted for dependent database objects. - -Database failures are raised as `MaterializationError`. Its `failure` attribute records the operation, qualified target, optional index name, reason, and original exception. The original database exception is also retained as the exception cause. Lifecycle helpers never print an error and continue within an aborted transaction. - -The `engine.begin()` context used in these examples rolls the transaction back automatically when an exception leaves the block. If an application manages a `Connection` transaction manually, it must roll back after a database error before attempting another statement on that connection. Catching `MaterializationError` does not make an aborted PostgreSQL transaction usable again. - -## Identity, indexes, and dependencies - -`logical_identity` describes the complete output columns that distinguish rows in the materialized view. Construction fails if an identity or index refers to a column that the selectable does not expose. The identity is descriptive until a unique index or another database constraint enforces it, so applications should test its uniqueness against representative data before deployment. - -`dependencies` records other qualified materialized views that must already exist. It is metadata for an application-owned registry or deployment planner; the single-view lifecycle helpers do not create, refresh, or drop dependencies automatically. This keeps orchestration policy, dependency order, and command-line behaviour in the system that owns the collection of views. - -The DDL elements `CreateMaterializedView`, `CreateMaterializedViewIndex`, `RefreshMaterializedView`, and `DropMaterializedView` can be compiled with the PostgreSQL dialect when a deployment tool needs to inspect or record SQL without executing it. - -## API reference - -::: omop_alchemy.toolkit.core.materialization +`create_mv()` creates the view and its declared indexes as one operation. +Creation fails by default if the target already exists, keeping definition +drift visible. Use `if_not_exists=True` only when an idempotent no-op is the +application's deliberate deployment policy. + +PostgreSQL requires a suitable unique index for +`refresh_mv(concurrently=True)`. `orm-loader` rejects a definition with no +declared unique index before execution, then lets PostgreSQL decide whether the +live database satisfies its concurrent-refresh prerequisites. A database +rejection is translated to `ConcurrentRefreshNotEligibleError`, preserving the +original exception as its cause. + +Lifecycle failures carry operation and target context through +`MaterializationError`. A failed statement can leave a caller-managed +PostgreSQL transaction aborted, so roll that transaction back before issuing +more statements. An `engine.begin()` context rolls back automatically when the +exception leaves the context. + +## Keep orchestration with the application + +OMOP Alchemy can own a reusable OMOP query and document its logical grain. The +application that deploys materialized views owns: + +* the registry of view classes; +* uniqueness checks against representative data; +* dependency and refresh order; +* replacement and rebuild policy; and +* command-line, migration, or scheduler integration. + +For the cohort delivery stack, those application concerns belong in +`omop-constructs`. Use `resolve_mv_refresh_order()` and `refresh_all_mvs()` from +`orm_loader.materialized_views` rather than implementing another dependency +resolver or refresh loop. + +Refer to the +[`orm-loader` materialized-view guide](https://australiancancerdatanetwork.github.io/orm-loader/tables/mat_view/) +for the complete API and backend behaviour. diff --git a/omop_alchemy/toolkit/core/__init__.py b/omop_alchemy/toolkit/core/__init__.py index 1efe65a..0d86c86 100644 --- a/omop_alchemy/toolkit/core/__init__.py +++ b/omop_alchemy/toolkit/core/__init__.py @@ -13,9 +13,6 @@ Canonical cross-table event identities and projection row shapes shared by timelines and episode builders. -``materialization`` - Qualified PostgreSQL materialized-view definitions and lifecycle helpers. - ``timeline`` Project heterogeneous clinical rows into a single ordered sequence of events for one person. @@ -25,5 +22,7 @@ Nothing in core imports from ``episodes``, ``analytics``, or ``integrations``. Domain-specific concept sets, thresholds, and grading -rules belong with their domain under ``analytics``, not here. +rules belong with their domain under ``analytics``, not here. Generic database +lifecycle mechanics, including materialized-view creation and refresh, belong +to ``orm-loader``. """ diff --git a/omop_alchemy/toolkit/core/materialization/__init__.py b/omop_alchemy/toolkit/core/materialization/__init__.py deleted file mode 100644 index bb7767e..0000000 --- a/omop_alchemy/toolkit/core/materialization/__init__.py +++ /dev/null @@ -1,58 +0,0 @@ -"""PostgreSQL materialized-view definitions and lifecycle helpers. - -This package owns the mechanics of targeting and operating on one materialized -view. Applications retain responsibility for dependency ordering, deployment -policy, and registry or command-line orchestration. -""" - -from .contracts import ( - MaterializedSelectable, - MaterializedViewIndex, - MaterializedViewSpec, - MaterializedViewTarget, -) -from .ddl import ( - CreateMaterializedView, - CreateMaterializedViewIndex, - DropMaterializedView, - RefreshMaterializedView, - render_materialized_view_target, -) -from .lifecycle import ( - ConcurrentRefreshNotEligibleError, - MaterializationError, - MaterializationFailure, - MaterializationOperation, - MaterializationOutcome, - UnsupportedMaterializationDialectError, - create_materialized_view, - create_materialized_view_index, - create_materialized_view_indexes, - drop_materialized_view, - materialized_view_has_eligible_unique_index, - refresh_materialized_view, -) - -__all__ = [ - "ConcurrentRefreshNotEligibleError", - "CreateMaterializedView", - "CreateMaterializedViewIndex", - "DropMaterializedView", - "MaterializationError", - "MaterializationFailure", - "MaterializationOperation", - "MaterializationOutcome", - "MaterializedSelectable", - "MaterializedViewIndex", - "MaterializedViewSpec", - "MaterializedViewTarget", - "RefreshMaterializedView", - "UnsupportedMaterializationDialectError", - "create_materialized_view", - "create_materialized_view_index", - "create_materialized_view_indexes", - "drop_materialized_view", - "materialized_view_has_eligible_unique_index", - "refresh_materialized_view", - "render_materialized_view_target", -] diff --git a/omop_alchemy/toolkit/core/materialization/contracts.py b/omop_alchemy/toolkit/core/materialization/contracts.py deleted file mode 100644 index 629f69b..0000000 --- a/omop_alchemy/toolkit/core/materialization/contracts.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Side-effect-free contracts for PostgreSQL materialized views.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Protocol, runtime_checkable - -from sqlalchemy.sql.selectable import SelectBase - - -def _require_identifier(value: str, *, field_name: str) -> None: - if not value.strip(): - raise ValueError(f"{field_name} must not be empty") - - -def _require_distinct(values: tuple[str, ...], *, field_name: str) -> None: - if len(values) != len(set(values)): - raise ValueError(f"{field_name} must not contain duplicates") - - -@dataclass(frozen=True, slots=True) -class MaterializedViewTarget: - """The schema and name that identify one materialized view.""" - - schema: str - name: str - - def __post_init__(self) -> None: - _require_identifier(self.schema, field_name="schema") - _require_identifier(self.name, field_name="name") - - -@dataclass(frozen=True, slots=True) -class MaterializedViewIndex: - """A simple column index required by a materialized view. - - Only column indexes are represented. This keeps concurrent-refresh - eligibility explicit: PostgreSQL requires a unique index without a - predicate or expressions that includes every row in the view. - """ - - name: str - columns: tuple[str, ...] - unique: bool = False - - def __post_init__(self) -> None: - _require_identifier(self.name, field_name="index name") - if not self.columns: - raise ValueError("index columns must not be empty") - for column in self.columns: - _require_identifier(column, field_name="index column") - _require_distinct(self.columns, field_name="index columns") - - -@runtime_checkable -class MaterializedSelectable(Protocol): - """Definition consumed by materialized-view lifecycle helpers.""" - - @property - def target(self) -> MaterializedViewTarget: ... - - @property - def selectable(self) -> SelectBase: ... - - @property - def logical_identity(self) -> tuple[str, ...]: ... - - @property - def dependencies(self) -> tuple[MaterializedViewTarget, ...]: ... - - @property - def indexes(self) -> tuple[MaterializedViewIndex, ...]: ... - - -@dataclass(frozen=True, slots=True) -class MaterializedViewSpec: - """Immutable materialized-view definition with executable row identity. - - Dependencies are metadata for an owning registry or deployment tool. The - lifecycle helpers deliberately operate on one view at a time and do not - infer orchestration from them. - """ - - target: MaterializedViewTarget - selectable: SelectBase - logical_identity: tuple[str, ...] - dependencies: tuple[MaterializedViewTarget, ...] = () - indexes: tuple[MaterializedViewIndex, ...] = () - - def __post_init__(self) -> None: - if not self.logical_identity: - raise ValueError("logical_identity must not be empty") - for column in self.logical_identity: - _require_identifier(column, field_name="logical identity column") - _require_distinct( - self.logical_identity, - field_name="logical_identity", - ) - - output_columns = set(self.selectable.selected_columns.keys()) - unknown_identity = sorted(set(self.logical_identity) - output_columns) - if unknown_identity: - raise ValueError( - f"logical_identity columns are not selected: {unknown_identity}" - ) - - index_names = tuple(index.name for index in self.indexes) - _require_distinct(index_names, field_name="index names") - for index in self.indexes: - unknown_index_columns = sorted(set(index.columns) - output_columns) - if unknown_index_columns: - raise ValueError( - f"index {index.name!r} columns are not selected: " - f"{unknown_index_columns}" - ) - - if self.target in self.dependencies: - raise ValueError("a materialized view cannot depend on itself") - if len(self.dependencies) != len(set(self.dependencies)): - raise ValueError("dependencies must not contain duplicates") - - @property - def concurrent_refresh_indexes(self) -> tuple[MaterializedViewIndex, ...]: - """Declared indexes whose shape can support concurrent refresh.""" - return tuple(index for index in self.indexes if index.unique) diff --git a/omop_alchemy/toolkit/core/materialization/ddl.py b/omop_alchemy/toolkit/core/materialization/ddl.py deleted file mode 100644 index 8d473e7..0000000 --- a/omop_alchemy/toolkit/core/materialization/ddl.py +++ /dev/null @@ -1,167 +0,0 @@ -"""PostgreSQL DDL for schema-qualified materialized views.""" - -from __future__ import annotations - -from typing import Any - -from sqlalchemy.engine import Dialect -from sqlalchemy.ext.compiler import compiles -from sqlalchemy.schema import DDLElement -from sqlalchemy.sql.compiler import DDLCompiler, IdentifierPreparer -from sqlalchemy.sql.selectable import SelectBase - -from .contracts import MaterializedViewIndex, MaterializedViewTarget - - -def _quote_identifier(preparer: IdentifierPreparer, value: str) -> str: - """Quote one user-declared identifier with the active dialect rules.""" - return preparer.quote_identifier(value) - - -def _qualified_target( - preparer: IdentifierPreparer, - target: MaterializedViewTarget, -) -> str: - """Render schema and object name without allowing identifier ambiguity.""" - return ".".join( - ( - _quote_identifier(preparer, target.schema), - _quote_identifier(preparer, target.name), - ) - ) - - -def render_materialized_view_target( - target: MaterializedViewTarget, - dialect: Dialect, -) -> str: - """Render a fully quoted materialized-view identifier for ``dialect``.""" - return _qualified_target(dialect.identifier_preparer, target) - - -class CreateMaterializedView(DDLElement): - """Create one PostgreSQL materialized view from a selectable.""" - - inherit_cache = False - - def __init__( - self, - target: MaterializedViewTarget, - selectable: SelectBase, - *, - with_data: bool = True, - if_not_exists: bool = False, - ) -> None: - self.view_target = target - self.selectable = selectable - self.with_data = with_data - self.if_not_exists = if_not_exists - - -class RefreshMaterializedView(DDLElement): - """Refresh one PostgreSQL materialized view.""" - - inherit_cache = False - - def __init__( - self, - target: MaterializedViewTarget, - *, - concurrently: bool = False, - ) -> None: - self.view_target = target - self.concurrently = concurrently - - -class DropMaterializedView(DDLElement): - """Drop one PostgreSQL materialized view.""" - - inherit_cache = False - - def __init__( - self, - target: MaterializedViewTarget, - *, - if_exists: bool = True, - cascade: bool = False, - ) -> None: - self.view_target = target - self.if_exists = if_exists - self.cascade = cascade - - -class CreateMaterializedViewIndex(DDLElement): - """Create one declared simple index on a materialized view.""" - - inherit_cache = False - - def __init__( - self, - target: MaterializedViewTarget, - index: MaterializedViewIndex, - *, - if_not_exists: bool = False, - ) -> None: - self.view_target = target - self.index = index - self.if_not_exists = if_not_exists - - -@compiles(CreateMaterializedView, "postgresql") -def _compile_create_materialized_view( - element: CreateMaterializedView, - compiler: DDLCompiler, - **_: Any, -) -> str: - target = _qualified_target(compiler.preparer, element.view_target) - # DDL must be executable as one standalone statement, so selectable - # literals are rendered into the CREATE text rather than bound parameters. - selectable = compiler.sql_compiler.process( - element.selectable, - literal_binds=True, - ) - existence = " IF NOT EXISTS" if element.if_not_exists else "" - population = "WITH DATA" if element.with_data else "WITH NO DATA" - return f"CREATE MATERIALIZED VIEW{existence} {target} AS {selectable} {population}" - - -@compiles(RefreshMaterializedView, "postgresql") -def _compile_refresh_materialized_view( - element: RefreshMaterializedView, - compiler: DDLCompiler, - **_: Any, -) -> str: - target = _qualified_target(compiler.preparer, element.view_target) - concurrency = " CONCURRENTLY" if element.concurrently else "" - return f"REFRESH MATERIALIZED VIEW{concurrency} {target}" - - -@compiles(DropMaterializedView, "postgresql") -def _compile_drop_materialized_view( - element: DropMaterializedView, - compiler: DDLCompiler, - **_: Any, -) -> str: - target = _qualified_target(compiler.preparer, element.view_target) - existence = " IF EXISTS" if element.if_exists else "" - cascade = " CASCADE" if element.cascade else "" - return f"DROP MATERIALIZED VIEW{existence} {target}{cascade}" - - -@compiles(CreateMaterializedViewIndex, "postgresql") -def _compile_create_materialized_view_index( - element: CreateMaterializedViewIndex, - compiler: DDLCompiler, - **_: Any, -) -> str: - target = _qualified_target(compiler.preparer, element.view_target) - # MaterializedViewIndex intentionally accepts simple column names only; - # quoting each declared name keeps reserved or mixed-case columns safe and - # avoids treating arbitrary SQL fragments as index expressions. - index_name = _quote_identifier(compiler.preparer, element.index.name) - columns = ", ".join( - _quote_identifier(compiler.preparer, column) for column in element.index.columns - ) - uniqueness = "UNIQUE " if element.index.unique else "" - existence = " IF NOT EXISTS" if element.if_not_exists else "" - return f"CREATE {uniqueness}INDEX{existence} {index_name} ON {target} ({columns})" diff --git a/omop_alchemy/toolkit/core/materialization/lifecycle.py b/omop_alchemy/toolkit/core/materialization/lifecycle.py deleted file mode 100644 index 6215345..0000000 --- a/omop_alchemy/toolkit/core/materialization/lifecycle.py +++ /dev/null @@ -1,319 +0,0 @@ -"""Execution helpers for one PostgreSQL materialized view at a time.""" - -from __future__ import annotations - -from dataclasses import dataclass -from enum import StrEnum -from typing import Any - -import sqlalchemy as sa - -from .contracts import ( - MaterializedSelectable, - MaterializedViewIndex, - MaterializedViewTarget, -) -from .ddl import ( - CreateMaterializedView, - CreateMaterializedViewIndex, - DropMaterializedView, - RefreshMaterializedView, -) - - -class MaterializationOperation(StrEnum): - """Lifecycle operation recorded in outcomes and failures.""" - - create = "create" - create_index = "create_index" - inspect_indexes = "inspect_indexes" - refresh = "refresh" - drop = "drop" - - -@dataclass(frozen=True, slots=True) -class MaterializationOutcome: - """A successfully executed materialized-view operation.""" - - operation: MaterializationOperation - target: MaterializedViewTarget - index_name: str | None = None - - -@dataclass(frozen=True, slots=True) -class MaterializationFailure: - """Structured context for a failed materialized-view operation.""" - - operation: MaterializationOperation - target: MaterializedViewTarget - reason: str - index_name: str | None = None - cause: BaseException | None = None - - -class MaterializationError(RuntimeError): - """Base exception carrying structured materialization failure context.""" - - def __init__(self, failure: MaterializationFailure) -> None: - self.failure = failure - super().__init__( - f"Could not {failure.operation} materialized view " - f"{failure.target.schema}.{failure.target.name}: {failure.reason}" - ) - - -class UnsupportedMaterializationDialectError(MaterializationError): - """Raised before execution when a connection is not PostgreSQL.""" - - -class ConcurrentRefreshNotEligibleError(MaterializationError): - """Raised before refresh when no eligible unique index is available.""" - - -_ELIGIBLE_UNIQUE_INDEX_SQL = sa.text( - # Concurrent refresh requires a valid, ready, unconditional unique index. - # The catalog query deliberately excludes partial and expression indexes; - # the declaration model supports the simple-column contract we can verify. - """ - SELECT EXISTS ( - SELECT 1 - FROM pg_catalog.pg_class AS materialized_view - JOIN pg_catalog.pg_namespace AS namespace - ON namespace.oid = materialized_view.relnamespace - JOIN pg_catalog.pg_index AS index_definition - ON index_definition.indrelid = materialized_view.oid - JOIN pg_catalog.pg_class AS index_relation - ON index_relation.oid = index_definition.indexrelid - WHERE namespace.nspname = :schema - AND materialized_view.relname = :name - AND index_relation.relname IN :index_names - AND materialized_view.relkind = 'm' - AND index_definition.indisunique - AND index_definition.indisvalid - AND index_definition.indisready - AND index_definition.indpred IS NULL - AND index_definition.indexprs IS NULL - ) - """ -).bindparams(sa.bindparam("index_names", expanding=True)) - - -def _require_postgresql( - connection: sa.Connection, - *, - operation: MaterializationOperation, - target: MaterializedViewTarget, -) -> None: - # These lifecycle statements are PostgreSQL-specific. Failing before - # execution keeps SQLite test connections from appearing to support a - # weaker, semantically different implementation. - if connection.dialect.name == "postgresql": - return - failure = MaterializationFailure( - operation=operation, - target=target, - reason=( - "materialized-view lifecycle operations require PostgreSQL; " - f"received {connection.dialect.name!r}" - ), - ) - raise UnsupportedMaterializationDialectError(failure) - - -def _execute( - connection: sa.Connection, - statement: Any, - *, - operation: MaterializationOperation, - target: MaterializedViewTarget, - index_name: str | None = None, -) -> MaterializationOutcome: - # Centralizing execution preserves the original DB exception in - # MaterializationFailure.cause while exposing stable operation/target - # context to callers. - _require_postgresql(connection, operation=operation, target=target) - try: - connection.execute(statement) - except Exception as error: - failure = MaterializationFailure( - operation=operation, - target=target, - index_name=index_name, - reason=str(error), - cause=error, - ) - raise MaterializationError(failure) from error - return MaterializationOutcome( - operation=operation, - target=target, - index_name=index_name, - ) - - -def create_materialized_view( - connection: sa.Connection, - materialized: MaterializedSelectable, - *, - with_data: bool = True, - if_not_exists: bool = False, -) -> MaterializationOutcome: - """Create a materialized view at its declared qualified target.""" - return _execute( - connection, - CreateMaterializedView( - materialized.target, - materialized.selectable, - with_data=with_data, - if_not_exists=if_not_exists, - ), - operation=MaterializationOperation.create, - target=materialized.target, - ) - - -def create_materialized_view_index( - connection: sa.Connection, - target: MaterializedViewTarget, - index: MaterializedViewIndex, - *, - if_not_exists: bool = False, -) -> MaterializationOutcome: - """Create one declared index against the same qualified view target.""" - return _execute( - connection, - CreateMaterializedViewIndex( - target, - index, - if_not_exists=if_not_exists, - ), - operation=MaterializationOperation.create_index, - target=target, - index_name=index.name, - ) - - -def create_materialized_view_indexes( - connection: sa.Connection, - materialized: MaterializedSelectable, - *, - if_not_exists: bool = False, -) -> tuple[MaterializationOutcome, ...]: - """Create every index declared by a materialized selectable.""" - return tuple( - create_materialized_view_index( - connection, - materialized.target, - index, - if_not_exists=if_not_exists, - ) - for index in materialized.indexes - ) - - -def materialized_view_has_eligible_unique_index( - connection: sa.Connection, - materialized: MaterializedSelectable, -) -> bool: - """Confirm that a declared unique index is eligible in PostgreSQL.""" - operation = MaterializationOperation.inspect_indexes - target = materialized.target - _require_postgresql(connection, operation=operation, target=target) - index_names = tuple(index.name for index in materialized.indexes if index.unique) - if not index_names: - # Avoid a catalog query when the declaration already proves that - # concurrent refresh cannot be eligible. - return False - try: - return bool( - connection.execute( - _ELIGIBLE_UNIQUE_INDEX_SQL, - { - "schema": target.schema, - "name": target.name, - "index_names": index_names, - }, - ).scalar_one() - ) - except Exception as error: - failure = MaterializationFailure( - operation=operation, - target=target, - reason=str(error), - cause=error, - ) - raise MaterializationError(failure) from error - - -def _require_concurrent_refresh_eligibility( - connection: sa.Connection, - materialized: MaterializedSelectable, -) -> None: - if not any(index.unique for index in materialized.indexes): - # This fast path gives a declaration-level error and avoids asking the - # database to inspect a target that cannot satisfy the contract. - raise ConcurrentRefreshNotEligibleError( - MaterializationFailure( - operation=MaterializationOperation.refresh, - target=materialized.target, - reason="no simple unique index is declared", - ) - ) - if not materialized_view_has_eligible_unique_index( - connection, - materialized, - ): - raise ConcurrentRefreshNotEligibleError( - MaterializationFailure( - operation=MaterializationOperation.refresh, - target=materialized.target, - reason="PostgreSQL has no eligible unique index on the target", - ) - ) - - -def refresh_materialized_view( - connection: sa.Connection, - materialized: MaterializedSelectable, - *, - concurrently: bool = False, -) -> MaterializationOutcome: - """Refresh a materialized view after any concurrent-refresh preflight.""" - if concurrently: - # Concurrent refresh performs catalog inspection before execution, so it - # needs the dialect guard before preflight. Ordinary refresh reaches the - # same guard once through _execute(). - _require_postgresql( - connection, - operation=MaterializationOperation.refresh, - target=materialized.target, - ) - _require_concurrent_refresh_eligibility(connection, materialized) - return _execute( - connection, - RefreshMaterializedView( - materialized.target, - concurrently=concurrently, - ), - operation=MaterializationOperation.refresh, - target=materialized.target, - ) - - -def drop_materialized_view( - connection: sa.Connection, - materialized: MaterializedSelectable, - *, - if_exists: bool = True, - cascade: bool = False, -) -> MaterializationOutcome: - """Drop one materialized view, preserving the original database failure.""" - return _execute( - connection, - DropMaterializedView( - materialized.target, - if_exists=if_exists, - cascade=cascade, - ), - operation=MaterializationOperation.drop, - target=materialized.target, - ) diff --git a/tests/test_materialization_ownership.py b/tests/test_materialization_ownership.py new file mode 100644 index 0000000..ba1ba66 --- /dev/null +++ b/tests/test_materialization_ownership.py @@ -0,0 +1,44 @@ +"""Protect the package boundary for generic database lifecycle mechanics.""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import omop_alchemy + + +FORBIDDEN_LIFECYCLE_DEFINITIONS = frozenset( + { + "CreateMaterializedView", + "CreateMaterializedViewIndex", + "DropMaterializedView", + "MaterializationError", + "MaterializedViewMixin", + "MaterializedViewSpec", + "RefreshMaterializedView", + "create_materialized_view", + "drop_materialized_view", + "refresh_all_mvs", + "refresh_materialized_view", + "resolve_mv_refresh_order", + } +) + + +def test_database_materialization_lifecycle_is_not_implemented_in_alchemy(): + """Keep generic lifecycle and DDL in orm-loader, its designated owner.""" + package_root = Path(omop_alchemy.__file__).parent + definitions: dict[str, Path] = {} + + for module_path in package_root.rglob("*.py"): + module = ast.parse(module_path.read_text(), filename=str(module_path)) + for node in ast.walk(module): + if isinstance(node, ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef): + if node.name in FORBIDDEN_LIFECYCLE_DEFINITIONS: + definitions[node.name] = module_path.relative_to(package_root) + + assert definitions == {}, ( + "materialized-view database lifecycle belongs in " + f"orm_loader.materialized_views, not omop_alchemy: {definitions}" + ) diff --git a/tests/test_materialized_view_lifecycle.py b/tests/test_materialized_view_lifecycle.py deleted file mode 100644 index e72b310..0000000 --- a/tests/test_materialized_view_lifecycle.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Materialized-view contracts, DDL, and lifecycle preflight tests.""" - -from __future__ import annotations - -from typing import Any, cast - -import pytest -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql, sqlite -from sqlalchemy.sql.elements import TextClause - -from omop_alchemy.toolkit.core.materialization import ( - ConcurrentRefreshNotEligibleError, - CreateMaterializedView, - CreateMaterializedViewIndex, - DropMaterializedView, - MaterializationError, - MaterializationOperation, - MaterializedSelectable, - MaterializedViewIndex, - MaterializedViewSpec, - MaterializedViewTarget, - RefreshMaterializedView, - UnsupportedMaterializationDialectError, - drop_materialized_view, - refresh_materialized_view, - render_materialized_view_target, -) - - -def _spec(*, unique_index: bool = True) -> MaterializedViewSpec: - indexes = ( - MaterializedViewIndex( - name="person events identity", - columns=("person_id", "event_id"), - unique=unique_index, - ), - ) - return MaterializedViewSpec( - target=MaterializedViewTarget( - schema="analysis space", - name='select"events', - ), - selectable=sa.select( - sa.literal(1).label("person_id"), - sa.literal(7).label("event_id"), - ), - logical_identity=("person_id", "event_id"), - indexes=indexes, - ) - - -def test_materialized_view_spec_implements_public_protocol(): - assert isinstance(_spec(), MaterializedSelectable) - - -def test_materialized_view_spec_validates_identity_and_index_columns(): - target = MaterializedViewTarget(schema="reporting", name="events") - selectable = sa.select(sa.literal(1).label("event_id")) - - with pytest.raises(ValueError, match="logical_identity columns"): - MaterializedViewSpec( - target=target, - selectable=selectable, - logical_identity=("person_id",), - ) - with pytest.raises(ValueError, match="index .* columns"): - MaterializedViewSpec( - target=target, - selectable=selectable, - logical_identity=("event_id",), - indexes=( - MaterializedViewIndex( - name="bad_index", - columns=("missing_column",), - ), - ), - ) - - -def test_qualified_target_is_always_schema_qualified_and_quoted(): - rendered = render_materialized_view_target( - _spec().target, - postgresql.dialect(), - ) - - assert rendered == '"analysis space"."select""events"' - - -def test_all_ddl_uses_the_same_qualified_target(): - spec = _spec() - dialect = postgresql.dialect() - qualified = render_materialized_view_target(spec.target, dialect) - statements = ( - CreateMaterializedView(spec.target, spec.selectable), - CreateMaterializedViewIndex(spec.target, spec.indexes[0]), - RefreshMaterializedView(spec.target, concurrently=True), - DropMaterializedView(spec.target, cascade=True), - ) - - compiled = tuple( - str(statement.compile(dialect=dialect)) for statement in statements - ) - - assert all(qualified in sql for sql in compiled) - assert compiled[0].startswith("CREATE MATERIALIZED VIEW ") - assert "IF NOT EXISTS" not in compiled[0] - assert "CREATE UNIQUE INDEX " in compiled[1] - assert "IF NOT EXISTS" not in compiled[1] - assert '"person_id", "event_id"' in compiled[1] - assert compiled[2].startswith("REFRESH MATERIALIZED VIEW CONCURRENTLY") - assert compiled[3].endswith(" CASCADE") - - -class _ScalarResult: - def __init__(self, value: bool) -> None: - self.value = value - - def scalar_one(self) -> bool: - return self.value - - -class _RecordingConnection: - def __init__( - self, - *, - dialect: sa.engine.Dialect | None = None, - eligible_index: bool = False, - error: Exception | None = None, - ) -> None: - self.dialect = dialect or postgresql.dialect() - self.eligible_index = eligible_index - self.error = error - self.statements: list[Any] = [] - - def execute(self, statement: Any, *_: Any, **__: Any) -> _ScalarResult: - self.statements.append(statement) - if self.error is not None: - raise self.error - return _ScalarResult(self.eligible_index) - - -def test_concurrent_refresh_without_declared_unique_index_executes_nothing(): - connection = _RecordingConnection() - - with pytest.raises(ConcurrentRefreshNotEligibleError) as error: - refresh_materialized_view( - cast(sa.Connection, connection), - _spec(unique_index=False), - concurrently=True, - ) - - assert connection.statements == [] - assert error.value.failure.operation is MaterializationOperation.refresh - assert "no simple unique index" in error.value.failure.reason - - -def test_concurrent_refresh_requires_the_declared_index_to_exist_in_postgres(): - connection = _RecordingConnection(eligible_index=False) - - with pytest.raises(ConcurrentRefreshNotEligibleError) as error: - refresh_materialized_view( - cast(sa.Connection, connection), - _spec(), - concurrently=True, - ) - - assert len(connection.statements) == 1 - assert isinstance(connection.statements[0], TextClause) - assert "PostgreSQL has no eligible unique index" in error.value.failure.reason - - -def test_drop_failure_retains_the_original_exception(): - original = RuntimeError("database transaction is aborted") - connection = _RecordingConnection(error=original) - - with pytest.raises(MaterializationError) as error: - drop_materialized_view( - cast(sa.Connection, connection), - _spec(), - ) - - assert error.value.failure.operation is MaterializationOperation.drop - assert error.value.failure.cause is original - assert error.value.__cause__ is original - assert len(connection.statements) == 1 - - -def test_lifecycle_rejects_non_postgresql_connections_before_execution(): - connection = _RecordingConnection(dialect=sqlite.dialect()) - - with pytest.raises(UnsupportedMaterializationDialectError): - drop_materialized_view( - cast(sa.Connection, connection), - _spec(), - ) - - assert connection.statements == [] diff --git a/tests/test_materialized_view_lifecycle_postgres.py b/tests/test_materialized_view_lifecycle_postgres.py deleted file mode 100644 index e736a32..0000000 --- a/tests/test_materialized_view_lifecycle_postgres.py +++ /dev/null @@ -1,104 +0,0 @@ -"""PostgreSQL integration coverage for qualified materialized-view lifecycle.""" - -from __future__ import annotations - -import pytest -import sqlalchemy as sa - -from omop_alchemy.toolkit.core.materialization import ( - MaterializedViewIndex, - MaterializedViewSpec, - MaterializedViewTarget, - create_materialized_view, - create_materialized_view_indexes, - drop_materialized_view, - materialized_view_has_eligible_unique_index, - refresh_materialized_view, -) - - -LEFT_SCHEMA = "mv_lifecycle_left" -RIGHT_SCHEMA = "mv_lifecycle_right" -VIEW_NAME = "shared name" - - -def _spec(schema: str, value: str) -> MaterializedViewSpec: - return MaterializedViewSpec( - target=MaterializedViewTarget(schema=schema, name=VIEW_NAME), - selectable=sa.select( - sa.literal(1).label("row_id"), - sa.literal(value).label("payload"), - ), - logical_identity=("row_id",), - indexes=( - MaterializedViewIndex( - name="shared_name_row_id_uq", - columns=("row_id",), - unique=True, - ), - ), - ) - - -def _view_exists(connection: sa.Connection, target: MaterializedViewTarget) -> bool: - return bool( - connection.execute( - sa.text( - """ - SELECT EXISTS ( - SELECT 1 - FROM pg_catalog.pg_matviews - WHERE schemaname = :schema - AND matviewname = :name - ) - """ - ), - {"schema": target.schema, "name": target.name}, - ).scalar_one() - ) - - -@pytest.mark.requires_database("test_cdm_db") -def test_lifecycle_is_scoped_to_the_requested_schema(pg_engine): - left = _spec(LEFT_SCHEMA, "left") - right = _spec(RIGHT_SCHEMA, "right") - - try: - with pg_engine.begin() as connection: - connection.execute( - sa.text(f'DROP SCHEMA IF EXISTS "{LEFT_SCHEMA}" CASCADE') - ) - connection.execute( - sa.text(f'DROP SCHEMA IF EXISTS "{RIGHT_SCHEMA}" CASCADE') - ) - connection.execute(sa.text(f'CREATE SCHEMA "{LEFT_SCHEMA}"')) - connection.execute(sa.text(f'CREATE SCHEMA "{RIGHT_SCHEMA}"')) - - create_materialized_view(connection, left) - create_materialized_view(connection, right) - create_materialized_view_indexes(connection, left) - create_materialized_view_indexes(connection, right) - - with pg_engine.begin() as connection: - assert materialized_view_has_eligible_unique_index( - connection, - left, - ) - assert materialized_view_has_eligible_unique_index( - connection, - right, - ) - - refresh_materialized_view(connection, left, concurrently=True) - drop_materialized_view(connection, left) - - assert not _view_exists(connection, left.target) - assert _view_exists(connection, right.target) - finally: - with pg_engine.begin() as connection: - connection.execute( - sa.text(f'DROP SCHEMA IF EXISTS "{LEFT_SCHEMA}" CASCADE') - ) - connection.execute( - sa.text(f'DROP SCHEMA IF EXISTS "{RIGHT_SCHEMA}" CASCADE') - ) From bb87b067b3983e3e6f237c0ad3b17cfc7ae356dd Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Wed, 2 Sep 2026 12:50:44 +1000 Subject: [PATCH 11/30] removed funky tests --- .github/CONTRIBUTING.md | 2 +- docs/toolkit/materialized-views.md | 182 +++++++++++++----------- tests/test_materialization_ownership.py | 44 ------ 3 files changed, 103 insertions(+), 125 deletions(-) delete mode 100644 tests/test_materialization_ownership.py diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 63ffdc4..4809ffc 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -12,7 +12,7 @@ uv run ruff check . Before adding general database or ORM infrastructure, check whether it belongs in a lower-level dependency. `orm-loader` owns domain-independent loading, serialization, and materialized-view lifecycle mechanics. OMOP Alchemy owns OMOP table models, clinical semantics, and the OMOP-specific selectables and row grains that consumers can pass to that infrastructure. -Do not add materialized-view DDL, lifecycle helpers, or orchestration to `omop_alchemy`. A consuming application owns its view registry, dependency and rebuild policy, and command-line or deployment workflow. The ownership test in `tests/test_materialization_ownership.py` protects this boundary. +Do not add materialized-view DDL, lifecycle helpers, or orchestration to `omop_alchemy`. A consuming application owns its view registry, dependency and rebuild policy, and command-line or deployment workflow. ## Opening a pull request diff --git a/docs/toolkit/materialized-views.md b/docs/toolkit/materialized-views.md index c49732e..6eb6a70 100644 --- a/docs/toolkit/materialized-views.md +++ b/docs/toolkit/materialized-views.md @@ -1,109 +1,131 @@ # Materialized views -Materialized-view definitions and database lifecycle operations are provided by -[`orm-loader`](https://australiancancerdatanetwork.github.io/orm-loader/tables/mat_view/). -OMOP Alchemy supplies OMOP models and query-building primitives that applications -can use in those definitions; it does not provide a second DDL or refresh API. +A materialized view is a persisted read model: an expensive or carefully defined query is computed once and then read like a table. This is useful when the same analytical shape is consumed repeatedly, but it also introduces a deployment lifecycle that ordinary query construction does not have. -The supported deployment contract is PostgreSQL with unqualified materialized -views resolving to the `public` schema. Omit the `schema` argument when calling -the lifecycle methods. Qualified non-`public` schemas are not currently part of -the supported contract. +OMOP Alchemy owns the OMOP models and the query-building vocabulary. [`orm-loader`](https://australiancancerdatanetwork.github.io/orm-loader/tables/mat_view/) owns the generic materialized-view definition and database lifecycle. Keeping that boundary means this package can describe an OMOP read model without growing a second implementation of DDL, refresh, index creation, or dependency ordering. -## Define a view over an OMOP query +## The deployment contract -Use the public `orm_loader.materialized_views` module. A definition states the -view name, its SQLAlchemy selectable, the complete logical row identity, any -dependencies, and any indexes that should be created with the view: +The supported deployment contract is PostgreSQL with unqualified materialized-view names resolving to the `public` schema through the connection's `search_path`. Leave `schema` at its default when calling the lifecycle methods. Qualified non-`public` schemas and schema translation are not part of this package's materialized-view contract. + +This page explains how an OMOP Alchemy query becomes a managed read model. The linked [`orm-loader` materialized-view guide](https://australiancancerdatanetwork.github.io/orm-loader/tables/mat_view/) is the authoritative reference for the complete API, supported options, backend behavior, and generated reference documentation. + +The important design decision is the shape of the result. Give the view a stable name, build its contents with the same SQLAlchemy expressions used elsewhere in the toolkit, and make the row grain explicit in the query. The example below creates one row per person and measurement concept, which makes the unique index meaningful as well as useful for concurrent refresh. ```python import sqlalchemy as sa -from orm_loader.materialized_views import ( - MaterializedViewIndex, - MaterializedViewMixin, -) +from omop_alchemy.cdm.model import Measurement +from orm_loader.mappers.materialised_view_contracts import MaterializedViewIndex +from orm_loader.mappers.materialised_view_mixin import MaterializedViewMixin -event_query = sa.select( - events.c.person_id, - events.c.event_id, - events.c.event_date, +measurement_summary = ( + sa.select( + Measurement.person_id, + Measurement.measurement_concept_id.label("concept_id"), + sa.func.max(Measurement.measurement_date).label("last_measurement_date"), + sa.func.count().label("measurement_count"), + ) + .group_by(Measurement.person_id, Measurement.measurement_concept_id) ) -class ClinicalEventsMV(MaterializedViewMixin): - __mv_name__ = "clinical_events" - __mv_select__ = event_query - __mv_logical_identity__ = ("person_id", "event_id") - __mv_dependencies__ = ("measurement", "observation") +class MeasurementSummaryMV(MaterializedViewMixin): + __mv_name__ = "measurement_summary" + __mv_select__ = measurement_summary __mv_indexes__ = ( MaterializedViewIndex( - name="clinical_events_identity_uq", - columns=("person_id", "event_id"), + name="measurement_summary_identity_uq", + columns=("person_id", "concept_id"), unique=True, ), ) ``` -`__mv_logical_identity__` documents the complete grain and is validated against -the selectable, but it is not itself a database constraint. Test that identity -against representative data and declare a matching unique index when the view -must support concurrent refresh. +`orm-loader` does not infer or validate the logical grain of a selectable. Treat the query's grouping and joins as the source of truth, test that grain against representative data, and declare a matching unique index when the view needs concurrent refresh. The index is the database-level guarantee; a comment or a convention in the Python class is not. + +`__mv_dependencies__` is for dependencies between managed materialized views. Its values are matched against the names of the view classes passed to the refresh-order resolver, so a base OMOP table such as `measurement` does not create a lifecycle dependency by itself. Add a dependency when one materialized view reads another: -`__mv_dependencies__` records tables or materialized views that the definition -depends on. The registry owner decides which dependencies are managed views and -uses that metadata to determine refresh order. +```python +class PersonMeasurementSummaryMV(MaterializedViewMixin): + __mv_name__ = "person_measurement_summary" + __mv_select__ = sa.select(measurement_summary.subquery()) + __mv_dependencies__ = {"measurement_summary"} +``` -## Create, refresh, and drop +Use the class as a schema-level definition, not as a promise that OMOP Alchemy will map the resulting relation or schedule its deployment. If application code needs ORM-style reads, map the relation separately according to the application's identity and session requirements. -The class methods accept either a SQLAlchemy `Engine` or `Connection`. Passing -an engine lets `orm-loader` manage the transaction. Passing a connection keeps -the operation inside the caller's transaction: +## Treat creation as deployment -```python -ClinicalEventsMV.create_mv(engine) -ClinicalEventsMV.refresh_mv(engine) -ClinicalEventsMV.drop_mv(engine) +Creation is usually part of an application migration, bootstrap command, or release step. Keep it explicit and make the existing-target policy deliberate: +```python with engine.begin() as connection: - ClinicalEventsMV.create_mv(connection) + MeasurementSummaryMV.create_mv(connection) +``` + +The default is idempotent: `create_mv()` emits `IF NOT EXISTS`, so an already-present view is left in place. That is convenient for repeatable bootstrap, but it does not detect definition drift. Pass `if_not_exists=False` when an existing target should fail the deployment and force an explicit replacement decision. + +The view's declared indexes are created by `create_mv()` by default. Pass `create_indexes=False` only when index creation is intentionally managed elsewhere. With an `Engine`, `orm-loader` manages the transaction for each backend operation; with a `Connection`, the caller controls the transaction and can keep view and index creation inside a larger migration transaction. If index creation fails after the view has been created through an engine, treat the deployment as incomplete and reconcile it before retrying. + +`with_data=False` is useful when the relation must exist before its source data is ready, but the resulting view cannot be queried until it has been refreshed: + +```python +MeasurementSummaryMV.create_mv(engine, with_data=False, if_not_exists=False) +# Load or migrate the source data, then make the read model available. +MeasurementSummaryMV.refresh_mv(engine) ``` -`create_mv()` creates the view and its declared indexes as one operation. -Creation fails by default if the target already exists, keeping definition -drift visible. Use `if_not_exists=True` only when an idempotent no-op is the -application's deliberate deployment policy. - -PostgreSQL requires a suitable unique index for -`refresh_mv(concurrently=True)`. `orm-loader` rejects a definition with no -declared unique index before execution, then lets PostgreSQL decide whether the -live database satisfies its concurrent-refresh prerequisites. A database -rejection is translated to `ConcurrentRefreshNotEligibleError`, preserving the -original exception as its cause. - -Lifecycle failures carry operation and target context through -`MaterializationError`. A failed statement can leave a caller-managed -PostgreSQL transaction aborted, so roll that transaction back before issuing -more statements. An `engine.begin()` context rolls back automatically when the -exception leaves the context. - -## Keep orchestration with the application - -OMOP Alchemy can own a reusable OMOP query and document its logical grain. The -application that deploys materialized views owns: - -* the registry of view classes; -* uniqueness checks against representative data; -* dependency and refresh order; -* replacement and rebuild policy; and -* command-line, migration, or scheduler integration. - -For the cohort delivery stack, those application concerns belong in -`omop-constructs`. Use `resolve_mv_refresh_order()` and `refresh_all_mvs()` from -`orm_loader.materialized_views` rather than implementing another dependency -resolver or refresh loop. - -Refer to the -[`orm-loader` materialized-view guide](https://australiancancerdatanetwork.github.io/orm-loader/tables/mat_view/) -for the complete API and backend behaviour. +For replacement or teardown, use the lifecycle options documented by [`orm-loader`](https://australiancancerdatanetwork.github.io/orm-loader/tables/mat_view/) rather than issuing raw `CREATE`, `DROP`, or `REFRESH` statements in this package. In particular, `cascade=True` is an explicit decision to remove dependent database objects as part of a drop. + +## Choose a refresh policy + +An ordinary refresh is the simple operational default: + +```python +MeasurementSummaryMV.refresh_mv(engine) +``` + +Concurrent refresh is a PostgreSQL feature for keeping the existing materialized view available while its contents are rebuilt. It requires an eligible unique index over the view, with no predicate or expression-based shortcut. `orm-loader` first fails closed when the class declares no unique index and then translates PostgreSQL's rejection when the live database still does not satisfy the requirement. Both paths raise `ConcurrentRefreshNotEligibleError`, with the database exception preserved as the cause when PostgreSQL produced one. + +```python +from orm_loader.backends import ConcurrentRefreshNotEligibleError + + +try: + MeasurementSummaryMV.refresh_mv(engine, concurrently=True) +except ConcurrentRefreshNotEligibleError as error: + logger.warning("Concurrent refresh unavailable: %s", error) + MeasurementSummaryMV.refresh_mv(engine) +``` + +Use the fallback only if serving a briefly stale or synchronously refreshed read model is acceptable. A failed statement can leave a caller-managed PostgreSQL transaction aborted, so roll that transaction back before issuing more statements. An `engine.begin()` context rolls back automatically when an exception leaves the context. + +## Orchestrate a family of views + +The application owns the registry because it knows which views belong to a deployment and which policy should govern them. `orm-loader` supplies the small amount of generic machinery needed to order managed views and invoke their lifecycle methods. + +```python +from orm_loader.mappers.materialised_view_mixin import ( + refresh_all_mvs, + resolve_mv_refresh_order, +) + + +ALL_MVS = [ + MeasurementSummaryMV, + PersonMeasurementSummaryMV, +] + +# Ordinary refreshes in dependency order. +refresh_all_mvs(engine, ALL_MVS) + +# For concurrent refreshes, retain the same order while choosing the policy. +for view_cls in resolve_mv_refresh_order(ALL_MVS): + view_cls.refresh_mv(engine, concurrently=True) +``` + +This registry is also the right place for application-specific choices such as whether to rebuild a view after a definition change, whether a failed refresh should block a release, and how to report lifecycle failures to operators. Do not duplicate the generic dependency resolver or database lifecycle in an OMOP Alchemy toolkit module; the ownership boundary is protected by [`tests/test_materialization_ownership.py`](https://github.com/AustralianCancerDataNetwork/OMOP_Alchemy/blob/main/tests/test_materialization_ownership.py). + +For the cohort delivery stack, these deployment concerns belong in `omop-constructs` or the application that runs its migrations and schedulers. OMOP Alchemy's role is to provide reusable OMOP query components and a clear read-model contract. diff --git a/tests/test_materialization_ownership.py b/tests/test_materialization_ownership.py deleted file mode 100644 index ba1ba66..0000000 --- a/tests/test_materialization_ownership.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Protect the package boundary for generic database lifecycle mechanics.""" - -from __future__ import annotations - -import ast -from pathlib import Path - -import omop_alchemy - - -FORBIDDEN_LIFECYCLE_DEFINITIONS = frozenset( - { - "CreateMaterializedView", - "CreateMaterializedViewIndex", - "DropMaterializedView", - "MaterializationError", - "MaterializedViewMixin", - "MaterializedViewSpec", - "RefreshMaterializedView", - "create_materialized_view", - "drop_materialized_view", - "refresh_all_mvs", - "refresh_materialized_view", - "resolve_mv_refresh_order", - } -) - - -def test_database_materialization_lifecycle_is_not_implemented_in_alchemy(): - """Keep generic lifecycle and DDL in orm-loader, its designated owner.""" - package_root = Path(omop_alchemy.__file__).parent - definitions: dict[str, Path] = {} - - for module_path in package_root.rglob("*.py"): - module = ast.parse(module_path.read_text(), filename=str(module_path)) - for node in ast.walk(module): - if isinstance(node, ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef): - if node.name in FORBIDDEN_LIFECYCLE_DEFINITIONS: - definitions[node.name] = module_path.relative_to(package_root) - - assert definitions == {}, ( - "materialized-view database lifecycle belongs in " - f"orm_loader.materialized_views, not omop_alchemy: {definitions}" - ) From b61f50e2ec7293d9a3ebde7b7b078229142e1ef6 Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Wed, 2 Sep 2026 16:31:38 +1000 Subject: [PATCH 12/30] pulling in mv-compliant orm-loader version --- pyproject.toml | 2 +- uv.lock | 28 ++++++++-------------------- 2 files changed, 9 insertions(+), 21 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a4e93ec..aa97601 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ "oa-configurator>=1.0.0,<2.0.0", "typer>=0.12", "rich>=13.0", - "orm-loader>=1.0.0,<2.0.0", + "orm-loader>=1.2.0,<2.0.0", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index ca0977a..c8a2436 100644 --- a/uv.lock +++ b/uv.lock @@ -3,10 +3,10 @@ revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.15' and sys_platform == 'win32'", - "python_full_version == '3.14.*' and sys_platform == 'win32'", "python_full_version >= '3.15' and sys_platform == 'emscripten'", - "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version >= '3.15' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'win32'", + "python_full_version == '3.14.*' and sys_platform == 'emscripten'", "python_full_version == '3.14.*' and sys_platform != 'emscripten' and sys_platform != 'win32'", "python_full_version < '3.14' and sys_platform == 'win32'", "python_full_version < '3.14' and sys_platform == 'emscripten'", @@ -182,7 +182,7 @@ name = "cffi" version = "2.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, + { name = "pycparser" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } wheels = [ @@ -620,9 +620,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" }, { url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" }, { url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" }, - { url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" }, { url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" }, - { url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" }, { url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" }, { url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" }, { url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" }, @@ -630,9 +628,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" }, { url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" }, { url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" }, - { url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" }, { url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" }, - { url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" }, { url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" }, { url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" }, { url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" }, @@ -640,9 +636,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" }, { url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" }, { url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" }, - { url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" }, { url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" }, { url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" }, { url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" }, { url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" }, @@ -650,18 +644,14 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" }, { url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" }, { url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" }, - { url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" }, { url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" }, { url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" }, { url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" }, { url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" }, { url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" }, { url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" }, { url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" }, - { url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" }, { url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" }, - { url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" }, { url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" }, { url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" }, { url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" }, @@ -669,9 +659,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" }, { url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" }, { url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" }, - { url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" }, { url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" }, - { url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" }, { url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" }, { url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" }, { url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" }, @@ -1484,7 +1472,7 @@ requires-dist = [ { name = "oa-configurator", specifier = ">=1.0.0,<2.0.0" }, { name = "oa-configurator", extras = ["dev", "postgres"], marker = "extra == 'dev'", specifier = ">=1.0.0,<2.0.0" }, { name = "omop-semantics", marker = "extra == 'semantics'", specifier = ">=0.6.0" }, - { name = "orm-loader", specifier = ">=1.0.0,<2.0.0" }, + { name = "orm-loader", specifier = ">=1.2.0,<2.0.0" }, { name = "pandas", specifier = ">=2.0" }, { name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, @@ -1531,7 +1519,7 @@ wheels = [ [[package]] name = "orm-loader" -version = "1.0.0" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "chardet" }, @@ -1540,9 +1528,9 @@ dependencies = [ { name = "pyarrow" }, { name = "sqlalchemy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/f5/d71344e3f65dc29021e3e0b59d7ebfbd2070b11f8c9224cdb891278dc649/orm_loader-1.0.0.tar.gz", hash = "sha256:171969195afec304398ef8e3cceab52dba4b082347d0dc84feefff10b81bed52", size = 136810, upload-time = "2026-08-12T04:49:18.284Z" } +sdist = { url = "https://files.pythonhosted.org/packages/17/42/093713d63ac7af5b29d94fd33f1888f4d192b38c867b16349caa53c1c0af/orm_loader-1.2.0.tar.gz", hash = "sha256:a000527daab1aeaaeb460e135984330805d2243f5d603c8c649b7d0e2cf75ae4", size = 158420, upload-time = "2026-09-02T06:22:56.506Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/f8/952d95e9e6573787cd30f276604b1c2aad166e1922b9e486fff516a48919/orm_loader-1.0.0-py3-none-any.whl", hash = "sha256:3212894544fe1ae5e0f9e88ebf873addff9854726ec70a84f9140e39e591d68a", size = 56790, upload-time = "2026-08-12T04:49:16.697Z" }, + { url = "https://files.pythonhosted.org/packages/46/65/96fe27564dbb9b74fc1991a5ef3d057bf1cfa227f65bd742e83b2328cef0/orm_loader-1.2.0-py3-none-any.whl", hash = "sha256:068ab02e4f2767047aae02908bf50ef63f3951905da5b841a3ee6b931926ae8b", size = 69661, upload-time = "2026-09-02T06:22:55.183Z" }, ] [[package]] @@ -1647,7 +1635,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "ptyprocess" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ From 6c3eb95620f503bb374732ab1e4ccd931752f619 Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Wed, 2 Sep 2026 16:32:18 +1000 Subject: [PATCH 13/30] pulling in mv-compliant orm-loader version --- docs/toolkit/materialized-views.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/toolkit/materialized-views.md b/docs/toolkit/materialized-views.md index 6eb6a70..6796f31 100644 --- a/docs/toolkit/materialized-views.md +++ b/docs/toolkit/materialized-views.md @@ -16,8 +16,8 @@ The important design decision is the shape of the result. Give the view a stable import sqlalchemy as sa from omop_alchemy.cdm.model import Measurement -from orm_loader.mappers.materialised_view_contracts import MaterializedViewIndex -from orm_loader.mappers.materialised_view_mixin import MaterializedViewMixin +from orm_loader.mappers import MaterializedViewIndex +from orm_loader.mappers import MaterializedViewMixin measurement_summary = ( @@ -107,7 +107,7 @@ Use the fallback only if serving a briefly stale or synchronously refreshed read The application owns the registry because it knows which views belong to a deployment and which policy should govern them. `orm-loader` supplies the small amount of generic machinery needed to order managed views and invoke their lifecycle methods. ```python -from orm_loader.mappers.materialised_view_mixin import ( +from orm_loader.mappers import ( refresh_all_mvs, resolve_mv_refresh_order, ) From 6b6e5e93b7f2c2a5cc4d7937853131be99b9d83f Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Mon, 7 Sep 2026 11:23:10 +1000 Subject: [PATCH 14/30] move condition modifiers to toolkit --- docs/advanced/fulltext.md | 63 ++---- docs/advanced/timelines.md | 19 +- docs/api/architecture.md | 10 +- docs/api/base.md | 12 +- docs/api/columns.md | 21 +- docs/api/index.md | 19 +- docs/api/query.md | 10 +- docs/api/relationships.md | 12 +- docs/api/typing.md | 10 +- docs/toolkit/analytics.md | 53 +++++ docs/toolkit/core.md | 63 ++++++ docs/toolkit/materialized-views.md | 4 - docs/toolkit/query-contracts.md | 54 +++++ .../toolkit/analytics/oncology/__init__.py | 30 +++ .../analytics/oncology/concept_sets.py | 88 ++++---- .../analytics/oncology/condition_modifiers.py | 144 ++++++++++++ omop_alchemy/toolkit/core/__init__.py | 4 + .../{episodes/derivation => core}/_ranking.py | 7 +- .../toolkit/core/concepts/__init__.py | 3 + omop_alchemy/toolkit/core/concepts/groups.py | 5 +- .../toolkit/core/concepts/semantics.py | 67 ++++++ .../toolkit/core/modifiers/__init__.py | 89 ++++++++ .../toolkit/core/modifiers/contracts.py | 202 +++++++++++++++++ .../toolkit/core/modifiers/metadata.py | 187 ++++++++++++++++ .../toolkit/core/modifiers/projections.py | 72 ++++++ .../toolkit/core/modifiers/selection.py | 124 ++++++++++ .../toolkit/core/modifiers/targets.py | 211 ++++++++++++++++++ pyproject.toml | 3 +- tests/test_concept_groups.py | 44 +++- tests/test_modifier_projections.py | 179 +++++++++++++++ tests/test_modifier_selection.py | 189 ++++++++++++++++ uv.lock | 8 +- 32 files changed, 1823 insertions(+), 183 deletions(-) create mode 100644 omop_alchemy/toolkit/analytics/oncology/condition_modifiers.py rename omop_alchemy/toolkit/{episodes/derivation => core}/_ranking.py (70%) create mode 100644 omop_alchemy/toolkit/core/concepts/semantics.py create mode 100644 omop_alchemy/toolkit/core/modifiers/__init__.py create mode 100644 omop_alchemy/toolkit/core/modifiers/contracts.py create mode 100644 omop_alchemy/toolkit/core/modifiers/metadata.py create mode 100644 omop_alchemy/toolkit/core/modifiers/projections.py create mode 100644 omop_alchemy/toolkit/core/modifiers/selection.py create mode 100644 omop_alchemy/toolkit/core/modifiers/targets.py create mode 100644 tests/test_modifier_projections.py create mode 100644 tests/test_modifier_selection.py diff --git a/docs/advanced/fulltext.md b/docs/advanced/fulltext.md index 4a73a33..8877bf7 100644 --- a/docs/advanced/fulltext.md +++ b/docs/advanced/fulltext.md @@ -1,7 +1,6 @@ # PostgreSQL Full-Text Search -OMOP Alchemy includes an **optional** PostgreSQL full-text search integration for -selected vocabulary text fields. +OMOP Alchemy includes an **optional** PostgreSQL full-text search integration for selected vocabulary text fields. This feature is deliberately bolt-on: @@ -32,19 +31,15 @@ backend.concept_synonym_name_tsvector_expression() These helpers return the best available expression for the configured environment: -- if the optional sidecar `tsvector` columns are registered in metadata, they return the - stored column -- otherwise they fall back to an inline computed PostgreSQL expression using - `to_tsvector(...)` +- if the optional sidecar `tsvector` columns are registered in metadata, they return the stored column +- otherwise they fall back to an inline computed PostgreSQL expression using `to_tsvector(...)` ### Example (PostgreSQL Documentation) -A tsvector value is a sorted list of distinct lexemes, which are words that have been normalized to merge different variants of the same word. -Sorting and duplicate-elimination are done automatically during input +A tsvector value is a sorted list of distinct lexemes, which are words that have been normalized to merge different variants of the same word. Sorting and duplicate-elimination are done automatically during input -A `tsvector` value is a sorted list of distinct lexemes (normalized word forms). -Sorting and duplicate elimination are applied automatically during input. +A `tsvector` value is a sorted list of distinct lexemes (normalized word forms). Sorting and duplicate elimination are applied automatically during input. ```sql SELECT 'a fat cat sat on a mat and ate a fat rat'::tsvector; @@ -63,8 +58,7 @@ omop-alchemy fulltext install omop-alchemy fulltext populate ``` -If your running Python process should use the stored sidecar columns through ORM -metadata, register them once at startup: +If your running Python process should use the stored sidecar columns through ORM metadata, register them once at startup: ```python from omop_alchemy.backends import resolve_backend @@ -73,8 +67,7 @@ backend = resolve_backend(engine) backend.register_fulltext_metadata() ``` -That is enough to activate the feature. The rest of this page explains when to use it -and how to operate it safely. +That is enough to activate the feature. The rest of this page explains when to use it and how to operate it safely. ## When To Use It @@ -101,12 +94,9 @@ Full-text search is useful, but it also introduces operational tradeoffs: - explicit backfill / refresh work - PostgreSQL-specific behavior -Many users only need occasional text matching and are perfectly fine with inline search -expressions. Others want fast repeated full-text lookups across large vocabularies and -are happy to manage the extra schema objects. +Many users only need occasional text matching and are perfectly fine with inline search expressions. Others want fast repeated full-text lookups across large vocabularies and are happy to manage the extra schema objects. -OMOP Alchemy therefore treats full-text sidecars as an **optional PostgreSQL -enhancement**, not as part of the core required OMOP schema. +OMOP Alchemy therefore treats full-text sidecars as an **optional PostgreSQL enhancement**, not as part of the core required OMOP schema. --- @@ -134,8 +124,7 @@ with Session(engine) as session: ) ``` -In practice you will often want the PostgreSQL full-text match operator rather than -equality: +In practice you will often want the PostgreSQL full-text match operator rather than equality: ```python vector = backend.concept_name_tsvector_expression() @@ -144,15 +133,13 @@ query = sa.func.plainto_tsquery("english", "edoxaban") stmt = sa.select(Concept).where(vector.op("@@")(query)) ``` -This mode is simple and portable at the library level, but PostgreSQL must compute the -vector expression at query time unless the planner can otherwise optimize it. +This mode is simple and portable at the library level, but PostgreSQL must compute the vector expression at query time unless the planner can otherwise optimize it. ### 2. Stored Sidecar Mode This mode adds real `tsvector` columns to the database and optionally GIN indexes. -Once installed and registered, the helper functions point at the stored columns instead -of recomputing vectors inline. +Once installed and registered, the helper functions point at the stored columns instead of recomputing vectors inline. This is the mode you want when: @@ -195,8 +182,7 @@ omop-alchemy fulltext drop ## Important Behavior -The current implementation uses **ordinary nullable sidecar `tsvector` columns**, not -generated columns and not trigger-managed columns. +The current implementation uses **ordinary nullable sidecar `tsvector` columns**, not generated columns and not trigger-managed columns. That means: @@ -204,8 +190,7 @@ That means: - `populate` backfills or refreshes the values - future data changes are **not** reflected automatically until you repopulate -This is a deliberate choice because it keeps the feature explicit and easier to manage -alongside bulk vocabulary loads. +This is a deliberate choice because it keeps the feature explicit and easier to manage alongside bulk vocabulary loads. --- @@ -245,8 +230,7 @@ The same idea applies to `backend.concept_synonym_name_tsvector_expression()`. ## Metadata Registration -If your process will use the stored sidecar columns directly, register them into the ORM -metadata: +If your process will use the stored sidecar columns directly, register them into the ORM metadata: ```python from omop_alchemy.backends import resolve_backend @@ -255,15 +239,13 @@ backend = resolve_backend(engine) backend.register_fulltext_metadata() ``` -If you later remove the columns from the database in the same process and want query -helpers to fall back cleanly again: +If you later remove the columns from the database in the same process and want query helpers to fall back cleanly again: ```python backend.unregister_fulltext_metadata() ``` -This only affects SQLAlchemy metadata in the current Python process. It does not alter -the database by itself. +This only affects SQLAlchemy metadata in the current Python process. It does not alter the database by itself. --- @@ -275,16 +257,13 @@ This feature is PostgreSQL-specific in its database form because it relies on: - PostgreSQL full-text query functions such as `to_tsvector` and `plainto_tsquery` - optional GIN indexes -The helper expressions can still be imported safely, but the sidecar install / populate / -drop lifecycle is only meaningful on PostgreSQL. +The helper expressions can still be imported safely, but the sidecar install / populate / drop lifecycle is only meaningful on PostgreSQL. --- -## Operational Gotchas +## Operational Notes: - treat the sidecar columns as **derived search state**, not source-of-truth data - if you bulk-load new vocabulary rows, rerun `omop-alchemy fulltext populate` -- if you use `reconcile-schema`, the sidecar columns and indexes are intentional - database additions outside the core OMOP schema -- GIN indexes can be expensive to build on large vocabularies, so plan that as a real - maintenance operation rather than a trivial toggle \ No newline at end of file +- if you use `reconcile-schema`, the sidecar columns and indexes are intentional database additions outside the core OMOP schema +- GIN indexes can be expensive to build on large vocabularies, so plan that as a real maintenance operation rather than a trivial toggle \ No newline at end of file diff --git a/docs/advanced/timelines.md b/docs/advanced/timelines.md index 3c37fa9..d804417 100644 --- a/docs/advanced/timelines.md +++ b/docs/advanced/timelines.md @@ -1,10 +1,8 @@ # Patient Timelines -OMOP Alchemy includes a lightweight timeline layer that projects OMOP CDM ORM objects -into a **unified, time-ordered event stream** per patient. +OMOP Alchemy includes a lightweight timeline layer that projects OMOP CDM ORM objects into a **unified, time-ordered event stream** per patient. -It is primarily intended for feature construction and exploratory analysis — not for -production query pipelines where raw SQLAlchemy queries are more appropriate. +It is primarily intended for feature construction and exploratory analysis — not for production query pipelines where raw SQLAlchemy queries are more appropriate. --- @@ -12,8 +10,7 @@ production query pipelines where raw SQLAlchemy queries are more appropriate. ### `EventTime` -A canonical temporal representation. Every clinical event has a start datetime; an end -datetime is optional. The `kind` property returns `"point"` or `"interval"`. +A canonical temporal representation. Every clinical event has a start datetime; an end datetime is optional. The `kind` property returns `"point"` or `"interval"`. ::: omop_alchemy.toolkit.core.timeline.event_timeline.EventTime @@ -37,11 +34,7 @@ Declares which ORM fields supply the concept, start/end datetimes, and value for ## The `ClinicalEvent` mixin -`ClinicalEvent` is a mixin that adds timeline behaviour to any CDM ORM class. It implements -the shared `toolkit.core.events.ClinicalEventRow` identity and projection fields, then reads -`_mapping` to add `event_time`, `event_value`, `event_metadata`, `to_dict`, and `to_json`. -The shared core contract keeps timeline events and SQL event projections aligned -without making `core.timeline` import the higher-level episode package. +`ClinicalEvent` is a mixin that adds timeline behaviour to any CDM ORM class. It implements the shared `toolkit.core.events.ClinicalEventRow` identity and projection fields, then reads `_mapping` to add `event_time`, `event_value`, `event_metadata`, `to_dict`, and `to_json`. The shared core contract keeps timeline events and SQL event projections aligned without making `core.timeline` import the higher-level episode package. ::: omop_alchemy.toolkit.core.timeline.event_timeline.ClinicalEvent @@ -70,9 +63,7 @@ Four CDM tables are pre-wired with `EventMapping`s: ## `Person_Timeline` -Extends the `Person` ORM class with `.events` and `.timeline` properties. Requires an -active SQLAlchemy session (i.e. the object must have been loaded from a session, not -constructed in memory). +Extends the `Person` ORM class with `.events` and `.timeline` properties. Requires an active SQLAlchemy session (i.e. the object must have been loaded from a session, not constructed in memory). ::: omop_alchemy.toolkit.core.timeline.event_timeline.Person_Timeline diff --git a/docs/api/architecture.md b/docs/api/architecture.md index dfbdd94..ad82cc5 100644 --- a/docs/api/architecture.md +++ b/docs/api/architecture.md @@ -2,8 +2,7 @@ OMOP Alchemy is built as a **deliberately layered system**. -Each layer adds capability while preserving the guarantees of the layer below it. -Responsibilities flow *downward*; semantic intent flows *upward*. +Each layer adds capability while preserving the guarantees of the layer below it. Responsibilities flow *downward*; semantic intent flows *upward*. The result is a system that is: @@ -71,7 +70,7 @@ This layer provides: * serialization helpers * generic materialized-view definition and lifecycle operations -It is deliberately domain-agnostic. +It is domain-agnostic. If something understands OMOP concepts, vocabularies, or clinical meaning, it does not belong here. @@ -81,10 +80,7 @@ Examples: * [SerialisableTableInterface](https://australiancancerdatanetwork.github.io/orm-loader/tables/serialisable_table/) * [Materialized views](https://australiancancerdatanetwork.github.io/orm-loader/tables/mat_view/) -OMOP Alchemy may supply an OMOP-specific selectable and its logical row -identity to this layer, but it does not implement database DDL or refresh -mechanics. Applications own collections of materialized views, dependency -policy, and deployment commands. +OMOP Alchemy may supply an OMOP-specific selectable and its logical row identity to this layer, but it does not implement database DDL or refresh mechanics. Applications own collections of materialized views, dependency policy, and deployment commands. #### cdm.base (L1) diff --git a/docs/api/base.md b/docs/api/base.md index b11d9ef..9888871 100644 --- a/docs/api/base.md +++ b/docs/api/base.md @@ -1,10 +1,8 @@ # Base Tables -Base tables define the **foundation of all concrete OMOP CDM models** -in OMOP Alchemy. +Base tables define the **foundation of all concrete OMOP CDM models** in OMOP Alchemy. -They establish the minimum structural and behavioral contract that -distinguishes a real CDM table from: +They establish the minimum structural and behavioral contract that distinguishes a real CDM table from: - mixins - views @@ -37,8 +35,7 @@ Those concerns live elsewhere. ## Relationship to `orm-loader` -`CDMTableBase` builds directly on infrastructure provided by -`orm-loader`. +`CDMTableBase` builds directly on infrastructure provided by `orm-loader`. Specifically, it inherits: @@ -58,8 +55,7 @@ This separation is intentional: ## `CDMTableBase` -The `CDMTableBase` class is the common ancestor for all concrete -OMOP CDM tables. +The `CDMTableBase` class is the common ancestor for all concrete OMOP CDM tables. ::: omop_alchemy.cdm.base.cdm_table_base.CDMTableBase options: diff --git a/docs/api/columns.md b/docs/api/columns.md index 85bb52d..5d288c4 100644 --- a/docs/api/columns.md +++ b/docs/api/columns.md @@ -1,7 +1,6 @@ # Columns & Structural Mixins -OMOP Alchemy provides a small set of **column helpers and mixins** -that encode recurring OMOP CDM patterns directly into ORM structure. +OMOP Alchemy provides a small set of **column helpers and mixins** that encode recurring OMOP CDM patterns directly into ORM structure. These utilities exist to: @@ -10,23 +9,19 @@ These utilities exist to: - keep table definitions readable - align ORM structure with CDM specifications -They are **structural**, not analytical: -they describe *how data is shaped*, not *what it means*. +They are **structural**, not analytical: they describe *how data is shaped*, not *what it means*. --- ## Column helper functions -Column helpers wrap common OMOP column patterns into -small, intention-revealing factory functions. +Column helpers wrap common OMOP column patterns into small, intention-revealing factory functions. -They are thin wrappers around `sqlalchemy.orm.mapped_column` -with defaults chosen to match the CDM Field-Level specifications. +They are thin wrappers around `sqlalchemy.orm.mapped_column` with defaults chosen to match the CDM Field-Level specifications. ### Concept foreign keys -OMOP relies heavily on concept identifiers, with specific semantics -around nullability and unknown values. +OMOP relies heavily on concept identifiers, with specific semantics around nullability and unknown values. ::: omop_alchemy.cdm.base.column_helpers.required_concept_fk options: @@ -40,8 +35,7 @@ around nullability and unknown values. ### Convenience wrappers -These helpers exist primarily for consistency and readability -when defining large tables with many fields. +These helpers exist primarily for consistency and readability when defining large tables with many fields. ::: omop_alchemy.cdm.base.column_helpers.optional_fk options: @@ -59,8 +53,7 @@ when defining large tables with many fields. ## Structural mixins -Structural mixins encode **table-level OMOP patterns** that recur -across multiple CDM tables. +Structural mixins encode **table-level OMOP patterns** that recur across multiple CDM tables. ::: omop_alchemy.cdm.base.column_mixins.PersonScoped options: diff --git a/docs/api/index.md b/docs/api/index.md index 7321e6d..c01baee 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -1,11 +1,8 @@ # API Reference +This section documents the **core authoring primitives** used to define OMOP CDM models in OMOP Alchemy. -This section documents the **core authoring primitives** used to define -OMOP CDM models in OMOP Alchemy. - -These APIs are intentionally **low-level, explicit, and composable**. -They are designed for *model authors*, not end-users or analysts. +These APIs are intentionally **low-level, explicit, and composable**. They are designed for *model authors*, not end-users or analysts. If you are: @@ -54,11 +51,9 @@ Layered architecture specification is described in [Architecture](./architecture ## Base table infrastructure -At the core of OMOP Alchemy is a small number of base classes that define -what it means to be a CDM table. +At the core of OMOP Alchemy is a small number of base classes that define what it means to be a CDM table. -These classes integrate with lower-level infrastructure (i.e. -`orm-loader`) but remain OMOP-specific. +These classes integrate with lower-level infrastructure (i.e. `orm-loader`) but remain OMOP-specific. **[Base tables](base.md)** @@ -93,8 +88,7 @@ Mixins encode these patterns once, and make them reusable and inspectable. ## Typing and semantic contracts -OMOP Alchemy makes heavy use of Python typing to express -*semantic expectations*: +OMOP Alchemy makes heavy use of Python typing to express *semantic expectations*: - “this object has a concept_id” - “this table participates in domain validation” @@ -111,8 +105,7 @@ These protocols support: ## Relationship to other layers -This API layer sits *above* generic ORM infrastructure -and *below* analytical or validation tooling. +This API layer sits *above* generic ORM infrastructure and *below* analytical or validation tooling. | Layer | Responsibility | |------|---------------| diff --git a/docs/api/query.md b/docs/api/query.md index e85bb48..23ea2e3 100644 --- a/docs/api/query.md +++ b/docs/api/query.md @@ -1,14 +1,8 @@ # Query Filtering -`omop_alchemy.cdm.query` provides `ConceptFilter`, a shared, reusable way to -filter CDM `concept`-table queries by domain, vocabulary, concept ID, and -standard/active status, with an optional row-count limit. +`omop_alchemy.cdm.query` provides `ConceptFilter`, a shared, reusable way to filter CDM `concept`-table queries by domain, vocabulary, concept ID, and standard/active status, with an optional row-count limit. -It exists so that packages consuming OMOP Alchemy (e.g. `omop-emb`, `omop-graph`) -don't each need to reimplement the same filtering logic against their own copy -of `Concept`'s column names — since this package owns the `Concept` model -directly, the filter can reference real columns rather than duck-typing against -an opaquely-imported table. +It exists so that packages consuming OMOP Alchemy (e.g. `omop-emb`, `omop-graph`) don't each need to reimplement the same filtering logic against their own copy of `Concept`'s column names — since this package owns the `Concept` model directly, the filter can reference real columns rather than duck-typing against an opaquely-imported table. ```python from sqlalchemy import select diff --git a/docs/api/relationships.md b/docs/api/relationships.md index e3ac2cc..6c9aada 100644 --- a/docs/api/relationships.md +++ b/docs/api/relationships.md @@ -2,15 +2,13 @@ OMOP Alchemy takes a **deliberately conservative approach to ORM relationships**. -Rather than eagerly wiring every foreign key into a bidirectional relationship, -it distinguishes between: +Rather than eagerly wiring every foreign key into a bidirectional relationship, it distinguishes between: - **structural foreign keys** (always present in tables) - **reference lookups** (read-only joins for navigation) - **analytical relationships** (used in views, not ETL) -This separation keeps core tables simple, predictable, and fast to load, -while still enabling rich, expressive navigation when you need it. +This separation keeps core tables simple, predictable, and fast to load, while still enabling rich, expressive navigation when you need it. --- @@ -40,8 +38,7 @@ OMOP Alchemy addresses this by introducing **Reference Contexts**. ## The core idea -Instead of defining relationships directly on a table class, -OMOP Alchemy encourages a **three-layer pattern**: +Instead of defining relationships directly on a table class, OMOP Alchemy encourages a **three-layer pattern**: 1. **Table** – structural definition only 2. **Context** – reference relationships @@ -160,8 +157,7 @@ Vocabulary tables (Concept, Domain, Vocabulary, etc.) are: * stable * not owned by fact tables -Allowing mutation through ORM relationships would blur those boundaries -and make ETL behavior harder to reason about. +Allowing mutation through ORM relationships would blur those boundaries and make ETL behavior harder to reason about. ### Performance considerations diff --git a/docs/api/typing.md b/docs/api/typing.md index 4216c40..ad7cabc 100644 --- a/docs/api/typing.md +++ b/docs/api/typing.md @@ -1,7 +1,6 @@ # Typing -OMOP Alchemy exposes a set of **Protocols and typed containers** for code that needs to -interact with CDM classes without coupling to specific ORM implementations. +OMOP Alchemy exposes a set of **Protocols and typed containers** for code that needs to interact with CDM classes without coupling to specific ORM implementations. These live in two modules: @@ -52,9 +51,7 @@ Satisfied by any object with an integer `episode_id` attribute. ### `DomainSemanticTable` -Structural protocol for CDM ORM classes that participate in domain validation. A class -satisfies this protocol if it has `__tablename__`, `__mapper__`, `__expected_domains__`, -and a `collect_domain_rules()` classmethod. +Structural protocol for CDM ORM classes that participate in domain validation. A class satisfies this protocol if it has `__tablename__`, `__mapper__`, `__expected_domains__`, and a `collect_domain_rules()` classmethod. ::: omop_alchemy.cdm.base.typing.DomainSemanticTable options: @@ -68,8 +65,7 @@ and a `collect_domain_rules()` classmethod. ### `ConceptRow` -A frozen dataclass representing the core fields of a concept lookup row. Used where a -lightweight, hashable concept record is preferable to a full ORM object. +A frozen dataclass representing the core fields of a concept lookup row. Used where a lightweight, hashable concept record is preferable to a full ORM object. ::: omop_alchemy.cdm.model.typing.ConceptRow options: diff --git a/docs/toolkit/analytics.md b/docs/toolkit/analytics.md index e2f8f66..f0a8ed7 100644 --- a/docs/toolkit/analytics.md +++ b/docs/toolkit/analytics.md @@ -67,6 +67,59 @@ The single-value properties return the first modality in this order for which th `OncologyProcedure` and `OncologyDrugExposure` expose the same governed classifications on individual facts. `OncologyEpisodeEvent` retains resolution diagnostics when a linked event cannot be loaded. +### Condition modifiers and preferred stage + +The oncology package publishes lazy governed concept specifications for T, N, +M, and group stage, tumour grade, and metastatic disease. Laterality and tumour +size are exposed as governed scalar accessors. These declarations consume +`omop-semantics`; the narrow metastatic-disease descendant group requires +`omop-semantics` 0.6.1. Importing the module does not expand a vocabulary or +contact a database. + +Stage selection is a query policy over an already filtered canonical modifier +source enriched with `modifier_concept_code`. By default, pathological codes +(trimmed, case-insensitive codes beginning with `p`) rank before clinical codes +(beginning with `c`), with unclassified codes retained as fallback. Time and +canonical modifier identity then break ties deterministically. + +```python +from omop_alchemy.toolkit.analytics.oncology import preferred_stage_select + +# Earliest pathological, otherwise earliest clinical, otherwise unclassified. +preferred = preferred_stage_select(stage_modifiers) +``` + +The preference is immutable and query-scoped. Override it explicitly rather +than changing process-global state: + +```python +from omop_alchemy.toolkit.analytics.oncology import StageSelectionSpec +from omop_alchemy.toolkit.core.modifiers import ModifierSelectionPolicy + +clinical_first = preferred_stage_select( + stage_modifiers, + spec=StageSelectionSpec.clinical_first(), +) + +chronological = preferred_stage_select( + stage_modifiers, + spec=StageSelectionSpec.chronological_only(), +) + +latest_pathological = preferred_stage_select( + stage_modifiers, + spec=StageSelectionSpec( + temporal_policy=ModifierSelectionPolicy.latest, + ), +) +``` + +Pass `concept_code_column=` when an enriched source uses another explicit +label. An empty basis priority disables pathological/clinical preference; a +non-empty priority must contain every `StageBasis` exactly once. + +::: omop_alchemy.toolkit.analytics.oncology.condition_modifiers + ::: omop_alchemy.toolkit.analytics.oncology.OncologyEpisode options: members: diff --git a/docs/toolkit/core.md b/docs/toolkit/core.md index 9010a3a..6331091 100644 --- a/docs/toolkit/core.md +++ b/docs/toolkit/core.md @@ -30,6 +30,12 @@ Creating the resolver reads the vocabulary tables, so create it once for a mappi Concept groups answer the complementary question: whether a known concept belongs to a governed set. A resolved group supports both in-memory membership and a SQLAlchemy expression derived from the same specification, so filtering loaded objects and filtering in SQL do not require separate definitions. +Domain packages can declare module-level governed groups with +`SemanticUnitRef(value_set, unit)`. The reference loads the optional +`omop-semantics` runtime only when its parent, excluded-parent, or exact IDs are +read. It always exposes a complete governed unit; narrower group definitions +belong in `omop-semantics`, not in consumer-side adapters. + Configuration-driven concept sets use `RuntimeConceptSetSpec`. It records exact and ancestral inclusions and exclusions without touching the database; see [Runtime concept sets](query-contracts.md#runtime-concept-sets) for the set semantics and current execution boundary. ## Resolve concepts to standard concepts @@ -109,6 +115,63 @@ Each source model contributes its own ID column and Field concept; branches with The [query contracts](query-contracts.md) explain how the canonical shape participates in episode attachment. +## Resolve OMOP modifiers + +Measurement and Observation both implement the OMOP polymorphic modifier link, +but use different physical column names. `canonical_modifier_projection()` and +`canonical_modifier_union()` normalize those tables to one shape containing a +table-scoped modifier identity, a Field-concept-scoped target identity, the +modifier date and concept, and all four OMOP value representations. + +Supported source and target models are declared explicitly in immutable +metadata. Shared event fields and the six clinical target definitions are +derived from the clinical-event registry; Episode is the only target extension. +This keeps imports deterministic without maintaining a second copy of the CDM +Field concepts and native event columns. + +```python +from omop_alchemy.cdm.model import Measurement, Observation +from omop_alchemy.toolkit.core.modifiers import canonical_modifier_union + +modifiers = canonical_modifier_union(Measurement, Observation).subquery() +``` + +A numeric modifier ID is unique only inside its source table. Likewise, a +numeric target ID is meaningful only with `target_field_concept_id`. Preserve +both parts of both identities when a query is joined, ranked, or materialized. + +Use `modifier_target_queries()` before reducing repeated modifiers. The accepted +link must agree on target ID, target Field concept, and person. Supported targets +are Condition Occurrence, Measurement, Observation, Procedure Occurrence, Drug +Exposure, Device Exposure, and Episode. Episode is deliberately a modifier +target without being added to the clinical-event union. + +```python +from omop_alchemy.cdm.model import Condition_Occurrence, Measurement +from omop_alchemy.toolkit.core.modifiers import modifier_target_queries + +result = modifier_target_queries( + Measurement, + Condition_Occurrence, + diagnostics=True, +) + +valid_modifiers = result.matches +rejected_links = result.diagnostics +``` + +Diagnostics distinguish incomplete target identities, unsupported target Field +concepts, missing target events, and cross-person links. Missing-row diagnostics +for a caller-supplied filtered target projection are relative to that projection; +use a complete target model when absence from the CDM itself is the question. + +`selected_modifier_select()` then applies an explicit earliest/latest policy. +The default partition is `(person_id, target_field_concept_id, target_event_id)`; +date, non-null datetime, source table, and modifier ID form its deterministic +order. Rows without a complete target identity do not participate. + +::: omop_alchemy.toolkit.core.modifiers + ## Work with a patient timeline The timeline adapter presents conditions, measurements, observations, and drug exposures as a single ordered sequence while retaining each row's source identity and value semantics. Use it when an application needs to display or serialise a patient's chronology rather than build a set-based analytical query. diff --git a/docs/toolkit/materialized-views.md b/docs/toolkit/materialized-views.md index 6796f31..b674e2d 100644 --- a/docs/toolkit/materialized-views.md +++ b/docs/toolkit/materialized-views.md @@ -125,7 +125,3 @@ refresh_all_mvs(engine, ALL_MVS) for view_cls in resolve_mv_refresh_order(ALL_MVS): view_cls.refresh_mv(engine, concurrently=True) ``` - -This registry is also the right place for application-specific choices such as whether to rebuild a view after a definition change, whether a failed refresh should block a release, and how to report lifecycle failures to operators. Do not duplicate the generic dependency resolver or database lifecycle in an OMOP Alchemy toolkit module; the ownership boundary is protected by [`tests/test_materialization_ownership.py`](https://github.com/AustralianCancerDataNetwork/OMOP_Alchemy/blob/main/tests/test_materialization_ownership.py). - -For the cohort delivery stack, these deployment concerns belong in `omop-constructs` or the application that runs its migrations and schedulers. OMOP Alchemy's role is to provide reusable OMOP query components and a clear read-model contract. diff --git a/docs/toolkit/query-contracts.md b/docs/toolkit/query-contracts.md index 202356f..26b2f42 100644 --- a/docs/toolkit/query-contracts.md +++ b/docs/toolkit/query-contracts.md @@ -342,8 +342,62 @@ matching_procedures = select(Procedure_Occurrence).where( Use `RuntimeConceptSetSpec` when the inclusions and exclusions form one configured set with exclusion precedence. Use `descendant_concept_select()` when the surrounding query or rule model owns how separate predicates are combined. +## Canonical modifier queries + +`ModifierIdentity(modifier_source_table, modifier_id)` and +`ModifierTargetIdentity(target_field_concept_id, target_event_id)` make both +table scopes explicit. The canonical source columns are: + +| Role | Columns | +|---|---| +| Source identity | `modifier_source_table`, `modifier_id` | +| Target identity | `target_field_concept_id`, `target_event_id` | +| Clinical row | `person_id`, `modifier_date`, `modifier_datetime`, `modifier_concept_id` | +| Nullable values | `value_as_number`, `value_as_concept_id`, `unit_concept_id`, `value_as_string` | + +Target validation is intentionally performed before selection. A valid link +matches target Field concept, target ID, and person. This prevents a modifier +for Condition Occurrence 7 from competing with one for Procedure Occurrence 7, +and prevents a malformed cross-person link from replacing valid evidence. + +```python +from omop_alchemy.cdm.model import Condition_Occurrence, Measurement +from omop_alchemy.toolkit.core.modifiers import ( + ModifierSelectionPolicy, + ModifierSelectionSpec, + modifier_target_queries, + selected_modifier_select, +) + +resolved = modifier_target_queries(Measurement, Condition_Occurrence) +selected = selected_modifier_select( + resolved.matches, + spec=ModifierSelectionSpec(policy=ModifierSelectionPolicy.earliest), +) +``` + +The default selection partition includes person and both target identity +columns. If one input contains several modifier categories and selection should +occur separately for each, filter to one category before ranking or add that +category discriminator to `partition_by`. Incomplete target identities are +excluded. The stable source table and modifier ID are always the final +tie-breakers under the default contract. + +`modifier_target_queries(..., diagnostics=True)` returns a second advisory query +covering `missing_target_identity`, `unsupported_target_field`, +`missing_target_event`, and `person_mismatch`. Diagnostics do not change the +valid result. For a filtered caller projection, missing events are input-relative. + +Oncology stage preference composes with this generic selector. Its public +default is pathological, clinical, then unclassified, followed by earliest +time. `StageSelectionSpec.clinical_first()`, +`StageSelectionSpec.chronological_only()`, and a `latest` temporal policy are +explicit query-scoped alternatives. + ## API reference ::: omop_alchemy.toolkit.core.events +::: omop_alchemy.toolkit.core.modifiers + ::: omop_alchemy.toolkit.episodes.derivation diff --git a/omop_alchemy/toolkit/analytics/oncology/__init__.py b/omop_alchemy/toolkit/analytics/oncology/__init__.py index 8a8e7de..e3b81a3 100644 --- a/omop_alchemy/toolkit/analytics/oncology/__init__.py +++ b/omop_alchemy/toolkit/analytics/oncology/__init__.py @@ -3,7 +3,14 @@ DIAGNOSTIC_STAGING_PROCEDURES, RADIOTHERAPY_PROCEDURES, SACT_DRUGS, + GROUP_STAGE_CONCEPTS, + M_STAGE_CONCEPTS, + METASTATIC_DISEASE_CONCEPTS, + N_STAGE_CONCEPTS, + T_STAGE_CONCEPTS, + TUMOR_GRADE_CONCEPTS, disease_episode_type_concept_ids, + laterality_modifier_concept_id, overarching_episode_type_concept_id, resolve_cancer_indicating_surgery_procedure_concept_ids, resolve_diagnostic_staging_procedure_concept_ids, @@ -12,6 +19,15 @@ treatment_cycle_episode_concept_id, treatment_episode_type_concept_ids, treatment_regimen_episode_concept_id, + tumor_size_modifier_concept_id, +) +from .condition_modifiers import ( + DEFAULT_STAGE_SELECTION, + StageBasis, + StageSelectionSpec, + preferred_stage_select, + stage_basis_expression, + stage_basis_priority_expression, ) from .oncology_critical_weight_loss import ( CriticalWeightLossSummary, @@ -39,8 +55,17 @@ "CANCER_INDICATING_SURGERY", "CriticalWeightLossSummary", "DIAGNOSTIC_STAGING_PROCEDURES", + "DEFAULT_STAGE_SELECTION", + "GROUP_STAGE_CONCEPTS", + "M_STAGE_CONCEPTS", + "METASTATIC_DISEASE_CONCEPTS", + "N_STAGE_CONCEPTS", "RADIOTHERAPY_PROCEDURES", "SACT_DRUGS", + "StageBasis", + "StageSelectionSpec", + "T_STAGE_CONCEPTS", + "TUMOR_GRADE_CONCEPTS", "OncologyCriticalWeightLossMixin", "OncologyDrugExposure", "OncologyEpisode", @@ -53,7 +78,9 @@ "RTDoseSummary", "SACTDoseSummary", "disease_episode_type_concept_ids", + "laterality_modifier_concept_id", "overarching_episode_type_concept_id", + "preferred_stage_select", "resolve_cancer_indicating_surgery_procedure_concept_ids", "resolve_diagnostic_staging_procedure_concept_ids", "resolve_rt_procedure_concept_ids", @@ -63,7 +90,10 @@ "sact_dose_evaluability", "summarize_rt_procedures_by", "summarize_sact_exposures_by", + "stage_basis_expression", + "stage_basis_priority_expression", "treatment_cycle_episode_concept_id", "treatment_episode_type_concept_ids", "treatment_regimen_episode_concept_id", + "tumor_size_modifier_concept_id", ] diff --git a/omop_alchemy/toolkit/analytics/oncology/concept_sets.py b/omop_alchemy/toolkit/analytics/oncology/concept_sets.py index 8405a65..30182d0 100644 --- a/omop_alchemy/toolkit/analytics/oncology/concept_sets.py +++ b/omop_alchemy/toolkit/analytics/oncology/concept_sets.py @@ -3,7 +3,7 @@ Every set here names an omop-semantics semantic unit rather than assembling concept IDs locally. That matters beyond tidiness: "what counts as radiotherapy" is a clinical claim, and it was previously written out by hand -both here and in omop-constructs, governed by neither. omop-semantics 0.6.0 +both here and in omop-constructs, governed by neither. omop-semantics 0.6+ publishes these as governed units, so both consumers name the same definition. Specs are declarative — importing this module resolves no semantics runtime and @@ -16,70 +16,68 @@ from __future__ import annotations -from typing import Any - import sqlalchemy.orm as so from omop_alchemy.toolkit.core._semantics import default_semantics_runtime from omop_alchemy.toolkit.core.concepts import ( ConceptGroupSpec, ResolvedConceptGroup, + SemanticUnitRef, resolve_concept_group, ) -def _unit(value_set_name: str, unit_name: str) -> Any: - """Resolve a governed semantic unit, lazily. - - Deferred rather than captured at import so that declaring a spec does not - load the semantics runtime. - """ - return getattr(getattr(default_semantics_runtime(), value_set_name), unit_name) - - -class _LazyUnit: - """Attribute proxy that resolves its semantic unit on first access. - - ``ConceptGroupSpec`` reads ``parent_ids`` / ``excluded_parent_ids`` / - ``exact_ids`` off its ``unit``. Holding a proxy rather than the unit itself - keeps module import free of semantics loading, which is what allows basic - CDM work to avoid paying for oncology concept sets. - """ - - __slots__ = ("_value_set", "_unit") - - def __init__(self, value_set_name: str, unit_name: str) -> None: - self._value_set = value_set_name - self._unit = unit_name - - def __getattr__(self, name: str) -> Any: - return getattr(_unit(self._value_set, self._unit), name) - - def __repr__(self) -> str: - return f"" - - # Governed concept sets. Names are the governed semantic-unit names, which are # also the cache keys -- so the cache key derives from the governed identity # rather than a locally invented label. RADIOTHERAPY_PROCEDURES = ConceptGroupSpec( name="radiotherapy", - unit=_LazyUnit("cancer_procedures", "radiotherapy"), + unit=SemanticUnitRef("cancer_procedures", "radiotherapy"), ) CANCER_INDICATING_SURGERY = ConceptGroupSpec( name="cancer_indicating_surgery", - unit=_LazyUnit("cancer_procedures", "cancer_indicating_surgery"), + unit=SemanticUnitRef("cancer_procedures", "cancer_indicating_surgery"), ) DIAGNOSTIC_STAGING_PROCEDURES = ConceptGroupSpec( name="diagnostic_staging_procedure", - unit=_LazyUnit("cancer_procedures", "diagnostic_staging_procedure"), + unit=SemanticUnitRef("cancer_procedures", "diagnostic_staging_procedure"), ) SACT_DRUGS = ConceptGroupSpec( name="sact_drug_classification", - unit=_LazyUnit("sact", "sact_drug_classification"), + unit=SemanticUnitRef("sact", "sact_drug_classification"), +) + +T_STAGE_CONCEPTS = ConceptGroupSpec( + name="t_stage_concepts", + unit=SemanticUnitRef("staging", "t_stage_concepts"), +) + +N_STAGE_CONCEPTS = ConceptGroupSpec( + name="n_stage_concepts", + unit=SemanticUnitRef("staging", "n_stage_concepts"), +) + +M_STAGE_CONCEPTS = ConceptGroupSpec( + name="m_stage_concepts", + unit=SemanticUnitRef("staging", "m_stage_concepts"), +) + +GROUP_STAGE_CONCEPTS = ConceptGroupSpec( + name="group_stage_concepts", + unit=SemanticUnitRef("staging", "group_stage_concepts"), +) + +TUMOR_GRADE_CONCEPTS = ConceptGroupSpec( + name="tumor_grade", + unit=SemanticUnitRef("condition_modifiers", "tumor_grade"), +) + +METASTATIC_DISEASE_CONCEPTS = ConceptGroupSpec( + name="metastatic_disease_concepts", + unit=SemanticUnitRef("condition_modifiers", "metastatic_disease_concepts"), ) @@ -123,3 +121,17 @@ def treatment_regimen_episode_concept_id() -> int: def treatment_cycle_episode_concept_id() -> int: return default_semantics_runtime().types.treatment_episode_types.treatment_cycle + + +def laterality_modifier_concept_id() -> int: + """Governed modifier concept used when a value records laterality.""" + return int( + default_semantics_runtime().condition_modifiers.condition_modifier_values.laterality + ) + + +def tumor_size_modifier_concept_id() -> int: + """Governed numeric modifier concept used for tumour size.""" + return int( + default_semantics_runtime().condition_modifiers.numeric_condition_modifiers.tumor_size + ) diff --git a/omop_alchemy/toolkit/analytics/oncology/condition_modifiers.py b/omop_alchemy/toolkit/analytics/oncology/condition_modifiers.py new file mode 100644 index 0000000..4b29860 --- /dev/null +++ b/omop_alchemy/toolkit/analytics/oncology/condition_modifiers.py @@ -0,0 +1,144 @@ +"""Oncology policies for condition modifiers and preferred stage values.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +import sqlalchemy as sa +from sqlalchemy.sql.selectable import FromClause, SelectBase + +from omop_alchemy.toolkit.core.modifiers import ( + ModifierSelectionPolicy, + ModifierSelectionSpec, + selected_modifier_select, +) + + +class StageBasis(StrEnum): + pathological = "pathological" + clinical = "clinical" + unclassified = "unclassified" + + +@dataclass(frozen=True, slots=True) +class StageSelectionSpec: + """Preferred stage basis followed by temporal tie-breaking policy. + + The default prefers pathological stage, then clinical stage, then values + whose vocabulary code does not identify a basis. Supply an empty + ``basis_priority`` for chronological-only selection. + """ + + basis_priority: tuple[StageBasis, ...] = ( + StageBasis.pathological, + StageBasis.clinical, + StageBasis.unclassified, + ) + temporal_policy: ModifierSelectionPolicy = ModifierSelectionPolicy.earliest + + def __post_init__(self) -> None: + expected = set(StageBasis) + if self.basis_priority and ( + len(self.basis_priority) != len(expected) + or set(self.basis_priority) != expected + ): + raise ValueError( + "basis_priority must be empty or contain each StageBasis exactly once" + ) + + @classmethod + def clinical_first( + cls, + *, + temporal_policy: ModifierSelectionPolicy = ModifierSelectionPolicy.earliest, + ) -> StageSelectionSpec: + return cls( + basis_priority=( + StageBasis.clinical, + StageBasis.pathological, + StageBasis.unclassified, + ), + temporal_policy=temporal_policy, + ) + + @classmethod + def chronological_only( + cls, + *, + temporal_policy: ModifierSelectionPolicy = ModifierSelectionPolicy.earliest, + ) -> StageSelectionSpec: + return cls( + basis_priority=(), + temporal_policy=temporal_policy, + ) + + +DEFAULT_STAGE_SELECTION = StageSelectionSpec() + + +def stage_basis_expression( + concept_code: sa.ColumnElement[Any], +) -> sa.ColumnElement[str]: + """Classify OMOP stage concept codes by their conventional p/c prefix.""" + normalized = sa.func.lower(sa.func.trim(concept_code)) + return sa.case( + (normalized.like("p%"), str(StageBasis.pathological)), + (normalized.like("c%"), str(StageBasis.clinical)), + else_=str(StageBasis.unclassified), + ) + + +def stage_basis_priority_expression( + concept_code: sa.ColumnElement[Any], + spec: StageSelectionSpec = DEFAULT_STAGE_SELECTION, +) -> sa.ColumnElement[int]: + """Render the configured stage-basis preference as a sortable SQL CASE.""" + if not spec.basis_priority: + return sa.literal(0) + basis = stage_basis_expression(concept_code) + return sa.case( + *( + (basis == str(candidate), rank) + for rank, candidate in enumerate(spec.basis_priority) + ), + else_=len(spec.basis_priority), + ) + + +def _as_source(source: FromClause | SelectBase) -> FromClause: + if isinstance(source, SelectBase): + return source.subquery("preferred_stage_source") + if isinstance(source, FromClause): + return source + raise TypeError("source must be a SQLAlchemy Select or FromClause") + + +def preferred_stage_select( + source: FromClause | SelectBase, + *, + spec: StageSelectionSpec = DEFAULT_STAGE_SELECTION, + concept_code_column: str = "modifier_concept_code", +) -> sa.Select[Any]: + """Select one preferred stage modifier for every canonical target.""" + modifiers = _as_source(source) + priority: tuple[sa.ColumnElement[Any], ...] = () + if spec.basis_priority: + if not concept_code_column.strip(): + raise ValueError("concept_code_column must not be empty") + if concept_code_column not in modifiers.c: + raise ValueError( + "stage source is missing required concept-code column: " + f"{concept_code_column}" + ) + priority = ( + stage_basis_priority_expression( + modifiers.c[concept_code_column], spec + ).asc(), + ) + return selected_modifier_select( + modifiers, + spec=ModifierSelectionSpec(policy=spec.temporal_policy), + priority=priority, + ) diff --git a/omop_alchemy/toolkit/core/__init__.py b/omop_alchemy/toolkit/core/__init__.py index 0d86c86..0054745 100644 --- a/omop_alchemy/toolkit/core/__init__.py +++ b/omop_alchemy/toolkit/core/__init__.py @@ -13,6 +13,10 @@ Canonical cross-table event identities and projection row shapes shared by timelines and episode builders. +``modifiers`` + Canonical Measurement/Observation modifier rows, polymorphic target + validation, diagnostics, and deterministic selection. + ``timeline`` Project heterogeneous clinical rows into a single ordered sequence of events for one person. diff --git a/omop_alchemy/toolkit/episodes/derivation/_ranking.py b/omop_alchemy/toolkit/core/_ranking.py similarity index 70% rename from omop_alchemy/toolkit/episodes/derivation/_ranking.py rename to omop_alchemy/toolkit/core/_ranking.py index af2cfce..c13d130 100644 --- a/omop_alchemy/toolkit/episodes/derivation/_ranking.py +++ b/omop_alchemy/toolkit/core/_ranking.py @@ -14,12 +14,9 @@ def deterministic_row_number( order_by: Iterable[sa.ColumnElement[Any]], label: str, ) -> sa.ColumnElement[int]: - """Build the row rank shared by temporal and observation selectors.""" + """Build a deterministic ``row_number`` expression for toolkit selectors.""" return ( sa.func.row_number() - .over( - partition_by=tuple(partition_by), - order_by=tuple(order_by), - ) + .over(partition_by=tuple(partition_by), order_by=tuple(order_by)) .label(label) ) diff --git a/omop_alchemy/toolkit/core/concepts/__init__.py b/omop_alchemy/toolkit/core/concepts/__init__.py index 7000786..f80b60f 100644 --- a/omop_alchemy/toolkit/core/concepts/__init__.py +++ b/omop_alchemy/toolkit/core/concepts/__init__.py @@ -111,6 +111,7 @@ descendant_concept_select, runtime_concept_predicate, ) +from .semantics import ConceptGroupAnchors, SemanticUnitRef __all__ = [ "DEFAULT_MAX_CACHE_BYTES", @@ -118,6 +119,7 @@ "STANDARD_CONCEPT_MAPPING_COLUMNS", "STANDARD_CONCEPT_MAPPING_UNIQUENESS", "ConceptGroupRegistry", + "ConceptGroupAnchors", "ConceptGroupSpec", "StandardConceptMappingColumn", "StandardConceptMappingSpec", @@ -127,6 +129,7 @@ "LookupSpec", "OMOPConceptSource", "ResolvedConceptGroup", + "SemanticUnitRef", "RuntimeConceptSetSpec", "build_concept_group", "clear_concept_group_cache", diff --git a/omop_alchemy/toolkit/core/concepts/groups.py b/omop_alchemy/toolkit/core/concepts/groups.py index 3cd9a54..7af4f77 100644 --- a/omop_alchemy/toolkit/core/concepts/groups.py +++ b/omop_alchemy/toolkit/core/concepts/groups.py @@ -29,6 +29,7 @@ import sqlalchemy.orm as so from .runtime import descendant_concept_select +from .semantics import ConceptGroupAnchors @dataclass(frozen=True) @@ -47,7 +48,7 @@ class ConceptGroupSpec: unit The omop-semantics ``RuntimeSemanticUnit`` supplying anchors. Read lazily, so a spec can be declared at module scope without loading the - semantics runtime. omop-semantics 0.6.0 put mixed-role composition on + semantics runtime. omop-semantics 0.6 put mixed-role composition on the semantic unit rather than on ``RuntimeGroup``, which is why this takes a unit: ``parent_ids`` expand through descendants while ``exact_ids`` are matched directly. @@ -68,7 +69,7 @@ class ConceptGroupSpec: """ name: str - unit: Any + unit: ConceptGroupAnchors include_descendants: bool = True require_standard: bool = False include_classification: bool = True diff --git a/omop_alchemy/toolkit/core/concepts/semantics.py b/omop_alchemy/toolkit/core/concepts/semantics.py new file mode 100644 index 0000000..793407a --- /dev/null +++ b/omop_alchemy/toolkit/core/concepts/semantics.py @@ -0,0 +1,67 @@ +"""Deferred references to governed ``omop-semantics`` units. + +Domain packages declare concept groups as module-level constants. Resolving the +bundled semantics runtime while those modules import would make an optional +dependency mandatory and could make otherwise declarative imports perform +unwanted work. ``SemanticUnitRef`` stores only a stable path at construction and +resolves that path when a concept-group role is first read. + +The reference exposes all three roles of the governed unit without interpreting +or reshaping them. Narrow clinical groupings therefore remain governed in +``omop-semantics`` rather than being assembled locally by consumers. +""" + +from __future__ import annotations + +from collections.abc import Collection +from dataclasses import dataclass +from typing import Protocol + +from omop_alchemy.toolkit.core._semantics import default_semantics_runtime + + +class ConceptGroupAnchors(Protocol): + """Role-aware anchors consumed by :class:`ConceptGroupSpec`.""" + + @property + def parent_ids(self) -> Collection[int]: ... + + @property + def excluded_parent_ids(self) -> Collection[int]: ... + + @property + def exact_ids(self) -> Collection[int]: ... + + +@dataclass(frozen=True, slots=True) +class SemanticUnitRef: + """Lazy, immutable reference to one complete governed semantic unit.""" + + value_set: str + unit: str + + def __post_init__(self) -> None: + if not self.value_set.strip() or not self.unit.strip(): + raise ValueError("semantic value-set and unit names must not be empty") + + def _semantic_unit(self) -> object: + value_set = getattr(default_semantics_runtime(), self.value_set) + return getattr(value_set, self.unit) + + def _role_ids(self, role: str) -> frozenset[int]: + return frozenset(int(value) for value in getattr(self._semantic_unit(), role)) + + @property + def parent_ids(self) -> frozenset[int]: + return self._role_ids("parent_ids") + + @property + def excluded_parent_ids(self) -> frozenset[int]: + return self._role_ids("excluded_parent_ids") + + @property + def exact_ids(self) -> frozenset[int]: + return self._role_ids("exact_ids") + + def __repr__(self) -> str: + return f"" diff --git a/omop_alchemy/toolkit/core/modifiers/__init__.py b/omop_alchemy/toolkit/core/modifiers/__init__.py new file mode 100644 index 0000000..c0b3358 --- /dev/null +++ b/omop_alchemy/toolkit/core/modifiers/__init__.py @@ -0,0 +1,89 @@ +"""Canonical modifier projections, target validation, and selection.""" + +from .contracts import ( + CANONICAL_MODIFIER_REQUIRED_COLUMNS, + CANONICAL_MODIFIER_VALUE_COLUMNS, + ModifierColumn, + ModifierIdentity, + ModifierRow, + ModifierSelectionPolicy, + ModifierSelectionSpec, + ModifierTargetDiagnostic, + ModifierTargetDiagnosticCode, + ModifierTargetDiagnosticColumn, + ModifierTargetIdentity, + ValuedModifierRow, +) +from .metadata import ( + MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE, + MODIFIER_TARGET_SPECS_BY_TABLE, + SUPPORTED_MODIFIER_MODELS, + ModifierSourceModelSpec, + ModifierTargetModelSpec, + UnsupportedModifierSourceModelError, + UnsupportedModifierTargetError, + modifier_source_model_spec, + modifier_target_model_spec, +) +from .projections import ( + canonical_modifier_projection, + canonical_modifier_union, +) +from .selection import ( + MODIFIER_RANK, + InvalidModifierSourceError, + modifier_order_expressions, + modifier_row_number, + ranked_modifier_select, + selected_modifier_select, +) +from .targets import ( + RESOLVED_TARGET_EVENT_ID, + RESOLVED_TARGET_FIELD_CONCEPT_ID, + RESOLVED_TARGET_PERSON_ID, + RESOLVED_TARGET_SOURCE_TABLE, + InvalidModifierTargetSourceError, + ModifierTargetQueries, + canonical_modifier_target_projection, + modifier_target_queries, +) + +__all__ = [ + "CANONICAL_MODIFIER_REQUIRED_COLUMNS", + "CANONICAL_MODIFIER_VALUE_COLUMNS", + "MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE", + "MODIFIER_RANK", + "MODIFIER_TARGET_SPECS_BY_TABLE", + "RESOLVED_TARGET_EVENT_ID", + "RESOLVED_TARGET_FIELD_CONCEPT_ID", + "RESOLVED_TARGET_PERSON_ID", + "RESOLVED_TARGET_SOURCE_TABLE", + "SUPPORTED_MODIFIER_MODELS", + "InvalidModifierSourceError", + "InvalidModifierTargetSourceError", + "ModifierColumn", + "ModifierIdentity", + "ModifierSourceModelSpec", + "ModifierRow", + "ModifierSelectionPolicy", + "ModifierSelectionSpec", + "ModifierTargetDiagnostic", + "ModifierTargetDiagnosticCode", + "ModifierTargetDiagnosticColumn", + "ModifierTargetIdentity", + "ModifierTargetModelSpec", + "ModifierTargetQueries", + "UnsupportedModifierSourceModelError", + "UnsupportedModifierTargetError", + "ValuedModifierRow", + "canonical_modifier_projection", + "canonical_modifier_target_projection", + "canonical_modifier_union", + "modifier_order_expressions", + "modifier_row_number", + "modifier_source_model_spec", + "modifier_target_model_spec", + "modifier_target_queries", + "ranked_modifier_select", + "selected_modifier_select", +] diff --git a/omop_alchemy/toolkit/core/modifiers/contracts.py b/omop_alchemy/toolkit/core/modifiers/contracts.py new file mode 100644 index 0000000..7d08510 --- /dev/null +++ b/omop_alchemy/toolkit/core/modifiers/contracts.py @@ -0,0 +1,202 @@ +"""Side-effect-free contracts for canonical OMOP modifier rows. + +OMOP represents a modifier as an ordinary Measurement or Observation carrying +a polymorphic link to another row. Both ends of that relationship have scoped +identities: + +* a modifier ID is unique only within its source table; and +* a target event ID is meaningful only alongside the OMOP Field concept naming + the target table's primary-key field. + +The contracts below preserve those scopes after heterogeneous source tables are +projected into one query. They intentionally contain no ORM model registry; +physical model metadata lives in :mod:`.metadata`, while this module remains a +small vocabulary for rows, identities, selection, and diagnostics. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, datetime +from enum import StrEnum +from typing import Any, Mapping, Protocol, runtime_checkable + + +class ModifierColumn(StrEnum): + """Stable labels emitted by Measurement/Observation modifier projections. + + Native columns such as ``measurement_event_id`` and + ``observation_event_id`` converge on these names so downstream query code + never needs to branch on the physical modifier source. + """ + + person_id = "person_id" + modifier_id = "modifier_id" + modifier_date = "modifier_date" + modifier_datetime = "modifier_datetime" + modifier_concept_id = "modifier_concept_id" + modifier_source_table = "modifier_source_table" + target_event_id = "target_event_id" + target_field_concept_id = "target_field_concept_id" + value_as_number = "value_as_number" + value_as_concept_id = "value_as_concept_id" + unit_concept_id = "unit_concept_id" + value_as_string = "value_as_string" + + +CANONICAL_MODIFIER_REQUIRED_COLUMNS: tuple[ModifierColumn, ...] = ( + ModifierColumn.person_id, + ModifierColumn.modifier_id, + ModifierColumn.modifier_date, + ModifierColumn.modifier_datetime, + ModifierColumn.modifier_concept_id, + ModifierColumn.modifier_source_table, + ModifierColumn.target_event_id, + ModifierColumn.target_field_concept_id, +) +"""Columns required to identify, date, classify, and target every modifier. + +``modifier_source_table`` scopes ``modifier_id``; +``target_field_concept_id`` scopes ``target_event_id``. Although OMOP permits +the two target fields to be null on unbound rows, their columns are required in +the projection so validation can report that state explicitly. +""" + +CANONICAL_MODIFIER_VALUE_COLUMNS: tuple[ModifierColumn, ...] = ( + ModifierColumn.value_as_number, + ModifierColumn.value_as_concept_id, + ModifierColumn.unit_concept_id, + ModifierColumn.value_as_string, +) +"""Nullable OMOP value representations emitted in a fixed union order. + +A particular source row usually populates only one representation. Keeping all +four positions avoids source-specific union shapes and does not imply that the +representations are interchangeable. +""" + + +@runtime_checkable +class ModifierRow(Protocol): + """Structural typing contract for the canonical identity and clinical row.""" + + person_id: int + modifier_id: int + modifier_date: date + modifier_datetime: datetime | None + modifier_concept_id: int + modifier_source_table: str + target_event_id: int | None + target_field_concept_id: int | None + + +@runtime_checkable +class ValuedModifierRow(ModifierRow, Protocol): + """Canonical modifier row extended with all nullable value representations.""" + + value_as_number: float | None + value_as_concept_id: int | None + unit_concept_id: int | None + value_as_string: str | None + + +@dataclass(frozen=True, order=True, slots=True) +class ModifierIdentity: + """Source-table-scoped identity of the modifier row itself.""" + + modifier_source_table: str + modifier_id: int + + def __post_init__(self) -> None: + if not self.modifier_source_table.strip(): + raise ValueError("modifier_source_table must not be empty") + + +@dataclass(frozen=True, order=True, slots=True) +class ModifierTargetIdentity: + """Field-concept-scoped identity of the row being modified.""" + + target_field_concept_id: int + target_event_id: int + + +class ModifierSelectionPolicy(StrEnum): + """Supported temporal directions after any caller-supplied priority.""" + + earliest = "earliest" + latest = "latest" + + +@dataclass(frozen=True, slots=True) +class ModifierSelectionSpec: + """Deterministic selection policy within a modifier target partition.""" + + policy: ModifierSelectionPolicy = ModifierSelectionPolicy.earliest + partition_by: tuple[str, ...] = ( + str(ModifierColumn.person_id), + str(ModifierColumn.target_field_concept_id), + str(ModifierColumn.target_event_id), + ) + date_column: str = str(ModifierColumn.modifier_date) + datetime_column: str = str(ModifierColumn.modifier_datetime) + stable_identity_columns: tuple[str, ...] = ( + str(ModifierColumn.modifier_source_table), + str(ModifierColumn.modifier_id), + ) + + def __post_init__(self) -> None: + names = ( + *self.partition_by, + self.date_column, + self.datetime_column, + *self.stable_identity_columns, + ) + if not self.partition_by or not self.stable_identity_columns: + raise ValueError("partition and stable identity columns must not be empty") + if any(not name.strip() for name in names): + raise ValueError("selection column names must not be empty") + if len(set(self.partition_by)) != len(self.partition_by): + raise ValueError("partition_by columns must be unique") + + +class ModifierTargetDiagnosticCode(StrEnum): + """Reasons a canonical modifier could not resolve to its supplied target.""" + + missing_target_identity = "missing_target_identity" + unsupported_target_field = "unsupported_target_field" + missing_target_event = "missing_target_event" + person_mismatch = "person_mismatch" + + +class ModifierTargetDiagnosticColumn(StrEnum): + """Stable output labels for advisory target-resolution diagnostics.""" + + diagnostic_code = "diagnostic_code" + modifier_source_table = "modifier_source_table" + modifier_id = "modifier_id" + target_field_concept_id = "target_field_concept_id" + target_event_id = "target_event_id" + message = "message" + + +@dataclass(frozen=True, slots=True) +class ModifierTargetDiagnostic: + """Typed value representation of one target-resolution diagnostic row.""" + + diagnostic_code: ModifierTargetDiagnosticCode + modifier_source_table: str + modifier_id: int + target_field_concept_id: int | None + target_event_id: int | None + message: str + + @classmethod + def from_mapping(cls, row: Mapping[str, Any]) -> ModifierTargetDiagnostic: + return cls( + diagnostic_code=ModifierTargetDiagnosticCode(row["diagnostic_code"]), + modifier_source_table=str(row["modifier_source_table"]), + modifier_id=int(row["modifier_id"]), + target_field_concept_id=row["target_field_concept_id"], + target_event_id=row["target_event_id"], + message=str(row["message"]), + ) diff --git a/omop_alchemy/toolkit/core/modifiers/metadata.py b/omop_alchemy/toolkit/core/modifiers/metadata.py new file mode 100644 index 0000000..21cec57 --- /dev/null +++ b/omop_alchemy/toolkit/core/modifiers/metadata.py @@ -0,0 +1,187 @@ +"""Explicit model metadata for canonical OMOP modifier queries. + +The row contracts in :mod:`.contracts` describe data after projection and must +remain independent of mapped ORM models. This module owns the separate question +of which models may produce or receive those rows. + +Two principles keep these registries small and predictable: + +* support is explicit and immutable; mapper discovery and subclass walks would + make accepted models depend on import order; and +* metadata already governed by the clinical-event layer is derived from that + layer rather than copied here. + +Measurement and Observation are the only initial modifier sources. Their own +event identity, date, datetime, concept, and source-table metadata comes from +``clinical_event_model_spec``. Both models already expose the target link using +the common ``modifier_of_event_id`` and ``modifier_of_field_concept_id`` hybrid +attributes, so this registry does not repeat their different physical column +names. + +The six clinical modifier targets are derived from the stable CDM clinical-event +registry. Episode is the sole explicit extension because it is a valid OMOP +modifier target but deliberately is not a clinical-event projection. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from types import MappingProxyType +from typing import Any, Mapping + +from omop_alchemy.cdm.base import ModifierFieldConcepts +from omop_alchemy.cdm.model.clinical import Measurement, Observation +from omop_alchemy.cdm.model.clinical.event_metadata import ( + CLINICAL_EVENT_TARGETS_BY_TABLE, +) +from omop_alchemy.cdm.model.structural import Episode +from omop_alchemy.toolkit.core.events import clinical_event_model_spec + + +MODIFIER_TARGET_EVENT_ATTRIBUTE = "modifier_of_event_id" +MODIFIER_TARGET_FIELD_ATTRIBUTE = "modifier_of_field_concept_id" + + +class UnsupportedModifierSourceModelError(TypeError): + """Raised when a model cannot provide a canonical modifier projection.""" + + def __init__(self, model: object, reason: str) -> None: + name = getattr(model, "__name__", repr(model)) + super().__init__(f"{name} is not a supported modifier model: {reason}") + + +class UnsupportedModifierTargetError(TypeError): + """Raised when a model cannot be a canonical modifier target.""" + + +@dataclass(frozen=True, slots=True) +class ModifierSourceModelSpec: + """Native model attributes used to emit one canonical modifier row.""" + + modifier_id_attribute: str + modifier_date_attribute: str + modifier_datetime_attribute: str + modifier_concept_id_attribute: str + target_event_id_attribute: str + target_field_concept_id_attribute: str + modifier_source_table: str + + +@dataclass(frozen=True, slots=True) +class ModifierTargetModelSpec: + """Native target identity and its canonical OMOP Field discriminator.""" + + event_id_attribute: str + event_field_concept_id: int + event_source_table: str + uses_clinical_event_projection: bool + + +def _source_spec(model: type[Any]) -> ModifierSourceModelSpec: + """Derive shared source fields while retaining an explicit support list.""" + event = clinical_event_model_spec(model) + if event.event_datetime_column is None: + raise TypeError(f"{model.__name__} must expose an event datetime column") + return ModifierSourceModelSpec( + modifier_id_attribute=event.event_id_column, + modifier_date_attribute=event.event_date_column, + modifier_datetime_attribute=event.event_datetime_column, + modifier_concept_id_attribute=event.event_concept_id_column, + target_event_id_attribute=MODIFIER_TARGET_EVENT_ATTRIBUTE, + target_field_concept_id_attribute=MODIFIER_TARGET_FIELD_ATTRIBUTE, + modifier_source_table=event.event_source_table, + ) + + +# This tuple is the deliberate modifier-source allow-list. The detailed column +# metadata is derived rather than restated below. +SUPPORTED_MODIFIER_MODELS: tuple[type[Any], ...] = (Measurement, Observation) + +MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE: Mapping[str, ModifierSourceModelSpec] = ( + MappingProxyType( + { + model.__tablename__: _source_spec(model) + for model in SUPPORTED_MODIFIER_MODELS + } + ) +) + + +def _clinical_target_spec(target: type[Any]) -> ModifierTargetModelSpec: + return ModifierTargetModelSpec( + event_id_attribute=target.__event_id_col__, + event_field_concept_id=target.modifier_field_concept_id(), + event_source_table=target.modifier_target_table(), + uses_clinical_event_projection=True, + ) + + +# Clinical target definitions remain single-sourced in event_metadata. Episode +# extends that surface explicitly without being registered as a clinical event. +MODIFIER_TARGET_SPECS_BY_TABLE: Mapping[str, ModifierTargetModelSpec] = ( + MappingProxyType( + { + **{ + table_name: _clinical_target_spec(target) + for table_name, target in CLINICAL_EVENT_TARGETS_BY_TABLE.items() + }, + Episode.__tablename__: ModifierTargetModelSpec( + event_id_attribute="episode_id", + event_field_concept_id=ModifierFieldConcepts.EPISODE, + event_source_table=Episode.__tablename__, + uses_clinical_event_projection=False, + ), + } + ) +) + + +def modifier_source_model_spec(model: type[Any]) -> ModifierSourceModelSpec: + """Resolve and validate immutable metadata for a modifier source model.""" + if not isinstance(model, type) or not hasattr(model, "__table__"): + raise UnsupportedModifierSourceModelError( + model, "expected a mapped ORM model class" + ) + spec = MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE.get( + str(getattr(model, "__tablename__", "")) + ) + if spec is None: + raise UnsupportedModifierSourceModelError( + model, "only Measurement and Observation carry the OMOP modifier link" + ) + required = ( + "person_id", + spec.modifier_id_attribute, + spec.modifier_date_attribute, + spec.modifier_datetime_attribute, + spec.modifier_concept_id_attribute, + spec.target_event_id_attribute, + spec.target_field_concept_id_attribute, + ) + missing = tuple(name for name in required if not hasattr(model, name)) + if missing: + raise UnsupportedModifierSourceModelError( + model, f"missing required attributes: {', '.join(missing)}" + ) + return spec + + +def modifier_target_model_spec(model: type[Any]) -> ModifierTargetModelSpec: + """Resolve and validate immutable metadata for a modifier target model.""" + if not isinstance(model, type) or not hasattr(model, "__table__"): + raise UnsupportedModifierTargetError("expected a mapped ORM model class") + spec = MODIFIER_TARGET_SPECS_BY_TABLE.get(str(getattr(model, "__tablename__", ""))) + if spec is None: + raise UnsupportedModifierTargetError( + f"{model.__name__} is not a supported modifier target" + ) + missing = tuple( + name + for name in ("person_id", spec.event_id_attribute) + if not hasattr(model, name) + ) + if missing: + raise UnsupportedModifierTargetError( + f"{model.__name__} is missing required attributes: {', '.join(missing)}" + ) + return spec diff --git a/omop_alchemy/toolkit/core/modifiers/projections.py b/omop_alchemy/toolkit/core/modifiers/projections.py new file mode 100644 index 0000000..a771da7 --- /dev/null +++ b/omop_alchemy/toolkit/core/modifiers/projections.py @@ -0,0 +1,72 @@ +"""Canonical SQLAlchemy projections for OMOP modifier-bearing tables.""" + +from __future__ import annotations + +from typing import Any + +import sqlalchemy as sa + +from .contracts import ModifierColumn +from .metadata import modifier_source_model_spec + + +def _nullable( + model: type[Any], name: ModifierColumn, sql_type: sa.types.TypeEngine[Any] +) -> sa.ColumnElement[Any]: + column = getattr(model, str(name), None) + if column is None: + return sa.cast(sa.null(), sql_type).label(str(name)) + return column.label(str(name)) + + +def canonical_modifier_projection( + model: type[Any], *, include_values: bool = True +) -> sa.Select[Any]: + """Project Measurement or Observation into one modifier row shape.""" + spec = modifier_source_model_spec(model) + columns: list[sa.ColumnElement[Any]] = [ + model.person_id.label(str(ModifierColumn.person_id)), + getattr(model, spec.modifier_id_attribute).label( + str(ModifierColumn.modifier_id) + ), + getattr(model, spec.modifier_date_attribute).label( + str(ModifierColumn.modifier_date) + ), + getattr(model, spec.modifier_datetime_attribute).label( + str(ModifierColumn.modifier_datetime) + ), + getattr(model, spec.modifier_concept_id_attribute).label( + str(ModifierColumn.modifier_concept_id) + ), + sa.literal(spec.modifier_source_table).label( + str(ModifierColumn.modifier_source_table) + ), + getattr(model, spec.target_event_id_attribute).label( + str(ModifierColumn.target_event_id) + ), + getattr(model, spec.target_field_concept_id_attribute).label( + str(ModifierColumn.target_field_concept_id) + ), + ] + if include_values: + columns.extend( + ( + _nullable(model, ModifierColumn.value_as_number, sa.Float()), + _nullable(model, ModifierColumn.value_as_concept_id, sa.Integer()), + _nullable(model, ModifierColumn.unit_concept_id, sa.Integer()), + _nullable(model, ModifierColumn.value_as_string, sa.String()), + ) + ) + return sa.select(*columns) + + +def canonical_modifier_union( + *models: type[Any], include_values: bool = True +) -> sa.Select[Any] | sa.CompoundSelect[Any]: + if not models: + raise ValueError("canonical_modifier_union requires at least one model") + projections = [ + canonical_modifier_projection(model, include_values=include_values) + for model in models + ] + return projections[0] if len(projections) == 1 else sa.union_all(*projections) diff --git a/omop_alchemy/toolkit/core/modifiers/selection.py b/omop_alchemy/toolkit/core/modifiers/selection.py new file mode 100644 index 0000000..c639eb7 --- /dev/null +++ b/omop_alchemy/toolkit/core/modifiers/selection.py @@ -0,0 +1,124 @@ +"""Deterministic selection of one canonical modifier per target partition.""" + +from __future__ import annotations + +from collections.abc import Sequence +from typing import Any + +import sqlalchemy as sa +from sqlalchemy.sql.selectable import FromClause, SelectBase + +from omop_alchemy.toolkit.core._ranking import deterministic_row_number + +from .contracts import ModifierColumn, ModifierSelectionPolicy, ModifierSelectionSpec + +MODIFIER_RANK = "modifier_rank" + + +class InvalidModifierSourceError(ValueError): + pass + + +def _as_source(source: FromClause | SelectBase, name: str) -> FromClause: + if isinstance(source, SelectBase): + return source.subquery(name) + if isinstance(source, FromClause): + return source + raise TypeError("source must be a SQLAlchemy Select or FromClause") + + +def modifier_order_expressions( + columns: sa.sql.base.ReadOnlyColumnCollection[str, Any], + spec: ModifierSelectionSpec, + *, + priority: Sequence[sa.ColumnElement[Any]] = (), +) -> tuple[sa.ColumnElement[Any], ...]: + """Return the complete, portable order for a modifier selection policy.""" + required = { + *spec.partition_by, + spec.date_column, + spec.datetime_column, + *spec.stable_identity_columns, + } + missing = tuple(sorted(required.difference(columns.keys()))) + if missing: + raise InvalidModifierSourceError( + f"modifier source is missing required columns: {', '.join(missing)}" + ) + direction = sa.desc if spec.policy is ModifierSelectionPolicy.latest else sa.asc + date_column = columns[spec.date_column] + datetime_column = columns[spec.datetime_column] + return ( + *priority, + date_column.is_(None).asc(), + direction(date_column), + datetime_column.is_(None).asc(), + direction(datetime_column), + *(columns[name].asc() for name in spec.stable_identity_columns), + ) + + +def modifier_row_number( + columns: sa.sql.base.ReadOnlyColumnCollection[str, Any], + spec: ModifierSelectionSpec, + *, + priority: Sequence[sa.ColumnElement[Any]] = (), + label: str = MODIFIER_RANK, +) -> sa.ColumnElement[int]: + """Build the deterministic window rank for canonical modifier columns.""" + order_by = modifier_order_expressions(columns, spec, priority=priority) + return deterministic_row_number( + partition_by=(columns[name] for name in spec.partition_by), + order_by=order_by, + label=label, + ) + + +def ranked_modifier_select( + source: FromClause | SelectBase, + spec: ModifierSelectionSpec = ModifierSelectionSpec(), + *, + priority: Sequence[sa.ColumnElement[Any]] = (), + rank_label: str = MODIFIER_RANK, +) -> sa.Select[Any]: + """Rank bound modifiers, with caller priorities preceding temporal policy.""" + modifiers = _as_source(source, "modifier_selection_source") + required = { + *spec.partition_by, + spec.date_column, + spec.datetime_column, + *spec.stable_identity_columns, + str(ModifierColumn.target_event_id), + str(ModifierColumn.target_field_concept_id), + } + missing = tuple(sorted(required.difference(modifiers.c.keys()))) + if missing: + raise InvalidModifierSourceError( + f"modifier source is missing required columns: {', '.join(missing)}" + ) + + rank = modifier_row_number( + modifiers.c, + spec, + priority=priority, + label=rank_label, + ) + return sa.select(*modifiers.c, rank).where( + modifiers.c[str(ModifierColumn.target_event_id)].is_not(None), + modifiers.c[str(ModifierColumn.target_field_concept_id)].is_not(None), + ) + + +def selected_modifier_select( + source: FromClause | SelectBase, + spec: ModifierSelectionSpec = ModifierSelectionSpec(), + *, + priority: Sequence[sa.ColumnElement[Any]] = (), +) -> sa.Select[Any]: + """Select the first deterministically ranked modifier in each partition.""" + ranked = ranked_modifier_select(source, spec=spec, priority=priority).subquery( + "ranked_modifiers" + ) + return sa.select( + *(column for column in ranked.c if column.key != MODIFIER_RANK) + ).where(ranked.c[MODIFIER_RANK] == 1) diff --git a/omop_alchemy/toolkit/core/modifiers/targets.py b/omop_alchemy/toolkit/core/modifiers/targets.py new file mode 100644 index 0000000..f4eb9a1 --- /dev/null +++ b/omop_alchemy/toolkit/core/modifiers/targets.py @@ -0,0 +1,211 @@ +"""Validate canonical modifier links against supported OMOP target tables.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import sqlalchemy as sa +from sqlalchemy.sql.selectable import FromClause, SelectBase + +from omop_alchemy.toolkit.core.events import ( + ClinicalEventColumn, + canonical_event_projection, +) + +from .contracts import ( + CANONICAL_MODIFIER_REQUIRED_COLUMNS, + ModifierColumn, + ModifierTargetDiagnosticCode, + ModifierTargetDiagnosticColumn, +) +from .metadata import modifier_target_model_spec +from .projections import canonical_modifier_projection + +RESOLVED_TARGET_PERSON_ID = "resolved_target_person_id" +RESOLVED_TARGET_EVENT_ID = "resolved_target_event_id" +RESOLVED_TARGET_FIELD_CONCEPT_ID = "resolved_target_field_concept_id" +RESOLVED_TARGET_SOURCE_TABLE = "resolved_target_source_table" + + +class InvalidModifierTargetSourceError(ValueError): + """Raised when a supplied projection lacks canonical columns.""" + + +@dataclass(frozen=True, slots=True) +class ModifierTargetQueries: + matches: sa.Select[Any] + diagnostics: SelectBase | None = None + + +def canonical_modifier_target_projection(model: type[Any]) -> sa.Select[Any]: + """Project a supported clinical event or Episode to target identity columns.""" + spec = modifier_target_model_spec(model) + if spec.uses_clinical_event_projection: + event = canonical_event_projection(model, include_values=False).subquery( + "modifier_target_event" + ) + return sa.select( + event.c[str(ClinicalEventColumn.person_id)], + event.c[str(ClinicalEventColumn.event_id)], + event.c[str(ClinicalEventColumn.event_field_concept_id)], + event.c[str(ClinicalEventColumn.event_source_table)], + ) + return sa.select( + model.person_id.label(str(ClinicalEventColumn.person_id)), + getattr(model, spec.event_id_attribute).label( + str(ClinicalEventColumn.event_id) + ), + sa.literal(spec.event_field_concept_id).label( + str(ClinicalEventColumn.event_field_concept_id) + ), + sa.literal(spec.event_source_table).label( + str(ClinicalEventColumn.event_source_table) + ), + ) + + +def _as_source(source: FromClause | SelectBase, name: str) -> FromClause: + if isinstance(source, SelectBase): + return source.subquery(name) + if isinstance(source, FromClause): + return source + raise TypeError(f"{name} must be a SQLAlchemy Select or FromClause") + + +def _require(source: FromClause, columns: tuple[str, ...], role: str) -> None: + missing = tuple(name for name in columns if name not in source.c) + if missing: + raise InvalidModifierTargetSourceError( + f"{role} is missing required columns: {', '.join(missing)}" + ) + + +def modifier_target_queries( + modifier_source: type[Any] | FromClause | SelectBase, + target_source: type[Any] | FromClause | SelectBase, + *, + include_unmatched: bool = False, + diagnostics: bool = False, +) -> ModifierTargetQueries: + """Resolve valid target links and optionally explain rejected modifier rows.""" + modifiers = ( + canonical_modifier_projection(modifier_source).subquery("target_modifiers") + if isinstance(modifier_source, type) + else _as_source(modifier_source, "target_modifiers") + ) + target_spec = ( + modifier_target_model_spec(target_source) + if isinstance(target_source, type) + else None + ) + targets = ( + canonical_modifier_target_projection(target_source).subquery("modifier_targets") + if isinstance(target_source, type) + else _as_source(target_source, "modifier_targets") + ) + _require( + modifiers, + tuple(str(column) for column in CANONICAL_MODIFIER_REQUIRED_COLUMNS), + "modifier source", + ) + _require( + targets, + tuple( + str(column) + for column in ( + ClinicalEventColumn.person_id, + ClinicalEventColumn.event_id, + ClinicalEventColumn.event_field_concept_id, + ClinicalEventColumn.event_source_table, + ) + ), + "target source", + ) + + person = str(ModifierColumn.person_id) + event_id = str(ClinicalEventColumn.event_id) + event_field = str(ClinicalEventColumn.event_field_concept_id) + target_id = str(ModifierColumn.target_event_id) + target_field = str(ModifierColumn.target_field_concept_id) + valid_link = sa.and_( + modifiers.c[target_id] == targets.c[event_id], + modifiers.c[target_field] == targets.c[event_field], + modifiers.c[person] == targets.c[person], + ) + joined = modifiers.join(targets, valid_link, isouter=include_unmatched) + matches = sa.select( + *modifiers.c, + targets.c[person].label(RESOLVED_TARGET_PERSON_ID), + targets.c[event_id].label(RESOLVED_TARGET_EVENT_ID), + targets.c[event_field].label(RESOLVED_TARGET_FIELD_CONCEPT_ID), + targets.c[str(ClinicalEventColumn.event_source_table)].label( + RESOLVED_TARGET_SOURCE_TABLE + ), + ).select_from(joined) + + if not diagnostics: + return ModifierTargetQueries(matches=matches) + + has_identity = sa.and_( + modifiers.c[target_id].is_not(None), modifiers.c[target_field].is_not(None) + ) + any_event = sa.exists( + sa.select(1) + .select_from(targets) + .where( + targets.c[event_id] == modifiers.c[target_id], + targets.c[event_field] == modifiers.c[target_field], + ) + ) + same_person = sa.exists(sa.select(1).select_from(targets).where(valid_link)) + + def branch( + code: ModifierTargetDiagnosticCode, + condition: sa.ColumnElement[bool], + message: str, + ) -> sa.Select[Any]: + return sa.select( + sa.literal(str(code)).label( + str(ModifierTargetDiagnosticColumn.diagnostic_code) + ), + modifiers.c[str(ModifierColumn.modifier_source_table)], + modifiers.c[str(ModifierColumn.modifier_id)], + modifiers.c[target_field], + modifiers.c[target_id], + sa.literal(message).label(str(ModifierTargetDiagnosticColumn.message)), + ).where(condition) + + branches: list[sa.Select[Any]] = [ + branch( + ModifierTargetDiagnosticCode.missing_target_identity, + sa.not_(has_identity), + "modifier does not identify both a target row and target field", + ) + ] + if target_spec is not None: + supported = modifiers.c[target_field] == target_spec.event_field_concept_id + branches.append( + branch( + ModifierTargetDiagnosticCode.unsupported_target_field, + sa.and_(has_identity, sa.not_(supported)), + "target field concept is not supported by the supplied target", + ) + ) + else: + supported = sa.true() + branches.extend( + ( + branch( + ModifierTargetDiagnosticCode.missing_target_event, + sa.and_(has_identity, supported, sa.not_(any_event)), + "the identified target event does not exist", + ), + branch( + ModifierTargetDiagnosticCode.person_mismatch, + sa.and_(has_identity, supported, any_event, sa.not_(same_person)), + "modifier and target event belong to different people", + ), + ) + ) + return ModifierTargetQueries(matches=matches, diagnostics=sa.union_all(*branches)) diff --git a/pyproject.toml b/pyproject.toml index aa97601..c6d4c19 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,8 @@ postgres = [ ] semantics = [ - "omop-semantics>=0.6.0", + # First release containing condition_modifiers.metastatic_disease_concepts. + "omop-semantics>=0.6.1", ] dev = [ diff --git a/tests/test_concept_groups.py b/tests/test_concept_groups.py index 452f606..ee55a8d 100644 --- a/tests/test_concept_groups.py +++ b/tests/test_concept_groups.py @@ -13,6 +13,7 @@ from omop_alchemy.toolkit.core.concepts import ( ConceptGroupSpec, + SemanticUnitRef, build_concept_group, clear_concept_group_cache, concept_group_cache_stats, @@ -49,6 +50,7 @@ def _clean_cache(): # ── laziness ──────────────────────────────────────────────────────────────── + def test_importing_oncology_touches_no_database_or_semantics(): """The oncology package must import with no database and no semantics runtime. @@ -82,6 +84,25 @@ def __getattr__(self, name): ConceptGroupSpec(name="lazy", unit=Exploding()) +def test_semantic_unit_reference_is_a_lazy_complete_role_adapter(): + pytest.importorskip("omop_semantics") + from omop_semantics.runtime.default_valuesets import runtime + + ref = SemanticUnitRef("condition_modifiers", "metastatic_disease_concepts") + + assert ref.parent_ids == { + runtime.condition_modifiers.condition_modifier_values.metastatic_disease + } + assert ref.excluded_parent_ids == set() + assert ref.exact_ids == set() + + +@pytest.mark.parametrize(("value_set", "unit"), [("", "unit"), ("set", "")]) +def test_semantic_unit_reference_rejects_empty_paths(value_set: str, unit: str): + with pytest.raises(ValueError, match="must not be empty"): + SemanticUnitRef(value_set, unit) + + # ── resolution semantics ──────────────────────────────────────────────────── # The fixture vocabulary ships an empty concept_ancestor, so these tests insert # the ancestry they need. They call build_concept_group directly rather than @@ -162,6 +183,7 @@ def test_membership_rejects_none(session): # ── the two access paths agree ────────────────────────────────────────────── + def test_python_and_sql_paths_agree(session): """The instance and expression forms must select the same concepts. @@ -204,6 +226,7 @@ def test_empty_group_expression_is_false(): # ── caching ───────────────────────────────────────────────────────────────── + def test_expansion_is_cached_per_vocabulary(session): """A second request must not rebuild.""" spec = _spec(name="cached", parents=(), exact=(1,)) @@ -221,7 +244,9 @@ def test_registered_identity_is_shared_across_engines(session): try: registry_a = concept_group_registry(session) with so.Session(engine) as other_session: - register_vocabulary_identity(other_session.get_bind().engine, "test-vocab-identity") + register_vocabulary_identity( + other_session.get_bind().engine, "test-vocab-identity" + ) registry_b = concept_group_registry(other_session) assert registry_a is registry_b finally: @@ -254,11 +279,14 @@ def test_connection_bound_sessions_share_their_engine_scope(session): engine = session.get_bind().engine with engine.connect() as connection: with so.Session(bind=connection) as conn_session: - assert concept_group_registry(conn_session) is concept_group_registry(session) + assert concept_group_registry(conn_session) is concept_group_registry( + session + ) # ── bounded cache and its observability ───────────────────────────────────── + def test_eviction_is_bounded_and_counted(session): """A too-small bound must evict, and a rebuild after eviction must be counted. @@ -297,12 +325,13 @@ def test_cache_stats_are_reportable(session): assert all("rebuilds_after_evict" in v for v in stats.values()) -# ── governed specs use the non-deprecated 0.6.0 surface ───────────────────── +# ── governed specs use the non-deprecated 0.6+ surface ────────────────────── + def test_governed_specs_emit_no_deprecation_warnings(): - """The oncology specs must read 0.6.0's role-specific accessors. + """The oncology specs must read the 0.6+ role-specific accessors. - Guards against slipping back to group-backed `.ids`, which 0.6.0 deprecates + Guards against slipping back to group-backed `.ids`, which 0.6 deprecates because it does not say whether members expand through descendants. """ pytest.importorskip("omop_semantics") @@ -347,13 +376,12 @@ def test_radiotherapy_spec_matches_governed_group(): # ConceptFilter: require_standard / include_classification. These pin that the # names mean the same thing here as everywhere else in the package. + def _rendered(spec) -> str: """Compile the group's membership predicate to inspectable SQL.""" column = sa.column("concept_id") return str( - spec.expression_for(column).compile( - compile_kwargs={"literal_binds": True} - ) + spec.expression_for(column).compile(compile_kwargs={"literal_binds": True}) ).lower() diff --git a/tests/test_modifier_projections.py b/tests/test_modifier_projections.py new file mode 100644 index 0000000..844fc55 --- /dev/null +++ b/tests/test_modifier_projections.py @@ -0,0 +1,179 @@ +"""Canonical modifier projection and target-resolution contracts.""" + +from __future__ import annotations + +import pytest +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql, sqlite + +from omop_alchemy.cdm.base import ModifierFieldConcepts +from omop_alchemy.cdm.model import ( + Condition_Occurrence, + Device_Exposure, + Drug_Exposure, + Measurement, + Observation, + Person, + Procedure_Occurrence, +) +from omop_alchemy.cdm.model.structural import Episode +from omop_alchemy.cdm.model.clinical.event_metadata import ( + CLINICAL_EVENT_TARGETS_BY_TABLE, +) +from omop_alchemy.toolkit.core.modifiers import ( + CANONICAL_MODIFIER_REQUIRED_COLUMNS, + CANONICAL_MODIFIER_VALUE_COLUMNS, + MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE, + MODIFIER_TARGET_SPECS_BY_TABLE, + UnsupportedModifierSourceModelError, + canonical_modifier_projection, + canonical_modifier_target_projection, + canonical_modifier_union, + modifier_target_model_spec, + modifier_target_queries, +) + + +@pytest.mark.parametrize( + ("model", "source_table"), + [(Measurement, "measurement"), (Observation, "observation")], +) +def test_modifier_projection_has_one_stable_shape(model, source_table: str): + statement = canonical_modifier_projection(model) + + assert tuple(statement.selected_columns.keys()) == tuple( + map( + str, + CANONICAL_MODIFIER_REQUIRED_COLUMNS + CANONICAL_MODIFIER_VALUE_COLUMNS, + ) + ) + compiled = str( + statement.compile( + dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True} + ) + ) + assert f"'{source_table}' AS modifier_source_table" in compiled + + +def test_modifier_union_preserves_shape_and_all_rows(): + statement = canonical_modifier_union(Measurement, Observation) + + assert tuple(statement.selected_columns.keys()) == tuple( + map( + str, + CANONICAL_MODIFIER_REQUIRED_COLUMNS + CANONICAL_MODIFIER_VALUE_COLUMNS, + ) + ) + assert "UNION ALL" in str(statement.compile(dialect=sqlite.dialect())) + + +def test_non_modifier_model_fails_at_query_construction(): + with pytest.raises(UnsupportedModifierSourceModelError, match="only Measurement"): + canonical_modifier_projection(Person) + + +def test_modifier_metadata_reuses_generic_model_interfaces(): + assert set(MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE) == {"measurement", "observation"} + for spec in MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE.values(): + assert spec.target_event_id_attribute == "modifier_of_event_id" + assert spec.target_field_concept_id_attribute == ( + "modifier_of_field_concept_id" + ) + + +def test_modifier_targets_derive_the_clinical_surface_and_extend_it_with_episode(): + assert set(MODIFIER_TARGET_SPECS_BY_TABLE) == { + *CLINICAL_EVENT_TARGETS_BY_TABLE, + "episode", + } + assert all( + MODIFIER_TARGET_SPECS_BY_TABLE[table].uses_clinical_event_projection + for table in CLINICAL_EVENT_TARGETS_BY_TABLE + ) + assert not MODIFIER_TARGET_SPECS_BY_TABLE["episode"].uses_clinical_event_projection + + +def test_episode_is_a_modifier_target_without_becoming_a_clinical_event(): + spec = modifier_target_model_spec(Episode) + projection = canonical_modifier_target_projection(Episode) + + assert spec.event_field_concept_id == ModifierFieldConcepts.EPISODE + assert tuple(projection.selected_columns.keys()) == ( + "person_id", + "event_id", + "event_field_concept_id", + "event_source_table", + ) + + +@pytest.mark.parametrize( + "target", + [ + Condition_Occurrence, + Device_Exposure, + Drug_Exposure, + Measurement, + Observation, + Procedure_Occurrence, + Episode, + ], +) +def test_target_resolution_queries_compile_for_supported_models(target): + queries = modifier_target_queries( + Measurement, target, include_unmatched=True, diagnostics=True + ) + + assert queries.diagnostics is not None + for dialect in (sqlite.dialect(), postgresql.dialect()): + assert "resolved_target_event_id" in str( + queries.matches.compile(dialect=dialect) + ) + assert "diagnostic_code" in str(queries.diagnostics.compile(dialect=dialect)) + + +def test_target_validation_rejects_missing_and_cross_person_links(): + def modifier( + modifier_id: int, + person_id: int, + target_id: int | None, + target_field: int | None, + ): + return sa.select( + sa.literal(person_id).label("person_id"), + sa.literal(modifier_id).label("modifier_id"), + sa.literal("2025-01-01").label("modifier_date"), + sa.cast(sa.null(), sa.DateTime()).label("modifier_datetime"), + sa.literal(100).label("modifier_concept_id"), + sa.literal("measurement").label("modifier_source_table"), + sa.literal(target_id).label("target_event_id"), + sa.literal(target_field).label("target_field_concept_id"), + ) + + modifiers = sa.union_all( + modifier(1, 10, 7, ModifierFieldConcepts.CONDITION_OCCURRENCE), + modifier(2, 10, None, None), + modifier(3, 10, 8, ModifierFieldConcepts.CONDITION_OCCURRENCE), + modifier(4, 11, 7, ModifierFieldConcepts.CONDITION_OCCURRENCE), + ) + targets = sa.select( + sa.literal(10).label("person_id"), + sa.literal(7).label("event_id"), + sa.literal(ModifierFieldConcepts.CONDITION_OCCURRENCE).label( + "event_field_concept_id" + ), + sa.literal("condition_occurrence").label("event_source_table"), + ) + queries = modifier_target_queries(modifiers, targets, diagnostics=True) + + engine = sa.create_engine("sqlite://") + with engine.connect() as connection: + matches = connection.execute(queries.matches).mappings().all() + assert queries.diagnostics is not None + diagnostics = connection.execute(queries.diagnostics).mappings().all() + + assert [row["modifier_id"] for row in matches] == [1] + assert {(row["modifier_id"], row["diagnostic_code"]) for row in diagnostics} == { + (2, "missing_target_identity"), + (3, "missing_target_event"), + (4, "person_mismatch"), + } diff --git a/tests/test_modifier_selection.py b/tests/test_modifier_selection.py new file mode 100644 index 0000000..1bec005 --- /dev/null +++ b/tests/test_modifier_selection.py @@ -0,0 +1,189 @@ +"""Deterministic modifier selection and oncology stage preferences.""" + +from __future__ import annotations + +from datetime import date, datetime + +import pytest +import sqlalchemy as sa + +from omop_alchemy.toolkit.analytics.oncology import ( + DEFAULT_STAGE_SELECTION, + GROUP_STAGE_CONCEPTS, + M_STAGE_CONCEPTS, + METASTATIC_DISEASE_CONCEPTS, + N_STAGE_CONCEPTS, + StageBasis, + StageSelectionSpec, + T_STAGE_CONCEPTS, + TUMOR_GRADE_CONCEPTS, + laterality_modifier_concept_id, + preferred_stage_select, + tumor_size_modifier_concept_id, +) +from omop_alchemy.toolkit.core.modifiers import ( + ModifierSelectionPolicy, + selected_modifier_select, +) + + +def _stage_source(*, reverse: bool = False) -> sa.CompoundSelect: + rows = ( + # Clinical is chronologically first, but pathological wins by default. + (1, 10, date(2025, 1, 1), datetime(2025, 1, 1, 9), 100, "cT2"), + (1, 11, date(2025, 2, 1), datetime(2025, 2, 1, 9), 101, "pT2"), + (1, 12, date(2025, 3, 1), datetime(2025, 3, 1, 9), 102, "pT3"), + ) + branches = [] + if reverse: + rows = tuple(reversed(rows)) + for ( + person_id, + modifier_id, + modifier_date, + modifier_datetime, + concept_id, + code, + ) in rows: + branches.append( + sa.select( + sa.literal(person_id).label("person_id"), + sa.literal(modifier_id).label("modifier_id"), + sa.literal(modifier_date).label("modifier_date"), + sa.literal(modifier_datetime).label("modifier_datetime"), + sa.literal(concept_id).label("modifier_concept_id"), + sa.literal("measurement").label("modifier_source_table"), + sa.literal(500).label("target_event_id"), + sa.literal(1147127).label("target_field_concept_id"), + sa.literal(code).label("modifier_concept_code"), + ) + ) + return sa.union_all(*branches) + + +@pytest.mark.parametrize( + ("spec", "expected_id"), + [ + (DEFAULT_STAGE_SELECTION, 11), + (StageSelectionSpec.clinical_first(), 10), + (StageSelectionSpec.chronological_only(), 10), + ( + StageSelectionSpec( + temporal_policy=ModifierSelectionPolicy.latest, + ), + 12, + ), + ], +) +def test_stage_selection_default_and_overrides(spec, expected_id: int): + engine = sa.create_engine("sqlite://") + with engine.connect() as connection: + selected = ( + connection.execute(preferred_stage_select(_stage_source(), spec=spec)) + .mappings() + .one() + ) + + assert selected["modifier_id"] == expected_id + + +def test_stage_selection_spec_is_immutable_and_validates_basis_permutation(): + with pytest.raises(AttributeError): + DEFAULT_STAGE_SELECTION.basis_priority = (StageBasis.clinical,) # type: ignore[misc] + + with pytest.raises(ValueError, match="each StageBasis exactly once"): + StageSelectionSpec(basis_priority=(StageBasis.pathological,)) + + +def test_chronological_policy_does_not_require_a_concept_code_column(): + source = _stage_source().subquery() + without_code = sa.select( + *(column for column in source.c if column.key != "modifier_concept_code") + ) + + preferred_stage_select(without_code, spec=StageSelectionSpec.chronological_only()) + + +def test_same_basis_tie_is_stable_when_input_order_reverses(): + engine = sa.create_engine("sqlite://") + selected_ids = [] + with engine.connect() as connection: + for reverse in (False, True): + source = _stage_source(reverse=reverse).subquery() + tied = sa.select(source).where(source.c.modifier_id.in_((11, 12))) + # Make both pathological candidates a true temporal tie. + tied_source = tied.subquery() + normalized = sa.select( + *( + sa.literal(date(2025, 2, 1)).label(column.key) + if column.key == "modifier_date" + else sa.literal(datetime(2025, 2, 1, 9)).label(column.key) + if column.key == "modifier_datetime" + else column + for column in tied_source.c + ) + ) + selected_ids.append( + connection.execute(preferred_stage_select(normalized)) + .mappings() + .one()["modifier_id"] + ) + + assert selected_ids == [11, 11] + + +def test_same_numeric_target_id_in_two_target_tables_forms_two_partitions(): + source = _stage_source().subquery() + condition = sa.select(source).where(source.c.modifier_id == 10) + procedure = sa.select( + *( + sa.literal(1147082).label(column.key) + if column.key == "target_field_concept_id" + else column + for column in source.c + ) + ).where(source.c.modifier_id == 11) + + engine = sa.create_engine("sqlite://") + with engine.connect() as connection: + rows = ( + connection.execute( + selected_modifier_select(sa.union_all(condition, procedure)) + ) + .mappings() + .all() + ) + + assert {(row["target_field_concept_id"], row["modifier_id"]) for row in rows} == { + (1147127, 10), + (1147082, 11), + } + + +def test_condition_modifier_specs_delegate_to_governed_semantics(): + pytest.importorskip("omop_semantics") + from omop_semantics.runtime.default_valuesets import runtime + + group_pairs = ( + (T_STAGE_CONCEPTS, runtime.staging.t_stage_concepts), + (N_STAGE_CONCEPTS, runtime.staging.n_stage_concepts), + (M_STAGE_CONCEPTS, runtime.staging.m_stage_concepts), + (GROUP_STAGE_CONCEPTS, runtime.staging.group_stage_concepts), + ( + METASTATIC_DISEASE_CONCEPTS, + runtime.condition_modifiers.metastatic_disease_concepts, + ), + ) + for spec, unit in group_pairs: + assert set(spec.parent_ids()) == set(unit.parent_ids) + assert set(TUMOR_GRADE_CONCEPTS.exact_ids()) == set( + runtime.condition_modifiers.tumor_grade.exact_ids + ) + assert ( + laterality_modifier_concept_id() + == runtime.condition_modifiers.condition_modifier_values.laterality + ) + assert ( + tumor_size_modifier_concept_id() + == runtime.condition_modifiers.numeric_condition_modifiers.tumor_size + ) diff --git a/uv.lock b/uv.lock index c8a2436..0dd6a7a 100644 --- a/uv.lock +++ b/uv.lock @@ -1471,7 +1471,7 @@ requires-dist = [ { name = "mkdocstrings-python", marker = "extra == 'dev'", specifier = ">=2.0.1" }, { name = "oa-configurator", specifier = ">=1.0.0,<2.0.0" }, { name = "oa-configurator", extras = ["dev", "postgres"], marker = "extra == 'dev'", specifier = ">=1.0.0,<2.0.0" }, - { name = "omop-semantics", marker = "extra == 'semantics'", specifier = ">=0.6.0" }, + { name = "omop-semantics", marker = "extra == 'semantics'", specifier = ">=0.6.1" }, { name = "orm-loader", specifier = ">=1.2.0,<2.0.0" }, { name = "pandas", specifier = ">=2.0" }, { name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2" }, @@ -1489,7 +1489,7 @@ provides-extras = ["dev", "postgres", "semantics"] [[package]] name = "omop-semantics" -version = "0.6.0" +version = "0.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ipykernel" }, @@ -1500,9 +1500,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/48/728207d2067363675ec6be073bba14bb1523e23895e2cd63b8a7f04e62ca/omop_semantics-0.6.0.tar.gz", hash = "sha256:88043ffaf36feb1eb023816de14d8ea232529d6bdd3c2d67a40e283004369791", size = 203380, upload-time = "2026-08-10T08:15:44.584Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/33/e42a3e0462d4843ec3b2c14731a5949b08a48659e0add053da89f42d1833/omop_semantics-0.6.1.tar.gz", hash = "sha256:5cf96c9cfa890e4d1519a7ad77c04823ab595bf4498bf2c3d5a08edf812c36aa", size = 203651, upload-time = "2026-09-07T01:12:31.86Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/45/38c8620119cabba3681a906a66a9006cb6f217ea9c11e5c7acfdd36ef34f/omop_semantics-0.6.0-py3-none-any.whl", hash = "sha256:a0285d213e2b98923fc46cc9ca16aff539aea289ed6fd040e863aa12ffec2793", size = 66670, upload-time = "2026-08-10T08:15:43.179Z" }, + { url = "https://files.pythonhosted.org/packages/e5/63/a8cfccf0beca66de03bd568a594d42248197bb0c65da30e9e52b610232d6/omop_semantics-0.6.1-py3-none-any.whl", hash = "sha256:aafe8b714de8a9348a8bad5161b6a0eb6d61cced2035daae310cd71c4055dce0", size = 66805, upload-time = "2026-09-07T01:12:30.485Z" }, ] [[package]] From d0f7d76e7bf7edf14907103f5536f71f5756b2c6 Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Mon, 7 Sep 2026 12:24:47 +1000 Subject: [PATCH 15/30] modifiers cleanup --- omop_alchemy/cdm/base/__init__.py | 3 +- omop_alchemy/cdm/base/modifier_interface.py | 43 ++++ .../cdm/model/clinical/event_metadata.py | 24 ++- .../cdm/model/clinical/measurement.py | 13 +- .../cdm/model/clinical/observation.py | 13 +- omop_alchemy/cdm/model/structural/episode.py | 13 +- .../toolkit/core/modifiers/__init__.py | 6 +- .../toolkit/core/modifiers/contracts.py | 15 +- .../toolkit/core/modifiers/metadata.py | 147 +++++-------- .../toolkit/core/modifiers/projections.py | 40 ++-- .../toolkit/core/modifiers/targets.py | 15 +- .../episodes/derivation/observations.py | 7 +- .../toolkit/episodes/derivation/temporal.py | 2 +- tests/test_modifier_projections.py | 200 ++++++++++++++++-- 14 files changed, 356 insertions(+), 185 deletions(-) diff --git a/omop_alchemy/cdm/base/__init__.py b/omop_alchemy/cdm/base/__init__.py index fbbe0b1..f0fd516 100644 --- a/omop_alchemy/cdm/base/__init__.py +++ b/omop_alchemy/cdm/base/__init__.py @@ -7,7 +7,7 @@ from .concept_validation import ConceptValidationMixin from .reference_context import ReferenceContext from .typing import HasConceptId, HasEpisodeId, HasPersonId, DomainSemanticTable -from .modifier_interface import ModifierTargetMixin +from .modifier_interface import ModifierSourceMixin, ModifierTargetMixin from .cdm_constants import ModifierFieldConcepts __all__ = [ @@ -33,6 +33,7 @@ "ConceptValidationMixin", "FactTable", "merge_table_args", + "ModifierSourceMixin", "ModifierTargetMixin", "ModifierFieldConcepts", "DomainRule", diff --git a/omop_alchemy/cdm/base/modifier_interface.py b/omop_alchemy/cdm/base/modifier_interface.py index a21f3e0..f6dd576 100644 --- a/omop_alchemy/cdm/base/modifier_interface.py +++ b/omop_alchemy/cdm/base/modifier_interface.py @@ -4,6 +4,49 @@ from datetime import date from sqlalchemy.sql.elements import SQLColumnExpression +class ModifierSourceMixin: + """ + Marker + helpers for OMOP tables that can modify another CDM row. + + OMOP puts the modifier link on the source table under table-specific + column names (``measurement_event_id`` / ``meas_event_field_concept_id`` + versus ``observation_event_id`` / ``obs_event_field_concept_id``). + Subclasses name those columns once and inherit a common vocabulary, so + query code never branches on the physical modifier source. + + This is a declarative marker, not a support list. Which models are + accepted as modifier sources stays an explicit allow-list in the toolkit; + wearing this mixin describes a model's shape, it does not enrol it. + """ + + __abstract__ = True + __tablename__: ClassVar[str] + __modifier_event_id_col__: ClassVar[str] + __modifier_field_concept_id_col__: ClassVar[str] + + @classmethod + def modifier_source_table(cls) -> str: + return cls.__tablename__ + + @hybrid_property + def modifier_of_event_id(self) -> Optional[int]: + return getattr(self, self.__modifier_event_id_col__) + + @modifier_of_event_id.inplace.expression + @classmethod + def _modifier_of_event_id(cls) -> SQLColumnExpression[Optional[int]]: + return getattr(cls, cls.__modifier_event_id_col__) + + @hybrid_property + def modifier_of_field_concept_id(self) -> Optional[int]: + return getattr(self, self.__modifier_field_concept_id_col__) + + @modifier_of_field_concept_id.inplace.expression + @classmethod + def _modifier_of_field_concept_id(cls) -> SQLColumnExpression[Optional[int]]: + return getattr(cls, cls.__modifier_field_concept_id_col__) + + class ModifierTargetMixin: """ Marker + helpers for OMOP tables that can be modified diff --git a/omop_alchemy/cdm/model/clinical/event_metadata.py b/omop_alchemy/cdm/model/clinical/event_metadata.py index 6fa4ed5..76f158e 100644 --- a/omop_alchemy/cdm/model/clinical/event_metadata.py +++ b/omop_alchemy/cdm/model/clinical/event_metadata.py @@ -1,4 +1,9 @@ -"""Stable metadata for CDM tables that participate in clinical-event APIs.""" +"""Stable metadata for CDM tables that participate in clinical-event APIs. + +**NOTE:** "is a clinical event" and "can be modified" are different questions +with different membership. Every clinical event is a valid modifier target, +but not every modifier target is a clinical event. +""" from __future__ import annotations @@ -13,6 +18,7 @@ from .measurement import Measurement, MeasurementView from .observation import Observation, ObservationView from .procedure_occurrence import Procedure_Occurrence, Procedure_OccurrenceView +from ..structural.episode import Episode, EpisodeView # Keep one explicit supported event set. Both lookup shapes are derived from it @@ -24,6 +30,8 @@ (Measurement, MeasurementView), (Observation, ObservationView), (Procedure_Occurrence, Procedure_OccurrenceView), + # Note that Episode itself is a valid modifier target, but it is not a clinical + # event and therefore is not listed here. ) CLINICAL_EVENT_TARGETS_BY_TABLE: Mapping[str, type[ModifierTargetMixin]] = ( @@ -40,6 +48,20 @@ ) ) +_STRUCTURAL_MODIFIER_TARGETS: tuple[tuple[type[Any], type[ModifierTargetMixin]], ...] = ( + (Episode, EpisodeView), +) + +STRUCTURAL_MODIFIER_TARGETS_BY_TABLE: Mapping[str, type[ModifierTargetMixin]] = ( + MappingProxyType( + {source.__tablename__: target for source, target in _STRUCTURAL_MODIFIER_TARGETS} + ) +) + +MODIFIER_TARGETS_BY_TABLE: Mapping[str, type[ModifierTargetMixin]] = MappingProxyType( + {**CLINICAL_EVENT_TARGETS_BY_TABLE, **STRUCTURAL_MODIFIER_TARGETS_BY_TABLE} +) + def clinical_event_target_for_table( table_name: str, diff --git a/omop_alchemy/cdm/model/clinical/measurement.py b/omop_alchemy/cdm/model/clinical/measurement.py index 25b725b..a87593f 100644 --- a/omop_alchemy/cdm/model/clinical/measurement.py +++ b/omop_alchemy/cdm/model/clinical/measurement.py @@ -2,7 +2,6 @@ import sqlalchemy as sa import sqlalchemy.orm as so -from sqlalchemy.ext.hybrid import hybrid_property from typing import Optional, TYPE_CHECKING from datetime import date, datetime from orm_loader.helpers import Base @@ -11,6 +10,7 @@ DomainValidationMixin, ExpectedDomain, ModifierFieldConcepts, + ModifierSourceMixin, ModifierTargetMixin, ReferenceContext, cdm_table, @@ -26,7 +26,7 @@ @cdm_table -class Measurement(Base, CDMTableBase, ValueMixin): +class Measurement(Base, CDMTableBase, ValueMixin, ModifierSourceMixin): __tablename__ = "measurement" __table_args__ = merge_table_args( omop_index(__tablename__, "person_id", cluster=True), @@ -85,13 +85,8 @@ class Measurement(Base, CDMTableBase, ValueMixin): doc="Identifies which OMOP table measurement_event_id refers to", ) - @hybrid_property - def modifier_of_event_id(self) -> Optional[int]: - return self.measurement_event_id - - @hybrid_property - def modifier_of_field_concept_id(self) -> Optional[int]: - return self.meas_event_field_concept_id + __modifier_event_id_col__ = "measurement_event_id" + __modifier_field_concept_id_col__ = "meas_event_field_concept_id" class MeasurementContext(ReferenceContext): diff --git a/omop_alchemy/cdm/model/clinical/observation.py b/omop_alchemy/cdm/model/clinical/observation.py index e9b0fa5..ba6232b 100644 --- a/omop_alchemy/cdm/model/clinical/observation.py +++ b/omop_alchemy/cdm/model/clinical/observation.py @@ -2,7 +2,6 @@ import sqlalchemy as sa import sqlalchemy.orm as so -from sqlalchemy.ext.hybrid import hybrid_property from typing import Optional, TYPE_CHECKING from datetime import date, datetime from orm_loader.helpers import Base @@ -11,6 +10,7 @@ DomainValidationMixin, ExpectedDomain, ModifierFieldConcepts, + ModifierSourceMixin, ModifierTargetMixin, ReferenceContext, cdm_table, @@ -26,7 +26,7 @@ @cdm_table -class Observation(Base, CDMTableBase, ValueMixin): +class Observation(Base, CDMTableBase, ValueMixin, ModifierSourceMixin): __tablename__ = "observation" __table_args__ = merge_table_args( omop_index(__tablename__, "person_id", cluster=True), @@ -77,13 +77,8 @@ class Observation(Base, CDMTableBase, ValueMixin): sa.ForeignKey("concept.concept_id") ) - @hybrid_property - def modifier_of_event_id(self) -> Optional[int]: - return self.observation_event_id - - @hybrid_property - def modifier_of_field_concept_id(self) -> Optional[int]: - return self.obs_event_field_concept_id + __modifier_event_id_col__ = "observation_event_id" + __modifier_field_concept_id_col__ = "obs_event_field_concept_id" class ObservationContext(ReferenceContext): diff --git a/omop_alchemy/cdm/model/structural/episode.py b/omop_alchemy/cdm/model/structural/episode.py index fcab297..befc498 100644 --- a/omop_alchemy/cdm/model/structural/episode.py +++ b/omop_alchemy/cdm/model/structural/episode.py @@ -16,6 +16,8 @@ ExpectedDomain, merge_table_args, omop_index, + ModifierTargetMixin, + ModifierFieldConcepts, ) if TYPE_CHECKING: @@ -99,7 +101,7 @@ def children(cls) -> so.Mapped[List["Episode"]]: uselist=True, ) -class EpisodeView(Episode, EpisodeContext, DomainValidationMixin): +class EpisodeView(Episode, EpisodeContext, DomainValidationMixin, ModifierTargetMixin): """ Navigable Episode view. @@ -111,6 +113,15 @@ class EpisodeView(Episode, EpisodeContext, DomainValidationMixin): __tablename__ = "episode" __mapper_args__ = {"concrete": False} + __event_id_col__ = "episode_id" + __concept_id_col__ = "episode_concept_id" + __start_date_col__ = "episode_start_date" + __end_date_col__ = "episode_end_date" + __type_concept_id_col__ = "episode_type_concept_id" + + @classmethod + def modifier_field_concept_id(cls) -> int: + return ModifierFieldConcepts.EPISODE __expected_domains__ = { "episode_concept_id": ExpectedDomain("Episode"), diff --git a/omop_alchemy/toolkit/core/modifiers/__init__.py b/omop_alchemy/toolkit/core/modifiers/__init__.py index c0b3358..e47151c 100644 --- a/omop_alchemy/toolkit/core/modifiers/__init__.py +++ b/omop_alchemy/toolkit/core/modifiers/__init__.py @@ -17,8 +17,7 @@ from .metadata import ( MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE, MODIFIER_TARGET_SPECS_BY_TABLE, - SUPPORTED_MODIFIER_MODELS, - ModifierSourceModelSpec, + CDM_MODIFIER_SOURCE_MODELS, ModifierTargetModelSpec, UnsupportedModifierSourceModelError, UnsupportedModifierTargetError, @@ -58,12 +57,11 @@ "RESOLVED_TARGET_FIELD_CONCEPT_ID", "RESOLVED_TARGET_PERSON_ID", "RESOLVED_TARGET_SOURCE_TABLE", - "SUPPORTED_MODIFIER_MODELS", + "CDM_MODIFIER_SOURCE_MODELS", "InvalidModifierSourceError", "InvalidModifierTargetSourceError", "ModifierColumn", "ModifierIdentity", - "ModifierSourceModelSpec", "ModifierRow", "ModifierSelectionPolicy", "ModifierSelectionSpec", diff --git a/omop_alchemy/toolkit/core/modifiers/contracts.py b/omop_alchemy/toolkit/core/modifiers/contracts.py index 7d08510..dc43e9d 100644 --- a/omop_alchemy/toolkit/core/modifiers/contracts.py +++ b/omop_alchemy/toolkit/core/modifiers/contracts.py @@ -43,7 +43,8 @@ class ModifierColumn(StrEnum): unit_concept_id = "unit_concept_id" value_as_string = "value_as_string" - +# we store this column listing because we want to be able to use Unions, +# which rely on column ordering by role CANONICAL_MODIFIER_REQUIRED_COLUMNS: tuple[ModifierColumn, ...] = ( ModifierColumn.person_id, ModifierColumn.modifier_id, @@ -54,13 +55,7 @@ class ModifierColumn(StrEnum): ModifierColumn.target_event_id, ModifierColumn.target_field_concept_id, ) -"""Columns required to identify, date, classify, and target every modifier. -``modifier_source_table`` scopes ``modifier_id``; -``target_field_concept_id`` scopes ``target_event_id``. Although OMOP permits -the two target fields to be null on unbound rows, their columns are required in -the projection so validation can report that state explicitly. -""" CANONICAL_MODIFIER_VALUE_COLUMNS: tuple[ModifierColumn, ...] = ( ModifierColumn.value_as_number, @@ -68,12 +63,6 @@ class ModifierColumn(StrEnum): ModifierColumn.unit_concept_id, ModifierColumn.value_as_string, ) -"""Nullable OMOP value representations emitted in a fixed union order. - -A particular source row usually populates only one representation. Keeping all -four positions avoids source-specific union shapes and does not imply that the -representations are interchangeable. -""" @runtime_checkable diff --git a/omop_alchemy/toolkit/core/modifiers/metadata.py b/omop_alchemy/toolkit/core/modifiers/metadata.py index 21cec57..cf3a66b 100644 --- a/omop_alchemy/toolkit/core/modifiers/metadata.py +++ b/omop_alchemy/toolkit/core/modifiers/metadata.py @@ -4,23 +4,8 @@ remain independent of mapped ORM models. This module owns the separate question of which models may produce or receive those rows. -Two principles keep these registries small and predictable: - -* support is explicit and immutable; mapper discovery and subclass walks would - make accepted models depend on import order; and -* metadata already governed by the clinical-event layer is derived from that - layer rather than copied here. - -Measurement and Observation are the only initial modifier sources. Their own -event identity, date, datetime, concept, and source-table metadata comes from -``clinical_event_model_spec``. Both models already expose the target link using -the common ``modifier_of_event_id`` and ``modifier_of_field_concept_id`` hybrid -attributes, so this registry does not repeat their different physical column -names. - -The six clinical modifier targets are derived from the stable CDM clinical-event -registry. Episode is the sole explicit extension because it is a valid OMOP -modifier target but deliberately is not a clinical-event projection. +``ModifierSourceMixin`` standardises the target link as the ``modifier_of_event_id`` +and ``modifier_of_field_concept_id`` hybrids """ from __future__ import annotations @@ -29,17 +14,14 @@ from types import MappingProxyType from typing import Any, Mapping -from omop_alchemy.cdm.base import ModifierFieldConcepts +from omop_alchemy.cdm.base import ModifierSourceMixin from omop_alchemy.cdm.model.clinical import Measurement, Observation -from omop_alchemy.cdm.model.clinical.event_metadata import ( - CLINICAL_EVENT_TARGETS_BY_TABLE, +from omop_alchemy.cdm.model.clinical.event_metadata import MODIFIER_TARGETS_BY_TABLE +from omop_alchemy.toolkit.core.events import ( + ClinicalEventModelSpec, + UnsupportedClinicalEventModelError, + clinical_event_model_spec, ) -from omop_alchemy.cdm.model.structural import Episode -from omop_alchemy.toolkit.core.events import clinical_event_model_spec - - -MODIFIER_TARGET_EVENT_ATTRIBUTE = "modifier_of_event_id" -MODIFIER_TARGET_FIELD_ATTRIBUTE = "modifier_of_field_concept_id" class UnsupportedModifierSourceModelError(TypeError): @@ -54,19 +36,6 @@ class UnsupportedModifierTargetError(TypeError): """Raised when a model cannot be a canonical modifier target.""" -@dataclass(frozen=True, slots=True) -class ModifierSourceModelSpec: - """Native model attributes used to emit one canonical modifier row.""" - - modifier_id_attribute: str - modifier_date_attribute: str - modifier_datetime_attribute: str - modifier_concept_id_attribute: str - target_event_id_attribute: str - target_field_concept_id_attribute: str - modifier_source_table: str - - @dataclass(frozen=True, slots=True) class ModifierTargetModelSpec: """Native target identity and its canonical OMOP Field discriminator.""" @@ -74,96 +43,88 @@ class ModifierTargetModelSpec: event_id_attribute: str event_field_concept_id: int event_source_table: str - uses_clinical_event_projection: bool -def _source_spec(model: type[Any]) -> ModifierSourceModelSpec: - """Derive shared source fields while retaining an explicit support list.""" - event = clinical_event_model_spec(model) +def _source_spec(model: type[Any]) -> ClinicalEventModelSpec: + """Resolve a source's own clinical-event metadata. + + A modifier source needs exactly the identity, date, datetime, concept and + table that a clinical event already describes, so the event spec is used + as-is. The modifier-flavoured renaming happens once, on the projection's + output labels, rather than in a parallel dataclass here. + """ + try: + event = clinical_event_model_spec(model) + except UnsupportedClinicalEventModelError as error: + raise UnsupportedModifierSourceModelError(model, error.reason) from error if event.event_datetime_column is None: - raise TypeError(f"{model.__name__} must expose an event datetime column") - return ModifierSourceModelSpec( - modifier_id_attribute=event.event_id_column, - modifier_date_attribute=event.event_date_column, - modifier_datetime_attribute=event.event_datetime_column, - modifier_concept_id_attribute=event.event_concept_id_column, - target_event_id_attribute=MODIFIER_TARGET_EVENT_ATTRIBUTE, - target_field_concept_id_attribute=MODIFIER_TARGET_FIELD_ATTRIBUTE, - modifier_source_table=event.event_source_table, - ) + raise UnsupportedModifierSourceModelError( + model, "must expose an event datetime column" + ) + return event -# This tuple is the deliberate modifier-source allow-list. The detailed column -# metadata is derived rather than restated below. -SUPPORTED_MODIFIER_MODELS: tuple[type[Any], ...] = (Measurement, Observation) +CDM_MODIFIER_SOURCE_MODELS: tuple[type[Any], ...] = (Measurement, Observation) -MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE: Mapping[str, ModifierSourceModelSpec] = ( +MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE: Mapping[str, ClinicalEventModelSpec] = ( MappingProxyType( { model.__tablename__: _source_spec(model) - for model in SUPPORTED_MODIFIER_MODELS + for model in CDM_MODIFIER_SOURCE_MODELS } ) ) -def _clinical_target_spec(target: type[Any]) -> ModifierTargetModelSpec: +def _target_spec(target: type[Any]) -> ModifierTargetModelSpec: + # Every field is read off the target's ModifierTargetMixin declaration, so + # no table's identity metadata is restated here. return ModifierTargetModelSpec( event_id_attribute=target.__event_id_col__, event_field_concept_id=target.modifier_field_concept_id(), event_source_table=target.modifier_target_table(), - uses_clinical_event_projection=True, ) -# Clinical target definitions remain single-sourced in event_metadata. Episode -# extends that surface explicitly without being registered as a clinical event. MODIFIER_TARGET_SPECS_BY_TABLE: Mapping[str, ModifierTargetModelSpec] = ( MappingProxyType( { - **{ - table_name: _clinical_target_spec(target) - for table_name, target in CLINICAL_EVENT_TARGETS_BY_TABLE.items() - }, - Episode.__tablename__: ModifierTargetModelSpec( - event_id_attribute="episode_id", - event_field_concept_id=ModifierFieldConcepts.EPISODE, - event_source_table=Episode.__tablename__, - uses_clinical_event_projection=False, - ), + table_name: _target_spec(target) + for table_name, target in MODIFIER_TARGETS_BY_TABLE.items() } ) ) -def modifier_source_model_spec(model: type[Any]) -> ModifierSourceModelSpec: - """Resolve and validate immutable metadata for a modifier source model.""" +def modifier_source_model_spec(model: type[Any]) -> ClinicalEventModelSpec: + """Resolve and validate metadata for any model declaring the modifier link.""" if not isinstance(model, type) or not hasattr(model, "__table__"): raise UnsupportedModifierSourceModelError( model, "expected a mapped ORM model class" ) - spec = MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE.get( - str(getattr(model, "__tablename__", "")) - ) - if spec is None: + if not issubclass(model, ModifierSourceMixin): raise UnsupportedModifierSourceModelError( - model, "only Measurement and Observation carry the OMOP modifier link" + model, "must declare the OMOP modifier link via ModifierSourceMixin" ) - required = ( - "person_id", - spec.modifier_id_attribute, - spec.modifier_date_attribute, - spec.modifier_datetime_attribute, - spec.modifier_concept_id_attribute, - spec.target_event_id_attribute, - spec.target_field_concept_id_attribute, + for declaration in ( + "__modifier_event_id_col__", + "__modifier_field_concept_id_col__", + ): + column_name = getattr(model, declaration, None) + if not isinstance(column_name, str) or not column_name.strip(): + raise UnsupportedModifierSourceModelError( + model, f"must declare {declaration}" + ) + if not hasattr(model, column_name): + raise UnsupportedModifierSourceModelError( + model, f"{declaration} names a missing column: {column_name}" + ) + spec = MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE.get( + str(getattr(model, "__tablename__", "")) ) - missing = tuple(name for name in required if not hasattr(model, name)) - if missing: - raise UnsupportedModifierSourceModelError( - model, f"missing required attributes: {', '.join(missing)}" - ) - return spec + if spec is not None: + return spec + return _source_spec(model) def modifier_target_model_spec(model: type[Any]) -> ModifierTargetModelSpec: diff --git a/omop_alchemy/toolkit/core/modifiers/projections.py b/omop_alchemy/toolkit/core/modifiers/projections.py index a771da7..cdff746 100644 --- a/omop_alchemy/toolkit/core/modifiers/projections.py +++ b/omop_alchemy/toolkit/core/modifiers/projections.py @@ -2,14 +2,25 @@ from __future__ import annotations -from typing import Any +from typing import Any, Mapping import sqlalchemy as sa -from .contracts import ModifierColumn +from .contracts import CANONICAL_MODIFIER_VALUE_COLUMNS, ModifierColumn from .metadata import modifier_source_model_spec +# The SQL type each value position is cast to when a source has no such column. +# Keyed by column so the emission order below comes from the contract rather +# than from a second hand-maintained list. +_VALUE_COLUMN_TYPES: Mapping[ModifierColumn, sa.types.TypeEngine[Any]] = { + ModifierColumn.value_as_number: sa.Float(), + ModifierColumn.value_as_concept_id: sa.Integer(), + ModifierColumn.unit_concept_id: sa.Integer(), + ModifierColumn.value_as_string: sa.String(), +} + + def _nullable( model: type[Any], name: ModifierColumn, sql_type: sa.types.TypeEngine[Any] ) -> sa.ColumnElement[Any]: @@ -26,36 +37,31 @@ def canonical_modifier_projection( spec = modifier_source_model_spec(model) columns: list[sa.ColumnElement[Any]] = [ model.person_id.label(str(ModifierColumn.person_id)), - getattr(model, spec.modifier_id_attribute).label( + getattr(model, spec.event_id_column).label( str(ModifierColumn.modifier_id) ), - getattr(model, spec.modifier_date_attribute).label( + getattr(model, spec.event_date_column).label( str(ModifierColumn.modifier_date) ), - getattr(model, spec.modifier_datetime_attribute).label( + getattr(model, spec.event_datetime_column).label( str(ModifierColumn.modifier_datetime) ), - getattr(model, spec.modifier_concept_id_attribute).label( + getattr(model, spec.event_concept_id_column).label( str(ModifierColumn.modifier_concept_id) ), - sa.literal(spec.modifier_source_table).label( + sa.literal(spec.event_source_table).label( str(ModifierColumn.modifier_source_table) ), - getattr(model, spec.target_event_id_attribute).label( - str(ModifierColumn.target_event_id) - ), - getattr(model, spec.target_field_concept_id_attribute).label( + # Guaranteed by ModifierSourceMixin, whatever the physical column name. + model.modifier_of_event_id.label(str(ModifierColumn.target_event_id)), + model.modifier_of_field_concept_id.label( str(ModifierColumn.target_field_concept_id) ), ] if include_values: columns.extend( - ( - _nullable(model, ModifierColumn.value_as_number, sa.Float()), - _nullable(model, ModifierColumn.value_as_concept_id, sa.Integer()), - _nullable(model, ModifierColumn.unit_concept_id, sa.Integer()), - _nullable(model, ModifierColumn.value_as_string, sa.String()), - ) + _nullable(model, column, _VALUE_COLUMN_TYPES[column]) + for column in CANONICAL_MODIFIER_VALUE_COLUMNS ) return sa.select(*columns) diff --git a/omop_alchemy/toolkit/core/modifiers/targets.py b/omop_alchemy/toolkit/core/modifiers/targets.py index f4eb9a1..81be3a9 100644 --- a/omop_alchemy/toolkit/core/modifiers/targets.py +++ b/omop_alchemy/toolkit/core/modifiers/targets.py @@ -8,10 +8,7 @@ import sqlalchemy as sa from sqlalchemy.sql.selectable import FromClause, SelectBase -from omop_alchemy.toolkit.core.events import ( - ClinicalEventColumn, - canonical_event_projection, -) +from omop_alchemy.toolkit.core.events import ClinicalEventColumn from .contracts import ( CANONICAL_MODIFIER_REQUIRED_COLUMNS, @@ -41,16 +38,6 @@ class ModifierTargetQueries: def canonical_modifier_target_projection(model: type[Any]) -> sa.Select[Any]: """Project a supported clinical event or Episode to target identity columns.""" spec = modifier_target_model_spec(model) - if spec.uses_clinical_event_projection: - event = canonical_event_projection(model, include_values=False).subquery( - "modifier_target_event" - ) - return sa.select( - event.c[str(ClinicalEventColumn.person_id)], - event.c[str(ClinicalEventColumn.event_id)], - event.c[str(ClinicalEventColumn.event_field_concept_id)], - event.c[str(ClinicalEventColumn.event_source_table)], - ) return sa.select( model.person_id.label(str(ClinicalEventColumn.person_id)), getattr(model, spec.event_id_attribute).label( diff --git a/omop_alchemy/toolkit/episodes/derivation/observations.py b/omop_alchemy/toolkit/episodes/derivation/observations.py index c151fc0..eb7a0b4 100644 --- a/omop_alchemy/toolkit/episodes/derivation/observations.py +++ b/omop_alchemy/toolkit/episodes/derivation/observations.py @@ -6,7 +6,7 @@ import sqlalchemy as sa -from ._ranking import deterministic_row_number +from omop_alchemy.toolkit.core._ranking import deterministic_row_number from .contracts import ObservationSelectionPolicy, ObservationSelectionSpec @@ -18,9 +18,8 @@ def observation_eligibility_predicate( ) -> sa.ColumnElement[bool]: """Return the date predicate required by an observation selection policy.""" if not spec.requires_anchor: - # Unanchored policies intentionally keep every source row eligible; - # callers can reuse the same ranking builder for episode and person - # level observations without inventing a sentinel anchor date. + # Unanchored policies keep every source row eligible; callers can reuse + # the ranking builder for episode and person level observations return sa.true() if anchor_date is None: # Missing anchor input is a configuration error, not an instruction to diff --git a/omop_alchemy/toolkit/episodes/derivation/temporal.py b/omop_alchemy/toolkit/episodes/derivation/temporal.py index 857209e..56e567c 100644 --- a/omop_alchemy/toolkit/episodes/derivation/temporal.py +++ b/omop_alchemy/toolkit/episodes/derivation/temporal.py @@ -10,7 +10,7 @@ from sqlalchemy.sql.compiler import SQLCompiler from sqlalchemy.sql.functions import FunctionElement -from ._ranking import deterministic_row_number +from omop_alchemy.toolkit.core._ranking import deterministic_row_number from .contracts import ( EpisodeWindowSpec, TemporalRankingSpec, diff --git a/tests/test_modifier_projections.py b/tests/test_modifier_projections.py index 844fc55..1963789 100644 --- a/tests/test_modifier_projections.py +++ b/tests/test_modifier_projections.py @@ -4,9 +4,15 @@ import pytest import sqlalchemy as sa +import sqlalchemy.orm as so +from datetime import date, datetime from sqlalchemy.dialects import postgresql, sqlite -from omop_alchemy.cdm.base import ModifierFieldConcepts +from omop_alchemy.cdm.base import ( + ModifierFieldConcepts, + ModifierSourceMixin, + ModifierTargetMixin, +) from omop_alchemy.cdm.model import ( Condition_Occurrence, Device_Exposure, @@ -16,24 +22,44 @@ Person, Procedure_Occurrence, ) -from omop_alchemy.cdm.model.structural import Episode +from omop_alchemy.cdm.model.structural import Episode, Episode_EventView from omop_alchemy.cdm.model.clinical.event_metadata import ( + CLINICAL_EVENT_TARGETS_BY_FIELD_CONCEPT_ID, CLINICAL_EVENT_TARGETS_BY_TABLE, + MODIFIER_TARGETS_BY_TABLE, + STRUCTURAL_MODIFIER_TARGETS_BY_TABLE, + clinical_event_target_for_table, +) +from omop_alchemy.toolkit.core.events import ClinicalEventModelSpec +from omop_alchemy.toolkit.core.modifiers.projections import _VALUE_COLUMN_TYPES +from omop_alchemy.toolkit.core.modifiers.contracts import ( + ModifierColumn, + ModifierRow, + ValuedModifierRow, ) from omop_alchemy.toolkit.core.modifiers import ( CANONICAL_MODIFIER_REQUIRED_COLUMNS, CANONICAL_MODIFIER_VALUE_COLUMNS, + CDM_MODIFIER_SOURCE_MODELS, MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE, MODIFIER_TARGET_SPECS_BY_TABLE, UnsupportedModifierSourceModelError, canonical_modifier_projection, canonical_modifier_target_projection, canonical_modifier_union, + modifier_source_model_spec, modifier_target_model_spec, modifier_target_queries, ) +# The full projection shape, in UNION position order. Only the tests need the +# whole; production code asks for the obligation it actually cares about. +_ALL_MODIFIER_COLUMNS = ( + CANONICAL_MODIFIER_REQUIRED_COLUMNS + CANONICAL_MODIFIER_VALUE_COLUMNS +) + + @pytest.mark.parametrize( ("model", "source_table"), [(Measurement, "measurement"), (Observation, "observation")], @@ -42,10 +68,7 @@ def test_modifier_projection_has_one_stable_shape(model, source_table: str): statement = canonical_modifier_projection(model) assert tuple(statement.selected_columns.keys()) == tuple( - map( - str, - CANONICAL_MODIFIER_REQUIRED_COLUMNS + CANONICAL_MODIFIER_VALUE_COLUMNS, - ) + map(str, _ALL_MODIFIER_COLUMNS) ) compiled = str( statement.compile( @@ -59,26 +82,168 @@ def test_modifier_union_preserves_shape_and_all_rows(): statement = canonical_modifier_union(Measurement, Observation) assert tuple(statement.selected_columns.keys()) == tuple( - map( - str, - CANONICAL_MODIFIER_REQUIRED_COLUMNS + CANONICAL_MODIFIER_VALUE_COLUMNS, - ) + map(str, _ALL_MODIFIER_COLUMNS) ) assert "UNION ALL" in str(statement.compile(dialect=sqlite.dialect())) def test_non_modifier_model_fails_at_query_construction(): - with pytest.raises(UnsupportedModifierSourceModelError, match="only Measurement"): + with pytest.raises( + UnsupportedModifierSourceModelError, match="ModifierSourceMixin" + ): canonical_modifier_projection(Person) def test_modifier_metadata_reuses_generic_model_interfaces(): + # The target link is no longer described by the spec at all; it is read off + # ModifierSourceMixin, which is asserted separately. assert set(MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE) == {"measurement", "observation"} - for spec in MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE.values(): - assert spec.target_event_id_attribute == "modifier_of_event_id" - assert spec.target_field_concept_id_attribute == ( - "modifier_of_field_concept_id" + for table, spec in MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE.items(): + # A source spec IS the clinical-event spec; the modifier-flavoured + # renaming happens on the projection's output labels, not here. + assert isinstance(spec, ClinicalEventModelSpec) + assert spec.event_source_table == table + + +def test_modifier_target_surface_is_the_clinical_and_structural_registries(): + assert set(MODIFIER_TARGETS_BY_TABLE) == { + *CLINICAL_EVENT_TARGETS_BY_TABLE, + *STRUCTURAL_MODIFIER_TARGETS_BY_TABLE, + } + assert not set(CLINICAL_EVENT_TARGETS_BY_TABLE) & set( + STRUCTURAL_MODIFIER_TARGETS_BY_TABLE + ) + assert set(STRUCTURAL_MODIFIER_TARGETS_BY_TABLE) == {"episode"} + + +def test_structural_groupers_never_enter_the_clinical_event_registry(): + """Episode groups clinical events, so it must never be resolvable as one. + + Admitting it would let an ``episode_event`` row resolve to an Episode, so a + grouper would appear inside its own ``EpisodeView.events`` list, and would + let canonical event projections double-count a grouper alongside the events + it groups. + """ + for table_name in STRUCTURAL_MODIFIER_TARGETS_BY_TABLE: + assert table_name not in CLINICAL_EVENT_TARGETS_BY_TABLE + assert clinical_event_target_for_table(table_name) is None + + structural_field_concepts = { + target.modifier_field_concept_id() + for target in STRUCTURAL_MODIFIER_TARGETS_BY_TABLE.values() + } + assert not structural_field_concepts & set( + CLINICAL_EVENT_TARGETS_BY_FIELD_CONCEPT_ID + ) + assert not structural_field_concepts & set( + Episode_EventView.resolved_event_target_classes() + ) + + +def test_canonical_column_vocabulary_is_stated_once(): + """The enum, the obligation tuples, and the row protocols must agree. + + Each names the same columns for a different audience, and nothing in the + language keeps them in step, so the agreement is asserted here. + """ + assert not set(CANONICAL_MODIFIER_REQUIRED_COLUMNS) & set( + CANONICAL_MODIFIER_VALUE_COLUMNS + ), "a column cannot be both required and an optional value" + assert set(_ALL_MODIFIER_COLUMNS) == set(ModifierColumn), ( + "every ModifierColumn must be classified as required or value" + ) + assert len(_ALL_MODIFIER_COLUMNS) == len(ModifierColumn) + + assert tuple(ModifierRow.__annotations__) == tuple( + map(str, CANONICAL_MODIFIER_REQUIRED_COLUMNS) + ) + assert tuple(ValuedModifierRow.__annotations__) == tuple( + map(str, CANONICAL_MODIFIER_VALUE_COLUMNS) + ) + + # The projection casts each value position when a source lacks the column, + # so every value column needs a declared SQL type to fall back to. + assert set(_VALUE_COLUMN_TYPES) == set(CANONICAL_MODIFIER_VALUE_COLUMNS) + + +def test_modifier_sources_declare_the_link_through_the_source_mixin(): + """The common vocabulary must resolve to each table's own physical columns.""" + expected = { + Measurement: ("measurement_event_id", "meas_event_field_concept_id"), + Observation: ("observation_event_id", "obs_event_field_concept_id"), + } + for model, (event_column, field_column) in expected.items(): + assert issubclass(model, ModifierSourceMixin) + assert model.modifier_source_table() == model.__tablename__ + for hybrid, column_name in ( + (model.modifier_of_event_id, event_column), + (model.modifier_of_field_concept_id, field_column), + ): + # The hybrid renders as an annotated form of the mapped column, so + # compare semantically rather than by object identity. + rendered = hybrid.__clause_element__() + assert rendered.compare(model.__table__.c[column_name]) + assert rendered.table is model.__table__ + + +def _make_custom_source(**overrides): + """Build a mapped modifier source outside the CDM, on its own registry.""" + + class LocalBase(so.DeclarativeBase): + pass + + class CustomSource(LocalBase, ModifierSourceMixin, ModifierTargetMixin): + __tablename__ = "custom_source" + __event_id_col__ = "custom_source_id" + __concept_id_col__ = "custom_concept_id" + __start_date_col__ = "custom_date" + __end_date_col__ = "custom_date" + __type_concept_id_col__ = "custom_concept_id" + __modifier_event_id_col__ = overrides.get( + "__modifier_event_id_col__", "custom_event_id" ) + __modifier_field_concept_id_col__ = "custom_event_field_concept_id" + + custom_source_id: so.Mapped[int] = so.mapped_column(primary_key=True) + person_id: so.Mapped[int] + custom_concept_id: so.Mapped[int] + custom_date: so.Mapped[date] + custom_datetime: so.Mapped[datetime | None] + custom_event_id: so.Mapped[int | None] + custom_event_field_concept_id: so.Mapped[int | None] + + @classmethod + def modifier_field_concept_id(cls) -> int: + return 999999 + + return CustomSource + + +def test_a_new_modifier_source_needs_no_change_to_the_toolkit(): + """Support is a property of the model, not a list held in this package.""" + custom = _make_custom_source() + + assert custom not in CDM_MODIFIER_SOURCE_MODELS + assert tuple(canonical_modifier_projection(custom).selected_columns.keys()) == ( + tuple(map(str, _ALL_MODIFIER_COLUMNS)) + ) + + spec = modifier_source_model_spec(custom) + assert spec.event_source_table == "custom_source" + assert spec.event_id_column == "custom_source_id" + + union = canonical_modifier_union(Measurement, custom) + assert "UNION ALL" in str(union.compile(dialect=sqlite.dialect())) + + +def test_a_source_naming_a_missing_link_column_is_rejected(): + """The mixin supplies the hybrids; the subclass must name real columns.""" + custom = _make_custom_source(__modifier_event_id_col__="no_such_column") + + with pytest.raises( + UnsupportedModifierSourceModelError, match="names a missing column" + ): + modifier_source_model_spec(custom) def test_modifier_targets_derive_the_clinical_surface_and_extend_it_with_episode(): @@ -87,10 +252,9 @@ def test_modifier_targets_derive_the_clinical_surface_and_extend_it_with_episode "episode", } assert all( - MODIFIER_TARGET_SPECS_BY_TABLE[table].uses_clinical_event_projection - for table in CLINICAL_EVENT_TARGETS_BY_TABLE + MODIFIER_TARGET_SPECS_BY_TABLE[table].event_source_table == table + for table in MODIFIER_TARGET_SPECS_BY_TABLE ) - assert not MODIFIER_TARGET_SPECS_BY_TABLE["episode"].uses_clinical_event_projection def test_episode_is_a_modifier_target_without_becoming_a_clinical_event(): From d03f417423b3521c6fabf4cda930ae24ed1beb42 Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Mon, 7 Sep 2026 12:34:57 +1000 Subject: [PATCH 16/30] modifier projections cleanup --- .../toolkit/core/modifiers/projections.py | 12 ++- .../toolkit/core/modifiers/targets.py | 24 +++-- tests/test_modifier_projections.py | 91 ++++++++++++++++++- 3 files changed, 113 insertions(+), 14 deletions(-) diff --git a/omop_alchemy/toolkit/core/modifiers/projections.py b/omop_alchemy/toolkit/core/modifiers/projections.py index cdff746..fc4ab70 100644 --- a/omop_alchemy/toolkit/core/modifiers/projections.py +++ b/omop_alchemy/toolkit/core/modifiers/projections.py @@ -7,7 +7,10 @@ import sqlalchemy as sa from .contracts import CANONICAL_MODIFIER_VALUE_COLUMNS, ModifierColumn -from .metadata import modifier_source_model_spec +from .metadata import ( + UnsupportedModifierSourceModelError, + modifier_source_model_spec, +) # The SQL type each value position is cast to when a source has no such column. @@ -35,6 +38,11 @@ def canonical_modifier_projection( ) -> sa.Select[Any]: """Project Measurement or Observation into one modifier row shape.""" spec = modifier_source_model_spec(model) + datetime_column = spec.event_datetime_column + if datetime_column is None: # pragma: no cover - the spec rejects such a model + raise UnsupportedModifierSourceModelError( + model, "must expose an event datetime column" + ) columns: list[sa.ColumnElement[Any]] = [ model.person_id.label(str(ModifierColumn.person_id)), getattr(model, spec.event_id_column).label( @@ -43,7 +51,7 @@ def canonical_modifier_projection( getattr(model, spec.event_date_column).label( str(ModifierColumn.modifier_date) ), - getattr(model, spec.event_datetime_column).label( + getattr(model, datetime_column).label( str(ModifierColumn.modifier_datetime) ), getattr(model, spec.event_concept_id_column).label( diff --git a/omop_alchemy/toolkit/core/modifiers/targets.py b/omop_alchemy/toolkit/core/modifiers/targets.py index 81be3a9..f00b1c6 100644 --- a/omop_alchemy/toolkit/core/modifiers/targets.py +++ b/omop_alchemy/toolkit/core/modifiers/targets.py @@ -179,20 +179,24 @@ def branch( "target field concept is not supported by the supplied target", ) ) - else: - supported = sa.true() - branches.extend( - ( + # Absence from the target set proves the row does not exist only when + # the set is a whole target table. A caller-supplied selectable may be + # filtered, where a missing row is the filter's doing, not a defect. + branches.append( branch( ModifierTargetDiagnosticCode.missing_target_event, sa.and_(has_identity, supported, sa.not_(any_event)), "the identified target event does not exist", - ), - branch( - ModifierTargetDiagnosticCode.person_mismatch, - sa.and_(has_identity, supported, any_event, sa.not_(same_person)), - "modifier and target event belong to different people", - ), + ) + ) + else: + supported = sa.true() + # Safe for either source: the mismatch is observed on a row that is present. + branches.append( + branch( + ModifierTargetDiagnosticCode.person_mismatch, + sa.and_(has_identity, supported, any_event, sa.not_(same_person)), + "modifier and target event belong to different people", ) ) return ModifierTargetQueries(matches=matches, diagnostics=sa.union_all(*branches)) diff --git a/tests/test_modifier_projections.py b/tests/test_modifier_projections.py index 1963789..7474e85 100644 --- a/tests/test_modifier_projections.py +++ b/tests/test_modifier_projections.py @@ -13,6 +13,7 @@ ModifierSourceMixin, ModifierTargetMixin, ) +from orm_loader.helpers import Base from omop_alchemy.cdm.model import ( Condition_Occurrence, Device_Exposure, @@ -295,7 +296,92 @@ def test_target_resolution_queries_compile_for_supported_models(target): assert "diagnostic_code" in str(queries.diagnostics.compile(dialect=dialect)) -def test_target_validation_rejects_missing_and_cross_person_links(): +def _seeded_engine(conditions, measurements): + """Build a SQLite database holding the given condition and measurement rows.""" + engine = sa.create_engine("sqlite://") + Base.metadata.create_all( + engine, tables=[Condition_Occurrence.__table__, Measurement.__table__] + ) + with engine.begin() as connection: + connection.execute(Condition_Occurrence.__table__.insert(), conditions) + connection.execute(Measurement.__table__.insert(), measurements) + return engine + + +def _condition(condition_occurrence_id: int, person_id: int): + return dict( + condition_occurrence_id=condition_occurrence_id, + person_id=person_id, + condition_concept_id=4, + condition_start_date=date(2020, 1, 1), + condition_type_concept_id=1, + ) + + +def _modifier_of(measurement_id: int, person_id: int, target_id: int): + return dict( + measurement_id=measurement_id, + person_id=person_id, + measurement_concept_id=9, + measurement_date=date(2020, 6, 1), + measurement_type_concept_id=1, + measurement_event_id=target_id, + meas_event_field_concept_id=ModifierFieldConcepts.CONDITION_OCCURRENCE, + ) + + +def _diagnostic_codes(engine, target) -> set[tuple[int, str]]: + queries = modifier_target_queries(Measurement, target, diagnostics=True) + assert queries.diagnostics is not None + with engine.connect() as connection: + rows = connection.execute(queries.diagnostics).mappings().all() + return {(row["modifier_id"], row["diagnostic_code"]) for row in rows} + + +def test_a_whole_target_table_can_prove_a_dangling_modifier(): + """Absence from an entire table does mean the target row does not exist.""" + engine = _seeded_engine( + conditions=[_condition(1, 10)], + measurements=[_modifier_of(100, 10, 1), _modifier_of(101, 10, 99)], + ) + + assert _diagnostic_codes(engine, Condition_Occurrence) == { + (101, "missing_target_event") + } + + +def test_a_filtered_target_never_reports_a_missing_target_event(): + """A narrowed target set cannot distinguish a defect from its own filter. + + Both modifiers below point at conditions that genuinely exist. Reporting + the excluded one as missing would turn the caller's filter into a false + data-quality defect for downstream baseline counts. + """ + engine = _seeded_engine( + conditions=[_condition(1, 10), _condition(2, 10)], + measurements=[_modifier_of(100, 10, 1), _modifier_of(101, 10, 2)], + ) + narrowed = sa.select( + *canonical_modifier_target_projection(Condition_Occurrence).subquery().c + ).where(sa.column("event_id") == 2) + + assert _diagnostic_codes(engine, narrowed) == set() + + +def test_a_filtered_target_still_reports_person_mismatch(): + """A mismatch is observed on a row that is present, so it stays provable.""" + engine = _seeded_engine( + conditions=[_condition(1, 10)], + measurements=[_modifier_of(100, 11, 1)], + ) + narrowed = sa.select( + *canonical_modifier_target_projection(Condition_Occurrence).subquery().c + ).where(sa.column("event_id") == 1) + + assert _diagnostic_codes(engine, narrowed) == {(100, "person_mismatch")} + + +def test_target_validation_rejects_null_identity_and_cross_person_links(): def modifier( modifier_id: int, person_id: int, @@ -336,8 +422,9 @@ def modifier( diagnostics = connection.execute(queries.diagnostics).mappings().all() assert [row["modifier_id"] for row in matches] == [1] + # Modifier 3 points outside the supplied selectable, which cannot prove the + # row is absent from the underlying table, so no code is asserted for it. assert {(row["modifier_id"], row["diagnostic_code"]) for row in diagnostics} == { (2, "missing_target_identity"), - (3, "missing_target_event"), (4, "person_mismatch"), } From b1d7ee7ea446749543886be4fd61857f0e5cb8fa Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Mon, 7 Sep 2026 12:43:42 +1000 Subject: [PATCH 17/30] bumping deps --- pyproject.toml | 3 +- uv.lock | 265 +------------------------------------------------ 2 files changed, 5 insertions(+), 263 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c6d4c19..d05c964 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,8 +45,7 @@ postgres = [ ] semantics = [ - # First release containing condition_modifiers.metastatic_disease_concepts. - "omop-semantics>=0.6.1", + "omop-semantics>=0.6.2", ] dev = [ diff --git a/uv.lock b/uv.lock index 0dd6a7a..b8e4ad0 100644 --- a/uv.lock +++ b/uv.lock @@ -46,15 +46,6 @@ version = "4.9.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034, upload-time = "2021-11-06T17:52:23.524Z" } -[[package]] -name = "appnope" -version = "0.1.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, -] - [[package]] name = "arrow" version = "1.4.0" @@ -177,91 +168,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] -[[package]] -name = "cffi" -version = "2.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pycparser" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, - { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, - { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, - { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, - { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, - { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, - { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, - { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, - { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, - { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, - { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, - { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, - { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, - { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, - { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, - { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, - { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, - { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, - { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, - { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, - { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, - { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, - { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, - { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, - { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, - { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, - { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, - { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, - { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, - { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, - { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, - { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, - { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, - { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, - { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, - { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, - { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, - { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, - { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, - { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, - { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, - { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, - { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, - { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, - { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, - { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, - { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, - { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, - { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, - { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, - { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, - { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, - { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, - { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, - { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, - { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, - { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, - { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, - { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, - { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, - { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, - { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, - { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, - { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, - { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, - { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, - { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, -] - [[package]] name = "cfgraph" version = "0.2.1" @@ -396,15 +302,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] -[[package]] -name = "comm" -version = "0.2.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, -] - [[package]] name = "coverage" version = "7.14.0" @@ -503,27 +400,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/e9/bfa4d4a47c03180422343be8c69df75f5942276e815a367ae377f32db2d5/curies-0.14.6-py3-none-any.whl", hash = "sha256:a3653afa27576029c491e6ef97d2bc7d9c388afbbf44973e993b8d24fbb0c99c", size = 82629, upload-time = "2026-08-05T08:11:45.123Z" }, ] -[[package]] -name = "debugpy" -version = "1.8.21" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/df/bf625547431a9cadc9f4cbfeda38866e2b17f6aed147b625377e87834449/debugpy-1.8.21-cp312-cp312-macosx_15_0_universal2.whl", hash = "sha256:9f96713896f39c3dff0ee841f47320c3f2983d33c341e009361bb0ebc79adc4e", size = 2483609, upload-time = "2026-06-01T19:30:50.794Z" }, - { url = "https://files.pythonhosted.org/packages/bf/09/59324b903599031ff9faaec1758292409f6561a0ec2492fe4b703327705a/debugpy-1.8.21-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:c193d474f0a211191f2b4449d2d06157c689013035bd952f3b617e0ef422b176", size = 3968900, upload-time = "2026-06-01T19:30:52.341Z" }, - { url = "https://files.pythonhosted.org/packages/14/cd/27f65b805d7fe005c44e1a36b9183ecdfbcdbf9d3e721a5115d461ecc7ee/debugpy-1.8.21-cp312-cp312-win32.whl", hash = "sha256:4743373c1cac7f9e74a1b9915bf1dbe0e900eca657ffb170ae07ac8363205ae9", size = 5336340, upload-time = "2026-06-01T19:30:54.047Z" }, - { url = "https://files.pythonhosted.org/packages/77/1d/c84e30c0c674184948b66f076ab271c01d940618a2824c23cd035a27bc20/debugpy-1.8.21-cp312-cp312-win_amd64.whl", hash = "sha256:bd7ba9dd3daa7c2f942c6ca8d4695a16bf9ac16b63615261c7982bc74f7ed20c", size = 5374751, upload-time = "2026-06-01T19:30:55.891Z" }, - { url = "https://files.pythonhosted.org/packages/77/6b/d817e1f8cc77aa055d37fba092e0febfdff40fe652d8d53d4cd7a86ad98d/debugpy-1.8.21-cp313-cp313-macosx_15_0_universal2.whl", hash = "sha256:13678151fc401e2d68c9880b91e28714f797d40422994572b24560ef80910a88", size = 2477398, upload-time = "2026-06-01T19:30:57.644Z" }, - { url = "https://files.pythonhosted.org/packages/48/57/412421516afc3055fa577516f00beec3d663f9b0ab330639547ae6c57720/debugpy-1.8.21-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:ecbd158386c31ffe71d46f72d44d56e66331ab9b16cad649156d514368f23ab2", size = 3962096, upload-time = "2026-06-01T19:30:59.235Z" }, - { url = "https://files.pythonhosted.org/packages/c1/62/2c616337cf6ba7b07ebbc97f02c6c945a8e2f76b365e33ee809c32ee36d1/debugpy-1.8.21-cp313-cp313-win32.whl", hash = "sha256:2c2ae706dec41d99a9ca1f7ebc987a83e65578363be6f6b3ac9067504917fae1", size = 5336288, upload-time = "2026-06-01T19:31:00.79Z" }, - { url = "https://files.pythonhosted.org/packages/f8/99/9175103392f84c4b1bf7622888cdc68da07f0ff7d9e581266428f6776033/debugpy-1.8.21-cp313-cp313-win_amd64.whl", hash = "sha256:aa648733047443eb1d07682c4ef287d36a54507b643ffdf38b09a3ef002c72a0", size = 5376567, upload-time = "2026-06-01T19:31:02.56Z" }, - { url = "https://files.pythonhosted.org/packages/ce/3d/f4bbb323a548bfab2af3d6b4ffd9bf22636e55956a1285d317a1de643aad/debugpy-1.8.21-cp314-cp314-macosx_15_0_universal2.whl", hash = "sha256:9bb2a685287a2ac9b181cde89edcec64845cb51de7faaa75badb9a698bc24782", size = 2477209, upload-time = "2026-06-01T19:31:04.157Z" }, - { url = "https://files.pythonhosted.org/packages/8c/2d/6e7ec524984a1702777868de49a4c53202bddac2a432a76a093469587750/debugpy-1.8.21-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:3d6922439bf33fd38a3e2c447869ebc7b97da5cd3d329ff1ef9bc06c4903437e", size = 3927115, upload-time = "2026-06-01T19:31:05.863Z" }, - { url = "https://files.pythonhosted.org/packages/97/47/d1aa6d64005a98a9144647d99306b419396f9ad7bf1d73c119e17a81fb4d/debugpy-1.8.21-cp314-cp314-win32.whl", hash = "sha256:15d4963bd5ffa48f0da0947fd06757fa7621945048a14ad7705431566d3c0e7c", size = 5336724, upload-time = "2026-06-01T19:31:07.711Z" }, - { url = "https://files.pythonhosted.org/packages/5f/67/b905b90d163af11878c1af8abafa4a25206335e112e284e413454543a6da/debugpy-1.8.21-cp314-cp314-win_amd64.whl", hash = "sha256:fe0744a12353406de0ae8ccff0d0a4a666f00801a3db8fd04e7a5f761cd520e8", size = 5373803, upload-time = "2026-06-01T19:31:09.469Z" }, - { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, -] - [[package]] name = "decorator" version = "5.3.1" @@ -790,30 +666,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] -[[package]] -name = "ipykernel" -version = "7.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "appnope", marker = "sys_platform == 'darwin'" }, - { name = "comm" }, - { name = "debugpy" }, - { name = "ipython" }, - { name = "jupyter-client" }, - { name = "jupyter-core" }, - { name = "matplotlib-inline" }, - { name = "nest-asyncio2" }, - { name = "packaging" }, - { name = "psutil" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/3d/c4/e4a38f579de4225a561305666f7541cdabb30075def2aa1ac17bd73c1fb5/ipykernel-7.3.0.tar.gz", hash = "sha256:9acaaaf97d16355166e4085afe9d225bfbdf2b7ef520f9df3be8f2b248275e09", size = 184899, upload-time = "2026-06-10T08:41:25.481Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/02/77b271f5dc58bfbc0b577c877b2365d1ffea2afe66a80c13f2312820348c/ipykernel-7.3.0-py3-none-any.whl", hash = "sha256:897eb64da762549ef610698fca5e9675195ec6ac8ec7f19d81ce1ca20c876057", size = 120583, upload-time = "2026-06-10T08:41:23.648Z" }, -] - [[package]] name = "ipython" version = "9.13.0" @@ -988,36 +840,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] -[[package]] -name = "jupyter-client" -version = "8.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jupyter-core" }, - { name = "python-dateutil" }, - { name = "pyzmq" }, - { name = "tornado" }, - { name = "traitlets" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81", size = 109828, upload-time = "2026-06-09T13:14:58.835Z" }, -] - -[[package]] -name = "jupyter-core" -version = "5.9.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "platformdirs" }, - { name = "traitlets" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, -] - [[package]] name = "linkml" version = "1.11.1" @@ -1322,15 +1144,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/28/79f0f8de97cce916d5ae88a7bee1ad724855e83e6019c0b4d5b3fabc80f3/mkdocstrings_python-2.0.3-py3-none-any.whl", hash = "sha256:0b83513478bdfd803ff05aa43e9b1fca9dd22bcd9471f09ca6257f009bc5ee12", size = 104779, upload-time = "2026-02-20T10:38:34.517Z" }, ] -[[package]] -name = "nest-asyncio2" -version = "1.7.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b4/73/731debf26e27e0a0323d7bda270dc2f634b398e38f040a09da1f4351d0aa/nest_asyncio2-1.7.2.tar.gz", hash = "sha256:1921d70b92cc4612c374928d081552efb59b83d91b2b789d935c665fa01729a8", size = 14743, upload-time = "2026-02-13T00:34:04.386Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/3c/3179b85b0e1c3659f0369940200cd6d0fa900e6cefcc7ea0bc6dd0e29ffb/nest_asyncio2-1.7.2-py3-none-any.whl", hash = "sha256:f5dfa702f3f81f6a03857e9a19e2ba578c0946a4ad417b4c50a24d7ba641fe01", size = 7843, upload-time = "2026-02-13T00:34:02.691Z" }, -] - [[package]] name = "numpy" version = "2.4.6" @@ -1471,7 +1284,7 @@ requires-dist = [ { name = "mkdocstrings-python", marker = "extra == 'dev'", specifier = ">=2.0.1" }, { name = "oa-configurator", specifier = ">=1.0.0,<2.0.0" }, { name = "oa-configurator", extras = ["dev", "postgres"], marker = "extra == 'dev'", specifier = ">=1.0.0,<2.0.0" }, - { name = "omop-semantics", marker = "extra == 'semantics'", specifier = ">=0.6.1" }, + { name = "omop-semantics", marker = "extra == 'semantics'", specifier = ">=0.6.2" }, { name = "orm-loader", specifier = ">=1.2.0,<2.0.0" }, { name = "pandas", specifier = ">=2.0" }, { name = "psycopg", extras = ["binary"], marker = "extra == 'postgres'", specifier = ">=3.2" }, @@ -1489,10 +1302,9 @@ provides-extras = ["dev", "postgres", "semantics"] [[package]] name = "omop-semantics" -version = "0.6.1" +version = "0.6.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ipykernel" }, { name = "linkml" }, { name = "linkml-runtime" }, { name = "python-dotenv" }, @@ -1500,9 +1312,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/35/33/e42a3e0462d4843ec3b2c14731a5949b08a48659e0add053da89f42d1833/omop_semantics-0.6.1.tar.gz", hash = "sha256:5cf96c9cfa890e4d1519a7ad77c04823ab595bf4498bf2c3d5a08edf812c36aa", size = 203651, upload-time = "2026-09-07T01:12:31.86Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/c8/89354f4c627a54490aa8d68175665f9f860c65be424e1efdffd16776adc5/omop_semantics-0.6.2.tar.gz", hash = "sha256:ba01bf589cbd7c68163390c07dfefd960780251635773d80a5a9f8243b2be8f5", size = 202242, upload-time = "2026-09-07T02:41:57.009Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/63/a8cfccf0beca66de03bd568a594d42248197bb0c65da30e9e52b610232d6/omop_semantics-0.6.1-py3-none-any.whl", hash = "sha256:aafe8b714de8a9348a8bad5161b6a0eb6d61cced2035daae310cd71c4055dce0", size = 66805, upload-time = "2026-09-07T01:12:30.485Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a3/9b71a40c0a269dffb6188f38cc908e13e2d6290f92201587d45b2bafaf8b/omop_semantics-0.6.2-py3-none-any.whl", hash = "sha256:1a9ac1dd8ae2f31c8ee150b8fb9e644ee21d287b071e1d11b4258f6954bd91bb", size = 66803, upload-time = "2026-09-07T02:41:55.506Z" }, ] [[package]] @@ -1847,15 +1659,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282, upload-time = "2026-04-21T10:51:16.815Z" }, ] -[[package]] -name = "pycparser" -version = "3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, -] - [[package]] name = "pydantic" version = "2.13.4" @@ -2160,49 +1963,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] -[[package]] -name = "pyzmq" -version = "27.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "implementation_name == 'pypy'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, - { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, - { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, - { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, - { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, - { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, - { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, - { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, - { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" }, - { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" }, - { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" }, - { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" }, - { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" }, - { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" }, - { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" }, - { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" }, - { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" }, - { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" }, - { url = "https://files.pythonhosted.org/packages/87/45/19efbb3000956e82d0331bafca5d9ac19ea2857722fa2caacefb6042f39d/pyzmq-27.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ce980af330231615756acd5154f29813d553ea555485ae712c491cd483df6b7a", size = 1341197, upload-time = "2025-09-08T23:08:44.973Z" }, - { url = "https://files.pythonhosted.org/packages/48/43/d72ccdbf0d73d1343936296665826350cb1e825f92f2db9db3e61c2162a2/pyzmq-27.1.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1779be8c549e54a1c38f805e56d2a2e5c009d26de10921d7d51cfd1c8d4632ea", size = 897175, upload-time = "2025-09-08T23:08:46.601Z" }, - { url = "https://files.pythonhosted.org/packages/2f/2e/a483f73a10b65a9ef0161e817321d39a770b2acf8bcf3004a28d90d14a94/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7200bb0f03345515df50d99d3db206a0a6bee1955fbb8c453c76f5bf0e08fb96", size = 660427, upload-time = "2025-09-08T23:08:48.187Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d2/5f36552c2d3e5685abe60dfa56f91169f7a2d99bbaf67c5271022ab40863/pyzmq-27.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01c0e07d558b06a60773744ea6251f769cd79a41a97d11b8bf4ab8f034b0424d", size = 847929, upload-time = "2025-09-08T23:08:49.76Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2a/404b331f2b7bf3198e9945f75c4c521f0c6a3a23b51f7a4a401b94a13833/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:80d834abee71f65253c91540445d37c4c561e293ba6e741b992f20a105d69146", size = 1650193, upload-time = "2025-09-08T23:08:51.7Z" }, - { url = "https://files.pythonhosted.org/packages/1c/0b/f4107e33f62a5acf60e3ded67ed33d79b4ce18de432625ce2fc5093d6388/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:544b4e3b7198dde4a62b8ff6685e9802a9a1ebf47e77478a5eb88eca2a82f2fd", size = 2024388, upload-time = "2025-09-08T23:08:53.393Z" }, - { url = "https://files.pythonhosted.org/packages/0d/01/add31fe76512642fd6e40e3a3bd21f4b47e242c8ba33efb6809e37076d9b/pyzmq-27.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cedc4c68178e59a4046f97eca31b148ddcf51e88677de1ef4e78cf06c5376c9a", size = 1885316, upload-time = "2025-09-08T23:08:55.702Z" }, - { url = "https://files.pythonhosted.org/packages/c4/59/a5f38970f9bf07cee96128de79590bb354917914a9be11272cfc7ff26af0/pyzmq-27.1.0-cp314-cp314t-win32.whl", hash = "sha256:1f0b2a577fd770aa6f053211a55d1c47901f4d537389a034c690291485e5fe92", size = 587472, upload-time = "2025-09-08T23:08:58.18Z" }, - { url = "https://files.pythonhosted.org/packages/70/d8/78b1bad170f93fcf5e3536e70e8fadac55030002275c9a29e8f5719185de/pyzmq-27.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:19c9468ae0437f8074af379e986c5d3d7d7bfe033506af442e8c879732bedbe0", size = 661401, upload-time = "2025-09-08T23:08:59.802Z" }, - { url = "https://files.pythonhosted.org/packages/81/d6/4bfbb40c9a0b42fc53c7cf442f6385db70b40f74a783130c5d0a5aa62228/pyzmq-27.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dc5dbf68a7857b59473f7df42650c621d7e8923fb03fa74a526890f4d33cc4d7", size = 575170, upload-time = "2025-09-08T23:09:01.418Z" }, -] - [[package]] name = "rdflib" version = "7.6.0" @@ -2685,23 +2445,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, ] -[[package]] -name = "tornado" -version = "6.5.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, - { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, - { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, - { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, - { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, - { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, - { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, - { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, -] - [[package]] name = "tqdm" version = "4.70.0" From 25821139208201e5302d4111d47adca3dc45fde9 Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Mon, 7 Sep 2026 13:09:10 +1000 Subject: [PATCH 18/30] minor code comments --- omop_alchemy/cdm/model/clinical/__init__.py | 4 +++- omop_alchemy/toolkit/core/modifiers/contracts.py | 3 +-- omop_alchemy/toolkit/episodes/derivation/observations.py | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/omop_alchemy/cdm/model/clinical/__init__.py b/omop_alchemy/cdm/model/clinical/__init__.py index c3033b2..ab59a0f 100644 --- a/omop_alchemy/cdm/model/clinical/__init__.py +++ b/omop_alchemy/cdm/model/clinical/__init__.py @@ -12,7 +12,9 @@ Procedure_OccurrenceContext, Procedure_OccurrenceView, ) -from .device_exposure import Device_Exposure, Device_ExposureContext, Device_ExposureView +from .device_exposure import ( + Device_Exposure, Device_ExposureContext, Device_ExposureView +) from .death import Death from .specimen import Specimen diff --git a/omop_alchemy/toolkit/core/modifiers/contracts.py b/omop_alchemy/toolkit/core/modifiers/contracts.py index dc43e9d..9ad542f 100644 --- a/omop_alchemy/toolkit/core/modifiers/contracts.py +++ b/omop_alchemy/toolkit/core/modifiers/contracts.py @@ -43,8 +43,7 @@ class ModifierColumn(StrEnum): unit_concept_id = "unit_concept_id" value_as_string = "value_as_string" -# we store this column listing because we want to be able to use Unions, -# which rely on column ordering by role +# Column ordering is important for UNION queries; this listing fixes the contract order. CANONICAL_MODIFIER_REQUIRED_COLUMNS: tuple[ModifierColumn, ...] = ( ModifierColumn.person_id, ModifierColumn.modifier_id, diff --git a/omop_alchemy/toolkit/episodes/derivation/observations.py b/omop_alchemy/toolkit/episodes/derivation/observations.py index eb7a0b4..787fa67 100644 --- a/omop_alchemy/toolkit/episodes/derivation/observations.py +++ b/omop_alchemy/toolkit/episodes/derivation/observations.py @@ -18,8 +18,8 @@ def observation_eligibility_predicate( ) -> sa.ColumnElement[bool]: """Return the date predicate required by an observation selection policy.""" if not spec.requires_anchor: - # Unanchored policies keep every source row eligible; callers can reuse - # the ranking builder for episode and person level observations + # Unanchored policies keep every source row eligible; callers can reuse + # the ranking builder for episode and person level observations return sa.true() if anchor_date is None: # Missing anchor input is a configuration error, not an instruction to From 7645a5032b8ca086c8a4a92e0633bddb126d41c8 Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Mon, 7 Sep 2026 18:13:06 +1000 Subject: [PATCH 19/30] updated postgres test coverage for modifier and stage selection --- tests/README.md | 28 ++++---- tests/test_load_vocab_postgres.py | 8 +-- tests/test_modifier_projections.py | 50 +++++++++++++ tests/test_modifier_selection.py | 108 +++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 21 deletions(-) diff --git a/tests/README.md b/tests/README.md index 0f70491..b19e588 100644 --- a/tests/README.md +++ b/tests/README.md @@ -4,27 +4,23 @@ ```bash # Unit and SQLite tests — no database required -uv run --extra dev pytest -m "not postgres" +uv run --extra dev pytest -m "not requires_database" -# PostgreSQL integration tests — requires the Docker container below -docker compose -f tests/docker-compose.yaml up -d -uv run --extra dev --extra postgres pytest -m postgres -v +# PostgreSQL integration tests — requires a configured test_cdm_db +uv run --extra dev --extra postgres pytest -m requires_database -v ``` ## PostgreSQL integration tests -The `postgres`-marked tests connect to a local PostgreSQL 16 container on -port **55432**. +PostgreSQL provisioning belongs to the workspace stack rather than this package; +there is no package-local Compose file. Configure its dedicated test database +using `omop-config configure omop_alchemy`. The resulting `test_cdm_db` +connection must have `test_only = true`—the test plugin rejects an ordinary +connection because these tests recreate its `public` schema. ```bash -# Start -docker compose -f tests/example-docker-compose.yaml up -d - -# Run (this will run all tests) -uv run --extra dev --extra postgres pytest -m "postgres or not postgres" -v - -# Stop -docker compose -f tests/docker-compose.yaml down +# Run the complete suite; database tests skip if test_cdm_db is absent. +uv run --extra dev --extra postgres pytest -v ``` ## Test markers @@ -32,10 +28,10 @@ docker compose -f tests/docker-compose.yaml down | Marker | Meaning | |--------|---------| | *(none)* | Runs on SQLite, no external dependencies | -| `postgres` | Requires the Docker container on port 55432 | +| `requires_database("test_cdm_db")` | Requires the configured PostgreSQL test database | ## Fixture data `tests/fixtures/athena_source/` contains a minimal set of Athena vocabulary CSVs (7 concepts) used to seed the SQLite test database. These are committed -to the repo and are sufficient for all non-postgres tests. +to the repo and are sufficient for all tests not marked `requires_database`. diff --git a/tests/test_load_vocab_postgres.py b/tests/test_load_vocab_postgres.py index 7e33727..df905a6 100644 --- a/tests/test_load_vocab_postgres.py +++ b/tests/test_load_vocab_postgres.py @@ -1,11 +1,9 @@ """ PostgreSQL integration tests for OMOP_Alchemy vocabulary loading. -These tests require a running PostgreSQL container. Start one with: - docker compose -f tests/docker-compose.yaml up -d - -Then run: - pytest -m postgres +These tests require a dedicated ``test_cdm_db`` PostgreSQL resource configured +with ``test_only = true``. Then run: + pytest -m requires_database """ from pathlib import Path diff --git a/tests/test_modifier_projections.py b/tests/test_modifier_projections.py index 7474e85..93f6cad 100644 --- a/tests/test_modifier_projections.py +++ b/tests/test_modifier_projections.py @@ -428,3 +428,53 @@ def modifier( (2, "missing_target_identity"), (4, "person_mismatch"), } + + +@pytest.mark.requires_database("test_cdm_db") +def test_postgresql_executes_modifier_target_validation_contracts(pg_session): + """PostgreSQL rejects incomplete and cross-person links before selection.""" + + def modifier( + modifier_id: int, + person_id: int, + target_id: int | None, + target_field: int | None, + ) -> sa.Select: + return sa.select( + sa.literal(person_id).label("person_id"), + sa.literal(modifier_id).label("modifier_id"), + sa.literal(date(2025, 1, 1)).label("modifier_date"), + sa.literal(datetime(2025, 1, 1, 9)).label("modifier_datetime"), + sa.literal(100).label("modifier_concept_id"), + sa.literal("measurement").label("modifier_source_table"), + sa.literal(target_id).label("target_event_id"), + sa.literal(target_field).label("target_field_concept_id"), + ) + + modifiers = sa.union_all( + modifier(1, 10, 7, ModifierFieldConcepts.CONDITION_OCCURRENCE), + modifier(2, 10, None, None), + modifier(3, 11, 7, ModifierFieldConcepts.CONDITION_OCCURRENCE), + ) + targets = sa.select( + sa.literal(10).label("person_id"), + sa.literal(7).label("event_id"), + sa.literal(ModifierFieldConcepts.CONDITION_OCCURRENCE).label( + "event_field_concept_id" + ), + sa.literal("condition_occurrence").label("event_source_table"), + ) + queries = modifier_target_queries(modifiers, targets, diagnostics=True) + + assert [ + row["modifier_id"] + for row in pg_session.execute(queries.matches).mappings() + ] == [1] + assert queries.diagnostics is not None + assert { + (row["modifier_id"], row["diagnostic_code"]) + for row in pg_session.execute(queries.diagnostics).mappings() + } == { + (2, "missing_target_identity"), + (3, "person_mismatch"), + } diff --git a/tests/test_modifier_selection.py b/tests/test_modifier_selection.py index 1bec005..8df3870 100644 --- a/tests/test_modifier_selection.py +++ b/tests/test_modifier_selection.py @@ -187,3 +187,111 @@ def test_condition_modifier_specs_delegate_to_governed_semantics(): tumor_size_modifier_concept_id() == runtime.condition_modifiers.numeric_condition_modifiers.tumor_size ) + + +@pytest.mark.requires_database("test_cdm_db") +def test_postgresql_executes_modifier_selection_and_stage_policy_contracts(pg_session): + """Execute collision, stable-tie, and stage overrides on PostgreSQL.""" + expected_by_spec = ( + (DEFAULT_STAGE_SELECTION, 11), + (StageSelectionSpec.clinical_first(), 10), + (StageSelectionSpec.chronological_only(), 10), + ( + StageSelectionSpec(temporal_policy=ModifierSelectionPolicy.latest), + 12, + ), + ) + for spec, expected_id in expected_by_spec: + selected = pg_session.execute( + preferred_stage_select(_stage_source(), spec=spec) + ).mappings().one() + assert selected["modifier_id"] == expected_id + + stage_source = _stage_source().subquery() + unclassified = sa.select( + *( + sa.literal("uT2").label(column.key) + if column.key == "modifier_concept_code" + else column + for column in stage_source.c + ) + ).where(stage_source.c.modifier_id == 10) + assert ( + pg_session.execute(preferred_stage_select(unclassified)) + .mappings() + .one()["modifier_id"] + == 10 + ) + + # A true same-basis temporal tie must use the canonical source identity, + # independently of branch order in the input UNION ALL. + selected_ids = [] + for reverse in (False, True): + source = _stage_source(reverse=reverse).subquery() + pathological = sa.select(source).where(source.c.modifier_id.in_((11, 12))) + tied = pathological.subquery() + normalized = sa.select( + *( + sa.literal(date(2025, 2, 1)).label(column.key) + if column.key == "modifier_date" + else sa.literal(datetime(2025, 2, 1, 9)).label(column.key) + if column.key == "modifier_datetime" + else column + for column in tied.c + ) + ) + selected_ids.append( + pg_session.execute(preferred_stage_select(normalized)) + .mappings() + .one()["modifier_id"] + ) + assert selected_ids == [11, 11] + + # A shared numeric target ID remains two partitions when the OMOP Field + # concept differs; each target therefore retains its own winner. + source = _stage_source().subquery() + condition = sa.select(source).where(source.c.modifier_id == 10) + procedure = sa.select( + *( + sa.literal(1147082).label(column.key) + if column.key == "target_field_concept_id" + else column + for column in source.c + ) + ).where(source.c.modifier_id == 11) + selected = pg_session.execute( + selected_modifier_select(sa.union_all(condition, procedure)) + ).mappings().all() + assert { + (row["target_field_concept_id"], row["modifier_id"]) for row in selected + } == { + (1147127, 10), + (1147082, 11), + } + + # Source-table scope keeps equal native IDs distinct. They also target two + # different OMOP tables that happen to use the same numeric event ID. + def modifier(source_table: str, target_field: int) -> sa.Select: + return sa.select( + sa.literal(1).label("person_id"), + sa.literal(7).label("modifier_id"), + sa.literal(date(2025, 1, 1)).label("modifier_date"), + sa.literal(datetime(2025, 1, 1, 9)).label("modifier_datetime"), + sa.literal(100).label("modifier_concept_id"), + sa.literal(source_table).label("modifier_source_table"), + sa.literal(500).label("target_event_id"), + sa.literal(target_field).label("target_field_concept_id"), + ) + + equal_native_ids = pg_session.execute( + selected_modifier_select( + sa.union_all( + modifier("measurement", 1147127), + modifier("observation", 1147082), + ) + ) + ).mappings().all() + assert { + (row["modifier_source_table"], row["modifier_id"]) + for row in equal_native_ids + } == {("measurement", 7), ("observation", 7)} From 88ebaf0660942569c7022948cb0f9a6f18188d14 Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Mon, 7 Sep 2026 21:05:50 +1000 Subject: [PATCH 20/30] export ATTACHMENT_METHOD --- .../toolkit/episodes/derivation/__init__.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/omop_alchemy/toolkit/episodes/derivation/__init__.py b/omop_alchemy/toolkit/episodes/derivation/__init__.py index fe930f9..e87eecf 100644 --- a/omop_alchemy/toolkit/episodes/derivation/__init__.py +++ b/omop_alchemy/toolkit/episodes/derivation/__init__.py @@ -1,21 +1,18 @@ """Construct episodes and resolve the relationships between them. Episodes in the CDM are rows that reference each other through -``episode_parent_id`` and reach clinical facts through ``Episode_Event``. -Turning that into something queryable — a regimen with its cycles, a -diagnosis with the treatment that followed — is this tier's job: queries -that select episodes by concept, join parent and child episodes into a -single result so a hierarchy can be read in one pass, and establish the -date windows relating one episode to another, written against the raw -``Episode``/``Episode_Event`` tables rather than any materialised view. +``episode_parent_id`` and resolve clinical facts through ``Episode_Event``. The public contracts in this area define episode-attachment identities and policies used by query builders. Shared clinical-event row names and identities -live in ``toolkit.core.events``. Projection, attachment, and ranking helpers -return SQLAlchemy statements or expressions without executing them. +live in ``toolkit.core.events``. + +Projection, attachment, and ranking helpers return SQLAlchemy statements or +expressions without executing them. """ from .attachments import ( + ATTACHMENT_METHOD, EpisodeAttachmentQueries, InvalidAttachmentSourceError, episode_attachment_queries, @@ -70,6 +67,7 @@ "EpisodeColumn", "EpisodeAttachmentDiagnostic", "EpisodeAttachmentIdentity", + "ATTACHMENT_METHOD", "EpisodeAttachmentMethod", "EpisodeAttachmentPolicy", "EpisodeAttachmentQueries", From 618c54397599035cfdd684be3278cf1be731753b Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Wed, 9 Sep 2026 21:42:22 +0930 Subject: [PATCH 21/30] Refactor shared toolkit SQL helpers and contracts, update projections/episode derivation and layering, and align oncology stage selection, documentation, and tests. --- omop_alchemy/toolkit/_utils.py | 60 +++++++++++++++++ .../analytics/oncology/condition_modifiers.py | 30 +++++---- omop_alchemy/toolkit/core/events/contracts.py | 32 ++++----- .../toolkit/core/events/projections.py | 26 ++------ .../toolkit/core/modifiers/contracts.py | 65 +++++++++++-------- .../toolkit/core/modifiers/projections.py | 32 +++------ .../toolkit/core/modifiers/selection.py | 33 ++++------ .../toolkit/core/modifiers/targets.py | 35 ++++------ .../episodes/derivation/attachments.py | 35 ++-------- .../toolkit/episodes/derivation/contracts.py | 41 +++++++++--- .../toolkit/episodes/derivation/structure.py | 32 ++------- tests/test_modifier_selection.py | 51 +++++++++++++-- 12 files changed, 253 insertions(+), 219 deletions(-) create mode 100644 omop_alchemy/toolkit/_utils.py diff --git a/omop_alchemy/toolkit/_utils.py b/omop_alchemy/toolkit/_utils.py new file mode 100644 index 0000000..173ef84 --- /dev/null +++ b/omop_alchemy/toolkit/_utils.py @@ -0,0 +1,60 @@ +"""Shared implementation details for toolkit query builders.""" + +from __future__ import annotations + +from collections.abc import Collection, Iterable, Sequence +from typing import Any + +import sqlalchemy as sa + +from sqlalchemy.sql.selectable import FromClause, SelectBase + + +def _nullable_column( + model: type[Any], + name: str, + sql_type: sa.types.TypeEngine[Any], +) -> sa.ColumnElement[Any]: + """Project a labelled column or a typed NULL when the model lacks it.""" + column = getattr(model, str(name), None) + if column is None: + # Unions need the same column positions across heterogeneous sources. + return sa.cast(sa.null(), sql_type).label(str(name)) + return column.label(str(name)) + + +def _select_or_union_all( + projections: Sequence[sa.Select[Any]], + *, + error_message: str, +) -> sa.Select[Any] | sa.CompoundSelect[Any]: + """Return one projection directly or combine several with ``UNION ALL``.""" + if not projections: + raise ValueError(error_message) + return projections[0] if len(projections) == 1 else sa.union_all(*projections) + + +def _as_from_clause( + source: FromClause | SelectBase, + *, + name: str, +) -> FromClause: + """Coerce a selectable to a named ``FromClause`` for query composition.""" + if isinstance(source, SelectBase): + return source.subquery(name) + if isinstance(source, FromClause): + return source + raise TypeError(f"{name} must be a SQLAlchemy Select or FromClause") + + +def _require_columns( + available: Collection[str], + required: Iterable[str], + *, + role: str, + error_type: type[Exception], +) -> None: + """Raise a domain-specific error when required column names are absent.""" + missing = tuple(sorted(set(required) - set(available))) + if missing: + raise error_type(f"{role} is missing required columns: {', '.join(missing)}") diff --git a/omop_alchemy/toolkit/analytics/oncology/condition_modifiers.py b/omop_alchemy/toolkit/analytics/oncology/condition_modifiers.py index 4b29860..60bd49c 100644 --- a/omop_alchemy/toolkit/analytics/oncology/condition_modifiers.py +++ b/omop_alchemy/toolkit/analytics/oncology/condition_modifiers.py @@ -9,6 +9,7 @@ import sqlalchemy as sa from sqlalchemy.sql.selectable import FromClause, SelectBase +from omop_alchemy.toolkit._utils import _as_from_clause from omop_alchemy.toolkit.core.modifiers import ( ModifierSelectionPolicy, ModifierSelectionSpec, @@ -81,7 +82,12 @@ def chronological_only( def stage_basis_expression( concept_code: sa.ColumnElement[Any], ) -> sa.ColumnElement[str]: - """Classify OMOP stage concept codes by their conventional p/c prefix.""" + """Classify stage codes using the source vocabulary's p/c convention. + + The staging vocabulary does not expose a reliable parent concept that + separates pathological from clinical staging. The code prefix is therefore + the intentional source-level contract rather than a synthetic hierarchy. + """ normalized = sa.func.lower(sa.func.trim(concept_code)) return sa.case( (normalized.like("p%"), str(StageBasis.pathological)), @@ -107,24 +113,24 @@ def stage_basis_priority_expression( ) -def _as_source(source: FromClause | SelectBase) -> FromClause: - if isinstance(source, SelectBase): - return source.subquery("preferred_stage_source") - if isinstance(source, FromClause): - return source - raise TypeError("source must be a SQLAlchemy Select or FromClause") - - def preferred_stage_select( source: FromClause | SelectBase, *, spec: StageSelectionSpec = DEFAULT_STAGE_SELECTION, - concept_code_column: str = "modifier_concept_code", + concept_code_column: str | None = None, ) -> sa.Select[Any]: - """Select one preferred stage modifier for every canonical target.""" - modifiers = _as_source(source) + """Select one preferred stage modifier for every canonical target. + + Basis-ranked selection requires a source enriched with a concept-code + column. Chronological-only selection does not require that enrichment. + """ + modifiers = _as_from_clause(source, name="preferred_stage_source") priority: tuple[sa.ColumnElement[Any], ...] = () if spec.basis_priority: + if concept_code_column is None: + raise ValueError( + "concept_code_column is required when basis ranking is enabled" + ) if not concept_code_column.strip(): raise ValueError("concept_code_column must not be empty") if concept_code_column not in modifiers.c: diff --git a/omop_alchemy/toolkit/core/events/contracts.py b/omop_alchemy/toolkit/core/events/contracts.py index 2466628..25d83f5 100644 --- a/omop_alchemy/toolkit/core/events/contracts.py +++ b/omop_alchemy/toolkit/core/events/contracts.py @@ -23,26 +23,6 @@ class ClinicalEventColumn(StrEnum): unit_concept_id = "unit_concept_id" -CANONICAL_EVENT_REQUIRED_COLUMNS: tuple[ClinicalEventColumn, ...] = ( - ClinicalEventColumn.person_id, - ClinicalEventColumn.event_id, - ClinicalEventColumn.event_date, - ClinicalEventColumn.event_datetime, - ClinicalEventColumn.event_concept_id, - ClinicalEventColumn.event_field_concept_id, - ClinicalEventColumn.event_source_table, -) -"""Columns every canonical clinical-event projection must expose.""" - - -CANONICAL_EVENT_OPTIONAL_COLUMNS: tuple[ClinicalEventColumn, ...] = ( - ClinicalEventColumn.value_as_number, - ClinicalEventColumn.value_as_concept_id, - ClinicalEventColumn.unit_concept_id, -) -"""Nullable value columns a projection may add when its source supports them.""" - - @runtime_checkable class ClinicalEventRow(Protocol): """Value-level view of the required canonical event projection. @@ -70,6 +50,18 @@ class ValuedClinicalEventRow(ClinicalEventRow, Protocol): unit_concept_id: int | None +CANONICAL_EVENT_REQUIRED_COLUMNS: tuple[ClinicalEventColumn, ...] = tuple( + ClinicalEventColumn[name] for name in ClinicalEventRow.__annotations__ +) +"""Columns every canonical clinical-event projection must expose.""" + + +CANONICAL_EVENT_OPTIONAL_COLUMNS: tuple[ClinicalEventColumn, ...] = tuple( + ClinicalEventColumn[name] for name in ValuedClinicalEventRow.__annotations__ +) +"""Nullable value columns a projection may add when its source supports them.""" + + @dataclass(frozen=True, order=True, slots=True) class ClinicalEventIdentity: """Cross-table event identity. diff --git a/omop_alchemy/toolkit/core/events/projections.py b/omop_alchemy/toolkit/core/events/projections.py index e6ba86f..aebc3e5 100644 --- a/omop_alchemy/toolkit/core/events/projections.py +++ b/omop_alchemy/toolkit/core/events/projections.py @@ -11,6 +11,7 @@ from omop_alchemy.cdm.model.clinical.event_metadata import ( clinical_event_target_for_table, ) +from omop_alchemy.toolkit._utils import _nullable_column, _select_or_union_all from .contracts import ClinicalEventColumn @@ -130,19 +131,6 @@ def clinical_event_model_spec(model: type[Any]) -> ClinicalEventModelSpec: ) -def _nullable_column( - model: type[Any], - name: ClinicalEventColumn, - sql_type: sa.types.TypeEngine[Any], -) -> sa.ColumnElement[Any]: - column = getattr(model, str(name), None) - if column is None: - # Unions need the same column positions across event tables. A typed - # NULL preserves that shape when a source has no corresponding value. - return sa.cast(sa.null(), sql_type).label(str(name)) - return column.label(str(name)) - - def canonical_event_projection( model: type[Any], *, @@ -201,15 +189,11 @@ def canonical_event_union( include_values: bool = True, ) -> sa.Select[Any] | sa.CompoundSelect[Any]: """Combine supported event models into one canonical ``UNION ALL`` query.""" - if not models: - raise ValueError("canonical_event_union requires at least one model") projections = [ canonical_event_projection(model, include_values=include_values) for model in models ] - # Keep one-model calls as Select objects while combining multiple models - # with UNION ALL; callers can therefore use the same canonical columns in - # either case without deduplicating clinically distinct rows. - if len(projections) == 1: - return projections[0] - return sa.union_all(*projections) + return _select_or_union_all( + projections, + error_message="canonical_event_union requires at least one model", + ) diff --git a/omop_alchemy/toolkit/core/modifiers/contracts.py b/omop_alchemy/toolkit/core/modifiers/contracts.py index 9ad542f..0815942 100644 --- a/omop_alchemy/toolkit/core/modifiers/contracts.py +++ b/omop_alchemy/toolkit/core/modifiers/contracts.py @@ -19,7 +19,7 @@ from dataclasses import dataclass from datetime import date, datetime from enum import StrEnum -from typing import Any, Mapping, Protocol, runtime_checkable +from typing import Protocol, TypedDict, runtime_checkable class ModifierColumn(StrEnum): @@ -43,26 +43,6 @@ class ModifierColumn(StrEnum): unit_concept_id = "unit_concept_id" value_as_string = "value_as_string" -# Column ordering is important for UNION queries; this listing fixes the contract order. -CANONICAL_MODIFIER_REQUIRED_COLUMNS: tuple[ModifierColumn, ...] = ( - ModifierColumn.person_id, - ModifierColumn.modifier_id, - ModifierColumn.modifier_date, - ModifierColumn.modifier_datetime, - ModifierColumn.modifier_concept_id, - ModifierColumn.modifier_source_table, - ModifierColumn.target_event_id, - ModifierColumn.target_field_concept_id, -) - - -CANONICAL_MODIFIER_VALUE_COLUMNS: tuple[ModifierColumn, ...] = ( - ModifierColumn.value_as_number, - ModifierColumn.value_as_concept_id, - ModifierColumn.unit_concept_id, - ModifierColumn.value_as_string, -) - @runtime_checkable class ModifierRow(Protocol): @@ -88,6 +68,18 @@ class ValuedModifierRow(ModifierRow, Protocol): value_as_string: str | None +# Column ordering is important for UNION queries; derive it from the row +# contracts so the labels and their typed fields cannot drift apart. +CANONICAL_MODIFIER_REQUIRED_COLUMNS: tuple[ModifierColumn, ...] = tuple( + ModifierColumn[name] for name in ModifierRow.__annotations__ +) + + +CANONICAL_MODIFIER_VALUE_COLUMNS: tuple[ModifierColumn, ...] = tuple( + ModifierColumn[name] for name in ValuedModifierRow.__annotations__ +) + + @dataclass(frozen=True, order=True, slots=True) class ModifierIdentity: """Source-table-scoped identity of the modifier row itself.""" @@ -167,6 +159,15 @@ class ModifierTargetDiagnosticColumn(StrEnum): message = "message" +class _ModifierTargetDiagnosticMapping(TypedDict): + diagnostic_code: str + modifier_source_table: str + modifier_id: int + target_field_concept_id: int | None + target_event_id: int | None + message: str + + @dataclass(frozen=True, slots=True) class ModifierTargetDiagnostic: """Typed value representation of one target-resolution diagnostic row.""" @@ -179,12 +180,20 @@ class ModifierTargetDiagnostic: message: str @classmethod - def from_mapping(cls, row: Mapping[str, Any]) -> ModifierTargetDiagnostic: + def from_mapping( + cls, row: _ModifierTargetDiagnosticMapping + ) -> ModifierTargetDiagnostic: return cls( - diagnostic_code=ModifierTargetDiagnosticCode(row["diagnostic_code"]), - modifier_source_table=str(row["modifier_source_table"]), - modifier_id=int(row["modifier_id"]), - target_field_concept_id=row["target_field_concept_id"], - target_event_id=row["target_event_id"], - message=str(row["message"]), + diagnostic_code=ModifierTargetDiagnosticCode( + row[str(ModifierTargetDiagnosticColumn.diagnostic_code)] + ), + modifier_source_table=str( + row[str(ModifierTargetDiagnosticColumn.modifier_source_table)] + ), + modifier_id=int(row[str(ModifierTargetDiagnosticColumn.modifier_id)]), + target_field_concept_id=row[ + str(ModifierTargetDiagnosticColumn.target_field_concept_id) + ], + target_event_id=row[str(ModifierTargetDiagnosticColumn.target_event_id)], + message=str(row[str(ModifierTargetDiagnosticColumn.message)]), ) diff --git a/omop_alchemy/toolkit/core/modifiers/projections.py b/omop_alchemy/toolkit/core/modifiers/projections.py index fc4ab70..b1447cf 100644 --- a/omop_alchemy/toolkit/core/modifiers/projections.py +++ b/omop_alchemy/toolkit/core/modifiers/projections.py @@ -6,6 +6,8 @@ import sqlalchemy as sa +from omop_alchemy.toolkit._utils import _nullable_column, _select_or_union_all + from .contracts import CANONICAL_MODIFIER_VALUE_COLUMNS, ModifierColumn from .metadata import ( UnsupportedModifierSourceModelError, @@ -24,15 +26,6 @@ } -def _nullable( - model: type[Any], name: ModifierColumn, sql_type: sa.types.TypeEngine[Any] -) -> sa.ColumnElement[Any]: - column = getattr(model, str(name), None) - if column is None: - return sa.cast(sa.null(), sql_type).label(str(name)) - return column.label(str(name)) - - def canonical_modifier_projection( model: type[Any], *, include_values: bool = True ) -> sa.Select[Any]: @@ -45,15 +38,9 @@ def canonical_modifier_projection( ) columns: list[sa.ColumnElement[Any]] = [ model.person_id.label(str(ModifierColumn.person_id)), - getattr(model, spec.event_id_column).label( - str(ModifierColumn.modifier_id) - ), - getattr(model, spec.event_date_column).label( - str(ModifierColumn.modifier_date) - ), - getattr(model, datetime_column).label( - str(ModifierColumn.modifier_datetime) - ), + getattr(model, spec.event_id_column).label(str(ModifierColumn.modifier_id)), + getattr(model, spec.event_date_column).label(str(ModifierColumn.modifier_date)), + getattr(model, datetime_column).label(str(ModifierColumn.modifier_datetime)), getattr(model, spec.event_concept_id_column).label( str(ModifierColumn.modifier_concept_id) ), @@ -68,7 +55,7 @@ def canonical_modifier_projection( ] if include_values: columns.extend( - _nullable(model, column, _VALUE_COLUMN_TYPES[column]) + _nullable_column(model, column, _VALUE_COLUMN_TYPES[column]) for column in CANONICAL_MODIFIER_VALUE_COLUMNS ) return sa.select(*columns) @@ -77,10 +64,11 @@ def canonical_modifier_projection( def canonical_modifier_union( *models: type[Any], include_values: bool = True ) -> sa.Select[Any] | sa.CompoundSelect[Any]: - if not models: - raise ValueError("canonical_modifier_union requires at least one model") projections = [ canonical_modifier_projection(model, include_values=include_values) for model in models ] - return projections[0] if len(projections) == 1 else sa.union_all(*projections) + return _select_or_union_all( + projections, + error_message="canonical_modifier_union requires at least one model", + ) diff --git a/omop_alchemy/toolkit/core/modifiers/selection.py b/omop_alchemy/toolkit/core/modifiers/selection.py index c639eb7..41e1497 100644 --- a/omop_alchemy/toolkit/core/modifiers/selection.py +++ b/omop_alchemy/toolkit/core/modifiers/selection.py @@ -8,6 +8,7 @@ import sqlalchemy as sa from sqlalchemy.sql.selectable import FromClause, SelectBase +from omop_alchemy.toolkit._utils import _as_from_clause, _require_columns from omop_alchemy.toolkit.core._ranking import deterministic_row_number from .contracts import ModifierColumn, ModifierSelectionPolicy, ModifierSelectionSpec @@ -19,14 +20,6 @@ class InvalidModifierSourceError(ValueError): pass -def _as_source(source: FromClause | SelectBase, name: str) -> FromClause: - if isinstance(source, SelectBase): - return source.subquery(name) - if isinstance(source, FromClause): - return source - raise TypeError("source must be a SQLAlchemy Select or FromClause") - - def modifier_order_expressions( columns: sa.sql.base.ReadOnlyColumnCollection[str, Any], spec: ModifierSelectionSpec, @@ -40,11 +33,12 @@ def modifier_order_expressions( spec.datetime_column, *spec.stable_identity_columns, } - missing = tuple(sorted(required.difference(columns.keys()))) - if missing: - raise InvalidModifierSourceError( - f"modifier source is missing required columns: {', '.join(missing)}" - ) + _require_columns( + columns.keys(), + required, + role="modifier source", + error_type=InvalidModifierSourceError, + ) direction = sa.desc if spec.policy is ModifierSelectionPolicy.latest else sa.asc date_column = columns[spec.date_column] datetime_column = columns[spec.datetime_column] @@ -82,7 +76,7 @@ def ranked_modifier_select( rank_label: str = MODIFIER_RANK, ) -> sa.Select[Any]: """Rank bound modifiers, with caller priorities preceding temporal policy.""" - modifiers = _as_source(source, "modifier_selection_source") + modifiers = _as_from_clause(source, name="modifier_selection_source") required = { *spec.partition_by, spec.date_column, @@ -91,11 +85,12 @@ def ranked_modifier_select( str(ModifierColumn.target_event_id), str(ModifierColumn.target_field_concept_id), } - missing = tuple(sorted(required.difference(modifiers.c.keys()))) - if missing: - raise InvalidModifierSourceError( - f"modifier source is missing required columns: {', '.join(missing)}" - ) + _require_columns( + modifiers.c.keys(), + required, + role="modifier source", + error_type=InvalidModifierSourceError, + ) rank = modifier_row_number( modifiers.c, diff --git a/omop_alchemy/toolkit/core/modifiers/targets.py b/omop_alchemy/toolkit/core/modifiers/targets.py index f00b1c6..ae19007 100644 --- a/omop_alchemy/toolkit/core/modifiers/targets.py +++ b/omop_alchemy/toolkit/core/modifiers/targets.py @@ -8,6 +8,7 @@ import sqlalchemy as sa from sqlalchemy.sql.selectable import FromClause, SelectBase +from omop_alchemy.toolkit._utils import _as_from_clause, _require_columns from omop_alchemy.toolkit.core.events import ClinicalEventColumn from .contracts import ( @@ -52,22 +53,6 @@ def canonical_modifier_target_projection(model: type[Any]) -> sa.Select[Any]: ) -def _as_source(source: FromClause | SelectBase, name: str) -> FromClause: - if isinstance(source, SelectBase): - return source.subquery(name) - if isinstance(source, FromClause): - return source - raise TypeError(f"{name} must be a SQLAlchemy Select or FromClause") - - -def _require(source: FromClause, columns: tuple[str, ...], role: str) -> None: - missing = tuple(name for name in columns if name not in source.c) - if missing: - raise InvalidModifierTargetSourceError( - f"{role} is missing required columns: {', '.join(missing)}" - ) - - def modifier_target_queries( modifier_source: type[Any] | FromClause | SelectBase, target_source: type[Any] | FromClause | SelectBase, @@ -79,7 +64,7 @@ def modifier_target_queries( modifiers = ( canonical_modifier_projection(modifier_source).subquery("target_modifiers") if isinstance(modifier_source, type) - else _as_source(modifier_source, "target_modifiers") + else _as_from_clause(modifier_source, name="target_modifiers") ) target_spec = ( modifier_target_model_spec(target_source) @@ -89,15 +74,16 @@ def modifier_target_queries( targets = ( canonical_modifier_target_projection(target_source).subquery("modifier_targets") if isinstance(target_source, type) - else _as_source(target_source, "modifier_targets") + else _as_from_clause(target_source, name="modifier_targets") ) - _require( - modifiers, + _require_columns( + modifiers.c.keys(), tuple(str(column) for column in CANONICAL_MODIFIER_REQUIRED_COLUMNS), - "modifier source", + role="modifier source", + error_type=InvalidModifierTargetSourceError, ) - _require( - targets, + _require_columns( + targets.c.keys(), tuple( str(column) for column in ( @@ -107,7 +93,8 @@ def modifier_target_queries( ClinicalEventColumn.event_source_table, ) ), - "target source", + role="target source", + error_type=InvalidModifierTargetSourceError, ) person = str(ModifierColumn.person_id) diff --git a/omop_alchemy/toolkit/episodes/derivation/attachments.py b/omop_alchemy/toolkit/episodes/derivation/attachments.py index 47b268e..9d0f55a 100644 --- a/omop_alchemy/toolkit/episodes/derivation/attachments.py +++ b/omop_alchemy/toolkit/episodes/derivation/attachments.py @@ -9,6 +9,7 @@ from sqlalchemy.sql.selectable import FromClause, SelectBase from omop_alchemy.cdm.model.structural import Episode, Episode_Event +from omop_alchemy.toolkit._utils import _as_from_clause, _require_columns from omop_alchemy.toolkit.core.events import ( CANONICAL_EVENT_REQUIRED_COLUMNS, ClinicalEventColumn, @@ -55,18 +56,6 @@ class EpisodeAttachmentQueries: diagnostics: sa.Select[Any] | None = None -def _as_from_clause( - source: FromClause | SelectBase, - *, - name: str, -) -> FromClause: - if isinstance(source, SelectBase): - return source.subquery(name) - if isinstance(source, FromClause): - return source - raise TypeError(f"{name} must be a SQLAlchemy Select or FromClause") - - def _event_source(source: type[Any] | FromClause | SelectBase) -> FromClause: if isinstance(source, type): return canonical_event_projection(source).subquery("attachment_events") @@ -91,19 +80,6 @@ def _episode_event_source( return _as_from_clause(source, name="attachment_episode_events") -def _require_columns( - source: FromClause, - required: tuple[str, ...], - *, - role: str, -) -> None: - missing = tuple(name for name in required if name not in source.c) - if missing: - raise InvalidAttachmentSourceError( - f"{role} is missing required columns: {', '.join(missing)}" - ) - - def _same_event(left: FromClause, right: FromClause) -> sa.ColumnElement[bool]: return sa.and_( left.c[str(ClinicalEventColumn.event_source_table)] @@ -283,12 +259,13 @@ def episode_attachment_queries( event_names = tuple(column.key for column in event_source.c) _require_columns( - event_source, + event_source.c.keys(), tuple(str(column) for column in CANONICAL_EVENT_REQUIRED_COLUMNS), role="events", + error_type=InvalidAttachmentSourceError, ) _require_columns( - episode_source, + episode_source.c.keys(), ( str(EpisodeColumn.episode_id), str(EpisodeColumn.person_id), @@ -296,11 +273,13 @@ def episode_attachment_queries( str(EpisodeColumn.episode_end_date), ), role="episodes", + error_type=InvalidAttachmentSourceError, ) _require_columns( - link_source, + link_source.c.keys(), ("episode_id", "event_id", "episode_event_field_concept_id"), role="episode_events", + error_type=InvalidAttachmentSourceError, ) for reserved in (ATTACHMENT_EPISODE_ID, ATTACHMENT_METHOD): if reserved in event_names: diff --git a/omop_alchemy/toolkit/episodes/derivation/contracts.py b/omop_alchemy/toolkit/episodes/derivation/contracts.py index 9e7fcbf..6de3bc4 100644 --- a/omop_alchemy/toolkit/episodes/derivation/contracts.py +++ b/omop_alchemy/toolkit/episodes/derivation/contracts.py @@ -9,7 +9,7 @@ from dataclasses import dataclass from enum import StrEnum -from typing import Any, Mapping +from typing import TypedDict from omop_alchemy.toolkit.core.events import ClinicalEventIdentity from omop_alchemy.toolkit.episodes.handling.event_windowing import ( @@ -142,6 +142,17 @@ class AttachmentDiagnosticColumn(StrEnum): """Columns exposed by an attachment diagnostics query.""" +class _EpisodeAttachmentDiagnosticMapping(TypedDict): + diagnostic_code: str + event_source_table: str + event_id: int + event_field_concept_id: int + linked_event_field_concept_id: int | None + episode_id: int | None + candidate_count: int | None + message: str + + @dataclass(frozen=True, slots=True) class EpisodeAttachmentDiagnostic: """Typed advisory result returned by an attachment diagnostics query.""" @@ -155,19 +166,29 @@ class EpisodeAttachmentDiagnostic: candidate_count: int | None = None @classmethod - def from_mapping(cls, row: Mapping[str, Any]) -> "EpisodeAttachmentDiagnostic": + def from_mapping( + cls, row: _EpisodeAttachmentDiagnosticMapping + ) -> "EpisodeAttachmentDiagnostic": """Convert one SQLAlchemy mapping result without leaking column-name handling.""" return cls( - code=AttachmentDiagnosticCode(row["diagnostic_code"]), + code=AttachmentDiagnosticCode( + row[str(AttachmentDiagnosticColumn.diagnostic_code)] + ), event=ClinicalEventIdentity( - event_source_table=row["event_source_table"], - event_id=row["event_id"], + event_source_table=row[ + str(AttachmentDiagnosticColumn.event_source_table) + ], + event_id=row[str(AttachmentDiagnosticColumn.event_id)], ), - event_field_concept_id=row["event_field_concept_id"], - linked_event_field_concept_id=row["linked_event_field_concept_id"], - episode_id=row["episode_id"], - candidate_count=row["candidate_count"], - message=row["message"], + event_field_concept_id=row[ + str(AttachmentDiagnosticColumn.event_field_concept_id) + ], + linked_event_field_concept_id=row[ + str(AttachmentDiagnosticColumn.linked_event_field_concept_id) + ], + episode_id=row[str(AttachmentDiagnosticColumn.episode_id)], + candidate_count=row[str(AttachmentDiagnosticColumn.candidate_count)], + message=row[str(AttachmentDiagnosticColumn.message)], ) diff --git a/omop_alchemy/toolkit/episodes/derivation/structure.py b/omop_alchemy/toolkit/episodes/derivation/structure.py index a717e60..1ff2c69 100644 --- a/omop_alchemy/toolkit/episodes/derivation/structure.py +++ b/omop_alchemy/toolkit/episodes/derivation/structure.py @@ -10,32 +10,20 @@ from omop_alchemy.cdm.model.structural import Episode, Episode_Event from omop_alchemy.cdm.model.vocabulary import Concept +from omop_alchemy.toolkit._utils import _as_from_clause from .contracts import CANONICAL_EPISODE_COLUMNS, EpisodeColumn EpisodeSource = type[Episode] | FromClause | SelectBase EpisodeEventSource = type[Episode_Event] | FromClause | SelectBase +EpisodeHierarchySource = type[Episode] | type[Episode_Event] | FromClause | SelectBase # Hierarchy builders deliberately accept both mapped tables and pre-shaped # selectables. This keeps filtering/aliasing at the caller boundary instead of # forcing recursive queries to rediscover or override that source definition. -def _as_from_clause( - source: FromClause | SelectBase, - *, - name: str, -) -> FromClause: - # Recursive joins and column lookup need a FromClause. Wrapping a Select - # once also gives it a stable name for readable SQL and repeated aliases. - if isinstance(source, SelectBase): - return source.subquery(name) - if isinstance(source, FromClause): - return source - raise TypeError(f"{name} must be a SQLAlchemy Select or FromClause") - - -def _episode_source(source: EpisodeSource, *, name: str) -> FromClause: +def _episode_source(source: EpisodeHierarchySource, *, name: str) -> FromClause: # Mapped classes contribute only their table here; relationship-bearing ORM # behavior is intentionally kept out of these SQL-only hierarchy builders. if isinstance(source, type): @@ -43,18 +31,6 @@ def _episode_source(source: EpisodeSource, *, name: str) -> FromClause: return _as_from_clause(source, name=name) -def _episode_event_source( - source: EpisodeEventSource, - *, - name: str, -) -> FromClause: - # Episode_Event is normalized separately because callers may supply a - # filtered link source while the hierarchy itself remains episode-relative. - if isinstance(source, type): - return cast(FromClause, getattr(source, "__table__")) - return _as_from_clause(source, name=name) - - def canonical_episode_projection( episode_model: EpisodeSource = Episode, *, @@ -194,7 +170,7 @@ def episode_event_hierarchy_projection( max_depth=max_depth, name="episode_event_descendants", ) - event = _episode_event_source( + event = _episode_source( episode_event_model, name="episode_event_hierarchy_events", ) diff --git a/tests/test_modifier_selection.py b/tests/test_modifier_selection.py index 8df3870..486b06a 100644 --- a/tests/test_modifier_selection.py +++ b/tests/test_modifier_selection.py @@ -79,7 +79,13 @@ def test_stage_selection_default_and_overrides(spec, expected_id: int): engine = sa.create_engine("sqlite://") with engine.connect() as connection: selected = ( - connection.execute(preferred_stage_select(_stage_source(), spec=spec)) + connection.execute( + preferred_stage_select( + _stage_source(), + spec=spec, + concept_code_column="modifier_concept_code", + ) + ) .mappings() .one() ) @@ -95,6 +101,14 @@ def test_stage_selection_spec_is_immutable_and_validates_basis_permutation(): StageSelectionSpec(basis_priority=(StageBasis.pathological,)) +def test_basis_selection_requires_an_explicit_concept_code_column(): + with pytest.raises( + ValueError, + match="concept_code_column is required when basis ranking is enabled", + ): + preferred_stage_select(_stage_source()) + + def test_chronological_policy_does_not_require_a_concept_code_column(): source = _stage_source().subquery() without_code = sa.select( @@ -124,7 +138,12 @@ def test_same_basis_tie_is_stable_when_input_order_reverses(): ) ) selected_ids.append( - connection.execute(preferred_stage_select(normalized)) + connection.execute( + preferred_stage_select( + normalized, + concept_code_column="modifier_concept_code", + ) + ) .mappings() .one()["modifier_id"] ) @@ -202,9 +221,17 @@ def test_postgresql_executes_modifier_selection_and_stage_policy_contracts(pg_se ), ) for spec, expected_id in expected_by_spec: - selected = pg_session.execute( - preferred_stage_select(_stage_source(), spec=spec) - ).mappings().one() + selected = ( + pg_session.execute( + preferred_stage_select( + _stage_source(), + spec=spec, + concept_code_column="modifier_concept_code", + ) + ) + .mappings() + .one() + ) assert selected["modifier_id"] == expected_id stage_source = _stage_source().subquery() @@ -217,7 +244,12 @@ def test_postgresql_executes_modifier_selection_and_stage_policy_contracts(pg_se ) ).where(stage_source.c.modifier_id == 10) assert ( - pg_session.execute(preferred_stage_select(unclassified)) + pg_session.execute( + preferred_stage_select( + unclassified, + concept_code_column="modifier_concept_code", + ) + ) .mappings() .one()["modifier_id"] == 10 @@ -241,7 +273,12 @@ def test_postgresql_executes_modifier_selection_and_stage_policy_contracts(pg_se ) ) selected_ids.append( - pg_session.execute(preferred_stage_select(normalized)) + pg_session.execute( + preferred_stage_select( + normalized, + concept_code_column="modifier_concept_code", + ) + ) .mappings() .one()["modifier_id"] ) From 02252477bbe0ce9254ee8c98c7d074d057f3812e Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Wed, 9 Sep 2026 22:06:49 +0930 Subject: [PATCH 22/30] docs updates --- .importlinter | 3 +- README.md | 47 ++---- docs/advanced/fulltext.md | 7 +- docs/api/architecture.md | 202 ++++++++------------------ docs/api/index.md | 13 +- docs/api/query.md | 2 +- docs/getting-started/configuration.md | 38 ++--- docs/getting-started/index.md | 7 +- docs/getting-started/installation.md | 14 +- docs/getting-started/maintenance.md | 104 ++++--------- docs/getting-started/quickstart.md | 7 +- docs/static/images/oa-configure.png | Bin 0 -> 67192 bytes docs/static/images/oa-fulltext.png | Bin 0 -> 62987 bytes docs/static/images/oa-info.png | Bin 0 -> 144767 bytes docs/toolkit/analytics.md | 14 +- docs/toolkit/core.md | 14 +- docs/toolkit/index.md | 6 +- docs/toolkit/materialized-views.md | 4 +- mkdocs.yml | 1 + 19 files changed, 156 insertions(+), 327 deletions(-) create mode 100644 docs/static/images/oa-configure.png create mode 100644 docs/static/images/oa-fulltext.png create mode 100644 docs/static/images/oa-info.png diff --git a/.importlinter b/.importlinter index 1078a5d..de9e387 100644 --- a/.importlinter +++ b/.importlinter @@ -9,4 +9,5 @@ layers = omop_alchemy.toolkit.analytics omop_alchemy.toolkit.episodes omop_alchemy.toolkit.core - omop_alchemy.cdm \ No newline at end of file + omop_alchemy.toolkit._utils + omop_alchemy.cdm diff --git a/README.md b/README.md index 7ddcb11..0b831f6 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,8 @@ # OMOP Alchemy -**OMOP Alchemy** provides a canonical, typed, SQLAlchemy-first representation of the -[OHDSI OMOP Common Data Model (CDM)](https://ohdsi.github.io/CommonDataModel/). +**OMOP Alchemy** provides a canonical, typed, SQLAlchemy-first representation of the [OHDSI OMOP Common Data Model (CDM)](https://ohdsi.github.io/CommonDataModel/). -It is designed to support **research-ready analytics, validation, and exploration** -of OMOP data using modern Python tooling, without imposing ETL conventions or -execution-time side effects. +It is designed to support fluency for **research-ready analytics, validation, and exploration** of OMOP data using modern Python tooling, without imposing ETL conventions or execution-time side effects. --- @@ -16,30 +13,11 @@ OMOP Alchemy is intentionally: - **Declarative** Defines tables, columns, relationships, and constraints -- **SQLAlchemy-native** - Built for SQLAlchemy 2.x ORM usage - -- **Safe to import anywhere** - No implicit engine creation, no global state, no environment assumptions. - - **Typed and inspectable** Models are fully typed and introspectable for validation, tooling, and IDE support. - **Backend-agnostic** - Designed to work across PostgreSQL, SQLite, and other SQLAlchemy-supported databases. - ---- - -## What this package does *not* do - -OMOP Alchemy deliberately avoids: - -- Enforcing ETL conventions or data pipelines -- Auto-creating databases or loading vocabularies -- Imposing analytics frameworks or dashboards -- Making assumptions about deployment environments - -These concerns are intentionally left to downstream tooling. + Layered abstractions for adding in new supported backend behaviours. --- @@ -47,10 +25,9 @@ These concerns are intentionally left to downstream tooling. - SQLAlchemy ORM models for OMOP CDM tables - Explicit foreign key and relationship definitions -- Read-only *View* classes for safe navigation and analytics +- Lightweight mapper versions to provide simple model validation against CDM for use in ETL loops without side-effects or performance hit that can come from relationship instantiation within the runtime +- Read-only *View* classes for safe navigation and analytics that include complex multi-table objects such as conditions with their modifiers, episodes with their events - Domain validation helpers for OMOP concept integrity -- CSV loading utilities for controlled ingestion and testing -- Lightweight schema and model validation against CDM specs --- @@ -60,18 +37,18 @@ These concerns are intentionally left to downstream tooling. from omop_alchemy.cdm.model.vocabulary.concept import ConceptView concept = session.get(ConceptView, 320128) # Lung cancer -concept.domain.domain_id # "Condition" -concept.vocabulary.vocabulary_id # "SNOMED" -concept.is_standard # True +concept.domain.domain_id # "Condition" +concept.vocabulary.vocabulary_id # "SNOMED" +concept.is_standard # True ``` --- ## Status -This project is currently beta. +The core API under `cdm/` should be considered stable as of the 1.x release. -The API is stabilising, but some modules may change as real-world use cases expand. Feedback and issues are welcome. +The toolkit API is stabilising, but some modules may change as real-world use cases expand. Feedback and issues are welcome. ### Some additional background @@ -82,9 +59,7 @@ This work builds on earlier research and tooling presented at the 2023 OHDSI APA ## Configuration -OMOP Alchemy reads all database connection and schema settings from -[oa-configurator](https://github.com/AustralianCancerDataNetwork/oa-configurator). -No `.env` files or `ENGINE` environment variables are needed. +OMOP Alchemy reads all database connection and schema settings from [oa-configurator](https://github.com/AustralianCancerDataNetwork/oa-configurator). No `.env` files or `ENGINE` environment variables are needed. Run once after installation: diff --git a/docs/advanced/fulltext.md b/docs/advanced/fulltext.md index 8877bf7..60876b5 100644 --- a/docs/advanced/fulltext.md +++ b/docs/advanced/fulltext.md @@ -36,10 +36,7 @@ These helpers return the best available expression for the configured environmen ### Example (PostgreSQL Documentation) -A tsvector value is a sorted list of distinct lexemes, which are words that have been normalized to merge different variants of the same word. Sorting and duplicate-elimination are done automatically during input - - -A `tsvector` value is a sorted list of distinct lexemes (normalized word forms). Sorting and duplicate elimination are applied automatically during input. +A `tsvector` value is a sorted list of distinct lexemes (normalised word forms). Sorting and duplicate elimination are applied automatically during input. ```sql SELECT 'a fat cat sat on a mat and ate a fat rat'::tsvector; @@ -266,4 +263,4 @@ The helper expressions can still be imported safely, but the sidecar install / p - treat the sidecar columns as **derived search state**, not source-of-truth data - if you bulk-load new vocabulary rows, rerun `omop-alchemy fulltext populate` - if you use `reconcile-schema`, the sidecar columns and indexes are intentional database additions outside the core OMOP schema -- GIN indexes can be expensive to build on large vocabularies, so plan that as a real maintenance operation rather than a trivial toggle \ No newline at end of file +- GIN indexes can be expensive to build on large vocabularies, so plan that as a real maintenance operation rather than a trivial toggle diff --git a/docs/api/architecture.md b/docs/api/architecture.md index ad82cc5..54a8125 100644 --- a/docs/api/architecture.md +++ b/docs/api/architecture.md @@ -2,7 +2,7 @@ OMOP Alchemy is built as a **deliberately layered system**. -Each layer adds capability while preserving the guarantees of the layer below it. Responsibilities flow *downward*; semantic intent flows *upward*. +Each layer adds capability while preserving the guarantees of the layers beneath it. The core layers build upward from generic infrastructure to OMOP-aware views; Toolkit and Semantic Validation build on those views independently. The result is a system that is: @@ -16,7 +16,7 @@ The result is a system that is: ```mermaid -flowchart TD +flowchart BT subgraph L0["orm-loader"] L0a["CSVLoadableTableInterface"] L0b["SerialisableTableInterface"] @@ -24,158 +24,78 @@ flowchart TD L0d["Materialized-view lifecycle"] end - subgraph L1["cdm.base"] - L1a["CDMTableBase"] - L1b["Column helpers"] - L1c["Structural mixins"] - L1d["ReferenceContext"] - L1e["DomainValidation primitives"] + subgraph CDM["omop_alchemy.cdm"] + direction BT + subgraph L1["cdm.base"] + L1a["CDMTableBase"] + L1b["Column helpers"] + L1c["Structural mixins"] + L1d["ReferenceContext"] + L1e["DomainValidation primitives"] + end + + subgraph L2["cdm.models"] + L2a["Concrete CDM tables"] + L2b["@cdm_table"] + L2c["OMOP-compliant schemas"] + end + + subgraph L3["Views & Contexts"] + L3a["Reference contexts"] + L3b["Derived properties"] + L3c["Hybrid expressions"] + end end - subgraph L2["cdm.models"] - L2a["Concrete CDM tables"] - L2b["@cdm_table"] - L2c["OMOP-compliant schemas"] + subgraph L4["Toolkit"] + L4a["Standard queries, composition & exploration"] end - subgraph L3["Views & Contexts"] - L3a["Reference contexts"] - L3b["Derived properties"] - L3c["Hybrid expressions"] + subgraph L5["Semantic Validation"] + L5a["ExpectedDomain"] + L5b["DomainRule"] + L5c["Runtime domain checks"] end - subgraph L4["Semantic Validation"] - L4a["ExpectedDomain"] - L4b["DomainRule"] - L4c["Runtime domain checks"] - end - - L0 --> L1 - L1 --> L2 - L2 --> L3 - L3 --> L4 + L0 -->|infrastructure| L1 + L1 -->|OMOP structure| L2 + L2 -->|views & navigation| L3 + L3 -->|reusable queries| L4 + L3 -->|semantic checks| L5 + + classDef infrastructure fill:#e8f1fb,stroke:#2b6cb0,stroke-width:1.5px,color:#17324d + classDef cdmLayer fill:#eaf6ef,stroke:#2f855a,stroke-width:1.5px,color:#193b29 + classDef toolkitLayer fill:#fff6df,stroke:#b7791f,stroke-width:1.5px,color:#4a3210 + classDef validationLayer fill:#f3eaff,stroke:#805ad5,stroke-width:1.5px,color:#33224d + + class L0a,L0b,L0c,L0d infrastructure + class L1a,L1b,L1c,L1d,L1e,L2a,L2b,L2c,L3a,L3b,L3c cdmLayer + class L4a toolkitLayer + class L5a,L5b,L5c validationLayer + + style CDM fill:#f8fafc,stroke:#64748b,stroke-width:2px,stroke-dasharray:5 5 + style L0 fill:#f5faff,stroke:#2b6cb0,stroke-width:1.5px + style L1 fill:#f3fbf5,stroke:#2f855a,stroke-width:1.5px + style L2 fill:#f3fbf5,stroke:#2f855a,stroke-width:1.5px + style L3 fill:#f3fbf5,stroke:#2f855a,stroke-width:1.5px + style L4 fill:#fffaf0,stroke:#b7791f,stroke-width:1.5px + style L5 fill:#faf7ff,stroke:#805ad5,stroke-width:1.5px ``` ### Layer responsibilities -#### orm-loader (L0) - -Purpose: ingestion and infrastructure - -This layer provides: - -* CSV loading -* bulk inserts -* type casting -* serialization helpers -* generic materialized-view definition and lifecycle operations - -It is domain-agnostic. - -If something understands OMOP concepts, vocabularies, or clinical meaning, it does not belong here. - -Examples: - -* [CSVLoadableTableInterface](https://australiancancerdatanetwork.github.io/orm-loader/loaders/) -* [SerialisableTableInterface](https://australiancancerdatanetwork.github.io/orm-loader/tables/serialisable_table/) -* [Materialized views](https://australiancancerdatanetwork.github.io/orm-loader/tables/mat_view/) - -OMOP Alchemy may supply an OMOP-specific selectable and its logical row identity to this layer, but it does not implement database DDL or refresh mechanics. Applications own collections of materialized views, dependency policy, and deployment commands. - -#### cdm.base (L1) - -Purpose: structural OMOP semantics - -This layer encodes: - -* common OMOP table structure -* column patterns (required vs optional) -* reusable mixins -* reference relationship mechanics -* domain validation primitives - -This is where OMOP’s shape lives, but not its analytical meaning. - -Examples: - -* [CDMTableBase](./base.md) -* [PersonScoped](./columns.md) -* [ReferenceContext](./relationships.md) - -This layer answers: - -“What does a valid OMOP table look like?” - -#### cdm.models (L2) - -Purpose: concrete OMOP tables - -This layer defines: - -* actual CDM tables -* exact column layouts -* primary keys and foreign keys -* official OMOP schemas - -Classes in this layer: - -* are safe for ETL -* are safe for bulk loading -* avoid eager relationships -* avoid analytical helpers - -Examples: - -[Person](../models/clinical/person.md) - -#### Views & Contexts (L3) - -Purpose: navigation and analysis - -This layer adds: - -* reference relationships -* derived properties -* hybrid expressions -* query-friendly helpers - -Examples: - -[PersonContext](../models/clinical/person.md) -[PersonView](../models/clinical/person.md) - -Views are designed for interactive use, not ingestion. - -> *Tables are for pipelines. Views are for people.* - -#### Semantic Validation (L4) - -Purpose: make semantic expectations explicit - -This layer introduces: - -* declared domain expectations -* inspectable rules -* runtime, advisory checks - -Examples: - -[ExpectedDomain](../validation/index.md) - -This layer answers: - -> “Does this object reference the kinds of concepts I think it does?” - -Validation here is: - -* non-blocking -* non-mutating -* safe to skip -* safe to run interactively +| Layer | Purpose | Responsibilities | Boundary and examples | +| --- | --- | --- | --- | +| **orm-loader (L0)** | Ingestion and infrastructure | CSV loading; bulk inserts; type casting; serialization; materialized-view definition and lifecycle operations | Domain-agnostic: it does not understand OMOP concepts, vocabularies, or clinical meaning. OMOP Alchemy may provide a selectable and logical row identity, while applications own view collections, dependency policy, and deployment. [CSVLoadableTableInterface](https://australiancancerdatanetwork.github.io/orm-loader/loaders/)
[SerialisableTableInterface](https://australiancancerdatanetwork.github.io/orm-loader/tables/serialisable_table/)
[Materialized views](https://australiancancerdatanetwork.github.io/orm-loader/tables/mat_view/) | +| **cdm.base (L1)** | Structural OMOP semantics | Common table structure; required and optional column patterns; reusable mixins; reference relationship mechanics; domain validation primitives | Defines OMOP’s shape, not its analytical meaning. Answers: “What does a valid OMOP table look like?” [CDMTableBase](./base.md); [PersonScoped](./columns.md); [ReferenceContext](./relationships.md) | +| **cdm.models (L2)** | Concrete OMOP tables | Actual CDM tables; exact column layouts; primary and foreign keys; official OMOP schemas | Safe for ETL and bulk loading; avoids eager relationships and analytical helpers. Example: [Person](../models/clinical/person.md) | +| **Views & Contexts (L3)** | Navigation and analysis | Reference relationships; derived properties; hybrid expressions; query-friendly helpers | Designed for interactive use, not ingestion: *tables are for pipelines; views are for people.* [PersonContext](../models/clinical/person.md); [PersonView](../models/clinical/person.md) | +| **Toolkit (L4)** | Reusable clinical queries and composition | Standard query contracts; vocabulary and concept resolution; event and episode composition; reusable analytical projections | Consumes CDM models and views, adding interpretation and retrieval rules without replacing models or hiding database access. [Toolkit overview](../toolkit/index.md); [Query contracts](../toolkit/query-contracts.md) | +| **Semantic Validation (L5)** | Explicit semantic expectations | Declared domain expectations; inspectable rules; runtime advisory checks | Non-blocking and non-mutating; safe to skip or run interactively. Answers: “Does this object reference the kinds of concepts I think it does?” [ExpectedDomain](../validation/index.md) | ### Directional guarantees -The stack is intentionally one-directional: +The dependency direction is intentionally one-way: higher layers may use lower layers, but lower layers never import higher layers. In the diagram, arrows show capability building upward from the foundation; Toolkit and Semantic Validation are peers rather than dependencies of one another. * lower layers never import higher layers * ETL code never depends on analytical helpers diff --git a/docs/api/index.md b/docs/api/index.md index c01baee..244096c 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -37,6 +37,7 @@ The APIs documented here follow a few consistent principles: All modules are import-safe and side-effect free. This makes the API suitable for: + - schema inspection - validation tooling - static analysis @@ -72,15 +73,16 @@ Column helpers provide **named, intention-revealing shortcuts** for these patter ## Structural mixins -Many OMOP tables share structural semantics: +Many OMOP tables share structural semantics for example: - person-scoped records - dated events - value-typed observations - unit concepts - health system attribution +- accepted modifiable targets / sources -Mixins encode these patterns once, and make them reusable and inspectable. +Don't define this behaviour directly unless it is truly a new pattern - instead you should use the available mixins, which encode these patterns once, and make them reusable and inspectable. **[Column mixins](columns.md)** @@ -95,6 +97,7 @@ OMOP Alchemy makes heavy use of Python typing to express *semantic expectations* - “this is a clinical event” These protocols support: + - static type checking - IDE assistance - tooling and validation layers @@ -111,6 +114,6 @@ This API layer sits *above* generic ORM infrastructure and *below* analytical or |------|---------------| | Database | Physical storage, constraints | | orm-loader | Generic table loading, serialization | -| OMOP Alchemy API | OMOP-specific structure & semantics | -| Validation | Domain and semantic checks | -| Analytics | Queries, cohorts, exploration | \ No newline at end of file +| **OMOP Alchemy API** | **OMOP-specific structure & semantics** | +| **OMOP Alchemy Toolkit** | **Standard, generalisable queries, composition, exploration** | +| Validation | Domain and semantic checks | \ No newline at end of file diff --git a/docs/api/query.md b/docs/api/query.md index 23ea2e3..5d35fc9 100644 --- a/docs/api/query.md +++ b/docs/api/query.md @@ -2,7 +2,7 @@ `omop_alchemy.cdm.query` provides `ConceptFilter`, a shared, reusable way to filter CDM `concept`-table queries by domain, vocabulary, concept ID, and standard/active status, with an optional row-count limit. -It exists so that packages consuming OMOP Alchemy (e.g. `omop-emb`, `omop-graph`) don't each need to reimplement the same filtering logic against their own copy of `Concept`'s column names — since this package owns the `Concept` model directly, the filter can reference real columns rather than duck-typing against an opaquely-imported table. +It exists so that downstream packages do not each need to reimplement the same filtering logic against their own copy of `Concept`'s column names. Since this package owns the `Concept` model directly, the filter can reference real columns rather than duck-typing against an opaquely imported table. ```python from sqlalchemy import select diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 8483ced..21156e3 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -1,23 +1,22 @@ # Configuration -OMOP_Alchemy reads all database connection and schema settings from -[oa_configurator](https://github.com/AustralianCancerDataNetwork/oa-configurator) — no -`.env` files or `ENGINE` environment variables needed. +OMOP_Alchemy reads all database connection and schema settings from [oa_configurator](https://github.com/AustralianCancerDataNetwork/oa-configurator) ## Minimal config -Run the interactive configure command to set up the CDM database connection and write -`~/.config/omop/config.toml`: +Run the interactive configure command to set up the CDM database connection and write `~/.config/omop/config.toml`: ```bash omop-config configure omop_alchemy ``` -This prompts for connection details (host, dialect, credentials) and schema name, then -saves them under the canonical database name `cdm_db` that all OMOP stack packages -recognise. +This prompts for connection details (host, dialect, credentials) and schema name, then saves them under the canonical database name `cdm_db` that all OMOP stack packages recognise. -The resulting TOML looks like: +The default location for this file is `~/.config/omop/config.toml` + +![configure](../static/images/oa-configure.png) + +The resulting TOML will look like: ```toml [connections.cdm] @@ -27,6 +26,7 @@ port = 5432 user = "omop" password = "changeme" database_name = "omop_cdm" +test_only = false [databases.cdm_db] kind = "cdm" @@ -37,7 +37,7 @@ schema_name = "omop" cdm_db = "cdm_db" ``` -You can also write or edit this file manually. +You can also write or edit this file manually. It follows the `oa-configurator` pattern of [physical]->[logical] resource definition, where one connection may serve multiple databases, and each application may define its own database resource, or choose to cross reference an existing one that will be resolved upon connection in the consuming application. ## Vocabulary loading @@ -49,11 +49,7 @@ cdm_db = "cdm_db" athena_source_path = "/path/to/athena/csvs" ``` -Or set it interactively: - -```bash -omop-config configure omop_alchemy -``` +This may be edited directly, set interactively in the `omop-config configure omop_alchemy` process, or set directly using the CLI. ## Verify @@ -61,22 +57,20 @@ omop-config configure omop_alchemy omop-alchemy info ``` -This prints the resolved config file path, connection details, and schema. A successful -run confirms that OMOP_Alchemy can reach your database. +This prints the resolved config file path, connection details, and schema. A successful run confirms that OMOP_Alchemy can reach your database. + +![info](../static/images/oa-info.png) ## Multiple instances -To configure a second CDM database (e.g. for production), create it under its own name -and point the field's own flag at it: +Note that postgres integration tests in this library and others following the `oa-configurator` utility may perform destructive actions and therefore require their own test-enabled configuration. This is typically only required for development use-cases. Test database configuration is separate to the inclusion of multiple CDM database connections (e.g. for staging/production). To configure a second CDM database, create it under its own name and point the field's own flag at it: ```bash omop-config databases add cdm_db_prod --kind cdm --connection cdm_prod omop-config configure omop_alchemy --cdm-db cdm_db_prod ``` -This creates `cdm_db_prod` without touching the existing `cdm_db`. There is no "default" -toggle to flip afterward; each deployment's `configure` call names the entry it wants -directly. +This creates `cdm_db_prod` without touching the existing `cdm_db`. There is no "default" toggle to flip afterward; each deployment's `configure` call names the entry it wants directly. See the [oa-configurator integration guide](https://AustralianCancerDataNetwork.github.io/oa-configurator/integration/#multiple-environments) for the full multi-environment guide. diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md index 0b3f91c..39c58f6 100644 --- a/docs/getting-started/index.md +++ b/docs/getting-started/index.md @@ -1,11 +1,8 @@ # Getting Started -This section introduces the core ideas behind OMOP Alchemy and how to begin using it in -your own projects. +This section introduces the core ideas behind OMOP Alchemy and how to begin using it in your own projects. -OMOP Alchemy is designed to be safe to import anywhere and flexible enough to integrate -with existing pipelines, research workflows, and analysis environments. These pages -cover installation, maintenance tooling, and a minimal quickstart to orient you. +These pages cover installation, maintenance tooling, and a minimal quickstart for orientation. --- diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 52e2106..d7dfb3c 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -93,22 +93,18 @@ Use engine_with_replica_role when: ## Optional PostgreSQL full-text search -OMOP Alchemy can optionally integrate with PostgreSQL full-text search for selected -vocabulary text fields. +OMOP Alchemy can optionally integrate with PostgreSQL full-text search for selected vocabulary text fields. -This feature is **not required** to use the library and is intentionally treated as an -optional enhancement rather than part of the core OMOP schema. +This feature is **not required** to use the library and is an optional enhancement rather than part of the core OMOP schema. At the library level: - query helpers can fall back to inline `to_tsvector(...)` expressions -- optional sidecar `tsvector` columns can be registered into SQLAlchemy metadata when - they exist in the database +- optional sidecar `tsvector` columns can be registered into SQLAlchemy metadata when they exist in the database At the database level: -- PostgreSQL-only sidecar columns and optional GIN indexes can be managed through the - maintenance CLI +- PostgreSQL-only sidecar columns and optional GIN indexes can be managed through the maintenance CLI - those sidecar columns are populated explicitly rather than auto-generated Typical maintenance workflow: @@ -124,6 +120,8 @@ If you later reload vocabulary data, rerun: omop-alchemy fulltext populate ``` +![fulltext](../static/images/oa-fulltext.png) + For the full design and query patterns, see: - [PostgreSQL Full-Text Search](../advanced/fulltext.md) diff --git a/docs/getting-started/maintenance.md b/docs/getting-started/maintenance.md index a58b64c..336b174 100644 --- a/docs/getting-started/maintenance.md +++ b/docs/getting-started/maintenance.md @@ -1,13 +1,6 @@ # Maintenance CLI -The `omop-alchemy` maintenance CLI handles everything you need to operate an OMOP CDM -database: creating tables, loading Athena vocabularies, managing indexes and foreign key -enforcement, running health checks, and taking backups. It talks directly to a SQLAlchemy -engine, so all connection details are controlled by the same engine URL configuration you -use for the ORM. - -> **Alpha status** -> Treat this CLI as alpha operational tooling. Interfaces and behavior may still change. +The `omop-alchemy` maintenance CLI handles everything you need to operate an OMOP CDM database: creating tables, loading Athena vocabularies, managing indexes and foreign key enforcement, running health checks, and taking backups. It talks directly to a SQLAlchemy engine, so all connection details are controlled by the same engine URL configuration you use for the ORM. --- @@ -19,8 +12,7 @@ Database connection and CDM schema come from [oa_configurator](../getting-starte ## Backend support -Some commands depend on PostgreSQL-specific features and will return a clear error -if you run them against SQLite. +Some commands depend on PostgreSQL-specific features and will return an error if you run them against an unsupported backend. | Command group | Requires PostgreSQL | Why | | --- | --- | --- | @@ -40,8 +32,7 @@ if you run them against SQLite. ### Fresh database setup -Use this when you are starting with an empty database and want to get an OMOP schema -populated from scratch. +Use this when you are starting with an empty database and want to get an OMOP schema populated from scratch. ```bash # 1. Create any OMOP tables that don't exist yet (safe to run on an existing DB) @@ -55,12 +46,9 @@ omop-alchemy load-vocab-source --athena-source ./athena_files omop-alchemy reset-sequences ``` -The `create-missing-tables` command compares ORM metadata against the live schema and -creates only what is missing. It is idempotent — running it again on a populated database -does nothing. +The `create-missing-tables` command compares ORM metadata against the live schema and creates only what is missing. It is idempotent — running it again on a populated database does nothing. -`load-vocab-source` automatically creates any missing vocabulary tables before loading, -so you can run it immediately after step 1 or even skip step 1 for vocabulary-only setups. +`load-vocab-source` automatically creates any missing vocabulary tables before loading, so you can run it immediately after step 1 or even skip step 1 for vocabulary-only setups. --- @@ -88,35 +76,22 @@ omop-alchemy fulltext populate ``` **About `--bulk-mode` (default on PostgreSQL):** -`load-vocab-source` disables FK triggers and drops vocabulary indexes once before the -load loop, then rebuilds them once at the end. This is much faster than the alternative -of toggling per table — for a full Athena export the difference can be 10–20×. SQLite -ignores this flag. Pass `--no-bulk-mode` if you need per-table rollback safety. +`load-vocab-source` disables FK triggers and drops vocabulary indexes once before the load loop, then rebuilds them once at the end. This is much faster than the alternative of toggling per table — for a full Athena export the difference can be 10–20×. SQLite ignores this flag. Pass `--no-bulk-mode` if you need per-table rollback safety. **About `--merge-strategy replace`:** -`replace` overwrites rows whose primary keys occur in the CSV; it does not delete rows -that are absent from the source. The explicit `truncate-tables` step above is therefore -required when the database must exactly mirror a new Athena export. Use `upsert` for -incremental vocabulary patches that must preserve existing conflicting rows. Use -`insert_if_empty` as the fastest path when the target tables are guaranteed empty. +`replace` overwrites rows whose primary keys occur in the CSV; it does not delete rows that are absent from the source. The explicit `truncate-tables` step above is therefore required when the database must exactly mirror a new Athena export. Use `upsert` for incremental vocabulary patches that must preserve existing conflicting rows. Use `insert_if_empty` as the fastest path when the target tables are guaranteed empty. **About `--quote-mode by_delimiter`:** -The default preserves double-quotes as data in tab-delimited Athena exports and uses -RFC-4180 quoting for comma-delimited files. Use `--quote-mode csv` only when the source -genuinely wraps fields in CSV quotes; `--quote-mode literal` forces quotes to remain data. -The `auto` mode samples content and is less predictable for large Athena files. +The default preserves double-quotes as data in tab-delimited Athena exports and uses RFC-4180 quoting for comma-delimited files. Use `--quote-mode csv` only when the source genuinely wraps fields in CSV quotes; `--quote-mode literal` forces quotes to remain data. The `auto` mode samples content and may be less predictable for large files. **About `--strict` on `foreign-keys enable`:** -`--strict` validates all FK relationships before re-enabling RI triggers. If violations -are found, no triggers are re-enabled and you get a report of the problematic rows. -Omit `--strict` to re-enable unconditionally. +`--strict` validates all FK relationships before re-enabling RI triggers. If violations are found, no triggers are re-enabled and you get a report of the problematic rows. Omit `--strict` to re-enable unconditionally. --- ### ETL bulk load cycle -Use this before and after a large clinical data load to avoid the overhead of FK and -index maintenance during insertion. +Use this before and after a large clinical data load to avoid the overhead of FK and index maintenance during insertion. ```bash # Before your ETL runs: suspend enforcement and remove indexes @@ -132,14 +107,9 @@ omop-alchemy foreign-keys enable --strict omop-alchemy analyze-tables --scope clinical ``` -`analyze-tables` refreshes planner statistics after a large load so query plans don't -degrade. `--scope clinical` targets only clinical tables; omit `--scope` to analyze -everything. +`analyze-tables` refreshes planner statistics after a large load so query plans don't degrade. `--scope clinical` targets only clinical tables; omit `--scope` to analyze everything. -`reset-sequences` ensures that any auto-increment columns are positioned above the -maximum key value present in the table. This matters when your ETL inserts explicit IDs -(common in OMOP) — without a reset, the next ORM insert would try to reuse an ID that -already exists. +`reset-sequences` ensures that any auto-increment columns are positioned above the maximum key value present in the table. This matters when your ETL inserts explicit IDs (common in OMOP) — without a reset, the next ORM insert would try to reuse an ID that already exists. --- @@ -151,8 +121,7 @@ already exists. omop-alchemy doctor ``` -Runs a fast, non-destructive pass over connection readiness, schema drift, and FK -trigger status. The output tells you what is wrong and what to do about it. +Runs a fast, non-destructive pass over connection readiness, schema drift, and FK trigger status. The output tells you what is wrong and what to do about it. **Deep FK validation (PostgreSQL):** @@ -160,9 +129,7 @@ trigger status. The output tells you what is wrong and what to do about it. omop-alchemy doctor --deep ``` -Adds a full FK constraint scan — it actually queries the data to find rows that violate -declared FK relationships. On large databases this can be slow; use it when you suspect -data integrity issues after an ETL or vocabulary patch. +Adds a full FK constraint scan — it actually queries the data to find rows that violate declared FK relationships. On large databases this can be slow; use it when you suspect data integrity issues after an ETL or vocabulary patch. **Full environment introspection:** @@ -170,10 +137,7 @@ data integrity issues after an ETL or vocabulary patch. omop-alchemy info ``` -Shows the active engine URL, installed backend driver, OMOP Alchemy version, optional -dependency state (orm-loader, psycopg2/psycopg, etc.), and which maintenance commands -are available given the current backend. Run this first when diagnosing "why doesn't -this command work". +Shows the active engine URL, installed backend driver, OMOP Alchemy version, optional dependency state (orm-loader, psycopg2/psycopg, etc.), and which maintenance commands are available given the current backend. Run this first when diagnosing "why doesn't this command work". **When doctor reports a problem:** @@ -188,8 +152,7 @@ this command work". ### Schema drift -The `reconcile-schema` command compares your ORM metadata against the live database and -reports what it finds: +The `reconcile-schema` command compares your ORM metadata against the live database and reports what it finds: ```bash omop-alchemy reconcile-schema @@ -203,15 +166,13 @@ Output categories: - **matched** — table exists in both and metadata is consistent. - **drifted** — table exists in both but column definitions differ (types, nullability, defaults). The CLI does not auto-migrate; you need to handle schema migrations manually. -For safe deployment: run `reconcile-schema` first, then `create-missing-tables --dry-run`, -then `create-missing-tables`. +For safe deployment: run `reconcile-schema` first, then `create-missing-tables --dry-run`, then `create-missing-tables`. --- ### Full-text search sidecars -Full-text search support adds `tsvector` sidecar columns (and `GIN` indexes) to the -`concept` and `concept_synonym` tables, enabling fast text search over vocabulary. +Full-text search support adds `tsvector` sidecar columns (and `GIN` indexes) to the `concept` and `concept_synonym` tables, enabling fast text search over vocabulary. ```bash # Install the sidecar columns and indexes (once, after vocabulary tables exist) @@ -221,8 +182,7 @@ omop-alchemy fulltext install omop-alchemy fulltext populate ``` -**You must rerun `fulltext populate` after every vocabulary reload.** Sidecar vectors -do not auto-refresh when the underlying concept data changes. +**You must rerun `fulltext populate` after every vocabulary reload.** Sidecar vectors do not auto-refresh when the underlying concept data changes. To remove the sidecars: @@ -230,19 +190,15 @@ To remove the sidecars: omop-alchemy fulltext drop ``` -The `--regconfig` option controls the PostgreSQL text search configuration -(default `english`). For multilingual vocabularies, use a suitable config such as -`simple`. +The `--regconfig` option controls the PostgreSQL text search configuration (default `english`). For multilingual vocabularies, use a suitable config such as `simple`. -For query-side usage and optional ORM metadata registration, see -[PostgreSQL Full-Text Search](../advanced/fulltext.md). +For query-side usage and optional ORM metadata registration, see [PostgreSQL Full-Text Search](../advanced/fulltext.md). --- ### Backup and restore -These commands wrap `pg_dump` and `pg_restore` / `psql`. PostgreSQL client tools must -be installed and on `PATH`. +These commands wrap `pg_dump` and `pg_restore` / `psql`. PostgreSQL client tools must be installed and on `PATH`. ```bash # Create a backup (custom format is recommended — smaller and restorable in parallel) @@ -267,8 +223,7 @@ omop-alchemy restore-database ./cdm-backup.dump \ - For `plain` format, the schema is embedded in the SQL dump; no selective schema restore is possible. - For `custom` format, `pg_restore` can be invoked manually with `-n ` for selective schema restore. -Use `--dry-run` on `backup-database` to see the `pg_dump` command that would be run -without executing it. +Use `--dry-run` on `backup-database` to see the `pg_dump` command that would be run without executing it. --- @@ -276,9 +231,7 @@ without executing it. ### Bulk load or vocabulary reload fails mid-way -If `load-vocab-source` (with `--bulk-mode`) or your ETL process fails after FK triggers -and indexes have been disabled, they stay disabled. The database continues to accept -writes but does not enforce FK constraints, and queries may use slow sequential scans. +If `load-vocab-source` (with `--bulk-mode`) or your ETL process fails after FK triggers and indexes have been disabled, they stay disabled. The database continues to accept writes but does not enforce FK constraints, and queries may use slow sequential scans. To recover: @@ -301,11 +254,9 @@ omop-alchemy foreign-keys enable --strict omop-alchemy foreign-keys validate ``` -This reports exactly which tables have violations, which constraints are affected, and -how many rows fail. Fix the data, then retry `foreign-keys enable --strict`. +This reports exactly which tables have violations, which constraints are affected, and how many rows fail. Fix the data, then retry `foreign-keys enable --strict`. -If you need to re-enable FK triggers despite the violations (for example, to allow the -application to run while you investigate), use `foreign-keys enable` without `--strict`. +If you need to re-enable FK triggers despite the violations (for example, to allow the application to run while you investigate), use `foreign-keys enable` without `--strict`. ### Sequences are out of sync after a bulk insert @@ -316,8 +267,7 @@ omop-alchemy reset-sequences # all managed tables omop-alchemy reset-sequences --vocab # vocabulary tables only ``` -`reset-sequences` sets each owned sequence to `MAX(pk) + 1`. It reports every table -it touches and the old/new sequence positions. +`reset-sequences` sets each owned sequence to `MAX(pk) + 1`. It reports every table it touches and the old/new sequence positions. --- diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 40f790f..2aa65ac 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -1,10 +1,5 @@ # Quickstart -`OMOP_Alchemy` itself makes no assumptions about how PostgreSQL is provisioned: any -reachable instance works, local or otherwise. Docker orchestration for the OMOP stack -is handled at the workspace root (compose files there bring up every package's -containers as peers), not by a per-package `docker-compose.yaml` in this repo. - ## Prerequisites - A running PostgreSQL instance (any version supported by `omop_alchemy`'s SQLAlchemy dialects) @@ -29,6 +24,8 @@ The test suite includes PostgreSQL-specific tests that skip automatically unless > schema on every run. `test_cdm_db` must point to a **dedicated, empty test database**, never > to a database that contains real data. The test suite enforces this: it fails loudly (not skips) if the > configured database is not marked `test_only = true` in your config. +> +> Refer to the CI/CD workflows at [cava-devops](http://github.com/AustralianCancerDataNetwork/cava-devops/.github/workflows/build-test-postgres.yml) for more details on how integration test runs are typically orchestrated. **Step 1 — Register a test database connection:** diff --git a/docs/static/images/oa-configure.png b/docs/static/images/oa-configure.png new file mode 100644 index 0000000000000000000000000000000000000000..ee2334850014cf3ca07101e5d4394edff2840e73 GIT binary patch literal 67192 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

&NgkooZJ{&P0q~@U|IuR&bcH% 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 HI`sbmgi9KS literal 0 HcmV?d00001 diff --git a/docs/static/images/oa-fulltext.png b/docs/static/images/oa-fulltext.png new file mode 100644 index 0000000000000000000000000000000000000000..6c0e7be80427e971237265d06af7afcf0612bbd0 GIT binary patch literal 62987 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 Sbp9paqot;Muk@~M=>Gr`fk8b0 literal 0 HcmV?d00001 diff --git a/docs/static/images/oa-info.png b/docs/static/images/oa-info.png new file mode 100644 index 0000000000000000000000000000000000000000..c55d233e6d611f17cd25dc18b590b061c96581dc GIT binary patch literal 144767 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≫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 a1fMN7i_Z9$Q=S6< Date: Wed, 9 Sep 2026 22:12:09 +0930 Subject: [PATCH 23/30] docs updates --- docs/toolkit/analytics.md | 24 ++++++++++++++++++++++++ docs/toolkit/index.md | 3 +-- docs/toolkit/integrations.md | 9 --------- mkdocs.yml | 1 - 4 files changed, 25 insertions(+), 12 deletions(-) delete mode 100644 docs/toolkit/integrations.md diff --git a/docs/toolkit/analytics.md b/docs/toolkit/analytics.md index cc8523f..d7c0f67 100644 --- a/docs/toolkit/analytics.md +++ b/docs/toolkit/analytics.md @@ -4,6 +4,16 @@ Analytics packages combine domain-neutral retrieval with governed concept sets a ## Oncology +| API | Capability | +|---|---| +| `OncologyEpisode` | Classifies episode purpose and modality; traverses events linked to the episode and its direct children. | +| `structural_modalities` / `concept_modalities` | Preserve every evidenced modality so mixed-treatment and SACT classification disagreements remain visible. | +| `structural_modality` / `concept_modality` | Select one deterministic modality in radiotherapy, surgery, diagnostic/staging, SACT priority order. | +| `OncologyProcedure` / `OncologyDrugExposure` | Add governed `is_radiotherapy`, `is_surgery`, `is_diagnostic_staging`, and `is_sact` questions to CDM facts. | +| `RTDoseSummary.from_procedures(...)` | Constructs one radiotherapy summary; `summarize_rt_procedures_by(...)` groups before construction. | +| `SACTDoseSummary.from_exposures(...)` | Constructs one SACT summary; `summarize_sact_exposures_by(...)` groups before construction. | +| `OncologyEpisodeEvent` | Resolves oncology-aware facts while retaining episode-event diagnostics. | + `OncologyEpisode` is the main entry point for an episode-centred oncology analysis. Keep the object attached to its SQLAlchemy session while accessing properties that traverse related events or resolve vocabulary-backed concept groups: ```python @@ -165,6 +175,14 @@ a non-empty priority must contain every `StageBasis` exactly once. ## Body metrics +| API | Capability | +|---|---| +| `MeasurementReading.from_measurement(...)` | Reduces an OMOP measurement to the fields used by calculations and records its resolution source. | +| `MeasurementSeriesMixin` | Resolves normalised measurement series for an episode. | +| `WeightTrajectoryMixin` | Exposes normalised weight and height, BMI, BSA, windowed change, trajectories, and a dict-shaped typed summary. | +| `WeightChange` | Represents percentage change and whether it was evaluable; unevaluable change has `pct_change=None`. | +| `WeightTrajectorySummary` | Types the DataFrame- and JSON-friendly mapping returned by `weight_trajectory_summary()`. | + `WeightTrajectoryMixin` turns an episode's weight measurements and the person's height measurements into a normalised longitudinal view. Weight is converted to kilograms, height to centimetres, and measurements with missing or unrecognised units are excluded from calculations. An episode that includes the mixin can produce a compact, tabular summary: @@ -207,6 +225,12 @@ Body-metric defaults resolve governed measurement and unit concepts. A deploymen ## Adverse events +| API | Policy | +|---|---| +| `ctcae_weight_loss_grade(...)` | Grades percentage weight loss against CTCAE-style bins. | +| `martin_weight_loss_grade(...)` | Applies the Martin et al. BMI-adjusted matrix. | +| `critical_weight_loss_grade(...)` | Uses the Martin matrix when BMI is available and otherwise falls back to CTCAE-style bins. | + The adverse-event functions apply grading policy to an already calculated percentage change and, where available, BMI: ```python diff --git a/docs/toolkit/index.md b/docs/toolkit/index.md index 004b458..fa8b147 100644 --- a/docs/toolkit/index.md +++ b/docs/toolkit/index.md @@ -32,9 +32,8 @@ Choose the part of the toolkit that matches the question you are asking: | Resolve incoming text or source codes to OMOP concepts, compare measurements in common units, or represent events from several CDM tables consistently | [`core`](core.md) | | Traverse episode relationships, retrieve episode-linked facts, or state how an event should be attached to an episode | [`episodes`](episodes.md) | | Apply a clinical interpretation such as oncology modality, dose summarisation, body-metric analysis, or weight-loss grading | [`analytics`](analytics.md) | -| Check the availability and expectations of outbound data-standard integrations | [`integrations`](integrations.md) | -The dependency direction follows the same order. `episodes` can use `core`; `analytics` can use both; `integrations` can use the whole toolkit. Lower layers never import a clinical specialty or an export format. This keeps general concepts such as event identity and unit conversion independent of the analyses that use them. +The dependency direction follows the same order. `episodes` can use `core`; `analytics` can use both. Lower layers never import a clinical specialty or an export format. This keeps general concepts such as event identity and unit conversion independent of the analyses that use them. ## Public imports diff --git a/docs/toolkit/integrations.md b/docs/toolkit/integrations.md deleted file mode 100644 index 87418f8..0000000 --- a/docs/toolkit/integrations.md +++ /dev/null @@ -1,9 +0,0 @@ -# Integrations - -Export to external data standards. Integrations sit at the outer edge of the toolkit — one may use anything in `core`, `episodes`, or `analytics`, and nothing in those tiers depends on an integration, so adding or changing an export format cannot affect the clinical logic beneath it. Each integration brings its own heavyweight dependencies and is gated behind an optional extra, so installing omop-alchemy does not pull in formats you are not exporting to. - -## meds_standard - -Export to the [Medical Event Data Standard](https://github.com/Medical-Event-Data-Standard/meds). - -Not yet populated. diff --git a/mkdocs.yml b/mkdocs.yml index 8afcdd3..4da05ce 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -140,7 +140,6 @@ nav: - Episodes: toolkit/episodes.md - Query contracts: toolkit/query-contracts.md - Clinical analytics: toolkit/analytics.md - - Integrations: toolkit/integrations.md - OMOP-Specific Validation: - Overview: validation/index.md From 163b226a081b55d8a019402ad1b56cfe30dcec7c Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Wed, 16 Sep 2026 10:55:42 +1000 Subject: [PATCH 24/30] review findings --- .github/CONTRIBUTING.md | 8 +- README.md | 4 +- docs/advanced/timelines.md | 4 +- docs/advanced/vocabulary_load_performance.md | 6 +- docs/getting-started/installation.md | 1 - docs/getting-started/quickstart.md | 2 +- docs/index.md | 2 +- docs/toolkit/core.md | 2 +- docs/toolkit/index.md | 6 +- docs/toolkit/materialized-views.md | 10 +- mkdocs.yml | 1 + omop_alchemy/cdm/base/modifier_interface.py | 17 ++- .../cdm/model/clinical/event_metadata.py | 52 +++++++-- .../cdm/model/clinical/measurement.py | 12 ++ .../cdm/model/clinical/observation.py | 5 + omop_alchemy/cdm/query.py | 5 +- .../analytics/oncology/concept_sets.py | 7 +- omop_alchemy/toolkit/core/concepts/groups.py | 14 +-- omop_alchemy/toolkit/core/concepts/lookup.py | 10 +- omop_alchemy/toolkit/core/errors.py | 25 +++++ .../toolkit/core/events/projections.py | 29 +++-- .../toolkit/core/modifiers/contracts.py | 12 +- .../toolkit/core/modifiers/metadata.py | 35 +++--- .../toolkit/core/modifiers/selection.py | 30 ++++- .../toolkit/core/modifiers/targets.py | 32 +++++- .../toolkit/core/timeline/event_timeline.py | 1 - .../episodes/derivation/attachments.py | 103 ++++++++++++++---- .../toolkit/episodes/derivation/contracts.py | 16 +-- tests/test_episode_attachment_queries.py | 49 +++++++++ tests/test_event_projections.py | 33 +++++- tests/test_event_timeline.py | 19 ++++ tests/test_modifier_projections.py | 100 ++++++++++++++++- 32 files changed, 534 insertions(+), 118 deletions(-) create mode 100644 omop_alchemy/toolkit/core/errors.py diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 4809ffc..ff0ff83 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -10,9 +10,13 @@ uv run ruff check . ## Ownership boundaries -Before adding general database or ORM infrastructure, check whether it belongs in a lower-level dependency. `orm-loader` owns domain-independent loading, serialization, and materialized-view lifecycle mechanics. OMOP Alchemy owns OMOP table models, clinical semantics, and the OMOP-specific selectables and row grains that consumers can pass to that infrastructure. +Before adding general database or ORM infrastructure, check whether it belongs in a lower-level dependency: -Do not add materialized-view DDL, lifecycle helpers, or orchestration to `omop_alchemy`. A consuming application owns its view registry, dependency and rebuild policy, and command-line or deployment workflow. +- [`orm-loader`](https://australiancancerdatanetwork.github.io/orm-loader/) owns domain-independent loading, serialization, and materialized-view lifecycle mechanics. +- `omop-alchemy` owns OMOP table models, clinical semantics, and OMOP-specific selectables and row grains. +- Consuming applications own view registries, dependency and rebuild policy, and deployment orchestration. + +Do not add materialized-view DDL, lifecycle helpers, or orchestration to `omop_alchemy`. ## Opening a pull request diff --git a/README.md b/README.md index 0b831f6..1f2ea72 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ concept.is_standard # True The core API under `cdm/` should be considered stable as of the 1.x release. -The toolkit API is stabilising, but some modules may change as real-world use cases expand. Feedback and issues are welcome. +The toolkit API is experimental and carries no compatibility guarantees. Feedback and issues are welcome. ### Some additional background @@ -68,4 +68,4 @@ omop-config init omop-config configure omop_alchemy ``` -See [Configuration](docs/getting-started/configuration.md) for full details. \ No newline at end of file +See [Configuration](docs/getting-started/configuration.md) for full details. diff --git a/docs/advanced/timelines.md b/docs/advanced/timelines.md index d804417..e45a279 100644 --- a/docs/advanced/timelines.md +++ b/docs/advanced/timelines.md @@ -47,7 +47,7 @@ Four CDM tables are pre-wired with `EventMapping`s: | Class | CDM table | Concept field | Value fields | |-------|-----------|---------------|--------------| | `Condition_Event` | `condition_occurrence` | `condition_concept_id` | — | -| `Measurement_Event` | `measurement` | `measurement_concept_id` | `value_as_number`, `value_as_concept_id`, `value_as_string` | +| `Measurement_Event` | `measurement` | `measurement_concept_id` | `value_as_concept_id`, `value_as_number` | | `Drug_Exposure_Event` | `drug_exposure` | `drug_concept_id` | `quantity` | | `Observation_Event` | `observation` | `observation_concept_id` | `value_as_concept_id`, `value_as_number`, `value_as_string` | @@ -82,6 +82,8 @@ with Session(engine) as session: print(event.to_dict()) ``` +Serialized timeline events include `event_id`, `event_source_table`, `event_field_concept_id` and `event_concept_id`. Direct `EventMapping` construction requires `event_id_field`, `event_source_table` and `event_field_concept_id`; `EventMapping.from_model()` derives these fields from model metadata. + --- ## Extending to new tables diff --git a/docs/advanced/vocabulary_load_performance.md b/docs/advanced/vocabulary_load_performance.md index 9ec4703..733efcc 100644 --- a/docs/advanced/vocabulary_load_performance.md +++ b/docs/advanced/vocabulary_load_performance.md @@ -23,11 +23,11 @@ Empirical timing on an 8 GB RAM dev machine with `concept_relationship` (~56 M r | Configuration | Total load time | |---------------|----------------| -| `--merge-batch-size 1_000_000` (old default) | ~120 min | +| `--merge-batch-size 1_000_000` | ~120 min | | `--merge-batch-size 20_000_000` | ~80 min | | No pagination (default) | **~40 min** | -The staging index build alone on a 56 M-row table adds 10–15 minutes regardless of batch size. **The default is now `None` (no pagination).** Only set `--merge-batch-size` if your system cannot hold the full merge in a single transaction. +The staging index build alone on a 56 M-row table adds 10–15 minutes regardless of batch size. **The default is `None` (no pagination).** Only set `--merge-batch-size` if your system cannot hold the full merge in a single transaction. > **Warning:** Setting `--merge-batch-size` to a large number to "avoid" pagination does not help if that number is still smaller than the largest table. For `concept_relationship` (~56 M rows), any value below 56 M will trigger the index build. If you need pagination, set it to your actual memory limit; if you don't, leave it unset. @@ -37,7 +37,7 @@ The next biggest bottleneck after pagination is `synchronous_commit=on` (the Pos ### Recommended settings -Apply these via `postgresql.conf` or `-c` flags on whatever PostgreSQL instance you're loading into (per-package `docker-compose.yaml` files no longer exist; Docker orchestration for the OMOP stack now happens at the workspace root). +Apply these via `postgresql.conf` or `-c` flags on the PostgreSQL instance you're loading into. **8 GB host:** ``` diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index d7dfb3c..e50fb31 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -88,7 +88,6 @@ This is supported for postgres only. Use engine_with_replica_role when: * Running schema-level operations that may open independent sessions -* Running schema-level operations that might trigger independent sessions * Using tooling that opens its own connections ## Optional PostgreSQL full-text search diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 2aa65ac..7f24586 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -25,7 +25,7 @@ The test suite includes PostgreSQL-specific tests that skip automatically unless > to a database that contains real data. The test suite enforces this: it fails loudly (not skips) if the > configured database is not marked `test_only = true` in your config. > -> Refer to the CI/CD workflows at [cava-devops](http://github.com/AustralianCancerDataNetwork/cava-devops/.github/workflows/build-test-postgres.yml) for more details on how integration test runs are typically orchestrated. +> Refer to the CI/CD workflows at [cava-devops](https://github.com/AustralianCancerDataNetwork/cava-devops/blob/main/.github/workflows/build-test-postgres.yml) for more details on how integration test runs are typically orchestrated. **Step 1 — Register a test database connection:** diff --git a/docs/index.md b/docs/index.md index 4192d15..34cb88f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -89,4 +89,4 @@ concept.is_standard ### Status -OMOP Alchemy is currently beta. The core model surface is stabilising; feedback is welcome. \ No newline at end of file +OMOP Alchemy is beta. Toolkit APIs are experimental and carry no compatibility guarantees; feedback is welcome. diff --git a/docs/toolkit/core.md b/docs/toolkit/core.md index f1bf59c..6d91fc6 100644 --- a/docs/toolkit/core.md +++ b/docs/toolkit/core.md @@ -71,7 +71,7 @@ assert measurement != procedure `ClinicalEventColumn` defines the common labels used when heterogeneous event tables are projected into one result. The required shape includes the person, table-scoped event identity, event date and datetime, clinical concept, and OMOP Field concept that identifies the source ID column. Optional labels cover numeric values, value concepts, and units. -`canonical_event_union()` turns supported event models into that shared shape. Measurement and Observation retain their value and unit columns; sources without those fields receive typed nulls so every branch of the union remains compatible: +`canonical_event_union()` turns supported event models into that shared shape. Measurement and Observation retain numeric values, value concepts and units; Observation string values are outside this projection. Sources without those fields receive typed nulls so every branch of the union remains compatible: ```python from omop_alchemy.cdm.model import ( diff --git a/docs/toolkit/index.md b/docs/toolkit/index.md index fa8b147..b5a1033 100644 --- a/docs/toolkit/index.md +++ b/docs/toolkit/index.md @@ -20,8 +20,8 @@ with Session(engine) as session: This is still ordinary SQLAlchemy. `OncologyEpisode` is mapped to the OMOP episode view, and properties may load related rows or resolve governed vocabulary sets through the active session. The toolkit adds interpretation and reusable retrieval rules; it does not replace the CDM models or hide when database access is required. -!!! warning "Toolkit stability" - Toolkit area packages are less stable than `omop_alchemy.cdm`. Treat the documented area import paths as the compatibility boundary, pin the package version in deployed applications, and review release notes before upgrading. Changes in the toolkit do not alter the CDM model API. +!!! warning "Experimental toolkit" + Toolkit APIs are experimental and may change without compatibility guarantees. Pin the package version in deployed applications and review release notes before upgrading. ## Where to begin @@ -44,4 +44,4 @@ from omop_alchemy.toolkit.core.concepts import make_concept_resolver from omop_alchemy.toolkit.analytics.oncology import OncologyEpisode ``` -The area packages re-export their public API. Module names below those packages are implementation details and may change without providing a compatibility import. +The area packages re-export their public API. Module names below those packages are implementation details. diff --git a/docs/toolkit/materialized-views.md b/docs/toolkit/materialized-views.md index 80c6b5b..f2c828a 100644 --- a/docs/toolkit/materialized-views.md +++ b/docs/toolkit/materialized-views.md @@ -50,7 +50,15 @@ class MeasurementSummaryMV(MaterializedViewMixin): ```python class PersonMeasurementSummaryMV(MaterializedViewMixin): __mv_name__ = "person_measurement_summary" - __mv_select__ = sa.select(measurement_summary.subquery()) + __mv_select__ = sa.select( + sa.table( + "measurement_summary", + sa.column("person_id", sa.Integer), + sa.column("concept_id", sa.Integer), + sa.column("last_measurement_date", sa.Date), + sa.column("measurement_count", sa.Integer), + ) + ) __mv_dependencies__ = {"measurement_summary"} ``` diff --git a/mkdocs.yml b/mkdocs.yml index 4da05ce..821b051 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -151,3 +151,4 @@ nav: - Backends: advanced/backends.md - Patient Timelines: advanced/timelines.md - PostgreSQL Full-Text Search: advanced/fulltext.md + - Vocabulary Load Performance: advanced/vocabulary_load_performance.md diff --git a/omop_alchemy/cdm/base/modifier_interface.py b/omop_alchemy/cdm/base/modifier_interface.py index f6dd576..17644c9 100644 --- a/omop_alchemy/cdm/base/modifier_interface.py +++ b/omop_alchemy/cdm/base/modifier_interface.py @@ -6,7 +6,8 @@ class ModifierSourceMixin: """ - Marker + helpers for OMOP tables that can modify another CDM row. + Marker and helpers for OMOP tables that record a supplementary fact about + another CDM row through OMOP's polymorphic modifier link. OMOP puts the modifier link on the source table under table-specific column names (``measurement_event_id`` / ``meas_event_field_concept_id`` @@ -17,6 +18,11 @@ class ModifierSourceMixin: This is a declarative marker, not a support list. Which models are accepted as modifier sources stays an explicit allow-list in the toolkit; wearing this mixin describes a model's shape, it does not enrol it. + + The mixin exposes row-level link columns and properties only. Bulk target + validation, including person and Field-concept checks, is provided by the + toolkit's ``modifier_target_queries`` builder rather than by an implicit + ``resolved_target`` lookup on each ORM instance. """ __abstract__ = True @@ -49,8 +55,11 @@ def _modifier_of_field_concept_id(cls) -> SQLColumnExpression[Optional[int]]: class ModifierTargetMixin: """ - Marker + helpers for OMOP tables that can be modified - by Measurements / Observations / Episode Events. + Marker and helpers for OMOP tables that can receive a polymorphic modifier + link from Measurements, Observations, or Episode Events. + + Wearing this mixin describes target-row identity metadata; it does not + enrol a class as a clinical event or modifier target in a registry. """ __abstract__ = True @@ -93,4 +102,4 @@ def end_date(self) -> Optional[date]: @property def type_concept_id(self) -> int: - return getattr(self, self.__type_concept_id_col__) \ No newline at end of file + return getattr(self, self.__type_concept_id_col__) diff --git a/omop_alchemy/cdm/model/clinical/event_metadata.py b/omop_alchemy/cdm/model/clinical/event_metadata.py index 76f158e..02639b0 100644 --- a/omop_alchemy/cdm/model/clinical/event_metadata.py +++ b/omop_alchemy/cdm/model/clinical/event_metadata.py @@ -1,12 +1,13 @@ """Stable metadata for CDM tables that participate in clinical-event APIs. -**NOTE:** "is a clinical event" and "can be modified" are different questions -with different membership. Every clinical event is a valid modifier target, -but not every modifier target is a clinical event. +Being a clinical event and being a valid modifier target are different +questions with different membership. Every clinical event is a valid modifier +target, but not every modifier target is a clinical event. """ from __future__ import annotations +from collections.abc import Callable from types import MappingProxyType from typing import Any, Mapping @@ -30,8 +31,43 @@ (Measurement, MeasurementView), (Observation, ObservationView), (Procedure_Occurrence, Procedure_OccurrenceView), - # Note that Episode itself is a valid modifier target, but it is not a clinical - # event and therefore is not listed here. + # Episode is a valid modifier target, but it is not a clinical event and is + # therefore deliberately absent from this registry. +) + +_STRUCTURAL_MODIFIER_TARGETS: tuple[tuple[type[Any], type[ModifierTargetMixin]], ...] = ( + (Episode, EpisodeView), +) + + +def _validate_unique_target_keys( + entries: tuple[tuple[type[Any], type[ModifierTargetMixin]], ...], + *, + key: Callable[[type[Any], type[ModifierTargetMixin]], object], + label: str, +) -> None: + """Reject duplicate target identities before a registry becomes immutable.""" + seen: dict[object, str] = {} + for source, target in entries: + identity = key(source, target) + previous = seen.get(identity) + if previous is not None: + raise ValueError( + f"duplicate {label} {identity!r}: {previous} and {target.__name__}" + ) + seen[identity] = target.__name__ + + +_ALL_MODIFIER_TARGETS = _CLINICAL_EVENT_TARGETS + _STRUCTURAL_MODIFIER_TARGETS +_validate_unique_target_keys( + _ALL_MODIFIER_TARGETS, + key=lambda source, _target: source.__tablename__, + label="modifier target table", +) +_validate_unique_target_keys( + _ALL_MODIFIER_TARGETS, + key=lambda _source, target: target.modifier_field_concept_id(), + label="modifier field concept ID", ) CLINICAL_EVENT_TARGETS_BY_TABLE: Mapping[str, type[ModifierTargetMixin]] = ( @@ -48,10 +84,6 @@ ) ) -_STRUCTURAL_MODIFIER_TARGETS: tuple[tuple[type[Any], type[ModifierTargetMixin]], ...] = ( - (Episode, EpisodeView), -) - STRUCTURAL_MODIFIER_TARGETS_BY_TABLE: Mapping[str, type[ModifierTargetMixin]] = ( MappingProxyType( {source.__tablename__: target for source, target in _STRUCTURAL_MODIFIER_TARGETS} @@ -66,5 +98,5 @@ def clinical_event_target_for_table( table_name: str, ) -> type[ModifierTargetMixin] | None: - """Return the analytical target that owns metadata for a bare CDM table.""" + """Return the registered analytical event target for a bare CDM table.""" return CLINICAL_EVENT_TARGETS_BY_TABLE.get(table_name) diff --git a/omop_alchemy/cdm/model/clinical/measurement.py b/omop_alchemy/cdm/model/clinical/measurement.py index a87593f..b1e9fc3 100644 --- a/omop_alchemy/cdm/model/clinical/measurement.py +++ b/omop_alchemy/cdm/model/clinical/measurement.py @@ -107,6 +107,18 @@ class MeasurementContext(ReferenceContext): remote_pk="concept_id", ) ) # type: ignore[assignment] + unit_concept: so.Mapped[Optional["Concept"]] = ( + ReferenceContext._reference_relationship( + target="Concept", local_fk="unit_concept_id", remote_pk="concept_id" + ) + ) # type: ignore[assignment] + unit_source_concept: so.Mapped[Optional["Concept"]] = ( + ReferenceContext._reference_relationship( + target="Concept", + local_fk="unit_source_concept_id", + remote_pk="concept_id", + ) + ) # type: ignore[assignment] provider: so.Mapped[Optional["Provider"]] = ( ReferenceContext._reference_relationship( target="Provider", local_fk="provider_id", remote_pk="provider_id" diff --git a/omop_alchemy/cdm/model/clinical/observation.py b/omop_alchemy/cdm/model/clinical/observation.py index ba6232b..a0fc0a7 100644 --- a/omop_alchemy/cdm/model/clinical/observation.py +++ b/omop_alchemy/cdm/model/clinical/observation.py @@ -99,6 +99,11 @@ class ObservationContext(ReferenceContext): remote_pk="concept_id", ) ) # type: ignore[assignment] + unit_concept: so.Mapped[Optional["Concept"]] = ( + ReferenceContext._reference_relationship( + target="Concept", local_fk="unit_concept_id", remote_pk="concept_id" + ) + ) # type: ignore[assignment] provider: so.Mapped[Optional["Provider"]] = ( ReferenceContext._reference_relationship( target="Provider", local_fk="provider_id", remote_pk="provider_id" diff --git a/omop_alchemy/cdm/query.py b/omop_alchemy/cdm/query.py index 834d632..e878aba 100644 --- a/omop_alchemy/cdm/query.py +++ b/omop_alchemy/cdm/query.py @@ -1,7 +1,4 @@ -"""Shared CDM concept-table query filtering. - -Consolidates filtering logic previously duplicated across downstream packages -""" +"""Shared CDM concept-table query filtering.""" from __future__ import annotations diff --git a/omop_alchemy/toolkit/analytics/oncology/concept_sets.py b/omop_alchemy/toolkit/analytics/oncology/concept_sets.py index 30182d0..1252d57 100644 --- a/omop_alchemy/toolkit/analytics/oncology/concept_sets.py +++ b/omop_alchemy/toolkit/analytics/oncology/concept_sets.py @@ -1,10 +1,7 @@ """Governed oncology concept sets. -Every set here names an omop-semantics semantic unit rather than assembling -concept IDs locally. That matters beyond tidiness: "what counts as -radiotherapy" is a clinical claim, and it was previously written out by hand -both here and in omop-constructs, governed by neither. omop-semantics 0.6+ -publishes these as governed units, so both consumers name the same definition. +Every set here references a governed omop-semantics semantic unit. The unit +defines clinical membership, such as which concepts count as radiotherapy. Specs are declarative — importing this module resolves no semantics runtime and touches no database. Expansion happens on first use and is cached per diff --git a/omop_alchemy/toolkit/core/concepts/groups.py b/omop_alchemy/toolkit/core/concepts/groups.py index 7af4f77..ac1b9d7 100644 --- a/omop_alchemy/toolkit/core/concepts/groups.py +++ b/omop_alchemy/toolkit/core/concepts/groups.py @@ -48,19 +48,17 @@ class ConceptGroupSpec: unit The omop-semantics ``RuntimeSemanticUnit`` supplying anchors. Read lazily, so a spec can be declared at module scope without loading the - semantics runtime. omop-semantics 0.6 put mixed-role composition on - the semantic unit rather than on ``RuntimeGroup``, which is why this - takes a unit: ``parent_ids`` expand through descendants while - ``exact_ids`` are matched directly. + semantics runtime. The unit supplies mixed-role anchors: + ``parent_ids`` expand through descendants while ``exact_ids`` are + matched directly. include_descendants Expand ``parent_ids`` through ``concept_ancestor``. When False only the anchors themselves are members. require_standard Restrict expansion to concepts carrying a standardness flag. Defaults to - False, matching the historical oncology behaviour of reading - ``concept_ancestor`` without a standard filter. Note this is the - *opposite* default to ``OMOPConceptSource.descendants``; the difference is - deliberate and declared here rather than left implicit. + False, so expansion reads ``concept_ancestor`` without a standard + filter. ``OMOPConceptSource.descendants`` defaults to requiring standard + concepts. include_classification Widens ``require_standard`` to admit classification ('C') concepts. Defaults to True so a governed group can be anchored on a classification diff --git a/omop_alchemy/toolkit/core/concepts/lookup.py b/omop_alchemy/toolkit/core/concepts/lookup.py index df4e5ed..09ee2f0 100644 --- a/omop_alchemy/toolkit/core/concepts/lookup.py +++ b/omop_alchemy/toolkit/core/concepts/lookup.py @@ -5,6 +5,11 @@ normalisation and correction. Keeping those responsibilities separate is important for bulk ETL, where a resolver must not reopen relationships or expand vocabulary hierarchies per row. + +OMOP Alchemy's resolver is scoped deterministic lookup over that materialised +index. Candidate generation, graph traversal and grounding constraints belong +to omop-graph; the two layers may share vocabulary concepts without sharing a +resolver contract. """ from typing import Iterable, Callable @@ -460,9 +465,8 @@ def __contains__(self, item: str | int) -> bool: def all_concepts(self) -> set[int]: """Every concept ID reachable through this resolver's index. - Cached: the index is fixed at construction, and callers legitimately - union several resolvers' sets, which previously rebuilt each one per - access. Returned by reference, so treat it as read-only. + Cached because the index is fixed at construction. Returned by + reference, so treat it as read-only. """ return set(self.index.mapping.values()) diff --git a/omop_alchemy/toolkit/core/errors.py b/omop_alchemy/toolkit/core/errors.py new file mode 100644 index 0000000..b399c54 --- /dev/null +++ b/omop_alchemy/toolkit/core/errors.py @@ -0,0 +1,25 @@ +"""Shared error contracts for toolkit model validation.""" + +from __future__ import annotations + +from typing import ClassVar + + +class UnsupportedModelError(TypeError): + """Base error carrying the model and reason for unsupported-model failures.""" + + model_kind: ClassVar[str] = "model" + + def __init__( + self, + model: object | None, + reason: str, + *, + message: str | None = None, + ) -> None: + self.model = model + self.reason = reason + if message is None: + name = getattr(model, "__name__", repr(model)) + message = f"{name} is not a supported {self.model_kind}: {reason}" + super().__init__(message) diff --git a/omop_alchemy/toolkit/core/events/projections.py b/omop_alchemy/toolkit/core/events/projections.py index aebc3e5..764bbc3 100644 --- a/omop_alchemy/toolkit/core/events/projections.py +++ b/omop_alchemy/toolkit/core/events/projections.py @@ -7,23 +7,20 @@ import sqlalchemy as sa -from omop_alchemy.cdm.base import ModifierTargetMixin +from omop_alchemy.cdm.base import ModifierSourceMixin, ModifierTargetMixin from omop_alchemy.cdm.model.clinical.event_metadata import ( clinical_event_target_for_table, ) from omop_alchemy.toolkit._utils import _nullable_column, _select_or_union_all +from omop_alchemy.toolkit.core.errors import UnsupportedModelError from .contracts import ClinicalEventColumn -class UnsupportedClinicalEventModelError(TypeError): +class UnsupportedClinicalEventModelError(UnsupportedModelError): """Raised when a model cannot provide a canonical clinical-event projection.""" - def __init__(self, model: object, reason: str) -> None: - self.model = model - self.reason = reason - name = getattr(model, "__name__", repr(model)) - super().__init__(f"{name} is not a supported clinical-event model: {reason}") + model_kind = "clinical-event model" @dataclass(frozen=True, slots=True) @@ -57,11 +54,21 @@ def _has_complete_event_metadata(model: type[Any]) -> bool: def _metadata_candidate(model: type[Any]) -> type[ModifierTargetMixin] | None: - # An explicitly supplied event view owns its metadata. Bare CDM tables use the - # registered CDM view for that table; unrelated subclasses are never discovered - # by walking Python's import-dependent subclass graph. + # An explicitly supplied registered event view (or its domain-specific + # subclass) owns its metadata. Bare CDM tables use the registered CDM view + # for that table; unrelated subclasses are never discovered by walking + # Python's import-dependent subclass graph. if _has_complete_event_metadata(model): - return model + table_name = getattr(model, "__tablename__", None) + registered_view = clinical_event_target_for_table(str(table_name)) + if registered_view is not None and issubclass(model, registered_view): + return model + # Modifier sources outside the built-in event set are intentionally + # supported by the canonical modifier projection. They carry the same + # event-shaped metadata, but do not become episode-resolvable events. + if issubclass(model, ModifierSourceMixin): + return model + return None # Lean CDM models intentionally do not carry modifier metadata. Resolve # their table through the configured analytical view without changing the # class used to read scalar event rows. diff --git a/omop_alchemy/toolkit/core/modifiers/contracts.py b/omop_alchemy/toolkit/core/modifiers/contracts.py index 0815942..7c904ee 100644 --- a/omop_alchemy/toolkit/core/modifiers/contracts.py +++ b/omop_alchemy/toolkit/core/modifiers/contracts.py @@ -185,15 +185,15 @@ def from_mapping( ) -> ModifierTargetDiagnostic: return cls( diagnostic_code=ModifierTargetDiagnosticCode( - row[str(ModifierTargetDiagnosticColumn.diagnostic_code)] + row[ModifierTargetDiagnosticColumn.diagnostic_code.value] ), modifier_source_table=str( - row[str(ModifierTargetDiagnosticColumn.modifier_source_table)] + row[ModifierTargetDiagnosticColumn.modifier_source_table.value] ), - modifier_id=int(row[str(ModifierTargetDiagnosticColumn.modifier_id)]), + modifier_id=int(row[ModifierTargetDiagnosticColumn.modifier_id.value]), target_field_concept_id=row[ - str(ModifierTargetDiagnosticColumn.target_field_concept_id) + ModifierTargetDiagnosticColumn.target_field_concept_id.value ], - target_event_id=row[str(ModifierTargetDiagnosticColumn.target_event_id)], - message=str(row[str(ModifierTargetDiagnosticColumn.message)]), + target_event_id=row[ModifierTargetDiagnosticColumn.target_event_id.value], + message=str(row[ModifierTargetDiagnosticColumn.message.value]), ) diff --git a/omop_alchemy/toolkit/core/modifiers/metadata.py b/omop_alchemy/toolkit/core/modifiers/metadata.py index cf3a66b..a19fd51 100644 --- a/omop_alchemy/toolkit/core/modifiers/metadata.py +++ b/omop_alchemy/toolkit/core/modifiers/metadata.py @@ -22,19 +22,28 @@ UnsupportedClinicalEventModelError, clinical_event_model_spec, ) +from omop_alchemy.toolkit.core.errors import UnsupportedModelError -class UnsupportedModifierSourceModelError(TypeError): +class UnsupportedModifierSourceModelError(UnsupportedModelError): """Raised when a model cannot provide a canonical modifier projection.""" - def __init__(self, model: object, reason: str) -> None: - name = getattr(model, "__name__", repr(model)) - super().__init__(f"{name} is not a supported modifier model: {reason}") + model_kind = "modifier model" -class UnsupportedModifierTargetError(TypeError): +class UnsupportedModifierTargetError(UnsupportedModelError): """Raised when a model cannot be a canonical modifier target.""" + model_kind = "modifier target" + + def __init__(self, model: object, reason: str | None = None) -> None: + # Keep the historical message-only constructor usable for callers that + # instantiated this public exception directly. + if reason is None: + super().__init__(None, str(model), message=str(model)) + return + super().__init__(model, reason) + @dataclass(frozen=True, slots=True) class ModifierTargetModelSpec: @@ -119,22 +128,21 @@ def modifier_source_model_spec(model: type[Any]) -> ClinicalEventModelSpec: raise UnsupportedModifierSourceModelError( model, f"{declaration} names a missing column: {column_name}" ) - spec = MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE.get( - str(getattr(model, "__tablename__", "")) - ) - if spec is not None: - return spec + # Only the bare built-ins share cached metadata. Views and subclasses may + # override event declarations and must resolve and validate their own spec. + if model in CDM_MODIFIER_SOURCE_MODELS: + return MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE[model.__tablename__] return _source_spec(model) def modifier_target_model_spec(model: type[Any]) -> ModifierTargetModelSpec: """Resolve and validate immutable metadata for a modifier target model.""" if not isinstance(model, type) or not hasattr(model, "__table__"): - raise UnsupportedModifierTargetError("expected a mapped ORM model class") + raise UnsupportedModifierTargetError(model, "expected a mapped ORM model class") spec = MODIFIER_TARGET_SPECS_BY_TABLE.get(str(getattr(model, "__tablename__", ""))) if spec is None: raise UnsupportedModifierTargetError( - f"{model.__name__} is not a supported modifier target" + model, "is not a supported modifier target" ) missing = tuple( name @@ -143,6 +151,7 @@ def modifier_target_model_spec(model: type[Any]) -> ModifierTargetModelSpec: ) if missing: raise UnsupportedModifierTargetError( - f"{model.__name__} is missing required attributes: {', '.join(missing)}" + model, + f"is missing required attributes: {', '.join(missing)}", ) return spec diff --git a/omop_alchemy/toolkit/core/modifiers/selection.py b/omop_alchemy/toolkit/core/modifiers/selection.py index 41e1497..9031a94 100644 --- a/omop_alchemy/toolkit/core/modifiers/selection.py +++ b/omop_alchemy/toolkit/core/modifiers/selection.py @@ -17,7 +17,7 @@ class InvalidModifierSourceError(ValueError): - pass + """Raised when a modifier selection input lacks a required column.""" def modifier_order_expressions( @@ -110,7 +110,33 @@ def selected_modifier_select( *, priority: Sequence[sa.ColumnElement[Any]] = (), ) -> sa.Select[Any]: - """Select the first deterministically ranked modifier in each partition.""" + """Select the first deterministically ranked modifier in each partition. + + Parameters + ---------- + source: + A selectable containing canonical modifier columns and target identity + columns. + spec: + Temporal direction, partition columns and stable identity columns used + to define the selection contract. + priority: + Optional SQL expressions placed before the temporal policy, such as a + domain-specific stage preference. + + Returns + ------- + sqlalchemy.sql.Select + A selectable containing the source columns, with one row at rank one + for each target partition. Rows missing either target identity column + are excluded before selection. + + Notes + ----- + The final tie-breakers come from ``spec.stable_identity_columns``. This + keeps the result deterministic when dates, datetimes and caller priorities + are equal. + """ ranked = ranked_modifier_select(source, spec=spec, priority=priority).subquery( "ranked_modifiers" ) diff --git a/omop_alchemy/toolkit/core/modifiers/targets.py b/omop_alchemy/toolkit/core/modifiers/targets.py index ae19007..35f6646 100644 --- a/omop_alchemy/toolkit/core/modifiers/targets.py +++ b/omop_alchemy/toolkit/core/modifiers/targets.py @@ -60,7 +60,37 @@ def modifier_target_queries( include_unmatched: bool = False, diagnostics: bool = False, ) -> ModifierTargetQueries: - """Resolve valid target links and optionally explain rejected modifier rows.""" + """Resolve valid target links and optionally explain rejected modifier rows. + + Parameters + ---------- + modifier_source: + A supported modifier model or a selectable exposing the canonical + modifier columns. + target_source: + A supported ORM target model or a selectable exposing target identity + columns. Selectables are treated as the caller's supplied scope. + include_unmatched: + If ``True``, retain modifier rows without a valid target in + ``matches``. Otherwise only valid links are returned. + diagnostics: + If ``True``, also build an advisory diagnostic selectable. Building + the queries does not execute either selectable. + + Returns + ------- + ModifierTargetQueries + ``matches`` contains the modifier columns plus resolved target + identity columns. ``diagnostics`` is ``None`` unless requested. + + Notes + ----- + A link is valid only when target event ID, target Field concept and person + ID all agree. For an ORM target model, diagnostics can report an + unsupported target field or a missing target event. For a caller-supplied + selectable, those absences may be caused by filtering, so only missing + identity and observed person mismatches are reported. + """ modifiers = ( canonical_modifier_projection(modifier_source).subquery("target_modifiers") if isinstance(modifier_source, type) diff --git a/omop_alchemy/toolkit/core/timeline/event_timeline.py b/omop_alchemy/toolkit/core/timeline/event_timeline.py index e49976a..4dca5bb 100644 --- a/omop_alchemy/toolkit/core/timeline/event_timeline.py +++ b/omop_alchemy/toolkit/core/timeline/event_timeline.py @@ -291,7 +291,6 @@ class Measurement_Event(ClinicalEvent, Measurement): value_fields=[ "value_as_concept_id", "value_as_number", - "value_as_string", ], ) diff --git a/omop_alchemy/toolkit/episodes/derivation/attachments.py b/omop_alchemy/toolkit/episodes/derivation/attachments.py index 9d0f55a..843d49d 100644 --- a/omop_alchemy/toolkit/episodes/derivation/attachments.py +++ b/omop_alchemy/toolkit/episodes/derivation/attachments.py @@ -243,10 +243,49 @@ def episode_attachment_queries( ) -> EpisodeAttachmentQueries: """Build explicit-first attachments from canonical event and episode inputs. - Explicit links are valid only when their event ID, Field-concept - discriminator, episode ID, and person all agree. An invalid explicit link - never suppresses fallback. Ranked fallback requires a ranking specification; - all-in-window fallback retains every eligible episode. + Parameters + ---------- + events: + A supported event model or selectable exposing the canonical event + columns. + policy: + Whether to use explicit links only, ranked fallback, or every eligible + episode in the fallback window. + episodes: + An Episode model or selectable exposing episode identity, person and + date bounds. + episode_events: + An Episode_Event model or selectable containing episode/event links and + their Field-concept discriminator. + ranking: + Temporal ranking used by ``explicit_first_ranked``. It must be omitted + for policies that do not rank fallback candidates. + window: + Episode-relative date window used to admit fallback candidates. + include_diagnostics: + If ``True``, return an advisory diagnostic selectable as well as the + attachment query. Building the queries does not execute them. + + Returns + ------- + EpisodeAttachmentQueries + ``attachments`` preserves event columns and appends ``episode_id`` and + ``attachment_method``. ``diagnostics`` is ``None`` unless requested. + + Notes + ----- + Resolution proceeds in three stages: validate explicit links, admit + same-person fallback candidates within the episode-relative window, then + retain every admitted candidate or apply temporal ranking according to + ``policy``. Explicit links suppress fallback only after the event ID, + Field-concept discriminator, episode ID and person all agree; the final + result is deduplicated by event source, event ID and episode ID. Fallback + ambiguity counts distinct eligible episodes, even when inputs repeat rows. + + Diagnostics explain rejected links and fallback outcomes without changing + attachment rows. Because inputs may be filtered selectables, the builder + does not infer missing source rows or unsupported discriminators from their + absence. """ if policy.requires_fallback_ranking and ranking is None: raise ValueError("explicit_first_ranked requires a temporal ranking") @@ -348,13 +387,48 @@ def episode_attachment_queries( # episode resolution stage 2 records the complete table-scoped identity # of every valid explicit event. The anti-existence check below must use # both columns: event_id alone is never a cross-table identity in OMOP. + fallback_join = event_source.join( + episode_source, + sa.and_( + event_source.c[person_id] == episode_source.c[episode_person_id], + episode_window_predicate( + event_source.c[event_date], + episode_source.c[episode_start], + episode_source.c[episode_end], + window=window, + ), + ), + ) + # Count identities before ranking: repeated source or episode rows are + # not evidence that another distinct episode is eligible. + fallback_episode_keys = ( + sa.select( + event_source.c[source_table], + event_source.c[event_id], + episode_source.c[episode_id], + ) + .select_from(fallback_join) + .where(_not_exists_for_event(event_source, valid_explicit_event_keys)) + .distinct() + .cte("fallback_episode_keys") + ) + fallback_counts = ( + sa.select( + fallback_episode_keys.c[source_table], + fallback_episode_keys.c[event_id], + sa.func.count().label(_FALLBACK_CANDIDATE_COUNT), + ) + .group_by( + fallback_episode_keys.c[source_table], + fallback_episode_keys.c[event_id], + ) + .cte("fallback_episode_counts") + ) fallback_columns: list[sa.ColumnElement[Any]] = [ *(event_source.c[name] for name in event_names), episode_source.c[episode_id].label(ATTACHMENT_EPISODE_ID), sa.literal(str(EpisodeAttachmentMethod.fallback)).label(ATTACHMENT_METHOD), - sa.func.count() - .over(partition_by=(event_source.c[source_table], event_source.c[event_id])) - .label(_FALLBACK_CANDIDATE_COUNT), + fallback_counts.c[_FALLBACK_CANDIDATE_COUNT], ] if policy.requires_fallback_ranking: assert ranking is not None # validated above @@ -386,21 +460,10 @@ def episode_attachment_queries( fallback_candidates = ( sa.select(*fallback_columns) .select_from( - event_source.join( - episode_source, - sa.and_( - event_source.c[person_id] - == episode_source.c[episode_person_id], - episode_window_predicate( - event_source.c[event_date], - episode_source.c[episode_start], - episode_source.c[episode_end], - window=window, - ), - ), + fallback_join.join( + fallback_counts, _same_event(event_source, fallback_counts) ) ) - .where(_not_exists_for_event(event_source, valid_explicit_event_keys)) .cte("fallback_attachment_candidates") ) selected_fallback = sa.select( diff --git a/omop_alchemy/toolkit/episodes/derivation/contracts.py b/omop_alchemy/toolkit/episodes/derivation/contracts.py index 6de3bc4..4397cea 100644 --- a/omop_alchemy/toolkit/episodes/derivation/contracts.py +++ b/omop_alchemy/toolkit/episodes/derivation/contracts.py @@ -172,23 +172,23 @@ def from_mapping( """Convert one SQLAlchemy mapping result without leaking column-name handling.""" return cls( code=AttachmentDiagnosticCode( - row[str(AttachmentDiagnosticColumn.diagnostic_code)] + row[AttachmentDiagnosticColumn.diagnostic_code.value] ), event=ClinicalEventIdentity( event_source_table=row[ - str(AttachmentDiagnosticColumn.event_source_table) + AttachmentDiagnosticColumn.event_source_table.value ], - event_id=row[str(AttachmentDiagnosticColumn.event_id)], + event_id=row[AttachmentDiagnosticColumn.event_id.value], ), event_field_concept_id=row[ - str(AttachmentDiagnosticColumn.event_field_concept_id) + AttachmentDiagnosticColumn.event_field_concept_id.value ], linked_event_field_concept_id=row[ - str(AttachmentDiagnosticColumn.linked_event_field_concept_id) + AttachmentDiagnosticColumn.linked_event_field_concept_id.value ], - episode_id=row[str(AttachmentDiagnosticColumn.episode_id)], - candidate_count=row[str(AttachmentDiagnosticColumn.candidate_count)], - message=row[str(AttachmentDiagnosticColumn.message)], + episode_id=row[AttachmentDiagnosticColumn.episode_id.value], + candidate_count=row[AttachmentDiagnosticColumn.candidate_count.value], + message=row[AttachmentDiagnosticColumn.message.value], ) diff --git a/tests/test_episode_attachment_queries.py b/tests/test_episode_attachment_queries.py index 1af5b02..fab3ea9 100644 --- a/tests/test_episode_attachment_queries.py +++ b/tests/test_episode_attachment_queries.py @@ -348,6 +348,55 @@ def test_diagnostics_explain_person_mismatches_and_fallback_outcomes(session): assert typed.episode_id == CROSS_PERSON_LINK.episode_id +@pytest.mark.parametrize( + "session_fixture", + [ + "session", + pytest.param("pg_session", marks=pytest.mark.requires_database("test_cdm_db")), + ], +) +@pytest.mark.parametrize("copies", [(2, 1), (1, 2), (2, 2)]) +@pytest.mark.parametrize("episode_count", [1, 2]) +@pytest.mark.parametrize( + "policy", + [ + EpisodeAttachmentPolicy.explicit_first_ranked, + EpisodeAttachmentPolicy.explicit_first_all_in_window, + ], +) +def test_fallback_counts_distinct_episodes_with_duplicate_inputs( + request, session_fixture, copies, episode_count, policy +): + session = request.getfixturevalue(session_fixture) + event_copies, episode_copies = copies + queries = episode_attachment_queries( + _event_source(*([COLLIDING_EVENTS[0]] * event_copies)), + episodes=_episode_source( + *(OVERLAPPING_EPISODES[:episode_count] * episode_copies) + ), + episode_events=_empty_link_source(), + policy=policy, + ranking=_nearest() if policy.requires_fallback_ranking else None, + include_diagnostics=True, + ) + rows = session.execute(queries.attachments).mappings().all() + expected_ids = ( + {1001} + if policy.requires_fallback_ranking + else {episode.episode_id for episode in OVERLAPPING_EPISODES[:episode_count]} + ) + assert len(rows) == len(expected_ids) + assert {row["episode_id"] for row in rows} == expected_ids + assert queries.diagnostics is not None + diagnostics = session.execute(queries.diagnostics).mappings().all() + if episode_count == 2 and policy.requires_fallback_ranking: + assert len(diagnostics) == 1 + assert diagnostics[0]["diagnostic_code"] == "ambiguous_fallback" + assert diagnostics[0]["candidate_count"] == 2 + else: + assert diagnostics == [] + + def test_diagnostics_and_fallback_share_the_explicit_event_key_cte(): queries = episode_attachment_queries( _event_source(COLLIDING_EVENTS[0]), diff --git a/tests/test_event_projections.py b/tests/test_event_projections.py index a10ccda..5233646 100644 --- a/tests/test_event_projections.py +++ b/tests/test_event_projections.py @@ -28,7 +28,10 @@ Procedure_OccurrenceView, ) from omop_alchemy.cdm.base import ModifierTargetMixin -from omop_alchemy.cdm.model.structural import Episode_EventView +from omop_alchemy.cdm.model.structural import Episode, Episode_EventView, EpisodeView +from omop_alchemy.cdm.model.clinical.event_metadata import ( + _validate_unique_target_keys, +) from omop_alchemy.toolkit.core.events import ( CANONICAL_EVENT_OPTIONAL_COLUMNS, CANONICAL_EVENT_REQUIRED_COLUMNS, @@ -119,9 +122,35 @@ def test_incomplete_modifier_target_has_a_typed_error(): with pytest.raises( UnsupportedClinicalEventModelError, match="no complete ModifierTargetMixin metadata", - ): + ) as raised: canonical_event_projection(Person) + assert raised.value.model is Person + assert raised.value.reason == "no complete ModifierTargetMixin metadata is available" + + +@pytest.mark.parametrize("model", [Episode, EpisodeView]) +def test_structural_modifier_targets_are_not_clinical_events(model): + with pytest.raises( + UnsupportedClinicalEventModelError, + match="no complete ModifierTargetMixin metadata", + ): + clinical_event_model_spec(model) + + +def test_target_registry_rejects_duplicate_identities(): + entries = ( + (Condition_Occurrence, Condition_OccurrenceView), + (Measurement, MeasurementView), + ) + + with pytest.raises(ValueError, match="duplicate test identity"): + _validate_unique_target_keys( + entries, + key=lambda _source, _target: "same", + label="test identity", + ) + def test_all_core_event_views_are_registered_episode_event_targets(): targets = Episode_EventView.resolved_event_target_classes() diff --git a/tests/test_event_timeline.py b/tests/test_event_timeline.py index fa7240d..ae896f2 100644 --- a/tests/test_event_timeline.py +++ b/tests/test_event_timeline.py @@ -9,11 +9,13 @@ from sqlalchemy.dialects import sqlite from omop_alchemy.cdm.base import ModifierFieldConcepts +from omop_alchemy.cdm.model.clinical import MeasurementView, ObservationView from omop_alchemy.toolkit.core.events import ClinicalEventRow from omop_alchemy.toolkit.core.timeline import ( ClinicalEvent, Condition_Event, Drug_Exposure_Event, + Measurement_Event, Observation_Event, Person_Timeline, ) @@ -73,6 +75,23 @@ def test_drug_exposure_quantity_is_a_numeric_timeline_value(): assert event.to_dict()["value"] == {"type": "numeric", "value": 12.5} +def test_measurement_event_only_declares_measurement_value_columns(): + assert Measurement_Event._mapping.value_fields == [ + "value_as_concept_id", + "value_as_number", + ] + + +def test_measurement_and_observation_views_expose_schema_backed_unit_context(): + measurement_relationships = MeasurementView.__mapper__.relationships + observation_relationships = ObservationView.__mapper__.relationships + + assert "unit_concept" in measurement_relationships + assert "unit_source_concept" in measurement_relationships + assert "unit_concept" in observation_relationships + assert "unit_source_concept" not in observation_relationships + + def test_all_timeline_events_use_clinical_event_behaviour(): assert Condition_Event.to_json is ClinicalEvent.to_json assert Drug_Exposure_Event.to_json is ClinicalEvent.to_json diff --git a/tests/test_modifier_projections.py b/tests/test_modifier_projections.py index 93f6cad..c216194 100644 --- a/tests/test_modifier_projections.py +++ b/tests/test_modifier_projections.py @@ -23,6 +23,7 @@ Person, Procedure_Occurrence, ) +from omop_alchemy.cdm.model.clinical import MeasurementView, ObservationView from omop_alchemy.cdm.model.structural import Episode, Episode_EventView from omop_alchemy.cdm.model.clinical.event_metadata import ( CLINICAL_EVENT_TARGETS_BY_FIELD_CONCEPT_ID, @@ -31,7 +32,10 @@ STRUCTURAL_MODIFIER_TARGETS_BY_TABLE, clinical_event_target_for_table, ) -from omop_alchemy.toolkit.core.events import ClinicalEventModelSpec +from omop_alchemy.toolkit.core.events import ( + ClinicalEventModelSpec, + clinical_event_model_spec, +) from omop_alchemy.toolkit.core.modifiers.projections import _VALUE_COLUMN_TYPES from omop_alchemy.toolkit.core.modifiers.contracts import ( ModifierColumn, @@ -45,6 +49,7 @@ MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE, MODIFIER_TARGET_SPECS_BY_TABLE, UnsupportedModifierSourceModelError, + UnsupportedModifierTargetError, canonical_modifier_projection, canonical_modifier_target_projection, canonical_modifier_union, @@ -91,9 +96,33 @@ def test_modifier_union_preserves_shape_and_all_rows(): def test_non_modifier_model_fails_at_query_construction(): with pytest.raises( UnsupportedModifierSourceModelError, match="ModifierSourceMixin" - ): + ) as raised: canonical_modifier_projection(Person) + assert raised.value.model is Person + assert raised.value.reason == ( + "must declare the OMOP modifier link via ModifierSourceMixin" + ) + + +def test_unsupported_modifier_target_error_preserves_model_and_reason(): + with pytest.raises( + UnsupportedModifierTargetError, + match="is not a supported modifier target", + ) as raised: + modifier_target_model_spec(Person) + + assert raised.value.model is Person + assert raised.value.reason == "is not a supported modifier target" + + +def test_unsupported_modifier_target_error_keeps_message_only_compatibility(): + error = UnsupportedModifierTargetError("legacy target message") + + assert str(error) == "legacy target message" + assert error.model is None + assert error.reason == "legacy target message" + def test_modifier_metadata_reuses_generic_model_interfaces(): # The target link is no longer described by the spec at all; it is read off @@ -247,6 +276,70 @@ def test_a_source_naming_a_missing_link_column_is_rejected(): modifier_source_model_spec(custom) +@pytest.mark.parametrize("source_model", [MeasurementView, ObservationView]) +def test_modifier_source_subclass_projects_its_own_event_metadata(source_model): + original = clinical_event_model_spec(source_model) + specialized = type( + f"{source_model.__name__}WithOverriddenEventMetadata", + (source_model,), + { + "__event_id_col__": "projected_id", + "projected_id": so.column_property( + getattr(source_model, original.event_id_column) + 100 + ), + "__concept_id_col__": "value_as_concept_id", + "__start_date_col__": "projected_date", + "projected_date": so.synonym(original.event_date_column), + "projected_datetime": so.synonym(original.event_datetime_column), + }, + ) + spec = modifier_source_model_spec(specialized) + assert spec == clinical_event_model_spec(specialized) + assert spec.event_date_column == "projected_date" + assert spec.event_datetime_column == "projected_datetime" + + engine = sa.create_engine("sqlite://") + Base.metadata.create_all(engine, tables=[source_model.__table__]) + with engine.begin() as connection: + connection.execute( + source_model.__table__.insert(), + { + original.event_id_column: 7, + "person_id": 101, + original.event_concept_id_column: 900_001, + original.event_date_column: date(2026, 1, 20), + source_model.__type_concept_id_col__: 32817, + "value_as_concept_id": 900_002, + }, + ) + row = ( + connection.execute(canonical_modifier_projection(specialized)) + .mappings() + .one() + ) + engine.dispose() + + assert row["modifier_id"] == 107 + assert row["modifier_concept_id"] == 900_002 + assert row["modifier_date"] == date(2026, 1, 20) + + +@pytest.mark.parametrize("source_model", [MeasurementView, ObservationView]) +@pytest.mark.parametrize( + "declaration", ["__event_id_col__", "__concept_id_col__", "__start_date_col__"] +) +def test_modifier_source_subclass_rejects_missing_declared_columns( + source_model, declaration +): + specialized = type( + f"{source_model.__name__}WithMissing{declaration.strip('_')}", + (source_model,), + {declaration: "no_such_column"}, + ) + with pytest.raises(UnsupportedModifierSourceModelError, match="no_such_column"): + canonical_modifier_projection(specialized) + + def test_modifier_targets_derive_the_clinical_surface_and_extend_it_with_episode(): assert set(MODIFIER_TARGET_SPECS_BY_TABLE) == { *CLINICAL_EVENT_TARGETS_BY_TABLE, @@ -467,8 +560,7 @@ def modifier( queries = modifier_target_queries(modifiers, targets, diagnostics=True) assert [ - row["modifier_id"] - for row in pg_session.execute(queries.matches).mappings() + row["modifier_id"] for row in pg_session.execute(queries.matches).mappings() ] == [1] assert queries.diagnostics is not None assert { From db8d23f7d0fc271c7203778fa637200106b22f43 Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Wed, 16 Sep 2026 11:19:20 +1000 Subject: [PATCH 25/30] deprecation marker --- .../model/clinical/clinical_event_union.py | 6 ++++ pyproject.toml | 1 + .../test_clinical_event_union_deprecation.py | 29 ++++++++++++++++++- uv.lock | 2 ++ 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/omop_alchemy/cdm/model/clinical/clinical_event_union.py b/omop_alchemy/cdm/model/clinical/clinical_event_union.py index 2cda850..03018b2 100644 --- a/omop_alchemy/cdm/model/clinical/clinical_event_union.py +++ b/omop_alchemy/cdm/model/clinical/clinical_event_union.py @@ -1,4 +1,5 @@ import warnings +from typing_extensions import deprecated from sqlalchemy import select, union_all, literal from .condition_occurrence import Condition_Occurrence @@ -41,6 +42,11 @@ ).subquery("clinical_event") +@deprecated( + "Use omop_alchemy.toolkit.core.events.canonical_event_union instead. " + "ClinicalEventView will be removed in omop-alchemy 2.0.", + category=None, +) class ClinicalEventView(Base): __table__ = clinical_event_union __mapper_args__ = { diff --git a/pyproject.toml b/pyproject.toml index d05c964..9f247ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "typer>=0.12", "rich>=13.0", "orm-loader>=1.2.0,<2.0.0", + "typing-extensions>=4.5", ] [project.optional-dependencies] diff --git a/tests/test_clinical_event_union_deprecation.py b/tests/test_clinical_event_union_deprecation.py index 4254c2a..ebb99d0 100644 --- a/tests/test_clinical_event_union_deprecation.py +++ b/tests/test_clinical_event_union_deprecation.py @@ -4,6 +4,7 @@ import subprocess import sys +import textwrap def test_clinical_event_union_module_warns_on_direct_import(): @@ -13,7 +14,33 @@ def test_clinical_event_union_module_warns_on_direct_import(): "-W", "always::DeprecationWarning", "-c", - "import omop_alchemy.cdm.model.clinical.clinical_event_union", + textwrap.dedent( + """ + import warnings + import sqlalchemy as sa + from sqlalchemy.dialects import sqlite + from omop_alchemy.cdm.model.clinical.clinical_event_union import ( + ClinicalEventView, clinical_event_union, + ) + + assert "canonical_event_union" in ClinicalEventView.__deprecated__ + assert "removed in omop-alchemy 2.0" in ClinicalEventView.__deprecated__ + with warnings.catch_warnings(record=True) as emitted: + warnings.simplefilter("always", DeprecationWarning) + mapper = sa.inspect(ClinicalEventView) + assert mapper.local_table is clinical_event_union + assert [column.key for column in mapper.primary_key] == [ + "domain", "event_id", + ] + row = ClinicalEventView(domain="condition", event_id=7) + assert sa.inspect(row).mapper is mapper + class SpecializedEvent(ClinicalEventView): + pass + assert sa.inspect(SpecializedEvent).inherits is mapper + sa.select(ClinicalEventView).compile(dialect=sqlite.dialect()) + assert not emitted + """ + ), ], capture_output=True, check=True, diff --git a/uv.lock b/uv.lock index b8e4ad0..ef9443e 100644 --- a/uv.lock +++ b/uv.lock @@ -1250,6 +1250,7 @@ dependencies = [ { name = "rich" }, { name = "sqlalchemy" }, { name = "typer" }, + { name = "typing-extensions" }, ] [package.optional-dependencies] @@ -1297,6 +1298,7 @@ requires-dist = [ { name = "sqlalchemy", specifier = ">=2.0.45" }, { name = "ty", marker = "extra == 'dev'", specifier = "==0.0.61" }, { name = "typer", specifier = ">=0.12" }, + { name = "typing-extensions", specifier = ">=4.5" }, ] provides-extras = ["dev", "postgres", "semantics"] From 0913aa6e314006c1ac500efa9d97b8bd9c3d2e38 Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Wed, 16 Sep 2026 11:48:40 +1000 Subject: [PATCH 26/30] minor test fixture updates --- tests/fixtures/query_contract_cases.py | 27 ++++++++++-------------- tests/test_episode_attachment_queries.py | 18 ++++++++-------- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/tests/fixtures/query_contract_cases.py b/tests/fixtures/query_contract_cases.py index e00ee20..5ec6ba6 100644 --- a/tests/fixtures/query_contract_cases.py +++ b/tests/fixtures/query_contract_cases.py @@ -13,11 +13,6 @@ from omop_alchemy.cdm.base import ModifierFieldConcepts from omop_alchemy.toolkit.core.events import ClinicalEventIdentity -MEASUREMENT_FIELD_CONCEPT_ID = ModifierFieldConcepts.MEASUREMENT -OBSERVATION_FIELD_CONCEPT_ID = ModifierFieldConcepts.OBSERVATION -PROCEDURE_FIELD_CONCEPT_ID = ModifierFieldConcepts.PROCEDURE_OCCURRENCE -DRUG_FIELD_CONCEPT_ID = ModifierFieldConcepts.DRUG_EXPOSURE - @dataclass(frozen=True, slots=True) class EventCase: @@ -59,19 +54,19 @@ class ObservationCase: ClinicalEventIdentity("measurement", 7), person_id=101, event_date=date(2026, 1, 20), - event_field_concept_id=MEASUREMENT_FIELD_CONCEPT_ID, + event_field_concept_id=ModifierFieldConcepts.MEASUREMENT, ), EventCase( ClinicalEventIdentity("procedure_occurrence", 7), person_id=101, event_date=date(2026, 1, 20), - event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, + event_field_concept_id=ModifierFieldConcepts.PROCEDURE_OCCURRENCE, ), EventCase( ClinicalEventIdentity("observation", 7), person_id=202, event_date=date(2026, 1, 20), - event_field_concept_id=OBSERVATION_FIELD_CONCEPT_ID, + event_field_concept_id=ModifierFieldConcepts.OBSERVATION, ), ) @@ -124,25 +119,25 @@ class ObservationCase: VALID_EXPLICIT_LINK = ExplicitLinkCase( event=ClinicalEventIdentity("procedure_occurrence", 7), episode_id=1002, - episode_event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, + episode_event_field_concept_id=ModifierFieldConcepts.PROCEDURE_OCCURRENCE, ) COLLIDING_VALID_LINK = ExplicitLinkCase( event=ClinicalEventIdentity("measurement", 7), episode_id=1001, - episode_event_field_concept_id=MEASUREMENT_FIELD_CONCEPT_ID, + episode_event_field_concept_id=ModifierFieldConcepts.MEASUREMENT, ) OUT_OF_SCOPE_LINK = ExplicitLinkCase( event=ClinicalEventIdentity("drug_exposure", 7), episode_id=1001, - episode_event_field_concept_id=DRUG_FIELD_CONCEPT_ID, + episode_event_field_concept_id=ModifierFieldConcepts.DRUG_EXPOSURE, ) CROSS_PERSON_LINK = ExplicitLinkCase( event=ClinicalEventIdentity("observation", 7), episode_id=1001, - episode_event_field_concept_id=OBSERVATION_FIELD_CONCEPT_ID, + episode_event_field_concept_id=ModifierFieldConcepts.OBSERVATION, ) @@ -153,25 +148,25 @@ class ObservationCase: ClinicalEventIdentity("measurement", 8), person_id=101, event_date=date(2025, 10, 17), - event_field_concept_id=MEASUREMENT_FIELD_CONCEPT_ID, + event_field_concept_id=ModifierFieldConcepts.MEASUREMENT, ), EventCase( ClinicalEventIdentity("measurement", 9), person_id=101, event_date=date(2025, 10, 16), - event_field_concept_id=MEASUREMENT_FIELD_CONCEPT_ID, + event_field_concept_id=ModifierFieldConcepts.MEASUREMENT, ), EventCase( ClinicalEventIdentity("measurement", 10), person_id=101, event_date=date(2026, 2, 5), - event_field_concept_id=MEASUREMENT_FIELD_CONCEPT_ID, + event_field_concept_id=ModifierFieldConcepts.MEASUREMENT, ), EventCase( ClinicalEventIdentity("measurement", 11), person_id=101, event_date=date(2026, 2, 6), - event_field_concept_id=MEASUREMENT_FIELD_CONCEPT_ID, + event_field_concept_id=ModifierFieldConcepts.MEASUREMENT, ), ) diff --git a/tests/test_episode_attachment_queries.py b/tests/test_episode_attachment_queries.py index fab3ea9..0285fab 100644 --- a/tests/test_episode_attachment_queries.py +++ b/tests/test_episode_attachment_queries.py @@ -8,6 +8,7 @@ import sqlalchemy as sa from sqlalchemy.dialects import postgresql, sqlite +from omop_alchemy.cdm.base import ModifierFieldConcepts from omop_alchemy.cdm.model import Procedure_Occurrence from omop_alchemy.toolkit.core.events import ClinicalEventIdentity from omop_alchemy.toolkit.episodes.derivation import ( @@ -31,7 +32,6 @@ EpisodeCase, EventCase, ExplicitLinkCase, - PROCEDURE_FIELD_CONCEPT_ID, ) @@ -114,7 +114,7 @@ def test_valid_explicit_links_suppress_ranked_fallback_with_colliding_ids(sessio identity=ClinicalEventIdentity("procedure_occurrence", 8), person_id=101, event_date=date(2026, 1, 20), - event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, + event_field_concept_id=ModifierFieldConcepts.PROCEDURE_OCCURRENCE, ) sources = episode_attachment_queries( _event_source(*COLLIDING_EVENTS, unlinked), @@ -196,7 +196,7 @@ def test_side_preference_is_applied_to_ranked_fallback(session): identity=ClinicalEventIdentity("procedure_occurrence", 8), person_id=101, event_date=date(2026, 1, 20), - event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, + event_field_concept_id=ModifierFieldConcepts.PROCEDURE_OCCURRENCE, ) def selected_episode(ranking: TemporalRankingSpec) -> int: @@ -221,7 +221,7 @@ def test_all_in_window_fallback_retains_each_eligible_episode(session): identity=ClinicalEventIdentity("procedure_occurrence", 8), person_id=101, event_date=date(2026, 1, 20), - event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, + event_field_concept_id=ModifierFieldConcepts.PROCEDURE_OCCURRENCE, ) queries = episode_attachment_queries( _event_source(event), @@ -242,7 +242,7 @@ def test_all_in_window_uses_a_window_contract_without_ranking(session): identity=ClinicalEventIdentity("procedure_occurrence", 8), person_id=101, event_date=date(2025, 10, 17), - event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, + event_field_concept_id=ModifierFieldConcepts.PROCEDURE_OCCURRENCE, ) queries = episode_attachment_queries( _event_source(boundary_event), @@ -260,7 +260,7 @@ def test_all_in_window_diagnostics_do_not_report_intended_fanout(session): identity=ClinicalEventIdentity("procedure_occurrence", 8), person_id=101, event_date=date(2026, 1, 20), - event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, + event_field_concept_id=ModifierFieldConcepts.PROCEDURE_OCCURRENCE, ) queries = episode_attachment_queries( _event_source(event), @@ -297,13 +297,13 @@ def test_diagnostics_explain_person_mismatches_and_fallback_outcomes(session): identity=ClinicalEventIdentity("procedure_occurrence", 8), person_id=101, event_date=date(2026, 1, 20), - event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, + event_field_concept_id=ModifierFieldConcepts.PROCEDURE_OCCURRENCE, ) unlinked = EventCase( identity=ClinicalEventIdentity("procedure_occurrence", 9), person_id=303, event_date=date(2026, 1, 20), - event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, + event_field_concept_id=ModifierFieldConcepts.PROCEDURE_OCCURRENCE, ) queries = episode_attachment_queries( _event_source(*COLLIDING_EVENTS, ambiguous, unlinked), @@ -461,7 +461,7 @@ def test_postgresql_executes_collision_and_stable_tie_contracts(pg_session): identity=ClinicalEventIdentity("procedure_occurrence", 8), person_id=101, event_date=date(2026, 1, 20), - event_field_concept_id=PROCEDURE_FIELD_CONCEPT_ID, + event_field_concept_id=ModifierFieldConcepts.PROCEDURE_OCCURRENCE, ) queries = episode_attachment_queries( _event_source(*COLLIDING_EVENTS[:2], unlinked), From 6d626d54f3de2eb14fd7b7718695894ee9a504be Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Thu, 17 Sep 2026 14:20:15 +1000 Subject: [PATCH 27/30] outstanding review comments --- docs/advanced/timelines.md | 2 +- .../images/oa-configure.png | Bin .../{static => assets}/images/oa-fulltext.png | Bin docs/{static => assets}/images/oa-info.png | Bin docs/getting-started/configuration.md | 4 +- docs/getting-started/installation.md | 2 +- docs/toolkit/query-contracts.md | 39 +++--- omop_alchemy/cdm/base/errors.py | 25 ++++ omop_alchemy/cdm/base/event_metadata.py | 70 ++++++++++ omop_alchemy/cdm/base/modifier_interface.py | 20 ++- omop_alchemy/cdm/base/reference_context.py | 68 +++++---- .../model/clinical/condition_occurrence.py | 8 +- omop_alchemy/cdm/model/clinical/death.py | 6 +- .../cdm/model/clinical/device_exposure.py | 13 +- .../cdm/model/clinical/drug_exposure.py | 8 +- .../cdm/model/clinical/event_metadata.py | 98 ++++++++++++- .../cdm/model/clinical/measurement.py | 14 +- .../cdm/model/clinical/observation.py | 13 +- omop_alchemy/cdm/model/clinical/person.py | 12 +- .../model/clinical/procedure_occurrence.py | 8 -- .../model/health_system/visit_occurrence.py | 6 +- omop_alchemy/cdm/model/structural/episode.py | 10 +- .../cdm/model/structural/episode_event.py | 11 +- omop_alchemy/cdm/model/unstructured/note.py | 9 -- omop_alchemy/cdm/model/vocabulary/concept.py | 6 +- omop_alchemy/toolkit/core/errors.py | 26 +--- .../toolkit/core/events/projections.py | 130 +----------------- .../toolkit/core/modifiers/metadata.py | 6 +- .../toolkit/core/timeline/event_timeline.py | 35 +++-- tests/test_event_projections.py | 36 +++++ tests/test_event_timeline.py | 53 +++++++ tests/test_reference_context.py | 116 ++++++++++++++++ 32 files changed, 558 insertions(+), 296 deletions(-) rename docs/{static => assets}/images/oa-configure.png (100%) rename docs/{static => assets}/images/oa-fulltext.png (100%) rename docs/{static => assets}/images/oa-info.png (100%) create mode 100644 omop_alchemy/cdm/base/errors.py create mode 100644 omop_alchemy/cdm/base/event_metadata.py create mode 100644 tests/test_reference_context.py diff --git a/docs/advanced/timelines.md b/docs/advanced/timelines.md index e45a279..482cba0 100644 --- a/docs/advanced/timelines.md +++ b/docs/advanced/timelines.md @@ -26,7 +26,7 @@ The value associated with a clinical event — numeric, concept, string, or none ### `EventMapping` -Declares which ORM fields supply the concept, start/end datetimes, and value for a particular CDM table. `EventMapping.from_model()` derives event identity, source, concept, and start fields from the same stable metadata used by canonical SQL projections. Timeline classes add only their end, value, and display-specific fields. +Declares which ORM fields supply the concept, start/end datetimes, and value for a particular CDM table. `EventMapping.from_model()` derives identity, source, concept and start fields from the shared CDM metadata used by canonical SQL projections. It also infers independent interval endpoints from that metadata: Measurement and Observation alias their one date column in the target API and remain point events in the timeline. Timeline classes add value and display-specific fields. Explicit endpoint strings override inference; explicit `None` disables the corresponding inferred endpoint. ::: omop_alchemy.toolkit.core.timeline.event_timeline.EventMapping diff --git a/docs/static/images/oa-configure.png b/docs/assets/images/oa-configure.png similarity index 100% rename from docs/static/images/oa-configure.png rename to docs/assets/images/oa-configure.png diff --git a/docs/static/images/oa-fulltext.png b/docs/assets/images/oa-fulltext.png similarity index 100% rename from docs/static/images/oa-fulltext.png rename to docs/assets/images/oa-fulltext.png diff --git a/docs/static/images/oa-info.png b/docs/assets/images/oa-info.png similarity index 100% rename from docs/static/images/oa-info.png rename to docs/assets/images/oa-info.png diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 21156e3..20241ca 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -14,7 +14,7 @@ This prompts for connection details (host, dialect, credentials) and schema name The default location for this file is `~/.config/omop/config.toml` -![configure](../static/images/oa-configure.png) +![configure](../assets/images/oa-configure.png) The resulting TOML will look like: @@ -59,7 +59,7 @@ omop-alchemy info This prints the resolved config file path, connection details, and schema. A successful run confirms that OMOP_Alchemy can reach your database. -![info](../static/images/oa-info.png) +![info](../assets/images/oa-info.png) ## Multiple instances diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index e50fb31..d154196 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -119,7 +119,7 @@ If you later reload vocabulary data, rerun: omop-alchemy fulltext populate ``` -![fulltext](../static/images/oa-fulltext.png) +![fulltext](../assets/images/oa-fulltext.png) For the full design and query patterns, see: diff --git a/docs/toolkit/query-contracts.md b/docs/toolkit/query-contracts.md index 26b2f42..1993cda 100644 --- a/docs/toolkit/query-contracts.md +++ b/docs/toolkit/query-contracts.md @@ -376,23 +376,28 @@ selected = selected_modifier_select( ) ``` -The default selection partition includes person and both target identity -columns. If one input contains several modifier categories and selection should -occur separately for each, filter to one category before ranking or add that -category discriminator to `partition_by`. Incomplete target identities are -excluded. The stable source table and modifier ID are always the final -tie-breakers under the default contract. - -`modifier_target_queries(..., diagnostics=True)` returns a second advisory query -covering `missing_target_identity`, `unsupported_target_field`, -`missing_target_event`, and `person_mismatch`. Diagnostics do not change the -valid result. For a filtered caller projection, missing events are input-relative. - -Oncology stage preference composes with this generic selector. Its public -default is pathological, clinical, then unclassified, followed by earliest -time. `StageSelectionSpec.clinical_first()`, -`StageSelectionSpec.chronological_only()`, and a `latest` temporal policy are -explicit query-scoped alternatives. +The default selection partition includes person and both target identity columns. If one input contains several modifier categories and selection should occur separately for each, filter to one category before ranking or add that category discriminator to `partition_by`. Incomplete target identities are excluded. The stable source table and modifier ID are always the final tie-breakers under the default contract. + +`modifier_target_queries(..., diagnostics=True)` returns a second advisory query covering `missing_target_identity`, `unsupported_target_field`, `missing_target_event`, and `person_mismatch`. Diagnostics do not change the valid result. For a caller-supplied selectable, only missing identity and observed person mismatches are reported: a filtered result cannot prove that an event is absent from the underlying table or that its Field is unsupported. + +Diagnostics support inspection and validation independently of selection. Consume SQLAlchemy mappings directly, or convert them with the thin typed adapters. The adapters do not execute queries or alter the valid matches: + +```python +from omop_alchemy.toolkit.core.modifiers import ModifierTargetDiagnostic + +checked = modifier_target_queries( + Measurement, Condition_Occurrence, diagnostics=True, +) +assert checked.diagnostics is not None +diagnostic_rows = session.execute(checked.diagnostics).mappings().all() +typed_diagnostics = [ + ModifierTargetDiagnostic.from_mapping(row) for row in diagnostic_rows +] +``` + +`EpisodeAttachmentDiagnostic` provides the corresponding convenience for attachment diagnostics, as shown above. These are exported downstream validation contracts; their presence does not imply an in-package workflow or a scheduled consumer integration. + +Oncology stage preference composes with this generic selector. Its public default is pathological, clinical, then unclassified, followed by earliest time. `StageSelectionSpec.clinical_first()`, `StageSelectionSpec.chronological_only()`, and a `latest` temporal policy are explicit query-scoped alternatives. ## API reference diff --git a/omop_alchemy/cdm/base/errors.py b/omop_alchemy/cdm/base/errors.py new file mode 100644 index 0000000..8419a72 --- /dev/null +++ b/omop_alchemy/cdm/base/errors.py @@ -0,0 +1,25 @@ +"""Shared error contracts for CDM model metadata validation.""" + +from __future__ import annotations + +from typing import ClassVar + + +class UnsupportedModelError(TypeError): + """Base error carrying the model and reason for unsupported-model failures.""" + + model_kind: ClassVar[str] = "model" + + def __init__( + self, + model: object | None, + reason: str, + *, + message: str | None = None, + ) -> None: + self.model = model + self.reason = reason + if message is None: + name = getattr(model, "__name__", repr(model)) + message = f"{name} is not a supported {self.model_kind}: {reason}" + super().__init__(message) diff --git a/omop_alchemy/cdm/base/event_metadata.py b/omop_alchemy/cdm/base/event_metadata.py new file mode 100644 index 0000000..c59d49c --- /dev/null +++ b/omop_alchemy/cdm/base/event_metadata.py @@ -0,0 +1,70 @@ +"""Database-free event metadata shapes and capability checks.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .errors import UnsupportedModelError +from .modifier_interface import ModifierTargetMixin + + +class UnsupportedClinicalEventModelError(UnsupportedModelError): + """Raised when a model cannot provide a canonical clinical-event projection.""" + + model_kind = "clinical-event model" + + +@dataclass(frozen=True, slots=True) +class ClinicalEventModelSpec: + """Resolved model metadata used to build a canonical event projection.""" + + event_id_column: str + event_concept_id_column: str + event_date_column: str + event_datetime_column: str | None + event_field_concept_id: int + event_source_table: str + event_end_date_column: str | None = None + event_end_datetime_column: str | None = None + + +def _has_complete_event_metadata(model: type[Any]) -> bool: + # A model is eligible to own metadata only when the complete modifier + # contract is present. Partial class attributes would produce a projection + # whose labels look valid while pointing at the wrong source columns. + if not issubclass(model, ModifierTargetMixin): + return False + if any( + not getattr(model, name, None) + for name in ("__event_id_col__", "__concept_id_col__", "__start_date_col__") + ): + return False + try: + model.modifier_field_concept_id() + except NotImplementedError: + return False + return True + + +def _datetime_column_name(model: type[Any], date_column_name: str) -> str | None: + """Return the conventional datetime counterpart only when exposed.""" + if date_column_name.endswith("_date"): + candidate = f"{date_column_name[:-5]}_datetime" + if hasattr(model, candidate): + return candidate + return None + + +def _interval_columns( + model: type[Any], metadata_model: type[ModifierTargetMixin] +) -> tuple[str | None, str | None]: + """Resolve independent endpoints; a start-date alias denotes a point.""" + end_date = getattr(metadata_model, "__end_date_col__", None) + if ( + not isinstance(end_date, str) + or end_date == metadata_model.__start_date_col__ + or not hasattr(model, end_date) + ): + return None, None + return end_date, _datetime_column_name(model, end_date) diff --git a/omop_alchemy/cdm/base/modifier_interface.py b/omop_alchemy/cdm/base/modifier_interface.py index 17644c9..4c2b2ed 100644 --- a/omop_alchemy/cdm/base/modifier_interface.py +++ b/omop_alchemy/cdm/base/modifier_interface.py @@ -15,18 +15,27 @@ class ModifierSourceMixin: Subclasses name those columns once and inherit a common vocabulary, so query code never branches on the physical modifier source. - This is a declarative marker, not a support list. Which models are - accepted as modifier sources stays an explicit allow-list in the toolkit; - wearing this mixin describes a model's shape, it does not enrol it. + Built-in CDM tables put this mixin on the bare Measurement and Observation + classes. Custom mapped sources may also use it when they supply complete + event and link metadata; source support is validated from that metadata, + not restricted to the built-in cached specs. Wearing this mixin does not + enrol a model in the clinical-event or modifier-target registries. The mixin exposes row-level link columns and properties only. Bulk target validation, including person and Field-concept checks, is provided by the toolkit's ``modifier_target_queries`` builder rather than by an implicit ``resolved_target`` lookup on each ORM instance. + + This asymmetry is deliberate: Episode_EventView retains a session-bound + convenience for navigating one existing link, without person validation. + Measurement and Observation instead use set-based target queries for bulk + processing and person/Field/event validation, avoiding implicit per-row + target loads on fact-table instances. """ __abstract__ = True __tablename__: ClassVar[str] + # Source-link metadata names the target identity, not this row's own ID. __modifier_event_id_col__: ClassVar[str] __modifier_field_concept_id_col__: ClassVar[str] @@ -60,10 +69,15 @@ class ModifierTargetMixin: Wearing this mixin describes target-row identity metadata; it does not enrol a class as a clinical event or modifier target in a registry. + + Built-in CDM models place it on analytical Views, keeping the bare tables + lean. That placement convention does not restrict custom mapped sources + from using both source and target metadata without a View/context base. """ __abstract__ = True __tablename__: ClassVar[str] + # Target-self metadata names this row's identity and clinical fields. __event_id_col__: ClassVar[str] __concept_id_col__: ClassVar[str] __start_date_col__: ClassVar[str] diff --git a/omop_alchemy/cdm/base/reference_context.py b/omop_alchemy/cdm/base/reference_context.py index 6948abe..d9bdd31 100644 --- a/omop_alchemy/cdm/base/reference_context.py +++ b/omop_alchemy/cdm/base/reference_context.py @@ -1,34 +1,16 @@ - from __future__ import annotations import sqlalchemy.orm as so import sqlalchemy as sa +from typing import Any -class ReferenceContext: - - """ - `ReferenceContext` - - A helper base class for defining **read-only reference relationships**. - - This class is purely structural: it resolves foreign keys - into reference tables (Domain, Vocabulary, ConceptClass, etc.) - with explicit join conditions. - - These relationships are: - - `viewonly=True` - - explicitly joined - - loaded using `selectin` (batched eager loading) - - defined outside the core table - - deterministic projections of foreign keys - - They are intended for: +class ReferenceContext: + """Read-only reference relationships for inspection and analytics. - - inspection - - analytics - - debugging - - view-level navigation - — **not** for ETL or mutation. + Analytical contexts define these relationships outside the bare ETL table. + Explicit joins resolve local references to single-column target identity, + with viewonly=True and selectin loading for batched navigation. Assigning + a related object does not mutate or populate the local reference column. """ @classmethod @@ -37,19 +19,45 @@ def _reference_relationship( *, target: str, local_fk: str, - remote_pk: str, + remote_pk: str | None = None, uselist: bool = False, ): + """Join a local reference to its target's single mapped primary key. + + Target metadata is resolved only when the join is configured, preserving + declaration/import order. Legacy remote_pk arguments are accepted but + must name the derived primary-key attribute. + """ return so.declared_attr( lambda cls_: so.relationship( target, - primaryjoin=lambda: getattr(cls_, local_fk) == getattr( - sa.inspect(cls_).registry._class_registry[target], - remote_pk, + primaryjoin=lambda: ( + getattr(cls_, local_fk) + == cls._reference_primary_key(cls_, target, remote_pk) ), foreign_keys=lambda: getattr(cls_, local_fk), viewonly=True, lazy="selectin", uselist=uselist, ) - ) \ No newline at end of file + ) + + @staticmethod + def _reference_primary_key( + model: type[Any], target: str, remote_pk: str | None + ) -> so.InstrumentedAttribute[Any]: + target_model = sa.inspect(model).registry._class_registry.get(target) + if not isinstance(target_model, type): + raise ValueError(f"reference target {target!r} is missing or ambiguous") + mapper = sa.inspect(target_model) + if len(mapper.primary_key) != 1: + raise ValueError( + f"reference target {target!r} must have exactly one mapped primary key" + ) + attribute = mapper.get_property_by_column(mapper.primary_key[0]).class_attribute + if remote_pk is not None and remote_pk != attribute.key: + raise ValueError( + f"reference target {target!r} primary key is {attribute.key!r}, " + f"not {remote_pk!r}" + ) + return attribute diff --git a/omop_alchemy/cdm/model/clinical/condition_occurrence.py b/omop_alchemy/cdm/model/clinical/condition_occurrence.py index e9160ca..65bd250 100644 --- a/omop_alchemy/cdm/model/clinical/condition_occurrence.py +++ b/omop_alchemy/cdm/model/clinical/condition_occurrence.py @@ -49,10 +49,10 @@ class Condition_Occurrence( condition_status_concept_id: so.Mapped[Optional[int]] = so.mapped_column(sa.ForeignKey("concept.concept_id")) class Condition_OccurrenceContext(ReferenceContext): - condition_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept", local_fk="condition_concept_id", remote_pk="concept_id") # type: ignore[assignment] - condition_type: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept", local_fk="condition_type_concept_id", remote_pk="concept_id") # type: ignore[assignment] - condition_source_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept", local_fk="condition_source_concept_id", remote_pk="concept_id") # type: ignore[assignment] - condition_status: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept", local_fk="condition_status_concept_id", remote_pk="concept_id") # type: ignore[assignment] + condition_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept", local_fk="condition_concept_id") # type: ignore[assignment] + condition_type: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept", local_fk="condition_type_concept_id") # type: ignore[assignment] + condition_source_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept", local_fk="condition_source_concept_id") # type: ignore[assignment] + condition_status: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept", local_fk="condition_status_concept_id") # type: ignore[assignment] @declared_attr def visit_occurrence(cls) -> so.Mapped[Optional["Visit_Occurrence"]]: diff --git a/omop_alchemy/cdm/model/clinical/death.py b/omop_alchemy/cdm/model/clinical/death.py index 85daeed..602af29 100644 --- a/omop_alchemy/cdm/model/clinical/death.py +++ b/omop_alchemy/cdm/model/clinical/death.py @@ -35,9 +35,9 @@ class Death(CDMTableBase, Base): class DeathContext(ReferenceContext): - person: so.Mapped["Person"] = ReferenceContext._reference_relationship(target="Person",local_fk="person_id",remote_pk="person_id") # type: ignore[assignment] - death_type_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept", local_fk="death_type_concept_id", remote_pk="concept_id") # type: ignore[assignment] - cause_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept", local_fk="cause_concept_id", remote_pk="concept_id") # type: ignore[assignment] + person: so.Mapped["Person"] = ReferenceContext._reference_relationship(target="Person",local_fk="person_id") # type: ignore[assignment] + death_type_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept", local_fk="death_type_concept_id") # type: ignore[assignment] + cause_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept", local_fk="cause_concept_id") # type: ignore[assignment] class DeathView(Death, DeathContext, DomainValidationMixin): __tablename__ = "death" diff --git a/omop_alchemy/cdm/model/clinical/device_exposure.py b/omop_alchemy/cdm/model/clinical/device_exposure.py index 2a5175b..fb4504a 100644 --- a/omop_alchemy/cdm/model/clinical/device_exposure.py +++ b/omop_alchemy/cdm/model/clinical/device_exposure.py @@ -66,54 +66,49 @@ class Device_ExposureContext(ReferenceContext): """Read-only analytical relationships for a Device Exposure row.""" person: so.Mapped["Person"] = ReferenceContext._reference_relationship( - target="Person", local_fk="person_id", remote_pk="person_id" + target="Person", local_fk="person_id" ) # type: ignore[assignment] device_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship( - target="Concept", local_fk="device_concept_id", remote_pk="concept_id" + target="Concept", local_fk="device_concept_id" ) # type: ignore[assignment] device_type_concept: so.Mapped["Concept"] = ( ReferenceContext._reference_relationship( target="Concept", local_fk="device_type_concept_id", - remote_pk="concept_id", ) ) # type: ignore[assignment] device_source_concept: so.Mapped[Optional["Concept"]] = ( ReferenceContext._reference_relationship( target="Concept", local_fk="device_source_concept_id", - remote_pk="concept_id", ) ) # type: ignore[assignment] unit_concept: so.Mapped[Optional["Concept"]] = ( ReferenceContext._reference_relationship( - target="Concept", local_fk="unit_concept_id", remote_pk="concept_id" + target="Concept", local_fk="unit_concept_id" ) ) # type: ignore[assignment] unit_source_concept: so.Mapped[Optional["Concept"]] = ( ReferenceContext._reference_relationship( target="Concept", local_fk="unit_source_concept_id", - remote_pk="concept_id", ) ) # type: ignore[assignment] provider: so.Mapped[Optional["Provider"]] = ( ReferenceContext._reference_relationship( - target="Provider", local_fk="provider_id", remote_pk="provider_id" + target="Provider", local_fk="provider_id" ) ) # type: ignore[assignment] visit_occurrence: so.Mapped[Optional["Visit_Occurrence"]] = ( ReferenceContext._reference_relationship( target="Visit_Occurrence", local_fk="visit_occurrence_id", - remote_pk="visit_occurrence_id", ) ) # type: ignore[assignment] visit_detail: so.Mapped[Optional["Visit_Detail"]] = ( ReferenceContext._reference_relationship( target="Visit_Detail", local_fk="visit_detail_id", - remote_pk="visit_detail_id", ) ) # type: ignore[assignment] diff --git a/omop_alchemy/cdm/model/clinical/drug_exposure.py b/omop_alchemy/cdm/model/clinical/drug_exposure.py index f27ba4f..c43bbf8 100644 --- a/omop_alchemy/cdm/model/clinical/drug_exposure.py +++ b/omop_alchemy/cdm/model/clinical/drug_exposure.py @@ -63,10 +63,10 @@ class Drug_Exposure( class Drug_ExposureContext(ReferenceContext): - drug_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept", local_fk="drug_concept_id", remote_pk="concept_id") # type: ignore[assignment] - drug_type: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept", local_fk="drug_type_concept_id", remote_pk="concept_id") # type: ignore[assignment] - route: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept", local_fk="route_concept_id", remote_pk="concept_id") # type: ignore[assignment] - drug_source_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept", local_fk="drug_source_concept_id", remote_pk="concept_id") # type: ignore[assignment] + drug_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept", local_fk="drug_concept_id") # type: ignore[assignment] + drug_type: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept", local_fk="drug_type_concept_id") # type: ignore[assignment] + route: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept", local_fk="route_concept_id") # type: ignore[assignment] + drug_source_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept", local_fk="drug_source_concept_id") # type: ignore[assignment] class Drug_ExposureView( diff --git a/omop_alchemy/cdm/model/clinical/event_metadata.py b/omop_alchemy/cdm/model/clinical/event_metadata.py index 02639b0..904d9ce 100644 --- a/omop_alchemy/cdm/model/clinical/event_metadata.py +++ b/omop_alchemy/cdm/model/clinical/event_metadata.py @@ -11,7 +11,14 @@ from types import MappingProxyType from typing import Any, Mapping -from omop_alchemy.cdm.base import ModifierTargetMixin +from omop_alchemy.cdm.base import ModifierSourceMixin, ModifierTargetMixin +from omop_alchemy.cdm.base.event_metadata import ( + ClinicalEventModelSpec, + UnsupportedClinicalEventModelError, + _datetime_column_name, + _has_complete_event_metadata, + _interval_columns, +) from .condition_occurrence import Condition_Occurrence, Condition_OccurrenceView from .device_exposure import Device_Exposure, Device_ExposureView @@ -35,9 +42,9 @@ # therefore deliberately absent from this registry. ) -_STRUCTURAL_MODIFIER_TARGETS: tuple[tuple[type[Any], type[ModifierTargetMixin]], ...] = ( - (Episode, EpisodeView), -) +_STRUCTURAL_MODIFIER_TARGETS: tuple[ + tuple[type[Any], type[ModifierTargetMixin]], ... +] = ((Episode, EpisodeView),) def _validate_unique_target_keys( @@ -86,7 +93,10 @@ def _validate_unique_target_keys( STRUCTURAL_MODIFIER_TARGETS_BY_TABLE: Mapping[str, type[ModifierTargetMixin]] = ( MappingProxyType( - {source.__tablename__: target for source, target in _STRUCTURAL_MODIFIER_TARGETS} + { + source.__tablename__: target + for source, target in _STRUCTURAL_MODIFIER_TARGETS + } ) ) @@ -100,3 +110,81 @@ def clinical_event_target_for_table( ) -> type[ModifierTargetMixin] | None: """Return the registered analytical event target for a bare CDM table.""" return CLINICAL_EVENT_TARGETS_BY_TABLE.get(table_name) + + +def _metadata_candidate(model: type[Any]) -> type[ModifierTargetMixin] | None: + # An explicitly supplied registered event view (or its domain-specific + # subclass) owns its metadata. Bare CDM tables use the registered CDM view + # for that table; unrelated subclasses are never discovered by walking + # Python's import-dependent subclass graph. + if _has_complete_event_metadata(model): + table_name = getattr(model, "__tablename__", None) + registered_view = clinical_event_target_for_table(str(table_name)) + if registered_view is not None and issubclass(model, registered_view): + return model + # Modifier sources outside the built-in event set are intentionally + # supported by the canonical modifier projection. They carry the same + # event-shaped metadata, but do not become episode-resolvable events. + if issubclass(model, ModifierSourceMixin): + return model + return None + # Lean CDM models intentionally do not carry modifier metadata. Resolve + # their table through the configured analytical view without changing the + # class used to read scalar event rows. + table_name = getattr(model, "__tablename__", None) + return clinical_event_target_for_table(str(table_name)) + + +def clinical_event_model_spec(model: type[Any]) -> ClinicalEventModelSpec: + """Resolve the event metadata for an ORM model without accessing a database.""" + # Resolve metadata before building SQL so unsupported models fail at query + # construction, rather than producing a partially shaped union at runtime. + if not isinstance(model, type) or not hasattr(model, "__table__"): + raise UnsupportedClinicalEventModelError( + model, "expected a mapped ORM model class" + ) + + metadata_model = _metadata_candidate(model) + if metadata_model is None: + raise UnsupportedClinicalEventModelError( + model, + "no complete ModifierTargetMixin metadata is available", + ) + + event_id_column = metadata_model.__event_id_col__ + event_concept_id_column = metadata_model.__concept_id_col__ + event_date_column = metadata_model.__start_date_col__ + required_columns = ( + event_id_column, + event_concept_id_column, + event_date_column, + "person_id", + ) + # Metadata may come from a sibling view, so validate the physical source + # model separately before using the view's canonical field-concept marker. + missing = tuple(name for name in required_columns if not hasattr(model, name)) + if missing: + raise UnsupportedClinicalEventModelError( + model, + f"missing required columns: {', '.join(missing)}", + ) + + try: + field_concept_id = metadata_model.modifier_field_concept_id() + except NotImplementedError as error: + raise UnsupportedClinicalEventModelError( + model, + "modifier Field concept is not defined", + ) from error + + end_date, end_datetime = _interval_columns(model, metadata_model) + return ClinicalEventModelSpec( + event_id_column=event_id_column, + event_concept_id_column=event_concept_id_column, + event_date_column=event_date_column, + event_datetime_column=_datetime_column_name(model, event_date_column), + event_field_concept_id=field_concept_id, + event_source_table=metadata_model.modifier_target_table(), + event_end_date_column=end_date, + event_end_datetime_column=end_datetime, + ) diff --git a/omop_alchemy/cdm/model/clinical/measurement.py b/omop_alchemy/cdm/model/clinical/measurement.py index b1e9fc3..d26bab2 100644 --- a/omop_alchemy/cdm/model/clinical/measurement.py +++ b/omop_alchemy/cdm/model/clinical/measurement.py @@ -93,49 +93,45 @@ class MeasurementContext(ReferenceContext): """Read-only analytical relationships for a Measurement row.""" person: so.Mapped["Person"] = ReferenceContext._reference_relationship( - target="Person", local_fk="person_id", remote_pk="person_id" + target="Person", local_fk="person_id" ) # type: ignore[assignment] measurement_concept: so.Mapped["Concept"] = ( ReferenceContext._reference_relationship( - target="Concept", local_fk="measurement_concept_id", remote_pk="concept_id" + target="Concept", local_fk="measurement_concept_id" ) ) # type: ignore[assignment] measurement_type_concept: so.Mapped["Concept"] = ( ReferenceContext._reference_relationship( target="Concept", local_fk="measurement_type_concept_id", - remote_pk="concept_id", ) ) # type: ignore[assignment] unit_concept: so.Mapped[Optional["Concept"]] = ( ReferenceContext._reference_relationship( - target="Concept", local_fk="unit_concept_id", remote_pk="concept_id" + target="Concept", local_fk="unit_concept_id" ) ) # type: ignore[assignment] unit_source_concept: so.Mapped[Optional["Concept"]] = ( ReferenceContext._reference_relationship( target="Concept", local_fk="unit_source_concept_id", - remote_pk="concept_id", ) ) # type: ignore[assignment] provider: so.Mapped[Optional["Provider"]] = ( ReferenceContext._reference_relationship( - target="Provider", local_fk="provider_id", remote_pk="provider_id" + target="Provider", local_fk="provider_id" ) ) # type: ignore[assignment] visit_occurrence: so.Mapped[Optional["Visit_Occurrence"]] = ( ReferenceContext._reference_relationship( target="Visit_Occurrence", local_fk="visit_occurrence_id", - remote_pk="visit_occurrence_id", ) ) # type: ignore[assignment] visit_detail: so.Mapped[Optional["Visit_Detail"]] = ( ReferenceContext._reference_relationship( target="Visit_Detail", local_fk="visit_detail_id", - remote_pk="visit_detail_id", ) ) # type: ignore[assignment] @@ -153,6 +149,8 @@ class MeasurementView( __event_id_col__ = "measurement_id" __concept_id_col__ = "measurement_concept_id" __start_date_col__ = "measurement_date" + # One date column: the target API aliases it; interval metadata treats + # equal start/end declarations as a point with no independent endpoint. __end_date_col__ = "measurement_date" __type_concept_id_col__ = "measurement_type_concept_id" __expected_domains__ = { diff --git a/omop_alchemy/cdm/model/clinical/observation.py b/omop_alchemy/cdm/model/clinical/observation.py index a0fc0a7..b7f4715 100644 --- a/omop_alchemy/cdm/model/clinical/observation.py +++ b/omop_alchemy/cdm/model/clinical/observation.py @@ -85,42 +85,39 @@ class ObservationContext(ReferenceContext): """Read-only analytical relationships for an Observation row.""" person: so.Mapped["Person"] = ReferenceContext._reference_relationship( - target="Person", local_fk="person_id", remote_pk="person_id" + target="Person", local_fk="person_id" ) # type: ignore[assignment] observation_concept: so.Mapped["Concept"] = ( ReferenceContext._reference_relationship( - target="Concept", local_fk="observation_concept_id", remote_pk="concept_id" + target="Concept", local_fk="observation_concept_id" ) ) # type: ignore[assignment] observation_type_concept: so.Mapped["Concept"] = ( ReferenceContext._reference_relationship( target="Concept", local_fk="observation_type_concept_id", - remote_pk="concept_id", ) ) # type: ignore[assignment] unit_concept: so.Mapped[Optional["Concept"]] = ( ReferenceContext._reference_relationship( - target="Concept", local_fk="unit_concept_id", remote_pk="concept_id" + target="Concept", local_fk="unit_concept_id" ) ) # type: ignore[assignment] provider: so.Mapped[Optional["Provider"]] = ( ReferenceContext._reference_relationship( - target="Provider", local_fk="provider_id", remote_pk="provider_id" + target="Provider", local_fk="provider_id" ) ) # type: ignore[assignment] visit_occurrence: so.Mapped[Optional["Visit_Occurrence"]] = ( ReferenceContext._reference_relationship( target="Visit_Occurrence", local_fk="visit_occurrence_id", - remote_pk="visit_occurrence_id", ) ) # type: ignore[assignment] visit_detail: so.Mapped[Optional["Visit_Detail"]] = ( ReferenceContext._reference_relationship( target="Visit_Detail", local_fk="visit_detail_id", - remote_pk="visit_detail_id", ) ) # type: ignore[assignment] @@ -138,6 +135,8 @@ class ObservationView( __event_id_col__ = "observation_id" __concept_id_col__ = "observation_concept_id" __start_date_col__ = "observation_date" + # One date column: the target API aliases it; interval metadata treats + # equal start/end declarations as a point with no independent endpoint. __end_date_col__ = "observation_date" __type_concept_id_col__ = "observation_type_concept_id" __expected_domains__ = { diff --git a/omop_alchemy/cdm/model/clinical/person.py b/omop_alchemy/cdm/model/clinical/person.py index a22b05b..d5c5153 100644 --- a/omop_alchemy/cdm/model/clinical/person.py +++ b/omop_alchemy/cdm/model/clinical/person.py @@ -65,12 +65,12 @@ def __repr__(self) -> str: return f"" class PersonContext(ReferenceContext): - gender: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept",local_fk="gender_concept_id",remote_pk="concept_id") # type: ignore[assignment] - race: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept",local_fk="race_concept_id",remote_pk="concept_id") # type: ignore[assignment] - ethnicity: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept",local_fk="ethnicity_concept_id",remote_pk="concept_id") # type: ignore[assignment] - location: so.Mapped["Location"] = ReferenceContext._reference_relationship(target="Location",local_fk="location_id",remote_pk="location_id") # type: ignore[assignment] - provider: so.Mapped["Provider"] = ReferenceContext._reference_relationship(target="Provider",local_fk="provider_id",remote_pk="provider_id") # type: ignore[assignment] - care_site: so.Mapped["Care_Site"] = ReferenceContext._reference_relationship(target="Care_Site",local_fk="care_site_id",remote_pk="care_site_id") # type: ignore[assignment] + gender: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept",local_fk="gender_concept_id") # type: ignore[assignment] + race: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept",local_fk="race_concept_id") # type: ignore[assignment] + ethnicity: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept",local_fk="ethnicity_concept_id") # type: ignore[assignment] + location: so.Mapped["Location"] = ReferenceContext._reference_relationship(target="Location",local_fk="location_id") # type: ignore[assignment] + provider: so.Mapped["Provider"] = ReferenceContext._reference_relationship(target="Provider",local_fk="provider_id") # type: ignore[assignment] + care_site: so.Mapped["Care_Site"] = ReferenceContext._reference_relationship(target="Care_Site",local_fk="care_site_id") # type: ignore[assignment] @declared_attr def death(cls) -> so.Mapped[Optional["Death"]]: diff --git a/omop_alchemy/cdm/model/clinical/procedure_occurrence.py b/omop_alchemy/cdm/model/clinical/procedure_occurrence.py index 4dfb65b..f911489 100644 --- a/omop_alchemy/cdm/model/clinical/procedure_occurrence.py +++ b/omop_alchemy/cdm/model/clinical/procedure_occurrence.py @@ -55,40 +55,34 @@ class Procedure_OccurrenceContext(ReferenceContext): person: so.Mapped["Person"] = ReferenceContext._reference_relationship( target="Person", local_fk="person_id", - remote_pk="person_id", ) # type: ignore[assignment] procedure_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship( target="Concept", local_fk="procedure_concept_id", - remote_pk="concept_id", ) # type: ignore[assignment] procedure_type_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship( target="Concept", local_fk="procedure_type_concept_id", - remote_pk="concept_id", ) # type: ignore[assignment] modifier_concept: so.Mapped[Optional["Concept"]] = ( ReferenceContext._reference_relationship( target="Concept", local_fk="modifier_concept_id", - remote_pk="concept_id", ) ) # type: ignore[assignment] provider: so.Mapped[Optional["Provider"]] = ReferenceContext._reference_relationship( target="Provider", local_fk="provider_id", - remote_pk="provider_id", ) # type: ignore[assignment] visit_occurrence: so.Mapped[Optional["Visit_Occurrence"]] = ( ReferenceContext._reference_relationship( target="Visit_Occurrence", local_fk="visit_occurrence_id", - remote_pk="visit_occurrence_id", ) ) # type: ignore[assignment] @@ -96,7 +90,6 @@ class Procedure_OccurrenceContext(ReferenceContext): ReferenceContext._reference_relationship( target="Visit_Detail", local_fk="visit_detail_id", - remote_pk="visit_detail_id", ) ) # type: ignore[assignment] @@ -104,7 +97,6 @@ class Procedure_OccurrenceContext(ReferenceContext): ReferenceContext._reference_relationship( target="Concept", local_fk="procedure_source_concept_id", - remote_pk="concept_id", ) ) # type: ignore[assignment] diff --git a/omop_alchemy/cdm/model/health_system/visit_occurrence.py b/omop_alchemy/cdm/model/health_system/visit_occurrence.py index 4167219..409b022 100644 --- a/omop_alchemy/cdm/model/health_system/visit_occurrence.py +++ b/omop_alchemy/cdm/model/health_system/visit_occurrence.py @@ -62,9 +62,9 @@ def __repr__(self) -> str: class VisitContext(ReferenceContext): - person: so.Mapped["Person"] = ReferenceContext._reference_relationship(target="Person", local_fk="person_id", remote_pk="person_id",) # type: ignore[assignment] - provider: so.Mapped["Provider"] = ReferenceContext._reference_relationship(target="Provider",local_fk="provider_id",remote_pk="provider_id",) # type: ignore[assignment] - care_site: so.Mapped["Care_Site"] = ReferenceContext._reference_relationship(target="Care_Site",local_fk="care_site_id",remote_pk="care_site_id",) # type: ignore[assignment] + person: so.Mapped["Person"] = ReferenceContext._reference_relationship(target="Person", local_fk="person_id",) # type: ignore[assignment] + provider: so.Mapped["Provider"] = ReferenceContext._reference_relationship(target="Provider",local_fk="provider_id",) # type: ignore[assignment] + care_site: so.Mapped["Care_Site"] = ReferenceContext._reference_relationship(target="Care_Site",local_fk="care_site_id",) # type: ignore[assignment] @declared_attr def procedure_providers(cls) -> so.Mapped[list["Provider"]]: diff --git a/omop_alchemy/cdm/model/structural/episode.py b/omop_alchemy/cdm/model/structural/episode.py index befc498..eba6cfe 100644 --- a/omop_alchemy/cdm/model/structural/episode.py +++ b/omop_alchemy/cdm/model/structural/episode.py @@ -60,11 +60,11 @@ def __repr__(self) -> str: class EpisodeContext(ReferenceContext): __table__: ClassVar[sa.Table] - person: so.Mapped["Person"] = ReferenceContext._reference_relationship(target="Person",local_fk="person_id",remote_pk="person_id") # type: ignore[assignment] - episode_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept",local_fk="episode_concept_id",remote_pk="concept_id") # type: ignore[assignment] - episode_object_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept",local_fk="episode_object_concept_id",remote_pk="concept_id") # type: ignore[assignment] - episode_type_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept",local_fk="episode_type_concept_id",remote_pk="concept_id") # type: ignore[assignment] - #parent_episode: so.Mapped[Optional["Episode"]] = ReferenceContext._reference_relationship(target="Episode",local_fk="episode_parent_id",remote_pk="episode_id") # type: ignore[assignment] + person: so.Mapped["Person"] = ReferenceContext._reference_relationship(target="Person",local_fk="person_id") # type: ignore[assignment] + episode_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept",local_fk="episode_concept_id") # type: ignore[assignment] + episode_object_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept",local_fk="episode_object_concept_id") # type: ignore[assignment] + episode_type_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship(target="Concept",local_fk="episode_type_concept_id") # type: ignore[assignment] + #parent_episode: so.Mapped[Optional["Episode"]] = ReferenceContext._reference_relationship(target="Episode",local_fk="episode_parent_id") # type: ignore[assignment] @declared_attr def episode_events(cls: type['HasEpisodeId']) -> so.Mapped[List["Episode_EventView"]]: diff --git a/omop_alchemy/cdm/model/structural/episode_event.py b/omop_alchemy/cdm/model/structural/episode_event.py index 121f35d..f700aa7 100644 --- a/omop_alchemy/cdm/model/structural/episode_event.py +++ b/omop_alchemy/cdm/model/structural/episode_event.py @@ -49,12 +49,10 @@ class Episode_EventContext(ReferenceContext): episode: so.Mapped["Episode"] = ReferenceContext._reference_relationship( target="Episode", local_fk="episode_id", - remote_pk="episode_id", ) # type: ignore[assignment] event_field: so.Mapped["Concept"] = ReferenceContext._reference_relationship( target="Concept", local_fk="episode_event_field_concept_id", - remote_pk="concept_id", ) # type: ignore[assignment] @@ -98,8 +96,13 @@ def resolved_event_class(self) -> Type[Any] | None: @cached_property def resolved_event(self) -> Any | None: """ - Resolve EVENT_ID to concrete OMOP row. - Cached per-instance. + Navigate one link to its concrete OMOP row, cached per instance. + + This existing convenience is deliberately session-bound: it returns + None without an attached session or known target class, and session.get + may load a target individually. It does not validate person identity. + Measurement/Observation intentionally have no equivalent implicit + lookup; use the bulk target/attachment queries for validated links. """ session = so.object_session(self) cls = self.resolved_event_class diff --git a/omop_alchemy/cdm/model/unstructured/note.py b/omop_alchemy/cdm/model/unstructured/note.py index 369f97c..48e9868 100644 --- a/omop_alchemy/cdm/model/unstructured/note.py +++ b/omop_alchemy/cdm/model/unstructured/note.py @@ -54,44 +54,37 @@ class NoteContext(ReferenceContext): person: so.Mapped["Person"] = ReferenceContext._reference_relationship( target="Person", local_fk="person_id", - remote_pk="person_id", ) # type: ignore[assignment] note_type_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship( target="Concept", local_fk="note_type_concept_id", - remote_pk="concept_id", ) # type: ignore[assignment] note_class_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship( target="Concept", local_fk="note_class_concept_id", - remote_pk="concept_id", ) # type: ignore[assignment] encoding_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship( target="Concept", local_fk="encoding_concept_id", - remote_pk="concept_id", ) # type: ignore[assignment] language_concept: so.Mapped["Concept"] = ReferenceContext._reference_relationship( target="Concept", local_fk="language_concept_id", - remote_pk="concept_id", ) # type: ignore[assignment] provider: so.Mapped[Optional["Provider"]] = ReferenceContext._reference_relationship( target="Provider", local_fk="provider_id", - remote_pk="provider_id", ) # type: ignore[assignment] visit_occurrence: so.Mapped[Optional["Visit_Occurrence"]] = ( ReferenceContext._reference_relationship( target="Visit_Occurrence", local_fk="visit_occurrence_id", - remote_pk="visit_occurrence_id", ) ) # type: ignore[assignment] @@ -99,7 +92,6 @@ class NoteContext(ReferenceContext): ReferenceContext._reference_relationship( target="Visit_Detail", local_fk="visit_detail_id", - remote_pk="visit_detail_id", ) ) # type: ignore[assignment] @@ -107,7 +99,6 @@ class NoteContext(ReferenceContext): ReferenceContext._reference_relationship( target="Concept", local_fk="note_event_field_concept_id", - remote_pk="concept_id", ) ) # type: ignore[assignment] diff --git a/omop_alchemy/cdm/model/vocabulary/concept.py b/omop_alchemy/cdm/model/vocabulary/concept.py index 5bab7ec..89af3e0 100644 --- a/omop_alchemy/cdm/model/vocabulary/concept.py +++ b/omop_alchemy/cdm/model/vocabulary/concept.py @@ -104,9 +104,9 @@ class ConceptContext(ReferenceContext): foreign keys into reference tables and hierarchy navigation. """ - domain: so.Mapped["Domain"] = ReferenceContext._reference_relationship(target="Domain",local_fk="domain_id",remote_pk="domain_id") # type: ignore[assignment] - vocabulary: so.Mapped["Vocabulary"] = ReferenceContext._reference_relationship(target="Vocabulary",local_fk="vocabulary_id",remote_pk="vocabulary_id") # type: ignore[assignment] - concept_class: so.Mapped["Concept_Class"] = ReferenceContext._reference_relationship(target="Concept_Class",local_fk="concept_class_id",remote_pk="concept_class_id") # type: ignore[assignment] + domain: so.Mapped["Domain"] = ReferenceContext._reference_relationship(target="Domain",local_fk="domain_id") # type: ignore[assignment] + vocabulary: so.Mapped["Vocabulary"] = ReferenceContext._reference_relationship(target="Vocabulary",local_fk="vocabulary_id") # type: ignore[assignment] + concept_class: so.Mapped["Concept_Class"] = ReferenceContext._reference_relationship(target="Concept_Class",local_fk="concept_class_id") # type: ignore[assignment] @declared_attr def outgoing_relationships(cls) -> so.Mapped[List["Concept_Relationship"]]: diff --git a/omop_alchemy/toolkit/core/errors.py b/omop_alchemy/toolkit/core/errors.py index b399c54..b2bc5b1 100644 --- a/omop_alchemy/toolkit/core/errors.py +++ b/omop_alchemy/toolkit/core/errors.py @@ -1,25 +1,3 @@ -"""Shared error contracts for toolkit model validation.""" +"""Compatibility import for shared model-validation errors.""" -from __future__ import annotations - -from typing import ClassVar - - -class UnsupportedModelError(TypeError): - """Base error carrying the model and reason for unsupported-model failures.""" - - model_kind: ClassVar[str] = "model" - - def __init__( - self, - model: object | None, - reason: str, - *, - message: str | None = None, - ) -> None: - self.model = model - self.reason = reason - if message is None: - name = getattr(model, "__name__", repr(model)) - message = f"{name} is not a supported {self.model_kind}: {reason}" - super().__init__(message) +from omop_alchemy.cdm.base.errors import UnsupportedModelError as UnsupportedModelError diff --git a/omop_alchemy/toolkit/core/events/projections.py b/omop_alchemy/toolkit/core/events/projections.py index 764bbc3..5eb7087 100644 --- a/omop_alchemy/toolkit/core/events/projections.py +++ b/omop_alchemy/toolkit/core/events/projections.py @@ -2,142 +2,22 @@ from __future__ import annotations -from dataclasses import dataclass from typing import Any import sqlalchemy as sa -from omop_alchemy.cdm.base import ModifierSourceMixin, ModifierTargetMixin +from omop_alchemy.cdm.base.event_metadata import ( + ClinicalEventModelSpec as ClinicalEventModelSpec, + UnsupportedClinicalEventModelError as UnsupportedClinicalEventModelError, +) from omop_alchemy.cdm.model.clinical.event_metadata import ( - clinical_event_target_for_table, + clinical_event_model_spec as clinical_event_model_spec, ) from omop_alchemy.toolkit._utils import _nullable_column, _select_or_union_all -from omop_alchemy.toolkit.core.errors import UnsupportedModelError from .contracts import ClinicalEventColumn -class UnsupportedClinicalEventModelError(UnsupportedModelError): - """Raised when a model cannot provide a canonical clinical-event projection.""" - - model_kind = "clinical-event model" - - -@dataclass(frozen=True, slots=True) -class ClinicalEventModelSpec: - """Resolved model metadata used to build a canonical event projection.""" - - event_id_column: str - event_concept_id_column: str - event_date_column: str - event_datetime_column: str | None - event_field_concept_id: int - event_source_table: str - - -def _has_complete_event_metadata(model: type[Any]) -> bool: - # A model is eligible to own metadata only when the complete modifier - # contract is present. Partial class attributes would produce a projection - # whose labels look valid while pointing at the wrong source columns. - if not issubclass(model, ModifierTargetMixin): - return False - if any( - not getattr(model, name, None) - for name in ("__event_id_col__", "__concept_id_col__", "__start_date_col__") - ): - return False - try: - model.modifier_field_concept_id() - except NotImplementedError: - return False - return True - - -def _metadata_candidate(model: type[Any]) -> type[ModifierTargetMixin] | None: - # An explicitly supplied registered event view (or its domain-specific - # subclass) owns its metadata. Bare CDM tables use the registered CDM view - # for that table; unrelated subclasses are never discovered by walking - # Python's import-dependent subclass graph. - if _has_complete_event_metadata(model): - table_name = getattr(model, "__tablename__", None) - registered_view = clinical_event_target_for_table(str(table_name)) - if registered_view is not None and issubclass(model, registered_view): - return model - # Modifier sources outside the built-in event set are intentionally - # supported by the canonical modifier projection. They carry the same - # event-shaped metadata, but do not become episode-resolvable events. - if issubclass(model, ModifierSourceMixin): - return model - return None - # Lean CDM models intentionally do not carry modifier metadata. Resolve - # their table through the configured analytical view without changing the - # class used to read scalar event rows. - table_name = getattr(model, "__tablename__", None) - return clinical_event_target_for_table(str(table_name)) - - -def _datetime_column_name(model: type[Any], date_column_name: str) -> str | None: - # Datetime is optional in OMOP event tables. Derive the conventional name - # only when the mapped model actually exposes that column. - if date_column_name.endswith("_date"): - candidate = f"{date_column_name[:-5]}_datetime" - if hasattr(model, candidate): - return candidate - return None - - -def clinical_event_model_spec(model: type[Any]) -> ClinicalEventModelSpec: - """Resolve the event metadata for an ORM model without accessing a database.""" - # Resolve metadata before building SQL so unsupported models fail at query - # construction, rather than producing a partially shaped union at runtime. - if not isinstance(model, type) or not hasattr(model, "__table__"): - raise UnsupportedClinicalEventModelError( - model, "expected a mapped ORM model class" - ) - - metadata_model = _metadata_candidate(model) - if metadata_model is None: - raise UnsupportedClinicalEventModelError( - model, - "no complete ModifierTargetMixin metadata is available", - ) - - event_id_column = metadata_model.__event_id_col__ - event_concept_id_column = metadata_model.__concept_id_col__ - event_date_column = metadata_model.__start_date_col__ - required_columns = ( - event_id_column, - event_concept_id_column, - event_date_column, - "person_id", - ) - # Metadata may come from a sibling view, so validate the physical source - # model separately before using the view's canonical field-concept marker. - missing = tuple(name for name in required_columns if not hasattr(model, name)) - if missing: - raise UnsupportedClinicalEventModelError( - model, - f"missing required columns: {', '.join(missing)}", - ) - - try: - field_concept_id = metadata_model.modifier_field_concept_id() - except NotImplementedError as error: - raise UnsupportedClinicalEventModelError( - model, - "modifier Field concept is not defined", - ) from error - - return ClinicalEventModelSpec( - event_id_column=event_id_column, - event_concept_id_column=event_concept_id_column, - event_date_column=event_date_column, - event_datetime_column=_datetime_column_name(model, event_date_column), - event_field_concept_id=field_concept_id, - event_source_table=metadata_model.modifier_target_table(), - ) - - def canonical_event_projection( model: type[Any], *, diff --git a/omop_alchemy/toolkit/core/modifiers/metadata.py b/omop_alchemy/toolkit/core/modifiers/metadata.py index a19fd51..6531d5c 100644 --- a/omop_alchemy/toolkit/core/modifiers/metadata.py +++ b/omop_alchemy/toolkit/core/modifiers/metadata.py @@ -16,10 +16,12 @@ from omop_alchemy.cdm.base import ModifierSourceMixin from omop_alchemy.cdm.model.clinical import Measurement, Observation -from omop_alchemy.cdm.model.clinical.event_metadata import MODIFIER_TARGETS_BY_TABLE -from omop_alchemy.toolkit.core.events import ( +from omop_alchemy.cdm.base.event_metadata import ( ClinicalEventModelSpec, UnsupportedClinicalEventModelError, +) +from omop_alchemy.cdm.model.clinical.event_metadata import ( + MODIFIER_TARGETS_BY_TABLE, clinical_event_model_spec, ) from omop_alchemy.toolkit.core.errors import UnsupportedModelError diff --git a/omop_alchemy/toolkit/core/timeline/event_timeline.py b/omop_alchemy/toolkit/core/timeline/event_timeline.py index 4dca5bb..ca5abce 100644 --- a/omop_alchemy/toolkit/core/timeline/event_timeline.py +++ b/omop_alchemy/toolkit/core/timeline/event_timeline.py @@ -12,8 +12,10 @@ import json from dataclasses import dataclass from typing import Protocol, Union, Literal +from types import EllipsisType -from omop_alchemy.toolkit.core.events import ClinicalEventRow, clinical_event_model_spec +from omop_alchemy.cdm.model.clinical.event_metadata import clinical_event_model_spec +from omop_alchemy.toolkit.core.events import ClinicalEventRow TemporalKind = Literal["point", "interval"] @@ -101,11 +103,16 @@ def from_model( cls, model: type[Any], *, - end_date_field: str | None = None, - end_datetime_field: str | None = None, + end_date_field: str | None | EllipsisType = ..., + end_datetime_field: str | None | EllipsisType = ..., value_fields: list[str] | None = None, ) -> "EventMapping": - """Build shared event fields from the canonical Core metadata definition.""" + """Build event fields from shared CDM metadata, including intervals. + + Equal start/end column declarations denote a point event. Omitted + endpoint arguments infer independent end columns; a string overrides + the corresponding column, and explicit None disables that endpoint. + """ spec = clinical_event_model_spec(model) return cls( event_id_field=spec.event_id_column, @@ -114,8 +121,16 @@ def from_model( concept_field=spec.event_concept_id_column, start_date_field=spec.event_date_column, start_datetime_field=spec.event_datetime_column, - end_date_field=end_date_field, - end_datetime_field=end_datetime_field, + end_date_field=( + spec.event_end_date_column + if isinstance(end_date_field, EllipsisType) + else end_date_field + ), + end_datetime_field=( + spec.event_end_datetime_column + if isinstance(end_datetime_field, EllipsisType) + else end_datetime_field + ), value_fields=value_fields, ) @@ -278,11 +293,7 @@ def to_json(self) -> str: class Condition_Event(ClinicalEvent, Condition_Occurrence): - _mapping = EventMapping.from_model( - Condition_Occurrence, - end_date_field="condition_end_date", - end_datetime_field="condition_end_datetime", - ) + _mapping = EventMapping.from_model(Condition_Occurrence) class Measurement_Event(ClinicalEvent, Measurement): @@ -302,8 +313,6 @@ def event_metadata(self) -> dict[str, Optional[int]]: class Drug_Exposure_Event(ClinicalEvent, Drug_Exposure): _mapping = EventMapping.from_model( Drug_Exposure, - end_date_field="drug_exposure_end_date", - end_datetime_field="drug_exposure_end_datetime", value_fields=["quantity"], ) diff --git a/tests/test_event_projections.py b/tests/test_event_projections.py index 5233646..2d4c0e7 100644 --- a/tests/test_event_projections.py +++ b/tests/test_event_projections.py @@ -245,3 +245,39 @@ def snapshot(): ) subprocess.run([sys.executable, "-c", code], check=True) + + +@pytest.mark.parametrize( + "first_import", + [ + "omop_alchemy.cdm.base.event_metadata", + "omop_alchemy.cdm.model.clinical.event_metadata", + "omop_alchemy.toolkit.core.events.projections", + "omop_alchemy.toolkit.core.modifiers.metadata", + "omop_alchemy.toolkit.core.timeline.event_timeline", + ], +) +def test_metadata_import_order_preserves_public_aliases(first_import): + code = textwrap.dedent( + f""" + import importlib + import sys + importlib.import_module({first_import!r}) + from omop_alchemy.cdm.base.event_metadata import ( + ClinicalEventModelSpec, UnsupportedClinicalEventModelError, + ) + from omop_alchemy.cdm.base.errors import UnsupportedModelError + from omop_alchemy.cdm.model.clinical.event_metadata import clinical_event_model_spec + from omop_alchemy.cdm.model.clinical import Measurement + if {first_import!r}.startswith('omop_alchemy.cdm.'): + assert not any(name.startswith('omop_alchemy.toolkit') for name in sys.modules) + from omop_alchemy.toolkit.core import events, errors + from omop_alchemy.toolkit.core.events import projections + assert events.ClinicalEventModelSpec is projections.ClinicalEventModelSpec is ClinicalEventModelSpec + assert events.UnsupportedClinicalEventModelError is projections.UnsupportedClinicalEventModelError is UnsupportedClinicalEventModelError + assert events.clinical_event_model_spec is projections.clinical_event_model_spec is clinical_event_model_spec + assert errors.UnsupportedModelError is UnsupportedModelError + assert isinstance(clinical_event_model_spec(Measurement), ClinicalEventModelSpec) + """ + ) + subprocess.run([sys.executable, "-c", code], check=True) diff --git a/tests/test_event_timeline.py b/tests/test_event_timeline.py index ae896f2..ca4ec57 100644 --- a/tests/test_event_timeline.py +++ b/tests/test_event_timeline.py @@ -6,11 +6,13 @@ import json import sqlalchemy as sa +import pytest from sqlalchemy.dialects import sqlite from omop_alchemy.cdm.base import ModifierFieldConcepts from omop_alchemy.cdm.model.clinical import MeasurementView, ObservationView from omop_alchemy.toolkit.core.events import ClinicalEventRow +from omop_alchemy.toolkit.core.timeline.event_timeline import EventMapping from omop_alchemy.toolkit.core.timeline import ( ClinicalEvent, Condition_Event, @@ -96,3 +98,54 @@ def test_all_timeline_events_use_clinical_event_behaviour(): assert Condition_Event.to_json is ClinicalEvent.to_json assert Drug_Exposure_Event.to_json is ClinicalEvent.to_json assert Observation_Event.to_json is ClinicalEvent.to_json + + +@pytest.mark.parametrize("model", [Measurement_Event, Observation_Event]) +@pytest.mark.parametrize("has_datetime", [False, True]) +def test_single_date_events_remain_points_with_inferred_metadata(model, has_datetime): + mapping = EventMapping.from_model(model) + fields = {mapping.start_date_field: date(2026, 9, 17)} + if has_datetime: + fields[mapping.start_datetime_field] = datetime(2026, 9, 17, 9, 30) + event = model(**fields) + assert mapping.end_date_field is None + assert mapping.end_datetime_field is None + assert event.event_time.kind == "point" + assert event.to_dict()["event_end"] is None + + +@pytest.mark.parametrize("model", [Condition_Event, Drug_Exposure_Event]) +@pytest.mark.parametrize("has_datetime", [False, True]) +def test_interval_events_infer_independent_endpoints(model, has_datetime): + mapping = EventMapping.from_model(model) + fields = { + mapping.start_date_field: date(2026, 9, 16), + mapping.end_date_field: date(2026, 9, 17), + } + expected_end = datetime(2026, 9, 17, 23, 59, 59, 999999) + if has_datetime: + fields[mapping.start_datetime_field] = datetime(2026, 9, 16, 9, 30) + fields[mapping.end_datetime_field] = expected_end = datetime( + 2026, 9, 17, 11, 30 + ) + event = model(**fields) + assert event.event_time.kind == "interval" + assert event.event_time.end == expected_end + assert event.to_dict()["event_end"] == expected_end.isoformat() + + +def test_explicit_endpoint_overrides_can_disable_inferred_intervals(): + mapping = EventMapping.from_model( + Condition_Event, + end_date_field=None, + end_datetime_field=None, + ) + assert mapping.end_date_field is None + assert mapping.end_datetime_field is None + overridden = EventMapping.from_model( + Condition_Event, + end_date_field="custom_end_date", + end_datetime_field="custom_end_datetime", + ) + assert overridden.end_date_field == "custom_end_date" + assert overridden.end_datetime_field == "custom_end_datetime" diff --git a/tests/test_reference_context.py b/tests/test_reference_context.py new file mode 100644 index 0000000..764d766 --- /dev/null +++ b/tests/test_reference_context.py @@ -0,0 +1,116 @@ +"""Reference joins derive mapper identity while preserving deferred loading.""" + +import pytest +import sqlalchemy as sa +import sqlalchemy.orm as so + +from omop_alchemy.cdm.base import ReferenceContext + + +@pytest.fixture(params=[None, "identifier"]) +def reference_models(request): + class LocalBase(so.DeclarativeBase): + pass + + # Declare the source before its target to exercise deferred resolution. + class Source(LocalBase): + __tablename__ = "source" + id: so.Mapped[int] = so.mapped_column(primary_key=True) + target_identifier: so.Mapped[str] + reference = ReferenceContext._reference_relationship( + target="Target", + local_fk="target_identifier", + remote_pk=request.param, + ) + + class Target(LocalBase): + __tablename__ = "target" + identifier: so.Mapped[str] = so.mapped_column("physical_key", primary_key=True) + alternative: so.Mapped[str] + + try: + yield LocalBase, Source, Target + finally: + LocalBase.registry.dispose() + + +def test_reference_join_uses_mapped_attribute_and_batches_loading(reference_models): + base, source, target = reference_models + relationship = sa.inspect(source).relationships.reference + assert relationship.primaryjoin.compare( + source.target_identifier == target.identifier + ) + assert relationship.viewonly + assert relationship.lazy == "selectin" + assert not relationship.uselist + + engine = sa.create_engine("sqlite://") + base.metadata.create_all(engine) + with so.Session(engine) as session: + session.add_all( + [ + target(identifier="pk-1", alternative="other-1"), + source(id=1, target_identifier="pk-1"), + source(id=2, target_identifier="pk-1"), + ] + ) + session.commit() + statements = [] + sa.event.listen( + engine, "before_cursor_execute", lambda *args: statements.append(args[2]) + ) + try: + with so.Session(engine) as session: + rows = session.scalars(sa.select(source).order_by(source.id)).all() + assert [row.reference.identifier for row in rows] == ["pk-1", "pk-1"] + assert len(statements) == 2 + finally: + engine.dispose() + + +@pytest.mark.parametrize( + "reference_models", ["alternative", "physical_key"], indirect=True +) +def test_wrong_legacy_reference_key_is_rejected(reference_models): + base, _, _ = reference_models + with pytest.raises(ValueError, match="primary key is 'identifier', not"): + base.registry.configure() + + +def test_missing_reference_target_is_rejected(reference_models): + base, _, _ = reference_models + + class MissingSource(base): + __tablename__ = "missing_source" + id: so.Mapped[int] = so.mapped_column(primary_key=True) + target_id: so.Mapped[int] + reference = ReferenceContext._reference_relationship( + target="Missing", + local_fk="target_id", + ) + + with pytest.raises( + ValueError, match="reference target 'Missing' is missing or ambiguous" + ): + base.registry.configure() + + +def test_composite_reference_key_is_rejected(reference_models): + base, _, _ = reference_models + + class CompositeSource(base): + __tablename__ = "composite_source" + id: so.Mapped[int] = so.mapped_column(primary_key=True) + target_id: so.Mapped[int] + reference = ReferenceContext._reference_relationship( + target="Composite", + local_fk="target_id", + ) + + class Composite(base): + __tablename__ = "composite" + id: so.Mapped[int] = so.mapped_column(primary_key=True) + other_id: so.Mapped[int] = so.mapped_column(primary_key=True) + + with pytest.raises(ValueError, match="exactly one mapped primary key"): + base.registry.configure() From d9a48d97e1f795e88f7f7d9ac2da5a3ef3cdad17 Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Thu, 17 Sep 2026 14:29:53 +1000 Subject: [PATCH 28/30] removed unnecessary internal branch compatibility affordances --- omop_alchemy/toolkit/core/errors.py | 3 --- omop_alchemy/toolkit/core/events/__init__.py | 6 ----- .../toolkit/core/events/projections.py | 8 ++---- .../toolkit/core/modifiers/metadata.py | 2 +- tests/test_event_projections.py | 27 ++++++++++--------- tests/test_modifier_projections.py | 6 ++--- 6 files changed, 19 insertions(+), 33 deletions(-) delete mode 100644 omop_alchemy/toolkit/core/errors.py diff --git a/omop_alchemy/toolkit/core/errors.py b/omop_alchemy/toolkit/core/errors.py deleted file mode 100644 index b2bc5b1..0000000 --- a/omop_alchemy/toolkit/core/errors.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Compatibility import for shared model-validation errors.""" - -from omop_alchemy.cdm.base.errors import UnsupportedModelError as UnsupportedModelError diff --git a/omop_alchemy/toolkit/core/events/__init__.py b/omop_alchemy/toolkit/core/events/__init__.py index 6956a35..55e861d 100644 --- a/omop_alchemy/toolkit/core/events/__init__.py +++ b/omop_alchemy/toolkit/core/events/__init__.py @@ -15,11 +15,8 @@ ValuedClinicalEventRow, ) from .projections import ( - ClinicalEventModelSpec, - UnsupportedClinicalEventModelError, canonical_event_projection, canonical_event_union, - clinical_event_model_spec, ) __all__ = [ @@ -27,11 +24,8 @@ "CANONICAL_EVENT_REQUIRED_COLUMNS", "ClinicalEventColumn", "ClinicalEventIdentity", - "ClinicalEventModelSpec", "ClinicalEventRow", "ValuedClinicalEventRow", - "UnsupportedClinicalEventModelError", "canonical_event_projection", "canonical_event_union", - "clinical_event_model_spec", ] diff --git a/omop_alchemy/toolkit/core/events/projections.py b/omop_alchemy/toolkit/core/events/projections.py index 5eb7087..e2fc522 100644 --- a/omop_alchemy/toolkit/core/events/projections.py +++ b/omop_alchemy/toolkit/core/events/projections.py @@ -6,12 +6,8 @@ import sqlalchemy as sa -from omop_alchemy.cdm.base.event_metadata import ( - ClinicalEventModelSpec as ClinicalEventModelSpec, - UnsupportedClinicalEventModelError as UnsupportedClinicalEventModelError, -) from omop_alchemy.cdm.model.clinical.event_metadata import ( - clinical_event_model_spec as clinical_event_model_spec, + clinical_event_model_spec as _clinical_event_model_spec, ) from omop_alchemy.toolkit._utils import _nullable_column, _select_or_union_all @@ -24,7 +20,7 @@ def canonical_event_projection( include_values: bool = True, ) -> sa.Select[Any]: """Project one supported OMOP event model to canonical event columns.""" - spec = clinical_event_model_spec(model) + spec = _clinical_event_model_spec(model) # The output deliberately uses canonical labels rather than source names; # downstream attachment, timeline, and union code should not branch on the # particular OMOP event table being projected. diff --git a/omop_alchemy/toolkit/core/modifiers/metadata.py b/omop_alchemy/toolkit/core/modifiers/metadata.py index 6531d5c..43e1bbe 100644 --- a/omop_alchemy/toolkit/core/modifiers/metadata.py +++ b/omop_alchemy/toolkit/core/modifiers/metadata.py @@ -20,11 +20,11 @@ ClinicalEventModelSpec, UnsupportedClinicalEventModelError, ) +from omop_alchemy.cdm.base.errors import UnsupportedModelError from omop_alchemy.cdm.model.clinical.event_metadata import ( MODIFIER_TARGETS_BY_TABLE, clinical_event_model_spec, ) -from omop_alchemy.toolkit.core.errors import UnsupportedModelError class UnsupportedModifierSourceModelError(UnsupportedModelError): diff --git a/tests/test_event_projections.py b/tests/test_event_projections.py index 2d4c0e7..a83db10 100644 --- a/tests/test_event_projections.py +++ b/tests/test_event_projections.py @@ -28,17 +28,19 @@ Procedure_OccurrenceView, ) from omop_alchemy.cdm.base import ModifierTargetMixin +from omop_alchemy.cdm.base.event_metadata import ( + UnsupportedClinicalEventModelError, +) from omop_alchemy.cdm.model.structural import Episode, Episode_EventView, EpisodeView from omop_alchemy.cdm.model.clinical.event_metadata import ( _validate_unique_target_keys, + clinical_event_model_spec, ) from omop_alchemy.toolkit.core.events import ( CANONICAL_EVENT_OPTIONAL_COLUMNS, CANONICAL_EVENT_REQUIRED_COLUMNS, - UnsupportedClinicalEventModelError, canonical_event_projection, canonical_event_union, - clinical_event_model_spec, ) @@ -204,10 +206,8 @@ def test_analytics_import_preserves_all_core_metadata_and_compiled_projections() Observation, Procedure_Occurrence, ) - from omop_alchemy.toolkit.core.events import ( - canonical_event_projection, - clinical_event_model_spec, - ) + from omop_alchemy.toolkit.core.events import canonical_event_projection + from omop_alchemy.cdm.model.clinical.event_metadata import clinical_event_model_spec from omop_alchemy.cdm.model.structural import Episode_EventView models = ( @@ -257,7 +257,7 @@ def snapshot(): "omop_alchemy.toolkit.core.timeline.event_timeline", ], ) -def test_metadata_import_order_preserves_public_aliases(first_import): +def test_metadata_import_order_keeps_metadata_in_cdm(first_import): code = textwrap.dedent( f""" import importlib @@ -266,17 +266,18 @@ def test_metadata_import_order_preserves_public_aliases(first_import): from omop_alchemy.cdm.base.event_metadata import ( ClinicalEventModelSpec, UnsupportedClinicalEventModelError, ) - from omop_alchemy.cdm.base.errors import UnsupportedModelError from omop_alchemy.cdm.model.clinical.event_metadata import clinical_event_model_spec from omop_alchemy.cdm.model.clinical import Measurement if {first_import!r}.startswith('omop_alchemy.cdm.'): assert not any(name.startswith('omop_alchemy.toolkit') for name in sys.modules) - from omop_alchemy.toolkit.core import events, errors + from omop_alchemy.toolkit.core import events from omop_alchemy.toolkit.core.events import projections - assert events.ClinicalEventModelSpec is projections.ClinicalEventModelSpec is ClinicalEventModelSpec - assert events.UnsupportedClinicalEventModelError is projections.UnsupportedClinicalEventModelError is UnsupportedClinicalEventModelError - assert events.clinical_event_model_spec is projections.clinical_event_model_spec is clinical_event_model_spec - assert errors.UnsupportedModelError is UnsupportedModelError + assert not hasattr(events, 'ClinicalEventModelSpec') + assert not hasattr(events, 'UnsupportedClinicalEventModelError') + assert not hasattr(events, 'clinical_event_model_spec') + assert not hasattr(projections, 'ClinicalEventModelSpec') + assert not hasattr(projections, 'UnsupportedClinicalEventModelError') + assert not hasattr(projections, 'clinical_event_model_spec') assert isinstance(clinical_event_model_spec(Measurement), ClinicalEventModelSpec) """ ) diff --git a/tests/test_modifier_projections.py b/tests/test_modifier_projections.py index c216194..61fa6f8 100644 --- a/tests/test_modifier_projections.py +++ b/tests/test_modifier_projections.py @@ -25,16 +25,14 @@ ) from omop_alchemy.cdm.model.clinical import MeasurementView, ObservationView from omop_alchemy.cdm.model.structural import Episode, Episode_EventView +from omop_alchemy.cdm.base.event_metadata import ClinicalEventModelSpec from omop_alchemy.cdm.model.clinical.event_metadata import ( CLINICAL_EVENT_TARGETS_BY_FIELD_CONCEPT_ID, CLINICAL_EVENT_TARGETS_BY_TABLE, MODIFIER_TARGETS_BY_TABLE, STRUCTURAL_MODIFIER_TARGETS_BY_TABLE, - clinical_event_target_for_table, -) -from omop_alchemy.toolkit.core.events import ( - ClinicalEventModelSpec, clinical_event_model_spec, + clinical_event_target_for_table, ) from omop_alchemy.toolkit.core.modifiers.projections import _VALUE_COLUMN_TYPES from omop_alchemy.toolkit.core.modifiers.contracts import ( From fc2537539654391a1fbd5079acfe2755f3314b09 Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Thu, 17 Sep 2026 14:46:38 +1000 Subject: [PATCH 29/30] minor comment update --- omop_alchemy/cdm/base/reference_context.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/omop_alchemy/cdm/base/reference_context.py b/omop_alchemy/cdm/base/reference_context.py index d9bdd31..0d2014e 100644 --- a/omop_alchemy/cdm/base/reference_context.py +++ b/omop_alchemy/cdm/base/reference_context.py @@ -25,8 +25,8 @@ def _reference_relationship( """Join a local reference to its target's single mapped primary key. Target metadata is resolved only when the join is configured, preserving - declaration/import order. Legacy remote_pk arguments are accepted but - must name the derived primary-key attribute. + declaration/import order. The released ``remote_pk`` argument remains + accepted, but must name the derived primary-key attribute. """ return so.declared_attr( lambda cls_: so.relationship( From 6f66f70b01f71ce4cc53a6f54b9935d8eec21cec Mon Sep 17 00:00:00 2001 From: Georgie Kennedy Date: Thu, 17 Sep 2026 20:07:34 +1000 Subject: [PATCH 30/30] fiiiine --- docs/toolkit/core.md | 21 +++ docs/toolkit/query-contracts.md | 4 +- omop_alchemy/cdm/base/__init__.py | 2 + omop_alchemy/cdm/base/errors.py | 14 +- omop_alchemy/cdm/base/event_metadata.py | 127 ++++++++++++------ omop_alchemy/cdm/base/modifier_interface.py | 6 +- .../model/clinical/condition_occurrence.py | 4 +- .../cdm/model/clinical/device_exposure.py | 4 +- .../cdm/model/clinical/drug_exposure.py | 4 +- .../cdm/model/clinical/event_metadata.py | 66 +++------ .../cdm/model/clinical/measurement.py | 4 +- .../cdm/model/clinical/observation.py | 4 +- .../model/clinical/procedure_occurrence.py | 4 +- omop_alchemy/toolkit/core/events/__init__.py | 4 - omop_alchemy/toolkit/core/events/contracts.py | 22 ++- .../toolkit/core/modifiers/__init__.py | 4 - .../toolkit/core/modifiers/contracts.py | 22 ++- .../toolkit/core/modifiers/metadata.py | 8 -- .../toolkit/core/modifiers/projections.py | 4 +- .../toolkit/core/modifiers/targets.py | 3 +- .../episodes/derivation/attachments.py | 3 +- tests/test_event_projections.py | 46 +++++-- tests/test_modifier_projections.py | 34 +++-- tests/test_query_builder_contracts.py | 10 +- 24 files changed, 231 insertions(+), 193 deletions(-) diff --git a/docs/toolkit/core.md b/docs/toolkit/core.md index 6d91fc6..faa7a94 100644 --- a/docs/toolkit/core.md +++ b/docs/toolkit/core.md @@ -71,6 +71,9 @@ assert measurement != procedure `ClinicalEventColumn` defines the common labels used when heterogeneous event tables are projected into one result. The required shape includes the person, table-scoped event identity, event date and datetime, clinical concept, and OMOP Field concept that identifies the source ID column. Optional labels cover numeric values, value concepts, and units. +`ClinicalEventColumn.required_columns()` and `optional_columns()` expose these +groups as ordered enum tuples derived from the field-only row contracts. + `canonical_event_union()` turns supported event models into that shared shape. Measurement and Observation retain numeric values, value concepts and units; Observation string values are outside this projection. Sources without those fields receive typed nulls so every branch of the union remains compatible: ```python @@ -93,6 +96,21 @@ for event in session.execute(events).mappings(): The projection resolves its ID, clinical concept, date, source table, and Field concept through stable CDM event metadata shared with episode-event resolution. Bare `Measurement`, `Observation`, and `Device_Exposure` classes remain lightweight mappings for ETL, while their analytical views provide reference context, domain validation, and episode-event resolution. Importing analytics modules cannot change either the Core projection metadata or the default resolution target. `UnsupportedClinicalEventModelError` is raised before SQL execution when no supported CDM definition exists. +The six clinical analytical Views inherit `ClinicalEventMixin`, which extends +`ModifierTargetMixin` with `has_complete_metadata()` and +`clinical_event_model_spec()`. The spec method can validate either the View's +columns or those of a supplied bare source model. The explicit registry still +controls event membership: `EpisodeView` is a modifier target rather than a +clinical event, and custom modifier sources may combine `ModifierSourceMixin` +with `ClinicalEventMixin` without registering as episode-event targets. + +```python +from omop_alchemy.cdm.model.clinical import Measurement, MeasurementView + +assert MeasurementView.has_complete_metadata() +spec = MeasurementView.clinical_event_model_spec(Measurement) +``` + ```mermaid flowchart LR M["Measurement
measurement_id"] --> U["canonical_event_union()"] @@ -113,6 +131,9 @@ but use different physical column names. `canonical_modifier_projection()` and table-scoped modifier identity, a Field-concept-scoped target identity, the modifier date and concept, and all four OMOP value representations. +`ModifierColumn.required_columns()` and `value_columns()` expose the ordered +modifier label groups without adding methods to the row Protocols. + Supported source and target models are declared explicitly in immutable metadata. Shared event fields and the six clinical target definitions are derived from the clinical-event registry; Episode is the only target extension. diff --git a/docs/toolkit/query-contracts.md b/docs/toolkit/query-contracts.md index 1993cda..77d44d7 100644 --- a/docs/toolkit/query-contracts.md +++ b/docs/toolkit/query-contracts.md @@ -29,7 +29,7 @@ observation = ClinicalEventIdentity("observation", 7) assert len({measurement, procedure, observation}) == 3 ``` -A cross-table projection needs more than an identity. `CANONICAL_EVENT_REQUIRED_COLUMNS` defines the labels a consumer can rely on: +A cross-table projection needs more than an identity. `ClinicalEventColumn.required_columns()` defines the labels a consumer can rely on: | Column | Meaning | |---|---| @@ -41,7 +41,7 @@ A cross-table projection needs more than an identity. `CANONICAL_EVENT_REQUIRED_ | `event_datetime` | Source datetime when one is available | | `event_concept_id` | Primary clinical concept carried by the event | -Numeric value, value concept, and unit labels are available through `CANONICAL_EVENT_OPTIONAL_COLUMNS` when a source table supports them. +Numeric value, value concept, and unit labels are available through `ClinicalEventColumn.optional_columns()` when a source table supports them. The Field concept is not interchangeable with the event's clinical concept. For example, a Procedure Occurrence projection uses the Field concept for `procedure_occurrence.procedure_occurrence_id` as its discriminator and the row's `procedure_concept_id` as its clinical concept. diff --git a/omop_alchemy/cdm/base/__init__.py b/omop_alchemy/cdm/base/__init__.py index f0fd516..0c76798 100644 --- a/omop_alchemy/cdm/base/__init__.py +++ b/omop_alchemy/cdm/base/__init__.py @@ -8,6 +8,7 @@ from .reference_context import ReferenceContext from .typing import HasConceptId, HasEpisodeId, HasPersonId, DomainSemanticTable from .modifier_interface import ModifierSourceMixin, ModifierTargetMixin +from .event_metadata import ClinicalEventMixin from .cdm_constants import ModifierFieldConcepts __all__ = [ @@ -35,6 +36,7 @@ "merge_table_args", "ModifierSourceMixin", "ModifierTargetMixin", + "ClinicalEventMixin", "ModifierFieldConcepts", "DomainRule", "omop_index", diff --git a/omop_alchemy/cdm/base/errors.py b/omop_alchemy/cdm/base/errors.py index 8419a72..4bdd1be 100644 --- a/omop_alchemy/cdm/base/errors.py +++ b/omop_alchemy/cdm/base/errors.py @@ -10,16 +10,8 @@ class UnsupportedModelError(TypeError): model_kind: ClassVar[str] = "model" - def __init__( - self, - model: object | None, - reason: str, - *, - message: str | None = None, - ) -> None: + def __init__(self, model: object, reason: str) -> None: self.model = model self.reason = reason - if message is None: - name = getattr(model, "__name__", repr(model)) - message = f"{name} is not a supported {self.model_kind}: {reason}" - super().__init__(message) + name = getattr(model, "__name__", repr(model)) + super().__init__(f"{name} is not a supported {self.model_kind}: {reason}") diff --git a/omop_alchemy/cdm/base/event_metadata.py b/omop_alchemy/cdm/base/event_metadata.py index c59d49c..eeadcf2 100644 --- a/omop_alchemy/cdm/base/event_metadata.py +++ b/omop_alchemy/cdm/base/event_metadata.py @@ -29,42 +29,91 @@ class ClinicalEventModelSpec: event_end_datetime_column: str | None = None -def _has_complete_event_metadata(model: type[Any]) -> bool: - # A model is eligible to own metadata only when the complete modifier - # contract is present. Partial class attributes would produce a projection - # whose labels look valid while pointing at the wrong source columns. - if not issubclass(model, ModifierTargetMixin): - return False - if any( - not getattr(model, name, None) - for name in ("__event_id_col__", "__concept_id_col__", "__start_date_col__") - ): - return False - try: - model.modifier_field_concept_id() - except NotImplementedError: - return False - return True - - -def _datetime_column_name(model: type[Any], date_column_name: str) -> str | None: - """Return the conventional datetime counterpart only when exposed.""" - if date_column_name.endswith("_date"): - candidate = f"{date_column_name[:-5]}_datetime" - if hasattr(model, candidate): - return candidate - return None - - -def _interval_columns( - model: type[Any], metadata_model: type[ModifierTargetMixin] -) -> tuple[str | None, str | None]: - """Resolve independent endpoints; a start-date alias denotes a point.""" - end_date = getattr(metadata_model, "__end_date_col__", None) - if ( - not isinstance(end_date, str) - or end_date == metadata_model.__start_date_col__ - or not hasattr(model, end_date) - ): - return None, None - return end_date, _datetime_column_name(model, end_date) +class ClinicalEventMixin(ModifierTargetMixin): + """CDM event metadata and helpers shared by clinical analytical Views. + + Inheritance provides a metadata capability, not registry membership. Custom + modifier sources may use this mixin without becoming episode-event targets. + The model registry selects supported events; toolkit builds SQL from specs. + """ + + @classmethod + def has_complete_metadata(cls) -> bool: + """Whether event identity, concept, date and Field metadata are declared.""" + if any( + not getattr(cls, name, None) + for name in ("__event_id_col__", "__concept_id_col__", "__start_date_col__") + ): + return False + try: + cls.modifier_field_concept_id() + except NotImplementedError: + return False + return True + + @classmethod + def clinical_event_model_spec( + cls, source_model: type[Any] | None = None + ) -> ClinicalEventModelSpec: + """Validate event metadata against this View or a bare source model. + + This method accesses no database or registry. A registered View can + supply declarations while a bare table supplies the physical columns. + """ + model = cls if source_model is None else source_model + if not isinstance(model, type) or not hasattr(model, "__table__"): + raise UnsupportedClinicalEventModelError( + model, "expected a mapped ORM model class" + ) + if not cls.has_complete_metadata(): + raise UnsupportedClinicalEventModelError( + model, "no complete ClinicalEventMixin metadata is available" + ) + + event_id_column = cls.__event_id_col__ + event_concept_id_column = cls.__concept_id_col__ + event_date_column = cls.__start_date_col__ + required_columns = ( + event_id_column, + event_concept_id_column, + event_date_column, + "person_id", + ) + missing = tuple(name for name in required_columns if not hasattr(model, name)) + if missing: + raise UnsupportedClinicalEventModelError( + model, f"missing required columns: {', '.join(missing)}" + ) + + end_date, end_datetime = cls._interval_columns(model) + return ClinicalEventModelSpec( + event_id_column=event_id_column, + event_concept_id_column=event_concept_id_column, + event_date_column=event_date_column, + event_datetime_column=cls._datetime_column_name(model, event_date_column), + event_field_concept_id=cls.modifier_field_concept_id(), + event_source_table=cls.modifier_target_table(), + event_end_date_column=end_date, + event_end_datetime_column=end_datetime, + ) + + @staticmethod + def _datetime_column_name(model: type[Any], date_column_name: str) -> str | None: + """Return the conventional datetime counterpart only when exposed.""" + if date_column_name.endswith("_date"): + candidate = f"{date_column_name[:-5]}_datetime" + if hasattr(model, candidate): + return candidate + return None + + @classmethod + def _interval_columns(cls, model: type[Any]) -> tuple[str | None, str | None]: + """Resolve independent endpoints; a start-date alias denotes a point.""" + end_date = getattr(cls, "__end_date_col__", None) + if ( + not isinstance(end_date, str) + or end_date == cls.__start_date_col__ + or not hasattr(model, end_date) + ): + return None, None + return end_date, cls._datetime_column_name(model, end_date) diff --git a/omop_alchemy/cdm/base/modifier_interface.py b/omop_alchemy/cdm/base/modifier_interface.py index 4c2b2ed..aee18d5 100644 --- a/omop_alchemy/cdm/base/modifier_interface.py +++ b/omop_alchemy/cdm/base/modifier_interface.py @@ -16,8 +16,8 @@ class ModifierSourceMixin: query code never branches on the physical modifier source. Built-in CDM tables put this mixin on the bare Measurement and Observation - classes. Custom mapped sources may also use it when they supply complete - event and link metadata; source support is validated from that metadata, + classes. Custom mapped sources combine it with ``ClinicalEventMixin`` to + supply event and link metadata; source support is validated from that metadata, not restricted to the built-in cached specs. Wearing this mixin does not enrol a model in the clinical-event or modifier-target registries. @@ -72,7 +72,7 @@ class ModifierTargetMixin: Built-in CDM models place it on analytical Views, keeping the bare tables lean. That placement convention does not restrict custom mapped sources - from using both source and target metadata without a View/context base. + from supplying source and event metadata without a View/context base. """ __abstract__ = True diff --git a/omop_alchemy/cdm/model/clinical/condition_occurrence.py b/omop_alchemy/cdm/model/clinical/condition_occurrence.py index 65bd250..bba3e43 100644 --- a/omop_alchemy/cdm/model/clinical/condition_occurrence.py +++ b/omop_alchemy/cdm/model/clinical/condition_occurrence.py @@ -12,7 +12,7 @@ CDMTableBase, cdm_table, ModifierFieldConcepts, - ModifierTargetMixin, + ClinicalEventMixin, merge_table_args, omop_index, ) @@ -67,7 +67,7 @@ def visit_occurrence(cls) -> so.Mapped[Optional["Visit_Occurrence"]]: class Condition_OccurrenceView( Condition_Occurrence, Condition_OccurrenceContext, - ModifierTargetMixin + ClinicalEventMixin ): __tablename__ = "condition_occurrence" __mapper_args__ = {"concrete": False} diff --git a/omop_alchemy/cdm/model/clinical/device_exposure.py b/omop_alchemy/cdm/model/clinical/device_exposure.py index fb4504a..4a77e7d 100644 --- a/omop_alchemy/cdm/model/clinical/device_exposure.py +++ b/omop_alchemy/cdm/model/clinical/device_exposure.py @@ -17,7 +17,7 @@ required_concept_fk, optional_concept_fk, optional_int, - ModifierTargetMixin, + ClinicalEventMixin, merge_table_args, omop_index, ) @@ -117,7 +117,7 @@ class Device_ExposureView( Device_Exposure, Device_ExposureContext, DomainValidationMixin, - ModifierTargetMixin, + ClinicalEventMixin, ): """Analytical Device Exposure mapping with event metadata and references.""" diff --git a/omop_alchemy/cdm/model/clinical/drug_exposure.py b/omop_alchemy/cdm/model/clinical/drug_exposure.py index c43bbf8..621401d 100644 --- a/omop_alchemy/cdm/model/clinical/drug_exposure.py +++ b/omop_alchemy/cdm/model/clinical/drug_exposure.py @@ -13,7 +13,7 @@ required_concept_fk, optional_concept_fk, optional_int, - ModifierTargetMixin, + ClinicalEventMixin, ModifierFieldConcepts, merge_table_args, omop_index, @@ -72,7 +72,7 @@ class Drug_ExposureContext(ReferenceContext): class Drug_ExposureView( Drug_Exposure, Drug_ExposureContext, - ModifierTargetMixin + ClinicalEventMixin ): __tablename__ = "drug_exposure" diff --git a/omop_alchemy/cdm/model/clinical/event_metadata.py b/omop_alchemy/cdm/model/clinical/event_metadata.py index 904d9ce..c1d2cf6 100644 --- a/omop_alchemy/cdm/model/clinical/event_metadata.py +++ b/omop_alchemy/cdm/model/clinical/event_metadata.py @@ -11,13 +11,14 @@ from types import MappingProxyType from typing import Any, Mapping -from omop_alchemy.cdm.base import ModifierSourceMixin, ModifierTargetMixin +from omop_alchemy.cdm.base import ( + ClinicalEventMixin, + ModifierSourceMixin, + ModifierTargetMixin, +) from omop_alchemy.cdm.base.event_metadata import ( ClinicalEventModelSpec, UnsupportedClinicalEventModelError, - _datetime_column_name, - _has_complete_event_metadata, - _interval_columns, ) from .condition_occurrence import Condition_Occurrence, Condition_OccurrenceView @@ -31,7 +32,7 @@ # Keep one explicit supported event set. Both lookup shapes are derived from it # so projection and episode-resolution support cannot drift independently. -_CLINICAL_EVENT_TARGETS: tuple[tuple[type[Any], type[ModifierTargetMixin]], ...] = ( +_CLINICAL_EVENT_TARGETS: tuple[tuple[type[Any], type[ClinicalEventMixin]], ...] = ( (Condition_Occurrence, Condition_OccurrenceView), (Device_Exposure, Device_ExposureView), (Drug_Exposure, Drug_ExposureView), @@ -77,12 +78,12 @@ def _validate_unique_target_keys( label="modifier field concept ID", ) -CLINICAL_EVENT_TARGETS_BY_TABLE: Mapping[str, type[ModifierTargetMixin]] = ( +CLINICAL_EVENT_TARGETS_BY_TABLE: Mapping[str, type[ClinicalEventMixin]] = ( MappingProxyType( {source.__tablename__: target for source, target in _CLINICAL_EVENT_TARGETS} ) ) -CLINICAL_EVENT_TARGETS_BY_FIELD_CONCEPT_ID: Mapping[int, type[ModifierTargetMixin]] = ( +CLINICAL_EVENT_TARGETS_BY_FIELD_CONCEPT_ID: Mapping[int, type[ClinicalEventMixin]] = ( MappingProxyType( { target.modifier_field_concept_id(): target @@ -107,17 +108,22 @@ def _validate_unique_target_keys( def clinical_event_target_for_table( table_name: str, -) -> type[ModifierTargetMixin] | None: +) -> type[ClinicalEventMixin] | None: """Return the registered analytical event target for a bare CDM table.""" return CLINICAL_EVENT_TARGETS_BY_TABLE.get(table_name) -def _metadata_candidate(model: type[Any]) -> type[ModifierTargetMixin] | None: +def _metadata_candidate(model: type[Any]) -> type[ClinicalEventMixin] | None: # An explicitly supplied registered event view (or its domain-specific # subclass) owns its metadata. Bare CDM tables use the registered CDM view # for that table; unrelated subclasses are never discovered by walking # Python's import-dependent subclass graph. - if _has_complete_event_metadata(model): + if issubclass(model, ModifierTargetMixin): + if ( + not issubclass(model, ClinicalEventMixin) + or not model.has_complete_metadata() + ): + return None table_name = getattr(model, "__tablename__", None) registered_view = clinical_event_target_for_table(str(table_name)) if registered_view is not None and issubclass(model, registered_view): @@ -148,43 +154,7 @@ def clinical_event_model_spec(model: type[Any]) -> ClinicalEventModelSpec: if metadata_model is None: raise UnsupportedClinicalEventModelError( model, - "no complete ModifierTargetMixin metadata is available", + "no complete ClinicalEventMixin metadata is available", ) - event_id_column = metadata_model.__event_id_col__ - event_concept_id_column = metadata_model.__concept_id_col__ - event_date_column = metadata_model.__start_date_col__ - required_columns = ( - event_id_column, - event_concept_id_column, - event_date_column, - "person_id", - ) - # Metadata may come from a sibling view, so validate the physical source - # model separately before using the view's canonical field-concept marker. - missing = tuple(name for name in required_columns if not hasattr(model, name)) - if missing: - raise UnsupportedClinicalEventModelError( - model, - f"missing required columns: {', '.join(missing)}", - ) - - try: - field_concept_id = metadata_model.modifier_field_concept_id() - except NotImplementedError as error: - raise UnsupportedClinicalEventModelError( - model, - "modifier Field concept is not defined", - ) from error - - end_date, end_datetime = _interval_columns(model, metadata_model) - return ClinicalEventModelSpec( - event_id_column=event_id_column, - event_concept_id_column=event_concept_id_column, - event_date_column=event_date_column, - event_datetime_column=_datetime_column_name(model, event_date_column), - event_field_concept_id=field_concept_id, - event_source_table=metadata_model.modifier_target_table(), - event_end_date_column=end_date, - event_end_datetime_column=end_datetime, - ) + return metadata_model.clinical_event_model_spec(model) diff --git a/omop_alchemy/cdm/model/clinical/measurement.py b/omop_alchemy/cdm/model/clinical/measurement.py index d26bab2..3342d86 100644 --- a/omop_alchemy/cdm/model/clinical/measurement.py +++ b/omop_alchemy/cdm/model/clinical/measurement.py @@ -11,7 +11,7 @@ ExpectedDomain, ModifierFieldConcepts, ModifierSourceMixin, - ModifierTargetMixin, + ClinicalEventMixin, ReferenceContext, cdm_table, ValueMixin, @@ -140,7 +140,7 @@ class MeasurementView( Measurement, MeasurementContext, DomainValidationMixin, - ModifierTargetMixin, + ClinicalEventMixin, ): """Analytical Measurement mapping with event metadata and reference context.""" diff --git a/omop_alchemy/cdm/model/clinical/observation.py b/omop_alchemy/cdm/model/clinical/observation.py index b7f4715..abd1ee8 100644 --- a/omop_alchemy/cdm/model/clinical/observation.py +++ b/omop_alchemy/cdm/model/clinical/observation.py @@ -11,7 +11,7 @@ ExpectedDomain, ModifierFieldConcepts, ModifierSourceMixin, - ModifierTargetMixin, + ClinicalEventMixin, ReferenceContext, cdm_table, ValueMixin, @@ -126,7 +126,7 @@ class ObservationView( Observation, ObservationContext, DomainValidationMixin, - ModifierTargetMixin, + ClinicalEventMixin, ): """Analytical Observation mapping with event metadata and reference context.""" diff --git a/omop_alchemy/cdm/model/clinical/procedure_occurrence.py b/omop_alchemy/cdm/model/clinical/procedure_occurrence.py index f911489..4b4cbfa 100644 --- a/omop_alchemy/cdm/model/clinical/procedure_occurrence.py +++ b/omop_alchemy/cdm/model/clinical/procedure_occurrence.py @@ -15,7 +15,7 @@ ReferenceContext, DomainValidationMixin, ExpectedDomain, - ModifierTargetMixin, + ClinicalEventMixin, ModifierFieldConcepts, merge_table_args, omop_index, @@ -105,7 +105,7 @@ class Procedure_OccurrenceView( Procedure_Occurrence, Procedure_OccurrenceContext, DomainValidationMixin, - ModifierTargetMixin, + ClinicalEventMixin, ): __tablename__ = "procedure_occurrence" __mapper_args__ = {"concrete": False} diff --git a/omop_alchemy/toolkit/core/events/__init__.py b/omop_alchemy/toolkit/core/events/__init__.py index 55e861d..28fefcf 100644 --- a/omop_alchemy/toolkit/core/events/__init__.py +++ b/omop_alchemy/toolkit/core/events/__init__.py @@ -7,8 +7,6 @@ """ from .contracts import ( - CANONICAL_EVENT_OPTIONAL_COLUMNS, - CANONICAL_EVENT_REQUIRED_COLUMNS, ClinicalEventColumn, ClinicalEventIdentity, ClinicalEventRow, @@ -20,8 +18,6 @@ ) __all__ = [ - "CANONICAL_EVENT_OPTIONAL_COLUMNS", - "CANONICAL_EVENT_REQUIRED_COLUMNS", "ClinicalEventColumn", "ClinicalEventIdentity", "ClinicalEventRow", diff --git a/omop_alchemy/toolkit/core/events/contracts.py b/omop_alchemy/toolkit/core/events/contracts.py index 25d83f5..b5e15f5 100644 --- a/omop_alchemy/toolkit/core/events/contracts.py +++ b/omop_alchemy/toolkit/core/events/contracts.py @@ -22,6 +22,16 @@ class ClinicalEventColumn(StrEnum): value_as_concept_id = "value_as_concept_id" unit_concept_id = "unit_concept_id" + @classmethod + def required_columns(cls) -> tuple[ClinicalEventColumn, ...]: + """Required projection labels in row-contract order.""" + return tuple(cls[name] for name in ClinicalEventRow.__annotations__) + + @classmethod + def optional_columns(cls) -> tuple[ClinicalEventColumn, ...]: + """Nullable value labels, excluding inherited required fields.""" + return tuple(cls[name] for name in ValuedClinicalEventRow.__annotations__) + @runtime_checkable class ClinicalEventRow(Protocol): @@ -50,18 +60,6 @@ class ValuedClinicalEventRow(ClinicalEventRow, Protocol): unit_concept_id: int | None -CANONICAL_EVENT_REQUIRED_COLUMNS: tuple[ClinicalEventColumn, ...] = tuple( - ClinicalEventColumn[name] for name in ClinicalEventRow.__annotations__ -) -"""Columns every canonical clinical-event projection must expose.""" - - -CANONICAL_EVENT_OPTIONAL_COLUMNS: tuple[ClinicalEventColumn, ...] = tuple( - ClinicalEventColumn[name] for name in ValuedClinicalEventRow.__annotations__ -) -"""Nullable value columns a projection may add when its source supports them.""" - - @dataclass(frozen=True, order=True, slots=True) class ClinicalEventIdentity: """Cross-table event identity. diff --git a/omop_alchemy/toolkit/core/modifiers/__init__.py b/omop_alchemy/toolkit/core/modifiers/__init__.py index e47151c..4ce7446 100644 --- a/omop_alchemy/toolkit/core/modifiers/__init__.py +++ b/omop_alchemy/toolkit/core/modifiers/__init__.py @@ -1,8 +1,6 @@ """Canonical modifier projections, target validation, and selection.""" from .contracts import ( - CANONICAL_MODIFIER_REQUIRED_COLUMNS, - CANONICAL_MODIFIER_VALUE_COLUMNS, ModifierColumn, ModifierIdentity, ModifierRow, @@ -48,8 +46,6 @@ ) __all__ = [ - "CANONICAL_MODIFIER_REQUIRED_COLUMNS", - "CANONICAL_MODIFIER_VALUE_COLUMNS", "MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE", "MODIFIER_RANK", "MODIFIER_TARGET_SPECS_BY_TABLE", diff --git a/omop_alchemy/toolkit/core/modifiers/contracts.py b/omop_alchemy/toolkit/core/modifiers/contracts.py index 7c904ee..af08536 100644 --- a/omop_alchemy/toolkit/core/modifiers/contracts.py +++ b/omop_alchemy/toolkit/core/modifiers/contracts.py @@ -43,6 +43,16 @@ class ModifierColumn(StrEnum): unit_concept_id = "unit_concept_id" value_as_string = "value_as_string" + @classmethod + def required_columns(cls) -> tuple[ModifierColumn, ...]: + """Required projection labels in row-contract order.""" + return tuple(cls[name] for name in ModifierRow.__annotations__) + + @classmethod + def value_columns(cls) -> tuple[ModifierColumn, ...]: + """Nullable value labels, excluding inherited required fields.""" + return tuple(cls[name] for name in ValuedModifierRow.__annotations__) + @runtime_checkable class ModifierRow(Protocol): @@ -68,18 +78,6 @@ class ValuedModifierRow(ModifierRow, Protocol): value_as_string: str | None -# Column ordering is important for UNION queries; derive it from the row -# contracts so the labels and their typed fields cannot drift apart. -CANONICAL_MODIFIER_REQUIRED_COLUMNS: tuple[ModifierColumn, ...] = tuple( - ModifierColumn[name] for name in ModifierRow.__annotations__ -) - - -CANONICAL_MODIFIER_VALUE_COLUMNS: tuple[ModifierColumn, ...] = tuple( - ModifierColumn[name] for name in ValuedModifierRow.__annotations__ -) - - @dataclass(frozen=True, order=True, slots=True) class ModifierIdentity: """Source-table-scoped identity of the modifier row itself.""" diff --git a/omop_alchemy/toolkit/core/modifiers/metadata.py b/omop_alchemy/toolkit/core/modifiers/metadata.py index 43e1bbe..f1f174a 100644 --- a/omop_alchemy/toolkit/core/modifiers/metadata.py +++ b/omop_alchemy/toolkit/core/modifiers/metadata.py @@ -38,14 +38,6 @@ class UnsupportedModifierTargetError(UnsupportedModelError): model_kind = "modifier target" - def __init__(self, model: object, reason: str | None = None) -> None: - # Keep the historical message-only constructor usable for callers that - # instantiated this public exception directly. - if reason is None: - super().__init__(None, str(model), message=str(model)) - return - super().__init__(model, reason) - @dataclass(frozen=True, slots=True) class ModifierTargetModelSpec: diff --git a/omop_alchemy/toolkit/core/modifiers/projections.py b/omop_alchemy/toolkit/core/modifiers/projections.py index b1447cf..48d9d36 100644 --- a/omop_alchemy/toolkit/core/modifiers/projections.py +++ b/omop_alchemy/toolkit/core/modifiers/projections.py @@ -8,7 +8,7 @@ from omop_alchemy.toolkit._utils import _nullable_column, _select_or_union_all -from .contracts import CANONICAL_MODIFIER_VALUE_COLUMNS, ModifierColumn +from .contracts import ModifierColumn from .metadata import ( UnsupportedModifierSourceModelError, modifier_source_model_spec, @@ -56,7 +56,7 @@ def canonical_modifier_projection( if include_values: columns.extend( _nullable_column(model, column, _VALUE_COLUMN_TYPES[column]) - for column in CANONICAL_MODIFIER_VALUE_COLUMNS + for column in ModifierColumn.value_columns() ) return sa.select(*columns) diff --git a/omop_alchemy/toolkit/core/modifiers/targets.py b/omop_alchemy/toolkit/core/modifiers/targets.py index 35f6646..4cb9d62 100644 --- a/omop_alchemy/toolkit/core/modifiers/targets.py +++ b/omop_alchemy/toolkit/core/modifiers/targets.py @@ -12,7 +12,6 @@ from omop_alchemy.toolkit.core.events import ClinicalEventColumn from .contracts import ( - CANONICAL_MODIFIER_REQUIRED_COLUMNS, ModifierColumn, ModifierTargetDiagnosticCode, ModifierTargetDiagnosticColumn, @@ -108,7 +107,7 @@ def modifier_target_queries( ) _require_columns( modifiers.c.keys(), - tuple(str(column) for column in CANONICAL_MODIFIER_REQUIRED_COLUMNS), + tuple(str(column) for column in ModifierColumn.required_columns()), role="modifier source", error_type=InvalidModifierTargetSourceError, ) diff --git a/omop_alchemy/toolkit/episodes/derivation/attachments.py b/omop_alchemy/toolkit/episodes/derivation/attachments.py index 843d49d..56dec93 100644 --- a/omop_alchemy/toolkit/episodes/derivation/attachments.py +++ b/omop_alchemy/toolkit/episodes/derivation/attachments.py @@ -11,7 +11,6 @@ from omop_alchemy.cdm.model.structural import Episode, Episode_Event from omop_alchemy.toolkit._utils import _as_from_clause, _require_columns from omop_alchemy.toolkit.core.events import ( - CANONICAL_EVENT_REQUIRED_COLUMNS, ClinicalEventColumn, canonical_event_projection, ) @@ -299,7 +298,7 @@ def episode_attachment_queries( _require_columns( event_source.c.keys(), - tuple(str(column) for column in CANONICAL_EVENT_REQUIRED_COLUMNS), + tuple(str(column) for column in ClinicalEventColumn.required_columns()), role="events", error_type=InvalidAttachmentSourceError, ) diff --git a/tests/test_event_projections.py b/tests/test_event_projections.py index a83db10..6524c37 100644 --- a/tests/test_event_projections.py +++ b/tests/test_event_projections.py @@ -27,7 +27,7 @@ ObservationView, Procedure_OccurrenceView, ) -from omop_alchemy.cdm.base import ModifierTargetMixin +from omop_alchemy.cdm.base import ClinicalEventMixin, ModifierTargetMixin from omop_alchemy.cdm.base.event_metadata import ( UnsupportedClinicalEventModelError, ) @@ -35,10 +35,10 @@ from omop_alchemy.cdm.model.clinical.event_metadata import ( _validate_unique_target_keys, clinical_event_model_spec, + clinical_event_target_for_table, ) from omop_alchemy.toolkit.core.events import ( - CANONICAL_EVENT_OPTIONAL_COLUMNS, - CANONICAL_EVENT_REQUIRED_COLUMNS, + ClinicalEventColumn, canonical_event_projection, canonical_event_union, ) @@ -73,12 +73,21 @@ def test_projection_resolves_source_metadata( field_concept_id: int, ): spec = clinical_event_model_spec(model) + view = clinical_event_target_for_table(source_table) + assert issubclass(view, ClinicalEventMixin) + assert view.has_complete_metadata() + assert view.clinical_event_model_spec(model) == spec + assert view.clinical_event_model_spec() == spec statement = canonical_event_projection(model) assert spec.event_source_table == source_table assert spec.event_field_concept_id == field_concept_id assert tuple(statement.selected_columns.keys()) == tuple( - map(str, CANONICAL_EVENT_REQUIRED_COLUMNS + CANONICAL_EVENT_OPTIONAL_COLUMNS) + map( + str, + ClinicalEventColumn.required_columns() + + ClinicalEventColumn.optional_columns(), + ) ) @@ -115,7 +124,11 @@ def test_projection_union_preserves_one_shared_shape(): compiled = str(statement.compile(dialect=sqlite.dialect())) assert tuple(statement.selected_columns.keys()) == tuple( - map(str, CANONICAL_EVENT_REQUIRED_COLUMNS + CANONICAL_EVENT_OPTIONAL_COLUMNS) + map( + str, + ClinicalEventColumn.required_columns() + + ClinicalEventColumn.optional_columns(), + ) ) assert compiled.count("UNION ALL") == 2 @@ -123,19 +136,19 @@ def test_projection_union_preserves_one_shared_shape(): def test_incomplete_modifier_target_has_a_typed_error(): with pytest.raises( UnsupportedClinicalEventModelError, - match="no complete ModifierTargetMixin metadata", + match="no complete ClinicalEventMixin metadata", ) as raised: canonical_event_projection(Person) assert raised.value.model is Person - assert raised.value.reason == "no complete ModifierTargetMixin metadata is available" + assert raised.value.reason == "no complete ClinicalEventMixin metadata is available" @pytest.mark.parametrize("model", [Episode, EpisodeView]) def test_structural_modifier_targets_are_not_clinical_events(model): with pytest.raises( UnsupportedClinicalEventModelError, - match="no complete ModifierTargetMixin metadata", + match="no complete ClinicalEventMixin metadata", ): clinical_event_model_spec(model) @@ -167,6 +180,9 @@ def test_all_core_event_views_are_registered_episode_event_targets(): } assert {field: targets[field] for field in expected} == expected + assert all(issubclass(view, ClinicalEventMixin) for view in expected.values()) + assert issubclass(EpisodeView, ModifierTargetMixin) + assert not issubclass(EpisodeView, ClinicalEventMixin) assert all( not issubclass(model, ModifierTargetMixin) for model in ( @@ -194,6 +210,20 @@ def test_registered_event_views_have_distinct_field_concepts(): assert len(field_concepts) == len(set(field_concepts)) == 6 +def test_event_mixin_rejects_metadata_without_a_field_concept(): + class MissingFieldConcept(ClinicalEventMixin): + __event_id_col__ = "measurement_id" + __concept_id_col__ = "measurement_concept_id" + __start_date_col__ = "measurement_date" + + assert not MissingFieldConcept.has_complete_metadata() + with pytest.raises( + UnsupportedClinicalEventModelError, + match="no complete ClinicalEventMixin metadata", + ): + MissingFieldConcept.clinical_event_model_spec(Measurement) + + def test_analytics_import_preserves_all_core_metadata_and_compiled_projections(): code = textwrap.dedent( """ diff --git a/tests/test_modifier_projections.py b/tests/test_modifier_projections.py index 61fa6f8..4a6d215 100644 --- a/tests/test_modifier_projections.py +++ b/tests/test_modifier_projections.py @@ -11,7 +11,7 @@ from omop_alchemy.cdm.base import ( ModifierFieldConcepts, ModifierSourceMixin, - ModifierTargetMixin, + ClinicalEventMixin, ) from orm_loader.helpers import Base from omop_alchemy.cdm.model import ( @@ -41,8 +41,6 @@ ValuedModifierRow, ) from omop_alchemy.toolkit.core.modifiers import ( - CANONICAL_MODIFIER_REQUIRED_COLUMNS, - CANONICAL_MODIFIER_VALUE_COLUMNS, CDM_MODIFIER_SOURCE_MODELS, MODIFIER_SOURCE_MODEL_SPECS_BY_TABLE, MODIFIER_TARGET_SPECS_BY_TABLE, @@ -60,7 +58,7 @@ # The full projection shape, in UNION position order. Only the tests need the # whole; production code asks for the obligation it actually cares about. _ALL_MODIFIER_COLUMNS = ( - CANONICAL_MODIFIER_REQUIRED_COLUMNS + CANONICAL_MODIFIER_VALUE_COLUMNS + ModifierColumn.required_columns() + ModifierColumn.value_columns() ) @@ -114,14 +112,6 @@ def test_unsupported_modifier_target_error_preserves_model_and_reason(): assert raised.value.reason == "is not a supported modifier target" -def test_unsupported_modifier_target_error_keeps_message_only_compatibility(): - error = UnsupportedModifierTargetError("legacy target message") - - assert str(error) == "legacy target message" - assert error.model is None - assert error.reason == "legacy target message" - - def test_modifier_metadata_reuses_generic_model_interfaces(): # The target link is no longer described by the spec at all; it is read off # ModifierSourceMixin, which is asserted separately. @@ -169,13 +159,13 @@ def test_structural_groupers_never_enter_the_clinical_event_registry(): def test_canonical_column_vocabulary_is_stated_once(): - """The enum, the obligation tuples, and the row protocols must agree. + """The enum, its column groups, and the row protocols must agree. Each names the same columns for a different audience, and nothing in the language keeps them in step, so the agreement is asserted here. """ - assert not set(CANONICAL_MODIFIER_REQUIRED_COLUMNS) & set( - CANONICAL_MODIFIER_VALUE_COLUMNS + assert not set(ModifierColumn.required_columns()) & set( + ModifierColumn.value_columns() ), "a column cannot be both required and an optional value" assert set(_ALL_MODIFIER_COLUMNS) == set(ModifierColumn), ( "every ModifierColumn must be classified as required or value" @@ -183,15 +173,15 @@ def test_canonical_column_vocabulary_is_stated_once(): assert len(_ALL_MODIFIER_COLUMNS) == len(ModifierColumn) assert tuple(ModifierRow.__annotations__) == tuple( - map(str, CANONICAL_MODIFIER_REQUIRED_COLUMNS) + map(str, ModifierColumn.required_columns()) ) assert tuple(ValuedModifierRow.__annotations__) == tuple( - map(str, CANONICAL_MODIFIER_VALUE_COLUMNS) + map(str, ModifierColumn.value_columns()) ) # The projection casts each value position when a source lacks the column, # so every value column needs a declared SQL type to fall back to. - assert set(_VALUE_COLUMN_TYPES) == set(CANONICAL_MODIFIER_VALUE_COLUMNS) + assert set(_VALUE_COLUMN_TYPES) == set(ModifierColumn.value_columns()) def test_modifier_sources_declare_the_link_through_the_source_mixin(): @@ -220,7 +210,7 @@ def _make_custom_source(**overrides): class LocalBase(so.DeclarativeBase): pass - class CustomSource(LocalBase, ModifierSourceMixin, ModifierTargetMixin): + class CustomSource(LocalBase, ModifierSourceMixin, ClinicalEventMixin): __tablename__ = "custom_source" __event_id_col__ = "custom_source_id" __concept_id_col__ = "custom_concept_id" @@ -259,6 +249,12 @@ def test_a_new_modifier_source_needs_no_change_to_the_toolkit(): spec = modifier_source_model_spec(custom) assert spec.event_source_table == "custom_source" assert spec.event_id_column == "custom_source_id" + assert custom.has_complete_metadata() + assert custom.clinical_event_model_spec() == spec + assert custom.modifier_target_table() not in MODIFIER_TARGETS_BY_TABLE + assert custom.modifier_field_concept_id() not in ( + Episode_EventView.resolved_event_target_classes() + ) union = canonical_modifier_union(Measurement, custom) assert "UNION ALL" in str(union.compile(dialect=sqlite.dialect())) diff --git a/tests/test_query_builder_contracts.py b/tests/test_query_builder_contracts.py index a2c5090..ecb0647 100644 --- a/tests/test_query_builder_contracts.py +++ b/tests/test_query_builder_contracts.py @@ -13,8 +13,6 @@ RuntimeConceptSetSpec, ) from omop_alchemy.toolkit.core.events import ( - CANONICAL_EVENT_OPTIONAL_COLUMNS, - CANONICAL_EVENT_REQUIRED_COLUMNS, ClinicalEventColumn, ClinicalEventIdentity, ClinicalEventRow, @@ -47,10 +45,12 @@ def test_canonical_event_shape_has_unique_stable_names(): - all_columns = CANONICAL_EVENT_REQUIRED_COLUMNS + CANONICAL_EVENT_OPTIONAL_COLUMNS + all_columns = ( + ClinicalEventColumn.required_columns() + ClinicalEventColumn.optional_columns() + ) assert len(all_columns) == len(set(all_columns)) - assert tuple(str(column) for column in CANONICAL_EVENT_REQUIRED_COLUMNS) == ( + assert tuple(str(column) for column in ClinicalEventColumn.required_columns()) == ( "person_id", "event_id", "event_date", @@ -320,7 +320,7 @@ def test_required_projection_contract_compiles_without_execution(dialect): ) assert tuple(statement.selected_columns.keys()) == tuple( - str(column) for column in CANONICAL_EVENT_REQUIRED_COLUMNS + str(column) for column in ClinicalEventColumn.required_columns() ) assert "event_field_concept_id" in compiled assert "event_source_table" in compiled