Skip to content

Commit 15154d1

Browse files
authored
Mapping rules JSON V2 (#1145)
1 parent f215a47 commit 15154d1

4 files changed

Lines changed: 255 additions & 72 deletions

File tree

app/airflow/dags/libs/rules_export/core.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,18 @@
22
from libs.utils import pull_validated_params
33
from airflow.providers.postgres.hooks.postgres import PostgresHook
44
from libs.types import FileHandlerConfig
5-
from libs.rules_export.file_services import build_rules_json, build_rules_csv
5+
from libs.rules_export.file_services import (
6+
build_rules_json,
7+
build_rules_csv,
8+
build_rules_json_v2,
9+
)
610
from typing import Dict
711
from datetime import datetime
812
from libs.queries import create_update_temp_rules_table_query, create_file_entry_query
913
from libs.storage_services import upload_blob_to_storage
1014
from libs.enums import JobStageType, StageStatusType
1115
from libs.utils import update_job_status
12-
from libs.settings import AIRFLOW_DEBUG_MODE
16+
from libs.settings import AIRFLOW_DEBUG_MODE, AIRFLOW_VAR_JSON_VERSION
1317

1418
# PostgreSQL connection hook
1519
pg_hook = PostgresHook(postgres_conn_id="postgres_db_conn")
@@ -69,7 +73,11 @@ def build_and_upload_rules_file(**kwargs) -> None:
6973
"csv",
7074
),
7175
"json": FileHandlerConfig(
72-
lambda: build_rules_json(scan_report_name, scan_report_id),
76+
lambda: (
77+
build_rules_json_v2(scan_report_name, scan_report_id)
78+
if AIRFLOW_VAR_JSON_VERSION == "v2"
79+
else build_rules_json(scan_report_name, scan_report_id)
80+
),
7381
"mapping_json",
7482
"json",
7583
),

app/airflow/dags/libs/rules_export/file_services.py

Lines changed: 241 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Dict
1+
from typing import Any, Dict, List
22
from libs.enums import JobStageType, StageStatusType
33
from libs.utils import update_job_status
44
import json
@@ -15,74 +15,6 @@
1515
pg_hook = PostgresHook(postgres_conn_id="postgres_db_conn")
1616

1717

18-
def build_rules_json(scan_report_name: str, scan_report_id: int) -> BytesIO:
19-
"""
20-
Builds the rules in JSON format.
21-
22-
Args:
23-
- scan_report_name: str, the name of the scan report
24-
- scan_report_id: int, the id of the scan report
25-
26-
Returns:
27-
- BytesIO, the rules in JSON format
28-
"""
29-
try:
30-
# Build the metadata for the JSON file
31-
metadata = {
32-
"date_created": datetime.now(timezone.utc).isoformat(),
33-
"dataset": scan_report_name,
34-
}
35-
# Get the all processed rules from the temp table as dataframe in Pandas
36-
processed_rules = pg_hook.get_pandas_df(
37-
"SELECT * FROM temp_rules_export_%(scan_report_id)s_json ORDER BY sr_concept_id;",
38-
parameters={"scan_report_id": scan_report_id},
39-
)
40-
41-
result: Dict[str, Any] = {}
42-
for _, row in processed_rules.iterrows():
43-
dest_table = row["dest_table"]
44-
concept_key = f"{row['concept_name']} {row['sr_concept_id']}"
45-
dest_field = row["dest_field"]
46-
source_table = row["source_table"].replace("\ufeff", "")
47-
source_field = row["source_field"].replace("\ufeff", "")
48-
49-
# Prepare the term_mapping value
50-
# will solve #1006 here (fields end with source_concept_id and concept_id will have different values),
51-
# then assign term_mapping step below
52-
if pd.notnull(row["term_mapping_value"]):
53-
term_mapping = {row["term_mapping_value"]: row["concept_id"]}
54-
else:
55-
term_mapping = row["concept_id"]
56-
57-
# Build the field entry
58-
field_entry = {"source_table": source_table, "source_field": source_field}
59-
# Assign term_mapping
60-
if dest_field.endswith("_concept_id"):
61-
field_entry["term_mapping"] = term_mapping
62-
63-
result.setdefault(dest_table, {}).setdefault(concept_key, {})[
64-
dest_field
65-
] = field_entry
66-
67-
cdm = {
68-
"metadata": metadata,
69-
"cdm": result,
70-
}
71-
json_data = json.dumps(cdm, indent=6)
72-
json_bytes = BytesIO(json_data.encode("utf-8"))
73-
json_bytes.seek(0)
74-
return json_bytes
75-
except Exception as e:
76-
logging.error(f"Error building rules JSON: {str(e)}")
77-
update_job_status(
78-
scan_report=scan_report_id,
79-
stage=JobStageType.DOWNLOAD_RULES,
80-
status=StageStatusType.FAILED,
81-
details=f"Error building rules JSON for scan report {scan_report_id}: {str(e)}",
82-
)
83-
raise e
84-
85-
8618
def build_rules_csv(scan_report_id: int) -> BytesIO:
8719
"""
8820
Builds the rules in CSV format.
@@ -210,3 +142,243 @@ def build_rules_csv(scan_report_id: int) -> BytesIO:
210142
details=f"Error building rules CSV for scan report {scan_report_id}: {str(e)}",
211143
)
212144
raise e
145+
146+
147+
def build_rules_json(scan_report_name: str, scan_report_id: int) -> BytesIO:
148+
"""
149+
Builds the rules in JSON format.
150+
151+
Args:
152+
- scan_report_name: str, the name of the scan report
153+
- scan_report_id: int, the id of the scan report
154+
155+
Returns:
156+
- BytesIO, the rules in JSON format
157+
"""
158+
try:
159+
# Build the metadata for the JSON file
160+
metadata = {
161+
"date_created": datetime.now(timezone.utc).isoformat(),
162+
"dataset": scan_report_name,
163+
}
164+
# Get the all processed rules from the temp table as dataframe in Pandas
165+
processed_rules = pg_hook.get_pandas_df(
166+
"SELECT * FROM temp_rules_export_%(scan_report_id)s_json ORDER BY sr_concept_id;",
167+
parameters={"scan_report_id": scan_report_id},
168+
)
169+
170+
result: Dict[str, Any] = {}
171+
for _, row in processed_rules.iterrows():
172+
dest_table = row["dest_table"]
173+
concept_key = f"{row['concept_name']} {row['sr_concept_id']}"
174+
dest_field = row["dest_field"]
175+
source_table = row["source_table"].replace("\ufeff", "")
176+
source_field = row["source_field"].replace("\ufeff", "")
177+
178+
# Prepare the term_mapping value
179+
# will solve #1006 here (fields end with source_concept_id and concept_id will have different values),
180+
# then assign term_mapping step below
181+
if pd.notnull(row["term_mapping_value"]):
182+
term_mapping = {row["term_mapping_value"]: row["concept_id"]}
183+
else:
184+
term_mapping = row["concept_id"]
185+
186+
# Build the field entry
187+
field_entry = {"source_table": source_table, "source_field": source_field}
188+
# Assign term_mapping
189+
if dest_field.endswith("_concept_id"):
190+
field_entry["term_mapping"] = term_mapping
191+
192+
result.setdefault(dest_table, {}).setdefault(concept_key, {})[
193+
dest_field
194+
] = field_entry
195+
196+
cdm = {
197+
"metadata": metadata,
198+
"cdm": result,
199+
}
200+
json_data = json.dumps(cdm, indent=6)
201+
json_bytes = BytesIO(json_data.encode("utf-8"))
202+
json_bytes.seek(0)
203+
return json_bytes
204+
except Exception as e:
205+
logging.error(f"Error building rules JSON: {str(e)}")
206+
update_job_status(
207+
scan_report=scan_report_id,
208+
stage=JobStageType.DOWNLOAD_RULES,
209+
status=StageStatusType.FAILED,
210+
details=f"Error building rules JSON for scan report {scan_report_id}: {str(e)}",
211+
)
212+
raise e
213+
214+
215+
def build_rules_json_v2(scan_report_name: str, scan_report_id: int) -> BytesIO:
216+
try:
217+
metadata = {
218+
"date_created": datetime.now(timezone.utc).isoformat(),
219+
"dataset": scan_report_name,
220+
}
221+
222+
processed_rules = pg_hook.get_pandas_df(
223+
"SELECT * FROM temp_rules_export_%(scan_report_id)s_json ORDER BY dest_table, source_table, source_field;",
224+
parameters={"scan_report_id": scan_report_id},
225+
)
226+
227+
result: Dict[str, Any] = {}
228+
229+
for dest_table, dest_table_group in processed_rules.groupby("dest_table"):
230+
dest_table_str = str(dest_table)
231+
result[dest_table_str] = {}
232+
233+
for source_table, source_table_group in dest_table_group.groupby(
234+
"source_table"
235+
):
236+
source_table_clean = str(source_table).replace("\ufeff", "")
237+
result[dest_table_str][source_table_clean] = {}
238+
239+
person_id_mappings = {}
240+
date_mappings = {}
241+
concept_mappings = {}
242+
243+
for source_field, source_field_group in source_table_group.groupby(
244+
"source_field"
245+
):
246+
source_field_clean = str(source_field).replace("\ufeff", "")
247+
dest_fields = source_field_group["dest_field"].dropna().unique()
248+
249+
# forming the person_id_mapping
250+
if "person_id" in [d.lower() for d in dest_fields]:
251+
person_id_mappings = {
252+
"source_field": source_field_clean,
253+
"dest_field": "person_id",
254+
}
255+
256+
# forming the date_mapping
257+
date_like_fields = [
258+
d
259+
for d in dest_fields
260+
if d and d.lower().endswith(("date", "datetime"))
261+
]
262+
if date_like_fields:
263+
date_mappings = {
264+
"source_field": source_field_clean,
265+
"dest_field": date_like_fields,
266+
}
267+
268+
is_measurement_observation_table = dest_table_str in [
269+
"measurement",
270+
"observation",
271+
]
272+
field_level_mappings: Dict[str, List[int]] = {}
273+
value_level_mappings: Dict[str, Dict[str, List[int]]] = {}
274+
275+
only_field_mappings = True
276+
only_value_mappings = True
277+
278+
for _, row in source_field_group.iterrows():
279+
dest_field = row["dest_field"]
280+
concept_id = row["concept_id"]
281+
term_mapping_value = row.get("term_mapping_value")
282+
283+
# Solved #1006 here
284+
if pd.notnull(term_mapping_value):
285+
only_field_mappings = False
286+
if term_mapping_value not in value_level_mappings:
287+
value_level_mappings[term_mapping_value] = {}
288+
if dest_field.endswith("_concept_id"):
289+
# initialize the dest_field if not exists
290+
if (
291+
dest_field
292+
not in value_level_mappings[term_mapping_value]
293+
):
294+
value_level_mappings[term_mapping_value][
295+
dest_field
296+
] = []
297+
value_level_mappings[term_mapping_value][
298+
dest_field
299+
].append(concept_id)
300+
else:
301+
only_value_mappings = False
302+
if dest_field.endswith("_concept_id"):
303+
# initialize the dest_field if not exists
304+
if dest_field not in field_level_mappings:
305+
field_level_mappings[dest_field] = []
306+
field_level_mappings[dest_field].append(concept_id)
307+
308+
concept_mapping: Dict[str, Any] = {}
309+
310+
# Forming the concept_mapping based on the mapping types
311+
if only_field_mappings:
312+
# Field-only mappings: put everything under "*" key
313+
concept_mapping["*"] = field_level_mappings
314+
315+
elif only_value_mappings:
316+
# Value-only mappings: put each value as direct key
317+
for value, mappings in value_level_mappings.items():
318+
if mappings:
319+
concept_mapping[value] = mappings
320+
321+
else:
322+
# Mixed mappings: "*" key for field-level + individual value keys
323+
# NOTE: If there are mappings at the date/pseron field and value_as_concept_id field taken into account,
324+
# it will be mixed mappings automatically, which will cause issues, so we need to have a way to add this mixed case in one of the above types
325+
concept_mapping["*"] = field_level_mappings
326+
327+
# For each value, create its own mapping
328+
for value, value_mappings in value_level_mappings.items():
329+
if value_mappings:
330+
concept_mapping[value] = value_mappings
331+
332+
# Forming the original_value field
333+
original_values = [
334+
f for f in dest_fields if f.endswith("_source_value")
335+
]
336+
# Add value_as_string/number for tables observation and measurement
337+
if is_measurement_observation_table:
338+
if "value_as_string" in dest_fields:
339+
original_values.append("value_as_string")
340+
if "value_as_number" in dest_fields:
341+
original_values.append("value_as_number")
342+
# Add original_value field with unique values
343+
concept_mapping["original_value"] = list(set(original_values))
344+
345+
# for a mappings of a source field group to be appear in the result, it must have original_value
346+
# (which should always have at least one _source_value field)
347+
if concept_mapping and concept_mapping.get("original_value"):
348+
concept_mappings[source_field_clean] = concept_mapping
349+
350+
# Adding the person_id_mapping, date_mapping, concept_mapping to the result
351+
if person_id_mappings:
352+
result[dest_table_str][source_table_clean][
353+
"person_id_mapping"
354+
] = person_id_mappings
355+
356+
if date_mappings:
357+
result[dest_table_str][source_table_clean][
358+
"date_mapping"
359+
] = date_mappings
360+
361+
if concept_mappings:
362+
result[dest_table_str][source_table_clean][
363+
"concept_mappings"
364+
] = concept_mappings
365+
366+
cdm = {
367+
"metadata": metadata,
368+
"cdm": result,
369+
}
370+
371+
json_data = json.dumps(cdm, indent=2)
372+
json_bytes = BytesIO(json_data.encode("utf-8"))
373+
json_bytes.seek(0)
374+
return json_bytes
375+
376+
except Exception as e:
377+
logging.error(f"Error building rules JSON v2: {str(e)}")
378+
update_job_status(
379+
scan_report=scan_report_id,
380+
stage=JobStageType.DOWNLOAD_RULES,
381+
status=StageStatusType.FAILED,
382+
details=f"Error building rules JSON v2 for scan report {scan_report_id}: {str(e)}",
383+
)
384+
raise e

app/airflow/dags/libs/settings.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,5 @@
1515

1616
# Timedelta for dagrun_timeout in minutes
1717
AIRFLOW_DAGRUN_TIMEOUT = os.getenv("AIRFLOW_DAGRUN_TIMEOUT", 60)
18+
19+
AIRFLOW_VAR_JSON_VERSION = os.getenv("AIRFLOW_VAR_JSON_VERSION", "v1")

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ services:
255255
# Search recommendations feature flag - controls whether search recommendations task is included in auto_mapping DAG
256256
SEARCH_ENABLED: False
257257
AIRFLOW_DAGRUN_TIMEOUT: 60 # Timeout for each dagrun in minutes
258+
AIRFLOW_VAR_JSON_VERSION: v2
258259

259260
volumes:
260261
db_data:

0 commit comments

Comments
 (0)