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


##########
hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieAnalysis.scala:
##########
@@ -639,3 +643,70 @@ case class HoodiePostAnalysisRule(sparkSession: 
SparkSession) extends Rule[Logic
     }
   }
 }
+
+/**
+ * Stamps a synthesized [[CatalogTable]] (table name, base path, schema) onto 
path-based
+ * Hudi reads whose underlying file index is incremental or CDC. Without it, 
lineage and
+ * governance tooling sees `LogicalRelation.catalogTable = None` and falls 
back to the
+ * relation's class name as the dataset identifier -- useless for tracking 
which table
+ * an incremental query came from.
+ *
+ * Scope is intentionally limited to incremental and CDC reads:
+ *  - Catalog-registered reads already have `catalogTable` populated.
+ *  - Path-based snapshot reads have a working file-path-based fallback in 
existing
+ *    lineage tooling; changing their behavior is a separate decision.
+ */
+object HoodieIncrementalRelationIdentifier extends Rule[LogicalPlan] {
+  override def apply(plan: LogicalPlan): LogicalPlan =
+    AnalysisHelper.allowInvokingTransformsInAnalyzer {
+      plan transform {
+        // Type pattern + nested match avoids destructuring `LogicalRelation`, 
whose
+        // case-class arity differs between Spark 3.x (4 args) and Spark 4.x 
(5 args). This
+        // rule lives in `hudi-spark`, which is compiled against every 
supported profile.
+        case lr: LogicalRelation if lr.catalogTable.isEmpty =>
+          lr.relation match {
+            case fsRelation: HadoopFsRelation =>
+              incrementalOrCDCIndex(fsRelation.location)
+                .flatMap(index => buildCatalogTable(index.metaClient, 
lr.schema))
+                .map(catalogTable => lr.copy(catalogTable = 
Some(catalogTable)))
+                .getOrElse(lr)
+            case _ => lr
+          }
+      }
+    }
+
+  /**
+   * Narrows a [[FileIndex]] to the incremental/CDC Hudi indexes this rule 
handles, or
+   * `None` for anything else. Both are [[HoodieFileIndex]] subclasses, so the 
widening is
+   * checked by the compiler rather than by an `asInstanceOf` that a future 
third index
+   * type could silently break.
+   */
+  private def incrementalOrCDCIndex(location: FileIndex): 
Option[HoodieFileIndex] =
+    location match {
+      case index: HoodieIncrementalFileIndex => Some(index)
+      case index: HoodieCDCFileIndex => Some(index)
+      case _ => None
+    }
+
+  private def buildCatalogTable(
+      metaClient: HoodieTableMetaClient,
+      schema: StructType): Option[CatalogTable] = {
+    val tableConfig = metaClient.getTableConfig
+    // `hoodie.table.name` is required for a valid Hudi table, but if it is 
somehow unset
+    // leave `catalogTable` as `None` -- the pre-existing behavior -- rather 
than synthesize
+    // a `TableIdentifier(null)` that would surface downstream as a garbage 
dataset name.
+    Option(tableConfig.getTableName).filter(_.nonEmpty).map { tableName =>
+      // Falls back to Spark's `default` database when `hoodie.database.name` 
is unset --
+      // matches existing path-based DataFrame read behavior.
+      val dbName = Option(tableConfig.getDatabaseName).filter(_.nonEmpty)
+      CatalogTable(
+        identifier = TableIdentifier(tableName, dbName),

Review Comment:
   **minor:** this name is `hoodie.properties`-derived and can diverge from the 
catalog identity: `CREATE TABLE ... LOCATION` over an existing table leaves 
name/db untouched (`HoodieCatalogTable.scala:204,234-235` set them only when 
`!hoodieTableExists`; `TestCreateTable.scala:240-313` registers one path under 
three names), and DataFrame-written tables default `hoodie.database.name` to 
`""` (`HoodieSparkSqlWriter.scala:241`), so they stamp unqualified. Not 
blocking. Could the scaladoc say `storage.locationUri` is the stable key and 
the identifier may not be catalog-resolvable?



##########
hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieAnalysis.scala:
##########
@@ -639,3 +643,70 @@ case class HoodiePostAnalysisRule(sparkSession: 
SparkSession) extends Rule[Logic
     }
   }
 }
+
+/**
+ * Stamps a synthesized [[CatalogTable]] (table name, base path, schema) onto 
path-based
+ * Hudi reads whose underlying file index is incremental or CDC. Without it, 
lineage and
+ * governance tooling sees `LogicalRelation.catalogTable = None` and falls 
back to the
+ * relation's class name as the dataset identifier -- useless for tracking 
which table
+ * an incremental query came from.

Review Comment:
   **minor:** OpenLineage never falls back to a class name: 
`LogicalRelationDatasetBuilder` checks `catalogTable.isDefined` first, then 
handles any `HadoopFsRelation` via `relation.location().rootPaths()` (unhandled 
relations throw). Since #17457 (`0c0c402a9a03`, 2025-12-07) incremental/CDC 
reads are `HadoopFsRelation`s, so for OL the delta is: same path dataset, plus 
a table symlink, minus the `size`/`fileCount` input facets (the catalogTable 
branch takes them from `catalogTable.stats` / `numFiles`, both absent here). 
The snapshot-exclusion rationale applies equally to incremental reads. Not 
blocking. Could the premise be restated as that delta and the scope 
re-justified on it?



##########
hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieAnalysis.scala:
##########
@@ -126,7 +129,8 @@ object HoodieAnalysis extends SparkAdapterSupport {
       // NOTE: By default all commands are converted into corresponding Hudi 
implementations during
       //       "post-hoc resolution" phase
       session => ResolveImplementations(session),
-      session => HoodiePostAnalysisRule(session)
+      session => HoodiePostAnalysisRule(session),
+      _ => HoodieIncrementalRelationIdentifier

Review Comment:
   **major:** the two halves do not compose for a MERGE source: 
`ResolveImplementations` runs first in this `Once` batch and turns the 
statement into a leaf whose `mapChildren` is `final ... = this` 
(`HoodieLeafLike.scala:27`), so `plan transform` never reaches a 
`hudi_table_changes('/path', ...)` source under `MERGE INTO ... USING`, and 
`innerChildren` then publishes that unstamped source. INSERT/CTAS sources are 
reached (`DataWritingCommand.child = query`); only MERGE misses. The other two 
post-hoc rules are root-only `plan match` on commands, so could this rule be 
registered first, with a MERGE-with-TVF-source test asserting the inner source 
relation is stamped?



##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/analysis/TestHoodieIncrementalRelationIdentifier.scala:
##########
@@ -0,0 +1,182 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.hudi.analysis
+
+import org.apache.hudi.{HoodieFileIndex, HoodieIncrementalFileIndex, 
ScalaAssertionSupport}
+import org.apache.hudi.HoodieConversionUtils.toJavaOption
+import org.apache.hudi.common.table.HoodieTableMetaClient
+import org.apache.hudi.common.table.timeline.HoodieInstant
+import org.apache.hudi.hadoop.fs.HadoopFSUtils
+import org.apache.hudi.testutils.HoodieClientTestBase
+import org.apache.hudi.util.JFunction
+
+import org.apache.spark.sql.{SparkSession, SparkSessionExtensions}
+import org.apache.spark.sql.execution.datasources.{HadoopFsRelation, 
LogicalRelation}
+import org.apache.spark.sql.hudi.HoodieSparkSessionExtension
+import org.junit.jupiter.api.{BeforeEach, Test}
+import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, assertTrue}
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.ValueSource
+
+import java.util.function.Consumer
+
+/**
+ * Verifies that [[HoodieIncrementalRelationIdentifier]] enriches path-based 
incremental
+ * reads (and only those), so that lineage tooling sees a real table 
identifier instead
+ * of falling back to the relation's class name.
+ */
+class TestHoodieIncrementalRelationIdentifier extends HoodieClientTestBase 
with ScalaAssertionSupport {
+
+  private var spark: SparkSession = _
+
+  @BeforeEach
+  override def setUp() {
+    setTableName("hoodie_incr_id_test")
+    initPath()
+    initSparkContexts()
+    spark = sqlContext.sparkSession
+  }
+
+  override def getSparkSessionExtensionsInjector: 
org.apache.hudi.common.util.Option[Consumer[SparkSessionExtensions]] =
+    toJavaOption(
+      Some(
+        JFunction.toJavaConsumer((receiver: SparkSessionExtensions) => new 
HoodieSparkSessionExtension().apply(receiver)))
+    )
+
+  @ParameterizedTest
+  @ValueSource(strings = Array("cow", "mor"))
+  def testPathBasedIncrementalReadGetsCatalogTable(tableType: String): Unit = {
+    val tablePath = s"$basePath/$tableName"
+    createAndPopulateTable(tableType, tablePath)
+
+    val firstInstant = firstCompletedInstantTime(tablePath)
+
+    val df = spark.read.format("hudi")
+      .option("hoodie.datasource.query.type", "incremental")
+      .option("hoodie.datasource.read.begin.instanttime", firstInstant)
+      .load(tablePath)
+
+    val analyzed = df.queryExecution.analyzed
+
+    val lrOpt = analyzed.collectFirst { case lr: LogicalRelation => lr }
+    assertTrue(lrOpt.isDefined,
+      s"Expected a LogicalRelation in analyzed plan, got:\n$analyzed")
+    val lr = lrOpt.get
+
+    // Sanity: confirm the path under test really exercises the incremental 
file index.
+    val location = lr.relation.asInstanceOf[HadoopFsRelation].location
+    assertTrue(location.isInstanceOf[HoodieIncrementalFileIndex],
+      s"Expected HoodieIncrementalFileIndex, got: 
${location.getClass.getName}")
+
+    assertTrue(lr.catalogTable.isDefined,
+      s"Expected catalogTable to be populated by 
HoodieIncrementalRelationIdentifier, got None")
+
+    val ct = lr.catalogTable.get
+    assertEquals(tableName, ct.identifier.table,
+      s"Expected catalogTable identifier to use Hudi table name '$tableName', 
got '${ct.identifier.table}'")
+    assertEquals(Some("hudi"), ct.provider,
+      s"Expected provider 'hudi', got '${ct.provider}'")
+    assertTrue(ct.storage.locationUri.isDefined,
+      "Expected catalogTable.storage.locationUri to be populated from 
metaClient.getBasePath")
+    assertTrue(ct.schema.fields.nonEmpty,
+      "Expected catalogTable.schema to mirror the relation's resolved output 
schema")
+  }
+
+  @Test
+  def testCatalogRegisteredReadIsNotMutated(): Unit = {
+    spark.sql(
+      s"""
+         |CREATE TABLE $tableName (
+         |  id int,
+         |  name string,
+         |  ts long
+         |) USING hudi
+         |TBLPROPERTIES (
+         |  type = 'cow',
+         |  primaryKey = 'id',
+         |  orderingFields = 'ts'
+         |)
+         |LOCATION '$basePath/$tableName'
+         """.stripMargin)
+    spark.sql(s"INSERT INTO $tableName VALUES (1, 'a1', 1000)")
+
+    val analyzed = spark.sql(s"SELECT * FROM 
$tableName").queryExecution.analyzed
+    val lrOpt = analyzed.collectFirst { case lr: LogicalRelation => lr }
+    assertTrue(lrOpt.isDefined,
+      s"Expected a LogicalRelation in analyzed plan, got:\n$analyzed")
+    val lr = lrOpt.get
+
+    assertTrue(lr.catalogTable.isDefined, "Expected catalogTable from catalog 
registration")
+    assertEquals(tableName, lr.catalogTable.get.identifier.table)
+  }
+
+  @Test
+  def testSnapshotPathBasedReadIsNotEnriched(): Unit = {
+    val tablePath = s"$basePath/$tableName"
+    createAndPopulateTable("cow", tablePath)
+
+    val df = spark.read.format("hudi").load(tablePath)
+    val analyzed = df.queryExecution.analyzed
+
+    val lrOpt = analyzed.collectFirst { case lr: LogicalRelation => lr }
+    assertTrue(lrOpt.isDefined,
+      s"Expected a LogicalRelation in analyzed plan, got:\n$analyzed")
+    val lr = lrOpt.get
+
+    val location = lr.relation.asInstanceOf[HadoopFsRelation].location
+    assertFalse(location.isInstanceOf[HoodieIncrementalFileIndex],
+      s"Snapshot read should not produce a HoodieIncrementalFileIndex, got: 
${location.getClass.getName}")
+    assertTrue(location.isInstanceOf[HoodieFileIndex],
+      s"Snapshot read should produce a HoodieFileIndex, got: 
${location.getClass.getName}")
+    assertFalse(lr.catalogTable.isDefined,
+      "Snapshot path-based read must not be enriched by 
HoodieIncrementalRelationIdentifier")
+  }
+
+  // --- helpers ---
+
+  private def createAndPopulateTable(tableType: String, tablePath: String): 
Unit = {
+    spark.sql(
+      s"""
+         |CREATE TABLE $tableName (
+         |  id int,
+         |  name string,
+         |  price double,
+         |  ts long
+         |) USING hudi
+         |TBLPROPERTIES (
+         |  type = '$tableType',
+         |  primaryKey = 'id',
+         |  orderingFields = 'ts'
+         |)
+         |LOCATION '$tablePath'
+         """.stripMargin)
+    // Two commits so the incremental range is never empty.
+    spark.sql(s"INSERT INTO $tableName VALUES (1, 'a1', 10, 1000), (2, 'a2', 
20, 2000)")
+    spark.sql(s"INSERT INTO $tableName VALUES (3, 'a3', 30, 3000)")
+  }
+
+  private def firstCompletedInstantTime(tablePath: String): String = {
+    val metaClient = HoodieTableMetaClient.builder()
+      .setBasePath(tablePath)
+      
.setConf(HadoopFSUtils.getStorageConf(spark.sparkContext.hadoopConfiguration)).build()
+    val instants: java.util.List[HoodieInstant] =
+      metaClient.getCommitsTimeline.filterCompletedInstants.getInstants
+    assertFalse(instants.isEmpty, s"Expected at least one completed instant at 
$tablePath")
+    instants.get(0).requestedTime

Review Comment:
   **minor:** `hoodie.datasource.read.begin.instanttime` is a completion time 
on table version 8+ (`DataSourceOptions.scala:117-119`; 
`MergeOnReadIncrementalRelationV2.scala:200,208`, `OPEN_CLOSED`), and 
requested(c1) < completion(c1), so this "incremental" read spans the whole 
table; the "two commits so the range is never empty" comment at L168 is not 
load-bearing either (`EmptyRelation` only at zero commits, 
`DefaultSource.scala:332`). Not blocking. Could this use the completion time so 
the fixture reads what its name says?
   
   ```suggestion
       instants.get(0).getCompletionTime
   ```



##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/analysis/TestHoodieIncrementalRelationIdentifier.scala:
##########
@@ -0,0 +1,182 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.hudi.analysis
+
+import org.apache.hudi.{HoodieFileIndex, HoodieIncrementalFileIndex, 
ScalaAssertionSupport}
+import org.apache.hudi.HoodieConversionUtils.toJavaOption
+import org.apache.hudi.common.table.HoodieTableMetaClient
+import org.apache.hudi.common.table.timeline.HoodieInstant
+import org.apache.hudi.hadoop.fs.HadoopFSUtils
+import org.apache.hudi.testutils.HoodieClientTestBase
+import org.apache.hudi.util.JFunction
+
+import org.apache.spark.sql.{SparkSession, SparkSessionExtensions}
+import org.apache.spark.sql.execution.datasources.{HadoopFsRelation, 
LogicalRelation}
+import org.apache.spark.sql.hudi.HoodieSparkSessionExtension
+import org.junit.jupiter.api.{BeforeEach, Test}
+import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, assertTrue}
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.ValueSource
+
+import java.util.function.Consumer
+
+/**
+ * Verifies that [[HoodieIncrementalRelationIdentifier]] enriches path-based 
incremental
+ * reads (and only those), so that lineage tooling sees a real table 
identifier instead
+ * of falling back to the relation's class name.
+ */
+class TestHoodieIncrementalRelationIdentifier extends HoodieClientTestBase 
with ScalaAssertionSupport {
+
+  private var spark: SparkSession = _
+
+  @BeforeEach
+  override def setUp() {

Review Comment:
   **nit:** procedure syntax is deprecated in Scala 2.13, which the spark4 
profiles compile under; same at 
`TestMergeIntoHoodieTableCommandInnerChildren.scala:46`. Feel free to ignore.
   
   ```suggestion
     override def setUp(): Unit = {
   ```



##########
hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieAnalysis.scala:
##########
@@ -639,3 +643,70 @@ case class HoodiePostAnalysisRule(sparkSession: 
SparkSession) extends Rule[Logic
     }
   }
 }
+
+/**
+ * Stamps a synthesized [[CatalogTable]] (table name, base path, schema) onto 
path-based
+ * Hudi reads whose underlying file index is incremental or CDC. Without it, 
lineage and
+ * governance tooling sees `LogicalRelation.catalogTable = None` and falls 
back to the
+ * relation's class name as the dataset identifier -- useless for tracking 
which table
+ * an incremental query came from.
+ *
+ * Scope is intentionally limited to incremental and CDC reads:
+ *  - Catalog-registered reads already have `catalogTable` populated.
+ *  - Path-based snapshot reads have a working file-path-based fallback in 
existing
+ *    lineage tooling; changing their behavior is a separate decision.
+ */
+object HoodieIncrementalRelationIdentifier extends Rule[LogicalPlan] {
+  override def apply(plan: LogicalPlan): LogicalPlan =
+    AnalysisHelper.allowInvokingTransformsInAnalyzer {
+      plan transform {
+        // Type pattern + nested match avoids destructuring `LogicalRelation`, 
whose
+        // case-class arity differs between Spark 3.x (4 args) and Spark 4.x 
(5 args). This
+        // rule lives in `hudi-spark`, which is compiled against every 
supported profile.
+        case lr: LogicalRelation if lr.catalogTable.isEmpty =>
+          lr.relation match {
+            case fsRelation: HadoopFsRelation =>

Review Comment:
   **nit:** on release-1.1.x, which lacks #17457, incremental/CDC reads are not 
`HadoopFsRelation`s, so this arm never fires and the rule is a silent no-op 
there; 1.2.x has #17457 and is fine. Only matters if 1.1.x is ever a backport 
target. Feel free to ignore.



##########
hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/hudi/command/MergeIntoHoodieTableCommand.scala:
##########
@@ -113,6 +113,23 @@ case class MergeIntoHoodieTableCommand(mergeInto: 
MergeIntoTable) extends Hoodie
   with ProvidesHoodieConfig
   with PredicateHelper {
 
+  // Required so that `HoodieLeafLike#children = Nil` (kept for Catalyst 
optimizer
+  // safety) does not also hide the source/target/merge-condition from 
EXPLAIN, lineage
+  // extractors, and other plan walkers. `innerChildren` is not traversed by 
`transform`
+  // or `mapChildren`.
+  //
+  // NOTE: this exposes the *analyzed* `MergeIntoTable`, carrying the 
statement's assignments
+  // exactly as written -- deliberately not a post-alignment plan. Hudi does 
expand partial
+  // `UPDATE SET` / `INSERT (...)` clauses to the full target schema, in 
`alignAssignments`,
+  // but that expansion is serialized into the write config 
(`PAYLOAD_*_CONDITION_AND_ASSIGNMENTS`)
+  // and never materialized as a `MergeIntoTable`. It also would not be a 
faithful description
+  // of the write if it were: the filler assignments are bound against the 
source-joined-target
+  // payload rather than the target alone, and under `ORIGINAL_VALUE` on a MOR 
table an untouched
+  // column is encoded as `Literal(null)` -- a plan walker would read that as 
"written as NULL"
+  // when it means "left alone". Consumers needing whole-row fidelity should 
fill in the
+  // unassigned target columns themselves.
+  override def innerChildren: Seq[QueryPlan[_]] = Seq(mergeInto)

Review Comment:
   **nit:** "consumed only by EXPLAIN and opt-in plan walkers" undersells it: 
`SQLExecution` computes `physicalPlanDescription = 
explainString(spark.sql.ui.explainMode)` (default `formatted`, which recurses 
`innerChildren`) on every execution, so each MERGE now ships its full 
source+target subtree into the SQL tab and event log, one per micro-batch for 
foreachBatch MERGE -- the same as INSERT already does. Feel free to ignore, but 
could the description say so in one line?



##########
hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieAnalysis.scala:
##########
@@ -639,3 +643,70 @@ case class HoodiePostAnalysisRule(sparkSession: 
SparkSession) extends Rule[Logic
     }
   }
 }
+
+/**
+ * Stamps a synthesized [[CatalogTable]] (table name, base path, schema) onto 
path-based
+ * Hudi reads whose underlying file index is incremental or CDC. Without it, 
lineage and
+ * governance tooling sees `LogicalRelation.catalogTable = None` and falls 
back to the
+ * relation's class name as the dataset identifier -- useless for tracking 
which table
+ * an incremental query came from.
+ *
+ * Scope is intentionally limited to incremental and CDC reads:
+ *  - Catalog-registered reads already have `catalogTable` populated.
+ *  - Path-based snapshot reads have a working file-path-based fallback in 
existing
+ *    lineage tooling; changing their behavior is a separate decision.
+ */
+object HoodieIncrementalRelationIdentifier extends Rule[LogicalPlan] {

Review Comment:
   **nit:** there is no opt-out, and no Hudi precedent either way (no rule in 
`HoodieAnalysis` reads a config). Given the OL facet change and the SQL-UI 
payload change noted elsewhere, would a `hoodie.*` boolean (default true) be 
worth adding as an escape hatch? Feel free to ignore.



##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/analysis/TestHoodieIncrementalRelationIdentifier.scala:
##########
@@ -0,0 +1,182 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.spark.sql.hudi.analysis
+
+import org.apache.hudi.{HoodieFileIndex, HoodieIncrementalFileIndex, 
ScalaAssertionSupport}
+import org.apache.hudi.HoodieConversionUtils.toJavaOption
+import org.apache.hudi.common.table.HoodieTableMetaClient
+import org.apache.hudi.common.table.timeline.HoodieInstant
+import org.apache.hudi.hadoop.fs.HadoopFSUtils
+import org.apache.hudi.testutils.HoodieClientTestBase
+import org.apache.hudi.util.JFunction
+
+import org.apache.spark.sql.{SparkSession, SparkSessionExtensions}
+import org.apache.spark.sql.execution.datasources.{HadoopFsRelation, 
LogicalRelation}
+import org.apache.spark.sql.hudi.HoodieSparkSessionExtension
+import org.junit.jupiter.api.{BeforeEach, Test}
+import org.junit.jupiter.api.Assertions.{assertEquals, assertFalse, assertTrue}
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.ValueSource
+
+import java.util.function.Consumer
+
+/**
+ * Verifies that [[HoodieIncrementalRelationIdentifier]] enriches path-based 
incremental
+ * reads (and only those), so that lineage tooling sees a real table 
identifier instead
+ * of falling back to the relation's class name.
+ */
+class TestHoodieIncrementalRelationIdentifier extends HoodieClientTestBase 
with ScalaAssertionSupport {
+
+  private var spark: SparkSession = _
+
+  @BeforeEach
+  override def setUp() {
+    setTableName("hoodie_incr_id_test")
+    initPath()
+    initSparkContexts()
+    spark = sqlContext.sparkSession
+  }
+
+  override def getSparkSessionExtensionsInjector: 
org.apache.hudi.common.util.Option[Consumer[SparkSessionExtensions]] =
+    toJavaOption(
+      Some(
+        JFunction.toJavaConsumer((receiver: SparkSessionExtensions) => new 
HoodieSparkSessionExtension().apply(receiver)))
+    )
+
+  @ParameterizedTest
+  @ValueSource(strings = Array("cow", "mor"))
+  def testPathBasedIncrementalReadGetsCatalogTable(tableType: String): Unit = {
+    val tablePath = s"$basePath/$tableName"
+    createAndPopulateTable(tableType, tablePath)
+
+    val firstInstant = firstCompletedInstantTime(tablePath)
+
+    val df = spark.read.format("hudi")
+      .option("hoodie.datasource.query.type", "incremental")
+      .option("hoodie.datasource.read.begin.instanttime", firstInstant)
+      .load(tablePath)
+
+    val analyzed = df.queryExecution.analyzed
+
+    val lrOpt = analyzed.collectFirst { case lr: LogicalRelation => lr }
+    assertTrue(lrOpt.isDefined,
+      s"Expected a LogicalRelation in analyzed plan, got:\n$analyzed")
+    val lr = lrOpt.get
+
+    // Sanity: confirm the path under test really exercises the incremental 
file index.
+    val location = lr.relation.asInstanceOf[HadoopFsRelation].location
+    assertTrue(location.isInstanceOf[HoodieIncrementalFileIndex],
+      s"Expected HoodieIncrementalFileIndex, got: 
${location.getClass.getName}")
+
+    assertTrue(lr.catalogTable.isDefined,
+      s"Expected catalogTable to be populated by 
HoodieIncrementalRelationIdentifier, got None")
+
+    val ct = lr.catalogTable.get
+    assertEquals(tableName, ct.identifier.table,
+      s"Expected catalogTable identifier to use Hudi table name '$tableName', 
got '${ct.identifier.table}'")
+    assertEquals(Some("hudi"), ct.provider,
+      s"Expected provider 'hudi', got '${ct.provider}'")
+    assertTrue(ct.storage.locationUri.isDefined,
+      "Expected catalogTable.storage.locationUri to be populated from 
metaClient.getBasePath")
+    assertTrue(ct.schema.fields.nonEmpty,
+      "Expected catalogTable.schema to mirror the relation's resolved output 
schema")
+  }
+
+  @Test
+  def testCatalogRegisteredReadIsNotMutated(): Unit = {
+    spark.sql(
+      s"""
+         |CREATE TABLE $tableName (
+         |  id int,
+         |  name string,
+         |  ts long
+         |) USING hudi
+         |TBLPROPERTIES (
+         |  type = 'cow',
+         |  primaryKey = 'id',
+         |  orderingFields = 'ts'
+         |)
+         |LOCATION '$basePath/$tableName'
+         """.stripMargin)
+    spark.sql(s"INSERT INTO $tableName VALUES (1, 'a1', 1000)")
+
+    val analyzed = spark.sql(s"SELECT * FROM 
$tableName").queryExecution.analyzed
+    val lrOpt = analyzed.collectFirst { case lr: LogicalRelation => lr }
+    assertTrue(lrOpt.isDefined,
+      s"Expected a LogicalRelation in analyzed plan, got:\n$analyzed")
+    val lr = lrOpt.get
+
+    assertTrue(lr.catalogTable.isDefined, "Expected catalogTable from catalog 
registration")
+    assertEquals(tableName, lr.catalogTable.get.identifier.table)
+  }
+
+  @Test
+  def testSnapshotPathBasedReadIsNotEnriched(): Unit = {
+    val tablePath = s"$basePath/$tableName"
+    createAndPopulateTable("cow", tablePath)
+
+    val df = spark.read.format("hudi").load(tablePath)
+    val analyzed = df.queryExecution.analyzed
+
+    val lrOpt = analyzed.collectFirst { case lr: LogicalRelation => lr }
+    assertTrue(lrOpt.isDefined,
+      s"Expected a LogicalRelation in analyzed plan, got:\n$analyzed")
+    val lr = lrOpt.get
+
+    val location = lr.relation.asInstanceOf[HadoopFsRelation].location
+    assertFalse(location.isInstanceOf[HoodieIncrementalFileIndex],
+      s"Snapshot read should not produce a HoodieIncrementalFileIndex, got: 
${location.getClass.getName}")
+    assertTrue(location.isInstanceOf[HoodieFileIndex],
+      s"Snapshot read should produce a HoodieFileIndex, got: 
${location.getClass.getName}")
+    assertFalse(lr.catalogTable.isDefined,
+      "Snapshot path-based read must not be enriched by 
HoodieIncrementalRelationIdentifier")
+  }
+
+  // --- helpers ---
+
+  private def createAndPopulateTable(tableType: String, tablePath: String): 
Unit = {
+    spark.sql(
+      s"""
+         |CREATE TABLE $tableName (
+         |  id int,
+         |  name string,
+         |  price double,
+         |  ts long
+         |) USING hudi
+         |TBLPROPERTIES (
+         |  type = '$tableType',
+         |  primaryKey = 'id',
+         |  orderingFields = 'ts'
+         |)
+         |LOCATION '$tablePath'
+         """.stripMargin)
+    // Two commits so the incremental range is never empty.
+    spark.sql(s"INSERT INTO $tableName VALUES (1, 'a1', 10, 1000), (2, 'a2', 
20, 2000)")
+    spark.sql(s"INSERT INTO $tableName VALUES (3, 'a3', 30, 3000)")

Review Comment:
   **minor:** every fixture is a SQL-created, catalog-registered table read 
back by path, so name == dir == catalog name (the `identifier.table` assertion 
cannot tell where the name came from) and `hoodie.database.name` is always 
`default`, so the `dbName = None` branch of `buildCatalogTable` is never 
reached. The PR's actual scenario -- a 
`df.write.format("hudi").option(TBL_NAME, ...).save(path)` table never 
registered -- exercises both. Not blocking. Could one arm write the table 
through the DataFrame writer with a name that differs from the directory?



##########
hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/analysis/HoodieAnalysis.scala:
##########
@@ -639,3 +643,70 @@ case class HoodiePostAnalysisRule(sparkSession: 
SparkSession) extends Rule[Logic
     }
   }
 }
+
+/**
+ * Stamps a synthesized [[CatalogTable]] (table name, base path, schema) onto 
path-based
+ * Hudi reads whose underlying file index is incremental or CDC. Without it, 
lineage and
+ * governance tooling sees `LogicalRelation.catalogTable = None` and falls 
back to the
+ * relation's class name as the dataset identifier -- useless for tracking 
which table
+ * an incremental query came from.
+ *
+ * Scope is intentionally limited to incremental and CDC reads:
+ *  - Catalog-registered reads already have `catalogTable` populated.
+ *  - Path-based snapshot reads have a working file-path-based fallback in 
existing
+ *    lineage tooling; changing their behavior is a separate decision.
+ */
+object HoodieIncrementalRelationIdentifier extends Rule[LogicalPlan] {
+  override def apply(plan: LogicalPlan): LogicalPlan =
+    AnalysisHelper.allowInvokingTransformsInAnalyzer {
+      plan transform {
+        // Type pattern + nested match avoids destructuring `LogicalRelation`, 
whose
+        // case-class arity differs between Spark 3.x (4 args) and Spark 4.x 
(5 args). This
+        // rule lives in `hudi-spark`, which is compiled against every 
supported profile.
+        case lr: LogicalRelation if lr.catalogTable.isEmpty =>
+          lr.relation match {
+            case fsRelation: HadoopFsRelation =>
+              incrementalOrCDCIndex(fsRelation.location)
+                .flatMap(index => buildCatalogTable(index.metaClient, 
lr.schema))
+                .map(catalogTable => lr.copy(catalogTable = 
Some(catalogTable)))
+                .getOrElse(lr)
+            case _ => lr
+          }
+      }
+    }
+
+  /**
+   * Narrows a [[FileIndex]] to the incremental/CDC Hudi indexes this rule 
handles, or
+   * `None` for anything else. Both are [[HoodieFileIndex]] subclasses, so the 
widening is
+   * checked by the compiler rather than by an `asInstanceOf` that a future 
third index
+   * type could silently break.
+   */
+  private def incrementalOrCDCIndex(location: FileIndex): 
Option[HoodieFileIndex] =
+    location match {
+      case index: HoodieIncrementalFileIndex => Some(index)
+      case index: HoodieCDCFileIndex => Some(index)
+      case _ => None
+    }
+
+  private def buildCatalogTable(
+      metaClient: HoodieTableMetaClient,
+      schema: StructType): Option[CatalogTable] = {
+    val tableConfig = metaClient.getTableConfig
+    // `hoodie.table.name` is required for a valid Hudi table, but if it is 
somehow unset
+    // leave `catalogTable` as `None` -- the pre-existing behavior -- rather 
than synthesize
+    // a `TableIdentifier(null)` that would surface downstream as a garbage 
dataset name.
+    Option(tableConfig.getTableName).filter(_.nonEmpty).map { tableName =>
+      // Falls back to Spark's `default` database when `hoodie.database.name` 
is unset --
+      // matches existing path-based DataFrame read behavior.
+      val dbName = Option(tableConfig.getDatabaseName).filter(_.nonEmpty)
+      CatalogTable(
+        identifier = TableIdentifier(tableName, dbName),
+        tableType = CatalogTableType.EXTERNAL,
+        storage = CatalogStorageFormat.empty.copy(
+          locationUri = Some(metaClient.getBasePath.toUri)),
+        schema = schema,
+        provider = Some("hudi")

Review Comment:
   Precedent worth having on this thread: the identifier form of 
`hudi_table_changes` has attached a Hudi-provider `CatalogTable` to 
`HoodieIncrementalFileIndex` / `HoodieCDCFileIndex` relations since #8729 
(`243098c39aff`, Jun 2023), under test in `TestHoodieTableValuedFunction` 
(`isTableId`), with no follow-up bug in that time. So the shape itself is not 
new; the negative test would only pin the `View` block.



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