voonhous commented on code in PR #19456:
URL: https://github.com/apache/hudi/pull/19456#discussion_r3698764299
##########
hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPageSourceProvider.java:
##########
@@ -482,41 +486,135 @@ public static List<HiveColumnHandle>
remapColumnIndicesToPhysical(
boolean caseSensitive)
{
// Create a map from column name to its physical index in the
fileSchema.
- Map<String, Integer> physicalIndexMap = new HashMap<>();
- List<Type> fileFields = fileSchema.getFields();
- for (int i = 0; i < fileFields.size(); i++) {
- Type field = fileFields.get(i);
- String fieldName = field.getName();
- String mapKey = caseSensitive ? fieldName :
fieldName.toLowerCase(Locale.ROOT);
- physicalIndexMap.put(mapKey, i);
- }
+ Map<String, Integer> physicalIndexMap =
buildPhysicalIndexMap(fileSchema, caseSensitive);
// Iterate through the columns requested by Trino IN ORDER.
List<HiveColumnHandle> remappedHandles = new
ArrayList<>(requestedColumns.size());
for (HiveColumnHandle originalHandle : requestedColumns) {
- String requestedName = originalHandle.getBaseColumnName();
-
- // Determine the key to use for looking up the physical index
- String lookupKey = caseSensitive ? requestedName :
requestedName.toLowerCase(Locale.ROOT);
-
// Find the physical index from the file schema map constructed
from fileSchema. A column the file
// does not carry keeps an index one past the last field, which
the parquet reader null-fills.
- Integer physicalIndex = physicalIndexMap.get(lookupKey);
-
- HiveColumnHandle remappedHandle = new HiveColumnHandle(
- requestedName,
- physicalIndex == null ? fileFields.size() : physicalIndex,
- originalHandle.getBaseHiveType(),
- originalHandle.getType(),
- originalHandle.getHiveColumnProjectionInfo(),
- originalHandle.getColumnType(),
- originalHandle.getComment());
- remappedHandles.add(remappedHandle);
+ Integer physicalIndex =
physicalIndexMap.get(lookupKey(originalHandle.getBaseColumnName(),
caseSensitive));
+ remappedHandles.add(withPhysicalIndex(originalHandle,
physicalIndex == null ? fileSchema.getFieldCount() : physicalIndex));
}
return remappedHandles;
}
+ /**
+ * Rebuilds a predicate's column handles on physical file ordinals, the
predicate-side counterpart of
+ * {@link #remapColumnIndicesToPhysical}. With {@code
hudi.parquet.use-column-names=false},
+ * {@code ParquetPageSourceFactory.getParquetTupleDomain} resolves a
predicate column positionally, as
+ * {@code fileSchema.getType(handle.getBaseHiveColumnIndex())}, but the
handles reaching it carry METASTORE
+ * ordinals: a metastore that omits the Hudi meta fields (hive sync with
{@code omit_metadata_fields=true})
+ * shifts every data column, and so does reordering or dropping one. Left
unremapped, the domain attaches to
+ * whichever column happens to sit at the stale ordinal and row groups are
pruned on that column's statistics,
+ * silently dropping rows.
+ * <p>
+ * Resolution is by name, so the predicate ends up bound to exactly the
column the projection reads - which is
+ * the property that matters, since the two are compared against each
other. It is not a defence against a
+ * column being dropped and re-added under full schema evolution:
name-based binding will match the new column
+ * to the old one, exactly as the projection remap and the whole {@code
use-column-names=true} mode already do.
+ * <p>
+ * A column the file does not carry is dropped from the predicate rather
than mapped to the
+ * {@link #remapColumnIndicesToPhysical} sentinel, which every absent
column would share. Dropping loses row
+ * group pruning but never a row: the static half of the predicate is
handed back to the engine in full as
+ * {@code HudiMetadata.applyFilter}'s remaining filter, and the dynamic
half is by construction redundant with
+ * the join above the scan. It is also what already happens today for a
predicate column the query does not
+ * read, since {@code descriptorsByPath} is derived from the projection and
+ * {@code getParquetTupleDomain} skips any column it cannot resolve.
+ *
+ * @param fileSchema The MessageType representing the physical schema of
the Parquet file.
+ * @param predicate The predicate to push down, keyed on handles carrying
metastore ordinals.
+ * @param caseSensitive Whether the lookup between Trino column names
(from handles) and Parquet field names (from fileSchema) should be
case-sensitive.
+ * @return The same domains, keyed on handles carrying physical ordinals,
minus the columns the file lacks.
+ */
+ @VisibleForTesting
+ public static TupleDomain<HiveColumnHandle>
remapPredicateColumnIndicesToPhysical(
+ MessageType fileSchema,
+ TupleDomain<HiveColumnHandle> predicate,
+ boolean caseSensitive)
+ {
+ if (predicate.isAll() || predicate.isNone()) {
+ return predicate;
+ }
+
+ Map<String, Integer> physicalIndexMap =
buildPhysicalIndexMap(fileSchema, caseSensitive);
+ Set<Integer> pushedPhysicalIndices = new HashSet<>();
+ Map<HiveColumnHandle, Domain> remappedDomains = new LinkedHashMap<>();
+ for (Map.Entry<HiveColumnHandle, Domain> entry :
predicate.getDomains().orElseThrow().entrySet()) {
+ Integer physicalIndex =
physicalIndexMap.get(lookupKey(entry.getKey().getBaseColumnName(),
caseSensitive));
+ if (physicalIndex == null) {
+ continue;
+ }
+ // Deduplicate on the physical index rather than on the rebuilt
handle: two handles whose names differ
+ // only by case resolve to one file column while staying unequal
to each other, and pushing both down
+ // would hand getParquetTupleDomain the same ColumnDescriptor
twice, which it rejects by failing the
+ // split. That needs a metastore holding two such columns, which
Hive's name normalisation rules out,
+ // but keeping only the first domain is a cheap guarantee that the
read can never be made worse than
+ // pushing nothing down.
+ if (pushedPhysicalIndices.add(physicalIndex)) {
+ remappedDomains.put(withPhysicalIndex(entry.getKey(),
physicalIndex), entry.getValue());
+ }
+ }
+ return TupleDomain.withColumnDomains(remappedDomains);
+ }
+
+ /**
+ * Maps each of {@code fileSchema}'s top-level field names to its physical
position.
+ */
+ private static Map<String, Integer> buildPhysicalIndexMap(MessageType
fileSchema, boolean caseSensitive)
+ {
+ Map<String, Integer> physicalIndexMap = new HashMap<>();
+ List<Type> fileFields = fileSchema.getFields();
+ for (int i = 0; i < fileFields.size(); i++) {
+ physicalIndexMap.put(lookupKey(fileFields.get(i).getName(),
caseSensitive), i);
+ }
+ return physicalIndexMap;
+ }
+
+ private static String lookupKey(String columnName, boolean caseSensitive)
+ {
+ return caseSensitive ? columnName :
columnName.toLowerCase(Locale.ROOT);
+ }
+
+ /**
+ * Copies {@code handle} with its base column index replaced by a physical
one, every other attribute carried
+ * over unchanged. Note that the constructor's fourth argument is the BASE
type: it differs from
+ * {@code getType()} only for a dereference handle, whose {@code
getType()} is the projected subfield's type
+ * rather than the column's, and {@code createParquetPageSource} reads the
base type throughout.
+ * <p>
+ * Copying a dereference handle's projection across is not a claim that
such a handle would be read correctly;
+ * {@code createParquetPageSource} ignores {@code
getHiveColumnProjectionInfo} entirely. The connector never
+ * produces one, because {@code HudiMetadata} does not implement {@code
applyProjection}.
Review Comment:
**Correctness (javadoc).** This says `createParquetPageSource` ignores
`getHiveColumnProjectionInfo` entirely. It does not. In Trino 481:
```java
//
trino-hive-481-sources.jar!io/trino/plugin/hive/parquet/ParquetPageSourceFactory.java:524-533
if (column.isBaseColumn()) {
transforms.column(ordinal, coercer.map(Function.identity()));
}
else {
transforms.dereferenceField(
ImmutableList.<Integer>builder().add(ordinal).addAll(getProjection(column,
baseColumn)).build(),
coercer.map(Function.identity()));
}
```
and the same method reads `baseColumn.getBaseType()` at `:509` and `:511` to
pick the coercer and the read type. That is exactly *why* the `getType()` ->
`getBaseType()` swap in `withPhysicalIndex` is right. As written, the comment
tells the next maintainer the projection copy is inert, which is an invitation
to revert the very fix this hunk makes.
```suggestion
* Copying a dereference handle's projection across matters: {@code
createParquetPageSource} branches on
* {@code isBaseColumn()} and dereferences through {@code
getHiveColumnProjectionInfo}, and it reads the base
* column's stored {@code baseType} on the way. The connector never
produces such a handle today, because
* {@code HudiMetadata} does not implement {@code applyProjection}.
```
##########
hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiPredicatePushdownColumnOrdinals.java:
##########
@@ -0,0 +1,354 @@
+/*
+ * Licensed 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 io.trino.plugin.hudi;
+
+import io.trino.filesystem.local.LocalInputFile;
+import io.trino.parquet.ParquetReaderOptions;
+import io.trino.plugin.base.metrics.FileFormatDataSourceStats;
+import io.trino.plugin.hive.HiveColumnHandle;
+import io.trino.plugin.hive.parquet.ParquetReaderConfig;
+import io.trino.plugin.hudi.file.HudiBaseFile;
+import io.trino.spi.SplitWeight;
+import io.trino.spi.connector.ColumnHandle;
+import io.trino.spi.connector.ConnectorPageSource;
+import io.trino.spi.connector.ConnectorSession;
+import io.trino.spi.connector.DynamicFilter;
+import io.trino.spi.predicate.Domain;
+import io.trino.spi.predicate.Range;
+import io.trino.spi.predicate.TupleDomain;
+import io.trino.spi.predicate.ValueSet;
+import io.trino.spi.type.Type;
+import io.trino.testing.MaterializedResult;
+import io.trino.testing.TestingConnectorSession;
+import org.apache.parquet.conf.PlainParquetConfiguration;
+import org.apache.parquet.example.data.Group;
+import org.apache.parquet.example.data.simple.SimpleGroupFactory;
+import org.apache.parquet.hadoop.ParquetFileReader;
+import org.apache.parquet.hadoop.ParquetWriter;
+import org.apache.parquet.hadoop.example.ExampleParquetWriter;
+import org.apache.parquet.io.LocalOutputFile;
+import org.apache.parquet.schema.LogicalTypeAnnotation;
+import org.apache.parquet.schema.MessageType;
+import org.apache.parquet.schema.PrimitiveType;
+import org.apache.parquet.schema.Types;
+import org.joda.time.DateTimeZone;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalLong;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+
+import static io.trino.metastore.HiveType.HIVE_INT;
+import static io.trino.plugin.hive.HiveColumnHandle.ColumnType.REGULAR;
+import static io.trino.plugin.hive.HiveColumnHandle.createBaseColumn;
+import static io.trino.plugin.hudi.HudiPageSourceProvider.createPageSource;
+import static io.trino.spi.type.IntegerType.INTEGER;
+import static io.trino.testing.MaterializedResult.materializeSourceDataStream;
+import static java.lang.Integer.parseInt;
+import static org.apache.parquet.schema.Type.Repetition.OPTIONAL;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Reads a base file whose physical column order does not match the
metastore's, the layout hive sync produces
+ * with {@code hoodie.datasource.hive_sync.omit_metadata_fields=true}: the
five {@code _hoodie_*} meta columns are
+ * absent from the metastore, so every data column's metastore ordinal is five
below its physical position.
+ * <p>
+ * With {@code hudi.parquet.use-column-names=false} the parquet page source
resolves columns positionally, so a
+ * predicate whose handle still carries the metastore ordinal lands on
whichever column physically sits there and
+ * row groups get pruned on that column's statistics. The fixture makes that
observable: {@code c7} grows with the
+ * row index while every other data column stays in 0..9, so a domain meant
for {@code c7} but applied to any other
+ * column excludes every row group and the read returns nothing.
+ * <p>
+ * Note that the shadowed column has to be part of the PROJECTION for the
damage to appear: {@code
+ * descriptorsByPath} is derived from the projection, so a domain resolving to
a column the query does not read
+ * finds no descriptor and is discarded instead. Do not "simplify" the
projections below to the predicate column
+ * alone - that turns these tests green against the unfixed code.
+ */
+class TestHudiPredicatePushdownColumnOrdinals
Review Comment:
**Coverage.** Nothing at the connector level pins this fix, and that is
exactly the blind spot that let the bug ship in the first place.
`TestHudiConnectorParquetColumnNamesTest` already reruns the entire smoke
suite -- including the five dynamic-filter pushdown tests at
`TestHudiSmokeTest:899,935,963,996,1034` -- with
`hudi.parquet.use-column-names=false`, and it never caught this. The reason is
the fixture: `ResourceHudiTablesInitializer.java:365` registers
`_hoodie_commit_time` and friends in the metastore `Column` list, so every
table's metastore ordinals already equal its physical ones.
#19288 established the pattern that would catch it --
`io/trino/plugin/hudi/testing/OmittedOrderingFieldHudiTablesInitializer.java:52`
(`omitted_ordering_field_mor`), a metastore whose column list deliberately
omits a column the Avro schema carries.
The tests in this file call the package-private `createPageSource` directly,
so they pin the helper but not the wiring above it (`createBaseFilePageSource`
-> `createPageSource`, and `shouldUseParquetColumnNames` reaching it from the
session).
Please add one initializer whose metastore `Column` list omits the five
`_hoodie_*` columns, register it alongside the existing
`use-column-names=false` suite, and assert that a single `WHERE <late_column> >
k` returns the right rows. That is the one test that survives a future refactor
of `createPageSource`'s signature.
##########
hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPageSourceProvider.java:
##########
@@ -482,41 +486,135 @@ public static List<HiveColumnHandle>
remapColumnIndicesToPhysical(
boolean caseSensitive)
{
// Create a map from column name to its physical index in the
fileSchema.
- Map<String, Integer> physicalIndexMap = new HashMap<>();
- List<Type> fileFields = fileSchema.getFields();
- for (int i = 0; i < fileFields.size(); i++) {
- Type field = fileFields.get(i);
- String fieldName = field.getName();
- String mapKey = caseSensitive ? fieldName :
fieldName.toLowerCase(Locale.ROOT);
- physicalIndexMap.put(mapKey, i);
- }
+ Map<String, Integer> physicalIndexMap =
buildPhysicalIndexMap(fileSchema, caseSensitive);
// Iterate through the columns requested by Trino IN ORDER.
List<HiveColumnHandle> remappedHandles = new
ArrayList<>(requestedColumns.size());
for (HiveColumnHandle originalHandle : requestedColumns) {
- String requestedName = originalHandle.getBaseColumnName();
-
- // Determine the key to use for looking up the physical index
- String lookupKey = caseSensitive ? requestedName :
requestedName.toLowerCase(Locale.ROOT);
-
// Find the physical index from the file schema map constructed
from fileSchema. A column the file
// does not carry keeps an index one past the last field, which
the parquet reader null-fills.
- Integer physicalIndex = physicalIndexMap.get(lookupKey);
-
- HiveColumnHandle remappedHandle = new HiveColumnHandle(
- requestedName,
- physicalIndex == null ? fileFields.size() : physicalIndex,
- originalHandle.getBaseHiveType(),
- originalHandle.getType(),
- originalHandle.getHiveColumnProjectionInfo(),
- originalHandle.getColumnType(),
- originalHandle.getComment());
- remappedHandles.add(remappedHandle);
+ Integer physicalIndex =
physicalIndexMap.get(lookupKey(originalHandle.getBaseColumnName(),
caseSensitive));
+ remappedHandles.add(withPhysicalIndex(originalHandle,
physicalIndex == null ? fileSchema.getFieldCount() : physicalIndex));
}
return remappedHandles;
}
+ /**
+ * Rebuilds a predicate's column handles on physical file ordinals, the
predicate-side counterpart of
+ * {@link #remapColumnIndicesToPhysical}. With {@code
hudi.parquet.use-column-names=false},
+ * {@code ParquetPageSourceFactory.getParquetTupleDomain} resolves a
predicate column positionally, as
+ * {@code fileSchema.getType(handle.getBaseHiveColumnIndex())}, but the
handles reaching it carry METASTORE
+ * ordinals: a metastore that omits the Hudi meta fields (hive sync with
{@code omit_metadata_fields=true})
+ * shifts every data column, and so does reordering or dropping one. Left
unremapped, the domain attaches to
+ * whichever column happens to sit at the stale ordinal and row groups are
pruned on that column's statistics,
+ * silently dropping rows.
+ * <p>
+ * Resolution is by name, so the predicate ends up bound to exactly the
column the projection reads - which is
+ * the property that matters, since the two are compared against each
other. It is not a defence against a
+ * column being dropped and re-added under full schema evolution:
name-based binding will match the new column
+ * to the old one, exactly as the projection remap and the whole {@code
use-column-names=true} mode already do.
+ * <p>
+ * A column the file does not carry is dropped from the predicate rather
than mapped to the
+ * {@link #remapColumnIndicesToPhysical} sentinel, which every absent
column would share. Dropping loses row
+ * group pruning but never a row: the static half of the predicate is
handed back to the engine in full as
+ * {@code HudiMetadata.applyFilter}'s remaining filter, and the dynamic
half is by construction redundant with
+ * the join above the scan. It is also what already happens today for a
predicate column the query does not
+ * read, since {@code descriptorsByPath} is derived from the projection and
+ * {@code getParquetTupleDomain} skips any column it cannot resolve.
+ *
+ * @param fileSchema The MessageType representing the physical schema of
the Parquet file.
+ * @param predicate The predicate to push down, keyed on handles carrying
metastore ordinals.
+ * @param caseSensitive Whether the lookup between Trino column names
(from handles) and Parquet field names (from fileSchema) should be
case-sensitive.
+ * @return The same domains, keyed on handles carrying physical ordinals,
minus the columns the file lacks.
+ */
+ @VisibleForTesting
+ public static TupleDomain<HiveColumnHandle>
remapPredicateColumnIndicesToPhysical(
+ MessageType fileSchema,
+ TupleDomain<HiveColumnHandle> predicate,
+ boolean caseSensitive)
+ {
+ if (predicate.isAll() || predicate.isNone()) {
+ return predicate;
+ }
+
+ Map<String, Integer> physicalIndexMap =
buildPhysicalIndexMap(fileSchema, caseSensitive);
Review Comment:
**Performance, minor.** Under `use-column-names=false`
`buildPhysicalIndexMap` now runs twice per split -- once at `:489` for the
projection remap and once here for the predicate remap -- and each pass does a
`toLowerCase(Locale.ROOT)` per top-level file field. On a wide table that is a
needless per-split allocation plus O(fields) string work.
Build it once in `createPageSource` and pass the map into both remaps;
`remapPredicateColumnIndicesToPhysical` only takes `fileSchema` in order to
build it. If you would rather keep the two helpers self-contained for the
`@VisibleForTesting` entry points, an overload taking the prebuilt map with the
current signature delegating to it keeps both properties.
##########
hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiPredicatePushdownColumnOrdinals.java:
##########
@@ -0,0 +1,354 @@
+/*
+ * Licensed 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 io.trino.plugin.hudi;
+
+import io.trino.filesystem.local.LocalInputFile;
+import io.trino.parquet.ParquetReaderOptions;
+import io.trino.plugin.base.metrics.FileFormatDataSourceStats;
+import io.trino.plugin.hive.HiveColumnHandle;
+import io.trino.plugin.hive.parquet.ParquetReaderConfig;
+import io.trino.plugin.hudi.file.HudiBaseFile;
+import io.trino.spi.SplitWeight;
+import io.trino.spi.connector.ColumnHandle;
+import io.trino.spi.connector.ConnectorPageSource;
+import io.trino.spi.connector.ConnectorSession;
+import io.trino.spi.connector.DynamicFilter;
+import io.trino.spi.predicate.Domain;
+import io.trino.spi.predicate.Range;
+import io.trino.spi.predicate.TupleDomain;
+import io.trino.spi.predicate.ValueSet;
+import io.trino.spi.type.Type;
+import io.trino.testing.MaterializedResult;
+import io.trino.testing.TestingConnectorSession;
+import org.apache.parquet.conf.PlainParquetConfiguration;
+import org.apache.parquet.example.data.Group;
+import org.apache.parquet.example.data.simple.SimpleGroupFactory;
+import org.apache.parquet.hadoop.ParquetFileReader;
+import org.apache.parquet.hadoop.ParquetWriter;
+import org.apache.parquet.hadoop.example.ExampleParquetWriter;
+import org.apache.parquet.io.LocalOutputFile;
+import org.apache.parquet.schema.LogicalTypeAnnotation;
+import org.apache.parquet.schema.MessageType;
+import org.apache.parquet.schema.PrimitiveType;
+import org.apache.parquet.schema.Types;
+import org.joda.time.DateTimeZone;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalLong;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+
+import static io.trino.metastore.HiveType.HIVE_INT;
+import static io.trino.plugin.hive.HiveColumnHandle.ColumnType.REGULAR;
+import static io.trino.plugin.hive.HiveColumnHandle.createBaseColumn;
+import static io.trino.plugin.hudi.HudiPageSourceProvider.createPageSource;
+import static io.trino.spi.type.IntegerType.INTEGER;
+import static io.trino.testing.MaterializedResult.materializeSourceDataStream;
+import static java.lang.Integer.parseInt;
+import static org.apache.parquet.schema.Type.Repetition.OPTIONAL;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Reads a base file whose physical column order does not match the
metastore's, the layout hive sync produces
+ * with {@code hoodie.datasource.hive_sync.omit_metadata_fields=true}: the
five {@code _hoodie_*} meta columns are
+ * absent from the metastore, so every data column's metastore ordinal is five
below its physical position.
+ * <p>
+ * With {@code hudi.parquet.use-column-names=false} the parquet page source
resolves columns positionally, so a
+ * predicate whose handle still carries the metastore ordinal lands on
whichever column physically sits there and
+ * row groups get pruned on that column's statistics. The fixture makes that
observable: {@code c7} grows with the
+ * row index while every other data column stays in 0..9, so a domain meant
for {@code c7} but applied to any other
+ * column excludes every row group and the read returns nothing.
+ * <p>
+ * Note that the shadowed column has to be part of the PROJECTION for the
damage to appear: {@code
+ * descriptorsByPath} is derived from the projection, so a domain resolving to
a column the query does not read
+ * finds no descriptor and is discarded instead. Do not "simplify" the
projections below to the predicate column
+ * alone - that turns these tests green against the unfixed code.
+ */
+class TestHudiPredicatePushdownColumnOrdinals
+{
+ private static final List<String> META_COLUMNS = List.of(
+ "_hoodie_commit_time",
+ "_hoodie_commit_seqno",
+ "_hoodie_record_key",
+ "_hoodie_partition_path",
+ "_hoodie_file_name");
+ private static final int DATA_COLUMN_COUNT = 10;
+ /** The column the predicate is on: physically at 12, but numbered 7 by a
metastore without the meta columns. */
+ private static final String PREDICATE_COLUMN = "c7";
+ /** The column physically sitting at {@code c7}'s stale ordinal, and
therefore the one that shadows it. */
+ private static final String SHADOWED_COLUMN = "c2";
+ private static final int ROW_COUNT = 1000;
+ private static final long THRESHOLD = 900;
+ private static final int MATCHING_ROW_COUNT = (int) (ROW_COUNT - THRESHOLD
- 1);
+
+ @TempDir
+ static Path tempDir;
+
+ private static Path baseFile;
+
+ @BeforeAll
+ static void writeBaseFile()
+ throws IOException
+ {
+ MessageType schema = fileSchema();
+ baseFile = tempDir.resolve("base_file.parquet");
+ SimpleGroupFactory groupFactory = new SimpleGroupFactory(schema);
+ try (ParquetWriter<Group> writer = ExampleParquetWriter.builder(new
LocalOutputFile(baseFile))
+ .withType(schema)
+ .withConf(new PlainParquetConfiguration())
+ .withRowGroupSize(1024L)
+ .withPageSize(512)
+ .build()) {
+ for (int row = 0; row < ROW_COUNT; row++) {
+ Group group = groupFactory.newGroup();
+ for (String metaColumn : META_COLUMNS) {
+ group.append(metaColumn, metaColumn + "_" + row);
+ }
+ for (int column = 0; column < DATA_COLUMN_COUNT; column++) {
+ String columnName = "c" + column;
+ group.append(columnName,
columnName.equals(PREDICATE_COLUMN) ? row : row % 10);
+ }
+ writer.write(group);
+ }
+ }
+ // The writer flushes a row group whenever the buffered size is over
withRowGroupSize, checked every
+ // parquet.page.size.row.check.min records (100 by default), which is
what actually splits this file.
+ // Assert the outcome rather than the knobs: with a single row group
there would be nothing to prune,
+ // and every test below would pass without proving anything.
+ assertThat(rowGroupCount(baseFile)).as("row groups
written").isGreaterThan(1);
+ }
+
+ @Test
+ public void testPredicateOnStaleOrdinalKeepsMatchingRows()
+ throws Exception
+ {
+ List<HiveColumnHandle> projection =
List.of(dataColumn(SHADOWED_COLUMN), dataColumn(PREDICATE_COLUMN));
+
+ MaterializedResult result = read(projection,
greaterThanThreshold(PREDICATE_COLUMN), false, DynamicFilter.EMPTY);
+
+ // The shadowed column never leaves 0..9, so a domain of "> 900"
applied to it prunes every row group
+ assertThat(matchingRowCount(result, projection, PREDICATE_COLUMN))
+ .as("rows matching %s > %s", PREDICATE_COLUMN, THRESHOLD)
+ .isEqualTo(MATCHING_ROW_COUNT);
+ }
+
Review Comment:
**Cleanliness.** `testPredicateOnStaleOrdinalKeepsMatchingRows` is strictly
subsumed by `testPredicateOnStaleOrdinalStillPrunesRowGroups` directly below
it: same `projection`, same `read(projection,
greaterThanThreshold(PREDICATE_COLUMN), false, DynamicFilter.EMPTY)`, and
`:169-171` repeats this test's only assertion verbatim (just a different
`.as()` label) and adds `rowCount < ROW_COUNT` on top.
Confirmed by A/B -- with the fix's call site reverted, both fail identically:
```
testPredicateOnStaleOrdinalKeepsMatchingRows:152 expected: 99L but was: 0L
testPredicateOnStaleOrdinalStillPrunesRowGroups:171 expected: 99L but was: 0L
```
Please delete it; the superset test loses nothing.
```suggestion
```
##########
hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPageSourceProvider.java:
##########
@@ -482,41 +486,135 @@ public static List<HiveColumnHandle>
remapColumnIndicesToPhysical(
boolean caseSensitive)
{
// Create a map from column name to its physical index in the
fileSchema.
- Map<String, Integer> physicalIndexMap = new HashMap<>();
- List<Type> fileFields = fileSchema.getFields();
- for (int i = 0; i < fileFields.size(); i++) {
- Type field = fileFields.get(i);
- String fieldName = field.getName();
- String mapKey = caseSensitive ? fieldName :
fieldName.toLowerCase(Locale.ROOT);
- physicalIndexMap.put(mapKey, i);
- }
+ Map<String, Integer> physicalIndexMap =
buildPhysicalIndexMap(fileSchema, caseSensitive);
// Iterate through the columns requested by Trino IN ORDER.
List<HiveColumnHandle> remappedHandles = new
ArrayList<>(requestedColumns.size());
for (HiveColumnHandle originalHandle : requestedColumns) {
- String requestedName = originalHandle.getBaseColumnName();
-
- // Determine the key to use for looking up the physical index
- String lookupKey = caseSensitive ? requestedName :
requestedName.toLowerCase(Locale.ROOT);
-
// Find the physical index from the file schema map constructed
from fileSchema. A column the file
// does not carry keeps an index one past the last field, which
the parquet reader null-fills.
- Integer physicalIndex = physicalIndexMap.get(lookupKey);
-
- HiveColumnHandle remappedHandle = new HiveColumnHandle(
- requestedName,
- physicalIndex == null ? fileFields.size() : physicalIndex,
- originalHandle.getBaseHiveType(),
- originalHandle.getType(),
- originalHandle.getHiveColumnProjectionInfo(),
- originalHandle.getColumnType(),
- originalHandle.getComment());
- remappedHandles.add(remappedHandle);
+ Integer physicalIndex =
physicalIndexMap.get(lookupKey(originalHandle.getBaseColumnName(),
caseSensitive));
+ remappedHandles.add(withPhysicalIndex(originalHandle,
physicalIndex == null ? fileSchema.getFieldCount() : physicalIndex));
}
return remappedHandles;
}
+ /**
+ * Rebuilds a predicate's column handles on physical file ordinals, the
predicate-side counterpart of
+ * {@link #remapColumnIndicesToPhysical}. With {@code
hudi.parquet.use-column-names=false},
+ * {@code ParquetPageSourceFactory.getParquetTupleDomain} resolves a
predicate column positionally, as
+ * {@code fileSchema.getType(handle.getBaseHiveColumnIndex())}, but the
handles reaching it carry METASTORE
+ * ordinals: a metastore that omits the Hudi meta fields (hive sync with
{@code omit_metadata_fields=true})
+ * shifts every data column, and so does reordering or dropping one. Left
unremapped, the domain attaches to
+ * whichever column happens to sit at the stale ordinal and row groups are
pruned on that column's statistics,
+ * silently dropping rows.
+ * <p>
+ * Resolution is by name, so the predicate ends up bound to exactly the
column the projection reads - which is
+ * the property that matters, since the two are compared against each
other. It is not a defence against a
+ * column being dropped and re-added under full schema evolution:
name-based binding will match the new column
+ * to the old one, exactly as the projection remap and the whole {@code
use-column-names=true} mode already do.
Review Comment:
**Follow-up issue, not a change request for this PR.** The PR description
says positional mode now "inherits whatever the name-based mode already does"
with a promoted metastore type. Worth recording what that inheritance actually
is: a hard query failure, in both modes.
With a metastore that says `c7` is DOUBLE after a float -> double evolution
while the base file still holds FLOAT:
```
PR HEAD, use-column-names=true -> TrinoException: Malformed Parquet file.
Corrupted statistics for column "[c7] optional float c7"
PR HEAD, use-column-names=false -> TrinoException: ... column "[c7]
optional float c7"
pre-PR, use-column-names=false -> TrinoException: ... column "[c2]
optional int32 c2"
```
`TupleDomainParquetPredicate.getDomain` (trino-parquet 481, `:420-424`)
casts a `Float` to `Double`; the CCE is wrapped as `ParquetCorruptionException`
at `:658` and mapped to `HUDI_BAD_DATA` by `createPageSource:448`. `int ->
long` is benign (`asLong` at `:684` takes any `Number`); it is the float/double
family that breaks.
So this is not a regression -- the PR strictly improves it, from failing
while naming the wrong column to failing while naming the right one. But
hudi-trino has no schema-evolution test at all today. Could you file a GitHub
issue with this repro and link it from #18780? No change needed here.
##########
hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiPredicatePushdownColumnOrdinals.java:
##########
@@ -0,0 +1,354 @@
+/*
+ * Licensed 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 io.trino.plugin.hudi;
+
+import io.trino.filesystem.local.LocalInputFile;
+import io.trino.parquet.ParquetReaderOptions;
+import io.trino.plugin.base.metrics.FileFormatDataSourceStats;
+import io.trino.plugin.hive.HiveColumnHandle;
+import io.trino.plugin.hive.parquet.ParquetReaderConfig;
+import io.trino.plugin.hudi.file.HudiBaseFile;
+import io.trino.spi.SplitWeight;
+import io.trino.spi.connector.ColumnHandle;
+import io.trino.spi.connector.ConnectorPageSource;
+import io.trino.spi.connector.ConnectorSession;
+import io.trino.spi.connector.DynamicFilter;
+import io.trino.spi.predicate.Domain;
+import io.trino.spi.predicate.Range;
+import io.trino.spi.predicate.TupleDomain;
+import io.trino.spi.predicate.ValueSet;
+import io.trino.spi.type.Type;
+import io.trino.testing.MaterializedResult;
+import io.trino.testing.TestingConnectorSession;
+import org.apache.parquet.conf.PlainParquetConfiguration;
+import org.apache.parquet.example.data.Group;
+import org.apache.parquet.example.data.simple.SimpleGroupFactory;
+import org.apache.parquet.hadoop.ParquetFileReader;
+import org.apache.parquet.hadoop.ParquetWriter;
+import org.apache.parquet.hadoop.example.ExampleParquetWriter;
+import org.apache.parquet.io.LocalOutputFile;
+import org.apache.parquet.schema.LogicalTypeAnnotation;
+import org.apache.parquet.schema.MessageType;
+import org.apache.parquet.schema.PrimitiveType;
+import org.apache.parquet.schema.Types;
+import org.joda.time.DateTimeZone;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalLong;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+
+import static io.trino.metastore.HiveType.HIVE_INT;
+import static io.trino.plugin.hive.HiveColumnHandle.ColumnType.REGULAR;
+import static io.trino.plugin.hive.HiveColumnHandle.createBaseColumn;
+import static io.trino.plugin.hudi.HudiPageSourceProvider.createPageSource;
+import static io.trino.spi.type.IntegerType.INTEGER;
+import static io.trino.testing.MaterializedResult.materializeSourceDataStream;
+import static java.lang.Integer.parseInt;
+import static org.apache.parquet.schema.Type.Repetition.OPTIONAL;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Reads a base file whose physical column order does not match the
metastore's, the layout hive sync produces
+ * with {@code hoodie.datasource.hive_sync.omit_metadata_fields=true}: the
five {@code _hoodie_*} meta columns are
+ * absent from the metastore, so every data column's metastore ordinal is five
below its physical position.
+ * <p>
+ * With {@code hudi.parquet.use-column-names=false} the parquet page source
resolves columns positionally, so a
+ * predicate whose handle still carries the metastore ordinal lands on
whichever column physically sits there and
+ * row groups get pruned on that column's statistics. The fixture makes that
observable: {@code c7} grows with the
+ * row index while every other data column stays in 0..9, so a domain meant
for {@code c7} but applied to any other
+ * column excludes every row group and the read returns nothing.
+ * <p>
+ * Note that the shadowed column has to be part of the PROJECTION for the
damage to appear: {@code
+ * descriptorsByPath} is derived from the projection, so a domain resolving to
a column the query does not read
+ * finds no descriptor and is discarded instead. Do not "simplify" the
projections below to the predicate column
+ * alone - that turns these tests green against the unfixed code.
+ */
+class TestHudiPredicatePushdownColumnOrdinals
+{
+ private static final List<String> META_COLUMNS = List.of(
+ "_hoodie_commit_time",
+ "_hoodie_commit_seqno",
+ "_hoodie_record_key",
+ "_hoodie_partition_path",
+ "_hoodie_file_name");
+ private static final int DATA_COLUMN_COUNT = 10;
+ /** The column the predicate is on: physically at 12, but numbered 7 by a
metastore without the meta columns. */
+ private static final String PREDICATE_COLUMN = "c7";
+ /** The column physically sitting at {@code c7}'s stale ordinal, and
therefore the one that shadows it. */
+ private static final String SHADOWED_COLUMN = "c2";
+ private static final int ROW_COUNT = 1000;
+ private static final long THRESHOLD = 900;
+ private static final int MATCHING_ROW_COUNT = (int) (ROW_COUNT - THRESHOLD
- 1);
+
+ @TempDir
+ static Path tempDir;
+
+ private static Path baseFile;
+
+ @BeforeAll
+ static void writeBaseFile()
+ throws IOException
+ {
+ MessageType schema = fileSchema();
+ baseFile = tempDir.resolve("base_file.parquet");
+ SimpleGroupFactory groupFactory = new SimpleGroupFactory(schema);
+ try (ParquetWriter<Group> writer = ExampleParquetWriter.builder(new
LocalOutputFile(baseFile))
+ .withType(schema)
+ .withConf(new PlainParquetConfiguration())
+ .withRowGroupSize(1024L)
+ .withPageSize(512)
+ .build()) {
+ for (int row = 0; row < ROW_COUNT; row++) {
+ Group group = groupFactory.newGroup();
+ for (String metaColumn : META_COLUMNS) {
+ group.append(metaColumn, metaColumn + "_" + row);
+ }
+ for (int column = 0; column < DATA_COLUMN_COUNT; column++) {
+ String columnName = "c" + column;
+ group.append(columnName,
columnName.equals(PREDICATE_COLUMN) ? row : row % 10);
+ }
+ writer.write(group);
+ }
+ }
+ // The writer flushes a row group whenever the buffered size is over
withRowGroupSize, checked every
+ // parquet.page.size.row.check.min records (100 by default), which is
what actually splits this file.
+ // Assert the outcome rather than the knobs: with a single row group
there would be nothing to prune,
+ // and every test below would pass without proving anything.
+ assertThat(rowGroupCount(baseFile)).as("row groups
written").isGreaterThan(1);
+ }
+
+ @Test
+ public void testPredicateOnStaleOrdinalKeepsMatchingRows()
+ throws Exception
+ {
+ List<HiveColumnHandle> projection =
List.of(dataColumn(SHADOWED_COLUMN), dataColumn(PREDICATE_COLUMN));
+
+ MaterializedResult result = read(projection,
greaterThanThreshold(PREDICATE_COLUMN), false, DynamicFilter.EMPTY);
+
+ // The shadowed column never leaves 0..9, so a domain of "> 900"
applied to it prunes every row group
+ assertThat(matchingRowCount(result, projection, PREDICATE_COLUMN))
+ .as("rows matching %s > %s", PREDICATE_COLUMN, THRESHOLD)
+ .isEqualTo(MATCHING_ROW_COUNT);
+ }
+
+ @Test
+ public void testPredicateOnStaleOrdinalStillPrunesRowGroups()
+ throws Exception
+ {
+ List<HiveColumnHandle> projection =
List.of(dataColumn(SHADOWED_COLUMN), dataColumn(PREDICATE_COLUMN));
+
+ MaterializedResult result = read(projection,
greaterThanThreshold(PREDICATE_COLUMN), false, DynamicFilter.EMPTY);
+
+ // Correct results alone would also be produced by pushing nothing
down; reading fewer rows than the file
+ // holds is only possible if the domain reached the column it was
written for, and the matching rows must
+ // survive that pruning
+ assertThat(result.getRowCount())
+ .as("rows read out of %s", ROW_COUNT)
+ .isLessThan(ROW_COUNT);
+ assertThat(matchingRowCount(result, projection, PREDICATE_COLUMN))
+ .as("rows matching %s > %s after pruning", PREDICATE_COLUMN,
THRESHOLD)
+ .isEqualTo(MATCHING_ROW_COUNT);
+ }
+
+ @Test
+ public void testStaleOrdinalArrivingThroughADynamicFilter()
+ throws Exception
+ {
+ List<HiveColumnHandle> projection =
List.of(dataColumn(SHADOWED_COLUMN), dataColumn(PREDICATE_COLUMN));
+
+ // A dynamic filter reaches getCombinedPredicate by its own route, and
its handles carry the same stale
+ // metastore ordinals the split's predicate does
+ MaterializedResult result = read(projection, TupleDomain.all(), false,
+ dynamicFilterOn(greaterThanThreshold(PREDICATE_COLUMN)));
+
+ assertThat(matchingRowCount(result, projection, PREDICATE_COLUMN))
+ .as("rows matching a dynamic filter of %s > %s",
PREDICATE_COLUMN, THRESHOLD)
+ .isEqualTo(MATCHING_ROW_COUNT);
+ }
+
+ @Test
+ public void testPredicateOnColumnAddedAfterBaseFileWasWritten()
+ throws Exception
+ {
+ // The metastore carries one column more than this base file does,
numbered 10 - an ordinal that is still
+ // in range physically, where it picks out "c5"
+ String addedColumn = "c" + DATA_COLUMN_COUNT;
+ List<HiveColumnHandle> projection = List.of(dataColumn("c5"),
dataColumn(PREDICATE_COLUMN), dataColumn(addedColumn));
+
+ // IS NULL, not a range: the added column is null in every row of this
base file, so this predicate is
+ // satisfied by all of them. A range predicate would be unsatisfiable
here and the buggy read's empty
+ // result would be the right answer by accident.
+ MaterializedResult result = read(projection,
+ TupleDomain.withColumnDomains(Map.of(dataColumn(addedColumn),
Domain.onlyNull(INTEGER))),
+ false, DynamicFilter.EMPTY);
+
+ // A column the file does not carry has to be dropped from the
pushed-down predicate. Pushed positionally
+ // it would land on "c5", which has no nulls at all, and every row
group would be pruned.
+ assertThat(result.getRowCount()).as("rows read").isEqualTo(ROW_COUNT);
+
assertThat(result.getMaterializedRows().getFirst().getField(2)).as("value of
%s", addedColumn).isNull();
+ }
+
+ @Test
+ public void testPositionalAndNameBasedResolutionAgree()
+ throws Exception
+ {
+ List<HiveColumnHandle> projection =
List.of(dataColumn(SHADOWED_COLUMN), dataColumn(PREDICATE_COLUMN));
+ TupleDomain<HiveColumnHandle> predicate =
greaterThanThreshold(PREDICATE_COLUMN);
+
+ MaterializedResult positional = read(projection, predicate, false,
DynamicFilter.EMPTY);
+ MaterializedResult byName = read(projection, predicate, true,
DynamicFilter.EMPTY);
+
+ // Anchor the comparison: both modes regressing to no pushdown at all
would otherwise agree happily
+ assertThat(byName.getRowCount()).as("rows read with
use-column-names=true").isLessThan(ROW_COUNT);
+ assertThat(positional.getMaterializedRows())
+ .as("hudi.parquet.use-column-names=false must read what
use-column-names=true reads")
+ .isEqualTo(byName.getMaterializedRows());
+ }
+
+ /**
+ * Reads the whole base file through the page source the connector builds
for a split with no log files, which
+ * is the only path on which it enables predicate pushdown.
+ */
+ private static MaterializedResult read(
+ List<HiveColumnHandle> projection,
+ TupleDomain<HiveColumnHandle> predicate,
+ boolean useParquetColumnNames,
+ DynamicFilter dynamicFilter)
+ throws Exception
+ {
+ long fileSize = Files.size(baseFile);
+ HudiSplit split = new HudiSplit(
+ new HudiBaseFile(baseFile.toString(),
baseFile.getFileName().toString(), fileSize, 0, 0, fileSize),
+ List.of(),
+ "000",
+ predicate,
+ List.of(),
+ SplitWeight.standard());
+ HudiSessionProperties sessionProperties = new HudiSessionProperties(
+ new
HudiConfig().setUseParquetColumnNames(useParquetColumnNames),
+ new ParquetReaderConfig());
+ ConnectorSession session = TestingConnectorSession.builder()
+ .setPropertyMetadata(sessionProperties.getSessionProperties())
+ .build();
+
+ List<Type> types =
projection.stream().map(HiveColumnHandle::getType).toList();
+ try (ConnectorPageSource pageSource = createPageSource(
+ session,
+ projection,
+ split,
+ new LocalInputFile(baseFile.toFile()),
+ baseFile.toString(),
+ 0L,
+ fileSize,
+ OptionalLong.of(fileSize),
+ new FileFormatDataSourceStats(),
+ ParquetReaderOptions.builder().build(),
+ DateTimeZone.UTC,
+ dynamicFilter,
+ true)) {
+ return materializeSourceDataStream(session, pageSource,
types).toTestTypes();
+ }
+ }
+
+ private static MessageType fileSchema()
Review Comment:
**Cleanliness.** This `fileSchema()` and
`TestHudiPageSourceProviderTest.hudiFileSchema(int)` are the same builder --
same five meta columns, same `c0..cN` loop -- duplicated only because the tests
live in two files. Same package, same class under test
(`HudiPageSourceProvider`), and a flat merge of this file's five tests plus its
helpers into `TestHudiPageSourceProviderTest` runs the whole class in 0.457s,
so there is no fixture-cost argument for the split.
This repo's PMCs have flagged test-file proliferation before, so please fold
this file into `TestHudiPageSourceProviderTest` and collapse the two schema
builders into one. (Note if you try it: `@Nested` silently discovers zero tests
here because `@BeforeAll` and `@TempDir` are static -- use a flat merge.)
`TestHudiSmokeTest` is *not* the right home despite already calling
`createPageSource` at `:1303` with `setUseParquetColumnNames(false)`; it
extends `AbstractTestQueryFramework` and would pay a full `QueryRunner` fixture.
##########
hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiPredicatePushdownColumnOrdinals.java:
##########
@@ -0,0 +1,354 @@
+/*
+ * Licensed 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 io.trino.plugin.hudi;
+
+import io.trino.filesystem.local.LocalInputFile;
+import io.trino.parquet.ParquetReaderOptions;
+import io.trino.plugin.base.metrics.FileFormatDataSourceStats;
+import io.trino.plugin.hive.HiveColumnHandle;
+import io.trino.plugin.hive.parquet.ParquetReaderConfig;
+import io.trino.plugin.hudi.file.HudiBaseFile;
+import io.trino.spi.SplitWeight;
+import io.trino.spi.connector.ColumnHandle;
+import io.trino.spi.connector.ConnectorPageSource;
+import io.trino.spi.connector.ConnectorSession;
+import io.trino.spi.connector.DynamicFilter;
+import io.trino.spi.predicate.Domain;
+import io.trino.spi.predicate.Range;
+import io.trino.spi.predicate.TupleDomain;
+import io.trino.spi.predicate.ValueSet;
+import io.trino.spi.type.Type;
+import io.trino.testing.MaterializedResult;
+import io.trino.testing.TestingConnectorSession;
+import org.apache.parquet.conf.PlainParquetConfiguration;
+import org.apache.parquet.example.data.Group;
+import org.apache.parquet.example.data.simple.SimpleGroupFactory;
+import org.apache.parquet.hadoop.ParquetFileReader;
+import org.apache.parquet.hadoop.ParquetWriter;
+import org.apache.parquet.hadoop.example.ExampleParquetWriter;
+import org.apache.parquet.io.LocalOutputFile;
+import org.apache.parquet.schema.LogicalTypeAnnotation;
+import org.apache.parquet.schema.MessageType;
+import org.apache.parquet.schema.PrimitiveType;
+import org.apache.parquet.schema.Types;
+import org.joda.time.DateTimeZone;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.OptionalLong;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+
+import static io.trino.metastore.HiveType.HIVE_INT;
+import static io.trino.plugin.hive.HiveColumnHandle.ColumnType.REGULAR;
+import static io.trino.plugin.hive.HiveColumnHandle.createBaseColumn;
+import static io.trino.plugin.hudi.HudiPageSourceProvider.createPageSource;
+import static io.trino.spi.type.IntegerType.INTEGER;
+import static io.trino.testing.MaterializedResult.materializeSourceDataStream;
+import static java.lang.Integer.parseInt;
+import static org.apache.parquet.schema.Type.Repetition.OPTIONAL;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Reads a base file whose physical column order does not match the
metastore's, the layout hive sync produces
+ * with {@code hoodie.datasource.hive_sync.omit_metadata_fields=true}: the
five {@code _hoodie_*} meta columns are
+ * absent from the metastore, so every data column's metastore ordinal is five
below its physical position.
+ * <p>
+ * With {@code hudi.parquet.use-column-names=false} the parquet page source
resolves columns positionally, so a
+ * predicate whose handle still carries the metastore ordinal lands on
whichever column physically sits there and
+ * row groups get pruned on that column's statistics. The fixture makes that
observable: {@code c7} grows with the
+ * row index while every other data column stays in 0..9, so a domain meant
for {@code c7} but applied to any other
+ * column excludes every row group and the read returns nothing.
+ * <p>
+ * Note that the shadowed column has to be part of the PROJECTION for the
damage to appear: {@code
+ * descriptorsByPath} is derived from the projection, so a domain resolving to
a column the query does not read
+ * finds no descriptor and is discarded instead. Do not "simplify" the
projections below to the predicate column
+ * alone - that turns these tests green against the unfixed code.
+ */
+class TestHudiPredicatePushdownColumnOrdinals
+{
+ private static final List<String> META_COLUMNS = List.of(
+ "_hoodie_commit_time",
+ "_hoodie_commit_seqno",
+ "_hoodie_record_key",
+ "_hoodie_partition_path",
+ "_hoodie_file_name");
+ private static final int DATA_COLUMN_COUNT = 10;
+ /** The column the predicate is on: physically at 12, but numbered 7 by a
metastore without the meta columns. */
+ private static final String PREDICATE_COLUMN = "c7";
+ /** The column physically sitting at {@code c7}'s stale ordinal, and
therefore the one that shadows it. */
+ private static final String SHADOWED_COLUMN = "c2";
+ private static final int ROW_COUNT = 1000;
+ private static final long THRESHOLD = 900;
+ private static final int MATCHING_ROW_COUNT = (int) (ROW_COUNT - THRESHOLD
- 1);
+
+ @TempDir
+ static Path tempDir;
+
+ private static Path baseFile;
+
+ @BeforeAll
+ static void writeBaseFile()
+ throws IOException
+ {
+ MessageType schema = fileSchema();
+ baseFile = tempDir.resolve("base_file.parquet");
+ SimpleGroupFactory groupFactory = new SimpleGroupFactory(schema);
+ try (ParquetWriter<Group> writer = ExampleParquetWriter.builder(new
LocalOutputFile(baseFile))
+ .withType(schema)
+ .withConf(new PlainParquetConfiguration())
+ .withRowGroupSize(1024L)
+ .withPageSize(512)
+ .build()) {
+ for (int row = 0; row < ROW_COUNT; row++) {
+ Group group = groupFactory.newGroup();
+ for (String metaColumn : META_COLUMNS) {
+ group.append(metaColumn, metaColumn + "_" + row);
+ }
+ for (int column = 0; column < DATA_COLUMN_COUNT; column++) {
+ String columnName = "c" + column;
+ group.append(columnName,
columnName.equals(PREDICATE_COLUMN) ? row : row % 10);
+ }
+ writer.write(group);
+ }
+ }
+ // The writer flushes a row group whenever the buffered size is over
withRowGroupSize, checked every
+ // parquet.page.size.row.check.min records (100 by default), which is
what actually splits this file.
+ // Assert the outcome rather than the knobs: with a single row group
there would be nothing to prune,
+ // and every test below would pass without proving anything.
+ assertThat(rowGroupCount(baseFile)).as("row groups
written").isGreaterThan(1);
+ }
+
+ @Test
+ public void testPredicateOnStaleOrdinalKeepsMatchingRows()
+ throws Exception
+ {
+ List<HiveColumnHandle> projection =
List.of(dataColumn(SHADOWED_COLUMN), dataColumn(PREDICATE_COLUMN));
+
+ MaterializedResult result = read(projection,
greaterThanThreshold(PREDICATE_COLUMN), false, DynamicFilter.EMPTY);
+
+ // The shadowed column never leaves 0..9, so a domain of "> 900"
applied to it prunes every row group
+ assertThat(matchingRowCount(result, projection, PREDICATE_COLUMN))
+ .as("rows matching %s > %s", PREDICATE_COLUMN, THRESHOLD)
+ .isEqualTo(MATCHING_ROW_COUNT);
+ }
+
+ @Test
+ public void testPredicateOnStaleOrdinalStillPrunesRowGroups()
+ throws Exception
+ {
+ List<HiveColumnHandle> projection =
List.of(dataColumn(SHADOWED_COLUMN), dataColumn(PREDICATE_COLUMN));
+
+ MaterializedResult result = read(projection,
greaterThanThreshold(PREDICATE_COLUMN), false, DynamicFilter.EMPTY);
+
+ // Correct results alone would also be produced by pushing nothing
down; reading fewer rows than the file
+ // holds is only possible if the domain reached the column it was
written for, and the matching rows must
+ // survive that pruning
+ assertThat(result.getRowCount())
+ .as("rows read out of %s", ROW_COUNT)
+ .isLessThan(ROW_COUNT);
+ assertThat(matchingRowCount(result, projection, PREDICATE_COLUMN))
+ .as("rows matching %s > %s after pruning", PREDICATE_COLUMN,
THRESHOLD)
+ .isEqualTo(MATCHING_ROW_COUNT);
+ }
+
+ @Test
+ public void testStaleOrdinalArrivingThroughADynamicFilter()
+ throws Exception
+ {
+ List<HiveColumnHandle> projection =
List.of(dataColumn(SHADOWED_COLUMN), dataColumn(PREDICATE_COLUMN));
+
+ // A dynamic filter reaches getCombinedPredicate by its own route, and
its handles carry the same stale
+ // metastore ordinals the split's predicate does
+ MaterializedResult result = read(projection, TupleDomain.all(), false,
+ dynamicFilterOn(greaterThanThreshold(PREDICATE_COLUMN)));
+
+ assertThat(matchingRowCount(result, projection, PREDICATE_COLUMN))
+ .as("rows matching a dynamic filter of %s > %s",
PREDICATE_COLUMN, THRESHOLD)
+ .isEqualTo(MATCHING_ROW_COUNT);
+ }
+
+ @Test
+ public void testPredicateOnColumnAddedAfterBaseFileWasWritten()
+ throws Exception
+ {
+ // The metastore carries one column more than this base file does,
numbered 10 - an ordinal that is still
+ // in range physically, where it picks out "c5"
+ String addedColumn = "c" + DATA_COLUMN_COUNT;
+ List<HiveColumnHandle> projection = List.of(dataColumn("c5"),
dataColumn(PREDICATE_COLUMN), dataColumn(addedColumn));
+
+ // IS NULL, not a range: the added column is null in every row of this
base file, so this predicate is
+ // satisfied by all of them. A range predicate would be unsatisfiable
here and the buggy read's empty
+ // result would be the right answer by accident.
+ MaterializedResult result = read(projection,
+ TupleDomain.withColumnDomains(Map.of(dataColumn(addedColumn),
Domain.onlyNull(INTEGER))),
+ false, DynamicFilter.EMPTY);
+
+ // A column the file does not carry has to be dropped from the
pushed-down predicate. Pushed positionally
+ // it would land on "c5", which has no nulls at all, and every row
group would be pruned.
Review Comment:
**Cleanliness, nit.** This comment overclaims what the test proves. All five
tests in this file still pass under a variant that maps an absent column to the
`fileSchema.getFieldCount()` sentinel instead of dropping it, because
`getParquetTupleDomain` -> `getBaseColumnParquetType` reports any index at or
beyond the field count as absent and skips it anyway
(`ParquetPageSourceFactory.java:438-442`).
What this test actually pins is the second sentence: the column must not be
left on a stale *in-range* ordinal. The drop-vs-sentinel choice is pinned by
the unit test in `TestHudiPageSourceProviderTest`, not here.
```suggestion
// The added column must not stay on its stale metastore ordinal:
pushed positionally it would land on
// "c5", which has no nulls at all, and every row group would be
pruned.
```
##########
hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPageSourceProvider.java:
##########
@@ -482,41 +486,135 @@ public static List<HiveColumnHandle>
remapColumnIndicesToPhysical(
boolean caseSensitive)
{
// Create a map from column name to its physical index in the
fileSchema.
- Map<String, Integer> physicalIndexMap = new HashMap<>();
- List<Type> fileFields = fileSchema.getFields();
- for (int i = 0; i < fileFields.size(); i++) {
- Type field = fileFields.get(i);
- String fieldName = field.getName();
- String mapKey = caseSensitive ? fieldName :
fieldName.toLowerCase(Locale.ROOT);
- physicalIndexMap.put(mapKey, i);
- }
+ Map<String, Integer> physicalIndexMap =
buildPhysicalIndexMap(fileSchema, caseSensitive);
// Iterate through the columns requested by Trino IN ORDER.
List<HiveColumnHandle> remappedHandles = new
ArrayList<>(requestedColumns.size());
for (HiveColumnHandle originalHandle : requestedColumns) {
- String requestedName = originalHandle.getBaseColumnName();
-
- // Determine the key to use for looking up the physical index
- String lookupKey = caseSensitive ? requestedName :
requestedName.toLowerCase(Locale.ROOT);
-
// Find the physical index from the file schema map constructed
from fileSchema. A column the file
// does not carry keeps an index one past the last field, which
the parquet reader null-fills.
- Integer physicalIndex = physicalIndexMap.get(lookupKey);
-
- HiveColumnHandle remappedHandle = new HiveColumnHandle(
- requestedName,
- physicalIndex == null ? fileFields.size() : physicalIndex,
- originalHandle.getBaseHiveType(),
- originalHandle.getType(),
- originalHandle.getHiveColumnProjectionInfo(),
- originalHandle.getColumnType(),
- originalHandle.getComment());
- remappedHandles.add(remappedHandle);
+ Integer physicalIndex =
physicalIndexMap.get(lookupKey(originalHandle.getBaseColumnName(),
caseSensitive));
+ remappedHandles.add(withPhysicalIndex(originalHandle,
physicalIndex == null ? fileSchema.getFieldCount() : physicalIndex));
}
return remappedHandles;
}
+ /**
+ * Rebuilds a predicate's column handles on physical file ordinals, the
predicate-side counterpart of
+ * {@link #remapColumnIndicesToPhysical}. With {@code
hudi.parquet.use-column-names=false},
+ * {@code ParquetPageSourceFactory.getParquetTupleDomain} resolves a
predicate column positionally, as
+ * {@code fileSchema.getType(handle.getBaseHiveColumnIndex())}, but the
handles reaching it carry METASTORE
+ * ordinals: a metastore that omits the Hudi meta fields (hive sync with
{@code omit_metadata_fields=true})
+ * shifts every data column, and so does reordering or dropping one. Left
unremapped, the domain attaches to
+ * whichever column happens to sit at the stale ordinal and row groups are
pruned on that column's statistics,
+ * silently dropping rows.
+ * <p>
+ * Resolution is by name, so the predicate ends up bound to exactly the
column the projection reads - which is
+ * the property that matters, since the two are compared against each
other. It is not a defence against a
+ * column being dropped and re-added under full schema evolution:
name-based binding will match the new column
+ * to the old one, exactly as the projection remap and the whole {@code
use-column-names=true} mode already do.
+ * <p>
+ * A column the file does not carry is dropped from the predicate rather
than mapped to the
+ * {@link #remapColumnIndicesToPhysical} sentinel, which every absent
column would share. Dropping loses row
+ * group pruning but never a row: the static half of the predicate is
handed back to the engine in full as
+ * {@code HudiMetadata.applyFilter}'s remaining filter, and the dynamic
half is by construction redundant with
+ * the join above the scan. It is also what already happens today for a
predicate column the query does not
+ * read, since {@code descriptorsByPath} is derived from the projection and
+ * {@code getParquetTupleDomain} skips any column it cannot resolve.
+ *
+ * @param fileSchema The MessageType representing the physical schema of
the Parquet file.
+ * @param predicate The predicate to push down, keyed on handles carrying
metastore ordinals.
+ * @param caseSensitive Whether the lookup between Trino column names
(from handles) and Parquet field names (from fileSchema) should be
case-sensitive.
+ * @return The same domains, keyed on handles carrying physical ordinals,
minus the columns the file lacks.
+ */
+ @VisibleForTesting
+ public static TupleDomain<HiveColumnHandle>
remapPredicateColumnIndicesToPhysical(
+ MessageType fileSchema,
+ TupleDomain<HiveColumnHandle> predicate,
+ boolean caseSensitive)
+ {
+ if (predicate.isAll() || predicate.isNone()) {
+ return predicate;
+ }
+
+ Map<String, Integer> physicalIndexMap =
buildPhysicalIndexMap(fileSchema, caseSensitive);
+ Set<Integer> pushedPhysicalIndices = new HashSet<>();
+ Map<HiveColumnHandle, Domain> remappedDomains = new LinkedHashMap<>();
+ for (Map.Entry<HiveColumnHandle, Domain> entry :
predicate.getDomains().orElseThrow().entrySet()) {
+ Integer physicalIndex =
physicalIndexMap.get(lookupKey(entry.getKey().getBaseColumnName(),
caseSensitive));
+ if (physicalIndex == null) {
+ continue;
+ }
+ // Deduplicate on the physical index rather than on the rebuilt
handle: two handles whose names differ
+ // only by case resolve to one file column while staying unequal
to each other, and pushing both down
+ // would hand getParquetTupleDomain the same ColumnDescriptor
twice, which it rejects by failing the
+ // split. That needs a metastore holding two such columns, which
Hive's name normalisation rules out,
+ // but keeping only the first domain is a cheap guarantee that the
read can never be made worse than
+ // pushing nothing down.
+ if (pushedPhysicalIndices.add(physicalIndex)) {
Review Comment:
**Correctness, latent.** The dedup keys on the base physical index alone,
which is broader than the collision it is guarding against.
`getParquetTupleDomain` does not skip dereference handles.
`HiveColumnHandle.getHiveType()` returns the *projection's* type, not the base
column's:
```java
// HiveColumnHandle.java:179-182
public HiveType getHiveType() {
return
hiveColumnProjectionInfo.map(HiveColumnProjectionInfo::getHiveType).orElse(baseHiveType);
}
```
so a dereference handle on a primitive subfield passes the
`getHiveType().getCategory() != PRIMITIVE` guard at
`ParquetPageSourceFactory.java:432` and gets a descriptor built for the
subfield path at `:451-460`. Two such handles on the same base column -- `c0.f
> 5` and `c0.g < 3` -- therefore produce two *distinct* `ColumnDescriptor`s, so
`buildOrThrow` would be perfectly happy, yet this dedup maps both to physical
index 5 and silently discards one.
Unreachable today (`HudiMetadata` has no `applyProjection`, so no
dereference handle exists) and the cost would be lost pruning, never a wrong
row -- so this is not a bug in this PR. But since the diff already carries a
dereference-handle test, it is worth making the guard degrade gracefully rather
than leaving a trap for whoever implements `applyProjection`:
```java
// line 542
Set<Map.Entry<Integer, Optional<HiveColumnProjectionInfo>>> pushed = new
HashSet<>();
// line 555
if (pushed.add(Map.entry(physicalIndex,
entry.getKey().getHiveColumnProjectionInfo()))) {
```
Optional -- fine to leave as is if you would rather not widen the diff, but
then please add a line to the comment above saying the guard is
base-column-only.
##########
hudi-trino/src/main/java/io/trino/plugin/hudi/HudiPageSourceProvider.java:
##########
@@ -397,7 +401,7 @@ static ConnectorPageSource createPageSource(
TupleDomain<ColumnDescriptor> parquetTupleDomain =
options.isIgnoreStatistics() || !enablePredicatePushDown
? TupleDomain.all()
- : getParquetTupleDomain(descriptorsByPath,
getCombinedPredicate(hudiSplit, dynamicFilter), fileSchema, useColumnNames);
+ : getParquetTupleDomain(descriptorsByPath,
getPushdownPredicate(hudiSplit, dynamicFilter, fileSchema, useColumnNames),
fileSchema, useColumnNames);
Review Comment:
**Coverage.** The remap only ever runs where `enablePredicatePushDown` is
true, and today that is only the base-file-only path -- I verified `:213`
passes `true` while the merge path `:253` and the RFC-103 log page source
`:270` both pass `false`.
That invariant is load-bearing and nothing pins it: flip `:253` to `true`
and base-file row groups whose log updates *would* make them match get pruned
before the merge ever sees them, so a MOR query silently loses rows. The new
tests call `createPageSource` directly with `enablePredicatePushDown=true`, so
they cannot catch that, and `TestHudiMorMergeModeSemantics:83-104` only filters
on the record key, which log records never change.
Please add one assertion that a predicate on a *log-updated* value still
returns the merged rows on a MOR split (or, cheaper, a direct assertion that
the merge path calls `createBaseFilePageSource` with `false`).
##########
hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiPageSourceProviderTest.java:
##########
@@ -193,6 +203,231 @@ public void testRemapColumnNotFound()
assertHandle(remapped.get(1), "col_x", fileSchema.getFieldCount(),
HiveType.HIVE_STRING, VARCHAR);
}
+ @Test
+ public void testRemapPredicateStaleMetastoreOrdinals()
+ {
+ // Physical Schema: the five Hudi meta columns, then [c0, c1, c2]
+ MessageType fileSchema = hudiFileSchema(3);
+
+ // A metastore synced with omit_metadata_fields=true carries no meta
columns, so "c2" is numbered 2
+ // while it physically sits at 7, and "c0" is numbered 0 while it
physically sits at 5.
+ HiveColumnHandle staleC2 = createDummyHandle("c2", 2,
HiveType.HIVE_INT, INTEGER);
+ HiveColumnHandle staleC0 = createDummyHandle("c0", 0,
HiveType.HIVE_INT, INTEGER);
+ Domain c2Domain =
Domain.create(ValueSet.ofRanges(Range.greaterThan(INTEGER, 900L)), false);
+ Domain c0Domain = Domain.singleValue(INTEGER, 7L);
+
+ TupleDomain<HiveColumnHandle> remapped =
remapPredicateColumnIndicesToPhysical(
+ fileSchema,
+ TupleDomain.withColumnDomains(Map.of(staleC2, c2Domain,
staleC0, c0Domain)),
+ false);
+
+ Map<HiveColumnHandle, Domain> domains =
remapped.getDomains().orElseThrow();
+ assertThat(domains).hasSize(2);
+ // Each domain now keys off the column's physical position, so it is
matched against that column's statistics
+ assertThat(handleOf(domains,
"c2").getBaseHiveColumnIndex()).isEqualTo(7);
+ assertThat(domains.get(handleOf(domains, "c2"))).isEqualTo(c2Domain);
+ assertThat(handleOf(domains,
"c0").getBaseHiveColumnIndex()).isEqualTo(5);
+ assertThat(domains.get(handleOf(domains, "c0"))).isEqualTo(c0Domain);
+ }
+
+ @Test
+ public void testRemapPredicateDropsColumnAbsentFromFile()
+ {
+ // Physical Schema: the five Hudi meta columns, then [c0]
+ MessageType fileSchema = hudiFileSchema(1);
+
+ HiveColumnHandle present = createDummyHandle("c0", 0,
HiveType.HIVE_INT, INTEGER);
+ // Added after this base file was written, so the file does not carry
it
+ HiveColumnHandle absent = createDummyHandle("c1", 1,
HiveType.HIVE_INT, INTEGER);
+ Domain presentDomain = Domain.singleValue(INTEGER, 1L);
+
+ TupleDomain<HiveColumnHandle> remapped =
remapPredicateColumnIndicesToPhysical(
+ fileSchema,
+ TupleDomain.withColumnDomains(Map.of(present, presentDomain,
absent, Domain.singleValue(INTEGER, 2L))),
+ false);
+
+ // The absent column is dropped rather than mapped to the projection
remap's out-of-range sentinel
+ Map<HiveColumnHandle, Domain> domains =
remapped.getDomains().orElseThrow();
+ assertThat(domains).hasSize(1);
+ assertThat(handleOf(domains,
"c0").getBaseHiveColumnIndex()).isEqualTo(5);
+ assertThat(domains.get(handleOf(domains,
"c0"))).isEqualTo(presentDomain);
+ }
+
+ @Test
+ public void testRemapPredicateWithSeveralAbsentColumnsDoesNotCollide()
+ {
+ // Physical Schema: the five Hudi meta columns, then [c0]
+ MessageType fileSchema = hudiFileSchema(1);
+
+ HiveColumnHandle firstAbsent = createDummyHandle("c1", 1,
HiveType.HIVE_INT, INTEGER);
+ HiveColumnHandle secondAbsent = createDummyHandle("c2", 2,
HiveType.HIVE_INT, INTEGER);
+
+ TupleDomain<HiveColumnHandle> remapped =
remapPredicateColumnIndicesToPhysical(
+ fileSchema,
+ TupleDomain.withColumnDomains(Map.of(
+ firstAbsent, Domain.singleValue(INTEGER, 1L),
+ secondAbsent, Domain.singleValue(INTEGER, 2L))),
+ false);
+
+ // Both would share the sentinel index, which
TupleDomain.transformKeys rejects as a duplicate key.
+ // Dropping them instead leaves nothing to push down, and the engine
still applies the filter itself.
+ assertThat(remapped.isAll()).isTrue();
+ }
Review Comment:
**Cleanliness.** `testRemapPredicateWithSeveralAbsentColumnsDoesNotCollide`
pins the same production decision as
`testRemapPredicateDropsColumnAbsentFromFile` above it -- "an absent column is
dropped, not mapped to the sentinel". Under a sentinel-instead-of-drop variant
of the helper, both fail, so the second test adds no discrimination:
```
testRemapPredicateDropsColumnAbsentFromFile:251
testRemapPredicateWithSeveralAbsentColumnsDoesNotCollide:274
```
The multiple-absent case is worth keeping, but as a widening of the existing
test rather than a second one. In
`testRemapPredicateDropsColumnAbsentFromFile`, add
```java
HiveColumnHandle secondAbsent = createDummyHandle("c2", 2,
HiveType.HIVE_INT, INTEGER);
```
and include it in the `Map.of(...)` at `:246`; the existing `hasSize(1)`
assertion then covers the collision case too. Verified green on the fix and
still failing under the sentinel variant. Then delete this test:
```suggestion
```
##########
hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiPageSourceProviderTest.java:
##########
@@ -193,6 +203,231 @@ public void testRemapColumnNotFound()
assertHandle(remapped.get(1), "col_x", fileSchema.getFieldCount(),
HiveType.HIVE_STRING, VARCHAR);
}
+ @Test
+ public void testRemapPredicateStaleMetastoreOrdinals()
+ {
+ // Physical Schema: the five Hudi meta columns, then [c0, c1, c2]
+ MessageType fileSchema = hudiFileSchema(3);
+
+ // A metastore synced with omit_metadata_fields=true carries no meta
columns, so "c2" is numbered 2
+ // while it physically sits at 7, and "c0" is numbered 0 while it
physically sits at 5.
+ HiveColumnHandle staleC2 = createDummyHandle("c2", 2,
HiveType.HIVE_INT, INTEGER);
+ HiveColumnHandle staleC0 = createDummyHandle("c0", 0,
HiveType.HIVE_INT, INTEGER);
+ Domain c2Domain =
Domain.create(ValueSet.ofRanges(Range.greaterThan(INTEGER, 900L)), false);
+ Domain c0Domain = Domain.singleValue(INTEGER, 7L);
+
+ TupleDomain<HiveColumnHandle> remapped =
remapPredicateColumnIndicesToPhysical(
+ fileSchema,
+ TupleDomain.withColumnDomains(Map.of(staleC2, c2Domain,
staleC0, c0Domain)),
+ false);
+
+ Map<HiveColumnHandle, Domain> domains =
remapped.getDomains().orElseThrow();
+ assertThat(domains).hasSize(2);
+ // Each domain now keys off the column's physical position, so it is
matched against that column's statistics
+ assertThat(handleOf(domains,
"c2").getBaseHiveColumnIndex()).isEqualTo(7);
+ assertThat(domains.get(handleOf(domains, "c2"))).isEqualTo(c2Domain);
+ assertThat(handleOf(domains,
"c0").getBaseHiveColumnIndex()).isEqualTo(5);
+ assertThat(domains.get(handleOf(domains, "c0"))).isEqualTo(c0Domain);
+ }
+
+ @Test
+ public void testRemapPredicateDropsColumnAbsentFromFile()
+ {
+ // Physical Schema: the five Hudi meta columns, then [c0]
+ MessageType fileSchema = hudiFileSchema(1);
+
+ HiveColumnHandle present = createDummyHandle("c0", 0,
HiveType.HIVE_INT, INTEGER);
+ // Added after this base file was written, so the file does not carry
it
+ HiveColumnHandle absent = createDummyHandle("c1", 1,
HiveType.HIVE_INT, INTEGER);
+ Domain presentDomain = Domain.singleValue(INTEGER, 1L);
+
+ TupleDomain<HiveColumnHandle> remapped =
remapPredicateColumnIndicesToPhysical(
+ fileSchema,
+ TupleDomain.withColumnDomains(Map.of(present, presentDomain,
absent, Domain.singleValue(INTEGER, 2L))),
+ false);
+
+ // The absent column is dropped rather than mapped to the projection
remap's out-of-range sentinel
+ Map<HiveColumnHandle, Domain> domains =
remapped.getDomains().orElseThrow();
+ assertThat(domains).hasSize(1);
+ assertThat(handleOf(domains,
"c0").getBaseHiveColumnIndex()).isEqualTo(5);
+ assertThat(domains.get(handleOf(domains,
"c0"))).isEqualTo(presentDomain);
+ }
+
+ @Test
+ public void testRemapPredicateWithSeveralAbsentColumnsDoesNotCollide()
+ {
+ // Physical Schema: the five Hudi meta columns, then [c0]
+ MessageType fileSchema = hudiFileSchema(1);
+
+ HiveColumnHandle firstAbsent = createDummyHandle("c1", 1,
HiveType.HIVE_INT, INTEGER);
+ HiveColumnHandle secondAbsent = createDummyHandle("c2", 2,
HiveType.HIVE_INT, INTEGER);
+
+ TupleDomain<HiveColumnHandle> remapped =
remapPredicateColumnIndicesToPhysical(
+ fileSchema,
+ TupleDomain.withColumnDomains(Map.of(
+ firstAbsent, Domain.singleValue(INTEGER, 1L),
+ secondAbsent, Domain.singleValue(INTEGER, 2L))),
+ false);
+
+ // Both would share the sentinel index, which
TupleDomain.transformKeys rejects as a duplicate key.
+ // Dropping them instead leaves nothing to push down, and the engine
still applies the filter itself.
+ assertThat(remapped.isAll()).isTrue();
+ }
+
+ @Test
+ public void testRemapPredicateKeepsOneDomainPerPhysicalColumn()
+ {
+ // Physical Schema: the five Hudi meta columns, then [c0]
+ MessageType fileSchema = hudiFileSchema(1);
+
+ // Two handles whose names differ only by case resolve to the same
file field, so both land on physical
+ // index 5 while remaining unequal to each other. The connector cannot
produce this - Hive normalises
+ // column names to lower case - but pushing both down would hand
getParquetTupleDomain one
+ // ColumnDescriptor twice, which it rejects by failing the whole split.
+ HiveColumnHandle upperCase = createDummyHandle("C0", 0,
HiveType.HIVE_INT, INTEGER);
+ HiveColumnHandle lowerCase = createDummyHandle("c0", 3,
HiveType.HIVE_INT, INTEGER);
+ Domain firstDomain =
Domain.create(ValueSet.ofRanges(Range.greaterThan(INTEGER, 10L)), false);
+ // Insertion-ordered so that "first wins" is a deterministic assertion
+ Map<HiveColumnHandle, Domain> predicate = new LinkedHashMap<>();
+ predicate.put(upperCase, firstDomain);
+ predicate.put(lowerCase,
Domain.create(ValueSet.ofRanges(Range.lessThan(INTEGER, 20L)), false));
+
+ TupleDomain<HiveColumnHandle> remapped =
remapPredicateColumnIndicesToPhysical(
+ fileSchema, TupleDomain.withColumnDomains(predicate), false);
+
+ // Only the first is pushed down, and no IllegalArgumentException
escapes
+ Map<HiveColumnHandle, Domain> domains =
remapped.getDomains().orElseThrow();
+ assertThat(domains).hasSize(1);
+ HiveColumnHandle survivor = handleOf(domains, "C0");
+ assertThat(survivor.getBaseHiveColumnIndex()).isEqualTo(5);
+ assertThat(domains.get(survivor)).isEqualTo(firstDomain);
+ }
+
+ @Test
+ public void testRemapPreservesTheBaseTypeOfADereferenceHandle()
+ {
+ // Physical Schema: the five Hudi meta columns, then [c0]
+ MessageType fileSchema = hudiFileSchema(1);
+
+ // A handle projecting one field out of a struct column. HudiMetadata
does not implement applyProjection,
+ // so the connector never builds one today, but the remap has to
rebuild it without corrupting it: the
+ // constructor's type argument is the BASE column's type, while
getType() is the projected field's.
+ RowType baseType = RowType.rowType(RowType.field("f", INTEGER));
+ HiveColumnHandle dereference = new HiveColumnHandle(
+ "c0",
+ 0,
+ HiveType.valueOf("struct<f:int>"),
+ baseType,
+ Optional.of(new HiveColumnProjectionInfo(List.of(0),
List.of("f"), HiveType.HIVE_INT, INTEGER)),
+ HiveColumnHandle.ColumnType.REGULAR,
+ Optional.empty());
+
+ HiveColumnHandle remapped = remapColumnIndicesToPhysical(fileSchema,
List.of(dereference), false).get(0);
+
+ assertThat(remapped.getBaseHiveColumnIndex())
+ .as("physical index")
+ .isEqualTo(5);
+ assertThat(remapped.getBaseType())
+ .as("base type, which is what the parquet page source reads")
+ .isEqualTo(baseType);
+ assertThat(remapped.getType())
+ .as("projected field type")
+ .isEqualTo(INTEGER);
+ assertThat(remapped.getHiveColumnProjectionInfo())
+ .as("projection info")
+ .isEqualTo(dereference.getHiveColumnProjectionInfo());
+ }
+
+ @Test
+ public void testRemapPredicateAllAndNonePassThrough()
+ {
+ MessageType fileSchema = hudiFileSchema(1);
+
+ assertThat(remapPredicateColumnIndicesToPhysical(fileSchema,
TupleDomain.<HiveColumnHandle>all(), false))
+ .isEqualTo(TupleDomain.all());
+ assertThat(remapPredicateColumnIndicesToPhysical(fileSchema,
TupleDomain.<HiveColumnHandle>none(), false))
+ .isEqualTo(TupleDomain.none());
+ }
+
+ @Test
+ public void testRemapPredicateCaseSensitivity()
+ {
+ // Physical Schema: the five Hudi meta columns, then [c0]
+ MessageType fileSchema = hudiFileSchema(1);
+
+ HiveColumnHandle upperCase = createDummyHandle("C0", 0,
HiveType.HIVE_INT, INTEGER);
+ TupleDomain<HiveColumnHandle> predicate =
TupleDomain.withColumnDomains(Map.of(upperCase, Domain.singleValue(INTEGER,
1L)));
+
+ // Case-insensitive: "C0" resolves to the file's "c0" at physical
index 5
+ Map<HiveColumnHandle, Domain> insensitive =
remapPredicateColumnIndicesToPhysical(fileSchema, predicate, false)
+ .getDomains().orElseThrow();
+ assertThat(handleOf(insensitive,
"C0").getBaseHiveColumnIndex()).isEqualTo(5);
+
+ // Case-sensitive: no match, so the domain is dropped instead of being
left on a stale ordinal
+ assertThat(remapPredicateColumnIndicesToPhysical(fileSchema,
predicate, true).isAll()).isTrue();
+ }
+
+ @Test
+ public void testRemapPredicatePreservesEveryOtherHandleAttribute()
+ {
+ // Physical Schema: the five Hudi meta columns, then [c0]
+ MessageType fileSchema = hudiFileSchema(1);
+
+ HiveColumnHandle original = new HiveColumnHandle(
+ "c0",
+ 0,
+ HiveType.HIVE_INT,
+ INTEGER,
+ Optional.empty(),
+ HiveColumnHandle.ColumnType.REGULAR,
+ Optional.of("a comment"));
+ Domain domain =
Domain.create(ValueSet.ofRanges(Range.greaterThan(INTEGER, 900L)), true);
+
+ Map<HiveColumnHandle, Domain> domains =
remapPredicateColumnIndicesToPhysical(
+ fileSchema,
+ TupleDomain.withColumnDomains(Map.of(original, domain)),
+ false)
+ .getDomains().orElseThrow();
+
+ HiveColumnHandle remapped = handleOf(domains, "c0");
+ assertHandle(remapped, "c0", 5, HiveType.HIVE_INT, INTEGER);
+ assertThat(remapped.getComment())
+ .as("Comment mismatch for c0")
+ .isEqualTo(Optional.of("a comment"));
+ assertThat(domains.get(remapped))
+ .as("Domain mismatch for c0")
+ .isEqualTo(domain);
+ }
+
+ /**
+ * Builds a file schema laid out like a Hudi base file: the five {@code
_hoodie_*} meta columns followed by
+ * {@code dataColumnCount} int columns named {@code c0..cN}. A metastore
synced with
+ * {@code hoodie.datasource.hive_sync.omit_metadata_fields=true} omits the
meta columns, so a data column's
+ * metastore ordinal is its physical ordinal minus five.
+ */
+ private static MessageType hudiFileSchema(int dataColumnCount)
+ {
+ List<org.apache.parquet.schema.Type> fields = new ArrayList<>();
+ for (String metaColumn : List.of("_hoodie_commit_time",
"_hoodie_commit_seqno", "_hoodie_record_key", "_hoodie_partition_path",
"_hoodie_file_name")) {
Review Comment:
**Cleanliness, nit.** The five meta-column names are hardcoded here and
again at `TestHudiPredicatePushdownColumnOrdinals.java:89`.
`HoodieRecord.HOODIE_META_COLUMNS`
(`hudi-common/.../HoodieRecord.java:102-104`) is exactly this list in exactly
this order, `hudi-common` is a compile-scope dependency of `hudi-trino`
(`hudi-trino/pom.xml:257`), and
`hudi-trino/src/test/java/io/trino/plugin/hudi/testing/HudiTestUtils.java:19`
already static-imports it.
```suggestion
for (String metaColumn : HOODIE_META_COLUMNS) {
```
with `import static
org.apache.hudi.common.model.HoodieRecord.HOODIE_META_COLUMNS;`. Feel free to
ignore if you would rather not add a hudi-common import to this test.
--
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]