blob: 3696bf851f3f034393f9f91ea5b411a2f58f44b5 [file]
# 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 collections
import datetime
import os
import shutil
import tempfile
import unittest
import pyarrow as pa
import pytest
pypaimon = pytest.importorskip("pypaimon")
ray = pytest.importorskip("ray")
import importlib
from unittest import mock
from pypaimon import CatalogFactory, Schema
from pypaimon.ray import range_join
rjmod = importlib.import_module("pypaimon.ray.range_join")
class RayRangeJoinTest(unittest.TestCase):
"""Range-aligned join must equal a global inner join, cutting the key space from
per-file min/max stats so each range is read/joined in its own task (no shuffle)."""
@classmethod
def setUpClass(cls):
cls.tempdir = tempfile.mkdtemp()
cls.catalog_options = {"warehouse": os.path.join(cls.tempdir, "wh")}
cls.catalog = CatalogFactory.create(cls.catalog_options)
cls.catalog.create_database("default", True)
if not ray.is_initialized():
ray.init(ignore_reinit_error=True, num_cpus=4)
@classmethod
def tearDownClass(cls):
try:
if ray.is_initialized():
ray.shutdown()
except Exception:
pass
shutil.rmtree(cls.tempdir, ignore_errors=True)
def _table(self, name, schema, commits, primary_keys=None, options=None):
"""Create a table and write each arrow table in ``commits`` as its own commit,
so the manifest holds several data files with distinct key ranges."""
self.catalog.create_table(
name,
Schema.from_pyarrow_schema(schema, primary_keys=primary_keys, options=options),
False)
t = self.catalog.get_table(name)
for data in commits:
wb = t.new_batch_write_builder()
w = wb.new_write()
w.write_arrow(data)
wb.new_commit().commit(w.prepare_commit())
w.close()
return name
def test_range_join_matches_global_join(self):
loc = pa.schema([("k", pa.int64()), ("row_id", pa.int64())])
ins = pa.schema([("k", pa.int64())])
# locator: k in 0..599 spread across three files with disjoint key ranges.
self._table("default.rj_loc", loc, [
pa.Table.from_pydict({"k": list(range(0, 200)),
"row_id": list(range(0, 200))}, schema=loc),
pa.Table.from_pydict({"k": list(range(200, 400)),
"row_id": list(range(200, 400))}, schema=loc),
pa.Table.from_pydict({"k": list(range(400, 600)),
"row_id": list(range(400, 600))}, schema=loc),
])
self._table("default.rj_in", ins, [
pa.Table.from_pydict({"k": list(range(0, 250))}, schema=ins),
])
ds = range_join(
"default.rj_in", "default.rj_loc", self.catalog_options,
on="k", left_projection=["k"], right_projection=["k", "row_id"], num_ranges=4)
got = {r["k"]: r["row_id"] for r in ds.take_all()}
self.assertEqual(set(got), set(range(250)))
self.assertTrue(all(got[i] == i for i in range(250)))
def test_fan_out_one_key_many_rows(self):
loc = pa.schema([("k", pa.int64()), ("row_id", pa.int64())])
ins = pa.schema([("k", pa.int64())])
self._table("default.rj_fan_loc", loc, [
pa.Table.from_pydict({"k": [5, 5, 7], "row_id": [0, 1, 2]}, schema=loc)])
self._table("default.rj_fan_in", ins, [
pa.Table.from_pydict({"k": [5]}, schema=ins)])
ds = range_join(
"default.rj_fan_in", "default.rj_fan_loc", self.catalog_options,
on="k", left_projection=["k"], right_projection=["k", "row_id"])
self.assertEqual(sorted(r["row_id"] for r in ds.take_all()), [0, 1])
def test_left_on_right_on_different_names(self):
right = pa.schema([("rid", pa.int64()), ("val", pa.string())])
left = pa.schema([("lid", pa.int64())])
self._table("default.rj_lr_right", right, [
pa.Table.from_pydict({"rid": list(range(100)),
"val": [f"v{i}" for i in range(100)]}, schema=right)])
self._table("default.rj_lr_left", left, [
pa.Table.from_pydict({"lid": list(range(30))}, schema=left)])
ds = range_join(
"default.rj_lr_left", "default.rj_lr_right", self.catalog_options,
left_on="lid", right_on="rid", num_ranges=3)
# Output keeps the left key name (pyarrow coalesces the right key into it).
got = {r["lid"]: r["val"] for r in ds.take_all()}
self.assertEqual(got, {i: f"v{i}" for i in range(30)})
def test_num_ranges_one_is_correct(self):
# A single range degenerates to one local join and must still be exact.
loc = pa.schema([("k", pa.int64()), ("row_id", pa.int64())])
ins = pa.schema([("k", pa.int64())])
self._table("default.rj_one_loc", loc, [
pa.Table.from_pydict({"k": list(range(50)),
"row_id": list(range(50))}, schema=loc)])
self._table("default.rj_one_in", ins, [
pa.Table.from_pydict({"k": list(range(20))}, schema=ins)])
ds = range_join(
"default.rj_one_in", "default.rj_one_loc", self.catalog_options,
on="k", left_projection=["k"], right_projection=["k", "row_id"], num_ranges=1)
got = {r["k"]: r["row_id"] for r in ds.take_all()}
self.assertEqual(got, {i: i for i in range(20)})
def test_dispatches_multiple_range_tasks(self):
# No global shuffle: several disjoint-range files produce more than one task.
loc = pa.schema([("k", pa.int64()), ("row_id", pa.int64())])
ins = pa.schema([("k", pa.int64())])
self._table("default.rj_disp_loc", loc, [
pa.Table.from_pydict({"k": list(range(0, 300)),
"row_id": list(range(0, 300))}, schema=loc),
pa.Table.from_pydict({"k": list(range(300, 600)),
"row_id": list(range(300, 600))}, schema=loc),
])
self._table("default.rj_disp_in", ins, [
pa.Table.from_pydict({"k": list(range(0, 600))}, schema=ins)])
captured = {}
real = ray.data.from_arrow_refs
def spy(refs):
captured["n"] = len(refs)
return real(refs)
with mock.patch.object(ray.data, "from_arrow_refs", spy):
ds = range_join(
"default.rj_disp_in", "default.rj_disp_loc", self.catalog_options,
on="k", left_projection=["k"], right_projection=["k", "row_id"], num_ranges=4)
ds.take_all()
self.assertGreater(captured["n"], 1)
def test_rejects_shared_non_key_column(self):
loc = pa.schema([("k", pa.int64()), ("v", pa.int64())])
ins = pa.schema([("k", pa.int64()), ("v", pa.int64())])
self._table("default.rj_col_loc", loc, [
pa.Table.from_pydict({"k": [1], "v": [1]}, schema=loc)])
self._table("default.rj_col_in", ins, [
pa.Table.from_pydict({"k": [1], "v": [2]}, schema=ins)])
with self.assertRaisesRegex(ValueError, "collide"):
range_join("default.rj_col_in", "default.rj_col_loc", self.catalog_options, on="k")
def test_rejects_key_type_mismatch(self):
loc = pa.schema([("k", pa.int32()), ("row_id", pa.int64())])
ins = pa.schema([("k", pa.int64())])
self._table("default.rj_ty_loc", loc, [
pa.Table.from_pydict({"k": pa.array([1], pa.int32()), "row_id": [1]}, schema=loc)])
self._table("default.rj_ty_in", ins, [
pa.Table.from_pydict({"k": [1]}, schema=ins)])
with self.assertRaisesRegex(ValueError, "same type"):
range_join("default.rj_ty_in", "default.rj_ty_loc", self.catalog_options, on="k")
def test_partition_filter_and_partitioned_table(self):
# range_join works on partitioned tables (unlike bucket_join); left_partitions
# prunes to the requested partition before planning.
loc = pa.schema([("p", pa.string()), ("k", pa.int64())])
self.catalog.create_table(
"default.rj_pf_l",
Schema.from_pyarrow_schema(loc, partition_keys=["p"]), False)
t = self.catalog.get_table("default.rj_pf_l")
wb = t.new_batch_write_builder()
w = wb.new_write()
w.write_arrow(pa.Table.from_pydict(
{"p": ["a", "a", "b", "b"], "k": [1, 2, 3, 4]}, schema=loc))
wb.new_commit().commit(w.prepare_commit())
w.close()
self._table("default.rj_pf_r", pa.schema([("k2", pa.int64()), ("val", pa.string())]), [
pa.Table.from_pydict({"k2": [1, 2, 3, 4], "val": ["v1", "v2", "v3", "v4"]},
schema=pa.schema([("k2", pa.int64()), ("val", pa.string())]))])
ds = range_join("default.rj_pf_l", "default.rj_pf_r", self.catalog_options,
left_on="k", right_on="k2", left_projection=["k"],
right_projection=["k2", "val"], left_partitions={"p": "a"}, num_ranges=2)
self.assertEqual(sorted((r["k"], r["val"]) for r in ds.take_all()),
[(1, "v1"), (2, "v2")])
# Whole partitioned table joins fine when unfiltered.
ds = range_join("default.rj_pf_l", "default.rj_pf_r", self.catalog_options,
left_on="k", right_on="k2", left_projection=["k"],
right_projection=["k2", "val"], num_ranges=2)
self.assertEqual(sorted(r["k"] for r in ds.take_all()), [1, 2, 3, 4])
def test_many_to_many_matches_global_join(self):
loc = pa.schema([("k", pa.int64()), ("rid", pa.int64())])
ins = pa.schema([("k", pa.int64())])
self._table("default.rj_mm_loc", loc, [
pa.Table.from_pydict({"k": [1, 1, 2], "rid": [10, 11, 20]}, schema=loc)])
self._table("default.rj_mm_in", ins, [
pa.Table.from_pydict({"k": [1, 1, 2]}, schema=ins)])
# key 1: 2 left x 2 right = 4 rows; key 2: 1 x 1 = 1 row.
for num_ranges in (1, 3):
ds = range_join("default.rj_mm_in", "default.rj_mm_loc", self.catalog_options,
on="k", left_projection=["k"], right_projection=["k", "rid"],
num_ranges=num_ranges)
got = sorted(r["rid"] for r in ds.take_all())
self.assertEqual(got, [10, 10, 11, 11, 20])
def test_rejects_bad_on_spec(self):
with self.assertRaisesRegex(ValueError, "exactly one of"):
range_join("a", "b", self.catalog_options) # neither on nor left_on/right_on
def test_rejects_float_range_key(self):
schema = pa.schema([("k", pa.float64()), ("v", pa.int64())])
self._table("default.rj_float_a", schema, [
pa.Table.from_pydict({"k": [1.0], "v": [1]}, schema=schema)])
self._table("default.rj_float_b", schema, [
pa.Table.from_pydict({"k": [1.0], "v": [2]}, schema=schema)])
with self.assertRaisesRegex(ValueError, "FLOAT/DOUBLE"):
range_join("default.rj_float_a", "default.rj_float_b", self.catalog_options,
on="k", left_projection=["k"], right_projection=["k"])
def test_rejects_left_key_vs_right_column_collision(self):
left = pa.schema([("lid", pa.int64()), ("x", pa.int64())])
right = pa.schema([("rid", pa.int64()), ("lid", pa.int64())])
self._table("default.rj_xn_left", left, [
pa.Table.from_pydict({"lid": [1], "x": [1]}, schema=left)])
self._table("default.rj_xn_right", right, [
pa.Table.from_pydict({"rid": [1], "lid": [9]}, schema=right)])
# Left key 'lid' collides with the right non-key column 'lid' in the output.
with self.assertRaisesRegex(ValueError, "collide"):
range_join("default.rj_xn_left", "default.rj_xn_right", self.catalog_options,
left_on="lid", right_on="rid")
def test_date_to_timestamp_schema_evolution(self):
# A DATE->TIMESTAMP evolved key yields date footers in old files and datetime in
# new ones; the planner must coerce both to the key type, not compare them raw.
from pypaimon.schema.data_types import AtomicType
from pypaimon.schema.schema_change import SchemaChange
a_date = pa.schema([("k", pa.date32())])
self.catalog.create_table(
"default.rj_ev_a", Schema.from_pyarrow_schema(a_date), False)
t = self.catalog.get_table("default.rj_ev_a")
wb = t.new_batch_write_builder()
w = wb.new_write()
w.write_arrow(pa.Table.from_pydict(
{"k": [datetime.date(2020, 1, 1), datetime.date(2020, 1, 2)]}, schema=a_date))
wb.new_commit().commit(w.prepare_commit())
w.close()
self.catalog.alter_table(
"default.rj_ev_a",
[SchemaChange.update_column_type("k", AtomicType("TIMESTAMP(6)"))], False)
t = self.catalog.get_table("default.rj_ev_a")
a_ts = pa.schema([("k", pa.timestamp("us"))])
wb = t.new_batch_write_builder()
w = wb.new_write()
w.write_arrow(pa.Table.from_pydict(
{"k": [datetime.datetime(2020, 6, 1), datetime.datetime(2020, 6, 2)]}, schema=a_ts))
wb.new_commit().commit(w.prepare_commit())
w.close()
b = pa.schema([("bk", pa.timestamp("us")), ("val", pa.string())])
self.catalog.create_table("default.rj_ev_b", Schema.from_pyarrow_schema(b), False)
t = self.catalog.get_table("default.rj_ev_b")
wb = t.new_batch_write_builder()
w = wb.new_write()
w.write_arrow(pa.Table.from_pydict(
{"bk": [datetime.datetime(2020, 1, 1), datetime.datetime(2020, 6, 1)],
"val": ["jan1", "jun1"]}, schema=b))
wb.new_commit().commit(w.prepare_commit())
w.close()
ds = range_join("default.rj_ev_a", "default.rj_ev_b", self.catalog_options,
left_on="k", right_on="bk", num_ranges=3)
got = sorted((str(r["k"]), r["val"]) for r in ds.take_all())
self.assertEqual(got, [("2020-01-01 00:00:00", "jan1"),
("2020-06-01 00:00:00", "jun1")])
def test_int_to_string_schema_evolution_no_dropped_rows(self):
# INT->STRING isn't order-preserving ('10' < '2'), so an old INT file's footer
# bounds are invalid under the new string order. Such files must be treated as
# unknown (join every range), not pruned, or rows are silently dropped.
from pypaimon.schema.data_types import AtomicType
from pypaimon.schema.schema_change import SchemaChange
a_int = pa.schema([("k", pa.int32())])
self.catalog.create_table(
"default.rj_is_a", Schema.from_pyarrow_schema(a_int), False)
t = self.catalog.get_table("default.rj_is_a")
wb = t.new_batch_write_builder()
w = wb.new_write()
# int order 5<42<100, but as strings '100'<'42'<'5'.
w.write_arrow(pa.Table.from_pydict({"k": [5, 42, 100]}, schema=a_int))
wb.new_commit().commit(w.prepare_commit())
w.close()
self.catalog.alter_table(
"default.rj_is_a",
[SchemaChange.update_column_type("k", AtomicType("STRING"))], False)
b = pa.schema([("bk", pa.string()), ("val", pa.string())])
self.catalog.create_table("default.rj_is_b", Schema.from_pyarrow_schema(b), False)
t = self.catalog.get_table("default.rj_is_b")
wb = t.new_batch_write_builder()
w = wb.new_write()
w.write_arrow(pa.Table.from_pydict(
{"bk": ["5", "42", "100"], "val": ["v5", "v42", "v100"]}, schema=b))
wb.new_commit().commit(w.prepare_commit())
w.close()
for num_ranges in (1, 3):
ds = range_join("default.rj_is_a", "default.rj_is_b", self.catalog_options,
left_on="k", right_on="bk", num_ranges=num_ranges)
got = sorted((r["k"], r["val"]) for r in ds.take_all())
self.assertEqual(got, [("100", "v100"), ("42", "v42"), ("5", "v5")])
def test_reread_budget_bounds_wide_and_unknown_splits(self):
Split = collections.namedtuple("Split", "files")
File = collections.namedtuple("File", "row_count file_size")
def rng(lo, hi, rows=100, size=100):
return (Split([File(rows, size)]), lo, hi)
# _total_reads = bytes x ranges a split overlaps.
self.assertEqual(rjmod._total_reads([rng(0, 10)], [], [(None, 5), (5, None)]), 200)
# Budget is bytes, not rows: a wide split (few rows, large files) counts its bytes.
wide_row = [rng(0, 10, rows=1, size=1000)]
self.assertEqual(rjmod._total_reads(wide_row, [], [(None, 5), (5, None)]), 2000)
# All-unknown collapses to a single range.
unknown = [(Split([File(100, 100)]), None, None)]
self.assertEqual(len(rjmod._bounded_ranges(unknown, unknown, 8)), 1)
# Wide known splits (each overlaps many ranges) are bounded by the budget.
wide = [rng(0, 100), rng(0, 100), rng(0, 100), rng(0, 100)]
ranges = rjmod._bounded_ranges(wide, wide, 16)
budget = rjmod._REREAD_BUDGET * (8 * 100)
self.assertTrue(len(ranges) == 1
or rjmod._total_reads(wide, wide, ranges) <= budget)
# Clustered (disjoint) splits keep at least as much parallelism as wide ones.
clustered = [rng(0, 9), rng(10, 19), rng(20, 29), rng(30, 39)]
self.assertGreaterEqual(len(rjmod._bounded_ranges(clustered, clustered, 4)),
len(rjmod._bounded_ranges(wide, wide, 4)))
def test_split_key_range_reads_stats(self):
# With default metadata.stats-mode=none, the planner falls back to the footer.
loc = pa.schema([("k", pa.int64()), ("row_id", pa.int64())])
self._table("default.rj_stats", loc, [
pa.Table.from_pydict({"k": [10, 20, 15], "row_id": [1, 2, 3]}, schema=loc)])
ranged, _ = rjmod._plan_ranged_splits(
"default.rj_stats", self.catalog_options, None, "k")
self.assertTrue(ranged)
los = [lo for _, lo, _ in ranged if lo is not None]
his = [hi for _, _, hi in ranged if hi is not None]
self.assertEqual(min(los), 10)
self.assertEqual(max(his), 20)
def test_manifest_stats_avoid_footer_reads(self):
schema = pa.schema([("k", pa.int64()), ("v", pa.string())])
self._table("default.rj_manifest", schema, [
pa.Table.from_pydict({"k": [10, 20, 15], "v": ["a", "b", "c"]}, schema=schema)
], options={"metadata.stats-mode": "full"})
with mock.patch("pyarrow.parquet.read_metadata",
side_effect=AssertionError("footer should not be read")):
ranged, _ = rjmod._plan_ranged_splits(
"default.rj_manifest", self.catalog_options, None, "k")
self.assertEqual([(lo, hi) for _, lo, hi in ranged], [(10, 20)])
def test_key_stats_avoid_footer_reads(self):
schema = pa.schema([("k", pa.int64()), ("v", pa.string())])
self._table("default.rj_key_stats", schema, [
pa.Table.from_pydict({"k": [10, 20, 15], "v": ["a", "b", "c"]}, schema=schema)
], primary_keys=["k"], options={"bucket": "1"})
with mock.patch("pyarrow.parquet.read_metadata",
side_effect=AssertionError("footer should not be read")):
ranged, _ = rjmod._plan_ranged_splits(
"default.rj_key_stats", self.catalog_options, None, "k")
self.assertEqual([(lo, hi) for _, lo, hi in ranged], [(10, 20)])
def test_manifest_stats_follow_field_id_after_rename(self):
from pypaimon.schema.schema_change import SchemaChange
schema = pa.schema([("k", pa.int64()), ("v", pa.string())])
name = "default.rj_manifest_rename"
self._table(name, schema, [
pa.Table.from_pydict({"k": [3, 9, 6], "v": ["a", "b", "c"]}, schema=schema)
], options={"metadata.stats-mode": "full"})
self.catalog.alter_table(
name, [SchemaChange.rename_column("k", "renamed")], False)
with mock.patch("pyarrow.parquet.read_metadata",
side_effect=AssertionError("footer should not be read")):
ranged, _ = rjmod._plan_ranged_splits(
name, self.catalog_options, None, "renamed")
self.assertEqual([(lo, hi) for _, lo, hi in ranged], [(3, 9)])
def test_footer_failure_degrades_to_unknown(self):
schema = pa.schema([("k", pa.int64())])
self._table("default.rj_footer_failure", schema, [
pa.Table.from_pydict({"k": [1, 2, 3]}, schema=schema)])
with self.assertLogs(rjmod._LOG, level="WARNING"):
with mock.patch("pyarrow.parquet.read_metadata",
side_effect=OSError("not seekable")):
ranged, _ = rjmod._plan_ranged_splits(
"default.rj_footer_failure", self.catalog_options, None, "k")
self.assertTrue(ranged)
self.assertTrue(all(lo is None and hi is None for _, lo, hi in ranged))
def test_stats_mode_none_still_correct(self):
# metadata.stats-mode=none only drops manifest stats; the parquet footer still
# carries min/max (range_join's actual source), so ranges still work. The
# unknown-split fallback itself is covered by the planning-logic tests.
no_stats = {"metadata.stats-mode": "none"}
loc = pa.schema([("k", pa.int64()), ("row_id", pa.int64())])
ins = pa.schema([("k", pa.int64())])
self._table("default.rj_ns_loc", loc, [
pa.Table.from_pydict({"k": list(range(0, 100)),
"row_id": list(range(0, 100))}, schema=loc),
pa.Table.from_pydict({"k": list(range(100, 200)),
"row_id": list(range(100, 200))}, schema=loc),
], options=no_stats)
self._table("default.rj_ns_in", ins, [
pa.Table.from_pydict({"k": list(range(50, 150))}, schema=ins)],
options=no_stats)
ds = range_join(
"default.rj_ns_in", "default.rj_ns_loc", self.catalog_options,
on="k", left_projection=["k"], right_projection=["k", "row_id"], num_ranges=4)
got = sorted((r["k"], r["row_id"]) for r in ds.take_all())
self.assertEqual(got, [(i, i) for i in range(50, 150)])
def test_null_keys_dropped_independent_of_num_ranges(self):
loc = pa.schema([("k", pa.int64()), ("row_id", pa.int64())])
ins = pa.schema([("k", pa.int64())])
self._table("default.rj_null_loc", loc, [
pa.Table.from_pydict({"k": [1, 2, None, 3], "row_id": [1, 2, 99, 3]}, schema=loc)])
self._table("default.rj_null_in", ins, [
pa.Table.from_pydict({"k": [1, None, 3, None]}, schema=ins)])
expected = [(1, 1), (3, 3)] # null never matches; no duplicates
for num_ranges in (1, 5):
ds = range_join(
"default.rj_null_in", "default.rj_null_loc", self.catalog_options,
on="k", left_projection=["k"], right_projection=["k", "row_id"],
num_ranges=num_ranges)
got = sorted((r["k"], r["row_id"]) for r in ds.take_all())
self.assertEqual(got, expected)
def test_pk_nonkey_range_col_untrusted(self):
# A PK table's non-PK column may be rewritten by merge (aggregation/partial-update)
# beyond the footer min/max, so its bounds are untrusted -> every split unknown.
schema = pa.schema([("id", pa.int64()), ("g", pa.int64())])
self._table("default.rj_agg", schema, [
pa.Table.from_pydict({"id": [1, 2], "g": [10, 20]}, schema=schema)],
primary_keys=["id"], options={"bucket": "1"})
ranged, _ = rjmod._plan_ranged_splits(
"default.rj_agg", self.catalog_options, None, "g")
self.assertTrue(ranged)
self.assertTrue(all(lo is None and hi is None for _, lo, hi in ranged))
def test_masked_range_col_untrusted(self):
from pypaimon.catalog.table_query_auth import TableQueryAuthResult
from pypaimon.read.query_auth_split import QueryAuthSplit
schema = pa.schema([("k", pa.int64())])
name = "default.rj_masked"
self._table(name, schema, [
pa.Table.from_pydict({"k": [1, 2, 3]}, schema=schema)])
table = self.catalog.get_table(name)
splits = list(table.new_read_builder().new_scan().plan().splits())
auth = TableQueryAuthResult(
None, {"k": "CAST(0 AS BIGINT)"})
masked = [QueryAuthSplit(split, auth) for split in splits]
self.assertFalse(rjmod._range_stats_trusted(table, masked, "k"))
def test_partition_key_validation(self):
loc = pa.schema([("k", pa.int64()), ("v", pa.string())])
self._table("default.rj_pv", loc, [
pa.Table.from_pydict({"k": [1], "v": ["a"]}, schema=loc)])
ins = pa.schema([("k", pa.int64())])
self._table("default.rj_pv_in", ins, [
pa.Table.from_pydict({"k": [1]}, schema=ins)])
with self.assertRaises(ValueError): # not a partition column
range_join("default.rj_pv_in", "default.rj_pv", self.catalog_options,
on="k", left_projection=["k"], right_projection=["k", "v"],
right_partitions={"nope": "a"})
def test_num_ranges_validation(self):
loc = pa.schema([("k", pa.int64()), ("v", pa.string())])
self._table("default.rj_nr", loc, [
pa.Table.from_pydict({"k": [1, 2], "v": ["a", "b"]}, schema=loc)])
ins = pa.schema([("k", pa.int64())])
self._table("default.rj_nr_in", ins, [
pa.Table.from_pydict({"k": [1]}, schema=ins)])
for bad in (0, -1, "5", 2.0):
with self.assertRaises(ValueError):
range_join("default.rj_nr_in", "default.rj_nr", self.catalog_options,
on="k", left_projection=["k"],
right_projection=["k", "v"], num_ranges=bad)
def test_rejects_unrangeable_key_types(self):
# Rejected at the driver (not inside a worker): nested and tz-aware keys.
arr = pa.schema([("k", pa.list_(pa.int64())), ("v", pa.string())])
self._table("default.rj_arr_a", arr, [])
self._table("default.rj_arr_b", arr, [])
with self.assertRaisesRegex(ValueError, "must not be"):
range_join("default.rj_arr_a", "default.rj_arr_b", self.catalog_options, on="k")
ltz = pa.schema([("k", pa.timestamp("us", tz="UTC")), ("v", pa.string())])
self._table("default.rj_ltz_a", ltz, [])
self._table("default.rj_ltz_b", ltz, [])
with self.assertRaisesRegex(ValueError, "must not be"):
range_join("default.rj_ltz_a", "default.rj_ltz_b", self.catalog_options, on="k")
# A nested SECOND key is rejected too (every key is validated, not just the range key).
multi = pa.schema([("k", pa.int64()), ("k2", pa.list_(pa.int64())), ("v", pa.string())])
self._table("default.rj_mk_a", multi, [])
self._table("default.rj_mk_b", multi, [])
with self.assertRaisesRegex(ValueError, "nested/complex"):
range_join("default.rj_mk_a", "default.rj_mk_b", self.catalog_options, on=["k", "k2"])
if __name__ == "__main__":
unittest.main()