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 4a7f072a0a [python] Support named projections in Torch streaming row 
datasets (#10808)
4a7f072a0a is described below

commit 4a7f072a0ab45ea8b3882349d2839a3b206349cf
Author: QuakeWang <[email protected]>
AuthorDate: Tue Oct 27 14:13:20 2026 +0800

    [python] Support named projections in Torch streaming row datasets (#10808)
---
 paimon-python/pypaimon/read/table_read.py          |  8 +-
 .../tests/named_projection_iterator_test.py        | 12 ++-
 paimon-python/pypaimon/tests/torch_read_test.py    | 97 ++++++++++++++++++++++
 3 files changed, 112 insertions(+), 5 deletions(-)

diff --git a/paimon-python/pypaimon/read/table_read.py 
b/paimon-python/pypaimon/read/table_read.py
index d25d651da8..7761aea4d4 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -485,7 +485,8 @@ class TableRead:
             prefetch_concurrency: Reader threads per DataLoader worker in row
                 format.
             batch_format: ``"row"``, ``"pyarrow"``, or ``"torch"``. Batch
-                formats require streaming.
+                formats require streaming. Streaming row format also supports
+                named column and path projections.
             batch_size: Rows per batch; ``None`` preserves reader batches when
                 unshuffled, or emits shuffle blocks of at most buffer_size 
rows.
             to_tensor_fn: Optional RecordBatch converter for Torch batches.
@@ -513,7 +514,10 @@ class TableRead:
         ):
             raise ValueError("batch_size must be a positive int or None")
         if batch_format == "row":
-            if self.output_projection is not None and 
self.output_projection.named:
+            from pypaimon.read.variant_read_type import has_variant_extractions
+
+            if (self.output_projection is not None and 
self.output_projection.named
+                    and (not streaming or 
has_variant_extractions(self.read_type))):
                 raise RuntimeError(
                     "Projection expressions are not supported by Torch row 
format; "
                     "use streaming=True with batch_format='pyarrow' or 
'torch'")
diff --git a/paimon-python/pypaimon/tests/named_projection_iterator_test.py 
b/paimon-python/pypaimon/tests/named_projection_iterator_test.py
index f858762794..9f0936af40 100644
--- a/paimon-python/pypaimon/tests/named_projection_iterator_test.py
+++ b/paimon-python/pypaimon/tests/named_projection_iterator_test.py
@@ -245,11 +245,14 @@ def 
test_named_iterator_keeps_map_key_authorization_boundary(projected_table):
         _rows(builder.new_read(), splits)
 
 
-def test_named_projection_keeps_torch_row_boundary(projected_table):
[email protected]('projection', [
+    {'user_id': 'id'}, {'city': 'profile.city'}, {'source': 
"attributes['source']"},
+])
+def 
test_named_projection_keeps_non_streaming_torch_row_boundary(projected_table, 
projection):
     table, _ = projected_table
-    read = table.new_read_builder().with_projection({'user_id': 
'id'}).new_read()
+    read = table.new_read_builder().with_projection(projection).new_read()
     with pytest.raises(RuntimeError, match='not supported by Torch row 
format'):
-        read.to_torch([])
+        read.to_torch([], streaming=False, batch_format='row')
 
 
 @pytest.mark.parametrize('function', ['variant_get', 'try_variant_get'])
@@ -270,4 +273,7 @@ def 
test_named_iterator_rejects_variant_extractions(tmp_path, function, nested,
     with patch('pypaimon.read.read_dispatch._native_read_execution') as native:
         with pytest.raises(RuntimeError, match='Variant extraction expressions 
are not supported by to_iterator'):
             read.to_iterator([])
+        for streaming in (False, True):
+            with pytest.raises(RuntimeError, match='not supported by Torch row 
format'):
+                read.to_torch([], streaming=streaming, batch_format='row')
         native.assert_not_called()
diff --git a/paimon-python/pypaimon/tests/torch_read_test.py 
b/paimon-python/pypaimon/tests/torch_read_test.py
index f6b1a2692c..2478d53dbf 100644
--- a/paimon-python/pypaimon/tests/torch_read_test.py
+++ b/paimon-python/pypaimon/tests/torch_read_test.py
@@ -592,6 +592,65 @@ class TorchReadTest(unittest.TestCase):
         actual = pa.Table.from_batches(list(dataset)).sort_by('id')
         self.assertEqual(actual.to_pylist(), expected)
 
+    @parameterized.expand(['append', 'pk'])
+    def test_torch_streaming_named_paths(self, mode):
+        table, values = 
self._create_named_projection_table('default.named_paths_' + mode, mode)
+        projections = [
+            ({'user_id': 'id'}, [0]),
+            ({'city': 'profile.city'}, [1]),
+            ({'source': "attributes['source']"}, [2]),
+            ({'city': 'profile.city', 'source': "attributes['source']", 
'user_id': 'id',
+              'city_again': 'profile.city', 'id_again': 'id',
+              'source_again': "attributes['source']"}, [1, 2, 0, 1, 0, 2]),
+        ]
+        for projection, positions in projections:
+            builder = table.new_read_builder().with_projection(projection)
+            read, splits = builder.new_read(), 
builder.new_scan().plan().splits()
+            if mode == 'append':
+                self.assertGreater(len(splits), 1)
+            expected = [dict(zip(projection, (row[i] for i in positions))) for 
row in values]
+            for options in [{}, {'prefetch_concurrency': 2},
+                            {'shuffle': True, 'seed': 17, 'buffer_size': 2, 
'max_buffer_input_splits': 1},
+                            {'shuffle': True, 'seed': 17, 'buffer_size': 2, 
'max_buffer_input_splits': 3}]:
+                with self.subTest(projection=projection, options=options):
+                    dataset = read.to_torch(splits, streaming=True, 
batch_format='row', **options)
+                    actual = list(DataLoader(dataset, batch_size=None, 
num_workers=0))
+                    self.assertEqual(sorted(actual, key=repr), 
sorted(expected, key=repr))
+                    self.assertTrue(all(list(row) == list(projection) for row 
in actual))
+
+    @parameterized.expand(['append', 'pk'])
+    def test_torch_streaming_named_paths_filter_limit_and_empty_input(self, 
mode):
+        table, _ = self._create_named_projection_table('default.named_filter_' 
+ mode, mode)
+        predicate = 
table.new_read_builder().new_predicate_builder().greater_than('v', 20)
+        for limit in [0, 1, 3, None]:
+            with self.subTest(limit=limit):
+                builder = table.new_read_builder().with_projection({
+                    'v': 'id', 'city': 'profile.city', 'source': 
"attributes['source']",
+                }).with_filter(predicate)
+                if limit is not None:
+                    builder.with_limit(limit)
+                read, splits = builder.new_read(), 
builder.new_scan().plan().splits()
+                actual = list(read.to_torch(splits, streaming=True, 
batch_format='row'))
+                expected = [{'v': 3, 'city': None, 'source': None},
+                            {'v': 4, 'city': 'Shenzhen', 'source': None},
+                            {'v': 5, 'city': 'Beijing', 'source': None}]
+                self.assertEqual(len(actual), len(expected) if limit is None 
else limit)
+                if limit is None or limit == 3:
+                    self.assertCountEqual(actual, expected)
+                else:
+                    self.assertTrue(all(row in expected for row in actual))
+                self.assertEqual(list(read.to_torch([], streaming=True, 
batch_format='row')), [])
+
+    def test_torch_streaming_named_map_keeps_authorization_boundary(self):
+        table, _ = 
self._create_named_projection_table('default.named_map_auth', 'append')
+        auth = TableQueryAuthResult(filter=None, column_masking={'attributes': 
'{"name":"NULL"}'})
+        with patch.object(table.catalog_environment, 'table_query_auth', 
return_value=lambda select: auth):
+            builder = table.new_read_builder().with_projection({'source': 
"attributes['source']"})
+            read, splits = builder.new_read(), 
builder.new_scan().plan().splits()
+            dataset = read.to_torch(splits, streaming=True, batch_format='row')
+            with self.assertRaisesRegex(NotImplementedError, 'with query 
authorization'):
+                list(dataset)
+
     @parameterized.expand([True, False])
     def test_torch_read(self, is_streaming: bool = False):
         schema = Schema.from_pyarrow_schema(self.pa_schema, 
partition_keys=['user_id'])
@@ -2131,6 +2190,44 @@ class TorchReadTest(unittest.TestCase):
                 max_buffer_input_splits=0,
             )
 
+    def _create_named_projection_table(self, identifier, mode):
+        schema = pa.schema([
+            pa.field('id', pa.int64(), nullable=False), ('v', pa.int64()),
+            ('profile', pa.struct([('city', pa.string())])),
+            ('attributes', pa.map_(pa.string(), pa.string())),
+        ])
+        self.catalog.create_table(identifier, Schema.from_pyarrow_schema(
+            schema, primary_keys=['id'] if mode == 'pk' else None,
+            partition_keys=['id'] if mode == 'append' else [],
+            options={'bucket': '1' if mode == 'pk' else '-1', 
'read.native.enabled': 'false',
+                     'scan.native-plan.enabled': 'false', 
'write.native.enabled': 'false'}), False)
+        table = self.catalog.get_table(identifier)
+        rows = [
+            {'id': 1, 'v': 10, 'profile': {'city': 'Hangzhou'},
+             'attributes': [('source', 'web'), ('source', 'mobile')]},
+            {'id': 2, 'v': 20, 'profile': None,
+             'attributes': [('source', None), ('source', 'later')]},
+            {'id': 3, 'v': 30, 'profile': {'city': None}, 'attributes': 
[('other', 'value')]},
+            {'id': 4, 'v': 40, 'profile': {'city': 'Shenzhen'}, 'attributes': 
None},
+            {'id': 5, 'v': 50, 'profile': {'city': 'Beijing'}, 'attributes': 
[]},
+        ]
+        expected = [(1, 'Hangzhou', 'web'), (2, None, None), (3, None, None),
+                    (4, 'Shenzhen', None), (5, 'Beijing', None)]
+        writes = [rows]
+        if mode == 'pk':
+            writes.append([dict(rows[0], profile={'city': 'Suzhou'}, 
attributes=[('source', 'api')])])
+            expected[0] = (1, 'Suzhou', 'api')
+        for values in writes:
+            builder = table.new_batch_write_builder()
+            writer, commit = builder.new_write(), builder.new_commit()
+            try:
+                writer.write_arrow(pa.Table.from_pylist(values, schema=schema))
+                commit.commit(writer.prepare_commit())
+            finally:
+                writer.close()
+                commit.close()
+        return table, expected
+
     def _create_shuffle_append_table(
         self,
         identifier,

Reply via email to