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/validate.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Validate a migration: compare row counts, per-column non-null counts and numeric sums across platforms.
|
|
2
|
+
|
|
3
|
+
Each platform is a ``Target``: a name, a function that runs SQL and returns rows, and a map from the logical
|
|
4
|
+
table name to that platform's qualified name. One of the targets is the reference (usually the source system).
|
|
5
|
+
Nothing here connects anywhere by itself; ``snowflake_runner``, ``athena_runner`` and ``bigquery_runner`` are
|
|
6
|
+
thin adapters over clients you already have.
|
|
7
|
+
|
|
8
|
+
What it proves: same row count, same count of non-null values per column, same sum of each numeric column
|
|
9
|
+
(within a tolerance). What it does not prove: row-by-row equality. For that use ``sql2iceberg verify`` on a case
|
|
10
|
+
folder, or compare a sample you export yourself.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
|
|
19
|
+
from .tracing import span
|
|
20
|
+
|
|
21
|
+
#: how each platform spells a floating-point cast
|
|
22
|
+
CASTS = {"snowflake": "FLOAT", "spark": "DOUBLE", "athena": "DOUBLE", "bigquery": "FLOAT64", "sqlite": "REAL"}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Target:
|
|
27
|
+
name: str
|
|
28
|
+
run: Callable[[str], list[tuple]]
|
|
29
|
+
tables: dict[str, str] # logical name -> qualified name on this platform
|
|
30
|
+
cast: str = "DOUBLE" # floating-point type name, see CASTS
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ColumnSpec:
|
|
35
|
+
name: str
|
|
36
|
+
numeric: bool = False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class CheckResult:
|
|
41
|
+
table: str
|
|
42
|
+
check: str # row_count | non_null:<col> | sum:<col>
|
|
43
|
+
values: dict[str, float | int | None] # platform -> value
|
|
44
|
+
ok: bool
|
|
45
|
+
note: str = ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ValidationReport:
|
|
50
|
+
reference: str
|
|
51
|
+
results: list[CheckResult] = field(default_factory=list)
|
|
52
|
+
errors: list[str] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def ok(self) -> bool:
|
|
56
|
+
return not self.errors and all(r.ok for r in self.results)
|
|
57
|
+
|
|
58
|
+
def to_dict(self) -> dict:
|
|
59
|
+
return {"reference": self.reference, "ok": self.ok, "errors": self.errors,
|
|
60
|
+
"results": [r.__dict__ for r in self.results]}
|
|
61
|
+
|
|
62
|
+
def to_markdown(self) -> str:
|
|
63
|
+
names = list(self.results[0].values) if self.results else []
|
|
64
|
+
head = ["table", "check", *names, "result"]
|
|
65
|
+
out = [f"# Validation (reference: {self.reference}): {'PASS' if self.ok else 'FAIL'}\n",
|
|
66
|
+
"| " + " | ".join(head) + " |", "|" + "---|" * len(head)]
|
|
67
|
+
for r in self.results:
|
|
68
|
+
vals = ["" if r.values.get(n) is None else str(r.values[n]) for n in names]
|
|
69
|
+
out.append("| " + " | ".join([r.table, r.check, *vals, "ok" if r.ok else f"MISMATCH {r.note}"]) + " |")
|
|
70
|
+
out += [f"\nERROR: {e}" for e in self.errors]
|
|
71
|
+
return "\n".join(out) + "\n"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _close(a, b, rel: float, abs_: float) -> bool:
|
|
75
|
+
if a is None or b is None:
|
|
76
|
+
return a is None and b is None
|
|
77
|
+
return math.isclose(float(a), float(b), rel_tol=rel, abs_tol=abs_)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def validate(columns: dict[str, list[ColumnSpec]], reference: Target, others: list[Target], *,
|
|
81
|
+
rel_tol: float = 1e-9, abs_tol: float = 1e-6) -> ValidationReport:
|
|
82
|
+
"""Compare every table in ``columns`` (logical name -> columns to check) across the targets."""
|
|
83
|
+
report = ValidationReport(reference.name)
|
|
84
|
+
targets = [reference, *others]
|
|
85
|
+
for table, cols in columns.items():
|
|
86
|
+
checks = [("row_count", "COUNT(*)", False)]
|
|
87
|
+
for c in cols:
|
|
88
|
+
checks.append((f"non_null:{c.name}", f"COUNT({c.name})", False))
|
|
89
|
+
if c.numeric:
|
|
90
|
+
checks.append((f"sum:{c.name}", f"SUM(CAST({c.name} AS {{cast}}))", True))
|
|
91
|
+
values: dict[str, dict[str, float | None]] = {t.name: {} for t in targets}
|
|
92
|
+
for t in targets:
|
|
93
|
+
qualified = t.tables.get(table)
|
|
94
|
+
if not qualified:
|
|
95
|
+
report.errors.append(f"{t.name}: no table mapped for {table}")
|
|
96
|
+
continue
|
|
97
|
+
# one query per table and platform: every check is a column of the same SELECT
|
|
98
|
+
# zero-padded aliases: clients that return columns sorted by name still give the right order
|
|
99
|
+
exprs = ", ".join(f"{expr.format(cast=t.cast)} AS c{i:03d}" for i, (_, expr, _) in enumerate(checks))
|
|
100
|
+
with span("validate.query", platform=t.name, table=table, checks=len(checks)):
|
|
101
|
+
try:
|
|
102
|
+
rows = t.run(f"SELECT {exprs} FROM {qualified}")
|
|
103
|
+
row = rows[0] if rows else ()
|
|
104
|
+
if len(row) != len(checks):
|
|
105
|
+
raise ValueError(f"expected {len(checks)} columns, got {len(row)}")
|
|
106
|
+
for (label, _, _), v in zip(checks, row, strict=True):
|
|
107
|
+
values[t.name][label] = float(v) if v is not None else None
|
|
108
|
+
except Exception as exc: # noqa: BLE001 report per target, keep checking the others
|
|
109
|
+
report.errors.append(f"{t.name} {table}: {type(exc).__name__}: {str(exc)[:200]}")
|
|
110
|
+
for label, _, is_sum in checks:
|
|
111
|
+
vals = {t.name: values[t.name].get(label) for t in targets}
|
|
112
|
+
ref = vals[reference.name]
|
|
113
|
+
bad = [t.name for t in others if t.name in vals and not _close(
|
|
114
|
+
ref, vals[t.name], rel_tol if is_sum else 0, abs_tol if is_sum else 0)]
|
|
115
|
+
report.results.append(CheckResult(table, label, vals, not bad, f"differs on {', '.join(bad)}" if bad else ""))
|
|
116
|
+
return report
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---- adapters ---------------------------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def snowflake_runner(connector) -> Callable[[str], list[tuple]]:
|
|
122
|
+
return connector.query
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def athena_runner(session, workgroup: str, output: str, database: str | None = None) -> Callable[[str], list[tuple]]:
|
|
126
|
+
import time
|
|
127
|
+
|
|
128
|
+
athena = session.client("athena")
|
|
129
|
+
|
|
130
|
+
def run(sql: str) -> list[tuple]:
|
|
131
|
+
kw = {"QueryExecutionContext": {"Database": database}} if database else {}
|
|
132
|
+
qid = athena.start_query_execution(QueryString=sql, WorkGroup=workgroup,
|
|
133
|
+
ResultConfiguration={"OutputLocation": output}, **kw)["QueryExecutionId"]
|
|
134
|
+
while True:
|
|
135
|
+
st = athena.get_query_execution(QueryExecutionId=qid)["QueryExecution"]["Status"]
|
|
136
|
+
if st["State"] in ("SUCCEEDED", "FAILED", "CANCELLED"):
|
|
137
|
+
break
|
|
138
|
+
time.sleep(1)
|
|
139
|
+
if st["State"] != "SUCCEEDED":
|
|
140
|
+
raise RuntimeError(f"Athena {st['State']}: {st.get('StateChangeReason', '')}")
|
|
141
|
+
rows = athena.get_query_results(QueryExecutionId=qid)["ResultSet"]["Rows"][1:]
|
|
142
|
+
return [tuple(c.get("VarCharValue") for c in r["Data"]) for r in rows]
|
|
143
|
+
|
|
144
|
+
return run
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def bigquery_runner(client) -> Callable[[str], list[tuple]]:
|
|
148
|
+
return lambda sql: [tuple(r) for r in client.query(sql).result()]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# ---- config-file entry point ------------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def _target_from_config(cfg: dict) -> Target:
|
|
154
|
+
kind = cfg["kind"]
|
|
155
|
+
if kind == "snowflake":
|
|
156
|
+
from .connectors import get_connector
|
|
157
|
+
|
|
158
|
+
conn = get_connector("snowflake")
|
|
159
|
+
return Target(cfg.get("name", "snowflake"), snowflake_runner(conn), cfg["tables"], CASTS["snowflake"])
|
|
160
|
+
if kind == "athena":
|
|
161
|
+
import boto3
|
|
162
|
+
|
|
163
|
+
session = boto3.Session(profile_name=cfg.get("profile"), region_name=cfg.get("region"))
|
|
164
|
+
run = athena_runner(session, cfg.get("workgroup", "primary"), cfg["output"], cfg.get("database"))
|
|
165
|
+
return Target(cfg.get("name", "athena"), run, cfg["tables"], CASTS["athena"])
|
|
166
|
+
if kind == "bigquery":
|
|
167
|
+
from google.cloud import bigquery
|
|
168
|
+
|
|
169
|
+
client = bigquery.Client(project=cfg["project"], location=cfg.get("location", "US"))
|
|
170
|
+
return Target(cfg.get("name", "bigquery"), bigquery_runner(client), cfg["tables"], CASTS["bigquery"])
|
|
171
|
+
raise ValueError(f"unknown target kind {kind!r}: use snowflake, athena or bigquery")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def report_from_config(cfg: dict) -> ValidationReport:
|
|
175
|
+
"""Build targets from a plain dict (the CLI's JSON file) and run :func:`validate`.
|
|
176
|
+
|
|
177
|
+
{"tables": {"orders": ["order_id", {"name": "amount", "numeric": true}]},
|
|
178
|
+
"reference": {"kind": "snowflake", "name": "source", "tables": {"orders": "DB.SCH.ORDERS"}},
|
|
179
|
+
"targets": [{"kind": "athena", ...}, {"kind": "bigquery", ...}]}
|
|
180
|
+
"""
|
|
181
|
+
columns = {t: [ColumnSpec(c) if isinstance(c, str) else ColumnSpec(c["name"], c.get("numeric", False))
|
|
182
|
+
for c in cols] for t, cols in cfg["tables"].items()}
|
|
183
|
+
with span("validate", tables=len(columns)):
|
|
184
|
+
return validate(columns, _target_from_config(cfg["reference"]),
|
|
185
|
+
[_target_from_config(t) for t in cfg["targets"]],
|
|
186
|
+
rel_tol=cfg.get("rel_tol", 1e-9), abs_tol=cfg.get("abs_tol", 1e-6))
|
sql2iceberg/verify.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Offline verification of one converted statement.
|
|
2
|
+
|
|
3
|
+
A case folder holds case.json, seed CSV files and an expected-results CSV. The expected CSV is what
|
|
4
|
+
the original Snowflake query returned on the same seed data (export it from your own Snowflake
|
|
5
|
+
account). The converted Spark SQL is run on the seed data and compared with it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .convert import UNPARSED, UNSUPPORTED, convert_statement
|
|
17
|
+
|
|
18
|
+
PASS, FAIL, BLOCKED = "pass", "fail", "blocked"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Verification:
|
|
23
|
+
case: str
|
|
24
|
+
outcome: str # pass | fail | blocked
|
|
25
|
+
conversion_status: str
|
|
26
|
+
spark_sql: str | None = None
|
|
27
|
+
differences: list[str] = field(default_factory=list)
|
|
28
|
+
findings: list[dict] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> dict:
|
|
31
|
+
return self.__dict__.copy()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def make_spark(iceberg: bool = False, warehouse: str | None = None):
|
|
35
|
+
from pyspark.sql import SparkSession
|
|
36
|
+
|
|
37
|
+
# A stray SPARK_HOME points PySpark at a different Spark than the pip package (py4j "does not exist in the JVM").
|
|
38
|
+
os.environ.pop("SPARK_HOME", None)
|
|
39
|
+
builder = (
|
|
40
|
+
SparkSession.builder.master("local[2]")
|
|
41
|
+
.appName("sql2iceberg-verify")
|
|
42
|
+
.config("spark.ui.enabled", "false")
|
|
43
|
+
.config("spark.sql.shuffle.partitions", "2")
|
|
44
|
+
)
|
|
45
|
+
if iceberg:
|
|
46
|
+
pkg = os.environ.get(
|
|
47
|
+
"ICEBERG_PKG", "org.apache.iceberg:iceberg-spark-runtime-3.5_2.12:1.9.2"
|
|
48
|
+
)
|
|
49
|
+
# ICEBERG_JAR: a local runtime jar, for machines where Ivy cannot reach Maven Central (e.g. TLS proxy).
|
|
50
|
+
jar = os.environ.get("ICEBERG_JAR")
|
|
51
|
+
builder = (
|
|
52
|
+
builder.config("spark.jars", jar) if jar else builder.config("spark.jars.packages", pkg)
|
|
53
|
+
)
|
|
54
|
+
builder = (
|
|
55
|
+
builder.config("spark.sql.extensions", "org.apache.iceberg.spark.extensions.IcebergSparkSessionExtensions")
|
|
56
|
+
.config("spark.sql.catalog.lake", "org.apache.iceberg.spark.SparkCatalog")
|
|
57
|
+
.config("spark.sql.catalog.lake.type", "hadoop")
|
|
58
|
+
.config("spark.sql.catalog.lake.warehouse", warehouse or "/tmp/sql2iceberg-warehouse")
|
|
59
|
+
.config("spark.sql.defaultCatalog", "lake")
|
|
60
|
+
)
|
|
61
|
+
spark = builder.getOrCreate()
|
|
62
|
+
spark.sparkContext.setLogLevel("ERROR")
|
|
63
|
+
if iceberg:
|
|
64
|
+
spark.sql("CREATE NAMESPACE IF NOT EXISTS lake.db")
|
|
65
|
+
spark.sql("USE lake.db")
|
|
66
|
+
return spark
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _same(a, b, tol: float) -> bool:
|
|
70
|
+
if a is None or b is None:
|
|
71
|
+
return a is None and b is None
|
|
72
|
+
if isinstance(a, float) or isinstance(b, float):
|
|
73
|
+
try:
|
|
74
|
+
return math.isclose(float(a), float(b), rel_tol=tol, abs_tol=tol)
|
|
75
|
+
except (TypeError, ValueError):
|
|
76
|
+
return False
|
|
77
|
+
return type(a) is type(b) and a == b
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _rows(df) -> list[tuple]:
|
|
81
|
+
return [tuple(r) for r in df.collect()]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _sort_key(row: tuple):
|
|
85
|
+
return tuple((v is None, str(type(v)), str(v)) for v in row)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def verify_case(case_dir: str | Path, spark=None, iceberg: bool = False) -> Verification:
|
|
89
|
+
case_dir = Path(case_dir)
|
|
90
|
+
spec = json.loads((case_dir / "case.json").read_text(encoding="utf-8"))
|
|
91
|
+
name = spec.get("name", case_dir.name)
|
|
92
|
+
result = convert_statement(spec["sql"])
|
|
93
|
+
ver = Verification(
|
|
94
|
+
case=name,
|
|
95
|
+
outcome=BLOCKED,
|
|
96
|
+
conversion_status=result.status,
|
|
97
|
+
spark_sql=result.spark_sql,
|
|
98
|
+
findings=[f.__dict__ for f in result.findings],
|
|
99
|
+
)
|
|
100
|
+
if result.status in (UNSUPPORTED, UNPARSED):
|
|
101
|
+
ver.differences.append("not executed: conversion is " + result.status)
|
|
102
|
+
return ver
|
|
103
|
+
|
|
104
|
+
own_session = spark is None
|
|
105
|
+
spark = spark or make_spark(iceberg)
|
|
106
|
+
try:
|
|
107
|
+
for table, t in spec["tables"].items():
|
|
108
|
+
df = spark.read.csv(str(case_dir / t["csv"]), header=True, schema=t["schema"])
|
|
109
|
+
if iceberg:
|
|
110
|
+
spark.sql(f"DROP TABLE IF EXISTS {table}")
|
|
111
|
+
df.writeTo(table).using("iceberg").create()
|
|
112
|
+
else:
|
|
113
|
+
df.createOrReplaceTempView(table) # no files left behind between runs
|
|
114
|
+
try:
|
|
115
|
+
got = _rows(spark.sql(result.spark_sql))
|
|
116
|
+
except Exception as exc: # noqa: BLE001 - report any Spark failure as a failed verification
|
|
117
|
+
ver.outcome = FAIL
|
|
118
|
+
ver.differences.append(f"Spark failed to run the converted SQL: {str(exc).splitlines()[0][:200]}")
|
|
119
|
+
return ver
|
|
120
|
+
expected = _rows(
|
|
121
|
+
spark.read.csv(str(case_dir / spec["expected"]), header=True, schema=spec["expected_schema"])
|
|
122
|
+
)
|
|
123
|
+
finally:
|
|
124
|
+
if own_session:
|
|
125
|
+
spark.stop()
|
|
126
|
+
|
|
127
|
+
if not spec.get("ordered", False):
|
|
128
|
+
got, expected = sorted(got, key=_sort_key), sorted(expected, key=_sort_key)
|
|
129
|
+
tol = float(spec.get("float_tolerance", 1e-9))
|
|
130
|
+
if len(got) != len(expected):
|
|
131
|
+
ver.differences.append(f"row count: Spark {len(got)} vs expected {len(expected)}")
|
|
132
|
+
for i, (g, e) in enumerate(zip(got, expected)):
|
|
133
|
+
if len(g) != len(e) or not all(_same(x, y, tol) for x, y in zip(g, e)):
|
|
134
|
+
ver.differences.append(f"row {i}: Spark {g!r} vs expected {e!r}")
|
|
135
|
+
if len(ver.differences) >= 6:
|
|
136
|
+
break
|
|
137
|
+
ver.outcome = PASS if not ver.differences else FAIL
|
|
138
|
+
return ver
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sql2iceberg
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convert SQL from Snowflake and other databases to PySpark, persist it as Apache Iceberg tables, expose them to Snowflake, AWS and GCP, and validate the result.
|
|
5
|
+
Project-URL: Homepage, https://github.com/jey1987-cmd/sql2iceberg
|
|
6
|
+
Project-URL: Documentation, https://github.com/jey1987-cmd/sql2iceberg/tree/main/docs
|
|
7
|
+
Project-URL: Issues, https://github.com/jey1987-cmd/sql2iceberg/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/jey1987-cmd/sql2iceberg/blob/main/CHANGELOG.md
|
|
9
|
+
Author: Jeyaraman Ramalingam
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: bigquery,data-engineering,glue,iceberg,migration,pyspark,snowflake,sql,sqlglot
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: Pytest
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Intended Audience :: Information Technology
|
|
17
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Database
|
|
24
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: sqlglot>=30
|
|
27
|
+
Provides-Extra: all
|
|
28
|
+
Requires-Dist: boto3>=1.34; extra == 'all'
|
|
29
|
+
Requires-Dist: google-cloud-bigquery>=3.20; extra == 'all'
|
|
30
|
+
Requires-Dist: pyspark>=3.5; extra == 'all'
|
|
31
|
+
Requires-Dist: snowflake-connector-python>=3.12; extra == 'all'
|
|
32
|
+
Provides-Extra: aws
|
|
33
|
+
Requires-Dist: boto3>=1.34; extra == 'aws'
|
|
34
|
+
Provides-Extra: dev
|
|
35
|
+
Requires-Dist: build; extra == 'dev'
|
|
36
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
37
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
38
|
+
Requires-Dist: twine; extra == 'dev'
|
|
39
|
+
Provides-Extra: gcp
|
|
40
|
+
Requires-Dist: google-cloud-bigquery>=3.20; extra == 'gcp'
|
|
41
|
+
Provides-Extra: mssql
|
|
42
|
+
Requires-Dist: pyodbc>=5; extra == 'mssql'
|
|
43
|
+
Provides-Extra: mysql
|
|
44
|
+
Requires-Dist: pymysql>=1.1; extra == 'mysql'
|
|
45
|
+
Provides-Extra: oracle
|
|
46
|
+
Requires-Dist: oracledb>=2; extra == 'oracle'
|
|
47
|
+
Provides-Extra: postgres
|
|
48
|
+
Requires-Dist: psycopg2-binary>=2.9; extra == 'postgres'
|
|
49
|
+
Provides-Extra: pytest
|
|
50
|
+
Requires-Dist: chispa>=0.10; extra == 'pytest'
|
|
51
|
+
Requires-Dist: pyspark>=3.5; extra == 'pytest'
|
|
52
|
+
Requires-Dist: pytest>=8; extra == 'pytest'
|
|
53
|
+
Provides-Extra: snowflake
|
|
54
|
+
Requires-Dist: snowflake-connector-python>=3.12; extra == 'snowflake'
|
|
55
|
+
Provides-Extra: spark
|
|
56
|
+
Requires-Dist: pyspark>=3.5; extra == 'spark'
|
|
57
|
+
Description-Content-Type: text/markdown
|
|
58
|
+
|
|
59
|
+
# sql2iceberg
|
|
60
|
+
|
|
61
|
+
Take the SQL in your code base, convert it to **PySpark**, persist the result as **Apache Iceberg** tables, expose those
|
|
62
|
+
tables to **Snowflake, AWS (Glue/Athena) and GCP (BigQuery)**, and **validate** the migration.
|
|
63
|
+
|
|
64
|
+
**Status: alpha (0.1.0).** One source platform (Snowflake) and three target platforms were exercised against real
|
|
65
|
+
accounts; everything else is written but untested and labelled so. See [what was tested](docs/environments.md#what-was-tested)
|
|
66
|
+
and [what is not supported yet](#limitations).
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
your code ──detect──▶ SQL ──convert──▶ Spark SQL ──generate──▶ PySpark job ──run──▶ Iceberg on S3 / GCS
|
|
70
|
+
│
|
|
71
|
+
validate ◀──── counts, nulls, sums ────────────────────────┤
|
|
72
|
+
├─▶ Snowflake (external Iceberg table)
|
|
73
|
+
├─▶ AWS Glue / Athena
|
|
74
|
+
└─▶ BigQuery (BigLake)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Core capabilities
|
|
78
|
+
|
|
79
|
+
| # | Capability | Command / API | Status |
|
|
80
|
+
|---|---|---|---|
|
|
81
|
+
| 1 | **Detect** the SQL platform and find SQL in `.sql`, `.py`, `.ipynb` | `detect`, `detect_platform()` | Snowflake tested; other dialects parse via SQLGlot |
|
|
82
|
+
| 2 | **Connect** to the source (you set up authentication first) | `connectors.get_connector()` | Snowflake tested live; Postgres, MySQL, SQL Server, Oracle **experimental** (mock-tested only) |
|
|
83
|
+
| 3 | **Convert** to Spark SQL and flag what cannot be trusted | `convert`, `convert_statement()` | tested |
|
|
84
|
+
| 4 | **Generate a PySpark job**: JDBC read, write Iceberg, run the curation SQL | `pyspark`, `pyspark_pipeline()` | tested end to end (Snowflake to Iceberg on S3 and on GCS) |
|
|
85
|
+
| 5 | **Expose** Iceberg tables to other platforms | `publish`, `publish.*Publisher` | Snowflake, AWS Glue/Athena, BigQuery all tested live |
|
|
86
|
+
| 6 | **Validate** counts, non-null counts and column sums across platforms | `validate`, `validate.validate()` | tested live across four platforms |
|
|
87
|
+
| 7 | **Report**: converted PySpark, plan of changes, validation results (Markdown and JSON) | all commands | tested |
|
|
88
|
+
| 8 | **Trace** every step with timings, secrets redacted | `--trace trace.jsonl`, `tracing.span()` | tested |
|
|
89
|
+
| 9 | **Diagnose** your environment; open a pre-filled GitHub issue | `doctor`, `issue` | tested |
|
|
90
|
+
|
|
91
|
+
Safety defaults: `publish` prints a plan and changes nothing unless you pass `--apply`; credentials are only read from
|
|
92
|
+
environment variables or your cloud profile; generated scripts never contain secrets.
|
|
93
|
+
|
|
94
|
+
## Install
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pip install sql2iceberg # core: detect + convert (needs only sqlglot)
|
|
98
|
+
pip install "sql2iceberg[spark]" # run the generated PySpark jobs locally
|
|
99
|
+
pip install "sql2iceberg[snowflake,aws,gcp]" # connectors and publishers you need
|
|
100
|
+
pip install "sql2iceberg[all]" # spark + snowflake + aws + gcp
|
|
101
|
+
pip install "sql2iceberg[pytest]" # pytest plugin and fixtures
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Extras: `spark`, `snowflake`, `postgres`, `mysql`, `mssql`, `oracle`, `aws`, `gcp`, `pytest`, `all`, `dev`.
|
|
105
|
+
|
|
106
|
+
## Environment dependencies (summary)
|
|
107
|
+
|
|
108
|
+
| Needed for | Requirement |
|
|
109
|
+
|---|---|
|
|
110
|
+
| everything | Python 3.10 or newer |
|
|
111
|
+
| running generated Spark jobs | **Java 17** (Java 21 should work), `pyspark` 3.5 or 4.x. Do not set `SPARK_HOME` to another Spark. |
|
|
112
|
+
| Iceberg tables | Iceberg Spark runtime jar matching your Spark (we used `iceberg-spark-runtime-4.1_2.13-1.12.0`) |
|
|
113
|
+
| Iceberg on S3 / GCS | `iceberg-aws-bundle` / `iceberg-gcp-bundle` jars |
|
|
114
|
+
| reading the source over JDBC | the JDBC driver jar (we used `snowflake-jdbc-3.19.0`) |
|
|
115
|
+
| Snowflake | account, role that can create external volumes, catalog integrations and Iceberg tables; a warehouse |
|
|
116
|
+
| AWS | an AWS profile or environment credentials with S3, Glue and (to count) Athena permissions |
|
|
117
|
+
| GCP | `gcloud auth login`, a project **with billing enabled**, a BigQuery connection to the bucket |
|
|
118
|
+
| behind a TLS-inspecting proxy | a CA bundle for Python and a Java truststore: see [troubleshooting](docs/troubleshooting.md) |
|
|
119
|
+
|
|
120
|
+
Full details, versions we tested and the list of every package and jar: [docs/environments.md](docs/environments.md) and
|
|
121
|
+
[docs/packages.md](docs/packages.md). Run `sql2iceberg doctor` to check your machine.
|
|
122
|
+
|
|
123
|
+
## Quick start
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
# 1. which platform is this code base for, and what SQL is in it?
|
|
127
|
+
sql2iceberg detect path/to/code
|
|
128
|
+
# 2. convert, and see what needs hand work
|
|
129
|
+
sql2iceberg convert path/to/code --out out
|
|
130
|
+
# 3. generate the PySpark job (reads the source over JDBC, writes Iceberg, runs the curation SQL)
|
|
131
|
+
sql2iceberg pyspark path/to/code --out out --platform snowflake \
|
|
132
|
+
--catalog-type jdbc --catalog-uri jdbc:sqlite:catalog.db \
|
|
133
|
+
--warehouse s3://my-bucket/warehouse --namespace live --iceberg-version 2
|
|
134
|
+
# 4. run it (set SNOWFLAKE_USER and SNOWFLAKE_PRIVATE_KEY_PATH or SNOWFLAKE_PASSWORD first)
|
|
135
|
+
spark-submit out/pipeline_pyspark.py
|
|
136
|
+
# 5. expose the tables: read the plan first, then --apply
|
|
137
|
+
sql2iceberg publish publish.json
|
|
138
|
+
sql2iceberg publish publish.json --apply
|
|
139
|
+
# 6. validate across platforms
|
|
140
|
+
sql2iceberg validate validate.json --out out
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Add `--trace trace.jsonl` to any command to record a timed trace of the run.
|
|
144
|
+
Worked examples from real runs (configs, plans, results): [docs/examples.md](docs/examples.md).
|
|
145
|
+
|
|
146
|
+
## Python API
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from sql2iceberg import detect_path, convert_statement
|
|
150
|
+
from sql2iceberg.pyspark_gen import MigrationConfig, pyspark_pipeline
|
|
151
|
+
from sql2iceberg.publish import SnowflakePublisher, SnowflakeTarget, tables_from_jdbc_catalog
|
|
152
|
+
from sql2iceberg.validate import validate, Target, ColumnSpec
|
|
153
|
+
|
|
154
|
+
stmts = detect_path("pipelines/", dialect="snowflake")
|
|
155
|
+
script, report = pyspark_pipeline(stmts, MigrationConfig(platform="snowflake", warehouse="s3://b/wh",
|
|
156
|
+
catalog_type="jdbc", catalog_uri="jdbc:sqlite:catalog.db"))
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Documentation
|
|
160
|
+
|
|
161
|
+
- [Environments and what was tested](docs/environments.md)
|
|
162
|
+
- [Every package, jar and service used in this build](docs/packages.md)
|
|
163
|
+
- [Examples from the live tests](docs/examples.md)
|
|
164
|
+
- [Troubleshooting frequent problems](docs/troubleshooting.md)
|
|
165
|
+
- [Known conversion differences between Snowflake and Spark](docs/known-issues.md)
|
|
166
|
+
- [Roadmap](docs/roadmap.md)
|
|
167
|
+
|
|
168
|
+
## Getting help and reporting problems
|
|
169
|
+
|
|
170
|
+
- Run `sql2iceberg doctor`, then `sql2iceberg issue --title "short summary" --open` to open a GitHub issue pre-filled with
|
|
171
|
+
your environment (names and versions only, never values or credentials).
|
|
172
|
+
- Attach a trace (`--trace trace.jsonl`) after reading it. Secrets are redacted but check anyway.
|
|
173
|
+
- Issues: <https://github.com/jey1987-cmd/sql2iceberg/issues>. Please read [troubleshooting](docs/troubleshooting.md) first.
|
|
174
|
+
- Security problems: do not open a public issue; see [CONTRIBUTING.md](CONTRIBUTING.md#security).
|
|
175
|
+
|
|
176
|
+
## Limitations
|
|
177
|
+
|
|
178
|
+
- Only **Snowflake as a source** and **Snowflake, AWS Glue/Athena, BigQuery as targets** were run against real systems.
|
|
179
|
+
Postgres, MySQL, SQL Server and Oracle connectors, REST/Hive catalogs, Spark 3.5, Azure and Databricks are untested.
|
|
180
|
+
- Tested with Iceberg **format-version 2**. Whether Snowflake and BigQuery read v3 tables is unverified here.
|
|
181
|
+
- BigQuery cannot read an S3 warehouse. The GCP path writes a second Iceberg copy to GCS.
|
|
182
|
+
- Snowflake object-store tables and BigQuery tables point at one metadata file (a snapshot): refresh after new writes.
|
|
183
|
+
- Validation compares counts, non-null counts and sums. It does not compare every row.
|
|
184
|
+
- Tested on Windows 10 with Python 3.13. Linux and macOS should work; CI covers Linux but live tests were not run there.
|
|
185
|
+
- Conversion uses [SQLGlot](https://github.com/tobymao/sqlglot); the converter flags constructs that behave differently in
|
|
186
|
+
Spark (see [known issues](docs/known-issues.md)) but a clean result still needs your own verification.
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
MIT. Not affiliated with Snowflake, the Apache Software Foundation, Amazon or Google. "Iceberg" is a trademark of the ASF.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
sql2iceberg/__init__.py,sha256=stAfaZcxB9EhVvz6fuc8jLRwVBQZ-WR0_4vs_tQ_ncg,395
|
|
2
|
+
sql2iceberg/cli.py,sha256=ruTmNvpwqsTilasCwba0ltQDtkMua3VWb1arJiA4u-E,12343
|
|
3
|
+
sql2iceberg/compare.py,sha256=nXz3yLBNjhouICGVq8URBuZt1gMoVH5dQCw7DrjWni8,3361
|
|
4
|
+
sql2iceberg/convert.py,sha256=HPuQjWu2DIyWq07oDqDE-OmKzt1ufh5ESLXCKedasxg,7587
|
|
5
|
+
sql2iceberg/detect.py,sha256=R4UDXKngHU1O5d-Xw9UK1zdLHR3wuGVtkfCTnE47tZc,3666
|
|
6
|
+
sql2iceberg/diagnostics.py,sha256=G3KSUEHAc5iCdt9F-aepXzfHnUG7nIO6YvCj_2HnUz0,5925
|
|
7
|
+
sql2iceberg/generate.py,sha256=3jc-FB8-xM3QDGv9tEufOvQNubBysNy40kZ7PkX7WDk,5021
|
|
8
|
+
sql2iceberg/lineage.py,sha256=LDeLaxG0QtG6P8CZxjsoe_8J7IhDnDekPYFGo6nq_5U,2643
|
|
9
|
+
sql2iceberg/platforms.py,sha256=4S81gJ4naS0D-A3LZ2T_N6gPMj3RCjGkBCM8Eqt7pCI,2831
|
|
10
|
+
sql2iceberg/pyspark_gen.py,sha256=SX3rRI2ANdypsCmSokrOw1OoVgvhA1qQTzeY8U6oU4s,11465
|
|
11
|
+
sql2iceberg/pytest_plugin.py,sha256=7KbrrSLJ72fvEVFmXahWJOPx7pF7SX3CUTOAJoYKXhA,2767
|
|
12
|
+
sql2iceberg/report.py,sha256=uQ9jxg-0JZkwUAGhe9e_Qb25DYbRKcrucrhYlDpPKDw,1884
|
|
13
|
+
sql2iceberg/tracing.py,sha256=bxm7jnXrc249JAYYZYF1lbrHJmFl7Hb8vFUq9oPwu2o,4467
|
|
14
|
+
sql2iceberg/validate.py,sha256=dRAasPsB84DFITSyDNdvHVKqLpoiwshmXa3xKWpJwQ8,8723
|
|
15
|
+
sql2iceberg/verify.py,sha256=iToMmKL9lPajfJwwGHJn3K5rIr8J3d_-aB_RmFCpcu8,5312
|
|
16
|
+
sql2iceberg/connectors/__init__.py,sha256=aw3Syhw-gEJT5JIJY9K9A2xwLPmD6cWv50m5hHGtptE,727
|
|
17
|
+
sql2iceberg/connectors/base.py,sha256=biiQV8FLvX8Ll_CSE9Rqe5_DODNKbqVynG7Voi5hQbc,4165
|
|
18
|
+
sql2iceberg/connectors/others.py,sha256=RMO82sToQBKEm-Rve5LyA93h0a2jY_Z40D-gQoWGLBg,4853
|
|
19
|
+
sql2iceberg/connectors/snowflake.py,sha256=8sGeKDdwdIp5EzT57J33fS2W64KueQT5iKWHOM0V8rA,1960
|
|
20
|
+
sql2iceberg/publish/__init__.py,sha256=SCSz_iZpfwCRD-HQ-rizo-iNAqSpKumyU3KZWfnqmDY,758
|
|
21
|
+
sql2iceberg/publish/aws.py,sha256=3pWAvLXJat0LBBt8mknj3Dca1YS-7D_1l0cpf1iZjyo,5338
|
|
22
|
+
sql2iceberg/publish/base.py,sha256=zS72f8jNx7pjFuWgkyQnA8Ntf3VsdS0E67gQediE91g,4744
|
|
23
|
+
sql2iceberg/publish/gcp.py,sha256=4KE6ZKK6kWf4TllQvTKEX8j7sdd-WFpsZUWTtebsN0U,3544
|
|
24
|
+
sql2iceberg/publish/metadata.py,sha256=SGCLEOxC3gmzgX2AB00ZR2xIobwHrrEf6qgvN5BI8go,2718
|
|
25
|
+
sql2iceberg/publish/snowflake.py,sha256=uqTdurlDj0uB18Al0zG26xbMifmYG4XE-O5mKOuyJco,7622
|
|
26
|
+
sql2iceberg-0.1.0.dist-info/METADATA,sha256=0qEpHUAZs2Rzz4GMETIJmwC2mPT4G24XE1L_evg2lCI,10776
|
|
27
|
+
sql2iceberg-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
28
|
+
sql2iceberg-0.1.0.dist-info/entry_points.txt,sha256=3oOnpyo7QgckSJtXQOeCA3m-OK1T4Fwmy4oXNroly7w,105
|
|
29
|
+
sql2iceberg-0.1.0.dist-info/licenses/LICENSE,sha256=rqA3WWMGK7lfkrqpbLaJYvH5FUfpnXbkiPakVC8AUOA,1077
|
|
30
|
+
sql2iceberg-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jeyaraman Ramalingam
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|