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
sql_lineage/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""sql_lineage — static column-to-column SQL lineage extractor.
|
|
2
|
+
|
|
3
|
+
Pipeline: sources -> parsing/lineage -> graph -> manifest (JSON).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from .config import LineageConfig
|
|
9
|
+
from .models import ColumnLineage, ColumnSource, ObjectNode, ParseError, SQLUnit
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"SQLUnit",
|
|
13
|
+
"ColumnSource",
|
|
14
|
+
"ColumnLineage",
|
|
15
|
+
"ObjectNode",
|
|
16
|
+
"ParseError",
|
|
17
|
+
"LineageConfig",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _resolve_version() -> str:
|
|
22
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
return version("sql-lineage-extractor")
|
|
26
|
+
except PackageNotFoundError: # pragma: no cover - source checkout without install
|
|
27
|
+
return "0.0.0+unknown"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
__version__ = _resolve_version()
|
sql_lineage/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 1)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
sql_lineage/cli.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Command-line interface (click): ``sql-lineage extract`` and ``validate``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from .config import LineageConfig
|
|
14
|
+
from .export import CsvExporter, SqlDbExporter, SqliteExporter, build_export_tables
|
|
15
|
+
from .manifest import validate_manifest, write_manifest
|
|
16
|
+
from .pipeline import run_extraction
|
|
17
|
+
from .sources import FileSQLSource, LiveSQLEndpointSource, NotebookSQLSource
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@click.group()
|
|
21
|
+
@click.version_option(package_name="sql-lineage-extractor")
|
|
22
|
+
def cli() -> None:
|
|
23
|
+
"""Static column-to-column SQL lineage extractor."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@cli.command()
|
|
27
|
+
@click.option(
|
|
28
|
+
"--source",
|
|
29
|
+
"source_kind",
|
|
30
|
+
type=click.Choice(["files", "notebooks", "live"]),
|
|
31
|
+
required=True,
|
|
32
|
+
help="Where the SQL comes from.",
|
|
33
|
+
)
|
|
34
|
+
@click.option("--path", type=click.Path(), default=None, help="Folder to scan (files/notebooks).")
|
|
35
|
+
@click.option(
|
|
36
|
+
"--dialect",
|
|
37
|
+
default=None,
|
|
38
|
+
help="sqlglot dialect (défaut: tsql, ou default_dialect du fichier de config).",
|
|
39
|
+
)
|
|
40
|
+
@click.option("--out", "out_path", type=click.Path(), required=True, help="Manifest output path.")
|
|
41
|
+
@click.option("--conn-string", default=None, help="DB-API connection string (live mode).")
|
|
42
|
+
@click.option("--glob", "glob_pattern", default=None, help="Override the file glob pattern.")
|
|
43
|
+
@click.option(
|
|
44
|
+
"--config",
|
|
45
|
+
"config_path",
|
|
46
|
+
type=click.Path(exists=True),
|
|
47
|
+
default=None,
|
|
48
|
+
help="Fichier de conventions (.toml/.json) ; les flags ci-dessous le surchargent.",
|
|
49
|
+
)
|
|
50
|
+
@click.option(
|
|
51
|
+
"--id-comment-pattern",
|
|
52
|
+
default=None,
|
|
53
|
+
help="Regex du commentaire d'id (1 groupe de capture). Défaut: '-- lineage:id=(...)'.",
|
|
54
|
+
)
|
|
55
|
+
@click.option(
|
|
56
|
+
"--var-prefix",
|
|
57
|
+
"var_prefixes",
|
|
58
|
+
multiple=True,
|
|
59
|
+
help="Préfixe de variable à retirer pour dériver l'id (répétable). Défaut: 'df_'.",
|
|
60
|
+
)
|
|
61
|
+
@click.option(
|
|
62
|
+
"--param-token",
|
|
63
|
+
default=None,
|
|
64
|
+
help="Jeton de neutralisation des interpolations f-string. Défaut: '__PARAM__'.",
|
|
65
|
+
)
|
|
66
|
+
def extract(
|
|
67
|
+
source_kind: str,
|
|
68
|
+
path: str | None,
|
|
69
|
+
dialect: str | None,
|
|
70
|
+
out_path: str,
|
|
71
|
+
conn_string: str | None,
|
|
72
|
+
glob_pattern: str | None,
|
|
73
|
+
config_path: str | None,
|
|
74
|
+
id_comment_pattern: str | None,
|
|
75
|
+
var_prefixes: tuple[str, ...],
|
|
76
|
+
param_token: str | None,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Extract lineage and write a manifest JSON."""
|
|
79
|
+
try:
|
|
80
|
+
config = _build_config(
|
|
81
|
+
config_path, id_comment_pattern, var_prefixes, param_token, dialect
|
|
82
|
+
)
|
|
83
|
+
except (ValueError, RuntimeError) as err:
|
|
84
|
+
raise click.UsageError(f"Configuration invalide: {err}") from err
|
|
85
|
+
|
|
86
|
+
source = _build_source(source_kind, path, conn_string, glob_pattern, config)
|
|
87
|
+
nodes, errors = run_extraction(source)
|
|
88
|
+
write_manifest(out_path, nodes, errors)
|
|
89
|
+
|
|
90
|
+
click.echo(
|
|
91
|
+
f"{len(nodes)} objet(s) analysé(s), {len(errors)} erreur(s) -> {out_path}"
|
|
92
|
+
)
|
|
93
|
+
for parse_err in errors:
|
|
94
|
+
click.echo(
|
|
95
|
+
f" ! {parse_err.id} ({parse_err.origine}): {parse_err.message}", err=True
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@cli.command()
|
|
100
|
+
@click.argument("manifest_path", type=click.Path(exists=True))
|
|
101
|
+
def validate(manifest_path: str) -> None:
|
|
102
|
+
"""Validate a manifest against the published JSON Schema."""
|
|
103
|
+
manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
|
|
104
|
+
try:
|
|
105
|
+
validate_manifest(manifest)
|
|
106
|
+
except Exception as err: # jsonschema.ValidationError and friends
|
|
107
|
+
click.echo(f"INVALIDE: {err}", err=True)
|
|
108
|
+
sys.exit(1)
|
|
109
|
+
click.echo("VALIDE: le manifest est conforme au schéma.")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@cli.command()
|
|
113
|
+
@click.argument("manifest_path", type=click.Path(exists=True))
|
|
114
|
+
@click.option(
|
|
115
|
+
"--format",
|
|
116
|
+
"fmt",
|
|
117
|
+
type=click.Choice(["csv", "sqlite", "sqlserver"]),
|
|
118
|
+
required=True,
|
|
119
|
+
help="Backend cible pour les tables BI.",
|
|
120
|
+
)
|
|
121
|
+
@click.option(
|
|
122
|
+
"--out", "out_path", type=click.Path(), default=None,
|
|
123
|
+
help="Dossier (csv) ou fichier (sqlite).",
|
|
124
|
+
)
|
|
125
|
+
@click.option("--conn-string", default=None, help="Connexion DB-API (format sqlserver).")
|
|
126
|
+
@click.option(
|
|
127
|
+
"--run-id", default=None,
|
|
128
|
+
help="Identifiant de run (défaut: generated_at du manifest).",
|
|
129
|
+
)
|
|
130
|
+
def export(
|
|
131
|
+
fmt: str,
|
|
132
|
+
manifest_path: str,
|
|
133
|
+
out_path: str | None,
|
|
134
|
+
conn_string: str | None,
|
|
135
|
+
run_id: str | None,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Dériver les tables BI (étoile) d'un manifest vers CSV / SQLite / SQL Server."""
|
|
138
|
+
manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
|
|
139
|
+
tables = build_export_tables(manifest, run_id=run_id)
|
|
140
|
+
|
|
141
|
+
if fmt == "csv":
|
|
142
|
+
if not out_path:
|
|
143
|
+
raise click.UsageError("--out (dossier) est requis pour --format csv.")
|
|
144
|
+
CsvExporter(out_path).export(tables)
|
|
145
|
+
target = out_path
|
|
146
|
+
elif fmt == "sqlite":
|
|
147
|
+
if not out_path:
|
|
148
|
+
raise click.UsageError("--out (fichier .db) est requis pour --format sqlite.")
|
|
149
|
+
with SqliteExporter(out_path) as sqlite_exporter:
|
|
150
|
+
sqlite_exporter.export(tables)
|
|
151
|
+
target = out_path
|
|
152
|
+
else: # sqlserver
|
|
153
|
+
if not conn_string:
|
|
154
|
+
raise click.UsageError("--conn-string est requis pour --format sqlserver.")
|
|
155
|
+
SqlDbExporter(_open_pyodbc(conn_string)).export(tables)
|
|
156
|
+
target = "<connexion SQL>"
|
|
157
|
+
|
|
158
|
+
counts = ", ".join(f"{name}={len(rows)}" for name, rows in tables.items())
|
|
159
|
+
click.echo(f"Tables exportées vers {target} : {counts}")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _build_config(
|
|
163
|
+
config_path: str | None,
|
|
164
|
+
id_comment_pattern: str | None,
|
|
165
|
+
var_prefixes: tuple[str, ...],
|
|
166
|
+
param_token: str | None,
|
|
167
|
+
dialect: str | None,
|
|
168
|
+
) -> LineageConfig:
|
|
169
|
+
"""Build the effective config: file (if any), then per-flag overrides."""
|
|
170
|
+
base = LineageConfig.from_file(config_path) if config_path else LineageConfig()
|
|
171
|
+
overrides: dict[str, Any] = {}
|
|
172
|
+
if id_comment_pattern is not None:
|
|
173
|
+
overrides["id_comment_pattern"] = id_comment_pattern
|
|
174
|
+
if var_prefixes: # non-empty tuple => the flag was provided
|
|
175
|
+
overrides["var_prefixes"] = tuple(var_prefixes)
|
|
176
|
+
if param_token is not None:
|
|
177
|
+
overrides["param_token"] = param_token
|
|
178
|
+
if dialect is not None:
|
|
179
|
+
overrides["default_dialect"] = dialect
|
|
180
|
+
return dataclasses.replace(base, **overrides) if overrides else base
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _build_source(
|
|
184
|
+
source_kind: str,
|
|
185
|
+
path: str | None,
|
|
186
|
+
conn_string: str | None,
|
|
187
|
+
glob_pattern: str | None,
|
|
188
|
+
config: LineageConfig,
|
|
189
|
+
):
|
|
190
|
+
if source_kind == "files":
|
|
191
|
+
if not path:
|
|
192
|
+
raise click.UsageError("--path est requis pour --source files.")
|
|
193
|
+
return FileSQLSource(path, pattern=glob_pattern or "**/*.sql", config=config)
|
|
194
|
+
if source_kind == "notebooks":
|
|
195
|
+
if not path:
|
|
196
|
+
raise click.UsageError("--path est requis pour --source notebooks.")
|
|
197
|
+
return NotebookSQLSource(path, pattern=glob_pattern or "**/*.ipynb", config=config)
|
|
198
|
+
if source_kind == "live":
|
|
199
|
+
if not conn_string:
|
|
200
|
+
raise click.UsageError("--conn-string est requis pour --source live.")
|
|
201
|
+
return LiveSQLEndpointSource(_open_pyodbc(conn_string), config=config)
|
|
202
|
+
raise click.UsageError(f"Source inconnue: {source_kind}")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _open_pyodbc(conn_string: str):
|
|
206
|
+
try:
|
|
207
|
+
import pyodbc # noqa: PLC0415 - optional dependency, imported lazily
|
|
208
|
+
except ImportError as err: # pragma: no cover - depends on optional extra
|
|
209
|
+
raise click.UsageError(
|
|
210
|
+
"Le mode live nécessite l'extra 'live' (pip install "
|
|
211
|
+
"sql-lineage-extractor[live])."
|
|
212
|
+
) from err
|
|
213
|
+
return pyodbc.connect(conn_string)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__": # pragma: no cover
|
|
217
|
+
cli()
|
sql_lineage/config.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Configuration of the *conventions* the extractor relies on.
|
|
2
|
+
|
|
3
|
+
The library must stay **generic**: nothing project- or client-specific may be
|
|
4
|
+
hard-coded. Every convention used to resolve a notebook object's id, to strip a
|
|
5
|
+
dataframe-variable prefix, to neutralize f-string interpolations, or to pick a
|
|
6
|
+
default SQL dialect lives here, in an immutable :class:`LineageConfig` that the
|
|
7
|
+
caller can override — programmatically, via CLI flags, or from a TOML/JSON file.
|
|
8
|
+
|
|
9
|
+
The defaults reproduce the historical behaviour exactly, so existing callers are
|
|
10
|
+
unaffected.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Mapping
|
|
19
|
+
|
|
20
|
+
# -- historical defaults (reproduce the previous hard-coded behaviour) ---------
|
|
21
|
+
|
|
22
|
+
#: Convention comment naming a unit explicitly, e.g. ``-- lineage:id=silver_x``.
|
|
23
|
+
DEFAULT_ID_COMMENT_PATTERN = r"--\s*lineage:id\s*=\s*([A-Za-z0-9_.]+)"
|
|
24
|
+
#: Dataframe-variable prefixes stripped when deriving an id (``df_x`` -> ``x``).
|
|
25
|
+
DEFAULT_VAR_PREFIXES: tuple[str, ...] = ("df_",)
|
|
26
|
+
#: Token substituted for every f-string interpolation before parsing.
|
|
27
|
+
DEFAULT_PARAM_TOKEN = "__PARAM__"
|
|
28
|
+
#: Dialect used when a source is created without an explicit one.
|
|
29
|
+
DEFAULT_DIALECT = "tsql"
|
|
30
|
+
|
|
31
|
+
#: Keys accepted in a config mapping / file, mapped to the dataclass fields.
|
|
32
|
+
_CONFIG_KEYS = frozenset(
|
|
33
|
+
{"id_comment_pattern", "var_prefixes", "param_token", "default_dialect"}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class LineageConfig:
|
|
39
|
+
"""Immutable set of conventions driving id resolution and neutralization.
|
|
40
|
+
|
|
41
|
+
All fields are optional; each defaults to the historical behaviour.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
id_comment_pattern: str = DEFAULT_ID_COMMENT_PATTERN
|
|
45
|
+
var_prefixes: tuple[str, ...] = DEFAULT_VAR_PREFIXES
|
|
46
|
+
param_token: str = DEFAULT_PARAM_TOKEN
|
|
47
|
+
default_dialect: str = DEFAULT_DIALECT
|
|
48
|
+
#: Compiled form of ``id_comment_pattern`` (built once, never part of eq/repr).
|
|
49
|
+
id_comment_re: "re.Pattern[str]" = field(init=False, compare=False, repr=False)
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
# Normalize a list/other iterable of prefixes to a tuple (hashable, frozen).
|
|
53
|
+
object.__setattr__(self, "var_prefixes", tuple(self.var_prefixes))
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
compiled = re.compile(self.id_comment_pattern)
|
|
57
|
+
except re.error as err:
|
|
58
|
+
raise ValueError(f"id_comment_pattern invalide: {err}") from err
|
|
59
|
+
if compiled.groups < 1:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
"id_comment_pattern doit contenir au moins un groupe de capture "
|
|
62
|
+
"délimitant l'id (ex. '-- lineage:id=(...)')."
|
|
63
|
+
)
|
|
64
|
+
object.__setattr__(self, "id_comment_re", compiled)
|
|
65
|
+
|
|
66
|
+
if not self.param_token:
|
|
67
|
+
raise ValueError("param_token ne peut pas être vide.")
|
|
68
|
+
if not self.default_dialect:
|
|
69
|
+
raise ValueError("default_dialect ne peut pas être vide.")
|
|
70
|
+
|
|
71
|
+
# -- id-resolution helpers (shared by notebook and file sources) ----------
|
|
72
|
+
|
|
73
|
+
def id_from_comment(self, text: str) -> str | None:
|
|
74
|
+
"""Return the id declared by an ``-- lineage:id=`` comment, if any."""
|
|
75
|
+
match = self.id_comment_re.search(text)
|
|
76
|
+
return match.group(1) if match else None
|
|
77
|
+
|
|
78
|
+
def strip_var_prefix(self, var_name: str) -> str:
|
|
79
|
+
"""Strip the first matching dataframe-variable prefix (``df_x`` -> ``x``)."""
|
|
80
|
+
for prefix in self.var_prefixes:
|
|
81
|
+
if prefix and var_name.startswith(prefix):
|
|
82
|
+
return var_name[len(prefix) :]
|
|
83
|
+
return var_name
|
|
84
|
+
|
|
85
|
+
# -- constructors ---------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_mapping(cls, data: Mapping[str, Any]) -> "LineageConfig":
|
|
89
|
+
"""Build a config from a plain mapping, rejecting unknown keys."""
|
|
90
|
+
unknown = set(data) - _CONFIG_KEYS
|
|
91
|
+
if unknown:
|
|
92
|
+
raise ValueError(
|
|
93
|
+
f"Clé(s) de configuration inconnue(s): {sorted(unknown)}. "
|
|
94
|
+
f"Clés valides: {sorted(_CONFIG_KEYS)}."
|
|
95
|
+
)
|
|
96
|
+
kwargs: dict[str, Any] = dict(data)
|
|
97
|
+
if kwargs.get("var_prefixes") is not None:
|
|
98
|
+
kwargs["var_prefixes"] = tuple(kwargs["var_prefixes"])
|
|
99
|
+
return cls(**kwargs)
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def from_file(cls, path: str | Path) -> "LineageConfig":
|
|
103
|
+
"""Load a config from a ``.toml`` or ``.json`` file.
|
|
104
|
+
|
|
105
|
+
The settings may sit at the top level, under a ``[sql_lineage]`` table,
|
|
106
|
+
or under ``[tool.sql_lineage]`` (so a shared ``pyproject.toml`` works).
|
|
107
|
+
"""
|
|
108
|
+
path = Path(path)
|
|
109
|
+
text = path.read_text(encoding="utf-8")
|
|
110
|
+
suffix = path.suffix.lower()
|
|
111
|
+
if suffix == ".json":
|
|
112
|
+
import json
|
|
113
|
+
|
|
114
|
+
data: Any = json.loads(text)
|
|
115
|
+
elif suffix == ".toml":
|
|
116
|
+
data = _load_toml(text)
|
|
117
|
+
else:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
f"Format de configuration non supporté: '{suffix}' "
|
|
120
|
+
"(attendu .toml ou .json)."
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if not isinstance(data, dict):
|
|
124
|
+
raise ValueError("Le fichier de configuration doit contenir une table.")
|
|
125
|
+
# Unwrap the conventional nesting if present.
|
|
126
|
+
tool = data.get("tool")
|
|
127
|
+
if isinstance(tool, dict) and isinstance(tool.get("sql_lineage"), dict):
|
|
128
|
+
data = tool["sql_lineage"]
|
|
129
|
+
elif isinstance(data.get("sql_lineage"), dict):
|
|
130
|
+
data = data["sql_lineage"]
|
|
131
|
+
return cls.from_mapping(data)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _load_toml(text: str) -> dict[str, Any]:
|
|
135
|
+
try:
|
|
136
|
+
import tomllib # Python >= 3.11
|
|
137
|
+
except ModuleNotFoundError: # pragma: no cover - only on 3.10
|
|
138
|
+
try:
|
|
139
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
140
|
+
except ModuleNotFoundError as err: # pragma: no cover
|
|
141
|
+
raise RuntimeError(
|
|
142
|
+
"La lecture d'un fichier TOML requiert Python 3.11+ ou le paquet "
|
|
143
|
+
"'tomli' (installé automatiquement sur 3.10 via l'extra du package)."
|
|
144
|
+
) from err
|
|
145
|
+
return tomllib.loads(text)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""BI serving layer: derive flat star-schema tables from a manifest.
|
|
2
|
+
|
|
3
|
+
The manifest JSON stays the canonical, diffable source of truth. This package
|
|
4
|
+
*derives* tabular tables from it (a pure, replayable step) and writes them to a
|
|
5
|
+
pluggable backend: CSV, SQLite, or any injected DB-API connection (SQL Server /
|
|
6
|
+
Fabric Warehouse).
|
|
7
|
+
|
|
8
|
+
Tables (all carry ``run_id`` so runs are appended, never overwritten):
|
|
9
|
+
|
|
10
|
+
* ``dim_run`` one row per extraction run
|
|
11
|
+
* ``dim_object`` one row per SQL object
|
|
12
|
+
* ``dim_column`` one row per output column
|
|
13
|
+
* ``fact_column_edge`` central fact: one row per source->target column edge
|
|
14
|
+
* ``object_edge`` object-level direct dependency (1 hop)
|
|
15
|
+
* ``closure`` transitive ancestor->descendant reachability (N hops)
|
|
16
|
+
* ``errors`` one row per parse error
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from .backends import CsvExporter, SqlDbExporter, SqliteExporter
|
|
20
|
+
from .tables import TABLE_COLUMNS, build_export_tables, default_layer
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"build_export_tables",
|
|
24
|
+
"default_layer",
|
|
25
|
+
"TABLE_COLUMNS",
|
|
26
|
+
"CsvExporter",
|
|
27
|
+
"SqliteExporter",
|
|
28
|
+
"SqlDbExporter",
|
|
29
|
+
]
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Pluggable export backends: CSV files, a SQLite file, or an injected DB-API
|
|
2
|
+
connection (SQL Server / Fabric Warehouse).
|
|
3
|
+
|
|
4
|
+
All backends are *append by run*: writing a run adds its rows. The database
|
|
5
|
+
backends first delete any existing rows for the same ``run_id`` so re-running a
|
|
6
|
+
given run is idempotent; the CSV backend appends (it never rewrites history).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import csv
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .tables import INT_COLUMNS, TABLE_COLUMNS
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CsvExporter:
|
|
19
|
+
"""Write one ``<table>.csv`` per table under ``out_dir`` (append mode)."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, out_dir: str | Path) -> None:
|
|
22
|
+
self.out_dir = Path(out_dir)
|
|
23
|
+
|
|
24
|
+
def export(self, tables: dict[str, list[dict[str, Any]]]) -> None:
|
|
25
|
+
self.out_dir.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
for name, cols in TABLE_COLUMNS.items():
|
|
27
|
+
path = self.out_dir / f"{name}.csv"
|
|
28
|
+
write_header = not path.exists()
|
|
29
|
+
with path.open("a", newline="", encoding="utf-8") as fh:
|
|
30
|
+
writer = csv.DictWriter(fh, fieldnames=cols)
|
|
31
|
+
if write_header:
|
|
32
|
+
writer.writeheader()
|
|
33
|
+
for row in tables.get(name, []):
|
|
34
|
+
writer.writerow({c: row.get(c, "") for c in cols})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _DbApiExporter:
|
|
38
|
+
"""Shared logic for any DB-API connection using ``?`` (qmark) parameters."""
|
|
39
|
+
|
|
40
|
+
# Backends override the column type used in CREATE TABLE.
|
|
41
|
+
TEXT_TYPE = "TEXT"
|
|
42
|
+
INT_TYPE = "INTEGER"
|
|
43
|
+
|
|
44
|
+
def __init__(self, connection: Any) -> None:
|
|
45
|
+
self.connection = connection
|
|
46
|
+
|
|
47
|
+
def export(self, tables: dict[str, list[dict[str, Any]]]) -> None:
|
|
48
|
+
cursor = self.connection.cursor()
|
|
49
|
+
try:
|
|
50
|
+
for name, cols in TABLE_COLUMNS.items():
|
|
51
|
+
self._create_table(cursor, name, cols)
|
|
52
|
+
rows = tables.get(name, [])
|
|
53
|
+
self._delete_runs(cursor, name, rows)
|
|
54
|
+
self._insert_rows(cursor, name, cols, rows)
|
|
55
|
+
self.connection.commit()
|
|
56
|
+
finally:
|
|
57
|
+
close = getattr(cursor, "close", None)
|
|
58
|
+
if callable(close):
|
|
59
|
+
close()
|
|
60
|
+
|
|
61
|
+
# -- overridable DDL ------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
def _create_table(self, cursor: Any, name: str, cols: list[str]) -> None:
|
|
64
|
+
defs = ", ".join(f"{c} {self._col_type(c)}" for c in cols)
|
|
65
|
+
cursor.execute(f"CREATE TABLE IF NOT EXISTS {name} ({defs})")
|
|
66
|
+
|
|
67
|
+
def _col_type(self, col: str) -> str:
|
|
68
|
+
return self.INT_TYPE if col in INT_COLUMNS else self.TEXT_TYPE
|
|
69
|
+
|
|
70
|
+
# -- data -----------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def _delete_runs(self, cursor: Any, name: str, rows: list[dict[str, Any]]) -> None:
|
|
73
|
+
run_ids = {r["run_id"] for r in rows}
|
|
74
|
+
for run_id in run_ids:
|
|
75
|
+
cursor.execute(f"DELETE FROM {name} WHERE run_id = ?", (run_id,))
|
|
76
|
+
|
|
77
|
+
def _insert_rows(
|
|
78
|
+
self, cursor: Any, name: str, cols: list[str], rows: list[dict[str, Any]]
|
|
79
|
+
) -> None:
|
|
80
|
+
if not rows:
|
|
81
|
+
return
|
|
82
|
+
placeholders = ", ".join("?" for _ in cols)
|
|
83
|
+
sql = f"INSERT INTO {name} ({', '.join(cols)}) VALUES ({placeholders})"
|
|
84
|
+
cursor.executemany(sql, [tuple(r.get(c) for c in cols) for r in rows])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class SqliteExporter(_DbApiExporter):
|
|
88
|
+
"""Write to a single SQLite file (stdlib ``sqlite3``, no dependency)."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, db_path: str | Path) -> None:
|
|
91
|
+
import sqlite3
|
|
92
|
+
|
|
93
|
+
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
super().__init__(sqlite3.connect(str(db_path)))
|
|
95
|
+
|
|
96
|
+
def close(self) -> None:
|
|
97
|
+
self.connection.close()
|
|
98
|
+
|
|
99
|
+
def __enter__(self) -> "SqliteExporter":
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def __exit__(self, *exc: object) -> None:
|
|
103
|
+
self.close()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class SqlDbExporter(_DbApiExporter):
|
|
107
|
+
"""Write to an injected DB-API connection (SQL Server / Fabric Warehouse).
|
|
108
|
+
|
|
109
|
+
The connection is provided by the caller (auth and driver are out of scope),
|
|
110
|
+
exactly like :class:`~sql_lineage.sources.LiveSQLEndpointSource`.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
TEXT_TYPE = "NVARCHAR(4000)"
|
|
114
|
+
INT_TYPE = "INT"
|
|
115
|
+
|
|
116
|
+
def _create_table(self, cursor: Any, name: str, cols: list[str]) -> None:
|
|
117
|
+
# T-SQL has no CREATE TABLE IF NOT EXISTS before recent versions.
|
|
118
|
+
defs = ", ".join(f"{c} {self._col_type(c)}" for c in cols)
|
|
119
|
+
cursor.execute(
|
|
120
|
+
f"IF OBJECT_ID(N'{name}', N'U') IS NULL "
|
|
121
|
+
f"CREATE TABLE {name} ({defs})"
|
|
122
|
+
)
|