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,53 @@
|
|
|
1
|
+
"""Column-to-column lineage for a single target column, via sqlglot."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sqlglot import exp
|
|
6
|
+
from sqlglot.lineage import Node, lineage
|
|
7
|
+
|
|
8
|
+
from ..models import ColumnSource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def extract_column_lineage(
|
|
12
|
+
column: str, sql: str, dialect: str
|
|
13
|
+
) -> tuple[str, list[ColumnSource]]:
|
|
14
|
+
"""Return (target expression SQL, sorted upstream (table, column) pairs).
|
|
15
|
+
|
|
16
|
+
Walks the sqlglot lineage tree down to its leaves; each leaf backed by a
|
|
17
|
+
base table becomes one ColumnSource. Results are de-duplicated and sorted so
|
|
18
|
+
the manifest stays deterministic.
|
|
19
|
+
"""
|
|
20
|
+
node = lineage(column, sql, dialect=dialect)
|
|
21
|
+
expression_sql = node.expression.sql(dialect=dialect)
|
|
22
|
+
|
|
23
|
+
sources: set[ColumnSource] = set()
|
|
24
|
+
for leaf in _leaves(node):
|
|
25
|
+
resolved = _resolve_leaf(leaf)
|
|
26
|
+
if resolved is not None:
|
|
27
|
+
sources.add(resolved)
|
|
28
|
+
|
|
29
|
+
return expression_sql, sorted(sources, key=lambda s: (s.table, s.column))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _leaves(node: Node):
|
|
33
|
+
if not node.downstream:
|
|
34
|
+
yield node
|
|
35
|
+
return
|
|
36
|
+
for child in node.downstream:
|
|
37
|
+
yield from _leaves(child)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _resolve_leaf(leaf: Node) -> ColumnSource | None:
|
|
41
|
+
source = leaf.source
|
|
42
|
+
if not isinstance(source, exp.Table):
|
|
43
|
+
return None
|
|
44
|
+
table = _qualified_table(source)
|
|
45
|
+
column = leaf.name.rsplit(".", 1)[-1]
|
|
46
|
+
if not table or not column:
|
|
47
|
+
return None
|
|
48
|
+
return ColumnSource(table=table, column=column)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _qualified_table(table: exp.Table) -> str:
|
|
52
|
+
parts = [p for p in (table.catalog, table.db, table.name) if p]
|
|
53
|
+
return ".".join(parts)
|
sql_lineage/pipeline.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Glue the four layers together: source -> parse -> graph -> (nodes, errors)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .graph import build_graph
|
|
6
|
+
from .models import ObjectNode, ParseError
|
|
7
|
+
from .parsing import parse_unit
|
|
8
|
+
from .sources.base import SQLSource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run_extraction(source: SQLSource) -> tuple[list[ObjectNode], list[ParseError]]:
|
|
12
|
+
"""Extract every unit from ``source`` and resolve the cross-object graph.
|
|
13
|
+
|
|
14
|
+
A parse failure on one object is collected as a ParseError and never stops
|
|
15
|
+
the others.
|
|
16
|
+
"""
|
|
17
|
+
nodes: list[ObjectNode] = []
|
|
18
|
+
errors: list[ParseError] = []
|
|
19
|
+
for unit in source.extract():
|
|
20
|
+
result = parse_unit(unit)
|
|
21
|
+
if isinstance(result, ParseError):
|
|
22
|
+
errors.append(result)
|
|
23
|
+
else:
|
|
24
|
+
nodes.append(result)
|
|
25
|
+
build_graph(nodes)
|
|
26
|
+
nodes.sort(key=lambda n: n.id)
|
|
27
|
+
return nodes, errors
|
sql_lineage/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from ..config import LineageConfig
|
|
2
|
+
from .base import SQLSource
|
|
3
|
+
from .file_source import FileSQLSource
|
|
4
|
+
from .live_sql_source import LiveSQLEndpointSource
|
|
5
|
+
from .notebook_source import NotebookSQLSource
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"SQLSource",
|
|
9
|
+
"FileSQLSource",
|
|
10
|
+
"NotebookSQLSource",
|
|
11
|
+
"LiveSQLEndpointSource",
|
|
12
|
+
"LineageConfig",
|
|
13
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Common source interface. Every source yields SQLUnit objects and nothing else."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from ..models import SQLUnit
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class SQLSource(Protocol):
|
|
12
|
+
def extract(self) -> list[SQLUnit]:
|
|
13
|
+
"""Return the normalized SQL units this source exposes."""
|
|
14
|
+
...
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""FileSQLSource — scan a directory for .sql files, one file == one SQLUnit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..config import LineageConfig
|
|
8
|
+
from ..models import SQLUnit
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FileSQLSource:
|
|
12
|
+
"""Yield one SQLUnit per file matching ``pattern`` under ``root``.
|
|
13
|
+
|
|
14
|
+
The ``id`` defaults to the file stem (``escale.sql`` -> ``escale``), but an
|
|
15
|
+
explicit ``-- lineage:id=`` comment inside the file wins (same convention as
|
|
16
|
+
notebooks, resolved through the injected :class:`LineageConfig`). Origins are
|
|
17
|
+
stored as POSIX-style relative paths when possible, for stable, diffable
|
|
18
|
+
manifests across operating systems.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
root: str | Path,
|
|
24
|
+
dialect: str | None = None,
|
|
25
|
+
pattern: str = "**/*.sql",
|
|
26
|
+
*,
|
|
27
|
+
config: LineageConfig | None = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
self.root = Path(root)
|
|
30
|
+
self.config = config or LineageConfig()
|
|
31
|
+
self.dialect = dialect if dialect is not None else self.config.default_dialect
|
|
32
|
+
self.pattern = pattern
|
|
33
|
+
|
|
34
|
+
def extract(self) -> list[SQLUnit]:
|
|
35
|
+
units: list[SQLUnit] = []
|
|
36
|
+
for path in sorted(self.root.glob(self.pattern)):
|
|
37
|
+
if not path.is_file():
|
|
38
|
+
continue
|
|
39
|
+
text = path.read_text(encoding="utf-8")
|
|
40
|
+
try:
|
|
41
|
+
origine = path.relative_to(self.root).as_posix()
|
|
42
|
+
except ValueError:
|
|
43
|
+
origine = path.as_posix()
|
|
44
|
+
uid = self.config.id_from_comment(text) or path.stem
|
|
45
|
+
units.append(
|
|
46
|
+
SQLUnit(
|
|
47
|
+
id=uid,
|
|
48
|
+
dialecte=self.dialect,
|
|
49
|
+
sql_text=text.strip(),
|
|
50
|
+
origine=origine,
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
return units
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""LiveSQLEndpointSource — read view definitions from a live DB-API connection.
|
|
2
|
+
|
|
3
|
+
The connection object is *injected*, never built here: authentication (Entra ID
|
|
4
|
+
for Fabric, SQL auth for SQL Server, ...) and driver choice are the caller's
|
|
5
|
+
concern, keeping this library free of any proprietary Fabric SDK dependency.
|
|
6
|
+
|
|
7
|
+
The default query targets T-SQL system catalogs (``sys.sql_modules``); if that
|
|
8
|
+
fails (permissions, non-SQL-Server engine) it falls back to the ANSI standard
|
|
9
|
+
``information_schema.views``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from ..config import LineageConfig
|
|
17
|
+
from ..models import SQLUnit
|
|
18
|
+
|
|
19
|
+
# Full CREATE VIEW ... AS <select> definitions, richest source when available.
|
|
20
|
+
TSQL_QUERY = (
|
|
21
|
+
"SELECT v.name AS nom_vue, m.definition AS sql_text "
|
|
22
|
+
"FROM sys.views v "
|
|
23
|
+
"JOIN sys.sql_modules m ON v.object_id = m.object_id"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Portable fallback: exposes only the SELECT body, no CREATE VIEW wrapper.
|
|
27
|
+
ANSI_FALLBACK_QUERY = (
|
|
28
|
+
"SELECT table_name AS nom_vue, view_definition AS sql_text "
|
|
29
|
+
"FROM information_schema.views"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class LiveSQLEndpointSource:
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
connection: Any,
|
|
37
|
+
dialect: str | None = None,
|
|
38
|
+
origine: str = "live",
|
|
39
|
+
query: str | None = None,
|
|
40
|
+
fallback_query: str | None = None,
|
|
41
|
+
*,
|
|
42
|
+
config: LineageConfig | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.connection = connection
|
|
45
|
+
self.config = config or LineageConfig()
|
|
46
|
+
self.dialect = dialect if dialect is not None else self.config.default_dialect
|
|
47
|
+
self.origine = origine
|
|
48
|
+
self.query = query or TSQL_QUERY
|
|
49
|
+
self.fallback_query = fallback_query or ANSI_FALLBACK_QUERY
|
|
50
|
+
|
|
51
|
+
def extract(self) -> list[SQLUnit]:
|
|
52
|
+
try:
|
|
53
|
+
rows = self._run(self.query)
|
|
54
|
+
except Exception:
|
|
55
|
+
# Any failure on the primary path (missing sys.sql_modules, denied
|
|
56
|
+
# permissions, unsupported engine) triggers the portable fallback.
|
|
57
|
+
rows = self._run(self.fallback_query)
|
|
58
|
+
|
|
59
|
+
units: list[SQLUnit] = []
|
|
60
|
+
for name, sql_text in rows:
|
|
61
|
+
if sql_text is None:
|
|
62
|
+
continue
|
|
63
|
+
units.append(
|
|
64
|
+
SQLUnit(
|
|
65
|
+
id=str(name),
|
|
66
|
+
dialecte=self.dialect,
|
|
67
|
+
sql_text=str(sql_text).strip(),
|
|
68
|
+
origine=f"{self.origine}:{name}",
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
return units
|
|
72
|
+
|
|
73
|
+
def _run(self, query: str) -> list[tuple[Any, Any]]:
|
|
74
|
+
cursor = self.connection.cursor()
|
|
75
|
+
try:
|
|
76
|
+
cursor.execute(query)
|
|
77
|
+
return [(row[0], row[1]) for row in cursor.fetchall()]
|
|
78
|
+
finally:
|
|
79
|
+
close = getattr(cursor, "close", None)
|
|
80
|
+
if callable(close):
|
|
81
|
+
close()
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""NotebookSQLSource — extract SQL from Jupyter/Fabric ``.ipynb`` notebooks.
|
|
2
|
+
|
|
3
|
+
Three cell shapes are understood:
|
|
4
|
+
|
|
5
|
+
* ``%%sql`` magic cells: everything after the magic line is taken verbatim.
|
|
6
|
+
* Python cells calling ``<something>.sql("...")`` with a string literal.
|
|
7
|
+
* Python cells calling ``<something>.sql(f"...")`` with an f-string; each
|
|
8
|
+
``{...}`` interpolation is replaced by a neutral token so sqlglot can still
|
|
9
|
+
parse the statement. The replaced fragments are recorded on the SQLUnit.
|
|
10
|
+
|
|
11
|
+
Python is parsed with the standard :mod:`ast` module — never with regexes —
|
|
12
|
+
because real notebook code mixes multi-line strings, nested calls and quote
|
|
13
|
+
styles that regexes get wrong.
|
|
14
|
+
|
|
15
|
+
Every convention (the ``-- lineage:id=`` comment, the ``df_`` variable prefix,
|
|
16
|
+
the neutralization token, the default dialect) comes from an injected
|
|
17
|
+
:class:`~sql_lineage.config.LineageConfig`; nothing is hard-coded here.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import ast
|
|
23
|
+
import json
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import TypeGuard
|
|
26
|
+
|
|
27
|
+
from ..config import DEFAULT_PARAM_TOKEN, LineageConfig
|
|
28
|
+
from ..models import SQLUnit
|
|
29
|
+
|
|
30
|
+
# Backward-compatible module-level alias for the default neutralization token.
|
|
31
|
+
# Kept so ``from ...notebook_source import PARAM_TOKEN`` keeps working.
|
|
32
|
+
PARAM_TOKEN = DEFAULT_PARAM_TOKEN
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class NotebookSQLSource:
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
root: str | Path,
|
|
39
|
+
dialect: str | None = None,
|
|
40
|
+
pattern: str = "**/*.ipynb",
|
|
41
|
+
*,
|
|
42
|
+
config: LineageConfig | None = None,
|
|
43
|
+
) -> None:
|
|
44
|
+
self.root = Path(root)
|
|
45
|
+
self.config = config or LineageConfig()
|
|
46
|
+
self.dialect = dialect if dialect is not None else self.config.default_dialect
|
|
47
|
+
self.pattern = pattern
|
|
48
|
+
|
|
49
|
+
def extract(self) -> list[SQLUnit]:
|
|
50
|
+
units: list[SQLUnit] = []
|
|
51
|
+
for path in sorted(self.root.glob(self.pattern)):
|
|
52
|
+
if not path.is_file():
|
|
53
|
+
continue
|
|
54
|
+
units.extend(self._extract_notebook(path))
|
|
55
|
+
return units
|
|
56
|
+
|
|
57
|
+
# -- per-notebook ---------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def _extract_notebook(self, path: Path) -> list[SQLUnit]:
|
|
60
|
+
try:
|
|
61
|
+
origine = path.relative_to(self.root).as_posix()
|
|
62
|
+
except ValueError:
|
|
63
|
+
origine = path.as_posix()
|
|
64
|
+
|
|
65
|
+
nb = json.loads(path.read_text(encoding="utf-8"))
|
|
66
|
+
notebook_name = path.stem
|
|
67
|
+
units: list[SQLUnit] = []
|
|
68
|
+
|
|
69
|
+
cell_index = -1
|
|
70
|
+
for cell in nb.get("cells", []):
|
|
71
|
+
cell_index += 1
|
|
72
|
+
if cell.get("cell_type") != "code":
|
|
73
|
+
continue
|
|
74
|
+
src = _cell_source(cell)
|
|
75
|
+
if not src.strip():
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
magic_sql = _strip_sql_magic(src)
|
|
79
|
+
if magic_sql is not None:
|
|
80
|
+
units.append(
|
|
81
|
+
self._make_unit(
|
|
82
|
+
sql_text=magic_sql,
|
|
83
|
+
params=[],
|
|
84
|
+
var_name=None,
|
|
85
|
+
origine=origine,
|
|
86
|
+
notebook_name=notebook_name,
|
|
87
|
+
cell_index=cell_index,
|
|
88
|
+
call_index=0,
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
calls = _python_sql_calls(src, self.config.param_token)
|
|
94
|
+
for call_index, (sql_text, params, var_name) in enumerate(calls):
|
|
95
|
+
units.append(
|
|
96
|
+
self._make_unit(
|
|
97
|
+
sql_text=sql_text,
|
|
98
|
+
params=params,
|
|
99
|
+
var_name=var_name,
|
|
100
|
+
origine=origine,
|
|
101
|
+
notebook_name=notebook_name,
|
|
102
|
+
cell_index=cell_index,
|
|
103
|
+
call_index=call_index,
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
return units
|
|
107
|
+
|
|
108
|
+
def _make_unit(
|
|
109
|
+
self,
|
|
110
|
+
*,
|
|
111
|
+
sql_text: str,
|
|
112
|
+
params: list[str],
|
|
113
|
+
var_name: str | None,
|
|
114
|
+
origine: str,
|
|
115
|
+
notebook_name: str,
|
|
116
|
+
cell_index: int,
|
|
117
|
+
call_index: int,
|
|
118
|
+
) -> SQLUnit:
|
|
119
|
+
warnings: list[str] = []
|
|
120
|
+
uid = self.config.id_from_comment(sql_text)
|
|
121
|
+
if uid is None and var_name:
|
|
122
|
+
uid = self.config.strip_var_prefix(var_name)
|
|
123
|
+
if uid is None:
|
|
124
|
+
uid = f"{notebook_name}#{cell_index}"
|
|
125
|
+
if call_index:
|
|
126
|
+
uid = f"{uid}#{call_index}"
|
|
127
|
+
warnings.append(
|
|
128
|
+
f"Aucun id explicite trouvé (ni commentaire '-- lineage:id=', ni "
|
|
129
|
+
f"variable cible) ; repli sur '{uid}'."
|
|
130
|
+
)
|
|
131
|
+
return SQLUnit(
|
|
132
|
+
id=uid,
|
|
133
|
+
dialecte=self.dialect,
|
|
134
|
+
sql_text=sql_text.strip(),
|
|
135
|
+
origine=f"{origine}#cell{cell_index}",
|
|
136
|
+
parametres_neutralises=params,
|
|
137
|
+
avertissements=warnings,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# -- module-level helpers -----------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _cell_source(cell: dict) -> str:
|
|
145
|
+
src = cell.get("source", "")
|
|
146
|
+
if isinstance(src, list):
|
|
147
|
+
return "".join(src)
|
|
148
|
+
return src
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _strip_sql_magic(src: str) -> str | None:
|
|
152
|
+
"""Return the SQL body if ``src`` is a ``%%sql`` cell, else None."""
|
|
153
|
+
lines = src.splitlines()
|
|
154
|
+
for i, line in enumerate(lines):
|
|
155
|
+
if not line.strip():
|
|
156
|
+
continue
|
|
157
|
+
if line.strip().startswith("%%sql"):
|
|
158
|
+
return "\n".join(lines[i + 1 :])
|
|
159
|
+
return None
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _python_sql_calls(
|
|
164
|
+
src: str, param_token: str
|
|
165
|
+
) -> list[tuple[str, list[str], str | None]]:
|
|
166
|
+
"""Find every ``*.sql(<str|fstring>)`` call, returning (sql, params, var)."""
|
|
167
|
+
try:
|
|
168
|
+
tree = ast.parse(src)
|
|
169
|
+
except SyntaxError:
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
# Map matching Call nodes to the variable they are assigned to (if any).
|
|
173
|
+
assigned: dict[int, str | None] = {}
|
|
174
|
+
for node in ast.walk(tree):
|
|
175
|
+
if isinstance(node, ast.Assign) and _is_sql_call(node.value):
|
|
176
|
+
assigned[id(node.value)] = _target_name(node.targets)
|
|
177
|
+
|
|
178
|
+
calls = [n for n in ast.walk(tree) if _is_sql_call(n)]
|
|
179
|
+
calls.sort(key=lambda n: (getattr(n, "lineno", 0), getattr(n, "col_offset", 0)))
|
|
180
|
+
|
|
181
|
+
results: list[tuple[str, list[str], str | None]] = []
|
|
182
|
+
for call in calls:
|
|
183
|
+
extracted = _extract_sql_arg(call.args[0], param_token)
|
|
184
|
+
if extracted is None:
|
|
185
|
+
continue
|
|
186
|
+
sql_text, params = extracted
|
|
187
|
+
results.append((sql_text, params, assigned.get(id(call))))
|
|
188
|
+
return results
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _is_sql_call(node: ast.AST) -> TypeGuard[ast.Call]:
|
|
192
|
+
return (
|
|
193
|
+
isinstance(node, ast.Call)
|
|
194
|
+
and isinstance(node.func, ast.Attribute)
|
|
195
|
+
and node.func.attr == "sql"
|
|
196
|
+
and len(node.args) >= 1
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _target_name(targets: list[ast.expr]) -> str | None:
|
|
201
|
+
for target in targets:
|
|
202
|
+
if isinstance(target, ast.Name):
|
|
203
|
+
return target.id
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _extract_sql_arg(arg: ast.expr, param_token: str) -> tuple[str, list[str]] | None:
|
|
208
|
+
"""Return (sql_text, neutralized_params) for a string or f-string arg."""
|
|
209
|
+
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
210
|
+
return arg.value, []
|
|
211
|
+
if isinstance(arg, ast.JoinedStr):
|
|
212
|
+
return _neutralize_fstring(arg, param_token)
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _neutralize_fstring(node: ast.JoinedStr, param_token: str) -> tuple[str, list[str]]:
|
|
217
|
+
parts: list[str] = []
|
|
218
|
+
params: list[str] = []
|
|
219
|
+
values = node.values
|
|
220
|
+
for i, value in enumerate(values):
|
|
221
|
+
if isinstance(value, ast.Constant) and isinstance(value.value, str):
|
|
222
|
+
parts.append(value.value)
|
|
223
|
+
elif isinstance(value, ast.FormattedValue):
|
|
224
|
+
try:
|
|
225
|
+
expr_src = ast.unparse(value.value)
|
|
226
|
+
except Exception: # pragma: no cover - very old python fallback
|
|
227
|
+
expr_src = "?"
|
|
228
|
+
params.append(expr_src)
|
|
229
|
+
# An interpolation standing alone on its line is a whole SQL *clause*
|
|
230
|
+
# (e.g. ``{'' if x is None else 'AND t.c IN ' + x}``), not a value.
|
|
231
|
+
# Replacing it by the neutral token would leave an unparsable bare
|
|
232
|
+
# word, so we drop it instead — WHERE/HAVING predicates never feed
|
|
233
|
+
# column lineage, so removing one is safe.
|
|
234
|
+
before = "".join(parts)
|
|
235
|
+
nxt = values[i + 1] if i + 1 < len(values) else None
|
|
236
|
+
after = (
|
|
237
|
+
nxt.value
|
|
238
|
+
if isinstance(nxt, ast.Constant) and isinstance(nxt.value, str)
|
|
239
|
+
else ""
|
|
240
|
+
)
|
|
241
|
+
parts.append("" if _is_statement_fragment(before, after) else param_token)
|
|
242
|
+
return "".join(parts), params
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _is_statement_fragment(before: str, after: str) -> bool:
|
|
246
|
+
"""Whether an interpolation stands alone on its line (a whole SQL clause).
|
|
247
|
+
|
|
248
|
+
True when nothing meaningful precedes it on the line (start of string or the
|
|
249
|
+
preceding text ends the line) and nothing meaningful follows it on the line
|
|
250
|
+
(end of string, a newline, or a closing paren). Such fragments are dropped
|
|
251
|
+
rather than tokenized. Value-position interpolations (``>= {x}``, ``'{x}'``,
|
|
252
|
+
``IN ({x})``) fail these tests and keep the neutral token.
|
|
253
|
+
"""
|
|
254
|
+
left_clear = before == "" or before.rstrip(" \t").endswith("\n")
|
|
255
|
+
stripped_after = after.lstrip(" \t")
|
|
256
|
+
right_clear = (
|
|
257
|
+
stripped_after == ""
|
|
258
|
+
or stripped_after.startswith("\n")
|
|
259
|
+
or stripped_after.startswith(")")
|
|
260
|
+
)
|
|
261
|
+
return left_clear and right_clear
|