zhuxiangyi commented on code in PR #9423:
URL: https://github.com/apache/paimon/pull/9423#discussion_r3891223269
##########
paimon-format/src/main/java/org/apache/parquet/filter2/predicate/ParquetFilters.java:
##########
@@ -273,9 +278,19 @@ public FilterPredicate visitNotIn(FieldRef fieldRef,
List<Object> literals) {
throw new UnsupportedOperationException();
}
+ /**
+ * A nested field carries no index into the file, only a path, so it
is re-dispatched under
+ * a {@link FieldRef} naming that path. Every other transform - casts,
string functions -
+ * has no column of its own to filter on and is given up here.
+ */
@Override
public FilterPredicate visitNonFieldLeaf(LeafPredicate predicate) {
- throw new UnsupportedOperationException();
+ if (!(predicate.transform() instanceof NestedFieldTransform)) {
+ throw new UnsupportedOperationException();
+ }
+ NestedFieldTransform nested = (NestedFieldTransform)
predicate.transform();
+ FieldRef pathRef = new FieldRef(UNUSED_INDEX, nested.fieldName(),
nested.outputType());
Review Comment:
Thanks, this was exactly right and I could reproduce it.
The path resolution itself was fine — `findFileColumn` walks the components
and returns a `FileColumn` carrying both the resolved path and the physical
type. What I got wrong was one level up: `primitiveType()` discarded the path
and returned only the type, so the decimal and timestamp visitors had nothing
to rebuild the column from except `PrimitiveType.getName()`, which can only be
the leaf's own name. A nested BIGINT happened to be fine because it goes
through `pushdownTarget`, which does keep the path — which is also why my
original tests missed this.
`primitiveType` is now `fileColumn` and returns the whole `FileColumn`;
`decimalColumn` and `timestampColumn` likewise, and the three visitors build
their column from `column.path`.
Tests against actual row groups as you asked — each writes two rows and
asserts the matching row survives the filter:
- `ParquetFormatReadWriteTest.testNestedDecimalPredicateKeepsMatchingRows`
- `ParquetFormatReadWriteTest.testNestedTimestampPredicateKeepsMatchingRows`
-
`ParquetFormatReadWriteTest.testNestedLocalZonedTimestampPredicateKeepsMatchingRows`
- `ParquetFormatReadWriteTest.testNestedBigIntPredicateKeepsMatchingRows`
(control)
One note on how they assert, in case it looks loose: parquet filtering is
row-group granular, so with both rows in one row group a matching predicate
legitimately returns both. The tests assert the matching row is present rather
than that exactly one row comes back, since what we need to catch is the match
disappearing. Happy to tighten this if you would rather see row groups
separated explicitly.
Filter-level coverage: `testNestedDecimalKeepsTheFullPath`,
`testNestedTimestampKeepsTheFullPath`,
`testNestedLocalZonedTimestampKeepsTheFullPath`, and
`testNestedTimestampMicrosKeepsTheFullPath` for the micros literal path.
Writing those turned up two more holes in my own coverage, now closed:
- the decimal visitor builds a column per physical type and I had only
exercised INT64 — `testNestedDecimalKeepsTheFullPathForEveryPhysicalType`
covers INT32, INT64, FIXED_LEN_BYTE_ARRAY and BINARY;
- `IN` / `NOT IN` build the column through the same visitor —
`testNestedDecimalInAndNotInKeepTheFullPath`.
##########
paimon-common/src/main/java/org/apache/paimon/predicate/NestedFieldTransform.java:
##########
@@ -0,0 +1,182 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.predicate;
+
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.RowType;
+
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnore;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import static org.apache.paimon.utils.InternalRowUtils.get;
+import static org.apache.paimon.utils.Preconditions.checkArgument;
+
+/**
+ * Transform that extracts a field nested inside a row-typed column, for
example {@code addr.city}.
+ *
+ * <p>The transform keeps the enclosing top-level column as its only {@link
#inputs() input}, so
+ * anything that rewrites field indices (schema projection, for instance)
keeps working without
+ * knowing about nesting. The positions below that column are held separately
in {@link #path()}.
+ *
+ * <p>Deliberately <b>not</b> a {@link FieldTransform}: {@link
LeafPredicate#fieldRefOptional()}
+ * returns empty for it, which is what keeps every consumer that equates a
leaf with a top-level
+ * column — min/max pruning, file index lookup, ORC pushdown, schema evolution
— from silently
+ * reading the enclosing column's metadata as if it belonged to the nested
field. Those consumers
+ * give up on this transform instead, which costs pruning but never rows.
+ */
+public class NestedFieldTransform implements Transform {
+
+ private static final long serialVersionUID = 1L;
+
+ public static final String NAME = "NESTED_FIELD_REF";
+
+ public static final String FIELD_FIELD_REF = "fieldRef";
+ public static final String FIELD_PATH = "path";
+
+ /** The top-level row-typed column the nested field lives in. */
+ private final FieldRef fieldRef;
+
+ /** Positions to descend, relative to {@code fieldRef}'s row type. Never
empty. */
+ private final List<Integer> path;
+
+ private final String name;
+ private final DataType outputType;
+
+ @JsonCreator
+ public NestedFieldTransform(
+ @JsonProperty(FIELD_FIELD_REF) FieldRef fieldRef,
+ @JsonProperty(FIELD_PATH) List<Integer> path) {
+ checkArgument(path != null && !path.isEmpty(), "Nested field path must
not be empty.");
+ this.fieldRef = fieldRef;
+ this.path = Collections.unmodifiableList(new ArrayList<>(path));
+
+ StringBuilder nameBuilder = new StringBuilder(fieldRef.name());
+ DataType current = fieldRef.type();
+ for (int position : this.path) {
+ checkArgument(
+ current instanceof RowType,
+ "Nested field path of '%s' descends into a non-row type
%s.",
+ fieldRef.name(),
+ current);
+ RowType rowType = (RowType) current;
+ checkArgument(
+ position >= 0 && position < rowType.getFieldCount(),
+ "Nested field position %s is out of range for %s.",
+ position,
+ rowType);
+
nameBuilder.append('.').append(rowType.getFields().get(position).name());
+ current = rowType.getTypeAt(position);
+ }
+ this.name = nameBuilder.toString();
+ this.outputType = current;
+ }
+
+ @Override
+ public String name() {
+ return NAME;
+ }
+
+ @JsonProperty(FIELD_FIELD_REF)
+ public FieldRef fieldRef() {
+ return fieldRef;
+ }
+
+ @JsonProperty(FIELD_PATH)
+ public List<Integer> path() {
+ return path;
+ }
+
+ /** Dot-separated name from the top-level column down to the nested field,
{@code addr.city}. */
+ @JsonIgnore
+ public String fieldName() {
+ return name;
+ }
+
+ @Override
+ @JsonIgnore
+ public List<Object> inputs() {
+ return Collections.singletonList(fieldRef);
+ }
+
+ @Override
+ @JsonIgnore
+ public DataType outputType() {
+ return outputType;
+ }
+
+ /**
+ * Reads the nested field out of {@code row}, which must match the row
type {@link #fieldRef}
+ * was built against. A null anywhere along the path yields null, matching
SQL semantics for
+ * field access on a null struct.
+ */
+ @Override
+ public Object transform(InternalRow row) {
+ int position = fieldRef.index();
+ if (row.isNullAt(position)) {
+ return null;
+ }
+ RowType currentType = (RowType) fieldRef.type();
+ InternalRow current = row.getRow(position,
currentType.getFieldCount());
+
+ for (int i = 0; i < path.size() - 1; i++) {
+ position = path.get(i);
+ if (current.isNullAt(position)) {
+ return null;
+ }
+ RowType nextType = (RowType) currentType.getTypeAt(position);
+ current = current.getRow(position, nextType.getFieldCount());
+ currentType = nextType;
+ }
+
+ int leaf = path.get(path.size() - 1);
+ return get(current, leaf, currentType.getTypeAt(leaf));
+ }
+
+ @Override
+ public Transform copyWithNewInputs(List<Object> inputs) {
+ checkArgument(inputs.size() == 1);
Review Comment:
Good catch, and I reproduced it: remapping a transform on `info.secret` onto
a pruned `ROW<region>` silently produced `info.region`. Storing a bare position
was my mistake — I had thought about the index moving but not about the row
type itself changing shape.
Took the first option you offered. The path is now the ordered component
names rather than positions, and `copyWithNewInputs` re-resolves them against
the replacement row type, so a pruned-away leaf fails closed and a reordered
row type still addresses the same field. Positions are derived once in the
constructor and used only for evaluation.
On the "while ensuring auth reads the full nested dependencies" part — I
have not done that. Keeping `info.secret` in the read schema when a row filter
references it means touching the projection layer, and I was not sure that
belonged in this PR. What is guaranteed now is that the case fails loudly
instead of resolving elsewhere: the exception propagates out of
`TableQueryAuthResult.remapPredicate` and no caller catches it
(`AbstractDataTableScan:134`). If you would rather have the dependency actually
pulled into the projection, please say so — I am glad to do it here or in a
follow-up, whichever you prefer.
Tests at the auth entry point, since that is the path you were pointing at:
-
`TableQueryAuthResultTest.testNestedRowFilterDoesNotDriftWhenTheLeafIsPruned`
-
`TableQueryAuthResultTest.testNestedRowFilterFollowsTheFieldWhenPositionsShift`
and at the transform level,
`NestedFieldTransformTest.testRemapOntoAPrunedRowTypeDoesNotDrift` /
`testRemapFollowsTheFieldWhenPositionsShift`. The second one is there to keep
me honest: a validation that simply throws would pass the first test but fail
this one, since a reordered row type has to resolve to the original field.
In case it is useful for judging the blast radius, I also checked the other
two `copyWithNewInputs` callers: `PredicateProjectionConverter` and
`PartitionValuePredicateVisitor` both pass `fieldRef.type()` through unchanged
and only remap the top-level index, so neither could drift.
`TableQueryAuthResult` is the one caller that re-derives the type from a
different row type.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]