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
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""AWS: make Iceberg tables visible in the Glue Data Catalog (so Athena, EMR, Redshift Spectrum can query them).
|
|
2
|
+
|
|
3
|
+
If the tables were written through a Glue catalog (``catalog_type="glue"``) they are already registered and
|
|
4
|
+
``plan()`` only adds a verification step. If they live in a Hadoop catalog on S3, they are registered in Glue
|
|
5
|
+
with Iceberg's ``register_table`` procedure, run in a Spark session that has a Glue catalog configured.
|
|
6
|
+
Needs ``pip install sql2iceberg[aws]`` (boto3) for verification. Credentials come from the usual AWS
|
|
7
|
+
profile / environment, never from this library's config.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from .base import Action, IcebergTable, Publisher, PublishResult, ident, lit
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class GlueTarget:
|
|
19
|
+
database: str # Glue database (Iceberg namespace)
|
|
20
|
+
region: str = ""
|
|
21
|
+
already_in_glue: bool = False # tables were written through a Glue catalog
|
|
22
|
+
spark_glue_catalog: str = "glue" # name of the Spark catalog configured with GlueCatalog
|
|
23
|
+
athena_workgroup: str = "primary"
|
|
24
|
+
athena_output: str = "" # s3://bucket/prefix/ for Athena results (needed to run the count check)
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
ident(self.database, "glue database")
|
|
28
|
+
ident(self.spark_glue_catalog, "spark catalog name")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GluePublisher(Publisher):
|
|
32
|
+
target = "aws"
|
|
33
|
+
|
|
34
|
+
def __init__(self, target: GlueTarget, spark=None, session=None) -> None:
|
|
35
|
+
super().__init__()
|
|
36
|
+
self.t, self._spark, self._session = target, spark, session
|
|
37
|
+
|
|
38
|
+
def plan(self, tables: list[IcebergTable]) -> PublishResult:
|
|
39
|
+
t = self.t
|
|
40
|
+
res = PublishResult()
|
|
41
|
+
add = res.actions.append
|
|
42
|
+
if not t.already_in_glue:
|
|
43
|
+
add(Action("aws", "sql", "Create Glue database via Spark",
|
|
44
|
+
f"CREATE NAMESPACE IF NOT EXISTS {t.spark_glue_catalog}.{t.database};"))
|
|
45
|
+
for tb in tables:
|
|
46
|
+
if not tb.metadata_location.startswith("s3"):
|
|
47
|
+
raise ValueError(f"{tb.name}: metadata_location must be an s3:// URI to register in Glue")
|
|
48
|
+
add(Action("aws", "sql", f"Register {tb.name} in Glue",
|
|
49
|
+
f"CALL {t.spark_glue_catalog}.system.register_table(table => "
|
|
50
|
+
f"{lit(t.database + '.' + ident(tb.name, 'table name'))}, "
|
|
51
|
+
f"metadata_file => {lit(tb.metadata_location)});"))
|
|
52
|
+
add(Action("aws", "api", "Verify tables in Glue",
|
|
53
|
+
"glue.get_table for: " + ", ".join(f"{t.database}.{tb.name}" for tb in tables)
|
|
54
|
+
+ " (expects Parameters.table_type == ICEBERG)"))
|
|
55
|
+
if t.athena_output:
|
|
56
|
+
add(Action("aws", "sql", "Count rows with Athena (billed per data scanned)",
|
|
57
|
+
"\n".join(f'SELECT \'{tb.name}\' AS table_name, COUNT(*) FROM "{t.database}"."{tb.name}";'
|
|
58
|
+
for tb in tables), costs_money=True))
|
|
59
|
+
return res
|
|
60
|
+
|
|
61
|
+
def _boto(self, service: str):
|
|
62
|
+
if self._session is None:
|
|
63
|
+
try:
|
|
64
|
+
import boto3
|
|
65
|
+
except ImportError as exc:
|
|
66
|
+
raise ImportError("pip install 'sql2iceberg[aws]'") from exc
|
|
67
|
+
self._session = boto3.Session(region_name=self.t.region or None)
|
|
68
|
+
return self._session.client(service)
|
|
69
|
+
|
|
70
|
+
def _run(self, action: Action) -> str | None:
|
|
71
|
+
if action.kind == "sql" and action.costs_money:
|
|
72
|
+
return self._athena(action.text)
|
|
73
|
+
elif action.kind == "sql":
|
|
74
|
+
if self._spark is None:
|
|
75
|
+
raise RuntimeError("pass spark=<SparkSession with a Glue catalog> to register tables")
|
|
76
|
+
for stmt in [s for s in action.text.split(";\n") if s.strip()]:
|
|
77
|
+
self._spark.sql(stmt.rstrip(";"))
|
|
78
|
+
else:
|
|
79
|
+
self._verify_glue(action.text)
|
|
80
|
+
|
|
81
|
+
def _verify_glue(self, text: str) -> None:
|
|
82
|
+
glue = self._boto("glue")
|
|
83
|
+
names = text.split("for: ")[1].split(" (")[0].split(", ")
|
|
84
|
+
for full in names:
|
|
85
|
+
db, name = full.split(".")
|
|
86
|
+
tbl = glue.get_table(DatabaseName=db, Name=name)["Table"]
|
|
87
|
+
if tbl.get("Parameters", {}).get("table_type", "").upper() != "ICEBERG":
|
|
88
|
+
raise RuntimeError(f"{full} exists in Glue but is not an Iceberg table")
|
|
89
|
+
|
|
90
|
+
def _athena(self, sql_text: str) -> str:
|
|
91
|
+
import time
|
|
92
|
+
|
|
93
|
+
athena = self._boto("athena")
|
|
94
|
+
lines: list[str] = []
|
|
95
|
+
for stmt in [s for s in sql_text.split(";\n") if s.strip()]:
|
|
96
|
+
qid = athena.start_query_execution(
|
|
97
|
+
QueryString=stmt.rstrip(";"), WorkGroup=self.t.athena_workgroup,
|
|
98
|
+
ResultConfiguration={"OutputLocation": self.t.athena_output})["QueryExecutionId"]
|
|
99
|
+
while True:
|
|
100
|
+
state = athena.get_query_execution(QueryExecutionId=qid)["QueryExecution"]["Status"]["State"]
|
|
101
|
+
if state in ("SUCCEEDED", "FAILED", "CANCELLED"):
|
|
102
|
+
break
|
|
103
|
+
time.sleep(1)
|
|
104
|
+
if state != "SUCCEEDED":
|
|
105
|
+
raise RuntimeError(f"Athena query {state}: {stmt[:80]}")
|
|
106
|
+
rows = athena.get_query_results(QueryExecutionId=qid)["ResultSet"]["Rows"][1:]
|
|
107
|
+
lines.extend(", ".join(c.get("VarCharValue", "") for c in r["Data"]) for r in rows)
|
|
108
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Publishers expose existing Iceberg tables to another platform (Snowflake, AWS Glue/Athena, BigQuery).
|
|
2
|
+
|
|
3
|
+
Every publisher first builds a plan (a list of Actions: SQL or an API call described as text) and only
|
|
4
|
+
changes anything in the target account when ``apply()`` is called. ``plan()`` never connects anywhere.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
|
|
13
|
+
from ..tracing import span
|
|
14
|
+
|
|
15
|
+
_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_$]*$")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def ident(value: str, what: str = "identifier") -> str:
|
|
19
|
+
"""Validate a SQL object name. Names are interpolated into DDL, so reject anything unusual."""
|
|
20
|
+
if not _IDENT.match(value or ""):
|
|
21
|
+
raise ValueError(f"unsafe or empty {what}: {value!r} (letters, digits, _ and $ only)")
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def lit(value: str) -> str:
|
|
26
|
+
"""Quote a SQL string literal."""
|
|
27
|
+
return "'" + str(value).replace("\\", "\\\\").replace("'", "''") + "'"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class IcebergTable:
|
|
32
|
+
name: str # table name (no catalog/namespace)
|
|
33
|
+
namespace: str = "db"
|
|
34
|
+
metadata_location: str = "" # full URI of the current metadata.json, e.g. s3://bucket/db/t/metadata/v3.metadata.json
|
|
35
|
+
format_version: int = 2
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Action:
|
|
40
|
+
target: str # snowflake | aws | gcp
|
|
41
|
+
kind: str # sql | api
|
|
42
|
+
description: str
|
|
43
|
+
text: str # SQL text, or a description of the API call
|
|
44
|
+
costs_money: bool = False # warehouse / query usage possible
|
|
45
|
+
done: bool = False
|
|
46
|
+
error: str = ""
|
|
47
|
+
output: str = "" # rows returned by a check, as text
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class PublishResult:
|
|
52
|
+
actions: list[Action] = field(default_factory=list)
|
|
53
|
+
warnings: list[str] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def ok(self) -> bool:
|
|
57
|
+
return all(a.done and not a.error for a in self.actions)
|
|
58
|
+
|
|
59
|
+
def to_markdown(self) -> str:
|
|
60
|
+
out = [f"# Publish plan ({len(self.actions)} actions)\n"]
|
|
61
|
+
out += [f"- WARNING: {w}" for w in self.warnings]
|
|
62
|
+
for i, a in enumerate(self.actions, 1):
|
|
63
|
+
state = "applied" if a.done else ("FAILED: " + a.error if a.error else "planned")
|
|
64
|
+
out.append(f"\n## {i}. {a.description} [{a.target}, {state}]")
|
|
65
|
+
out.append(f"```{'sql' if a.kind == 'sql' else 'text'}\n{a.text}\n```")
|
|
66
|
+
if a.output:
|
|
67
|
+
out.append(f"Result:\n```\n{a.output}\n```")
|
|
68
|
+
return "\n".join(out) + "\n"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Publisher(ABC):
|
|
72
|
+
target = ""
|
|
73
|
+
|
|
74
|
+
def __init__(self) -> None:
|
|
75
|
+
self.warnings: list[str] = []
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
def plan(self, tables: list[IcebergTable]) -> PublishResult: ...
|
|
79
|
+
|
|
80
|
+
@abstractmethod
|
|
81
|
+
def _run(self, action: Action) -> str | None:
|
|
82
|
+
"""Execute one action against the real account. Raise on failure; return any rows as text."""
|
|
83
|
+
|
|
84
|
+
def apply(self, tables: list[IcebergTable], *, confirm: bool = False) -> PublishResult:
|
|
85
|
+
"""Run the plan. ``confirm=True`` is required so an accidental call cannot change an account."""
|
|
86
|
+
if not confirm:
|
|
87
|
+
raise PermissionError("apply() changes a real account: call it with confirm=True after reading plan()")
|
|
88
|
+
result = self.plan(tables)
|
|
89
|
+
for action in result.actions:
|
|
90
|
+
try:
|
|
91
|
+
with span("publish.action", target=action.target, step=action.description):
|
|
92
|
+
action.output = self._run(action) or ""
|
|
93
|
+
action.done = True
|
|
94
|
+
except Exception as exc: # noqa: BLE001 report and stop: later steps depend on earlier ones
|
|
95
|
+
action.error = f"{type(exc).__name__}: {exc}"[:1500]
|
|
96
|
+
break
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def publisher_from_config(cfg: dict) -> tuple[Publisher, list[IcebergTable]]:
|
|
101
|
+
"""Build a publisher and its tables from a plain dict (the JSON file the CLI reads).
|
|
102
|
+
|
|
103
|
+
{"target": "snowflake" | "aws" | "gcp", "<target>": {...target fields...},
|
|
104
|
+
"tables": [{"name": ..., "namespace": ..., "metadata_location": ..., "format_version": 2}]}
|
|
105
|
+
"""
|
|
106
|
+
from .aws import GluePublisher, GlueTarget
|
|
107
|
+
from .gcp import BigQueryPublisher, BigQueryTarget
|
|
108
|
+
from .snowflake import SnowflakePublisher, SnowflakeTarget
|
|
109
|
+
|
|
110
|
+
kind = cfg.get("target")
|
|
111
|
+
tables = [IcebergTable(**t) for t in cfg.get("tables", [])]
|
|
112
|
+
if not tables:
|
|
113
|
+
raise ValueError("config has no tables")
|
|
114
|
+
if kind == "snowflake":
|
|
115
|
+
return SnowflakePublisher(SnowflakeTarget(**cfg["snowflake"])), tables
|
|
116
|
+
if kind == "aws":
|
|
117
|
+
return GluePublisher(GlueTarget(**cfg["aws"])), tables
|
|
118
|
+
if kind == "gcp":
|
|
119
|
+
return BigQueryPublisher(BigQueryTarget(**cfg["gcp"])), tables
|
|
120
|
+
raise ValueError("target must be snowflake, aws or gcp")
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""GCP: BigLake external Iceberg tables in BigQuery.
|
|
2
|
+
|
|
3
|
+
BigQuery reads Iceberg data from Cloud Storage, so the warehouse must be on ``gs://``. Each table is created
|
|
4
|
+
from its current metadata.json (a snapshot: re-run after writes). Needs a BigQuery connection resource with
|
|
5
|
+
read access to the bucket, and ``pip install sql2iceberg[gcp]`` to apply. Authentication is Application Default
|
|
6
|
+
Credentials (``gcloud auth application-default login`` or a service account), never stored here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from .base import Action, IcebergTable, Publisher, PublishResult, ident, lit
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class BigQueryTarget:
|
|
18
|
+
project: str
|
|
19
|
+
dataset: str
|
|
20
|
+
connection: str # "<project>.<location>.<connection_id>"
|
|
21
|
+
location: str = "US"
|
|
22
|
+
|
|
23
|
+
def __post_init__(self) -> None:
|
|
24
|
+
for v, what in ((self.project.replace("-", "_"), "project"), (self.dataset, "dataset")):
|
|
25
|
+
ident(v, what)
|
|
26
|
+
if len(self.connection.split(".")) != 3:
|
|
27
|
+
raise ValueError("connection must look like project.location.connection_id")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BigQueryPublisher(Publisher):
|
|
31
|
+
target = "gcp"
|
|
32
|
+
|
|
33
|
+
def __init__(self, target: BigQueryTarget, client=None) -> None:
|
|
34
|
+
super().__init__()
|
|
35
|
+
self.t, self._client = target, client
|
|
36
|
+
|
|
37
|
+
def plan(self, tables: list[IcebergTable]) -> PublishResult:
|
|
38
|
+
t = self.t
|
|
39
|
+
res = PublishResult()
|
|
40
|
+
res.warnings.append("BigLake Iceberg tables are snapshots of one metadata file: re-run after each write.")
|
|
41
|
+
if any(tb.format_version >= 3 for tb in tables):
|
|
42
|
+
res.warnings.append("Some tables are Iceberg format-version 3; check BigQuery's supported versions "
|
|
43
|
+
"and write with --iceberg-version 2 if unsure.")
|
|
44
|
+
res.actions.append(Action("gcp", "sql", "Create dataset",
|
|
45
|
+
f"CREATE SCHEMA IF NOT EXISTS `{t.project}.{t.dataset}` OPTIONS (location = {lit(t.location)});"))
|
|
46
|
+
for tb in tables:
|
|
47
|
+
if not tb.metadata_location.startswith("gs://"):
|
|
48
|
+
raise ValueError(f"{tb.name}: BigQuery needs a gs:// metadata_location, got {tb.metadata_location!r}")
|
|
49
|
+
res.actions.append(Action(
|
|
50
|
+
"gcp", "sql", f"Expose {tb.name}",
|
|
51
|
+
f"CREATE OR REPLACE EXTERNAL TABLE `{t.project}.{t.dataset}.{ident(tb.name, 'table name')}`\n"
|
|
52
|
+
f" WITH CONNECTION `{t.connection}`\n"
|
|
53
|
+
f" OPTIONS (format = 'ICEBERG', uris = [{lit(tb.metadata_location)}]);"))
|
|
54
|
+
res.actions.append(Action(
|
|
55
|
+
"gcp", "sql", "Count rows (billed per bytes processed)",
|
|
56
|
+
"\n".join(f"SELECT '{tb.name}' AS table_name, COUNT(*) AS row_count "
|
|
57
|
+
f"FROM `{t.project}.{t.dataset}.{tb.name}`;" for tb in tables), costs_money=True))
|
|
58
|
+
return res
|
|
59
|
+
|
|
60
|
+
def _run(self, action: Action) -> str | None:
|
|
61
|
+
if self._client is None:
|
|
62
|
+
try:
|
|
63
|
+
from google.cloud import bigquery
|
|
64
|
+
except ImportError as exc:
|
|
65
|
+
raise ImportError("pip install 'sql2iceberg[gcp]'") from exc
|
|
66
|
+
self._client = bigquery.Client(project=self.t.project, location=self.t.location)
|
|
67
|
+
lines = []
|
|
68
|
+
for stmt in [s for s in action.text.split(";\n") if s.strip()]:
|
|
69
|
+
rows = self._client.query(stmt.rstrip(";")).result()
|
|
70
|
+
if stmt.lstrip().upper().startswith("SELECT"):
|
|
71
|
+
lines.extend(", ".join(str(v) for v in tuple(r)) for r in rows)
|
|
72
|
+
return "\n".join(lines) or None
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Find the current metadata.json of Iceberg tables."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .base import IcebergTable
|
|
9
|
+
|
|
10
|
+
_VERSION = re.compile(r"^(?:v)?(\d+)(?:-[0-9a-f-]+)?\.(?:gz\.)?metadata\.json$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def latest_local_metadata(warehouse: str | Path, namespace: str, table: str) -> Path:
|
|
14
|
+
"""Newest metadata file of a Hadoop-catalog table on a local or mounted path."""
|
|
15
|
+
folder = Path(warehouse) / namespace / table / "metadata"
|
|
16
|
+
best: tuple[int, Path] | None = None
|
|
17
|
+
for f in folder.glob("*.metadata.json*"):
|
|
18
|
+
m = _VERSION.match(f.name)
|
|
19
|
+
if m and (best is None or int(m.group(1)) > best[0]):
|
|
20
|
+
best = (int(m.group(1)), f)
|
|
21
|
+
if best is None:
|
|
22
|
+
raise FileNotFoundError(f"no metadata.json under {folder}")
|
|
23
|
+
return best[1]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def metadata_from_spark(spark, catalog: str, namespace: str, table: str) -> str:
|
|
27
|
+
"""Current metadata location from Iceberg's metadata_log_entries (works for any catalog type)."""
|
|
28
|
+
rows = spark.sql(
|
|
29
|
+
f"SELECT file FROM {catalog}.{namespace}.{table}.metadata_log_entries ORDER BY timestamp DESC LIMIT 1"
|
|
30
|
+
).collect()
|
|
31
|
+
if not rows:
|
|
32
|
+
raise LookupError(f"{catalog}.{namespace}.{table} has no metadata log")
|
|
33
|
+
return rows[0][0]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def tables_from_spark(spark, catalog: str, namespace: str) -> list[IcebergTable]:
|
|
37
|
+
"""Every table in a namespace with its current metadata location and format version."""
|
|
38
|
+
out = []
|
|
39
|
+
for row in spark.sql(f"SHOW TABLES IN {catalog}.{namespace}").collect():
|
|
40
|
+
name = row["tableName"]
|
|
41
|
+
loc = metadata_from_spark(spark, catalog, namespace, name)
|
|
42
|
+
props = {r[0]: r[1] for r in spark.sql(f"SHOW TBLPROPERTIES {catalog}.{namespace}.{name}").collect()}
|
|
43
|
+
out.append(IcebergTable(name, namespace, loc, int(props.get("format-version", 2))))
|
|
44
|
+
return out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def tables_from_jdbc_catalog(sqlite_file: str | Path, namespace: str, catalog: str = "lake",
|
|
48
|
+
format_version: int = 2) -> list[IcebergTable]:
|
|
49
|
+
"""Tables and current metadata locations from an Iceberg JdbcCatalog stored in a SQLite file.
|
|
50
|
+
|
|
51
|
+
Use this when the catalog is the SQLite file written by ``catalog_type="jdbc"``. ``format_version`` is not
|
|
52
|
+
stored in the catalog table, so pass the version the tables were written with.
|
|
53
|
+
"""
|
|
54
|
+
import sqlite3
|
|
55
|
+
|
|
56
|
+
con = sqlite3.connect(str(sqlite_file))
|
|
57
|
+
try:
|
|
58
|
+
rows = con.execute(
|
|
59
|
+
"SELECT table_name, metadata_location FROM iceberg_tables "
|
|
60
|
+
"WHERE catalog_name = ? AND table_namespace = ? ORDER BY table_name", (catalog, namespace)).fetchall()
|
|
61
|
+
finally:
|
|
62
|
+
con.close()
|
|
63
|
+
return [IcebergTable(n, namespace, loc, format_version) for n, loc in rows]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Snowflake: query Iceberg tables you manage elsewhere, as externally managed Iceberg tables.
|
|
2
|
+
|
|
3
|
+
Two ways to tell Snowflake where the table is:
|
|
4
|
+
* ``glue`` catalog integration to AWS Glue; Snowflake follows the Glue table (CATALOG_TABLE_NAME)
|
|
5
|
+
* ``object_store`` catalog integration on the storage itself; each table points at a metadata.json file
|
|
6
|
+
|
|
7
|
+
SQL follows the Snowflake documentation for CREATE EXTERNAL VOLUME, CREATE CATALOG INTEGRATION and
|
|
8
|
+
CREATE ICEBERG TABLE (externally managed). The table DDL matches the shape of a table already created on a live
|
|
9
|
+
account by an earlier session (object_store mode); Glue mode and the volume/integration DDL are unverified
|
|
10
|
+
until the live test in tests/test_live.py has been run.
|
|
11
|
+
Creating these objects needs a role with the matching privileges, and queries use warehouse credits.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
from .base import Action, IcebergTable, Publisher, PublishResult, ident, lit
|
|
20
|
+
|
|
21
|
+
_PROVIDERS = {"s3": "S3", "gs": "GCS", "gcs": "GCS", "azure": "AZURE", "abfss": "AZURE"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class SnowflakeTarget:
|
|
26
|
+
database: str
|
|
27
|
+
schema: str
|
|
28
|
+
base_url: str # storage root that contains the warehouse, e.g. s3://bucket/warehouse/
|
|
29
|
+
mode: str = "object_store" # object_store | glue
|
|
30
|
+
external_volume: str = "SF2ICE_VOL"
|
|
31
|
+
catalog_integration: str = "SF2ICE_CATALOG"
|
|
32
|
+
aws_role_arn: str = "" # role Snowflake assumes to read the bucket (S3) and Glue (glue mode)
|
|
33
|
+
glue_catalog_id: str = "" # AWS account id (glue mode)
|
|
34
|
+
glue_region: str = ""
|
|
35
|
+
glue_namespace: str = "db"
|
|
36
|
+
azure_tenant_id: str = ""
|
|
37
|
+
auto_refresh: bool = False # glue mode only
|
|
38
|
+
create_volume: bool = True # False: reuse an existing external volume with this name
|
|
39
|
+
create_integration: bool = True # False: reuse an existing catalog integration with this name
|
|
40
|
+
create_schema: bool = True
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
for field_name in ("database", "schema", "external_volume", "catalog_integration"):
|
|
44
|
+
ident(getattr(self, field_name), field_name)
|
|
45
|
+
if self.mode not in ("object_store", "glue"):
|
|
46
|
+
raise ValueError("mode must be object_store or glue")
|
|
47
|
+
scheme = urlparse(self.base_url).scheme
|
|
48
|
+
if scheme not in _PROVIDERS:
|
|
49
|
+
raise ValueError(f"base_url must start with one of {sorted(_PROVIDERS)}://")
|
|
50
|
+
if scheme == "s3" and self.create_volume and not self.aws_role_arn:
|
|
51
|
+
raise ValueError("aws_role_arn is required for an S3 external volume")
|
|
52
|
+
if self.mode == "glue" and self.create_integration and not (
|
|
53
|
+
self.glue_catalog_id and self.glue_region and self.aws_role_arn):
|
|
54
|
+
raise ValueError("glue mode needs glue_catalog_id, glue_region and aws_role_arn")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SnowflakePublisher(Publisher):
|
|
58
|
+
target = "snowflake"
|
|
59
|
+
|
|
60
|
+
def __init__(self, target: SnowflakeTarget, connector=None) -> None:
|
|
61
|
+
super().__init__()
|
|
62
|
+
self.t = target
|
|
63
|
+
self._connector = connector
|
|
64
|
+
|
|
65
|
+
def _volume_sql(self) -> str:
|
|
66
|
+
t = self.t
|
|
67
|
+
scheme = urlparse(t.base_url).scheme
|
|
68
|
+
provider = _PROVIDERS[scheme]
|
|
69
|
+
base = self._base()
|
|
70
|
+
fields = ["NAME = 'loc1'", f"STORAGE_PROVIDER = '{provider}'", f"STORAGE_BASE_URL = {lit(base)}"]
|
|
71
|
+
if provider == "S3":
|
|
72
|
+
fields.append(f"STORAGE_AWS_ROLE_ARN = {lit(t.aws_role_arn)}")
|
|
73
|
+
if provider == "AZURE":
|
|
74
|
+
fields.append(f"AZURE_TENANT_ID = {lit(t.azure_tenant_id)}")
|
|
75
|
+
return (f"CREATE EXTERNAL VOLUME IF NOT EXISTS {t.external_volume}\n STORAGE_LOCATIONS = (( "
|
|
76
|
+
+ ", ".join(fields) + " ));")
|
|
77
|
+
|
|
78
|
+
def _integration_sql(self) -> str:
|
|
79
|
+
t = self.t
|
|
80
|
+
if t.mode == "glue":
|
|
81
|
+
return (f"CREATE CATALOG INTEGRATION IF NOT EXISTS {t.catalog_integration}\n"
|
|
82
|
+
f" CATALOG_SOURCE = GLUE\n CATALOG_NAMESPACE = {lit(t.glue_namespace)}\n TABLE_FORMAT = ICEBERG\n"
|
|
83
|
+
f" GLUE_AWS_ROLE_ARN = {lit(t.aws_role_arn)}\n GLUE_CATALOG_ID = {lit(t.glue_catalog_id)}\n"
|
|
84
|
+
f" GLUE_REGION = {lit(t.glue_region)}\n ENABLED = TRUE;")
|
|
85
|
+
return (f"CREATE CATALOG INTEGRATION IF NOT EXISTS {t.catalog_integration}\n"
|
|
86
|
+
f" CATALOG_SOURCE = OBJECT_STORE\n TABLE_FORMAT = ICEBERG\n ENABLED = TRUE;")
|
|
87
|
+
|
|
88
|
+
def _base(self) -> str:
|
|
89
|
+
base = self.t.base_url if self.t.base_url.endswith("/") else self.t.base_url + "/"
|
|
90
|
+
return base.replace("gs://", "gcs://", 1)
|
|
91
|
+
|
|
92
|
+
def _relative_path(self, uri: str) -> str:
|
|
93
|
+
"""Path of a metadata file relative to the external volume's base URL."""
|
|
94
|
+
uri, base = uri.replace("gs://", "gcs://", 1), self._base()
|
|
95
|
+
if not uri.startswith(base):
|
|
96
|
+
raise ValueError(f"{uri} is not under the external volume base {base}")
|
|
97
|
+
return uri[len(base):]
|
|
98
|
+
|
|
99
|
+
def _table_sql(self, tb: IcebergTable) -> str:
|
|
100
|
+
t = self.t
|
|
101
|
+
head = f"CREATE ICEBERG TABLE IF NOT EXISTS {t.database}.{t.schema}.{ident(tb.name, 'table name')}\n"
|
|
102
|
+
head += f" EXTERNAL_VOLUME = {lit(t.external_volume)}\n CATALOG = {lit(t.catalog_integration)}\n"
|
|
103
|
+
if t.mode == "glue":
|
|
104
|
+
return head + f" CATALOG_TABLE_NAME = {lit(tb.name)}" + (
|
|
105
|
+
"\n AUTO_REFRESH = TRUE" if t.auto_refresh else "") + ";"
|
|
106
|
+
if not tb.metadata_location:
|
|
107
|
+
raise ValueError(f"{tb.name}: metadata_location is required in object_store mode")
|
|
108
|
+
return head + f" METADATA_FILE_PATH = {lit(self._relative_path(tb.metadata_location))};"
|
|
109
|
+
|
|
110
|
+
def plan(self, tables: list[IcebergTable]) -> PublishResult:
|
|
111
|
+
t = self.t
|
|
112
|
+
res = PublishResult()
|
|
113
|
+
if any(tb.format_version >= 3 for tb in tables):
|
|
114
|
+
res.warnings.append("Some tables are Iceberg format-version 3. Check the current Snowflake docs for "
|
|
115
|
+
"v3 support in externally managed tables; use --iceberg-version 2 if unsure.")
|
|
116
|
+
if t.mode == "object_store":
|
|
117
|
+
res.warnings.append("object_store mode points at a fixed metadata file. Re-run after each write "
|
|
118
|
+
"(ALTER ICEBERG TABLE ... REFRESH 'new/metadata.json') or use glue mode.")
|
|
119
|
+
add = res.actions.append
|
|
120
|
+
if t.create_volume:
|
|
121
|
+
add(Action("snowflake", "sql", "Create external volume", self._volume_sql()))
|
|
122
|
+
if t.create_integration:
|
|
123
|
+
add(Action("snowflake", "sql", "Create catalog integration", self._integration_sql()))
|
|
124
|
+
if t.create_schema:
|
|
125
|
+
add(Action("snowflake", "sql", "Create schema", f"CREATE SCHEMA IF NOT EXISTS {t.database}.{t.schema};"))
|
|
126
|
+
for tb in tables:
|
|
127
|
+
add(Action("snowflake", "sql", f"Expose {tb.name}", self._table_sql(tb)))
|
|
128
|
+
add(Action("snowflake", "sql", "Count rows (uses a warehouse)",
|
|
129
|
+
"\n".join(f"SELECT '{tb.name}' AS table_name, COUNT(*) AS row_count "
|
|
130
|
+
f"FROM {t.database}.{t.schema}.{tb.name};" for tb in tables), costs_money=True))
|
|
131
|
+
return res
|
|
132
|
+
|
|
133
|
+
def _run(self, action: Action) -> str | None:
|
|
134
|
+
if self._connector is None:
|
|
135
|
+
from ..connectors import get_connector
|
|
136
|
+
|
|
137
|
+
self._connector = get_connector("snowflake")
|
|
138
|
+
lines = []
|
|
139
|
+
for stmt in [x for x in action.text.split(";\n") if x.strip()]:
|
|
140
|
+
rows = self._connector.query(stmt.rstrip(";"))
|
|
141
|
+
if stmt.lstrip().upper().startswith("SELECT"):
|
|
142
|
+
lines.extend(", ".join(str(v) for v in r) for r in rows)
|
|
143
|
+
return "\n".join(lines) or None
|