This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new b63437353c [python] Integrate native REST authorization casts (#10551)
b63437353c is described below

commit b63437353c595e315308c939b5ee574b60040fdc
Author: Jingsong Lee <[email protected]>
AuthorDate: Wed Oct 14 22:48:35 2026 +0800

    [python] Integrate native REST authorization casts (#10551)
---
 .github/workflows/ci-python.yml                    |  17 +-
 .../pypaimon/read/reader/auth_masking_reader.py    |   2 +-
 paimon-python/pypaimon/read/table_read.py          |  35 ++-
 .../pypaimon/tests/native_cast_completion_test.py  | 288 +++++++++++++++++++++
 .../tests/native_schema_evolution_cast_test.py     |  31 +++
 5 files changed, 364 insertions(+), 9 deletions(-)

diff --git a/.github/workflows/ci-python.yml b/.github/workflows/ci-python.yml
index 56e6cbe546..c28becd9f5 100644
--- a/.github/workflows/ci-python.yml
+++ b/.github/workflows/ci-python.yml
@@ -183,7 +183,7 @@ jobs:
       - name: Install system dependencies
         run: |
           apt-get update && apt-get install -y --no-install-recommends \
-            build-essential git curl cmake pkg-config libssl-dev libclang-dev \
+            build-essential git curl cmake pkg-config libssl-dev libclang-dev 
tzdata \
             && rm -rf /var/lib/apt/lists/*
 
       - name: Install Rust toolchain
@@ -227,6 +227,21 @@ jobs:
         run: |
           bash paimon-python/dev/lint-python.sh -i pytest
 
+      - name: Verify native temporal casts in non-UTC system zones
+        shell: bash
+        env:
+          PYPAIMON_TEST_NATIVE_PLAN: '1'
+          PYPAIMON_TEST_NATIVE_READ: '1'
+          PYPAIMON_TEST_NATIVE_WRITE: '1'
+          PYPAIMON_TEST_NATIVE_COMMIT: '1'
+        run: |
+          for zone in Asia/Shanghai America/New_York; do
+            TZ="$zone" python -m pytest -q \
+              paimon-python/pypaimon/tests/native_cast_completion_test.py \
+              paimon-python/pypaimon/tests/native_temporal_zone_cast_test.py \
+              -k 'TIMESTAMP_LTZ or temporal'
+          done
+
   torch:
     name: PyTorch / Python 3.10
     timeout-minutes: 60
diff --git a/paimon-python/pypaimon/read/reader/auth_masking_reader.py 
b/paimon-python/pypaimon/read/reader/auth_masking_reader.py
index f34dbc748a..5c6873052a 100644
--- a/paimon-python/pypaimon/read/reader/auth_masking_reader.py
+++ b/paimon-python/pypaimon/read/reader/auth_masking_reader.py
@@ -221,7 +221,7 @@ def apply_auth_masking(batch: pa.RecordBatch, parsed_rules: 
Dict[str, dict]):
         original_field = original_batch.schema.field(col_idx)
         batch = batch.set_column(
             col_idx,
-            pa.field(original_field.name, masked_array.type, nullable=True),
+            original_field.with_type(masked_array.type).with_nullable(True),
             masked_array)
     return batch
 
diff --git a/paimon-python/pypaimon/read/table_read.py 
b/paimon-python/pypaimon/read/table_read.py
index a7188cd9c9..537c2ae7b2 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -40,7 +40,8 @@ from pypaimon.read.reader.limited_record_reader import 
LimitedRecordBatchReader
 from pypaimon.read.reader.filter_record_batch_reader import 
FilterRecordBatchReader
 from pypaimon.read.split import Split
 from pypaimon.read.variant_read_type import has_variant_extractions
-from pypaimon.read.read_type import (adapter_output_paths, extract_array, 
output_schema, reader_adapter)
+from pypaimon.read.read_type import (
+    OutputProjection, adapter_output_paths, extract_array, output_schema, 
reader_adapter)
 from pypaimon.read.split_read import (DataEvolutionSplitRead,
                                       MergeFileSplitRead, RawFileSplitRead,
                                       SplitRead, deferred_blob_field_names)
@@ -294,7 +295,7 @@ class TableRead:
             splits: List[Split],
             blob_parallelism: Optional[int] = None,
             parallelism: Optional[int] = None):
-        schema = self._output_arrow_schema()
+        schema = self._output_arrow_schema(splits)
         if self.include_row_kind:
             schema = self._add_row_kind_to_schema(schema)
         effective = self._effective_parallelism(parallelism, len(splits))
@@ -392,7 +393,7 @@ class TableRead:
         """
         effective_bp = self._resolve_blob_parallelism(blob_parallelism)
         effective = self._effective_parallelism(parallelism, len(splits))
-        schema = self._output_arrow_schema()
+        schema = self._output_arrow_schema(splits)
         if self.include_row_kind:
             schema = self._add_row_kind_to_schema(schema)
 
@@ -462,8 +463,23 @@ class TableRead:
         extra_names = {field.name for field in projected[0]} - {field.name for 
field in adapter[0]}
         return projected if predicate_field_names(self.predicate) & 
extra_names else adapter
 
-    def _output_arrow_schema(self):
-        return 
output_schema(PyarrowFieldParser.from_paimon_schema(self.read_type), 
self.output_projection)
+    def _output_arrow_schema(self, splits=None):
+        schema = PyarrowFieldParser.from_paimon_schema(self.read_type)
+        masks = set()
+        if splits:
+            for split in splits:
+                if isinstance(split, QueryAuthSplit) and split.auth_result is 
not None:
+                    masks.update(split.auth_result.column_masking or {})
+        elif self.table.options.query_auth_enabled:
+            # Before a plan or for an empty plan, the grant is unknown. Masks
+            # may null any top-level column, including a declared NOT NULL one.
+            masks.update(schema.names)
+        if masks:
+            schema = pyarrow.schema([
+                field.with_nullable(True) if field.name in masks else field
+                for field in schema
+            ], metadata=schema.metadata)
+        return output_schema(schema, self.output_projection)
 
     @staticmethod
     def _apply_output_projection_to_schema(schema, projection):
@@ -1646,13 +1662,18 @@ class TableRead:
             _, paths = self._reader_adapter()
             outputs = adapter_output_paths(self.output_projection, 
self._adapter_read_type, paths)
             arrays = [extract_array(batch, path) for _, path in outputs]
-            fields = list(self._output_arrow_schema())
+            # Project the already masked schema, retaining captured 
nullability.
+            adapted_projection = OutputProjection([(alias, path) for alias, 
path in outputs])
+            actual = output_schema(batch.schema, adapted_projection)
+            declared = 
output_schema(PyarrowFieldParser.from_paimon_schema(self.read_type), 
self.output_projection)
+            fields = [field.with_nullable(actual[i].nullable) for i, field in 
enumerate(declared)]
             if self.include_row_kind and ROW_KIND_COLUMN in batch.schema.names:
                 arrays.insert(0, batch.column(ROW_KIND_COLUMN))
                 fields.insert(0, batch.schema.field(ROW_KIND_COLUMN))
             if not arrays:
                 return batch.select([])
-            return pyarrow.RecordBatch.from_arrays(arrays, 
schema=pyarrow.schema(fields))
+            return pyarrow.RecordBatch.from_arrays(
+                arrays, schema=pyarrow.schema(fields, 
metadata=batch.schema.metadata))
         if not self._needs_output_projection():
             return batch
         output_names = list(self._output_column_names)
diff --git a/paimon-python/pypaimon/tests/native_cast_completion_test.py 
b/paimon-python/pypaimon/tests/native_cast_completion_test.py
new file mode 100644
index 0000000000..f047c79937
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_cast_completion_test.py
@@ -0,0 +1,288 @@
+# 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.
+
+"""REST authorization is executed by Rust; Python only adapts result schemas.
+
+These are representative Java results for the Python-to-Rust bridge. The full
+CAST capability matrix and generated Java vectors live in paimon-rust.
+"""
+
+import json
+from contextlib import contextmanager
+from datetime import datetime, timezone
+from decimal import Decimal
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import Schema
+from pypaimon.api.api_response import AuthTableQueryResponse
+from pypaimon.common.predicate_builder import PredicateBuilder
+from pypaimon.schema.data_types import DataTypeParser, PyarrowFieldParser
+from pypaimon.schema.schema_change import SchemaChange
+from pypaimon.tests import native_query_auth_test
+
+rest_catalog = native_query_auth_test.rest_catalog
+pytestmark = native_query_auth_test.pytestmark
+
+
[email protected](params=[
+    ('append', {}), ('primary-key', {'bucket': '1'}),
+    ('data-evolution', {'row-tracking.enabled': 'true', 
'data-evolution.enabled': 'true'}),
+], ids=lambda mode: mode[0])
+def table_mode(request):
+    return request.param
+
+
+@contextmanager
+def native_only():
+    with patch('pypaimon.read.table_read.TableRead._create_split_read',
+               side_effect=AssertionError('Native read fell back')), \
+            
patch('pypaimon.common.predicate_json_parser._apply_predicate_transform',
+                  side_effect=AssertionError('Python executed an authorization 
transform')), \
+            
patch('pypaimon.read.reader.auth_masking_reader._apply_predicate_transform',
+                  side_effect=AssertionError('Python executed an authorization 
mask')):
+        yield
+
+
+def _read(table, projection=None, predicate=None, limit=None):
+    with native_only():
+        return native_query_auth_test._read(table, True, projection, 
predicate, limit)
+
+
+def _create_table(rest_catalog, table_mode, schema, data, logical_types=None):
+    catalog, server = rest_catalog
+    mode, options = table_mode
+    definition = Schema.from_pyarrow_schema(
+        schema, primary_keys=['id'] if mode == 'primary-key' else None, 
options=options)
+    for field in definition.fields:
+        if logical_types and field.name in logical_types:
+            field.type = 
DataTypeParser.parse_data_type(logical_types[field.name])
+    catalog.create_table('default.cast', definition, False)
+    table = catalog.get_table('default.cast')
+    builder = table.new_batch_write_builder()
+    writer, commit = builder.new_write(), builder.new_commit()
+    try:
+        writer.write_arrow(data)
+        commit.commit(writer.prepare_commit())
+    finally:
+        writer.close()
+        commit.close()
+    catalog.alter_table(table.identifier, 
[SchemaChange.set_option('query-auth.enabled', 'true')])
+    return catalog.get_table('default.cast'), server
+
+
+def _case(source, target, values, expected, original=None):
+    return source, target, values, expected, original or source
+
+
+# Cover each executor family and original-rule rebinding through actual REST
+# plans and Parquet reads. No Python CAST implementation computes expectations.
+# Keep the process zone unchanged: Rust caches its system-zone lookup, while
+# Python's tzset() would only change libc. These epoch cases have no DST gap or
+# overlap; expectations use the system offset rather than assuming UTC.
+_EPOCH_OFFSET_MS = 
int(datetime.fromtimestamp(0).astimezone().utcoffset().total_seconds()) * 1000
+_LTZ_INSTANTS = [datetime(2024, 1, 1, tzinfo=timezone.utc), datetime(2024, 7, 
1, tzinfo=timezone.utc)]
+
+_CASES = [
+    _case('BIGINT', 'TINYINT', [128, -129, 4294967297, None], [-128, 127, 1, 
None]),
+    _case('DOUBLE', 'TINYINT', [-129.9, 1e30, None], [127, -1, None]),
+    _case('FLOAT', 'INT', [1.9, -1.9, 1e30, None], [1, -1, 2147483647, None]),
+    _case('DOUBLE', 'BIGINT', [1e30, -1e30, None], [(1 << 63) - 1, -(1 << 63), 
None]),
+    _case('BOOLEAN', 'INT', [True, False, None], [1, 0, None]),
+    _case('INT', 'BOOLEAN', [0, -1, None], [False, True, None]),
+    _case('STRING', 'BOOLEAN', ['yes', 'FALSE', None], [True, False, None]),
+    _case('BOOLEAN', 'STRING', [True, False, None], ['true', 'false', None]),
+    _case('STRING', 'INT', ['1.9', '-2.5', None], [1, -2, None]),
+    _case('STRING', 'FLOAT', ['0x1.8p1', '1.25f', None], [3.0, 1.25, None]),
+    _case('STRING', 'DOUBLE', ['0x1.8p1', ' 2.5D ', None], [3.0, 2.5, None]),
+    _case('FLOAT', 'STRING', [1e23, 0.0001, None], ['1.0E23', '1.0E-4', None]),
+    _case('DOUBLE', 'STRING', [1e23, None], ['9.999999999999999E22', None]),
+    _case('FLOAT', 'DECIMAL(4,2)', [1.005, 99.995, None], [Decimal('1.00'), 
None, None]),
+    _case('DOUBLE', 'DECIMAL(4,2)', [1.005, 99.995, None], [Decimal('1.01'), 
None, None]),
+    _case('STRING', 'DECIMAL(4,2)', ['1.005', '99.995', None], 
[Decimal('1.01'), None, None]),
+    _case('DECIMAL(18,1)', 'DOUBLE', [Decimal('900719925474099.3'), None], 
[900719925474099.2, None]),
+    _case('DECIMAL(38,1)', 'DOUBLE', [Decimal('900719925474099.5'), None], 
[900719925474099.5, None]),
+    _case('DECIMAL(38,1)', 'BIGINT', [Decimal('9223372036854775808.9'), None], 
[-(1 << 63), None]),
+    _case('DECIMAL(38,10)', 'STRING', [Decimal('0.0000000001'), None], 
['0.0000000001', None]),
+    _case('STRING', 'CHAR(3)', ['πŸ™‚δΈ­', 'a', None], ['πŸ™‚δΈ­ ', 'a  ', None]),
+    _case('STRING', 'BINARY(3)', ['ab', None], [b'ab\x00', None]),
+    _case('STRING', 'VARBINARY(1)', ['πŸ™‚', None], [b'\xf0', None]),
+    _case('BYTES', 'VARCHAR(3)', [b'abcdef', None], ['abcdef', None]),
+    _case('DATE', 'STRING', [0, -1, None], ['1970-01-01', '1969-12-31', None]),
+    _case('STRING', 'DATE', ['1970-01-02', '-1', None], [1, -1, None]),
+    _case('TIME(1)', 'STRING', [101, -1, None], ['00:00:00.1', '23:59:59.9', 
None]),
+    _case('DATE', 'TIMESTAMP(3)', [0, -1, None], [0, -86400000, None]),
+    _case('BIGINT', 'TIMESTAMP(3)', [1, -1, None], [1000, -1000, None]),
+    _case('STRING', 'TIMESTAMP(1)', ['1970-01-01 00:00:00.123456', None], 
[100, None]),
+    _case('STRING', 'TIMESTAMP_LTZ(3)', ['1970-01-01 00:00:00.001', None], [1 
- _EPOCH_OFFSET_MS, None]),
+    _case('DATE', 'TIMESTAMP_LTZ(3)', [0, -1, None], [-_EPOCH_OFFSET_MS, 
-86400000 - _EPOCH_OFFSET_MS, None]),
+    _case('BIGINT', 'TIMESTAMP_LTZ(3)', [0, 1, -1, None],
+          [_EPOCH_OFFSET_MS, 1000 + _EPOCH_OFFSET_MS, -1000 + 
_EPOCH_OFFSET_MS, None]),
+    _case('TIMESTAMP_LTZ(3)', 'STRING', [int(value.timestamp()) * 1000 for 
value in _LTZ_INSTANTS] + [None],
+          [value.astimezone().strftime('%Y-%m-%d %H:%M:%S') + '.000' for value 
in _LTZ_INSTANTS] + [None]),
+    _case('TIMESTAMP(3)', 'DATE', [-1, 86400000, None], [-1, 1, None]),
+    _case('TIMESTAMP(3)', 'TIME(0)', [123, -1, None], [123, 86399999, None]),
+    _case('TIMESTAMP(3)', 'BIGINT', [1999, -1999, None], [1, -2, None]),
+    _case('TIMESTAMP_LTZ(3)', 'BIGINT', [1999, -1999, None], [1, -2, None]),
+    _case('TIMESTAMP(3)', 'TIMESTAMP_LTZ(6)', [1, -1, None],
+          [(1 - _EPOCH_OFFSET_MS) * 1000, (-1 - _EPOCH_OFFSET_MS) * 1000, 
None]),
+    _case('TIMESTAMP_LTZ(6)', 'TIMESTAMP(1)', [123456, -1, None],
+          [100 + _EPOCH_OFFSET_MS, -100 + _EPOCH_OFFSET_MS, None]),
+    _case('DOUBLE', 'DECIMAL(4,2)', [1.9, -1.9, None], [Decimal('1.00'), 
Decimal('-1.00'), None], 'INT'),
+    _case('DOUBLE', 'STRING', [1.0, None], ['1.0', None], 'INT'),
+    _case('TIMESTAMP(3)', 'STRING', [0, -1, None],
+          ['1970-01-01T00:00', '1969-12-31T23:59:59.999', None], 'INT'),
+    _case('TIMESTAMP_LTZ(3)', 'STRING', [1, None], ['1970-01-01 
00:00:00.001000', None], 'TIMESTAMP(6)'),
+]
+
+
[email protected]('case', _CASES, ids=lambda case: '%s_to_%s_from_%s' % 
(case[0], case[1], case[4]))
+def test_authorization_casts_execute_in_rust(rest_catalog, table_mode, case):
+    source, target, values, expected, original = case
+    schema = pa.schema([
+        ('id', pa.int32()),
+        ('v', 
PyarrowFieldParser.from_paimon_type(DataTypeParser.parse_data_type(source))),
+        ('masked', 
PyarrowFieldParser.from_paimon_type(DataTypeParser.parse_data_type(target))),
+    ])
+    count = len(values)
+    data = pa.Table.from_arrays([pa.array(range(count), type=pa.int32()), 
pa.array(values, type=schema.field('v').type),
+                                pa.nulls(count, 
type=schema.field('masked').type)], schema=schema)
+    table, server = _create_table(rest_catalog, table_mode, schema, data, 
{'v': source, 'masked': target})
+    transform = {'name': 'CAST', 'fieldRef': {'name': 'v', 'index': 99, 
'type': original}, 'type': target}
+    grant_key = table.identifier.get_full_name()
+    server.table_query_auth_store[grant_key] = 
AuthTableQueryResponse(column_masking={'masked': json.dumps(transform)})
+    result = _read(table, ['id', 'masked']).sort_by([('id', 'ascending')])
+    assert result.column('masked').combine_chunks().equals(pa.array(expected, 
type=schema.field('masked').type))
+    non_null = [i for i, value in enumerate(expected) if value is not None]
+    predicate = PredicateBuilder(table.fields).is_not_null('masked')
+    assert _read(table, ['id'], predicate, limit=1).to_pylist() == [{'id': 
non_null[0]}]
+    rule = {'kind': 'LEAF', 'transform': transform, 'function': 'IS_NOT_NULL', 
'literals': []}
+    server.table_query_auth_store[grant_key] = 
AuthTableQueryResponse(filter=[json.dumps(rule)])
+    assert _read(table, ['id']).sort_by([('id', 'ascending')]).to_pylist() == 
[{'id': i} for i in non_null]
+
+
[email protected]('mask', ['null', 'overflow'])
+def test_not_null_mask_adapts_only_output_schema(rest_catalog, table_mode, 
mask):
+    target = pa.string() if mask == 'null' else pa.decimal128(4, 2)
+    schema = pa.schema([pa.field('id', pa.int32(), nullable=False), 
pa.field('v', pa.int32()),
+                        pa.field('masked', target, nullable=False)])
+    data = pa.Table.from_arrays([pa.array([0, 1], type=pa.int32()), 
pa.array([1, 100], type=pa.int32()),
+                                pa.array(['secret'] * 2 if mask == 'null' else 
[Decimal('0')] * 2,
+                                         type=target)], schema=schema)
+    table, server = _create_table(rest_catalog, table_mode, schema, data)
+    transform = ({'name': 'NULL'} if mask == 'null' else {
+        'name': 'CAST', 'fieldRef': {'name': 'v', 'type': 'INT'}, 'type': 
'DECIMAL(4,2)'})
+    server.table_query_auth_store[table.identifier.get_full_name()] = 
AuthTableQueryResponse(
+        column_masking={'masked': json.dumps(transform)})
+    expected = [None, None] if mask == 'null' else [Decimal('1.00'), None]
+    result = _read(table, ['masked', 'id']).sort_by([('id', 'ascending')])
+    assert result.column('masked').to_pylist() == expected
+    assert result.schema.field('masked').nullable and not 
result.schema.field('id').nullable
+    assert _read(table, ['masked'], limit=0).schema.field('masked').nullable
+    projection = {'first': 'masked', 'second': 'masked', 'id': 'id'}
+    result = _read(table, projection).sort_by([('id', 'ascending')])
+    assert result.column('first').to_pylist() == expected == 
result.column('second').to_pylist()
+    assert result.schema.field('first').nullable and 
result.schema.field('second').nullable
+    builder = table.copy({'read.native.enabled': 'true', 
'scan.native-plan.enabled': 'true'}).new_read_builder()
+    builder.with_projection(['masked', 'id'])
+    splits = builder.new_scan().plan().splits()
+    reader = builder.new_read()
+    assert not reader.read_type[0].type.nullable
+    with native_only(), reader.to_arrow_batch_reader(splits) as batches:
+        batch = batches.read_next_batch()
+        assert batches.schema.equals(batch.schema, check_metadata=True)
+        assert batch.column('masked').to_pylist() == expected
+    if mask == 'null':
+        # Captured rules must still determine output nullability on the 
existing
+        # fallback reader; no additional Python CAST implementation is needed.
+        fallback = table.copy({'query-auth.enabled': 'false', 
'read.native.enabled': 'false',
+                               'scan.native-plan.enabled': 'false'})
+        captured = fallback.new_read_builder().with_projection(['masked', 
'id'])
+        with captured.new_read().to_arrow_batch_reader(splits) as batches:
+            batch = batches.read_next_batch()
+            assert batches.schema.equals(batch.schema, check_metadata=True)
+            assert batch.column('masked').to_pylist() == [None, None]
+
+
[email protected]('target,mask_type,values,expected', [
+    (pa.decimal128(4, 2), 'DECIMAL(6,3)', ['2.670', '1000'], [Decimal('2.67'), 
None]),
+    (pa.timestamp('ms'), 'TIMESTAMP(6)', ['1000', '-1000'], [1, -1]),
+])
+def test_rust_adapts_mask_storage_without_loss(rest_catalog, table_mode, 
target, mask_type, values, expected):
+    schema = pa.schema([('id', pa.int32()), ('v', pa.string()), ('masked', 
target)])
+    data = pa.Table.from_arrays([pa.array([0, 1], type=pa.int32()), 
pa.array(values),
+                                pa.nulls(2, type=target)], schema=schema)
+    table, server = _create_table(rest_catalog, table_mode, schema, data)
+    transform = {'name': 'CAST', 'fieldRef': {'name': 'v', 'type': 'STRING'}, 
'type': mask_type}
+    server.table_query_auth_store[table.identifier.get_full_name()] = 
AuthTableQueryResponse(
+        column_masking={'masked': json.dumps(transform)})
+    result = _read(table, ['id', 'masked']).sort_by([('id', 'ascending')])
+    assert result.column('masked').combine_chunks().equals(pa.array(expected, 
type=target))
+
+
[email protected]('target,mask_type,value', [
+    (pa.decimal128(4, 2), 'DECIMAL(6,3)', '2.675'),
+    (pa.decimal128(4, 2), 'DECIMAL(7,3)', '1000'),
+    (pa.timestamp('ms'), 'TIMESTAMP(6)', '1001'),
+])
+def test_rust_rejects_lossy_mask_output(rest_catalog, table_mode, target, 
mask_type, value):
+    schema = pa.schema([('id', pa.int32()), ('v', pa.string()), ('masked', 
target)])
+    table, server = _create_table(rest_catalog, table_mode, schema, 
pa.Table.from_arrays([
+        pa.array([0], type=pa.int32()), pa.array([value]), pa.nulls(1, 
type=target),
+    ], schema=schema))
+    transform = {'name': 'CAST', 'fieldRef': {'name': 'v', 'type': 'STRING'}, 
'type': mask_type}
+    server.table_query_auth_store[table.identifier.get_full_name()] = 
AuthTableQueryResponse(
+        column_masking={'masked': json.dumps(transform)})
+    with pytest.raises((RuntimeError, ValueError, pa.ArrowException)):
+        _read(table, ['masked'])
+
+
[email protected]('function', ['AND', 'OR', 'entries'])
+def test_rust_short_circuits_authorization_cast_errors(rest_catalog, 
table_mode, function):
+    schema = pa.schema([('id', pa.int32()), ('v', pa.string()), ('masked', 
pa.int32())])
+    data = pa.table({'id': [0, 1, 2], 'v': ['bad', '1.9', None], 'masked': [0] 
* 3}, schema=schema)
+    table, server = _create_table(rest_catalog, table_mode, schema, data)
+    guard = {'kind': 'LEAF', 'function': 'EQUAL', 'literals': [0 if function 
== 'OR' else 1],
+             'transform': {'name': 'FIELD_REF', 'fieldRef': {'name': 'id', 
'type': 'INT'}}}
+    cast = {'kind': 'LEAF', 'function': 'EQUAL', 'literals': [1], 'transform': 
{
+        'name': 'CAST', 'fieldRef': {'name': 'v', 'type': 'STRING'}, 'type': 
'INT'}}
+    filters = [guard, cast] if function == 'entries' else [
+        {'kind': 'COMPOUND', 'function': function, 'children': [guard, cast]}]
+    server.table_query_auth_store[table.identifier.get_full_name()] = 
AuthTableQueryResponse(
+        filter=[json.dumps(rule) for rule in filters])
+    result = _read(table, ['id']).sort_by([('id', 'ascending')]).to_pylist()
+    assert result == ([{'id': 0}, {'id': 1}] if function == 'OR' else [{'id': 
1}])
+
+
[email protected]('guard', [True, False])
+def test_rust_checks_mask_class_after_authorization_filter(rest_catalog, 
table_mode, guard):
+    schema = pa.schema([('id', pa.int32()), ('v', pa.string()), ('masked', 
pa.int32())])
+    data = pa.table({'id': [0, 1], 'v': [None, 'bad'], 'masked': [1, 2]}, 
schema=schema)
+    table, server = _create_table(rest_catalog, table_mode, schema, data)
+    transform = {'name': 'CAST', 'fieldRef': {'name': 'v', 'type': 'STRING'}, 
'type': 'STRING'}
+    filter_ = {'kind': 'LEAF', 'function': 'IS_NULL', 'literals': [],
+               'transform': {'name': 'FIELD_REF', 'fieldRef': {'name': 'v', 
'type': 'STRING'}}}
+    server.table_query_auth_store[table.identifier.get_full_name()] = 
AuthTableQueryResponse(
+        filter=[json.dumps(filter_)] if guard else None, 
column_masking={'masked': json.dumps(transform)})
+    if guard:
+        assert _read(table, ['id', 'masked']).to_pylist() == [{'id': 0, 
'masked': None}]
+    else:
+        with pytest.raises((RuntimeError, ValueError, pa.ArrowException)):
+            _read(table, ['masked'])
+    assert _read(table, ['masked'], limit=0).num_rows == 0
diff --git a/paimon-python/pypaimon/tests/native_schema_evolution_cast_test.py 
b/paimon-python/pypaimon/tests/native_schema_evolution_cast_test.py
index 881983b779..55f7fe6f90 100644
--- a/paimon-python/pypaimon/tests/native_schema_evolution_cast_test.py
+++ b/paimon-python/pypaimon/tests/native_schema_evolution_cast_test.py
@@ -20,9 +20,12 @@
 from contextlib import ExitStack
 from datetime import datetime, timezone
 from decimal import Decimal
+import os
 from unittest.mock import patch
+from urllib.parse import unquote, urlparse
 
 import pyarrow as pa
+import pyarrow.parquet as pq
 import pytest
 
 from pypaimon import Schema
@@ -85,6 +88,34 @@ def _read(table, native, predicate=None, projection=None):
     return result.sort_by([('id', 'ascending')])
 
 
+def 
test_large_utf8_old_parquet_reads_as_integer_without_native_fallback(rest_catalog):
+    catalog, _ = rest_catalog
+    _table(catalog, 'append', pa.string(), ['1.9', '-2.5', None])
+    catalog.alter_table('default.t', [SchemaChange.update_column_type('v', 
AtomicType('INT'))], False)
+    table = catalog.get_table('default.t')
+    # Plan once before rewriting the disposable old file. Preserve its logical
+    # STRING schema while storing the supported LargeUtf8 Arrow representation.
+    planning_table = table.copy({'scan.native-plan.enabled': 'false', 
'read.native.enabled': 'false'})
+    splits = planning_table.new_read_builder().new_scan().plan().splits()
+    assert splits
+    for split in splits:
+        for meta in split.files:
+            path = meta.file_path
+            if path.startswith('file:'):
+                path = unquote(urlparse(path).path)
+            physical = pq.read_table(path)
+            index = physical.schema.get_field_index('v')
+            physical = physical.set_column(index, 'v', 
physical.column('v').cast(pa.large_string()))
+            pq.write_table(physical, path)
+            meta.file_size = os.stat(path).st_size
+            assert pq.read_schema(path).field('v').type == pa.large_string()
+    reader = table.copy({'read.native.enabled': 
'true'}).new_read_builder().new_read()
+    with patch('pypaimon.read.table_read.TableRead._create_split_read',
+               side_effect=AssertionError('Native read fell back')):
+        result = reader.to_arrow(splits).sort_by([('id', 'ascending')])
+    assert result.column('v').to_pylist() == [1, -2, None]
+
+
 _TEMPORAL_STRING_CASES = [
     (pa.time32('ms'), [0, 100, 101, 12345678, None],
      ['00:00:00.0', '00:00:00.1', '00:00:00.101', '03:25:45.678', None]),

Reply via email to