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 34ef05103e [flink] Push down predicates on fields nested inside a row 
(#10144)
34ef05103e is described below

commit 34ef05103e9199ce18bca5e38b0e3069c91bd762
Author: Xiangyi Zhu <[email protected]>
AuthorDate: Thu Sep 24 17:56:26 2026 +0800

    [flink] Push down predicates on fields nested inside a row (#10144)
---
 .../apache/paimon/predicate/PredicateBuilder.java  |   4 +
 .../apache/paimon/flink/NestedFieldReferences.java |  85 ++++
 .../apache/paimon/flink/PredicateConverter.java    | 307 +++++++++----
 .../paimon/flink/source/FlinkTableSource.java      |   6 +-
 .../paimon/flink/NestedFieldReferencesTest.java    | 109 +++++
 .../paimon/flink/NestedPredicateConverterTest.java | 499 +++++++++++++++++++++
 .../source/NestedFieldFilterPushDownITCase.java    | 392 ++++++++++++++++
 7 files changed, 1319 insertions(+), 83 deletions(-)

diff --git 
a/paimon-common/src/main/java/org/apache/paimon/predicate/PredicateBuilder.java 
b/paimon-common/src/main/java/org/apache/paimon/predicate/PredicateBuilder.java
index cd358c8620..2c8ecba4c1 100644
--- 
a/paimon-common/src/main/java/org/apache/paimon/predicate/PredicateBuilder.java
+++ 
b/paimon-common/src/main/java/org/apache/paimon/predicate/PredicateBuilder.java
@@ -70,6 +70,10 @@ public class PredicateBuilder {
         this.fieldNames = rowType.getFieldNames();
     }
 
+    public RowType rowType() {
+        return rowType;
+    }
+
     public int indexOf(String field) {
         return fieldNames.indexOf(field);
     }
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/NestedFieldReferences.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/NestedFieldReferences.java
new file mode 100644
index 0000000000..b42e2f9b29
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/NestedFieldReferences.java
@@ -0,0 +1,85 @@
+/*
+ * 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.flink;
+
+import org.apache.flink.table.expressions.Expression;
+import org.apache.flink.table.types.DataType;
+
+/**
+ * Access to Flink's {@code NestedFieldReferenceExpression}, which only exists 
from Flink 1.19 on.
+ *
+ * <p>This module is compiled once and bundled into every {@code 
paimon-flink-*} distribution,
+ * including those for Flink 1.16 to 1.18 where the class is absent. Every 
direct reference to it
+ * therefore lives in {@link Holder}, a separate class file that is only ever 
loaded once {@link
+ * #AVAILABLE} has confirmed the expression is on the classpath.
+ */
+public class NestedFieldReferences {
+
+    private static final String CLASS_NAME =
+            
"org.apache.flink.table.expressions.NestedFieldReferenceExpression";
+
+    private static final boolean AVAILABLE = isOnClasspath();
+
+    private NestedFieldReferences() {}
+
+    private static boolean isOnClasspath() {
+        try {
+            Class.forName(CLASS_NAME, false, 
NestedFieldReferences.class.getClassLoader());
+            return true;
+        } catch (ClassNotFoundException | LinkageError e) {
+            return false;
+        }
+    }
+
+    /** Whether {@code expression} references a field nested inside a row. */
+    public static boolean isNestedFieldReference(Expression expression) {
+        return AVAILABLE && Holder.isInstance(expression);
+    }
+
+    /**
+     * The path to the referenced field, starting with the name of the 
top-level field it is nested
+     * in. Callers must check {@link #isNestedFieldReference} first.
+     */
+    public static String[] fieldNames(Expression expression) {
+        return Holder.fieldNames(expression);
+    }
+
+    /** The type of the referenced field. Callers must check {@link 
#isNestedFieldReference}. */
+    public static DataType outputDataType(Expression expression) {
+        return Holder.outputDataType(expression);
+    }
+
+    private static class Holder {
+
+        static boolean isInstance(Expression expression) {
+            return expression
+                    instanceof 
org.apache.flink.table.expressions.NestedFieldReferenceExpression;
+        }
+
+        static String[] fieldNames(Expression expression) {
+            return 
((org.apache.flink.table.expressions.NestedFieldReferenceExpression) expression)
+                    .getFieldNames();
+        }
+
+        static DataType outputDataType(Expression expression) {
+            return 
((org.apache.flink.table.expressions.NestedFieldReferenceExpression) expression)
+                    .getOutputDataType();
+        }
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/PredicateConverter.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/PredicateConverter.java
index d14b1ccce1..47105a7258 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/PredicateConverter.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/PredicateConverter.java
@@ -19,8 +19,11 @@
 package org.apache.paimon.flink;
 
 import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.predicate.FieldRef;
+import org.apache.paimon.predicate.NestedFieldTransform;
 import org.apache.paimon.predicate.Predicate;
 import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.predicate.Transform;
 import org.apache.paimon.utils.TypeUtils;
 
 import org.apache.flink.table.data.conversion.DataStructureConverters;
@@ -39,8 +42,11 @@ import 
org.apache.flink.table.types.logical.LogicalTypeFamily;
 import org.apache.flink.table.types.logical.LogicalTypeRoot;
 import org.apache.flink.table.types.logical.RowType;
 
+import javax.annotation.Nullable;
+
 import java.util.ArrayDeque;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Deque;
 import java.util.List;
 import java.util.Optional;
@@ -61,12 +67,26 @@ public class PredicateConverter implements 
ExpressionVisitor<Predicate> {
 
     private final PredicateBuilder builder;
 
+    /**
+     * The table's own type, used to address fields nested inside a row. A 
type round-tripped
+     * through Flink renumbers field ids, and {@link NestedFieldTransform} 
carries those ids as its
+     * identity, so on an evolved schema they would no longer match the table. 
Null when the caller
+     * only has a Flink type.
+     */
+    @Nullable private final org.apache.paimon.types.RowType tableType;
+
     public PredicateConverter(RowType type) {
         this(new PredicateBuilder(toDataType(type)));
     }
 
     public PredicateConverter(PredicateBuilder builder) {
+        this(builder, null);
+    }
+
+    private PredicateConverter(
+            PredicateBuilder builder, @Nullable 
org.apache.paimon.types.RowType tableType) {
         this.builder = builder;
+        this.tableType = tableType;
     }
 
     /** Accepts simple LIKE patterns like "abc%". */
@@ -96,56 +116,57 @@ public class PredicateConverter implements 
ExpressionVisitor<Predicate> {
             return visitComparison(
                     children,
                     negated,
-                    builder::notEqual,
-                    builder::notEqual,
-                    builder::equal,
-                    builder::equal);
+                    op(builder::notEqual, builder::notEqual),
+                    op(builder::notEqual, builder::notEqual),
+                    op(builder::equal, builder::equal),
+                    op(builder::equal, builder::equal));
         } else if (func == BuiltInFunctionDefinitions.NOT_EQUALS) {
             return visitComparison(
                     children,
                     negated,
-                    builder::equal,
-                    builder::equal,
-                    builder::notEqual,
-                    builder::notEqual);
+                    op(builder::equal, builder::equal),
+                    op(builder::equal, builder::equal),
+                    op(builder::notEqual, builder::notEqual),
+                    op(builder::notEqual, builder::notEqual));
         } else if (func == BuiltInFunctionDefinitions.GREATER_THAN) {
             return visitComparison(
                     children,
                     negated,
-                    builder::lessOrEqual,
-                    builder::greaterOrEqual,
-                    builder::greaterThan,
-                    builder::lessThan);
+                    op(builder::lessOrEqual, builder::lessOrEqual),
+                    op(builder::greaterOrEqual, builder::greaterOrEqual),
+                    op(builder::greaterThan, builder::greaterThan),
+                    op(builder::lessThan, builder::lessThan));
         } else if (func == BuiltInFunctionDefinitions.GREATER_THAN_OR_EQUAL) {
             return visitComparison(
                     children,
                     negated,
-                    builder::lessThan,
-                    builder::greaterThan,
-                    builder::greaterOrEqual,
-                    builder::lessOrEqual);
+                    op(builder::lessThan, builder::lessThan),
+                    op(builder::greaterThan, builder::greaterThan),
+                    op(builder::greaterOrEqual, builder::greaterOrEqual),
+                    op(builder::lessOrEqual, builder::lessOrEqual));
         } else if (func == BuiltInFunctionDefinitions.LESS_THAN) {
             return visitComparison(
                     children,
                     negated,
-                    builder::greaterOrEqual,
-                    builder::lessOrEqual,
-                    builder::lessThan,
-                    builder::greaterThan);
+                    op(builder::greaterOrEqual, builder::greaterOrEqual),
+                    op(builder::lessOrEqual, builder::lessOrEqual),
+                    op(builder::lessThan, builder::lessThan),
+                    op(builder::greaterThan, builder::greaterThan));
         } else if (func == BuiltInFunctionDefinitions.LESS_THAN_OR_EQUAL) {
             return visitComparison(
                     children,
                     negated,
-                    builder::greaterThan,
-                    builder::lessThan,
-                    builder::lessOrEqual,
-                    builder::greaterOrEqual);
+                    op(builder::greaterThan, builder::greaterThan),
+                    op(builder::lessThan, builder::lessThan),
+                    op(builder::lessOrEqual, builder::lessOrEqual),
+                    op(builder::greaterOrEqual, builder::greaterOrEqual));
         } else if (func == BuiltInFunctionDefinitions.IN) {
             requireAtLeastArity(children, 2);
             ResolvedField field = resolveField(children.get(0));
+            rejectNestedFloatingPoint(field);
             List<Object> literals = new ArrayList<>();
             for (int i = 1; i < children.size(); i++) {
-                
literals.add(extractLiteral(field.expression.getOutputDataType(), 
children.get(i)));
+                literals.add(extractLiteral(field.type(), children.get(i)));
             }
             if (negated) {
                 // SQL WHERE: v NOT IN (..., NULL, ...) is never true 
regardless of
@@ -154,27 +175,31 @@ public class PredicateConverter implements 
ExpressionVisitor<Predicate> {
                 if (literals.contains(null)) {
                     return PredicateBuilder.alwaysFalse();
                 }
-                rejectNegatedFloatingPoint(field.expression);
-                return builder.notIn(field.index, literals);
+                rejectNegatedFloatingPoint(field);
+                return build(field, builder::notIn, builder::notIn, literals);
             }
-            return builder.in(field.index, literals);
+            return build(field, builder::in, builder::in, literals);
         } else if (func == BuiltInFunctionDefinitions.IS_NULL) {
             requireArity(children, 1);
             ResolvedField field = resolveField(children.get(0));
-            return negated ? builder.isNotNull(field.index) : 
builder.isNull(field.index);
+            return negated ? isNotNull(field) : isNull(field);
         } else if (func == BuiltInFunctionDefinitions.IS_NOT_NULL) {
             requireArity(children, 1);
             ResolvedField field = resolveField(children.get(0));
-            return negated ? builder.isNull(field.index) : 
builder.isNotNull(field.index);
+            return negated ? isNull(field) : isNotNull(field);
         } else if (func == BuiltInFunctionDefinitions.BETWEEN) {
             requireArity(children, 3);
             ResolvedField field = resolveField(children.get(0));
-            DataType fieldType = field.expression.getOutputDataType();
+            rejectNestedFloatingPoint(field);
+            DataType fieldType = field.type();
             Object lower = extractLiteral(fieldType, children.get(1));
             Object upper = extractLiteral(fieldType, children.get(2));
-            Predicate between = builder.between(field.index, lower, upper);
+            Predicate between =
+                    field.isNested()
+                            ? builder.between(field.transform, lower, upper)
+                            : builder.between(field.index, lower, upper);
             if (negated) {
-                rejectNegatedFloatingPoint(field.expression);
+                rejectNegatedFloatingPoint(field);
                 // LeafTernaryFunction.test returns false if any literal is 
null, but
                 // 12 NOT BETWEEN 15 AND NULL is TRUE (TRUE OR UNKNOWN). Keep 
residual
                 // so Flink can evaluate the three-valued cases.
@@ -189,22 +214,16 @@ public class PredicateConverter implements 
ExpressionVisitor<Predicate> {
                 throw new UnsupportedExpression();
             }
             ResolvedField field = resolveField(children.get(0));
-            if (field.expression
-                    .getOutputDataType()
+            if (field.type()
                     .getLogicalType()
                     .getTypeRoot()
                     .getFamilies()
                     .contains(LogicalTypeFamily.CHARACTER_STRING)) {
-                String sqlPattern =
-                        
extractNonNullLiteral(field.expression.getOutputDataType(), children.get(1))
-                                .toString();
+                String sqlPattern = extractNonNullLiteral(field.type(), 
children.get(1)).toString();
                 String escape =
                         children.size() <= 2
                                 ? null
-                                : extractNonNullLiteral(
-                                                
field.expression.getOutputDataType(),
-                                                children.get(2))
-                                        .toString();
+                                : extractNonNullLiteral(field.type(), 
children.get(2)).toString();
                 String escapedSqlPattern = sqlPattern;
                 boolean allowQuick = false;
                 if (escape == null && !sqlPattern.contains("_")) {
@@ -252,8 +271,11 @@ public class PredicateConverter implements 
ExpressionVisitor<Predicate> {
                             // residual filter evaluated by Flink.
                             throw new UnsupportedExpression();
                         }
-                        return builder.startsWith(
-                                field.index, 
BinaryString.fromString(beginMatcher.group(1)));
+                        return build(
+                                field,
+                                builder::startsWith,
+                                builder::startsWith,
+                                
BinaryString.fromString(beginMatcher.group(1)));
                     }
                 }
             }
@@ -282,16 +304,25 @@ public class PredicateConverter implements 
ExpressionVisitor<Predicate> {
     }
 
     private Predicate booleanTest(ResolvedField field, boolean expected, 
boolean complement) {
-        if (field.expression.getOutputDataType().getLogicalType().getTypeRoot()
-                != LogicalTypeRoot.BOOLEAN) {
+        if (field.type().getLogicalType().getTypeRoot() != 
LogicalTypeRoot.BOOLEAN) {
             throw new UnsupportedExpression();
         }
-        Predicate equals = builder.equal(field.index, expected);
+        Predicate equals = build(field, builder::equal, builder::equal, 
expected);
         if (!complement) {
             return equals;
         }
         return PredicateBuilder.or(
-                builder.isNull(field.index), builder.notEqual(field.index, 
expected));
+                isNull(field), build(field, builder::notEqual, 
builder::notEqual, expected));
+    }
+
+    private Predicate isNull(ResolvedField field) {
+        return field.isNested() ? builder.isNull(field.transform) : 
builder.isNull(field.index);
+    }
+
+    private Predicate isNotNull(ResolvedField field) {
+        return field.isNested()
+                ? builder.isNotNull(field.transform)
+                : builder.isNotNull(field.index);
     }
 
     private Predicate negate(Predicate predicate) {
@@ -338,10 +369,10 @@ public class PredicateConverter implements 
ExpressionVisitor<Predicate> {
     private Predicate visitComparison(
             List<Expression> children,
             boolean negated,
-            BiFunction<Integer, Object, Predicate> negatedVisit1,
-            BiFunction<Integer, Object, Predicate> negatedVisit2,
-            BiFunction<Integer, Object, Predicate> visit1,
-            BiFunction<Integer, Object, Predicate> visit2) {
+            LeafFunction negatedVisit1,
+            LeafFunction negatedVisit2,
+            LeafFunction visit1,
+            LeafFunction visit2) {
         // Flink FLOAT/DOUBLE comparisons use Java operators; Paimon uses
         // Float/Double.compareTo. Negated equality, inequality, IN and BETWEEN
         // are therefore not equivalent (NaN identity and signed zeros). Simple
@@ -356,8 +387,20 @@ public class PredicateConverter implements 
ExpressionVisitor<Predicate> {
                 : visitBiFunction(children, visit1, visit2);
     }
 
-    private void rejectNegatedFloatingPoint(FieldReferenceExpression field) {
-        if (isFloatingPointField(field)) {
+    /**
+     * Flink compares FLOAT/DOUBLE with Java operators, so {@code -0.0 = 0.0} 
holds; Paimon's
+     * predicates use {@code compareTo}, which tells the two apart. Pruning a 
file on such a
+     * predicate could drop a row Flink would have kept, before Flink's own 
filter sees it, so
+     * comparisons, IN and BETWEEN on a nested floating-point field are left 
to Flink.
+     */
+    private void rejectNestedFloatingPoint(ResolvedField field) {
+        if (field.isNested() && isFloatingPointType(field.type())) {
+            throw new UnsupportedExpression();
+        }
+    }
+
+    private void rejectNegatedFloatingPoint(ResolvedField field) {
+        if (isFloatingPointType(field.type())) {
             throw new UnsupportedExpression();
         }
     }
@@ -365,46 +408,113 @@ public class PredicateConverter implements 
ExpressionVisitor<Predicate> {
     private boolean isFloatingPointComparison(List<Expression> children) {
         for (Expression child : children) {
             Optional<FieldReferenceExpression> field = 
extractFieldReference(child);
-            if (field.isPresent() && isFloatingPointField(field.get())) {
+            if (field.isPresent() && 
isFloatingPointType(field.get().getOutputDataType())) {
+                return true;
+            }
+            if (NestedFieldReferences.isNestedFieldReference(child)
+                    && 
isFloatingPointType(NestedFieldReferences.outputDataType(child))) {
                 return true;
             }
         }
         return false;
     }
 
-    private boolean isFloatingPointField(FieldReferenceExpression field) {
-        LogicalTypeRoot root = 
field.getOutputDataType().getLogicalType().getTypeRoot();
+    private boolean isFloatingPointType(DataType type) {
+        LogicalTypeRoot root = type.getLogicalType().getTypeRoot();
         return root == LogicalTypeRoot.FLOAT || root == LogicalTypeRoot.DOUBLE;
     }
 
     private Predicate visitBiFunction(
-            List<Expression> children,
-            BiFunction<Integer, Object, Predicate> visit1,
-            BiFunction<Integer, Object, Predicate> visit2) {
+            List<Expression> children, LeafFunction visit1, LeafFunction 
visit2) {
         requireArity(children, 2);
-        Optional<FieldReferenceExpression> fieldRefExpr = 
extractFieldReference(children.get(0));
-        if (fieldRefExpr.isPresent()) {
-            int fieldIndex = resolveFieldIndex(fieldRefExpr.get());
-            Object literal =
-                    extractLiteral(fieldRefExpr.get().getOutputDataType(), 
children.get(1));
-            return visit1.apply(fieldIndex, literal);
-        } else {
-            fieldRefExpr = extractFieldReference(children.get(1));
-            if (fieldRefExpr.isPresent()) {
-                int fieldIndex = resolveFieldIndex(fieldRefExpr.get());
-                Object literal =
-                        extractLiteral(fieldRefExpr.get().getOutputDataType(), 
children.get(0));
-                return visit2.apply(fieldIndex, literal);
-            }
+        if (isFieldReference(children.get(0))) {
+            ResolvedField field = resolveField(children.get(0));
+            rejectNestedFloatingPoint(field);
+            return visit1.apply(field, extractLiteral(field.type(), 
children.get(1)));
+        }
+        if (isFieldReference(children.get(1))) {
+            ResolvedField field = resolveField(children.get(1));
+            rejectNestedFloatingPoint(field);
+            return visit2.apply(field, extractLiteral(field.type(), 
children.get(0)));
         }
 
         throw new UnsupportedExpression();
     }
 
+    private boolean isFieldReference(Expression expression) {
+        return expression instanceof FieldReferenceExpression
+                || NestedFieldReferences.isNestedFieldReference(expression);
+    }
+
+    /** A {@link PredicateBuilder} method that builds a leaf predicate over a 
field. */
+    @FunctionalInterface
+    private interface LeafFunction {
+        Predicate apply(ResolvedField field, Object literal);
+    }
+
+    /**
+     * Pairs the two {@link PredicateBuilder} overloads of one operation, so 
that a field can be
+     * addressed either by index or, when it is nested inside a row, by 
transform.
+     */
+    private static LeafFunction op(
+            BiFunction<Integer, Object, Predicate> byIndex,
+            BiFunction<Transform, Object, Predicate> byTransform) {
+        return (field, literal) -> build(field, byIndex, byTransform, literal);
+    }
+
     private ResolvedField resolveField(Expression expression) {
+        if (NestedFieldReferences.isNestedFieldReference(expression)) {
+            return resolveNestedField(expression);
+        }
         FieldReferenceExpression field =
                 
extractFieldReference(expression).orElseThrow(UnsupportedExpression::new);
-        return new ResolvedField(field, resolveFieldIndex(field));
+        return ResolvedField.topLevel(field, resolveFieldIndex(field));
+    }
+
+    /**
+     * Resolves a field nested inside a row. Flink hands the path down as the 
names of the fields
+     * walked through, starting at the top-level one, which is what {@link 
NestedFieldTransform}
+     * addresses the field by as well.
+     */
+    private ResolvedField resolveNestedField(Expression expression) {
+        String[] fieldNames = NestedFieldReferences.fieldNames(expression);
+        if (fieldNames.length < 2) {
+            throw new UnsupportedExpression();
+        }
+
+        int rootIndex = builder.indexOf(fieldNames[0]);
+        if (rootIndex < 0) {
+            throw new UnsupportedExpression();
+        }
+        // Prefer the table's own type: its field ids are the transform's 
identity, and the
+        // round-tripped type behind the builder has renumbered them.
+        org.apache.paimon.types.RowType rootSource =
+                tableType != null ? tableType : builder.rowType();
+        FieldRef rootRef = new FieldRef(rootIndex, fieldNames[0], 
rootSource.getTypeAt(rootIndex));
+        List<String> path = Arrays.asList(fieldNames).subList(1, 
fieldNames.length);
+        try {
+            return ResolvedField.nested(
+                    NestedFieldReferences.outputDataType(expression),
+                    new NestedFieldTransform(rootRef, path));
+        } catch (IllegalArgumentException e) {
+            // The path does not address a field of this table: the root is 
not a row, or a field
+            // along the way was renamed or dropped. Leave the filter for 
Flink to evaluate.
+            throw new UnsupportedExpression();
+        }
+    }
+
+    /**
+     * Binds a {@link PredicateBuilder} method to a field, choosing the 
overload that addresses it:
+     * by index for a top-level field, by transform for one nested inside a 
row.
+     */
+    private static <T> Predicate build(
+            ResolvedField field,
+            BiFunction<Integer, T, Predicate> byIndex,
+            BiFunction<Transform, T, Predicate> byTransform,
+            T argument) {
+        return field.isNested()
+                ? byTransform.apply(field.transform, argument)
+                : byIndex.apply(field.index, argument);
     }
 
     private int resolveFieldIndex(FieldReferenceExpression field) {
@@ -504,14 +614,36 @@ public class PredicateConverter implements 
ExpressionVisitor<Predicate> {
         }
     }
 
+    /**
+     * A field a predicate can be built on: either a top-level field, 
addressed by its index, or a
+     * field nested inside a row, addressed by a {@link NestedFieldTransform}.
+     */
     private static class ResolvedField {
 
-        private final FieldReferenceExpression expression;
+        private final DataType type;
         private final int index;
+        @Nullable private final Transform transform;
 
-        private ResolvedField(FieldReferenceExpression expression, int index) {
-            this.expression = expression;
+        private ResolvedField(DataType type, int index, @Nullable Transform 
transform) {
+            this.type = type;
             this.index = index;
+            this.transform = transform;
+        }
+
+        static ResolvedField topLevel(FieldReferenceExpression expression, int 
index) {
+            return new ResolvedField(expression.getOutputDataType(), index, 
null);
+        }
+
+        static ResolvedField nested(DataType type, Transform transform) {
+            return new ResolvedField(type, -1, transform);
+        }
+
+        boolean isNested() {
+            return transform != null;
+        }
+
+        DataType type() {
+            return type;
         }
     }
 
@@ -542,8 +674,25 @@ public class PredicateConverter implements 
ExpressionVisitor<Predicate> {
      * @return {@link Predicate} if no {@link UnsupportedExpression} thrown.
      */
     public static Optional<Predicate> convert(RowType rowType, 
ResolvedExpression filter) {
+        return convert(new PredicateConverter(rowType), filter);
+    }
+
+    /**
+     * Like {@link #convert(RowType, ResolvedExpression)}, for a table whose 
Paimon type is at hand.
+     * Predicates on fields nested inside a row are then bound to the table's 
own field ids, which
+     * is what reading the table later checks them against.
+     */
+    public static Optional<Predicate> convert(
+            org.apache.paimon.types.RowType tableType, ResolvedExpression 
filter) {
+        PredicateBuilder builder =
+                new 
PredicateBuilder(toDataType(LogicalTypeConversion.toLogicalType(tableType)));
+        return convert(new PredicateConverter(builder, tableType), filter);
+    }
+
+    private static Optional<Predicate> convert(
+            PredicateConverter converter, ResolvedExpression filter) {
         try {
-            return Optional.ofNullable(filter.accept(new 
PredicateConverter(rowType)));
+            return Optional.ofNullable(filter.accept(converter));
         } catch (UnsupportedExpression e) {
             return Optional.empty();
         }
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/FlinkTableSource.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/FlinkTableSource.java
index dd4b7439e0..6c2028eea0 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/FlinkTableSource.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/source/FlinkTableSource.java
@@ -21,7 +21,6 @@ package org.apache.paimon.flink.source;
 import org.apache.paimon.CoreOptions;
 import org.apache.paimon.data.BinaryRow;
 import org.apache.paimon.flink.FlinkConnectorOptions;
-import org.apache.paimon.flink.LogicalTypeConversion;
 import org.apache.paimon.flink.PredicateConverter;
 import org.apache.paimon.flink.lookup.DynamicPartitionLoader;
 import org.apache.paimon.flink.lookup.PartitionLoader;
@@ -48,7 +47,6 @@ import 
org.apache.flink.table.connector.source.abilities.SupportsProjectionPushD
 import org.apache.flink.table.expressions.ResolvedExpression;
 import org.apache.flink.table.plan.stats.TableStats;
 import org.apache.flink.table.types.DataType;
-import org.apache.flink.table.types.logical.RowType;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -112,7 +110,6 @@ public abstract class FlinkTableSource
     @Override
     public Result applyFilters(List<ResolvedExpression> filters) {
         List<String> partitionKeys = table.partitionKeys();
-        RowType rowType = LogicalTypeConversion.toLogicalType(table.rowType());
 
         // The source must ensure the consumed filters are fully evaluated, 
otherwise the result
         // of query will be wrong.
@@ -123,7 +120,8 @@ public abstract class FlinkTableSource
                 new PartitionPredicateVisitor(partitionKeys);
 
         for (ResolvedExpression filter : filters) {
-            Optional<Predicate> predicateOptional = 
PredicateConverter.convert(rowType, filter);
+            Optional<Predicate> predicateOptional =
+                    PredicateConverter.convert(table.rowType(), filter);
 
             if (!predicateOptional.isPresent()) {
                 unConsumedFilters.add(filter);
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/NestedFieldReferencesTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/NestedFieldReferencesTest.java
new file mode 100644
index 0000000000..afbe1df09a
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/NestedFieldReferencesTest.java
@@ -0,0 +1,109 @@
+/*
+ * 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.flink;
+
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Method;
+import java.net.URL;
+import java.net.URLClassLoader;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+/** Tests for {@link NestedFieldReferences}. */
+public class NestedFieldReferencesTest {
+
+    private static final String HIDDEN =
+            
"org.apache.flink.table.expressions.NestedFieldReferenceExpression";
+
+    /**
+     * Flink only has the nested field reference from 1.19 on, but this module 
is compiled once and
+     * bundled for Flink 1.16 to 1.18 as well. Loaded where the class is 
absent, asking whether an
+     * expression is a nested reference must answer no rather than fail to 
link.
+     */
+    @Test
+    public void testAnswersNoWhenTheExpressionIsNotOnTheClasspath() throws 
Exception {
+        try (URLClassLoader withoutNestedReferences = hidingClassLoader()) {
+            Class<?> loaded =
+                    
withoutNestedReferences.loadClass(NestedFieldReferences.class.getName());
+            
assertThat(loaded.getClassLoader()).isSameAs(withoutNestedReferences);
+
+            Method isNestedFieldReference =
+                    loaded.getMethod(
+                            "isNestedFieldReference",
+                            withoutNestedReferences.loadClass(
+                                    
"org.apache.flink.table.expressions.Expression"));
+
+            // A real expression, not null: `null instanceof X` answers 
without ever resolving X,
+            // so only a non-null argument exercises the class the guard has 
to keep away.
+            Object expression =
+                    withoutNestedReferences
+                            
.loadClass("org.apache.flink.table.expressions.ValueLiteralExpression")
+                            .getConstructor(Object.class)
+                            .newInstance(1);
+
+            assertThatCode(() -> isNestedFieldReference.invoke(null, 
expression))
+                    .doesNotThrowAnyException();
+            assertThat(isNestedFieldReference.invoke(null, 
expression)).isEqualTo(false);
+        }
+    }
+
+    /** Loads this module's own classes itself, and pretends the nested 
reference does not exist. */
+    private static URLClassLoader hidingClassLoader() {
+        URLClassLoader appLoader = (URLClassLoader) buildClassPathLoader();
+        return new URLClassLoader(appLoader.getURLs(), appLoader.getParent()) {
+            @Override
+            protected Class<?> loadClass(String name, boolean resolve)
+                    throws ClassNotFoundException {
+                if (name.equals(HIDDEN)) {
+                    throw new ClassNotFoundException(name);
+                }
+                if 
(name.startsWith("org.apache.paimon.flink.NestedFieldReferences")) {
+                    synchronized (getClassLoadingLock(name)) {
+                        Class<?> loaded = findLoadedClass(name);
+                        if (loaded == null) {
+                            loaded = findClass(name);
+                        }
+                        if (resolve) {
+                            resolveClass(loaded);
+                        }
+                        return loaded;
+                    }
+                }
+                return super.loadClass(name, resolve);
+            }
+        };
+    }
+
+    private static ClassLoader buildClassPathLoader() {
+        String[] entries = 
System.getProperty("java.class.path").split(java.io.File.pathSeparator);
+        URL[] urls = new URL[entries.length];
+        for (int i = 0; i < entries.length; i++) {
+            try {
+                urls[i] = new java.io.File(entries[i]).toURI().toURL();
+            } catch (Exception e) {
+                throw new RuntimeException(e);
+            }
+        }
+        // Not getPlatformClassLoader(): Paimon builds with Java 8, where it 
does not exist. The
+        // system loader's parent serves the same purpose here - a parent 
without the classpath.
+        return new URLClassLoader(urls, 
ClassLoader.getSystemClassLoader().getParent());
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/NestedPredicateConverterTest.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/NestedPredicateConverterTest.java
new file mode 100644
index 0000000000..2db576d9eb
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/NestedPredicateConverterTest.java
@@ -0,0 +1,499 @@
+/*
+ * 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.flink;
+
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.predicate.FieldRef;
+import org.apache.paimon.predicate.NestedFieldTransform;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.predicate.PredicateRemapper;
+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.flink.table.expressions.CallExpression;
+import org.apache.flink.table.expressions.FieldReferenceExpression;
+import org.apache.flink.table.expressions.NestedFieldReferenceExpression;
+import org.apache.flink.table.expressions.ResolvedExpression;
+import org.apache.flink.table.expressions.ValueLiteralExpression;
+import org.apache.flink.table.functions.BuiltInFunctionDefinition;
+import org.apache.flink.table.functions.BuiltInFunctionDefinitions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests that {@link PredicateConverter} converts a predicate on a field 
nested inside a row, which
+ * Flink hands down as a {@code NestedFieldReferenceExpression}.
+ */
+public class NestedPredicateConverterTest {
+
+    private static final RowType DEEP =
+            RowType.of(new DataType[] {DataTypes.INT()}, new String[] {"x"});
+
+    private static final RowType NESTED =
+            RowType.of(
+                    new DataType[] {
+                        DataTypes.INT(),
+                        DataTypes.STRING(),
+                        DataTypes.DOUBLE(),
+                        DataTypes.BOOLEAN(),
+                        DEEP,
+                        DataTypes.FLOAT()
+                    },
+                    new String[] {"a", "b", "d", "flag", "inner", "f"});
+
+    private static final RowType TABLE =
+            RowType.of(
+                    new DataType[] {DataTypes.INT(), NESTED, DataTypes.INT(), 
DataTypes.DOUBLE()},
+                    new String[] {"pk", "s", "t", "td"});
+
+    /**
+     * The type the converter itself works on. Round-tripping through Flink's 
type system is what
+     * {@link PredicateConverter#convert} does, and it assigns its own field 
ids, so expected
+     * predicates have to be built from the same type to compare equal.
+     */
+    private static final RowType CONVERTED =
+            
LogicalTypeConversion.toDataType(LogicalTypeConversion.toLogicalType(TABLE));
+
+    private static final PredicateBuilder BUILDER = new 
PredicateBuilder(CONVERTED);
+
+    private static NestedFieldTransform transform(String... path) {
+        return new NestedFieldTransform(
+                new FieldRef(1, "s", CONVERTED.getTypeAt(1)), 
Arrays.asList(path));
+    }
+
+    private static final NestedFieldTransform S_A = transform("a");
+    private static final NestedFieldTransform S_B = transform("b");
+    private static final NestedFieldTransform S_D = transform("d");
+    private static final NestedFieldTransform S_FLAG = transform("flag");
+    private static final NestedFieldTransform S_INNER_X = transform("inner", 
"x");
+
+    private static NestedFieldReferenceExpression ref(
+            org.apache.flink.table.types.DataType type, String... path) {
+        int[] indices = new int[path.length];
+        return new NestedFieldReferenceExpression(path, indices, type);
+    }
+
+    private static NestedFieldReferenceExpression intRef(String... path) {
+        return ref(org.apache.flink.table.api.DataTypes.INT(), path);
+    }
+
+    private static ResolvedExpression call(
+            BuiltInFunctionDefinition func, ResolvedExpression... children) {
+        return CallExpression.permanent(
+                func, Arrays.asList(children), 
org.apache.flink.table.api.DataTypes.BOOLEAN());
+    }
+
+    private static ResolvedExpression not(ResolvedExpression child) {
+        return call(BuiltInFunctionDefinitions.NOT, child);
+    }
+
+    private static Predicate convert(ResolvedExpression expression) {
+        return 
PredicateConverter.convert(LogicalTypeConversion.toLogicalType(TABLE), 
expression)
+                .orElse(null);
+    }
+
+    // 
------------------------------------------------------------------------------------
+    // comparisons
+    // 
------------------------------------------------------------------------------------
+
+    @Test
+    public void testNestedComparisons() {
+        ValueLiteralExpression seven = new ValueLiteralExpression(7);
+
+        assertThat(convert(call(BuiltInFunctionDefinitions.EQUALS, intRef("s", 
"a"), seven)))
+                .isEqualTo(BUILDER.equal(S_A, 7));
+        assertThat(convert(call(BuiltInFunctionDefinitions.NOT_EQUALS, 
intRef("s", "a"), seven)))
+                .isEqualTo(BUILDER.notEqual(S_A, 7));
+        assertThat(convert(call(BuiltInFunctionDefinitions.GREATER_THAN, 
intRef("s", "a"), seven)))
+                .isEqualTo(BUILDER.greaterThan(S_A, 7));
+        assertThat(
+                        convert(
+                                call(
+                                        
BuiltInFunctionDefinitions.GREATER_THAN_OR_EQUAL,
+                                        intRef("s", "a"),
+                                        seven)))
+                .isEqualTo(BUILDER.greaterOrEqual(S_A, 7));
+        assertThat(convert(call(BuiltInFunctionDefinitions.LESS_THAN, 
intRef("s", "a"), seven)))
+                .isEqualTo(BUILDER.lessThan(S_A, 7));
+        assertThat(
+                        convert(
+                                call(
+                                        
BuiltInFunctionDefinitions.LESS_THAN_OR_EQUAL,
+                                        intRef("s", "a"),
+                                        seven)))
+                .isEqualTo(BUILDER.lessOrEqual(S_A, 7));
+    }
+
+    /** The field may be on either side of the comparison; the operator flips 
with it. */
+    @Test
+    public void testNestedComparisonWithLiteralOnTheLeft() {
+        ValueLiteralExpression seven = new ValueLiteralExpression(7);
+
+        assertThat(convert(call(BuiltInFunctionDefinitions.GREATER_THAN, 
seven, intRef("s", "a"))))
+                .isEqualTo(BUILDER.lessThan(S_A, 7));
+        assertThat(convert(call(BuiltInFunctionDefinitions.LESS_THAN, seven, 
intRef("s", "a"))))
+                .isEqualTo(BUILDER.greaterThan(S_A, 7));
+    }
+
+    @Test
+    public void testNegatedNestedComparison() {
+        ValueLiteralExpression seven = new ValueLiteralExpression(7);
+
+        assertThat(convert(not(call(BuiltInFunctionDefinitions.EQUALS, 
intRef("s", "a"), seven))))
+                .isEqualTo(BUILDER.notEqual(S_A, 7));
+        assertThat(
+                        convert(
+                                not(
+                                        call(
+                                                
BuiltInFunctionDefinitions.GREATER_THAN,
+                                                intRef("s", "a"),
+                                                seven))))
+                .isEqualTo(BUILDER.lessOrEqual(S_A, 7));
+    }
+
+    // 
------------------------------------------------------------------------------------
+    // set, range, null and string predicates
+    // 
------------------------------------------------------------------------------------
+
+    @Test
+    public void testNestedInAndNotIn() {
+        ResolvedExpression in =
+                call(
+                        BuiltInFunctionDefinitions.IN,
+                        intRef("s", "a"),
+                        new ValueLiteralExpression(1),
+                        new ValueLiteralExpression(2));
+
+        assertThat(convert(in)).isEqualTo(BUILDER.in(S_A, Arrays.asList(1, 
2)));
+        assertThat(convert(not(in))).isEqualTo(BUILDER.notIn(S_A, 
Arrays.asList(1, 2)));
+    }
+
+    /** {@code v NOT IN (..., NULL, ...)} is never true, whatever the field. */
+    @Test
+    public void testNestedNotInWithNullLiteralIsAlwaysFalse() {
+        ResolvedExpression in =
+                call(
+                        BuiltInFunctionDefinitions.IN,
+                        intRef("s", "a"),
+                        new ValueLiteralExpression(1),
+                        new ValueLiteralExpression(
+                                null, 
org.apache.flink.table.api.DataTypes.INT()));
+
+        assertThat(convert(not(in))).isEqualTo(PredicateBuilder.alwaysFalse());
+    }
+
+    @Test
+    public void testNestedIsNullAndIsNotNull() {
+        assertThat(convert(call(BuiltInFunctionDefinitions.IS_NULL, 
intRef("s", "a"))))
+                .isEqualTo(BUILDER.isNull(S_A));
+        assertThat(convert(call(BuiltInFunctionDefinitions.IS_NOT_NULL, 
intRef("s", "a"))))
+                .isEqualTo(BUILDER.isNotNull(S_A));
+        assertThat(convert(not(call(BuiltInFunctionDefinitions.IS_NULL, 
intRef("s", "a")))))
+                .isEqualTo(BUILDER.isNotNull(S_A));
+    }
+
+    @Test
+    public void testNestedBetweenAndNotBetween() {
+        ResolvedExpression between =
+                call(
+                        BuiltInFunctionDefinitions.BETWEEN,
+                        intRef("s", "a"),
+                        new ValueLiteralExpression(1),
+                        new ValueLiteralExpression(3));
+
+        assertThat(convert(between)).isEqualTo(BUILDER.between(S_A, 1, 3));
+        assertThat(convert(not(between)))
+                .isEqualTo(BUILDER.between(S_A, 1, 
3).negate().orElseThrow(AssertionError::new));
+    }
+
+    @Test
+    public void testNestedLikePrefixBecomesStartsWith() {
+        ResolvedExpression like =
+                call(
+                        BuiltInFunctionDefinitions.LIKE,
+                        ref(org.apache.flink.table.api.DataTypes.STRING(), 
"s", "b"),
+                        new ValueLiteralExpression("ab%"));
+
+        assertThat(convert(like)).isEqualTo(BUILDER.startsWith(S_B, 
BinaryString.fromString("ab")));
+    }
+
+    @Test
+    public void testNestedBooleanTests() {
+        NestedFieldReferenceExpression flag =
+                ref(org.apache.flink.table.api.DataTypes.BOOLEAN(), "s", 
"flag");
+
+        assertThat(convert(call(BuiltInFunctionDefinitions.IS_TRUE, flag)))
+                .isEqualTo(BUILDER.equal(S_FLAG, true));
+        assertThat(convert(call(BuiltInFunctionDefinitions.IS_FALSE, flag)))
+                .isEqualTo(BUILDER.equal(S_FLAG, false));
+        assertThat(convert(call(BuiltInFunctionDefinitions.IS_NOT_TRUE, flag)))
+                .isEqualTo(
+                        PredicateBuilder.or(
+                                BUILDER.isNull(S_FLAG), 
BUILDER.notEqual(S_FLAG, true)));
+        assertThat(convert(call(BuiltInFunctionDefinitions.IS_NOT_FALSE, 
flag)))
+                .isEqualTo(
+                        PredicateBuilder.or(
+                                BUILDER.isNull(S_FLAG), 
BUILDER.notEqual(S_FLAG, false)));
+    }
+
+    // 
------------------------------------------------------------------------------------
+    // floating point: comparisons on a nested FLOAT/DOUBLE are left to Flink
+    // 
------------------------------------------------------------------------------------
+
+    /**
+     * Flink compares FLOAT/DOUBLE with Java operators ({@code -0.0 = 0.0} 
holds), Paimon with
+     * {@code compareTo} (it does not), so no comparison on a nested 
floating-point field is
+     * converted: not in either operand order, not negated, not as IN or 
BETWEEN.
+     */
+    @Test
+    public void testNestedFloatingPointComparisonsAreNotConverted() {
+        NestedFieldReferenceExpression d =
+                ref(org.apache.flink.table.api.DataTypes.DOUBLE(), "s", "d");
+        NestedFieldReferenceExpression f =
+                ref(org.apache.flink.table.api.DataTypes.FLOAT(), "s", "f");
+        ValueLiteralExpression one = new ValueLiteralExpression(1.0d);
+        ValueLiteralExpression two = new ValueLiteralExpression(2.0d);
+
+        for (BuiltInFunctionDefinition comparison :
+                Arrays.asList(
+                        BuiltInFunctionDefinitions.EQUALS,
+                        BuiltInFunctionDefinitions.NOT_EQUALS,
+                        BuiltInFunctionDefinitions.GREATER_THAN,
+                        BuiltInFunctionDefinitions.GREATER_THAN_OR_EQUAL,
+                        BuiltInFunctionDefinitions.LESS_THAN,
+                        BuiltInFunctionDefinitions.LESS_THAN_OR_EQUAL)) {
+            assertThat(convert(call(comparison, d, one))).as("s.d %s", 
comparison).isNull();
+            assertThat(convert(call(comparison, one, d))).as("%s s.d", 
comparison).isNull();
+            assertThat(convert(not(call(comparison, d, one))))
+                    .as("NOT s.d %s", comparison)
+                    .isNull();
+            assertThat(convert(call(comparison, f, new 
ValueLiteralExpression(1.0f))))
+                    .as("s.f %s", comparison)
+                    .isNull();
+        }
+
+        ResolvedExpression in = call(BuiltInFunctionDefinitions.IN, d, one, 
two);
+        assertThat(convert(in)).as("IN").isNull();
+        assertThat(convert(not(in))).as("NOT IN").isNull();
+        ResolvedExpression between = call(BuiltInFunctionDefinitions.BETWEEN, 
d, one, two);
+        assertThat(convert(between)).as("BETWEEN").isNull();
+        assertThat(convert(not(between))).as("NOT BETWEEN").isNull();
+    }
+
+    /** A null check is not a comparison and has no signed-zero problem, so it 
is still pushed. */
+    @Test
+    public void testNestedFloatingPointNullChecksAreStillConverted() {
+        NestedFieldReferenceExpression d =
+                ref(org.apache.flink.table.api.DataTypes.DOUBLE(), "s", "d");
+
+        assertThat(convert(call(BuiltInFunctionDefinitions.IS_NULL, d)))
+                .isEqualTo(BUILDER.isNull(S_D));
+        assertThat(convert(call(BuiltInFunctionDefinitions.IS_NOT_NULL, d)))
+                .isEqualTo(BUILDER.isNotNull(S_D));
+    }
+
+    /** The guard is for nested fields only: a top-level double converts as 
before. */
+    @Test
+    public void testTopLevelFloatingPointComparisonIsUnchanged() {
+        ResolvedExpression equals =
+                call(
+                        BuiltInFunctionDefinitions.EQUALS,
+                        new FieldReferenceExpression(
+                                "td", 
org.apache.flink.table.api.DataTypes.DOUBLE(), 0, 3),
+                        new ValueLiteralExpression(1.0d));
+
+        assertThat(convert(equals)).isEqualTo(BUILDER.equal(3, 1.0d));
+    }
+
+    // 
------------------------------------------------------------------------------------
+    // field ids
+    // 
------------------------------------------------------------------------------------
+
+    /**
+     * On an evolved schema the table's field ids have gaps; a type 
round-tripped through Flink
+     * numbers them afresh. Given the table's type, the nested transform 
carries the table's own
+     * ids, so remapping it onto the table - as a masked read does - keeps its 
identity.
+     */
+    @Test
+    public void testTableTypeEntryPointKeepsTheTablesFieldIds() {
+        RowType evolved = evolvedTable();
+        ResolvedExpression onC =
+                call(
+                        BuiltInFunctionDefinitions.EQUALS,
+                        intRef("s", "c"),
+                        new ValueLiteralExpression(7));
+
+        Predicate predicate =
+                PredicateConverter.convert(evolved, 
onC).orElseThrow(AssertionError::new);
+        assertThatCode(() -> PredicateRemapper.remap(predicate, evolved))
+                .doesNotThrowAnyException();
+    }
+
+    /**
+     * Why callers pass the table's type: from a Flink type alone the 
converter cannot know the
+     * table's ids, and the transform it builds carries renumbered ones.
+     */
+    @Test
+    public void testFlinkTypeEntryPointCannotKnowTheTablesFieldIds() {
+        RowType evolved = evolvedTable();
+        ResolvedExpression onC =
+                call(
+                        BuiltInFunctionDefinitions.EQUALS,
+                        intRef("s", "c"),
+                        new ValueLiteralExpression(7));
+
+        Predicate predicate =
+                
PredicateConverter.convert(LogicalTypeConversion.toLogicalType(evolved), onC)
+                        .orElseThrow(AssertionError::new);
+        assertThatThrownBy(() -> PredicateRemapper.remap(predicate, evolved))
+                .hasMessageContaining("changed identity");
+    }
+
+    /** Only nested fields use the table's type; every top-level predicate 
converts as before. */
+    @Test
+    public void testTableTypeEntryPointLeavesTopLevelPredicatesUnchanged() {
+        FieldReferenceExpression pk =
+                new FieldReferenceExpression(
+                        "pk", org.apache.flink.table.api.DataTypes.INT(), 0, 
0);
+        for (ResolvedExpression topLevel :
+                Arrays.asList(
+                        call(BuiltInFunctionDefinitions.EQUALS, pk, new 
ValueLiteralExpression(1)),
+                        call(
+                                BuiltInFunctionDefinitions.GREATER_THAN,
+                                pk,
+                                new ValueLiteralExpression(1)),
+                        call(
+                                BuiltInFunctionDefinitions.IN,
+                                pk,
+                                new ValueLiteralExpression(1),
+                                new ValueLiteralExpression(2)),
+                        call(BuiltInFunctionDefinitions.IS_NULL, pk))) {
+            assertThat(PredicateConverter.convert(TABLE, topLevel))
+                    .isEqualTo(
+                            PredicateConverter.convert(
+                                    
LogicalTypeConversion.toLogicalType(TABLE), topLevel));
+        }
+    }
+
+    /** {@code pk#0 s#1{a#2 b#3 c#6} t#4}: s.c was added after another column 
was dropped. */
+    private static RowType evolvedTable() {
+        RowType nested =
+                new RowType(
+                        Arrays.asList(
+                                new DataField(2, "a", DataTypes.INT()),
+                                new DataField(3, "b", DataTypes.STRING()),
+                                new DataField(6, "c", DataTypes.INT())));
+        return new RowType(
+                Arrays.asList(
+                        new DataField(0, "pk", DataTypes.INT()),
+                        new DataField(1, "s", nested),
+                        new DataField(4, "t", DataTypes.INT())));
+    }
+
+    // 
------------------------------------------------------------------------------------
+    // paths
+    // 
------------------------------------------------------------------------------------
+
+    @Test
+    public void testPathThroughSeveralRows() {
+        assertThat(
+                        convert(
+                                call(
+                                        BuiltInFunctionDefinitions.EQUALS,
+                                        intRef("s", "inner", "x"),
+                                        new ValueLiteralExpression(7))))
+                .isEqualTo(BUILDER.equal(S_INNER_X, 7));
+    }
+
+    @Test
+    public void testNestedCombinesWithTopLevelPredicates() {
+        ResolvedExpression nested =
+                call(
+                        BuiltInFunctionDefinitions.EQUALS,
+                        intRef("s", "a"),
+                        new ValueLiteralExpression(7));
+        ResolvedExpression topLevel =
+                call(
+                        BuiltInFunctionDefinitions.EQUALS,
+                        new FieldReferenceExpression(
+                                "pk", 
org.apache.flink.table.api.DataTypes.INT(), 0, 0),
+                        new ValueLiteralExpression(1));
+
+        assertThat(convert(call(BuiltInFunctionDefinitions.AND, nested, 
topLevel)))
+                .isEqualTo(PredicateBuilder.and(BUILDER.equal(S_A, 7), 
BUILDER.equal(0, 1)));
+        assertThat(convert(call(BuiltInFunctionDefinitions.OR, nested, 
topLevel)))
+                .isEqualTo(PredicateBuilder.or(BUILDER.equal(S_A, 7), 
BUILDER.equal(0, 1)));
+    }
+
+    /** A path whose leaf is not a field of the row is left for Flink to 
evaluate. */
+    @Test
+    public void testUnknownLeafIsNotConverted() {
+        assertThat(
+                        convert(
+                                call(
+                                        BuiltInFunctionDefinitions.EQUALS,
+                                        intRef("s", "missing"),
+                                        new ValueLiteralExpression(7))))
+                .isNull();
+    }
+
+    /** Nor is a path rooted at a field the table does not have. */
+    @Test
+    public void testUnknownRootIsNotConverted() {
+        assertThat(
+                        convert(
+                                call(
+                                        BuiltInFunctionDefinitions.EQUALS,
+                                        intRef("missing", "a"),
+                                        new ValueLiteralExpression(7))))
+                .isNull();
+    }
+
+    /** Only rows can be descended into; a path rooted at a non-row field is 
not converted. */
+    @Test
+    public void testPathUnderNonRowFieldIsNotConverted() {
+        assertThat(
+                        convert(
+                                call(
+                                        BuiltInFunctionDefinitions.EQUALS,
+                                        intRef("t", "a"),
+                                        new ValueLiteralExpression(7))))
+                .isNull();
+    }
+
+    /** A reference that names only a top-level field is not a nested path. */
+    @Test
+    public void testSingleComponentPathIsNotConverted() {
+        assertThat(
+                        convert(
+                                call(
+                                        BuiltInFunctionDefinitions.EQUALS,
+                                        intRef("pk"),
+                                        new ValueLiteralExpression(7))))
+                .isNull();
+    }
+}
diff --git 
a/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/NestedFieldFilterPushDownITCase.java
 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/NestedFieldFilterPushDownITCase.java
new file mode 100644
index 0000000000..b01051d235
--- /dev/null
+++ 
b/paimon-flink/paimon-flink-common/src/test/java/org/apache/paimon/flink/source/NestedFieldFilterPushDownITCase.java
@@ -0,0 +1,392 @@
+/*
+ * 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.flink.source;
+
+import org.apache.paimon.catalog.TableQueryAuthResult;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
+import org.apache.paimon.flink.CatalogITCaseBase;
+import org.apache.paimon.flink.sink.FlinkTableSink;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateRemapper;
+import org.apache.paimon.reader.RecordReader;
+import org.apache.paimon.table.FileStoreTable;
+import org.apache.paimon.table.sink.BatchTableCommit;
+import org.apache.paimon.table.sink.BatchTableWrite;
+import org.apache.paimon.table.sink.BatchWriteBuilder;
+import org.apache.paimon.table.source.ReadBuilder;
+
+import org.apache.flink.api.common.RuntimeExecutionMode;
+import org.apache.flink.api.connector.source.Source;
+import org.apache.flink.api.dag.Transformation;
+import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
+import org.apache.flink.streaming.api.transformations.SourceTransformation;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
+import org.apache.flink.table.catalog.ObjectIdentifier;
+import org.apache.flink.table.expressions.CallExpression;
+import org.apache.flink.table.expressions.FieldReferenceExpression;
+import org.apache.flink.table.expressions.NestedFieldReferenceExpression;
+import org.apache.flink.table.expressions.ResolvedExpression;
+import org.apache.flink.table.expressions.ValueLiteralExpression;
+import org.apache.flink.table.functions.BuiltInFunctionDefinitions;
+import org.apache.flink.types.Row;
+import org.junit.jupiter.api.Test;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatCode;
+
+/**
+ * ITCase for predicates on a field nested inside a row: that pushing them 
down leaves every result
+ * unchanged, that a real SQL plan hands them to the scan, and that the scan 
reads less for it.
+ *
+ * <p>The scan digest in {@code EXPLAIN} is deliberately not asserted on. 
{@link
+ * FlinkTableSource#applyFilters} reports every filter as accepted whether or 
not it could be
+ * converted, so {@code filter=[...]} reads the same either way. Instead, the 
source a real SQL plan
+ * ends up with is taken out of the translated job, and its own {@link 
ReadBuilder} is used to count
+ * what the scan returns.
+ */
+public class NestedFieldFilterPushDownITCase extends CatalogITCaseBase {
+
+    @Override
+    public List<String> ddl() {
+        return Arrays.asList(
+                "CREATE TABLE NT (pk INT, s ROW<a INT, b STRING>, d DOUBLE)",
+                "CREATE TABLE FT (pk INT, s ROW<d DOUBLE, f FLOAT>, d DOUBLE)",
+                "CREATE TABLE PPT (dt STRING, pk INT, s ROW<a INT>,"
+                        + " PRIMARY KEY (dt, pk) NOT ENFORCED) PARTITIONED BY 
(dt)"
+                        + " WITH ('bucket' = '1')");
+    }
+
+    // 
------------------------------------------------------------------------------------
+    // results do not change
+    // 
------------------------------------------------------------------------------------
+
+    @Test
+    public void testNestedPredicateKeepsMatchingRows() {
+        batchSql(
+                "INSERT INTO NT VALUES (1, ROW(7, 'abc'), 1.5), (2, ROW(8, 
'xyz'), 2.5),"
+                        + " (3, CAST(NULL AS ROW<a INT, b STRING>), 3.5)");
+
+        assertThat(batchSql("SELECT pk FROM NT WHERE s.a = 
7")).containsExactly(Row.of(1));
+        assertThat(batchSql("SELECT pk FROM NT WHERE s.a > 
7")).containsExactly(Row.of(2));
+        assertThat(batchSql("SELECT pk FROM NT WHERE s.a IN (7, 8)"))
+                .containsExactlyInAnyOrder(Row.of(1), Row.of(2));
+        assertThat(batchSql("SELECT pk FROM NT WHERE s.b LIKE 
'ab%%'")).containsExactly(Row.of(1));
+        assertThat(batchSql("SELECT pk FROM NT WHERE s.a IS 
NULL")).containsExactly(Row.of(3));
+        assertThat(batchSql("SELECT pk FROM NT WHERE s.a IS NOT NULL"))
+                .containsExactlyInAnyOrder(Row.of(1), Row.of(2));
+    }
+
+    @Test
+    public void testNegatedNestedPredicateKeepsMatchingRows() {
+        batchSql("INSERT INTO NT VALUES (1, ROW(7, 'abc'), 1.5), (2, ROW(8, 
'xyz'), 2.5)");
+
+        assertThat(batchSql("SELECT pk FROM NT WHERE NOT (s.a = 
7)")).containsExactly(Row.of(2));
+        assertThat(batchSql("SELECT pk FROM NT WHERE s.a NOT IN 
(7)")).containsExactly(Row.of(2));
+        assertThat(batchSql("SELECT pk FROM NT WHERE s.a NOT BETWEEN 1 AND 7"))
+                .containsExactly(Row.of(2));
+    }
+
+    @Test
+    public void testNestedAndTopLevelPredicatesCombine() {
+        batchSql(
+                "INSERT INTO NT VALUES (1, ROW(7, 'abc'), 1.5), (2, ROW(7, 
'xyz'), 2.5),"
+                        + " (3, ROW(8, 'abc'), 3.5)");
+
+        assertThat(batchSql("SELECT pk FROM NT WHERE s.a = 7 AND pk = 2"))
+                .containsExactly(Row.of(2));
+        assertThat(batchSql("SELECT pk FROM NT WHERE s.a = 8 OR pk = 1"))
+                .containsExactlyInAnyOrder(Row.of(1), Row.of(3));
+    }
+
+    // 
------------------------------------------------------------------------------------
+    // a real SQL plan hands the predicate to the scan, and the scan prunes on 
it
+    // 
------------------------------------------------------------------------------------
+
+    /**
+     * Two files whose {@code s.a} ranges do not overlap. The count is what 
the planned source's own
+     * scan returns, with no row-by-row filtering, so it can only drop below 
the table's six rows if
+     * the predicate reached the scan and whole row groups were skipped.
+     */
+    @Test
+    public void testRealPlanPushesNestedPredicateToScan() throws Exception {
+        writeTwoFilesWithDisjointNestedValues();
+
+        assertThat(rowsScannedByPlan("SELECT pk FROM NT")).isEqualTo(6);
+        // 3, not 1: the second file's row group is skipped whole and the 
first file's three rows
+        // all come back. A 1 would mean rows were filtered individually 
rather than pruned.
+        assertThat(rowsScannedByPlan("SELECT pk FROM NT WHERE s.a = 
2")).isEqualTo(3);
+        assertThat(rowsScannedByPlan("SELECT pk FROM NT WHERE s.a = 
9999")).isZero();
+        assertThat(rowsScannedByPlan("SELECT pk FROM NT WHERE s.a > 
0")).isEqualTo(6);
+
+        // and pruning never costs a matching row
+        assertThat(batchSql("SELECT pk FROM NT WHERE s.a = 
2")).containsExactly(Row.of(2));
+        assertThat(batchSql("SELECT pk FROM NT WHERE s.a = 9999")).isEmpty();
+    }
+
+    // 
------------------------------------------------------------------------------------
+    // floating point: nested comparisons are left to Flink
+    // 
------------------------------------------------------------------------------------
+
+    /** Flink SQL treats {@code -0.0 = 0.0} as true; the nested row must not 
be pruned away. */
+    @Test
+    public void testNestedDoubleSignedZeroKeepsMatchingRows() throws Exception 
{
+        writeNegativeZero();
+        assertThat(batchSql("SELECT pk FROM FT WHERE s.d = 
0.0")).containsExactly(Row.of(1));
+        assertThat(batchSql("SELECT pk FROM FT WHERE s.f = 
0.0")).containsExactly(Row.of(1));
+    }
+
+    /**
+     * Two files whose nested floating-point values do not overlap, so any of 
these predicates would
+     * prune one of them if it reached the scan. None may: the scan must 
return every row. {@code IS
+     * NULL} is not a comparison and is still pushed down, so it does prune.
+     */
+    @Test
+    public void testNestedFloatingPointComparisonsAreNotPushedToScan() throws 
Exception {
+        batchSql(
+                "INSERT INTO FT VALUES (1, ROW(1.0, CAST(1.0 AS FLOAT)), 1.0),"
+                        + " (2, ROW(2.0, CAST(2.0 AS FLOAT)), 2.0)");
+        batchSql(
+                "INSERT INTO FT VALUES (3, ROW(1001.0, CAST(1001.0 AS FLOAT)), 
3.0),"
+                        + " (4, ROW(1002.0, CAST(1002.0 AS FLOAT)), 4.0)");
+
+        for (String where :
+                new String[] {
+                    "s.d = 1.0",
+                    "s.d <> 1.0",
+                    "s.d < 5.0",
+                    "s.d <= 5.0",
+                    "s.d > 1000.0",
+                    "s.d >= 1000.0",
+                    "5.0 > s.d",
+                    "s.d IN (1.0, 2.0)",
+                    "s.d BETWEEN 0.0 AND 5.0",
+                    "s.f = 1.0",
+                    "s.f < 5.0"
+                }) {
+            assertThat(rowsScannedByPlan("SELECT pk FROM FT WHERE " + where))
+                    .as("%s must be left to Flink, not pushed to the scan", 
where)
+                    .isEqualTo(4);
+        }
+
+        assertThat(rowsScannedByPlan("SELECT pk FROM FT WHERE s.d IS 
NULL")).isZero();
+
+        // and Flink still evaluates them correctly
+        assertThat(batchSql("SELECT pk FROM FT WHERE s.d = 
1.0")).containsExactly(Row.of(1));
+        assertThat(batchSql("SELECT pk FROM FT WHERE s.d BETWEEN 0.0 AND 5.0"))
+                .containsExactlyInAnyOrder(Row.of(1), Row.of(2));
+    }
+
+    // 
------------------------------------------------------------------------------------
+    // field ids: the predicate carries the table's own ids
+    // 
------------------------------------------------------------------------------------
+
+    /**
+     * On an evolved schema, field ids are not the ones a type round-tripped 
through Flink would
+     * number: here {@code s.c} is added after a column was dropped. A masked 
read remaps the filter
+     * onto the table's read schema ({@code ReadTransform} keeps the conjuncts 
on masked columns via
+     * {@code TableQueryAuthResult.retainFields}, then {@code 
PredicateRemapper.remap}) and the
+     * nested transform checks the ids it carries against the table's.
+     *
+     * <p>The predicate is taken from {@link FlinkTableSource#applyFilters} 
itself, so this covers
+     * how the source builds it, not only the converter.
+     */
+    @Test
+    public void testNestedPredicateKeepsTableFieldIdsOnEvolvedSchema() throws 
Exception {
+        evolveNestedRow();
+        FileStoreTable table = paimonTable("NT");
+
+        DataTableSource source =
+                new DataTableSource(
+                        ObjectIdentifier.of("PAIMON", "default", "NT"), table, 
false, null);
+        source.applyFilters(Collections.singletonList(nestedEqualsInt("s", 
"c", 1)));
+        assertThat(source.predicate).as("s.c = 1 must be 
converted").isNotNull();
+
+        Predicate onMaskedColumn =
+                TableQueryAuthResult.retainFields(source.predicate, 
Collections.singleton("s"));
+        assertThatCode(() -> PredicateRemapper.remap(onMaskedColumn, 
table.rowType()))
+                .doesNotThrowAnyException();
+    }
+
+    /** Queries on an evolved nested row keep returning the right rows. */
+    @Test
+    public void testNestedPredicateOnEvolvedSchemaKeepsMatchingRows() {
+        batchSql("INSERT INTO NT VALUES (1, ROW(7, 'x'), 1.0)");
+        evolveNestedRow();
+        batchSql("INSERT INTO NT VALUES (2, ROW(8, 'y', 1), 2.0), (3, ROW(9, 
'z', 2), 3.0)");
+
+        assertThat(batchSql("SELECT pk FROM NT WHERE s.c = 
1")).containsExactly(Row.of(2));
+        // rows written before s.c existed read it as null
+        assertThat(batchSql("SELECT pk FROM NT WHERE s.c IS 
NULL")).containsExactly(Row.of(1));
+        assertThat(batchSql("SELECT pk FROM NT WHERE s.a = 
7")).containsExactly(Row.of(1));
+    }
+
+    // 
------------------------------------------------------------------------------------
+    // DELETE: a nested predicate is never executed by Paimon as a partition 
drop
+    // 
------------------------------------------------------------------------------------
+
+    /**
+     * {@code applyDeleteFilters} returning true hands the whole DELETE to 
Paimon, which can only
+     * execute it by dropping partitions. A nested predicate must never 
qualify, alone or next to a
+     * partition key, or rows it does not match would be deleted with the 
partition.
+     *
+     * <p>Called directly rather than through {@code DELETE FROM ... WHERE s.a 
= ...}: Flink's own
+     * delete push-down resolves such a filter without a row type and fails in 
the planner before
+     * this sink is ever asked.
+     */
+    @Test
+    public void testNestedDeleteFilterIsNeverExecutedByPaimon() throws 
Exception {
+        ObjectIdentifier identifier = ObjectIdentifier.of("PAIMON", "default", 
"PPT");
+        ResolvedExpression partition =
+                CallExpression.permanent(
+                        BuiltInFunctionDefinitions.EQUALS,
+                        Arrays.asList(
+                                new FieldReferenceExpression("dt", 
DataTypes.STRING(), 0, 0),
+                                new ValueLiteralExpression("p1")),
+                        DataTypes.BOOLEAN());
+        ResolvedExpression nested = nestedEqualsInt("s", "a", 2);
+
+        // control: a partition key alone is handed to Paimon as a partition 
drop
+        assertThat(
+                        new FlinkTableSink(identifier, paimonTable("PPT"), 
null)
+                                
.applyDeleteFilters(Collections.singletonList(partition)))
+                .isTrue();
+
+        assertThat(
+                        new FlinkTableSink(identifier, paimonTable("PPT"), 
null)
+                                
.applyDeleteFilters(Collections.singletonList(nested)))
+                .isFalse();
+        assertThat(
+                        new FlinkTableSink(identifier, paimonTable("PPT"), 
null)
+                                .applyDeleteFilters(Arrays.asList(partition, 
nested)))
+                .as("a nested predicate next to a partition key must not drop 
the partition")
+                .isFalse();
+    }
+
+    // 
------------------------------------------------------------------------------------
+    // helpers
+    // 
------------------------------------------------------------------------------------
+
+    private void writeTwoFilesWithDisjointNestedValues() {
+        // two commits, so the rows land in two files whose s.a ranges do not 
overlap
+        batchSql(
+                "INSERT INTO NT VALUES (1, ROW(1, 'x'), 1.0), (2, ROW(2, 'x'), 
1.0),"
+                        + " (3, ROW(3, 'x'), 1.0)");
+        batchSql(
+                "INSERT INTO NT VALUES (4, ROW(1001, 'y'), 2.0), (5, ROW(1002, 
'y'), 2.0),"
+                        + " (6, ROW(1003, 'y'), 2.0)");
+    }
+
+    private void evolveNestedRow() {
+        // leaves a gap in the field ids, then adds s.c after it
+        batchSql("ALTER TABLE NT ADD extra INT");
+        batchSql("ALTER TABLE NT DROP extra");
+        batchSql("ALTER TABLE NT MODIFY s ROW<a INT, b STRING, c INT>");
+    }
+
+    private static ResolvedExpression nestedEqualsInt(String root, String 
leaf, int literal) {
+        return CallExpression.permanent(
+                BuiltInFunctionDefinitions.EQUALS,
+                Arrays.asList(
+                        new NestedFieldReferenceExpression(
+                                new String[] {root, leaf}, new int[] {1, 2}, 
DataTypes.INT()),
+                        new ValueLiteralExpression(literal)),
+                DataTypes.BOOLEAN());
+    }
+
+    /**
+     * Writes one row whose nested and top-level floating-point values are all 
negative zero.
+     * Written through the table API because SQL cannot express it: {@code 
CAST('-0.0' AS DOUBLE)}
+     * goes through a decimal, which has no negative zero, and comes back as 
{@code 0.0}.
+     */
+    private void writeNegativeZero() throws Exception {
+        FileStoreTable table = paimonTable("FT");
+        BatchWriteBuilder builder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = builder.newWrite();
+                BatchTableCommit commit = builder.newCommit()) {
+            write.write(GenericRow.of(1, GenericRow.of(-0.0d, -0.0f), -0.0d));
+            commit.commit(write.prepareCommit());
+        }
+
+        // it really is stored as negative zero
+        List<InternalRow> rows = new ArrayList<>();
+        ReadBuilder readBuilder = table.newReadBuilder();
+        readBuilder
+                .newRead()
+                .createReader(readBuilder.newScan().plan())
+                .forEachRemaining(rows::add);
+        assertThat(rows).hasSize(1);
+        InternalRow nested = rows.get(0).getRow(1, 2);
+        assertThat(Double.doubleToRawLongBits(nested.getDouble(0)))
+                .isEqualTo(Double.doubleToRawLongBits(-0.0d));
+        assertThat(Float.floatToRawIntBits(nested.getFloat(1)))
+                .isEqualTo(Float.floatToRawIntBits(-0.0f));
+    }
+
+    /**
+     * Plans {@code sql} the way a real query is planned, takes the source out 
of the translated
+     * job, and counts what that source's own scan returns. Nothing filters 
row by row here: a count
+     * below the table's size means the predicate reached the scan and pruned.
+     */
+    private int rowsScannedByPlan(String sql) throws Exception {
+        StreamExecutionEnvironment env = 
StreamExecutionEnvironment.getExecutionEnvironment();
+        env.setRuntimeMode(RuntimeExecutionMode.BATCH);
+        StreamTableEnvironment planner = StreamTableEnvironment.create(env);
+        planner.registerCatalog("PAIMON", tEnv.getCatalog("PAIMON").get());
+        planner.useCatalog("PAIMON");
+
+        FlinkSource source =
+                
plannedSource(planner.toDataStream(planner.sqlQuery(sql)).getTransformation());
+        ReadBuilder readBuilder = source.readBuilder;
+        AtomicInteger count = new AtomicInteger();
+        try (RecordReader<InternalRow> reader =
+                
readBuilder.newRead().createReader(readBuilder.newScan().plan())) {
+            reader.forEachRemaining(row -> count.incrementAndGet());
+        }
+        return count.get();
+    }
+
+    private static FlinkSource plannedSource(Transformation<?> transformation) 
throws Exception {
+        if (transformation instanceof SourceTransformation) {
+            Source<?, ?, ?> source = ((SourceTransformation<?, ?, ?>) 
transformation).getSource();
+            if (source instanceof PaimonDataStreamSource) {
+                // the wrapper keeps the source it delegates to private
+                Field inner = 
PaimonDataStreamSource.class.getDeclaredField("source");
+                inner.setAccessible(true);
+                source = (Source<?, ?, ?>) inner.get(source);
+            }
+            return (FlinkSource) source;
+        }
+        for (Transformation<?> input : transformation.getInputs()) {
+            FlinkSource found = plannedSource(input);
+            if (found != null) {
+                return found;
+            }
+        }
+        return null;
+    }
+}

Reply via email to