sql2iceberg 0.1.0__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.
- sql2iceberg/__init__.py +15 -0
- sql2iceberg/cli.py +258 -0
- sql2iceberg/compare.py +68 -0
- sql2iceberg/connectors/__init__.py +22 -0
- sql2iceberg/connectors/base.py +126 -0
- sql2iceberg/connectors/others.py +133 -0
- sql2iceberg/connectors/snowflake.py +47 -0
- sql2iceberg/convert.py +170 -0
- sql2iceberg/detect.py +95 -0
- sql2iceberg/diagnostics.py +124 -0
- sql2iceberg/generate.py +112 -0
- sql2iceberg/lineage.py +58 -0
- sql2iceberg/platforms.py +63 -0
- sql2iceberg/publish/__init__.py +18 -0
- sql2iceberg/publish/aws.py +108 -0
- sql2iceberg/publish/base.py +120 -0
- sql2iceberg/publish/gcp.py +72 -0
- sql2iceberg/publish/metadata.py +63 -0
- sql2iceberg/publish/snowflake.py +143 -0
- sql2iceberg/pyspark_gen.py +251 -0
- sql2iceberg/pytest_plugin.py +74 -0
- sql2iceberg/report.py +49 -0
- sql2iceberg/tracing.py +134 -0
- sql2iceberg/validate.py +186 -0
- sql2iceberg/verify.py +138 -0
- sql2iceberg-0.1.0.dist-info/METADATA +190 -0
- sql2iceberg-0.1.0.dist-info/RECORD +30 -0
- sql2iceberg-0.1.0.dist-info/WHEEL +4 -0
- sql2iceberg-0.1.0.dist-info/entry_points.txt +5 -0
- sql2iceberg-0.1.0.dist-info/licenses/LICENSE +21 -0
sql2iceberg/convert.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Convert Snowflake SQL to Spark SQL with SQLGlot, then flag what cannot be trusted.
|
|
2
|
+
|
|
3
|
+
Findings come from checks against behavior observed with Spark 3.5.6 (see docs/known-issues.md).
|
|
4
|
+
SQLGlot does not raise for these cases, so this module adds its own checks.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
import sqlglot
|
|
14
|
+
from sqlglot import ErrorLevel, exp
|
|
15
|
+
from sqlglot.errors import ParseError
|
|
16
|
+
|
|
17
|
+
# status values
|
|
18
|
+
CONVERTED = "converted" # no known problems found (still needs verification)
|
|
19
|
+
REVIEW = "review" # converted, but semantics may differ from Snowflake
|
|
20
|
+
UNSUPPORTED = "unsupported" # output is known not to run in Spark
|
|
21
|
+
UNPARSED = "unparsed" # could not parse as Snowflake SQL
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Finding:
|
|
26
|
+
code: str
|
|
27
|
+
severity: str # "unsupported" or "review"
|
|
28
|
+
message: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Result:
|
|
33
|
+
source_sql: str
|
|
34
|
+
spark_sql: str | None
|
|
35
|
+
status: str
|
|
36
|
+
findings: list[Finding] = field(default_factory=list)
|
|
37
|
+
|
|
38
|
+
def to_dict(self) -> dict:
|
|
39
|
+
return {
|
|
40
|
+
"source_sql": self.source_sql,
|
|
41
|
+
"spark_sql": self.spark_sql,
|
|
42
|
+
"status": self.status,
|
|
43
|
+
"findings": [f.__dict__ for f in self.findings],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# Patterns in the converted SQL that are known to fail in Spark 3.5 (confirmed by execution).
|
|
48
|
+
_OUTPUT_RULES = [
|
|
49
|
+
(re.compile(r"EXPLODE\(\s*input\s*=>", re.IGNORECASE), "FLATTEN",
|
|
50
|
+
("FLATTEN became EXPLODE(input => ...), which Spark rejects. Rewrite with explode/posexplode "
|
|
51
|
+
"on a real array column (parse JSON strings with from_json first).")),
|
|
52
|
+
(re.compile(r"\bWITHIN\s+GROUP\b", re.IGNORECASE), "WITHIN_GROUP",
|
|
53
|
+
("WITHIN GROUP is not valid for collect_list or listagg in Spark 3.5. Order inside a subquery "
|
|
54
|
+
"or use array_sort/sort_array with array_join.")),
|
|
55
|
+
(re.compile(r"\bGET_PATH\s*\(", re.IGNORECASE), "GET_PATH",
|
|
56
|
+
("VARIANT path access stayed as GET_PATH, which Spark does not have. Use get_json_object on a JSON string "
|
|
57
|
+
"or struct field access.")),
|
|
58
|
+
(re.compile(r"\bMONTHNAME\s*\(", re.IGNORECASE), "MONTHNAME",
|
|
59
|
+
"MONTHNAME is not a Spark function; use date_format(col, 'MMM')."),
|
|
60
|
+
(re.compile(r"\b(SEQ[1248]|GENERATOR)\s*\(", re.IGNORECASE), "GENERATOR",
|
|
61
|
+
"SEQ*/GENERATOR have no Spark equivalent; use range() or sequence()."),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
_REVIEW_MESSAGES = {
|
|
65
|
+
"DATEADD": "Spark DATE_ADD returns a DATE, so the time of day is dropped on timestamps. "
|
|
66
|
+
"Use timestampadd or an interval for timestamp columns.",
|
|
67
|
+
"DIVIDE": "Division by zero returns NULL in Spark 3.5 (ANSI off) but raises in Snowflake; "
|
|
68
|
+
"Spark 4 defaults to ANSI on. Record spark.sql.ansi.enabled.",
|
|
69
|
+
"TO_DATE_FORMAT": "TO_DATE format argument was dropped. Confirm the format equals the default; "
|
|
70
|
+
"bad strings give NULL in Spark but raise in Snowflake (use TRY_TO_DATE).",
|
|
71
|
+
"REGEXP_LIKE": "Snowflake REGEXP_LIKE matches the whole string; Spark RLIKE matches anywhere. "
|
|
72
|
+
"Anchor the pattern (^...$) to keep the meaning.",
|
|
73
|
+
"VARIANT_PATH": "VARIANT path access became get_json_object, valid only if the column holds a "
|
|
74
|
+
"JSON string, not a struct or map.",
|
|
75
|
+
"OBJECT_CONSTRUCT": "OBJECT_CONSTRUCT became STRUCT; null handling and dynamic keys differ.",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _source_checks(expr: exp.Expression) -> list[Finding]:
|
|
80
|
+
found: list[Finding] = []
|
|
81
|
+
codes: set[str] = set()
|
|
82
|
+
for node in expr.walk():
|
|
83
|
+
if isinstance(node, exp.DateAdd):
|
|
84
|
+
codes.add("DATEADD")
|
|
85
|
+
elif isinstance(node, exp.Div):
|
|
86
|
+
codes.add("DIVIDE")
|
|
87
|
+
elif isinstance(node, exp.RegexpLike):
|
|
88
|
+
codes.add("REGEXP_LIKE")
|
|
89
|
+
elif isinstance(node, (exp.TsOrDsToDate, exp.StrToDate)) and node.args.get("format"):
|
|
90
|
+
codes.add("TO_DATE_FORMAT")
|
|
91
|
+
elif isinstance(node, (exp.JSONExtract, exp.JSONBExtract)):
|
|
92
|
+
codes.add("VARIANT_PATH")
|
|
93
|
+
elif isinstance(node, (exp.Struct,)) or (
|
|
94
|
+
isinstance(node, exp.Anonymous) and node.name.upper() == "OBJECT_CONSTRUCT"
|
|
95
|
+
):
|
|
96
|
+
codes.add("OBJECT_CONSTRUCT")
|
|
97
|
+
for code in sorted(codes):
|
|
98
|
+
found.append(Finding(code, "review", _REVIEW_MESSAGES[code]))
|
|
99
|
+
return found
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _keep_try_semantics(node: exp.Expression) -> exp.Expression:
|
|
103
|
+
"""SQLGlot drops the TRY_ of TRY_TO_NUMBER/TRY_TO_DOUBLE/TRY_TO_DATE in Spark output. Under Spark 4 (ANSI on)
|
|
104
|
+
the plain CAST/TO_DATE then raises on bad input where Snowflake returns NULL, so keep the safe form."""
|
|
105
|
+
if node.args.get("safe") and not node.args.get("format"):
|
|
106
|
+
if isinstance(node, exp.ToNumber):
|
|
107
|
+
precision = node.args.get("precision") or exp.Literal.number(38)
|
|
108
|
+
scale = node.args.get("scale") or exp.Literal.number(0)
|
|
109
|
+
decimal = exp.DataType(
|
|
110
|
+
this=exp.DataType.Type.DECIMAL,
|
|
111
|
+
expressions=[exp.DataTypeParam(this=precision), exp.DataTypeParam(this=scale)],
|
|
112
|
+
)
|
|
113
|
+
return exp.TryCast(this=node.this, to=decimal, safe=True)
|
|
114
|
+
if isinstance(node, exp.ToDouble):
|
|
115
|
+
return exp.TryCast(this=node.this, to=exp.DataType.build("DOUBLE"), safe=True)
|
|
116
|
+
if isinstance(node, exp.TsOrDsToDate) and node.args.get("safe"):
|
|
117
|
+
plain = node.copy()
|
|
118
|
+
plain.set("safe", False)
|
|
119
|
+
text = plain.sql(dialect="spark", unsupported_level=ErrorLevel.IGNORE)
|
|
120
|
+
# try_to_date exists from Spark 4.0; earlier versions have no safe date parse
|
|
121
|
+
return sqlglot.parse_one(re.sub(r"^TO_DATE", "TRY_TO_DATE", text), read="spark")
|
|
122
|
+
return node
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class _Capture(logging.Handler):
|
|
126
|
+
"""Collect SQLGlot's warnings about arguments it cannot express in the target dialect."""
|
|
127
|
+
|
|
128
|
+
def __init__(self) -> None:
|
|
129
|
+
super().__init__(level=logging.WARNING)
|
|
130
|
+
self.messages: list[str] = []
|
|
131
|
+
|
|
132
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
133
|
+
self.messages.append(record.getMessage())
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def convert_statement(sql: str, dialect: str = "snowflake") -> Result:
|
|
137
|
+
"""Convert one statement from ``dialect`` to Spark SQL and report findings."""
|
|
138
|
+
try:
|
|
139
|
+
expr = sqlglot.parse_one(sql, read=dialect)
|
|
140
|
+
except ParseError as exc:
|
|
141
|
+
return Result(sql, None, UNPARSED, [Finding("PARSE", "unsupported", str(exc)[:200])])
|
|
142
|
+
if dialect == "snowflake":
|
|
143
|
+
expr = expr.transform(_keep_try_semantics)
|
|
144
|
+
cap = _Capture()
|
|
145
|
+
logger = logging.getLogger("sqlglot")
|
|
146
|
+
logger.addHandler(cap)
|
|
147
|
+
try:
|
|
148
|
+
spark = expr.sql(dialect="spark", unsupported_level=ErrorLevel.WARN, comments=False)
|
|
149
|
+
finally:
|
|
150
|
+
logger.removeHandler(cap)
|
|
151
|
+
findings: list[Finding] = []
|
|
152
|
+
for msg in dict.fromkeys(cap.messages):
|
|
153
|
+
findings.append(Finding("DROPPED", "review",
|
|
154
|
+
f"SQLGlot could not express part of this in Spark and dropped it: {msg[:160]}. "
|
|
155
|
+
"The result can differ from Snowflake."))
|
|
156
|
+
for pattern, code, message in _OUTPUT_RULES:
|
|
157
|
+
if pattern.search(spark):
|
|
158
|
+
findings.append(Finding(code, "unsupported", message))
|
|
159
|
+
findings.extend(_source_checks(expr))
|
|
160
|
+
try:
|
|
161
|
+
sqlglot.parse_one(spark, read="spark")
|
|
162
|
+
except ParseError as exc:
|
|
163
|
+
findings.append(Finding("SPARK_PARSE", "unsupported", f"output does not parse as Spark SQL: {str(exc)[:160]}"))
|
|
164
|
+
if any(f.severity == "unsupported" for f in findings):
|
|
165
|
+
status = UNSUPPORTED
|
|
166
|
+
elif findings:
|
|
167
|
+
status = REVIEW
|
|
168
|
+
else:
|
|
169
|
+
status = CONVERTED
|
|
170
|
+
return Result(sql, spark, status, findings)
|
sql2iceberg/detect.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Find SQL statements in .sql files, Python files and notebooks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import sqlglot
|
|
11
|
+
from sqlglot.errors import ParseError
|
|
12
|
+
|
|
13
|
+
# Calls whose first argument is usually SQL text.
|
|
14
|
+
SQL_CALLS = {"execute", "executemany", "execute_string", "sql", "read_sql", "read_sql_query"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Statement:
|
|
19
|
+
id: str
|
|
20
|
+
file: str
|
|
21
|
+
line: int
|
|
22
|
+
sql: str
|
|
23
|
+
dynamic: bool = False # built from variables: needs parameters, not converted
|
|
24
|
+
parse_error: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _split(sql: str, dialect: str = "snowflake") -> list[str]:
|
|
28
|
+
"""Split a SQL script into statements with the source dialect parser."""
|
|
29
|
+
out = []
|
|
30
|
+
for expr in sqlglot.parse(sql, read=dialect):
|
|
31
|
+
if expr is not None:
|
|
32
|
+
out.append(expr.sql(dialect=dialect, comments=False))
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def detect_sql_text(text: str, file: str = "<text>", first_line: int = 1,
|
|
37
|
+
dialect: str = "snowflake") -> list[Statement]:
|
|
38
|
+
statements: list[Statement] = []
|
|
39
|
+
try:
|
|
40
|
+
parts = _split(text, dialect)
|
|
41
|
+
except ParseError as exc:
|
|
42
|
+
return [Statement(f"{file}:{first_line}", file, first_line, text.strip(), parse_error=str(exc)[:200])]
|
|
43
|
+
for i, sql in enumerate(parts, 1):
|
|
44
|
+
statements.append(Statement(f"{file}:{first_line}#{i}", file, first_line, sql))
|
|
45
|
+
return statements
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def detect_python(source: str, file: str = "<python>", dialect: str = "snowflake") -> list[Statement]:
|
|
49
|
+
"""Find string literals passed to cursor.execute(), spark.sql() and similar calls."""
|
|
50
|
+
found: list[Statement] = []
|
|
51
|
+
try:
|
|
52
|
+
tree = ast.parse(source)
|
|
53
|
+
except SyntaxError as exc:
|
|
54
|
+
return [Statement(f"{file}:1", file, 1, "", parse_error=f"python syntax error: {exc.msg}")]
|
|
55
|
+
for node in ast.walk(tree):
|
|
56
|
+
if not isinstance(node, ast.Call) or not node.args:
|
|
57
|
+
continue
|
|
58
|
+
func = node.func
|
|
59
|
+
name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "")
|
|
60
|
+
if name not in SQL_CALLS:
|
|
61
|
+
continue
|
|
62
|
+
arg = node.args[0]
|
|
63
|
+
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
|
|
64
|
+
found.extend(detect_sql_text(arg.value, file, arg.lineno, dialect))
|
|
65
|
+
elif isinstance(arg, (ast.JoinedStr, ast.BinOp)):
|
|
66
|
+
found.append(Statement(f"{file}:{arg.lineno}", file, arg.lineno, "", dynamic=True))
|
|
67
|
+
return found
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def detect_notebook(path: Path, dialect: str = "snowflake") -> list[Statement]:
|
|
71
|
+
nb = json.loads(path.read_text(encoding="utf-8"))
|
|
72
|
+
found: list[Statement] = []
|
|
73
|
+
for n, cell in enumerate(nb.get("cells", []), 1):
|
|
74
|
+
if cell.get("cell_type") != "code":
|
|
75
|
+
continue
|
|
76
|
+
src = "".join(cell.get("source", []))
|
|
77
|
+
src = "\n".join(l for l in src.splitlines() if not l.lstrip().startswith(("%", "!")))
|
|
78
|
+
found.extend(detect_python(src, f"{path}#cell{n}", dialect))
|
|
79
|
+
return found
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def detect_path(path: str | Path, dialect: str = "snowflake") -> list[Statement]:
|
|
83
|
+
"""Detect SQL in a file or in every supported file under a folder."""
|
|
84
|
+
p = Path(path)
|
|
85
|
+
files = sorted(f for f in p.rglob("*") if f.is_file()) if p.is_dir() else [p]
|
|
86
|
+
found: list[Statement] = []
|
|
87
|
+
for f in files:
|
|
88
|
+
suffix = f.suffix.lower()
|
|
89
|
+
if suffix == ".sql":
|
|
90
|
+
found.extend(detect_sql_text(f.read_text(encoding="utf-8"), str(f), dialect=dialect))
|
|
91
|
+
elif suffix == ".py":
|
|
92
|
+
found.extend(detect_python(f.read_text(encoding="utf-8"), str(f), dialect))
|
|
93
|
+
elif suffix == ".ipynb":
|
|
94
|
+
found.extend(detect_notebook(f, dialect))
|
|
95
|
+
return found
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""``sql2iceberg doctor``: check the local environment, and build a pre-filled GitHub issue link.
|
|
2
|
+
|
|
3
|
+
Only names and versions are collected: never values of environment variables, never credentials.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import importlib.metadata as md
|
|
9
|
+
import os
|
|
10
|
+
import platform
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from urllib.parse import quote
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
|
|
20
|
+
REPO = "jey1987-cmd/sql2iceberg"
|
|
21
|
+
ISSUES_URL = f"https://github.com/{REPO}/issues"
|
|
22
|
+
|
|
23
|
+
# (distribution name, needed for)
|
|
24
|
+
PACKAGES = [
|
|
25
|
+
("sqlglot", "core: SQL parsing and conversion"),
|
|
26
|
+
("pyspark", "Spark jobs, verify, tests (extra: spark)"),
|
|
27
|
+
("pytest", "pytest plugin (extra: pytest)"),
|
|
28
|
+
("chispa", "DataFrame comparison in tests (extra: pytest)"),
|
|
29
|
+
("snowflake-connector-python", "Snowflake connector (extra: snowflake)"),
|
|
30
|
+
("psycopg2-binary", "Postgres connector, experimental (extra: postgres)"),
|
|
31
|
+
("pymysql", "MySQL connector, experimental (extra: mysql)"),
|
|
32
|
+
("pyodbc", "SQL Server connector, experimental (extra: mssql)"),
|
|
33
|
+
("oracledb", "Oracle connector, experimental (extra: oracle)"),
|
|
34
|
+
("boto3", "AWS Glue/Athena publisher (extra: aws)"),
|
|
35
|
+
("google-cloud-bigquery", "BigQuery publisher (extra: gcp)"),
|
|
36
|
+
("python-dotenv", "optional: load a .env file in your own scripts"),
|
|
37
|
+
]
|
|
38
|
+
ENV_NAMES = [
|
|
39
|
+
"SNOWFLAKE_ACCOUNT", "SNOWFLAKE_USER", "SNOWFLAKE_PASSWORD", "SNOWFLAKE_PRIVATE_KEY_PATH",
|
|
40
|
+
"SNOWFLAKE_AUTHENTICATOR", "SNOWFLAKE_WAREHOUSE", "AWS_PROFILE", "AWS_REGION", "AWS_CA_BUNDLE",
|
|
41
|
+
"GOOGLE_APPLICATION_CREDENTIALS", "JAVA_HOME", "SPARK_HOME", "JDK_JAVA_OPTIONS", "JAVA_TOOL_OPTIONS",
|
|
42
|
+
"SQL2ICEBERG_TRACE", "SQL2ICE_ICEBERG",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _java_version(java: str) -> str | None:
|
|
47
|
+
try:
|
|
48
|
+
out = subprocess.run([java, "-version"], capture_output=True, text=True, timeout=15, check=False)
|
|
49
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
50
|
+
return None
|
|
51
|
+
m = re.search(r'version "([^"]+)"', out.stderr + out.stdout)
|
|
52
|
+
return m.group(1) if m else None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _major(v: str | None) -> int | None:
|
|
56
|
+
if not v:
|
|
57
|
+
return None
|
|
58
|
+
parts = v.split(".")
|
|
59
|
+
return int(parts[1]) if parts[0] == "1" and len(parts) > 1 else int(re.match(r"\d+", parts[0]).group())
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def collect() -> dict:
|
|
63
|
+
"""Return {"info": {...}, "checks": [(level, message)]}. Level is OK, WARN or FAIL."""
|
|
64
|
+
checks: list[tuple[str, str]] = []
|
|
65
|
+
info: dict = {"sql2iceberg": __version__, "python": sys.version.split()[0],
|
|
66
|
+
"os": f"{platform.system()} {platform.release()}"}
|
|
67
|
+
pkgs = {}
|
|
68
|
+
for dist, _ in PACKAGES:
|
|
69
|
+
try:
|
|
70
|
+
pkgs[dist] = md.version(dist)
|
|
71
|
+
except md.PackageNotFoundError:
|
|
72
|
+
pkgs[dist] = None
|
|
73
|
+
info["packages"] = pkgs
|
|
74
|
+
# Java and Spark
|
|
75
|
+
on_path = shutil.which("java")
|
|
76
|
+
home = os.environ.get("JAVA_HOME")
|
|
77
|
+
path_v = _java_version(on_path) if on_path else None
|
|
78
|
+
home_java = str(Path(home) / "bin" / "java") if home else None
|
|
79
|
+
home_v = _java_version(home_java) if home_java and Path(home_java + (".exe" if os.name == "nt" else "")).exists() else None
|
|
80
|
+
info["java_on_path"], info["java_home_version"] = path_v, home_v
|
|
81
|
+
if pkgs.get("pyspark"):
|
|
82
|
+
if not (path_v or home_v):
|
|
83
|
+
checks.append(("FAIL", "pyspark is installed but no Java was found. Install JDK 17 and set JAVA_HOME."))
|
|
84
|
+
for label, v in (("PATH", path_v), ("JAVA_HOME", home_v)):
|
|
85
|
+
m = _major(v)
|
|
86
|
+
if m is not None and m not in (17, 21) and pkgs["pyspark"].startswith("4"):
|
|
87
|
+
checks.append(("WARN", f"Java on {label} is {v}; PySpark 4 needs Java 17 or 21."))
|
|
88
|
+
elif m is not None and m not in (8, 11, 17) and pkgs["pyspark"].startswith("3.5"):
|
|
89
|
+
checks.append(("WARN", f"Java on {label} is {v}; PySpark 3.5 supports Java 8, 11 and 17."))
|
|
90
|
+
if path_v and home_v and _major(path_v) != _major(home_v):
|
|
91
|
+
checks.append(("WARN", (f"JAVA_HOME ({home_v}) differs from java on PATH ({path_v}). Spark uses JAVA_HOME. "
|
|
92
|
+
"See docs/troubleshooting.md: UnsupportedClassVersionError.")))
|
|
93
|
+
sh = os.environ.get("SPARK_HOME")
|
|
94
|
+
if sh and pkgs.get("pyspark") and "site-packages" not in sh.replace("\\", "/"):
|
|
95
|
+
checks.append(("WARN", ("SPARK_HOME points outside the pip-installed PySpark and will override it. "
|
|
96
|
+
"Unset it (docs/troubleshooting.md: wrong Spark version).")))
|
|
97
|
+
# network trust
|
|
98
|
+
if os.environ.get("AWS_CA_BUNDLE") or os.environ.get("REQUESTS_CA_BUNDLE"):
|
|
99
|
+
checks.append(("OK", ("A custom CA bundle is set for Python/AWS. Java needs its own truststore "
|
|
100
|
+
"(docs/troubleshooting.md: certificate_unknown).")))
|
|
101
|
+
# jars
|
|
102
|
+
jars = sorted(p.name for p in Path("jars").glob("*.jar")) if Path("jars").is_dir() else []
|
|
103
|
+
info["local_jars"] = jars
|
|
104
|
+
info["env_set"] = [n for n in ENV_NAMES if os.environ.get(n)]
|
|
105
|
+
if not checks:
|
|
106
|
+
checks.append(("OK", "No problems found"))
|
|
107
|
+
return {"info": info, "checks": checks}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def to_markdown(result: dict) -> str:
|
|
111
|
+
i = result["info"]
|
|
112
|
+
lines = [f"sql2iceberg {i['sql2iceberg']} | Python {i['python']} | {i['os']}",
|
|
113
|
+
f"Java on PATH: {i['java_on_path']} | JAVA_HOME java: {i['java_home_version']}", "", "Packages:"]
|
|
114
|
+
lines += [f"- {k}: {v or 'not installed'}" for k, v in i["packages"].items()]
|
|
115
|
+
lines += ["", "Local jars: " + (", ".join(i["local_jars"]) or "none"),
|
|
116
|
+
"Environment variables set (names only): " + (", ".join(i["env_set"]) or "none"), "", "Checks:"]
|
|
117
|
+
lines += [f"- [{lvl}] {msg}" for lvl, msg in result["checks"]]
|
|
118
|
+
return "\n".join(lines) + "\n"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def issue_url(title: str = "", template: str = "bug_report.yml") -> str:
|
|
122
|
+
body = "```\n" + to_markdown(collect()) + "```\n"
|
|
123
|
+
return (f"{ISSUES_URL}/new?template={quote(template)}&title={quote(title)}"
|
|
124
|
+
f"&environment={quote(body)}")
|
sql2iceberg/generate.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Generate Iceberg table DDL (Spark, format version 3) and Snowflake-managed Iceberg DDL.
|
|
2
|
+
|
|
3
|
+
Snowflake syntax follows docs.snowflake.com "CREATE ICEBERG TABLE (Snowflake as the Iceberg catalog)".
|
|
4
|
+
Type notes below are conservative guesses to review, not verified against a live account.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sqlglot
|
|
10
|
+
from sqlglot import exp
|
|
11
|
+
|
|
12
|
+
from .convert import UNSUPPORTED, convert_statement
|
|
13
|
+
|
|
14
|
+
# Spark 3.5 has no VARIANT/OBJECT type and rejects a bare ARRAY.
|
|
15
|
+
_SPARK_TYPE = {
|
|
16
|
+
"VARIANT": "STRING", "OBJECT": "MAP<STRING,STRING>", "ARRAY": "ARRAY<STRING>",
|
|
17
|
+
"MAP": "MAP<STRING,STRING>",
|
|
18
|
+
}
|
|
19
|
+
_SNOW_REVIEW = {
|
|
20
|
+
"VARIANT": "VARIANT: check the Snowflake Iceberg data-types page; may need a structured type or VARCHAR",
|
|
21
|
+
"OBJECT": "OBJECT: Iceberg tables need a structured OBJECT(...) definition",
|
|
22
|
+
"ARRAY": "ARRAY: Iceberg tables need a typed ARRAY(<type>)",
|
|
23
|
+
"TIMESTAMP_TZ": "TIMESTAMP_TZ: check whether Iceberg tables accept it; TIMESTAMP_LTZ may be required",
|
|
24
|
+
"FLOAT": "FLOAT/DOUBLE: allowed as data, but not as a primary key",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _create_tables(ddl_sql: str) -> list[exp.Create]:
|
|
29
|
+
out = []
|
|
30
|
+
for e in sqlglot.parse(ddl_sql, read="snowflake"):
|
|
31
|
+
if isinstance(e, exp.Create) and (e.args.get("kind") or "").upper() == "TABLE" and not e.expression:
|
|
32
|
+
out.append(e)
|
|
33
|
+
return out
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _columns(create: exp.Create) -> tuple[str, list[exp.ColumnDef]]:
|
|
37
|
+
schema = create.this
|
|
38
|
+
table = schema.this
|
|
39
|
+
name = ".".join(p.name for p in (table.args.get("db"), table.this) if p)
|
|
40
|
+
return name, [c for c in schema.expressions if isinstance(c, exp.ColumnDef)]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _spark_type(col: exp.ColumnDef, warnings: list[str], table: str) -> str:
|
|
44
|
+
dt: exp.DataType = col.args["kind"]
|
|
45
|
+
key = dt.this.name if hasattr(dt.this, "name") else str(dt.this)
|
|
46
|
+
if key in _SPARK_TYPE and not dt.expressions:
|
|
47
|
+
warnings.append(f"{table}.{col.name}: {key} stored as {_SPARK_TYPE[key]} (Spark 3.5 has no {key})")
|
|
48
|
+
return _SPARK_TYPE[key]
|
|
49
|
+
return dt.sql(dialect="spark")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def spark_iceberg_ddl(ddl_sql: str, catalog: str = "lake", namespace: str = "db",
|
|
53
|
+
format_version: int = 3) -> tuple[str, list[str]]:
|
|
54
|
+
warnings: list[str] = []
|
|
55
|
+
stmts = []
|
|
56
|
+
for c in _create_tables(ddl_sql):
|
|
57
|
+
name, cols = _columns(c)
|
|
58
|
+
body = ",\n ".join(f"{col.name} {_spark_type(col, warnings, name)}" for col in cols)
|
|
59
|
+
stmts.append(
|
|
60
|
+
f"CREATE TABLE IF NOT EXISTS {catalog}.{namespace}.{name.split('.')[-1]} (\n {body}\n)\n"
|
|
61
|
+
f"USING iceberg\nTBLPROPERTIES ('format-version'='{format_version}');"
|
|
62
|
+
)
|
|
63
|
+
return "\n\n".join(stmts) + "\n", warnings
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def snowflake_iceberg_ddl(ddl_sql: str, external_volume: str | None = None, base_prefix: str = "",
|
|
67
|
+
iceberg_version: int = 3) -> tuple[str, list[str]]:
|
|
68
|
+
"""Snowflake-managed Iceberg tables (CATALOG = 'SNOWFLAKE'). Run in your own account."""
|
|
69
|
+
warnings: list[str] = []
|
|
70
|
+
stmts = []
|
|
71
|
+
for c in _create_tables(ddl_sql):
|
|
72
|
+
name, cols = _columns(c)
|
|
73
|
+
lines = []
|
|
74
|
+
for col in cols:
|
|
75
|
+
dt = col.args["kind"]
|
|
76
|
+
key = dt.this.name if hasattr(dt.this, "name") else str(dt.this)
|
|
77
|
+
if key in _SNOW_REVIEW:
|
|
78
|
+
warnings.append(f"{name}.{col.name}: {_SNOW_REVIEW[key]}")
|
|
79
|
+
lines.append(f"{col.name} {dt.sql(dialect='snowflake')}")
|
|
80
|
+
opts = []
|
|
81
|
+
if external_volume:
|
|
82
|
+
opts.append(f"EXTERNAL_VOLUME = '{external_volume}'")
|
|
83
|
+
opts += ["CATALOG = 'SNOWFLAKE'", f"BASE_LOCATION = '{base_prefix}{name.split('.')[-1].lower()}/'",
|
|
84
|
+
f"ICEBERG_VERSION = {iceberg_version}"]
|
|
85
|
+
stmts.append(f"CREATE ICEBERG TABLE IF NOT EXISTS {name} (\n " + ",\n ".join(lines) + ")\n "
|
|
86
|
+
+ "\n ".join(opts) + ";")
|
|
87
|
+
return "\n\n".join(stmts) + "\n", warnings
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def pyspark_script(statements, catalog: str = "lake", namespace: str = "db") -> str:
|
|
91
|
+
"""A PySpark script with each converted statement as spark.sql(...). Unsupported ones are commented out."""
|
|
92
|
+
out = [
|
|
93
|
+
'"""Generated by sql2iceberg. Review every statement; none has been run against your data."""',
|
|
94
|
+
"from pyspark.sql import SparkSession", "",
|
|
95
|
+
"spark = SparkSession.builder.getOrCreate()",
|
|
96
|
+
f'spark.sql("USE {catalog}.{namespace}")', "",
|
|
97
|
+
]
|
|
98
|
+
for s in statements:
|
|
99
|
+
if s.dynamic or s.parse_error:
|
|
100
|
+
out.append(f"# {s.id}: skipped ({'built from variables' if s.dynamic else 'could not parse'})")
|
|
101
|
+
continue
|
|
102
|
+
r = convert_statement(s.sql)
|
|
103
|
+
out.append(f"# {s.id} [{r.status}]")
|
|
104
|
+
for f in r.findings:
|
|
105
|
+
out.append(f"# {f.code}: {f.message}")
|
|
106
|
+
if r.status == UNSUPPORTED or r.spark_sql is None:
|
|
107
|
+
out.append("# NOT GENERATED: rewrite by hand")
|
|
108
|
+
out.append("# " + (r.spark_sql or s.sql).replace("\n", "\n# "))
|
|
109
|
+
else:
|
|
110
|
+
out.append(f'spark.sql("""{r.spark_sql}""")')
|
|
111
|
+
out.append("")
|
|
112
|
+
return "\n".join(out)
|
sql2iceberg/lineage.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Map the inputs and outputs of each SQL statement (which tables it reads and writes)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlglot
|
|
6
|
+
from sqlglot import exp
|
|
7
|
+
from sqlglot.errors import ParseError
|
|
8
|
+
|
|
9
|
+
_WRITERS = (exp.Create, exp.Insert, exp.Merge, exp.Delete, exp.Update)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _name(t: exp.Table) -> str:
|
|
13
|
+
return ".".join(p.name for p in (t.args.get("catalog"), t.args.get("db"), t.this) if p)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def map_statement(sql: str) -> dict:
|
|
17
|
+
"""Return {kind, outputs, inputs} for one statement. Table names are as written."""
|
|
18
|
+
try:
|
|
19
|
+
tree = sqlglot.parse_one(sql, read="snowflake")
|
|
20
|
+
except ParseError as exc:
|
|
21
|
+
return {"kind": "unparsed", "outputs": [], "inputs": [], "error": str(exc)[:160]}
|
|
22
|
+
kind = type(tree).__name__.upper()
|
|
23
|
+
outputs: list[str] = []
|
|
24
|
+
if isinstance(tree, _WRITERS):
|
|
25
|
+
target = tree.this.this if isinstance(tree, exp.Create) and isinstance(tree.this, exp.Schema) else tree.this
|
|
26
|
+
if isinstance(target, exp.Table):
|
|
27
|
+
outputs.append(_name(target))
|
|
28
|
+
ctes = {c.alias for c in tree.find_all(exp.CTE)}
|
|
29
|
+
target_ids = {id(tree.this)} | {id(tree.this.this)} if isinstance(tree, _WRITERS) else set()
|
|
30
|
+
inputs: list[str] = []
|
|
31
|
+
for t in tree.find_all(exp.Table):
|
|
32
|
+
n = _name(t)
|
|
33
|
+
if not n or n in ctes or id(t) in target_ids:
|
|
34
|
+
continue
|
|
35
|
+
if isinstance(t.this, exp.Anonymous) or not isinstance(t.this, exp.Identifier):
|
|
36
|
+
continue # table functions such as GENERATOR(...)
|
|
37
|
+
if n not in inputs:
|
|
38
|
+
inputs.append(n)
|
|
39
|
+
return {"kind": kind, "outputs": outputs, "inputs": inputs}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def map_statements(statements) -> dict:
|
|
43
|
+
"""Build a lineage map over detected statements: per statement plus per table."""
|
|
44
|
+
items, tables = [], {}
|
|
45
|
+
for s in statements:
|
|
46
|
+
if s.dynamic or s.parse_error:
|
|
47
|
+
items.append({"id": s.id, "kind": "dynamic" if s.dynamic else "unparsed", "inputs": [], "outputs": []})
|
|
48
|
+
continue
|
|
49
|
+
m = map_statement(s.sql)
|
|
50
|
+
items.append({"id": s.id, **m})
|
|
51
|
+
for n in m["inputs"]:
|
|
52
|
+
tables.setdefault(n, {"read_by": [], "written_by": []})["read_by"].append(s.id)
|
|
53
|
+
for n in m["outputs"]:
|
|
54
|
+
tables.setdefault(n, {"read_by": [], "written_by": []})["written_by"].append(s.id)
|
|
55
|
+
# external = read but never written inside the scanned code: these need to exist before the run
|
|
56
|
+
sources = sorted(n for n, v in tables.items() if v["read_by"] and not v["written_by"])
|
|
57
|
+
targets = sorted(n for n, v in tables.items() if v["written_by"])
|
|
58
|
+
return {"statements": items, "tables": tables, "source_tables": sources, "target_tables": targets}
|
sql2iceberg/platforms.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Work out which SQL platform a code base targets, from imports, connection strings and env vars."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
# platform -> (strong signals: drivers/URLs, weight 3; weak signals: SQL syntax, weight 1)
|
|
11
|
+
_STRONG: dict[str, list[str]] = {
|
|
12
|
+
"snowflake": [r"snowflake\.connector", r"snowflake\.snowpark", r"snowflakecomputing\.com"],
|
|
13
|
+
"postgres": [r"psycopg2?", r"asyncpg", r"postgres(ql)?://"],
|
|
14
|
+
"mysql": [r"pymysql", r"mysql\.connector", r"mysqlclient", r"mysql://"],
|
|
15
|
+
"mssql": [r"pyodbc", r"pymssql", r"mssql\+"],
|
|
16
|
+
"oracle": [r"oracledb", r"cx_Oracle"],
|
|
17
|
+
}
|
|
18
|
+
_WEAK: dict[str, list[str]] = {
|
|
19
|
+
"snowflake": [r"\bQUALIFY\b", r"\bIFF\s*\(", r"\bLATERAL\s+FLATTEN\b", r"\bVARIANT\b"],
|
|
20
|
+
"postgres": [r"\bRETURNING\b", r"::\s*jsonb"],
|
|
21
|
+
"mysql": [r"\bAUTO_INCREMENT\b"],
|
|
22
|
+
"mssql": [r"\bNOLOCK\b", r"\bTOP\s+\d+\b", r"\bGETDATE\s*\("],
|
|
23
|
+
"oracle": [r"\bROWNUM\b", r"\bSYSDATE\b", r"\bNVL\s*\(", r"\bFROM\s+DUAL\b"],
|
|
24
|
+
}
|
|
25
|
+
_ENV_SIGNALS = {
|
|
26
|
+
"snowflake": "SNOWFLAKE_ACCOUNT", "postgres": "PGHOST", "mysql": "MYSQL_HOST",
|
|
27
|
+
"mssql": "MSSQL_SERVER", "oracle": "ORACLE_DSN",
|
|
28
|
+
}
|
|
29
|
+
_SUFFIXES = {".py", ".sql", ".ipynb", ".yml", ".yaml", ".toml", ".cfg", ".ini", ".json"}
|
|
30
|
+
_SKIP_DIRS = {".venv", ".git", "node_modules", "__pycache__"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def detect_platform(path: str | Path, env: dict[str, str] | None = None) -> dict:
|
|
34
|
+
"""Return ``{"platform": name | None, "scores": {...}, "evidence": [...]}``.
|
|
35
|
+
|
|
36
|
+
Driver imports and connection strings count 3, SQL-syntax hints 1, a set env var 2.
|
|
37
|
+
A tie or no signal gives ``platform=None``: the caller must then ask the user.
|
|
38
|
+
"""
|
|
39
|
+
env = os.environ if env is None else env
|
|
40
|
+
p = Path(path)
|
|
41
|
+
files = [f for f in p.rglob("*") if f.is_file()] if p.is_dir() else [p]
|
|
42
|
+
scores: Counter = Counter()
|
|
43
|
+
evidence: list[str] = []
|
|
44
|
+
for f in files:
|
|
45
|
+
if f.suffix.lower() not in _SUFFIXES or _SKIP_DIRS & set(f.parts):
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
text = f.read_text(encoding="utf-8", errors="ignore")
|
|
49
|
+
except OSError:
|
|
50
|
+
continue
|
|
51
|
+
for table, weight in ((_STRONG, 3), (_WEAK, 1)):
|
|
52
|
+
for platform, patterns in table.items():
|
|
53
|
+
for pat in patterns:
|
|
54
|
+
if re.search(pat, text, re.IGNORECASE):
|
|
55
|
+
scores[platform] += weight
|
|
56
|
+
evidence.append(f"{f.name}: {platform} ~ {pat}")
|
|
57
|
+
for platform, var in _ENV_SIGNALS.items():
|
|
58
|
+
if env.get(var):
|
|
59
|
+
scores[platform] += 2
|
|
60
|
+
evidence.append(f"env {var} is set")
|
|
61
|
+
ranked = scores.most_common(2)
|
|
62
|
+
best = ranked[0][0] if ranked and (len(ranked) == 1 or ranked[0][1] > ranked[1][1]) else None
|
|
63
|
+
return {"platform": best, "scores": dict(scores), "evidence": evidence[:20]}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Expose Iceberg tables to Snowflake, AWS Glue/Athena and BigQuery. Plans first; changes accounts only on apply."""
|
|
2
|
+
|
|
3
|
+
from .aws import GluePublisher, GlueTarget
|
|
4
|
+
from .base import Action, IcebergTable, Publisher, PublishResult
|
|
5
|
+
from .gcp import BigQueryPublisher, BigQueryTarget
|
|
6
|
+
from .metadata import (
|
|
7
|
+
latest_local_metadata,
|
|
8
|
+
metadata_from_spark,
|
|
9
|
+
tables_from_jdbc_catalog,
|
|
10
|
+
tables_from_spark,
|
|
11
|
+
)
|
|
12
|
+
from .snowflake import SnowflakePublisher, SnowflakeTarget
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Action", "BigQueryPublisher", "BigQueryTarget", "GluePublisher", "GlueTarget", "IcebergTable",
|
|
16
|
+
"PublishResult", "Publisher", "SnowflakePublisher", "SnowflakeTarget",
|
|
17
|
+
"latest_local_metadata", "metadata_from_spark", "tables_from_jdbc_catalog", "tables_from_spark",
|
|
18
|
+
]
|