sql-lineage-extractor 0.1.1__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.
- sql_lineage/__init__.py +30 -0
- sql_lineage/_version.py +24 -0
- sql_lineage/cli.py +217 -0
- sql_lineage/config.py +145 -0
- sql_lineage/export/__init__.py +29 -0
- sql_lineage/export/backends.py +122 -0
- sql_lineage/export/tables.py +179 -0
- sql_lineage/graph/__init__.py +3 -0
- sql_lineage/graph/builder.py +49 -0
- sql_lineage/manifest/__init__.py +17 -0
- sql_lineage/manifest/schema.json +73 -0
- sql_lineage/manifest/writer.py +95 -0
- sql_lineage/models.py +71 -0
- sql_lineage/parsing/__init__.py +4 -0
- sql_lineage/parsing/engine.py +166 -0
- sql_lineage/parsing/lineage_extractor.py +53 -0
- sql_lineage/pipeline.py +27 -0
- sql_lineage/py.typed +0 -0
- sql_lineage/sources/__init__.py +13 -0
- sql_lineage/sources/base.py +14 -0
- sql_lineage/sources/file_source.py +53 -0
- sql_lineage/sources/live_sql_source.py +81 -0
- sql_lineage/sources/notebook_source.py +261 -0
- sql_lineage_extractor-0.1.1.dist-info/METADATA +351 -0
- sql_lineage_extractor-0.1.1.dist-info/RECORD +30 -0
- sql_lineage_extractor-0.1.1.dist-info/WHEEL +5 -0
- sql_lineage_extractor-0.1.1.dist-info/entry_points.txt +2 -0
- sql_lineage_extractor-0.1.1.dist-info/licenses/LICENSE +201 -0
- sql_lineage_extractor-0.1.1.dist-info/licenses/NOTICE +13 -0
- sql_lineage_extractor-0.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Derive the flat BI tables (as row dicts) from a manifest dict.
|
|
2
|
+
|
|
3
|
+
Pure and backend-agnostic: this module never touches CSV / a database. It only
|
|
4
|
+
turns the nested manifest into ordered lists of rows, so it is trivial to test
|
|
5
|
+
and the same rows feed every backend.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections import deque
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
# Ordered column layout per table. Backends rely on this order (CSV headers,
|
|
14
|
+
# INSERT column lists) so the schema stays stable and diffable.
|
|
15
|
+
TABLE_COLUMNS: dict[str, list[str]] = {
|
|
16
|
+
"dim_run": ["run_id", "generated_at", "schema_version", "n_nodes", "n_errors"],
|
|
17
|
+
"dim_object": ["run_id", "node_id", "origine", "dialecte", "layer", "n_columns"],
|
|
18
|
+
"dim_column": ["run_id", "node_id", "column_name", "expression_sql", "n_sources"],
|
|
19
|
+
"fact_column_edge": [
|
|
20
|
+
"run_id",
|
|
21
|
+
"target_node",
|
|
22
|
+
"target_column",
|
|
23
|
+
"source_table",
|
|
24
|
+
"source_column",
|
|
25
|
+
"expression_sql",
|
|
26
|
+
],
|
|
27
|
+
"object_edge": ["run_id", "target_node", "depends_on_node"],
|
|
28
|
+
"closure": ["run_id", "ancestor_node", "descendant_node", "depth"],
|
|
29
|
+
"errors": ["run_id", "node_id", "origine", "message"],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# Columns that hold integers rather than text (matters for typed backends).
|
|
33
|
+
INT_COLUMNS: set[str] = {"n_nodes", "n_errors", "n_columns", "n_sources", "depth"}
|
|
34
|
+
|
|
35
|
+
# Medallion layers recognised by the default layer-derivation heuristic.
|
|
36
|
+
KNOWN_LAYERS = ("raw", "bronze", "silver", "gold", "platinum")
|
|
37
|
+
|
|
38
|
+
LayerFn = Callable[[str], str]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def default_layer(node_id: str) -> str:
|
|
42
|
+
"""Best-effort medallion layer from an id prefix; '' when unknown."""
|
|
43
|
+
head = node_id.split(".", 1)[0].split("_", 1)[0].lower()
|
|
44
|
+
return head if head in KNOWN_LAYERS else ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def build_export_tables(
|
|
48
|
+
manifest: dict[str, Any],
|
|
49
|
+
run_id: str | None = None,
|
|
50
|
+
layer_of: LayerFn = default_layer,
|
|
51
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
52
|
+
"""Return {table_name: [row, ...]} for the whole star schema.
|
|
53
|
+
|
|
54
|
+
``run_id`` defaults to the manifest's ``generated_at`` so every row of a run
|
|
55
|
+
shares one stable, sortable key.
|
|
56
|
+
"""
|
|
57
|
+
run = run_id or manifest.get("generated_at", "")
|
|
58
|
+
nodes: dict[str, Any] = manifest.get("nodes", {})
|
|
59
|
+
errors: list[Any] = manifest.get("errors", [])
|
|
60
|
+
|
|
61
|
+
dim_object: list[dict[str, Any]] = []
|
|
62
|
+
dim_column: list[dict[str, Any]] = []
|
|
63
|
+
fact_column_edge: list[dict[str, Any]] = []
|
|
64
|
+
object_edge: list[dict[str, Any]] = []
|
|
65
|
+
|
|
66
|
+
for node_id, node in nodes.items():
|
|
67
|
+
columns = node.get("columns", {})
|
|
68
|
+
dim_object.append(
|
|
69
|
+
{
|
|
70
|
+
"run_id": run,
|
|
71
|
+
"node_id": node_id,
|
|
72
|
+
"origine": node.get("origine", ""),
|
|
73
|
+
"dialecte": node.get("dialecte", ""),
|
|
74
|
+
"layer": layer_of(node_id),
|
|
75
|
+
"n_columns": len(columns),
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
for col_name, col in columns.items():
|
|
79
|
+
depends_on = col.get("depends_on", [])
|
|
80
|
+
dim_column.append(
|
|
81
|
+
{
|
|
82
|
+
"run_id": run,
|
|
83
|
+
"node_id": node_id,
|
|
84
|
+
"column_name": col_name,
|
|
85
|
+
"expression_sql": col.get("expression_sql", ""),
|
|
86
|
+
"n_sources": len(depends_on),
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
for src in depends_on:
|
|
90
|
+
fact_column_edge.append(
|
|
91
|
+
{
|
|
92
|
+
"run_id": run,
|
|
93
|
+
"target_node": node_id,
|
|
94
|
+
"target_column": col_name,
|
|
95
|
+
"source_table": src.get("table", ""),
|
|
96
|
+
"source_column": src.get("column", ""),
|
|
97
|
+
"expression_sql": col.get("expression_sql", ""),
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
for dep in node.get("depends_on_nodes", []):
|
|
101
|
+
object_edge.append(
|
|
102
|
+
{"run_id": run, "target_node": node_id, "depends_on_node": dep}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
closure = _closure_rows(run, object_edge)
|
|
106
|
+
|
|
107
|
+
dim_run = [
|
|
108
|
+
{
|
|
109
|
+
"run_id": run,
|
|
110
|
+
"generated_at": manifest.get("generated_at", run),
|
|
111
|
+
"schema_version": manifest.get("schema_version", ""),
|
|
112
|
+
"n_nodes": len(nodes),
|
|
113
|
+
"n_errors": len(errors),
|
|
114
|
+
}
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
errors_rows = [
|
|
118
|
+
{
|
|
119
|
+
"run_id": run,
|
|
120
|
+
"node_id": e.get("id", ""),
|
|
121
|
+
"origine": e.get("origine", ""),
|
|
122
|
+
"message": e.get("message", ""),
|
|
123
|
+
}
|
|
124
|
+
for e in errors
|
|
125
|
+
]
|
|
126
|
+
|
|
127
|
+
tables = {
|
|
128
|
+
"dim_run": dim_run,
|
|
129
|
+
"dim_object": dim_object,
|
|
130
|
+
"dim_column": dim_column,
|
|
131
|
+
"fact_column_edge": fact_column_edge,
|
|
132
|
+
"object_edge": object_edge,
|
|
133
|
+
"closure": closure,
|
|
134
|
+
"errors": errors_rows,
|
|
135
|
+
}
|
|
136
|
+
return {name: _sorted(name, rows) for name, rows in tables.items()}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _closure_rows(run: str, object_edge: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
140
|
+
"""Transitive closure over object dependencies (BFS, cycle-safe).
|
|
141
|
+
|
|
142
|
+
An ``object_edge`` row says ``target_node`` depends on ``depends_on_node``,
|
|
143
|
+
so the dependency (upstream) is the *ancestor* and the consumer is the
|
|
144
|
+
*descendant*. We record every (ancestor, descendant, min-depth) pair.
|
|
145
|
+
"""
|
|
146
|
+
down: dict[str, set[str]] = {}
|
|
147
|
+
for edge in object_edge:
|
|
148
|
+
ancestor = edge["depends_on_node"]
|
|
149
|
+
descendant = edge["target_node"]
|
|
150
|
+
down.setdefault(ancestor, set()).add(descendant)
|
|
151
|
+
|
|
152
|
+
rows: list[dict[str, Any]] = []
|
|
153
|
+
for ancestor in down:
|
|
154
|
+
best: dict[str, int] = {}
|
|
155
|
+
queue: deque[tuple[str, int]] = deque((d, 1) for d in down[ancestor])
|
|
156
|
+
while queue:
|
|
157
|
+
node, depth = queue.popleft()
|
|
158
|
+
if node in best and best[node] <= depth:
|
|
159
|
+
continue
|
|
160
|
+
best[node] = depth
|
|
161
|
+
for nxt in down.get(node, ()): # noqa: SIM113
|
|
162
|
+
queue.append((nxt, depth + 1))
|
|
163
|
+
for descendant, depth in best.items():
|
|
164
|
+
if descendant == ancestor:
|
|
165
|
+
continue # ignore self-loops from cyclic graphs
|
|
166
|
+
rows.append(
|
|
167
|
+
{
|
|
168
|
+
"run_id": run,
|
|
169
|
+
"ancestor_node": ancestor,
|
|
170
|
+
"descendant_node": descendant,
|
|
171
|
+
"depth": depth,
|
|
172
|
+
}
|
|
173
|
+
)
|
|
174
|
+
return rows
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _sorted(name: str, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
178
|
+
cols = TABLE_COLUMNS[name]
|
|
179
|
+
return sorted(rows, key=lambda r: tuple(str(r[c]) for c in cols))
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Global graph: link objects when one reads a table produced by another.
|
|
2
|
+
|
|
3
|
+
Upstream tables are detected as qualified names (``bronze_navis.escale``) while
|
|
4
|
+
object ids often come from file stems or notebook variables (``bronze_navis_escale``
|
|
5
|
+
or ``escale``). Matching therefore normalizes dots to underscores and also
|
|
6
|
+
allows a match on the table's final segment.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ..models import ObjectNode
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_graph(nodes: list[ObjectNode]) -> list[ObjectNode]:
|
|
15
|
+
"""Populate ``depends_on_nodes`` on each node in place, then return them."""
|
|
16
|
+
# canonical full form ("bronze_navis.escale" -> "bronze_navis_escale")
|
|
17
|
+
full_index: dict[str, str] = {}
|
|
18
|
+
# whole-id form, for matching a table's final segment against short ids
|
|
19
|
+
id_index: dict[str, str] = {}
|
|
20
|
+
for node in nodes:
|
|
21
|
+
full_index.setdefault(_canon(node.id), node.id)
|
|
22
|
+
id_index.setdefault(node.id.lower(), node.id)
|
|
23
|
+
|
|
24
|
+
for node in nodes:
|
|
25
|
+
deps: set[str] = set()
|
|
26
|
+
for table in node.source_tables:
|
|
27
|
+
target = _match(table, full_index, id_index)
|
|
28
|
+
if target is not None and target != node.id:
|
|
29
|
+
deps.add(target)
|
|
30
|
+
node.depends_on_nodes = sorted(deps)
|
|
31
|
+
return nodes
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _canon(name: str) -> str:
|
|
35
|
+
return name.lower().replace(".", "_")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _match(
|
|
39
|
+
table: str, full_index: dict[str, str], id_index: dict[str, str]
|
|
40
|
+
) -> str | None:
|
|
41
|
+
canon = _canon(table)
|
|
42
|
+
if canon in full_index:
|
|
43
|
+
return full_index[canon]
|
|
44
|
+
if table.lower() in id_index:
|
|
45
|
+
return id_index[table.lower()]
|
|
46
|
+
tail = table.rsplit(".", 1)[-1].lower()
|
|
47
|
+
if tail in id_index:
|
|
48
|
+
return id_index[tail]
|
|
49
|
+
return None
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .writer import (
|
|
2
|
+
SCHEMA_VERSION,
|
|
3
|
+
build_manifest,
|
|
4
|
+
dumps_manifest,
|
|
5
|
+
load_schema,
|
|
6
|
+
validate_manifest,
|
|
7
|
+
write_manifest,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"SCHEMA_VERSION",
|
|
12
|
+
"build_manifest",
|
|
13
|
+
"dumps_manifest",
|
|
14
|
+
"load_schema",
|
|
15
|
+
"validate_manifest",
|
|
16
|
+
"write_manifest",
|
|
17
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"$id": "https://raw.githubusercontent.com/easytalents/sql-lineage-extractor/main/src/sql_lineage/manifest/schema.json",
|
|
4
|
+
"title": "sql-lineage manifest",
|
|
5
|
+
"description": "Contrat public du manifest de lineage. Le champ schema_version suit MAJOR.MINOR et évolue indépendamment de la version du package (voir docs/MANIFEST_SCHEMA.md).",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["schema_version", "generated_at", "nodes", "errors"],
|
|
8
|
+
"additionalProperties": false,
|
|
9
|
+
"properties": {
|
|
10
|
+
"schema_version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+$" },
|
|
11
|
+
"generated_at": { "type": "string" },
|
|
12
|
+
"nodes": {
|
|
13
|
+
"type": "object",
|
|
14
|
+
"additionalProperties": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"required": ["origine", "columns", "depends_on_nodes"],
|
|
17
|
+
"additionalProperties": false,
|
|
18
|
+
"properties": {
|
|
19
|
+
"origine": { "type": "string" },
|
|
20
|
+
"dialecte": { "type": "string" },
|
|
21
|
+
"columns": {
|
|
22
|
+
"type": "object",
|
|
23
|
+
"additionalProperties": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"required": ["expression_sql", "depends_on"],
|
|
26
|
+
"additionalProperties": false,
|
|
27
|
+
"properties": {
|
|
28
|
+
"expression_sql": { "type": "string" },
|
|
29
|
+
"depends_on": {
|
|
30
|
+
"type": "array",
|
|
31
|
+
"items": {
|
|
32
|
+
"type": "object",
|
|
33
|
+
"required": ["table", "column"],
|
|
34
|
+
"additionalProperties": false,
|
|
35
|
+
"properties": {
|
|
36
|
+
"table": { "type": "string" },
|
|
37
|
+
"column": { "type": "string" }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
"depends_on_nodes": {
|
|
45
|
+
"type": "array",
|
|
46
|
+
"items": { "type": "string" }
|
|
47
|
+
},
|
|
48
|
+
"parametres_neutralises": {
|
|
49
|
+
"type": "array",
|
|
50
|
+
"items": { "type": "string" }
|
|
51
|
+
},
|
|
52
|
+
"avertissements": {
|
|
53
|
+
"type": "array",
|
|
54
|
+
"items": { "type": "string" }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"errors": {
|
|
60
|
+
"type": "array",
|
|
61
|
+
"items": {
|
|
62
|
+
"type": "object",
|
|
63
|
+
"required": ["id", "origine", "message"],
|
|
64
|
+
"additionalProperties": false,
|
|
65
|
+
"properties": {
|
|
66
|
+
"id": { "type": "string" },
|
|
67
|
+
"origine": { "type": "string" },
|
|
68
|
+
"message": { "type": "string" }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Serialize parsed objects + errors into the versioned manifest JSON.
|
|
2
|
+
|
|
3
|
+
Determinism is a hard requirement: keys are sorted, lists are pre-sorted by the
|
|
4
|
+
upstream layers, and the only non-reproducible field (``generated_at``) is
|
|
5
|
+
injectable so callers/tests can pin it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as _dt
|
|
11
|
+
import json
|
|
12
|
+
from functools import lru_cache
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import jsonschema
|
|
17
|
+
|
|
18
|
+
from ..models import ObjectNode, ParseError
|
|
19
|
+
|
|
20
|
+
SCHEMA_VERSION = "1.0"
|
|
21
|
+
|
|
22
|
+
_SCHEMA_PATH = Path(__file__).with_name("schema.json")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@lru_cache(maxsize=1)
|
|
26
|
+
def load_schema() -> dict[str, Any]:
|
|
27
|
+
return json.loads(_SCHEMA_PATH.read_text(encoding="utf-8"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_manifest(
|
|
31
|
+
nodes: list[ObjectNode],
|
|
32
|
+
errors: list[ParseError],
|
|
33
|
+
generated_at: str | None = None,
|
|
34
|
+
) -> dict[str, Any]:
|
|
35
|
+
"""Return the manifest as a plain dict, ready to be dumped or validated."""
|
|
36
|
+
return {
|
|
37
|
+
"schema_version": SCHEMA_VERSION,
|
|
38
|
+
"generated_at": generated_at or _now_iso(),
|
|
39
|
+
"nodes": {node.id: _node_to_dict(node) for node in nodes},
|
|
40
|
+
"errors": [
|
|
41
|
+
{"id": e.id, "origine": e.origine, "message": e.message}
|
|
42
|
+
for e in sorted(errors, key=lambda e: (e.id, e.origine, e.message))
|
|
43
|
+
],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _node_to_dict(node: ObjectNode) -> dict[str, Any]:
|
|
48
|
+
return {
|
|
49
|
+
"origine": node.origine,
|
|
50
|
+
"dialecte": node.dialecte,
|
|
51
|
+
"columns": {
|
|
52
|
+
col: {
|
|
53
|
+
"expression_sql": lin.expression_sql,
|
|
54
|
+
"depends_on": [
|
|
55
|
+
{"table": s.table, "column": s.column} for s in lin.depends_on
|
|
56
|
+
],
|
|
57
|
+
}
|
|
58
|
+
for col, lin in node.columns.items()
|
|
59
|
+
},
|
|
60
|
+
"depends_on_nodes": list(node.depends_on_nodes),
|
|
61
|
+
"parametres_neutralises": list(node.parametres_neutralises),
|
|
62
|
+
"avertissements": list(node.avertissements),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def dumps_manifest(manifest: dict[str, Any]) -> str:
|
|
67
|
+
"""Deterministic JSON text: sorted keys, stable spacing, trailing newline."""
|
|
68
|
+
return (
|
|
69
|
+
json.dumps(manifest, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def write_manifest(
|
|
74
|
+
path: str | Path,
|
|
75
|
+
nodes: list[ObjectNode],
|
|
76
|
+
errors: list[ParseError],
|
|
77
|
+
generated_at: str | None = None,
|
|
78
|
+
) -> dict[str, Any]:
|
|
79
|
+
manifest = build_manifest(nodes, errors, generated_at=generated_at)
|
|
80
|
+
Path(path).write_text(dumps_manifest(manifest), encoding="utf-8")
|
|
81
|
+
return manifest
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def validate_manifest(manifest: dict[str, Any]) -> None:
|
|
85
|
+
"""Raise jsonschema.ValidationError if the manifest is non-conforming."""
|
|
86
|
+
jsonschema.validate(instance=manifest, schema=load_schema())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _now_iso() -> str:
|
|
90
|
+
return (
|
|
91
|
+
_dt.datetime.now(tz=_dt.timezone.utc)
|
|
92
|
+
.replace(microsecond=0)
|
|
93
|
+
.isoformat()
|
|
94
|
+
.replace("+00:00", "Z")
|
|
95
|
+
)
|
sql_lineage/models.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Core data structures shared across every layer of the pipeline.
|
|
2
|
+
|
|
3
|
+
These are intentionally plain dataclasses with no behaviour beyond a couple of
|
|
4
|
+
serialization helpers, so each layer can be tested in isolation and the manifest
|
|
5
|
+
writer stays the single source of truth for the JSON shape.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SQLUnit:
|
|
15
|
+
"""A single normalized SQL object, whatever its origin.
|
|
16
|
+
|
|
17
|
+
Every source (files, notebooks, live endpoint) produces these and nothing
|
|
18
|
+
else, so the parsing layer never needs to know where the SQL came from.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
id: str
|
|
22
|
+
dialecte: str
|
|
23
|
+
sql_text: str
|
|
24
|
+
origine: str
|
|
25
|
+
# f-string / dynamic fragments that were replaced by a neutral token before
|
|
26
|
+
# handing the text to sqlglot. Kept for transparency in the manifest.
|
|
27
|
+
parametres_neutralises: list[str] = field(default_factory=list)
|
|
28
|
+
# Non-fatal warnings raised while building the unit (e.g. id fallback).
|
|
29
|
+
avertissements: list[str] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class ColumnSource:
|
|
34
|
+
"""A single upstream (table, column) pair feeding a target column."""
|
|
35
|
+
|
|
36
|
+
table: str
|
|
37
|
+
column: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class ColumnLineage:
|
|
42
|
+
"""Lineage of one output column of an object."""
|
|
43
|
+
|
|
44
|
+
expression_sql: str
|
|
45
|
+
depends_on: list[ColumnSource] = field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ObjectNode:
|
|
50
|
+
"""A successfully parsed SQL object with its per-column lineage."""
|
|
51
|
+
|
|
52
|
+
id: str
|
|
53
|
+
origine: str
|
|
54
|
+
dialecte: str
|
|
55
|
+
columns: dict[str, ColumnLineage] = field(default_factory=dict)
|
|
56
|
+
parametres_neutralises: list[str] = field(default_factory=list)
|
|
57
|
+
avertissements: list[str] = field(default_factory=list)
|
|
58
|
+
# Filled by the graph builder: ids of other ObjectNodes this one reads from.
|
|
59
|
+
depends_on_nodes: list[str] = field(default_factory=list)
|
|
60
|
+
# Raw upstream table names detected while parsing (used by the graph builder
|
|
61
|
+
# to resolve depends_on_nodes). Not serialized directly.
|
|
62
|
+
source_tables: set[str] = field(default_factory=set)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ParseError:
|
|
67
|
+
"""A recoverable failure on a single object; never aborts the whole run."""
|
|
68
|
+
|
|
69
|
+
id: str
|
|
70
|
+
origine: str
|
|
71
|
+
message: str
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Parsing engine: turn one SQLUnit into an ObjectNode or a ParseError.
|
|
2
|
+
|
|
3
|
+
This layer never raises on malformed SQL — a failure on one object is captured
|
|
4
|
+
and returned as a ParseError so the run keeps going for every other object.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sqlglot
|
|
10
|
+
from sqlglot import exp
|
|
11
|
+
from sqlglot.errors import SqlglotError
|
|
12
|
+
from sqlglot.optimizer.scope import Scope, build_scope
|
|
13
|
+
|
|
14
|
+
from ..models import ColumnLineage, ObjectNode, ParseError, SQLUnit
|
|
15
|
+
from .lineage_extractor import extract_column_lineage
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_unit(unit: SQLUnit) -> ObjectNode | ParseError:
|
|
19
|
+
try:
|
|
20
|
+
return _parse_unit(unit)
|
|
21
|
+
except ParseUnitError as err:
|
|
22
|
+
return ParseError(id=unit.id, origine=unit.origine, message=str(err))
|
|
23
|
+
except SqlglotError as err:
|
|
24
|
+
return ParseError(id=unit.id, origine=unit.origine, message=f"Erreur sqlglot: {err}")
|
|
25
|
+
except Exception as err: # defensive: never let one object abort the run
|
|
26
|
+
return ParseError(
|
|
27
|
+
id=unit.id,
|
|
28
|
+
origine=unit.origine,
|
|
29
|
+
message=f"Erreur inattendue ({type(err).__name__}): {err}",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ParseUnitError(Exception):
|
|
34
|
+
"""Internal signal for an expected, recoverable parsing problem."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parse_unit(unit: SQLUnit) -> ObjectNode:
|
|
38
|
+
try:
|
|
39
|
+
tree = sqlglot.parse_one(unit.sql_text, dialect=unit.dialecte)
|
|
40
|
+
except SqlglotError as err:
|
|
41
|
+
raise ParseUnitError(f"SQL non analysable: {err}") from err
|
|
42
|
+
|
|
43
|
+
if tree is None:
|
|
44
|
+
raise ParseUnitError("SQL vide ou non analysable.")
|
|
45
|
+
|
|
46
|
+
select = tree if isinstance(tree, exp.Select) else tree.find(exp.Select)
|
|
47
|
+
if select is None:
|
|
48
|
+
raise ParseUnitError("Aucune instruction SELECT trouvée dans l'objet.")
|
|
49
|
+
|
|
50
|
+
target_columns = _target_columns(select)
|
|
51
|
+
if not target_columns:
|
|
52
|
+
raise ParseUnitError("Aucune colonne de sortie résoluble dans l'objet.")
|
|
53
|
+
|
|
54
|
+
node = ObjectNode(
|
|
55
|
+
id=unit.id,
|
|
56
|
+
origine=unit.origine,
|
|
57
|
+
dialecte=unit.dialecte,
|
|
58
|
+
parametres_neutralises=list(unit.parametres_neutralises),
|
|
59
|
+
avertissements=list(unit.avertissements),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
for column in target_columns:
|
|
63
|
+
expression_sql, sources = extract_column_lineage(
|
|
64
|
+
column, unit.sql_text, unit.dialecte
|
|
65
|
+
)
|
|
66
|
+
node.columns[column] = ColumnLineage(
|
|
67
|
+
expression_sql=expression_sql,
|
|
68
|
+
depends_on=sources,
|
|
69
|
+
)
|
|
70
|
+
for src in sources:
|
|
71
|
+
node.source_tables.add(src.table)
|
|
72
|
+
|
|
73
|
+
return node
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _target_columns(select: exp.Select) -> list[str]:
|
|
77
|
+
"""Validate projections and return the list of resolvable output columns.
|
|
78
|
+
|
|
79
|
+
A ``SELECT *`` (or qualified ``t.*``) is expanded when its source columns are
|
|
80
|
+
statically knowable — i.e. the source is a CTE or a sub-query whose own
|
|
81
|
+
projection is enumerable (recursively). It is only rejected when the source is
|
|
82
|
+
a **physical table**, whose columns are unknown without a live schema. An
|
|
83
|
+
unaliased computed column is always rejected (no stable output name).
|
|
84
|
+
"""
|
|
85
|
+
scope = _safe_build_scope(select)
|
|
86
|
+
columns: list[str] = []
|
|
87
|
+
for projection in select.expressions:
|
|
88
|
+
if _is_star(projection):
|
|
89
|
+
expanded = _expand_star(projection, scope) if scope is not None else None
|
|
90
|
+
if expanded is None:
|
|
91
|
+
raise ParseUnitError(
|
|
92
|
+
"SELECT * non résoluble : la source est une table physique "
|
|
93
|
+
"(colonnes inconnues en analyse statique) ; listez les colonnes "
|
|
94
|
+
"explicitement, ou lisez depuis un CTE/sous-requête énumérable."
|
|
95
|
+
)
|
|
96
|
+
columns.extend(expanded)
|
|
97
|
+
continue
|
|
98
|
+
name = projection.output_name
|
|
99
|
+
if not name:
|
|
100
|
+
raise ParseUnitError(
|
|
101
|
+
"Colonne calculée sans alias détectée "
|
|
102
|
+
f"('{projection.sql()}') : un alias explicite est requis."
|
|
103
|
+
)
|
|
104
|
+
columns.append(name)
|
|
105
|
+
return columns
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _is_star(projection: exp.Expression) -> bool:
|
|
109
|
+
if isinstance(projection, exp.Star):
|
|
110
|
+
return True
|
|
111
|
+
# qualified star such as t.*
|
|
112
|
+
return isinstance(projection, exp.Column) and isinstance(projection.this, exp.Star)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _safe_build_scope(select: exp.Select) -> Scope | None:
|
|
116
|
+
"""Best-effort scope for star resolution; never raises."""
|
|
117
|
+
try:
|
|
118
|
+
return build_scope(select)
|
|
119
|
+
except Exception: # pragma: no cover - defensive; falls back to rejecting stars
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _star_qualifier(projection: exp.Expression) -> str | None:
|
|
124
|
+
"""Return the table qualifier of a ``t.*`` projection, or None for a bare ``*``."""
|
|
125
|
+
if isinstance(projection, exp.Column) and isinstance(projection.this, exp.Star):
|
|
126
|
+
return projection.table or None
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _expand_star(projection: exp.Expression, scope: Scope) -> list[str] | None:
|
|
131
|
+
"""Column names a star expands to, or None if any source is unresolvable."""
|
|
132
|
+
qualifier = _star_qualifier(projection)
|
|
133
|
+
out: list[str] = []
|
|
134
|
+
matched = False
|
|
135
|
+
for alias, source in scope.sources.items():
|
|
136
|
+
if qualifier is not None and alias != qualifier:
|
|
137
|
+
continue
|
|
138
|
+
matched = True
|
|
139
|
+
if isinstance(source, Scope):
|
|
140
|
+
names = _scope_output_names(source)
|
|
141
|
+
if names is None:
|
|
142
|
+
return None
|
|
143
|
+
out.extend(names)
|
|
144
|
+
else: # exp.Table -> physical table, columns unknown statically
|
|
145
|
+
return None
|
|
146
|
+
return out if matched else None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _scope_output_names(scope: Scope) -> list[str] | None:
|
|
150
|
+
"""Output column names of a (CTE / sub-query) scope, expanding nested stars."""
|
|
151
|
+
expression = scope.expression
|
|
152
|
+
if not isinstance(expression, exp.Select):
|
|
153
|
+
return None # set-operations / other shapes: not expanded
|
|
154
|
+
names: list[str] = []
|
|
155
|
+
for projection in expression.expressions:
|
|
156
|
+
if _is_star(projection):
|
|
157
|
+
expanded = _expand_star(projection, scope)
|
|
158
|
+
if expanded is None:
|
|
159
|
+
return None
|
|
160
|
+
names.extend(expanded)
|
|
161
|
+
continue
|
|
162
|
+
name = projection.output_name
|
|
163
|
+
if not name:
|
|
164
|
+
return None
|
|
165
|
+
names.append(name)
|
|
166
|
+
return names
|