voonhous commented on code in PR #19288:
URL: https://github.com/apache/hudi/pull/19288#discussion_r3662903760


##########
hudi-trino/src/main/java/io/trino/plugin/hudi/HudiUtil.java:
##########
@@ -471,12 +602,38 @@ public static List<HiveColumnHandle> 
getMergeRequiredColumnHandles(
             }
         }
 
-        if (requiredColumnNames.isEmpty()) {
-            return Collections.emptyList();
-        }
         return buildColumnHandles(table, typeManager, requiredColumnNames, 
timestampPrecision);
     }
 
+    /**
+     * The merge-mandatory column names that do not depend on a custom merger, 
mirroring the non-CUSTOM
+     * branch of {@code 
FileGroupReaderSchemaHandler.getMandatoryFieldsForMerging}. The delete-marker 
and
+     * operation fields are added unconditionally: {@code buildColumnHandles} 
drops any name that is not a
+     * data column of the table, so tables without them read nothing extra.
+     */
+    @VisibleForTesting
+    static LinkedHashSet<String> mergeRequiredColumnNames(HoodieTableConfig 
tableConfig, RecordMergeMode recordMergeMode)
+    {
+        LinkedHashSet<String> requiredColumnNames = new LinkedHashSet<>();
+        if (recordMergeMode != null && recordMergeMode != 
RecordMergeMode.COMMIT_TIME_ORDERING) {
+            requiredColumnNames.addAll(tableConfig.getOrderingFields());
+        }
+        // Without populated meta fields the file-group reader keys records on 
the record-key data columns
+        if (!tableConfig.populateMetaFields()) {
+            tableConfig.getRecordKeyFields().ifPresent(fields -> 
requiredColumnNames.addAll(Arrays.asList(fields)));
+        }
+        // Delete markers and the operation field decide record deletion at 
merge time
+        requiredColumnNames.add(HOODIE_IS_DELETED_FIELD);

Review Comment:
   Not out of scope -- done. `appendMissingMergeRequiredColumns` now takes 
`readerProps` and, under CUSTOM mode, asks the same resolved merger for 
`getMandatoryFieldsForMerging` against the resolved schema, so merger-declared 
columns are recovered too. Pinned both ways: a unit test with 
`MaxRankRecordMerger`, and a fourth fixture (`omitted_rank_field_mor`) whose 
metastore omits `merge_rank` -- its tests fail with the guard error if the 
merger-ask is removed.
   



##########
hudi-trino/src/main/java/io/trino/plugin/hudi/HudiUtil.java:
##########
@@ -348,6 +365,135 @@ public static Schema.Field getFieldFromSchema(String 
columnName, Schema schema)
                 "Failed to get column " + columnName + " from table schema");
     }
 
+    /**
+     * Builds a {@link HiveColumnHandle} for a table-schema field, typing it 
from the field's Avro schema.
+     * Used to resolve columns the file-group reader requires for merging but 
the connector projection does
+     * not carry (e.g. {@code _hoodie_commit_time} or a delete-marker column 
on a narrow query).
+     * <p>
+     * The handle's {@code hiveColumnIndex} is a placeholder: page sources 
built from these handles resolve
+     * parquet columns by NAME (directly when {@code 
hudi.parquet.use-column-names=true}, otherwise
+     * {@link HudiPageSourceProvider#remapColumnIndicesToPhysical} rebuilds 
every index from the file
+     * schema by name), so the ordinal is never used to locate data.
+     * <p>
+     * Avro types whose Trino mapping has no Hive counterpart (uuid, 
time-millis/micros) fail with
+     * NOT_SUPPORTED from {@link HiveTypeTranslator#toHiveType}; such columns 
are equally unreadable
+     * through the metastore schema, so this surfaces the same limitation with 
a clear error.
+     */
+    public static HiveColumnHandle toColumnHandle(HoodieSchemaField field)
+    {
+        Type trinoType;
+        try {
+            trinoType = 
AVRO_TYPE_HANDLER.typeFor(field.schema().toAvroSchema());
+        }
+        catch (AvroTypeException e) {
+            throw new TrinoException(HUDI_SCHEMA_ERROR,
+                    "Failed to map Avro type of column " + field.name() + " to 
a Trino type", e);
+        }
+        return new HiveColumnHandle(
+                field.name(),
+                0,
+                HiveTypeTranslator.toHiveType(trinoType),
+                trinoType,
+                Optional.empty(),
+                HiveColumnHandle.ColumnType.REGULAR,
+                Optional.empty());
+    }
+
+    /**
+     * Resolves the merge mode and merge strategy id the file-group reader 
will use for this table,
+     * applying hudi-common's version-gated inference: the merge MODE is 
inferred for any table below
+     * version 9 ({@code 
FileGroupReaderSchemaHandler.generateRequiredSchema}), while the STRATEGY ID
+     * the merger is resolved with is inferred only below version 8
+     * ({@code HoodieReaderContext.initRecordMerger}). The asymmetry is 
deliberate and must mirror
+     * hudi-common exactly: pre-v9 tables may persist neither config (a 0.x 
table with a custom payload
+     * class infers CUSTOM mode with the payload-based strategy id), so a 
connector-side decision built
+     * on the raw configs would disagree with the file-group reader's.
+     */
+    public static Pair<RecordMergeMode, String> 
resolveMergeModeAndStrategyId(HoodieTableConfig tableConfig)
+    {
+        RecordMergeMode mergeMode = tableConfig.getRecordMergeMode();
+        String mergeStrategyId = tableConfig.getRecordMergeStrategyId();
+        if (tableConfig.getTableVersion().lesserThan(HoodieTableVersion.NINE)) 
{
+            Triple<RecordMergeMode, String, String> inferred = 
HoodieTableConfig.inferMergingConfigsForPreV9Table(
+                    tableConfig.getRecordMergeMode(),
+                    tableConfig.getPayloadClass(),
+                    tableConfig.getRecordMergeStrategyId(),
+                    tableConfig.getOrderingFieldsStr().orElse(null),
+                    tableConfig.getTableVersion());
+            mergeMode = inferred.getLeft();
+            if 
(tableConfig.getTableVersion().lesserThan(HoodieTableVersion.EIGHT)) {
+                mergeStrategyId = inferred.getRight();
+            }
+        }
+        return Pair.of(mergeMode, mergeStrategyId);
+    }
+
+    /**
+     * Returns whether reads of this table go through a CUSTOM record merger 
that is NOT projection
+     * compatible. For such mergers the file-group reader demands the FULL 
table schema as its required
+     * schema on any split with log files ({@code 
FileGroupReaderSchemaHandler.generateRequiredSchema}),
+     * so the connector must read every table column, not just the query 
projection.
+     * <p>
+     * Callers must pass the merge mode and strategy id resolved by {@link 
#resolveMergeModeAndStrategyId}
+     * so the {@link HoodieRecordUtils#createValidRecordMerger} call here sees 
the same inputs as the
+     * file-group reader's own resolution. A CUSTOM mode without a strategy id 
returns false: no projection
+     * expansion is possible without a merger to ask, and {@code 
HudiTrinoReaderContext.getRecordMerger}
+     * rejects the read with an actionable error when the merger is actually 
needed -- hudi-common's
+     * {@link HoodieRecordUtils#createValidRecordMerger} would otherwise NPE 
on the null id.
+     * <p>
+     * Note the payload-based strategy id resolves {@code 
HoodieAvroRecordMerger}, which keeps the
+     * interface default {@code isProjectionCompatible() == false} -- so EVERY 
custom-payload table takes
+     * the full-schema read path, not just tables with exotic custom mergers. 
That matches the file-group
+     * reader (and Spark); the extra I/O is inherent to payload-based merging.
+     */
+    public static boolean usesNonProjectionCompatibleMerger(RecordMergeMode 
mergeMode, String mergeStrategyId, String mergeImplClasses)
+    {
+        if (mergeMode != RecordMergeMode.CUSTOM || 
StringUtils.isNullOrEmpty(mergeStrategyId)) {
+            return false;
+        }
+        Option<HoodieRecordMerger> merger = 
HoodieRecordUtils.createValidRecordMerger(EngineType.JAVA, mergeImplClasses, 
mergeStrategyId);
+        return merger.isPresent() && !merger.get().isProjectionCompatible();
+    }
+
+    /**
+     * Rejects a CUSTOM-mode read that has no merge strategy id to resolve a 
record merger with. The
+     * strategy id is only inferred below table version 8 ({@link 
#resolveMergeModeAndStrategyId}), so a
+     * version 8 table written without a persisted id reaches merger 
resolution with a null id, which
+     * {@link HoodieRecordUtils#createValidRecordMerger} dereferences straight 
away.
+     */
+    public static void validateCustomMergeStrategyId(String mergeStrategyId)
+    {
+        if (StringUtils.isNullOrEmpty(mergeStrategyId)) {
+            String strategyIdKey = 
HoodieTableConfig.RECORD_MERGE_STRATEGY_ID.key();
+            throw new TrinoException(HUDI_BAD_DATA,
+                    ("Table resolved to %s merge mode but persists no `%s`, so 
no record merger can be resolved for merging log files. "
+                            + "Table version 8 tables written without a 
persisted strategy id resolve this way; set `%s` on the table to the "

Review Comment:
   Widened both to "version 8 or later".
   



##########
hudi-trino/src/main/java/io/trino/plugin/hudi/HudiUtil.java:
##########
@@ -471,12 +622,73 @@ public static List<HiveColumnHandle> 
getMergeRequiredColumnHandles(
             }
         }
 
-        if (requiredColumnNames.isEmpty()) {
-            return Collections.emptyList();
-        }
         return buildColumnHandles(table, typeManager, requiredColumnNames, 
timestampPrecision);
     }
 
+    /**
+     * The merge-mandatory column names that do not depend on a custom merger, 
mirroring the non-CUSTOM
+     * branch of {@code 
FileGroupReaderSchemaHandler.getMandatoryFieldsForMerging}. The delete-marker 
and
+     * operation fields are added unconditionally: {@code buildColumnHandles} 
keeps only metastore data
+     * columns, and the merge read path recovers any name the metastore does 
not carry from the resolved
+     * table schema ({@link #appendMissingMergeRequiredColumns}) -- the gate 
the file-group reader itself
+     * applies -- so tables whose schema has neither field read nothing extra.
+     */
+    @VisibleForTesting
+    static LinkedHashSet<String> mergeRequiredColumnNames(HoodieTableConfig 
tableConfig, RecordMergeMode recordMergeMode)
+    {
+        LinkedHashSet<String> requiredColumnNames = new LinkedHashSet<>();
+        if (recordMergeMode != null && recordMergeMode != 
RecordMergeMode.COMMIT_TIME_ORDERING) {
+            requiredColumnNames.addAll(tableConfig.getOrderingFields());
+        }
+        // Without populated meta fields the file-group reader keys records on 
the record-key data columns
+        if (!tableConfig.populateMetaFields()) {
+            tableConfig.getRecordKeyFields().ifPresent(fields -> 
requiredColumnNames.addAll(Arrays.asList(fields)));
+        }
+        // Delete markers and the operation field decide record deletion at 
merge time
+        requiredColumnNames.add(HOODIE_IS_DELETED_FIELD);
+        requiredColumnNames.add(OPERATION_METADATA_FIELD);
+        String deleteKey = tableConfig.getProps().getProperty(DELETE_KEY);
+        String deleteMarker = 
tableConfig.getProps().getProperty(DELETE_MARKER);
+        // DeleteContext only honors a custom delete key when the marker value 
is also set
+        if (!StringUtils.isNullOrEmpty(deleteKey) && 
!StringUtils.isNullOrEmpty(deleteMarker)) {
+            requiredColumnNames.add(deleteKey);
+        }
+        return requiredColumnNames;
+    }
+
+    /**
+     * Returns {@code projection} extended with handles for the merge-required 
column names it does not
+     * already carry (matched case-insensitively), typed from {@code 
dataSchema} via {@link #toColumnHandle};
+     * names absent from the schema as well are dropped. The Avro table schema 
is the gate the file-group

Review Comment:
   Scoped it: the javadoc now says the schema gate is the reader's own only for 
`_hoodie_is_deleted`/`_hoodie_operation`, and that the 
ordering/record-key/delete-key names are added without a schema check but are 
table columns whenever the config is coherent.
   



##########
hudi-trino/src/test/java/io/trino/plugin/hudi/TestHudiMergeRequiredColumns.java:
##########
@@ -0,0 +1,154 @@
+/*
+ * 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.metastore.HiveType;
+import io.trino.plugin.hive.HiveColumnHandle;
+import org.apache.avro.SchemaBuilder;
+import org.apache.hudi.common.config.RecordMergeMode;
+import org.apache.hudi.common.schema.HoodieSchema;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Optional;
+
+import static io.trino.plugin.hive.HiveColumnHandle.ColumnType.REGULAR;
+import static io.trino.plugin.hive.HiveColumnHandle.createBaseColumn;
+import static io.trino.plugin.hudi.HudiUtil.appendMissingMergeRequiredColumns;
+import static io.trino.plugin.hudi.HudiUtil.mergeRequiredColumnNames;
+import static io.trino.spi.type.BigintType.BIGINT;
+import static io.trino.spi.type.VarcharType.VARCHAR;
+import static 
org.apache.hudi.common.model.DefaultHoodieRecordPayload.DELETE_KEY;
+import static 
org.apache.hudi.common.model.DefaultHoodieRecordPayload.DELETE_MARKER;
+import static 
org.apache.hudi.common.model.HoodieRecord.HOODIE_IS_DELETED_FIELD;
+import static 
org.apache.hudi.common.model.HoodieRecord.OPERATION_METADATA_FIELD;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests {@link HudiUtil#mergeRequiredColumnNames}, which mirrors the 
non-CUSTOM branch of the file-group
+ * reader's {@code FileGroupReaderSchemaHandler.getMandatoryFieldsForMerging} 
so the base-file read
+ * projection carries every column the merge may consult (ordering fields, 
delete markers, the operation
+ * field, record-key data columns) even when the query does not project them, 
plus the merge-path recovery
+ * of names the metastore could not resolve ({@link 
HudiUtil#appendMissingMergeRequiredColumns}).
+ */
+class TestHudiMergeRequiredColumns
+{
+    @Test
+    public void testOrderingFieldsFollowMergeMode()
+    {
+        HoodieTableConfig tableConfig = new HoodieTableConfig();
+        tableConfig.setValue(HoodieTableConfig.ORDERING_FIELDS, "ts,seq");
+
+        assertThat(mergeRequiredColumnNames(tableConfig, 
RecordMergeMode.EVENT_TIME_ORDERING))
+                .contains("ts", "seq");
+        // Commit-time merging ignores ordering fields
+        assertThat(mergeRequiredColumnNames(tableConfig, 
RecordMergeMode.COMMIT_TIME_ORDERING))
+                .doesNotContain("ts", "seq");
+        assertThat(mergeRequiredColumnNames(tableConfig, null))
+                .doesNotContain("ts", "seq");
+    }
+
+    @Test
+    public void testDeleteAndOperationColumnsAlwaysRequested()
+    {
+        // Requested unconditionally: getMergeRequiredColumnHandles drops 
names that are in neither the

Review Comment:
   Reworded to split the claim: the metastore half in 
`getMergeRequiredColumnHandles`, the schema half in 
`appendMissingMergeRequiredColumns` on the page-source path.
   



##########
hudi-trino/src/test/java/io/trino/plugin/hudi/testing/AbstractMergerHudiTablesInitializer.java:
##########
@@ -0,0 +1,291 @@
+/*
+ * 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.testing;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import io.trino.filesystem.Location;
+import io.trino.filesystem.TrinoFileSystem;
+import io.trino.filesystem.TrinoFileSystemFactory;
+import io.trino.metastore.Column;
+import io.trino.metastore.HiveMetastore;
+import io.trino.metastore.HiveMetastoreFactory;
+import io.trino.metastore.PrincipalPrivileges;
+import io.trino.metastore.StorageFormat;
+import io.trino.metastore.Table;
+import io.trino.plugin.hudi.HudiConnector;
+import io.trino.spi.security.ConnectorIdentity;
+import io.trino.testing.QueryRunner;
+import org.apache.avro.Schema;
+import org.apache.avro.generic.GenericRecord;
+import org.apache.hadoop.conf.Configuration;
+import org.apache.hadoop.fs.Path;
+import org.apache.hudi.client.HoodieJavaWriteClient;
+import org.apache.hudi.client.common.HoodieJavaEngineContext;
+import org.apache.hudi.common.bootstrap.index.NoOpBootstrapIndex;
+import org.apache.hudi.common.config.HoodieMetadataConfig;
+import org.apache.hudi.common.model.HoodieAvroPayload;
+import org.apache.hudi.common.model.HoodieAvroRecord;
+import org.apache.hudi.common.model.HoodieKey;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.model.HoodieTableType;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.marker.MarkerType;
+import org.apache.hudi.common.util.Option;
+import org.apache.hudi.config.HoodieCompactionConfig;
+import org.apache.hudi.config.HoodieIndexConfig;
+import org.apache.hudi.config.HoodieWriteConfig;
+import org.apache.hudi.index.HoodieIndex;
+import org.apache.hudi.storage.hadoop.HadoopStorageConfiguration;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static com.google.common.io.MoreFiles.deleteRecursively;
+import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE;
+import static io.trino.hive.formats.HiveClassNames.HUDI_PARQUET_INPUT_FORMAT;
+import static 
io.trino.hive.formats.HiveClassNames.HUDI_PARQUET_REALTIME_INPUT_FORMAT;
+import static 
io.trino.hive.formats.HiveClassNames.MAPRED_PARQUET_OUTPUT_FORMAT_CLASS;
+import static io.trino.hive.formats.HiveClassNames.PARQUET_HIVE_SERDE_CLASS;
+import static io.trino.metastore.HiveType.HIVE_STRING;
+import static io.trino.plugin.hive.TableType.EXTERNAL_TABLE;
+import static java.nio.file.Files.createTempDirectory;
+import static java.util.Objects.requireNonNull;
+
+/**
+ * Shared machinery for the non-partitioned Merge-On-Read fixtures that exist 
to exercise record merging at
+ * read time. The table is written by the Hudi Java write client into a local 
staging directory and then
+ * mirrored into the Trino filesystem the connector reads from; two metastore 
tables are registered against
+ * that location, a read-optimized one (base files only) and a real-time one 
(suffix {@code _rt}) that merges
+ * base + log files through the file group reader.
+ * <p>
+ * Inline compaction is off for every fixture so the log files written by the 
delta commits survive and must
+ * be merged at read time, and the metadata table is off because MDT writes 
need hbase dependencies that are
+ * not on the Trino test classpath.
+ * <p>
+ * Subclasses supply the schema, the merge-related table and write 
configuration, and the commits; everything
+ * a fixture does not vary lives here.
+ */
+public abstract class AbstractMergerHudiTablesInitializer
+        implements HudiTablesInitializer
+{
+    /** Every fixture built on this base is keyed on {@code key} and ordered 
by {@code ts}, in a single unnamed partition. */
+    protected static final String RECORD_KEY_FIELD = "key";
+    protected static final String ORDERING_FIELD = "ts";
+
+    private static final String PARTITION_PATH = "";
+
+    /** Hudi metadata columns, prepended to every table's data columns in the 
metastore definition. */
+    private static final List<Column> HUDI_META_COLUMNS = ImmutableList.of(
+            new Column("_hoodie_commit_time", HIVE_STRING, Optional.empty(), 
Map.of()),
+            new Column("_hoodie_commit_seqno", HIVE_STRING, Optional.empty(), 
Map.of()),
+            new Column("_hoodie_record_key", HIVE_STRING, Optional.empty(), 
Map.of()),
+            new Column("_hoodie_partition_path", HIVE_STRING, 
Optional.empty(), Map.of()),
+            new Column("_hoodie_file_name", HIVE_STRING, Optional.empty(), 
Map.of()));
+
+    private final String tableName;
+
+    private TrinoFileSystem fileSystem;
+    private Location tableLocation;
+    private java.nio.file.Path stagingDir;
+    private Path stagingTablePath;
+    private HoodieJavaWriteClient<HoodieAvroPayload> writeClient;
+
+    protected AbstractMergerHudiTablesInitializer(String tableName)
+    {
+        this.tableName = requireNonNull(tableName, "tableName is null");
+    }
+
+    @Override
+    public final void initializeTables(QueryRunner queryRunner, Location 
externalLocation, String schemaName)
+            throws Exception
+    {
+        fileSystem = ((HudiConnector) 
queryRunner.getCoordinator().getConnector("hudi")).getInjector()
+                .getInstance(TrinoFileSystemFactory.class)
+                .create(ConnectorIdentity.ofUser("test"));
+        HiveMetastore metastore = ((HudiConnector) 
queryRunner.getCoordinator().getConnector("hudi")).getInjector()
+                .getInstance(HiveMetastoreFactory.class)
+                .createMetastore(Optional.empty());
+
+        tableLocation = externalLocation.appendPath(tableName);
+        stagingDir = createTempDirectory(tableName.replace('_', '-'));
+        stagingTablePath = new Path(stagingDir.resolve(tableName).toUri());
+
+        boolean initialized = false;
+        try {
+            initTable();
+            afterTableInit();
+            writeClient = createWriteClient();
+            writeInitialCommits(writeClient);
+            syncToTrino();
+
+            metastore.createTable(createTableDefinition(schemaName, tableName, 
false), PrincipalPrivileges.NO_PRIVILEGES);
+            metastore.createTable(createTableDefinition(schemaName, tableName 
+ "_rt", true), PrincipalPrivileges.NO_PRIVILEGES);
+            initialized = true;
+        }
+        finally {
+            // Only fixtures that keep writing commits after initialization 
need the staging directory and the
+            // write client to outlive this call; they are responsible for 
calling close().
+            if (!initialized || !keepsWriterOpen()) {
+                close();
+            }
+        }
+    }
+
+    /** The table's data columns, in schema order; the Hudi metadata columns 
are prepended by this class. */
+    protected abstract List<Column> dataColumns();
+
+    /** The Avro schema the write client writes, matching {@link 
#dataColumns()}. */
+    protected abstract Schema avroSchema();
+
+    /**
+     * Applies the fixture's merge-related table configuration (merge mode, 
merge strategy id, payload class).
+     * The table type, record key fields and ordering fields are set by this 
class.
+     */
+    protected abstract void 
configureTableConfig(HoodieTableMetaClient.TableBuilder tableBuilder);
+
+    /** Applies the fixture's merge-related write configuration (merge mode, 
merge strategy id, merger impl classes). */
+    protected abstract void configureWriteConfig(HoodieWriteConfig.Builder 
writeConfigBuilder);
+
+    /** Writes the commits that make up the fixture's initial state. */
+    protected abstract void 
writeInitialCommits(HoodieJavaWriteClient<HoodieAvroPayload> client)
+            throws IOException;
+
+    /** Runs on the freshly created table, before the write client exists and 
before any data is written. */
+    protected void afterTableInit()
+            throws IOException {}
+
+    /** Inline compaction is disabled, so this only has to stay above the 
number of delta commits a fixture writes. */
+    protected int maxDeltaCommitsBeforeCompaction()
+    {
+        return 100;
+    }
+
+    /** Whether the staging directory and write client survive {@link 
#initializeTables}; such fixtures must call {@link #close()}. */
+    protected boolean keepsWriterOpen()
+    {
+        return false;
+    }
+
+    public void close()
+            throws IOException
+    {
+        if (writeClient != null) {
+            writeClient.close();
+            writeClient = null;
+        }
+        if (stagingDir != null) {
+            deleteRecursively(stagingDir, ALLOW_INSECURE);
+            stagingDir = null;
+        }
+    }
+
+    /** Local directory the write client writes into, before {@link 
#syncToTrino()} mirrors it to the connector. */
+    protected java.nio.file.Path stagingTableDirectory()
+    {
+        return stagingDir.resolve(tableName);
+    }
+
+    protected HoodieJavaWriteClient<HoodieAvroPayload> writeClient()
+    {
+        return writeClient;
+    }
+
+    protected static HoodieRecord<HoodieAvroPayload> avroRecord(GenericRecord 
record, String key)
+    {
+        return new HoodieAvroRecord<>(new HoodieKey(key, PARTITION_PATH), new 
HoodieAvroPayload(Option.of(record)), null);
+    }
+
+    /** Mirrors the staged table into the Trino filesystem so the connector 
observes the commits written so far. */
+    protected void syncToTrino()
+    {
+        try {
+            if (fileSystem.directoryExists(tableLocation).orElse(false)) {
+                fileSystem.deleteDirectory(tableLocation);
+            }
+            ResourceHudiTablesInitializer.copyDir(stagingTableDirectory(), 
fileSystem, tableLocation);
+        }
+        catch (IOException e) {
+            throw new RuntimeException("Failed to sync staged Hudi table to 
Trino filesystem", e);
+        }
+    }
+
+    private void initTable()
+    {
+        HoodieTableMetaClient.TableBuilder tableBuilder = 
HoodieTableMetaClient.newTableBuilder()
+                .setTableType(HoodieTableType.MERGE_ON_READ)
+                .setTableName(tableName)
+                .setTimelineLayoutVersion(1)
+                .setBootstrapIndexClass(NoOpBootstrapIndex.class.getName())
+                .setRecordKeyFields(RECORD_KEY_FIELD)
+                .setOrderingFields(ORDERING_FIELD);
+        configureTableConfig(tableBuilder);
+        try {
+            tableBuilder.initTable(new HadoopStorageConfiguration(new 
Configuration()), stagingTablePath.toString());
+        }
+        catch (IOException e) {
+            throw new RuntimeException("Could not init table " + tableName, e);
+        }
+    }
+
+    private HoodieJavaWriteClient<HoodieAvroPayload> createWriteClient()
+    {
+        Configuration conf = new Configuration();
+        HoodieWriteConfig.Builder writeConfigBuilder = 
HoodieWriteConfig.newBuilder()
+                .withPath(stagingTablePath.toString())
+                .withSchema(avroSchema().toString())
+                .withParallelism(2, 2)
+                .withDeleteParallelism(2)
+                .forTable(tableName)
+                
.withIndexConfig(HoodieIndexConfig.newBuilder().withIndexType(HoodieIndex.IndexType.INMEMORY).build())
+                // Keep log files around so merging runs at read time.
+                .withCompactionConfig(HoodieCompactionConfig.newBuilder()
+                        .withInlineCompaction(false)
+                        
.withMaxNumDeltaCommitsBeforeCompaction(maxDeltaCommitsBeforeCompaction())
+                        .build())
+                .withEmbeddedTimelineServerEnabled(false)
+                .withMarkersType(MarkerType.DIRECT.name())
+                // MDT writes require hbase deps not present in the Trino 
runtime.
+                
.withMetadataConfig(HoodieMetadataConfig.newBuilder().enable(false).build());
+        // The ordering field is carried by the table config 
(setOrderingFields); the deprecated

Review Comment:
   Moved it back into the chain, at the spot `withPreCombineField` would go.
   



##########
hudi-trino/src/test/java/io/trino/plugin/hudi/testing/CompositeHudiTablesInitializer.java:
##########
@@ -0,0 +1,44 @@
+/*
+ * 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.testing;
+
+import com.google.common.collect.ImmutableList;
+import io.trino.filesystem.Location;
+import io.trino.testing.QueryRunner;
+
+import java.util.List;
+
+/**
+ * Runs several {@link HudiTablesInitializer}s against one query runner, in 
order, so a single test class can
+ * load more than one table fixture. Each delegate must create its own tables 
under a distinct name.
+ */
+public class CompositeHudiTablesInitializer
+        implements HudiTablesInitializer
+{
+    private final List<HudiTablesInitializer> delegates;
+
+    public CompositeHudiTablesInitializer(HudiTablesInitializer... delegates)
+    {
+        this.delegates = ImmutableList.copyOf(delegates);
+    }
+
+    @Override
+    public void initializeTables(QueryRunner queryRunner, Location 
externalLocation, String schemaName)
+            throws Exception
+    {
+        for (HudiTablesInitializer delegate : delegates) {
+            delegate.initializeTables(queryRunner, externalLocation, 
schemaName);

Review Comment:
   Went with the javadoc: the composite now says delegates must finish their 
writes within `initializeTables`, and that a `keepsWriterOpen()` fixture can't 
be composed since the interface has no close to forward. Didn't want to grow 
the interface for a case nothing needs yet.
   



##########
hudi-trino/src/test/java/io/trino/plugin/hudi/testing/PayloadOnlyMergerHudiTablesInitializer.java:
##########
@@ -0,0 +1,208 @@
+/*
+ * 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.testing;
+
+import com.google.common.collect.ImmutableList;
+import io.trino.metastore.Column;
+import org.apache.avro.Schema;
+import org.apache.avro.generic.GenericData;
+import org.apache.avro.generic.GenericRecord;
+import org.apache.hudi.client.HoodieJavaWriteClient;
+import org.apache.hudi.client.WriteStatus;
+import org.apache.hudi.common.model.HoodieAvroPayload;
+import org.apache.hudi.common.model.HoodieRecord;
+import org.apache.hudi.common.table.HoodieTableConfig;
+import org.apache.hudi.common.table.HoodieTableMetaClient;
+import org.apache.hudi.common.table.HoodieTableVersion;
+import org.apache.hudi.config.HoodieWriteConfig;
+
+import java.io.IOException;
+import java.io.InputStream;
+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.Properties;
+
+import static io.trino.metastore.HiveType.HIVE_LONG;
+import static io.trino.metastore.HiveType.HIVE_STRING;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+/**
+ * Creates a non-partitioned Merge-On-Read table at TABLE VERSION 6 whose only 
merge configuration is a
+ * {@link org.apache.hudi.common.model.HoodieRecordPayload} class ({@link 
RankBasedTestPayload}), the way a
+ * genuine pre-1.0 table looks on storage.
+ * <p>
+ * Such a table resolves at read time to {@code CUSTOM} merge mode with the 
payload-based merge strategy, which
+ * in turn resolves {@code HoodieAvroRecordMerger}. That merger is not 
projection compatible, so the file group
+ * reader demands the FULL table schema for base and log reads alike and 
nothing prepends the payload's decision
+ * column into the connector's read projection.
+ * <p>
+ * One {@code bulkInsert} (base files) is followed by one {@code upsert} of 
the same keys (log files), with data
+ * laid out so each merge direction is distinguishable: {@code k1}'s winning 
rank is on the LOG record and
+ * {@code k2}'s on the BASE record. See {@code 
TestHudiNonProjectionCompatibleMerger}.
+ */
+public class PayloadOnlyMergerHudiTablesInitializer
+        extends AbstractMergerHudiTablesInitializer
+{
+    public static final String TABLE_NAME = "payload_only_mor";
+    public static final String RT_TABLE_NAME = TABLE_NAME + "_rt";
+
+    private static final String RANK_FIELD = RankBasedTestPayload.RANK_COLUMN;
+    private static final HoodieTableVersion TABLE_VERSION = 
HoodieTableVersion.SIX;
+
+    public PayloadOnlyMergerHudiTablesInitializer()
+    {
+        super(TABLE_NAME);
+    }
+
+    @Override
+    protected List<Column> dataColumns()
+    {
+        return ImmutableList.of(
+                new Column(RECORD_KEY_FIELD, HIVE_STRING, Optional.empty(), 
Map.of()),
+                new Column("name", HIVE_STRING, Optional.empty(), Map.of()),
+                new Column("value", HIVE_LONG, Optional.empty(), Map.of()),
+                new Column(RANK_FIELD, HIVE_LONG, Optional.empty(), Map.of()),
+                new Column(ORDERING_FIELD, HIVE_LONG, Optional.empty(), 
Map.of()));
+    }
+
+    @Override
+    protected Schema avroSchema()
+    {
+        List<Schema.Field> fields = ImmutableList.of(
+                new Schema.Field(RECORD_KEY_FIELD, 
Schema.create(Schema.Type.STRING)),
+                new Schema.Field("name", Schema.create(Schema.Type.STRING)),
+                new Schema.Field("value", Schema.create(Schema.Type.LONG)),
+                new Schema.Field(RANK_FIELD, Schema.create(Schema.Type.LONG)),
+                new Schema.Field(ORDERING_FIELD, 
Schema.create(Schema.Type.LONG)));
+        return Schema.createRecord(TABLE_NAME, null, null, false, new 
ArrayList<>(fields));
+    }
+
+    @Override
+    protected void configureTableConfig(HoodieTableMetaClient.TableBuilder 
tableBuilder)
+    {
+        // Only the payload class: the merge mode and the merge strategy id 
must stay out of hoodie.properties
+        // so the reader has to infer them, which is what makes this a pre-1.0 
payload-only table.
+        tableBuilder
+                .setTableVersion(TABLE_VERSION)
+                .setPayloadClassName(RankBasedTestPayload.class.getName());
+    }
+
+    @Override
+    protected void configureWriteConfig(HoodieWriteConfig.Builder 
writeConfigBuilder)
+    {
+        writeConfigBuilder
+                .withWriteTableVersion(TABLE_VERSION.versionCode())
+                // Without this the write client silently upgrades the table 
to the current version on the first commit.
+                .withAutoUpgradeVersion(false)
+                .withWritePayLoad(RankBasedTestPayload.class.getName());
+    }
+
+    @Override
+    protected void afterTableInit()
+            throws IOException
+    {
+        stripInferredMergeConfigs();
+    }
+
+    @Override
+    protected void 
writeInitialCommits(HoodieJavaWriteClient<HoodieAvroPayload> client)
+            throws IOException
+    {
+        Schema schema = avroSchema();
+        // First commit: bulk insert base records (produces base parquet 
files).
+        String firstCommit = client.startCommit();
+        List<WriteStatus> firstStatuses = client.bulkInsert(ImmutableList.of(
+                record(schema, "k1", "k1_base", 10L, 5L, 1L),
+                record(schema, "k2", "k2_base", 100L, 9L, 1L)), firstCommit);
+        client.commit(firstCommit, firstStatuses);
+
+        // Second commit: upserts the same keys (produces log files since 
inline compaction is disabled).
+        // k1 update has a HIGHER rank (7 > 5) -> the payload keeps the update 
(99): the LOG record's rank decides.
+        // k2 update has a LOWER rank (1 < 9) -> the payload keeps the base 
record (100): the BASE record's rank
+        // decides, which only works when the base read carries merge_rank 
despite it never being projected.
+        String secondCommit = client.startCommit();
+        List<WriteStatus> secondStatuses = client.upsert(ImmutableList.of(
+                record(schema, "k1", "k1_updated", 99L, 7L, 2L),
+                record(schema, "k2", "k2_updated", 4L, 1L, 2L)), secondCommit);
+        client.commit(secondCommit, secondStatuses);
+
+        // The commits go through the table config; re-check that the writer 
left the fixture payload-only.
+        stripInferredMergeConfigs();
+    }
+
+    /**
+     * Removes the merge mode and merge strategy id that table creation infers 
and persists for every pre-v9
+     * table, then verifies what is left. The fixture has to mimic a genuine 
pre-1.0 table, which persists its
+     * payload class and nothing else about merging; leaving the inferred keys 
in would hand the reader the
+     * answer this fixture exists to make it derive.
+     */
+    private void stripInferredMergeConfigs()
+            throws IOException
+    {
+        Path metaDirectory = stagingTableDirectory().resolve(".hoodie");
+        Path propertiesFile = metaDirectory.resolve("hoodie.properties");
+        List<String> retainedLines = Files.readAllLines(propertiesFile, 
UTF_8).stream()
+                .filter(line -> !isEntryFor(line, 
HoodieTableConfig.RECORD_MERGE_MODE.key())

Review Comment:
   Checked `dropInvalidConfigs` and confirmed: the mode's config is 
since-version 1.0.0, so it never reaches disk for a v6 table. The filter now 
strips just the strategy id, the javadoc explains why, and the `checkAbsent` on 
the mode stays as a pin of creation's own behavior.
   



##########
hudi-trino/src/main/java/io/trino/plugin/hudi/util/PrefilledColumnValues.java:
##########
@@ -0,0 +1,126 @@
+/*
+ * 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.util;
+
+import com.google.common.collect.ImmutableMap;
+import io.trino.plugin.hive.HiveColumnHandle;
+import io.trino.plugin.hive.HivePartitionKey;
+import io.trino.plugin.hudi.HudiSplit;
+import io.trino.plugin.hudi.file.HudiFile;
+import io.trino.spi.block.Block;
+import io.trino.spi.block.BlockBuilder;
+import io.trino.spi.block.RunLengthEncodedBlock;
+
+import java.util.List;
+import java.util.Map;
+import java.util.OptionalInt;
+
+import static io.trino.metastore.Partitions.makePartName;
+import static 
io.trino.plugin.hive.HiveColumnHandle.isFileModifiedTimeColumnHandle;
+import static io.trino.plugin.hive.HiveColumnHandle.isFileSizeColumnHandle;
+import static io.trino.plugin.hive.HiveColumnHandle.isPartitionColumnHandle;
+import static io.trino.plugin.hive.HiveColumnHandle.isPathColumnHandle;
+import static io.trino.plugin.hive.util.HiveUtil.getPrefilledColumnValue;
+import static io.trino.spi.type.TypeUtils.writeNativeValue;
+
+/**
+ * Per-split constant values for the output columns that are not stored in the 
data file: Hive-style
+ * partition columns and Trino's hidden metadata columns ({@code $path}, 
{@code $file_size},
+ * {@code $file_modified_time}, {@code $partition}). Value computation 
delegates to
+ * {@link io.trino.plugin.hive.util.HiveUtil#getPrefilledColumnValue}, the 
same implementation the Hive
+ * connector uses for these columns (including the {@code "\N"} hive-null 
partition-value convention).
+ */
+public class PrefilledColumnValues
+{
+    private final Map<String, HivePartitionKey> partitionKeysByName;
+    private final String partitionName;
+    private final String filePath;
+    private final long fileSize;
+    private final long fileModifiedTime;
+
+    public static PrefilledColumnValues create(HudiSplit hudiSplit)
+    {
+        return new PrefilledColumnValues(hudiSplit);
+    }
+
+    private PrefilledColumnValues(HudiSplit hudiSplit)
+    {
+        List<HivePartitionKey> partitionKeys = hudiSplit.getPartitionKeys();
+        // ImmutableMap preserves insertion order, so $partition renders the 
keys in the split's
+        // partition-column order.
+        ImmutableMap.Builder<String, HivePartitionKey> byName = 
ImmutableMap.builder();
+        partitionKeys.forEach(partitionKey -> byName.put(partitionKey.name(), 
partitionKey));
+        this.partitionKeysByName = byName.buildOrThrow();
+        this.partitionName = makePartName(
+                partitionKeys.stream().map(HivePartitionKey::name).toList(),
+                partitionKeys.stream().map(HivePartitionKey::value).toList());
+        // Parquet files will be prioritised over log files
+        HudiFile hudiFile = hudiSplit.getBaseFile().isPresent()
+                ? hudiSplit.getBaseFile().get()
+                : hudiSplit.getLogFiles().getFirst();
+        this.filePath = hudiFile.getPath();
+        this.fileSize = hudiFile.getFileSize();
+        this.fileModifiedTime = hudiFile.getModificationTime();
+    }
+
+    /**
+     * Returns whether this split can provide a value for the column, i.e. it 
is a partition column of
+     * the split or a hidden metadata column Hudi populates.
+     */
+    public boolean isPrefilled(HiveColumnHandle columnHandle)
+    {
+        return partitionKeysByName.containsKey(columnHandle.getName())
+                || isPathColumnHandle(columnHandle)
+                || isFileSizeColumnHandle(columnHandle)
+                || isFileModifiedTimeColumnHandle(columnHandle)
+                || isPartitionColumnHandle(columnHandle);
+    }
+
+    /**
+     * Appends the column's value for this split to the builder; appends null 
for a column this split
+     * cannot provide.
+     */
+    public void appendTo(HiveColumnHandle columnHandle, BlockBuilder 
blockBuilder)
+    {
+        writeNativeValue(columnHandle.getType(), blockBuilder, 
nativeValueOf(columnHandle));
+    }
+
+    /**
+     * Builds a run-length-encoded {@link Block} repeating the column's 
constant value for
+     * {@code positionCount} positions (null-filled for a column this split 
cannot provide).
+     */
+    public Block toRleBlock(HiveColumnHandle columnHandle, int positionCount)
+    {
+        return RunLengthEncodedBlock.create(columnHandle.getType(), 
nativeValueOf(columnHandle), positionCount);
+    }
+
+    private Object nativeValueOf(HiveColumnHandle columnHandle)
+    {
+        if (!isPrefilled(columnHandle)) {
+            // Lenient null fill, e.g. for a hidden column Trino defines but 
Hudi does not populate.
+            return null;
+        }
+        HivePartitionKey partitionKey = 
partitionKeysByName.get(columnHandle.getName());
+        return getPrefilledColumnValue(

Review Comment:
   Folded them all into the Summary and Changelog: the `$file_modified_time` 
zone change joins the `PrefilledColumnValues` delta list, and the null-fill 
under `use-column-names=false`, the `HUDI_BAD_DATA` guard, and the 
metastore-omitted recovery each have their own bullet now. Also refreshed the 
stale "stacked on #18837" opener while in there.
   



-- 
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]


Reply via email to