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
4 changes: 0 additions & 4 deletions omop_alchemy/backends/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@
CONCEPT_NAME_TSVECTOR_COLUMN,
CONCEPT_SYNONYM_NAME_TSVECTOR_COLUMN,
FeatureNotSupportedError,
FullTextAction,
FullTextError,
FullTextResult,
FullTextTargetConfig,
backend_supports,
require_backend_support,
Expand All @@ -22,9 +20,7 @@
"CONCEPT_NAME_TSVECTOR_COLUMN",
"CONCEPT_SYNONYM_NAME_TSVECTOR_COLUMN",
"FeatureNotSupportedError",
"FullTextAction",
"FullTextError",
"FullTextResult",
"FullTextTargetConfig",
"backend_supports",
"require_backend_support",
Expand Down
20 changes: 0 additions & 20 deletions omop_alchemy/backends/base.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import StrEnum
from typing import TYPE_CHECKING, Any
import sqlalchemy as sa

Expand All @@ -24,25 +23,6 @@ class FullTextTargetConfig:
index_name: str


class FullTextAction(StrEnum):
INSTALL = "install"
POPULATE = "populate"
DROP = "drop"


@dataclass(frozen=True)
class FullTextResult:
target_name: str
table_name: str
source_column_name: str
vector_column_name: str
index_name: str
action: FullTextAction
status: str
detail: str
row_count: int | None = None


class FullTextError(RuntimeError):
"""Raised when a full-text search maintenance operation fails."""

Expand Down
5 changes: 3 additions & 2 deletions omop_alchemy/cdm/model/vocabulary/concept_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
cdm_table,
CDMTableBase,
merge_table_args,
omop_index,
omop_primary_key_index_name,
omop_table_options,
)

@cdm_table
class Concept_Class(Base, ReferenceTable, CDMTableBase):
__tablename__ = "concept_class"
__table_args__ = merge_table_args(
omop_index(__tablename__, "concept_class_id", cluster=True)
omop_table_options(cluster_on=omop_primary_key_index_name("concept_class")),
)
concept_class_id: so.Mapped[str] = so.mapped_column(sa.String(20), primary_key=True)
concept_class_name: so.Mapped[str] = so.mapped_column(sa.String(255), nullable=False)
Expand Down
5 changes: 3 additions & 2 deletions omop_alchemy/cdm/model/vocabulary/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
cdm_table,
CDMTableBase,
merge_table_args,
omop_index
omop_primary_key_index_name,
omop_table_options,
)

@cdm_table
class Domain(Base, ReferenceTable, CDMTableBase):
__tablename__ = "domain"
__table_args__ = merge_table_args(
omop_index(__tablename__, "domain_id", cluster=True),
omop_table_options(cluster_on=omop_primary_key_index_name("domain")),
)
domain_id: so.Mapped[str] = so.mapped_column(sa.String(20), primary_key=True)
domain_name: so.Mapped[str] = so.mapped_column(sa.String(255), nullable=False)
Expand Down
5 changes: 3 additions & 2 deletions omop_alchemy/cdm/model/vocabulary/relationship.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,15 @@
cdm_table,
CDMTableBase,
merge_table_args,
omop_index
omop_primary_key_index_name,
omop_table_options,
)

@cdm_table
class Relationship(Base, ReferenceTable, CDMTableBase):
__tablename__ = "relationship"
__table_args__ = merge_table_args(
omop_index(__tablename__, "relationship_id", cluster=True),
omop_table_options(cluster_on=omop_primary_key_index_name("relationship")),
)
relationship_id: so.Mapped[str] = so.mapped_column(sa.String(20), primary_key=True)
relationship_name: so.Mapped[str] = so.mapped_column(sa.String(255), nullable=False)
Expand Down
5 changes: 3 additions & 2 deletions omop_alchemy/cdm/model/vocabulary/vocabulary.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@
cdm_table,
CDMTableBase,
merge_table_args,
omop_index
omop_primary_key_index_name,
omop_table_options,
)

@cdm_table
class Vocabulary(Base, ReferenceTable, CDMTableBase):
__tablename__ = "vocabulary"
__table_args__ = merge_table_args(
omop_index(__tablename__, "vocabulary_id", cluster=True),
omop_table_options(cluster_on=omop_primary_key_index_name("vocabulary")),
)
vocabulary_id: so.Mapped[str] = so.mapped_column(sa.String(20), primary_key=True)
vocabulary_name: so.Mapped[str] = so.mapped_column(sa.String(255), nullable=False)
Expand Down
116 changes: 109 additions & 7 deletions omop_alchemy/maintenance/_cli_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,121 @@
import functools
import inspect
from dataclasses import dataclass
from enum import StrEnum
from typing import Any, Callable, TypeVar

import sqlalchemy as sa
import typer
from orm_loader.backends import STAGING_SCHEMA
from sqlalchemy.exc import SQLAlchemyError

from .tables import TableScope
from .tables import TableCategory
from .ui import console, render_error, render_command_header
from ..backends import BackendNotSupportedError, resolve_backend


_F = TypeVar("_F", bound=Callable[..., Any])


class ReservedSchema(StrEnum):
"""Schema names reserved for OMOP_Alchemy/orm-loader internal bookkeeping.
A user-configured db_schema may never collide with one of these.
"""

STAGING = STAGING_SCHEMA
MAINTENANCE = "omop_alchemy_maintenance"


def reject_reserved_schema(db_schema: str | None) -> None:
"""Raise if db_schema collides with a schema name reserved for internal bookkeeping."""
if db_schema in set(ReservedSchema):
raise RuntimeError(
f"db_schema cannot be {db_schema!r}: reserved for OMOP_Alchemy/orm-loader internal use."
)


class Severity(StrEnum):
"""Coarse-grained outcome classification shared by every maintenance
command's status vocabulary.

Parameters
----------
code : str
The severity's string value.
style : str
Rich color/style name used to render any Status with this severity.
"""

def __new__(cls, code: str, style: str):
obj = str.__new__(cls, code)
obj._value_ = code
return obj

def __init__(self, code: str, style: str):
self.style = style

OK = ("ok", "green")
INFO = ("info", "cyan")
WARNING = ("warning", "yellow")
ERROR = ("error", "red")


class Status(StrEnum):
"""A maintenance command result status, carrying its Severity (and, via
Severity.style, its render color).

Parameters
----------
code : str
The status's string value (unchanged from the plain strings used
before this type existed, so existing string comparisons/dict
lookups by value keep working).
severity : Severity
How severe this status is, and (via severity.style) how it renders.
"""

def __new__(cls, code: str, severity: Severity):
obj = str.__new__(cls, code)
obj._value_ = code
return obj

def __init__(self, code: str, severity: Severity):
self.severity = severity

# -- shared across every dry-run/apply-style domain --
PLANNED = ("planned", Severity.INFO)
APPLIED = ("applied", Severity.OK)
SKIPPED = ("skipped", Severity.WARNING)

# -- domain-specific "applied" words (backup, index restore/capture,
# sequence reset, vocab load) -- same OK severity as APPLIED, kept as
# distinct words since the specific outcome is worth seeing at a glance --
CREATED = ("created", Severity.OK)
LOADED = ("loaded", Severity.OK)
RESET = ("reset", Severity.OK)
RESTORED = ("restored", Severity.OK)
CAPTURED = ("captured", Severity.OK)
READY = ("ready", Severity.OK)
PASSED = ("passed", Severity.OK)
MATCHED = ("matched", Severity.OK)

# -- warnings: something worth a look, but not blocking --
WARNING = ("warning", Severity.WARNING)
LIMITED = ("limited", Severity.WARNING)
DRIFTED = ("drifted", Severity.WARNING)

# -- informational: an intentionally supported state --
RENAMED = ("renamed", Severity.INFO)

# -- errors/failures --
MISSING = ("missing", Severity.ERROR)
UNEXPECTED = ("unexpected", Severity.ERROR)
MISMATCH = ("mismatch", Severity.ERROR)
BLOCKED = ("blocked", Severity.ERROR)
UNSUPPORTED = ("unsupported", Severity.ERROR)
FAILED = ("failed", Severity.ERROR)


@dataclass(frozen=True)
class _ConnContext:
"""Connection context derived from the oa_configurator resolved resource."""
Expand Down Expand Up @@ -53,6 +154,7 @@ def wrapper(**kwargs: Any) -> Any:
try:
from ..config import create_cdm_engine, get_cdm_context
pkg_config, resolved = get_cdm_context()
reject_reserved_schema(resolved.cdm_schema)
engine = create_cdm_engine(resolved)
conn = _ConnContext(
db_schema=resolved.cdm_schema,
Expand Down Expand Up @@ -139,9 +241,9 @@ def handle_error(exc: Exception) -> None:
raise exc


def dry_status(dry_run: bool, applied: str = "applied") -> str:
"""Return 'planned' when dry_run is True, otherwise the applied label."""
return "planned" if dry_run else applied
def dry_status(dry_run: bool, applied: Status = Status.APPLIED) -> Status:
"""Return Status.PLANNED when dry_run is True, otherwise the applied status."""
return Status.PLANNED if dry_run else applied


def dry_label(dry_run: bool, planned: str, applied: str) -> str:
Expand All @@ -151,10 +253,10 @@ def dry_label(dry_run: bool, planned: str, applied: str) -> str:

def resolve_selection(
*,
scope: TableScope | None,
scope: TableCategory | None,
tables: list[str] | None,
default_scope: TableScope | None = None,
) -> tuple[TableScope | None, tuple[str, ...] | None]:
default_scope: TableCategory | None = None,
) -> tuple[TableCategory | None, tuple[str, ...] | None]:
if scope is not None and tables:
raise RuntimeError("Use either `--scope` or `--table`, not both.")
selected = tuple(tables) if tables else None
Expand Down
8 changes: 5 additions & 3 deletions omop_alchemy/maintenance/cli_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import typer

from ..backends import resolve_backend, require_backend_support, backend_support_note
from ._cli_utils import dry_label, dry_status, omop_command
from ._cli_utils import Status, dry_label, dry_status, omop_command, reject_reserved_schema
from .ui import (
console,
render_backup_result,
Expand Down Expand Up @@ -41,7 +41,7 @@ class BackupResult:

file_path: str
backup_format: BackupFormat
status: str
status: Status
detail: str
database_name: str
backend: str
Expand All @@ -65,6 +65,7 @@ def create_database_backup(
dry_run: bool = False,
) -> BackupResult:
"""Create a database backup artifact at output_path. Runs the subprocess unless dry_run is True."""
reject_reserved_schema(db_schema)
backend = resolve_backend(engine)
require_backend_support(backend, "prepare_backup", "Database backup")
resolved_output_path = Path(output_path) if output_path is not None else _default_output_path(backup_format)
Expand Down Expand Up @@ -92,7 +93,7 @@ def create_database_backup(
return BackupResult(
file_path=str(resolved_output_path),
backup_format=backup_format,
status=dry_status(dry_run, applied="created"),
status=dry_status(dry_run, applied=Status.CREATED),
detail=dry_label(dry_run, "Database backup would be created with pg_dump.", "Database backup created with pg_dump."),
database_name=database_name,
backend=engine.dialect.name,
Expand All @@ -111,6 +112,7 @@ def restore_database_backup(
dry_run: bool = False,
) -> BackupResult:
"""Restore a database backup. Runs the subprocess unless dry_run is True."""
reject_reserved_schema(db_schema)
backend = resolve_backend(engine)
require_backend_support(backend, "prepare_restore", "Database restore")
resolved_input_path = Path(input_path).expanduser().resolve()
Expand Down
11 changes: 6 additions & 5 deletions omop_alchemy/maintenance/cli_foreign_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import typer

from ..backends import Backend, resolve_backend, require_backend_support, backend_support_note
from ._cli_utils import dry_label, dry_status, omop_command
from ._cli_utils import Status, dry_label, dry_status, omop_command, reject_reserved_schema
from .tables import (
TableCategory,
existing_maintenance_tables,
Expand Down Expand Up @@ -46,7 +46,7 @@ class _FKTableInfo(ForeignKeyBase):
class ForeignKeyManagementResult(_FKTableInfo):
"""Outcome of a FK trigger enable or disable operation for one table."""
enable: bool
status: str
status: Status
detail: str


Expand All @@ -62,7 +62,7 @@ class ForeignKeyValidationResult(_FKTableInfo):
"""FK constraint validation outcome for one table, with counts of violating constraints and rows."""
violating_constraint_count: int
violating_row_count: int
status: str
status: Status
detail: str


Expand Down Expand Up @@ -257,7 +257,7 @@ def validate_foreign_key_constraints(
incoming_constraint_count=target.incoming_constraint_count,
violating_constraint_count=violating_constraint_count,
violating_row_count=violating_row_count,
status="failed" if violations else "passed",
status=Status.FAILED if violations else Status.PASSED,
detail=(
_fk_violation_detail(violations)
if violations
Expand Down Expand Up @@ -286,6 +286,7 @@ def manage_foreign_key_triggers(
strict: bool = False,
) -> list[ForeignKeyManagementResult]:
"""Enable or disable RI trigger enforcement. With strict=True, aborts on any FK violation."""
reject_reserved_schema(db_schema)
backend = resolve_backend(engine)
require_backend_support(backend, "toggle_fk_triggers", "FK trigger management")

Expand Down Expand Up @@ -314,7 +315,7 @@ def manage_foreign_key_triggers(
outgoing_constraint_count=target.outgoing_constraint_count,
incoming_constraint_count=target.incoming_constraint_count,
enable=enable,
status="failed" if violations else "skipped",
status=Status.FAILED if violations else Status.SKIPPED,
detail=(
_fk_violation_detail(violations, strict_abort=True)
if violations
Expand Down
Loading
Loading