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/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)}")
@@ -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}
@@ -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
+ ]