Skip to content

Commit 79a588e

Browse files
committed
fix: recommendation database persistence (id set/get)
1 parent e428be3 commit 79a588e

File tree

10 files changed

+208
-62
lines changed

10 files changed

+208
-62
lines changed

execution_engine/converter/time_from_event/abstract.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def _wrap_criteria_with_factory(
2626
Recursively wraps all Criterion instances within a combination using the factory.
2727
"""
2828
# Create a new combination of the same type with the same operator
29-
new_combo = combo.__class__(operator=combo.operator, category=combo.category)
29+
new_combo = combo.__class__(operator=combo.operator)
3030

3131
# Loop through all elements
3232
for element in combo:

execution_engine/execution_engine.py

Lines changed: 63 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ def execute(
141141
# database, its _id slot is not None. Otherwise, register
142142
# the recommendation to store it into the database and
143143
# assign an id.
144-
if recommendation._id is None:
144+
if not recommendation.is_persisted():
145145
self.register_recommendation(recommendation)
146146
run_id = self.register_run(
147147
recommendation, start_datetime=start_datetime, end_datetime=end_datetime
@@ -193,14 +193,34 @@ def load_recommendation_from_database(
193193
recommendation = cohort.Recommendation.from_json(
194194
rec_db.recommendation_json.decode()
195195
)
196-
# All objects in the deserialized object graph must have
197-
# an id.
198-
assert recommendation._id is not None
199-
assert recommendation._base_criterion._id is not None
200-
for pi_pair in recommendation._pi_pairs:
201-
assert pi_pair._id is not None
196+
197+
# we'll run registration again to set all ids
198+
recommendation.set_id(rec_db.recommendation_id)
199+
200+
assert (
201+
recommendation.base_criterion is not None
202+
), "Base Criterion must be set"
203+
self.register_criterion(recommendation.base_criterion)
204+
205+
for pi_pair in recommendation.population_intervention_pairs():
206+
self.register_population_intervention_pair(
207+
pi_pair, rec_db.recommendation_id
208+
)
209+
210+
for criterion in pi_pair.flatten():
211+
self.register_criterion(criterion)
212+
213+
# All objects in the deserialized object graph must have an id.
214+
assert recommendation.id is not None
215+
assert recommendation.base_criterion is not None
216+
assert recommendation.base_criterion.id is not None
217+
218+
for pi_pair in recommendation.population_intervention_pairs():
219+
assert pi_pair.id is not None
220+
202221
for criterion in pi_pair.flatten():
203-
assert criterion._id is not None
222+
assert criterion.id is not None
223+
204224
return recommendation
205225

206226
return None
@@ -229,7 +249,12 @@ def register_recommendation(self, recommendation: cohort.Recommendation) -> None
229249
rec_db = con.execute(query).fetchone()
230250

231251
if rec_db is not None:
232-
recommendation.id = rec_db.recommendation_id
252+
if recommendation.is_persisted():
253+
assert (
254+
recommendation.id == rec_db.recommendation_id
255+
), "Recommendation IDs do not match"
256+
else:
257+
recommendation.set_id(rec_db.recommendation_id)
233258
else:
234259
query = (
235260
insert(recommendation_table)
@@ -247,11 +272,17 @@ def register_recommendation(self, recommendation: cohort.Recommendation) -> None
247272
)
248273

249274
result = con.execute(query)
250-
recommendation.id = result.fetchone().recommendation_id
275+
recommendation.set_id(result.fetchone().recommendation_id)
276+
251277
# Register all child objects. After that, the recommendation
252278
# and all child objects have valid ids (either restored or
253279
# fresh).
254-
self.register_criterion(recommendation._base_criterion)
280+
281+
if recommendation.base_criterion is None:
282+
raise ValueError("Base Criterion must be set when storing recommendation")
283+
284+
self.register_criterion(recommendation.base_criterion)
285+
255286
for pi_pair in recommendation.population_intervention_pairs():
256287
self.register_population_intervention_pair(
257288
pi_pair, recommendation_id=recommendation.id
@@ -260,12 +291,14 @@ def register_recommendation(self, recommendation: cohort.Recommendation) -> None
260291
self.register_criterion(criterion)
261292

262293
assert recommendation.id is not None
263-
# TODO(jmoringe): mypy doesn't like this one. Not sure why.
264-
# assert recommendation._base_criterion._id is not None
265-
for pi_pair in recommendation._pi_pairs:
266-
assert pi_pair._id is not None
294+
assert recommendation.base_criterion is not None
295+
assert recommendation.base_criterion.id is not None
296+
297+
for pi_pair in recommendation.population_intervention_pairs():
298+
assert pi_pair.id is not None
299+
267300
for criterion in pi_pair.flatten():
268-
assert criterion._id is not None
301+
assert criterion.id is not None
269302

270303
# Update the recommendation in the database with the final
271304
# JSON representation and execution graph (now that
@@ -306,7 +339,12 @@ def register_population_intervention_pair(
306339
pi_pair_db = con.execute(query).fetchone()
307340

308341
if pi_pair_db is not None:
309-
pi_pair.id = pi_pair_db.pi_pair_id
342+
if pi_pair.is_persisted():
343+
assert (
344+
pi_pair.id == pi_pair_db.pi_pair_id
345+
), "Population/Intervention Pair IDs do not match"
346+
else:
347+
pi_pair.set_id(pi_pair_db.pi_pair_id)
310348
else:
311349
query = (
312350
insert(result_db.PopulationInterventionPair)
@@ -320,7 +358,7 @@ def register_population_intervention_pair(
320358
)
321359

322360
result = con.execute(query)
323-
pi_pair.id = result.fetchone().pi_pair_id
361+
pi_pair.set_id(result.fetchone().pi_pair_id)
324362

325363
def register_criterion(self, criterion: Criterion) -> None:
326364
"""
@@ -337,7 +375,12 @@ def register_criterion(self, criterion: Criterion) -> None:
337375
criterion_db = con.execute(query).fetchone()
338376

339377
if criterion_db is not None:
340-
criterion.id = criterion_db.criterion_id
378+
if criterion.is_persisted():
379+
assert (
380+
criterion.id == criterion_db.criterion_id
381+
), "Criterion IDs do not match"
382+
else:
383+
criterion.set_id(criterion_db.criterion_id)
341384
else:
342385
query = (
343386
insert(result_db.Criterion)
@@ -349,7 +392,7 @@ def register_criterion(self, criterion: Criterion) -> None:
349392
)
350393

351394
result = con.execute(query)
352-
criterion.id = result.fetchone().criterion_id
395+
criterion.set_id(result.fetchone().criterion_id)
353396

354397
assert criterion.id is not None
355398

execution_engine/omop/cohort/population_intervention_pair.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ class PopulationInterventionPair(Serializable):
3838
(e.g. "has condition X and lab value Y >= Z").
3939
"""
4040

41-
_id: int | None
4241
_name: str
4342
_population: CriterionCombination
4443
_intervention: CriterionCombination
@@ -50,9 +49,7 @@ def __init__(
5049
base_criterion: Criterion,
5150
population: CriterionCombination | None = None,
5251
intervention: CriterionCombination | None = None,
53-
id: int | None = None,
5452
) -> None:
55-
self._id = id
5653
self._name = name
5754
self._url = url
5855
self._base_criterion = base_criterion

execution_engine/omop/cohort/recommendation.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ def __init__(
5151
url: str,
5252
version: str,
5353
description: str,
54-
recommendation_id: int | None = None,
5554
package_version: str | None = None,
5655
) -> None:
5756
self._pi_pairs: list[cohort.PopulationInterventionPair] = pi_pairs
@@ -62,8 +61,6 @@ def __init__(
6261
self._version: str = version
6362
self._description: str = description
6463
self._package_version: str | None = package_version
65-
# The id is used in the recommendation_id field in the result tables.
66-
self._id = recommendation_id
6764

6865
def __repr__(self) -> str:
6966
"""
@@ -131,6 +128,13 @@ def description(self) -> str:
131128
"""
132129
return self._description
133130

131+
@property
132+
def base_criterion(self) -> Criterion | None:
133+
"""
134+
Get the base criterion of the recommendation.
135+
"""
136+
return self._base_criterion
137+
134138
def execution_graph(self) -> ExecutionGraph:
135139
"""
136140
Get the execution maps of the full recommendation.

execution_engine/omop/serializable.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,42 @@ class Serializable(ABC):
99
"""
1010

1111
_id: int | None = None
12+
"""
13+
The id is used in the database tables.
14+
"""
15+
16+
def set_id(self, value: int) -> None:
17+
"""
18+
Assigns the database ID to the object. This can only be done once.
19+
20+
This ID corresponds to the primary key in the database and is set
21+
when the object is persisted.
22+
23+
:param value: The database ID assigned to the object.
24+
:raises ValueError: If the ID has already been set.
25+
"""
26+
if self._id is not None:
27+
raise ValueError("Database ID has already been set!")
28+
self._id = value
1229

1330
@property
1431
def id(self) -> int:
1532
"""
16-
Get the id of the object (used in the database).
33+
Retrieves the database ID of the object.
34+
35+
This ID is only available after the object has been stored in the database.
36+
37+
:return: The database ID, or None if the object has not been stored yet.
1738
"""
1839
if self._id is None:
19-
raise ValueError("Id not set")
40+
raise ValueError("Database ID has not been set yet!")
2041
return self._id
2142

22-
@id.setter
23-
def id(self, value: int) -> None:
43+
def is_persisted(self) -> bool:
2444
"""
25-
Set the id of the object (used in the database).
45+
Returns True if the object has been stored in the database.
2646
"""
27-
self._id = value
47+
return self._id is not None
2848

2949
@abstractmethod
3050
def dict(self) -> dict:

tests/execution_engine/omop/cohort/test_cohort_recommendation.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import pytest
22

3+
from execution_engine.omop.cohort import PopulationInterventionPair
34
from execution_engine.omop.cohort.recommendation import Recommendation
45
from execution_engine.omop.concepts import Concept
56
from execution_engine.omop.criterion.visit_occurrence import ActivePatients
@@ -58,3 +59,79 @@ def test_serialization_with_active_patients(self, concept):
5859
json = original.json()
5960
deserialized = Recommendation.from_json(json)
6061
assert original == deserialized
62+
63+
def test_database_serialization(self, concept):
64+
from execution_engine.execution_engine import ExecutionEngine
65+
from execution_engine.omop.criterion.factory import register_criterion_class
66+
67+
register_criterion_class("MockCriterion", MockCriterion)
68+
69+
e = ExecutionEngine(verbose=False)
70+
71+
url = "http://example.com/fhir/recommendation"
72+
73+
base_criterion = MockCriterion("c")
74+
population_criterion = MockCriterion("p")
75+
intervention_criterion = MockCriterion("i)")
76+
77+
pi_pair = PopulationInterventionPair(
78+
name="foo",
79+
url="foo",
80+
base_criterion=base_criterion,
81+
population=population_criterion,
82+
intervention=intervention_criterion,
83+
)
84+
85+
pi_pairs = [pi_pair]
86+
87+
recommendation = Recommendation(
88+
pi_pairs=pi_pairs,
89+
base_criterion=ActivePatients(),
90+
name="foo",
91+
title="bar",
92+
url=url,
93+
version="1.0",
94+
description="test",
95+
package_version="1.0",
96+
)
97+
98+
with pytest.raises(ValueError, match=r"Database ID has not been set yet!"):
99+
assert recommendation.id is None
100+
101+
with pytest.raises(ValueError, match=r"Database ID has not been set yet!"):
102+
assert recommendation.base_criterion.id is None
103+
104+
for pi_pair in recommendation.population_intervention_pairs():
105+
with pytest.raises(ValueError, match=r"Database ID has not been set yet!"):
106+
assert pi_pair.id is None
107+
108+
for criterion in pi_pair.flatten():
109+
with pytest.raises(
110+
ValueError, match=r"Database ID has not been set yet!"
111+
):
112+
assert criterion.id is None
113+
114+
e.register_recommendation(recommendation)
115+
116+
assert recommendation.id is not None
117+
assert recommendation.base_criterion.id is not None
118+
for pi_pair in recommendation.population_intervention_pairs():
119+
assert pi_pair.id is not None
120+
for criterion in pi_pair.flatten():
121+
assert criterion.id is not None
122+
123+
rec_loaded = e.load_recommendation_from_database(url)
124+
125+
assert recommendation == rec_loaded
126+
assert rec_loaded.id == recommendation.id
127+
assert len(rec_loaded._pi_pairs) == len(pi_pairs)
128+
129+
for pi_pair_loaded, pi_pair in zip(rec_loaded._pi_pairs, pi_pairs):
130+
assert pi_pair_loaded.id == pi_pair.id
131+
132+
assert len(pi_pair_loaded.flatten()) == len(pi_pair.flatten())
133+
134+
for criterion_loaded, criterion in zip(
135+
pi_pair_loaded.flatten(), pi_pair.flatten()
136+
):
137+
assert criterion.id == criterion_loaded.id

tests/execution_engine/omop/criterion/combination/__init__.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def from_dict(cls, data: Dict[str, Any]) -> Self:
4141
"""
4242
Create an object from a dictionary.
4343
"""
44-
return cls(**data)
44+
return cls()
4545

4646
def description(self) -> str:
4747
"""
@@ -53,6 +53,4 @@ def dict(self) -> dict:
5353
"""
5454
Get a dictionary representation of the object.
5555
"""
56-
return {
57-
"class": self.__class__.__name__,
58-
}
56+
return {}

0 commit comments

Comments
 (0)