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 43a7e6d106 [python] Push down typed Variant projections (#10336)
43a7e6d106 is described below
commit 43a7e6d10619d4c2729817056f66bbfb2572f612
Author: XiaoHongbo <[email protected]>
AuthorDate: Mon Oct 5 19:12:21 2026 +0800
[python] Push down typed Variant projections (#10336)
---
docs/docs/pypaimon/data-types.md | 14 ++
.../data/variant/VariantMetadataUtilsTest.java | 61 +++++
paimon-python/pypaimon/ray/ray_paimon.py | 11 +-
.../pypaimon/read/datasource/ray_datasource.py | 8 +
.../pypaimon/read/datasource/split_provider.py | 19 +-
paimon-python/pypaimon/read/native_plan.py | 23 +-
paimon-python/pypaimon/read/read_builder.py | 184 +++++++++++++--
paimon-python/pypaimon/read/stream_read_builder.py | 20 +-
paimon-python/pypaimon/read/table_read.py | 167 +++++++++++---
paimon-python/pypaimon/read/variant_read_type.py | 80 +++++++
paimon-python/pypaimon/tests/native_plan_test.py | 92 ++++++++
paimon-python/pypaimon/tests/native_read_test.py | 72 +++++-
.../tests/py36/named_variant_projection_test.py | 53 +++++
.../pypaimon/tests/ray_integration_test.py | 72 ++++++
.../pypaimon/tests/split_provider_test.py | 14 +-
.../pypaimon/tests/stream_read_builder_test.py | 21 +-
.../tests/test_read_builder_nested_projection.py | 254 ++++++++++++++++++++-
paimon-python/pypaimon/tests/variant_test.py | 230 +++++++++++++++++++
18 files changed, 1312 insertions(+), 83 deletions(-)
diff --git a/docs/docs/pypaimon/data-types.md b/docs/docs/pypaimon/data-types.md
index a5bcf1e30e..63893d72fe 100644
--- a/docs/docs/pypaimon/data-types.md
+++ b/docs/docs/pypaimon/data-types.md
@@ -233,6 +233,20 @@ Field names are literal (`state.x` is not a nested path).
Missing fields are
omitted; VARIANT NULL is `None`. Values use natural Python types, even when
types differ between rows. This does not reduce Parquet I/O.
+To project VARIANT fields as named float32 columns:
+
+```python
+read_builder = table.new_read_builder().with_projection({
+ 'id': 'id',
+ 'x': "try_variant_get(payload, '$.x', 'float')",
+})
+```
+
+This requires `read.native.enabled=true` and a compatible `pypaimon-rust`; it
+does not fall back to Python. `try_variant_get` returns NULL on cast errors,
+while `variant_get` raises. Row iterators, Torch row format, and paths
containing
+`;` are unsupported.
+
### Read and update typed paths
Read existing paths as Arrow arrays, use Arrow compute, and replace them
diff --git
a/paimon-common/src/test/java/org/apache/paimon/data/variant/VariantMetadataUtilsTest.java
b/paimon-common/src/test/java/org/apache/paimon/data/variant/VariantMetadataUtilsTest.java
index cea9c27be0..79afd42fba 100644
---
a/paimon-common/src/test/java/org/apache/paimon/data/variant/VariantMetadataUtilsTest.java
+++
b/paimon-common/src/test/java/org/apache/paimon/data/variant/VariantMetadataUtilsTest.java
@@ -19,8 +19,10 @@
package org.apache.paimon.data.variant;
import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.DataType;
import org.apache.paimon.types.DataTypes;
import org.apache.paimon.types.RowType;
+import org.apache.paimon.utils.JsonSerdeUtil;
import org.junit.jupiter.api.Test;
@@ -46,6 +48,65 @@ public class VariantMetadataUtilsTest {
.hasMessageContaining("must not contain ';'");
}
+ @Test
+ public void testReadTypeJsonInteropWithRustAndPython() {
+ // This is the read-type JSON emitted by Rust (which omits optional
nullable fields).
+ String rustJson =
+ "{\"type\":\"ROW\",\"fields\":[{\"id\":7,\"name\":\"payload\","
+ + "\"type\":{\"type\":\"ROW\",\"fields\":["
+ + "{\"id\":0,\"name\":\"0\",\"type\":\"FLOAT\","
+ +
"\"description\":\"__VARIANT_METADATA$.x;false;Asia/Shanghai\"},"
+ + "{\"id\":1,\"name\":\"1\",\"type\":\"FLOAT\","
+ +
"\"description\":\"__VARIANT_METADATA$.y;true;UTC\"}]}}]}";
+ RowType javaRow =
+ RowType.of(
+ new DataField(
+ 7,
+ "payload",
+ RowType.of(
+ new DataField(
+ 0,
+ "0",
+ DataTypes.FLOAT(),
+
VariantMetadataUtils.buildVariantMetadata(
+ "$.x", false,
"Asia/Shanghai")),
+ new DataField(
+ 1,
+ "1",
+ DataTypes.FLOAT(),
+
VariantMetadataUtils.buildVariantMetadata(
+ "$.y", true,
"UTC")))));
+ assertThat(JsonSerdeUtil.toFlatJson(javaRow)).isEqualTo(rustJson);
+ RowType parsedRustRow = (RowType) JsonSerdeUtil.fromJson(rustJson,
DataType.class);
+ RowType rustVariantRow = (RowType)
parsedRustRow.getFields().get(0).type();
+
assertThat(VariantMetadataUtils.path(rustVariantRow.getField(0).description()))
+ .isEqualTo("$.x");
+
assertThat(VariantMetadataUtils.failOnError(rustVariantRow.getField(0).description()))
+ .isFalse();
+
assertThat(VariantMetadataUtils.timeZoneId(rustVariantRow.getField(0).description()))
+ .isEqualTo(ZoneId.of("Asia/Shanghai"));
+
assertThat(VariantMetadataUtils.path(rustVariantRow.getField(1).description()))
+ .isEqualTo("$.y");
+
assertThat(VariantMetadataUtils.failOnError(rustVariantRow.getField(1).description()))
+ .isTrue();
+
assertThat(VariantMetadataUtils.timeZoneId(rustVariantRow.getField(1).description()))
+ .isEqualTo(ZoneId.of("UTC"));
+
+ // PyPaimon includes nullable in its RowType.to_dict representation.
+ String pythonJson =
+ "{\"type\":\"ROW\",\"fields\":[{\"id\":7,\"name\":\"payload\","
+ + "\"type\":{\"type\":\"ROW\",\"fields\":["
+ + "{\"id\":0,\"name\":\"0\",\"type\":\"FLOAT\","
+ +
"\"description\":\"__VARIANT_METADATA$.x;false;UTC\"}],"
+ + "\"nullable\":true}}],\"nullable\":true}";
+ DataType parsed = JsonSerdeUtil.fromJson(pythonJson, DataType.class);
+ RowType extracted = (RowType) ((RowType)
parsed).getFields().get(0).type();
+ String description = extracted.getField(0).description();
+ assertThat(VariantMetadataUtils.path(description)).isEqualTo("$.x");
+ assertThat(VariantMetadataUtils.failOnError(description)).isFalse();
+
assertThat(VariantMetadataUtils.timeZoneId(description)).isEqualTo(ZoneId.of("UTC"));
+ }
+
@Test
public void testIsVariantRow() {
// Create a row type with variant metadata
diff --git a/paimon-python/pypaimon/ray/ray_paimon.py
b/paimon-python/pypaimon/ray/ray_paimon.py
index 65380919d6..3ed118b401 100644
--- a/paimon-python/pypaimon/ray/ray_paimon.py
+++ b/paimon-python/pypaimon/ray/ray_paimon.py
@@ -29,7 +29,7 @@ Usage::
import hashlib
import importlib
import uuid
-from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING
+from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING, Union
from pypaimon.common.predicate import Predicate
@@ -55,7 +55,7 @@ def read_paimon(
catalog_options: Dict[str, str],
*,
filter: Optional[Predicate] = None,
- projection: Optional[List[str]] = None,
+ projection: Optional[Union[List[str], Dict[str, str]]] = None,
limit: Optional[int] = None,
snapshot_id: Optional[int] = None,
tag_name: Optional[str] = None,
@@ -73,7 +73,7 @@ def read_paimon(
catalog_options: Options passed to ``CatalogFactory.create()``,
e.g. ``{"warehouse": "/path/to/warehouse"}``.
filter: Optional predicate to push down into the scan.
- projection: Optional list of column names to read.
+ projection: Optional column names or output-name-to-expression mapping.
limit: Optional row limit for the scan.
snapshot_id: Optional snapshot id to time-travel to. Mutually
exclusive with ``tag_name``.
@@ -120,6 +120,11 @@ def read_paimon(
schema = PyarrowFieldParser.from_paimon_schema(
split_provider.read_type()
)
+ expression_projection = split_provider.expression_projection()
+ if expression_projection is not None:
+ from pypaimon.read.table_read import TableRead
+ schema = TableRead._apply_expression_projection_to_schema(
+ schema, expression_projection)
import pyarrow
empty_table = pyarrow.Table.from_arrays(
[pyarrow.array([], type=field.type) for field in schema],
diff --git a/paimon-python/pypaimon/read/datasource/ray_datasource.py
b/paimon-python/pypaimon/read/datasource/ray_datasource.py
index 4b7e9459da..ca06429766 100644
--- a/paimon-python/pypaimon/read/datasource/ray_datasource.py
+++ b/paimon-python/pypaimon/read/datasource/ray_datasource.py
@@ -125,6 +125,7 @@ class RayDatasource(Datasource):
predicate = self._split_provider.predicate()
read_type = self._split_provider.read_type()
nested_name_paths = self._split_provider.nested_name_paths()
+ expression_projection = self._split_provider.expression_projection()
splits = self._split_provider.splits()
limit = self._split_provider.limit()
include_row_kind = self._split_provider.include_row_kind()
@@ -133,6 +134,10 @@ class RayDatasource(Datasource):
if self._schema is None:
self._schema = PyarrowFieldParser.from_paimon_schema(read_type)
+ if expression_projection is not None:
+ from pypaimon.read.table_read import TableRead
+ self._schema =
TableRead._apply_expression_projection_to_schema(
+ self._schema, expression_projection)
if include_row_kind:
from pypaimon.read.table_read import TableRead
self._schema = TableRead._add_row_kind_to_schema(self._schema)
@@ -154,6 +159,7 @@ class RayDatasource(Datasource):
schema=schema,
limit=limit,
nested_name_paths=nested_name_paths,
+ expression_projection=expression_projection,
include_row_kind=include_row_kind,
) -> Iterable[pyarrow.Table]:
"""Read function that will be executed by Ray workers."""
@@ -165,6 +171,7 @@ class RayDatasource(Datasource):
worker_table_read = TableRead(
table, predicate, read_type, limit=limit,
nested_name_paths=nested_name_paths,
+ expression_projection=expression_projection,
include_row_kind=include_row_kind)
batch_reader = worker_table_read.to_arrow_batch_reader(splits)
@@ -193,6 +200,7 @@ class RayDatasource(Datasource):
schema=schema,
limit=limit,
nested_name_paths=nested_name_paths,
+ expression_projection=expression_projection,
include_row_kind=include_row_kind,
)
diff --git a/paimon-python/pypaimon/read/datasource/split_provider.py
b/paimon-python/pypaimon/read/datasource/split_provider.py
index 224a74fe54..fba5cc0df5 100644
--- a/paimon-python/pypaimon/read/datasource/split_provider.py
+++ b/paimon-python/pypaimon/read/datasource/split_provider.py
@@ -26,7 +26,7 @@ bridge (which already has a fully resolved ``TableRead``).
"""
from abc import ABC, abstractmethod
-from typing import Dict, List, Optional
+from typing import Dict, List, Optional, Union
from pypaimon.read.split import Split
@@ -82,6 +82,10 @@ class SplitProvider(ABC):
"""
return None
+ def expression_projection(self):
+ """Named output expressions for a pre-resolved read, or ``None``."""
+ return None
+
class CatalogSplitProvider(SplitProvider):
"""Plan splits from a fully-qualified table identifier and catalog options.
@@ -96,7 +100,7 @@ class CatalogSplitProvider(SplitProvider):
table_identifier: str,
catalog_options: Dict[str, str],
predicate=None,
- projection: Optional[List[str]] = None,
+ projection: Optional[Union[List[str], Dict[str, str]]] = None,
limit: Optional[int] = None,
snapshot_id: Optional[int] = None,
tag_name: Optional[str] = None,
@@ -142,6 +146,7 @@ class CatalogSplitProvider(SplitProvider):
self._splits_cached = None
self._read_type_cached = None
self._nested_name_paths_cached = None
+ self._expression_projection_cached = None
def _ensure_table(self):
if self._table_cached is None:
@@ -176,6 +181,7 @@ class CatalogSplitProvider(SplitProvider):
rb = rb.with_limit(self._limit)
self._read_type_cached = rb.read_type()
self._nested_name_paths_cached = rb._nested_name_paths()
+ self._expression_projection_cached = rb._expression_projection
self._splits_cached = rb.new_scan().plan().splits()
@property
@@ -197,6 +203,10 @@ class CatalogSplitProvider(SplitProvider):
self._ensure_planned()
return self._nested_name_paths_cached
+ def expression_projection(self):
+ self._ensure_planned()
+ return self._expression_projection_cached
+
def predicate(self):
return self._predicate
@@ -217,6 +227,7 @@ class PreResolvedSplitProvider(SplitProvider):
def __init__(self, table, splits: List[Split], read_type, predicate=None,
limit: Optional[int] = None, nested_name_paths=None,
+ expression_projection=None,
include_row_kind: bool = False):
self._table = table
self._splits = splits
@@ -224,6 +235,7 @@ class PreResolvedSplitProvider(SplitProvider):
self._predicate = predicate
self._limit = limit
self._nested_name_paths = nested_name_paths
+ self._expression_projection = expression_projection
self._include_row_kind = include_row_kind
def table(self):
@@ -238,6 +250,9 @@ class PreResolvedSplitProvider(SplitProvider):
def nested_name_paths(self) -> Optional[List[List[str]]]:
return self._nested_name_paths
+ def expression_projection(self):
+ return self._expression_projection
+
def include_row_kind(self) -> bool:
return self._include_row_kind
diff --git a/paimon-python/pypaimon/read/native_plan.py
b/paimon-python/pypaimon/read/native_plan.py
index c314a75e5c..8c7bd41de1 100644
--- a/paimon-python/pypaimon/read/native_plan.py
+++ b/paimon-python/pypaimon/read/native_plan.py
@@ -37,6 +37,7 @@ from pypaimon.read.plan import Plan
from pypaimon.read.split import Split
from pypaimon.read.split_serializer import (
deserialize_split_v1, serialize_split_v1)
+from pypaimon.schema.data_types import DataField, RowType
def native_runtime_available() -> bool:
@@ -350,10 +351,17 @@ def _native_read_builder(table):
def _configure_native_read_builder(builder, predicate, limit, projection,
nested_projection=None,
+ read_type: Optional[List[DataField]] = None,
include_row_kind=False):
+ if read_type is not None:
+ if nested_projection is not None:
+ raise ValueError(
+ "read_type cannot be combined with nested projection")
+ builder = builder.with_read_type(
+ json.dumps(RowType(True, read_type).to_dict()))
if nested_projection is not None:
builder = builder.with_nested_projection(nested_projection)
- elif projection is not None:
+ elif projection is not None and read_type is None:
builder = builder.with_projection(projection)
if predicate is not None:
builder = builder.with_filter(_predicate_to_native(predicate))
@@ -369,6 +377,7 @@ def _prepare_native_read(table, predicate:
Optional[Predicate] = None,
projection: Optional[List[str]] = None,
blob_parallelism: Optional[int] = None,
nested_projection: Optional[List[List[str]]] = None,
+ read_type: Optional[List[DataField]] = None,
include_row_kind: bool = False):
"""Create one Rust reader reusable across split groups."""
if not native_reader_available():
@@ -377,6 +386,7 @@ def _prepare_native_read(table, predicate:
Optional[Predicate] = None,
builder = _configure_native_read_builder(
_native_read_builder(table), predicate, limit, projection,
nested_projection=nested_projection,
+ read_type=read_type,
include_row_kind=include_row_kind)
if blob_parallelism is not None:
builder = builder.with_blob_parallelism(blob_parallelism)
@@ -390,11 +400,18 @@ def native_read(table, splits, predicate:
Optional[Predicate] = None,
projection: Optional[List[str]] = None,
blob_parallelism: Optional[int] = None,
nested_projection: Optional[List[List[str]]] = None,
+ read_type: Optional[List[DataField]] = None,
include_row_kind: bool = False):
"""Read Rust ``Split`` objects into PyArrow ``RecordBatch`` objects."""
read_splits = _prepare_native_read(
- table, predicate, limit, projection, blob_parallelism,
- nested_projection, include_row_kind)
+ table,
+ predicate=predicate,
+ limit=limit,
+ projection=projection,
+ blob_parallelism=blob_parallelism,
+ nested_projection=nested_projection,
+ read_type=read_type,
+ include_row_kind=include_row_kind)
return read_splits(splits)
diff --git a/paimon-python/pypaimon/read/read_builder.py
b/paimon-python/pypaimon/read/read_builder.py
index ccac24842b..39dc0d11b7 100644
--- a/paimon-python/pypaimon/read/read_builder.py
+++ b/paimon-python/pypaimon/read/read_builder.py
@@ -16,7 +16,9 @@
# under the License.
import ast
-from typing import List, Optional, Sequence, Union
+from typing import Dict, List, Optional, Sequence, Tuple, Union
+
+import pyarrow
from pypaimon.common.predicate import Predicate
from pypaimon.common.predicate_builder import PredicateBuilder
@@ -26,8 +28,9 @@ from pypaimon.read.push_down_utils import
predicate_field_names
from pypaimon.read.query_auth_split import QueryAuthSplit
from pypaimon.read.scan_stats import ScanStats
from pypaimon.read.split import Split
-from pypaimon.read.table_read import TableRead
+from pypaimon.read.table_read import ROW_KIND_COLUMN, TableRead
from pypaimon.read.table_scan import TableScan
+from pypaimon.read.variant_read_type import with_variant_extractions
from pypaimon.schema.data_types import AtomicType, DataField, MapType
from pypaimon.table.special_fields import SpecialFields
from pypaimon.utils.projection import MapKey, Projection, is_row_type
@@ -36,6 +39,50 @@ from pypaimon.utils.projection import MapKey, Projection,
is_row_type
ProjectionPath = Sequence[Union[int, MapKey]]
+def _string_literal(node: ast.AST) -> Optional[str]:
+ if isinstance(node, ast.Str):
+ return node.s
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
+ return node.value
+ return None
+
+
+def _normalize_sql_literals(expression: str) -> str:
+ """Decode SQL doubled quotes before validating the expression with AST."""
+ normalized = []
+ index = 0
+ previous_was_literal = False
+ while index < len(expression):
+ quote = expression[index]
+ if quote not in ("'", '"'):
+ normalized.append(quote)
+ if not quote.isspace():
+ previous_was_literal = False
+ index += 1
+ continue
+ if previous_was_literal:
+ raise ValueError("Adjacent string literals are not supported")
+ index += 1
+ literal = []
+ while index < len(expression):
+ char = expression[index]
+ if char == quote:
+ if index + 1 < len(expression) and expression[index + 1] ==
quote:
+ literal.append(quote)
+ index += 2
+ else:
+ index += 1
+ break
+ else:
+ literal.append(char)
+ index += 1
+ else:
+ raise ValueError("Unterminated string literal in projection
expression")
+ normalized.append(repr(''.join(literal)))
+ previous_was_literal = True
+ return ''.join(normalized)
+
+
class _ReadPredicateBuilder(PredicateBuilder):
def __init__(self, fields, unsupported_fields):
@@ -58,12 +105,13 @@ class ReadBuilder:
self.table: FileStoreTable = table
self._predicate: Optional[Predicate] = None
- # ``_projection`` stores the user-facing name list from
- # :meth:`with_projection`. When nested selectors are present,
- # ``_nested_paths`` is also populated and takes precedence
- # in ``read_type()`` and downstream consumers.
+ # ``_projection`` stores physical source columns. Expression aliases
+ # are kept separately for the Arrow output schema.
self._projection: Optional[List[str]] = None
self._nested_paths: Optional[List[ProjectionPath]] = None
+ self._resolved_read_type: Optional[List[DataField]] = None
+ self._expression_projection: Optional[
+ List[Tuple[str, str, Optional[int]]]] = None
self._partition_filter: Optional[Predicate] = None
self._limit: Optional[int] = None
@@ -75,23 +123,35 @@ class ReadBuilder:
self._partition_filter = partition_filter
return self
- def with_projection(self, projection: List[str]) -> 'ReadBuilder':
- """Project to the given column names.
-
- Names containing a dot (e.g. ``"struct.subfield"``) walk into ROW
- children. A quoted bracket selector on a top-level
- ``MAP<STRING, ...>`` selects one literal key (e.g.
- ``"attrs['key.with.dots']"``). Unknown names are silently skipped to
- preserve the pre-existing contract.
+ def with_projection(
+ self,
+ projection: Union[List[str], Dict[str, str]],
+ ) -> 'ReadBuilder':
+ """Project columns or named float32 VARIANT expressions.
- An exact top-level field match takes precedence over both forms.
+ Lists retain column, nested ROW and MAP-key projection semantics.
+ A mapping assigns output names to source columns or float32
+ ``variant_get`` / ``try_variant_get`` expressions. Variant
+ expressions require native reading.
"""
- self._projection = projection
+ if isinstance(projection, dict):
+ projection, variants, outputs = self._parse_expression_projection(
+ projection)
+ else:
+ variants, outputs = None, None
if projection and any(
'.' in name or '[' in name for name in projection):
- self._nested_paths = self._resolve_projection_paths(projection)
+ paths = self._resolve_projection_paths(projection)
+ nested_paths = (paths if any(len(path) > 1 for path in paths)
+ else None)
else:
- self._nested_paths = None
+ nested_paths = None
+ read_type = self._resolve_read_type(projection, nested_paths)
+ read_type = with_variant_extractions(read_type, variants)
+ self._projection = projection
+ self._nested_paths = nested_paths
+ self._resolved_read_type = read_type
+ self._expression_projection = outputs
return self
def with_limit(self, limit: int) -> 'ReadBuilder':
@@ -116,6 +176,7 @@ class ReadBuilder:
predicate=self._predicate,
read_type=self.read_type(),
nested_name_paths=self._nested_name_paths(),
+ expression_projection=self._expression_projection,
limit=self._limit,
)
@@ -168,24 +229,103 @@ class ReadBuilder:
)
def read_type(self) -> List[DataField]:
+ return (self._resolved_read_type if self._resolved_read_type is not
None
+ else self.table.fields)
+
+ def _resolve_read_type(
+ self, projection: Optional[List[str]],
+ nested_paths: Optional[List[ProjectionPath]]) -> List[DataField]:
table_fields = self.table.fields
- if not self._projection and not self._nested_paths:
+ if not projection and not nested_paths:
return table_fields
if self.table.options.row_tracking_enabled():
table_fields =
SpecialFields.row_type_with_row_tracking(table_fields)
- if self._nested_paths:
- return Projection.of(self._nested_paths).project(table_fields)
+ if nested_paths:
+ return Projection.of(nested_paths).project(table_fields)
field_map = {field.name: field for field in table_fields}
- return [field_map[name] for name in self._projection if name in
field_map]
+ return [field_map[name] for name in projection if name in field_map]
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
+ def _parse_expression_projection(self, expressions: Dict[str, str]):
+ if not expressions:
+ raise ValueError("Projection expression mapping must not be empty")
+ table_fields = self.table.fields
+ if self.table.options.row_tracking_enabled():
+ table_fields =
SpecialFields.row_type_with_row_tracking(table_fields)
+ field_map = {field.name: field for field in table_fields}
+ projection = []
+ variants = {}
+ outputs = []
+ direct_columns = set()
+ for alias, expression in expressions.items():
+ if not isinstance(alias, str) or not alias:
+ raise TypeError("Projection output names must be non-empty
strings")
+ if alias == ROW_KIND_COLUMN:
+ raise ValueError("Projection output name %r is reserved" %
alias)
+ if not isinstance(expression, str) or not expression:
+ raise TypeError("Projection expressions must be non-empty
strings")
+ if expression in field_map:
+ source, child = expression, None
+ direct_columns.add(source)
+ else:
+ try:
+ call = ast.parse(
+ _normalize_sql_literals(expression), mode='eval').body
+ except SyntaxError as error:
+ raise ValueError(
+ "Unsupported projection expression %r" % expression
+ ) from error
+ if (not isinstance(call, ast.Call)
+ or not isinstance(call.func, ast.Name)
+ or call.func.id.lower() not in ('variant_get',
'try_variant_get')
+ or len(call.args) != 3 or call.keywords
+ or not (isinstance(call.args[0], ast.Name)
+ or _string_literal(call.args[0]) is not None)
+ or any(_string_literal(arg) is None
+ for arg in call.args[1:])):
+ raise ValueError(
+ "Unsupported projection expression %r" % expression)
+ source = (call.args[0].id
+ if isinstance(call.args[0], ast.Name)
+ else _string_literal(call.args[0]))
+ field = field_map.get(source)
+ if (field is None
+ or not isinstance(field.type, AtomicType)
+ or field.type.type.upper() != 'VARIANT'):
+ raise ValueError(
+ "Variant extraction requires a VARIANT column: %r"
+ % source)
+ path, target_type = (_string_literal(arg) for arg in
call.args[1:])
+ if ';' in path:
+ raise ValueError(
+ "Variant extraction path must not contain ';': %s"
+ % path)
+ if target_type.lower() != 'float':
+ raise ValueError(
+ "Only float32 Variant extractions are supported")
+ options = variants.setdefault(source, {
+ 'paths': [], 'target_type': pyarrow.float32(),
+ 'fail_on_error': [],
+ })
+ child = len(options['paths'])
+ options['paths'].append(path)
+ options['fail_on_error'].append(
+ call.func.id.lower() == 'variant_get')
+ if source not in projection:
+ projection.append(source)
+ outputs.append((alias, source, child))
+ if direct_columns & variants.keys():
+ raise ValueError(
+ "A VARIANT column cannot be read both whole and extracted")
+ return projection, variants or None, outputs
+
def _resolve_projection_paths(self, names: List[str]) ->
List[ProjectionPath]:
"""Translate ROW paths and MAP-key selectors into internal paths."""
table_fields = self.table.fields
diff --git a/paimon-python/pypaimon/read/stream_read_builder.py
b/paimon-python/pypaimon/read/stream_read_builder.py
index 0a2753eebe..e28819be4e 100644
--- a/paimon-python/pypaimon/read/stream_read_builder.py
+++ b/paimon-python/pypaimon/read/stream_read_builder.py
@@ -22,7 +22,7 @@ This module provides a builder for configuring streaming
reads from Paimon
tables, similar to ReadBuilder but for continuous streaming use cases.
"""
-from typing import Callable, List, Optional, Set
+from typing import Callable, Dict, List, Optional, Set, Union
from pypaimon.common.predicate import Predicate
from pypaimon.common.predicate_builder import PredicateBuilder
@@ -54,7 +54,7 @@ class StreamReadBuilder:
self.table: FileStoreTable = table
self._predicate: Optional[Predicate] = None
- self._projection: Optional[List[str]] = None
+ self._read_builder = ReadBuilder(table)
self._poll_interval_ms: int = 1000
self._include_row_kind: bool = False
self._bucket_filter: Optional[Callable[[int], bool]] = None
@@ -63,11 +63,15 @@ class StreamReadBuilder:
def with_filter(self, predicate: Predicate) -> 'StreamReadBuilder':
"""Set a filter predicate for the streaming read."""
self._predicate = predicate
+ self._read_builder.with_filter(predicate)
return self
- def with_projection(self, projection: List[str]) -> 'StreamReadBuilder':
+ def with_projection(
+ self,
+ projection: Union[List[str], Dict[str, str]],
+ ) -> 'StreamReadBuilder':
"""Set column projection for the streaming read."""
- self._projection = projection
+ self._read_builder.with_projection(projection)
return self
def with_poll_interval_ms(self, poll_interval_ms: int) ->
'StreamReadBuilder':
@@ -136,6 +140,7 @@ class StreamReadBuilder:
predicate=self._predicate,
read_type=projection.read_type(),
nested_name_paths=projection._nested_name_paths(),
+ expression_projection=projection._expression_projection,
include_row_kind=self._include_row_kind
)
@@ -153,9 +158,4 @@ class StreamReadBuilder:
def _projection_builder(self) -> ReadBuilder:
"""Share projection and validation semantics with batch reads."""
- builder = ReadBuilder(self.table)
- if self._projection is not None:
- builder.with_projection(self._projection)
- if self._predicate is not None:
- builder.with_filter(self._predicate)
- return builder
+ return self._read_builder
diff --git a/paimon-python/pypaimon/read/table_read.py
b/paimon-python/pypaimon/read/table_read.py
index 3ec796c68e..bef58ff0f5 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -20,7 +20,7 @@ import os
import queue
import threading
from concurrent.futures import ThreadPoolExecutor, as_completed
-from typing import Any, Callable, Dict, Iterator, List, Optional
+from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
import pandas
import pyarrow
@@ -38,6 +38,7 @@ from pypaimon.read.reader.auth_masking_reader import (
from pypaimon.read.reader.iface.record_batch_reader import RecordBatchReader
from pypaimon.read.reader.limited_record_reader import LimitedRecordBatchReader
from pypaimon.read.split import Split
+from pypaimon.read.variant_read_type import has_variant_extractions
from pypaimon.read.split_read import (DataEvolutionSplitRead,
MergeFileSplitRead, RawFileSplitRead,
SplitRead, deferred_blob_field_names)
@@ -147,6 +148,8 @@ class TableRead:
read_type: List[DataField],
include_row_kind: bool = False,
nested_name_paths: Optional[List[List[str]]] = None,
+ expression_projection: Optional[
+ List[Tuple[str, str, Optional[int]]]] = None,
limit: Optional[int] = None,
):
from pypaimon.read.merge_engine_support import check_supported
@@ -193,11 +196,16 @@ class TableRead:
)
self.include_row_kind = include_row_kind
self.nested_name_paths = nested_name_paths
+ self.expression_projection = expression_projection
self.limit = limit
self._read_parallelism = self.table.options.read_parallelism()
self._parquet_row_group_cache = None
def to_iterator(self, splits: List[Split]) -> Iterator:
+ if self.expression_projection is not None:
+ raise RuntimeError(
+ "Projection expressions are not supported by to_iterator(); "
+ "use to_arrow() or to_arrow_batch_reader()")
self._check_python_merge_supported()
self._begin_auth_read(splits)
limit = self.limit
@@ -274,7 +282,7 @@ class TableRead:
splits: List[Split],
blob_parallelism: Optional[int] = None,
parallelism: Optional[int] = None):
- schema = PyarrowFieldParser.from_paimon_schema(self.read_type)
+ schema = self._output_arrow_schema()
if self.include_row_kind:
schema = self._add_row_kind_to_schema(schema)
effective = self._effective_parallelism(parallelism, len(splits))
@@ -372,7 +380,7 @@ class TableRead:
"""
effective_bp = self._resolve_blob_parallelism(blob_parallelism)
effective = self._effective_parallelism(parallelism, len(splits))
- schema = PyarrowFieldParser.from_paimon_schema(self.read_type)
+ schema = self._output_arrow_schema()
if self.include_row_kind:
schema = self._add_row_kind_to_schema(schema)
@@ -423,6 +431,48 @@ class TableRead:
schema=schema)
return pyarrow.Table.from_batches(batches)
+ def _output_arrow_schema(self) -> pyarrow.Schema:
+ schema = PyarrowFieldParser.from_paimon_schema(self.read_type)
+ return self._apply_expression_projection_to_schema(
+ schema, self.expression_projection)
+
+ @staticmethod
+ def _apply_expression_projection_to_schema(
+ schema: pyarrow.Schema,
+ expressions: Optional[List[Tuple[str, str, Optional[int]]]],
+ ) -> pyarrow.Schema:
+ if expressions is None:
+ return schema
+ fields = []
+ for alias, source, child in expressions:
+ index = schema.get_field_index(source)
+ if index < 0:
+ raise ValueError("Projection source %r is not in the read
type" % source)
+ field = schema.field(index)
+ if child is None:
+ fields.append(field.with_name(alias))
+ else:
+ if not pyarrow.types.is_struct(field.type):
+ raise TypeError("Variant projection %r is not a struct" %
source)
+ fields.append(pyarrow.field(alias, field.type[child].type))
+ return pyarrow.schema(fields, metadata=schema.metadata)
+
+ def _native_fallback(
+ self,
+ reason: str,
+ error: Optional[Exception] = None,
+ log: bool = False,
+ ):
+ if has_variant_extractions(self.read_type):
+ raise RuntimeError(
+ "Variant extraction requires a compatible native reader: %s"
+ % reason) from error
+ if log:
+ logger.warning(
+ "Native read failed, falling back to the Python reader: %s",
+ reason)
+ return None
+
def _try_native_batches(
self,
splits: List[Split],
@@ -432,9 +482,10 @@ class TableRead:
streaming: bool = False):
"""Return Rust-read batches, or ``None`` when this read must fall
back."""
if not self.table.options.native_read_enabled():
- return None
+ return self._native_fallback("read.native.enabled is false")
if self.table.options.file_format() not in _NATIVE_READ_FILE_FORMATS:
- return None
+ return self._native_fallback(
+ "the table file format is not supported")
if not splits:
return []
sequence_fields = self.table.options.sequence_field()
@@ -444,37 +495,40 @@ class TableRead:
# Native merge cannot extract floating sequence values: they
# would silently become missing sequence values.
if any(pyarrow.types.is_floating(field.type) for field in
sequence_schema):
- return None
+ return self._native_fallback(
+ "floating sequence fields are not supported")
if not self._native_blob_view_supported():
- return None
+ return self._native_fallback(
+ "the table uses an unsupported BLOB view")
if (self._deferred_blob_limit_may_prune(splits)
and not self.table.options.data_evolution_enabled()):
- return None
+ return self._native_fallback(
+ "deferred BLOB LIMIT pruning requires the Python reader")
# Query authorization has additional filtering, masking and projection
# semantics which are already implemented by the Python reader.
if any(isinstance(split, QueryAuthSplit) for split in splits):
- return None
+ return self._native_fallback(
+ "query authorization requires the Python reader")
try:
from pypaimon.read.native_plan import (
_prepare_native_read, native_read, native_split_from_python)
except Exception as e:
- logger.warning(
- "Native read failed, falling back to the Python reader: %s", e)
- return None
+ return self._native_fallback(str(e), e, log=True)
rust_splits = []
split_weights = []
for split in splits:
if not self._native_split_files_supported(split):
- return None
+ return self._native_fallback(
+ "a split contains a file unsupported by the native reader")
rust_split = getattr(split, '_native_split', None)
if rust_split is None:
try:
rust_split = native_split_from_python(split)
except Exception as e:
- logger.warning(
- "Native split conversion failed, falling back to the "
- "Python reader: %s", e)
- return None
+ return self._native_fallback(
+ "native split conversion failed: %s" % e,
+ e,
+ log=True)
rust_splits.append(rust_split)
split_weights.append(self._native_split_weight(split))
if (parallelism is not None
@@ -483,9 +537,7 @@ class TableRead:
try:
read_splits = _prepare_native_read(self.table, **read_kwargs)
except Exception as e:
- logger.warning(
- "Native read failed, falling back to the Python reader:
%s", e)
- return None
+ return self._native_fallback(str(e), e, log=True)
if streaming:
groups = self._native_split_groups(
rust_splits, parallelism, split_weights)
@@ -505,9 +557,7 @@ class TableRead:
"another reader failed to start",
exc_info=True,
)
- logger.warning(
- "Native read failed, falling back to the Python
reader: %s", e)
- return None
+ return self._native_fallback(str(e), e, log=True)
batches = self._native_batches_parallel_streaming(readers)
return self._convert_native_batches(batches, schema)
try:
@@ -515,28 +565,26 @@ class TableRead:
read_splits, rust_splits, schema, parallelism,
split_weights)
except _NativeReadSetupError as e:
- logger.warning(
- "Native read failed, falling back to the Python reader:
%s", e)
- return None
+ return self._native_fallback(str(e), e, log=True)
try:
read_kwargs = self._native_read_kwargs(blob_parallelism)
batches = native_read(self.table, rust_splits, **read_kwargs)
except Exception as e:
- logger.warning(
- "Native read failed, falling back to the Python reader: %s", e)
- return None
+ return self._native_fallback(str(e), e, log=True)
return self._convert_native_batches(batches, schema)
def _native_read_kwargs(self, blob_parallelism=None):
kwargs = {
'predicate': self.predicate,
'limit': self.limit,
- 'projection': [field.name for field in self.read_type],
}
if blob_parallelism is not None:
kwargs['blob_parallelism'] = blob_parallelism
if self.nested_name_paths:
+ # Nested and MAP-key selectors still need their source paths.
kwargs['nested_projection'] = self.nested_name_paths
+ else:
+ kwargs['read_type'] = self.read_type
if self.include_row_kind:
kwargs['include_row_kind'] = True
return kwargs
@@ -874,7 +922,7 @@ class TableRead:
break
if len(row_tuple_chunk) >= chunk_size:
- yield from
self._convert_rows_to_arrow_batches_with_row_kind(
+ yield from
self._convert_rows_to_output_batches(
row_tuple_chunk, row_kind_chunk, schema
)
row_tuple_chunk = []
@@ -883,7 +931,7 @@ class TableRead:
break
if row_tuple_chunk:
- yield from
self._convert_rows_to_arrow_batches_with_row_kind(
+ yield from self._convert_rows_to_output_batches(
row_tuple_chunk, row_kind_chunk, schema
)
finally:
@@ -1105,17 +1153,34 @@ class TableRead:
row_kind_chunk.append(row.get_row_kind().to_string())
if len(row_tuple_chunk) >= chunk_size:
-
out.extend(self._convert_rows_to_arrow_batches_with_row_kind(
+ out.extend(self._convert_rows_to_output_batches(
row_tuple_chunk, row_kind_chunk, schema))
row_tuple_chunk = []
row_kind_chunk = []
if row_tuple_chunk:
-
out.extend(self._convert_rows_to_arrow_batches_with_row_kind(
+ out.extend(self._convert_rows_to_output_batches(
row_tuple_chunk, row_kind_chunk, schema))
finally:
reader.close()
return out
+ def _convert_rows_to_output_batches(
+ self,
+ row_tuples: List[tuple],
+ row_kinds: List[str],
+ schema: pyarrow.Schema,
+ ) -> Iterator[pyarrow.RecordBatch]:
+ if self.expression_projection is None:
+ yield from self._convert_rows_to_arrow_batches_with_row_kind(
+ row_tuples, row_kinds, schema)
+ return
+ physical_schema = PyarrowFieldParser.from_paimon_schema(self.read_type)
+ if self.include_row_kind:
+ physical_schema = self._add_row_kind_to_schema(physical_schema)
+ for batch in self._convert_rows_to_arrow_batches_with_row_kind(
+ row_tuples, row_kinds, physical_schema):
+ yield self._project_batch_to_output(batch)
+
def _convert_rows_to_arrow_batches_with_row_kind(
self,
row_tuples: List[tuple],
@@ -1243,7 +1308,7 @@ class TableRead:
import ray
if not splits:
- schema = PyarrowFieldParser.from_paimon_schema(self.read_type)
+ schema = self._output_arrow_schema()
if self.include_row_kind:
schema = self._add_row_kind_to_schema(schema)
empty_table = pyarrow.Table.from_arrays(
@@ -1266,6 +1331,7 @@ class TableRead:
predicate=self.predicate,
limit=self.limit,
nested_name_paths=self.nested_name_paths,
+ expression_projection=self.expression_projection,
include_row_kind=self.include_row_kind,
)
)
@@ -1338,6 +1404,10 @@ class TableRead:
):
raise ValueError("batch_size must be a positive int or None")
if batch_format == "row":
+ if self.expression_projection is not None:
+ raise RuntimeError(
+ "Projection expressions are not supported by Torch row
format; "
+ "use streaming=True with batch_format='pyarrow' or
'torch'")
if batch_size is not None:
raise ValueError(
"batch_size requires batch_format='pyarrow' or 'torch'"
@@ -1597,6 +1667,31 @@ class TableRead:
return tuple(converted)
def _project_batch_to_output(self, batch: pyarrow.RecordBatch) ->
pyarrow.RecordBatch:
+ if self.expression_projection is not None:
+ fields = []
+ arrays = []
+ if self.include_row_kind and ROW_KIND_COLUMN in batch.schema.names:
+ fields.append(batch.schema.field(ROW_KIND_COLUMN))
+ arrays.append(batch.column(ROW_KIND_COLUMN))
+ output_schema = self._output_arrow_schema()
+ for alias, source, child in self.expression_projection:
+ index = batch.schema.get_field_index(source)
+ if index < 0:
+ raise ValueError("Projection source %r is missing" %
source)
+ array = batch.column(index)
+ if child is not None:
+ if not pyarrow.types.is_struct(array.type):
+ raise TypeError("Variant projection %r is not a
struct" % source)
+ parent = array
+ array = parent.field(child)
+ if parent.null_count:
+ array = pyarrow_compute.if_else(
+ parent.is_null(),
+ pyarrow.scalar(None, type=array.type), array)
+ arrays.append(array)
+ fields.append(output_schema.field(alias))
+ return pyarrow.RecordBatch.from_arrays(
+ arrays, schema=pyarrow.schema(fields))
if not self._needs_output_projection():
return batch
output_names = list(self._output_column_names)
@@ -1611,7 +1706,7 @@ class TableRead:
arrays, schema=pyarrow.schema(fields))
def _needs_output_projection(self) -> bool:
- return bool(self._predicate_extra_fields)
+ return bool(self._predicate_extra_fields or self.expression_projection)
def _output_extract_name_paths(self) -> List[List[str]]:
return [[f.name] for f in self.read_type]
diff --git a/paimon-python/pypaimon/read/variant_read_type.py
b/paimon-python/pypaimon/read/variant_read_type.py
new file mode 100644
index 0000000000..28cd081345
--- /dev/null
+++ b/paimon-python/pypaimon/read/variant_read_type.py
@@ -0,0 +1,80 @@
+# 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.
+
+"""Variant extractions encoded in the reader's Paimon ROW read type."""
+
+from typing import Any, Dict, List, Optional
+
+import pyarrow
+
+from pypaimon.schema.data_types import AtomicType, DataField, RowType
+
+
+_VARIANT_METADATA_PREFIX = '__VARIANT_METADATA'
+
+
+def with_variant_extractions(
+ read_type: List[DataField],
+ extractions: Optional[Dict[str, Dict[str, Any]]]) -> List[DataField]:
+ """Resolve expression paths once, using Java's DataField description
format."""
+ if not extractions:
+ return read_type
+ fields = []
+ projected = set()
+ for field in read_type:
+ options = extractions.get(field.name)
+ if options is None:
+ fields.append(field)
+ continue
+ if not isinstance(field.type, AtomicType) or field.type.type.upper()
!= 'VARIANT':
+ raise ValueError("Variant extraction column %r must be VARIANT" %
field.name)
+ if options['target_type'] != pyarrow.float32():
+ raise ValueError("Variant extraction target type must be float32")
+ error_policy = options['fail_on_error']
+ if isinstance(error_policy, bool):
+ error_policy = [error_policy] * len(options['paths'])
+ if len(error_policy) != len(options['paths']):
+ raise ValueError("Variant paths and error policies must match")
+ children = []
+ for index, (path, fail_on_error) in enumerate(
+ zip(options['paths'], error_policy)):
+ if ';' in path:
+ raise ValueError(
+ "Variant extraction path must not contain ';': %s" % path)
+ children.append(DataField(
+ index, str(index), AtomicType('FLOAT'),
+ '%s%s;%s;UTC' % (
+ _VARIANT_METADATA_PREFIX, path,
str(fail_on_error).lower())))
+ projected.add(field.name)
+ fields.append(DataField(
+ field.id, field.name, RowType(field.type.nullable, children),
+ field.description, field.default_value))
+ missing = set(extractions) - projected
+ if missing:
+ raise ValueError(
+ "Variant extraction column %r is not in the read type" %
min(missing))
+ return fields
+
+
+def has_variant_extractions(read_type: List[DataField]) -> bool:
+ """Recognize typed Variant children from their read-type metadata."""
+ return any(
+ isinstance(field.type, RowType)
+ and bool(field.type.fields)
+ and all(child.description is not None
+ and child.description.startswith(_VARIANT_METADATA_PREFIX)
+ for child in field.type.fields)
+ for field in read_type)
diff --git a/paimon-python/pypaimon/tests/native_plan_test.py
b/paimon-python/pypaimon/tests/native_plan_test.py
index ccbc92e160..e2dad2f191 100644
--- a/paimon-python/pypaimon/tests/native_plan_test.py
+++ b/paimon-python/pypaimon/tests/native_plan_test.py
@@ -21,6 +21,7 @@ import unittest
from types import ModuleType, SimpleNamespace
from unittest.mock import Mock, call, patch
+import pyarrow
from pypaimon.catalog.catalog_context import CatalogContext
from pypaimon.catalog.filesystem_catalog_loader import FileSystemCatalogLoader
from pypaimon.catalog.jdbc_catalog_loader import JdbcCatalogLoader
@@ -33,6 +34,7 @@ from pypaimon.globalindex.global_index_result import
GlobalIndexResult
from pypaimon.globalindex.vector_search_result import ScoredGlobalIndexResult
from pypaimon.read.native_plan import (
_catalog_options,
+ _configure_native_read_builder,
_native_read_builder,
_predicate_to_native,
_resolved_schema_json,
@@ -43,12 +45,18 @@ from pypaimon.read.native_plan import (
)
from pypaimon.read.plan import Plan
from pypaimon.read.table_scan import TableScan
+from pypaimon.read.variant_read_type import with_variant_extractions
from pypaimon.schema.data_types import AtomicType, DataField, MapType,
MultisetType, RowType
from pypaimon.schema.table_schema import TableSchema
from pypaimon.table.bucket_mode import BucketMode
from pypaimon.utils.range import Range
+def _variant_read_type_json(read_type, options):
+ return json.dumps(RowType(True, with_variant_extractions(
+ read_type, options)).to_dict())
+
+
def _scan(native_enabled, file_scanner):
"""Build a TableScan without running its heavy __init__."""
scan = TableScan.__new__(TableScan)
@@ -92,6 +100,90 @@ def _scan(native_enabled, file_scanner):
class NativePlanTest(unittest.TestCase):
+ def test_variant_read_type_matches_java_interop_fixture(self):
+ actual = _variant_read_type_json(
+ [DataField(7, 'payload', AtomicType('VARIANT'))],
+ {'payload': {
+ 'paths': ['$.x'],
+ 'target_type': pyarrow.float32(),
+ 'fail_on_error': False,
+ }})
+ # Parsed by Java
VariantMetadataUtilsTest.testReadTypeJsonInteropWithRustAndPython.
+ java_fixture = (
+ '{"type":"ROW","fields":[{"id":7,"name":"payload",'
+ '"type":{"type":"ROW","fields":[{"id":0,"name":"0",'
+ '"type":"FLOAT","description":'
+ '"__VARIANT_METADATA$.x;false;UTC"}],"nullable":true}}],'
+ '"nullable":true}'
+ )
+ self.assertEqual(json.loads(actual), json.loads(java_fixture))
+
+ strict = _variant_read_type_json(
+ [DataField(7, 'payload', AtomicType('VARIANT'))],
+ {'payload': {
+ 'paths': ['$.x'],
+ 'target_type': pyarrow.float32(),
+ 'fail_on_error': True,
+ }})
+ self.assertEqual(
+
json.loads(strict)['fields'][0]['type']['fields'][0]['description'],
+ '__VARIANT_METADATA$.x;true;UTC')
+
+ mixed = _variant_read_type_json(
+ [DataField(7, 'payload', AtomicType('VARIANT'))],
+ {'payload': {
+ 'paths': ['$.x', '$.y'],
+ 'target_type': pyarrow.float32(),
+ 'fail_on_error': [False, True],
+ }})
+ children = json.loads(mixed)['fields'][0]['type']['fields']
+ self.assertEqual(
+ [child['description'] for child in children],
+ ['__VARIANT_METADATA$.x;false;UTC',
+ '__VARIANT_METADATA$.y;true;UTC'])
+
+ def test_variant_read_type_rejects_semicolon_path(self):
+ with self.assertRaisesRegex(ValueError, "must not contain ';'"):
+ _variant_read_type_json(
+ [DataField(7, 'payload', AtomicType('VARIANT'))],
+ {'payload': {
+ 'paths': ["$['a;b']"],
+ 'target_type': pyarrow.float32(),
+ 'fail_on_error': False,
+ }})
+
+ def test_native_builder_receives_variant_read_type(self):
+ builder = Mock()
+ builder.with_read_type.return_value = builder
+ variant_fields = {
+ 'payload': {
+ 'paths': ['$.ratio'],
+ 'target_type': pyarrow.float32(),
+ 'fail_on_error': False,
+ }
+ }
+ read_type = [
+ DataField(1, 'id', AtomicType('INT')),
+ DataField(2, 'payload', AtomicType('VARIANT')),
+ ]
+
+ result = _configure_native_read_builder(
+ builder,
+ predicate=None,
+ limit=None,
+ projection=['id', 'payload'],
+ read_type=with_variant_extractions(read_type, variant_fields),
+ )
+
+ self.assertIs(result, builder)
+ fields = json.loads(builder.with_read_type.call_args.args[0])['fields']
+ self.assertEqual([field['name'] for field in fields], ['id',
'payload'])
+ self.assertEqual(fields[1]['id'], 2)
+ self.assertEqual(fields[1]['type']['fields'][0]['type'], 'FLOAT')
+ self.assertEqual(fields[1]['type']['fields'][0]['description'],
+ '__VARIANT_METADATA$.ratio;false;UTC')
+ builder.with_projection.assert_not_called()
+
def setUp(self):
# Make the real capability probe see a split-API-capable pypaimon-rust
so
# gate tests route natively. Tests that call native_plan() directly
override
diff --git a/paimon-python/pypaimon/tests/native_read_test.py
b/paimon-python/pypaimon/tests/native_read_test.py
index f57c9deff0..ba466939c3 100644
--- a/paimon-python/pypaimon/tests/native_read_test.py
+++ b/paimon-python/pypaimon/tests/native_read_test.py
@@ -23,6 +23,7 @@ import pytest
from pypaimon.read.query_auth_split import QueryAuthSplit
from pypaimon.read.table_read import TableRead
+from pypaimon.read.variant_read_type import with_variant_extractions
from pypaimon.schema.data_types import AtomicType, DataField
@@ -47,6 +48,7 @@ def _table_read(limit=None):
read.read_type = [DataField(0, 'id', AtomicType('INT'))]
read.include_row_kind = False
read.nested_name_paths = None
+ read.expression_projection = None
read.limit = limit
read._read_parallelism = 1
read._deferred_blob_fields = set()
@@ -69,6 +71,72 @@ def _id_batch(values):
[pa.array(values, type=pa.int32())], names=['id'])
+def test_native_read_returns_named_variant_expression_columns():
+ read = _table_read()
+ variants = {
+ 'payload': {
+ 'paths': ['$.ratio', '$.missing'],
+ 'target_type': pa.float32(),
+ 'fail_on_error': [False, True],
+ }
+ }
+ read.read_type = with_variant_extractions([
+ DataField(0, 'id', AtomicType('INT')),
+ DataField(1, 'payload', AtomicType('VARIANT')),
+ ], variants)
+ read._scan_read_type = read.read_type
+ read._output_column_names = ['id', 'payload']
+ read.expression_projection = [
+ ('identifier', 'id', None),
+ ('ratio', 'payload', 0),
+ ('missing', 'payload', 1),
+ ]
+ split = _Split()
+ split._native_split = object()
+ payload_type = pa.struct([
+ pa.field('0', pa.float32()),
+ pa.field('1', pa.float32()),
+ ])
+ batch = pa.record_batch([
+ pa.array([1, 2, 3], type=pa.int32()),
+ pa.array([
+ {'0': 1.25, '1': None},
+ {'0': 2.5, '1': None},
+ None,
+ ], type=payload_type),
+ ], names=['id', 'payload'])
+
+ with patch('pypaimon.read.native_plan.native_read',
+ return_value=[batch]) as native:
+ result = read.to_arrow([split])
+
+ assert result.column_names == ['identifier', 'ratio', 'missing']
+ assert result.schema.field('ratio').type == pa.float32()
+ assert result.column('identifier').to_pylist() == [1, 2, 3]
+ assert result.column('ratio').to_pylist() == [1.25, 2.5, None]
+ assert result.column('missing').to_pylist() == [None, None, None]
+ assert 'variant_fields' not in native.call_args.kwargs
+ assert native.call_args.kwargs['read_type'] == read.read_type
+
+
+def test_variant_fields_never_silently_falls_back_to_python():
+ read = _table_read()
+ read.read_type = with_variant_extractions(
+ [DataField(0, 'payload', AtomicType('VARIANT'))],
+ {'payload': {
+ 'paths': ['$.ratio'],
+ 'target_type': pa.float32(),
+ 'fail_on_error': False,
+ }})
+ read._scan_read_type = read.read_type
+ read._output_column_names = ['payload']
+ read.expression_projection = [('ratio', 'payload', 0)]
+ read.table.options.native_read_enabled.return_value = False
+
+ with pytest.raises(RuntimeError, match='read.native.enabled is false'):
+ read.to_arrow([_Split()])
+
+
@pytest.mark.parametrize('type_', ['FLOAT', 'DOUBLE'])
def test_floating_sequence_falls_back_before_native_read(type_):
read = _table_read()
@@ -100,7 +168,7 @@ def
test_native_read_consumes_retained_rust_splits_and_enforces_limit():
[first._native_split, second._native_split],
predicate=None,
limit=2,
- projection=['id'],
+ read_type=read.read_type,
blob_parallelism=1,
)
@@ -196,7 +264,7 @@ def test_native_read_bridges_python_planned_split():
[converted],
predicate=None,
limit=None,
- projection=['id'],
+ read_type=read.read_type,
)
diff --git a/paimon-python/pypaimon/tests/py36/named_variant_projection_test.py
b/paimon-python/pypaimon/tests/py36/named_variant_projection_test.py
new file mode 100644
index 0000000000..9cf59a57c0
--- /dev/null
+++ b/paimon-python/pypaimon/tests/py36/named_variant_projection_test.py
@@ -0,0 +1,53 @@
+# 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 unittest
+from unittest.mock import Mock
+
+from pypaimon.read.read_builder import ReadBuilder
+from pypaimon.schema.data_types import AtomicType, DataField
+
+
+class NamedVariantProjectionTest(unittest.TestCase):
+
+ def test_string_literals_work_on_supported_python_versions(self):
+ table = Mock()
+ table.fields = [
+ DataField(0, 'id', AtomicType('INT')),
+ DataField(1, 'payload', AtomicType('VARIANT')),
+ ]
+ table.options.row_tracking_enabled.return_value = False
+
+ builder = ReadBuilder(table).with_projection({
+ 'identifier': 'id',
+ 'x': "try_variant_get(payload, '$.x', 'float')",
+ 'y': 'variant_get("payload", "$.y", "float")',
+ })
+
+ self.assertEqual(builder._projection, ['id', 'payload'])
+ self.assertEqual(
+ [field.description for field in
builder.read_type()[1].type.fields],
+ ['__VARIANT_METADATA$.x;false;UTC',
+ '__VARIANT_METADATA$.y;true;UTC'])
+ self.assertEqual(builder._expression_projection, [
+ ('identifier', 'id', None),
+ ('x', 'payload', 0),
+ ('y', 'payload', 1),
+ ])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/paimon-python/pypaimon/tests/ray_integration_test.py
b/paimon-python/pypaimon/tests/ray_integration_test.py
index 05aff4bc63..c3e47ae5f9 100644
--- a/paimon-python/pypaimon/tests/ray_integration_test.py
+++ b/paimon-python/pypaimon/tests/ray_integration_test.py
@@ -176,6 +176,78 @@ class RayIntegrationTest(unittest.TestCase):
self.assertEqual(set(df.columns), {'id', 'name'})
self.assertEqual(len(df), 2)
+ def test_read_paimon_with_named_projection(self):
+ from pypaimon.ray import read_paimon
+
+ pa_schema = pa.schema([('id', pa.int32()), ('name', pa.string())])
+ identifier = self._create_and_populate_table(
+ 'test_read_named_proj', pa_schema,
+ {'id': [1, 2], 'name': ['a', 'b']},
+ )
+ projection = {'identifier': 'id', 'label': 'name'}
+ ds = read_paimon(identifier, self.catalog_options,
+ projection=projection)
+ self.assertEqual(ds.schema().names, list(projection))
+ rows = sorted(ds.take_all(), key=lambda row: row['identifier'])
+ self.assertEqual(rows, [
+ {'identifier': 1, 'label': 'a'},
+ {'identifier': 2, 'label': 'b'},
+ ])
+
+ empty_schema = Schema.from_pyarrow_schema(pa_schema)
+ catalog = CatalogFactory.create(self.catalog_options)
+ catalog.create_table('default.test_empty_named_proj',
+ empty_schema, False)
+ empty = read_paimon('default.test_empty_named_proj',
+ self.catalog_options, projection=projection)
+ self.assertEqual(empty.schema().names, list(projection))
+ self.assertEqual(empty.count(), 0)
+
+ def test_ray_named_variant_projection_uses_resolved_read_type(self):
+ from pypaimon.data.generic_variant import GenericVariant
+ from pypaimon.read.datasource.ray_datasource import RayDatasource
+ from pypaimon.read.datasource.split_provider import
CatalogSplitProvider
+ from pypaimon.read.native_plan import (
+ native_method_available, native_split_bridge_available)
+ from pypaimon.ray import read_paimon
+
+ variant_type = pa.struct([
+ pa.field('value', pa.binary(), nullable=False),
+ pa.field('metadata', pa.binary(), nullable=False),
+ ])
+ schema = pa.schema([('id', pa.int32()), ('payload', variant_type)])
+ identifier = self._create_and_populate_table(
+ 'test_ray_named_variant_read_type', schema,
+ {'id': [1], 'payload': GenericVariant.to_arrow_array([
+ GenericVariant.from_python({'x': 1.25})])},
+ options={'read.native.enabled': 'true'},
+ )
+ expressions = {
+ 'identifier': 'id',
+ 'x': "try_variant_get(payload, '$.x', 'float')",
+ }
+ table =
CatalogFactory.create(self.catalog_options).get_table(identifier)
+ batch_type =
table.new_read_builder().with_projection(expressions).read_type()
+ stream_type = table.new_stream_read_builder().with_projection(
+ expressions).read_type()
+ provider = CatalogSplitProvider(
+ identifier, self.catalog_options, projection=expressions)
+ datasource = RayDatasource(provider)
+ self.assertEqual(batch_type, stream_type)
+ self.assertEqual(batch_type, provider.read_type())
+ self.assertEqual(
+ batch_type[1].type.fields[0].description,
+ '__VARIANT_METADATA$.x;false;UTC')
+ self.assertEqual(len(datasource.get_read_tasks(1)), 1)
+ self.assertEqual(datasource._schema.names, list(expressions))
+ self.assertEqual(datasource._schema.field('x').type, pa.float32())
+ if (native_split_bridge_available()
+ and native_method_available('ReadBuilder', 'with_read_type')):
+ rows = read_paimon(
+ identifier, self.catalog_options,
+ projection=expressions).take_all()
+ self.assertEqual(rows, [{'identifier': 1, 'x': 1.25}])
+
def test_read_paimon_count_with_query_auth(self):
from pypaimon.ray import read_paimon
diff --git a/paimon-python/pypaimon/tests/split_provider_test.py
b/paimon-python/pypaimon/tests/split_provider_test.py
index 7602a9978e..b14fce0889 100644
--- a/paimon-python/pypaimon/tests/split_provider_test.py
+++ b/paimon-python/pypaimon/tests/split_provider_test.py
@@ -100,6 +100,15 @@ class SplitProviderTest(unittest.TestCase):
field_names = [f.name for f in read_type]
self.assertEqual(field_names, ['id'])
+ named = CatalogSplitProvider(
+ table_identifier=self.identifier,
+ catalog_options=self.catalog_options,
+ projection={'identifier': 'id'},
+ )
+ self.assertEqual([field.name for field in named.read_type()], ['id'])
+ self.assertEqual(named.expression_projection(),
+ [('identifier', 'id', None)])
+
def test_catalog_provider_propagates_predicate(self):
"""``predicate`` is held on the provider and surfaced via
predicate()."""
catalog = CatalogFactory.create(self.catalog_options)
@@ -285,8 +294,11 @@ class SplitProviderTest(unittest.TestCase):
self.assertFalse(provider.include_row_kind())
provider = PreResolvedSplitProvider(
- table, splits, read_type, include_row_kind=True)
+ table, splits, read_type, include_row_kind=True,
+ expression_projection=[('alias', 'id', None)])
self.assertTrue(provider.include_row_kind())
+ self.assertEqual([('alias', 'id', None)],
+ provider.expression_projection())
if __name__ == '__main__':
diff --git a/paimon-python/pypaimon/tests/stream_read_builder_test.py
b/paimon-python/pypaimon/tests/stream_read_builder_test.py
index 459c896481..de0667c27a 100644
--- a/paimon-python/pypaimon/tests/stream_read_builder_test.py
+++ b/paimon-python/pypaimon/tests/stream_read_builder_test.py
@@ -17,12 +17,13 @@
"""Tests for StreamReadBuilder."""
-from unittest.mock import MagicMock
+from unittest.mock import MagicMock, patch
import pytest
from pypaimon.read.stream_read_builder import StreamReadBuilder
from pypaimon.read.streaming_table_scan import AsyncStreamingTableScan
+from pypaimon.schema.data_types import AtomicType, DataField
class MockEntry:
@@ -99,6 +100,24 @@ class TestStreamReadBuilderValidation:
assert builder._bucket_filter is not None
assert builder._include_row_kind is True
+ def test_named_variant_projection_is_read_not_plan_schema(self,
mock_table):
+ mock_table.fields = [
+ DataField(0, 'id', AtomicType('INT')),
+ DataField(1, 'payload', AtomicType('VARIANT')),
+ ]
+ builder = StreamReadBuilder(mock_table).with_projection({
+ 'id_alias': 'id',
+ 'ratio': "try_variant_get(payload, '$.ratio', 'float')",
+ })
+ scan = builder.new_streaming_scan()
+ assert [field.name for field in scan._read_type] == ['id', 'payload']
+ with patch('pypaimon.read.stream_read_builder.TableRead') as read:
+ builder.new_read()
+ assert read.call_args.kwargs['expression_projection'] == [
+ ('id_alias', 'id', None), ('ratio', 'payload', 0)]
+ assert
(read.call_args.kwargs['read_type'][1].type.fields[0].description
+ == '__VARIANT_METADATA$.ratio;false;UTC')
+
class TestAsyncStreamingTableScanFiltering:
"""Test AsyncStreamingTableScan._filter_entries_for_shard()."""
diff --git
a/paimon-python/pypaimon/tests/test_read_builder_nested_projection.py
b/paimon-python/pypaimon/tests/test_read_builder_nested_projection.py
index 4897bd96b1..df99ec907e 100644
--- a/paimon-python/pypaimon/tests/test_read_builder_nested_projection.py
+++ b/paimon-python/pypaimon/tests/test_read_builder_nested_projection.py
@@ -19,10 +19,17 @@ import os
import shutil
import tempfile
import unittest
+from unittest.mock import Mock, patch
import pyarrow as pa
from pypaimon import CatalogFactory, Schema
+from pypaimon.read.datasource.split_provider import (
+ CatalogSplitProvider, PreResolvedSplitProvider)
+from pypaimon.read.read_builder import ReadBuilder
+from pypaimon.read.stream_read_builder import StreamReadBuilder
+from pypaimon.read.table_read import _RemainingRows
+from pypaimon.schema.data_types import AtomicType, DataField, RowType
class _ReadBuilderTestBase(unittest.TestCase):
@@ -60,6 +67,196 @@ class _ReadBuilderTestBase(unittest.TestCase):
class ReadBuilderProjectionStateTest(_ReadBuilderTestBase):
+ @staticmethod
+ def _variant_descriptions(builder, column='payload'):
+ field = next(field for field in builder.read_type()
+ if field.name == column)
+ return [child.description for child in field.type.fields]
+
+ def test_named_variant_expressions_group_paths_and_error_policies(self):
+ table = Mock()
+ table.fields = [
+ DataField(0, 'id', AtomicType('INT')),
+ DataField(1, 'payload', AtomicType('VARIANT')),
+ ]
+ builder = ReadBuilder(table).with_projection(
+ {'identifier': 'id',
+ 'ratio': "try_variant_get(payload, '$.ratio', 'float')",
+ 'age': "variant_get(payload, '$.age', 'float')"})
+
+ self.assertEqual(['id', 'payload'], builder._projection)
+ self.assertEqual(
+ ['__VARIANT_METADATA$.ratio;false;UTC',
+ '__VARIANT_METADATA$.age;true;UTC'],
+ self._variant_descriptions(builder))
+ self.assertEqual(
+ [('identifier', 'id', None), ('ratio', 'payload', 0),
+ ('age', 'payload', 1)], builder._expression_projection)
+
+ builder.with_projection(['id'])
+ self.assertEqual(['id'], [field.name for field in builder.read_type()])
+ self.assertIsNone(builder._expression_projection)
+
+ def
test_variant_read_type_is_shared_by_batch_stream_and_ray_providers(self):
+ table = Mock()
+ table.fields = [
+ DataField(0, 'id', AtomicType('INT')),
+ DataField(1, 'payload', AtomicType('VARIANT')),
+ ]
+ table.options.row_tracking_enabled.return_value = False
+ expressions = {
+ 'identifier': 'id',
+ 'ratio': "try_variant_get(payload, '$.ratio', 'float')",
+ }
+ batch = ReadBuilder(table).with_projection(expressions)
+ stream = StreamReadBuilder(table).with_projection(expressions)
+ expected = batch.read_type()
+
+ with patch('pypaimon.read.read_builder.TableRead') as batch_read:
+ batch.new_read()
+ with patch('pypaimon.read.stream_read_builder.TableRead') as
stream_read:
+ stream.new_read()
+ self.assertEqual(expected, batch_read.call_args.kwargs['read_type'])
+ self.assertEqual(expected, stream_read.call_args.kwargs['read_type'])
+ self.assertEqual(expected, stream.new_streaming_scan()._read_type)
+
+ pre_resolved = PreResolvedSplitProvider(table, [], expected)
+ self.assertIs(expected, pre_resolved.read_type())
+ catalog = CatalogSplitProvider('default.t', {}, projection=expressions)
+ with patch.object(catalog, '_ensure_table', return_value=table), \
+ patch('pypaimon.read.read_builder.ReadBuilder.new_scan') as
scan:
+ scan.return_value.plan.return_value.splits.return_value = []
+ self.assertEqual(expected, catalog.read_type())
+
+ batch.with_projection(['id'])
+ stream.with_projection(['id'])
+ self.assertEqual([DataField(0, 'id', AtomicType('INT'))],
+ batch.read_type())
+ self.assertEqual(batch.read_type(), stream.read_type())
+
+ def test_variant_target_type_matches_native_float32_only(self):
+ table = Mock()
+ table.fields = [DataField(1, 'payload', AtomicType('VARIANT'))]
+
+ builder = ReadBuilder(table).with_projection(
+ {'x': "try_variant_get(payload, '$.x', 'float')"})
+ self.assertEqual(AtomicType('FLOAT'),
+ builder.read_type()[0].type.fields[0].type)
+ upper = ReadBuilder(table).with_projection(
+ {'x': "TRY_VARIANT_GET(payload, '$.x', 'FLOAT')"})
+ self.assertEqual(
+ ['__VARIANT_METADATA$.x;false;UTC'],
+ self._variant_descriptions(upper))
+
+ for target_type in ('double', 'int', 'string', 'timestamp', 'float32'):
+ with self.subTest(target_type=target_type):
+ with self.assertRaisesRegex(ValueError, 'Only float32'):
+ ReadBuilder(table).with_projection(
+ {'x': "try_variant_get(payload, '$.x', '%s')"
+ % target_type})
+
+ def test_variant_path_rejects_java_metadata_delimiter(self):
+ table = Mock()
+ table.fields = [DataField(1, 'payload', AtomicType('VARIANT'))]
+
+ with self.assertRaisesRegex(ValueError, "must not contain ';'"):
+ ReadBuilder(table).with_projection(
+ {'x': 'try_variant_get(payload, "$[\'a;b\']", "float")'})
+
+ def test_variant_path_preserves_sql_escaped_quotes(self):
+ table = Mock()
+ table.fields = [DataField(1, 'payload', AtomicType('VARIANT'))]
+ for function in ('variant_get', 'try_variant_get'):
+ with self.subTest(function=function):
+ builder = ReadBuilder(table).with_projection({
+ 'x': "%s(payload, '$[\"it''s\"]', 'float')" % function,
+ })
+ self.assertEqual(
+ ['__VARIANT_METADATA$["it\'s"];%s;UTC' %
+ ('true' if function == 'variant_get' else 'false')],
+ self._variant_descriptions(builder))
+ with self.assertRaisesRegex(ValueError, 'Adjacent string literals'):
+ ReadBuilder(table).with_projection({
+ 'x': "try_variant_get(payload, '$.it' 's', 'float')",
+ })
+
+ def test_named_projection_rejects_unknown_and_non_variant_sources(self):
+ table = Mock()
+ table.fields = [
+ DataField(0, 'id', AtomicType('INT')),
+ DataField(1, 'payload', AtomicType('VARIANT')),
+ ]
+
+ with self.assertRaisesRegex(ValueError, 'Unsupported projection'):
+ ReadBuilder(table).with_projection(
+ {'missing': 'not_a_column'})
+ with self.assertRaisesRegex(ValueError, 'requires a VARIANT'):
+ ReadBuilder(table).with_projection(
+ {'x': "try_variant_get(id, '$.x', 'float')"})
+ with self.assertRaisesRegex(ValueError, 'both whole and extracted'):
+ ReadBuilder(table).with_projection({
+ 'whole': 'payload',
+ 'x': "try_variant_get(payload, '$.x', 'float')"})
+
+ def test_named_projection_accepts_literal_top_level_punctuation(self):
+ table = Mock()
+ table.fields = [
+ DataField(0, 'id.dot', AtomicType('INT')),
+ DataField(1, 'payload', AtomicType('VARIANT')),
+ DataField(2, 'payload.dot', AtomicType('VARIANT')),
+ DataField(3, 'payload[raw]', AtomicType('VARIANT')),
+ ]
+ table.options.row_tracking_enabled.return_value = False
+
+ for projection, column, sources in (
+ ({'x': 'try_variant_get("payload.dot", "$.ratio", "float")'},
+ 'payload.dot', ['payload.dot']),
+ ({'x': 'try_variant_get("payload[raw]", "$.ratio", "float")'},
+ 'payload[raw]', ['payload[raw]']),
+ ({'id': 'id.dot',
+ 'x': 'try_variant_get(payload, "$.ratio", "float")'},
+ 'payload', ['id.dot', 'payload']),
+ ):
+ with self.subTest(projection=projection):
+ batch = ReadBuilder(table).with_projection(projection)
+ stream = StreamReadBuilder(table).with_projection(projection)
+ self.assertIsNone(batch._nested_paths)
+ self.assertIsNone(batch._nested_name_paths())
+ self.assertEqual(
+ sources, [field.name for field in batch.read_type()])
+ self.assertIsNone(stream._nested_name_paths())
+ self.assertEqual(
+ sources, [field.name for field in stream.read_type()])
+ with patch('pypaimon.read.read_builder.TableRead') as read:
+ batch.new_read()
+ self.assertIsNone(
+ read.call_args.kwargs['nested_name_paths'])
+ fields = read.call_args.kwargs['read_type']
+ self.assertIn(column, [field.name for field in fields])
+ with patch('pypaimon.read.stream_read_builder.TableRead') as
read:
+ stream.new_read()
+ self.assertIsNone(
+ read.call_args.kwargs['nested_name_paths'])
+ fields = read.call_args.kwargs['read_type']
+ self.assertIn(column, [field.name for field in fields])
+
+ def test_mapping_rejects_other_expressions(self):
+ table = Mock()
+ table.fields = [
+ DataField(0, 'row', RowType(True, [
+ DataField(1, 'value', AtomicType('INT'))])),
+ DataField(2, 'payload', AtomicType('VARIANT')),
+ ]
+ table.options.row_tracking_enabled.return_value = False
+ for expression in (
+ 'row.value', 'payload + 1',
+ "try_variant_get(payload, '$.ratio', 'float') + 1",
+ "try_variant_get(payload, '$.ratio', 'float', 1)",
+ ):
+ with self.subTest(expression=expression):
+ with self.assertRaisesRegex(ValueError, 'Unsupported
projection'):
+ ReadBuilder(table).with_projection({'x': expression})
+
def test_no_projection_returns_full_schema(self):
rb = self.table.new_read_builder()
fields = rb.read_type()
@@ -78,6 +275,57 @@ class ReadBuilderProjectionStateTest(_ReadBuilderTestBase):
# No nested paths derived; only names are stored.
self.assertIsNone(rb._nested_paths)
+ def test_named_ordinary_columns_keep_aliases_without_native_read(self):
+ read = self.table.new_read_builder().with_projection({
+ 'value': 'val', 'key': 'pk'}).new_read()
+ self.assertEqual(['value', 'key'], read._output_arrow_schema().names)
+ batch = pa.record_batch([
+ pa.array(['x']), pa.array([7], type=pa.int64()),
+ ], names=['val', 'pk'])
+ projected = read._project_batch_to_output(batch)
+ self.assertEqual(['value', 'key'], projected.schema.names)
+ self.assertEqual(['x'], projected.column('value').to_pylist())
+ self.assertEqual([7], projected.column('key').to_pylist())
+
+ def test_named_duplicate_columns_after_primary_key_merge(self):
+ pa_schema = pa.schema([
+ pa.field('pk', pa.int64(), nullable=False),
+ pa.field('val', pa.int64()),
+ ])
+ schema = Schema.from_pyarrow_schema(
+ pa_schema, primary_keys=['pk'], options={
+ 'bucket': '1', 'file.format': 'parquet',
+ 'read.native.enabled': 'false',
+ })
+ self.catalog.create_table('default.rb_named_duplicate_pk', schema,
False)
+ table = self.catalog.get_table('default.rb_named_duplicate_pk')
+ for value in (20, 30):
+ write_builder = table.new_batch_write_builder()
+ writer = write_builder.new_write()
+ commit = write_builder.new_commit()
+ writer.write_arrow(pa.Table.from_arrays([
+ pa.array([1], type=pa.int64()),
+ pa.array([value], type=pa.int64()),
+ ], schema=pa_schema))
+ commit.commit(writer.prepare_commit())
+ writer.close()
+ commit.close()
+
+ builder = table.new_read_builder().with_projection({
+ 'one': 'pk', 'two': 'pk', 'value': 'val',
+ })
+ splits = builder.new_scan().plan().splits()
+ read = builder.new_read()
+ expected = {'one': [1], 'two': [1], 'value': [30]}
+ self.assertEqual(
+ expected, read.to_arrow(splits, parallelism=1).to_pydict())
+ self.assertEqual(
+ expected, read.to_arrow_batch_reader(
+ splits, parallelism=1).read_all().to_pydict())
+ batches = read._read_one_split_to_batches(
+ splits[0], read._output_arrow_schema(), _RemainingRows(None))
+ self.assertEqual(expected, pa.Table.from_batches(batches).to_pydict())
+
def test_dotted_name_resolves_to_nested_path(self):
rb = self.table.new_read_builder().with_projection(
['mv.latest_version', 'pk'])
@@ -91,8 +339,8 @@ class ReadBuilderProjectionStateTest(_ReadBuilderTestBase):
def test_dotted_name_unknown_top_silently_skipped(self):
rb = self.table.new_read_builder().with_projection(
['nope.x', 'val'])
- # Only 'val' resolved; the dot trigger still populates _nested_paths.
- self.assertEqual(rb._nested_paths, [[2]])
+ # Only 'val' resolved, with no nested field actually selected.
+ self.assertIsNone(rb._nested_paths)
names = [f.name for f in rb.read_type()]
self.assertEqual(names, ['val'])
@@ -100,7 +348,7 @@ class ReadBuilderProjectionStateTest(_ReadBuilderTestBase):
rb = self.table.new_read_builder().with_projection(
['mv.no_such_subfield', 'pk'])
# The bad path drops out, the plain name survives.
- self.assertEqual(rb._nested_paths, [[0]])
+ self.assertIsNone(rb._nested_paths)
names = [f.name for f in rb.read_type()]
self.assertEqual(names, ['pk'])
diff --git a/paimon-python/pypaimon/tests/variant_test.py
b/paimon-python/pypaimon/tests/variant_test.py
index 6e85718d17..4a27543fe0 100644
--- a/paimon-python/pypaimon/tests/variant_test.py
+++ b/paimon-python/pypaimon/tests/variant_test.py
@@ -40,6 +40,7 @@ Sections
shredded VARIANT.
"""
+import asyncio
import io
import json
import os
@@ -74,6 +75,7 @@ from pypaimon.data.variant_shredding import (
rebuild_value,
shredding_schema_to_arrow_type,
)
+from pypaimon.read.native_plan import native_split_bridge_available
from pypaimon.schema.data_types import (
AtomicType,
DataField,
@@ -101,6 +103,16 @@ def _variant_arrow_type() -> pa.StructType:
])
+def _native_variant_projection_available() -> bool:
+ if not native_split_bridge_available():
+ return False
+ try:
+ from pypaimon_rust.datafusion import ReadBuilder as NativeReadBuilder
+ return callable(getattr(NativeReadBuilder, 'with_read_type', None))
+ except (ImportError, AttributeError, TypeError, ValueError):
+ return False
+
+
def _make_metadata(*keys: str) -> bytes:
"""Build variant metadata bytes. With no keys returns the minimal
header."""
if keys:
@@ -1194,6 +1206,224 @@ class TestVariantPaimonTable(unittest.TestCase):
pa.field('payload', _variant_arrow_type()),
])
+ def test_native_variant_projection_rejects_row_readers(self):
+ for mode, options in (
+ ('append', {}),
+ ('lazy', {
+ 'data-evolution.enabled': 'true',
+ 'row-tracking.enabled': 'true',
+ }),
+ ):
+ with self.subTest(mode=mode):
+ identifier = 'default.variant_projection_row_' + mode
+ schema = Schema.from_pyarrow_schema(
+ self._pa_schema(), options=options)
+ self.catalog.create_table(identifier, schema, False)
+ table = self.catalog.get_table(identifier)
+ data = pa.table({
+ 'id': [1],
+ 'payload': GenericVariant.to_arrow_array([
+ GenericVariant.from_python({'ratio': 1.25})]),
+ }, schema=self._pa_schema())
+ write_builder = table.new_batch_write_builder()
+ writer = write_builder.new_write()
+ commit = write_builder.new_commit()
+ writer.write_arrow(data)
+ commit.commit(writer.prepare_commit())
+ writer.close()
+ commit.close()
+
+ builder = table.new_read_builder().with_projection(
+ {'ratio': "try_variant_get(payload, '$.ratio', 'float')"})
+ splits = builder.new_scan().plan().splits()
+ self.assertTrue(splits)
+ read = builder.new_read()
+
+ with self.assertRaisesRegex(RuntimeError, 'to_iterator'):
+ read.to_iterator(splits)
+ with self.assertRaisesRegex(RuntimeError, 'Torch row format'):
+ read.to_torch(splits, streaming=True)
+ with self.assertRaisesRegex(RuntimeError, 'Torch row format'):
+ read.to_torch(splits, streaming=False)
+
+ def _check_variant_projection_literal_top_level_names(
+ self, suffix, native_read):
+ pa_schema = pa.schema([
+ pa.field('id.dot', pa.int64()),
+ pa.field('payload', _variant_arrow_type()),
+ pa.field('payload.dot', _variant_arrow_type()),
+ pa.field('payload[raw]', _variant_arrow_type()),
+ ])
+ schema = Schema.from_pyarrow_schema(
+ pa_schema, options={'read.native.enabled': 'true'})
+ identifier = 'default.variant_projection_literal_names_' + suffix
+ self.catalog.create_table(identifier, schema, False)
+ table = self.catalog.get_table(identifier)
+ values = GenericVariant.to_arrow_array([
+ GenericVariant.from_python({'ratio': 1.25})])
+ data = pa.Table.from_arrays([
+ pa.array([1], type=pa.int64()), values, values, values,
+ ], schema=pa_schema)
+ write_builder = table.new_batch_write_builder()
+ writer = write_builder.new_write()
+ commit = write_builder.new_commit()
+ writer.write_arrow(data)
+ commit.commit(writer.prepare_commit())
+ writer.close()
+ commit.close()
+
+ for expressions, projection, column in (
+ ({'ratio': 'try_variant_get("payload.dot", "$.ratio", "float")'},
+ ['payload.dot'], 'payload.dot'),
+ ({'ratio': 'try_variant_get("payload[raw]", "$.ratio", "float")'},
+ ['payload[raw]'], 'payload[raw]'),
+ ({'id': 'id.dot',
+ 'ratio': 'try_variant_get(payload, "$.ratio", "float")'},
+ ['id.dot', 'payload'], 'payload'),
+ ):
+ with self.subTest(expressions=expressions):
+ builder = table.new_read_builder().with_projection(
+ expressions)
+ splits = builder.new_scan().plan().splits()
+ self.assertTrue(splits)
+ read = builder.new_read()
+ kwargs = read._native_read_kwargs()
+ self.assertEqual(
+ [field.name for field in kwargs['read_type']], projection)
+ self.assertNotIn('nested_projection', kwargs)
+ field = next(field for field in kwargs['read_type']
+ if field.name == column)
+ self.assertEqual(
+ field.type.fields[0].description,
+ '__VARIANT_METADATA$.ratio;false;UTC')
+ self.assertEqual(
+ read._output_arrow_schema().field('ratio').type,
+ pa.float32())
+ if native_read:
+ projected = read.to_arrow(splits)
+ self.assertEqual(projected.column_names,
+ list(expressions))
+ self.assertEqual(
+ projected['ratio'].to_pylist(), [1.25])
+ plain = table.new_read_builder().with_projection(projection)
+ self.assertEqual(
+ plain.new_read().to_arrow(splits).column_names,
+ projection)
+
+ def test_variant_projection_literal_top_level_names_bridge(self):
+ self._check_variant_projection_literal_top_level_names(
+ 'bridge', native_read=False)
+
+ @unittest.skipUnless(_native_variant_projection_available(),
+ 'compatible native Variant projection API not
installed')
+ def test_variant_projection_literal_top_level_names_native(self):
+ self._check_variant_projection_literal_top_level_names(
+ 'native', native_read=True)
+
+ @unittest.skipUnless(_native_variant_projection_available(),
+ 'compatible native Variant projection API not
installed')
+ def test_named_variant_projection_sql_escaped_path(self):
+ schema = Schema.from_pyarrow_schema(
+ self._pa_schema(), options={'read.native.enabled': 'true'})
+ identifier = 'default.named_variant_escaped_path'
+ self.catalog.create_table(identifier, schema, False)
+ table = self.catalog.get_table(identifier)
+ data = pa.table({
+ 'id': [1],
+ 'payload': GenericVariant.to_arrow_array([
+ GenericVariant.from_python({"it's": 1.0, 'its': 2.0})]),
+ }, schema=self._pa_schema())
+ write_builder = table.new_batch_write_builder()
+ writer = write_builder.new_write()
+ commit = write_builder.new_commit()
+ writer.write_arrow(data)
+ commit.commit(writer.prepare_commit())
+ writer.close()
+ commit.close()
+
+ from pypaimon_rust.datafusion import SQLContext
+ context = SQLContext()
+ context.register_catalog(
+ 'paimon', {'warehouse': os.path.join(self.tmpdir, 'warehouse')})
+ for function in ('variant_get', 'try_variant_get'):
+ expression = "%s(payload, '$[\"it''s\"]', 'float')" % function
+ builder = table.new_read_builder().with_projection({'x':
expression})
+ splits = builder.new_scan().plan().splits()
+ actual = builder.new_read().to_arrow(splits)['x'].to_pylist()
+ sql = pa.Table.from_batches(context.sql(
+ 'SELECT %s AS x FROM %s' % (expression, identifier)))
+ self.assertEqual([1.0], actual)
+ self.assertEqual(sql['x'].to_pylist(), actual)
+
+ @unittest.skipUnless(_native_variant_projection_available(),
+ 'compatible native Variant projection API not
installed')
+ def test_named_variant_projection_batch_and_stream(self):
+ schema = Schema.from_pyarrow_schema(
+ self._pa_schema(), options={'read.native.enabled': 'true'})
+ identifier = 'default.named_variant_projection'
+ self.catalog.create_table(identifier, schema, False)
+ table = self.catalog.get_table(identifier)
+ data = pa.table({
+ 'id': [1, 2],
+ 'payload': GenericVariant.to_arrow_array([
+ GenericVariant.from_python({'x': 1.25, 'y': 2}), None]),
+ }, schema=self._pa_schema())
+ writer_builder = table.new_batch_write_builder()
+ writer = writer_builder.new_write()
+ commit = writer_builder.new_commit()
+ writer.write_arrow(data)
+ commit.commit(writer.prepare_commit())
+ writer.close()
+ commit.close()
+
+ expressions = {
+ 'identifier': 'id',
+ 'x': "try_variant_get(payload, '$.x', 'float')",
+ 'y': "variant_get(payload, '$.y', 'float')",
+ }
+ batch_builder = table.new_read_builder().with_projection(expressions)
+ expected = {'identifier': [1, 2], 'x': [1.25, None], 'y': [2.0, None]}
+ result = batch_builder.new_read().to_arrow(
+ batch_builder.new_scan().plan().splits())
+ self.assertEqual(result.column_names, list(expressions))
+ self.assertEqual(result.to_pydict(), expected)
+
+ stream_builder = table.new_stream_read_builder().with_projection(
+ expressions)
+
+ async def first_plan():
+ stream = stream_builder.new_streaming_scan().stream()
+ try:
+ return await stream.__anext__()
+ finally:
+ await stream.aclose()
+
+ stream_result = stream_builder.new_read().to_arrow(
+ asyncio.run(first_plan()).splits())
+ self.assertEqual(stream_result.to_pydict(), expected)
+
+ bad = pa.table({
+ 'id': [3],
+ 'payload': GenericVariant.to_arrow_array([
+ GenericVariant.from_python({'x': 'bad'})]),
+ }, schema=self._pa_schema())
+ writer = writer_builder.new_write()
+ commit = writer_builder.new_commit()
+ writer.write_arrow(bad)
+ commit.commit(writer.prepare_commit())
+ writer.close()
+ commit.close()
+
+ tolerant = table.new_read_builder().with_projection({
+ 'x': "try_variant_get(payload, '$.x', 'float')"})
+ result = tolerant.new_read().to_arrow(
+ tolerant.new_scan().plan().splits())
+ self.assertEqual(result['x'].to_pylist(), [1.25, None, None])
+ strict = table.new_read_builder().with_projection({
+ 'x': "variant_get(payload, '$.x', 'float')"})
+ with self.assertRaisesRegex(Exception, 'Cannot cast Variant'):
+ strict.new_read().to_arrow(strict.new_scan().plan().splits())
+
def test_plain_variant_write_and_read(self):
"""Plain VARIANT: GenericVariant → write_arrow → to_arrow →
GenericVariant."""
schema = Schema.from_pyarrow_schema(self._pa_schema())