| # 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. |
| |
| import os |
| import shutil |
| import tempfile |
| import unittest |
| |
| import pyarrow as pa |
| |
| from pypaimon import CatalogFactory, Schema |
| from pypaimon.catalog.catalog import Identifier |
| from pypaimon.common.external_path_provider import ( |
| EntropyInjectExternalPathProvider, ExternalPathProvider, |
| RoundRobinExternalPathProvider, WeightedExternalPathProvider, _murmur3_32) |
| from pypaimon.common.options.core_options import (CoreOptions, |
| ExternalPathStrategy) |
| |
| |
| class ExternalPathProviderTest(unittest.TestCase): |
| """Test ExternalPathProvider functionality.""" |
| |
| def test_path_selection_and_structure(self): |
| """Test path selection (round-robin) and path structure with various scenarios.""" |
| # Test multiple paths with round-robin |
| external_paths = [ |
| "oss://bucket1/external", |
| "oss://bucket2/external", |
| "oss://bucket3/external", |
| ] |
| relative_path = "partition=value/bucket-0" |
| provider = RoundRobinExternalPathProvider(external_paths, relative_path) |
| |
| paths = [provider.get_next_external_data_path("file.parquet") for _ in range(6)] |
| |
| # Verify all buckets are used (2 cycles = 2 times each) |
| bucket_counts = {f"bucket{i}": sum(1 for p in paths if f"bucket{i}" in p) for i in [1, 2, 3]} |
| self.assertEqual(bucket_counts["bucket1"], 2) |
| self.assertEqual(bucket_counts["bucket2"], 2) |
| self.assertEqual(bucket_counts["bucket3"], 2) |
| |
| # Verify path structure |
| self.assertIn("partition=value", paths[0]) |
| self.assertIn("bucket-0", paths[0]) |
| self.assertIn("file.parquet", paths[0]) |
| |
| # Test single path |
| single_provider = RoundRobinExternalPathProvider(["oss://bucket/external"], "bucket-0") |
| single_path = single_provider.get_next_external_data_path("data.parquet") |
| self.assertIn("bucket/external", single_path) |
| self.assertIn("bucket-0", single_path) |
| self.assertIn("data.parquet", single_path) |
| |
| # Test empty relative path |
| empty_provider = RoundRobinExternalPathProvider(["oss://bucket/external"], "") |
| empty_path = empty_provider.get_next_external_data_path("file.parquet") |
| self.assertIn("bucket/external", empty_path) |
| self.assertIn("file.parquet", empty_path) |
| |
| def test_factory_create_round_robin(self): |
| """Test ExternalPathProvider.create() with round-robin strategy.""" |
| provider = ExternalPathProvider.create( |
| "round-robin", ["oss://a/path", "oss://b/path"], "bucket-0" |
| ) |
| self.assertIsInstance(provider, RoundRobinExternalPathProvider) |
| paths = [provider.get_next_external_data_path("f.parquet") for _ in range(4)] |
| schemes_used = {p.split("://")[1].split("/")[0] for p in paths} |
| self.assertEqual(len(schemes_used), 2) |
| |
| def test_factory_create_specific_fs(self): |
| """Test ExternalPathProvider.create() with specific-fs (falls through to round-robin).""" |
| provider = ExternalPathProvider.create( |
| "specific-fs", ["oss://bucket/path"] |
| ) |
| self.assertIsInstance(provider, RoundRobinExternalPathProvider) |
| |
| def test_factory_create_none(self): |
| """Test ExternalPathProvider.create() with none strategy returns None.""" |
| provider = ExternalPathProvider.create("none", ["oss://bucket/path"]) |
| self.assertIsNone(provider) |
| |
| def test_factory_create_entropy_inject(self): |
| """Test ExternalPathProvider.create() with entropy-inject strategy.""" |
| provider = ExternalPathProvider.create( |
| "entropy-inject", ["oss://a/path", "oss://b/path"], "bucket-0" |
| ) |
| self.assertIsInstance(provider, EntropyInjectExternalPathProvider) |
| |
| def test_factory_create_weighted(self): |
| """Test ExternalPathProvider.create() with weight-robin strategy.""" |
| provider = ExternalPathProvider.create( |
| "weight-robin", ["oss://a/path", "oss://b/path"], "bucket-0", [10, 5] |
| ) |
| self.assertIsInstance(provider, WeightedExternalPathProvider) |
| |
| def test_factory_create_weighted_fallback(self): |
| """Test weight-robin falls back to round-robin when paths < 2 or no weights.""" |
| provider = ExternalPathProvider.create( |
| "weight-robin", ["oss://a/path"], "bucket-0", [10] |
| ) |
| self.assertIsInstance(provider, RoundRobinExternalPathProvider) |
| |
| provider2 = ExternalPathProvider.create( |
| "weight-robin", ["oss://a/path", "oss://b/path"], "bucket-0", None |
| ) |
| self.assertIsInstance(provider2, RoundRobinExternalPathProvider) |
| |
| |
| class Murmur3HashTest(unittest.TestCase): |
| """Test murmur3_32 hash implementation for Java Guava compatibility.""" |
| |
| def test_empty_string(self): |
| """Empty string should produce a deterministic hash.""" |
| result = _murmur3_32(b'') |
| # Guava: Hashing.murmur3_32().hashString("", UTF_8).asInt() == 0 |
| self.assertEqual(result, 0) |
| |
| def test_deterministic(self): |
| """Same input always produces same output.""" |
| for s in [b'test', b'hello world', b'data-0001.blob']: |
| self.assertEqual(_murmur3_32(s), _murmur3_32(s)) |
| |
| def test_known_values(self): |
| """Verify against Guava Hashing.murmur3_32().hashString(s, UTF_8).asInt(). |
| |
| Values confirmed by running Java Guava 32.0.0. |
| """ |
| self.assertEqual(_murmur3_32(b''), 0) |
| self.assertEqual(_murmur3_32(b'a'), 1009084850) |
| self.assertEqual(_murmur3_32(b'hello'), 613153351) |
| self.assertEqual(_murmur3_32(b'world'), -74040069) |
| self.assertEqual(_murmur3_32(b'test'), -1167338989) |
| self.assertEqual(_murmur3_32(b'data-abc.blob'), 894520562) |
| self.assertEqual(_murmur3_32(b'data-xyz.blob'), -822867934) |
| |
| def test_signed_32bit_range(self): |
| """Result should be in signed 32-bit integer range.""" |
| for s in [b'a', b'ab', b'abc', b'abcd', b'abcde']: |
| result = _murmur3_32(s) |
| self.assertGreaterEqual(result, -(2 ** 31)) |
| self.assertLessEqual(result, 2 ** 31 - 1) |
| |
| |
| class EntropyInjectExternalPathProviderTest(unittest.TestCase): |
| """Test EntropyInjectExternalPathProvider functionality.""" |
| |
| def test_hash_directory_structure(self): |
| """Hash directories should have depth=3 with 4-bit segments + remainder.""" |
| provider = EntropyInjectExternalPathProvider(["oss://bucket/ext"], "bucket-0") |
| hash_dirs = provider._compute_hash("test-file.blob") |
| parts = hash_dirs.split("/") |
| self.assertEqual(len(parts), 4) |
| self.assertEqual(len(parts[0]), 4) |
| self.assertEqual(len(parts[1]), 4) |
| self.assertEqual(len(parts[2]), 4) |
| self.assertEqual(len(parts[3]), 8) |
| for part in parts: |
| self.assertTrue(all(c in ('0', '1') for c in part)) |
| |
| def test_deterministic_path(self): |
| """Same filename always produces same hash directories.""" |
| provider = EntropyInjectExternalPathProvider( |
| ["oss://bucket/ext"], "dt=20240101/bucket-0" |
| ) |
| hash1 = provider._compute_hash("data-001.parquet") |
| hash2 = provider._compute_hash("data-001.parquet") |
| self.assertEqual(hash1, hash2) |
| |
| def test_path_format(self): |
| """Full path should include base/relative/hashDirs/fileName.""" |
| provider = EntropyInjectExternalPathProvider( |
| ["oss://bucket/ext"], "dt=20240101/bucket-0" |
| ) |
| path = provider.get_next_external_data_path("data-001.parquet") |
| self.assertIn("oss://bucket/ext", path) |
| self.assertIn("dt=20240101/bucket-0", path) |
| self.assertIn("data-001.parquet", path) |
| # Should have hash dirs between relative path and filename |
| parts_between = path.split("bucket-0/")[1].split("/data-001.parquet")[0] |
| self.assertEqual(len(parts_between.split("/")), 4) |
| |
| def test_multi_path_rotation(self): |
| """Paths should rotate across external paths.""" |
| provider = EntropyInjectExternalPathProvider( |
| ["oss://a/ext", "oss://b/ext", "oss://c/ext"], "" |
| ) |
| paths = [provider.get_next_external_data_path(f"file-{i}.parquet") for i in range(6)] |
| bases = [p.split("://")[1][0] for p in paths] |
| self.assertEqual(set(bases), {'a', 'b', 'c'}) |
| |
| |
| class WeightedExternalPathProviderTest(unittest.TestCase): |
| """Test WeightedExternalPathProvider functionality.""" |
| |
| def test_weight_distribution(self): |
| """Paths should be selected roughly proportional to weights.""" |
| import random as _random |
| _random.seed(42) |
| provider = WeightedExternalPathProvider( |
| ["oss://a/path", "oss://b/path"], "bucket-0", [90, 10] |
| ) |
| counts = {"a": 0, "b": 0} |
| for i in range(10000): |
| path = provider.get_next_external_data_path(f"file-{i}.parquet") |
| if "://a/" in path: |
| counts["a"] += 1 |
| else: |
| counts["b"] += 1 |
| |
| # With 90:10 weights, "a" should get ~90% (allow 5% tolerance) |
| ratio_a = counts["a"] / 10000 |
| self.assertGreater(ratio_a, 0.85) |
| self.assertLess(ratio_a, 0.95) |
| |
| def test_equal_weights(self): |
| """Equal weights should distribute roughly evenly.""" |
| import random as _random |
| _random.seed(42) |
| provider = WeightedExternalPathProvider( |
| ["oss://a/path", "oss://b/path", "oss://c/path"], "bucket-0", [1, 1, 1] |
| ) |
| counts = {"a": 0, "b": 0, "c": 0} |
| for i in range(9000): |
| path = provider.get_next_external_data_path(f"file-{i}.parquet") |
| for key in counts: |
| if f"://{key}/" in path: |
| counts[key] += 1 |
| |
| for key in counts: |
| ratio = counts[key] / 9000 |
| self.assertGreater(ratio, 0.28) |
| self.assertLess(ratio, 0.39) |
| |
| def test_path_format(self): |
| """Path should include base/relative/fileName.""" |
| provider = WeightedExternalPathProvider( |
| ["oss://a/path", "oss://b/path"], "dt=20240101/bucket-0", [5, 5] |
| ) |
| path = provider.get_next_external_data_path("data.parquet") |
| self.assertIn("dt=20240101/bucket-0", path) |
| self.assertIn("data.parquet", path) |
| |
| def test_mismatched_lengths_raises(self): |
| """Should raise ValueError if paths and weights have different lengths.""" |
| with self.assertRaises(ValueError): |
| WeightedExternalPathProvider( |
| ["oss://a/path", "oss://b/path"], "bucket-0", [10] |
| ) |
| |
| |
| class WeightsParsingTest(unittest.TestCase): |
| """Test CoreOptions.data_file_external_paths_weights() parsing and validation.""" |
| |
| def test_valid_weights(self): |
| """Normal comma-separated positive integers.""" |
| from pypaimon.common.options.core_options import CoreOptions |
| opts = CoreOptions.from_dict({"data-file.external-paths.weights": "10,5,15"}) |
| self.assertEqual(opts.data_file_external_paths_weights(), [10, 5, 15]) |
| |
| def test_none_when_not_configured(self): |
| """Returns None when option is not set.""" |
| from pypaimon.common.options.core_options import CoreOptions |
| opts = CoreOptions.from_dict({}) |
| self.assertIsNone(opts.data_file_external_paths_weights()) |
| |
| def test_zero_weight_raises(self): |
| """Zero weight should raise ValueError.""" |
| from pypaimon.common.options.core_options import CoreOptions |
| opts = CoreOptions.from_dict({"data-file.external-paths.weights": "10,0,5"}) |
| with self.assertRaises(ValueError) as ctx: |
| opts.data_file_external_paths_weights() |
| self.assertIn("positive", str(ctx.exception)) |
| |
| def test_negative_weight_raises(self): |
| """Negative weight should raise ValueError.""" |
| from pypaimon.common.options.core_options import CoreOptions |
| opts = CoreOptions.from_dict({"data-file.external-paths.weights": "10,-5"}) |
| with self.assertRaises(ValueError) as ctx: |
| opts.data_file_external_paths_weights() |
| self.assertIn("positive", str(ctx.exception)) |
| |
| def test_empty_element_raises(self): |
| """Empty element like '10,,5' should raise ValueError (align with Java NumberFormatException).""" |
| from pypaimon.common.options.core_options import CoreOptions |
| opts = CoreOptions.from_dict({"data-file.external-paths.weights": "10,,5"}) |
| with self.assertRaises(ValueError): |
| opts.data_file_external_paths_weights() |
| |
| |
| class ExternalPathsConfigTest(unittest.TestCase): |
| """Test external paths configuration parsing through FileStoreTable._create_external_paths().""" |
| |
| @classmethod |
| def setUpClass(cls): |
| """Set up test environment.""" |
| cls.temp_dir = tempfile.mkdtemp() |
| cls.warehouse = os.path.join(cls.temp_dir, "warehouse") |
| cls.catalog = CatalogFactory.create({"warehouse": cls.warehouse}) |
| cls.catalog.create_database("test_db", False) |
| |
| @classmethod |
| def tearDownClass(cls): |
| """Clean up test environment.""" |
| shutil.rmtree(cls.temp_dir, ignore_errors=True) |
| |
| def _create_table_with_options(self, options: dict) -> 'FileStoreTable': |
| """Helper method to create a table with specific options.""" |
| table_name = "test_db.config_test" |
| # Manually delete table directory if it exists to ensure clean test environment |
| # FileSystemCatalog doesn't have drop_table method, so we need to delete manually |
| try: |
| table_path = self.catalog.get_table_path(Identifier.from_string(table_name)) |
| # file_io.exists and delete accept Union[Path, URL, str] |
| if self.catalog.file_io.exists(table_path): |
| self.catalog.file_io.delete(table_path, recursive=True) |
| except Exception: |
| pass # Table may not exist, ignore |
| pa_schema = pa.schema([("id", pa.int32()), ("name", pa.string())]) |
| schema = Schema.from_pyarrow_schema(pa_schema, options=options) |
| self.catalog.create_table(table_name, schema, True) |
| return self.catalog.get_table(table_name) |
| |
| def test_external_paths_strategies(self): |
| """Test different external path strategies (round-robin, specific-fs, none).""" |
| # Test round-robin strategy |
| options = { |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS.key(): "oss://bucket1/path1,oss://bucket2/path2,oss://bucket3/path3", |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS_STRATEGY.key(): ExternalPathStrategy.ROUND_ROBIN, |
| } |
| table = self._create_table_with_options(options) |
| paths = table._create_external_paths() |
| self.assertEqual(len(paths), 3) |
| self.assertEqual(str(paths[0]), "oss://bucket1/path1") |
| self.assertEqual(str(paths[1]), "oss://bucket2/path2") |
| self.assertEqual(str(paths[2]), "oss://bucket3/path3") |
| |
| # Test specific-fs strategy |
| options2 = { |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS.key(): "oss://bucket1/path1,s3://bucket2/path2,oss://bucket3/path3", |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS_STRATEGY.key(): ExternalPathStrategy.SPECIFIC_FS, |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS_SPECIFIC_FS.key(): "oss", |
| } |
| table2 = self._create_table_with_options(options2) |
| paths2 = table2._create_external_paths() |
| self.assertEqual(len(paths2), 2) |
| self.assertIn("oss://bucket1/path1", [str(p) for p in paths2]) |
| self.assertIn("oss://bucket3/path3", [str(p) for p in paths2]) |
| self.assertNotIn("s3://bucket2/path2", [str(p) for p in paths2]) |
| |
| # Test none strategy |
| options3 = { |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS.key(): "oss://bucket1/path1", |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS_STRATEGY.key(): ExternalPathStrategy.NONE, |
| } |
| table3 = self._create_table_with_options(options3) |
| paths3 = table3._create_external_paths() |
| self.assertEqual(len(paths3), 0) |
| |
| def test_external_paths_edge_cases(self): |
| """Test edge cases: empty string, no config, invalid scheme.""" |
| # Test empty string |
| options = { |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS.key(): "", |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS_STRATEGY.key(): ExternalPathStrategy.ROUND_ROBIN, |
| } |
| table = self._create_table_with_options(options) |
| self.assertEqual(len(table._create_external_paths()), 0) |
| |
| # Test no external paths option |
| options2 = {CoreOptions.DATA_FILE_EXTERNAL_PATHS_STRATEGY.key(): ExternalPathStrategy.ROUND_ROBIN} |
| table2 = self._create_table_with_options(options2) |
| self.assertEqual(len(table2._create_external_paths()), 0) |
| |
| # Test invalid scheme (no scheme) |
| options3 = { |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS.key(): "/invalid/path", |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS_STRATEGY.key(): ExternalPathStrategy.ROUND_ROBIN, |
| } |
| table3 = self._create_table_with_options(options3) |
| with self.assertRaises(ValueError) as context: |
| table3._create_external_paths() |
| self.assertIn("scheme", str(context.exception)) |
| |
| def test_create_external_path_provider(self): |
| """Test creating ExternalPathProvider from path factory.""" |
| table_name = "test_db.config_test" |
| # Drop table if exists to ensure clean test environment |
| try: |
| self.catalog.drop_table(table_name, True) |
| except Exception: |
| pass # Table may not exist, ignore |
| options = { |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS.key(): "oss://bucket1/path1,oss://bucket2/path2", |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS_STRATEGY.key(): ExternalPathStrategy.ROUND_ROBIN, |
| } |
| pa_schema = pa.schema([ |
| ("id", pa.int32()), |
| ("name", pa.string()), |
| ("dt", pa.string()), |
| ]) |
| schema = Schema.from_pyarrow_schema(pa_schema, partition_keys=["dt"], options=options) |
| self.catalog.create_table(table_name, schema, True) |
| table = self.catalog.get_table(table_name) |
| path_factory = table.path_factory() |
| |
| # Test with external paths configured |
| provider = path_factory.create_external_path_provider(("value1",), 0) |
| self.assertIsNotNone(provider) |
| self.assertIsInstance(provider, RoundRobinExternalPathProvider) |
| path = provider.get_next_external_data_path("file.parquet") |
| self.assertTrue("bucket1" in str(path) or "bucket2" in str(path)) |
| self.assertIn("dt=value1", str(path)) |
| self.assertIn("bucket-0", str(path)) |
| |
| # Test with none strategy (should return None) |
| # Use a different table name to avoid conflicts |
| options2 = { |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS.key(): "oss://bucket1/path1", |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS_STRATEGY.key(): ExternalPathStrategy.NONE, |
| } |
| pa_schema2 = pa.schema([("id", pa.int32()), ("name", pa.string())]) |
| schema2 = Schema.from_pyarrow_schema(pa_schema2, options=options2) |
| table_name2 = "test_db.config_test_none" |
| try: |
| # Manually delete table directory if it exists |
| table_path2 = self.catalog.get_table_path(Identifier.from_string(table_name2)) |
| # file_io.exists and delete accept Union[Path, URL, str] |
| if self.catalog.file_io.exists(table_path2): |
| self.catalog.file_io.delete(table_path2, recursive=True) |
| except Exception: |
| pass # Table may not exist, ignore |
| self.catalog.create_table(table_name2, schema2, True) |
| table2 = self.catalog.get_table(table_name2) |
| provider2 = table2.path_factory().create_external_path_provider((), 0) |
| self.assertIsNone(provider2) |
| |
| # Test without external paths config (should return None) |
| # Use a different table name to avoid conflicts |
| options3 = {} |
| pa_schema3 = pa.schema([("id", pa.int32()), ("name", pa.string())]) |
| schema3 = Schema.from_pyarrow_schema(pa_schema3, options=options3) |
| table_name3 = "test_db.config_test_empty" |
| try: |
| # Manually delete table directory if it exists |
| table_path3 = self.catalog.get_table_path(Identifier.from_string(table_name3)) |
| # file_io.exists and delete accept Union[Path, URL, str] |
| if self.catalog.file_io.exists(table_path3): |
| self.catalog.file_io.delete(table_path3, recursive=True) |
| except Exception: |
| pass # Table may not exist, ignore |
| self.catalog.create_table(table_name3, schema3, True) |
| table3 = self.catalog.get_table(table_name3) |
| provider3 = table3.path_factory().create_external_path_provider((), 0) |
| self.assertIsNone(provider3) |
| |
| def test_create_entropy_inject_provider(self): |
| """Test creating EntropyInject provider from path factory.""" |
| table_name = "test_db.entropy_test" |
| # Manually delete table directory if it exists |
| try: |
| table_path = self.catalog.get_table_path(Identifier.from_string(table_name)) |
| if self.catalog.file_io.exists(table_path): |
| self.catalog.file_io.delete(table_path, recursive=True) |
| except Exception: |
| pass # Table may not exist, ignore |
| options = { |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS.key(): "oss://bucket1/path1,oss://bucket2/path2", |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS_STRATEGY.key(): ExternalPathStrategy.ENTROPY_INJECT, |
| } |
| pa_schema = pa.schema([("id", pa.int32()), ("name", pa.string())]) |
| schema = Schema.from_pyarrow_schema(pa_schema, options=options) |
| self.catalog.create_table(table_name, schema, True) |
| table = self.catalog.get_table(table_name) |
| path_factory = table.path_factory() |
| |
| provider = path_factory.create_external_path_provider((), 0) |
| self.assertIsNotNone(provider) |
| self.assertIsInstance(provider, EntropyInjectExternalPathProvider) |
| |
| def test_create_weighted_provider(self): |
| """Test creating Weighted provider from path factory.""" |
| table_name = "test_db.weighted_test" |
| # Manually delete table directory if it exists |
| try: |
| table_path = self.catalog.get_table_path(Identifier.from_string(table_name)) |
| if self.catalog.file_io.exists(table_path): |
| self.catalog.file_io.delete(table_path, recursive=True) |
| except Exception: |
| pass # Table may not exist, ignore |
| options = { |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS.key(): "oss://bucket1/path1,oss://bucket2/path2", |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS_STRATEGY.key(): ExternalPathStrategy.WEIGHTED, |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS_WEIGHTS.key(): "10,5", |
| } |
| pa_schema = pa.schema([("id", pa.int32()), ("name", pa.string())]) |
| schema = Schema.from_pyarrow_schema(pa_schema, options=options) |
| self.catalog.create_table(table_name, schema, True) |
| table = self.catalog.get_table(table_name) |
| path_factory = table.path_factory() |
| |
| provider = path_factory.create_external_path_provider((), 0) |
| self.assertIsNotNone(provider) |
| self.assertIsInstance(provider, WeightedExternalPathProvider) |
| |
| |
| class ExternalPathsIntegrationTest(unittest.TestCase): |
| """Integration tests for external paths feature.""" |
| |
| @classmethod |
| def setUpClass(cls): |
| """Set up test environment.""" |
| cls.temp_dir = tempfile.mkdtemp() |
| cls.warehouse = os.path.join(cls.temp_dir, "warehouse") |
| cls.external_dir = os.path.join(cls.temp_dir, "external_data") |
| |
| # Create external directory |
| os.makedirs(cls.external_dir, exist_ok=True) |
| |
| cls.catalog = CatalogFactory.create({ |
| "warehouse": cls.warehouse |
| }) |
| cls.catalog.create_database("test_db", False) |
| |
| @classmethod |
| def tearDownClass(cls): |
| """Clean up test environment.""" |
| shutil.rmtree(cls.temp_dir, ignore_errors=True) |
| |
| def test_write_with_external_paths(self): |
| """Test writing data with external paths configured.""" |
| pa_schema = pa.schema([ |
| ("id", pa.int32()), |
| ("name", pa.string()), |
| ("value", pa.float64()), |
| ]) |
| |
| # Create table with external paths |
| external_path = f"file://{self.external_dir}" |
| table_options = { |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS.key(): external_path, |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS_STRATEGY.key(): ExternalPathStrategy.ROUND_ROBIN, |
| } |
| schema = Schema.from_pyarrow_schema(pa_schema, options=table_options) |
| |
| self.catalog.create_table("test_db.external_test", schema, False) |
| table = self.catalog.get_table("test_db.external_test") |
| |
| # Write data (use explicit schema to match table schema) |
| data = pa.Table.from_pydict({ |
| "id": [1, 2, 3], |
| "name": ["Alice", "Bob", "Charlie"], |
| "value": [10.5, 20.3, 30.7], |
| }, schema=pa_schema) |
| |
| write_builder = table.new_batch_write_builder() |
| table_write = write_builder.new_write() |
| table_commit = write_builder.new_commit() |
| table_write.write_arrow(data) |
| commit_messages = table_write.prepare_commit() |
| |
| # Verify external_path is set in file metadata |
| self.assertGreater(len(commit_messages), 0) |
| for commit_msg in commit_messages: |
| self.assertGreater(len(commit_msg.new_files), 0) |
| for file_meta in commit_msg.new_files: |
| # External path should be set |
| self.assertIsNotNone(file_meta.external_path) |
| self.assertTrue(file_meta.external_path.startswith("file://")) |
| self.assertIn(self.external_dir, file_meta.external_path) |
| |
| table_commit.commit(commit_messages) |
| table_write.close() |
| table_commit.close() |
| |
| def test_read_with_external_paths(self): |
| """Test reading data with external paths.""" |
| pa_schema = pa.schema([ |
| ("id", pa.int32()), |
| ("name", pa.string()), |
| ]) |
| |
| # Create table with external paths |
| external_path = f"file://{self.external_dir}" |
| table_options = { |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS.key(): external_path, |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS_STRATEGY.key(): ExternalPathStrategy.ROUND_ROBIN, |
| } |
| schema = Schema.from_pyarrow_schema(pa_schema, options=table_options) |
| |
| self.catalog.create_table("test_db.external_read_test", schema, False) |
| table = self.catalog.get_table("test_db.external_read_test") |
| |
| # Write data (use explicit schema to match table schema) |
| write_data = pa.Table.from_pydict({ |
| "id": [1, 2, 3, 4, 5], |
| "name": ["A", "B", "C", "D", "E"], |
| }, schema=pa_schema) |
| |
| write_builder = table.new_batch_write_builder() |
| table_write = write_builder.new_write() |
| table_commit = write_builder.new_commit() |
| table_write.write_arrow(write_data) |
| table_commit.commit(table_write.prepare_commit()) |
| table_write.close() |
| table_commit.close() |
| |
| # Read data back |
| read_builder = table.new_read_builder() |
| table_scan = read_builder.new_scan() |
| table_read = read_builder.new_read() |
| result = table_read.to_arrow(table_scan.plan().splits()) |
| |
| # Verify data |
| self.assertEqual(result.num_rows, 5) |
| self.assertEqual(result.num_columns, 2) |
| self.assertListEqual(result.column("id").to_pylist(), [1, 2, 3, 4, 5]) |
| self.assertListEqual(result.column("name").to_pylist(), ["A", "B", "C", "D", "E"]) |
| |
| def test_write_without_external_paths(self): |
| """Test that writing without external paths still works.""" |
| pa_schema = pa.schema([ |
| ("id", pa.int32()), |
| ("name", pa.string()), |
| ]) |
| |
| schema = Schema.from_pyarrow_schema(pa_schema) |
| self.catalog.create_table("test_db.normal_test", schema, False) |
| table = self.catalog.get_table("test_db.normal_test") |
| |
| # Write data (use explicit schema to match table schema) |
| data = pa.Table.from_pydict({ |
| "id": [1, 2, 3], |
| "name": ["X", "Y", "Z"], |
| }, schema=pa_schema) |
| |
| write_builder = table.new_batch_write_builder() |
| table_write = write_builder.new_write() |
| table_commit = write_builder.new_commit() |
| table_write.write_arrow(data) |
| commit_messages = table_write.prepare_commit() |
| |
| # Verify external_path is None (not configured) |
| for commit_msg in commit_messages: |
| for file_meta in commit_msg.new_files: |
| self.assertIsNone(file_meta.external_path) |
| |
| table_commit.commit(commit_messages) |
| table_write.close() |
| table_commit.close() |
| |
| def test_external_paths_with_partition(self): |
| """Test external paths with partitioned table.""" |
| pa_schema = pa.schema([ |
| ("id", pa.int32()), |
| ("name", pa.string()), |
| ("dt", pa.string()), |
| ]) |
| |
| external_path = f"file://{self.external_dir}" |
| table_options = { |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS.key(): external_path, |
| CoreOptions.DATA_FILE_EXTERNAL_PATHS_STRATEGY.key(): ExternalPathStrategy.ROUND_ROBIN, |
| } |
| schema = Schema.from_pyarrow_schema(pa_schema, partition_keys=["dt"], options=table_options) |
| |
| self.catalog.create_table("test_db.partitioned_external", schema, False) |
| table = self.catalog.get_table("test_db.partitioned_external") |
| |
| # Write data with partition (use explicit schema to match table schema) |
| data = pa.Table.from_pydict({ |
| "id": [1, 2, 3], |
| "name": ["A", "B", "C"], |
| "dt": ["2024-01-01", "2024-01-01", "2024-01-02"], |
| }, schema=pa_schema) |
| |
| write_builder = table.new_batch_write_builder() |
| table_write = write_builder.new_write() |
| table_commit = write_builder.new_commit() |
| table_write.write_arrow(data) |
| commit_messages = table_write.prepare_commit() |
| |
| # Verify external paths include partition info |
| for commit_msg in commit_messages: |
| for file_meta in commit_msg.new_files: |
| self.assertIsNotNone(file_meta.external_path) |
| # Should contain partition path |
| self.assertIn("dt=", file_meta.external_path) |
| |
| table_commit.commit(commit_messages) |
| table_write.close() |
| table_commit.close() |
| |
| |
| if __name__ == "__main__": |
| unittest.main() |