continuo-validation-trino 0.1.0__tar.gz
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.
- continuo_validation_trino-0.1.0/.gitignore +8 -0
- continuo_validation_trino-0.1.0/PKG-INFO +37 -0
- continuo_validation_trino-0.1.0/README.md +23 -0
- continuo_validation_trino-0.1.0/continuo_validation_trino/__init__.py +1 -0
- continuo_validation_trino-0.1.0/continuo_validation_trino/adapter.py +130 -0
- continuo_validation_trino-0.1.0/pyproject.toml +37 -0
- continuo_validation_trino-0.1.0/testinfra/catalog/iceberg.properties +10 -0
- continuo_validation_trino-0.1.0/tests/__init__.py +0 -0
- continuo_validation_trino-0.1.0/tests/test_adapter.py +22 -0
- continuo_validation_trino-0.1.0/tests/test_integration_trino.py +149 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: continuo-validation-trino
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Trino warehouse adapter for the continuo validation runner.
|
|
5
|
+
Author: Simone Carolini
|
|
6
|
+
Maintainer: Simone Carolini
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
10
|
+
Requires-Python: >=3.14
|
|
11
|
+
Requires-Dist: continuo-validation-contract==0.2.0
|
|
12
|
+
Requires-Dist: trino==0.338.0
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# continuo-validation-trino
|
|
16
|
+
|
|
17
|
+
Trino warehouse adapter for the [continuo](https://github.com/carolsimone/continuo)
|
|
18
|
+
validation runner. Implements `continuo_validation_contract.port.WarehouseAdapter`;
|
|
19
|
+
continuo's harness discovers it through the `continuo_validation.adapters` entry point.
|
|
20
|
+
|
|
21
|
+
Trino's namespace is `catalog.schema`. The catalog comes from `TRINO_CATALOG` and every
|
|
22
|
+
statement is fully qualified, so continuo passes a bare candidate schema name and never
|
|
23
|
+
needs to know about catalogs.
|
|
24
|
+
|
|
25
|
+
## Connection env
|
|
26
|
+
|
|
27
|
+
| Env | Required | Default | Purpose |
|
|
28
|
+
|---|---|---|---|
|
|
29
|
+
| `TRINO_HOST` | yes | — | Coordinator host |
|
|
30
|
+
| `TRINO_CATALOG` | yes | — | Catalog holding candidate schemas (e.g. `iceberg`) |
|
|
31
|
+
| `TRINO_PORT` | no | `8080` | Coordinator port |
|
|
32
|
+
| `TRINO_USER` | no | `continuo` | Trino user |
|
|
33
|
+
| `TRINO_HTTP_SCHEME` | no | `http` | `http` or `https` |
|
|
34
|
+
| `TRINO_PASSWORD` | no | unset | Basic auth; requires `TRINO_HTTP_SCHEME=https` |
|
|
35
|
+
|
|
36
|
+
The catalog must be backed by a connector that supports schema and table creation
|
|
37
|
+
(Iceberg and Hive do). The library is tested live against Iceberg (REST catalog + S3).
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# continuo-validation-trino
|
|
2
|
+
|
|
3
|
+
Trino warehouse adapter for the [continuo](https://github.com/carolsimone/continuo)
|
|
4
|
+
validation runner. Implements `continuo_validation_contract.port.WarehouseAdapter`;
|
|
5
|
+
continuo's harness discovers it through the `continuo_validation.adapters` entry point.
|
|
6
|
+
|
|
7
|
+
Trino's namespace is `catalog.schema`. The catalog comes from `TRINO_CATALOG` and every
|
|
8
|
+
statement is fully qualified, so continuo passes a bare candidate schema name and never
|
|
9
|
+
needs to know about catalogs.
|
|
10
|
+
|
|
11
|
+
## Connection env
|
|
12
|
+
|
|
13
|
+
| Env | Required | Default | Purpose |
|
|
14
|
+
|---|---|---|---|
|
|
15
|
+
| `TRINO_HOST` | yes | — | Coordinator host |
|
|
16
|
+
| `TRINO_CATALOG` | yes | — | Catalog holding candidate schemas (e.g. `iceberg`) |
|
|
17
|
+
| `TRINO_PORT` | no | `8080` | Coordinator port |
|
|
18
|
+
| `TRINO_USER` | no | `continuo` | Trino user |
|
|
19
|
+
| `TRINO_HTTP_SCHEME` | no | `http` | `http` or `https` |
|
|
20
|
+
| `TRINO_PASSWORD` | no | unset | Basic auth; requires `TRINO_HTTP_SCHEME=https` |
|
|
21
|
+
|
|
22
|
+
The catalog must be backed by a connector that supports schema and table creation
|
|
23
|
+
(Iceberg and Hive do). The library is tested live against Iceberg (REST catalog + S3).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Trino warehouse adapter for the continuo validation runner."""
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Trino implementation of the WarehouseAdapter port.
|
|
2
|
+
|
|
3
|
+
Trino's namespace is catalog.schema: the catalog comes from TRINO_CATALOG and every
|
|
4
|
+
statement is fully qualified, so the contract's `schema` argument stays an opaque bare
|
|
5
|
+
name. Two Trino traits shape this adapter: there is no DROP SCHEMA ... CASCADE, so
|
|
6
|
+
teardown drops the schema's tables first; and there are no advisory locks, so
|
|
7
|
+
ensure_schema relies on IF NOT EXISTS plus tolerating a concurrent creation.
|
|
8
|
+
"""
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from continuo_validation_contract.port import WarehouseAdapter
|
|
13
|
+
|
|
14
|
+
import trino
|
|
15
|
+
|
|
16
|
+
from trino.auth import BasicAuthentication
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("continuo_validation_trino")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _quote(identifier: str) -> str:
|
|
22
|
+
"""Double-quote a Trino identifier, escaping any embedded double quotes."""
|
|
23
|
+
escaped = identifier.replace('"', '""')
|
|
24
|
+
return f'"{escaped}"'
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class TrinoAdapter(WarehouseAdapter):
|
|
28
|
+
"""WarehouseAdapter speaking Trino DDL over the trino DBAPI."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, conn: "trino.dbapi.Connection", catalog: str) -> None:
|
|
31
|
+
self._conn = conn
|
|
32
|
+
self._catalog = catalog
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def required_env(cls) -> list[str]:
|
|
36
|
+
"""Vars that must be non-empty before connecting."""
|
|
37
|
+
return ["TRINO_HOST", "TRINO_CATALOG"]
|
|
38
|
+
|
|
39
|
+
@classmethod
|
|
40
|
+
def from_env(cls) -> "TrinoAdapter":
|
|
41
|
+
"""Connect from TRINO_* env (port 8080, user continuo, http; password optional)."""
|
|
42
|
+
catalog = os.environ["TRINO_CATALOG"]
|
|
43
|
+
user = os.environ.get("TRINO_USER", "continuo")
|
|
44
|
+
http_scheme = os.environ.get("TRINO_HTTP_SCHEME", "http")
|
|
45
|
+
password = os.environ.get("TRINO_PASSWORD", "")
|
|
46
|
+
if password and http_scheme != "https":
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"TRINO_PASSWORD is set but TRINO_HTTP_SCHEME is not 'https'; "
|
|
49
|
+
"Trino refuses basic auth over plaintext"
|
|
50
|
+
)
|
|
51
|
+
conn = trino.dbapi.connect(
|
|
52
|
+
host=os.environ["TRINO_HOST"],
|
|
53
|
+
port=int(os.environ.get("TRINO_PORT", "8080")),
|
|
54
|
+
user=user,
|
|
55
|
+
catalog=catalog,
|
|
56
|
+
http_scheme=http_scheme,
|
|
57
|
+
auth=BasicAuthentication(user, password) if password else None,
|
|
58
|
+
)
|
|
59
|
+
return cls(conn, catalog)
|
|
60
|
+
|
|
61
|
+
def _schema_ref(self, schema: str) -> str:
|
|
62
|
+
return f"{_quote(self._catalog)}.{_quote(schema)}"
|
|
63
|
+
|
|
64
|
+
def _table_ref(self, schema: str, table: str) -> str:
|
|
65
|
+
return f"{self._schema_ref(schema)}.{_quote(table)}"
|
|
66
|
+
|
|
67
|
+
def _execute(self, statement: str) -> list[tuple]:
|
|
68
|
+
"""Run one statement to completion and return its rows.
|
|
69
|
+
|
|
70
|
+
The trino DBAPI is lazy: execute() only starts the query, so the results must
|
|
71
|
+
be consumed for DDL to actually take effect.
|
|
72
|
+
"""
|
|
73
|
+
cur = self._conn.cursor()
|
|
74
|
+
try:
|
|
75
|
+
cur.execute(statement)
|
|
76
|
+
rows: list[tuple] = cur.fetchall()
|
|
77
|
+
return rows
|
|
78
|
+
finally:
|
|
79
|
+
cur.close()
|
|
80
|
+
|
|
81
|
+
def ensure_schema(self, schema: str) -> None:
|
|
82
|
+
"""Idempotently create *schema*; safe under concurrent callers."""
|
|
83
|
+
logger.info("ensuring candidate schema %s.%s exists", self._catalog, schema)
|
|
84
|
+
try:
|
|
85
|
+
self._execute(f"CREATE SCHEMA IF NOT EXISTS {self._schema_ref(schema)}")
|
|
86
|
+
except trino.exceptions.TrinoUserError as exc:
|
|
87
|
+
# Trino has no advisory locks; a concurrent creator can still win the race
|
|
88
|
+
# between IF NOT EXISTS and the metastore write. The desired end state holds.
|
|
89
|
+
if "already exists" not in str(exc).lower():
|
|
90
|
+
raise
|
|
91
|
+
logger.info("schema %s already exists (concurrent create); continuing", schema)
|
|
92
|
+
|
|
93
|
+
def drop_schema(self, schema: str) -> None:
|
|
94
|
+
"""Idempotently drop *schema* and everything in it; no-op if absent.
|
|
95
|
+
|
|
96
|
+
Trino has no DROP SCHEMA ... CASCADE, so the schema's tables are dropped first.
|
|
97
|
+
"""
|
|
98
|
+
ref = self._schema_ref(schema)
|
|
99
|
+
try:
|
|
100
|
+
rows = self._execute(f"SHOW TABLES FROM {ref}")
|
|
101
|
+
except trino.exceptions.TrinoUserError as exc:
|
|
102
|
+
if "does not exist" not in str(exc).lower():
|
|
103
|
+
raise
|
|
104
|
+
logger.info("candidate schema %s absent; nothing to drop", schema)
|
|
105
|
+
return
|
|
106
|
+
logger.info("dropping candidate schema %s (%d tables)", schema, len(rows))
|
|
107
|
+
for row in rows:
|
|
108
|
+
self._execute(f"DROP TABLE IF EXISTS {self._table_ref(schema, row[0])}")
|
|
109
|
+
self._execute(f"DROP SCHEMA IF EXISTS {ref}")
|
|
110
|
+
|
|
111
|
+
def build_empty_from_sql(self, schema: str, table: str, compiled_sql: str) -> None:
|
|
112
|
+
"""Create ``schema.table`` empty, shaped by the compiled SELECT."""
|
|
113
|
+
# Strip any trailing terminator so the SELECT nests cleanly inside AS ( ... ).
|
|
114
|
+
inner = compiled_sql.strip().rstrip(";").strip()
|
|
115
|
+
ref = self._table_ref(schema, table)
|
|
116
|
+
self._execute(f"DROP TABLE IF EXISTS {ref}")
|
|
117
|
+
self._execute(f"CREATE TABLE {ref} AS ({inner}) WITH NO DATA")
|
|
118
|
+
|
|
119
|
+
def clone_empty_from_prod(self, candidate_schema: str, prod_schema: str, table: str) -> None:
|
|
120
|
+
"""Create ``candidate_schema.table`` empty, shaped like ``prod_schema.table``."""
|
|
121
|
+
ref = self._table_ref(candidate_schema, table)
|
|
122
|
+
self._execute(f"DROP TABLE IF EXISTS {ref}")
|
|
123
|
+
self._execute(
|
|
124
|
+
f"CREATE TABLE {ref} AS "
|
|
125
|
+
f"SELECT * FROM {self._table_ref(prod_schema, table)} WITH NO DATA"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def close(self) -> None:
|
|
129
|
+
"""Release the underlying connection."""
|
|
130
|
+
self._conn.close()
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
version = "0.1.0"
|
|
3
|
+
name = "continuo-validation-trino"
|
|
4
|
+
description = "Trino warehouse adapter for the continuo validation runner."
|
|
5
|
+
authors = [{ name = "Simone Carolini" }]
|
|
6
|
+
maintainers = [{ name = "Simone Carolini" }]
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
requires-python = ">= 3.14"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"continuo-validation-contract==0.2.0",
|
|
11
|
+
"trino==0.338.0",
|
|
12
|
+
]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Programming Language :: Python :: 3.14",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.entry-points."continuo_validation.adapters"]
|
|
20
|
+
trino = "continuo_validation_trino.adapter:TrinoAdapter"
|
|
21
|
+
|
|
22
|
+
[dependency-groups]
|
|
23
|
+
dev = [
|
|
24
|
+
"ruff==0.3.0",
|
|
25
|
+
"mypy==1.18.2",
|
|
26
|
+
]
|
|
27
|
+
test = [
|
|
28
|
+
"pytest==9.0.3",
|
|
29
|
+
"pytest-cov==4.1.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[build-system]
|
|
33
|
+
requires = ["hatchling"]
|
|
34
|
+
build-backend = "hatchling.build"
|
|
35
|
+
|
|
36
|
+
[tool.hatch.build.targets.wheel]
|
|
37
|
+
packages = ["continuo_validation_trino"]
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
connector.name=iceberg
|
|
2
|
+
iceberg.catalog.type=rest
|
|
3
|
+
iceberg.rest-catalog.uri=http://iceberg-rest:8181
|
|
4
|
+
iceberg.rest-catalog.warehouse=s3://warehouse/
|
|
5
|
+
fs.native-s3.enabled=true
|
|
6
|
+
s3.endpoint=http://minio:9000
|
|
7
|
+
s3.region=us-east-1
|
|
8
|
+
s3.path-style-access=true
|
|
9
|
+
s3.aws-access-key=minioadmin
|
|
10
|
+
s3.aws-secret-key=minioadmin
|
|
File without changes
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Unit tests for the trino adapter — mock-free.
|
|
2
|
+
|
|
3
|
+
DDL behavior (ensure_schema, drop_schema, build_empty_from_sql, clone_empty_from_prod,
|
|
4
|
+
the connection built by from_env, and DDL validity) is verified against a live Trino +
|
|
5
|
+
Iceberg stack in test_integration_trino.py, not with mocked cursors/connections here.
|
|
6
|
+
"""
|
|
7
|
+
from importlib.metadata import entry_points
|
|
8
|
+
|
|
9
|
+
from continuo_validation_trino.adapter import TrinoAdapter
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_required_env_names_host_and_catalog():
|
|
13
|
+
"""Test that required_env lists exactly the two mandatory TRINO_* vars."""
|
|
14
|
+
assert TrinoAdapter.required_env() == ["TRINO_HOST", "TRINO_CATALOG"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_entry_point_registered_and_loads_adapter():
|
|
18
|
+
"""Test that the trino entry point is registered and loads TrinoAdapter."""
|
|
19
|
+
eps = [ep for ep in entry_points(group="continuo_validation.adapters")
|
|
20
|
+
if ep.name == "trino"]
|
|
21
|
+
assert len(eps) == 1
|
|
22
|
+
assert eps[0].load() is TrinoAdapter
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Tier-2 live tests against Trino + Iceberg REST (docker-compose.integration.yml).
|
|
2
|
+
|
|
3
|
+
Run the stack first:
|
|
4
|
+
docker compose -f docker-compose.integration.yml --profile trino up -d --wait
|
|
5
|
+
"""
|
|
6
|
+
import os
|
|
7
|
+
import uuid
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from continuo_validation_trino.adapter import TrinoAdapter
|
|
12
|
+
|
|
13
|
+
TRINO_ENV = {
|
|
14
|
+
"TRINO_HOST": "localhost",
|
|
15
|
+
"TRINO_PORT": os.environ.get("VR_IT_TRINO_PORT", "18080"),
|
|
16
|
+
"TRINO_USER": "continuo",
|
|
17
|
+
"TRINO_CATALOG": "iceberg",
|
|
18
|
+
"TRINO_HTTP_SCHEME": "http",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _adapter() -> TrinoAdapter:
|
|
23
|
+
"""Build an adapter from the live stack's connection env."""
|
|
24
|
+
for key, value in TRINO_ENV.items():
|
|
25
|
+
os.environ[key] = value
|
|
26
|
+
os.environ.pop("TRINO_PASSWORD", None)
|
|
27
|
+
return TrinoAdapter.from_env()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _schema_exists(adapter: TrinoAdapter, schema: str) -> bool:
|
|
31
|
+
rows = adapter._execute("SHOW SCHEMAS FROM iceberg")
|
|
32
|
+
return any(row[0] == schema for row in rows)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture()
|
|
36
|
+
def candidate_schema():
|
|
37
|
+
"""Yield a unique candidate schema name, dropped after the test."""
|
|
38
|
+
schema = f"_candidate_it_{uuid.uuid4().hex[:8]}"
|
|
39
|
+
yield schema
|
|
40
|
+
cleanup = _adapter()
|
|
41
|
+
cleanup.drop_schema(schema)
|
|
42
|
+
cleanup.close()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.integration
|
|
46
|
+
def test_ensure_schema_creates_and_is_idempotent(candidate_schema):
|
|
47
|
+
"""ensure_schema creates the schema; a second call succeeds unchanged."""
|
|
48
|
+
a = _adapter()
|
|
49
|
+
a.ensure_schema(candidate_schema)
|
|
50
|
+
assert _schema_exists(a, candidate_schema)
|
|
51
|
+
a.ensure_schema(candidate_schema) # must not raise
|
|
52
|
+
assert _schema_exists(a, candidate_schema)
|
|
53
|
+
a.close()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@pytest.mark.integration
|
|
57
|
+
def test_drop_schema_removes_schema_containing_tables(candidate_schema):
|
|
58
|
+
"""Trino has no DROP SCHEMA CASCADE: drop_schema must drop the tables first."""
|
|
59
|
+
a = _adapter()
|
|
60
|
+
a.ensure_schema(candidate_schema)
|
|
61
|
+
# Two tables, created directly so this test does not depend on the build methods.
|
|
62
|
+
for table in ("t_one", "t_two"):
|
|
63
|
+
a._execute(
|
|
64
|
+
f'CREATE TABLE iceberg."{candidate_schema}"."{table}" AS '
|
|
65
|
+
f"SELECT 1 AS id WITH NO DATA"
|
|
66
|
+
)
|
|
67
|
+
a.drop_schema(candidate_schema)
|
|
68
|
+
assert not _schema_exists(a, candidate_schema)
|
|
69
|
+
a.close()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@pytest.mark.integration
|
|
73
|
+
def test_drop_schema_on_absent_schema_is_a_noop():
|
|
74
|
+
"""Teardown must never fail a release for an already-clean warehouse."""
|
|
75
|
+
a = _adapter()
|
|
76
|
+
a.drop_schema(f"_candidate_missing_{uuid.uuid4().hex[:8]}") # must not raise
|
|
77
|
+
a.close()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _columns(adapter: TrinoAdapter, schema: str, table: str) -> list[tuple[str, str]]:
|
|
81
|
+
rows = adapter._execute(
|
|
82
|
+
"SELECT column_name, data_type FROM iceberg.information_schema.columns "
|
|
83
|
+
f"WHERE table_schema = '{schema}' AND table_name = '{table}' "
|
|
84
|
+
"ORDER BY ordinal_position"
|
|
85
|
+
)
|
|
86
|
+
return [(row[0], row[1]) for row in rows]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _count(adapter: TrinoAdapter, schema: str, table: str) -> int:
|
|
90
|
+
rows = adapter._execute(f'SELECT count(*) FROM iceberg."{schema}"."{table}"')
|
|
91
|
+
return int(rows[0][0])
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.fixture()
|
|
95
|
+
def prod_table():
|
|
96
|
+
"""Yield a prod-like schema with one populated table; dropped after the test."""
|
|
97
|
+
schema = f"_prod_it_{uuid.uuid4().hex[:8]}"
|
|
98
|
+
a = _adapter()
|
|
99
|
+
a.ensure_schema(schema)
|
|
100
|
+
a._execute(
|
|
101
|
+
f'CREATE TABLE iceberg."{schema}"."src_table" AS '
|
|
102
|
+
"SELECT * FROM (VALUES (1, 'a'), (2, 'b')) AS t(id, name)"
|
|
103
|
+
)
|
|
104
|
+
a.close()
|
|
105
|
+
yield schema
|
|
106
|
+
cleanup = _adapter()
|
|
107
|
+
cleanup.drop_schema(schema)
|
|
108
|
+
cleanup.close()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@pytest.mark.integration
|
|
112
|
+
def test_build_empty_from_sql_live(candidate_schema, prod_table):
|
|
113
|
+
"""Build an empty candidate table shaped by a compiled SELECT."""
|
|
114
|
+
a = _adapter()
|
|
115
|
+
a.ensure_schema(candidate_schema)
|
|
116
|
+
a.build_empty_from_sql(
|
|
117
|
+
candidate_schema, "built",
|
|
118
|
+
f'SELECT id, name FROM iceberg."{prod_table}"."src_table"',
|
|
119
|
+
)
|
|
120
|
+
assert _columns(a, candidate_schema, "built") == [("id", "integer"), ("name", "varchar")]
|
|
121
|
+
assert _count(a, candidate_schema, "built") == 0
|
|
122
|
+
a.close()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pytest.mark.integration
|
|
126
|
+
def test_build_is_rerun_idempotent(candidate_schema, prod_table):
|
|
127
|
+
"""Building the same table twice drops and recreates instead of failing."""
|
|
128
|
+
a = _adapter()
|
|
129
|
+
a.ensure_schema(candidate_schema)
|
|
130
|
+
for _ in range(2):
|
|
131
|
+
a.build_empty_from_sql(
|
|
132
|
+
candidate_schema, "rerun",
|
|
133
|
+
f'SELECT id FROM iceberg."{prod_table}"."src_table"',
|
|
134
|
+
)
|
|
135
|
+
assert _count(a, candidate_schema, "rerun") == 0
|
|
136
|
+
a.close()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.integration
|
|
140
|
+
def test_clone_empty_from_prod_live(candidate_schema, prod_table):
|
|
141
|
+
"""Clone an empty candidate table shaped like the prod table."""
|
|
142
|
+
a = _adapter()
|
|
143
|
+
a.ensure_schema(candidate_schema)
|
|
144
|
+
a.clone_empty_from_prod(candidate_schema, prod_table, "src_table")
|
|
145
|
+
assert _columns(a, candidate_schema, "src_table") == [
|
|
146
|
+
("id", "integer"), ("name", "varchar"),
|
|
147
|
+
]
|
|
148
|
+
assert _count(a, candidate_schema, "src_table") == 0
|
|
149
|
+
a.close()
|