omop-constructs 0.2.1__tar.gz → 0.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/PKG-INFO +2 -2
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/pyproject.toml +2 -2
- omop_constructs-0.2.3/src/omop_constructs/alchemy/episodes/__init__.py +13 -0
- omop_constructs-0.2.3/src/omop_constructs/alchemy/episodes/condition_episode_mv.py +65 -0
- omop_constructs-0.2.3/src/omop_constructs/alchemy/episodes/episode_factories.py +98 -0
- omop_constructs-0.2.3/src/omop_constructs/alchemy/episodes/episode_joins.py +22 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/alchemy/modifiers/modifier_joins.py +5 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/alchemy/modifiers/modifier_mappers.py +9 -33
- omop_constructs-0.2.3/src/omop_constructs/core/registry.py +274 -0
- omop_constructs-0.2.1/src/omop_constructs/core/registry.py +0 -115
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/README.md +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/__init__.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/alchemy/__init__.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/alchemy/adapters.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/alchemy/concepts/__init__.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/alchemy/concepts/concept_mappers.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/alchemy/concepts/concept_standardisation.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/alchemy/conditions/condition_joins.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/alchemy/conditions/condition_mappers.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/alchemy/modifiers/__init__.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/alchemy/modifiers/condition_modifier_join.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/alchemy/modifiers/condition_modifier_mv.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/alchemy/modifiers/modifier_factories.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/alchemy/source_factories.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/core/__init__.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/core/base.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/core/constructs.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/core/ddl.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/core/errors.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/core/materialized.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/core/plan.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/py.typed +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/semantics/__init__.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/semantics/adapters.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/semantics/lookups.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/semantics/runtime_lookups.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/services/__init__.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/services/concept.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/services/person.py +0 -0
- {omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/typing.py +0 -0
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: omop-constructs
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: For building complex constructs on top of the omop-alchemy library.
|
|
5
5
|
Author: Georgie Kennedy
|
|
6
6
|
Author-email: Georgie Kennedy <georgina.kennedy@unsw.edu.au>
|
|
7
|
-
Requires-Dist: omop-alchemy>=0.5.
|
|
7
|
+
Requires-Dist: omop-alchemy>=0.5.12
|
|
8
8
|
Requires-Dist: omop-semantics>=0.1.8
|
|
9
9
|
Requires-Dist: psycopg2-binary>=2.9.11
|
|
10
10
|
Requires-Dist: sqlalchemy>=2.0.45
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "omop-constructs"
|
|
3
|
-
version = "0.2.
|
|
3
|
+
version = "0.2.3"
|
|
4
4
|
description = "For building complex constructs on top of the omop-alchemy library."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -8,7 +8,7 @@ authors = [
|
|
|
8
8
|
]
|
|
9
9
|
requires-python = ">=3.12"
|
|
10
10
|
dependencies = [
|
|
11
|
-
"omop-alchemy>=0.5.
|
|
11
|
+
"omop-alchemy>=0.5.12",
|
|
12
12
|
"omop-semantics>=0.1.8",
|
|
13
13
|
"psycopg2-binary>=2.9.11",
|
|
14
14
|
"sqlalchemy>=2.0.45",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .condition_episode_mv import OverarchingDiseaseEpisodeMV
|
|
2
|
+
from .episode_joins import episode_of_care_select, disease_extent_select, overarching_disease_episode
|
|
3
|
+
from .episode_factories import get_episode_query, require_condition_anchor, get_episode_hierarchy_query
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"OverarchingDiseaseEpisodeMV",
|
|
7
|
+
"episode_of_care_select",
|
|
8
|
+
"disease_extent_select",
|
|
9
|
+
"overarching_disease_episode",
|
|
10
|
+
"get_episode_query",
|
|
11
|
+
"require_condition_anchor",
|
|
12
|
+
"get_episode_hierarchy_query",
|
|
13
|
+
]
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import sqlalchemy as sa
|
|
2
|
+
from orm_loader.helpers import Base
|
|
3
|
+
from .episode_joins import overarching_disease_episode
|
|
4
|
+
from ...core.materialized import MaterializedViewMixin
|
|
5
|
+
from ...core.constructs import register_construct
|
|
6
|
+
|
|
7
|
+
class OverarchingDiseaseEpisodeCols:
|
|
8
|
+
__table_args__ = {"extend_existing": True}
|
|
9
|
+
|
|
10
|
+
disease_episode_id = sa.Column(sa.Integer, primary_key=True)
|
|
11
|
+
person_id = sa.Column(sa.Integer)
|
|
12
|
+
|
|
13
|
+
disease_episode_concept_id = sa.Column(sa.Integer)
|
|
14
|
+
disease_episode_label = sa.Column(sa.String)
|
|
15
|
+
|
|
16
|
+
disease_episode_start_date = sa.Column(sa.Date)
|
|
17
|
+
disease_episode_end_date = sa.Column(sa.Date)
|
|
18
|
+
|
|
19
|
+
extent_episode_id = sa.Column(sa.Integer)
|
|
20
|
+
extent_episode_concept_id = sa.Column(sa.Integer)
|
|
21
|
+
extent_episode_label = sa.Column(sa.String)
|
|
22
|
+
|
|
23
|
+
extent_start_date = sa.Column(sa.Date)
|
|
24
|
+
extent_end_date = sa.Column(sa.Date)
|
|
25
|
+
|
|
26
|
+
@register_construct
|
|
27
|
+
class OverarchingDiseaseEpisodeMV(
|
|
28
|
+
OverarchingDiseaseEpisodeCols,
|
|
29
|
+
MaterializedViewMixin,
|
|
30
|
+
Base,
|
|
31
|
+
):
|
|
32
|
+
"""
|
|
33
|
+
Materialized view representing overarching disease episodes
|
|
34
|
+
with optional child disease-extent episodes.
|
|
35
|
+
|
|
36
|
+
One row per (episode_of_care, disease_extent_episode?) pair.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
__mv_name__ = "overarching_disease_episode_mv"
|
|
40
|
+
__mv_select__ = overarching_disease_episode.select()
|
|
41
|
+
__mv_index__ = "disease_episode_id"
|
|
42
|
+
__deps__ = ()
|
|
43
|
+
__tablename__ = __mv_name__
|
|
44
|
+
|
|
45
|
+
def __repr__(self) -> str:
|
|
46
|
+
if self.has_extent:
|
|
47
|
+
return (
|
|
48
|
+
f"<OverarchingDiseaseEpisode "
|
|
49
|
+
f"{self.disease_episode_id} -> extent {self.extent_episode_id}>"
|
|
50
|
+
)
|
|
51
|
+
return f"<OverarchingDiseaseEpisode {self.disease_episode_id}>"
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def has_extent(self) -> bool:
|
|
55
|
+
return self.extent_episode_id is not None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def disease_interval(self):
|
|
59
|
+
return (self.disease_episode_start_date, self.disease_episode_end_date)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def extent_interval(self):
|
|
63
|
+
if self.extent_episode_id is None:
|
|
64
|
+
return None
|
|
65
|
+
return (self.extent_start_date, self.extent_end_date)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import sqlalchemy as sa
|
|
2
|
+
import sqlalchemy.orm as so
|
|
3
|
+
from typing import Iterable
|
|
4
|
+
from omop_alchemy.cdm.model import (
|
|
5
|
+
Condition_Occurrence,
|
|
6
|
+
Episode,
|
|
7
|
+
Concept,
|
|
8
|
+
Episode_Event
|
|
9
|
+
)
|
|
10
|
+
from omop_semantics.runtime.default_valuesets import runtime
|
|
11
|
+
|
|
12
|
+
episode_concept = so.aliased(Concept, name="episode_concept")
|
|
13
|
+
|
|
14
|
+
def get_episode_query(
|
|
15
|
+
episode_concept_ids: Iterable[int],
|
|
16
|
+
name: str = "episode_construct",
|
|
17
|
+
) -> sa.Subquery:
|
|
18
|
+
"""
|
|
19
|
+
Base query for Episode-derived constructs.
|
|
20
|
+
Guarantees at most 1 row per episode_id.
|
|
21
|
+
"""
|
|
22
|
+
return (
|
|
23
|
+
sa.select(
|
|
24
|
+
Episode.episode_id,
|
|
25
|
+
Episode.person_id,
|
|
26
|
+
Episode.episode_start_date,
|
|
27
|
+
Episode.episode_start_datetime,
|
|
28
|
+
Episode.episode_end_date,
|
|
29
|
+
Episode.episode_end_datetime,
|
|
30
|
+
Episode.episode_concept_id,
|
|
31
|
+
episode_concept.concept_name.label("episode_label"),
|
|
32
|
+
Episode.episode_object_concept_id,
|
|
33
|
+
Episode.episode_type_concept_id,
|
|
34
|
+
Episode.episode_parent_id,
|
|
35
|
+
)
|
|
36
|
+
.join(
|
|
37
|
+
episode_concept,
|
|
38
|
+
episode_concept.concept_id == Episode.episode_concept_id,
|
|
39
|
+
isouter=True,
|
|
40
|
+
)
|
|
41
|
+
.where(Episode.episode_concept_id.in_(list(episode_concept_ids)))
|
|
42
|
+
.subquery(name=name)
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def require_condition_anchor(
|
|
46
|
+
episode_subq: sa.Subquery,
|
|
47
|
+
name: str | None = None,
|
|
48
|
+
) -> sa.Subquery:
|
|
49
|
+
"""
|
|
50
|
+
Filters an Episode subquery to only episodes with at least one
|
|
51
|
+
linked Condition_Occurrence via Episode_Event.
|
|
52
|
+
"""
|
|
53
|
+
exists_condition = sa.exists().where(
|
|
54
|
+
sa.and_(
|
|
55
|
+
Episode_Event.episode_id == episode_subq.c.episode_id,
|
|
56
|
+
Episode_Event.episode_event_field_concept_id
|
|
57
|
+
== runtime.modifiers.modifier_fields.condition_occurrence_id,
|
|
58
|
+
Condition_Occurrence.condition_occurrence_id == Episode_Event.event_id,
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
return (
|
|
62
|
+
sa.select(*episode_subq.c)
|
|
63
|
+
.where(exists_condition)
|
|
64
|
+
.subquery(name=name or episode_subq.name)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_episode_hierarchy_query(
|
|
69
|
+
parent_episode_subq: sa.Subquery,
|
|
70
|
+
child_episode_subq: sa.Subquery,
|
|
71
|
+
name: str = "episode_optional_children",
|
|
72
|
+
) -> sa.Subquery:
|
|
73
|
+
"""
|
|
74
|
+
Expands an episode subquery to optionally include child episodes.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
sa.select(
|
|
79
|
+
parent_episode_subq.c.episode_id.label("parent_episode_id"),
|
|
80
|
+
parent_episode_subq.c.person_id.label("person_id"),
|
|
81
|
+
parent_episode_subq.c.episode_concept_id.label("parent_episode_concept_id"),
|
|
82
|
+
parent_episode_subq.c.episode_label.label("parent_episode_label"),
|
|
83
|
+
parent_episode_subq.c.episode_start_date.label("parent_start_date"),
|
|
84
|
+
parent_episode_subq.c.episode_end_date.label("parent_end_date"),
|
|
85
|
+
|
|
86
|
+
child_episode_subq.c.episode_id.label("child_episode_id"),
|
|
87
|
+
child_episode_subq.c.episode_concept_id.label("child_episode_concept_id"),
|
|
88
|
+
child_episode_subq.c.episode_label.label("child_episode_label"),
|
|
89
|
+
child_episode_subq.c.episode_start_date.label("child_start_date"),
|
|
90
|
+
child_episode_subq.c.episode_end_date.label("child_end_date"),
|
|
91
|
+
)
|
|
92
|
+
.join(
|
|
93
|
+
child_episode_subq,
|
|
94
|
+
child_episode_subq.c.episode_parent_id == parent_episode_subq.c.episode_id,
|
|
95
|
+
isouter=True,
|
|
96
|
+
)
|
|
97
|
+
.subquery(name=name)
|
|
98
|
+
)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from .episode_factories import require_condition_anchor, get_episode_query, get_episode_hierarchy_query
|
|
2
|
+
from omop_semantics.runtime.default_valuesets import runtime
|
|
3
|
+
|
|
4
|
+
episode_of_care_select = require_condition_anchor(
|
|
5
|
+
get_episode_query(
|
|
6
|
+
[runtime.episodes.disease_episode_types.episode_of_care], # type: ignore
|
|
7
|
+
name="episode_of_care"
|
|
8
|
+
)
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
disease_extent_select = require_condition_anchor(
|
|
12
|
+
get_episode_query(
|
|
13
|
+
[runtime.episodes.disease_episode_types.metastatic, runtime.episodes.disease_episode_types.disease_progression], # type: ignore
|
|
14
|
+
name="disease_extent"
|
|
15
|
+
)
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
overarching_disease_episode = get_episode_hierarchy_query(
|
|
19
|
+
episode_of_care_select,
|
|
20
|
+
disease_extent_select,
|
|
21
|
+
name="overarching_disease_episode",
|
|
22
|
+
)
|
|
@@ -35,4 +35,9 @@ size_select = earliest_modifier(
|
|
|
35
35
|
grade_select = earliest_modifier(
|
|
36
36
|
get_direct_modifier_query(list(registry['tumor_grade'].all_concepts), name="tumor_grade"),
|
|
37
37
|
name="tumor_grade_earliest"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
mets_select = earliest_modifier(
|
|
41
|
+
get_direct_modifier_query(list(registry['metastatic_disease'].all_concepts), name="metastatic_disease"),
|
|
42
|
+
name="metastatic_disease_earliest"
|
|
38
43
|
)
|
|
@@ -8,6 +8,7 @@ from .modifier_joins import (
|
|
|
8
8
|
laterality_select,
|
|
9
9
|
size_select,
|
|
10
10
|
grade_select,
|
|
11
|
+
mets_select
|
|
11
12
|
)
|
|
12
13
|
from ...core.materialized import MaterializedViewMixin
|
|
13
14
|
from ...core.constructs import register_construct
|
|
@@ -102,37 +103,12 @@ class LateralityModifierMV(MeasModCols, MaterializedViewMixin, Base):
|
|
|
102
103
|
__mv_index__ = "measurement_event_id"
|
|
103
104
|
__deps__ = ()
|
|
104
105
|
__tablename__ = __mv_name__
|
|
105
|
-
value_as_concept_id = sa.Column(sa.Integer)
|
|
106
|
-
|
|
107
|
-
# class TStage(StageColumns, Base):
|
|
108
|
-
# __table__ = t_stage_select
|
|
109
|
-
# __tablename__ = 't_stage'
|
|
110
|
-
|
|
111
|
-
# class NStage(StageColumns, Base):
|
|
112
|
-
# __table__ = n_stage_select
|
|
113
|
-
# __tablename__ = 'n_stage'
|
|
114
|
-
|
|
115
|
-
# class MStage(StageColumns, Base):
|
|
116
|
-
# __table__ = m_stage_select
|
|
117
|
-
# __tablename__ = 'm_stage'
|
|
118
|
-
|
|
119
|
-
# class GroupStage(StageColumns, Base):
|
|
120
|
-
# __table__ = group_stage_select
|
|
121
|
-
# __tablename__ = 'group_stage'
|
|
122
|
-
|
|
123
|
-
# class SizeModifier(MeasModCols, Base):
|
|
124
|
-
# __table__ = size_select
|
|
125
|
-
# __tablename__ = 'size_modifier'
|
|
126
|
-
# value_as_number = size_select.c.value_as_number
|
|
127
|
-
# unit_concept_id = size_select.c.unit_concept_id
|
|
128
|
-
|
|
129
|
-
# class GradeModifier(MeasModCols, Base):
|
|
130
|
-
# __table__ = grade_select
|
|
131
|
-
# __tablename__ = 'grade_modifier'
|
|
132
|
-
# measurement_concept_id = grade_select.c.measurement_concept_id
|
|
133
|
-
|
|
134
|
-
# class LatModifier(MeasModCols, Base):
|
|
135
|
-
# __table__ = laterality_select
|
|
136
|
-
# __tablename__ = 'laterality_modifier'
|
|
137
|
-
# value_as_concept_id = laterality_select.c.value_as_concept_id
|
|
138
106
|
|
|
107
|
+
@register_construct
|
|
108
|
+
class MetastaticDiseaseModifierMV(MeasModCols, MaterializedViewMixin, Base):
|
|
109
|
+
__mv_name__ = "metastatic_disease_modifier_mv"
|
|
110
|
+
__mv_select__ = mets_select.select()
|
|
111
|
+
__mv_index__ = "measurement_event_id"
|
|
112
|
+
__deps__ = ()
|
|
113
|
+
__tablename__ = __mv_name__
|
|
114
|
+
value_as_concept_id = sa.Column(sa.Integer)
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Iterable, Iterator, Type
|
|
4
|
+
import sqlalchemy as sa
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from .plan import ConstructNode, topo_sort
|
|
8
|
+
from ..typing import SupportsMaterializedView
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ConstructPlanItem:
|
|
12
|
+
name: str
|
|
13
|
+
kind: str
|
|
14
|
+
deps: tuple[str, ...]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def materialized_view_exists(bind, name: str, schema: str = "public") -> bool:
|
|
18
|
+
sql = sa.text("""
|
|
19
|
+
SELECT EXISTS (
|
|
20
|
+
SELECT 1
|
|
21
|
+
FROM pg_matviews
|
|
22
|
+
WHERE schemaname = :schema
|
|
23
|
+
AND matviewname = :name
|
|
24
|
+
)
|
|
25
|
+
""")
|
|
26
|
+
return bool(bind.execute(sql, {"schema": schema, "name": name}).scalar())
|
|
27
|
+
|
|
28
|
+
class ConstructRegistry:
|
|
29
|
+
"""
|
|
30
|
+
Registry of materialized views / constructs.
|
|
31
|
+
|
|
32
|
+
Handles creation, refresh, and teardown in a controlled way.
|
|
33
|
+
"""
|
|
34
|
+
_constructs: dict[str, Type[SupportsMaterializedView]]
|
|
35
|
+
|
|
36
|
+
def __init__(self, constructs: Iterable[type[SupportsMaterializedView]]):
|
|
37
|
+
self._constructs = {c.__mv_name__: c for c in constructs}
|
|
38
|
+
|
|
39
|
+
def __iter__(self) -> Iterator[type[SupportsMaterializedView]]:
|
|
40
|
+
return iter(self._constructs.values())
|
|
41
|
+
|
|
42
|
+
def get(self, name: str) -> type[SupportsMaterializedView]:
|
|
43
|
+
return self._constructs[name]
|
|
44
|
+
|
|
45
|
+
def plan(self) -> tuple[ConstructPlanItem, ...]:
|
|
46
|
+
nodes = [
|
|
47
|
+
ConstructNode(name=k, deps=v.__deps__, kind=getattr(v.__construct__, "kind", "materialized_view"))
|
|
48
|
+
for k, v in self._constructs.items()
|
|
49
|
+
]
|
|
50
|
+
ordered = topo_sort(nodes)
|
|
51
|
+
return tuple(ConstructPlanItem(n.name, n.kind, n.deps) for n in ordered)
|
|
52
|
+
|
|
53
|
+
def create_all(self, bind, *, with_data: bool = True):
|
|
54
|
+
for item in self.plan():
|
|
55
|
+
cls = self._constructs[item.name]
|
|
56
|
+
cls.create_mv(bind, with_data=with_data)
|
|
57
|
+
|
|
58
|
+
def refresh_all(self, bind, *, concurrently: bool = False) -> None:
|
|
59
|
+
for item in self.plan():
|
|
60
|
+
self._constructs[item.name].refresh_mv(bind, concurrently=concurrently)
|
|
61
|
+
|
|
62
|
+
def drop_all(self, bind, *, cascade: bool = False) -> None:
|
|
63
|
+
# drop reverse order
|
|
64
|
+
for item in reversed(self.plan()):
|
|
65
|
+
self._constructs[item.name].drop_mv(bind, cascade=cascade)
|
|
66
|
+
|
|
67
|
+
def create_missing(self, bind, *, with_data: bool = True, schema: str = "public") -> list[str]:
|
|
68
|
+
"""
|
|
69
|
+
Create only materialized views that do not yet exist.
|
|
70
|
+
|
|
71
|
+
Returns a list of MV names that were created.
|
|
72
|
+
"""
|
|
73
|
+
created: list[str] = []
|
|
74
|
+
|
|
75
|
+
for item in self.plan():
|
|
76
|
+
cls = self._constructs[item.name]
|
|
77
|
+
|
|
78
|
+
exists = materialized_view_exists(bind, cls.__mv_name__, schema=schema)
|
|
79
|
+
if exists:
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
cls.create_mv(bind, with_data=with_data)
|
|
83
|
+
created.append(cls.__mv_name__)
|
|
84
|
+
|
|
85
|
+
return created
|
|
86
|
+
|
|
87
|
+
def refresh_existing(self, bind, *, concurrently: bool = False, schema: str = "public") -> list[str]:
|
|
88
|
+
"""
|
|
89
|
+
Refresh only materialized views that already exist.
|
|
90
|
+
"""
|
|
91
|
+
refreshed: list[str] = []
|
|
92
|
+
|
|
93
|
+
for item in self.plan():
|
|
94
|
+
cls = self._constructs[item.name]
|
|
95
|
+
if materialized_view_exists(bind, cls.__mv_name__, schema=schema):
|
|
96
|
+
cls.refresh_mv(bind, concurrently=concurrently)
|
|
97
|
+
refreshed.append(cls.__mv_name__)
|
|
98
|
+
|
|
99
|
+
return refreshed
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def explain(self, bind, *, schema: str = "public", with_counts: bool = True) -> str:
|
|
103
|
+
"""
|
|
104
|
+
For each materialized view in the registry, show the SQL definition and whether it exists in the database.
|
|
105
|
+
If with_counts is True, also attempt to query the row count for existing MVs.
|
|
106
|
+
"""
|
|
107
|
+
lines = ["<ConstructRegistry explain>"]
|
|
108
|
+
|
|
109
|
+
for item in self.plan():
|
|
110
|
+
cls = self._constructs[item.name]
|
|
111
|
+
|
|
112
|
+
sql = str(cls.__mv_select__.compile(bind, compile_kwargs={"literal_binds": True}))
|
|
113
|
+
sql_kb = round(len(sql) / 1024, 1)
|
|
114
|
+
|
|
115
|
+
exists = materialized_view_exists(bind, cls.__mv_name__, schema=schema)
|
|
116
|
+
|
|
117
|
+
row_info = ""
|
|
118
|
+
if with_counts and exists:
|
|
119
|
+
try:
|
|
120
|
+
count = bind.execute(
|
|
121
|
+
sa.text(f"SELECT count(*) FROM {schema}.{cls.__mv_name__}")
|
|
122
|
+
).scalar()
|
|
123
|
+
row_info = f", rows={count:,}"
|
|
124
|
+
except Exception as e:
|
|
125
|
+
row_info = f", rows=<?> ({e.__class__.__name__})"
|
|
126
|
+
|
|
127
|
+
lines.append(
|
|
128
|
+
f" - {cls.__mv_name__:25s} sql≈{sql_kb:6.1f} KB, exists={exists}{row_info}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return "\n".join(lines)
|
|
132
|
+
|
|
133
|
+
def validate(self, bind, *, schema: str = "public") -> str:
|
|
134
|
+
"""
|
|
135
|
+
Check to see if each materialized view exists, and if so whether its schema matches
|
|
136
|
+
the mapper definition.
|
|
137
|
+
"""
|
|
138
|
+
lines = ["<ConstructRegistry validate>"]
|
|
139
|
+
|
|
140
|
+
for item in self.plan():
|
|
141
|
+
cls = self._constructs[item.name]
|
|
142
|
+
name = cls.__mv_name__
|
|
143
|
+
|
|
144
|
+
exists = materialized_view_exists(bind, name, schema=schema)
|
|
145
|
+
if not exists:
|
|
146
|
+
lines.append(f" ✗ {name}: MV does not exist")
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
cols = bind.execute(sa.text(f"""
|
|
150
|
+
SELECT column_name
|
|
151
|
+
FROM information_schema.columns
|
|
152
|
+
WHERE table_schema = :schema
|
|
153
|
+
AND table_name = :name
|
|
154
|
+
ORDER BY ordinal_position
|
|
155
|
+
"""), {"schema": schema, "name": name}).scalars().all()
|
|
156
|
+
|
|
157
|
+
orm_cols = {
|
|
158
|
+
k for k, v in vars(cls).items()
|
|
159
|
+
if hasattr(v, "property") or hasattr(v, "type")
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
missing = sorted(set(orm_cols) - set(cols))
|
|
163
|
+
extra = sorted(set(cols) - set(orm_cols))
|
|
164
|
+
|
|
165
|
+
if not missing and not extra:
|
|
166
|
+
lines.append(f" ✓ {name}: schema OK")
|
|
167
|
+
else:
|
|
168
|
+
lines.append(f" ⚠ {name}: schema mismatch")
|
|
169
|
+
if missing:
|
|
170
|
+
lines.append(f" - missing in MV: {missing}")
|
|
171
|
+
if extra:
|
|
172
|
+
lines.append(f" - extra in MV: {extra}")
|
|
173
|
+
|
|
174
|
+
return "\n".join(lines)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def build_plan_json(self) -> dict:
|
|
178
|
+
"""
|
|
179
|
+
Dumps plan information as JSON-serializable dict, e.g. for API responses or logging.
|
|
180
|
+
"""
|
|
181
|
+
items = self.plan()
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
"generated_at": datetime.utcnow().isoformat(),
|
|
185
|
+
"constructs": [
|
|
186
|
+
{
|
|
187
|
+
"name": item.name,
|
|
188
|
+
"kind": item.kind,
|
|
189
|
+
"deps": list(item.deps),
|
|
190
|
+
}
|
|
191
|
+
for item in items
|
|
192
|
+
],
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
def status(self, bind, *, schema: str = "public") -> str:
|
|
196
|
+
"""
|
|
197
|
+
Which each MV exists or not, in dependency order.
|
|
198
|
+
"""
|
|
199
|
+
lines = ["<ConstructRegistry status>"]
|
|
200
|
+
for item in self.plan():
|
|
201
|
+
name = item.name
|
|
202
|
+
exists = materialized_view_exists(bind, name, schema=schema)
|
|
203
|
+
flag = "✓" if exists else "✗"
|
|
204
|
+
lines.append(f" {flag} {name}")
|
|
205
|
+
return "\n".join(lines)
|
|
206
|
+
|
|
207
|
+
def __repr__(self) -> str:
|
|
208
|
+
try:
|
|
209
|
+
items = self.plan()
|
|
210
|
+
except Exception:
|
|
211
|
+
# fallback if deps are broken / incomplete during import time
|
|
212
|
+
names = sorted(self._constructs.keys())
|
|
213
|
+
body = ", ".join(names)
|
|
214
|
+
return f"<ConstructRegistry [{body}]>"
|
|
215
|
+
|
|
216
|
+
body = ", ".join(item.name for item in items)
|
|
217
|
+
return f"<ConstructRegistry [{body}]>"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def describe(self, full: bool = False) -> str:
|
|
221
|
+
try:
|
|
222
|
+
items = self.plan()
|
|
223
|
+
except Exception:
|
|
224
|
+
return f"<ConstructRegistry (invalid dependency graph) n={len(self._constructs)}>"
|
|
225
|
+
|
|
226
|
+
lines = ["<ConstructRegistry>"]
|
|
227
|
+
for item in items:
|
|
228
|
+
deps = ", ".join(item.deps) if item.deps else "—"
|
|
229
|
+
lines.append(f" - {item.name:25s} kind={item.kind:18s} deps=[{deps}]")
|
|
230
|
+
|
|
231
|
+
if full:
|
|
232
|
+
cls = self._constructs[item.name]
|
|
233
|
+
sql = str(cls.__mv_select__)
|
|
234
|
+
lines.append(f" sql_length={len(sql):,} chars")
|
|
235
|
+
|
|
236
|
+
return "\n".join(lines)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def ascii_dag(self) -> str:
|
|
240
|
+
"""
|
|
241
|
+
Render a simple ASCII dependency graph of the construct registry.
|
|
242
|
+
"""
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
items = self.plan()
|
|
246
|
+
except Exception as e:
|
|
247
|
+
return f"<ConstructRegistry DAG unavailable: {e}>"
|
|
248
|
+
|
|
249
|
+
# adjacency: parent -> children
|
|
250
|
+
children: dict[str, list[str]] = {name: [] for name in self._constructs}
|
|
251
|
+
for name, cls in self._constructs.items():
|
|
252
|
+
for dep in cls.__deps__:
|
|
253
|
+
if dep in children:
|
|
254
|
+
children[dep].append(name)
|
|
255
|
+
|
|
256
|
+
roots = [name for name, cls in self._constructs.items() if not cls.__deps__]
|
|
257
|
+
|
|
258
|
+
lines: list[str] = []
|
|
259
|
+
|
|
260
|
+
def walk(node: str, prefix: str = "", is_last: bool = True):
|
|
261
|
+
connector = "└─ " if is_last else "├─ "
|
|
262
|
+
lines.append(prefix + connector + node)
|
|
263
|
+
|
|
264
|
+
kids = children.get(node, [])
|
|
265
|
+
for i, child in enumerate(kids):
|
|
266
|
+
last = i == len(kids) - 1
|
|
267
|
+
next_prefix = prefix + (" " if is_last else "│ ")
|
|
268
|
+
walk(child, next_prefix, last)
|
|
269
|
+
|
|
270
|
+
for i, root in enumerate(roots):
|
|
271
|
+
last_root = i == len(roots) - 1
|
|
272
|
+
walk(root, "", last_root)
|
|
273
|
+
|
|
274
|
+
return "\n".join(lines)
|
|
@@ -1,115 +0,0 @@
|
|
|
1
|
-
from typing import Iterable, Iterator, Type
|
|
2
|
-
from .materialized import MaterializedViewMixin
|
|
3
|
-
from .plan import ConstructNode, topo_sort
|
|
4
|
-
from ..typing import SupportsMaterializedView
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
|
|
7
|
-
@dataclass(frozen=True)
|
|
8
|
-
class ConstructPlanItem:
|
|
9
|
-
name: str
|
|
10
|
-
kind: str
|
|
11
|
-
deps: tuple[str, ...]
|
|
12
|
-
|
|
13
|
-
class ConstructRegistry:
|
|
14
|
-
"""
|
|
15
|
-
Registry of materialized views / constructs.
|
|
16
|
-
|
|
17
|
-
Handles creation, refresh, and teardown in a controlled way.
|
|
18
|
-
"""
|
|
19
|
-
_constructs: dict[str, Type[SupportsMaterializedView]]
|
|
20
|
-
|
|
21
|
-
def __init__(self, constructs: Iterable[type[SupportsMaterializedView]]):
|
|
22
|
-
self._constructs = {c.__mv_name__: c for c in constructs}
|
|
23
|
-
|
|
24
|
-
def __iter__(self) -> Iterator[type[SupportsMaterializedView]]:
|
|
25
|
-
return iter(self._constructs.values())
|
|
26
|
-
|
|
27
|
-
def get(self, name: str) -> type[SupportsMaterializedView]:
|
|
28
|
-
return self._constructs[name]
|
|
29
|
-
|
|
30
|
-
def plan(self) -> tuple[ConstructPlanItem, ...]:
|
|
31
|
-
nodes = [
|
|
32
|
-
ConstructNode(name=k, deps=v.__deps__, kind=getattr(v.__construct__, "kind", "materialized_view"))
|
|
33
|
-
for k, v in self._constructs.items()
|
|
34
|
-
]
|
|
35
|
-
ordered = topo_sort(nodes)
|
|
36
|
-
return tuple(ConstructPlanItem(n.name, n.kind, n.deps) for n in ordered)
|
|
37
|
-
|
|
38
|
-
def create_all(self, bind, *, with_data: bool = True):
|
|
39
|
-
for item in self.plan():
|
|
40
|
-
cls = self._constructs[item.name]
|
|
41
|
-
cls.create_mv(bind, with_data=with_data)
|
|
42
|
-
|
|
43
|
-
def refresh_all(self, bind, *, concurrently: bool = False) -> None:
|
|
44
|
-
for item in self.plan():
|
|
45
|
-
self._constructs[item.name].refresh_mv(bind, concurrently=concurrently)
|
|
46
|
-
|
|
47
|
-
def drop_all(self, bind, *, cascade: bool = False) -> None:
|
|
48
|
-
# drop reverse order
|
|
49
|
-
for item in reversed(self.plan()):
|
|
50
|
-
self._constructs[item.name].drop_mv(bind, cascade=cascade)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def __repr__(self) -> str:
|
|
54
|
-
try:
|
|
55
|
-
items = self.plan()
|
|
56
|
-
except Exception:
|
|
57
|
-
# fallback if deps are broken / incomplete during import time
|
|
58
|
-
names = sorted(self._constructs.keys())
|
|
59
|
-
body = ", ".join(names)
|
|
60
|
-
return f"<ConstructRegistry [{body}]>"
|
|
61
|
-
|
|
62
|
-
body = ", ".join(item.name for item in items)
|
|
63
|
-
return f"<ConstructRegistry [{body}]>"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def describe(self) -> str:
|
|
67
|
-
try:
|
|
68
|
-
items = self.plan()
|
|
69
|
-
except Exception:
|
|
70
|
-
return f"<ConstructRegistry (invalid dependency graph) n={len(self._constructs)}>"
|
|
71
|
-
|
|
72
|
-
lines = ["<ConstructRegistry>"]
|
|
73
|
-
for item in items:
|
|
74
|
-
deps = ", ".join(item.deps) if item.deps else "—"
|
|
75
|
-
lines.append(f" - {item.name:25s} kind={item.kind:18s} deps=[{deps}]")
|
|
76
|
-
|
|
77
|
-
return "\n".join(lines)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def ascii_dag(self) -> str:
|
|
81
|
-
"""
|
|
82
|
-
Render a simple ASCII dependency graph of the construct registry.
|
|
83
|
-
"""
|
|
84
|
-
|
|
85
|
-
try:
|
|
86
|
-
items = self.plan()
|
|
87
|
-
except Exception as e:
|
|
88
|
-
return f"<ConstructRegistry DAG unavailable: {e}>"
|
|
89
|
-
|
|
90
|
-
# adjacency: parent -> children
|
|
91
|
-
children: dict[str, list[str]] = {name: [] for name in self._constructs}
|
|
92
|
-
for name, cls in self._constructs.items():
|
|
93
|
-
for dep in cls.__deps__:
|
|
94
|
-
if dep in children:
|
|
95
|
-
children[dep].append(name)
|
|
96
|
-
|
|
97
|
-
roots = [name for name, cls in self._constructs.items() if not cls.__deps__]
|
|
98
|
-
|
|
99
|
-
lines: list[str] = []
|
|
100
|
-
|
|
101
|
-
def walk(node: str, prefix: str = "", is_last: bool = True):
|
|
102
|
-
connector = "└─ " if is_last else "├─ "
|
|
103
|
-
lines.append(prefix + connector + node)
|
|
104
|
-
|
|
105
|
-
kids = children.get(node, [])
|
|
106
|
-
for i, child in enumerate(kids):
|
|
107
|
-
last = i == len(kids) - 1
|
|
108
|
-
next_prefix = prefix + (" " if is_last else "│ ")
|
|
109
|
-
walk(child, next_prefix, last)
|
|
110
|
-
|
|
111
|
-
for i, root in enumerate(roots):
|
|
112
|
-
last_root = i == len(roots) - 1
|
|
113
|
-
walk(root, "", last_root)
|
|
114
|
-
|
|
115
|
-
return "\n".join(lines)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/alchemy/concepts/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/alchemy/modifiers/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/alchemy/source_factories.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{omop_constructs-0.2.1 → omop_constructs-0.2.3}/src/omop_constructs/semantics/runtime_lookups.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|