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.
@@ -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", ()