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 e2949f8521 [python] Apply query authorization to the Daft blob-table
native read (#10026)
e2949f8521 is described below
commit e2949f8521fe4c48513d027c774ecf54b5673dc5
Author: Jiajia Li <[email protected]>
AuthorDate: Mon Sep 21 10:39:22 2026 +0800
[python] Apply query authorization to the Daft blob-table native read
(#10026)
---
paimon-python/pypaimon/daft/daft_datasource.py | 18 ++-
.../pypaimon/tests/daft/daft_blob_auth_test.py | 175 +++++++++++++++++++++
2 files changed, 191 insertions(+), 2 deletions(-)
diff --git a/paimon-python/pypaimon/daft/daft_datasource.py
b/paimon-python/pypaimon/daft/daft_datasource.py
index 1745daa32b..766c1cc848 100644
--- a/paimon-python/pypaimon/daft/daft_datasource.py
+++ b/paimon-python/pypaimon/daft/daft_datasource.py
@@ -365,6 +365,14 @@ class _PaimonPKSplitTask(DataSourceTask):
for batch in iter(reader.read_next_batch, None):
if self._output_columns is not None:
batch = batch.select(self._output_columns)
+ if batch.num_columns == 0:
+ # Daft cannot build a record batch without arrays, so carry
the row
+ # count in a placeholder column and project it away.
+ rows = pa.RecordBatch.from_pydict(
+ {"__rows": pa.nulls(batch.num_rows, pa.int8())})
+ yield RecordBatch.from_arrow_record_batches(
+ [rows], rows.schema).eval_expression_list([])
+ continue
if has_blob_columns:
batch = _convert_blob_columns(
batch,
@@ -769,7 +777,8 @@ class PaimonDataSource(DataSource):
split.files
if routing.use_native_reader
else self._blob_table_native_files(
- split.files, read_pushdowns.task_columns,
has_deletion_vectors
+ split.files, read_pushdowns.task_columns,
has_deletion_vectors,
+ has_auth,
)
)
if native_files is not None and self._has_incompatible_file_schema(
@@ -863,6 +872,7 @@ class PaimonDataSource(DataSource):
getattr(split, "data_files", None) or [],
read_pushdowns.task_columns,
split.has_deletion_vectors,
+ paimon_scan.has_auth,
)
)
candidate_files = (
@@ -999,15 +1009,19 @@ class PaimonDataSource(DataSource):
files: list[DataFileMeta],
task_columns: list[str] | None,
has_deletion_vectors: bool,
+ has_auth: bool,
) -> list[DataFileMeta] | None:
"""Files of a blob-table split that can be read via the native parquet
reader because no BLOB column is projected, or ``None`` to keep the
pypaimon fallback. Only applies to non-PK parquet blob tables without
- deletion vectors and with an explicit projection."""
+ deletion vectors, without query authorization, and with an explicit
+ projection. A blob table never reaches ``use_native_reader``, so this
is
+ where authorization has to stop the native reader."""
if (
not self._has_blob_columns
or not self._is_parquet
or has_deletion_vectors
+ or has_auth
or self._table.is_primary_key_table
or task_columns is None
):
diff --git a/paimon-python/pypaimon/tests/daft/daft_blob_auth_test.py
b/paimon-python/pypaimon/tests/daft/daft_blob_auth_test.py
new file mode 100644
index 0000000000..d69794c0bf
--- /dev/null
+++ b/paimon-python/pypaimon/tests/daft/daft_blob_auth_test.py
@@ -0,0 +1,175 @@
+################################################################################
+# 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 asyncio
+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.daft.daft_compat import has_file_range_reads
+
+pytest = __import__("pytest")
+daft = pytest.importorskip("daft")
+
+
+def _authorize(table, auth_result):
+ table.catalog_environment.table_query_auth = (
+ lambda options, identifier: (lambda select: auth_result))
+
+
+async def _tasks(table, columns):
+ from daft import context, runners
+ from daft.daft import StorageConfig
+ from daft.io.pushdowns import Pushdowns
+
+ from pypaimon.daft.daft_datasource import PaimonDataSource
+
+ io_config = context.get_context().daft_planning_config.default_io_config
+ storage_config = StorageConfig(
+ runners.get_or_create_runner().name != "ray", io_config)
+ source = PaimonDataSource(
+ table, storage_config=storage_config, catalog_options={})
+ return [t async for t in source.get_tasks(Pushdowns(columns=columns))]
+
+
+async def _row_count(table, columns):
+ total = 0
+ for task in await _tasks(table, columns):
+ async for batch in task.read():
+ total += len(batch)
+ return total
+
+
+async def _batches(table, columns):
+ out = []
+ for task in await _tasks(table, columns):
+ async for batch in task.read():
+ out.append(batch.to_pydict())
+ return out
+
+
[email protected](has_file_range_reads(),
+ "installed Daft lacks File range reads")
+class DaftBlobTableQueryAuthTest(unittest.TestCase):
+
+ def setUp(self):
+ self.tempdir = tempfile.mkdtemp()
+ catalog_options = {"warehouse": os.path.join(self.tempdir, "wh")}
+ self.catalog = CatalogFactory.create(catalog_options)
+ self.catalog.create_database("default", True)
+ self.pa_schema = pa.schema([
+ ("id", pa.int32()), ("name", pa.string()),
+ ("content", pa.large_binary())])
+ self.catalog.create_table("default.blob_auth",
Schema.from_pyarrow_schema(
+ self.pa_schema,
+ options={"row-tracking.enabled": "true",
+ "data-evolution.enabled": "true"}), False)
+ table = self.catalog.get_table("default.blob_auth")
+ writer = table.new_batch_write_builder().new_write()
+ writer.write_arrow(pa.Table.from_pydict(
+ {"id": list(range(6)),
+ "name": ["n%d" % i for i in range(6)],
+ "content": [os.urandom(64) for _ in range(6)]},
+ schema=self.pa_schema))
+ table.new_batch_write_builder().new_commit().commit(
+ writer.prepare_commit())
+ writer.close()
+
+ def tearDown(self):
+ shutil.rmtree(self.tempdir, ignore_errors=True)
+
+ def _table(self):
+ return self.catalog.get_table("default.blob_auth")
+
+ def test_scalar_projection_is_native_without_auth(self):
+ tasks = asyncio.run(_tasks(self._table(), ["id", "name"]))
+ self.assertTrue(tasks)
+ self.assertFalse(any(type(t).__name__ == "_PaimonPKSplitTask"
+ for t in tasks))
+
+ def test_masked_scalar_projection_falls_back_and_masks(self):
+ table = self._table()
+ _authorize(table, TableQueryAuthResult(
+ filter=None,
+ column_masking={"name": json.dumps({"name": "NULL"})}))
+
+ tasks = asyncio.run(_tasks(table, ["id", "name"]))
+ self.assertTrue(tasks)
+ self.assertTrue(all(type(t).__name__ == "_PaimonPKSplitTask"
+ for t in tasks))
+
+ names = []
+ for batch in asyncio.run(_batches(table, ["id", "name"])):
+ names.extend(batch["name"])
+ self.assertEqual(len(names), 6)
+ self.assertTrue(all(n is None for n in names), names)
+
+ def test_row_filter_falls_back_and_filters(self):
+ table = self._table()
+ _authorize(table, TableQueryAuthResult(
+ filter=[json.dumps({
+ "kind": "LEAF",
+ "transform": {
+ "name": "FIELD_REF",
+ "fieldRef": {"index": 0, "name": "id", "type": "INT"},
+ },
+ "function": "LESS_THAN",
+ "literals": [3],
+ })],
+ column_masking=None))
+
+ tasks = asyncio.run(_tasks(table, ["id", "name"]))
+ self.assertTrue(all(type(t).__name__ == "_PaimonPKSplitTask"
+ for t in tasks))
+
+ ids = []
+ for batch in asyncio.run(_batches(table, ["id", "name"])):
+ ids.extend(batch["id"])
+ self.assertEqual(sorted(ids), [0, 1, 2])
+
+ def test_zero_column_projection_under_an_auth_filter_is_filtered(self):
+ table = self._table()
+ _authorize(table, TableQueryAuthResult(
+ filter=[json.dumps({
+ "kind": "LEAF",
+ "transform": {
+ "name": "FIELD_REF",
+ "fieldRef": {"index": 0, "name": "id", "type": "INT"},
+ },
+ "function": "LESS_THAN",
+ "literals": [3],
+ })],
+ column_masking=None))
+ self.assertEqual(asyncio.run(_row_count(table, [])), 3)
+
+ def test_zero_column_projection_under_an_auth_mask(self):
+ table = self._table()
+ _authorize(table, TableQueryAuthResult(
+ filter=None,
+ column_masking={"name": json.dumps({"name": "NULL"})}))
+ self.assertEqual(asyncio.run(_row_count(table, [])), 6)
+
+
+if __name__ == '__main__':
+ unittest.main()