This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git
The following commit(s) were added to refs/heads/main by this push:
new 60101154 test: replace Spark provisioning with golden fixtures (#1234)
60101154 is described below
commit 60101154291fb8d1f7773b6fe720c14fe25f6e65
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Oct 27 21:20:41 2026 +0800
test: replace Spark provisioning with golden fixtures (#1234)
---
.github/workflows/ci.yml | 14 -
CONTRIBUTING.md | 12 +-
Cargo.lock | 3 +
Makefile | 38 -
bindings/go/Makefile | 6 +-
bindings/go/tests/paimon_test.go | 25 +-
bindings/go/tests/resource_test.go | 5 -
bindings/go/tests/warehouse_test.go | 81 ++
bindings/python/README.md | 6 +-
bindings/python/tests/test_datafusion.py | 61 +-
crates/integration_tests/Cargo.toml | 3 +
crates/integration_tests/tests/read_tables.rs | 288 +----
crates/integrations/datafusion/src/lib.rs | 3 +
crates/integrations/datafusion/src/table/mod.rs | 37 +-
.../datafusion/testdata/warehouse/README.md | 64 +
.../datafusion/testdata/warehouse/mod.rs | 125 ++
.../datafusion/testdata/warehouse/warehouse.tar.gz | Bin 0 -> 118891 bytes
.../datafusion/tests/dynamic_bucket_tables.rs | 36 +-
.../integrations/datafusion/tests/read_tables.rs | 97 +-
dev/docker-compose.yaml | 41 -
dev/spark/Dockerfile | 53 -
dev/spark/README.md | 60 -
dev/spark/provision.py | 1363 --------------------
dev/spark/spark-defaults.conf | 25 -
docs/src/contributing.md | 9 +-
docs/src/getting-started.md | 4 +-
26 files changed, 486 insertions(+), 1973 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9d9e69d0..feeed88c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -226,22 +226,16 @@ jobs:
matrix:
include:
- suite: rust
- needs_docker: true
needs_uv: false
- suite: datafusion
- needs_docker: true
needs_uv: false
- suite: python
- needs_docker: true
needs_uv: true
- suite: go
- needs_docker: true
needs_uv: false
- suite: jindo
- needs_docker: false
needs_uv: false
- suite: oss-cpp
- needs_docker: false
needs_uv: false
env:
RUST_LOG: DEBUG
@@ -260,10 +254,6 @@ jobs:
restore-keys: |
integration-${{ runner.os }}-
- - name: Start Docker containers
- if: matrix.needs_docker == true
- run: make docker-up
-
- name: Rust Integration Test
if: matrix.suite == 'rust'
run: cargo test --locked -p paimon-integration-tests --all-targets
@@ -314,10 +304,6 @@ jobs:
io::storage_jindo::tests::test_real_sdk_io_and_failure_mapping
-- --ignored --exact
- - name: Stop Docker containers
- if: ${{ always() && matrix.needs_docker }}
- run: make docker-down
-
- name: Checkout OSS C++ SDK
if: matrix.suite == 'oss-cpp'
uses: actions/checkout@v7
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 07bf247b..51ec7376 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -31,19 +31,17 @@ commands CI runs, which are the ones to reproduce locally:
- `cargo test -p paimon --all-targets --features fulltext,vortex`: Run the
unit tests. Prefer this over a bare `cargo test`, which also builds the Python
binding and can fail to link `libpython` on some platforms.
- `cargo test -p paimon-rest-server --all-targets`: Run the REST server tests.
-The integration suites additionally need a Paimon warehouse written by Spark,
so
-they are not pure `cargo`:
+The integration suites automatically extract the committed Java-written golden
+warehouse into temporary directories. No Spark or Docker setup is needed:
```bash
-make docker-up # builds the Spark image and writes /tmp/paimon-warehouse
cargo test -p paimon-integration-tests --all-targets
cargo test -p paimon-datafusion --all-targets
-make docker-down # tear down when finished
```
-Without that warehouse those tests fail with `TableNotExist` rather than being
-skipped. Override the location with `PAIMON_TEST_WAREHOUSE` if you need a
-different path.
+Set `PAIMON_TEST_WAREHOUSE` to use an existing warehouse instead. Missing
tables
+fail the tests rather than being skipped or generated by the Rust writer. See
+[the golden
warehouse](crates/integrations/datafusion/testdata/warehouse/README.md) for
fixture provenance.
### Setting up the Development Environment
1. Install Rust using `rustup`. Follow the instructions on the [rustup
website](https://rustup.rs/) to install Rust on your system.
diff --git a/Cargo.lock b/Cargo.lock
index b22c9933..a2fb5f15 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4807,9 +4807,12 @@ dependencies = [
"arrow-array",
"arrow-schema",
"axum",
+ "flate2",
"futures",
"paimon",
"serde_json",
+ "tar",
+ "tempfile",
"tokio",
]
diff --git a/Makefile b/Makefile
deleted file mode 100644
index 1e18aa7f..00000000
--- a/Makefile
+++ /dev/null
@@ -1,38 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-# Version defaults (can be overridden via environment variables)
-PAIMON_VERSION ?= 1.3.1
-
-# Warehouse path for integration tests
-PAIMON_WAREHOUSE ?= /tmp/paimon-warehouse
-
-.EXPORT_ALL_VARIABLES:
-
-# Docker targets for integration tests.
-# Build and run Spark + Paimon container to provision test data.
-# Usage:
-# make docker-up # use default versions
-# PAIMON_VERSION=1.3.0 make docker-up # override Paimon version
-docker-up:
- docker compose -f dev/docker-compose.yaml build \
- --build-arg PAIMON_VERSION=$(PAIMON_VERSION)
- docker compose -f dev/docker-compose.yaml run --rm spark-paimon
-
-# Stop and remove Docker containers, networks, and volumes.
-docker-down:
- docker compose -f dev/docker-compose.yaml down -v
diff --git a/bindings/go/Makefile b/bindings/go/Makefile
index 44ece884..55e36908 100644
--- a/bindings/go/Makefile
+++ b/bindings/go/Makefile
@@ -49,12 +49,10 @@ build:
zstd -19 -f $(TARGET_DIR)/release/libpaimon_c.$(LIB_EXT) -o
$(GO_DIR)/$(LIB_NAME).zst
# Run Go integration tests.
-# Requires test data: run 'make docker-up' from the repo root first.
+# The tests extract the committed golden warehouse into temporary directories.
# CGO is needed by arrow-go's cdata package (imported by the main paimon
package).
-PAIMON_TEST_WAREHOUSE ?= /tmp/paimon-warehouse
-
test: build
- cd $(GO_DIR)/tests && PAIMON_TEST_WAREHOUSE=$(PAIMON_TEST_WAREHOUSE) go
test -v ./...
+ cd $(GO_DIR)/tests && go test -v ./...
clean:
cd $(RUST_ROOT) && cargo clean
diff --git a/bindings/go/tests/paimon_test.go b/bindings/go/tests/paimon_test.go
index 8a359b0c..bbb257db 100644
--- a/bindings/go/tests/paimon_test.go
+++ b/bindings/go/tests/paimon_test.go
@@ -46,14 +46,6 @@ type partitionedRow struct {
dt string
}
-func testWarehouse() string {
- warehouse := os.Getenv("PAIMON_TEST_WAREHOUSE")
- if warehouse == "" {
- return "/tmp/paimon-warehouse"
- }
- return warehouse
-}
-
func copyDirectory(source, target string) error {
info, err := os.Stat(source)
if err != nil {
@@ -132,11 +124,8 @@ func openCopiedTestTable(t *testing.T) *paimon.Table {
func openCopiedTable(t *testing.T, tableName string) *paimon.Table {
t.Helper()
- warehouse := testWarehouse()
+ warehouse := testWarehouse(t)
source := filepath.Join(warehouse, "default.db", tableName)
- if _, err := os.Stat(source); os.IsNotExist(err) {
- t.Skipf("Skipping: table %s does not exist (run 'make
docker-up' first)", source)
- }
targetWarehouse := t.TempDir()
target := filepath.Join(targetWarehouse, "default.db", tableName)
@@ -275,14 +264,13 @@ func readRows(t *testing.T, rb *paimon.ReadBuilder) []row
{
}
// openTestTable creates a catalog, opens the simple_log_table, and returns
-// the table along with a cleanup function. Skips the test if the warehouse
-// does not exist.
+// the table along with a cleanup function. Missing fixture data fails the
test.
func openTestTable(t *testing.T) *paimon.Table {
t.Helper()
- warehouse := testWarehouse()
+ warehouse := testWarehouse(t)
if _, err := os.Stat(warehouse); os.IsNotExist(err) {
- t.Skipf("Skipping: warehouse %s does not exist (run 'make
docker-up' first)", warehouse)
+ t.Fatalf("Missing test warehouse: %s", warehouse)
}
return openTableAt(t, warehouse, "simple_log_table")
}
@@ -441,11 +429,8 @@ func TestWriteOverwriteUsesBuilderMode(t *testing.T) {
}
func TestOverwriteRetrySameIdentifierIsIdempotent(t *testing.T) {
- warehouse := testWarehouse()
+ warehouse := testWarehouse(t)
source := filepath.Join(warehouse, "default.db", "simple_pk_table")
- if _, err := os.Stat(source); os.IsNotExist(err) {
- t.Skipf("Skipping: table %s does not exist (run 'make
docker-up' first)", source)
- }
targetWarehouse := t.TempDir()
target := filepath.Join(targetWarehouse, "default.db",
"simple_pk_table")
if err := copyDirectory(source, target); err != nil {
diff --git a/bindings/go/tests/resource_test.go
b/bindings/go/tests/resource_test.go
index 83c6e0cf..c4a76654 100644
--- a/bindings/go/tests/resource_test.go
+++ b/bindings/go/tests/resource_test.go
@@ -21,7 +21,6 @@ package paimon_test
import (
"errors"
- "os"
"path/filepath"
"strings"
"testing"
@@ -165,10 +164,6 @@ func TestWriteBuildersShareResourceBudget(t *testing.T) {
}
func TestPostponeWriteBuilderWithResourceBudget(t *testing.T) {
- warehouse := testWarehouse()
- if _, err := os.Stat(filepath.Join(warehouse, "default.db",
"postpone_fixed_bucket_pk_table")); os.IsNotExist(err) {
- t.Skip("postpone fixed-bucket test table is unavailable")
- }
table := openCopiedTable(t, "postpone_fixed_bucket_pk_table")
resources, err := paimon.NewResourceContext(0)
if err != nil {
diff --git a/bindings/go/tests/warehouse_test.go
b/bindings/go/tests/warehouse_test.go
new file mode 100644
index 00000000..4e0345e3
--- /dev/null
+++ b/bindings/go/tests/warehouse_test.go
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package paimon_test
+
+import (
+ "archive/tar"
+ "compress/gzip"
+ "io"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func testWarehouse(t *testing.T) string {
+ t.Helper()
+ if warehouse, ok := os.LookupEnv("PAIMON_TEST_WAREHOUSE"); ok {
+ return warehouse
+ }
+
+ // go test runs each package in its source directory, including with
-trimpath.
+ archivePath := filepath.Join("..", "..", "..", "crates",
"integrations", "datafusion", "testdata", "warehouse", "warehouse.tar.gz")
+ file, err := os.Open(archivePath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer file.Close()
+ decoder, err := gzip.NewReader(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer decoder.Close()
+
+ warehouse := t.TempDir()
+ archive := tar.NewReader(decoder)
+ for {
+ entry, err := archive.Next()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if entry.Typeflag != tar.TypeReg ||
!filepath.IsLocal(entry.Name) {
+ t.Fatalf("Unexpected golden archive entry: %s",
entry.Name)
+ }
+ target := filepath.Join(warehouse,
filepath.FromSlash(entry.Name))
+ if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
+ t.Fatal(err)
+ }
+ output, err := os.Create(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, copyErr := io.Copy(output, archive)
+ closeErr := output.Close()
+ if copyErr != nil {
+ t.Fatal(copyErr)
+ }
+ if closeErr != nil {
+ t.Fatal(closeErr)
+ }
+ }
+ return warehouse
+}
diff --git a/bindings/python/README.md b/bindings/python/README.md
index 696e746a..fd549a08 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -528,11 +528,11 @@ make build
## Test
-Python integration tests expect the shared Paimon test warehouse to be prepared
-first from the repository root:
+Python integration tests automatically extract the committed golden warehouse
+into a temporary directory. Set `PAIMON_TEST_WAREHOUSE` to use an existing
+warehouse instead. Spark and Docker are not required.
```shell
-make docker-up
cd bindings/python
```
diff --git a/bindings/python/tests/test_datafusion.py
b/bindings/python/tests/test_datafusion.py
index 4fc8cfc8..6589c3d4 100644
--- a/bindings/python/tests/test_datafusion.py
+++ b/bindings/python/tests/test_datafusion.py
@@ -22,6 +22,7 @@ import os
import re
import struct
import sys
+import tarfile
import tempfile
import types
from pathlib import Path
@@ -38,7 +39,49 @@ from pypaimon_rust.datafusion import (
udf,
)
-WAREHOUSE = os.environ.get("PAIMON_TEST_WAREHOUSE", "/tmp/paimon-warehouse")
+
[email protected](scope="module")
+def golden_warehouse(tmp_path_factory):
+ override = os.environ.get("PAIMON_TEST_WAREHOUSE")
+ if override is not None:
+ return override
+
+ warehouse = tmp_path_factory.mktemp("golden-warehouse")
+ archive = (
+ Path(__file__).resolve().parents[3]
+ / "crates/integrations/datafusion/testdata/warehouse/warehouse.tar.gz"
+ )
+ with tarfile.open(archive, "r:gz") as fixture:
+ if hasattr(tarfile, "data_filter"):
+ fixture.extraction_filter = tarfile.data_filter
+ members = fixture.getmembers()
+ for member in members:
+ target = (warehouse / member.name).resolve()
+ if not member.isfile() or not target.is_relative_to(warehouse):
+ raise ValueError(f"Unexpected golden archive entry:
{member.name}")
+ fixture.extractall(warehouse, members=members)
+ return str(warehouse)
+
+
+def test_golden_warehouse_with_legacy_tarfile(monkeypatch, tmp_path_factory):
+ monkeypatch.delenv("PAIMON_TEST_WAREHOUSE", raising=False)
+ if hasattr(tarfile, "data_filter"):
+ original_extractall = tarfile.TarFile.extractall
+ trusted_filter = tarfile.fully_trusted_filter
+
+ def legacy_extractall(self, path=".", members=None, *,
numeric_owner=False):
+ return original_extractall(
+ self, path, members, numeric_owner=numeric_owner,
filter=trusted_filter
+ )
+
+ monkeypatch.setattr(tarfile.TarFile, "extractall", legacy_extractall)
+ monkeypatch.delattr(tarfile, "data_filter")
+
+ warehouse = golden_warehouse.__wrapped__(tmp_path_factory)
+ catalog = PaimonCatalog({"warehouse": warehouse})
+ assert
catalog.get_table("default.simple_log_table").latest_snapshot().id() == 1
+
+
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
BLOB_DESCRIPTOR_MAGIC = 0x424C4F4244455343
@@ -544,8 +587,8 @@ def test_vector_json_bridge_functions():
assert json.loads(row["vector_json"]) == [1.0, 2.5]
-def test_query_simple_table_via_catalog_provider():
- catalog = PaimonCatalog({"warehouse": WAREHOUSE})
+def test_query_simple_table_via_catalog_provider(golden_warehouse):
+ catalog = PaimonCatalog({"warehouse": golden_warehouse})
ctx = SessionContext()
ctx.register_catalog_provider("paimon", catalog)
@@ -980,8 +1023,8 @@ def test_register_catalog_discovers_pyjindo(monkeypatch,
tmp_path):
assert str(library) in str(error.value)
-def test_list_databases_and_tables():
- catalog = PaimonCatalog({"warehouse": WAREHOUSE})
+def test_list_databases_and_tables(golden_warehouse):
+ catalog = PaimonCatalog({"warehouse": golden_warehouse})
assert "default" in catalog.list_databases()
@@ -1000,8 +1043,8 @@ def test_list_databases_and_tables():
assert schema.partition_keys() == []
# ---------------- #285: observability ----------------
-def test_snapshots_for_simple_table():
- catalog = PaimonCatalog({"warehouse": WAREHOUSE})
+def test_snapshots_for_simple_table(golden_warehouse):
+ catalog = PaimonCatalog({"warehouse": golden_warehouse})
table = catalog.get_table("default.simple_log_table")
snap = table.latest_snapshot()
@@ -1016,8 +1059,8 @@ def test_snapshots_for_simple_table():
assert snaps[0].id() == snap.id()
-def test_partitions_and_tags_smoke():
- catalog = PaimonCatalog({"warehouse": WAREHOUSE})
+def test_partitions_and_tags_smoke(golden_warehouse):
+ catalog = PaimonCatalog({"warehouse": golden_warehouse})
table = catalog.get_table("default.simple_log_table")
# Non-partitioned, non-tagged table: both should be empty but well-typed.
diff --git a/crates/integration_tests/Cargo.toml
b/crates/integration_tests/Cargo.toml
index c93bfe79..75ec6617 100644
--- a/crates/integration_tests/Cargo.toml
+++ b/crates/integration_tests/Cargo.toml
@@ -33,5 +33,8 @@ tokio = { version = "1", features = ["macros",
"rt-multi-thread"] }
futures = "0.3"
[dev-dependencies]
+flate2 = "1"
+tar = "0.4"
+tempfile = "3"
serde_json = "1"
axum = { version = "0.7", features = ["macros", "tokio", "http1", "http2"] }
diff --git a/crates/integration_tests/tests/read_tables.rs
b/crates/integration_tests/tests/read_tables.rs
index 7de3ea71..2ad1c816 100644
--- a/crates/integration_tests/tests/read_tables.rs
+++ b/crates/integration_tests/tests/read_tables.rs
@@ -15,30 +15,28 @@
// specific language governing permissions and limitations
// under the License.
-//! Integration tests for reading Paimon tables provisioned by Spark.
+//! Integration tests for reading the committed Java-written golden warehouse.
use arrow_array::{
Array, ArrowPrimitiveType, Int32Array, Int64Array, ListArray, MapArray,
PrimitiveArray,
RecordBatch, StringArray, StructArray,
};
-use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Schema as
ArrowSchema};
use futures::TryStreamExt;
use paimon::api::ConfigResponse;
use paimon::catalog::{Identifier, RESTCatalog};
use paimon::common::Options;
use paimon::spec::{DataType, IntType, Predicate, Schema, VarCharType};
-use paimon::{Catalog, CatalogOptions, Error, FileSystemCatalog, Plan,
ScanStats};
+use paimon::{Catalog, Error, Plan, ScanStats};
use std::collections::{HashMap, HashSet};
-use std::sync::{Arc, OnceLock};
+
+#[path = "../../integrations/datafusion/testdata/warehouse/mod.rs"]
+mod golden_warehouse;
+use golden_warehouse::{test_catalog as create_file_system_catalog,
test_warehouse, TestWarehouse};
#[path = "../../paimon/tests/mock_server.rs"]
mod mock_server;
use mock_server::start_mock_server;
-fn get_test_warehouse() -> String {
- std::env::var("PAIMON_TEST_WAREHOUSE").unwrap_or_else(|_|
"/tmp/paimon-warehouse".to_string())
-}
-
async fn scan_and_read<C: Catalog + ?Sized>(
catalog: &C,
table_name: &str,
@@ -76,172 +74,17 @@ async fn get_table_from_catalog<C: Catalog + ?Sized>(
table_name: &str,
) -> paimon::Table {
let identifier = Identifier::new("default", table_name);
- match catalog.get_table(&identifier).await {
- Ok(table) => table,
- Err(Error::TableNotExist { .. }) if table_name ==
"data_evolution_table" => {
- ensure_data_evolution_table(catalog).await;
- catalog
- .get_table(&identifier)
- .await
- .expect("Failed to get generated data_evolution_table")
- }
- Err(err) => panic!("Failed to get table: {err:?}"),
- }
-}
-
-const DATA_EVOLUTION_FIXTURE_MARKER: &str = "paimon-rust.integration-fixture";
-const DATA_EVOLUTION_FIXTURE_VERSION: &str = "data-evolution-limit-v2";
-
-static DATA_EVOLUTION_FIXTURE_LOCK: OnceLock<tokio::sync::Mutex<()>> =
OnceLock::new();
-
-async fn ensure_data_evolution_table<C: Catalog + ?Sized>(catalog: &C) {
- let lock = DATA_EVOLUTION_FIXTURE_LOCK.get_or_init(||
tokio::sync::Mutex::new(()));
- let _guard = lock.lock().await;
-
- let identifier = Identifier::new("default", "data_evolution_table");
- if let Ok(table) = catalog.get_table(&identifier).await {
- let options = table.schema().options();
- let fixture_marker = options
- .get(DATA_EVOLUTION_FIXTURE_MARKER)
- .map(String::as_str);
- if fixture_marker == Some(DATA_EVOLUTION_FIXTURE_VERSION) ||
fixture_marker.is_none() {
- return;
- }
- catalog
- .drop_table(&identifier, true)
- .await
- .expect("Failed to replace stale generated data_evolution_table
fixture");
- }
-
- catalog
- .create_database("default", true, HashMap::new())
- .await
- .expect("Failed to create default database for data_evolution_table
fixture");
catalog
- .create_table(&identifier, data_evolution_fixture_schema(), true)
- .await
- .expect("Failed to create data_evolution_table fixture");
-
- let table = catalog
.get_table(&identifier)
.await
- .expect("Failed to load data_evolution_table fixture");
-
- append_data_evolution_batch(
- &table,
- data_evolution_fixture_batch(
- vec![1, 2, 3],
- vec!["alice", "bob", "carol"],
- vec![100, 200, 300],
- ),
- )
- .await;
- append_data_evolution_batch(
- &table,
- data_evolution_fixture_batch(vec![4, 5], vec!["dave", "eve"],
vec![400, 500]),
- )
- .await;
-
- let wb = table.new_write_builder();
- let mut update = wb
- .new_data_evolution_writer(vec!["name".to_string()])
- .expect("Failed to create data-evolution update writer");
- update
- .add_matched_batch(data_evolution_update_batch())
- .expect("Failed to add data-evolution update batch");
- wb.new_commit()
- .commit(
- update
- .prepare_commit()
- .await
- .expect("Failed to prepare data-evolution update commit"),
- )
- .await
- .expect("Failed to commit data-evolution update fixture");
-}
-
-fn data_evolution_fixture_schema() -> Schema {
- Schema::builder()
- .column("id", DataType::Int(IntType::new()))
- .column(
- "name",
-
DataType::VarChar(VarCharType::new(VarCharType::MAX_LENGTH).unwrap()),
- )
- .column("value", DataType::Int(IntType::new()))
- .option("row-tracking.enabled", "true")
- .option("data-evolution.enabled", "true")
- .option("source.split.target-size", "1b")
- .option("source.split.open-file-cost", "1b")
- .option(
- DATA_EVOLUTION_FIXTURE_MARKER,
- DATA_EVOLUTION_FIXTURE_VERSION,
- )
- .build()
- .unwrap()
-}
-
-fn data_evolution_fixture_batch(ids: Vec<i32>, names: Vec<&str>, values:
Vec<i32>) -> RecordBatch {
- let schema = Arc::new(ArrowSchema::new(vec![
- ArrowField::new("id", ArrowDataType::Int32, false),
- ArrowField::new("name", ArrowDataType::Utf8, false),
- ArrowField::new("value", ArrowDataType::Int32, false),
- ]));
- RecordBatch::try_new(
- schema,
- vec![
- Arc::new(Int32Array::from(ids)),
- Arc::new(StringArray::from(names)),
- Arc::new(Int32Array::from(values)),
- ],
- )
- .unwrap()
-}
-
-fn data_evolution_update_batch() -> RecordBatch {
- let schema = Arc::new(ArrowSchema::new(vec![
- ArrowField::new("_ROW_ID", ArrowDataType::Int64, false),
- ArrowField::new("name", ArrowDataType::Utf8, false),
- ]));
- RecordBatch::try_new(
- schema,
- vec![
- Arc::new(Int64Array::from(vec![0, 2, 3])),
- Arc::new(StringArray::from(vec!["alice-v2", "carol-v2", "dave"])),
- ],
- )
- .unwrap()
-}
-
-async fn append_data_evolution_batch(table: &paimon::Table, batch:
RecordBatch) {
- let wb = table.new_write_builder();
- let mut write = wb.new_write().expect("Failed to create fixture writer");
- write
- .write_arrow_batch(&batch)
- .await
- .expect("Failed to write fixture batch");
- wb.new_commit()
- .commit(
- write
- .prepare_commit()
- .await
- .expect("Failed to prepare fixture commit"),
- )
- .await
- .expect("Failed to commit fixture batch");
-}
-
-fn create_file_system_catalog() -> FileSystemCatalog {
- let warehouse = get_test_warehouse();
- let mut options = Options::new();
- options.set(CatalogOptions::WAREHOUSE, warehouse);
- FileSystemCatalog::new(options).expect("Failed to create
FileSystemCatalog")
+ .unwrap_or_else(|err| panic!("Failed to get golden table {table_name}:
{err:?}"))
}
async fn scan_and_read_with_fs_catalog(
table_name: &str,
projection: Option<&[&str]>,
) -> (Plan, Vec<RecordBatch>) {
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
scan_and_read(&catalog, table_name, projection).await
}
@@ -636,7 +479,7 @@ async fn test_read_with_column_projection() {
#[tokio::test]
async fn test_read_projection_empty() {
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "simple_log_table").await;
let mut read_builder = table.new_read_builder();
@@ -680,7 +523,7 @@ async fn test_read_projection_empty() {
#[tokio::test]
async fn test_read_projection_unknown_column() {
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "simple_log_table").await;
let mut read_builder = table.new_read_builder();
@@ -702,7 +545,7 @@ async fn test_read_projection_unknown_column() {
#[tokio::test]
async fn test_read_projection_all_invalid() {
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "simple_log_table").await;
let mut read_builder = table.new_read_builder();
@@ -724,7 +567,7 @@ async fn test_read_projection_all_invalid() {
#[tokio::test]
async fn test_read_projection_duplicate_column() {
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "simple_log_table").await;
// `id` exists, so with_projection passes its best-effort early check; the
@@ -752,7 +595,7 @@ async fn test_read_projection_duplicate_column() {
async fn test_read_partitioned_table_with_filter() {
use paimon::spec::{Datum, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog,
"partitioned_log_table").await;
let schema = table.schema();
let pb = PredicateBuilder::new(schema.fields());
@@ -782,7 +625,7 @@ async fn test_read_partitioned_table_with_filter() {
async fn test_read_multi_partitioned_table_with_filter() {
use paimon::spec::{Datum, Predicate, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog,
"multi_partitioned_log_table").await;
let schema = table.schema();
let pb = PredicateBuilder::new(schema.fields());
@@ -812,7 +655,7 @@ async fn test_read_multi_partitioned_table_with_filter() {
async fn test_read_partitioned_table_data_only_filter_prunes_all_files() {
use paimon::spec::{Datum, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog,
"partitioned_log_table").await;
let schema = table.schema();
let pb = PredicateBuilder::new(schema.fields());
@@ -841,7 +684,7 @@ async fn
test_read_partitioned_table_data_only_filter_prunes_all_files() {
async fn test_read_partitioned_table_mixed_and_filter() {
use paimon::spec::{Datum, Predicate, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog,
"partitioned_log_table").await;
let schema = table.schema();
let pb = PredicateBuilder::new(schema.fields());
@@ -871,7 +714,7 @@ async fn test_read_partitioned_table_mixed_and_filter() {
async fn
test_read_partitioned_table_data_only_filter_keeps_matching_partition() {
use paimon::spec::{Datum, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog,
"partitioned_log_table").await;
let schema = table.schema();
let pb = PredicateBuilder::new(schema.fields());
@@ -902,7 +745,7 @@ async fn
test_read_partitioned_table_data_only_filter_keeps_matching_partition()
async fn
test_read_multi_partitioned_table_or_of_mixed_ands_prunes_partitions() {
use paimon::spec::{Datum, Predicate, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog,
"multi_partitioned_log_table").await;
let schema = table.schema();
let pb = PredicateBuilder::new(schema.fields());
@@ -946,7 +789,7 @@ async fn
test_read_multi_partitioned_table_or_of_mixed_ands_prunes_partitions()
async fn test_read_partitioned_table_mixed_or_filter_preserves_all() {
use paimon::spec::{Datum, Predicate, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog,
"partitioned_log_table").await;
let schema = table.schema();
let pb = PredicateBuilder::new(schema.fields());
@@ -981,7 +824,7 @@ async fn
test_read_partitioned_table_mixed_or_filter_preserves_all() {
async fn test_read_partitioned_table_filter_matches_no_partition() {
use paimon::spec::{Datum, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog,
"partitioned_log_table").await;
let schema = table.schema();
let pb = PredicateBuilder::new(schema.fields());
@@ -1007,7 +850,7 @@ async fn
test_read_partitioned_table_filter_matches_no_partition() {
async fn test_read_partitioned_table_eval_row_error_fails_open() {
use paimon::spec::{ArrayType, DataType, Datum, IntType, PredicateOperator};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog,
"partitioned_log_table").await;
let dt_index = table
.schema()
@@ -1046,7 +889,7 @@ async fn
test_read_partitioned_table_eval_row_error_fails_open() {
// ======================= REST Catalog read tests
===============================
-/// Build a simple test schema matching the Spark-provisioned tables (id INT,
name VARCHAR).
+/// Build a simple test schema matching the golden tables (id INT, name
VARCHAR).
fn simple_log_schema() -> Schema {
Schema::builder()
.column("id", DataType::Int(IntType::new()))
@@ -1055,12 +898,13 @@ fn simple_log_schema() -> Schema {
.expect("Failed to build schema")
}
-/// Start a mock REST server backed by Spark-provisioned data on disk,
+/// Start a mock REST server backed by golden data on disk,
/// register the given tables, and return a connected `RESTCatalog`.
async fn setup_rest_catalog_with_tables(
table_configs: &[(&str, &str, Schema)],
-) -> (mock_server::RESTServer, RESTCatalog) {
- let catalog_path = get_test_warehouse();
+) -> (TestWarehouse, mock_server::RESTServer, RESTCatalog) {
+ let warehouse = test_warehouse();
+ let catalog_path = warehouse.path().to_string();
// Use a simple warehouse name (no slashes) to avoid URL-encoding issues
let warehouse_name = "test_warehouse";
let prefix = "mock-test";
@@ -1093,16 +937,16 @@ async fn setup_rest_catalog_with_tables(
.await
.expect("Failed to create RESTCatalog");
- (server, catalog)
+ (warehouse, server, catalog)
}
/// Test reading an append-only (log) table via REST catalog backed by mock
server.
///
-/// The mock server returns table metadata pointing to Spark-provisioned data
on disk.
+/// The mock server returns table metadata pointing to golden data on disk.
#[tokio::test]
async fn test_rest_catalog_read_append_table() {
let table_name = "simple_log_table";
- let (_server, catalog) =
+ let (_warehouse, _server, catalog) =
setup_rest_catalog_with_tables(&[("default", table_name,
simple_log_schema())]).await;
let (plan, batches) = scan_and_read(&catalog, table_name, None).await;
@@ -1135,7 +979,7 @@ async fn test_rest_catalog_read_append_table() {
/// Test reading a data-evolution enabled append-only table.
///
-/// The table is provisioned by Spark with `data-evolution.enabled=true` and
+/// The table is stored in the golden warehouse with
`data-evolution.enabled=true` and
/// `row-tracking.enabled=true`. Multiple inserts produce files with
`first_row_id`
/// set, exercising the data evolution scan and read path.
#[tokio::test]
@@ -1254,7 +1098,7 @@ async fn plan_table_with_stats(table: &paimon::Table,
limit: Option<usize>) -> (
/// Test limit pushdown: when limit is smaller than total rows, fewer data
files may be generated.
#[tokio::test]
async fn test_limit_pushdown() {
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
// Test limit pushdown for data evolution table
let table = get_table_from_catalog(&catalog, "data_evolution_table").await;
@@ -1424,7 +1268,7 @@ async fn test_read_format_schema_evolution_add_column() {
use paimon::spec::{Datum, PredicateBuilder};
let table_name = "format_schema_evolution_add_column";
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, table_name).await;
let (plan, batches) = scan_and_read(&catalog, table_name, None).await;
assert_plan_file_formats(&plan, &["avro", "orc", "parquet"], table_name);
@@ -1494,7 +1338,7 @@ async fn
test_read_partitioned_format_schema_evolution_add_column() {
use paimon::spec::{Datum, Predicate, PredicateBuilder};
let table_name = "partitioned_format_schema_evolution_add_column";
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, table_name).await;
let (plan, batches) = scan_and_read(&catalog, table_name, None).await;
assert_plan_file_formats(&plan, &["avro", "orc", "parquet"], table_name);
@@ -1626,7 +1470,7 @@ async fn
test_read_format_schema_evolution_type_promotion() {
use paimon::spec::{Datum, PredicateBuilder};
let table_name = "format_schema_evolution_type_promotion";
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, table_name).await;
let (plan, batches) = scan_and_read(&catalog, table_name, None).await;
assert_plan_file_formats(&plan, &["avro", "orc", "parquet"], table_name);
@@ -1688,7 +1532,7 @@ async fn
test_read_format_schema_evolution_type_promotion() {
async fn
test_stats_pruning_schema_evolution_added_column_eq_prunes_old_files() {
use paimon::spec::{Datum, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog,
"schema_evolution_add_column").await;
let pb = PredicateBuilder::new(table.schema().fields());
let filter = pb
@@ -1715,7 +1559,7 @@ async fn
test_stats_pruning_schema_evolution_added_column_eq_prunes_old_files()
async fn
test_stats_pruning_schema_evolution_added_column_is_null_prunes_new_files() {
use paimon::spec::PredicateBuilder;
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog,
"schema_evolution_add_column").await;
let pb = PredicateBuilder::new(table.schema().fields());
let filter = pb.is_null("age").expect("Failed to build predicate");
@@ -1740,7 +1584,7 @@ async fn
test_stats_pruning_schema_evolution_added_column_is_null_prunes_new_fil
async fn
test_stats_pruning_schema_evolution_type_promotion_prunes_old_int_files() {
use paimon::spec::{Datum, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog,
"schema_evolution_type_promotion").await;
let pb = PredicateBuilder::new(table.schema().fields());
let filter = pb
@@ -2008,7 +1852,7 @@ async fn test_read_schema_evolution_drop_column() {
async fn test_read_schema_evolution_rename_column() {
use paimon::spec::{Datum, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table_name = "schema_evolution_rename_column";
let table = get_table_from_catalog(&catalog, table_name).await;
let (plan, batches) = scan_and_read(&catalog, table_name, None).await;
@@ -2096,7 +1940,7 @@ async fn
test_read_mixed_format_schema_evolution_drop_column() {
use paimon::spec::{Datum, PredicateBuilder};
let table_name = "mixed_format_schema_evolution_drop_column";
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, table_name).await;
let (plan, batches) = scan_and_read(&catalog, table_name, None).await;
assert_plan_file_formats(&plan, &["avro", "orc", "parquet"], table_name);
@@ -2200,7 +2044,7 @@ async fn
test_read_mixed_format_schema_evolution_reorder_move_column() {
use paimon::spec::{Datum, PredicateBuilder};
let table_name = "mixed_format_schema_evolution_reorder_move_column";
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, table_name).await;
let (plan, batches) = scan_and_read(&catalog, table_name, None).await;
@@ -2431,7 +2275,7 @@ async fn test_read_complex_type_table() {
/// Reading a primary-key table without deletion vectors should work via
sort-merge reader.
#[tokio::test]
async fn test_read_pk_table_without_dv_via_sort_merge() {
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "simple_pk_table").await;
let read_builder = table.new_read_builder();
@@ -2472,7 +2316,7 @@ async fn test_read_pk_table_without_dv_via_sort_merge() {
/// The table has been compacted so all files are level > 0, and the scan
skips level-0 files.
#[tokio::test]
async fn test_read_first_row_pk_table() {
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "first_row_pk_table").await;
let read_builder = table.new_read_builder();
@@ -2534,7 +2378,7 @@ async fn test_read_non_pk_table_with_dv() {
/// and should NOT be visible to batch readers. The plan should produce no
splits.
#[tokio::test]
async fn test_read_postpone_bucket_pk_table_returns_empty() {
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog,
"postpone_bucket_pk_table").await;
let read_builder = table.new_read_builder();
@@ -2557,7 +2401,7 @@ async fn
test_read_postpone_bucket_pk_table_returns_empty() {
async fn test_data_evolution_table_with_filter() {
use paimon::spec::{Datum, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "data_evolution_table").await;
let pb = PredicateBuilder::new(table.schema().fields());
@@ -2616,7 +2460,7 @@ async fn test_data_evolution_table_with_filter() {
async fn test_bucket_predicate_filtering() {
use paimon::spec::{Datum, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog,
"multi_bucket_pk_table").await;
let schema = table.schema();
let pb = PredicateBuilder::new(schema.fields());
@@ -2668,7 +2512,7 @@ async fn test_bucket_predicate_filtering() {
async fn test_bucket_predicate_filtering_in() {
use paimon::spec::{Datum, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog,
"multi_bucket_pk_table").await;
let schema = table.schema();
let pb = PredicateBuilder::new(schema.fields());
@@ -2701,7 +2545,7 @@ async fn test_bucket_predicate_filtering_in() {
/// Time travel by snapshot id: snapshot 1 should return only the first batch.
#[tokio::test]
async fn test_time_travel_by_snapshot_id() {
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "time_travel_table").await;
// Snapshot 1: (1, 'alice'), (2, 'bob')
@@ -2755,7 +2599,7 @@ async fn test_time_travel_by_snapshot_id() {
/// Time travel by tag name.
#[tokio::test]
async fn test_time_travel_by_tag_name() {
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "time_travel_table").await;
// Tag 'snapshot1' -> snapshot 1: (1, 'alice'), (2, 'bob')
@@ -2808,7 +2652,7 @@ async fn test_time_travel_by_tag_name() {
#[tokio::test]
async fn time_travel_schema_evolution() {
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog,
"time_travel_schema_evolution").await;
for (tag, expected_fields, expected_rows) in [
@@ -2957,7 +2801,7 @@ async fn time_travel_schema_evolution() {
#[tokio::test]
async fn test_time_travel_conflicting_selectors_fail() {
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "time_travel_table").await;
// Java resolves scan.version before validating conflicts with other
selectors.
@@ -2998,7 +2842,7 @@ async fn test_time_travel_conflicting_selectors_fail() {
#[tokio::test]
async fn test_time_travel_invalid_version_fails() {
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "time_travel_table").await;
let invalid = table.copy_with_options(HashMap::from([(
@@ -3090,7 +2934,7 @@ async fn test_read_data_evolution_drop_column() {
async fn test_limit_pushdown_disabled_with_data_predicates() {
use paimon::spec::{Datum, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "data_evolution_table").await;
let pb = PredicateBuilder::new(table.schema().fields());
@@ -3147,7 +2991,7 @@ fn extract_code_value(batches: &[RecordBatch]) ->
Vec<(String, i32)> {
async fn test_bucket_predicate_filtering_short_string_key() {
use paimon::spec::{Datum, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog,
"string_bucket_short_key").await;
let pb = PredicateBuilder::new(table.schema().fields());
@@ -3189,7 +3033,7 @@ async fn
test_bucket_predicate_filtering_short_string_key() {
async fn test_bucket_predicate_filtering_long_string_key() {
use paimon::spec::{Datum, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog,
"string_bucket_long_key").await;
let pb = PredicateBuilder::new(table.schema().fields());
@@ -3278,7 +3122,7 @@ fn extract_id_name_value(batches: &[RecordBatch]) ->
Vec<(i32, String, i32)> {
async fn test_read_data_evolution_table_with_row_ranges() {
use paimon::RowRange;
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "data_evolution_table").await;
let (full_plan, full_batches) = scan_and_read(&catalog,
"data_evolution_table", None).await;
@@ -3322,7 +3166,7 @@ async fn test_read_data_evolution_table_with_row_ranges()
{
async fn test_read_data_evolution_table_with_empty_row_ranges() {
use paimon::RowRange;
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "data_evolution_table").await;
let (plan, batches) =
@@ -3337,7 +3181,7 @@ async fn
test_read_data_evolution_table_with_empty_row_ranges() {
async fn test_read_data_evolution_table_with_full_row_ranges() {
use paimon::RowRange;
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "data_evolution_table").await;
let (_, full_batches) = scan_and_read(&catalog, "data_evolution_table",
None).await;
@@ -3354,7 +3198,7 @@ async fn
test_read_data_evolution_table_with_full_row_ranges() {
async fn test_limit_pushdown_disabled_with_row_ranges() {
use paimon::RowRange;
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "data_evolution_table").await;
let mut row_range_reader = table.new_read_builder();
@@ -3393,7 +3237,7 @@ async fn test_limit_pushdown_disabled_with_row_ranges() {
#[tokio::test]
async fn test_read_data_evolution_table_with_row_id_projection() {
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "data_evolution_table").await;
// Project _ROW_ID along with regular columns
@@ -3449,7 +3293,7 @@ async fn
test_read_data_evolution_table_with_row_id_projection() {
async fn test_read_data_evolution_table_only_row_id_with_row_ranges() {
use paimon::RowRange;
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "data_evolution_table").await;
// Get full row ID range
@@ -3501,7 +3345,7 @@ async fn
test_read_data_evolution_table_only_row_id_with_row_ranges() {
#[tokio::test]
async fn test_read_data_evolution_mixed_format_row_id_projection() {
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog,
"data_evolution_mixed_format_add_column").await;
let mut read_builder = table.new_read_builder();
@@ -3851,7 +3695,7 @@ async fn test_read_full_types_table() {
async fn test_read_orc_with_filter_only_column_projection() {
use paimon::spec::{Datum, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "full_types_table").await;
let pb = PredicateBuilder::new(table.schema().fields());
let filter = pb
@@ -3880,7 +3724,7 @@ async fn assert_full_types_orc_filter_matches(
projected_column: &str,
expected_string_values: &[&str],
) {
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "full_types_table").await;
let (_, batches) =
@@ -3904,7 +3748,7 @@ async fn assert_full_types_orc_filter_matches(
async fn test_read_orc_with_supported_predicate_pushdown_types() {
use paimon::spec::{Datum, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "full_types_table").await;
let pb = PredicateBuilder::new(table.schema().fields());
@@ -3976,7 +3820,7 @@ async fn
test_read_orc_with_supported_predicate_pushdown_types() {
async fn test_read_orc_with_unsupported_date_predicate_remains_residual() {
use paimon::spec::{Datum, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "full_types_table").await;
let pb = PredicateBuilder::new(table.schema().fields());
let filter = pb
@@ -3990,7 +3834,7 @@ async fn
test_read_orc_with_unsupported_date_predicate_remains_residual() {
async fn test_read_orc_predicate_pushdown_remains_conservative() {
use paimon::spec::{Datum, PredicateBuilder};
- let catalog = create_file_system_catalog();
+ let (_warehouse, catalog) = create_file_system_catalog();
let table = get_table_from_catalog(&catalog, "full_types_table").await;
let pb = PredicateBuilder::new(table.schema().fields());
let filter = pb.equal("id", Datum::Int(2)).expect("build id predicate");
diff --git a/crates/integrations/datafusion/src/lib.rs
b/crates/integrations/datafusion/src/lib.rs
index 84bbdd16..89910c17 100644
--- a/crates/integrations/datafusion/src/lib.rs
+++ b/crates/integrations/datafusion/src/lib.rs
@@ -49,6 +49,9 @@ mod format_partition_repair;
mod format_table_truncate;
#[cfg(feature = "fulltext")]
mod full_text_search;
+#[cfg(test)]
+#[path = "../testdata/warehouse/mod.rs"]
+mod golden_warehouse;
mod hybrid_search;
mod lateral_vector_search;
mod memory;
diff --git a/crates/integrations/datafusion/src/table/mod.rs
b/crates/integrations/datafusion/src/table/mod.rs
index a3ba2325..730cd30b 100644
--- a/crates/integrations/datafusion/src/table/mod.rs
+++ b/crates/integrations/datafusion/src/table/mod.rs
@@ -565,8 +565,9 @@ mod tests {
use datafusion::prelude::{SessionConfig, SessionContext};
use paimon::catalog::Identifier;
use paimon::spec::{ArrayType, MapType, RowType, VarCharType};
- use paimon::{Catalog, CatalogOptions, DataSplit, FileSystemCatalog,
Options};
+ use paimon::{Catalog, DataSplit};
+ use crate::golden_warehouse::{test_catalog as create_catalog,
TestWarehouse};
use crate::physical_plan::PaimonTableScan;
#[test]
@@ -587,27 +588,17 @@ mod tests {
assert_eq!(result, vec![vec![1, 2, 3]]);
}
- fn get_test_warehouse() -> String {
- std::env::var("PAIMON_TEST_WAREHOUSE")
- .unwrap_or_else(|_| "/tmp/paimon-warehouse".to_string())
- }
-
- fn create_catalog() -> FileSystemCatalog {
- let warehouse = get_test_warehouse();
- let mut options = Options::new();
- options.set(CatalogOptions::WAREHOUSE, warehouse);
- FileSystemCatalog::new(options).expect("Failed to create catalog")
- }
-
- async fn create_provider(table_name: &str) -> PaimonTableProvider {
- let catalog = create_catalog();
+ async fn create_provider(table_name: &str) -> (TestWarehouse,
PaimonTableProvider) {
+ let (warehouse, catalog) = create_catalog();
let identifier = Identifier::new("default", table_name);
let table = catalog
.get_table(&identifier)
.await
.expect("Failed to get table");
- PaimonTableProvider::try_new(table).expect("Failed to create table
provider")
+ let provider =
+ PaimonTableProvider::try_new(table).expect("Failed to create table
provider");
+ (warehouse, provider)
}
async fn plan_partitions(
@@ -937,7 +928,7 @@ mod tests {
#[tokio::test]
async fn test_scan_partition_filter_plans_matching_partition_set() {
- let provider = create_provider("partitioned_log_table").await;
+ let (_warehouse, provider) =
create_provider("partitioned_log_table").await;
let planned_partitions =
plan_partitions(&provider, vec![col("dt").eq(lit("2024-01-01"))],
None).await;
@@ -949,7 +940,7 @@ mod tests {
#[tokio::test]
async fn test_scan_mixed_and_filter_keeps_partition_pruning() {
- let provider = create_provider("partitioned_log_table").await;
+ let (_warehouse, provider) =
create_provider("partitioned_log_table").await;
let planned_partitions = plan_partitions(
&provider,
vec![col("dt").eq(lit("2024-01-01")).and(col("id").gt(lit(1)))],
@@ -965,7 +956,7 @@ mod tests {
#[tokio::test]
async fn test_scan_multi_partition_filter_plans_exact_partition_set() {
- let provider = create_provider("multi_partitioned_log_table").await;
+ let (_warehouse, provider) =
create_provider("multi_partitioned_log_table").await;
let dt_only_partitions =
plan_partitions(&provider, vec![col("dt").eq(lit("2024-01-01"))],
None).await;
@@ -991,7 +982,7 @@ mod tests {
#[tokio::test]
async fn
test_scan_partially_translated_not_filter_prunes_partitions_but_skips_limit_hint()
{
- let provider = create_provider("multi_partitioned_log_table").await;
+ let (_warehouse, provider) =
create_provider("multi_partitioned_log_table").await;
let filter = col("dt")
.eq(lit("2024-01-01"))
.and(Expr::Not(Box::new(col("hr").eq(lit(10)))));
@@ -1020,7 +1011,7 @@ mod tests {
#[tokio::test]
async fn test_scan_keeps_pushed_predicate_for_execute() {
- let provider = create_provider("partitioned_log_table").await;
+ let (_warehouse, provider) =
create_provider("partitioned_log_table").await;
let filter = col("id").gt(lit(1));
let config = SessionConfig::new().with_target_partitions(8);
@@ -1093,7 +1084,7 @@ mod tests {
#[tokio::test]
async fn test_scan_applies_limit_hint_only_when_safe() {
- let provider = create_provider("partitioned_log_table").await;
+ let (_warehouse, provider) =
create_provider("partitioned_log_table").await;
let full_plan = plan_partitions(&provider, vec![], None).await;
let plan = plan_scan(&provider, vec![], Some(1)).await;
let scan = plan
@@ -1115,7 +1106,7 @@ mod tests {
#[tokio::test]
async fn test_scan_keeps_limit_but_skips_limit_pruning_for_data_filters() {
- let provider = create_provider("partitioned_log_table").await;
+ let (_warehouse, provider) =
create_provider("partitioned_log_table").await;
let filter = col("id").gt(lit(1));
let full_plan = plan_partitions(&provider, vec![filter.clone()],
None).await;
let plan = plan_scan(&provider, vec![filter], Some(1)).await;
diff --git a/crates/integrations/datafusion/testdata/warehouse/README.md
b/crates/integrations/datafusion/testdata/warehouse/README.md
new file mode 100644
index 00000000..aaff2995
--- /dev/null
+++ b/crates/integrations/datafusion/testdata/warehouse/README.md
@@ -0,0 +1,64 @@
+<!--
+ ~ Licensed to the Apache Software Foundation (ASF) under one
+ ~ or more contributor license agreements. See the NOTICE file
+ ~ distributed with this work for additional information
+ ~ regarding copyright ownership. The ASF licenses this file
+ ~ to you under the Apache License, Version 2.0 (the
+ ~ "License"); you may not use this file except in compliance
+ ~ with the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing,
+ ~ software distributed under the License is distributed on an
+ ~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ ~ KIND, either express or implied. See the License for the
+ ~ specific language governing permissions and limitations
+ ~ under the License.
+-->
+
+# Java-written golden warehouse
+
+`warehouse.tar.gz` contains 34 Paimon tables under `default.db/`, shared by the
+Rust, DataFusion, Python, and Go integration tests. Each suite extracts its own
+temporary copy unless `PAIMON_TEST_WAREHOUSE` points to an existing warehouse.
+The tests need no Spark, Java, Docker, network download, or provisioning step.
+Rust readers retain an owned guard until their reads finish; concurrent readers
+share a weakly cached extraction that is deleted after the last guard drops.
+Write tests use private copies and must not modify this archive.
+
+## Provenance
+
+- Producer: Apache Spark 3.5.3 with Apache Paimon 1.3.1, `local[2]`, UTC.
+- Source: the former
+
[`dev/spark/provision.py`](https://github.com/apache/paimon-rust/blob/fb5e8f7efeb592fab916b8ee8873405a947e2f17/dev/spark/provision.py).
+- Generator SHA-256:
`54c4f866308bf5d2b1b9744315555d5305a3bb17ebf58c18fb4dee220265b8f7`.
+- Archive SHA-256:
`dd9bc2d03975955800ea5bc0b2b9ed39612c3461fba00708b3ff76f450cdac13`.
+- Contents: 658 files, 588,603 uncompressed bytes; 118,891 compressed bytes.
+
+The tables were produced through the Java writer's normal catalog, commit,
+compaction, and schema-evolution paths. File contents have not been rewritten.
+Only archive paths, permissions, ownership, entry order, and archive timestamps
+were normalized. All schemas, snapshots, tags, manifests, deletion vectors,
+bucket indexes, and data files are retained, including historical files needed
+for time travel. UUIDs and commit timestamps inside those files remain frozen.
+
+## Coverage
+
+- Append and primary-key tables, including deletion vectors, multiple buckets,
+ partitioned tables, and the first-row merge engine.
+- Dynamic, postponed, and postponed fixed buckets; short and long string keys.
+- Schema addition, type promotion, dropping, renaming, and column reordering.
+- Data evolution with row tracking, partial column updates, and mixed formats.
+- Snapshot and tag time travel across schema changes.
+- Parquet, ORC, and Avro; complex types, full type coverage, and boundary
values.
+
+Expected rows and query behavior remain explicit in the tests. A missing or
+invalid fixture must fail rather than falling back to data generated by the
+Rust implementation under test.
+
+When adding a cross-language compatibility case, generate the table with an
+independent Java Paimon writer, record its version and source, and add explicit
+behavioral assertions. Preserve existing fixtures when extending coverage for a
+new format version. Changes to UUIDs, timestamps, file counts, and split layout
+need review; matching row values alone does not preserve metadata coverage.
diff --git a/crates/integrations/datafusion/testdata/warehouse/mod.rs
b/crates/integrations/datafusion/testdata/warehouse/mod.rs
new file mode 100644
index 00000000..f4162b9a
--- /dev/null
+++ b/crates/integrations/datafusion/testdata/warehouse/mod.rs
@@ -0,0 +1,125 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+//! Scoped access to the shared Java-written warehouse.
+
+use std::sync::{Arc, Mutex, Weak};
+
+use paimon::{CatalogOptions, FileSystemCatalog, Options};
+
+pub enum TestWarehouse {
+ External(String),
+ Golden(Arc<tempfile::TempDir>),
+}
+
+impl TestWarehouse {
+ pub fn path(&self) -> &str {
+ match self {
+ Self::External(path) => path,
+ Self::Golden(directory) => directory.path().to_str().expect("UTF-8
warehouse path"),
+ }
+ }
+}
+
+pub fn test_catalog() -> (TestWarehouse, FileSystemCatalog) {
+ let warehouse = test_warehouse();
+ let mut options = Options::new();
+ options.set(CatalogOptions::WAREHOUSE, warehouse.path());
+ let catalog = FileSystemCatalog::new(options).expect("Failed to create
FileSystemCatalog");
+ (warehouse, catalog)
+}
+
+pub fn test_warehouse() -> TestWarehouse {
+ if let Ok(path) = std::env::var("PAIMON_TEST_WAREHOUSE") {
+ return TestWarehouse::External(path);
+ }
+
+ static WAREHOUSE: Mutex<Weak<tempfile::TempDir>> = Mutex::new(Weak::new());
+ unpack_warehouse(&WAREHOUSE)
+}
+
+fn unpack_warehouse(cache: &Mutex<Weak<tempfile::TempDir>>) -> TestWarehouse {
+ let mut cached = cache.lock().expect("Golden warehouse cache poisoned");
+ let directory = cached.upgrade().unwrap_or_else(|| {
+ let temp = tempfile::tempdir().expect("Failed to create golden
warehouse directory");
+ let decoder =
flate2::read::GzDecoder::new(include_bytes!("warehouse.tar.gz").as_slice());
+ tar::Archive::new(decoder)
+ .unpack(temp.path())
+ .expect("Failed to extract golden warehouse");
+ let directory = Arc::new(temp);
+ *cached = Arc::downgrade(&directory);
+ directory
+ });
+ TestWarehouse::Golden(directory)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{test_warehouse, unpack_warehouse};
+ use std::sync::{Mutex, Weak};
+
+ #[test]
+ fn warehouse_directory_is_removed_on_exit() {
+ const PROBE: &str = "PAIMON_GOLDEN_WAREHOUSE_CLEANUP_PROBE";
+ if let Some(output) = std::env::var_os(PROBE) {
+ let warehouse = test_warehouse();
+ std::fs::write(output, warehouse.path()).unwrap();
+ return;
+ }
+
+ let probe = tempfile::tempdir().unwrap();
+ let output = probe.path().join("warehouse-path");
+ let status =
std::process::Command::new(std::env::current_exe().unwrap())
+ .args([
+ "--exact",
+
"golden_warehouse::tests::warehouse_directory_is_removed_on_exit",
+ ])
+ .env(PROBE, &output)
+ .env_remove("PAIMON_TEST_WAREHOUSE")
+ .status()
+ .unwrap();
+ assert!(status.success());
+ let warehouse = std::fs::read_to_string(output).unwrap();
+ let leaked = std::path::Path::new(&warehouse).exists();
+ if leaked {
+ std::fs::remove_dir_all(&warehouse).unwrap();
+ }
+ assert!(
+ !leaked,
+ "Golden warehouse leaked after process exit: {warehouse}"
+ );
+ }
+ #[test]
+ fn warehouse_lives_until_the_last_reader_finishes() {
+ let cache = Mutex::new(Weak::new());
+ let first = unpack_warehouse(&cache);
+ let second = unpack_warehouse(&cache);
+ assert_eq!(first.path(), second.path());
+ let path = std::path::PathBuf::from(first.path());
+ let schema = path.join("default.db/simple_log_table/schema/schema-0");
+ assert!(schema.is_file());
+ drop(first);
+ assert!(schema.is_file(), "Another reader still owns the warehouse");
+ drop(second);
+ assert!(!path.exists(), "Last reader should clean up the warehouse");
+
+ let next = unpack_warehouse(&cache);
+ assert!(std::path::Path::new(next.path())
+ .join("default.db/simple_log_table/schema/schema-0")
+ .is_file());
+ }
+}
diff --git a/crates/integrations/datafusion/testdata/warehouse/warehouse.tar.gz
b/crates/integrations/datafusion/testdata/warehouse/warehouse.tar.gz
new file mode 100644
index 00000000..42fa10c7
Binary files /dev/null and
b/crates/integrations/datafusion/testdata/warehouse/warehouse.tar.gz differ
diff --git a/crates/integrations/datafusion/tests/dynamic_bucket_tables.rs
b/crates/integrations/datafusion/tests/dynamic_bucket_tables.rs
index 8db284c6..a83db584 100644
--- a/crates/integrations/datafusion/tests/dynamic_bucket_tables.rs
+++ b/crates/integrations/datafusion/tests/dynamic_bucket_tables.rs
@@ -19,6 +19,9 @@
mod common;
+#[path = "../testdata/warehouse/mod.rs"]
+mod golden_warehouse;
+
use common::{
collect_id_name, collect_id_value, create_sql_context, create_test_env,
setup_sql_context,
string_value,
@@ -1047,35 +1050,28 @@ async fn
test_pk_dynamic_bucket_partitioned_insert_overwrite() {
);
}
-/// Read the Spark-provisioned dynamic_bucket_pk_table, write the same data
+/// Read the Java-written golden dynamic_bucket_pk_table, write the same data
/// into a new dynamic bucket table, and verify the HASH index values are
identical.
///
/// Spark may distribute rows across multiple buckets due to parallelism, so we
/// compare the aggregate set of hash values rather than per-bucket entries.
-///
-/// Requires: `make docker-up` + colima copy (see dev/spark/README.md).
#[tokio::test]
-#[ignore]
-async fn test_read_spark_dynamic_bucket_and_compare_index() {
- // --- Read from Spark-provisioned table ---
- let warehouse =
- std::env::var("PAIMON_TEST_WAREHOUSE").unwrap_or_else(|_|
"/tmp/paimon-warehouse".into());
- let mut opts = Options::new();
- opts.set(CatalogOptions::WAREHOUSE, &warehouse);
- let spark_catalog = FileSystemCatalog::new(opts).unwrap();
- let spark_table = spark_catalog
+async fn test_read_golden_dynamic_bucket_and_compare_index() {
+ // Read the Java-written table from the golden warehouse.
+ let (_warehouse, golden_catalog) = golden_warehouse::test_catalog();
+ let golden_table = golden_catalog
.get_table(&Identifier::new("default", "dynamic_bucket_pk_table"))
.await
.unwrap();
- // Read all rows from the Spark table
- let plan = spark_table
+ // Read all rows from the golden table
+ let plan = golden_table
.new_read_builder()
.new_scan()
.plan()
.await
.unwrap();
- let reader = spark_table.new_read_builder().new_read().unwrap();
+ let reader = golden_table.new_read_builder().new_read().unwrap();
let stream = reader.to_arrow(plan.splits()).unwrap();
use arrow_array::RecordBatch;
@@ -1097,7 +1093,7 @@ async fn
test_read_spark_dynamic_bucket_and_compare_index() {
}
all_rows.sort_by_key(|(id, _)| *id);
- // Verify Spark data: two commits with overlapping keys
+ // Verify golden data: two commits with overlapping keys
assert_eq!(
all_rows,
vec![
@@ -1121,7 +1117,7 @@ async fn
test_read_spark_dynamic_bucket_and_compare_index() {
.await
.unwrap();
- // Replicate the same two commits as provision.py
+ // Replicate the same two commits as the golden table
sql_context
.sql("INSERT INTO paimon.test_db.t_dyn_cmp VALUES (1, 'alice'), (2,
'bob'), (3, 'carol')")
.await
@@ -1163,11 +1159,11 @@ async fn
test_read_spark_dynamic_bucket_and_compare_index() {
.await
.unwrap();
- let spark_hashes = collect_all_hashes(&spark_table).await;
+ let golden_hashes = collect_all_hashes(&golden_table).await;
let rust_hashes = collect_all_hashes(&rust_table).await;
assert_eq!(
- spark_hashes, rust_hashes,
- "HASH index values mismatch between Spark and Rust tables"
+ golden_hashes, rust_hashes,
+ "HASH index values mismatch between the golden and Rust tables"
);
}
diff --git a/crates/integrations/datafusion/tests/read_tables.rs
b/crates/integrations/datafusion/tests/read_tables.rs
index ef42a2d4..dbd626cf 100644
--- a/crates/integrations/datafusion/tests/read_tables.rs
+++ b/crates/integrations/datafusion/tests/read_tables.rs
@@ -20,6 +20,10 @@ use std::sync::Arc;
mod common;
+#[path = "../testdata/warehouse/mod.rs"]
+mod golden_warehouse;
+use golden_warehouse::{test_catalog as create_catalog, TestWarehouse};
+
use common::string_value;
use datafusion::arrow::array::{Array, Int32Array};
use datafusion::arrow::record_batch::RecordBatch;
@@ -30,46 +34,28 @@ use datafusion::logical_expr::{col, lit,
TableProviderFilterPushDown};
use datafusion::physical_plan::{displayable, ExecutionPlan};
use datafusion::prelude::SessionConfig;
use paimon::catalog::Identifier;
-use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options};
+use paimon::Catalog;
use paimon_datafusion::{PaimonCatalogProvider, PaimonTableProvider,
SQLContext};
-fn get_test_warehouse() -> String {
- std::env::var("PAIMON_TEST_WAREHOUSE").unwrap_or_else(|_|
"/tmp/paimon-warehouse".to_string())
-}
-
-fn create_catalog() -> FileSystemCatalog {
- let warehouse = get_test_warehouse();
- let mut options = Options::new();
- options.set(CatalogOptions::WAREHOUSE, warehouse);
- FileSystemCatalog::new(options).expect("Failed to create catalog")
-}
-
-async fn create_context() -> SQLContext {
- let catalog = create_catalog();
+async fn create_context() -> (TestWarehouse, SQLContext) {
+ let (warehouse, catalog) = create_catalog();
let catalog: Arc<dyn Catalog> = Arc::new(catalog);
let mut ctx = SQLContext::new();
ctx.register_catalog("paimon", catalog)
.await
.expect("Failed to register catalog");
- ctx
+ (warehouse, ctx)
}
-async fn create_provider(table_name: &str) -> PaimonTableProvider {
- let catalog = create_catalog();
- let identifier = Identifier::new("default", table_name);
- let table = catalog
- .get_table(&identifier)
- .await
- .expect("Failed to get table");
-
- PaimonTableProvider::try_new(table).expect("Failed to create table
provider")
+async fn create_provider(table_name: &str) -> (TestWarehouse,
PaimonTableProvider) {
+ create_provider_with_options(table_name, HashMap::new()).await
}
async fn create_provider_with_options(
table_name: &str,
extra_options: HashMap<String, String>,
-) -> PaimonTableProvider {
- let catalog = create_catalog();
+) -> (TestWarehouse, PaimonTableProvider) {
+ let (warehouse, catalog) = create_catalog();
let identifier = Identifier::new("default", table_name);
let table = catalog
.get_table(&identifier)
@@ -77,7 +63,8 @@ async fn create_provider_with_options(
.expect("Failed to get table")
.copy_with_options(extra_options);
- PaimonTableProvider::try_new(table).expect("Failed to create table
provider")
+ let provider = PaimonTableProvider::try_new(table).expect("Failed to
create table provider");
+ (warehouse, provider)
}
async fn read_rows(table_name: &str) -> Vec<(i32, String)> {
@@ -97,7 +84,7 @@ async fn read_rows(table_name: &str) -> Vec<(i32, String)> {
}
async fn collect_query(sql: &str) ->
datafusion::error::Result<Vec<RecordBatch>> {
- let ctx = create_context().await;
+ let (_warehouse, ctx) = create_context().await;
ctx.sql(sql).await?.collect().await
}
@@ -149,9 +136,12 @@ async fn assert_time_travel_schema_evolution_rows(
assert_sql_rows(&sql, expected).await;
}
-async fn create_physical_plan(sql: &str) -> datafusion::error::Result<Arc<dyn
ExecutionPlan>> {
- let ctx = create_context().await;
- ctx.sql(sql).await?.create_physical_plan().await
+async fn create_physical_plan(
+ sql: &str,
+) -> datafusion::error::Result<(TestWarehouse, Arc<dyn ExecutionPlan>)> {
+ let (warehouse, ctx) = create_context().await;
+ let plan = ctx.sql(sql).await?.create_physical_plan().await?;
+ Ok((warehouse, plan))
}
fn extract_id_name_rows(
@@ -261,7 +251,7 @@ async fn test_projection_via_datafusion() {
#[tokio::test]
async fn test_supports_partition_filters_pushdown() {
- let provider = create_provider("multi_partitioned_log_table").await;
+ let (_warehouse, provider) =
create_provider("multi_partitioned_log_table").await;
let partition_filter = col("dt").eq(lit("2024-01-01"));
let mixed_and_filter =
col("dt").eq(lit("2024-01-01")).and(col("id").gt(lit(1)));
let data_filter = col("id").gt(lit(1));
@@ -285,7 +275,7 @@ async fn test_supports_partition_filters_pushdown() {
/// partition count is still capped by `target_partitions`.
#[tokio::test]
async fn test_scan_partition_count_respects_session_config() {
- let provider = create_provider("partitioned_log_table").await;
+ let (_warehouse, provider) =
create_provider("partitioned_log_table").await;
// With generous target_partitions, the plan should expose more than one
partition.
let config = SessionConfig::new().with_target_partitions(8);
@@ -369,7 +359,7 @@ async fn
test_mixed_and_filter_keeps_residual_datafusion_filter() {
#[tokio::test]
async fn
test_partially_translated_filter_keeps_partition_pruning_and_correctness() {
let sql = "SELECT id, name FROM paimon.default.multi_partitioned_log_table
WHERE dt = '2024-01-01' AND hr + 1 > 20 LIMIT 1";
- let plan = create_physical_plan(sql)
+ let (_warehouse, plan) = create_physical_plan(sql)
.await
.expect("Physical plan creation should succeed");
let plan_text = format_physical_plan(&plan);
@@ -621,7 +611,7 @@ async fn
test_limit_pushdown_on_data_evolution_table_returns_merged_rows() {
#[tokio::test]
async fn
test_limit_pushdown_marks_safe_scan_limit_hint_and_keeps_correctness() {
let sql = "SELECT id, name FROM paimon.default.simple_log_table LIMIT 2";
- let plan = create_physical_plan(sql)
+ let (_warehouse, plan) = create_physical_plan(sql)
.await
.expect("Physical plan creation should succeed");
let plan_text = format_physical_plan(&plan);
@@ -644,7 +634,7 @@ async fn
test_limit_pushdown_marks_safe_scan_limit_hint_and_keeps_correctness()
#[tokio::test]
async fn test_offset_limit_pushdown_keeps_correctness_without_fetch_contract()
{
let sql = "SELECT id, name FROM paimon.default.partitioned_log_table
OFFSET 1 LIMIT 1";
- let plan = create_physical_plan(sql)
+ let (_warehouse, plan) = create_physical_plan(sql)
.await
.expect("Physical plan creation should succeed");
let plan_text = format_physical_plan(&plan);
@@ -673,7 +663,7 @@ async fn
test_offset_limit_pushdown_keeps_correctness_without_fetch_contract() {
#[tokio::test]
async fn test_inexact_filter_limit_keeps_correctness_without_fetch_contract() {
let sql = "SELECT id, name FROM paimon.default.partitioned_log_table WHERE
id > 1 LIMIT 1";
- let plan = create_physical_plan(sql)
+ let (_warehouse, plan) = create_physical_plan(sql)
.await
.expect("Physical plan creation should succeed");
let plan_text = format_physical_plan(&plan);
@@ -701,7 +691,7 @@ async fn
test_inexact_filter_limit_keeps_correctness_without_fetch_contract() {
#[tokio::test]
async fn test_residual_filter_limit_keeps_connector_limit_and_correctness() {
let sql = "SELECT id, name FROM paimon.default.simple_log_table WHERE id +
1 > 3 LIMIT 1";
- let plan = create_physical_plan(sql)
+ let (_warehouse, plan) = create_physical_plan(sql)
.await
.expect("Physical plan creation should succeed");
let plan_text = format_physical_plan(&plan);
@@ -737,7 +727,7 @@ async fn
test_residual_filter_limit_keeps_connector_limit_and_correctness() {
// ======================= Catalog Provider Tests =======================
#[tokio::test]
async fn test_query_via_catalog_provider() {
- let catalog = create_catalog();
+ let (_warehouse, catalog) = create_catalog();
let catalog: Arc<dyn Catalog> = Arc::new(catalog);
let mut ctx = SQLContext::new();
ctx.register_catalog("paimon", catalog)
@@ -756,7 +746,7 @@ async fn test_query_via_catalog_provider() {
#[tokio::test]
async fn test_missing_database_returns_no_schema() {
- let catalog = create_catalog();
+ let (_warehouse, catalog) = create_catalog();
let provider = PaimonCatalogProvider::new(
None,
Arc::new(catalog),
@@ -773,20 +763,9 @@ async fn test_missing_database_returns_no_schema() {
// ======================= Time Travel Tests =======================
-/// Helper: create a SQLContext with catalog + relation planner for time
travel.
-async fn create_time_travel_context() -> SQLContext {
- let catalog = create_catalog();
- let catalog: Arc<dyn Catalog> = Arc::new(catalog);
- let mut ctx = SQLContext::new();
- ctx.register_catalog("paimon", catalog)
- .await
- .expect("Failed to register catalog");
- ctx
-}
-
#[tokio::test]
async fn test_time_travel_by_snapshot_id() {
- let ctx = create_time_travel_context().await;
+ let (_warehouse, ctx) = create_context().await;
// Snapshot 1: should contain only the first insert (alice, bob)
let batches = ctx
@@ -830,7 +809,7 @@ async fn test_time_travel_by_snapshot_id() {
#[tokio::test]
async fn test_time_travel_by_tag_name() {
- let ctx = create_time_travel_context().await;
+ let (_warehouse, ctx) = create_context().await;
// Tag 'snapshot1' points to snapshot 1: should contain only (alice, bob)
let batches = ctx
@@ -1014,7 +993,7 @@ async fn test_time_travel_conflicting_selectors_fail() {
("1", "scan.snapshot-id"),
("watermark-1", "scan.watermark"),
] {
- let provider = create_provider_with_options(
+ let (_provider_warehouse, provider) = create_provider_with_options(
"time_travel_table",
HashMap::from([
("scan.version".to_string(), version.to_string()),
@@ -1023,7 +1002,7 @@ async fn test_time_travel_conflicting_selectors_fail() {
)
.await;
- let ctx = create_context().await;
+ let (_warehouse, ctx) = create_context().await;
ctx.register_temp_table("paimon.default.time_travel_table",
Arc::new(provider))
.expect("Failed to register temp table");
@@ -1051,13 +1030,13 @@ async fn test_time_travel_conflicting_selectors_fail() {
#[tokio::test]
async fn test_time_travel_invalid_version_fails() {
- let provider = create_provider_with_options(
+ let (_provider_warehouse, provider) = create_provider_with_options(
"time_travel_table",
HashMap::from([("scan.version".to_string(),
"nonexistent-tag".to_string())]),
)
.await;
- let ctx = create_context().await;
+ let (_warehouse, ctx) = create_context().await;
ctx.register_temp_table("paimon.default.time_travel_table",
Arc::new(provider))
.expect("Failed to register temp table");
@@ -1349,7 +1328,7 @@ async fn test_read_complex_type_table_via_datafusion() {
async fn test_select_row_id_from_data_evolution_table() {
use datafusion::arrow::array::Int64Array;
- let ctx = create_context().await;
+ let (_warehouse, ctx) = create_context().await;
let batches = ctx
.sql(r#"SELECT "_ROW_ID", id, name FROM
paimon.default.data_evolution_table"#)
@@ -1385,7 +1364,7 @@ async fn test_select_row_id_from_data_evolution_table() {
async fn test_filter_row_id_from_data_evolution_table() {
use datafusion::arrow::array::Int64Array;
- let ctx = create_context().await;
+ let (_warehouse, ctx) = create_context().await;
let all_batches = ctx
.sql(r#"SELECT "_ROW_ID" FROM paimon.default.data_evolution_table"#)
diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml
deleted file mode 100644
index c5431c09..00000000
--- a/dev/docker-compose.yaml
+++ /dev/null
@@ -1,41 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-#
-# Spark + Paimon for integration tests: writes tables to a local volume
-# so paimon-rust can read them (no MinIO/S3).
-#
-# Usage:
-# 1. Generate test data (run once):
-# docker compose -f dev/docker-compose.yaml run --rm spark-paimon
-# 2. Run read integration tests on the host:
-# PAIMON_TEST_WAREHOUSE=/tmp/paimon-warehouse cargo test -p
paimon-integration-tests read_from_spark
-#
-# To re-provision: remove /tmp/paimon-warehouse and run step 1 again.
-
-services:
- spark-paimon:
- build:
- context: ./spark
- dockerfile: Dockerfile
- volumes:
- # Bind warehouse to host so paimon-rust can read it
- - /tmp/paimon-warehouse:/tmp/paimon-warehouse
- # Run as root to avoid permission issues with mounted volumes
- user: root
- # Run provision script and exit (data remains in /tmp/paimon-warehouse).
- # Use full path: entrypoint exec does not have SPARK_HOME/bin in PATH.
- command: ["/opt/spark/bin/spark-submit", "--master", "local[*]",
"/opt/provision.py"]
diff --git a/dev/spark/Dockerfile b/dev/spark/Dockerfile
deleted file mode 100644
index baf0d2bc..00000000
--- a/dev/spark/Dockerfile
+++ /dev/null
@@ -1,53 +0,0 @@
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-
-# Spark with Paimon connector for integration tests.
-# Writes Paimon tables to /tmp/paimon-warehouse (mount this as a volume
-# so paimon-rust can read the data).
-#
-# Build args can be overridden via Makefile or docker build:
-# --build-arg PAIMON_VERSION=1.3.1
-FROM apache/spark:3.5.3
-
-# Re-declare ARGs so they are available in this stage (ARGs before FROM are
not).
-ARG PAIMON_VERSION
-
-USER root
-WORKDIR ${SPARK_HOME}
-
-RUN apt-get install -y --no-install-recommends curl && \
- rm -rf /var/lib/apt/lists/*
-
-# Paimon Spark runtime JAR (Spark 3.5)
-#
https://repo.maven.apache.org/maven2/org/apache/paimon/paimon-spark-3.5/1.3.1/paimon-spark-3.5-1.3.1.jar
-ENV PAIMON_JAR="paimon-spark-3.5-${PAIMON_VERSION}.jar"
-ENV
PAIMON_JAR_URL="https://repo.maven.apache.org/maven2/org/apache/paimon/paimon-spark-3.5/${PAIMON_VERSION}/${PAIMON_JAR}"
-
-RUN curl -fsSL --retry 3 -o "${SPARK_HOME}/jars/${PAIMON_JAR}" \
- "${PAIMON_JAR_URL}" && \
- chown -R spark:spark "${SPARK_HOME}/jars"
-
-COPY spark-defaults.conf ${SPARK_HOME}/conf/
-COPY provision.py /opt/provision.py
-RUN chown spark:spark ${SPARK_HOME}/conf/spark-defaults.conf /opt/provision.py
-
-USER spark
-
-# Default: run provision script to create tables and write data to
/tmp/paimon-warehouse.
-# Override in docker-compose or when running manually.
-# Use full path: entrypoint exec does not have SPARK_HOME/bin in PATH.
-CMD ["/opt/spark/bin/spark-submit", "--master", "local[*]",
"/opt/provision.py"]
diff --git a/dev/spark/README.md b/dev/spark/README.md
deleted file mode 100644
index 519a526f..00000000
--- a/dev/spark/README.md
+++ /dev/null
@@ -1,60 +0,0 @@
-<!--
-Licensed to the Apache Software Foundation (ASF) under one
-or more contributor license agreements. See the NOTICE file
-distributed with this work for additional information
-regarding copyright ownership. The ASF licenses this file
-to you under the Apache License, Version 2.0 (the
-"License"); you may not use this file except in compliance
-with the License. You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing,
-software distributed under the License is distributed on an
-"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-KIND, either express or implied. See the License for the
-specific language governing permissions and limitations
-under the License.
--->
-
-# Spark Provisioning for Integration Tests
-
-This directory contains the Spark + Paimon setup that provisions test tables
into `/tmp/paimon-warehouse`.
-
-## Prerequisites
-
-- Docker (via colima or Docker Desktop)
-- `docker compose`
-
-## Provision Test Data
-
-```bash
-# Build and run (from repo root):
-make docker-up
-
-# Or manually:
-docker compose -f dev/docker-compose.yaml build --build-arg
PAIMON_VERSION=1.3.1
-docker compose -f dev/docker-compose.yaml run --rm spark-paimon
-```
-
-`provision.py` automatically clears the warehouse directory before creating
tables, so re-running is always safe.
-
-### colima Users
-
-With colima, Docker volumes mount inside the colima VM, **not** on the macOS
host filesystem. After provisioning, copy the data to the host:
-
-```bash
-colima ssh -- sudo tar cf - -C /tmp paimon-warehouse | tar xf - -C /tmp
-```
-
-## Run Integration Tests
-
-```bash
-cargo test -p paimon-integration-tests
-```
-
-## Files
-
-- `Dockerfile` — Spark 3.5 + Paimon connector image
-- `spark-defaults.conf` — Spark config with Paimon catalog
-- `provision.py` — Creates all test tables and inserts data
diff --git a/dev/spark/provision.py b/dev/spark/provision.py
deleted file mode 100644
index 0b209d0e..00000000
--- a/dev/spark/provision.py
+++ /dev/null
@@ -1,1363 +0,0 @@
-#!/usr/bin/env python3
-#
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-#
-# Provisions Paimon tables into the warehouse (file:/tmp/paimon-warehouse)
-# for paimon-rust integration tests to read.
-
-import shutil
-import time
-from pathlib import Path
-from urllib.parse import unquote, urlparse
-
-from pyspark.sql import SparkSession
-
-
-def _warehouse_path_from_spark_conf(spark: SparkSession) -> Path:
- warehouse_uri = spark.conf.get("spark.sql.catalog.paimon.warehouse")
- parsed = urlparse(warehouse_uri)
-
- if parsed.scheme not in ("", "file"):
- raise ValueError(
- f"Unsupported Paimon warehouse URI scheme {parsed.scheme!r}:
{warehouse_uri}"
- )
-
- if parsed.netloc not in ("", "localhost"):
- raise ValueError(
- f"Unsupported remote Paimon warehouse location {parsed.netloc!r}:
{warehouse_uri}"
- )
-
- warehouse_path = Path(unquote(parsed.path if parsed.scheme else
warehouse_uri))
- if not warehouse_path.is_absolute() or str(warehouse_path) == "/":
- raise ValueError(f"Refusing to clear unsafe warehouse path:
{warehouse_path}")
-
- return warehouse_path
-
-
-def _reset_warehouse_dir(warehouse_path: Path) -> None:
- warehouse_path.mkdir(parents=True, exist_ok=True)
-
- for child in warehouse_path.iterdir():
- for attempt in range(3):
- try:
- if child.is_symlink() or child.is_file():
- child.unlink()
- else:
- shutil.rmtree(child)
- break
- except OSError:
- if attempt == 2:
- raise
- time.sleep(0.1)
-
-
-def main():
- spark = SparkSession.builder.getOrCreate()
-
- warehouse_path = _warehouse_path_from_spark_conf(spark)
- _reset_warehouse_dir(warehouse_path)
-
- # Use Paimon catalog (configured in spark-defaults.conf with warehouse
file:/tmp/paimon-warehouse)
- spark.sql("USE paimon.default")
-
- # Table: simple log table for read tests
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS simple_log_table (
- id INT,
- name STRING
- ) USING paimon
- """
- )
- spark.sql("INSERT INTO simple_log_table VALUES (1, 'alice'), (2, 'bob'),
(3, 'carol')")
-
- # Spark SQL here does not accept table constraints like
- # PRIMARY KEY (id) NOT ENFORCED inside the column list, so use
- # Paimon table properties instead.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS simple_pk_table (
- id INT,
- name STRING
- ) USING paimon
- TBLPROPERTIES (
- 'primary-key' = 'id',
- 'bucket' = '1'
- )
- """
- )
- spark.sql(
- """
- INSERT INTO simple_pk_table VALUES
- (1, 'alice'),
- (2, 'bob'),
- (3, 'carol')
- """
- )
-
- # Table: primary key table with deletion vectors enabled.
- # Re-inserting the same keys with newer values creates deleted historical
- # rows that readers must filter via deletion vectors.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS simple_dv_pk_table (
- id INT,
- name STRING
- ) USING paimon
- TBLPROPERTIES (
- 'primary-key' = 'id',
- 'bucket' = '2',
- 'deletion-vectors.enabled' = 'true'
- )
- """
- )
-
- spark.sql(
- """
- INSERT INTO simple_dv_pk_table VALUES
- (1, 'alice-v1'),
- (2, 'bob-v1'),
- (3, 'carol-v1'),
- (5, 'eve-v1')
- """
- )
-
- spark.sql(
- """
- INSERT INTO simple_dv_pk_table VALUES
- (2, 'bob-v2'),
- (3, 'carol-v2'),
- (4, 'dave-v1'),
- (6, 'frank-v1')
- """
- )
-
- spark.sql(
- """
- INSERT INTO simple_dv_pk_table VALUES
- (1, 'alice-v2'),
- (4, 'dave-v2'),
- (5, 'eve-v2')
- """
- )
-
- # ===== Partitioned table: single partition key (dt) =====
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS partitioned_log_table (
- id INT,
- name STRING,
- dt STRING
- ) USING paimon
- PARTITIONED BY (dt)
- """
- )
- spark.sql(
- """
- INSERT INTO partitioned_log_table VALUES
- (1, 'alice', '2024-01-01'),
- (2, 'bob', '2024-01-01'),
- (3, 'carol', '2024-01-02')
- """
- )
-
- # ===== Partitioned table: multiple partition keys (dt, hr) =====
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS multi_partitioned_log_table (
- id INT,
- name STRING,
- dt STRING,
- hr INT
- ) USING paimon
- PARTITIONED BY (dt, hr)
- """
- )
- spark.sql(
- """
- INSERT INTO multi_partitioned_log_table VALUES
- (1, 'alice', '2024-01-01', 10),
- (2, 'bob', '2024-01-01', 10),
- (3, 'carol', '2024-01-01', 20),
- (4, 'dave', '2024-01-02', 10)
- """
- )
-
- # ===== Partitioned table: PK + DV enabled =====
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS partitioned_dv_pk_table (
- id INT,
- name STRING,
- dt STRING
- ) USING paimon
- PARTITIONED BY (dt)
- TBLPROPERTIES (
- 'primary-key' = 'id,dt',
- 'bucket' = '1',
- 'deletion-vectors.enabled' = 'true'
- )
- """
- )
-
- spark.sql(
- """
- INSERT INTO partitioned_dv_pk_table VALUES
- (1, 'alice-v1', '2024-01-01'),
- (2, 'bob-v1', '2024-01-01'),
- (1, 'alice-v1', '2024-01-02'),
- (3, 'carol-v1', '2024-01-02')
- """
- )
-
- spark.sql(
- """
- INSERT INTO partitioned_dv_pk_table VALUES
- (1, 'alice-v2', '2024-01-01'),
- (3, 'carol-v2', '2024-01-02'),
- (4, 'dave-v1', '2024-01-02')
- """
- )
-
- spark.sql(
- """
- INSERT INTO partitioned_dv_pk_table VALUES
- (2, 'bob-v2', '2024-01-01'),
- (4, 'dave-v2', '2024-01-02')
- """
- )
-
- # ===== Data Evolution table: append-only with row tracking =====
- # data-evolution.enabled + row-tracking.enabled allows partial column
updates
- # via MERGE INTO. This produces files with different write_cols covering
the
- # same row ID ranges, exercising the column-wise merge read path.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS data_evolution_table (
- id INT,
- name STRING,
- value INT
- ) USING paimon
- TBLPROPERTIES (
- 'row-tracking.enabled' = 'true',
- 'data-evolution.enabled' = 'true'
- )
- """
- )
-
- # First batch: rows with row_id 0, 1, 2 — all columns written
- spark.sql(
- """
- INSERT INTO data_evolution_table VALUES
- (1, 'alice', 100),
- (2, 'bob', 200),
- (3, 'carol', 300)
- """
- )
-
- # Second batch: rows with row_id 3, 4
- spark.sql(
- """
- INSERT INTO data_evolution_table VALUES
- (4, 'dave', 400),
- (5, 'eve', 500)
- """
- )
-
- # MERGE INTO: partial column update on existing rows.
- # This writes new files containing only the updated column (name) with the
- # same first_row_id, so the reader must merge columns from multiple files.
- # Paimon 1.3.1 requires the source table to be a Paimon table.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS data_evolution_updates (
- id INT,
- name STRING
- ) USING paimon
- """
- )
- spark.sql(
- """
- INSERT INTO data_evolution_updates VALUES (1, 'alice-v2'), (3,
'carol-v2')
- """
- )
- spark.sql(
- """
- MERGE INTO data_evolution_table t
- USING data_evolution_updates s
- ON t.id = s.id
- WHEN MATCHED THEN UPDATE SET t.name = s.name
- """
- )
- spark.sql("DROP TABLE data_evolution_updates")
-
- # ===== Time travel table: multiple snapshots for time travel tests =====
- # Snapshot 1: rows (1, 'alice'), (2, 'bob')
- # Snapshot 2: rows (1, 'alice'), (2, 'bob'), (3, 'carol'), (4, 'dave')
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS time_travel_table (
- id INT,
- name STRING
- ) USING paimon
- """
- )
- spark.sql(
- """
- INSERT INTO time_travel_table VALUES
- (1, 'alice'),
- (2, 'bob')
- """
- )
- spark.sql(
- """
- INSERT INTO time_travel_table VALUES
- (3, 'carol'),
- (4, 'dave')
- """
- )
-
- # Create tags for tag-based time travel tests
- # Tag 'snapshot1' points to snapshot 1 (alice, bob)
- # Tag 'snapshot2' points to snapshot 2 (alice, bob, carol, dave)
- spark.sql("CALL sys.create_tag('default.time_travel_table', 'snapshot1',
1)")
- spark.sql("CALL sys.create_tag('default.time_travel_table', 'snapshot2',
2)")
-
- # ===== Time travel + schema evolution table =====
- # Each tag points at a stable schema boundary. Tests read by tag name
rather
- # than depending on global warehouse snapshot numbering.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS time_travel_schema_evolution (
- id INT,
- name STRING
- ) USING paimon
- """
- )
- spark.sql(
- """
- INSERT INTO time_travel_schema_evolution VALUES
- (1, 'alice'),
- (2, 'bob')
- """
- )
- spark.sql("CALL sys.create_tag('default.time_travel_schema_evolution',
'before_add_column', 1)")
-
- spark.sql("ALTER TABLE time_travel_schema_evolution ADD COLUMNS (age INT)")
- spark.sql(
- """
- INSERT INTO time_travel_schema_evolution VALUES
- (3, 'carol', 30),
- (4, 'dave', 40)
- """
- )
- spark.sql("CALL sys.create_tag('default.time_travel_schema_evolution',
'after_add_column', 2)")
- spark.sql("CALL sys.create_tag('default.time_travel_schema_evolution',
'before_rename', 2)")
-
- spark.sql("ALTER TABLE time_travel_schema_evolution RENAME COLUMN name TO
full_name")
- spark.sql(
- """
- INSERT INTO time_travel_schema_evolution VALUES
- (5, 'erin', 50),
- (6, 'frank', 60)
- """
- )
- spark.sql("CALL sys.create_tag('default.time_travel_schema_evolution',
'after_rename', 3)")
- spark.sql("CALL sys.create_tag('default.time_travel_schema_evolution',
'before_drop', 3)")
-
- spark.sql("ALTER TABLE time_travel_schema_evolution DROP COLUMN age")
- spark.sql(
- """
- INSERT INTO time_travel_schema_evolution VALUES
- (7, 'grace'),
- (8, 'hank')
- """
- )
- spark.sql("CALL sys.create_tag('default.time_travel_schema_evolution',
'after_drop', 4)")
- spark.sql("CALL sys.create_tag('default.time_travel_schema_evolution',
'before_reorder', 4)")
-
- spark.sql("ALTER TABLE time_travel_schema_evolution ALTER COLUMN full_name
FIRST")
- spark.sql(
- """
- INSERT INTO time_travel_schema_evolution VALUES
- ('ivy', 9),
- ('jane', 10)
- """
- )
- spark.sql("CALL sys.create_tag('default.time_travel_schema_evolution',
'after_reorder', 5)")
-
- # ===== Schema Evolution: Add Column =====
- # Old files have (id, name); after ALTER TABLE ADD COLUMNS, new files have
(id, name, age).
- # Reader must fill nulls for 'age' when reading old files.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS schema_evolution_add_column (
- id INT,
- name STRING
- ) USING paimon
- """
- )
- spark.sql(
- "INSERT INTO schema_evolution_add_column VALUES (1, 'alice'), (2,
'bob')"
- )
- spark.sql("ALTER TABLE schema_evolution_add_column ADD COLUMNS (age INT)")
- spark.sql(
- "INSERT INTO schema_evolution_add_column VALUES (3, 'carol', 30), (4,
'dave', 40)"
- )
-
- # ===== Schema Evolution: Type Promotion (INT -> BIGINT) =====
- # Old files have value as INT; after ALTER TABLE, new files have value as
BIGINT.
- # Reader must cast INT to BIGINT when reading old files.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS schema_evolution_type_promotion (
- id INT,
- value INT
- ) USING paimon
- """
- )
- spark.sql(
- "INSERT INTO schema_evolution_type_promotion VALUES (1, 100), (2, 200)"
- )
- spark.sql(
- "ALTER TABLE schema_evolution_type_promotion ALTER COLUMN value TYPE
BIGINT"
- )
- spark.sql(
- "INSERT INTO schema_evolution_type_promotion VALUES (3, 3000000000)"
- )
-
- # ===== Mixed-format Schema Evolution: Add Column =====
- # Old Parquet files lack age; new ORC/Avro files contain age.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS format_schema_evolution_add_column (
- id INT,
- name STRING
- ) USING paimon
- TBLPROPERTIES (
- 'file.format' = 'parquet'
- )
- """
- )
- spark.sql(
- "INSERT INTO format_schema_evolution_add_column VALUES (1, 'alice'),
(2, 'bob')"
- )
- spark.sql("ALTER TABLE format_schema_evolution_add_column ADD COLUMNS (age
INT)")
- spark.sql("ALTER TABLE format_schema_evolution_add_column SET
TBLPROPERTIES ('file.format' = 'orc')")
- spark.sql(
- "INSERT INTO format_schema_evolution_add_column VALUES (3, 'carol',
30), (4, 'dave', 40)"
- )
- spark.sql("ALTER TABLE format_schema_evolution_add_column SET
TBLPROPERTIES ('file.format' = 'avro')")
- spark.sql(
- "INSERT INTO format_schema_evolution_add_column VALUES (5, 'eve', 50),
(6, 'frank', 60)"
- )
-
- # ===== Partitioned Mixed-format Schema Evolution: Add Column =====
- # Old Parquet files lack extra; new ORC/Avro files contain extra across dt
partitions.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS
partitioned_format_schema_evolution_add_column (
- id INT,
- name STRING,
- dt STRING
- ) USING paimon
- PARTITIONED BY (dt)
- TBLPROPERTIES (
- 'file.format' = 'parquet'
- )
- """
- )
- spark.sql(
- """
- INSERT INTO partitioned_format_schema_evolution_add_column VALUES
- (1, 'alice', '2024-01-01'),
- (2, 'bob', '2024-01-02')
- """
- )
- spark.sql(
- "ALTER TABLE partitioned_format_schema_evolution_add_column ADD
COLUMNS (extra STRING)"
- )
- spark.sql(
- "ALTER TABLE partitioned_format_schema_evolution_add_column SET
TBLPROPERTIES ('file.format' = 'orc')"
- )
- spark.sql(
- """
- INSERT INTO partitioned_format_schema_evolution_add_column (id, name,
extra, dt) VALUES
- (3, 'carol', 'orc-extra-1', '2024-01-01'),
- (4, 'dave', 'orc-extra-2', '2024-01-03')
- """
- )
- spark.sql(
- "ALTER TABLE partitioned_format_schema_evolution_add_column SET
TBLPROPERTIES ('file.format' = 'avro')"
- )
- spark.sql(
- """
- INSERT INTO partitioned_format_schema_evolution_add_column (id, name,
extra, dt) VALUES
- (5, 'eve', 'avro-extra-1', '2024-01-02'),
- (6, 'frank', 'avro-extra-2', '2024-01-03')
- """
- )
-
- # ===== Mixed-format Schema Evolution: Type Promotion (INT -> BIGINT) =====
- # Old Parquet files have value as INT; new ORC/Avro files have value as
BIGINT.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS format_schema_evolution_type_promotion (
- id INT,
- value INT
- ) USING paimon
- TBLPROPERTIES (
- 'file.format' = 'parquet'
- )
- """
- )
- spark.sql(
- "INSERT INTO format_schema_evolution_type_promotion VALUES (1, 100),
(2, 200)"
- )
- spark.sql(
- "ALTER TABLE format_schema_evolution_type_promotion ALTER COLUMN value
TYPE BIGINT"
- )
- spark.sql("ALTER TABLE format_schema_evolution_type_promotion SET
TBLPROPERTIES ('file.format' = 'orc')")
- spark.sql(
- "INSERT INTO format_schema_evolution_type_promotion VALUES (3,
3000000000), (4, 4000000000)"
- )
- spark.sql("ALTER TABLE format_schema_evolution_type_promotion SET
TBLPROPERTIES ('file.format' = 'avro')")
- spark.sql(
- "INSERT INTO format_schema_evolution_type_promotion VALUES (5,
5000000000), (6, 6000000000)"
- )
-
- # ===== Mixed-format Data Evolution: Add Column =====
- # Combines row-tracking/data-evolution with ADD COLUMN and mixed file
formats.
- # Old Parquet files lack extra; new ORC/Avro files contain extra.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS data_evolution_mixed_format_add_column (
- id INT,
- name STRING,
- value INT
- ) USING paimon
- TBLPROPERTIES (
- 'row-tracking.enabled' = 'true',
- 'data-evolution.enabled' = 'true',
- 'file.format' = 'parquet'
- )
- """
- )
- spark.sql(
- """
- INSERT INTO data_evolution_mixed_format_add_column VALUES
- (1, 'alice', 100),
- (2, 'bob', 200)
- """
- )
- spark.sql("ALTER TABLE data_evolution_mixed_format_add_column ADD COLUMNS
(extra STRING)")
- spark.sql("ALTER TABLE data_evolution_mixed_format_add_column SET
TBLPROPERTIES ('file.format' = 'orc')")
- spark.sql(
- "INSERT INTO data_evolution_mixed_format_add_column VALUES (3,
'carol', 300, 'orc-extra')"
- )
- spark.sql("ALTER TABLE data_evolution_mixed_format_add_column SET
TBLPROPERTIES ('file.format' = 'avro')")
- spark.sql(
- "INSERT INTO data_evolution_mixed_format_add_column VALUES (4, 'dave',
400, 'avro-extra')"
- )
-
- # ===== Mixed-format Data Evolution: Type Promotion =====
- # Old Parquet files have INT; new ORC/Avro files have BIGINT.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS data_evolution_mixed_format_type_promotion (
- id INT,
- value INT
- ) USING paimon
- TBLPROPERTIES (
- 'row-tracking.enabled' = 'true',
- 'data-evolution.enabled' = 'true',
- 'file.format' = 'parquet'
- )
- """
- )
- spark.sql(
- "INSERT INTO data_evolution_mixed_format_type_promotion VALUES (1,
100), (2, 200)"
- )
- spark.sql(
- "ALTER TABLE data_evolution_mixed_format_type_promotion ALTER COLUMN
value TYPE BIGINT"
- )
- spark.sql("ALTER TABLE data_evolution_mixed_format_type_promotion SET
TBLPROPERTIES ('file.format' = 'orc')")
- spark.sql(
- "INSERT INTO data_evolution_mixed_format_type_promotion VALUES (3,
3000000000)"
- )
- spark.sql("ALTER TABLE data_evolution_mixed_format_type_promotion SET
TBLPROPERTIES ('file.format' = 'avro')")
- spark.sql(
- "INSERT INTO data_evolution_mixed_format_type_promotion VALUES (4,
4000000000)"
- )
-
- # ===== Data Evolution + Schema Evolution: Add Column =====
- # Combines data-evolution (row-tracking + MERGE INTO) with ALTER TABLE ADD
COLUMNS.
- # Old files lack the new column; MERGE INTO produces partial-column files.
- # Reader must fill nulls for missing columns AND merge columns across
files.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS data_evolution_add_column (
- id INT,
- name STRING,
- value INT
- ) USING paimon
- TBLPROPERTIES (
- 'row-tracking.enabled' = 'true',
- 'data-evolution.enabled' = 'true'
- )
- """
- )
- spark.sql(
- """
- INSERT INTO data_evolution_add_column VALUES
- (1, 'alice', 100),
- (2, 'bob', 200)
- """
- )
- spark.sql("ALTER TABLE data_evolution_add_column ADD COLUMNS (extra
STRING)")
- spark.sql(
- """
- INSERT INTO data_evolution_add_column VALUES
- (3, 'carol', 300, 'new'),
- (4, 'dave', 400, 'new')
- """
- )
- # MERGE INTO to trigger merge_files_by_columns with schema evolution.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS data_evolution_add_column_updates (
- id INT,
- name STRING
- ) USING paimon
- """
- )
- spark.sql(
- "INSERT INTO data_evolution_add_column_updates VALUES (1, 'alice-v2')"
- )
- spark.sql(
- """
- MERGE INTO data_evolution_add_column t
- USING data_evolution_add_column_updates s
- ON t.id = s.id
- WHEN MATCHED THEN UPDATE SET t.name = s.name
- """
- )
- spark.sql("DROP TABLE data_evolution_add_column_updates")
-
- # ===== Data Evolution + Schema Evolution: Type Promotion =====
- # Combines data-evolution with ALTER TABLE ALTER COLUMN TYPE (INT ->
BIGINT).
- # Old files have INT; new files have BIGINT. MERGE INTO updates some rows.
- # Reader must cast old INT columns to BIGINT AND merge columns across
files.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS data_evolution_type_promotion (
- id INT,
- value INT
- ) USING paimon
- TBLPROPERTIES (
- 'row-tracking.enabled' = 'true',
- 'data-evolution.enabled' = 'true'
- )
- """
- )
- spark.sql(
- "INSERT INTO data_evolution_type_promotion VALUES (1, 100), (2, 200)"
- )
- spark.sql(
- "ALTER TABLE data_evolution_type_promotion ALTER COLUMN value TYPE
BIGINT"
- )
- spark.sql(
- "INSERT INTO data_evolution_type_promotion VALUES (3, 3000000000)"
- )
- # MERGE INTO to trigger merge_files_by_columns with type promotion.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS data_evolution_type_promotion_updates (
- id INT,
- value BIGINT
- ) USING paimon
- """
- )
- spark.sql(
- "INSERT INTO data_evolution_type_promotion_updates VALUES (1, 999)"
- )
- spark.sql(
- """
- MERGE INTO data_evolution_type_promotion t
- USING data_evolution_type_promotion_updates s
- ON t.id = s.id
- WHEN MATCHED THEN UPDATE SET t.value = s.value
- """
- )
- spark.sql("DROP TABLE data_evolution_type_promotion_updates")
-
- # ===== Data Evolution + Drop Column: tests NULL-fill when no file
provides a column =====
- # After MERGE INTO on old rows, the merge group files all predate ADD
COLUMN.
- # SELECT on the new column should return NULLs for old rows (not silently
drop them).
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS data_evolution_drop_column (
- id INT,
- name STRING,
- value INT
- ) USING paimon
- TBLPROPERTIES (
- 'row-tracking.enabled' = 'true',
- 'data-evolution.enabled' = 'true'
- )
- """
- )
- spark.sql(
- """
- INSERT INTO data_evolution_drop_column VALUES
- (1, 'alice', 100),
- (2, 'bob', 200)
- """
- )
- # MERGE INTO to create a partial-column file in the same row_id range.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS data_evolution_drop_column_updates (
- id INT,
- name STRING
- ) USING paimon
- """
- )
- spark.sql(
- "INSERT INTO data_evolution_drop_column_updates VALUES (1, 'alice-v2')"
- )
- spark.sql(
- """
- MERGE INTO data_evolution_drop_column t
- USING data_evolution_drop_column_updates s
- ON t.id = s.id
- WHEN MATCHED THEN UPDATE SET t.name = s.name
- """
- )
- spark.sql("DROP TABLE data_evolution_drop_column_updates")
- # Add a new column that no existing file contains.
- spark.sql("ALTER TABLE data_evolution_drop_column ADD COLUMNS (extra
STRING)")
- # Insert new rows that DO have the extra column.
- spark.sql(
- """
- INSERT INTO data_evolution_drop_column VALUES
- (3, 'carol', 300, 'new')
- """
- )
-
- # ===== Schema Evolution: Drop Column =====
- # Old files have (id, name, score); after ALTER TABLE DROP COLUMN, table
has (id, name).
- # Reader should ignore the dropped column when reading old files.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS schema_evolution_drop_column (
- id INT,
- name STRING,
- score INT
- ) USING paimon
- """
- )
- spark.sql(
- """
- INSERT INTO schema_evolution_drop_column VALUES
- (1, 'alice', 100),
- (2, 'bob', 200)
- """
- )
- spark.sql("ALTER TABLE schema_evolution_drop_column DROP COLUMN score")
- spark.sql(
- """
- INSERT INTO schema_evolution_drop_column VALUES
- (3, 'carol'),
- (4, 'dave')
- """
- )
-
- # ===== Schema Evolution: Rename Column across mixed file formats =====
- # Old files have physical column name 'payload'; after RENAME COLUMN, new
files
- # have 'renamed_payload'. Reader should map by field id and expose the new
name.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS schema_evolution_rename_column (
- id INT,
- payload STRING
- ) USING paimon
- TBLPROPERTIES (
- 'file.format' = 'parquet'
- )
- """
- )
- spark.sql(
- """
- INSERT INTO schema_evolution_rename_column VALUES
- (1, 'parquet-old'),
- (2, 'parquet-old-2')
- """
- )
- spark.sql(
- "ALTER TABLE schema_evolution_rename_column RENAME COLUMN payload TO
renamed_payload"
- )
- spark.sql("ALTER TABLE schema_evolution_rename_column SET TBLPROPERTIES
('file.format' = 'orc')")
- spark.sql(
- """
- INSERT INTO schema_evolution_rename_column VALUES
- (3, 'orc-new')
- """
- )
- spark.sql("ALTER TABLE schema_evolution_rename_column SET TBLPROPERTIES
('file.format' = 'avro')")
- spark.sql(
- """
- INSERT INTO schema_evolution_rename_column VALUES
- (4, 'avro-new')
- """
- )
-
- # ===== Mixed-format Schema Evolution: Drop Column =====
- # Old Parquet/ORC files have (id, name, score); after DROP COLUMN, Avro
files
- # have only (id, name). Reader should ignore the dropped column in old
files.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS mixed_format_schema_evolution_drop_column (
- id INT,
- name STRING,
- score INT
- ) USING paimon
- TBLPROPERTIES (
- 'file.format' = 'parquet'
- )
- """
- )
- spark.sql(
- """
- INSERT INTO mixed_format_schema_evolution_drop_column VALUES
- (1, 'parquet-alice', 100),
- (2, 'parquet-bob', 200)
- """
- )
- spark.sql(
- "ALTER TABLE mixed_format_schema_evolution_drop_column SET
TBLPROPERTIES ('file.format' = 'orc')"
- )
- spark.sql(
- """
- INSERT INTO mixed_format_schema_evolution_drop_column VALUES
- (3, 'orc-carol', 300),
- (4, 'orc-dave', 400)
- """
- )
- spark.sql("ALTER TABLE mixed_format_schema_evolution_drop_column DROP
COLUMN score")
- spark.sql(
- "ALTER TABLE mixed_format_schema_evolution_drop_column SET
TBLPROPERTIES ('file.format' = 'avro')"
- )
- spark.sql(
- """
- INSERT INTO mixed_format_schema_evolution_drop_column VALUES
- (5, 'avro-eve'),
- (6, 'avro-frank')
- """
- )
-
- # ===== Mixed-format Schema Evolution: Reorder/Move Column =====
- # Old Parquet files use the original order (id, left_value, right_value).
- # ORC and Avro files are written after moving columns; readers should
expose
- # the current table schema order and map old/new files by field id.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS
mixed_format_schema_evolution_reorder_move_column (
- id INT,
- left_value STRING,
- right_value STRING
- ) USING paimon
- TBLPROPERTIES (
- 'file.format' = 'parquet'
- )
- """
- )
- spark.sql(
- """
- INSERT INTO mixed_format_schema_evolution_reorder_move_column VALUES
- (1, 'parquet-left-1', 'parquet-right-1'),
- (2, 'parquet-left-2', 'parquet-right-2')
- """
- )
- spark.sql(
- "ALTER TABLE mixed_format_schema_evolution_reorder_move_column ALTER
COLUMN right_value FIRST"
- )
- spark.sql(
- "ALTER TABLE mixed_format_schema_evolution_reorder_move_column SET
TBLPROPERTIES ('file.format' = 'orc')"
- )
- spark.sql(
- """
- INSERT INTO mixed_format_schema_evolution_reorder_move_column VALUES
- ('orc-right-3', 3, 'orc-left-3'),
- ('orc-right-4', 4, 'orc-left-4')
- """
- )
- spark.sql(
- "ALTER TABLE mixed_format_schema_evolution_reorder_move_column ALTER
COLUMN left_value AFTER right_value"
- )
- spark.sql(
- "ALTER TABLE mixed_format_schema_evolution_reorder_move_column SET
TBLPROPERTIES ('file.format' = 'avro')"
- )
- spark.sql(
- """
- INSERT INTO mixed_format_schema_evolution_reorder_move_column VALUES
- ('avro-right-5', 'avro-left-5', 5),
- ('avro-right-6', 'avro-left-6', 6)
- """
- )
-
- # ===== Complex Types table: ARRAY, MAP, STRUCT =====
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS complex_type_table (
- id INT,
- int_array ARRAY<INT>,
- string_map MAP<STRING, INT>,
- row_field STRUCT<name: STRING, value: INT>
- ) USING paimon
- """
- )
- spark.sql(
- """
- INSERT INTO complex_type_table VALUES
- (1, array(1, 2, 3), map('a', 10, 'b', 20), named_struct('name',
'alice', 'value', 100)),
- (2, array(4, 5), map('c', 30), named_struct('name', 'bob',
'value', 200)),
- (3, array(), map(), named_struct('name', 'carol', 'value', 300))
- """
- )
-
- # ===== Non-PK table with deletion vectors enabled =====
- # Append-only table with DV: level-0 files should NOT be filtered out
- # because there is no primary key merge.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS simple_dv_log_table (
- id INT,
- name STRING
- ) USING paimon
- TBLPROPERTIES (
- 'deletion-vectors.enabled' = 'true'
- )
- """
- )
- spark.sql(
- """
- INSERT INTO simple_dv_log_table VALUES
- (1, 'alice'),
- (2, 'bob'),
- (3, 'carol')
- """
- )
-
- # ===== Postpone bucket PK table (bucket = -2) =====
- # New data lands in bucket-postpone and is NOT visible to readers until
compacted.
- # Without running compaction, the table should appear empty to batch
readers.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS postpone_bucket_pk_table (
- id INT,
- name STRING
- ) USING paimon
- TBLPROPERTIES (
- 'primary-key' = 'id',
- 'bucket' = '-2',
- 'deletion-vectors.enabled' = 'true'
- )
- """
- )
- spark.sql(
- """
- INSERT INTO postpone_bucket_pk_table VALUES
- (1, 'alice'),
- (2, 'bob'),
- (3, 'carol')
- """
- )
-
- # Empty postpone table for Go fixed-bucket write tests.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS postpone_fixed_bucket_pk_table (
- id INT,
- name STRING,
- dt STRING
- ) USING paimon
- PARTITIONED BY (dt)
- TBLPROPERTIES (
- 'primary-key' = 'id,dt',
- 'bucket' = '-2'
- )
- """
- )
-
- # ===== Dynamic bucket PK table (bucket=-1) =====
- # Two commits with overlapping keys to exercise dynamic bucket assignment
- # and index file generation. Used to verify that paimon-rust produces
- # identical hash index values when writing the same data.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS dynamic_bucket_pk_table (
- id INT,
- name STRING
- ) USING paimon
- TBLPROPERTIES (
- 'primary-key' = 'id',
- 'bucket' = '-1'
- )
- """
- )
- spark.sql(
- """
- INSERT INTO dynamic_bucket_pk_table VALUES
- (1, 'alice'),
- (2, 'bob'),
- (3, 'carol')
- """
- )
- spark.sql(
- """
- INSERT INTO dynamic_bucket_pk_table VALUES
- (2, 'bob-v2'),
- (3, 'carol-v2'),
- (4, 'dave')
- """
- )
-
- # ===== Multi-bucket PK table for bucket predicate filtering tests =====
- # PK table with bucket=4 so data distributes across multiple buckets.
- # Bucket key defaults to primary key (id). Used to test that bucket
predicate
- # filtering correctly computes target buckets from equality predicates.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS multi_bucket_pk_table (
- id INT,
- name STRING
- ) USING paimon
- TBLPROPERTIES (
- 'primary-key' = 'id',
- 'bucket' = '4',
- 'deletion-vectors.enabled' = 'true'
- )
- """
- )
- spark.sql(
- """
- INSERT INTO multi_bucket_pk_table VALUES
- (1, 'alice'),
- (2, 'bob'),
- (3, 'carol'),
- (4, 'dave'),
- (5, 'eve'),
- (6, 'frank'),
- (7, 'grace'),
- (8, 'heidi')
- """
- )
-
- # ===== String bucket key tables for variable-length hash tests =====
- # Short string keys (<=7 bytes) use inline encoding in BinaryRow.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS string_bucket_short_key (
- code STRING,
- value INT
- ) USING paimon
- TBLPROPERTIES (
- 'primary-key' = 'code',
- 'bucket' = '4',
- 'deletion-vectors.enabled' = 'true'
- )
- """
- )
- spark.sql(
- """
- INSERT INTO string_bucket_short_key VALUES
- ('aaa', 1),
- ('bbb', 2),
- ('ccc', 3),
- ('ddd', 4),
- ('eee', 5),
- ('fff', 6),
- ('ggg', 7),
- ('hhh', 8)
- """
- )
-
- # Long string keys (>7 bytes) use variable-length encoding in BinaryRow.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS string_bucket_long_key (
- code STRING,
- value INT
- ) USING paimon
- TBLPROPERTIES (
- 'primary-key' = 'code',
- 'bucket' = '4',
- 'deletion-vectors.enabled' = 'true'
- )
- """
- )
- spark.sql(
- """
- INSERT INTO string_bucket_long_key VALUES
- ('alpha-long-key', 1),
- ('bravo-long-key', 2),
- ('charlie-long-key', 3),
- ('delta-long-key', 4),
- ('echo-long-key', 5),
- ('foxtrot-long-key', 6),
- ('golf-long-key', 7),
- ('hotel-long-key', 8)
- """
- )
-
- # ===== Full types table: parquet, orc, avro =====
- # Note: Spark 3.x does not support parameterized timestamp precision (e.g.
TIMESTAMP(3)),
- # so all timestamps here use the default precision 6 (microseconds).
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS full_types_table (
- id INT,
- col_boolean BOOLEAN,
- col_tinyint TINYINT,
- col_smallint SMALLINT,
- col_int INT,
- col_bigint BIGINT,
- col_float FLOAT,
- col_double DOUBLE,
- col_decimal DECIMAL(10, 2),
- col_decimal5 DECIMAL(5),
- col_decimal38 DECIMAL(38, 18),
- col_string STRING,
- col_binary BINARY,
- col_date DATE,
- col_timestamp TIMESTAMP_NTZ,
- col_timestamp_ltz TIMESTAMP,
- col_array ARRAY<INT>,
- col_map MAP<STRING, INT>,
- col_struct STRUCT<name: STRING, value: INT>
- ) USING paimon
- TBLPROPERTIES (
- 'file.format' = 'parquet'
- )
- """
- )
- # Parquet row
- spark.sql(
- """
- INSERT INTO full_types_table VALUES
- (1, true, CAST(1 AS TINYINT), CAST(100 AS SMALLINT), 1000, 100000,
- CAST(1.5 AS FLOAT), 2.5, CAST(123.45 AS DECIMAL(10,2)),
- CAST(12345 AS DECIMAL(5)), CAST(12345.678901234567890 AS
DECIMAL(38,18)),
- 'parquet-hello', X'DEADBEEF',
- DATE '2024-01-01',
- TIMESTAMP_NTZ '2024-01-01 10:00:00.123456',
- TIMESTAMP '2024-01-01 10:00:00.123456',
- array(1, 2, 3), map('a', 10, 'b', 20),
- named_struct('name', 'alice', 'value', 100))
- """
- )
- # Switch to ORC
- spark.sql("ALTER TABLE full_types_table SET TBLPROPERTIES ('file.format' =
'orc')")
- spark.sql(
- """
- INSERT INTO full_types_table VALUES
- (2, false, CAST(2 AS TINYINT), CAST(200 AS SMALLINT), 2000, 200000,
- CAST(3.5 AS FLOAT), 4.5, CAST(678.90 AS DECIMAL(10,2)),
- CAST(99999 AS DECIMAL(5)), CAST(99999.999999999999999 AS
DECIMAL(38,18)),
- 'orc-world', X'CAFEBABE',
- DATE '2024-06-15',
- TIMESTAMP_NTZ '2024-06-15 12:30:00.456789',
- TIMESTAMP '2024-06-15 12:30:00.456789',
- array(4, 5), map('c', 30),
- named_struct('name', 'bob', 'value', 200))
- """
- )
- # Switch to Avro
- spark.sql("ALTER TABLE full_types_table SET TBLPROPERTIES ('file.format' =
'avro')")
- spark.sql(
- """
- INSERT INTO full_types_table VALUES
- (3, true, CAST(3 AS TINYINT), CAST(300 AS SMALLINT), 3000, 300000,
- CAST(5.5 AS FLOAT), 6.5, CAST(999.99 AS DECIMAL(10,2)),
- CAST(0 AS DECIMAL(5)), CAST(0.000000000000000001 AS
DECIMAL(38,18)),
- 'avro-test', X'01020304',
- DATE '2025-12-31',
- TIMESTAMP_NTZ '2025-12-31 23:59:59.999999',
- TIMESTAMP '2025-12-31 23:59:59.999999',
- array(6), map('d', 40, 'e', 50),
- named_struct('name', 'carol', 'value', 300))
- """
- )
-
- # ===== Full types boundary table: parquet, orc, avro =====
- # Each format writes one boundary row and one all-null row for nullable
fields.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS full_types_boundary_table (
- id INT,
- col_boolean BOOLEAN,
- col_tinyint TINYINT,
- col_smallint SMALLINT,
- col_int INT,
- col_bigint BIGINT,
- col_float FLOAT,
- col_double DOUBLE,
- col_decimal DECIMAL(10, 2),
- col_decimal5 DECIMAL(5),
- col_decimal38 DECIMAL(38, 18),
- col_string STRING,
- col_binary BINARY,
- col_date DATE,
- col_timestamp TIMESTAMP_NTZ,
- col_timestamp_ltz TIMESTAMP,
- col_array ARRAY<INT>,
- col_map MAP<STRING, INT>,
- col_struct STRUCT<name: STRING, value: INT>
- ) USING paimon
- TBLPROPERTIES (
- 'file.format' = 'parquet'
- )
- """
- )
- spark.sql(
- """
- INSERT INTO full_types_boundary_table VALUES
- (1, false, CAST('-128' AS TINYINT), CAST('-32768' AS SMALLINT),
- CAST('-2147483648' AS INT), CAST('-9223372036854775808' AS
BIGINT),
- CAST(-0.5 AS FLOAT), -1.25, CAST('-99999999.99' AS DECIMAL(10,2)),
- CAST('-99999' AS DECIMAL(5)),
- CAST('-99999999999999999999.999999999999999999' AS
DECIMAL(38,18)),
- '', X'',
- DATE '1969-12-31',
- TIMESTAMP_NTZ '1970-01-01 00:00:00.000001',
- TIMESTAMP '1970-01-01 00:00:00.000001',
- array(CAST(NULL AS INT), CAST('-2147483648' AS INT), CAST(0 AS
INT)),
- map('negative', CAST('-2147483648' AS INT), 'zero', CAST(NULL AS
INT)),
- named_struct('name', CAST(NULL AS STRING), 'value', CAST(-1 AS
INT))),
- (2, CAST(NULL AS BOOLEAN), CAST(NULL AS TINYINT), CAST(NULL AS
SMALLINT),
- CAST(NULL AS INT), CAST(NULL AS BIGINT),
- CAST(NULL AS FLOAT), CAST(NULL AS DOUBLE), CAST(NULL AS
DECIMAL(10,2)),
- CAST(NULL AS DECIMAL(5)), CAST(NULL AS DECIMAL(38,18)),
- CAST(NULL AS STRING), CAST(NULL AS BINARY),
- CAST(NULL AS DATE),
- CAST(NULL AS TIMESTAMP_NTZ),
- CAST(NULL AS TIMESTAMP),
- CAST(NULL AS ARRAY<INT>),
- CAST(NULL AS MAP<STRING, INT>),
- CAST(NULL AS STRUCT<name: STRING, value: INT>))
- """
- )
- spark.sql("ALTER TABLE full_types_boundary_table SET TBLPROPERTIES
('file.format' = 'orc')")
- spark.sql(
- """
- INSERT INTO full_types_boundary_table VALUES
- (3, true, CAST('127' AS TINYINT), CAST('32767' AS SMALLINT),
- CAST('2147483647' AS INT), CAST('9223372036854775807' AS BIGINT),
- CAST(0.25 AS FLOAT), 0.5, CAST('99999999.99' AS DECIMAL(10,2)),
- CAST('99999' AS DECIMAL(5)),
- CAST('99999999999999999999.999999999999999999' AS DECIMAL(38,18)),
- 'orc-boundary', X'00FF',
- DATE '1970-01-01',
- TIMESTAMP_NTZ '1970-01-01 00:00:00',
- TIMESTAMP '1970-01-01 00:00:00',
- CAST(array() AS ARRAY<INT>),
- CAST(map() AS MAP<STRING, INT>),
- named_struct('name', 'orc', 'value', CAST(NULL AS INT))),
- (4, CAST(NULL AS BOOLEAN), CAST(NULL AS TINYINT), CAST(NULL AS
SMALLINT),
- CAST(NULL AS INT), CAST(NULL AS BIGINT),
- CAST(NULL AS FLOAT), CAST(NULL AS DOUBLE), CAST(NULL AS
DECIMAL(10,2)),
- CAST(NULL AS DECIMAL(5)), CAST(NULL AS DECIMAL(38,18)),
- CAST(NULL AS STRING), CAST(NULL AS BINARY),
- CAST(NULL AS DATE),
- CAST(NULL AS TIMESTAMP_NTZ),
- CAST(NULL AS TIMESTAMP),
- CAST(NULL AS ARRAY<INT>),
- CAST(NULL AS MAP<STRING, INT>),
- CAST(NULL AS STRUCT<name: STRING, value: INT>))
- """
- )
- spark.sql("ALTER TABLE full_types_boundary_table SET TBLPROPERTIES
('file.format' = 'avro')")
- spark.sql(
- """
- INSERT INTO full_types_boundary_table VALUES
- (5, false, CAST(0 AS TINYINT), CAST(0 AS SMALLINT),
- 0, 0,
- CAST(0.0 AS FLOAT), 0.0, CAST(0.00 AS DECIMAL(10,2)),
- CAST(0 AS DECIMAL(5)), CAST(0 AS DECIMAL(38,18)),
- 'avro-boundary', X'0102',
- DATE '1970-01-02',
- TIMESTAMP_NTZ '1970-01-01 00:00:00.999999',
- TIMESTAMP '1970-01-01 00:00:00.999999',
- array(CAST(7 AS INT)),
- map('seven', 7),
- named_struct('name', 'avro', 'value', 7)),
- (6, CAST(NULL AS BOOLEAN), CAST(NULL AS TINYINT), CAST(NULL AS
SMALLINT),
- CAST(NULL AS INT), CAST(NULL AS BIGINT),
- CAST(NULL AS FLOAT), CAST(NULL AS DOUBLE), CAST(NULL AS
DECIMAL(10,2)),
- CAST(NULL AS DECIMAL(5)), CAST(NULL AS DECIMAL(38,18)),
- CAST(NULL AS STRING), CAST(NULL AS BINARY),
- CAST(NULL AS DATE),
- CAST(NULL AS TIMESTAMP_NTZ),
- CAST(NULL AS TIMESTAMP),
- CAST(NULL AS ARRAY<INT>),
- CAST(NULL AS MAP<STRING, INT>),
- CAST(NULL AS STRUCT<name: STRING, value: INT>))
- """
- )
-
-
- # ===== First-Row merge engine PK table =====
- # first-row keeps the earliest inserted row per key; later duplicates are
ignored.
- # After compaction, level-0 files are promoted so the batch reader (which
skips
- # level-0 for first-row) can see the compacted data.
- spark.sql(
- """
- CREATE TABLE IF NOT EXISTS first_row_pk_table (
- id INT,
- name STRING
- ) USING paimon
- TBLPROPERTIES (
- 'primary-key' = 'id',
- 'bucket' = '1',
- 'merge-engine' = 'first-row'
- )
- """
- )
- spark.sql(
- """
- INSERT INTO first_row_pk_table VALUES
- (1, 'alice'),
- (2, 'bob'),
- (3, 'carol')
- """
- )
- spark.sql(
- """
- INSERT INTO first_row_pk_table VALUES
- (2, 'bob-v2'),
- (3, 'carol-v2'),
- (4, 'dave')
- """
- )
- # Compact to promote level-0 files so the batch reader can see them.
- spark.sql("CALL sys.compact('default.first_row_pk_table')")
-
-
-if __name__ == "__main__":
- main()
diff --git a/dev/spark/spark-defaults.conf b/dev/spark/spark-defaults.conf
deleted file mode 100644
index 8f314858..00000000
--- a/dev/spark/spark-defaults.conf
+++ /dev/null
@@ -1,25 +0,0 @@
-#
-# Licensed to the Apache Software Foundation (ASF) under one
-# or more contributor license agreements. See the NOTICE file
-# distributed with this work for additional information
-# regarding copyright ownership. The ASF licenses this file
-# to you under the Apache License, Version 2.0 (the
-# "License"); you may not use this file except in compliance
-# with the License. You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing,
-# software distributed under the License is distributed on an
-# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
-# KIND, either express or implied. See the License for the
-# specific language governing permissions and limitations
-# under the License.
-#
-
-# Paimon filesystem catalog – warehouse is mounted at /tmp/paimon-warehouse
-spark.sql.catalog.paimon
org.apache.paimon.spark.SparkCatalog
-spark.sql.catalog.paimon.warehouse file:/tmp/paimon-warehouse
-spark.sql.extensions
org.apache.paimon.spark.extensions.PaimonSparkSessionExtensions
-
-spark.sql.defaultCatalog paimon
diff --git a/docs/src/contributing.md b/docs/src/contributing.md
index 6fb2e4ca..c0a057a5 100644
--- a/docs/src/contributing.md
+++ b/docs/src/contributing.md
@@ -51,16 +51,17 @@ cargo fmt --all
cargo clippy --locked --all-targets --workspace --features fulltext,vortex --
-D warnings
```
-The integration suites read a Paimon warehouse written by Spark, so they need
-Docker rather than `cargo` alone:
+The integration suites automatically extract a committed Java-written golden
+warehouse into temporary directories; Spark and Docker are not required:
```bash
-make docker-up # writes /tmp/paimon-warehouse
cargo test -p paimon-integration-tests --all-targets
cargo test -p paimon-datafusion --all-targets
-make docker-down
```
+Set `PAIMON_TEST_WAREHOUSE` to test an existing warehouse instead. Missing
fixture
+tables fail the tests rather than being skipped or generated by the Rust
writer.
+
## Finding Issues
- Check [open issues](https://github.com/apache/paimon-rust/issues) for tasks
to work on
diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md
index 26cb18f8..bd19817b 100644
--- a/docs/src/getting-started.md
+++ b/docs/src/getting-started.md
@@ -553,8 +553,6 @@ cargo build
# Unit tests
cargo test
-# Integration tests (requires Docker)
-make docker-up
+# Integration tests (automatically extract the committed golden warehouse)
cargo test -p paimon-integration-tests
-make docker-down
```