Skip to content

Commit ca476fa

Browse files
authored
Added logic to create table from schema. (#219)
1 parent 0771dd5 commit ca476fa

17 files changed

Lines changed: 577 additions & 45 deletions

File tree

‎ice-rest-catalog/src/test/java/com/altinity/ice/rest/catalog/DockerElasticMQWatchIT.java‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ public class DockerElasticMQWatchIT {
8080
private static final String DEFAULT_CATALOG_IMAGE =
8181
"altinity/ice-rest-catalog:debug-with-ice-local";
8282
private static final String DEFAULT_ELASTICMQ_IMAGE = "softwaremill/elasticmq-native:1.6.15";
83-
private static final String DEFAULT_MINIO_IMAGE = "quay.io/minio/minio:latest";
83+
private static final String DEFAULT_MINIO_IMAGE = "rustfs/rustfs:1.0.0";
8484

8585
private static final String BUCKET = "test-bucket";
8686
private static final String QUEUE_NAME = "s3-events";
@@ -116,10 +116,10 @@ public void setUp() throws Exception {
116116
.withNetwork(network)
117117
.withNetworkAliases("minio")
118118
.withExposedPorts(9000)
119-
.withEnv("MINIO_ACCESS_KEY", "minioadmin")
120-
.withEnv("MINIO_SECRET_KEY", "minioadmin")
121-
.withCommand("server", "/data")
122-
.waitingFor(Wait.forHttp("/minio/health/live").forPort(9000));
119+
.withEnv("RUSTFS_ACCESS_KEY", "minioadmin")
120+
.withEnv("RUSTFS_SECRET_KEY", "minioadmin")
121+
.withCommand("/data")
122+
.waitingFor(Wait.forHttp("/health").forPort(9000));
123123
minio.start();
124124

125125
String minioHostEndpoint = "http://" + minio.getHost() + ":" + minio.getMappedPort(9000);

‎ice-rest-catalog/src/test/java/com/altinity/ice/rest/catalog/DockerScenarioBasedIT.java‎

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import org.testcontainers.containers.GenericContainer;
2121
import org.testcontainers.containers.Network;
2222
import org.testcontainers.containers.wait.strategy.Wait;
23+
import org.testcontainers.images.builder.ImageFromDockerfile;
2324
import org.testcontainers.utility.MountableFile;
2425
import org.testng.annotations.AfterClass;
2526
import org.testng.annotations.BeforeClass;
@@ -49,6 +50,8 @@ public class DockerScenarioBasedIT extends RESTCatalogTestBase {
4950

5051
private GenericContainer<?> clickhouse;
5152

53+
private GenericContainer<?> pyiceberg;
54+
5255
@Override
5356
@BeforeClass
5457
@SuppressWarnings("resource")
@@ -61,14 +64,14 @@ public void setUp() throws Exception {
6164

6265
// Start MinIO
6366
minio =
64-
new GenericContainer<>("quay.io/minio/minio:latest")
67+
new GenericContainer<>("rustfs/rustfs:1.0.0")
6568
.withNetwork(network)
6669
.withNetworkAliases("minio")
6770
.withExposedPorts(9000)
68-
.withEnv("MINIO_ACCESS_KEY", "minioadmin")
69-
.withEnv("MINIO_SECRET_KEY", "minioadmin")
70-
.withCommand("server", "/data")
71-
.waitingFor(Wait.forHttp("/minio/health/live").forPort(9000));
71+
.withEnv("RUSTFS_ACCESS_KEY", "minioadmin")
72+
.withEnv("RUSTFS_SECRET_KEY", "minioadmin")
73+
.withCommand("/data")
74+
.waitingFor(Wait.forHttp("/health").forPort(9000));
7275
minio.start();
7376

7477
// Create test bucket via MinIO's host-mapped port
@@ -149,6 +152,29 @@ public void setUp() throws Exception {
149152
throw e;
150153
}
151154

155+
// PyIceberg container: independent Iceberg implementation used by scenarios (validate.py)
156+
// to cross-check tables/data written by ice. Built locally from test resources.
157+
URL pyicebergDockerfile = getClass().getClassLoader().getResource("pyiceberg/Dockerfile");
158+
if (pyicebergDockerfile == null) {
159+
throw new IllegalStateException("pyiceberg/Dockerfile not found on classpath");
160+
}
161+
pyiceberg =
162+
new GenericContainer<>(
163+
new ImageFromDockerfile("ice-test-pyiceberg", false)
164+
.withDockerfile(Paths.get(pyicebergDockerfile.toURI())))
165+
.withNetwork(network)
166+
.withNetworkAliases("pyiceberg")
167+
.withCopyFileToContainer(MountableFile.forHostPath(scenariosDir), "/scenarios")
168+
.withCommand("sleep infinity");
169+
try {
170+
pyiceberg.start();
171+
} catch (Exception e) {
172+
if (pyiceberg != null) {
173+
logger.error("PyIceberg container logs: {}", pyiceberg.getLogs());
174+
}
175+
throw e;
176+
}
177+
152178
// Copy CLI config into container so ice CLI can talk to co-located REST server.
153179
// The s3 section lets CLI commands that read s3:// paths directly (e.g. describe-metadata on
154180
// metadata.json) reach MinIO via its network alias instead of defaulting to AWS.
@@ -180,6 +206,9 @@ public void setUp() throws Exception {
180206
@Override
181207
@AfterClass
182208
public void tearDown() {
209+
if (pyiceberg != null) {
210+
pyiceberg.close();
211+
}
183212
if (clickhouse != null) {
184213
clickhouse.close();
185214
}
@@ -220,9 +249,25 @@ protected ScenarioTestRunner createScenarioRunner(String scenarioName) throws Ex
220249
"Could not set ClickHouse wrapper script executable: " + chWrapperScript);
221250
}
222251

252+
// Wrapper script on host: docker exec <pyiceberg container> python3 "$@" (runs validate.py
253+
// inside the pyiceberg container; /scenarios is mounted at the same path as in catalog)
254+
File pyWrapperScript = File.createTempFile("py-docker-exec-", ".sh");
255+
pyWrapperScript.deleteOnExit();
256+
Files.writeString(
257+
pyWrapperScript.toPath(),
258+
"#!/bin/sh\nexec docker exec -e CATALOG_URI_INTERNAL=http://catalog:5000"
259+
+ " -e S3_ENDPOINT_INTERNAL=http://minio:9000 "
260+
+ pyiceberg.getContainerId()
261+
+ " python3 \"$@\"\n");
262+
if (!pyWrapperScript.setExecutable(true)) {
263+
throw new IllegalStateException(
264+
"Could not set PyIceberg wrapper script executable: " + pyWrapperScript);
265+
}
266+
223267
Map<String, String> templateVars = new HashMap<>();
224268
templateVars.put("ICE_CLI", wrapperScript.getAbsolutePath());
225269
templateVars.put("CH_EXEC", chWrapperScript.getAbsolutePath());
270+
templateVars.put("PY_EXEC", pyWrapperScript.getAbsolutePath());
226271
templateVars.put("CLI_CONFIG", "/tmp/ice-cli.yaml");
227272
templateVars.put("SCENARIO_DIR", "/scenarios/" + scenarioName);
228273
templateVars.put("MINIO_ENDPOINT", "");

‎ice-rest-catalog/src/test/java/com/altinity/ice/rest/catalog/RESTCatalogTestBase.java‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,11 @@ public abstract class RESTCatalogTestBase {
5050

5151
@SuppressWarnings("rawtypes")
5252
protected final GenericContainer minio =
53-
new GenericContainer("quay.io/minio/minio:latest")
53+
new GenericContainer("rustfs/rustfs:1.0.0")
5454
.withExposedPorts(9000)
55-
.withEnv("MINIO_ACCESS_KEY", "minioadmin")
56-
.withEnv("MINIO_SECRET_KEY", "minioadmin")
57-
.withCommand("server", "/data");
55+
.withEnv("RUSTFS_ACCESS_KEY", "minioadmin")
56+
.withEnv("RUSTFS_SECRET_KEY", "minioadmin")
57+
.withCommand("/data");
5858

5959
@BeforeClass
6060
public void setUp() throws Exception {

‎ice-rest-catalog/src/test/java/com/altinity/ice/rest/catalog/ScenarioBasedIT.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ protected ScenarioTestRunner createScenarioRunner(String scenarioName) throws Ex
3939
templateVars.put("CATALOG_URI", getCatalogUri());
4040
// DockerScenarioBasedIT sets these for ClickHouse; empty so basic-operations skips CH block.
4141
templateVars.put("CH_EXEC", "");
42+
// DockerScenarioBasedIT sets this for PyIceberg; empty so scenarios skip pyiceberg validation.
43+
templateVars.put("PY_EXEC", "");
4244
templateVars.put("CATALOG_URI_INTERNAL", "");
4345
templateVars.put("S3_ENDPOINT_INTERNAL", "");
4446

‎ice-rest-catalog/src/test/pyiceberg/uv.lock‎

Lines changed: 7 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# PyIceberg test image for scenario ITs: independent Iceberg reader used to validate
2+
# tables/data written by ice. Built at test time via Testcontainers ImageFromDockerfile.
3+
FROM python:3.12-slim
4+
ARG PYICEBERG_VERSION=0.12.0
5+
RUN pip install --no-cache-dir "pyiceberg[pyarrow,s3fs]==${PYICEBERG_VERSION}"
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#!/bin/bash
2+
set -e
3+
4+
echo "Running Iceberg v3 unknown-type (--schema) test..."
5+
6+
# Create namespace
7+
{{ICE_CLI}} --config {{CLI_CONFIG}} create-namespace ${NAMESPACE_NAME}
8+
echo "OK Created namespace: ${NAMESPACE_NAME}"
9+
10+
# Create a v3 table with an explicit schema containing a v3-only unknown column
11+
# (not representable in Parquet, so --schema-from-parquet cannot express it)
12+
{{ICE_CLI}} --config {{CLI_CONFIG}} create-table ${TABLE_NAME} --format-version=3 \
13+
--schema '[{"name":"id","type":"long","required":true},{"name":"payload","type":"unknown"}]'
14+
echo "OK Created table ${TABLE_NAME} via --schema (unknown column)"
15+
16+
# Verify schema output shows the unknown column
17+
{{ICE_CLI}} --config {{CLI_CONFIG}} describe -s ${TABLE_NAME} > /tmp/v3_unknown_schema.txt
18+
if ! grep -q "payload: optional unknown" /tmp/v3_unknown_schema.txt; then
19+
echo "FAIL describe -s output missing 'payload: optional unknown'"
20+
cat /tmp/v3_unknown_schema.txt
21+
exit 1
22+
fi
23+
echo "OK describe -s shows payload: optional unknown"
24+
25+
# Verify describe output shows formatVersion 3 (Iceberg strips format-version from properties)
26+
{{ICE_CLI}} --config {{CLI_CONFIG}} describe ${TABLE_NAME} > /tmp/v3_unknown_describe.txt
27+
if ! grep -q "formatVersion: 3" /tmp/v3_unknown_describe.txt; then
28+
echo "FAIL describe output missing 'formatVersion: 3'"
29+
cat /tmp/v3_unknown_describe.txt
30+
exit 1
31+
fi
32+
echo "OK describe shows formatVersion: 3"
33+
34+
# Negative: unknown type must be rejected without --format-version=3 (default is v2)
35+
if {{ICE_CLI}} --config {{CLI_CONFIG}} create-table ${NAMESPACE_NAME}.t2 \
36+
--schema '[{"name":"payload","type":"unknown"}]' > /tmp/v3_unknown_v2.out 2>&1; then
37+
echo "FAIL create-table with unknown type on v2 succeeded unexpectedly"
38+
exit 1
39+
fi
40+
if ! grep -q "not supported until v3" /tmp/v3_unknown_v2.out; then
41+
echo "FAIL v2 rejection missing 'not supported until v3'"
42+
cat /tmp/v3_unknown_v2.out
43+
exit 1
44+
fi
45+
echo "OK v2 table with unknown type rejected"
46+
47+
# Independent validation via pyiceberg (Docker runner only; empty PY_EXEC = skipped)
48+
if [ -n "{{PY_EXEC}}" ]; then
49+
{{PY_EXEC}} "${SCENARIO_DIR}/validate.py" ${TABLE_NAME}
50+
echo "OK pyiceberg validation passed"
51+
else
52+
echo "SKIP pyiceberg validation (PY_EXEC not available)"
53+
fi
54+
55+
# Cleanup
56+
{{ICE_CLI}} --config {{CLI_CONFIG}} delete-table ${TABLE_NAME}
57+
echo "OK Deleted table: ${TABLE_NAME}"
58+
59+
{{ICE_CLI}} --config {{CLI_CONFIG}} delete-namespace ${NAMESPACE_NAME}
60+
echo "OK Deleted namespace: ${NAMESPACE_NAME}"
61+
62+
echo "Iceberg v3 unknown-type test completed successfully"
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
name: "Iceberg v3 Unknown Type via --schema"
2+
description: "Tests create-table --schema with a v3-only unknown column; verifies via ice describe and pyiceberg (independent reader)"
3+
4+
catalogConfig:
5+
warehouse: "s3://test-bucket/warehouse"
6+
7+
env:
8+
NAMESPACE_NAME: "test_v3_unknown"
9+
TABLE_NAME: "test_v3_unknown.t1"
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Independent validation of an ice-created v3 table using PyIceberg.
2+
3+
Runs inside the pyiceberg test container (see scenarios/pyiceberg/Dockerfile).
4+
Reads the table via the REST catalog and asserts:
5+
- format_version == 3
6+
- the payload column has the v3-only UnknownType
7+
- the table scans to zero rows (unknown columns are always null per spec)
8+
9+
Usage: python3 validate.py <table-id> (e.g. test_v3_unknown.t1)
10+
"""
11+
12+
import os
13+
import sys
14+
15+
from pyiceberg.catalog import load_catalog
16+
from pyiceberg.types import UnknownType
17+
18+
19+
def main() -> int:
20+
table_id = sys.argv[1] if len(sys.argv) > 1 else "test_v3_unknown.t1"
21+
catalog_uri = os.environ.get("CATALOG_URI_INTERNAL", "http://catalog:5000")
22+
s3_endpoint = os.environ.get("S3_ENDPOINT_INTERNAL", "http://minio:9000")
23+
24+
catalog = load_catalog(
25+
"rest",
26+
uri=catalog_uri,
27+
warehouse="s3://test-bucket/warehouse",
28+
**{
29+
# ice-rest-catalog test config uses anonymous access; without this, pyiceberg
30+
# sends "Authorization: Bearer None" (LegacyOAuth2AuthManager) -> 403 Invalid token
31+
"auth": {"type": "noop"},
32+
"s3.endpoint": s3_endpoint,
33+
"s3.access-key-id": "minioadmin",
34+
"s3.secret-access-key": "minioadmin",
35+
},
36+
)
37+
table = catalog.load_table(table_id)
38+
39+
format_version = table.metadata.format_version
40+
assert format_version == 3, f"expected format_version 3, got {format_version}"
41+
print(f"OK pyiceberg: format_version == {format_version}")
42+
43+
field = table.schema().find_field("payload")
44+
assert isinstance(
45+
field.field_type, UnknownType
46+
), f"expected UnknownType for payload, got {field.field_type}"
47+
print(f"OK pyiceberg: payload type is {field.field_type}")
48+
49+
rows = table.scan().to_arrow()
50+
assert rows.num_rows == 0, f"expected 0 rows, got {rows.num_rows}"
51+
print("OK pyiceberg: scan returned 0 rows")
52+
return 0
53+
54+
55+
if __name__ == "__main__":
56+
sys.exit(main())

‎ice-rest-catalog/src/test/resources/scenarios/insert-scan/run.sh.tmpl‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ fi
3434

3535
echo "OK Scan output contains expected columns"
3636

37+
# Independent validation via pyiceberg (Docker runner only; empty PY_EXEC = skipped)
38+
if [ -n "{{PY_EXEC}}" ]; then
39+
{{PY_EXEC}} "${SCENARIO_DIR}/validate.py" ${TABLE_NAME} "${INPUT_PATH}"
40+
echo "OK pyiceberg validation passed"
41+
else
42+
echo "SKIP pyiceberg validation (PY_EXEC not available)"
43+
fi
44+
3745
# Cleanup
3846
{{ICE_CLI}} --config {{CLI_CONFIG}} delete-table ${TABLE_NAME}
3947
echo "OK Deleted table: ${TABLE_NAME}"

0 commit comments

Comments
 (0)