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/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""sql2iceberg: detect Snowflake SQL, convert it for Spark/Iceberg, verify under pytest."""
|
|
2
|
+
|
|
3
|
+
from .convert import Finding, Result, convert_statement
|
|
4
|
+
from .detect import Statement, detect_path, detect_python, detect_sql_text
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Finding",
|
|
8
|
+
"Result",
|
|
9
|
+
"Statement",
|
|
10
|
+
"convert_statement",
|
|
11
|
+
"detect_path",
|
|
12
|
+
"detect_python",
|
|
13
|
+
"detect_sql_text",
|
|
14
|
+
]
|
|
15
|
+
__version__ = "0.1.0"
|
sql2iceberg/cli.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Command line: sql2iceberg detect | convert | report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import report as rpt
|
|
10
|
+
from .convert import UNPARSED, UNSUPPORTED, convert_statement
|
|
11
|
+
from .detect import detect_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _run(path: str) -> tuple[list, dict]:
|
|
15
|
+
statements = detect_path(path)
|
|
16
|
+
results = {
|
|
17
|
+
s.id: convert_statement(s.sql)
|
|
18
|
+
for s in statements
|
|
19
|
+
if not s.dynamic and not s.parse_error
|
|
20
|
+
}
|
|
21
|
+
return statements, results
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _main(argv: list[str]) -> int:
|
|
25
|
+
parser = argparse.ArgumentParser(prog="sql2iceberg", description=__doc__)
|
|
26
|
+
parser.add_argument("--trace", metavar="FILE", help="write a JSONL trace of this run (secrets are redacted)")
|
|
27
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
28
|
+
sub.add_parser("doctor", help="check Java, Spark, packages and certificates; print what to put in a bug report")
|
|
29
|
+
iss = sub.add_parser("issue", help="print (or open) a GitHub issue link pre-filled with doctor output")
|
|
30
|
+
iss.add_argument("--title", default="")
|
|
31
|
+
iss.add_argument("--open", action="store_true", help="open the link in your browser")
|
|
32
|
+
va = sub.add_parser("validate", help="compare counts, non-null counts and sums across platforms")
|
|
33
|
+
va.add_argument("config", help="JSON file: tables, reference and targets (see docs/examples.md)")
|
|
34
|
+
va.add_argument("--out", help="write validation.md and validation.json here")
|
|
35
|
+
v = sub.add_parser("verify", help="run one case folder: seed, execute converted SQL in Spark, compare")
|
|
36
|
+
v.add_argument("case", help="case folder with case.json, seed CSVs and expected CSV")
|
|
37
|
+
v.add_argument("--iceberg", action="store_true", help="use an Iceberg Hadoop catalog (needs the Iceberg runtime jar)")
|
|
38
|
+
m = sub.add_parser("map", help="map the tables each statement reads and writes")
|
|
39
|
+
m.add_argument("path")
|
|
40
|
+
m.add_argument("--out", help="write lineage.json here")
|
|
41
|
+
g = sub.add_parser("generate", help="write Iceberg v3 Spark DDL, Snowflake-managed Iceberg DDL and a PySpark script")
|
|
42
|
+
g.add_argument("path", help="code folder (statements to convert)")
|
|
43
|
+
g.add_argument("--ddl", help="SQL file or folder with CREATE TABLE statements (default: same path)")
|
|
44
|
+
g.add_argument("--out", required=True)
|
|
45
|
+
g.add_argument("--external-volume", help="Snowflake external volume name (optional)")
|
|
46
|
+
g.add_argument("--iceberg-version", type=int, default=2, choices=(2, 3))
|
|
47
|
+
pg = sub.add_parser("pyspark", help="generate a PySpark script: JDBC ingest to Iceberg, then curation SQL")
|
|
48
|
+
pg.add_argument("path", help="code folder or file (.sql, .py, .ipynb)")
|
|
49
|
+
pg.add_argument("--out", required=True, help="output folder")
|
|
50
|
+
pg.add_argument("--platform", help="source platform (default: detected from the code)")
|
|
51
|
+
pg.add_argument("--catalog", default="lake")
|
|
52
|
+
pg.add_argument("--namespace", default="db")
|
|
53
|
+
pg.add_argument("--catalog-type", default="hadoop", choices=("hadoop", "glue", "rest", "hive"))
|
|
54
|
+
pg.add_argument("--warehouse", default="./warehouse", help="path or s3://bucket/prefix")
|
|
55
|
+
pg.add_argument("--catalog-uri", default="")
|
|
56
|
+
pg.add_argument("--jdbc-url", default="", help="default: built from the platform's environment variables")
|
|
57
|
+
pg.add_argument("--source-schema", default="")
|
|
58
|
+
pg.add_argument("--iceberg-version", type=int, default=2, choices=(2, 3))
|
|
59
|
+
pu = sub.add_parser("publish", help="expose Iceberg tables to snowflake, aws (Glue) or gcp (BigQuery)")
|
|
60
|
+
pu.add_argument("config", help="JSON file: target, target settings and tables (see docs/publish.md)")
|
|
61
|
+
pu.add_argument("--apply", action="store_true", help="really change the target account (default: print the plan)")
|
|
62
|
+
pu.add_argument("--out", help="write publish_plan.md here")
|
|
63
|
+
c = sub.add_parser("compare", help="compare row counts and estimate cost")
|
|
64
|
+
c.add_argument("--snowflake-counts", required=True, help="CSV with table_name,row_count from Snowflake")
|
|
65
|
+
c.add_argument("--iceberg-counts", required=True, help="CSV with table_name,row_count from Iceberg/Spark")
|
|
66
|
+
c.add_argument("--snowflake-credits", type=float)
|
|
67
|
+
c.add_argument("--credit-price", type=float, help="your price per credit")
|
|
68
|
+
c.add_argument("--spark-seconds", type=float)
|
|
69
|
+
c.add_argument("--spark-hourly-rate", type=float, help="your cost per hour of the Spark cluster")
|
|
70
|
+
c.add_argument("--out")
|
|
71
|
+
q = sub.add_parser("sql", help="print SQL to run in Snowflake: row counts or warehouse credits")
|
|
72
|
+
q.add_argument("--counts", nargs="+", metavar="TABLE")
|
|
73
|
+
q.add_argument("--credits", nargs=3, metavar=("WAREHOUSE", "START", "END"))
|
|
74
|
+
for name in ("detect", "convert", "report"):
|
|
75
|
+
p = sub.add_parser(name)
|
|
76
|
+
p.add_argument("path", help="file or folder (.sql, .py, .ipynb)")
|
|
77
|
+
if name != "detect":
|
|
78
|
+
p.add_argument("--out", help="output folder for report files (default: print)")
|
|
79
|
+
p.add_argument("--fail-on-unsupported", action="store_true")
|
|
80
|
+
args = parser.parse_args(argv)
|
|
81
|
+
|
|
82
|
+
if args.cmd == "doctor":
|
|
83
|
+
from . import diagnostics
|
|
84
|
+
|
|
85
|
+
result = diagnostics.collect()
|
|
86
|
+
print(diagnostics.to_markdown(result))
|
|
87
|
+
return 1 if any(lvl == "FAIL" for lvl, _ in result["checks"]) else 0
|
|
88
|
+
|
|
89
|
+
if args.cmd == "issue":
|
|
90
|
+
from . import diagnostics
|
|
91
|
+
|
|
92
|
+
url = diagnostics.issue_url(args.title)
|
|
93
|
+
print(url)
|
|
94
|
+
if args.open:
|
|
95
|
+
import webbrowser
|
|
96
|
+
|
|
97
|
+
webbrowser.open(url)
|
|
98
|
+
return 0
|
|
99
|
+
|
|
100
|
+
if args.cmd == "validate":
|
|
101
|
+
import json
|
|
102
|
+
|
|
103
|
+
from .validate import report_from_config
|
|
104
|
+
|
|
105
|
+
rep = report_from_config(json.loads(Path(args.config).read_text(encoding="utf-8")))
|
|
106
|
+
print(rep.to_markdown())
|
|
107
|
+
if args.out:
|
|
108
|
+
Path(args.out).mkdir(parents=True, exist_ok=True)
|
|
109
|
+
(Path(args.out) / "validation.md").write_text(rep.to_markdown(), encoding="utf-8")
|
|
110
|
+
(Path(args.out) / "validation.json").write_text(json.dumps(rep.to_dict(), indent=2, default=str), encoding="utf-8")
|
|
111
|
+
return 0 if rep.ok else 1
|
|
112
|
+
|
|
113
|
+
if args.cmd == "verify":
|
|
114
|
+
import json
|
|
115
|
+
|
|
116
|
+
from .verify import verify_case
|
|
117
|
+
|
|
118
|
+
ver = verify_case(args.case, iceberg=args.iceberg)
|
|
119
|
+
print(json.dumps(ver.to_dict(), indent=2, default=str))
|
|
120
|
+
return 0 if ver.outcome == "pass" else 1
|
|
121
|
+
|
|
122
|
+
if args.cmd == "pyspark":
|
|
123
|
+
from .connectors import REGISTRY
|
|
124
|
+
from .platforms import detect_platform
|
|
125
|
+
from .pyspark_gen import MigrationConfig, pyspark_pipeline
|
|
126
|
+
|
|
127
|
+
platform = args.platform or detect_platform(args.path)["platform"]
|
|
128
|
+
if not platform:
|
|
129
|
+
print("could not detect the source platform: pass --platform", file=sys.stderr)
|
|
130
|
+
return 2
|
|
131
|
+
cfg = MigrationConfig(
|
|
132
|
+
platform=platform, catalog=args.catalog, namespace=args.namespace, catalog_type=args.catalog_type,
|
|
133
|
+
warehouse=args.warehouse, catalog_uri=args.catalog_uri, jdbc_url=args.jdbc_url,
|
|
134
|
+
source_schema=args.source_schema, format_version=args.iceberg_version,
|
|
135
|
+
)
|
|
136
|
+
stmts = detect_path(args.path, dialect=REGISTRY[platform].sqlglot_dialect)
|
|
137
|
+
script, rep = pyspark_pipeline(stmts, cfg)
|
|
138
|
+
out = Path(args.out)
|
|
139
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
(out / "pipeline_pyspark.py").write_text(script, encoding="utf-8")
|
|
141
|
+
bad = [r for r in rep if r["status"] in ("unsupported", "unparsed", "skipped")]
|
|
142
|
+
print(f"platform={platform}; wrote {out / 'pipeline_pyspark.py'}; {len(rep)} statements, {len(bad)} need hand work")
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
if args.cmd == "publish":
|
|
146
|
+
import json
|
|
147
|
+
|
|
148
|
+
from .publish.base import publisher_from_config
|
|
149
|
+
|
|
150
|
+
pub, tables = publisher_from_config(json.loads(Path(args.config).read_text(encoding="utf-8")))
|
|
151
|
+
result = pub.apply(tables, confirm=True) if args.apply else pub.plan(tables)
|
|
152
|
+
md = result.to_markdown()
|
|
153
|
+
if args.out:
|
|
154
|
+
Path(args.out).mkdir(parents=True, exist_ok=True)
|
|
155
|
+
(Path(args.out) / "publish_plan.md").write_text(md, encoding="utf-8")
|
|
156
|
+
print(md)
|
|
157
|
+
return 0 if (not args.apply or result.ok) else 1
|
|
158
|
+
|
|
159
|
+
if args.cmd == "compare":
|
|
160
|
+
from . import compare as cmp
|
|
161
|
+
|
|
162
|
+
counts = cmp.compare_counts(cmp.read_counts(args.snowflake_counts), cmp.read_counts(args.iceberg_counts))
|
|
163
|
+
cost = None
|
|
164
|
+
if any(v is not None for v in (args.snowflake_credits, args.spark_seconds)):
|
|
165
|
+
cost = cmp.cost_summary(args.snowflake_credits, args.credit_price, args.spark_seconds, args.spark_hourly_rate)
|
|
166
|
+
md = cmp.to_markdown(counts, cost)
|
|
167
|
+
if args.out:
|
|
168
|
+
Path(args.out).mkdir(parents=True, exist_ok=True)
|
|
169
|
+
(Path(args.out) / "compare.md").write_text(md, encoding="utf-8")
|
|
170
|
+
print(f"wrote {Path(args.out) / 'compare.md'}")
|
|
171
|
+
else:
|
|
172
|
+
print(md)
|
|
173
|
+
return 0 if all(c["status"] == "match" for c in counts) else 1
|
|
174
|
+
|
|
175
|
+
if args.cmd == "sql":
|
|
176
|
+
from . import compare as cmp
|
|
177
|
+
|
|
178
|
+
if args.counts:
|
|
179
|
+
print(cmp.count_sql(args.counts))
|
|
180
|
+
if args.credits:
|
|
181
|
+
print(cmp.credits_sql(*args.credits))
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
if args.cmd == "generate":
|
|
185
|
+
from . import generate as gen
|
|
186
|
+
|
|
187
|
+
ddl_text = "\n".join(s.sql.rstrip(";") + ";" for s in detect_path(args.ddl or args.path)
|
|
188
|
+
if not s.dynamic and not s.parse_error)
|
|
189
|
+
out = Path(args.out)
|
|
190
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
spark_ddl, w1 = gen.spark_iceberg_ddl(ddl_text, format_version=args.iceberg_version)
|
|
192
|
+
sf_ddl, w2 = gen.snowflake_iceberg_ddl(ddl_text, args.external_volume, iceberg_version=args.iceberg_version)
|
|
193
|
+
(out / "iceberg_spark.sql").write_text(spark_ddl, encoding="utf-8")
|
|
194
|
+
(out / "snowflake_managed_iceberg.sql").write_text(sf_ddl, encoding="utf-8")
|
|
195
|
+
(out / "pipeline_pyspark.py").write_text(gen.pyspark_script(detect_path(args.path)), encoding="utf-8")
|
|
196
|
+
(out / "warnings.txt").write_text("\n".join(w1 + w2) + "\n", encoding="utf-8")
|
|
197
|
+
print(f"wrote 4 files to {out}; {len(w1) + len(w2)} type warnings to review")
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
statements, results = _run(args.path) if args.cmd != "map" else (detect_path(args.path), {})
|
|
201
|
+
if args.cmd == "map":
|
|
202
|
+
import json
|
|
203
|
+
|
|
204
|
+
from .lineage import map_statements
|
|
205
|
+
|
|
206
|
+
lin = map_statements(statements)
|
|
207
|
+
text = json.dumps(lin, indent=2)
|
|
208
|
+
if args.out:
|
|
209
|
+
Path(args.out).mkdir(parents=True, exist_ok=True)
|
|
210
|
+
(Path(args.out) / "lineage.json").write_text(text, encoding="utf-8")
|
|
211
|
+
print(f"sources: {lin['source_tables']}\ntargets: {lin['target_tables']}")
|
|
212
|
+
return 0
|
|
213
|
+
|
|
214
|
+
if args.cmd == "detect":
|
|
215
|
+
for s in statements:
|
|
216
|
+
tag = "DYNAMIC" if s.dynamic else ("UNPARSED" if s.parse_error else "SQL")
|
|
217
|
+
print(f"{s.id}\t{tag}\t{s.sql[:80].replace(chr(10), ' ')}")
|
|
218
|
+
return 0
|
|
219
|
+
|
|
220
|
+
report = rpt.build(statements, results)
|
|
221
|
+
if args.out:
|
|
222
|
+
out = Path(args.out)
|
|
223
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
(out / "report.json").write_text(rpt.to_json(report), encoding="utf-8")
|
|
225
|
+
(out / "report.md").write_text(rpt.to_markdown(report), encoding="utf-8")
|
|
226
|
+
print(f"wrote {out/'report.json'} and {out/'report.md'}")
|
|
227
|
+
else:
|
|
228
|
+
print(rpt.to_markdown(report))
|
|
229
|
+
print(f"summary: {report['summary']}", file=sys.stderr)
|
|
230
|
+
bad = [i for i in report["statements"] if i["status"] in (UNSUPPORTED, UNPARSED)]
|
|
231
|
+
return 1 if (args.fail_on_unsupported and bad) else 0
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def main(argv: list[str] | None = None) -> int:
|
|
235
|
+
"""Entry point. ``--trace FILE`` is handled here so every command is wrapped in a span."""
|
|
236
|
+
from .tracing import Tracer, set_tracer, span
|
|
237
|
+
|
|
238
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
239
|
+
pre = argparse.ArgumentParser(add_help=False)
|
|
240
|
+
pre.add_argument("--trace")
|
|
241
|
+
known, rest = pre.parse_known_args(argv)
|
|
242
|
+
tracer = Tracer(known.trace) if known.trace else None
|
|
243
|
+
if tracer:
|
|
244
|
+
set_tracer(tracer)
|
|
245
|
+
cmd = next((a for a in rest if not a.startswith("-")), "help")
|
|
246
|
+
try:
|
|
247
|
+
with span(f"cli.{cmd}") as sp:
|
|
248
|
+
rc = _main(rest)
|
|
249
|
+
sp.set(exit_code=rc)
|
|
250
|
+
finally:
|
|
251
|
+
if tracer:
|
|
252
|
+
print(tracer.summary(), file=sys.stderr)
|
|
253
|
+
set_tracer(None)
|
|
254
|
+
return rc
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
if __name__ == "__main__":
|
|
258
|
+
raise SystemExit(main())
|
sql2iceberg/compare.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Compare row counts and cost between Snowflake and Iceberg runs.
|
|
2
|
+
|
|
3
|
+
Counts: two CSVs (table,row_count) that you export. Cost: Snowflake credits come from your own
|
|
4
|
+
ACCOUNT_USAGE views (up to ~3 hours late); Spark cost is runtime x a rate you supply. Costs are estimates.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import csv
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
SNOWFLAKE_COUNT_SQL = "SELECT '{t}' AS table_name, COUNT(*) AS row_count FROM {t};"
|
|
13
|
+
|
|
14
|
+
# Credits for one warehouse in a time window. ACCOUNT_USAGE data lags (up to 180 minutes; cloud services up to 6 h).
|
|
15
|
+
SNOWFLAKE_CREDITS_SQL = """SELECT SUM(credits_used_compute) AS compute_credits,
|
|
16
|
+
SUM(credits_used_cloud_services) AS cloud_services_credits,
|
|
17
|
+
SUM(credits_used) AS credits_used -- may be higher than billed (cloud services adjustment)
|
|
18
|
+
FROM snowflake.account_usage.warehouse_metering_history
|
|
19
|
+
WHERE warehouse_name = '{warehouse}'
|
|
20
|
+
AND start_time >= '{start}'::timestamp_ltz AND end_time <= '{end}'::timestamp_ltz;"""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def count_sql(tables: list[str]) -> str:
|
|
24
|
+
return "\nUNION ALL\n".join(SNOWFLAKE_COUNT_SQL.format(t=t).rstrip(";") for t in tables) + ";"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def credits_sql(warehouse: str, start: str, end: str) -> str:
|
|
28
|
+
return SNOWFLAKE_CREDITS_SQL.format(warehouse=warehouse, start=start, end=end)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def read_counts(path: str | Path) -> dict[str, int]:
|
|
32
|
+
with open(path, newline="", encoding="utf-8") as fh:
|
|
33
|
+
rows = list(csv.DictReader(fh))
|
|
34
|
+
return {r["table_name"].strip().lower().split(".")[-1]: int(r["row_count"]) for r in rows}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def spark_counts(spark, tables: list[str]) -> dict[str, int]:
|
|
38
|
+
return {t.lower().split(".")[-1]: spark.table(t).count() for t in tables}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def compare_counts(snowflake: dict[str, int], iceberg: dict[str, int]) -> list[dict]:
|
|
42
|
+
out = []
|
|
43
|
+
for t in sorted(set(snowflake) | set(iceberg)):
|
|
44
|
+
a, b = snowflake.get(t), iceberg.get(t)
|
|
45
|
+
status = "missing" if a is None or b is None else ("match" if a == b else "mismatch")
|
|
46
|
+
out.append({"table": t, "snowflake": a, "iceberg": b, "status": status})
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cost_summary(snowflake_credits: float | None, credit_price: float | None,
|
|
51
|
+
spark_seconds: float | None, spark_hourly_rate: float | None) -> dict:
|
|
52
|
+
sf = round(snowflake_credits * credit_price, 4) if snowflake_credits is not None and credit_price is not None else None
|
|
53
|
+
sp = round(spark_seconds / 3600 * spark_hourly_rate, 4) if spark_seconds is not None and spark_hourly_rate is not None else None
|
|
54
|
+
return {
|
|
55
|
+
"snowflake_credits": snowflake_credits, "snowflake_cost": sf,
|
|
56
|
+
"spark_seconds": spark_seconds, "spark_cost": sp,
|
|
57
|
+
"note": "estimate: Snowflake from credits x your price; Spark from runtime x your hourly rate; "
|
|
58
|
+
"storage, network and engineer time are not included",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def to_markdown(counts: list[dict], cost: dict | None) -> str:
|
|
63
|
+
lines = ["# Count and cost comparison", "", "| table | snowflake | iceberg | status |", "|---|---|---|---|"]
|
|
64
|
+
lines += [f"| {c['table']} | {c['snowflake']} | {c['iceberg']} | {c['status']} |" for c in counts]
|
|
65
|
+
if cost:
|
|
66
|
+
lines += ["", "## Cost (estimate)", "", f"- Snowflake: {cost['snowflake_credits']} credits, cost {cost['snowflake_cost']}",
|
|
67
|
+
f"- Spark: {cost['spark_seconds']} s, cost {cost['spark_cost']}", f"- {cost['note']}"]
|
|
68
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Source connectors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import ColumnInfo, Connector, TableInfo
|
|
6
|
+
from .others import MySQLConnector, OracleConnector, PostgresConnector, SQLServerConnector
|
|
7
|
+
from .snowflake import SnowflakeConnector
|
|
8
|
+
|
|
9
|
+
REGISTRY: dict[str, type[Connector]] = {
|
|
10
|
+
c.name: c
|
|
11
|
+
for c in (SnowflakeConnector, PostgresConnector, MySQLConnector, SQLServerConnector, OracleConnector)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_connector(name: str, **params: str) -> Connector:
|
|
16
|
+
try:
|
|
17
|
+
return REGISTRY[name.lower()](**params)
|
|
18
|
+
except KeyError:
|
|
19
|
+
raise ValueError(f"unknown platform {name!r}; choose from {sorted(REGISTRY)}") from None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__all__ = ["REGISTRY", "ColumnInfo", "Connector", "TableInfo", "get_connector"]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Connector interface: every source platform implements the same small surface.
|
|
2
|
+
|
|
3
|
+
Credentials are never read from config files. Each connector reads them from the environment
|
|
4
|
+
(or the platform's own profile/SSO mechanism) so the user sets up authentication before running.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ColumnInfo:
|
|
16
|
+
name: str
|
|
17
|
+
data_type: str
|
|
18
|
+
nullable: bool = True
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class TableInfo:
|
|
23
|
+
schema: str
|
|
24
|
+
name: str
|
|
25
|
+
columns: list[ColumnInfo] = field(default_factory=list)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def qualified(self) -> str:
|
|
29
|
+
return f"{self.schema}.{self.name}"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Connector(ABC):
|
|
33
|
+
"""A live connection to a source system."""
|
|
34
|
+
|
|
35
|
+
#: registry key
|
|
36
|
+
name: str = ""
|
|
37
|
+
#: SQLGlot dialect used to parse this platform's SQL
|
|
38
|
+
sqlglot_dialect: str = ""
|
|
39
|
+
#: pip extra that installs the driver, e.g. ``pip install sql2iceberg[snowflake]``
|
|
40
|
+
extra: str = ""
|
|
41
|
+
#: only Snowflake is verified against a live system
|
|
42
|
+
tested: bool = False
|
|
43
|
+
#: env vars that must be set; checked for a clear error before connecting
|
|
44
|
+
required_env: tuple[str, ...] = ()
|
|
45
|
+
#: JDBC driver class so Spark can read the source in parallel
|
|
46
|
+
jdbc_driver: str = ""
|
|
47
|
+
#: env vars holding the JDBC login, and the Maven coordinate of the driver (review the version)
|
|
48
|
+
jdbc_user_env: str = ""
|
|
49
|
+
jdbc_password_env: str = ""
|
|
50
|
+
jdbc_package: str = ""
|
|
51
|
+
#: optional key-pair login: env var with the private-key file path, and the JDBC property that takes it
|
|
52
|
+
jdbc_key_env: str = ""
|
|
53
|
+
jdbc_key_option: str = ""
|
|
54
|
+
|
|
55
|
+
def __init__(self, **params: str) -> None:
|
|
56
|
+
self.params = params
|
|
57
|
+
self._conn = None
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def missing_env(cls, env: dict[str, str] | None = None) -> list[str]:
|
|
61
|
+
env = os.environ if env is None else env
|
|
62
|
+
return [k for k in cls.required_env if not env.get(k)]
|
|
63
|
+
|
|
64
|
+
@abstractmethod
|
|
65
|
+
def connect(self):
|
|
66
|
+
"""Open the DB-API connection, store it on ``self._conn`` and return it."""
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def jdbc_url(self) -> str:
|
|
70
|
+
"""JDBC URL for Spark reads (never contains credentials)."""
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def _tables_sql(self, schema: str | None) -> tuple[str, tuple]:
|
|
74
|
+
"""SQL returning (schema, table, column, type, nullable) rows, plus its parameters."""
|
|
75
|
+
|
|
76
|
+
def _count_sql(self, table: str) -> str:
|
|
77
|
+
return f"SELECT COUNT(*) FROM {table}"
|
|
78
|
+
|
|
79
|
+
def _require_driver(self, module: str):
|
|
80
|
+
import importlib
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
return importlib.import_module(module)
|
|
84
|
+
except ImportError as exc:
|
|
85
|
+
raise ImportError(
|
|
86
|
+
f"{self.name} support needs its driver: pip install 'sql2iceberg[{self.extra}]'"
|
|
87
|
+
) from exc
|
|
88
|
+
|
|
89
|
+
def _ensure(self):
|
|
90
|
+
if self._conn is None:
|
|
91
|
+
missing = self.missing_env()
|
|
92
|
+
if missing:
|
|
93
|
+
raise RuntimeError(f"Set these environment variables first: {', '.join(missing)}")
|
|
94
|
+
self.connect()
|
|
95
|
+
return self._conn
|
|
96
|
+
|
|
97
|
+
def query(self, sql: str, params: tuple = ()) -> list[tuple]:
|
|
98
|
+
cur = self._ensure().cursor()
|
|
99
|
+
try:
|
|
100
|
+
cur.execute(sql, params) if params else cur.execute(sql)
|
|
101
|
+
return list(cur.fetchall())
|
|
102
|
+
finally:
|
|
103
|
+
cur.close()
|
|
104
|
+
|
|
105
|
+
def list_tables(self, schema: str | None = None) -> list[TableInfo]:
|
|
106
|
+
sql, params = self._tables_sql(schema)
|
|
107
|
+
tables: dict[tuple[str, str], TableInfo] = {}
|
|
108
|
+
for sch, tbl, col, dtype, nullable in self.query(sql, params):
|
|
109
|
+
info = tables.setdefault((sch, tbl), TableInfo(sch, tbl))
|
|
110
|
+
info.columns.append(ColumnInfo(col, dtype, str(nullable).upper() in ("YES", "Y", "1", "TRUE")))
|
|
111
|
+
return list(tables.values())
|
|
112
|
+
|
|
113
|
+
def row_count(self, table: str) -> int:
|
|
114
|
+
return int(self.query(self._count_sql(table))[0][0])
|
|
115
|
+
|
|
116
|
+
def close(self) -> None:
|
|
117
|
+
if self._conn is not None:
|
|
118
|
+
self._conn.close()
|
|
119
|
+
self._conn = None
|
|
120
|
+
|
|
121
|
+
def __enter__(self):
|
|
122
|
+
self._ensure()
|
|
123
|
+
return self
|
|
124
|
+
|
|
125
|
+
def __exit__(self, *exc) -> None:
|
|
126
|
+
self.close()
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Postgres, MySQL, SQL Server and Oracle connectors.
|
|
2
|
+
|
|
3
|
+
EXPERIMENTAL: written to the same interface as Snowflake and covered by mock-based unit tests only.
|
|
4
|
+
They have not been run against live servers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
|
|
11
|
+
from .base import Connector
|
|
12
|
+
|
|
13
|
+
_INFO_SCHEMA = (
|
|
14
|
+
"SELECT table_schema, table_name, column_name, data_type, is_nullable "
|
|
15
|
+
"FROM information_schema.columns WHERE table_schema NOT IN ({excl})"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _info_schema_query(excl: str, schema: str | None, marker: str = "%s"):
|
|
20
|
+
sql = _INFO_SCHEMA.format(excl=excl)
|
|
21
|
+
if schema:
|
|
22
|
+
return sql + f" AND table_schema = {marker} ORDER BY 1, 2, ordinal_position", (schema,)
|
|
23
|
+
return sql + " ORDER BY 1, 2, ordinal_position", ()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PostgresConnector(Connector):
|
|
27
|
+
name = "postgres"
|
|
28
|
+
sqlglot_dialect = "postgres"
|
|
29
|
+
extra = "postgres"
|
|
30
|
+
required_env = ("PGHOST", "PGUSER", "PGDATABASE")
|
|
31
|
+
jdbc_driver = "org.postgresql.Driver"
|
|
32
|
+
jdbc_user_env = "PGUSER"
|
|
33
|
+
jdbc_password_env = "PGPASSWORD"
|
|
34
|
+
jdbc_package = "org.postgresql:postgresql:42.7.4"
|
|
35
|
+
|
|
36
|
+
def connect(self):
|
|
37
|
+
pg = self._require_driver("psycopg2")
|
|
38
|
+
self._conn = pg.connect(
|
|
39
|
+
host=os.environ["PGHOST"], port=os.environ.get("PGPORT", "5432"),
|
|
40
|
+
user=os.environ["PGUSER"], password=os.environ.get("PGPASSWORD"),
|
|
41
|
+
dbname=os.environ["PGDATABASE"],
|
|
42
|
+
)
|
|
43
|
+
return self._conn
|
|
44
|
+
|
|
45
|
+
def jdbc_url(self) -> str:
|
|
46
|
+
e = os.environ
|
|
47
|
+
return f"jdbc:postgresql://{e['PGHOST']}:{e.get('PGPORT', '5432')}/{e['PGDATABASE']}"
|
|
48
|
+
|
|
49
|
+
def _tables_sql(self, schema):
|
|
50
|
+
return _info_schema_query("'pg_catalog','information_schema'", schema)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MySQLConnector(Connector):
|
|
54
|
+
name = "mysql"
|
|
55
|
+
sqlglot_dialect = "mysql"
|
|
56
|
+
extra = "mysql"
|
|
57
|
+
required_env = ("MYSQL_HOST", "MYSQL_USER", "MYSQL_DATABASE")
|
|
58
|
+
jdbc_driver = "com.mysql.cj.jdbc.Driver"
|
|
59
|
+
jdbc_user_env = "MYSQL_USER"
|
|
60
|
+
jdbc_password_env = "MYSQL_PASSWORD"
|
|
61
|
+
jdbc_package = "com.mysql:mysql-connector-j:8.4.0"
|
|
62
|
+
|
|
63
|
+
def connect(self):
|
|
64
|
+
my = self._require_driver("pymysql")
|
|
65
|
+
self._conn = my.connect(
|
|
66
|
+
host=os.environ["MYSQL_HOST"], port=int(os.environ.get("MYSQL_PORT", "3306")),
|
|
67
|
+
user=os.environ["MYSQL_USER"], password=os.environ.get("MYSQL_PASSWORD", ""),
|
|
68
|
+
database=os.environ["MYSQL_DATABASE"],
|
|
69
|
+
)
|
|
70
|
+
return self._conn
|
|
71
|
+
|
|
72
|
+
def jdbc_url(self) -> str:
|
|
73
|
+
e = os.environ
|
|
74
|
+
return f"jdbc:mysql://{e['MYSQL_HOST']}:{e.get('MYSQL_PORT', '3306')}/{e['MYSQL_DATABASE']}"
|
|
75
|
+
|
|
76
|
+
def _tables_sql(self, schema):
|
|
77
|
+
return _info_schema_query("'mysql','information_schema','performance_schema','sys'", schema)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SQLServerConnector(Connector):
|
|
81
|
+
name = "mssql"
|
|
82
|
+
sqlglot_dialect = "tsql"
|
|
83
|
+
extra = "mssql"
|
|
84
|
+
required_env = ("MSSQL_SERVER", "MSSQL_DATABASE")
|
|
85
|
+
jdbc_driver = "com.microsoft.sqlserver.jdbc.SQLServerDriver"
|
|
86
|
+
jdbc_user_env = "MSSQL_USER"
|
|
87
|
+
jdbc_password_env = "MSSQL_PASSWORD"
|
|
88
|
+
jdbc_package = "com.microsoft.sqlserver:mssql-jdbc:12.8.1.jre11"
|
|
89
|
+
|
|
90
|
+
def connect(self):
|
|
91
|
+
py = self._require_driver("pyodbc")
|
|
92
|
+
e = os.environ
|
|
93
|
+
auth = ("Trusted_Connection=yes" if not e.get("MSSQL_USER")
|
|
94
|
+
else f"UID={e['MSSQL_USER']};PWD={e.get('MSSQL_PASSWORD', '')}")
|
|
95
|
+
driver = e.get("MSSQL_ODBC_DRIVER", "ODBC Driver 18 for SQL Server")
|
|
96
|
+
self._conn = py.connect(
|
|
97
|
+
f"DRIVER={{{driver}}};SERVER={e['MSSQL_SERVER']};DATABASE={e['MSSQL_DATABASE']};{auth}"
|
|
98
|
+
)
|
|
99
|
+
return self._conn
|
|
100
|
+
|
|
101
|
+
def jdbc_url(self) -> str:
|
|
102
|
+
e = os.environ
|
|
103
|
+
return f"jdbc:sqlserver://{e['MSSQL_SERVER']};databaseName={e['MSSQL_DATABASE']}"
|
|
104
|
+
|
|
105
|
+
def _tables_sql(self, schema):
|
|
106
|
+
return _info_schema_query("'sys','INFORMATION_SCHEMA'", schema, marker="?")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class OracleConnector(Connector):
|
|
110
|
+
name = "oracle"
|
|
111
|
+
sqlglot_dialect = "oracle"
|
|
112
|
+
extra = "oracle"
|
|
113
|
+
required_env = ("ORACLE_DSN", "ORACLE_USER")
|
|
114
|
+
jdbc_driver = "oracle.jdbc.OracleDriver"
|
|
115
|
+
jdbc_user_env = "ORACLE_USER"
|
|
116
|
+
jdbc_password_env = "ORACLE_PASSWORD"
|
|
117
|
+
jdbc_package = "com.oracle.database.jdbc:ojdbc11:23.5.0.24.07"
|
|
118
|
+
|
|
119
|
+
def connect(self):
|
|
120
|
+
ora = self._require_driver("oracledb")
|
|
121
|
+
self._conn = ora.connect(user=os.environ["ORACLE_USER"],
|
|
122
|
+
password=os.environ.get("ORACLE_PASSWORD"), dsn=os.environ["ORACLE_DSN"])
|
|
123
|
+
return self._conn
|
|
124
|
+
|
|
125
|
+
def jdbc_url(self) -> str:
|
|
126
|
+
return f"jdbc:oracle:thin:@{os.environ['ORACLE_DSN']}"
|
|
127
|
+
|
|
128
|
+
def _tables_sql(self, schema):
|
|
129
|
+
sql = ("SELECT owner, table_name, column_name, data_type, nullable FROM all_tab_columns "
|
|
130
|
+
"WHERE owner NOT IN ('SYS','SYSTEM')")
|
|
131
|
+
if schema:
|
|
132
|
+
return sql + " AND owner = :1 ORDER BY 1, 2, column_id", (schema.upper(),)
|
|
133
|
+
return sql + " ORDER BY 1, 2, column_id", ()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from .base import Connector
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SnowflakeConnector(Connector):
|
|
9
|
+
name = "snowflake"
|
|
10
|
+
sqlglot_dialect = "snowflake"
|
|
11
|
+
extra = "snowflake"
|
|
12
|
+
tested = True
|
|
13
|
+
required_env = ("SNOWFLAKE_ACCOUNT", "SNOWFLAKE_USER")
|
|
14
|
+
jdbc_driver = "net.snowflake.client.jdbc.SnowflakeDriver"
|
|
15
|
+
jdbc_user_env = "SNOWFLAKE_USER"
|
|
16
|
+
jdbc_password_env = "SNOWFLAKE_PASSWORD"
|
|
17
|
+
jdbc_package = "net.snowflake:snowflake-jdbc:3.19.0"
|
|
18
|
+
jdbc_key_env = "SNOWFLAKE_PRIVATE_KEY_PATH"
|
|
19
|
+
jdbc_key_option = "private_key_file"
|
|
20
|
+
|
|
21
|
+
def connect(self):
|
|
22
|
+
sf = self._require_driver("snowflake.connector")
|
|
23
|
+
p = {"account": os.environ["SNOWFLAKE_ACCOUNT"], "user": os.environ["SNOWFLAKE_USER"]}
|
|
24
|
+
if os.environ.get("SNOWFLAKE_ROLE"):
|
|
25
|
+
p["role"] = os.environ["SNOWFLAKE_ROLE"]
|
|
26
|
+
if os.environ.get("SNOWFLAKE_AUTHENTICATOR"):
|
|
27
|
+
p["authenticator"] = os.environ["SNOWFLAKE_AUTHENTICATOR"]
|
|
28
|
+
elif os.environ.get("SNOWFLAKE_PRIVATE_KEY_PATH"):
|
|
29
|
+
p["private_key_file"] = os.environ["SNOWFLAKE_PRIVATE_KEY_PATH"]
|
|
30
|
+
else:
|
|
31
|
+
p["password"] = os.environ.get("SNOWFLAKE_PASSWORD", "")
|
|
32
|
+
for key in ("warehouse", "database", "schema"):
|
|
33
|
+
value = self.params.get(key) or os.environ.get(f"SNOWFLAKE_{key.upper()}")
|
|
34
|
+
if value:
|
|
35
|
+
p[key] = value
|
|
36
|
+
self._conn = sf.connect(**p)
|
|
37
|
+
return self._conn
|
|
38
|
+
|
|
39
|
+
def jdbc_url(self) -> str:
|
|
40
|
+
return f"jdbc:snowflake://{os.environ['SNOWFLAKE_ACCOUNT']}.snowflakecomputing.com/"
|
|
41
|
+
|
|
42
|
+
def _tables_sql(self, schema):
|
|
43
|
+
sql = ("SELECT table_schema, table_name, column_name, data_type, is_nullable "
|
|
44
|
+
"FROM information_schema.columns WHERE table_schema <> 'INFORMATION_SCHEMA'")
|
|
45
|
+
if schema:
|
|
46
|
+
return sql + " AND table_schema = %s ORDER BY 1, 2, ordinal_position", (schema.upper(),)
|
|
47
|
+
return sql + " ORDER BY 1, 2, ordinal_position", ()
|