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.
@@ -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,3 @@
1
+ from .builder import build_graph
2
+
3
+ __all__ = ["build_graph"]
@@ -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,4 @@
1
+ from .engine import parse_unit
2
+ from .lineage_extractor import extract_column_lineage
3
+
4
+ __all__ = ["parse_unit", "extract_column_lineage"]
@@ -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