Skip to content

Commit 47274f7

Browse files
authored
Merge pull request #133 from SCAI-BIO/feat-sql-jsonl-import-export
feat: JSONL import/export functionality for SQL adapters
2 parents 7fedfa9 + 51fdd4b commit 47274f7

16 files changed

Lines changed: 713 additions & 208 deletions

datastew/process/jsonl_adapter.py

Lines changed: 185 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,38 @@
11
import json
22
import os
3+
from abc import ABC, abstractmethod
4+
from typing import Any, Dict, Union
35

6+
import numpy as np
47
import pandas as pd
58
from tqdm import tqdm
69
from weaviate.util import generate_uuid5
710

811
from datastew.embedding import Vectorizer
9-
from datastew.repository import WeaviateRepository
12+
from datastew.repository import PostgreSQLRepository, SQLLiteRepository, WeaviateRepository
13+
from datastew.repository.base import BaseRepository
14+
from datastew.repository.model import Concept, Mapping, Terminology
1015
from datastew.repository.weaviate_schema import (
1116
concept_schema,
1217
mapping_schema_user_vectors,
1318
terminology_schema,
1419
)
1520

1621

17-
class WeaviateJsonlConverter(object):
18-
"""
19-
Converts data to our JSONL format for Weaviate schema.
20-
"""
22+
class BaseJsonlConverter(ABC):
23+
def __init__(self, dest_dir: str, buffer_size: int = 1000):
24+
"""Initialize the converter.
2125
22-
def __init__(
23-
self,
24-
dest_dir: str,
25-
terminology_schema: dict = terminology_schema.schema,
26-
concept_schema: dict = concept_schema.schema,
27-
mapping_schema: dict = mapping_schema_user_vectors.schema,
28-
buffer_size: int = 1000,
29-
):
26+
:param dest_dir: Destination directory for the exported JSONL files.
27+
:param buffer_size: Number of records to buffer before writing to disk, defaults to 1000
28+
"""
3029
self.dest_dir = dest_dir
31-
self.terminology_schema = terminology_schema
32-
self.concept_schema = concept_schema
33-
self.mapping_schema = mapping_schema
3430
self._buffer = []
3531
self._buffer_size = buffer_size
3632
self._ensure_directories_exist()
3733

3834
def _ensure_directories_exist(self):
39-
"""
40-
Ensures the output directory exists.
41-
42-
:return: None
43-
"""
35+
"""Ensures the output directory exists."""
4436
os.makedirs(self.dest_dir, exist_ok=True)
4537

4638
def _get_file_path(self, collection: str) -> str:
@@ -79,33 +71,187 @@ def _flush_to_file(self, file_path: str):
7971

8072
with open(file_path, "a", encoding="utf-8") as file:
8173
for entry in self._buffer:
74+
if isinstance(entry.get("embedding"), np.ndarray):
75+
entry["embedding"] = entry["embedding"].tolist()
8276
file.write(json.dumps(entry) + "\n")
8377

8478
self._buffer.clear()
8579

86-
def from_repository(self, repository: WeaviateRepository) -> None:
80+
@abstractmethod
81+
def from_repository(self, repository: BaseRepository):
82+
pass
83+
84+
@abstractmethod
85+
def from_ohdsi(self, src: str, vectorizer: Vectorizer = Vectorizer(), include_vectors: bool = True):
86+
pass
87+
88+
@abstractmethod
89+
def _object_to_dict(self, obj: Any) -> Dict[str, Any]:
90+
pass
91+
92+
93+
class SQLJsonlConverter(BaseJsonlConverter):
94+
def __init__(self, dest_dir: str, buffer_size: int = 1000):
95+
super().__init__(dest_dir=dest_dir, buffer_size=buffer_size)
96+
97+
def from_repository(self, repository: Union[PostgreSQLRepository, SQLLiteRepository]):
98+
"""Export all records from a PostgreSQLRepository to JSONL files
99+
100+
:param repository: Active database repository instace.
101+
"""
102+
session = repository.session
103+
104+
# Export Terminologies
105+
terminology_file_path = self._get_file_path("terminology")
106+
for t in tqdm(session.query(Terminology).all(), desc="Exporting Terminologies"):
107+
terminology = self._object_to_dict(t)
108+
self._write_to_jsonl(terminology_file_path, terminology)
109+
self._flush_to_file(terminology_file_path)
110+
111+
# Export Concepts
112+
concept_file_path = self._get_file_path("concept")
113+
for c in tqdm(session.query(Concept).all(), desc="Exporting Concepts"):
114+
concept = self._object_to_dict(c)
115+
self._write_to_jsonl(concept_file_path, concept)
116+
self._flush_to_file(concept_file_path)
117+
118+
# Export Mappings
119+
mapping_file_path = self._get_file_path("mapping")
120+
for m in tqdm(session.query(Mapping).all(), desc="Exporting Mappings"):
121+
mapping = self._object_to_dict(m)
122+
self._write_to_jsonl(mapping_file_path, mapping)
123+
self._flush_to_file(mapping_file_path)
124+
125+
def from_ohdsi(self, src: str, vectorizer: Vectorizer = Vectorizer(), include_vectors: bool = True):
126+
"""
127+
Converts data from OHDSI to SQL-compatible JSONL format.
128+
129+
:param src: Path to the OHDSI CONCEPT.csv file.
130+
:param vectorizer: Vectorizer to use for text embeddings.
131+
:param include_vectors: Whether to include vector data in mappings.
132+
"""
133+
if not os.path.exists(src):
134+
raise FileNotFoundError(f"OHDSI concept file '{src}' does not exist or is not a file.")
135+
136+
terminology_file_path = self._get_file_path("terminology")
137+
concept_file_path = self._get_file_path("concept")
138+
mapping_file_path = self._get_file_path("mapping")
139+
140+
# Write single OHDSI terminology entry
141+
self._write_to_jsonl(terminology_file_path, {"id": "OHDSI", "name": "OHDSI"})
142+
self._flush_to_file(terminology_file_path)
143+
144+
for chunk in tqdm(
145+
pd.read_csv(
146+
src,
147+
delimiter="\t",
148+
usecols=["concept_name", "concept_id"],
149+
chunksize=10000,
150+
dtype={"concept_id": str, "concept_name": str},
151+
),
152+
desc="Processing OHDSI concepts",
153+
):
154+
155+
concepts = []
156+
mappings = []
157+
158+
concept_names = chunk["concept_name"].astype(str).tolist()
159+
concept_ids = chunk["concept_id"].astype(str).tolist()
160+
161+
if include_vectors:
162+
embeddings = vectorizer.get_embeddings(concept_names)
163+
164+
for i in range(len(concept_names)):
165+
concept_identifier = f"OHDSI:{concept_ids[i]}"
166+
label = concept_names[i]
167+
168+
# Concept JSON
169+
concepts.append(
170+
{
171+
"concept_identifier": concept_identifier,
172+
"pref_label": label,
173+
"terminology_id": "OHDSI",
174+
}
175+
)
176+
177+
# Mapping JSON
178+
mapping = {
179+
"concept_identifier": concept_identifier,
180+
"text": label,
181+
}
182+
if include_vectors:
183+
mapping["sentence_embedder"] = vectorizer.model_name
184+
mapping["embedding"] = embeddings[i]
185+
186+
mappings.append(mapping)
187+
188+
# Write results in batch
189+
for concept_data in concepts:
190+
self._write_to_jsonl(concept_file_path, concept_data)
191+
self._flush_to_file(concept_file_path)
192+
for mapping_data in mappings:
193+
self._write_to_jsonl(mapping_file_path, mapping_data)
194+
self._flush_to_file(mapping_file_path)
195+
196+
def _object_to_dict(self, obj: Union[Terminology, Concept, Mapping]) -> Dict[str, Any]:
197+
if isinstance(obj, Terminology):
198+
return {
199+
"id": obj.id,
200+
"name": obj.name,
201+
}
202+
elif isinstance(obj, Concept):
203+
return {
204+
"concept_identifier": obj.concept_identifier,
205+
"pref_label": obj.pref_label,
206+
"terminology_id": obj.terminology.id,
207+
}
208+
elif isinstance(obj, Mapping):
209+
return {
210+
"concept_identifier": obj.concept.concept_identifier,
211+
"text": obj.text,
212+
"embedding": obj.embedding,
213+
"sentence_embedder": obj.sentence_embedder,
214+
}
215+
else:
216+
raise TypeError(f"Unsupported object type: {type(obj)}")
217+
218+
219+
class WeaviateJsonlConverter(BaseJsonlConverter):
220+
def __init__(
221+
self,
222+
dest_dir: str,
223+
terminology_schema: dict = terminology_schema.schema,
224+
concept_schema: dict = concept_schema.schema,
225+
mapping_schema: dict = mapping_schema_user_vectors.schema,
226+
buffer_size: int = 1000,
227+
):
228+
super().__init__(dest_dir=dest_dir, buffer_size=buffer_size)
229+
self.terminology_schema = terminology_schema
230+
self.concept_schema = concept_schema
231+
self.mapping_schema = mapping_schema
232+
233+
def from_repository(self, repository: WeaviateRepository):
87234
"""
88235
Converts data from a WeaviateRepository to our JSONL format.
89236
90237
:param repository: WeaviateRepository
91-
:return: None
92238
"""
93239
# Process terminology first
94240
terminology_file_path = self._get_file_path("terminology")
95241
for terminology in repository.get_iterator(self.terminology_schema["class"]):
96-
self._write_to_jsonl(terminology_file_path, self._weaviate_object_to_dict(terminology))
242+
self._write_to_jsonl(terminology_file_path, self._object_to_dict(terminology))
97243
self._flush_to_file(terminology_file_path)
98244

99245
# Process concept next
100246
concept_file_path = self._get_file_path("concept")
101247
for concept in repository.get_iterator(self.concept_schema["class"]):
102-
self._write_to_jsonl(concept_file_path, self._weaviate_object_to_dict(concept))
248+
self._write_to_jsonl(concept_file_path, self._object_to_dict(concept))
103249
self._flush_to_file(concept_file_path)
104250

105251
# Process mapping last
106252
mapping_file_path = self._get_file_path("mapping")
107253
for mapping in repository.get_iterator(self.mapping_schema["class"]):
108-
self._write_to_jsonl(mapping_file_path, self._weaviate_object_to_dict(mapping))
254+
self._write_to_jsonl(mapping_file_path, self._object_to_dict(mapping))
109255
self._flush_to_file(mapping_file_path)
110256

111257
def from_ohdsi(self, src: str, vectorizer: Vectorizer = Vectorizer(), include_vectors: bool = True):
@@ -128,7 +274,7 @@ def from_ohdsi(self, src: str, vectorizer: Vectorizer = Vectorizer(), include_ve
128274
terminology_properties = {"name": "OHDSI"}
129275
terminology_id = generate_uuid5(terminology_properties)
130276
ohdsi_terminology = {
131-
"class": self.terminology_schema["class"],
277+
"class": "Terminology",
132278
"id": terminology_id,
133279
"properties": terminology_properties,
134280
}
@@ -206,22 +352,25 @@ def from_ohdsi(self, src: str, vectorizer: Vectorizer = Vectorizer(), include_ve
206352
self._write_to_jsonl(mapping_file_path, mapping_data)
207353
self._flush_to_file(mapping_file_path)
208354

209-
@staticmethod
210-
def _weaviate_object_to_dict(weaviate_object):
355+
def _object_to_dict(self, obj) -> Dict[str, Any]:
356+
"""Conver a Weaviate object to a schema-compliant JSON dictionary.
211357
212-
if weaviate_object.references is not None:
358+
:param obj: Weaviate object instance.
359+
:return: Formatted dictionary containing class, id, properties, vector, and references.
360+
"""
361+
if obj.references is not None:
213362
# FIXME: This is a hack to get the UUID of the referenced object. Replace as soon as weaviate devs offer an
214363
# actual solution for this.
215-
vals = [value.objects for _, value in weaviate_object.references.items()]
364+
vals = [value.objects for _, value in obj.references.items()]
216365
uuid = [str(obj.uuid) for sublist in vals for obj in sublist][0]
217-
references = {key: uuid for key, _ in weaviate_object.references.items()}
366+
references = {key: uuid for key, _ in obj.references.items()}
218367
else:
219368
references = {}
220369

221370
return {
222-
"class": weaviate_object.collection,
223-
"id": str(weaviate_object.uuid),
224-
"properties": weaviate_object.properties,
225-
"vector": weaviate_object.vector,
371+
"class": obj.collection,
372+
"id": str(obj.uuid),
373+
"properties": obj.properties,
374+
"vector": obj.vector,
226375
"references": references,
227376
}

datastew/repository/__init__.py

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,6 @@
11
from .model import Concept, Mapping, Terminology
2+
from .postgresql import PostgreSQLRepository
23
from .sqllite import SQLLiteRepository
34
from .weaviate import WeaviateRepository
4-
from .weaviate_schema import (concept_schema,
5-
mapping_schema_preconfigured_embeddings,
6-
mapping_schema_user_vectors, terminology_schema)
75

8-
__all__ = [
9-
"Terminology",
10-
"Concept",
11-
"Mapping",
12-
"SQLLiteRepository",
13-
"WeaviateRepository",
14-
]
6+
__all__ = ["Terminology", "Concept", "Mapping", "SQLLiteRepository", "WeaviateRepository", "PostgreSQLRepository"]

datastew/repository/base.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,12 @@ def __init__(self, vectorizer: Vectorizer = Vectorizer()):
1515
self.vectorizer = vectorizer
1616

1717
@abstractmethod
18-
def store(self, model_object_instance):
18+
def store(self, model_object_instance: Union[Terminology, Concept, Mapping]):
1919
"""Store a single model object instance."""
2020
pass
2121

2222
@abstractmethod
23-
def store_all(self, model_object_instances):
23+
def store_all(self, model_object_instances: List[Union[Terminology, Concept, Mapping]]):
2424
"""Store multiple model object instances."""
2525
pass
2626

@@ -30,7 +30,7 @@ def get_concept(self, concept_id: str) -> Concept:
3030
pass
3131

3232
@abstractmethod
33-
def get_concepts(self) -> Page[Concept]:
33+
def get_concepts(self, terminology_name: Optional[str] = None, offset: int = 0, limit: int = 100) -> Page[Concept]:
3434
"""Retrieve all concepts from the database."""
3535
pass
3636

@@ -95,6 +95,10 @@ def import_data_dictionary(self, data_dictionary: DataDictionarySource, terminol
9595
logger.exception("Failed to import data dictionary.")
9696
raise RuntimeError(f"Failed to import data dictionary source: {e}")
9797

98+
@abstractmethod
99+
def import_from_jsonl(self, jsonl_path: str, object_type: str, chunk_size: int = 100):
100+
pass
101+
98102
def _parse_data_dictionary(
99103
self, data_dictionary: DataDictionarySource, terminology_name: str
100104
) -> List[Union[Concept, Mapping, Terminology]]:

datastew/repository/model.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,6 @@ class Concept(Base):
7373
pref_label = Column(String)
7474
terminology_id = Column(String, ForeignKey("terminology.id"))
7575
terminology = relationship("Terminology")
76-
uuid = Column(String)
7776

7877
def __init__(self, terminology: Terminology, pref_label: str, concept_identifier: str, id: Optional[str] = None):
7978
self.terminology = terminology

0 commit comments

Comments
 (0)