Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/api/query.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# 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.

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
from omop_alchemy.cdm.model.vocabulary import Concept
from omop_alchemy.cdm.query import ConceptFilter

concept_filter = ConceptFilter(
domains=("Condition", "Drug"),
require_standard=True,
)
query = concept_filter.apply(select(Concept))
```

All fields are optional and combinable.

::: omop_alchemy.cdm.query.ConceptFilter
options:
heading_level: 3
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ nav:
- CDM Base: api/base.md
- Columns: api/columns.md
- Relationships: api/relationships.md
- Query Filtering: api/query.md
- Typing: api/typing.md

- Object-Relational Mappings:
Expand Down
12 changes: 11 additions & 1 deletion omop_alchemy/cdm/model/vocabulary/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
from .concept_ancestor import Concept_Ancestor
from .concept_class import Concept_Class
from .concept import Concept, ConceptContext, ConceptView
from .concept import (
Concept,
ConceptContext,
ConceptView,
InvalidReasonFlag,
StandardConceptFlag,
normalised_flag_expr,
)
from .concept_relationship import Concept_Relationship
from .domain import Domain
from .relationship import Relationship
Expand All @@ -15,6 +22,9 @@
"Concept",
"ConceptContext",
"ConceptView",
"InvalidReasonFlag",
"StandardConceptFlag",
"normalised_flag_expr",
"Concept_Relationship",
"Domain",
"Relationship",
Expand Down
67 changes: 58 additions & 9 deletions omop_alchemy/cdm/model/vocabulary/concept.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import sqlalchemy as sa
import sqlalchemy.orm as so
from sqlalchemy.ext.declarative import declared_attr
from enum import StrEnum, nonmember
from typing import Optional, TYPE_CHECKING, List
from datetime import date
if TYPE_CHECKING:
Expand All @@ -22,6 +23,43 @@
omop_table_options,
)


class StandardConceptFlag(StrEnum):
"""Allowed non-null values of ``concept.standard_concept`` (OMOP CDM v5.4)."""

STANDARD = "S"
CLASSIFICATION = "C"

# Precomputed membership set for the hot Python-side check (Concept.is_standard) --
# re-deriving this from the enum on every call is measurably expensive (~10x).
values = nonmember(frozenset({STANDARD, CLASSIFICATION}))


class InvalidReasonFlag(StrEnum):
"""Allowed non-null values of ``concept.invalid_reason`` (OMOP CDM v5.4)."""

DELETED = "D"
UPDATED = "U"


def normalised_flag_expr(
column: sa.SQLColumnExpression[Optional[str]],
) -> sa.SQLColumnExpression[Optional[str]]:
"""Return a canonical OMOP flag expression.

OMOP CDM v5.4 allows only ``NULL``/``'S'``/``'C'`` for ``standard_concept``
and ``NULL``/``'D'``/``'U'`` for ``invalid_reason``. Some real-world loads
contain blank or whitespace-only strings instead of ``NULL``; those are
normalised here defensively so callers do not need to reimplement the same
tolerance logic.

Non-empty non-canonical values are left unchanged so downstream validation
can still detect them as bad data rather than silently treating them as a
valid state.
"""
return sa.func.nullif(sa.func.trim(column), "")


@cdm_table
class Concept(
ReferenceTable,
Expand Down Expand Up @@ -54,6 +92,26 @@ class Concept(
valid_end_date: so.Mapped[date] = so.mapped_column(sa.Date(), nullable=False)
invalid_reason: so.Mapped[Optional[str]] = so.mapped_column(sa.String(1), nullable=True)

@property
def is_standard(self) -> bool:
value = self.standard_concept.strip() if self.standard_concept is not None else ""
return bool(value) and value in StandardConceptFlag.values

@classmethod
def is_standard_expr(cls) -> sa.SQLColumnExpression[bool]:
"""SQL-side counterpart to :attr:`is_standard`, for use in query filters."""
return normalised_flag_expr(cls.standard_concept).in_(StandardConceptFlag.values)

@property
def is_valid(self) -> bool:
value = self.invalid_reason.strip() if self.invalid_reason is not None else ""
return not value

@classmethod
def is_valid_expr(cls) -> sa.SQLColumnExpression[bool]:
"""SQL-side counterpart to :attr:`is_valid`, for use in query filters."""
return normalised_flag_expr(cls.invalid_reason).is_(None)

class ConceptContext(ReferenceContext):
"""
Navigational relationships for Concept.
Expand Down Expand Up @@ -119,12 +177,3 @@ class ConceptView(Concept, ConceptContext):
"""
__tablename__ = "concept"
__mapper_args__ = {"concrete": False}


@property
def is_standard(self) -> bool:
return self.standard_concept == "S"

@property
def is_valid(self) -> bool:
return self.invalid_reason is None
87 changes: 87 additions & 0 deletions omop_alchemy/cdm/query.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Shared CDM concept-table query filtering.

Consolidates filtering logic previously duplicated across downstream packages
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Optional

import sqlalchemy as sa

from omop_alchemy.cdm.model.vocabulary import Concept


@dataclass(frozen=True)
class ConceptFilter:
"""Search constraints for plain CDM ``concept``-table queries.

All fields are optional. Unset fields impose no constraint.

Attributes
----------
concept_ids : tuple[int, ...], optional
Restrict results to this set of concept IDs.
domains : tuple[str, ...], optional
Restrict results to concepts in these OMOP domains.
vocabularies : tuple[str, ...], optional
Restrict results to concepts from these vocabularies.
require_standard : bool
When ``True``, only concepts where ``Concept.is_standard`` is
``True`` are returned (``standard_concept`` in ``('S', 'C')``,
tolerating blank/whitespace-only values as unset). Default ``False``.
require_active : bool
When ``True``, only concepts where ``Concept.is_valid`` is ``True``
are returned (``invalid_reason`` is ``NULL``/blank/whitespace, i.e.
not ``'D'`` or ``'U'``). Default ``False``.
limit : int, optional
Maximum number of rows to return. If not set, all matching rows are
returned.
"""

concept_ids: Optional[tuple[int, ...]] = None
domains: Optional[tuple[str, ...]] = None
vocabularies: Optional[tuple[str, ...]] = None
require_standard: bool = False
require_active: bool = False
limit: Optional[int] = None

def __post_init__(self) -> None:
if self.limit is not None and self.limit <= 0:
raise ValueError(
f"ConceptFilter.limit must be a positive integer, got {self.limit}."
)

def apply(self, query: sa.Select) -> sa.Select:
"""Apply filter constraints to a Select already targeting Concept."""
if self.concept_ids is not None:
query = query.where(Concept.concept_id.in_(self.concept_ids))

if self.domains is not None:
query = query.where(Concept.domain_id.in_(self.domains))

if self.vocabularies is not None:
query = query.where(Concept.vocabulary_id.in_(self.vocabularies))

if self.require_standard:
query = query.where(Concept.is_standard_expr())

if self.require_active:
query = query.where(Concept.is_valid_expr())

if self.limit is not None:
query = query.limit(self.limit)

return query

def is_empty(self) -> bool:
"""Return ``True`` if no constraints are set."""
return (
self.concept_ids is None
and self.domains is None
and self.vocabularies is None
and not self.require_standard
and not self.require_active
and self.limit is None
)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ dev = [
"requests>=2.33.0",
"pytest>=9.0.3",
"pytest-cov>=4.0",
"ty>=0.0.59",
"ty==0.0.61",
"ruff>=0.4",
"mkdocs-material>=9.7.1",
"mkdocstrings-python>=2.0.1",
Expand Down
Loading
Loading