Metadata-Version: 2.4
Name: sql-lineage-extractor
Version: 0.1.4
Summary: Static column-to-column SQL lineage extractor producing a dbt-manifest-like JSON graph.
Author-email: Jeffrey YAPI <yapi.jeffrey@gmail.com>
Maintainer-email: EasyTalents <yapi.jeffrey@gmail.com>
License-Expression: Apache-2.0
Project-URL: Homepage, https://github.com/easytalents/sql-lineage-extractor
Project-URL: Repository, https://github.com/easytalents/sql-lineage-extractor
Project-URL: Changelog, https://github.com/easytalents/sql-lineage-extractor/blob/main/CHANGELOG.md
Project-URL: Issues, https://github.com/easytalents/sql-lineage-extractor/issues
Keywords: sql,lineage,sqlglot,dbt,manifest,data-lineage,fabric
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Topic :: Database
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
License-File: NOTICE
Requires-Dist: sqlglot<27,>=25
Requires-Dist: click>=8.1
Requires-Dist: jsonschema>=4.0
Requires-Dist: tomli>=2.0; python_version < "3.11"
Provides-Extra: live
Requires-Dist: pyodbc>=5.0; extra == "live"
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"
Requires-Dist: pytest-cov>=5.0; extra == "dev"
Requires-Dist: ruff>=0.6; extra == "dev"
Requires-Dist: mypy>=1.11; extra == "dev"
Requires-Dist: types-jsonschema>=4.0; extra == "dev"
Dynamic: license-file

# sql-lineage-extractor

[![CI](https://github.com/easytalents/sql-lineage-extractor/actions/workflows/ci.yml/badge.svg)](https://github.com/easytalents/sql-lineage-extractor/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/sql-lineage-extractor.svg)](https://pypi.org/project/sql-lineage-extractor/)
[![Python](https://img.shields.io/pypi/pyversions/sql-lineage-extractor.svg)](https://pypi.org/project/sql-lineage-extractor/)
[![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE)

Extraction **statique** de lineage SQL **colonne-à-colonne**, inspirée du
`manifest.json` de dbt — mais **sans moteur d'exécution ni orchestration**.
L'outil n'exécute aucune requête de transformation, ne charge aucune donnée : il
analyse du **texte SQL** et produit un **graphe structuré** (JSON versionné et
diffable).

Le SQL analysé peut provenir indifféremment de :

- fichiers `.sql` ;
- notebooks Fabric / Jupyter (`.ipynb`) ;
- une connexion **live** à un endpoint SQL (Fabric SQL endpoint, SQL Server,
  Synapse) via une connexion DB-API générique **injectée de l'extérieur** ;
- une simple **chaîne de caractères** en mémoire — requête, définition de vue, ou
  contenu de notebook (voir [SQL en mémoire](#sql-en-mémoire-string)).

Aucune dépendance à un SDK Fabric propriétaire. Le parsing repose sur
[`sqlglot`](https://github.com/tobymao/sqlglot).

## Installation

```bash
pip install -e .
# mode live (connexion DB-API, ex. pyodbc) :
pip install -e ".[live]"
# outillage de dev (tests) :
pip install -e ".[dev]"
```

Python ≥ 3.10.

## Architecture — pipeline en 4 couches

```
Sources SQL (fichiers / notebooks / live / string en mémoire)
        │  -> SQLUnit (id, dialecte, sql_text, origine)
        ▼
Parsing + lineage colonne-à-colonne (sqlglot)   -> ObjectNode | ParseError
        ▼
Construction du graphe multi-objets (depends_on_nodes)
        ▼
Sérialisation en manifest JSON (schématisé, déterministe)
```

Chaque couche est testable indépendamment (`tests/`).

## Utilisation (CLI)

```bash
# Fichiers .sql
sql-lineage extract --source files     --path ./vues       --dialect tsql  --out manifest.json

# Notebooks .ipynb
sql-lineage extract --source notebooks --path ./notebooks  --dialect spark --out manifest.json

# Endpoint SQL live (nécessite l'extra [live])
sql-lineage extract --source live      --conn-string "<DSN ou chaîne ODBC>" --dialect tsql --out manifest.json

# Analyser une requête isolée (argument, --file, ou stdin)
sql-lineage explain "SELECT e.a AS x FROM bronze.escale e" --dialect tsql
cat vue.sql | sql-lineage explain --dialect tsql --id silver_escale --json

# Vérifier qu'un manifest est conforme au schéma publié
sql-lineage validate manifest.json
```

Options utiles : `--glob` pour restreindre le motif de fichiers
(`--glob "silver/**/*.sql"`).

`explain` affiche un résumé colonne par colonne ; `--json` sort à la place un
manifest conforme au schéma, donc réutilisable par `validate` / `export` une fois
redirigé dans un fichier. Il ne résout pas les arêtes
`depends_on_nodes` : une requête isolée n'a pas de voisins — voir
[SQL en mémoire](#sql-en-mémoire-string) ci-dessous.

### Utilisation programmatique

```python
from sql_lineage.sources import FileSQLSource
from sql_lineage.pipeline import run_extraction
from sql_lineage.manifest import write_manifest

nodes, errors = run_extraction(FileSQLSource("./vues", dialect="tsql"))
write_manifest("manifest.json", nodes, errors)
```

Pour le mode live, la connexion est **fournie par l'appelant** (jamais construite
dans la librairie) :

```python
import pyodbc
from sql_lineage.sources import LiveSQLEndpointSource

conn = pyodbc.connect("Driver=...;Server=...;Authentication=ActiveDirectoryInteractive;...")
source = LiveSQLEndpointSource(conn, dialect="tsql")
```

### SQL en mémoire (string)

Rien n'oblige à passer par un fichier ou une connexion : le SQL peut être fourni
directement sous forme de **chaîne**. Pour **une** requête, `lineage_of` renvoie
l'`ObjectNode` (et lève `LineageError` si elle est inanalysable) :

```python
from sql_lineage import lineage_of

node = lineage_of("""
    SELECT e.date_arrivee AS date_traitement
    FROM bronze_navis.escale e
""", dialect="tsql", id="silver_escale")

node.columns["date_traitement"].depends_on  # [ColumnSource(table='bronze_navis.escale', ...)]
node.source_tables                          # {'bronze_navis.escale'}
```

Une définition complète `CREATE VIEW ... AS SELECT` est acceptée telle quelle —
c'est exactement ce que renvoie `sys.sql_modules`. L'`id` passé en argument n'est
qu'un **repli** : un commentaire `-- lineage:id=` dans le SQL l'emporte, comme
pour les fichiers et notebooks.

⚠️ Sur une requête isolée, `depends_on_nodes` est **nécessairement vide** : les
arêtes objet→objet se résolvent *entre* les unités d'un même run. Pour les
obtenir, passez les requêtes **ensemble** via `InlineSQLSource` — qui est une
source à part entière, donc tout le pipeline (manifest, export BI) s'applique :

```python
from sql_lineage import run_extraction
from sql_lineage.sources import InlineSQLSource
from sql_lineage.manifest import write_manifest

source = InlineSQLSource(
    {"bronze_navis_escale": sql_bronze, "silver_escale": sql_silver},  # {id: sql}
    dialect="tsql",
    origine="fabric-api",   # étiquette libre : d'où vient le texte
)
nodes, errors = run_extraction(source)
write_manifest("manifest.json", nodes, errors)
```

`InlineSQLSource` accepte une chaîne, une liste de chaînes (ids de repli
positionnels `inline#0`, avec avertissement) ou un mapping `{id: sql}`.

Le **contenu d'un notebook** déjà en mémoire (récupéré via l'API Fabric, lu
depuis un lakehouse, généré en test) se traite avec la même logique que le mode
fichier — magies `%%sql`, appels `.sql()`, neutralisation des f-strings,
résolution d'id :

```python
from sql_lineage.sources import NotebookSQLSource

source = NotebookSQLSource.from_string(contenu_ipynb, name="00_Silver_Ipaki", dialect="spark")
nodes, errors = run_extraction(source)
```

`contenu_ipynb` peut être le texte JSON brut ou un dict déjà désérialisé.

### Table de destination

Un objet a deux noms distincts : son **`id`** (l'identité de la transformation)
et sa **`destination`** (la table physique qu'il alimente). Le graphe rapproche
les tables sources des **destinations d'abord**, et retombe sur les `id` — donc
sans destination, le comportement est exactement celui d'avant.

Elle est **détectée automatiquement** quand le SQL ou le code la nomme :

| Motif | Destination détectée |
|---|---|
| `INSERT INTO silver.escale SELECT …` | `silver.escale` |
| `CREATE TABLE silver.escale AS SELECT …` | `silver.escale` |
| `CREATE [OR REPLACE] VIEW silver.escale AS SELECT …` | `silver.escale` |
| `df.write.saveAsTable("silver.escale")` | `silver.escale` |
| `df.write.mode("overwrite").format("delta").saveAsTable("…")` | idem |
| `df.write.insertInto("…")` / `df.writeTo("…").append()` | idem |

Dans un notebook, l'écriture est rapprochée du `spark.sql(...)` par **le nom de
la variable** (`df_silver = spark.sql(...)` … `df_silver.write.saveAsTable(...)`),
y compris quand elle vit dans une **cellule ultérieure** — motif le plus courant.
Un nom de table calculé (`saveAsTable(nom_table)`) n'est jamais deviné.

Sinon, elle se **fournit explicitement** — et l'explicite l'emporte toujours :

```python
lineage_of(sql, dialect="spark", id="extract_bl", destination="silver.ipakidry_bl")

InlineSQLSource(
    {"extract_bl": sql_bl, "extract_invoice": sql_inv},
    dialect="spark",
    destinations={"extract_bl": "silver.ipakidry_bl"},  # par id ; une str = pour tous
)
```

```bash
sql-lineage explain "SELECT …" --dialect spark --id extract_bl --destination silver.ipakidry_bl
```

Dans le manifest, `destination` est **optionnel** (schéma `1.1`) : la clé est
absente quand elle est inconnue, et l'export BI la porte dans
`dim_object.destination`.

### Lineage d'une vue déjà présente en base

Deux approches selon le besoin. Pour **une** vue, récupérez sa définition et
donnez-la en chaîne :

```python
cur = conn.cursor()
cur.execute("SELECT OBJECT_DEFINITION(OBJECT_ID('silver.escale'))")
node = lineage_of(cur.fetchone()[0], dialect="tsql", id="silver_escale")
```

Pour un **sous-ensemble** de vues avec le graphe résolu entre elles, filtrez la
requête catalogue de `LiveSQLEndpointSource` (contrat : deux colonnes,
`(nom, sql_text)`) :

```python
source = LiveSQLEndpointSource(
    conn,
    dialect="tsql",
    query=(
        "SELECT SCHEMA_NAME(v.schema_id) + '_' + v.name AS nom_vue, m.definition "
        "FROM sys.views v JOIN sys.sql_modules m ON v.object_id = m.object_id "
        "WHERE SCHEMA_NAME(v.schema_id) IN ('silver', 'gold')"
    ),
)
```

Deux points d'attention : la requête par défaut nomme les objets par `v.name`
**sans le schéma** (d'où le préfixe explicite ci-dessus si vous couvrez plusieurs
schémas), et la chaîne `query` est envoyée telle quelle — construisez-la
vous-même si un nom vient d'une entrée externe.

## Schéma du manifest

Documenté et versionné dans
[`src/sql_lineage/manifest/schema.json`](src/sql_lineage/manifest/schema.json)
(`schema_version` en tête de fichier, actuellement `1.1`). La **politique
d'évolution** du contrat (MAJOR.MINOR, dépréciation, golden tests) est décrite
dans [docs/MANIFEST_SCHEMA.md](docs/MANIFEST_SCHEMA.md). Extrait :

```json
{
  "schema_version": "1.1",
  "generated_at": "2026-07-20T10:00:00Z",
  "nodes": {
    "silver_shipping_escale": {
      "origine": "vues-shipping/silver/escale.sql",
      "dialecte": "tsql",
      "destination": "silver.escale",
      "columns": {
        "date_traitement": {
          "expression_sql": "e.date_arrivee AS date_traitement",
          "depends_on": [{"table": "bronze_navis.escale", "column": "date_arrivee"}]
        }
      },
      "depends_on_nodes": ["bronze_navis_escale"],
      "parametres_neutralises": [],
      "avertissements": []
    }
  },
  "errors": [
    {"id": "silver_finance_xyz", "origine": "...", "message": "..."}
  ]
}
```

**Déterminisme** : même entrée → même sortie. Clés triées, listes ordonnées,
formatage stable — pour un diff propre en CI. Le seul champ non reproductible,
`generated_at`, est injectable (`build_manifest(..., generated_at=...)`).

## Export vers la BI (Power BI, etc.)

Le manifest JSON reste **canonique** (contrat versionné, diffable en CI). Pour la
BI on en **dérive** une couche de service tabulaire — schéma en étoile — que
Power BI consomme directement, sans avoir à « expand » du JSON imbriqué :

```
manifest.json  (canonique)
      │  sql-lineage export  (dérivation pure, rejouable, append par run)
      ▼
dim_run · dim_object · dim_column · fact_column_edge · object_edge · closure · errors
```

| Table | Grain | Rôle |
|---|---|---|
| `dim_run` | 1 run | dimension temps (historisation) |
| `dim_object` | 1 objet | dimension (slice par `layer` bronze/silver/gold) |
| `dim_column` | 1 colonne | dimension colonne |
| `fact_column_edge` | 1 arête colonne→colonne | **fait central** |
| `object_edge` | 1 dépendance objet directe | graphe 1-saut |
| `closure` | 1 couple ancêtre→descendant | **multi-saut** (impact / provenance) |
| `errors` | 1 erreur | suivi qualité |

Toutes les tables portent un `run_id` : chaque run est **ajouté** (jamais
écrasé), ce qui ouvre la dérive dans le temps, l'« as-of date » et l'audit. La
table `closure` (fermeture transitive précalculée en Python) débloque les
rapports *impact* (« si cette source change, quoi en aval ? ») et *provenance*
(« d'où vient cette colonne à la source ? ») sur plusieurs niveaux — impossible
en DAX récursif sur un DAG.

### Backends

```bash
# CSV (un fichier par table)
sql-lineage export manifest.json --format csv       --out ./exports

# SQLite (fichier unique, lisible par Power BI via ODBC)
sql-lineage export manifest.json --format sqlite    --out lineage.db

# SQL Server / Fabric Warehouse (connexion injectée)
sql-lineage export manifest.json --format sqlserver --conn-string "<chaîne ODBC>"

# ... dans un schéma dédié, qui doit exister au préalable
sql-lineage export manifest.json --format sqlserver --conn-string "..." --schema lineage
```

Le backend `sqlserver` utilise l'extra `[live]` (pyodbc). Les backends base de
données sont **idempotents par run** (les lignes d'un `run_id` existant sont
remplacées) ; le CSV est append-only.

Sans `--schema` (ou `SqlDbExporter(conn)`), les tables vivent dans le schéma par
défaut de la connexion — `dbo` sur SQL Server / Fabric Warehouse. Avec
`--schema lineage` / `SqlDbExporter(conn, schema="lineage")`, tous les noms sont
qualifiés. Le schéma lui-même n'est **jamais créé par l'export** : c'est un acte
d'administration ponctuel, pas quelque chose à rejouer à chaque extraction.

> **Fabric** : guide d'usage complet (exécution hors Spark, connexion Warehouse
> via Service Principal, script d'orchestration prêt à coller) dans
> [docs/FABRIC_USAGE.md](docs/FABRIC_USAGE.md) et
> [examples/fabric_orchestration.py](examples/fabric_orchestration.py). Pour
> **explorer** interactivement le lineage d'autres notebooks depuis Fabric :
> [examples/fabric_lineage_explore.ipynb](examples/fabric_lineage_explore.ipynb).
>
> **Power BI** : guide prescriptif de construction d'un rapport à partir des
> tables (modèle, relations, mesures DAX, pages) dans
> [docs/POWERBI_REPORTING_GUIDE.md](docs/POWERBI_REPORTING_GUIDE.md).

### Modélisation Power BI (indicatif)

- Relations : `dim_object[node_id] 1—* fact_column_edge[target_node]` ;
  `dim_run[run_id] 1—*` sur chaque table (filtre de run).
- Impact/provenance : filtrer `closure[ancestor_node]` (aval) ou
  `closure[descendant_node]` (amont), `depth` donne la distance.
- Volume faible → **mode Import** suffit (pas besoin de DirectQuery/DirectLake).

## Conventions

### Identifiant d'un objet dans un notebook

Priorité de résolution de l'`id` d'une cellule / d'un appel `spark.sql` :

1. **Commentaire de convention** en tête du SQL :
   `-- lineage:id=silver_shipping_escale` *(valeur par défaut, ajustable si
   l'équipe data préfère une autre convention)*.
2. **Nom de la variable cible** en Python (`df_silver_finance = spark.sql(...)`
   → `silver_finance` ; le préfixe `df_` est retiré).
3. **Repli** `nom_notebook#numero_cellule` — ce cas émet un **avertissement**
   (champ `avertissements`), il n'est jamais silencieux.

### Neutralisation des f-strings

Une cellule Python `spark.sql(f"... WHERE d >= '{date_execution}'")` est parsée
avec le module `ast` (jamais par regex). Chaque interpolation `{...}` **en
position valeur** (`>= {x}`, `'{x}'`, `IN ({x})`) est remplacée par le jeton
neutre `__PARAM__` avant transmission à sqlglot, pour ne pas casser le parsing.

Une interpolation qui occupe **une ligne à elle seule** est traitée comme un
**fragment de clause SQL** (motif courant en Fabric :
`{'' if terminal_names is None else 'AND t.col IN ' + terminal_names}`) et
**supprimée** plutôt que tokenisée — un `__PARAM__` orphelin casserait le parsing,
et un prédicat `WHERE`/`HAVING` ne contribue jamais au lineage colonne.

Dans les deux cas, les fragments remplacés/supprimés sont conservés dans le champ
`parametres_neutralises` du nœud, pour transparence.

### Personnaliser les conventions (`LineageConfig`)

**Aucune convention n'est codée en dur** : le commentaire d'id, le préfixe de
variable, le jeton de neutralisation et le dialecte par défaut proviennent tous
d'un objet `LineageConfig` **injectable**. Les défauts reproduisent exactement le
comportement décrit ci-dessus (rétro-compatible).

```python
from sql_lineage import LineageConfig
from sql_lineage.sources import NotebookSQLSource

config = LineageConfig(
    id_comment_pattern=r"--\s*lin:id\s*=\s*(\w+)",  # 1 groupe de capture = l'id
    var_prefixes=("df_", "result_"),                 # préfixes retirés de l'id
    param_token="@@PARAM@@",
    default_dialect="spark",
)
source = NotebookSQLSource("./notebooks", config=config)
```

La même chose en CLI (les flags surchargent un éventuel `--config`) :

```bash
sql-lineage extract --source notebooks --path ./notebooks \
    --config conventions.toml \
    --var-prefix df_ --var-prefix result_ \
    --id-comment-pattern '--\s*lin:id\s*=\s*(\w+)' \
    --param-token '@@PARAM@@' \
    --out manifest.json
```

Fichier de conventions (`.toml` ou `.json`) — les clés peuvent aussi vivre sous
`[tool.sql_lineage]` d'un `pyproject.toml` partagé :

```toml
[sql_lineage]
var_prefixes = ["df_", "result_"]
param_token = "@@PARAM@@"
default_dialect = "spark"
```

La convention `-- lineage:id=` s'applique aussi aux **fichiers `.sql`** : un
commentaire présent dans le fichier l'emporte sur l'id dérivé du nom de fichier.

## Cas d'erreur (attendus)

Une erreur de parsing sur **un** objet n'interrompt **jamais** le traitement des
autres : elle est capturée et reportée dans `errors`. Cas rejetés :

- `SELECT *` (et `t.*`) **sur une table physique** : colonnes inconnues en
  analyse statique. En revanche, un `SELECT *` **au-dessus d'un CTE ou d'une
  sous-requête énumérable est expansé automatiquement** (y compris les cas
  imbriqués `T.*`), donc supporté.
- Colonne calculée **sans alias** (`e.a + e.b`) : pas de nom de sortie stable.
- SQL non analysable par sqlglot pour le dialecte donné.

## Limites connues

- **SQL procédural complexe** (batchs T-SQL multi-instructions, variables,
  `MERGE`, procédures stockées) : seul le premier `SELECT` pertinent est analysé.
- **Dialectes** : testés sur `tsql` et `spark`. Les autres dialectes sqlglot
  fonctionnent probablement mais ne sont pas couverts par les tests — à étendre
  selon les besoins.
- **Résolution du graphe** : le rapprochement `table source ↔ objet` normalise
  les points en underscores (`bronze_navis.escale` ↔ `bronze_navis_escale`) et
  autorise un match sur le dernier segment. Des collisions de noms courts sont
  théoriquement possibles — une `destination` explicite lève l'ambiguïté.
- **Non-objectifs** : pas d'exécution de requêtes, pas de génération de site de
  documentation (le manifest est destiné à être consommé par un outil de rendu
  séparé), pas de tests de qualité de données.

## Tests

```bash
pytest
```

Aucun test ne nécessite de connexion réseau ni d'accès à un environnement Fabric
réel — le mode live est couvert par une connexion **mockée**.

## Structure du dépôt

```
src/sql_lineage/
  models.py              # SQLUnit, ColumnLineage, ObjectNode, ParseError, ColumnSource
  sources/               # file / notebook / live / inline  (interface SQLSource)
  parsing/               # wrapper sqlglot + extraction de lineage
  graph/                 # construction des arêtes depends_on_nodes
  manifest/              # schema.json + writer (sérialisation déterministe)
  pipeline.py            # orchestration source -> parse -> graph ; lineage_of()
  cli.py                 # commandes `extract` / `explain` / `validate` / `export`
tests/                   # fixtures .sql / .ipynb + tests par couche
```
