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,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