omop-constructs 0.2.0__tar.gz → 0.2.2__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.
Files changed (39) hide show
  1. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/PKG-INFO +4 -3
  2. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/pyproject.toml +6 -5
  3. omop_constructs-0.2.2/src/omop_constructs/alchemy/episodes/condition_episode_mv.py +65 -0
  4. omop_constructs-0.2.2/src/omop_constructs/alchemy/episodes/episode_factories.py +98 -0
  5. omop_constructs-0.2.2/src/omop_constructs/alchemy/episodes/episode_joins.py +22 -0
  6. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/alchemy/modifiers/modifier_joins.py +5 -0
  7. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/alchemy/modifiers/modifier_mappers.py +9 -33
  8. omop_constructs-0.2.2/src/omop_constructs/core/registry.py +274 -0
  9. omop_constructs-0.2.0/src/omop_constructs/core/registry.py +0 -115
  10. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/README.md +0 -0
  11. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/__init__.py +0 -0
  12. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/alchemy/__init__.py +0 -0
  13. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/alchemy/adapters.py +0 -0
  14. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/alchemy/concepts/__init__.py +0 -0
  15. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/alchemy/concepts/concept_mappers.py +0 -0
  16. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/alchemy/concepts/concept_standardisation.py +0 -0
  17. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/alchemy/conditions/condition_joins.py +0 -0
  18. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/alchemy/conditions/condition_mappers.py +0 -0
  19. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/alchemy/modifiers/__init__.py +0 -0
  20. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/alchemy/modifiers/condition_modifier_join.py +0 -0
  21. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/alchemy/modifiers/condition_modifier_mv.py +0 -0
  22. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/alchemy/modifiers/modifier_factories.py +0 -0
  23. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/alchemy/source_factories.py +0 -0
  24. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/core/__init__.py +0 -0
  25. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/core/base.py +0 -0
  26. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/core/constructs.py +0 -0
  27. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/core/ddl.py +0 -0
  28. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/core/errors.py +0 -0
  29. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/core/materialized.py +0 -0
  30. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/core/plan.py +0 -0
  31. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/py.typed +0 -0
  32. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/semantics/__init__.py +0 -0
  33. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/semantics/adapters.py +0 -0
  34. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/semantics/lookups.py +0 -0
  35. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/semantics/runtime_lookups.py +0 -0
  36. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/services/__init__.py +0 -0
  37. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/services/concept.py +0 -0
  38. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/services/person.py +0 -0
  39. {omop_constructs-0.2.0 → omop_constructs-0.2.2}/src/omop_constructs/typing.py +0 -0
@@ -1,11 +1,12 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: omop-constructs
3
- Version: 0.2.0
3
+ Version: 0.2.2
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.11
8
- Requires-Dist: omop-semantics==0.1.8
7
+ Requires-Dist: omop-alchemy>=0.5.12
8
+ Requires-Dist: omop-semantics>=0.1.8
9
+ Requires-Dist: psycopg2-binary>=2.9.11
9
10
  Requires-Dist: sqlalchemy>=2.0.45
10
11
  Requires-Dist: urllib3>=2.6.3
11
12
  Requires-Python: >=3.12
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "omop-constructs"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "For building complex constructs on top of the omop-alchemy library."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -8,10 +8,11 @@ authors = [
8
8
  ]
9
9
  requires-python = ">=3.12"
10
10
  dependencies = [
11
- "omop-alchemy==0.5.11",
12
- "omop-semantics==0.1.8",
11
+ "omop-alchemy>=0.5.12",
12
+ "omop-semantics>=0.1.8",
13
+ "psycopg2-binary>=2.9.11",
13
14
  "sqlalchemy>=2.0.45",
14
- "urllib3>=2.6.3"
15
+ "urllib3>=2.6.3",
15
16
  ]
16
17
 
17
18
  [project.scripts]
@@ -19,4 +20,4 @@ omop-constructs = "omop_constructs.cli:main"
19
20
 
20
21
  [build-system]
21
22
  requires = ["uv_build>=0.9.2,<0.10.0"]
22
- build-backend = "uv_build"
23
+ build-backend = "uv_build"
@@ -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)