databricks-explore 0.5.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.
- databricks_explore/__init__.py +3 -0
- databricks_explore/__main__.py +6 -0
- databricks_explore/app.py +673 -0
- databricks_explore/canvases.py +333 -0
- databricks_explore/cli.py +80 -0
- databricks_explore/concepts.py +325 -0
- databricks_explore/dbx.py +537 -0
- databricks_explore/demo.py +653 -0
- databricks_explore/demo_history.py +157 -0
- databricks_explore/genie.py +281 -0
- databricks_explore/layout.py +1764 -0
- databricks_explore/mcp_server/__init__.py +93 -0
- databricks_explore/mcp_server/auth.py +95 -0
- databricks_explore/mcp_server/rpc.py +88 -0
- databricks_explore/mcp_server/schema.py +68 -0
- databricks_explore/mcp_server/sqlguard.py +59 -0
- databricks_explore/mcp_server/tooling.py +74 -0
- databricks_explore/mcp_server/tools_canvas.py +430 -0
- databricks_explore/mcp_server/tools_data.py +472 -0
- databricks_explore/mining.py +433 -0
- databricks_explore/ontology.py +508 -0
- databricks_explore/relationships.py +987 -0
- databricks_explore/sqltools.py +525 -0
- databricks_explore/web_dist/THIRD-PARTY-NOTICES.txt +5853 -0
- databricks_explore/web_dist/assets/index-6I1cPS7i.css +1 -0
- databricks_explore/web_dist/assets/index-CR9j5CJG.js +330 -0
- databricks_explore/web_dist/index.html +13 -0
- databricks_explore-0.5.0.dist-info/METADATA +207 -0
- databricks_explore-0.5.0.dist-info/RECORD +32 -0
- databricks_explore-0.5.0.dist-info/WHEEL +4 -0
- databricks_explore-0.5.0.dist-info/entry_points.txt +2 -0
- databricks_explore-0.5.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,987 @@
|
|
|
1
|
+
"""Relationship store: one model for every edge between tables or columns, with provenance, in SQLite.
|
|
2
|
+
|
|
3
|
+
Derived kinds (fk, lineage, view_dep) are never stored. They are normalized from Explorer.relations()
|
|
4
|
+
at read time with status "derived". Every other kind is persisted in data/relationships/<profile>.sqlite.
|
|
5
|
+
A user note on a derived edge is kept in an overlay keyed by the edge's natural key.
|
|
6
|
+
|
|
7
|
+
Natural key: (kind, source table, source column, target table, target column). Writes upsert on it,
|
|
8
|
+
so re-importing the same edge never duplicates it. Undirected edges are stored with their endpoints
|
|
9
|
+
in sorted order, so (a, b) and (b, a) are the same edge.
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import sqlite3
|
|
14
|
+
import uuid
|
|
15
|
+
from itertools import zip_longest
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Annotated, Literal, Optional
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, ValidationError, field_validator, model_validator
|
|
20
|
+
from pydantic_core import PydanticCustomError
|
|
21
|
+
|
|
22
|
+
from . import dbx
|
|
23
|
+
|
|
24
|
+
DERIVED_KINDS = ("fk", "lineage", "view_dep")
|
|
25
|
+
STORED_KINDS = ("join_mined", "manual", "sketch", "concept_link", "ontology")
|
|
26
|
+
STORED_STATUSES = ("proposed", "accepted", "rejected")
|
|
27
|
+
EXPORT_VERSION = 1
|
|
28
|
+
CONCEPT_STATUSES = ("draft", "reviewed", "certified")
|
|
29
|
+
CONCEPT_ORIGINS = ("manual", "uc_tag", "ontology")
|
|
30
|
+
# What a concept link can say, by what it points at.
|
|
31
|
+
TABLE_RELATIONS = ("defines", "measures", "identifies", "describes")
|
|
32
|
+
CONCEPT_RELATIONS = ("is_a", "part_of", "related_to")
|
|
33
|
+
|
|
34
|
+
Kind = Literal["fk", "lineage", "view_dep", "join_mined", "manual", "sketch", "concept_link", "ontology"]
|
|
35
|
+
Status = Literal["derived", "proposed", "accepted", "rejected"]
|
|
36
|
+
|
|
37
|
+
_TABLE = re.compile(r"^[^.\s]+\.[^.\s]+\.[^.\s]+$")
|
|
38
|
+
_PLACEHOLDER = "ph:"
|
|
39
|
+
_CONCEPT = "concept:"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RelationshipInvalid(ValueError):
|
|
43
|
+
"""Validation failed. str(e) is '<path>: <reason>' (routes return it as HTTP 400)."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RelationshipNotFound(LookupError):
|
|
47
|
+
"""No relationship with that id (routes return HTTP 404)."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ---------- models ----------
|
|
51
|
+
|
|
52
|
+
def new_id():
|
|
53
|
+
"""A fresh id for a stored edge."""
|
|
54
|
+
return "e_" + uuid.uuid4().hex
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Endpoint(BaseModel):
|
|
58
|
+
"""One end of an edge: a table (3-part name, ph:<uuid> placeholder or concept:<uuid>) and optionally a column."""
|
|
59
|
+
model_config = ConfigDict(extra="forbid")
|
|
60
|
+
table: str
|
|
61
|
+
column: Optional[str] = None
|
|
62
|
+
|
|
63
|
+
@field_validator("table")
|
|
64
|
+
@classmethod
|
|
65
|
+
def _table_name(cls, v):
|
|
66
|
+
if v.startswith(_PLACEHOLDER):
|
|
67
|
+
try:
|
|
68
|
+
return _PLACEHOLDER + str(uuid.UUID(v[len(_PLACEHOLDER):]))
|
|
69
|
+
except ValueError:
|
|
70
|
+
raise PydanticCustomError("bad_table", 'a placeholder must be "ph:<uuid>"') from None
|
|
71
|
+
if v.startswith(_CONCEPT):
|
|
72
|
+
try:
|
|
73
|
+
return _CONCEPT + str(uuid.UUID(v[len(_CONCEPT):]))
|
|
74
|
+
except ValueError:
|
|
75
|
+
raise PydanticCustomError("bad_table", 'a concept must be "concept:<uuid>"') from None
|
|
76
|
+
if not _TABLE.match(v):
|
|
77
|
+
raise PydanticCustomError("bad_table", 'a table must be "catalog.schema.table" or "ph:<uuid>"')
|
|
78
|
+
return v
|
|
79
|
+
|
|
80
|
+
@model_validator(mode="after")
|
|
81
|
+
def _concept_has_no_column(self):
|
|
82
|
+
if self.table.startswith(_CONCEPT) and self.column is not None:
|
|
83
|
+
raise PydanticCustomError("bad_column", "a concept has no columns")
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
@field_validator("column")
|
|
87
|
+
@classmethod
|
|
88
|
+
def _column_name(cls, v):
|
|
89
|
+
if v is not None and not v.strip():
|
|
90
|
+
raise PydanticCustomError("bad_column", "a column must not be empty (omit it for a table-level end)")
|
|
91
|
+
return v
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Provenance(BaseModel):
|
|
95
|
+
"""Where an edge came from: an origin label, an optional reference into it, and when it was read."""
|
|
96
|
+
model_config = ConfigDict(extra="forbid")
|
|
97
|
+
origin: Annotated[str, Field(min_length=1)]
|
|
98
|
+
source_ref: Optional[str] = None
|
|
99
|
+
fetched_at: Optional[str] = None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
MAX_TOGETHER = 5 # composite keys kept per edge
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class Together(BaseModel):
|
|
106
|
+
"""Column pairs (source column, target column) that were joined in one condition, and how many runs had them so."""
|
|
107
|
+
model_config = ConfigDict(extra="forbid")
|
|
108
|
+
columns: list[tuple[str, str]]
|
|
109
|
+
runs: Annotated[StrictInt, Field(ge=0)]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class Evidence(BaseModel):
|
|
113
|
+
"""How often an edge was observed, for kinds that are mined from activity."""
|
|
114
|
+
model_config = ConfigDict(extra="forbid")
|
|
115
|
+
runs: Optional[Annotated[StrictInt, Field(ge=0)]] = None
|
|
116
|
+
users: Optional[Annotated[StrictInt, Field(ge=0)]] = None
|
|
117
|
+
first_seen: Optional[str] = None
|
|
118
|
+
last_seen: Optional[str] = None
|
|
119
|
+
sample_refs: list[str] = Field(default_factory=list)
|
|
120
|
+
together: list[Together] = Field(default_factory=list) # composite keys this pair is part of
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class Edge(BaseModel):
|
|
124
|
+
"""One relationship between two endpoints. The shape the API returns and the export file holds."""
|
|
125
|
+
model_config = ConfigDict(extra="forbid")
|
|
126
|
+
id: Annotated[str, Field(min_length=1)] = Field(default_factory=new_id)
|
|
127
|
+
kind: Kind
|
|
128
|
+
source: Endpoint
|
|
129
|
+
target: Endpoint
|
|
130
|
+
directed: StrictBool = True
|
|
131
|
+
provenance: Provenance
|
|
132
|
+
evidence: Optional[Evidence] = None
|
|
133
|
+
status: Status = "proposed"
|
|
134
|
+
note: str = ""
|
|
135
|
+
relation: Optional[str] = None # concept_link only: defines, measures, identifies, describes, is_a, part_of, related_to
|
|
136
|
+
created_at: str = Field(default_factory=dbx.now)
|
|
137
|
+
updated_at: str = Field(default_factory=dbx.now)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ConceptProvenance(Provenance):
|
|
141
|
+
"""Where a concept came from: manual, a Unity Catalog tag (uc_tag) or an ontology import."""
|
|
142
|
+
origin: Literal["manual", "uc_tag", "ontology"] = "manual"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def new_concept_id():
|
|
146
|
+
"""A fresh concept id (a bare uuid)."""
|
|
147
|
+
return str(uuid.uuid4())
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _clean_list(v):
|
|
151
|
+
"""Strip each string, drop empties and case-insensitive repeats, keep order."""
|
|
152
|
+
out, seen = [], set()
|
|
153
|
+
for item in v:
|
|
154
|
+
item = item.strip()
|
|
155
|
+
if item and item.casefold() not in seen:
|
|
156
|
+
seen.add(item.casefold())
|
|
157
|
+
out.append(item)
|
|
158
|
+
return out
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class Concept(BaseModel):
|
|
162
|
+
"""A business term: name, definition, synonyms, domain and review status. Links to tables live in edges."""
|
|
163
|
+
model_config = ConfigDict(extra="forbid")
|
|
164
|
+
id: str = Field(default_factory=new_concept_id)
|
|
165
|
+
name: Annotated[str, Field(min_length=1, max_length=200)]
|
|
166
|
+
definition: str = ""
|
|
167
|
+
synonyms: list[str] = Field(default_factory=list)
|
|
168
|
+
domain: Optional[str] = None
|
|
169
|
+
status: Literal["draft", "reviewed", "certified"] = "draft"
|
|
170
|
+
tags: list[str] = Field(default_factory=list)
|
|
171
|
+
provenance: ConceptProvenance = Field(default_factory=lambda: ConceptProvenance(fetched_at=dbx.now()))
|
|
172
|
+
created_at: str = Field(default_factory=dbx.now)
|
|
173
|
+
updated_at: str = Field(default_factory=dbx.now)
|
|
174
|
+
|
|
175
|
+
@field_validator("id")
|
|
176
|
+
@classmethod
|
|
177
|
+
def _id(cls, v):
|
|
178
|
+
try:
|
|
179
|
+
return str(uuid.UUID(v))
|
|
180
|
+
except ValueError:
|
|
181
|
+
raise PydanticCustomError("bad_id", "a concept id must be a uuid") from None
|
|
182
|
+
|
|
183
|
+
@field_validator("name", mode="before")
|
|
184
|
+
@classmethod
|
|
185
|
+
def _name(cls, v):
|
|
186
|
+
return v.strip() if isinstance(v, str) else v
|
|
187
|
+
|
|
188
|
+
@field_validator("synonyms", "tags")
|
|
189
|
+
@classmethod
|
|
190
|
+
def _lists(cls, v):
|
|
191
|
+
return _clean_list(v)
|
|
192
|
+
|
|
193
|
+
@field_validator("domain")
|
|
194
|
+
@classmethod
|
|
195
|
+
def _domain(cls, v):
|
|
196
|
+
v = v.strip() if v is not None else None
|
|
197
|
+
return v or None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class ColumnPair(BaseModel):
|
|
201
|
+
"""One column-level member of a rolled-up table edge. A side is None when that end names no column."""
|
|
202
|
+
source: Optional[str] = None
|
|
203
|
+
target: Optional[str] = None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class Rolled(Edge):
|
|
207
|
+
"""A table-level edge rolled up from its members (column edges, or the edge itself)."""
|
|
208
|
+
columns: list[ColumnPair] = Field(default_factory=list)
|
|
209
|
+
members: list[str] = Field(default_factory=list)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class EdgeCreate(BaseModel):
|
|
213
|
+
"""Body of POST /api/relationships: a user-drawn edge."""
|
|
214
|
+
model_config = ConfigDict(extra="forbid")
|
|
215
|
+
source: Endpoint
|
|
216
|
+
target: Endpoint
|
|
217
|
+
directed: StrictBool = True
|
|
218
|
+
note: str = ""
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class EdgePatch(BaseModel):
|
|
222
|
+
"""Body of PATCH /api/relationships/<id>: a status and/or a note."""
|
|
223
|
+
model_config = ConfigDict(extra="forbid")
|
|
224
|
+
status: Optional[Literal["derived", "proposed", "accepted", "rejected"]] = None
|
|
225
|
+
note: Optional[str] = None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class BulkStatus(BaseModel):
|
|
229
|
+
"""Body of POST /api/relationships/bulk: one status for several stored edges."""
|
|
230
|
+
model_config = ConfigDict(extra="forbid")
|
|
231
|
+
ids: Annotated[list[Annotated[str, Field(min_length=1)]], Field(min_length=1, max_length=1000)]
|
|
232
|
+
status: Literal["proposed", "accepted", "rejected"]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class Overlay(BaseModel):
|
|
236
|
+
"""A note on a derived edge, in the export file."""
|
|
237
|
+
model_config = ConfigDict(extra="forbid")
|
|
238
|
+
kind: Literal["fk", "lineage", "view_dep"]
|
|
239
|
+
source: Endpoint
|
|
240
|
+
target: Endpoint
|
|
241
|
+
note: Annotated[str, Field(min_length=1)]
|
|
242
|
+
updated_at: str = Field(default_factory=dbx.now)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class ExportFile(BaseModel):
|
|
246
|
+
"""The export/import format: stored edges plus notes on derived edges."""
|
|
247
|
+
model_config = ConfigDict(extra="forbid")
|
|
248
|
+
version: Annotated[StrictInt, Field(ge=EXPORT_VERSION, le=EXPORT_VERSION)]
|
|
249
|
+
exported_at: Optional[str] = None
|
|
250
|
+
edges: list[Edge]
|
|
251
|
+
overlays: list[Overlay] = Field(default_factory=list)
|
|
252
|
+
concepts: list[Concept] = Field(default_factory=list)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _path(loc):
|
|
256
|
+
out = ""
|
|
257
|
+
for part in loc:
|
|
258
|
+
out += f"[{part}]" if isinstance(part, int) else (f".{part}" if out else str(part))
|
|
259
|
+
return out or "(root)"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _parse(model, data):
|
|
263
|
+
"""Validate data against a model. Raises RelationshipInvalid with a path-prefixed message."""
|
|
264
|
+
if not isinstance(data, dict):
|
|
265
|
+
raise RelationshipInvalid("(root): body must be a JSON object")
|
|
266
|
+
try:
|
|
267
|
+
return model.model_validate(data)
|
|
268
|
+
except ValidationError as e:
|
|
269
|
+
first = e.errors()[0]
|
|
270
|
+
raise RelationshipInvalid(f"{_path(first['loc'])}: {first['msg']}") from e
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def parse_create(data) -> EdgeCreate:
|
|
274
|
+
"""Validate a POST body."""
|
|
275
|
+
edge = _parse(EdgeCreate, data)
|
|
276
|
+
for side in ("source", "target"):
|
|
277
|
+
if getattr(edge, side).table.startswith(_CONCEPT):
|
|
278
|
+
raise RelationshipInvalid(f"{side}.table: a concept is linked through /api/concepts, not drawn as an edge")
|
|
279
|
+
_check_endpoints(edge.source, edge.target, "target")
|
|
280
|
+
return edge
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def parse_patch(data) -> EdgePatch:
|
|
284
|
+
"""Validate a PATCH body: at least one of status and note."""
|
|
285
|
+
patch = _parse(EdgePatch, data)
|
|
286
|
+
if patch.status is None and patch.note is None:
|
|
287
|
+
raise RelationshipInvalid("(root): give a status, a note, or both")
|
|
288
|
+
return patch
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def parse_bulk(data) -> BulkStatus:
|
|
292
|
+
"""Validate a bulk status body. Derived ids are refused: only stored edges have a status."""
|
|
293
|
+
body = _parse(BulkStatus, data)
|
|
294
|
+
for i, rid in enumerate(body.ids):
|
|
295
|
+
if not rid.startswith("e_"):
|
|
296
|
+
raise RelationshipInvalid(f"ids[{i}]: only stored edges (e_...) have a status")
|
|
297
|
+
return body
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def check_statuses(statuses):
|
|
301
|
+
"""Validate the status filter of the suggestions list. Returns it as a tuple."""
|
|
302
|
+
bad = [s for s in statuses if s not in STORED_STATUSES]
|
|
303
|
+
if bad or not statuses:
|
|
304
|
+
raise RelationshipInvalid(f"status: must be some of {', '.join(STORED_STATUSES)}")
|
|
305
|
+
return tuple(statuses)
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def parse_export(data) -> ExportFile:
|
|
309
|
+
"""Validate an export file, including that every edge is of a stored kind."""
|
|
310
|
+
file = _parse(ExportFile, data)
|
|
311
|
+
for what, values in (("id", [c.id for c in file.concepts]), ("name", [c.name.casefold() for c in file.concepts])):
|
|
312
|
+
if len(set(values)) != len(values):
|
|
313
|
+
raise RelationshipInvalid(f"concepts: two concepts share a {what}")
|
|
314
|
+
for i, e in enumerate(file.edges):
|
|
315
|
+
check_storable(e, f"edges[{i}]")
|
|
316
|
+
_check_endpoints(e.source, e.target, f"edges[{i}].target")
|
|
317
|
+
for i, o in enumerate(file.overlays):
|
|
318
|
+
_check_endpoints(o.source, o.target, f"overlays[{i}].target")
|
|
319
|
+
return file
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _check_endpoints(source, target, path):
|
|
323
|
+
if (source.table, source.column) == (target.table, target.column):
|
|
324
|
+
raise RelationshipInvalid(f"{path}: an edge cannot connect an endpoint to itself")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def check_storable(edge, path="(edge)"):
|
|
328
|
+
"""Derived kinds and the derived status are never stored. A concept link must be a well-formed one."""
|
|
329
|
+
if edge.kind in DERIVED_KINDS:
|
|
330
|
+
raise RelationshipInvalid(f'{path}.kind: "{edge.kind}" is derived from Unity Catalog and never stored')
|
|
331
|
+
if edge.status not in STORED_STATUSES:
|
|
332
|
+
raise RelationshipInvalid(f'{path}.status: "{edge.status}" is not a stored status (use proposed, accepted or rejected)')
|
|
333
|
+
ends = (edge.source.table.startswith(_CONCEPT), edge.target.table.startswith(_CONCEPT))
|
|
334
|
+
if edge.kind != "concept_link":
|
|
335
|
+
if any(ends):
|
|
336
|
+
raise RelationshipInvalid(f"{path}.source: only a concept_link edge can touch a concept")
|
|
337
|
+
if edge.relation is not None:
|
|
338
|
+
raise RelationshipInvalid(f"{path}.relation: only a concept_link edge has a relation")
|
|
339
|
+
return
|
|
340
|
+
if not ends[0]:
|
|
341
|
+
raise RelationshipInvalid(f"{path}.source: a concept link starts at a concept (concept:<uuid>)")
|
|
342
|
+
if edge.target.table.startswith(_PLACEHOLDER):
|
|
343
|
+
raise RelationshipInvalid(f"{path}.target: a concept links to a table, a column or a concept")
|
|
344
|
+
allowed = CONCEPT_RELATIONS if ends[1] else TABLE_RELATIONS
|
|
345
|
+
if edge.relation not in allowed:
|
|
346
|
+
raise RelationshipInvalid(f"{path}.relation: a link to {'a concept' if ends[1] else 'a table or column'} "
|
|
347
|
+
f"is one of {', '.join(allowed)}")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# ---------- natural keys and ids ----------
|
|
351
|
+
|
|
352
|
+
def _end(e):
|
|
353
|
+
return (e.table, e.column or "")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def key_of(kind, source, target, directed=True):
|
|
357
|
+
"""The natural key (kind, source table, source column, target table, target column); '' for no column."""
|
|
358
|
+
a, b = _end(source), _end(target)
|
|
359
|
+
if not directed and b < a:
|
|
360
|
+
a, b = b, a
|
|
361
|
+
return (kind, *a, *b)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def derived_id(kind, source, target):
|
|
365
|
+
"""The id of a derived edge: kind:table[#column]->table[#column]. Doubles as its overlay key."""
|
|
366
|
+
def end(e):
|
|
367
|
+
return e.table + (f"#{e.column}" if e.column else "")
|
|
368
|
+
return f"{kind}:{end(source)}->{end(target)}"
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def parse_derived_id(rid):
|
|
372
|
+
"""Inverse of derived_id() for the derived kinds. Returns (kind, source, target) or None."""
|
|
373
|
+
kind, sep, rest = rid.partition(":")
|
|
374
|
+
if not sep or kind not in DERIVED_KINDS:
|
|
375
|
+
return None
|
|
376
|
+
head, arrow, tail = rest.partition("->")
|
|
377
|
+
if not arrow:
|
|
378
|
+
return None
|
|
379
|
+
ends = []
|
|
380
|
+
for part in (head, tail):
|
|
381
|
+
table, _, column = part.partition("#")
|
|
382
|
+
try:
|
|
383
|
+
ends.append(Endpoint(table=table, column=column or None))
|
|
384
|
+
except ValidationError:
|
|
385
|
+
return None
|
|
386
|
+
return kind, ends[0], ends[1]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
# ---------- derived edges ----------
|
|
390
|
+
|
|
391
|
+
def derive_edges(relations, times=None):
|
|
392
|
+
"""Normalize an Explorer.relations() response into Edge objects with status 'derived'.
|
|
393
|
+
|
|
394
|
+
FK edges become one column-level edge per column pair (table-level when the columns are unknown).
|
|
395
|
+
A lineage edge seen in both directions becomes two directed edges, one per direction.
|
|
396
|
+
`times` maps "object" and "lineage" to the fetch time of the cached data behind them.
|
|
397
|
+
"""
|
|
398
|
+
times = times or {}
|
|
399
|
+
out = []
|
|
400
|
+
|
|
401
|
+
def add(kind, source, target, prov, evidence=None):
|
|
402
|
+
stamp = prov.fetched_at or dbx.now()
|
|
403
|
+
out.append(Edge(id=derived_id(kind, source, target), kind=kind, source=source, target=target,
|
|
404
|
+
provenance=prov, evidence=evidence, status="derived", created_at=stamp, updated_at=stamp))
|
|
405
|
+
|
|
406
|
+
for e in relations.get("edges", []):
|
|
407
|
+
s, t = e["source"], e["target"]
|
|
408
|
+
if e["kind"] == "fk":
|
|
409
|
+
prov = Provenance(origin="unity_catalog:table_constraints", source_ref=e.get("constraint"),
|
|
410
|
+
fetched_at=times.get("object"))
|
|
411
|
+
pairs = [(c, p) for c, p in zip_longest(e.get("child_columns") or [], e.get("parent_columns") or [])
|
|
412
|
+
if c and p]
|
|
413
|
+
for c, p in pairs or [(None, None)]:
|
|
414
|
+
add("fk", Endpoint(table=s, column=c), Endpoint(table=t, column=p), prov)
|
|
415
|
+
elif e["kind"] == "view_dep":
|
|
416
|
+
add("view_dep", Endpoint(table=s), Endpoint(table=t),
|
|
417
|
+
Provenance(origin="unity_catalog:view_dependencies", fetched_at=times.get("object")))
|
|
418
|
+
elif e["kind"] == "lineage":
|
|
419
|
+
seen = e.get("last_seen_by_dir") or {f"{s}->{t}": e.get("last_seen")}
|
|
420
|
+
prov = Provenance(origin="unity_catalog:table_lineage", fetched_at=times.get("lineage"))
|
|
421
|
+
for direction, ts in seen.items():
|
|
422
|
+
a, _, b = direction.partition("->")
|
|
423
|
+
add("lineage", Endpoint(table=a), Endpoint(table=b), prov, Evidence(last_seen=ts))
|
|
424
|
+
return out
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def derive_column_lineage(rows, fetched_at=None):
|
|
428
|
+
"""Column-level lineage edges from system.access.column_lineage rows, with status 'derived'.
|
|
429
|
+
|
|
430
|
+
Each row has source_table_full_name, source_column_name, target_table_full_name, target_column_name and
|
|
431
|
+
last_seen. A row that does not name two valid columns (a path-based source, a self loop) is skipped.
|
|
432
|
+
"""
|
|
433
|
+
prov = Provenance(origin="unity_catalog:column_lineage", fetched_at=fetched_at)
|
|
434
|
+
stamp = fetched_at or dbx.now()
|
|
435
|
+
out, seen = [], set()
|
|
436
|
+
for r in rows:
|
|
437
|
+
try:
|
|
438
|
+
source = Endpoint(table=r["source_table_full_name"], column=r["source_column_name"])
|
|
439
|
+
target = Endpoint(table=r["target_table_full_name"], column=r["target_column_name"])
|
|
440
|
+
_check_endpoints(source, target, "target")
|
|
441
|
+
except (ValidationError, RelationshipInvalid, KeyError, TypeError):
|
|
442
|
+
continue
|
|
443
|
+
rid = derived_id("lineage", source, target)
|
|
444
|
+
if rid in seen:
|
|
445
|
+
continue
|
|
446
|
+
seen.add(rid)
|
|
447
|
+
out.append(Edge(id=rid, kind="lineage", source=source, target=target, provenance=prov,
|
|
448
|
+
evidence=Evidence(last_seen=r.get("last_seen")), status="derived",
|
|
449
|
+
created_at=stamp, updated_at=stamp))
|
|
450
|
+
return out
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
# ---------- rollup ----------
|
|
454
|
+
|
|
455
|
+
_PRIORITY = ("accepted", "proposed", "derived", "rejected")
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _merge_evidence(items):
|
|
459
|
+
items = [i for i in items if i]
|
|
460
|
+
if not items:
|
|
461
|
+
return None
|
|
462
|
+
seen = [x for i in items for x in (i.first_seen, i.last_seen) if x]
|
|
463
|
+
refs = list(dict.fromkeys(r for i in items for r in i.sample_refs))[:10]
|
|
464
|
+
runs = [i.runs for i in items if i.runs is not None]
|
|
465
|
+
users = [i.users for i in items if i.users is not None]
|
|
466
|
+
return Evidence(runs=sum(runs) if runs else None, users=max(users) if users else None,
|
|
467
|
+
first_seen=min(seen) if seen else None, last_seen=max(seen) if seen else None, sample_refs=refs)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _add_evidence(old, new):
|
|
471
|
+
"""Evidence of the same edge from two mining runs: runs added, users the larger, newest sample refs first (5)."""
|
|
472
|
+
both = _merge_evidence([old, new])
|
|
473
|
+
both.sample_refs = list(dict.fromkeys([*new.sample_refs, *old.sample_refs]))[:5]
|
|
474
|
+
runs = {}
|
|
475
|
+
for t in [*old.together, *new.together]:
|
|
476
|
+
key = tuple(t.columns)
|
|
477
|
+
runs[key] = runs.get(key, 0) + t.runs
|
|
478
|
+
both.together = [Together(columns=list(k), runs=n)
|
|
479
|
+
for k, n in sorted(runs.items(), key=lambda kv: (-kv[1], kv[0]))[:MAX_TOGETHER]]
|
|
480
|
+
return both
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def rollup(edges, notes=None):
|
|
484
|
+
"""The table-level view: one Rolled edge per (kind, source table, target table), built from its members.
|
|
485
|
+
|
|
486
|
+
A column edge implies its table edge. `columns` lists the column pairs of the members, `members` their ids.
|
|
487
|
+
A group of one table-level edge keeps that edge's id. Any other group gets the table-level derived id.
|
|
488
|
+
The status is the strongest member's (accepted, proposed, derived, rejected). `notes` maps natural keys to
|
|
489
|
+
overlay notes and wins for derived edges.
|
|
490
|
+
"""
|
|
491
|
+
notes = notes or {}
|
|
492
|
+
groups = {}
|
|
493
|
+
for e in edges:
|
|
494
|
+
pair = (e.source.table, e.target.table)
|
|
495
|
+
key = (e.kind, *(pair if e.directed else tuple(sorted(pair))), e.directed)
|
|
496
|
+
groups.setdefault(key, []).append(e)
|
|
497
|
+
out = []
|
|
498
|
+
for (kind, a, b, directed), members in groups.items():
|
|
499
|
+
first = members[0]
|
|
500
|
+
src, tgt = Endpoint(table=a), Endpoint(table=b)
|
|
501
|
+
pairs = []
|
|
502
|
+
for m in members:
|
|
503
|
+
sc, tc = (m.target.column, m.source.column) if (m.source.table != a) else (m.source.column, m.target.column)
|
|
504
|
+
if (sc or tc) and ColumnPair(source=sc, target=tc) not in pairs:
|
|
505
|
+
pairs.append(ColumnPair(source=sc, target=tc))
|
|
506
|
+
single = len(members) == 1 and not (first.source.column or first.target.column)
|
|
507
|
+
rid = first.id if single else derived_id(kind, src, tgt)
|
|
508
|
+
note = notes.get(key_of(kind, src, tgt, directed)) or next((m.note for m in members if m.note), "")
|
|
509
|
+
status = next(s for s in _PRIORITY if any(m.status == s for m in members))
|
|
510
|
+
out.append(Rolled(
|
|
511
|
+
id=rid, kind=kind, source=src, target=tgt, directed=directed,
|
|
512
|
+
provenance=first.provenance,
|
|
513
|
+
evidence=_merge_evidence([m.evidence for m in members]), status=status, note=note,
|
|
514
|
+
created_at=min(m.created_at for m in members), updated_at=max(m.updated_at for m in members),
|
|
515
|
+
columns=pairs, members=[m.id for m in members]))
|
|
516
|
+
return out
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# ---------- the SQLite store ----------
|
|
520
|
+
|
|
521
|
+
_V1 = """
|
|
522
|
+
CREATE TABLE edge (
|
|
523
|
+
id TEXT PRIMARY KEY,
|
|
524
|
+
kind TEXT NOT NULL,
|
|
525
|
+
src_table TEXT NOT NULL,
|
|
526
|
+
src_column TEXT NOT NULL DEFAULT '',
|
|
527
|
+
tgt_table TEXT NOT NULL,
|
|
528
|
+
tgt_column TEXT NOT NULL DEFAULT '',
|
|
529
|
+
directed INTEGER NOT NULL,
|
|
530
|
+
provenance TEXT NOT NULL,
|
|
531
|
+
evidence TEXT,
|
|
532
|
+
status TEXT NOT NULL,
|
|
533
|
+
note TEXT NOT NULL DEFAULT '',
|
|
534
|
+
created_at TEXT NOT NULL,
|
|
535
|
+
updated_at TEXT NOT NULL,
|
|
536
|
+
UNIQUE (kind, src_table, src_column, tgt_table, tgt_column)
|
|
537
|
+
);
|
|
538
|
+
CREATE INDEX edge_src_table ON edge (src_table);
|
|
539
|
+
CREATE INDEX edge_tgt_table ON edge (tgt_table);
|
|
540
|
+
CREATE TABLE derived_note (
|
|
541
|
+
kind TEXT NOT NULL,
|
|
542
|
+
src_table TEXT NOT NULL,
|
|
543
|
+
src_column TEXT NOT NULL DEFAULT '',
|
|
544
|
+
tgt_table TEXT NOT NULL,
|
|
545
|
+
tgt_column TEXT NOT NULL DEFAULT '',
|
|
546
|
+
note TEXT NOT NULL,
|
|
547
|
+
updated_at TEXT NOT NULL,
|
|
548
|
+
PRIMARY KEY (kind, src_table, src_column, tgt_table, tgt_column)
|
|
549
|
+
);
|
|
550
|
+
"""
|
|
551
|
+
|
|
552
|
+
# Concepts, and the relation a concept link carries. concept.name_key is the case-folded name: unique per database,
|
|
553
|
+
# and there is one database per profile.
|
|
554
|
+
_V2 = """
|
|
555
|
+
CREATE TABLE concept (
|
|
556
|
+
id TEXT PRIMARY KEY,
|
|
557
|
+
name TEXT NOT NULL,
|
|
558
|
+
name_key TEXT NOT NULL UNIQUE,
|
|
559
|
+
definition TEXT NOT NULL DEFAULT '',
|
|
560
|
+
synonyms TEXT NOT NULL DEFAULT '[]',
|
|
561
|
+
domain TEXT,
|
|
562
|
+
status TEXT NOT NULL,
|
|
563
|
+
tags TEXT NOT NULL DEFAULT '[]',
|
|
564
|
+
provenance TEXT NOT NULL,
|
|
565
|
+
created_at TEXT NOT NULL,
|
|
566
|
+
updated_at TEXT NOT NULL
|
|
567
|
+
);
|
|
568
|
+
ALTER TABLE edge ADD COLUMN relation TEXT;
|
|
569
|
+
"""
|
|
570
|
+
|
|
571
|
+
# MIGRATIONS[i] takes the database from user_version i to i + 1. Append; never edit a shipped entry.
|
|
572
|
+
MIGRATIONS = [_V1, _V2]
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def migrate(conn):
|
|
576
|
+
"""Bring the database up to the latest schema, tracked by PRAGMA user_version."""
|
|
577
|
+
version = conn.execute("PRAGMA user_version").fetchone()[0]
|
|
578
|
+
if version > len(MIGRATIONS):
|
|
579
|
+
raise RuntimeError(f"relationship database is schema version {version}; this app knows up to {len(MIGRATIONS)}")
|
|
580
|
+
for i in range(version, len(MIGRATIONS)):
|
|
581
|
+
conn.executescript(f"BEGIN;\n{MIGRATIONS[i]}\nPRAGMA user_version = {i + 1};\nCOMMIT;")
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def db_path(profile):
|
|
585
|
+
"""data/relationships/<profile>.sqlite, under whatever dbx.DATA is when called."""
|
|
586
|
+
return dbx.DATA / "relationships" / f"{dbx.safe(profile)}.sqlite"
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def open_store(profile):
|
|
590
|
+
"""Open (and migrate) the store for one profile."""
|
|
591
|
+
return Store(db_path(profile))
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
class Store:
|
|
595
|
+
"""The persisted (non-derived) edges and the notes on derived edges."""
|
|
596
|
+
|
|
597
|
+
def __init__(self, path):
|
|
598
|
+
if str(path) != ":memory:":
|
|
599
|
+
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
600
|
+
self.conn = sqlite3.connect(str(path))
|
|
601
|
+
self.conn.row_factory = sqlite3.Row
|
|
602
|
+
migrate(self.conn)
|
|
603
|
+
|
|
604
|
+
def close(self):
|
|
605
|
+
"""Close the connection."""
|
|
606
|
+
self.conn.close()
|
|
607
|
+
|
|
608
|
+
def __enter__(self):
|
|
609
|
+
return self
|
|
610
|
+
|
|
611
|
+
def __exit__(self, *exc):
|
|
612
|
+
self.close()
|
|
613
|
+
|
|
614
|
+
# -- rows <-> edges --
|
|
615
|
+
@staticmethod
|
|
616
|
+
def _edge(row):
|
|
617
|
+
return Edge(
|
|
618
|
+
id=row["id"], kind=row["kind"],
|
|
619
|
+
source=Endpoint(table=row["src_table"], column=row["src_column"] or None),
|
|
620
|
+
target=Endpoint(table=row["tgt_table"], column=row["tgt_column"] or None),
|
|
621
|
+
directed=bool(row["directed"]), provenance=json.loads(row["provenance"]),
|
|
622
|
+
evidence=json.loads(row["evidence"]) if row["evidence"] else None,
|
|
623
|
+
status=row["status"], note=row["note"], relation=row["relation"],
|
|
624
|
+
created_at=row["created_at"], updated_at=row["updated_at"])
|
|
625
|
+
|
|
626
|
+
def _by_key(self, key):
|
|
627
|
+
return self.conn.execute(
|
|
628
|
+
"SELECT * FROM edge WHERE kind=? AND src_table=? AND src_column=? AND tgt_table=? AND tgt_column=?",
|
|
629
|
+
key).fetchone()
|
|
630
|
+
|
|
631
|
+
def _upsert(self, edge):
|
|
632
|
+
"""Insert or update by natural key without committing. Returns (stored edge, created)."""
|
|
633
|
+
key = key_of(edge.kind, edge.source, edge.target, edge.directed)
|
|
634
|
+
prov = edge.provenance.model_dump_json()
|
|
635
|
+
ev = edge.evidence.model_dump_json() if edge.evidence else None
|
|
636
|
+
if edge.kind == "concept_link":
|
|
637
|
+
self._require_concepts(edge)
|
|
638
|
+
row = self._by_key(key)
|
|
639
|
+
if row:
|
|
640
|
+
self.conn.execute(
|
|
641
|
+
"UPDATE edge SET directed=?, provenance=?, evidence=?, status=?, note=?, relation=?, updated_at=? "
|
|
642
|
+
"WHERE id=?",
|
|
643
|
+
(int(edge.directed), prov, ev, edge.status, edge.note, edge.relation, dbx.now(), row["id"]))
|
|
644
|
+
return self.get(row["id"]), False
|
|
645
|
+
rid = edge.id
|
|
646
|
+
if self.conn.execute("SELECT 1 FROM edge WHERE id=?", (rid,)).fetchone():
|
|
647
|
+
rid = new_id() # the incoming id belongs to a different edge here
|
|
648
|
+
self.conn.execute("INSERT INTO edge (id, kind, src_table, src_column, tgt_table, tgt_column, directed, provenance,"
|
|
649
|
+
" evidence, status, note, relation, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
650
|
+
(rid, *key, int(edge.directed), prov, ev, edge.status, edge.note, edge.relation,
|
|
651
|
+
edge.created_at, edge.updated_at))
|
|
652
|
+
return self.get(rid), True
|
|
653
|
+
|
|
654
|
+
def _require_concepts(self, edge):
|
|
655
|
+
"""A concept link's concept ends must exist. RelationshipNotFound otherwise."""
|
|
656
|
+
for side in ("source", "target"):
|
|
657
|
+
table = getattr(edge, side).table
|
|
658
|
+
if table.startswith(_CONCEPT) and not self.conn.execute(
|
|
659
|
+
"SELECT 1 FROM concept WHERE id=?", (table[len(_CONCEPT):],)).fetchone():
|
|
660
|
+
raise RelationshipNotFound(f"concept not found: {table[len(_CONCEPT):]}")
|
|
661
|
+
|
|
662
|
+
# -- stored edges --
|
|
663
|
+
def get(self, rid):
|
|
664
|
+
"""The stored edge with this id; RelationshipNotFound if none."""
|
|
665
|
+
row = self.conn.execute("SELECT * FROM edge WHERE id=?", (rid,)).fetchone()
|
|
666
|
+
if not row:
|
|
667
|
+
raise RelationshipNotFound(f"relationship not found: {rid}")
|
|
668
|
+
return self._edge(row)
|
|
669
|
+
|
|
670
|
+
def upsert(self, edge):
|
|
671
|
+
"""Insert or update by natural key. Returns (stored edge, created). Derived kinds are refused."""
|
|
672
|
+
check_storable(edge)
|
|
673
|
+
_check_endpoints(edge.source, edge.target, "target")
|
|
674
|
+
with self.conn:
|
|
675
|
+
return self._upsert(edge)
|
|
676
|
+
|
|
677
|
+
def create_manual(self, body):
|
|
678
|
+
"""Add a user-drawn edge (accepted). Drawing an existing edge again re-accepts it. Returns (edge, created)."""
|
|
679
|
+
provenance = Provenance(origin="user", fetched_at=dbx.now())
|
|
680
|
+
edge = Edge(kind="manual", source=body.source, target=body.target, directed=body.directed,
|
|
681
|
+
provenance=provenance, status="accepted", note=body.note)
|
|
682
|
+
with self.conn:
|
|
683
|
+
row = self._by_key(key_of("manual", edge.source, edge.target, edge.directed))
|
|
684
|
+
if row:
|
|
685
|
+
note = body.note or row["note"]
|
|
686
|
+
self.conn.execute("UPDATE edge SET status='accepted', note=?, updated_at=? WHERE id=?",
|
|
687
|
+
(note, dbx.now(), row["id"]))
|
|
688
|
+
return self.get(row["id"]), False
|
|
689
|
+
return self._upsert(edge)
|
|
690
|
+
|
|
691
|
+
def patch(self, rid, status=None, note=None):
|
|
692
|
+
"""Change a stored edge's status and/or note."""
|
|
693
|
+
if status is not None and status not in STORED_STATUSES:
|
|
694
|
+
raise RelationshipInvalid(f'status: "{status}" is not a stored status (use proposed, accepted or rejected)')
|
|
695
|
+
self.get(rid)
|
|
696
|
+
with self.conn:
|
|
697
|
+
if status is not None:
|
|
698
|
+
self.conn.execute("UPDATE edge SET status=?, updated_at=? WHERE id=?", (status, dbx.now(), rid))
|
|
699
|
+
if note is not None:
|
|
700
|
+
self.conn.execute("UPDATE edge SET note=?, updated_at=? WHERE id=?", (note, dbx.now(), rid))
|
|
701
|
+
return self.get(rid)
|
|
702
|
+
|
|
703
|
+
def delete(self, rid):
|
|
704
|
+
"""Remove a stored edge; RelationshipNotFound if none."""
|
|
705
|
+
self.get(rid)
|
|
706
|
+
with self.conn:
|
|
707
|
+
self.conn.execute("DELETE FROM edge WHERE id=?", (rid,))
|
|
708
|
+
|
|
709
|
+
def upsert_mined(self, edges, replace=False):
|
|
710
|
+
"""Store edges mined from activity in one transaction. Returns (created, updated).
|
|
711
|
+
|
|
712
|
+
A new edge is stored as given (status proposed). An existing one keeps its status, note and id, so an
|
|
713
|
+
accepted or rejected edge stays that way, and takes the new evidence: added to what it had, or in place of it
|
|
714
|
+
when `replace` is set (a re-mine of the whole window). Added evidence sums runs, keeps the larger user count
|
|
715
|
+
(users are only counted, never kept, so across runs the count is a lower bound), and spans first to last seen.
|
|
716
|
+
"""
|
|
717
|
+
created = updated = 0
|
|
718
|
+
with self.conn:
|
|
719
|
+
for edge in edges:
|
|
720
|
+
check_storable(edge)
|
|
721
|
+
_check_endpoints(edge.source, edge.target, "target")
|
|
722
|
+
row = self._by_key(key_of(edge.kind, edge.source, edge.target, edge.directed))
|
|
723
|
+
if not row:
|
|
724
|
+
self._upsert(edge)
|
|
725
|
+
created += 1
|
|
726
|
+
continue
|
|
727
|
+
old = self._edge(row)
|
|
728
|
+
evidence = edge.evidence if replace or not old.evidence else _add_evidence(old.evidence, edge.evidence)
|
|
729
|
+
self.conn.execute("UPDATE edge SET provenance=?, evidence=?, updated_at=? WHERE id=?",
|
|
730
|
+
(edge.provenance.model_dump_json(), evidence.model_dump_json() if evidence else None,
|
|
731
|
+
dbx.now(), row["id"]))
|
|
732
|
+
updated += 1
|
|
733
|
+
return created, updated
|
|
734
|
+
|
|
735
|
+
def suggestions(self, statuses=("proposed",), kind="join_mined", table=None):
|
|
736
|
+
"""Stored edges of one kind with one of these statuses, most evidence first (runs, users, last seen)."""
|
|
737
|
+
marks = ",".join("?" * len(statuses))
|
|
738
|
+
sql = f"SELECT * FROM edge WHERE kind=? AND status IN ({marks})"
|
|
739
|
+
args = [kind, *statuses]
|
|
740
|
+
if table:
|
|
741
|
+
sql += " AND (src_table=? OR tgt_table=?)"
|
|
742
|
+
args += [table, table]
|
|
743
|
+
edges = [self._edge(r) for r in self.conn.execute(sql, args)]
|
|
744
|
+
edges.sort(key=lambda e: e.id)
|
|
745
|
+
edges.sort(key=lambda e: (e.evidence.last_seen if e.evidence else None) or "", reverse=True)
|
|
746
|
+
edges.sort(key=lambda e: ((e.evidence.runs or 0, e.evidence.users or 0) if e.evidence else (0, 0)), reverse=True)
|
|
747
|
+
return edges
|
|
748
|
+
|
|
749
|
+
def status_counts(self, kind="join_mined"):
|
|
750
|
+
"""How many stored edges of one kind have each status."""
|
|
751
|
+
rows = self.conn.execute("SELECT status, COUNT(*) AS n FROM edge WHERE kind=? GROUP BY status", (kind,))
|
|
752
|
+
return {s: 0 for s in STORED_STATUSES} | {r["status"]: r["n"] for r in rows}
|
|
753
|
+
|
|
754
|
+
def set_status_many(self, ids, status):
|
|
755
|
+
"""Set one status on several stored edges, all or none. Returns how many. Unknown ids are RelationshipNotFound."""
|
|
756
|
+
if status not in STORED_STATUSES:
|
|
757
|
+
raise RelationshipInvalid(f'status: "{status}" is not a stored status (use proposed, accepted or rejected)')
|
|
758
|
+
ids = list(dict.fromkeys(ids))
|
|
759
|
+
with self.conn:
|
|
760
|
+
for rid in ids:
|
|
761
|
+
self.get(rid)
|
|
762
|
+
self.conn.executemany("UPDATE edge SET status=?, updated_at=? WHERE id=?",
|
|
763
|
+
[(status, dbx.now(), rid) for rid in ids])
|
|
764
|
+
return len(ids)
|
|
765
|
+
|
|
766
|
+
def all_edges(self):
|
|
767
|
+
"""Every stored edge, oldest first."""
|
|
768
|
+
return [self._edge(r) for r in self.conn.execute("SELECT * FROM edge ORDER BY created_at, id")]
|
|
769
|
+
|
|
770
|
+
def edges_for(self, table):
|
|
771
|
+
"""Stored edges with this table at either end. Concept links are not table relationships and are left out."""
|
|
772
|
+
rows = self.conn.execute("SELECT * FROM edge WHERE (src_table=? OR tgt_table=?) AND kind<>'concept_link' "
|
|
773
|
+
"ORDER BY created_at, id", (table, table))
|
|
774
|
+
return [self._edge(r) for r in rows]
|
|
775
|
+
|
|
776
|
+
def table_view(self, table=None):
|
|
777
|
+
"""Stored edges rolled up to table level, for one table or for all. Concept links are left out."""
|
|
778
|
+
return rollup(self.edges_for(table) if table else [e for e in self.all_edges() if e.kind != "concept_link"])
|
|
779
|
+
|
|
780
|
+
def upsert_edges(self, edges):
|
|
781
|
+
"""Upsert several edges in one transaction, all or none. Returns (created, updated)."""
|
|
782
|
+
created = updated = 0
|
|
783
|
+
for edge in edges:
|
|
784
|
+
check_storable(edge)
|
|
785
|
+
_check_endpoints(edge.source, edge.target, "target")
|
|
786
|
+
with self.conn:
|
|
787
|
+
for edge in edges:
|
|
788
|
+
_, was_created = self._upsert(edge)
|
|
789
|
+
created += was_created
|
|
790
|
+
updated += not was_created
|
|
791
|
+
return created, updated
|
|
792
|
+
|
|
793
|
+
def delete_edges(self, ids):
|
|
794
|
+
"""Remove stored edges by id, ignoring unknown ids. Returns how many were removed."""
|
|
795
|
+
with self.conn:
|
|
796
|
+
return sum(self.conn.execute("DELETE FROM edge WHERE id=?", (rid,)).rowcount for rid in ids)
|
|
797
|
+
|
|
798
|
+
# -- concepts --
|
|
799
|
+
@staticmethod
|
|
800
|
+
def _concept(row):
|
|
801
|
+
return Concept(id=row["id"], name=row["name"], definition=row["definition"],
|
|
802
|
+
synonyms=json.loads(row["synonyms"]), domain=row["domain"], status=row["status"],
|
|
803
|
+
tags=json.loads(row["tags"]), provenance=json.loads(row["provenance"]),
|
|
804
|
+
created_at=row["created_at"], updated_at=row["updated_at"])
|
|
805
|
+
|
|
806
|
+
def concepts(self):
|
|
807
|
+
"""Every concept, by name."""
|
|
808
|
+
rows = self.conn.execute("SELECT * FROM concept ORDER BY name_key, id")
|
|
809
|
+
return [self._concept(r) for r in rows]
|
|
810
|
+
|
|
811
|
+
def concept(self, cid):
|
|
812
|
+
"""The concept with this id; RelationshipNotFound if none."""
|
|
813
|
+
row = self.conn.execute("SELECT * FROM concept WHERE id=?", (cid,)).fetchone()
|
|
814
|
+
if not row:
|
|
815
|
+
raise RelationshipNotFound(f"concept not found: {cid}")
|
|
816
|
+
return self._concept(row)
|
|
817
|
+
|
|
818
|
+
def _upsert_concept(self, c):
|
|
819
|
+
"""Insert or update a concept by id without committing. Returns True when it was inserted."""
|
|
820
|
+
key = c.name.casefold()
|
|
821
|
+
clash = self.conn.execute("SELECT id FROM concept WHERE name_key=? AND id<>?", (key, c.id)).fetchone()
|
|
822
|
+
if clash:
|
|
823
|
+
raise RelationshipInvalid(f'name: a concept named "{c.name}" already exists')
|
|
824
|
+
values = (c.name, key, c.definition, json.dumps(c.synonyms), c.domain, c.status, json.dumps(c.tags),
|
|
825
|
+
c.provenance.model_dump_json(), c.updated_at)
|
|
826
|
+
if self.conn.execute("SELECT 1 FROM concept WHERE id=?", (c.id,)).fetchone():
|
|
827
|
+
self.conn.execute("UPDATE concept SET name=?, name_key=?, definition=?, synonyms=?, domain=?, status=?, "
|
|
828
|
+
"tags=?, provenance=?, updated_at=? WHERE id=?", (*values, c.id))
|
|
829
|
+
return False
|
|
830
|
+
self.conn.execute("INSERT INTO concept (name, name_key, definition, synonyms, domain, status, tags, provenance,"
|
|
831
|
+
" updated_at, id, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)", (*values, c.id, c.created_at))
|
|
832
|
+
return True
|
|
833
|
+
|
|
834
|
+
def save_concept(self, concept):
|
|
835
|
+
"""Insert or update a concept by id. Returns (concept, created). A taken name is RelationshipInvalid."""
|
|
836
|
+
with self.conn:
|
|
837
|
+
created = self._upsert_concept(concept)
|
|
838
|
+
return self.concept(concept.id), created
|
|
839
|
+
|
|
840
|
+
def delete_concept(self, cid):
|
|
841
|
+
"""Remove a concept and every link that starts or ends at it. Returns how many links went."""
|
|
842
|
+
self.concept(cid)
|
|
843
|
+
end = _CONCEPT + cid
|
|
844
|
+
with self.conn:
|
|
845
|
+
gone = self.conn.execute("DELETE FROM edge WHERE kind='concept_link' AND (src_table=? OR tgt_table=?)",
|
|
846
|
+
(end, end)).rowcount
|
|
847
|
+
self.conn.execute("DELETE FROM concept WHERE id=?", (cid,))
|
|
848
|
+
return gone
|
|
849
|
+
|
|
850
|
+
def concept_links(self, cid, incoming=False):
|
|
851
|
+
"""Links that start at this concept, or (incoming) that point at it from another concept."""
|
|
852
|
+
col = "tgt_table" if incoming else "src_table"
|
|
853
|
+
rows = self.conn.execute(f"SELECT * FROM edge WHERE kind='concept_link' AND {col}=? ORDER BY created_at, rowid",
|
|
854
|
+
(_CONCEPT + cid,))
|
|
855
|
+
return [self._edge(r) for r in rows]
|
|
856
|
+
|
|
857
|
+
def links_to_tables(self, tables):
|
|
858
|
+
"""Concept links whose target is one of these tables (a table or one of its columns)."""
|
|
859
|
+
tables = list(dict.fromkeys(tables))
|
|
860
|
+
if not tables:
|
|
861
|
+
return []
|
|
862
|
+
marks = ",".join("?" * len(tables))
|
|
863
|
+
rows = self.conn.execute(f"SELECT * FROM edge WHERE kind='concept_link' AND tgt_table IN ({marks}) "
|
|
864
|
+
"ORDER BY created_at, rowid", tables)
|
|
865
|
+
return [self._edge(r) for r in rows]
|
|
866
|
+
|
|
867
|
+
def link_counts(self):
|
|
868
|
+
"""Map of concept id to how many links start at it."""
|
|
869
|
+
rows = self.conn.execute("SELECT src_table, COUNT(*) AS n FROM edge WHERE kind='concept_link' GROUP BY src_table")
|
|
870
|
+
return {r["src_table"][len(_CONCEPT):]: r["n"] for r in rows}
|
|
871
|
+
|
|
872
|
+
# -- notes on derived edges --
|
|
873
|
+
def derived_notes(self):
|
|
874
|
+
"""Map of natural key to note for every derived edge that has one."""
|
|
875
|
+
rows = self.conn.execute("SELECT * FROM derived_note")
|
|
876
|
+
return {(r["kind"], r["src_table"], r["src_column"], r["tgt_table"], r["tgt_column"]): r["note"] for r in rows}
|
|
877
|
+
|
|
878
|
+
def _set_note(self, key, note, stamp=None):
|
|
879
|
+
if note:
|
|
880
|
+
self.conn.execute("INSERT OR REPLACE INTO derived_note VALUES (?,?,?,?,?,?,?)",
|
|
881
|
+
(*key, note, stamp or dbx.now()))
|
|
882
|
+
else:
|
|
883
|
+
self.conn.execute("DELETE FROM derived_note WHERE kind=? AND src_table=? AND src_column=? "
|
|
884
|
+
"AND tgt_table=? AND tgt_column=?", key)
|
|
885
|
+
|
|
886
|
+
def set_derived_note(self, rid, note):
|
|
887
|
+
"""Attach (or, when empty, clear) a note on the derived edge with this id. Returns it as an Edge."""
|
|
888
|
+
parsed = parse_derived_id(rid)
|
|
889
|
+
if not parsed:
|
|
890
|
+
raise RelationshipNotFound(f"relationship not found: {rid}")
|
|
891
|
+
kind, source, target = parsed
|
|
892
|
+
with self.conn:
|
|
893
|
+
self._set_note(key_of(kind, source, target), note)
|
|
894
|
+
return Edge(id=rid, kind=kind, source=source, target=target, status="derived", note=note,
|
|
895
|
+
provenance=Provenance(origin="derived"))
|
|
896
|
+
|
|
897
|
+
def clear_derived_note(self, rid):
|
|
898
|
+
"""Remove the note on a derived edge; RelationshipNotFound if it had none."""
|
|
899
|
+
parsed = parse_derived_id(rid)
|
|
900
|
+
key = key_of(*parsed) if parsed else None
|
|
901
|
+
if not key or key not in self.derived_notes():
|
|
902
|
+
raise RelationshipNotFound(f"relationship not found: {rid}")
|
|
903
|
+
with self.conn:
|
|
904
|
+
self._set_note(key, "")
|
|
905
|
+
|
|
906
|
+
# -- export / import --
|
|
907
|
+
def export(self, concepts_only=False):
|
|
908
|
+
"""Every stored edge, derived-edge note and concept, in the import format.
|
|
909
|
+
|
|
910
|
+
With `concepts_only`, just the concepts and their links (no overlays, no other edges).
|
|
911
|
+
"""
|
|
912
|
+
overlays = [Overlay(kind=k[0], source=Endpoint(table=k[1], column=k[2] or None),
|
|
913
|
+
target=Endpoint(table=k[3], column=k[4] or None), note=n)
|
|
914
|
+
for k, n in sorted(self.derived_notes().items())]
|
|
915
|
+
edges = self.all_edges()
|
|
916
|
+
if concepts_only:
|
|
917
|
+
overlays, edges = [], [e for e in edges if e.kind == "concept_link"]
|
|
918
|
+
file = ExportFile(version=EXPORT_VERSION, exported_at=dbx.now(), edges=edges, overlays=overlays,
|
|
919
|
+
concepts=self.concepts())
|
|
920
|
+
return file.model_dump(mode="json")
|
|
921
|
+
|
|
922
|
+
def import_file(self, file):
|
|
923
|
+
"""Upsert a parsed export file in one transaction (concepts first, then edges). Returns counts.
|
|
924
|
+
|
|
925
|
+
A failure, such as a name another concept already has or a link to a concept that exists nowhere,
|
|
926
|
+
leaves the store as it was.
|
|
927
|
+
"""
|
|
928
|
+
created = updated = concepts_created = 0
|
|
929
|
+
with self.conn:
|
|
930
|
+
for c in file.concepts:
|
|
931
|
+
concepts_created += self._upsert_concept(c)
|
|
932
|
+
for e in file.edges:
|
|
933
|
+
_, was_created = self._upsert(e)
|
|
934
|
+
created += was_created
|
|
935
|
+
updated += not was_created
|
|
936
|
+
for o in file.overlays:
|
|
937
|
+
self._set_note(key_of(o.kind, o.source, o.target), o.note, o.updated_at)
|
|
938
|
+
return {"imported": len(file.edges), "created": created, "updated": updated, "overlays": len(file.overlays),
|
|
939
|
+
"concepts": len(file.concepts), "concepts_created": concepts_created}
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
# ---------- the unified read ----------
|
|
943
|
+
|
|
944
|
+
def unified(store, table, relations=None, times=None, level="table", column_lineage=None):
|
|
945
|
+
"""Derived edges (from `relations`) plus stored edges for one table, with notes on derived ones applied.
|
|
946
|
+
|
|
947
|
+
level "table" rolls column edges up to table edges; "column" returns each edge as it is, and adds the column
|
|
948
|
+
lineage rows in `column_lineage` ({"fetched_at", "data": [rows]}, as Explorer.column_lineage returns it).
|
|
949
|
+
Returns {"table", "level", "nodes", "edges", "errors"}; nodes and errors pass through from `relations`.
|
|
950
|
+
"""
|
|
951
|
+
if level not in ("table", "column"):
|
|
952
|
+
raise RelationshipInvalid('level: must be "table" or "column"')
|
|
953
|
+
relations = relations or {"nodes": [], "edges": [], "errors": []}
|
|
954
|
+
notes = store.derived_notes()
|
|
955
|
+
derived = derive_edges(relations, times)
|
|
956
|
+
if level == "column" and column_lineage:
|
|
957
|
+
derived += derive_column_lineage(column_lineage.get("data") or [], column_lineage.get("fetched_at"))
|
|
958
|
+
for e in derived:
|
|
959
|
+
e.note = notes.get(key_of(e.kind, e.source, e.target), "")
|
|
960
|
+
stored = store.edges_for(table)
|
|
961
|
+
edges = derived + stored
|
|
962
|
+
out = rollup(edges, notes) if level == "table" else edges
|
|
963
|
+
nodes = list(relations.get("nodes", []))
|
|
964
|
+
known = {n["id"] for n in nodes}
|
|
965
|
+
if not any(n["id"] == table for n in nodes):
|
|
966
|
+
nodes.insert(0, {"id": table, "label": table.split(".")[-1], "kind": "table", "focus": True})
|
|
967
|
+
known.add(table)
|
|
968
|
+
for e in [*stored, *(d for d in derived if d.provenance.origin.endswith(":column_lineage"))]:
|
|
969
|
+
for end in (e.source, e.target):
|
|
970
|
+
if end.table not in known:
|
|
971
|
+
known.add(end.table)
|
|
972
|
+
nodes.append({"id": end.table, "label": end.table.split(".")[-1],
|
|
973
|
+
"kind": "placeholder" if end.table.startswith(_PLACEHOLDER) else "table"})
|
|
974
|
+
return {"table": table, "level": level, "nodes": nodes,
|
|
975
|
+
"edges": [e.model_dump(mode="json") for e in out], "errors": relations.get("errors", [])}
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def check_table(table):
|
|
979
|
+
"""Validate a table name or placeholder from a query string. Returns it normalized."""
|
|
980
|
+
if not table:
|
|
981
|
+
raise RelationshipInvalid("table: required")
|
|
982
|
+
if table.startswith(_CONCEPT):
|
|
983
|
+
raise RelationshipInvalid("table: a concept is not a table")
|
|
984
|
+
try:
|
|
985
|
+
return Endpoint(table=table).table
|
|
986
|
+
except ValidationError as e:
|
|
987
|
+
raise RelationshipInvalid(f"table: {e.errors()[0]['msg']}") from e
|