dbgraph 0.1.0__py3-none-any.whl
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.
- dbgraph/__init__.py +21 -0
- dbgraph/application.py +70 -0
- dbgraph/builder/__init__.py +0 -0
- dbgraph/builder/graph_builder.py +22 -0
- dbgraph/builder/sql_graph_builder.py +245 -0
- dbgraph/descriptor/__init__.py +0 -0
- dbgraph/descriptor/graph_descriptor.py +68 -0
- dbgraph/descriptor/langchain_graph_descriptor.py +74 -0
- dbgraph/descriptor/prompt_templates.py +80 -0
- dbgraph/entity/__init__.py +0 -0
- dbgraph/entity/aspect.py +153 -0
- dbgraph/entity/asset.py +26 -0
- dbgraph/entity/asset_type.py +13 -0
- dbgraph/entity/dbgraph.py +152 -0
- dbgraph/entity/link.py +37 -0
- dbgraph/entity/link_type.py +13 -0
- dbgraph/entity/rdbgraph.py +76 -0
- dbgraph/event/__init__.py +0 -0
- dbgraph/event/event.py +20 -0
- dbgraph/event/event_bus.py +61 -0
- dbgraph/event/event_handler.py +11 -0
- dbgraph/io/__init__.py +0 -0
- dbgraph/io/graph_loader.py +11 -0
- dbgraph/io/graph_writer.py +11 -0
- dbgraph/io/json_graph_loader.py +160 -0
- dbgraph/io/json_graph_writer.py +17 -0
- dbgraph/persistent/__init__.py +0 -0
- dbgraph/persistent/graph_persistent.py +98 -0
- dbgraph/persistent/models.py +222 -0
- dbgraph/persistent/sql_graph_persistent.py +388 -0
- dbgraph/render/__init__.py +0 -0
- dbgraph/render/graph_renderer.py +11 -0
- dbgraph/render/markdown_renderer.py +111 -0
- dbgraph/render/text_renderer.py +11 -0
- dbgraph/search/__init__.py +0 -0
- dbgraph/search/bm25_search_engine.py +33 -0
- dbgraph/search/search_engine.py +15 -0
- dbgraph/utils/__init__.py +0 -0
- dbgraph/utils/singleton.py +37 -0
- dbgraph-0.1.0.dist-info/METADATA +165 -0
- dbgraph-0.1.0.dist-info/RECORD +42 -0
- dbgraph-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
from dataclasses import asdict, dataclass
|
|
2
|
+
|
|
3
|
+
from sqlalchemy import create_engine, delete, select, update
|
|
4
|
+
from sqlalchemy.orm import Session
|
|
5
|
+
|
|
6
|
+
from dbgraph.entity.aspect import (
|
|
7
|
+
Aspect,
|
|
8
|
+
RCategoricalStatistics,
|
|
9
|
+
RColumnSchemaAspect,
|
|
10
|
+
RColumnStatisticsAspect,
|
|
11
|
+
RForeignKeyAspect,
|
|
12
|
+
RNumericalStatistics,
|
|
13
|
+
RTableSchemaAspect,
|
|
14
|
+
RTableStatisticsAspect,
|
|
15
|
+
SemanticAspect,
|
|
16
|
+
)
|
|
17
|
+
from dbgraph.entity.asset import Asset
|
|
18
|
+
from dbgraph.entity.asset_type import AssetType
|
|
19
|
+
from dbgraph.entity.dbgraph import DatabaseGraph
|
|
20
|
+
from dbgraph.entity.link import Link
|
|
21
|
+
from dbgraph.entity.link_type import LinkType
|
|
22
|
+
from dbgraph.persistent.graph_persistent import GraphPersistent
|
|
23
|
+
from dbgraph.persistent.models import (
|
|
24
|
+
AssetAspectModel,
|
|
25
|
+
AssetModel,
|
|
26
|
+
Base,
|
|
27
|
+
GraphModel,
|
|
28
|
+
LinkAspectModel,
|
|
29
|
+
LinkModel,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class SQLGraphPersistent(GraphPersistent):
|
|
35
|
+
"""Implementation of GraphPersistent using SQL databases and SQLAlchemy"""
|
|
36
|
+
|
|
37
|
+
db_uri: str
|
|
38
|
+
|
|
39
|
+
def __post_init__(self):
|
|
40
|
+
self.engine = create_engine(self.db_uri)
|
|
41
|
+
Base.metadata.create_all(bind=self.engine)
|
|
42
|
+
|
|
43
|
+
def create_graph(self, graph_id: str, graph_name: str):
|
|
44
|
+
graph_model = GraphModel(graph_id=graph_id, name=graph_name, completed=False)
|
|
45
|
+
with Session(self.engine) as session:
|
|
46
|
+
session.add(graph_model)
|
|
47
|
+
session.commit()
|
|
48
|
+
|
|
49
|
+
def delete_graph(self, graph_id: str):
|
|
50
|
+
with Session(self.engine) as session:
|
|
51
|
+
session.execute(delete(GraphModel).where(GraphModel.graph_id == graph_id))
|
|
52
|
+
session.commit()
|
|
53
|
+
|
|
54
|
+
def insert_assets(self, assets: list[Asset], graph_id: str):
|
|
55
|
+
"""Insert the assets into database"""
|
|
56
|
+
models = [
|
|
57
|
+
AssetModel(
|
|
58
|
+
asset_id=asset.asset_id,
|
|
59
|
+
name=asset.name,
|
|
60
|
+
asset_type=asset.type.value,
|
|
61
|
+
graph_id=graph_id,
|
|
62
|
+
)
|
|
63
|
+
for asset in assets
|
|
64
|
+
]
|
|
65
|
+
aspects = []
|
|
66
|
+
for asset in assets:
|
|
67
|
+
for aspect_type, aspect in asset.aspects.items():
|
|
68
|
+
aspect = AssetAspectModel(
|
|
69
|
+
asset_id=asset.asset_id,
|
|
70
|
+
name=aspect.name,
|
|
71
|
+
type=aspect_type,
|
|
72
|
+
json_aspect=asdict(aspect),
|
|
73
|
+
)
|
|
74
|
+
aspects.append(aspect)
|
|
75
|
+
with Session(self.engine) as session:
|
|
76
|
+
session.add_all(models)
|
|
77
|
+
session.add_all(aspects)
|
|
78
|
+
session.commit()
|
|
79
|
+
|
|
80
|
+
def insert_asset(self, asset: Asset, graph_id: str):
|
|
81
|
+
"""Insert a single asset"""
|
|
82
|
+
model = AssetModel(
|
|
83
|
+
asset_id=asset.asset_id,
|
|
84
|
+
name=asset.name,
|
|
85
|
+
asset_type=asset.type.value,
|
|
86
|
+
graph_id=graph_id,
|
|
87
|
+
)
|
|
88
|
+
with Session(self.engine) as session:
|
|
89
|
+
session.add(model)
|
|
90
|
+
session.commit()
|
|
91
|
+
self.insert_asset_aspects(asset.asset_id, asset.aspects)
|
|
92
|
+
|
|
93
|
+
def insert_links(self, links: list[Link], graph_id: str):
|
|
94
|
+
"""Insert links into database"""
|
|
95
|
+
models = [
|
|
96
|
+
LinkModel(
|
|
97
|
+
link_id=link.link_id,
|
|
98
|
+
name=link.name,
|
|
99
|
+
link_type=link.type.value,
|
|
100
|
+
source_id=link.source_id,
|
|
101
|
+
destination_id=link.destination_id,
|
|
102
|
+
graph_id=graph_id,
|
|
103
|
+
)
|
|
104
|
+
for link in links
|
|
105
|
+
]
|
|
106
|
+
aspects = []
|
|
107
|
+
for link in links:
|
|
108
|
+
for aspect_type, aspect in link.aspects.items():
|
|
109
|
+
link_aspect = LinkAspectModel(
|
|
110
|
+
link_id=link.link_id,
|
|
111
|
+
name=aspect.name,
|
|
112
|
+
type=aspect_type,
|
|
113
|
+
json_aspect=asdict(aspect),
|
|
114
|
+
)
|
|
115
|
+
aspects.append(link_aspect)
|
|
116
|
+
with Session(self.engine) as session:
|
|
117
|
+
session.add_all(models)
|
|
118
|
+
session.add_all(aspects)
|
|
119
|
+
session.commit()
|
|
120
|
+
|
|
121
|
+
def insert_link(self, link: Link, graph_id: str):
|
|
122
|
+
"""Insert a single link into the database"""
|
|
123
|
+
model = LinkModel(
|
|
124
|
+
link_id=link.link_id,
|
|
125
|
+
name=link.name,
|
|
126
|
+
link_type=link.type.value,
|
|
127
|
+
source_id=link.source_id,
|
|
128
|
+
destination_id=link.destination_id,
|
|
129
|
+
graph_id=graph_id,
|
|
130
|
+
)
|
|
131
|
+
with Session(self.engine) as session:
|
|
132
|
+
session.add(model)
|
|
133
|
+
session.commit()
|
|
134
|
+
self.insert_link_aspects(link.link_id, link.aspects)
|
|
135
|
+
|
|
136
|
+
def get_link(self, link_id: str, graph_id: str) -> Link:
|
|
137
|
+
with Session(self.engine) as session:
|
|
138
|
+
model = session.scalar(
|
|
139
|
+
select(LinkModel).where(
|
|
140
|
+
LinkModel.link_id == link_id and LinkModel.graph_id == graph_id
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
if model is None:
|
|
144
|
+
raise KeyError(f"Link with id={link_id} not found in graph={graph_id}")
|
|
145
|
+
aspects = self.get_aspects_of_link(link_id)
|
|
146
|
+
return Link(
|
|
147
|
+
link_id=link_id,
|
|
148
|
+
name=model.name,
|
|
149
|
+
source_id=model.source_id,
|
|
150
|
+
destination_id=model.destination_id,
|
|
151
|
+
aspects=aspects,
|
|
152
|
+
type=LinkType(model.link_type),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def remove_assets(self, assets_ids: list[str], graph_id: str):
|
|
156
|
+
with Session(self.engine) as session:
|
|
157
|
+
session.execute(
|
|
158
|
+
delete(AssetModel).where(AssetModel.asset_id.in_(assets_ids))
|
|
159
|
+
)
|
|
160
|
+
session.commit()
|
|
161
|
+
|
|
162
|
+
def remove_asset(self, asset_id: str, graph_id: str):
|
|
163
|
+
"""Remove the asset from the database and return it"""
|
|
164
|
+
with Session(self.engine) as session:
|
|
165
|
+
session.execute(delete(AssetModel).where(AssetModel.asset_id == asset_id))
|
|
166
|
+
session.commit()
|
|
167
|
+
|
|
168
|
+
def get_asset(self, asset_id: str, graph_id: str) -> Asset:
|
|
169
|
+
with Session(self.engine) as session:
|
|
170
|
+
model = session.scalar(
|
|
171
|
+
select(AssetModel).where(
|
|
172
|
+
AssetModel.asset_id == asset_id and AssetModel.graph_id == graph_id
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
if model is None:
|
|
176
|
+
raise KeyError(f"Asset with id={asset_id} not found in graph={graph_id}")
|
|
177
|
+
aspects = self.get_aspects_of_asset(asset_id)
|
|
178
|
+
return Asset(
|
|
179
|
+
asset_id=asset_id,
|
|
180
|
+
name=model.name,
|
|
181
|
+
type=AssetType(model.asset_type),
|
|
182
|
+
aspects=aspects,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def remove_links(self, links_ids: list[str], graph_id: str):
|
|
186
|
+
"""Remove the links and return them"""
|
|
187
|
+
with Session(self.engine) as session:
|
|
188
|
+
session.execute(delete(LinkModel).where(LinkModel.link_id.in_(links_ids)))
|
|
189
|
+
session.commit()
|
|
190
|
+
|
|
191
|
+
def remove_link(self, link_id: str, graph_id: str):
|
|
192
|
+
"""Remove the link and return it"""
|
|
193
|
+
with Session(self.engine) as session:
|
|
194
|
+
session.execute(delete(LinkModel).where(LinkModel.link_id == link_id))
|
|
195
|
+
session.commit()
|
|
196
|
+
|
|
197
|
+
def insert_asset_aspects(self, asset_id: str, aspects: dict[str, Aspect]):
|
|
198
|
+
"""Insert aspects into asset"""
|
|
199
|
+
models = [
|
|
200
|
+
AssetAspectModel(
|
|
201
|
+
name=aspect.name,
|
|
202
|
+
type=aspect_type,
|
|
203
|
+
asset_id=asset_id,
|
|
204
|
+
json_aspect=asdict(aspect),
|
|
205
|
+
)
|
|
206
|
+
for aspect_type, aspect in aspects.items()
|
|
207
|
+
]
|
|
208
|
+
with Session(self.engine) as session:
|
|
209
|
+
session.add_all(models)
|
|
210
|
+
session.commit()
|
|
211
|
+
|
|
212
|
+
def insert_link_aspects(self, link_id: str, aspects: dict[str, Aspect]):
|
|
213
|
+
"""Insert aspects into link"""
|
|
214
|
+
models = [
|
|
215
|
+
LinkAspectModel(
|
|
216
|
+
name=aspect.name,
|
|
217
|
+
type=aspect_type,
|
|
218
|
+
link_id=link_id,
|
|
219
|
+
json_aspect=asdict(aspect),
|
|
220
|
+
)
|
|
221
|
+
for aspect_type, aspect in aspects.items()
|
|
222
|
+
]
|
|
223
|
+
with Session(self.engine) as session:
|
|
224
|
+
session.add_all(models)
|
|
225
|
+
session.commit()
|
|
226
|
+
|
|
227
|
+
def _parse_asset_aspect_model(
|
|
228
|
+
self, model: AssetAspectModel, asset_type: AssetType
|
|
229
|
+
) -> Aspect:
|
|
230
|
+
"""Turn SQL Alchemy model to Aspect"""
|
|
231
|
+
json_aspect = model.json_aspect
|
|
232
|
+
match model.type:
|
|
233
|
+
case "schema_properties":
|
|
234
|
+
if asset_type == AssetType.RTABLE:
|
|
235
|
+
aspect = RTableSchemaAspect(**json_aspect)
|
|
236
|
+
elif asset_type == AssetType.RCOLUMN:
|
|
237
|
+
aspect = RColumnSchemaAspect(**json_aspect)
|
|
238
|
+
else:
|
|
239
|
+
raise NotImplementedError()
|
|
240
|
+
case "statistical_properties":
|
|
241
|
+
if asset_type == AssetType.RTABLE:
|
|
242
|
+
aspect = RTableStatisticsAspect(**json_aspect)
|
|
243
|
+
elif asset_type == AssetType.RCOLUMN:
|
|
244
|
+
if json_aspect["numerical_stats"] is not None:
|
|
245
|
+
numerical_aspect = RNumericalStatistics(
|
|
246
|
+
**json_aspect["numerical_stats"]
|
|
247
|
+
)
|
|
248
|
+
categorical_aspect = None
|
|
249
|
+
elif json_aspect["categorical_stats"] is not None:
|
|
250
|
+
categorical_aspect = RCategoricalStatistics(
|
|
251
|
+
**json_aspect["categorical_stats"]
|
|
252
|
+
)
|
|
253
|
+
numerical_aspect = None
|
|
254
|
+
else:
|
|
255
|
+
raise RuntimeError(
|
|
256
|
+
"Neither categorical or numerical stats exists"
|
|
257
|
+
)
|
|
258
|
+
aspect = RColumnStatisticsAspect(
|
|
259
|
+
name=json_aspect["name"],
|
|
260
|
+
non_null_count=json_aspect["non_null_count"],
|
|
261
|
+
null_count=json_aspect["null_count"],
|
|
262
|
+
categorical_stats=categorical_aspect,
|
|
263
|
+
numerical_stats=numerical_aspect,
|
|
264
|
+
)
|
|
265
|
+
else:
|
|
266
|
+
raise NotImplementedError()
|
|
267
|
+
case "semantic_properties":
|
|
268
|
+
aspect = SemanticAspect(**json_aspect)
|
|
269
|
+
case _:
|
|
270
|
+
raise NotImplementedError()
|
|
271
|
+
return aspect
|
|
272
|
+
|
|
273
|
+
def get_aspects_of_asset(self, asset_id: str) -> dict[str, Aspect]:
|
|
274
|
+
"""Get the aspects of an asset"""
|
|
275
|
+
with Session(self.engine) as session:
|
|
276
|
+
models = session.scalars(
|
|
277
|
+
select(AssetAspectModel).where(AssetAspectModel.asset_id == asset_id)
|
|
278
|
+
)
|
|
279
|
+
asset = session.scalar(
|
|
280
|
+
select(AssetModel).where(AssetModel.asset_id == asset_id)
|
|
281
|
+
)
|
|
282
|
+
if asset is None:
|
|
283
|
+
raise KeyError(f"Asset with id={asset_id} not found!")
|
|
284
|
+
asset_type = AssetType(asset.asset_type)
|
|
285
|
+
answers: dict[str, Aspect] = {}
|
|
286
|
+
for model in models:
|
|
287
|
+
answers[model.type] = self._parse_asset_aspect_model(model, asset_type)
|
|
288
|
+
return answers
|
|
289
|
+
|
|
290
|
+
def _parse_link_aspect_model(self, model: LinkAspectModel) -> Aspect:
|
|
291
|
+
"""Turn SQL Alchemy model to Link Aspect"""
|
|
292
|
+
json_aspect = model.json_aspect
|
|
293
|
+
match model.type:
|
|
294
|
+
case "foreign_key_properties":
|
|
295
|
+
aspect = RForeignKeyAspect(**json_aspect)
|
|
296
|
+
case _:
|
|
297
|
+
raise NotImplementedError()
|
|
298
|
+
return aspect
|
|
299
|
+
|
|
300
|
+
def get_aspects_of_link(self, link_id: str) -> dict[str, Aspect]:
|
|
301
|
+
"""Get the aspects of a link"""
|
|
302
|
+
with Session(self.engine) as session:
|
|
303
|
+
models = session.scalars(
|
|
304
|
+
select(LinkAspectModel).where(LinkAspectModel.link_id == link_id)
|
|
305
|
+
)
|
|
306
|
+
link = session.scalar(select(LinkModel).where(LinkModel.link_id == link_id))
|
|
307
|
+
if link is None:
|
|
308
|
+
raise KeyError(f"Link with id={link_id} not found!")
|
|
309
|
+
answers: dict[str, Aspect] = {}
|
|
310
|
+
for model in models:
|
|
311
|
+
answers[model.type] = self._parse_link_aspect_model(model)
|
|
312
|
+
return answers
|
|
313
|
+
|
|
314
|
+
def get_assets(self, graph_id: str) -> list[Asset]:
|
|
315
|
+
with Session(self.engine) as session:
|
|
316
|
+
models = session.scalars(
|
|
317
|
+
select(AssetModel).where(AssetModel.graph_id == graph_id)
|
|
318
|
+
)
|
|
319
|
+
assets = []
|
|
320
|
+
for model in models:
|
|
321
|
+
aspects = {}
|
|
322
|
+
for aspect_model in model.asset_aspects:
|
|
323
|
+
aspects[aspect_model.type] = self._parse_asset_aspect_model(
|
|
324
|
+
aspect_model, AssetType(model.asset_type)
|
|
325
|
+
)
|
|
326
|
+
asset = Asset(
|
|
327
|
+
asset_id=model.asset_id,
|
|
328
|
+
name=model.name,
|
|
329
|
+
type=AssetType(model.asset_type),
|
|
330
|
+
aspects=aspects,
|
|
331
|
+
)
|
|
332
|
+
assets.append(asset)
|
|
333
|
+
return assets
|
|
334
|
+
|
|
335
|
+
def get_links(self, graph_id: str) -> list[Link]:
|
|
336
|
+
with Session(self.engine) as session:
|
|
337
|
+
models = session.scalars(
|
|
338
|
+
select(LinkModel).where(LinkModel.graph_id == graph_id)
|
|
339
|
+
)
|
|
340
|
+
links = []
|
|
341
|
+
for model in models:
|
|
342
|
+
aspects = {}
|
|
343
|
+
for aspect_model in model.link_aspects:
|
|
344
|
+
aspects[aspect_model.type] = self._parse_link_aspect_model(
|
|
345
|
+
aspect_model
|
|
346
|
+
)
|
|
347
|
+
link = Link(
|
|
348
|
+
link_id=model.link_id,
|
|
349
|
+
name=model.name,
|
|
350
|
+
type=LinkType(model.link_type),
|
|
351
|
+
aspects=aspects,
|
|
352
|
+
source_id=model.source_id,
|
|
353
|
+
destination_id=model.destination_id,
|
|
354
|
+
)
|
|
355
|
+
links.append(link)
|
|
356
|
+
return links
|
|
357
|
+
|
|
358
|
+
def load_graph(self, graph_id: str) -> DatabaseGraph:
|
|
359
|
+
"""Load the full database graph"""
|
|
360
|
+
links = self.get_links(graph_id)
|
|
361
|
+
assets = self.get_assets(graph_id)
|
|
362
|
+
return DatabaseGraph(assets, links)
|
|
363
|
+
|
|
364
|
+
def save_graph(self, graph: DatabaseGraph, graph_id: str):
|
|
365
|
+
"""Save the graph into database"""
|
|
366
|
+
|
|
367
|
+
self.insert_assets(graph.assets, graph_id)
|
|
368
|
+
self.insert_links(graph.links, graph_id)
|
|
369
|
+
|
|
370
|
+
def set_graph_state(self, graph_id: str, completed: bool):
|
|
371
|
+
"""Mark the graph as completely built and saved in the database or hasn't been finished building yet"""
|
|
372
|
+
stmt = (
|
|
373
|
+
update(GraphModel)
|
|
374
|
+
.where(GraphModel.graph_id == graph_id)
|
|
375
|
+
.values(completed=completed)
|
|
376
|
+
)
|
|
377
|
+
with Session(self.engine) as session:
|
|
378
|
+
session.execute(stmt)
|
|
379
|
+
session.commit()
|
|
380
|
+
|
|
381
|
+
def get_graph_info(self, graph_id: str) -> tuple[str, str, bool]:
|
|
382
|
+
with Session(self.engine) as session:
|
|
383
|
+
model = session.scalar(
|
|
384
|
+
select(GraphModel).where(GraphModel.graph_id == graph_id)
|
|
385
|
+
)
|
|
386
|
+
if model is None:
|
|
387
|
+
raise KeyError(f"Graph with id={graph_id} not found")
|
|
388
|
+
return model.graph_id, model.name, model.completed
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from dbgraph.entity.dbgraph import DatabaseGraph
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class GraphRenderer(ABC):
|
|
7
|
+
"""Interface for rendering the graph"""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def render(self, graph: DatabaseGraph):
|
|
11
|
+
"""Render the graph, store data inside the class"""
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from dbgraph import AssetType, LinkType
|
|
4
|
+
from dbgraph.entity.aspect import (
|
|
5
|
+
RColumnSchemaAspect,
|
|
6
|
+
RColumnStatisticsAspect,
|
|
7
|
+
RForeignKeyAspect,
|
|
8
|
+
RTableSchemaAspect,
|
|
9
|
+
RTableStatisticsAspect,
|
|
10
|
+
SemanticAspect,
|
|
11
|
+
)
|
|
12
|
+
from dbgraph.entity.asset import Asset
|
|
13
|
+
from dbgraph.entity.dbgraph import DatabaseGraph
|
|
14
|
+
from dbgraph.entity.link import Link
|
|
15
|
+
from dbgraph.entity.rdbgraph import RDatabaseGraph
|
|
16
|
+
from dbgraph.render.text_renderer import TextRenderer
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class MarkdownRenderer(TextRenderer):
|
|
21
|
+
"""Render the graph to Markdown format"""
|
|
22
|
+
|
|
23
|
+
categorical_value_max_char: int = 50
|
|
24
|
+
|
|
25
|
+
def __post_init__(self):
|
|
26
|
+
self.content = ""
|
|
27
|
+
|
|
28
|
+
def _dump_table(self, asset: Asset) -> str:
|
|
29
|
+
if asset.type != AssetType.RTABLE:
|
|
30
|
+
raise TypeError(f"Asset of type {asset.type} is not table type")
|
|
31
|
+
if "semantic_properties" in asset.aspects and isinstance(
|
|
32
|
+
asset.aspects["semantic_properties"], SemanticAspect
|
|
33
|
+
):
|
|
34
|
+
answer = f"""# Table `{asset.name}`
|
|
35
|
+
{asset.aspects["semantic_properties"].description}
|
|
36
|
+
- ID: {asset.asset_id}"""
|
|
37
|
+
else:
|
|
38
|
+
answer = f"""# Table `{asset.name}`
|
|
39
|
+
- ID: {asset.asset_id}"""
|
|
40
|
+
for aspect_name, aspect in asset.aspects.items():
|
|
41
|
+
if isinstance(aspect, RTableStatisticsAspect):
|
|
42
|
+
answer += f"\n- `{aspect_name}`: {aspect.num_rows} row(s), {aspect.num_columns} columns"
|
|
43
|
+
elif isinstance(aspect, RTableSchemaAspect):
|
|
44
|
+
answer += f"\n- `{aspect_name}`:"
|
|
45
|
+
answer += f"\n\t- Primary keys: {', '.join(aspect.pks)}"
|
|
46
|
+
return answer
|
|
47
|
+
|
|
48
|
+
def _dump_column(self, asset: Asset) -> str:
|
|
49
|
+
header = f"""## Column `{asset.name}`"""
|
|
50
|
+
if asset.type != AssetType.RCOLUMN:
|
|
51
|
+
raise TypeError(f"Asset of type {asset.type} is not column type")
|
|
52
|
+
if "semantic_properties" in asset.aspects and isinstance(
|
|
53
|
+
asset.aspects["semantic_properties"], SemanticAspect
|
|
54
|
+
):
|
|
55
|
+
answer = f"""{asset.aspects["semantic_properties"].description}
|
|
56
|
+
- ID: {asset.asset_id}"""
|
|
57
|
+
else:
|
|
58
|
+
answer = f"""- ID: {asset.asset_id}"""
|
|
59
|
+
for aspect_name, aspect in asset.aspects.items():
|
|
60
|
+
if isinstance(aspect, RColumnSchemaAspect):
|
|
61
|
+
answer += f"\n- `{aspect_name}`: dtype={aspect.dtype}, nullable={aspect.is_nullable}"
|
|
62
|
+
if aspect.is_pk:
|
|
63
|
+
header += "(Primary Key)"
|
|
64
|
+
elif isinstance(aspect, RColumnStatisticsAspect):
|
|
65
|
+
if aspect.numerical_stats is not None:
|
|
66
|
+
answer += f"\n- Numerical stats: min={aspect.numerical_stats.min}, max={aspect.numerical_stats.max}, mean={aspect.numerical_stats.mean}"
|
|
67
|
+
elif aspect.categorical_stats is not None:
|
|
68
|
+
answer += "\n- Top 10 Value counts: "
|
|
69
|
+
for key, value in aspect.categorical_stats.value_counts.items():
|
|
70
|
+
if len(key) > self.categorical_value_max_char:
|
|
71
|
+
key = key[: self.categorical_value_max_char] + "..."
|
|
72
|
+
answer += f"\n\t- {key}: {value}"
|
|
73
|
+
return header + "\n" + answer
|
|
74
|
+
|
|
75
|
+
def _dump_fk(self, link: Link, graph: DatabaseGraph) -> str:
|
|
76
|
+
if link.type != LinkType.FOREIGN_KEY:
|
|
77
|
+
raise TypeError(f"Link of type {link.type} is not foreign key type")
|
|
78
|
+
src_name = graph.get_asset(link.source_id).name
|
|
79
|
+
dst_name = graph.get_asset(link.destination_id).name
|
|
80
|
+
if "foreign_key_properties" in link.aspects and isinstance(
|
|
81
|
+
link.aspects["foreign_key_properties"], RForeignKeyAspect
|
|
82
|
+
):
|
|
83
|
+
aspect = link.aspects["foreign_key_properties"]
|
|
84
|
+
answer = (
|
|
85
|
+
f"- {src_name}.{aspect.from_column} -> {dst_name}.{aspect.to_column}"
|
|
86
|
+
)
|
|
87
|
+
else:
|
|
88
|
+
answer = f"- {src_name} -> {dst_name}"
|
|
89
|
+
|
|
90
|
+
return answer
|
|
91
|
+
|
|
92
|
+
def render(self, graph: DatabaseGraph):
|
|
93
|
+
graph = RDatabaseGraph.from_graph(graph)
|
|
94
|
+
tables = graph.get_tables()
|
|
95
|
+
answer = ""
|
|
96
|
+
for table in tables:
|
|
97
|
+
answer += f"\n{self._dump_table(table)}"
|
|
98
|
+
columns = graph.get_columns(table.asset_id)
|
|
99
|
+
for column in columns:
|
|
100
|
+
answer += f"\n{self._dump_column(column)}"
|
|
101
|
+
foreign_keys = graph.get_foreign_keys()
|
|
102
|
+
if len(foreign_keys) > 0:
|
|
103
|
+
answer += "\n# List of Foreign Key Constraints"
|
|
104
|
+
for fk in foreign_keys:
|
|
105
|
+
answer += f"\n{self._dump_fk(fk, graph)}"
|
|
106
|
+
self.content = answer
|
|
107
|
+
|
|
108
|
+
def get_content(self) -> str:
|
|
109
|
+
if not self.content:
|
|
110
|
+
raise RuntimeError("Content is empty. Please run `render()` first!")
|
|
111
|
+
return self.content
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from dbgraph.render.graph_renderer import GraphRenderer
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TextRenderer(GraphRenderer, ABC):
|
|
7
|
+
"""Render the graph to text-based representation"""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def get_content(self) -> str:
|
|
11
|
+
"""Get the text data"""
|
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import bm25s
|
|
5
|
+
import Stemmer
|
|
6
|
+
|
|
7
|
+
from dbgraph.entity.aspect import SemanticAspect
|
|
8
|
+
from dbgraph.search.search_engine import SearchEngine
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class BM25SearchEngine(SearchEngine):
|
|
13
|
+
"""Implementation of SearchEngine using BM25"""
|
|
14
|
+
|
|
15
|
+
index_path: Path
|
|
16
|
+
|
|
17
|
+
def search(self, text: str, top_k: int = 10) -> list[str]:
|
|
18
|
+
retriever = bm25s.BM25.load(self.index_path, load_corpus=True)
|
|
19
|
+
stemmer = Stemmer.Stemmer("english")
|
|
20
|
+
tokens = bm25s.tokenize(text, stopwords="en", stemmer=stemmer)
|
|
21
|
+
results, _ = retriever.retrieve(tokens, k=top_k)
|
|
22
|
+
results = results[0]
|
|
23
|
+
return [r["id"] for r in results]
|
|
24
|
+
|
|
25
|
+
def index(self, semantic_aspects: dict[str, SemanticAspect]):
|
|
26
|
+
stemmer = Stemmer.Stemmer("english")
|
|
27
|
+
corpus = [{"id": k, "text": v.description} for k, v in semantic_aspects.items()]
|
|
28
|
+
tokens = bm25s.tokenize(
|
|
29
|
+
[doc["text"] for doc in corpus], stopwords="en", stemmer=stemmer
|
|
30
|
+
)
|
|
31
|
+
retriever = bm25s.BM25(corpus=corpus)
|
|
32
|
+
retriever.index(tokens)
|
|
33
|
+
retriever.save(self.index_path)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
|
|
3
|
+
from dbgraph.entity.aspect import SemanticAspect
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SearchEngine(ABC):
|
|
7
|
+
"""Interface for search engines"""
|
|
8
|
+
|
|
9
|
+
@abstractmethod
|
|
10
|
+
def search(self, text: str, top_k: int = 10) -> list[str]:
|
|
11
|
+
"""Search relevant resources and return list of IDs"""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def index(self, semantic_aspects: dict[str, SemanticAspect]):
|
|
15
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
from threading import Lock
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SingletonMeta(type):
|
|
6
|
+
"""
|
|
7
|
+
This is a thread-safe implementation of Singleton.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
_instances: typing.ClassVar = {}
|
|
11
|
+
|
|
12
|
+
_lock: Lock = Lock()
|
|
13
|
+
"""
|
|
14
|
+
We now have a lock object that will be used to synchronize threads during
|
|
15
|
+
first access to the Singleton.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __call__(cls, *args, **kwargs):
|
|
19
|
+
"""
|
|
20
|
+
Possible changes to the value of the `__init__` argument do not affect
|
|
21
|
+
the returned instance.
|
|
22
|
+
"""
|
|
23
|
+
# Now, imagine that the program has just been launched. Since there's no
|
|
24
|
+
# Singleton instance yet, multiple threads can simultaneously pass the
|
|
25
|
+
# previous conditional and reach this point almost at the same time. The
|
|
26
|
+
# first of them will acquire lock and will proceed further, while the
|
|
27
|
+
# rest will wait here.
|
|
28
|
+
with cls._lock:
|
|
29
|
+
# The first thread to acquire the lock, reaches this conditional,
|
|
30
|
+
# goes inside and creates the Singleton instance. Once it leaves the
|
|
31
|
+
# lock block, a thread that might have been waiting for the lock
|
|
32
|
+
# release may then enter this section. But since the Singleton field
|
|
33
|
+
# is already initialized, the thread won't create a new object.
|
|
34
|
+
if cls not in cls._instances:
|
|
35
|
+
instance = super().__call__(*args, **kwargs)
|
|
36
|
+
cls._instances[cls] = instance
|
|
37
|
+
return cls._instances[cls]
|