shangxinli commented on code in PR #18665:
URL: https://github.com/apache/hudi/pull/18665#discussion_r4371338259


##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/dml/schema/TestBlobDataType.scala:
##########
@@ -282,4 +283,346 @@ class TestBlobDataType extends HoodieSparkSqlTestBase {
         "Expected at least one .clean instant on the timeline after 
compaction")
     })
   }
+
+  test("Test Query Log Only MOR Table With BLOB INLINE column triggers 
compaction (Lance)") {
+    assume(System.getProperty("lance.skip.tests") != "true",
+      "Lance tests disabled via -Dlance.skip.tests=true")
+    // Covers log-only MOR ingest of INLINE blobs on a Lance base format, the 
inline
+    // compaction trigger, and the post-compaction inline read shape.
+
+    withRecordType()(withTempDir { tmp =>
+      val tablePath = new File(tmp, "hudi").getCanonicalPath
+      val tableName = generateTableName
+      spark.sql(
+        s"""
+           |create table $tableName (
+           |  id int,
+           |  data blob,
+           |  ts long
+           |) using hudi
+           | location '$tablePath'
+           | tblproperties (
+           |  primaryKey = 'id',
+           |  type = 'mor',
+           |  preCombineField = 'ts',
+           |  hoodie.index.type = 'INMEMORY',
+           |  hoodie.compact.inline = 'true',
+           |  hoodie.clean.commits.retained = '1',
+           |  'hoodie.table.base.file.format' = 'LANCE'
+           | )
+       """.stripMargin)
+
+      // Verify the LANCE config was actually persisted to hoodie.properties.
+      assertResult(HoodieFileFormat.LANCE)(
+        createMetaClient(spark, tablePath).getTableConfig.getBaseFileFormat)
+
+      spark.sql(s"insert into $tableName values (1, 
${inlineBlobLiteral("01")}, 1000)")
+      spark.sql(s"insert into $tableName values (2, 
${inlineBlobLiteral("02")}, 1000)")
+      spark.sql(s"insert into $tableName values (3, 
${inlineBlobLiteral("03")}, 1000)")
+      assertResult(true)(DataSourceTestUtils.isLogFileOnly(tablePath))
+
+      spark.sql(
+        s"""
+           |merge into $tableName h0
+           |using (
+           |  select 1 as id, ${inlineBlobLiteral("11")} as data, 1001L as ts
+           |) s0
+           | on h0.id = s0.id
+           | when matched then update set *
+           |""".stripMargin)
+      assertResult(true)(DataSourceTestUtils.isLogFileOnly(tablePath))
+
+      spark.sql(
+        s"""
+           |merge into $tableName h0
+           |using (
+           |  select 4 as id, ${inlineBlobLiteral("04")} as data, 1000L as ts
+           |) s0
+           | on h0.id = s0.id
+           | when not matched then insert *
+           |""".stripMargin)
+
+      assertResult(false)(DataSourceTestUtils.isLogFileOnly(tablePath))
+
+      val bytesById = spark.sql(
+        s"select id, read_blob(data) as bytes from $tableName order by id"
+      ).collect().map(r => r.getInt(0) -> r.getAs[Array[Byte]]("bytes")).toMap
+      assertResult(4)(bytesById.size)
+      assert(bytesById(1).sameElements(Array(0x11.toByte)))
+      assert(bytesById(2).sameElements(Array(0x02.toByte)))
+      assert(bytesById(3).sameElements(Array(0x03.toByte)))
+      assert(bytesById(4).sameElements(Array(0x04.toByte)))
+
+      spark.sql(s"select id, data from $tableName order by 
id").collect().foreach { row =>
+        val blob = row.getStruct(1)
+        
assertResult("INLINE")(blob.getString(blob.fieldIndex(HoodieSchema.Blob.TYPE)))
+        
assert(!blob.isNullAt(blob.fieldIndex(HoodieSchema.Blob.INLINE_DATA_FIELD)))
+        // Lance materializes the `reference` struct as non-null with all-null 
leaves for
+        // INLINE rows (vs. a null struct on Parquet). `type` is the canonical 
INLINE
+        // discriminator per RFC-100; tolerate either shape and just check the 
leaves.
+        val refIdx = blob.fieldIndex(HoodieSchema.Blob.EXTERNAL_REFERENCE)
+        if (!blob.isNullAt(refIdx)) {
+          val ref = blob.getStruct(refIdx)
+          
assert(ref.isNullAt(ref.fieldIndex(HoodieSchema.Blob.EXTERNAL_REFERENCE_PATH)))
+        }
+      }
+
+      val blobField = spark.table(tableName).schema.find(_.name == "data").get
+      assert(blobField.metadata.contains(HoodieSchema.TYPE_METADATA_FIELD),
+        s"Expected BLOB type metadata on data field after compaction, " +
+          s"got: ${blobField.metadata}")
+      assertResult(HoodieSchemaType.BLOB.name())(
+        blobField.metadata.getString(HoodieSchema.TYPE_METADATA_FIELD))
+
+      // 6th commit drives an auto-clean that retires the now-superseded 
log-only slice.
+      spark.sql(
+        s"""
+           |merge into $tableName h0
+           |using (
+           |  select 2 as id, ${inlineBlobLiteral("22")} as data, 1002L as ts
+           |) s0
+           | on h0.id = s0.id
+           | when matched then update set *
+           |""".stripMargin)
+      val updatedBytesById = spark.sql(
+        s"select id, read_blob(data) as bytes from $tableName order by id"
+      ).collect().map(r => r.getInt(0) -> r.getAs[Array[Byte]]("bytes")).toMap
+      assert(updatedBytesById(2).sameElements(Array(0x22.toByte)))
+
+      val metaClient = createMetaClient(spark, tablePath)
+      metaClient.reloadActiveTimeline()
+      assert(metaClient.getActiveTimeline.getCleanerTimeline.countInstants() > 
0,
+        "Expected at least one .clean instant on the timeline after 
compaction")
+    })
+  }
+
+  test("Test Query Log Only MOR Table With BLOB OUT_OF_LINE column triggers 
compaction (Lance)") {
+    assume(System.getProperty("lance.skip.tests") != "true",
+      "Lance tests disabled via -Dlance.skip.tests=true")
+    // Lance writer has no BLOB handling today (RFC-100 Phase 2). Expected to 
fail

Review Comment:
   The comment says this is expected to fail until Lance gets BLOB support, but 
the test body below runs a full set of unconditional assertions on the written 
and compacted data with no exception handling, so either the comment is stale 
or this test is currently red.



##########
hudi-spark-datasource/hudi-spark3-common/src/main/scala/org/apache/spark/sql/hudi/command/InsertIntoHoodieTableCommand.scala:
##########
@@ -168,7 +168,13 @@ object InsertIntoHoodieTableCommand extends Logging with 
ProvidesHoodieConfig wi
     //       since such columns wouldn't be otherwise specified w/in the query 
itself and therefore couldn't be matched
     //       positionally for example
     val expectedQueryColumns = 
catalogTable.tableSchemaWithoutMetaFields.filterNot(f => 
staticPartitionValues.contains(f.name))
-    val coercedQueryOutput = 
coerceQueryOutputColumns(StructType(expectedQueryColumns), cleanedQuery, 
catalogTable, conf)
+    // Canonicalize partial BLOB struct expressions before TableOutputResolver 
runs,
+    // so OUT_OF_LINE-style `{type, reference}` literals (which Spark's 
positional
+    // struct check would otherwise reject for not aligning with the canonical
+    // `{type, data, reference}` layout) are accepted on the SQL ingest path.
+    val canonicalizedQuery = 
InsertBlobCanonicalizer.padPartialBlobsAgainstTarget(

Review Comment:
   padPartialBlobsAgainstTarget only gets wired into the INSERT INTO path here. 
MergeIntoHoodieTableCommand resolves its insert output with its own 
castIfNeeded logic and never calls this, so a partial out-of-line blob struct 
written through a merge insert clause skips canonicalization entirely.



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