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 4e1116627f [python] Widen the read with the columns query auth rules 
need (#10033)
4e1116627f is described below

commit 4e1116627f51eb8ba3c662d166fca12ddbbab0a8
Author: Jiajia Li <[email protected]>
AuthorDate: Mon Sep 21 15:05:33 2026 +0800

    [python] Widen the read with the columns query auth rules need (#10033)
---
 paimon-python/pypaimon/catalog/table_query_auth.py |  39 +++--
 paimon-python/pypaimon/read/table_read.py          |  43 +++--
 .../pypaimon/tests/deferred_blob_resolve_test.py   |   4 +-
 .../pypaimon/tests/query_auth_widening_test.py     | 191 +++++++++++++++++++++
 .../pypaimon/tests/table_query_auth_test.py        |  10 +-
 5 files changed, 249 insertions(+), 38 deletions(-)

diff --git a/paimon-python/pypaimon/catalog/table_query_auth.py 
b/paimon-python/pypaimon/catalog/table_query_auth.py
index 07cb260e75..fc82a07ffc 100644
--- a/paimon-python/pypaimon/catalog/table_query_auth.py
+++ b/paimon-python/pypaimon/catalog/table_query_auth.py
@@ -284,21 +284,34 @@ class TableQueryAuthResult:
                     "nested field '{}'; the second copy would be raw."
                     .format(path[0], '.'.join(path)))
 
-    def get_extra_fields_for_filter(
+    def get_extra_fields(
             self,
             read_fields: List[DataField],
             table_fields: List[DataField],
     ) -> List[DataField]:
-        if not self.filter:
+        """Columns the rules read that the projection does not carry: every 
filter operand,
+        and the inputs of every mask whose target is readable. A mask whose 
target stays
+        outside the projection is inert and widens nothing."""
+        if not self.has_restrictions:
             return []
-        read_field_names = {f.name for f in read_fields}
-        extra = []
-        for json_str in self.filter:
-            referenced = extract_referenced_fields(json_str)
-            for name in referenced:
-                if name not in read_field_names:
-                    field = next((f for f in table_fields if f.name == name), 
None)
-                    if field:
-                        extra.append(field)
-                        read_field_names.add(name)
-        return extra
+        projected = {f.name for f in read_fields}
+        masking = self.parsed_column_masking()
+        readable = set(projected)
+        pending = list(readable)
+        needed = set()
+        for operand in self.filter_field_names():
+            needed.add(operand)
+            if operand not in readable:
+                readable.add(operand)
+                pending.append(operand)
+        while pending:
+            transform = masking.get(pending.pop())
+            if transform is None:
+                continue
+            for name in _collect_all_field_refs_from_transform(transform):
+                needed.add(name)
+                if name not in readable:
+                    readable.add(name)
+                    pending.append(name)
+        by_name = {f.name: f for f in table_fields}
+        return [by_name[name] for name in sorted(needed - projected) if name 
in by_name]
diff --git a/paimon-python/pypaimon/read/table_read.py 
b/paimon-python/pypaimon/read/table_read.py
index 34bed8aa97..0215d8eec5 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -1399,17 +1399,23 @@ class TableRead:
         ) if push_down_limit else None
         effective_read_type = read_type if read_type is not None else 
self.read_type
         scan_read_type = self._with_predicate_extra_fields(read_type) if 
read_type is not None else self._scan_read_type
+        # the columns authorization widens the read with are plain top-level 
ones, so each
+        # gets a one-segment path; without them the outer extraction sees 
fewer paths than fields
+        nested_name_paths = self.nested_name_paths
+        if nested_name_paths and len(nested_name_paths) < 
len(effective_read_type):
+            nested_name_paths = list(nested_name_paths) + [
+                [f.name] for f in effective_read_type[len(nested_name_paths):]]
         if self.table.is_primary_key_table and (
                 getattr(split, 'is_streaming', False) or not 
split.raw_convertible):
             inner_read_type = scan_read_type
             outer_extract_name_paths: Optional[List[List[str]]] = None
-            if self.nested_name_paths and any(
-                    len(p) > 1 for p in self.nested_name_paths):
+            if nested_name_paths and any(
+                    len(p) > 1 for p in nested_name_paths):
                 # Inner: full ROW for the merge function. Outer: extract
                 # the requested sub-paths back to the user's flat schema.
                 inner_read_type = self._with_predicate_extra_fields(
-                    self._widen_to_top_level_for_merge())
-                outer_extract_name_paths = self.nested_name_paths
+                    self._widen_to_top_level_for_merge(nested_name_paths))
+                outer_extract_name_paths = nested_name_paths
 
             # When the user's projection drops a ``sequence.field``, the merge
             # heap can't compare it. Inject the missing sequence field(s) into
@@ -1450,15 +1456,15 @@ class TableRead:
                 limit=effective_limit,
             )
         elif self.table.options.data_evolution_enabled():
-            if self.nested_name_paths and any(
-                    len(p) > 1 for p in self.nested_name_paths):
+            if nested_name_paths and any(
+                    len(p) > 1 for p in nested_name_paths):
                 if not self._only_map_key_nested_paths():
                     raise NotImplementedError(
                         "ROW nested-field projection on data-evolution tables "
                         "is not yet supported")
                 scan_read_type = self._with_predicate_extra_fields(
-                    self._widen_to_top_level_for_merge())
-                outer_extract_name_paths = self.nested_name_paths
+                    self._widen_to_top_level_for_merge(nested_name_paths))
+                outer_extract_name_paths = nested_name_paths
             else:
                 outer_extract_name_paths = None
             if (outer_extract_name_paths is None
@@ -1473,7 +1479,7 @@ class TableRead:
                 row_tracking_enabled=True,
                 nested_name_paths=(
                     None if outer_extract_name_paths
-                    else self.nested_name_paths),
+                    else nested_name_paths),
                 outer_extract_name_paths=outer_extract_name_paths,
                 outer_flat_read_type=(
                     self.read_type if outer_extract_name_paths else None),
@@ -1485,16 +1491,16 @@ class TableRead:
         else:
             inner_read_type = scan_read_type
             outer_extract_name_paths: Optional[List[List[str]]] = None
-            if self.nested_name_paths and any(
-                    len(p) > 1 for p in self.nested_name_paths):
+            if nested_name_paths and any(
+                    len(p) > 1 for p in nested_name_paths):
                 # Mirror the merge path: read the full top-level columns so
                 # the per-file field-id normalization applies (a leaf path is
                 # only valid against the latest schema, not each file's own
                 # names/types), then extract the requested sub-paths back to
                 # the user's flat schema.
                 inner_read_type = self._with_predicate_extra_fields(
-                    self._widen_to_top_level_for_merge())
-                outer_extract_name_paths = self.nested_name_paths
+                    self._widen_to_top_level_for_merge(nested_name_paths))
+                outer_extract_name_paths = nested_name_paths
             if read_type is None and outer_extract_name_paths is None and 
self._needs_output_projection():
                 outer_extract_name_paths = self._output_extract_name_paths()
             return RawFileSplitRead(
@@ -1551,15 +1557,16 @@ class TableRead:
             fields = SpecialFields.row_type_with_row_tracking(fields)
         return fields
 
-    def _widen_to_top_level_for_merge(self) -> List[DataField]:
-        """Unique top-level fields from ``self.nested_name_paths``, in path 
order."""
+    def _widen_to_top_level_for_merge(self, name_paths=None) -> 
List[DataField]:
+        """Unique top-level fields from ``name_paths``, in path order."""
+        paths = self.nested_name_paths if name_paths is None else name_paths
         table_fields_by_name = {f.name: f for f in self._table_read_fields()}
         paths_by_top = {}
-        for path in self.nested_name_paths or []:
+        for path in paths or []:
             paths_by_top.setdefault(path[0], []).append(path)
         seen = set()
         widened: List[DataField] = []
-        for path in self.nested_name_paths or []:
+        for path in paths or []:
             top_name = path[0]
             if top_name in seen:
                 continue
@@ -1650,7 +1657,7 @@ class TableRead:
         auth_result.validate_read_type(
             latest_fields, read_fields, self.nested_name_paths, table_fields)
 
-        extra_fields = auth_result.get_extra_fields_for_filter(read_fields, 
table_fields)
+        extra_fields = auth_result.get_extra_fields(read_fields, table_fields)
         effective_read_type = read_fields
         if extra_fields:
             effective_read_type = read_fields + extra_fields
diff --git a/paimon-python/pypaimon/tests/deferred_blob_resolve_test.py 
b/paimon-python/pypaimon/tests/deferred_blob_resolve_test.py
index 83d47fdc70..e23ff0e8df 100644
--- a/paimon-python/pypaimon/tests/deferred_blob_resolve_test.py
+++ b/paimon-python/pypaimon/tests/deferred_blob_resolve_test.py
@@ -69,7 +69,7 @@ class _RejectScoreOneAuthResult(TableQueryAuthResult):
         super().__init__(filter=_RejectScoreOneAuthResult.filter, 
column_masking=None)
 
     @staticmethod
-    def get_extra_fields_for_filter(read_fields, table_fields):
+    def get_extra_fields(read_fields, table_fields):
         return []
 
     @staticmethod
@@ -96,7 +96,7 @@ class _PayloadAuthResult(TableQueryAuthResult):
         })], column_masking=None)
 
     @staticmethod
-    def get_extra_fields_for_filter(read_fields, table_fields):
+    def get_extra_fields(read_fields, table_fields):
         return []
 
     def extract_row_filter(self):
diff --git a/paimon-python/pypaimon/tests/query_auth_widening_test.py 
b/paimon-python/pypaimon/tests/query_auth_widening_test.py
new file mode 100644
index 0000000000..db426f9705
--- /dev/null
+++ b/paimon-python/pypaimon/tests/query_auth_widening_test.py
@@ -0,0 +1,191 @@
+################################################################################
+#  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 json
+import os
+import shutil
+import tempfile
+import unittest
+
+import pyarrow as pa
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.catalog.table_query_auth import TableQueryAuthResult
+from pypaimon.schema.data_types import AtomicType, DataField
+
+
+def _field_ref(index, name, type_name="STRING"):
+    return {"name": "FIELD_REF",
+            "fieldRef": {"index": index, "name": name, "type": type_name}}
+
+
+def _null_mask():
+    return json.dumps({"name": "NULL"})
+
+
+def _leaf(index, name, function, literals, type_name="INT"):
+    return json.dumps({
+        "kind": "LEAF",
+        "transform": _field_ref(index, name, type_name),
+        "function": function,
+        "literals": literals,
+    })
+
+
+def _fields(*names):
+    return [DataField(i, n, AtomicType("STRING")) for i, n in enumerate(names)]
+
+
+class TestExtraFieldsWidening(unittest.TestCase):
+
+    def _extra(self, result, read, table):
+        return [f.name for f in result.get_extra_fields(
+            _fields(*read), _fields(*table))]
+
+    def test_filter_operands_widen(self):
+        result = TableQueryAuthResult(
+            filter=[_leaf(2, "score", "GREATER_THAN", [5])], 
column_masking=None)
+        self.assertEqual(self._extra(result, ["id"], ["id", "name", "score"]),
+                         ["score"])
+
+    def test_readable_mask_input_widens(self):
+        result = TableQueryAuthResult(filter=None, column_masking={
+            "secret": json.dumps(_field_ref(2, "salt"))})
+        self.assertEqual(
+            self._extra(result, ["id", "secret"], ["id", "secret", "salt"]),
+            ["salt"])
+
+    def test_unreadable_mask_target_widens_nothing(self):
+        result = TableQueryAuthResult(filter=None, column_masking={
+            "secret": json.dumps(_field_ref(2, "salt"))})
+        self.assertEqual(self._extra(result, ["id"], ["id", "secret", 
"salt"]), [])
+
+    def test_widening_is_transitive(self):
+        result = TableQueryAuthResult(filter=None, column_masking={
+            "a": json.dumps(_field_ref(1, "b")),
+            "b": json.dumps(_field_ref(2, "c")),
+        })
+        self.assertEqual(self._extra(result, ["a"], ["a", "b", "c"]), ["b", 
"c"])
+
+    def test_a_filter_operand_makes_its_mask_inputs_needed(self):
+        result = TableQueryAuthResult(
+            filter=[_leaf(1, "b", "GREATER_THAN", [5])],
+            column_masking={"b": json.dumps(_field_ref(2, "c"))})
+        self.assertEqual(self._extra(result, ["a"], ["a", "b", "c"]), ["b", 
"c"])
+
+    def test_a_name_absent_from_the_table_is_skipped(self):
+        result = TableQueryAuthResult(
+            filter=[_leaf(9, "gone", "GREATER_THAN", [5])], 
column_masking=None)
+        self.assertEqual(self._extra(result, ["id"], ["id"]), [])
+
+    def test_no_restrictions_widens_nothing(self):
+        self.assertEqual(
+            self._extra(TableQueryAuthResult(filter=None, column_masking=None),
+                        ["id"], ["id", "score"]), [])
+
+
+class TestWidenedReadsDoNotCrash(unittest.TestCase):
+
+    @classmethod
+    def setUpClass(cls):
+        cls.tempdir = tempfile.mkdtemp()
+        cls.catalog = CatalogFactory.create(
+            {'warehouse': os.path.join(cls.tempdir, 'wh')})
+        cls.catalog.create_database('db', True)
+
+        cls.flat_schema = pa.schema([
+            ('id', pa.int32()), ('secret', pa.string()), ('salt', pa.string()),
+            ('score', pa.int32())])
+        cls._create('flat', cls.flat_schema, {
+            'id': [1, 2], 'secret': ['a', 'b'], 'salt': ['x', 'y'],
+            'score': [1, 9]})
+
+        cls.nested_schema = pa.schema([
+            ('id', pa.int32()),
+            ('s', pa.struct([('a', pa.string()), ('b', pa.string())])),
+            ('score', pa.int32())])
+        cls._create('nested', cls.nested_schema, {
+            'id': [1, 2],
+            's': [{'a': 'a1', 'b': 'b1'}, {'a': 'a2', 'b': 'b2'}],
+            'score': [1, 9]})
+
+    @classmethod
+    def _create(cls, name, pa_schema, rows):
+        cls.catalog.create_table(
+            'db.' + name, Schema.from_pyarrow_schema(pa_schema), False)
+        table = cls.catalog.get_table('db.' + name)
+        writer = table.new_batch_write_builder().new_write()
+        writer.write_arrow(pa.Table.from_pydict(rows, schema=pa_schema))
+        table.new_batch_write_builder().new_commit().commit(
+            writer.prepare_commit())
+        writer.close()
+
+    @classmethod
+    def tearDownClass(cls):
+        shutil.rmtree(cls.tempdir, ignore_errors=True)
+
+    def _read(self, name, projection, auth_result=None):
+        table = self.catalog.get_table('db.' + name)
+        if auth_result is not None:
+            table.catalog_environment.table_query_auth = (
+                lambda options, identifier: (lambda select: auth_result))
+        builder = table.new_read_builder()
+        if projection is not None:
+            builder = builder.with_projection(projection)
+        splits = builder.new_scan().plan().splits()
+        return builder.new_read().to_arrow(splits).to_pydict()
+
+    def test_mask_input_outside_the_projection(self):
+        out = self._read('flat', ['id', 'secret'], TableQueryAuthResult(
+            filter=None,
+            column_masking={'secret': json.dumps(_field_ref(2, 'salt'))}))
+        self.assertEqual(out, {'id': [1, 2], 'secret': ['x', 'y']})
+
+    def test_a_mask_reading_a_masked_column_is_still_refused(self):
+        with self.assertRaises(ValueError) as ctx:
+            self._read('flat', ['id', 'secret'], TableQueryAuthResult(
+                filter=None,
+                column_masking={'secret': json.dumps(_field_ref(2, 'salt')),
+                                'salt': _null_mask()}))
+        self.assertIn("which is masked too", str(ctx.exception))
+
+    def test_filter_operand_outside_the_projection(self):
+        out = self._read('flat', ['id', 'secret'], TableQueryAuthResult(
+            filter=[_leaf(3, 'score', 'GREATER_THAN', [5])],
+            column_masking=None))
+        self.assertEqual(out, {'id': [2], 'secret': ['b']})
+
+    def test_nested_projection_with_filter_on_an_unprojected_column(self):
+        out = self._read('nested', ['id', 's.a'], TableQueryAuthResult(
+            filter=[_leaf(2, 'score', 'GREATER_THAN', [5])],
+            column_masking=None))
+        self.assertEqual(out, {'id': [2], 's_a': ['a2']})
+
+    def test_nested_projection_with_a_mask_input_outside_the_projection(self):
+        out = self._read('nested', ['id', 's.a'], TableQueryAuthResult(
+            filter=None,
+            column_masking={'id': json.dumps(_field_ref(2, 'score', 'INT'))}))
+        self.assertEqual(out, {'id': [1, 9], 's_a': ['a1', 'a2']})
+
+    def test_nested_projection_without_authorization_is_unchanged(self):
+        self.assertEqual(self._read('nested', ['id', 's.a']),
+                         {'id': [1, 2], 's_a': ['a1', 'a2']})
+
+
+if __name__ == '__main__':
+    unittest.main()
diff --git a/paimon-python/pypaimon/tests/table_query_auth_test.py 
b/paimon-python/pypaimon/tests/table_query_auth_test.py
index 0f28ace877..05deff7101 100644
--- a/paimon-python/pypaimon/tests/table_query_auth_test.py
+++ b/paimon-python/pypaimon/tests/table_query_auth_test.py
@@ -161,7 +161,7 @@ class 
TestTableQueryAuthResultConvertPlan(unittest.TestCase):
 
     def test_blank_filter_no_extra_fields(self):
         result = TableQueryAuthResult([""], None)
-        extra = result.get_extra_fields_for_filter(
+        extra = result.get_extra_fields(
             [_FakeField("a")], [_FakeField("a"), _FakeField("b")])
         self.assertEqual(extra, [])
 
@@ -247,7 +247,7 @@ class 
TestTableQueryAuthResultExtraFields(unittest.TestCase):
             _FakeField("dept"),
         ]
         result = TableQueryAuthResult([_simple_filter_json("dept")], None)
-        extra = result.get_extra_fields_for_filter(read_fields, table_fields)
+        extra = result.get_extra_fields(read_fields, table_fields)
         self.assertEqual(len(extra), 1)
         self.assertEqual(extra[0].name, "dept")
 
@@ -255,12 +255,12 @@ class 
TestTableQueryAuthResultExtraFields(unittest.TestCase):
         read_fields = [_FakeField("name"), _FakeField("dept")]
         table_fields = read_fields + [_FakeField("age")]
         result = TableQueryAuthResult([_simple_filter_json("dept")], None)
-        extra = result.get_extra_fields_for_filter(read_fields, table_fields)
+        extra = result.get_extra_fields(read_fields, table_fields)
         self.assertEqual(len(extra), 0)
 
     def test_no_extra_when_no_filter(self):
         result = TableQueryAuthResult(None, None)
-        extra = result.get_extra_fields_for_filter(
+        extra = result.get_extra_fields(
             [_FakeField("a")], [_FakeField("a"), _FakeField("b")]
         )
         self.assertEqual(len(extra), 0)
@@ -271,7 +271,7 @@ class 
TestTableQueryAuthResultExtraFields(unittest.TestCase):
         read_fields = [_FakeField("name")]
         table_fields = [_FakeField("name"), _FakeField("dept")]
         result = TableQueryAuthResult([f1, f2], None)
-        extra = result.get_extra_fields_for_filter(read_fields, table_fields)
+        extra = result.get_extra_fields(read_fields, table_fields)
         self.assertEqual(len(extra), 1)
 
 

Reply via email to