| # 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 sys |
| import tempfile |
| import unittest |
| from types import SimpleNamespace |
| |
| import pandas as pd |
| import pyarrow as pa |
| import pyarrow.dataset as ds |
| |
| from pypaimon import CatalogFactory, Schema |
| from pypaimon.common.predicate import Predicate |
| from pypaimon.manifest.manifest_list_manager import ManifestListManager |
| from pypaimon.table.row.offset_row import OffsetRow |
| |
| |
| def _filter_batch_arrow(batch, predicate): |
| expr = predicate.to_arrow() |
| table = ds.InMemoryDataset(pa.Table.from_batches([batch])).scanner(filter=expr).to_table() |
| if table.num_rows == 0: |
| return batch.slice(0, 0) |
| batches = table.to_batches() |
| if len(batches) == 1: |
| return batches[0] |
| return pa.RecordBatch.from_arrays( |
| [table.column(i) for i in range(table.num_columns)], schema=table.schema |
| ) |
| |
| |
| def _filter_batch_row_by_row(batch, predicate, ncols): |
| nrows = batch.num_rows |
| mask = [] |
| row_tuple = [None] * ncols |
| offset_row = OffsetRow(row_tuple, 0, ncols) |
| for i in range(nrows): |
| for j in range(ncols): |
| row_tuple[j] = batch.column(j)[i].as_py() |
| offset_row.replace(tuple(row_tuple)) |
| try: |
| mask.append(predicate.test(offset_row)) |
| except (TypeError, ValueError): |
| mask.append(False) |
| if not any(mask): |
| return batch.slice(0, 0) |
| return batch.filter(pa.array(mask)) |
| |
| |
| def _batches_equal(a, b): |
| if a.num_rows != b.num_rows or a.num_columns != b.num_columns: |
| return False |
| for i in range(a.num_columns): |
| col_a, col_b = a.column(i), b.column(i) |
| for j in range(a.num_rows): |
| va_py = col_a[j].as_py() if hasattr(col_a[j], "as_py") else col_a[j] |
| vb_py = col_b[j].as_py() if hasattr(col_b[j], "as_py") else col_b[j] |
| if va_py != vb_py: |
| return False |
| return True |
| |
| |
| class DataEvolutionTest(unittest.TestCase): |
| @classmethod |
| def setUpClass(cls): |
| cls.tempdir = tempfile.mkdtemp() |
| cls.warehouse = os.path.join(cls.tempdir, 'warehouse') |
| cls.catalog = CatalogFactory.create({ |
| 'warehouse': cls.warehouse |
| }) |
| cls.catalog.create_database('default', False) |
| |
| @classmethod |
| def tearDownClass(cls): |
| shutil.rmtree(cls.tempdir, ignore_errors=True) |
| |
| def test_basic(self): |
| simple_pa_schema = pa.schema([ |
| ('f0', pa.int8()), |
| ('f1', pa.int16()), |
| ]) |
| schema = Schema.from_pyarrow_schema(simple_pa_schema, |
| options={'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'}) |
| self.catalog.create_table('default.test_row_tracking', schema, False) |
| table = self.catalog.get_table('default.test_row_tracking') |
| |
| # write 1 |
| write_builder = table.new_batch_write_builder() |
| table_write = write_builder.new_write() |
| table_commit = write_builder.new_commit() |
| expect_data = pa.Table.from_pydict({ |
| 'f0': [-1, 2], |
| 'f1': [-1001, 1002] |
| }, schema=simple_pa_schema) |
| table_write.write_arrow(expect_data) |
| table_commit.commit(table_write.prepare_commit()) |
| table_write.close() |
| table_commit.close() |
| |
| # write 2 |
| table_write = write_builder.new_write().with_write_type(['f0']) |
| table_commit = write_builder.new_commit() |
| data2 = pa.Table.from_pydict({ |
| 'f0': [3, 4], |
| }, schema=pa.schema([ |
| ('f0', pa.int8()), |
| ])) |
| table_write.write_arrow(data2) |
| cmts = table_write.prepare_commit() |
| cmts[0].new_files[0].first_row_id = 0 |
| table_commit.commit(cmts) |
| table_write.close() |
| table_commit.close() |
| |
| read_builder = table.new_read_builder() |
| table_scan = read_builder.new_scan() |
| table_read = read_builder.new_read() |
| actual_data = table_read.to_arrow(table_scan.plan().splits()) |
| expect_data = pa.Table.from_pydict({ |
| 'f0': [3, 4], |
| 'f1': [-1001, 1002] |
| }, schema=pa.schema([ |
| ('f0', pa.int8()), |
| ('f1', pa.int16()), |
| ])) |
| self.assertEqual(actual_data, expect_data) |
| self.assertEqual( |
| len(actual_data.schema), len(expect_data.schema), |
| 'Read output column count must match schema') |
| self.assertEqual( |
| actual_data.schema.names, expect_data.schema.names, |
| 'Read output column names must match schema') |
| |
| def test_partitioned_read_requested_column_missing_in_file(self): |
| pa_schema = pa.schema([('f0', pa.int32()), ('f1', pa.string()), ('dt', pa.string())]) |
| schema = Schema.from_pyarrow_schema( |
| pa_schema, |
| partition_keys=['dt'], |
| options={'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'} |
| ) |
| self.catalog.create_table('default.test_partition_missing_col', schema, False) |
| table = self.catalog.get_table('default.test_partition_missing_col') |
| wb = table.new_batch_write_builder() |
| |
| tw1 = wb.new_write() |
| tc1 = wb.new_commit() |
| tw1.write_arrow(pa.Table.from_pydict( |
| {'f0': [1, 2], 'f1': ['a', 'b'], 'dt': ['p1', 'p1']}, |
| schema=pa_schema |
| )) |
| tc1.commit(tw1.prepare_commit()) |
| tw1.close() |
| tc1.close() |
| |
| tw2 = wb.new_write().with_write_type(['f0', 'dt']) |
| tc2 = wb.new_commit() |
| # Row key extractor uses table column indices; pass table-ordered data with null for f1 |
| tw2.write_arrow(pa.Table.from_pydict( |
| {'f0': [3, 4], 'f1': [None, None], 'dt': ['p1', 'p1']}, |
| schema=pa_schema |
| )) |
| tc2.commit(tw2.prepare_commit()) |
| tw2.close() |
| tc2.close() |
| |
| actual = table.new_read_builder().new_read().to_arrow(table.new_read_builder().new_scan().plan().splits()) |
| self.assertEqual(len(actual.schema), 3, 'Must have f0, f1, dt (no silent drop when f1 missing in file)') |
| self.assertEqual(actual.schema.names, ['f0', 'f1', 'dt']) |
| self.assertEqual(actual.num_rows, 4) |
| f1_col = actual.column('f1') |
| self.assertEqual(f1_col[0].as_py(), 'a') |
| self.assertEqual(f1_col[1].as_py(), 'b') |
| self.assertIsNone(f1_col[2].as_py()) |
| self.assertIsNone(f1_col[3].as_py()) |
| |
| # Assert manifest file meta contains min and max row id |
| manifest_list_manager = ManifestListManager(table) |
| snapshot_manager = table.snapshot_manager() |
| all_manifests = manifest_list_manager.read_all(snapshot_manager.get_latest_snapshot()) |
| first_commit = next((m for m in all_manifests if m.min_row_id == 0 and m.max_row_id == 1), None) |
| self.assertIsNotNone(first_commit, "Should have a manifest with min_row_id=0, max_row_id=1") |
| second_commit = next((m for m in all_manifests if m.min_row_id == 2 and m.max_row_id == 3), None) |
| self.assertIsNotNone(second_commit, "Should have a manifest with min_row_id=2, max_row_id=3") |
| |
| def test_merge_reader(self): |
| from pypaimon.read.reader.concat_batch_reader import MergeAllBatchReader |
| |
| simple_pa_schema = pa.schema([ |
| ('f0', pa.int32()), |
| ('f1', pa.string()), |
| ('f2', pa.string()), |
| ]) |
| schema = Schema.from_pyarrow_schema( |
| simple_pa_schema, |
| options={ |
| 'row-tracking.enabled': 'true', |
| 'data-evolution.enabled': 'true', |
| 'read.batch-size': '4096', |
| }, |
| ) |
| self.catalog.create_table('default.test_merge_reader_batch_sizes', schema, False) |
| table = self.catalog.get_table('default.test_merge_reader_batch_sizes') |
| |
| write_builder = table.new_batch_write_builder() |
| size = 5000 |
| w0 = write_builder.new_write().with_write_type(['f0', 'f1']) |
| w1 = write_builder.new_write().with_write_type(['f2']) |
| c = write_builder.new_commit() |
| d0 = pa.Table.from_pydict( |
| {'f0': list(range(size)), 'f1': [f'a{i}' for i in range(size)]}, |
| schema=pa.schema([('f0', pa.int32()), ('f1', pa.string())]), |
| ) |
| d1 = pa.Table.from_pydict( |
| {'f2': [f'b{i}' for i in range(size)]}, |
| schema=pa.schema([('f2', pa.string())]), |
| ) |
| w0.write_arrow(d0) |
| w1.write_arrow(d1) |
| cmts = w0.prepare_commit() + w1.prepare_commit() |
| for msg in cmts: |
| for nf in msg.new_files: |
| nf.first_row_id = 0 |
| c.commit(cmts) |
| w0.close() |
| w1.close() |
| c.close() |
| |
| original_merge_all = MergeAllBatchReader |
| call_count = [0] |
| |
| def patched_merge_all(reader_suppliers, batch_size=1024): |
| call_count[0] += 1 |
| if call_count[0] == 2: |
| batch_size = 999 |
| return original_merge_all(reader_suppliers, batch_size=batch_size) |
| |
| import pypaimon.read.split_read as split_read_module |
| split_read_module.MergeAllBatchReader = patched_merge_all |
| try: |
| read_builder = table.new_read_builder() |
| table_scan = read_builder.new_scan() |
| table_read = read_builder.new_read() |
| splits = table_scan.plan().splits() |
| actual_data = table_read.to_arrow(splits) |
| expect_data = pa.Table.from_pydict({ |
| 'f0': list(range(size)), |
| 'f1': [f'a{i}' for i in range(size)], |
| 'f2': [f'b{i}' for i in range(size)], |
| }, schema=simple_pa_schema) |
| self.assertEqual(actual_data.num_rows, size) |
| self.assertEqual(actual_data, expect_data) |
| finally: |
| split_read_module.MergeAllBatchReader = original_merge_all |
| |
| def test_with_slice(self): |
| pa_schema = pa.schema([ |
| ("id", pa.int64()), |
| ("b", pa.int32()), |
| ("c", pa.int32()), |
| ]) |
| schema = Schema.from_pyarrow_schema( |
| pa_schema, |
| options={ |
| "row-tracking.enabled": "true", |
| "data-evolution.enabled": "true", |
| "source.split.target-size": "512m", |
| }, |
| ) |
| table_name = "default.test_with_slice_data_evolution" |
| self.catalog.create_table(table_name, schema, ignore_if_exists=True) |
| table = self.catalog.get_table(table_name) |
| |
| for batch in [ |
| {"id": [1, 2], "b": [10, 20], "c": [100, 200]}, |
| {"id": [1001, 2001], "b": [1011, 2011], "c": [1001, 2001]}, |
| {"id": [-1, -2], "b": [-10, -20], "c": [-100, -200]}, |
| ]: |
| wb = table.new_batch_write_builder() |
| tw = wb.new_write() |
| tc = wb.new_commit() |
| tw.write_arrow(pa.Table.from_pydict(batch, schema=pa_schema)) |
| tc.commit(tw.prepare_commit()) |
| tw.close() |
| tc.close() |
| |
| rb = table.new_read_builder() |
| full_splits = rb.new_scan().plan().splits() |
| full_result = rb.new_read().to_pandas(full_splits) |
| self.assertEqual( |
| len(full_result), |
| 6, |
| "Full scan should return 6 rows", |
| ) |
| self.assertEqual( |
| sorted(full_result["id"].tolist()), |
| [-2, -1, 1, 2, 1001, 2001], |
| "Full set ids mismatch", |
| ) |
| |
| # with_slice(1, 4) -> row indices [1, 2, 3] -> 3 rows with id in (2, 1001, 2001) |
| scan = rb.new_scan().with_slice(1, 4) |
| splits = scan.plan().splits() |
| result = rb.new_read().to_pandas(splits) |
| self.assertEqual( |
| len(result), |
| 3, |
| "with_slice(1, 4) should return 3 rows (indices 1,2,3). " |
| "Bug: DataEvolutionSplitGenerator returns 2 when split has multiple data files.", |
| ) |
| ids = result["id"].tolist() |
| self.assertEqual( |
| sorted(ids), |
| [2, 1001, 2001], |
| "with_slice(1, 4) should return id in (2, 1001, 2001). Got ids=%s" % ids, |
| ) |
| scan_oob = rb.new_scan().with_slice(10, 12) |
| splits_oob = scan_oob.plan().splits() |
| result_oob = rb.new_read().to_pandas(splits_oob) |
| self.assertEqual( |
| len(result_oob), |
| 0, |
| "with_slice(10, 12) on 6 rows should return 0 rows (out of bounds), got %d" % len(result_oob), |
| ) |
| |
| # Out-of-bounds slice: 6 rows total, slice(10, 12) should return 0 rows |
| scan_oob = rb.new_scan().with_slice(10, 12) |
| splits_oob = scan_oob.plan().splits() |
| result_oob = rb.new_read().to_pandas(splits_oob) |
| self.assertEqual( |
| len(result_oob), |
| 0, |
| "with_slice(10, 12) on 6 rows should return 0 rows (out of bounds), got %d" |
| % len(result_oob), |
| ) |
| |
| def test_with_slice_partitioned_table(self): |
| pa_schema = pa.schema([ |
| ("pt", pa.int64()), |
| ("b", pa.int32()), |
| ("c", pa.int32()), |
| ]) |
| schema = Schema.from_pyarrow_schema( |
| pa_schema, |
| partition_keys=["pt"], |
| options={ |
| "row-tracking.enabled": "true", |
| "data-evolution.enabled": "true", |
| "source.split.target-size": "512m", |
| }, |
| ) |
| table_name = "default.test_with_slice_partitioned_table" |
| self.catalog.create_table(table_name, schema, ignore_if_exists=True) |
| table = self.catalog.get_table(table_name) |
| |
| for batch in [ |
| {"pt": [1, 1], "b": [10, 20], "c": [100, 200]}, |
| {"pt": [2, 2], "b": [1011, 2011], "c": [1001, 2001]}, |
| {"pt": [2, 2], "b": [-10, -20], "c": [-100, -200]}, |
| ]: |
| wb = table.new_batch_write_builder() |
| tw = wb.new_write() |
| tc = wb.new_commit() |
| tw.write_arrow(pa.Table.from_pydict(batch, schema=pa_schema)) |
| tc.commit(tw.prepare_commit()) |
| tw.close() |
| tc.close() |
| |
| rb: ReadBuilder = table.new_read_builder() |
| full_splits = rb.new_scan().plan().splits() |
| full_result = rb.new_read().to_pandas(full_splits) |
| self.assertEqual( |
| len(full_result), |
| 6, |
| "Full scan should return 6 rows", |
| ) |
| |
| predicate_builder = rb.new_predicate_builder() |
| rb.with_filter(predicate_builder.equal("pt", 2)) |
| |
| # 0 to 2 |
| scan_oob = rb.new_scan().with_slice(0, 2) |
| splits_oob = scan_oob.plan().splits() |
| result_oob = rb.new_read().to_pandas(splits_oob) |
| self.assertEqual( |
| sorted(result_oob["b"].tolist()), |
| [1011, 2011], |
| "Full set b mismatch", |
| ) |
| |
| # 2 to 4 |
| scan_oob = rb.new_scan().with_slice(2, 4) |
| splits_oob = scan_oob.plan().splits() |
| result_oob = rb.new_read().to_pandas(splits_oob) |
| self.assertEqual( |
| sorted(result_oob["b"].tolist()), |
| [-20, -10], |
| "Full set b mismatch", |
| ) |
| |
| def test_multiple_appends(self): |
| simple_pa_schema = pa.schema([ |
| ('f0', pa.int32()), |
| ('f1', pa.string()), |
| ('f2', pa.string()), |
| ]) |
| schema = Schema.from_pyarrow_schema( |
| simple_pa_schema, |
| options={'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'} |
| ) |
| self.catalog.create_table('default.test_multiple_appends', schema, False) |
| table = self.catalog.get_table('default.test_multiple_appends') |
| |
| write_builder = table.new_batch_write_builder() |
| |
| # write 100 rows: (1, "a", "b") |
| table_write = write_builder.new_write() |
| table_commit = write_builder.new_commit() |
| init_data = pa.Table.from_pydict({ |
| 'f0': [1] * 100, |
| 'f1': ['a'] * 100, |
| 'f2': ['b'] * 100, |
| }, schema=simple_pa_schema) |
| table_write.write_arrow(init_data) |
| table_commit.commit(table_write.prepare_commit()) |
| table_write.close() |
| table_commit.close() |
| # append:write (2, "x") and ("y"), set first_row_id = 100 |
| write0 = write_builder.new_write().with_write_type(['f0', 'f1']) |
| write1 = write_builder.new_write().with_write_type(['f2']) |
| commit = write_builder.new_commit() |
| data0 = pa.Table.from_pydict({'f0': [2], 'f1': ['x']}, |
| schema=pa.schema([('f0', pa.int32()), ('f1', pa.string())])) |
| data1 = pa.Table.from_pydict({'f2': ['y']}, schema=pa.schema([('f2', pa.string())])) |
| write0.write_arrow(data0) |
| write1.write_arrow(data1) |
| cmts = write0.prepare_commit() + write1.prepare_commit() |
| for c in cmts: |
| for nf in c.new_files: |
| nf.first_row_id = 100 |
| commit.commit(cmts) |
| write0.close() |
| write1.close() |
| commit.close() |
| |
| # append:write (3, "c") and ("d"), set first_row_id = 101 |
| write0 = write_builder.new_write().with_write_type(['f0', 'f1']) |
| commit0 = write_builder.new_commit() |
| data0 = pa.Table.from_pydict({'f0': [3], 'f1': ['c']}, |
| schema=pa.schema([('f0', pa.int32()), ('f1', pa.string())])) |
| write0.write_arrow(data0) |
| cmts0 = write0.prepare_commit() |
| for c in cmts0: |
| for nf in c.new_files: |
| nf.first_row_id = 101 |
| commit0.commit(cmts0) |
| write0.close() |
| commit0.close() |
| |
| write1 = write_builder.new_write().with_write_type(['f2']) |
| commit1 = write_builder.new_commit() |
| data1 = pa.Table.from_pydict({'f2': ['d']}, schema=pa.schema([('f2', pa.string())])) |
| write1.write_arrow(data1) |
| cmts1 = write1.prepare_commit() |
| for c in cmts1: |
| for nf in c.new_files: |
| nf.first_row_id = 101 |
| commit1.commit(cmts1) |
| write1.close() |
| commit1.close() |
| |
| read_builder = table.new_read_builder() |
| table_scan = read_builder.new_scan() |
| table_read = read_builder.new_read() |
| actual = table_read.to_arrow(table_scan.plan().splits()) |
| |
| self.assertEqual(actual.num_rows, 102) |
| expect = pa.Table.from_pydict({ |
| 'f0': [1] * 100 + [2] + [3], |
| 'f1': ['a'] * 100 + ['x'] + ['c'], |
| 'f2': ['b'] * 100 + ['y'] + ['d'], |
| }, schema=simple_pa_schema) |
| self.assertEqual(actual, expect) |
| self.assertEqual(len(actual.schema), len(expect.schema), 'Merge read output column count must match schema') |
| self.assertEqual(actual.schema.names, expect.schema.names, 'Merge read output column names must match schema') |
| |
| def test_disorder_cols_append(self): |
| simple_pa_schema = pa.schema([ |
| ('f0', pa.int32()), |
| ('f1', pa.string()), |
| ('f2', pa.string()), |
| ]) |
| schema = Schema.from_pyarrow_schema( |
| simple_pa_schema, |
| options={'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'} |
| ) |
| self.catalog.create_table('default.test_disorder_cols_append', schema, False) |
| table = self.catalog.get_table('default.test_disorder_cols_append') |
| |
| write_builder = table.new_batch_write_builder() |
| num_rows = 100 |
| # write 1 rows: (1, "a", "b") |
| table_write = write_builder.new_write() |
| table_commit = write_builder.new_commit() |
| init_data = pa.Table.from_pydict({ |
| 'f0': [1] * num_rows, |
| 'f1': ['a'] * num_rows, |
| 'f2': ['b'] * num_rows, |
| }, schema=simple_pa_schema) |
| table_write.write_arrow(init_data) |
| table_commit.commit(table_write.prepare_commit()) |
| table_write.close() |
| table_commit.close() |
| |
| # append:set first_row_id = 0 to modify the row with columns write |
| write0 = write_builder.new_write().with_write_type(['f0', 'f2']) |
| write1 = write_builder.new_write().with_write_type(['f1']) |
| commit = write_builder.new_commit() |
| data0 = pa.Table.from_pydict({'f0': [2] * num_rows, 'f2': ['y'] * num_rows}, |
| schema=pa.schema([('f0', pa.int32()), ('f2', pa.string())])) |
| data1 = pa.Table.from_pydict({'f1': ['x'] * num_rows}, schema=pa.schema([('f1', pa.string())])) |
| write0.write_arrow(data0) |
| write1.write_arrow(data1) |
| cmts = write0.prepare_commit() + write1.prepare_commit() |
| for c in cmts: |
| for nf in c.new_files: |
| nf.first_row_id = 0 |
| commit.commit(cmts) |
| write0.close() |
| write1.close() |
| commit.close() |
| |
| read_builder = table.new_read_builder() |
| table_scan = read_builder.new_scan() |
| table_read = read_builder.new_read() |
| actual = table_read.to_arrow(table_scan.plan().splits()) |
| |
| self.assertEqual(actual.num_rows, 100) |
| expect = pa.Table.from_pydict({ |
| 'f0': [2] * num_rows, |
| 'f1': ['x'] * num_rows, |
| 'f2': ['y'] * num_rows, |
| }, schema=simple_pa_schema) |
| self.assertEqual(actual, expect) |
| |
| read_builder2 = table.new_read_builder() |
| read_builder2.with_projection(['f2', 'f0', 'f1']) |
| actual2 = read_builder2.new_read().to_arrow( |
| read_builder2.new_scan().plan().splits()) |
| self.assertEqual(actual2.column('f0').to_pylist(), [2] * num_rows) |
| self.assertEqual(actual2.column('f1').to_pylist(), ['x'] * num_rows) |
| self.assertEqual(actual2.column('f2').to_pylist(), ['y'] * num_rows) |
| |
| def test_only_some_columns(self): |
| simple_pa_schema = pa.schema([ |
| ('f0', pa.int32()), |
| ('f1', pa.string()), |
| ('f2', pa.string()), |
| ]) |
| schema = Schema.from_pyarrow_schema( |
| simple_pa_schema, |
| options={'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'} |
| ) |
| self.catalog.create_table('default.test_only_some_columns', schema, False) |
| table = self.catalog.get_table('default.test_only_some_columns') |
| |
| write_builder = table.new_batch_write_builder() |
| |
| # Commit 1: f0 |
| w0 = write_builder.new_write().with_write_type(['f0']) |
| c0 = write_builder.new_commit() |
| d0 = pa.Table.from_pydict({'f0': [1]}, schema=pa.schema([('f0', pa.int32())])) |
| w0.write_arrow(d0) |
| c0.commit(w0.prepare_commit()) |
| w0.close() |
| c0.close() |
| |
| # Commit 2: f1, first_row_id = 0 |
| w1 = write_builder.new_write().with_write_type(['f1']) |
| c1 = write_builder.new_commit() |
| d1 = pa.Table.from_pydict({'f1': ['a']}, schema=pa.schema([('f1', pa.string())])) |
| w1.write_arrow(d1) |
| cmts1 = w1.prepare_commit() |
| for c in cmts1: |
| for nf in c.new_files: |
| nf.first_row_id = 0 |
| c1.commit(cmts1) |
| w1.close() |
| c1.close() |
| |
| # Commit 3: f2, first_row_id = 0 |
| w2 = write_builder.new_write().with_write_type(['f2']) |
| c2 = write_builder.new_commit() |
| d2 = pa.Table.from_pydict({'f2': ['b']}, schema=pa.schema([('f2', pa.string())])) |
| w2.write_arrow(d2) |
| cmts2 = w2.prepare_commit() |
| for c in cmts2: |
| for nf in c.new_files: |
| nf.first_row_id = 0 |
| c2.commit(cmts2) |
| w2.close() |
| c2.close() |
| |
| read_builder = table.new_read_builder() |
| table_scan = read_builder.new_scan() |
| table_read = read_builder.new_read() |
| actual = table_read.to_arrow(table_scan.plan().splits()) |
| |
| expect = pa.Table.from_pydict({ |
| 'f0': [1], |
| 'f1': ['a'], |
| 'f2': ['b'], |
| }, schema=simple_pa_schema) |
| self.assertEqual(actual, expect) |
| |
| def _create_filter_test_table(self, table_name: str): |
| pa_schema = pa.schema([ |
| ("id", pa.int64()), |
| ("b", pa.int32()), |
| pa.field("c", pa.int32(), nullable=True), |
| ]) |
| schema = Schema.from_pyarrow_schema( |
| pa_schema, options={"row-tracking.enabled": "true", "data-evolution.enabled": "true"}, |
| ) |
| self.catalog.create_table(table_name, schema, ignore_if_exists=True) |
| table = self.catalog.get_table(table_name) |
| wb = table.new_batch_write_builder() |
| w0, c0 = wb.new_write().with_write_type(["id", "b"]), wb.new_commit() |
| w0.write_arrow(pa.Table.from_pydict( |
| {"id": [1, 2, 3], "b": [10, 20, 30]}, |
| schema=pa.schema([("id", pa.int64()), ("b", pa.int32())]), |
| )) |
| c0.commit(w0.prepare_commit()) |
| w0.close() |
| c0.close() |
| w1, c1 = wb.new_write().with_write_type(["c"]), wb.new_commit() |
| w1.write_arrow(pa.Table.from_pydict( |
| {"c": [100, None, 200]}, |
| schema=pa.schema([pa.field("c", pa.int32(), nullable=True)]), |
| )) |
| cmts1 = w1.prepare_commit() |
| for cmt in cmts1: |
| for nf in cmt.new_files: |
| nf.first_row_id = 0 |
| c1.commit(cmts1) |
| w1.close() |
| c1.close() |
| return table |
| |
| def test_with_filter(self): |
| table = self._create_filter_test_table("default.test_filter_on_evolved_column") |
| rb = table.new_read_builder() |
| splits = rb.new_scan().plan().splits() |
| |
| full_df = rb.new_read().to_pandas(splits) |
| self.assertEqual(len(full_df), 3, "Full scan must return 3 rows") |
| full_sorted = full_df.sort_values("id").reset_index(drop=True) |
| self.assertEqual(full_sorted["id"].tolist(), [1, 2, 3]) |
| self.assertEqual(full_sorted["b"].tolist(), [10, 20, 30]) |
| self.assertEqual(full_sorted["c"].iloc[0], 100) |
| self.assertTrue(pd.isna(full_sorted["c"].iloc[1]), "Row id=2 must have NULL c") |
| self.assertEqual(full_sorted["c"].iloc[2], 200) |
| |
| predicate_gt = rb.new_predicate_builder().greater_than("c", 150) |
| rb_gt = table.new_read_builder().with_filter(predicate_gt) |
| result_gt = rb_gt.new_read().to_pandas(rb_gt.new_scan().plan().splits()) |
| self.assertEqual(len(result_gt), 1, "Filter c > 150 should return 1 row (c=200)") |
| self.assertEqual(result_gt["id"].iloc[0], 3, "Row with c=200 must have id=3") |
| self.assertEqual(result_gt["b"].iloc[0], 30, "Row with c=200 must have b=30") |
| self.assertEqual(result_gt["c"].iloc[0], 200, "Filtered row must have c=200") |
| |
| predicate_lt = rb.new_predicate_builder().less_than("c", 150) |
| rb_lt = table.new_read_builder().with_filter(predicate_lt) |
| result_lt = rb_lt.new_read().to_pandas(rb_lt.new_scan().plan().splits()) |
| self.assertEqual(len(result_lt), 1, "Filter c < 150 should return 1 row (c=100)") |
| self.assertEqual(result_lt["id"].iloc[0], 1, "Row with c=100 must have id=1") |
| self.assertEqual(result_lt["c"].iloc[0], 100, "Filtered row must have c=100") |
| |
| predicate_id = rb.new_predicate_builder().equal("id", 2) |
| rb_id = table.new_read_builder().with_filter(predicate_id) |
| result_id = rb_id.new_read().to_pandas(rb_id.new_scan().plan().splits()) |
| self.assertEqual(len(result_id), 1, "Filter id == 2 should return 1 row") |
| self.assertEqual(result_id["id"].iloc[0], 2, "Filtered row must have id=2") |
| self.assertTrue(pd.isna(result_id["c"].iloc[0]), "Row id=2 must have c=NULL") |
| |
| pb = rb.new_predicate_builder() |
| predicate_and = pb.and_predicates([ |
| pb.greater_than("c", 50), |
| pb.less_than("c", 150), |
| ]) |
| rb_and = table.new_read_builder().with_filter(predicate_and) |
| result_and = rb_and.new_read().to_pandas(rb_and.new_scan().plan().splits()) |
| self.assertEqual( |
| len(result_and), 1, |
| "Filter c>50 AND c<150 should return 1 row (c=100)", |
| ) |
| self.assertEqual(result_and["id"].iloc[0], 1, "Row with c=100 must have id=1") |
| self.assertEqual(result_and["c"].iloc[0], 100, "Filtered row must have c=100") |
| |
| predicate_is_null = rb.new_predicate_builder().is_null("c") |
| rb_null = table.new_read_builder().with_filter(predicate_is_null) |
| result_null = rb_null.new_read().to_pandas(rb_null.new_scan().plan().splits()) |
| self.assertEqual(len(result_null), 1, "Filter c IS NULL should return 1 row (id=2)") |
| self.assertEqual(result_null["id"].iloc[0], 2, "NULL row must have id=2") |
| self.assertTrue(pd.isna(result_null["c"].iloc[0]), "Filtered row c must be NULL") |
| |
| predicate_not_null = rb.new_predicate_builder().is_not_null("c") |
| rb_not_null = table.new_read_builder().with_filter(predicate_not_null) |
| result_not_null = rb_not_null.new_read().to_pandas( |
| rb_not_null.new_scan().plan().splits()) |
| self.assertEqual( |
| len(result_not_null), 2, |
| "Filter c IS NOT NULL should return 2 rows (id=1, id=3)", |
| ) |
| result_not_null_sorted = result_not_null.sort_values("id").reset_index(drop=True) |
| self.assertEqual(result_not_null_sorted["id"].tolist(), [1, 3]) |
| self.assertEqual(result_not_null_sorted["c"].tolist(), [100, 200]) |
| |
| predicate_or = pb.or_predicates([ |
| pb.greater_than("c", 150), |
| pb.less_than("c", 100), |
| ]) |
| rb_or = table.new_read_builder().with_filter(predicate_or) |
| result_or = rb_or.new_read().to_pandas(rb_or.new_scan().plan().splits()) |
| self.assertEqual( |
| len(result_or), 1, |
| "Filter c>150 OR c<100 should return 1 row (id=3, c=200)", |
| ) |
| self.assertEqual(result_or["id"].iloc[0], 3, "Row with c=200 must have id=3") |
| self.assertEqual(result_or["c"].iloc[0], 200, "Filtered row must have c=200") |
| |
| def test_with_filter_and_projection(self): |
| table = self._create_filter_test_table("default.test_filter_and_projection_evolved") |
| rb_full = table.new_read_builder() |
| predicate = rb_full.new_predicate_builder().greater_than("c", 150) |
| rb_filtered = table.new_read_builder().with_projection(["c", "id"]).with_filter(predicate) |
| result = rb_filtered.new_read().to_pandas(rb_filtered.new_scan().plan().splits()) |
| self.assertEqual(len(result), 1, "Filter c > 150 with projection [c, id] should return 1 row") |
| self.assertEqual(result["id"].iloc[0], 3) |
| self.assertEqual(result["c"].iloc[0], 200) |
| for _, row in result.iterrows(): |
| self.assertGreater( |
| row["c"], |
| 150, |
| "Each row must satisfy predicate c > 150 (row-by-row path uses predicate.index; " |
| "if schema_fields != read_type, wrong column is compared).", |
| ) |
| |
| predicate2 = rb_full.new_predicate_builder().is_null("c") |
| rb2_filtered = table.new_read_builder().with_projection(["id", "c"]).with_filter(predicate2) |
| result2 = rb2_filtered.new_read().to_pandas(rb2_filtered.new_scan().plan().splits()) |
| self.assertEqual(len(result2), 1, "Filter c IS NULL with projection [id, c] should return 1 row") |
| self.assertEqual(result2["id"].iloc[0], 2) |
| self.assertTrue(pd.isna(result2["c"].iloc[0])) |
| |
| predicate3 = rb_full.new_predicate_builder().greater_than("c", 50) |
| rb3_filtered = table.new_read_builder().with_projection(["c"]).with_filter(predicate3) |
| result3 = rb3_filtered.new_read().to_pandas(rb3_filtered.new_scan().plan().splits()) |
| self.assertEqual(len(result3), 2, "Filter c > 50 with projection [c] should return 2 rows (c=100, 200)") |
| self.assertEqual(sorted(result3["c"].tolist()), [100, 200]) |
| |
| # Build predicate from same read_type as projection [id, c] so indices match (c at index 1). |
| rb4 = table.new_read_builder().with_projection(["id", "c"]) |
| pb4 = rb4.new_predicate_builder() |
| predicate_compound = pb4.and_predicates([ |
| pb4.greater_than("c", 150), |
| pb4.is_not_null("c"), |
| ]) |
| rb4_filtered = rb4.with_filter(predicate_compound) |
| result4 = rb4_filtered.new_read().to_pandas(rb4_filtered.new_scan().plan().splits()) |
| self.assertEqual(len(result4), 1, "Filter c>150 AND c IS NOT NULL with projection [id,c] should return 1 row") |
| self.assertEqual(result4["id"].iloc[0], 3) |
| self.assertEqual(result4["c"].iloc[0], 200) |
| |
| predicate_filter_on_non_projected = rb_full.new_predicate_builder().greater_than("c", 150) |
| rb_non_projected = table.new_read_builder().with_projection(["id"]).with_filter( |
| predicate_filter_on_non_projected |
| ) |
| result_non_projected = rb_non_projected.new_read().to_pandas( |
| rb_non_projected.new_scan().plan().splits() |
| ) |
| self.assertEqual( |
| len(result_non_projected), |
| 3, |
| "Filter c > 150 with projection [id]: c not in read_type so filter is dropped, all 3 rows returned.", |
| ) |
| self.assertEqual( |
| list(result_non_projected.columns), |
| ["id"], |
| "Projection [id] should return only id column.", |
| ) |
| table_read = rb_non_projected.new_read() |
| splits = rb_non_projected.new_scan().plan().splits() |
| expected_output_arity = len(table_read.read_type) |
| try: |
| rows_from_iterator = list(table_read.to_iterator(splits)) |
| except ValueError as e: |
| if "Expected Arrow table or array" in str(e): |
| self.skipTest( |
| "RecordBatchReader path uses polars.from_arrow(RecordBatch) which fails; " |
| "skip to_iterator projection assertion on this path" |
| ) |
| raise |
| self.assertEqual(len(rows_from_iterator), 3, "to_iterator should return same row count as to_pandas") |
| for row in rows_from_iterator: |
| self.assertIsInstance(row, OffsetRow) |
| self.assertEqual( |
| row.arity, |
| expected_output_arity, |
| "to_iterator must yield rows with only read_type columns (arity=%d)." |
| % expected_output_arity, |
| ) |
| |
| def test_null_predicate_arrow_vs_row_by_row(self): |
| schema = pa.schema([("id", pa.int64()), ("c", pa.int64())]) |
| batch = pa.RecordBatch.from_pydict( |
| {"id": [1, 2, 3], "c": [10, None, 20]}, |
| schema=schema, |
| ) |
| ncols = 2 |
| |
| # is_null('c'): Arrow and row-by-row must return same rows |
| pred_is_null = Predicate(method="isNull", index=1, field="c", literals=None) |
| arrow_res = _filter_batch_arrow(batch, pred_is_null) |
| row_res = _filter_batch_row_by_row(batch, pred_is_null, ncols) |
| self.assertEqual(arrow_res.num_rows, row_res.num_rows) |
| self.assertTrue(_batches_equal(arrow_res, row_res)) |
| self.assertEqual(arrow_res.num_rows, 1) |
| self.assertEqual(arrow_res.column("id")[0].as_py(), 2) |
| self.assertIsNone(arrow_res.column("c")[0].as_py()) |
| |
| # is_not_null('c'): Arrow and row-by-row must return same rows |
| pred_not_null = Predicate(method="isNotNull", index=1, field="c", literals=None) |
| arrow_res2 = _filter_batch_arrow(batch, pred_not_null) |
| row_res2 = _filter_batch_row_by_row(batch, pred_not_null, ncols) |
| self.assertEqual(arrow_res2.num_rows, row_res2.num_rows) |
| self.assertTrue(_batches_equal(arrow_res2, row_res2)) |
| self.assertEqual(arrow_res2.num_rows, 2) |
| |
| pred_eq_null = Predicate(method="equal", index=1, field="c", literals=[None]) |
| row_res3 = _filter_batch_row_by_row(batch, pred_eq_null, ncols) |
| self.assertEqual(row_res3.num_rows, 0) # Paimon: val is None -> False, no row matches |
| arrow_res3 = _filter_batch_arrow(batch, pred_eq_null) |
| self.assertEqual(arrow_res3.num_rows, 0) # Arrow: NULL==NULL is null, filtered out |
| self.assertEqual(arrow_res3.num_rows, row_res3.num_rows) |
| |
| def test_filter_row_by_row_mismatched_schema(self): |
| batch = pa.RecordBatch.from_pydict( |
| {"c": [1, 200, 50], "id": [100, 2, 3]}, |
| schema=pa.schema([("c", pa.int64()), ("id", pa.int64())]), |
| ) |
| pred = Predicate(method="greaterThan", index=0, field="c", literals=[150]) |
| |
| ncols = 3 |
| nrows = batch.num_rows |
| id_col = batch.column("id") |
| c_col = batch.column("c") |
| row_tuple = [None] * ncols |
| offset_row = OffsetRow(row_tuple, 0, ncols) |
| mask = [] |
| for i in range(nrows): |
| row_tuple[0] = id_col[i].as_py() |
| row_tuple[1] = None |
| row_tuple[2] = c_col[i].as_py() |
| offset_row.replace(tuple(row_tuple)) |
| try: |
| mask.append(pred.test(offset_row)) |
| except (TypeError, ValueError): |
| mask.append(False) |
| rows_passing_wrong_layout = sum(mask) |
| self.assertEqual( |
| rows_passing_wrong_layout, |
| 0, |
| "With wrong layout (position 0 = id), predicate c > 150 becomes id > 150 -> 0 rows. " |
| "This reproduces FilterRecordBatchReader bug when schema_fields=table.fields.", |
| ) |
| ncols_right = 2 |
| row_tuple_right = [None] * ncols_right |
| offset_row_right = OffsetRow(row_tuple_right, 0, ncols_right) |
| mask_right = [] |
| for i in range(nrows): |
| row_tuple_right[0] = c_col[i].as_py() |
| row_tuple_right[1] = id_col[i].as_py() |
| offset_row_right.replace(tuple(row_tuple_right)) |
| try: |
| mask_right.append(pred.test(offset_row_right)) |
| except (TypeError, ValueError): |
| mask_right.append(False) |
| rows_passing_right_layout = sum(mask_right) |
| self.assertEqual( |
| rows_passing_right_layout, |
| 1, |
| "With correct layout (position 0 = c), predicate c > 150 -> 1 row (c=200).", |
| ) |
| |
| def test_null_values(self): |
| simple_pa_schema = pa.schema([ |
| ('f0', pa.int32()), |
| ('f1', pa.string()), |
| ('f2', pa.string()), |
| ]) |
| schema = Schema.from_pyarrow_schema( |
| simple_pa_schema, |
| options={'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'} |
| ) |
| self.catalog.create_table('default.test_null_values', schema, False) |
| table = self.catalog.get_table('default.test_null_values') |
| |
| write_builder = table.new_batch_write_builder() |
| |
| # Commit 1: some cols are null |
| w0 = write_builder.new_write().with_write_type(['f0', 'f1']) |
| w1 = write_builder.new_write().with_write_type(['f2']) |
| c = write_builder.new_commit() |
| |
| d0 = pa.Table.from_pydict({'f0': [1], 'f1': [None]}, |
| schema=pa.schema([('f0', pa.int32()), ('f1', pa.string())])) |
| d1 = pa.Table.from_pydict({'f2': [None]}, schema=pa.schema([('f2', pa.string())])) |
| w0.write_arrow(d0) |
| w1.write_arrow(d1) |
| cmts = w0.prepare_commit() + w1.prepare_commit() |
| for msg in cmts: |
| for nf in msg.new_files: |
| nf.first_row_id = 0 |
| c.commit(cmts) |
| w0.close() |
| w1.close() |
| c.close() |
| |
| # Commit 2 |
| w1 = write_builder.new_write().with_write_type(['f2']) |
| c1 = write_builder.new_commit() |
| d1 = pa.Table.from_pydict({'f2': ['c']}, schema=pa.schema([('f2', pa.string())])) |
| w1.write_arrow(d1) |
| cmts1 = w1.prepare_commit() |
| for msg in cmts1: |
| for nf in msg.new_files: |
| nf.first_row_id = 0 |
| c1.commit(cmts1) |
| w1.close() |
| c1.close() |
| |
| read_builder = table.new_read_builder() |
| table_scan = read_builder.new_scan() |
| table_read = read_builder.new_read() |
| actual = table_read.to_arrow(table_scan.plan().splits()) |
| expect = pa.Table.from_pydict({ |
| 'f0': [1], |
| 'f1': [None], |
| 'f2': ['c'], |
| }, schema=simple_pa_schema) |
| self.assertEqual(actual, expect) |
| |
| # different first_row_id append multiple times |
| def test_multiple_appends_different_first_row_ids(self): |
| simple_pa_schema = pa.schema([ |
| ('f0', pa.int32()), |
| ('f1', pa.string()), |
| ('f2', pa.string()), |
| ]) |
| schema = Schema.from_pyarrow_schema( |
| simple_pa_schema, |
| options={'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'} |
| ) |
| self.catalog.create_table('default.test_multiple_appends_diff_rowid', schema, False) |
| table = self.catalog.get_table('default.test_multiple_appends_diff_rowid') |
| |
| write_builder = table.new_batch_write_builder() |
| |
| # commit 1 |
| w0 = write_builder.new_write().with_write_type(['f0', 'f1']) |
| w1 = write_builder.new_write().with_write_type(['f2']) |
| c = write_builder.new_commit() |
| d0 = pa.Table.from_pydict({'f0': [1], 'f1': ['a']}, |
| schema=pa.schema([('f0', pa.int32()), ('f1', pa.string())])) |
| d1 = pa.Table.from_pydict({'f2': ['b']}, schema=pa.schema([('f2', pa.string())])) |
| w0.write_arrow(d0) |
| w1.write_arrow(d1) |
| cmts = w0.prepare_commit() + w1.prepare_commit() |
| for msg in cmts: |
| for nf in msg.new_files: |
| nf.first_row_id = 0 |
| c.commit(cmts) |
| w0.close() |
| w1.close() |
| c.close() |
| |
| # commit 2 |
| w0 = write_builder.new_write().with_write_type(['f0', 'f1']) |
| c0 = write_builder.new_commit() |
| d0 = pa.Table.from_pydict({'f0': [2], 'f1': ['c']}, |
| schema=pa.schema([('f0', pa.int32()), ('f1', pa.string())])) |
| w0.write_arrow(d0) |
| cmts0 = w0.prepare_commit() |
| for msg in cmts0: |
| for nf in msg.new_files: |
| nf.first_row_id = 1 |
| c0.commit(cmts0) |
| w0.close() |
| c0.close() |
| |
| # commit 3 |
| w1 = write_builder.new_write().with_write_type(['f2']) |
| c1 = write_builder.new_commit() |
| d1 = pa.Table.from_pydict({'f2': ['d']}, schema=pa.schema([('f2', pa.string())])) |
| w1.write_arrow(d1) |
| cmts1 = w1.prepare_commit() |
| for msg in cmts1: |
| for nf in msg.new_files: |
| nf.first_row_id = 1 |
| c1.commit(cmts1) |
| w1.close() |
| c1.close() |
| |
| read_builder = table.new_read_builder() |
| table_scan = read_builder.new_scan() |
| table_read = read_builder.new_read() |
| actual = table_read.to_arrow(table_scan.plan().splits()) |
| |
| expect = pa.Table.from_pydict({ |
| 'f0': [1, 2], |
| 'f1': ['a', 'c'], |
| 'f2': ['b', 'd'], |
| }, schema=simple_pa_schema) |
| self.assertEqual(actual, expect) |
| |
| read_builder = table.new_read_builder() |
| read_builder.with_projection(['f0', 'f1', 'f2', '_ROW_ID', '_SEQUENCE_NUMBER']) |
| table_scan = read_builder.new_scan() |
| table_read = read_builder.new_read() |
| actual_with_meta = table_read.to_arrow(table_scan.plan().splits()) |
| self.assertFalse( |
| actual_with_meta.schema.field('_ROW_ID').nullable, |
| '_ROW_ID must be non-nullable per SpecialFields', |
| ) |
| self.assertFalse( |
| actual_with_meta.schema.field('_SEQUENCE_NUMBER').nullable, |
| '_SEQUENCE_NUMBER must be non-nullable per SpecialFields', |
| ) |
| |
| rb_with_row_id = table.new_read_builder().with_projection(['f0', 'f1', 'f2', '_ROW_ID']) |
| pb = rb_with_row_id.new_predicate_builder() |
| rb_eq0 = table.new_read_builder().with_filter(pb.equal('_ROW_ID', 0)) |
| result_eq0 = rb_eq0.new_read().to_arrow(rb_eq0.new_scan().plan().splits()) |
| self.assertEqual(result_eq0, pa.Table.from_pydict( |
| {'f0': [1], 'f1': ['a'], 'f2': ['b']}, schema=simple_pa_schema)) |
| rb_eq1 = table.new_read_builder().with_filter(pb.equal('_ROW_ID', 1)) |
| result_eq1 = rb_eq1.new_read().to_arrow(rb_eq1.new_scan().plan().splits()) |
| self.assertEqual(result_eq1, pa.Table.from_pydict( |
| {'f0': [2], 'f1': ['c'], 'f2': ['d']}, schema=simple_pa_schema)) |
| rb_in = table.new_read_builder().with_filter(pb.is_in('_ROW_ID', [0, 1])) |
| result_in = rb_in.new_read().to_arrow(rb_in.new_scan().plan().splits()) |
| self.assertEqual(result_in, expect) |
| |
| def test_filter_by_row_id(self): |
| simple_pa_schema = pa.schema([('f0', pa.int32())]) |
| schema = Schema.from_pyarrow_schema( |
| simple_pa_schema, |
| options={'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'}, |
| ) |
| self.catalog.create_table('default.test_row_id_filter_empty_and_or', schema, False) |
| table = self.catalog.get_table('default.test_row_id_filter_empty_and_or') |
| write_builder = table.new_batch_write_builder() |
| |
| # Commit 1: _ROW_ID 0, 1 with f0=1, 2 |
| w = write_builder.new_write().with_write_type(['f0']) |
| c = write_builder.new_commit() |
| w.write_arrow(pa.Table.from_pydict( |
| {'f0': [1, 2]}, schema=pa.schema([('f0', pa.int32())]))) |
| cmts = w.prepare_commit() |
| for msg in cmts: |
| for nf in msg.new_files: |
| nf.first_row_id = 0 |
| c.commit(cmts) |
| w.close() |
| c.close() |
| |
| # Commit 2: _ROW_ID 2, 3 with f0=101, 102 |
| w = write_builder.new_write().with_write_type(['f0']) |
| c = write_builder.new_commit() |
| w.write_arrow(pa.Table.from_pydict( |
| {'f0': [101, 102]}, schema=pa.schema([('f0', pa.int32())]))) |
| cmts = w.prepare_commit() |
| for msg in cmts: |
| for nf in msg.new_files: |
| nf.first_row_id = 2 |
| c.commit(cmts) |
| w.close() |
| c.close() |
| |
| rb_with_row_id = table.new_read_builder().with_projection(['f0', '_ROW_ID']) |
| pb = rb_with_row_id.new_predicate_builder() |
| |
| # 1. Non-existent _ROW_ID -> empty |
| rb_eq999 = table.new_read_builder().with_filter(pb.equal('_ROW_ID', 999)) |
| result_eq999 = rb_eq999.new_read().to_arrow(rb_eq999.new_scan().plan().splits()) |
| self.assertEqual(len(result_eq999), 0, "Non-existent _ROW_ID should return empty") |
| |
| # 2. AND: _ROW_ID=0 AND f0=1 -> 1 row |
| rb_and = table.new_read_builder().with_filter( |
| pb.and_predicates([pb.equal('_ROW_ID', 0), pb.equal('f0', 1)]) |
| ) |
| result_and = rb_and.new_read().to_arrow(rb_and.new_scan().plan().splits()) |
| self.assertEqual(len(result_and), 1) |
| self.assertEqual(result_and['f0'][0].as_py(), 1) |
| |
| # 3. OR: _ROW_ID=0 OR f0>100 -> at least row with _ROW_ID=0 and all f0>100 |
| rb_or = table.new_read_builder().with_filter( |
| pb.or_predicates([pb.equal('_ROW_ID', 0), pb.greater_than('f0', 100)]) |
| ) |
| result_or = rb_or.new_read().to_arrow(rb_or.new_scan().plan().splits()) |
| f0_vals = set(result_or['f0'][i].as_py() for i in range(len(result_or))) |
| self.assertGreaterEqual(len(result_or), 3, "OR should return _ROW_ID=0 row and f0>100 rows") |
| self.assertIn(1, f0_vals, "_ROW_ID=0 row has f0=1") |
| self.assertIn(101, f0_vals) |
| self.assertIn(102, f0_vals) |
| |
| def test_filter_manifest_entries_by_row_ranges(self): |
| from pypaimon.read.scanner.file_scanner import _filter_manifest_entries_by_row_ranges |
| |
| entry_0 = SimpleNamespace(file=SimpleNamespace(first_row_id=0, row_count=1)) |
| entries = [entry_0] |
| row_ranges = [] |
| |
| filtered = _filter_manifest_entries_by_row_ranges(entries, row_ranges) |
| self.assertEqual(filtered, [], "empty row_ranges must return no entries, not all entries") |
| |
| def test_more_data(self): |
| simple_pa_schema = pa.schema([ |
| ('f0', pa.int32()), |
| ('f1', pa.string()), |
| ('f2', pa.string()), |
| ]) |
| schema = Schema.from_pyarrow_schema( |
| simple_pa_schema, |
| options={'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'} |
| ) |
| self.catalog.create_table('default.test_more_data', schema, False) |
| table = self.catalog.get_table('default.test_more_data') |
| |
| write_builder = table.new_batch_write_builder() |
| |
| # first commit:100k rows |
| w0 = write_builder.new_write().with_write_type(['f0', 'f1']) |
| w1 = write_builder.new_write().with_write_type(['f2']) |
| c = write_builder.new_commit() |
| size = 100000 |
| d0 = pa.Table.from_pydict({ |
| 'f0': list(range(size)), |
| 'f1': [f'a{i}' for i in range(size)], |
| }, schema=pa.schema([('f0', pa.int32()), ('f1', pa.string())])) |
| d1 = pa.Table.from_pydict({ |
| 'f2': [f'b{i}' for i in range(size)], |
| }, schema=pa.schema([('f2', pa.string())])) |
| w0.write_arrow(d0) |
| w1.write_arrow(d1) |
| cmts = w0.prepare_commit() + w1.prepare_commit() |
| for msg in cmts: |
| for nf in msg.new_files: |
| nf.first_row_id = 0 |
| c.commit(cmts) |
| w0.close() |
| w1.close() |
| c.close() |
| |
| # second commit:overwrite f2 to 'c{i}' |
| w1 = write_builder.new_write().with_write_type(['f2']) |
| c1 = write_builder.new_commit() |
| d1 = pa.Table.from_pydict({ |
| 'f2': [f'c{i}' for i in range(size)], |
| }, schema=pa.schema([('f2', pa.string())])) |
| w1.write_arrow(d1) |
| cmts1 = w1.prepare_commit() |
| c1.commit(cmts1) |
| w1.close() |
| c1.close() |
| |
| read_builder = table.new_read_builder() |
| table_scan = read_builder.new_scan() |
| table_read = read_builder.new_read() |
| actual = table_read.to_arrow(table_scan.plan().splits()) |
| |
| expect = pa.Table.from_pydict({ |
| 'f0': list(range(size)), |
| 'f1': [f'a{i}' for i in range(size)], |
| 'f2': [f'c{i}' for i in range(size)], |
| }, schema=simple_pa_schema) |
| self.assertEqual(actual, expect) |
| |
| def test_read_row_tracking_metadata(self): |
| simple_pa_schema = pa.schema([ |
| ('f0', pa.int8()), |
| ('f1', pa.int16()), |
| ]) |
| schema = Schema.from_pyarrow_schema(simple_pa_schema, |
| options={'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'}) |
| self.catalog.create_table('default.test_row_tracking_meta', schema, False) |
| table = self.catalog.get_table('default.test_row_tracking_meta') |
| |
| # write 1 |
| write_builder = table.new_batch_write_builder() |
| table_write = write_builder.new_write() |
| table_commit = write_builder.new_commit() |
| expect_data = pa.Table.from_pydict({ |
| 'f0': [-1, 2], |
| 'f1': [-1001, 1002] |
| }, schema=simple_pa_schema) |
| table_write.write_arrow(expect_data) |
| table_commit.commit(table_write.prepare_commit()) |
| table_write.close() |
| table_commit.close() |
| |
| read_builder = table.new_read_builder() |
| read_builder.with_projection(['f0', '_ROW_ID', 'f1', '_SEQUENCE_NUMBER']) |
| table_scan = read_builder.new_scan() |
| table_read = read_builder.new_read() |
| actual_data = table_read.to_arrow(table_scan.plan().splits()) |
| expect_data = pa.Table.from_pydict({ |
| 'f0': [-1, 2], |
| '_ROW_ID': [0, 1], |
| 'f1': [-1001, 1002], |
| '_SEQUENCE_NUMBER': [1, 1], |
| }, schema=pa.schema([ |
| ('f0', pa.int8()), |
| pa.field('_ROW_ID', pa.int64(), nullable=False), |
| ('f1', pa.int16()), |
| pa.field('_SEQUENCE_NUMBER', pa.int64(), nullable=False), |
| ])) |
| self.assertEqual(actual_data, expect_data) |
| self.assertEqual(len(actual_data.schema), len(expect_data.schema), 'Read output column count must match schema') |
| |
| # write 2 |
| table_write = write_builder.new_write().with_write_type(['f0']) |
| table_commit = write_builder.new_commit() |
| data2 = pa.Table.from_pydict({ |
| 'f0': [3, 4], |
| }, schema=pa.schema([ |
| ('f0', pa.int8()), |
| ])) |
| table_write.write_arrow(data2) |
| cmts = table_write.prepare_commit() |
| cmts[0].new_files[0].first_row_id = 0 |
| table_commit.commit(cmts) |
| table_write.close() |
| table_commit.close() |
| |
| read_builder = table.new_read_builder() |
| read_builder.with_projection(['f0', 'f1', '_ROW_ID', '_SEQUENCE_NUMBER']) |
| table_scan = read_builder.new_scan() |
| table_read = read_builder.new_read() |
| actual_data = table_read.to_arrow(table_scan.plan().splits()) |
| self.assertFalse(actual_data.schema.field('_ROW_ID').nullable) |
| self.assertFalse(actual_data.schema.field('_SEQUENCE_NUMBER').nullable) |
| expect_data = pa.Table.from_pydict({ |
| 'f0': [3, 4], |
| 'f1': [-1001, 1002], |
| '_ROW_ID': [0, 1], |
| '_SEQUENCE_NUMBER': [2, 2], |
| }, schema=pa.schema([ |
| ('f0', pa.int8()), |
| ('f1', pa.int16()), |
| pa.field('_ROW_ID', pa.int64(), nullable=False), |
| pa.field('_SEQUENCE_NUMBER', pa.int64(), nullable=False), |
| ])) |
| self.assertEqual(actual_data, expect_data) |
| self.assertEqual(len(actual_data.schema), len(expect_data.schema), 'Read output column count must match schema') |
| |
| def test_with_blob(self): |
| from pypaimon.table.row.blob import BlobDescriptor |
| |
| pa_schema = pa.schema([ |
| ('id', pa.int32()), |
| ('picture', pa.large_binary()), |
| ]) |
| schema = Schema.from_pyarrow_schema( |
| pa_schema, |
| options={ |
| 'row-tracking.enabled': 'true', |
| 'data-evolution.enabled': 'true', |
| 'blob-as-descriptor': 'true', |
| }, |
| ) |
| self.catalog.create_table('default.test_with_blob', schema, False) |
| table = self.catalog.get_table('default.test_with_blob') |
| |
| blob_path = os.path.join(self.tempdir, 'blob_ev') |
| with open(blob_path, 'wb') as f: |
| f.write(b'x') |
| descriptor = BlobDescriptor(blob_path, 0, 1) |
| |
| wb = table.new_batch_write_builder() |
| tw = wb.new_write() |
| tc = wb.new_commit() |
| tw.write_arrow(pa.Table.from_pydict( |
| {'id': [1], 'picture': [descriptor.serialize()]}, |
| schema=pa_schema, |
| )) |
| cmts = tw.prepare_commit() |
| if cmts and cmts[0].new_files: |
| for nf in cmts[0].new_files: |
| nf.first_row_id = 0 |
| tc.commit(cmts) |
| tw.close() |
| tc.close() |
| |
| tw = wb.new_write() |
| tc = wb.new_commit() |
| tw.write_arrow(pa.Table.from_pydict( |
| {'id': [2], 'picture': [descriptor.serialize()]}, |
| schema=pa_schema, |
| )) |
| cmts = tw.prepare_commit() |
| if cmts and cmts[0].new_files: |
| for nf in cmts[0].new_files: |
| nf.first_row_id = 1 |
| tc.commit(cmts) |
| tw.close() |
| tc.close() |
| |
| rb = table.new_read_builder() |
| rb.with_projection(['id', '_ROW_ID', 'picture', '_SEQUENCE_NUMBER']) |
| actual = rb.new_read().to_arrow(rb.new_scan().plan().splits()) |
| self.assertEqual(actual.num_rows, 2) |
| self.assertEqual(actual.column('id').to_pylist(), [1, 2]) |
| self.assertEqual(actual.column('_ROW_ID').to_pylist(), [0, 1]) |
| |
| def test_from_arrays_without_schema(self): |
| schema = pa.schema([ |
| ('f0', pa.int8()), |
| pa.field('_ROW_ID', pa.int64(), nullable=False), |
| pa.field('_SEQUENCE_NUMBER', pa.int64(), nullable=False), |
| ]) |
| batch = pa.RecordBatch.from_pydict( |
| {'f0': [1], '_ROW_ID': [0], '_SEQUENCE_NUMBER': [1]}, |
| schema=schema |
| ) |
| self.assertFalse(batch.schema.field('_ROW_ID').nullable) |
| self.assertFalse(batch.schema.field('_SEQUENCE_NUMBER').nullable) |
| |
| arrays = list(batch.columns) |
| rebuilt = pa.RecordBatch.from_arrays(arrays, names=batch.schema.names) |
| self.assertTrue(rebuilt.schema.field('_ROW_ID').nullable) |
| self.assertTrue(rebuilt.schema.field('_SEQUENCE_NUMBER').nullable) |
| |
| def test_read_full_schema_on_write_before_evolution(self): |
| from pypaimon.schema.schema_change import SchemaChange |
| from pypaimon.schema.data_types import AtomicType |
| |
| # Step 1: Create table with [f0, f1] |
| initial_schema = pa.schema([ |
| ('f0', pa.int32()), |
| ('f1', pa.string()), |
| ]) |
| schema = Schema.from_pyarrow_schema( |
| initial_schema, |
| options={'row-tracking.enabled': 'true', 'data-evolution.enabled': 'true'}, |
| ) |
| table_name = 'default.test_no_write_cols_schema_evo' |
| self.catalog.create_table(table_name, schema, False) |
| table = self.catalog.get_table(table_name) |
| |
| # Step 2: Write data with ALL columns of old schema → write_cols = None |
| write_builder = table.new_batch_write_builder() |
| table_write = write_builder.new_write() |
| table_commit = write_builder.new_commit() |
| table_write.write_arrow(pa.Table.from_pydict( |
| {'f0': [1, 2], 'f1': ['a', 'b']}, |
| schema=initial_schema, |
| )) |
| cmts = table_write.prepare_commit() |
| for c in cmts: |
| for nf in c.new_files: |
| self.assertIsNone(nf.write_cols) |
| table_commit.commit(cmts) |
| table_write.close() |
| table_commit.close() |
| |
| # Step 3: Schema evolution - add column f2 |
| self.catalog.alter_table( |
| table_name, |
| [SchemaChange.add_column('f2', AtomicType('STRING'))], |
| ) |
| table = self.catalog.get_table(table_name) |
| |
| # Step 4: Write f2 only for same rows (first_row_id = 0) |
| write_builder = table.new_batch_write_builder() |
| table_write = write_builder.new_write().with_write_type(['f2']) |
| table_commit = write_builder.new_commit() |
| table_write.write_arrow(pa.Table.from_pydict( |
| {'f2': ['x', 'y']}, |
| schema=pa.schema([('f2', pa.string())]), |
| )) |
| cmts_f2 = table_write.prepare_commit() |
| for c in cmts_f2: |
| for nf in c.new_files: |
| nf.first_row_id = 0 |
| table_commit.commit(cmts_f2) |
| table_write.close() |
| table_commit.close() |
| |
| # Step 5: Read all columns |
| read_builder = table.new_read_builder() |
| table_scan = read_builder.new_scan() |
| table_read = read_builder.new_read() |
| splits = table_scan.plan().splits() |
| |
| for split in splits: |
| for f in split.files: |
| if f.write_cols is None: |
| f.max_sequence_number = 999999 |
| |
| actual = table_read.to_arrow(splits) |
| |
| expect = pa.Table.from_pydict({ |
| 'f0': [1, 2], |
| 'f1': ['a', 'b'], |
| 'f2': ['x', 'y'], |
| }, schema=pa.schema([ |
| ('f0', pa.int32()), |
| ('f1', pa.string()), |
| ('f2', pa.string()), |
| ])) |
| self.assertEqual(actual.num_rows, 2) |
| self.assertEqual(actual, expect) |
| |
| @unittest.skipIf(sys.version_info < (3, 11), "vortex-data requires Python >= 3.11") |
| def test_vortex_basic(self): |
| """Test basic data evolution read/write with Vortex format.""" |
| pa_schema = pa.schema([ |
| ('f0', pa.int32()), |
| ('f1', pa.string()), |
| ('f2', pa.string()), |
| ]) |
| schema = Schema.from_pyarrow_schema( |
| pa_schema, |
| options={ |
| 'row-tracking.enabled': 'true', |
| 'data-evolution.enabled': 'true', |
| 'file.format': 'vortex', |
| } |
| ) |
| self.catalog.create_table('default.test_vortex_basic', schema, False) |
| table = self.catalog.get_table('default.test_vortex_basic') |
| |
| write_builder = table.new_batch_write_builder() |
| |
| # Commit 1: write f0, f1 |
| w0 = write_builder.new_write().with_write_type(['f0', 'f1']) |
| w1 = write_builder.new_write().with_write_type(['f2']) |
| c = write_builder.new_commit() |
| d0 = pa.Table.from_pydict( |
| {'f0': [1, 2, 3], 'f1': ['a', 'b', 'c']}, |
| schema=pa.schema([('f0', pa.int32()), ('f1', pa.string())])) |
| d1 = pa.Table.from_pydict( |
| {'f2': ['x', 'y', 'z']}, |
| schema=pa.schema([('f2', pa.string())])) |
| w0.write_arrow(d0) |
| w1.write_arrow(d1) |
| cmts = w0.prepare_commit() + w1.prepare_commit() |
| for msg in cmts: |
| for nf in msg.new_files: |
| nf.first_row_id = 0 |
| c.commit(cmts) |
| w0.close() |
| w1.close() |
| c.close() |
| |
| read_builder = table.new_read_builder() |
| actual = read_builder.new_read().to_arrow(read_builder.new_scan().plan().splits()) |
| expected = pa.Table.from_pydict({ |
| 'f0': [1, 2, 3], |
| 'f1': ['a', 'b', 'c'], |
| 'f2': ['x', 'y', 'z'], |
| }, schema=pa_schema) |
| self.assertEqual(actual, expected) |
| |
| @unittest.skipIf(sys.version_info < (3, 11), "vortex-data requires Python >= 3.11") |
| def test_vortex_row_id_filter(self): |
| """Test that Vortex row_indices pushdown works via file_reader_supplier row_ranges.""" |
| pa_schema = pa.schema([ |
| ('f0', pa.int32()), |
| ('f1', pa.string()), |
| ]) |
| schema = Schema.from_pyarrow_schema( |
| pa_schema, |
| options={ |
| 'row-tracking.enabled': 'true', |
| 'data-evolution.enabled': 'true', |
| 'file.format': 'vortex', |
| } |
| ) |
| self.catalog.create_table('default.test_vortex_row_id_filter', schema, False) |
| table = self.catalog.get_table('default.test_vortex_row_id_filter') |
| |
| write_builder = table.new_batch_write_builder() |
| |
| # Commit 1: rows 0-4 |
| w = write_builder.new_write() |
| c = write_builder.new_commit() |
| w.write_arrow(pa.Table.from_pydict( |
| {'f0': list(range(5)), 'f1': [f'v{i}' for i in range(5)]}, |
| schema=pa_schema)) |
| c.commit(w.prepare_commit()) |
| w.close() |
| c.close() |
| |
| # Commit 2: rows 5-9 |
| w = write_builder.new_write() |
| c = write_builder.new_commit() |
| w.write_arrow(pa.Table.from_pydict( |
| {'f0': list(range(5, 10)), 'f1': [f'v{i}' for i in range(5, 10)]}, |
| schema=pa_schema)) |
| c.commit(w.prepare_commit()) |
| w.close() |
| c.close() |
| |
| # Full read |
| rb = table.new_read_builder() |
| full = rb.new_read().to_arrow(rb.new_scan().plan().splits()) |
| self.assertEqual(full.num_rows, 10) |
| |
| # Filter by _ROW_ID using predicate — triggers row_ranges pushdown in Vortex |
| rb_with_rowid = table.new_read_builder().with_projection(['f0', 'f1', '_ROW_ID']) |
| pb = rb_with_rowid.new_predicate_builder() |
| rb_filtered = table.new_read_builder().with_filter(pb.equal('_ROW_ID', 3)) |
| filtered = rb_filtered.new_read().to_arrow(rb_filtered.new_scan().plan().splits()) |
| self.assertEqual(filtered.num_rows, 1) |
| self.assertEqual(filtered.column('f0')[0].as_py(), 3) |
| self.assertEqual(filtered.column('f1')[0].as_py(), 'v3') |
| |
| # Filter by _ROW_ID range spanning two files |
| rb_range = table.new_read_builder().with_filter(pb.between('_ROW_ID', 3, 6)) |
| range_result = rb_range.new_read().to_arrow(rb_range.new_scan().plan().splits()) |
| self.assertEqual(range_result.num_rows, 4) |
| self.assertEqual(sorted(range_result.column('f0').to_pylist()), [3, 4, 5, 6]) |
| |
| @unittest.skipIf(sys.version_info < (3, 11), "vortex-data requires Python >= 3.11") |
| def test_vortex_with_slice(self): |
| """Test with_slice on Vortex data evolution table.""" |
| pa_schema = pa.schema([ |
| ('id', pa.int64()), |
| ('val', pa.int32()), |
| ]) |
| schema = Schema.from_pyarrow_schema( |
| pa_schema, |
| options={ |
| 'row-tracking.enabled': 'true', |
| 'data-evolution.enabled': 'true', |
| 'file.format': 'vortex', |
| 'source.split.target-size': '512m', |
| } |
| ) |
| self.catalog.create_table('default.test_vortex_with_slice', schema, False) |
| table = self.catalog.get_table('default.test_vortex_with_slice') |
| |
| for batch in [ |
| {'id': [1, 2], 'val': [10, 20]}, |
| {'id': [3, 4], 'val': [30, 40]}, |
| {'id': [5, 6], 'val': [50, 60]}, |
| ]: |
| wb = table.new_batch_write_builder() |
| tw = wb.new_write() |
| tc = wb.new_commit() |
| tw.write_arrow(pa.Table.from_pydict(batch, schema=pa_schema)) |
| tc.commit(tw.prepare_commit()) |
| tw.close() |
| tc.close() |
| |
| rb = table.new_read_builder() |
| |
| # Full read |
| full = rb.new_read().to_arrow(rb.new_scan().plan().splits()) |
| self.assertEqual(full.num_rows, 6) |
| |
| # with_slice(1, 4) -> rows at index 1,2,3 -> id in (2,3,4) |
| scan = rb.new_scan().with_slice(1, 4) |
| sliced = rb.new_read().to_arrow(scan.plan().splits()) |
| self.assertEqual(sliced.num_rows, 3) |
| self.assertEqual(sorted(sliced.column('id').to_pylist()), [2, 3, 4]) |
| |
| def test_large_file_read(self): |
| pa_schema = pa.schema([ |
| ('id', pa.int32()), |
| ('name', pa.string()), |
| ]) |
| schema = Schema.from_pyarrow_schema(pa_schema, options={ |
| 'row-tracking.enabled': 'true', |
| 'data-evolution.enabled': 'true', |
| }) |
| self.catalog.create_table('default.test_large_file_row_id', schema, False) |
| table = self.catalog.get_table('default.test_large_file_row_id') |
| |
| # Write >1024 rows in a single file |
| num_rows = 2000 |
| data = pa.Table.from_pydict({ |
| 'id': list(range(num_rows)), |
| 'name': [f'name_{i}' for i in range(num_rows)], |
| }, schema=pa_schema) |
| |
| wb = table.new_batch_write_builder() |
| tw = wb.new_write() |
| tc = wb.new_commit() |
| tw.write_arrow(data) |
| tc.commit(tw.prepare_commit()) |
| tw.close() |
| tc.close() |
| |
| update_ids = list(range(0, 1500)) |
| upsert_data = pa.Table.from_pydict({ |
| 'id': update_ids, |
| 'name': [f'upsert_{i}' for i in update_ids], |
| }, schema=pa_schema) |
| |
| wb = table.new_batch_write_builder() |
| tu = wb.new_update() |
| msgs = tu.upsert_by_arrow_with_key(upsert_data, ['id']) |
| tc = wb.new_commit() |
| tc.commit(msgs) |
| tc.close() |