This is an automated email from the ASF dual-hosted git repository.

hongze pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/gluten.git


The following commit(s) were added to refs/heads/main by this push:
     new 41a6fd83c1 [GLUTEN-10215][VL] Delta 4 UT: sync test infra from Delta 
3.3 suites (#11938)
41a6fd83c1 is described below

commit 41a6fd83c1a65e863e436f31a5dc0a1a1a564c01
Author: Mohammad Linjawi <[email protected]>
AuthorDate: Thu Apr 16 06:33:35 2026 +0300

    [GLUTEN-10215][VL] Delta 4 UT: sync test infra from Delta 3.3 suites 
(#11938)
    
    Co-authored-by: Mohammad Linjawi <[email protected]>
---
 .../spark/sql/delta/DeletionVectorsTestUtils.scala | 367 ++++++++++++
 .../sql/delta/DeltaColumnMappingTestUtils.scala    | 487 ++++++++++++++++
 ...DeltaExcludedBySparkVersionTestMixinShims.scala |  45 ++
 .../apache/spark/sql/delta/DeltaMetricsUtils.scala | 210 +++++++
 .../apache/spark/sql/delta/DeltaTestUtils.scala    | 635 +++++++++++++++++++++
 .../apache/spark/sql/delta/FakeFileSystem.scala    |  32 ++
 .../test/DeltaColumnMappingSelectedTestMixin.scala |  76 +++
 .../sql/delta/test/DeltaExcludedTestMixin.scala    |  40 ++
 .../spark/sql/delta/test/DeltaSQLCommandTest.scala |  57 ++
 .../spark/sql/delta/test/DeltaSQLTestUtils.scala   |  79 +++
 .../spark/sql/delta/test/DeltaTestImplicits.scala  | 204 +++++++
 .../spark/sql/delta/test/ScanReportHelper.scala    | 165 ++++++
 12 files changed, 2397 insertions(+)

diff --git 
a/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeletionVectorsTestUtils.scala
 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeletionVectorsTestUtils.scala
new file mode 100644
index 0000000000..fdfa516bac
--- /dev/null
+++ 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeletionVectorsTestUtils.scala
@@ -0,0 +1,367 @@
+/*
+ * 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.delta
+
+import org.apache.spark.sql.{DataFrame, QueryTest, RuntimeConfig, SparkSession}
+import org.apache.spark.sql.delta.DeltaOperations.Truncate
+import org.apache.spark.sql.delta.actions.{Action, AddFile, 
DeletionVectorDescriptor, RemoveFile}
+import org.apache.spark.sql.delta.deletionvectors.{RoaringBitmapArray, 
RoaringBitmapArrayFormat}
+import org.apache.spark.sql.delta.sources.DeltaSQLConf
+import org.apache.spark.sql.delta.storage.dv.DeletionVectorStore
+import org.apache.spark.sql.delta.test.DeltaSQLTestUtils
+import org.apache.spark.sql.delta.util.PathWithFileSystem
+import org.apache.spark.sql.functions.{col, lit}
+import org.apache.spark.sql.test.SharedSparkSession
+
+import org.apache.commons.io.FileUtils
+import org.apache.hadoop.fs.Path
+
+import java.io.File
+import java.util.UUID
+
+// spotless:off
+/** Collection of test utilities related with persistent Deletion Vectors. */
+trait DeletionVectorsTestUtils extends QueryTest with SharedSparkSession with 
DeltaSQLTestUtils {
+
+  def enableDeletionVectors(
+      spark: SparkSession,
+      delete: Boolean = false,
+      update: Boolean = false,
+      merge: Boolean = false): Unit = {
+    val global = delete || update || merge
+    spark.conf
+      
.set(DeltaConfigs.ENABLE_DELETION_VECTORS_CREATION.defaultTablePropertyKey, 
global.toString)
+    spark.conf.set(DeltaSQLConf.DELETE_USE_PERSISTENT_DELETION_VECTORS.key, 
delete.toString)
+    spark.conf.set(DeltaSQLConf.UPDATE_USE_PERSISTENT_DELETION_VECTORS.key, 
update.toString)
+    spark.conf.set(DeltaSQLConf.MERGE_USE_PERSISTENT_DELETION_VECTORS.key, 
merge.toString)
+  }
+
+  def enableDeletionVectorsForAllSupportedOperations(spark: SparkSession): 
Unit =
+    enableDeletionVectors(spark, delete = true, update = true)
+
+  def testWithDVs(testName: String, testTags: org.scalatest.Tag*)(thunk: => 
Unit): Unit = {
+    test(testName, testTags : _*) {
+      withDeletionVectorsEnabled() {
+        thunk
+      }
+    }
+  }
+
+  /** Run a thunk with Deletion Vectors enabled/disabled. */
+  def withDeletionVectorsEnabled(enabled: Boolean = true)(thunk: => Unit): 
Unit = {
+    val enabledStr = enabled.toString
+    withSQLConf(
+      DeltaConfigs.ENABLE_DELETION_VECTORS_CREATION.defaultTablePropertyKey -> 
enabledStr,
+      DeltaSQLConf.DELETE_USE_PERSISTENT_DELETION_VECTORS.key -> enabledStr,
+      DeltaSQLConf.UPDATE_USE_PERSISTENT_DELETION_VECTORS.key -> enabledStr,
+      DeltaSQLConf.MERGE_USE_PERSISTENT_DELETION_VECTORS.key -> enabledStr) {
+      thunk
+    }
+  }
+
+  /** Helper to run 'fn' with a temporary Delta table. */
+  def withTempDeltaTable(
+      dataDF: DataFrame,
+      partitionBy: Seq[String] = Seq.empty,
+      enableDVs: Boolean = true,
+      conf: Seq[(String, String)] = Nil)
+      (fn: (() => io.delta.tables.DeltaTable, DeltaLog) => Unit): Unit = {
+    withTempPath { path =>
+      val tablePath = new Path(path.getAbsolutePath)
+      withSQLConf(conf: _*) {
+        dataDF.write
+          .option(DeltaConfigs.ENABLE_DELETION_VECTORS_CREATION.key, 
enableDVs.toString)
+          .partitionBy(partitionBy: _*)
+          .format("delta")
+          .save(tablePath.toString)
+      }
+      // DeltaTable hangs on to the DataFrame it is created with for the 
entire object lifetime.
+      // That means subsequent `targetTable.toDF` calls will return the same 
snapshot.
+      // The DV tests are generally written assuming `targetTable.toDF` would 
return a new snapshot.
+      // So create a function here instead of a n instance, so 
`targetTable().toDF`
+      // will actually provide a new snapshot.
+      val targetTable =
+        () => io.delta.tables.DeltaTable.forPath(tablePath.toString)
+      val targetLog = DeltaLog.forTable(spark, tablePath)
+      fn(targetTable, targetLog)
+    }
+  }
+
+  /** Create a temp path which contains special characters. */
+  override def withTempPath(f: File => Unit): Unit = {
+    super.withTempPath(prefix = "s p a r k %2a")(f)
+  }
+
+  /** Create a temp path which contains special characters. */
+  override protected def withTempDir(f: File => Unit): Unit = {
+    super.withTempDir(prefix = "s p a r k %2a")(f)
+  }
+
+  /** Helper that verifies whether a defined number of DVs exist */
+  def verifyDVsExist(targetLog: DeltaLog, filesWithDVsSize: Int): Unit = {
+    val filesWithDVs = getFilesWithDeletionVectors(targetLog)
+    assert(filesWithDVs.size === filesWithDVsSize)
+    assertDeletionVectorsExist(targetLog, filesWithDVs)
+  }
+
+  /** Returns all [[AddFile]] actions of a Delta table that contain Deletion 
Vectors. */
+  def getFilesWithDeletionVectors(log: DeltaLog): Seq[AddFile] =
+    log.update().allFiles.collect().filter(_.deletionVector != null).toSeq
+
+  /** Lists the Deletion Vectors files of a table. */
+  def listDeletionVectors(log: DeltaLog): Seq[File] = {
+    val dir = new File(log.dataPath.toUri.getPath)
+    dir.listFiles().filter(_.getName.startsWith(
+      DeletionVectorDescriptor.DELETION_VECTOR_FILE_NAME_CORE))
+  }
+
+  /** Helper to check that the Deletion Vectors of the provided file actions 
exist on disk. */
+  def assertDeletionVectorsExist(log: DeltaLog, filesWithDVs: Seq[AddFile]): 
Unit = {
+    val tablePath = new Path(log.dataPath.toUri.getPath)
+    for (file <- filesWithDVs) {
+      val dv = file.deletionVector
+      assert(dv != null)
+      assert(dv.isOnDisk && !dv.isInline)
+      assert(dv.offset.isDefined)
+
+      // Check that DV exists.
+      val dvPath = dv.absolutePath(tablePath)
+      assert(new File(dvPath.toString).exists(), s"DV not found $dvPath")
+
+      // Check that cardinality is correct.
+      val bitmap = newDVStore.read(dvPath, dv.offset.get, dv.sizeInBytes)
+      assert(dv.cardinality === bitmap.cardinality)
+    }
+  }
+
+  /** Enable persistent deletion vectors in new Delta tables. */
+  def enableDeletionVectorsInNewTables(conf: RuntimeConfig): Unit =
+    
conf.set(DeltaConfigs.ENABLE_DELETION_VECTORS_CREATION.defaultTablePropertyKey, 
"true")
+
+  /** Enable persistent Deletion Vectors in a Delta table. */
+  def enableDeletionVectorsInTable(tablePath: Path, enable: Boolean): Unit =
+    spark.sql(
+      s"""ALTER TABLE delta.`$tablePath`
+         |SET TBLPROPERTIES 
('${DeltaConfigs.ENABLE_DELETION_VECTORS_CREATION.key}' = '$enable')
+         |""".stripMargin)
+
+  /** Enable persistent Deletion Vectors in a Delta table. */
+  def enableDeletionVectorsInTable(deltaLog: DeltaLog, enable: Boolean = 
true): Unit =
+    enableDeletionVectorsInTable(deltaLog.dataPath, enable)
+
+  /** Enable persistent deletion vectors in new tables and DELETE DML 
commands. */
+  def enableDeletionVectors(conf: RuntimeConfig): Unit = {
+    enableDeletionVectorsInNewTables(conf)
+    conf.set(DeltaSQLConf.DELETE_USE_PERSISTENT_DELETION_VECTORS.key, "true")
+  }
+
+  // ======== HELPER METHODS TO WRITE DVs ==========
+  /** Helper method to remove the specified rows in the given file using DVs */
+  protected def removeRowsFromFileUsingDV(
+      log: DeltaLog,
+      addFile: AddFile,
+      rowIds: Seq[Long]): Seq[Action] = {
+    val dv = RoaringBitmapArray(rowIds: _*)
+    writeFileWithDV(log, addFile, dv)
+  }
+
+  /** Utility method to remove a ratio of rows from the given file */
+  protected def deleteRows(
+      log: DeltaLog, file: AddFile, approxPhyRows: Long, ratioOfRowsToDelete: 
Double): Unit = {
+    val numRowsToDelete =
+      Math.ceil(ratioOfRowsToDelete * 
file.numPhysicalRecords.getOrElse(approxPhyRows)).toInt
+    removeRowsFromFile(log, file, Seq.range(0, numRowsToDelete))
+  }
+
+  /** Utility method to remove the given rows from the given file using DVs */
+  protected def removeRowsFromFile(
+      log: DeltaLog, addFile: AddFile, rowIndexesToRemove: Seq[Long]): Unit = {
+    val txn = log.startTransaction(catalogTableOpt = None)
+    val actions = removeRowsFromFileUsingDV(log, addFile, rowIndexesToRemove)
+    txn.commit(actions, Truncate())
+  }
+
+  protected def getFileActionsInLastVersion(log: DeltaLog): (Seq[AddFile], 
Seq[RemoveFile]) = {
+    val version = log.update().version
+    val allFiles = log.getChanges(version).toSeq.head._2
+    val add = allFiles.collect { case a: AddFile => a }
+    val remove = allFiles.collect { case r: RemoveFile => r }
+    (add, remove)
+  }
+
+  protected def serializeRoaringBitmapArrayWithDefaultFormat(
+      dv: RoaringBitmapArray): Array[Byte] = {
+    val serializationFormat = RoaringBitmapArrayFormat.Portable
+    dv.serializeAsByteArray(serializationFormat)
+  }
+
+  /**
+   * Produce a new [[AddFile]] that will store `dv` in the log using default 
settings for choosing
+   * inline or on-disk storage.
+   *
+   * Also returns the corresponding [[RemoveFile]] action for `currentFile`.
+   *
+   * TODO: Always on-disk for now. Inline support comes later.
+   */
+  protected def writeFileWithDV(
+      log: DeltaLog,
+      currentFile: AddFile,
+      dv: RoaringBitmapArray): Seq[Action] = {
+    writeFileWithDVOnDisk(log, currentFile, dv)
+  }
+
+  /** Name of the partition column used by [[createTestDF()]]. */
+  val PARTITION_COL = "partitionColumn"
+
+  def createTestDF(
+    start: Long,
+    end: Long,
+    numFiles: Int,
+    partitionColumn: Option[Int] = None): DataFrame = {
+    val df = spark.range(start, end, 1, numFiles).withColumn("v", col("id"))
+    if (partitionColumn.isEmpty) {
+      df
+    } else {
+      df.withColumn(PARTITION_COL, lit(partitionColumn.get))
+    }
+  }
+
+  /**
+   * Produce a new [[AddFile]] that will reference the `dv` in the log while 
storing it on-disk.
+   *
+   * Also returns the corresponding [[RemoveFile]] action for `currentFile`.
+   */
+  protected def writeFileWithDVOnDisk(
+      log: DeltaLog,
+      currentFile: AddFile,
+      dv: RoaringBitmapArray): Seq[Action] = writeFilesWithDVsOnDisk(log, 
Seq((currentFile, dv)))
+
+  protected def withDVWriter[T](
+      log: DeltaLog,
+      dvFileID: UUID)(fn: DeletionVectorStore.Writer => T): T = {
+    val dvStore = newDVStore
+    // scalastyle:off deltahadoopconfiguration
+    val conf = spark.sessionState.newHadoopConf()
+    // scalastyle:on deltahadoopconfiguration
+    val tableWithFS = PathWithFileSystem.withConf(log.dataPath, conf)
+    val dvPath =
+      
DeletionVectorStore.assembleDeletionVectorPathWithFileSystem(tableWithFS, 
dvFileID)
+    val writer = dvStore.createWriter(dvPath)
+    try {
+      fn(writer)
+    } finally {
+      writer.close()
+    }
+  }
+
+  /**
+   * Produce new [[AddFile]] actions that will reference associated DVs in the 
log while storing
+   * all DVs in the same file on-disk.
+   *
+   * Also returns the corresponding [[RemoveFile]] actions for the original 
file entries.
+   */
+  protected def writeFilesWithDVsOnDisk(
+      log: DeltaLog,
+      filesWithDVs: Seq[(AddFile, RoaringBitmapArray)]): Seq[Action] = {
+    val dvFileId = UUID.randomUUID()
+    withDVWriter(log, dvFileId) { writer =>
+      filesWithDVs.flatMap { case (currentFile, dv) =>
+        val range = 
writer.write(serializeRoaringBitmapArrayWithDefaultFormat(dv))
+        val dvData = DeletionVectorDescriptor.onDiskWithRelativePath(
+          id = dvFileId,
+          sizeInBytes = range.length,
+          cardinality = dv.cardinality,
+          offset = Some(range.offset))
+        val (add, remove) = currentFile.removeRows(
+          dvData,
+          updateStats = true
+        )
+        Seq(add, remove)
+      }
+    }
+  }
+
+  /**
+   * Removes the `numRowsToRemovePerFile` from each file via DV.
+   * Returns the total number of rows removed.
+   */
+  protected def removeRowsFromAllFilesInLog(
+      log: DeltaLog,
+      numRowsToRemovePerFile: Long): Long = {
+    var numFiles: Option[Int] = None
+    // This is needed to make the manual commit work correctly, since we are 
not actually
+    // running a command that produces metrics.
+    withSQLConf(DeltaSQLConf.DELTA_HISTORY_METRICS_ENABLED.key -> "false") {
+      val txn = log.startTransaction(catalogTableOpt = None)
+      val allAddFiles = txn.snapshot.allFiles.collect()
+      numFiles = Some(allAddFiles.length)
+      val bitmap = RoaringBitmapArray(0L until numRowsToRemovePerFile: _*)
+      val actions = allAddFiles.flatMap { file =>
+        if (file.numPhysicalRecords.isDefined) {
+          // Only when stats are enabled. Can't check when stats are disabled
+          assert(file.numPhysicalRecords.get > numRowsToRemovePerFile)
+        }
+        writeFileWithDV(log, file, bitmap)
+      }
+      txn.commit(actions, DeltaOperations.Delete(predicate = Seq.empty))
+    }
+    numFiles.get * numRowsToRemovePerFile
+  }
+
+  def newDVStore(): DeletionVectorStore = {
+    // scalastyle:off deltahadoopconfiguration
+    DeletionVectorStore.createInstance(spark.sessionState.newHadoopConf())
+    // scalastyle:on deltahadoopconfiguration
+  }
+
+  /**
+   * Updates an [[AddFile]] with a [[DeletionVectorDescriptor]].
+   */
+  protected def updateFileDV(
+      addFile: AddFile,
+      dvDescriptor: DeletionVectorDescriptor): (AddFile, RemoveFile) = {
+    addFile.removeRows(
+      dvDescriptor,
+      updateStats = true
+    )
+  }
+
+  /** Delete the DV file in the given [[AddFile]]. Assumes the [[AddFile]] has 
a valid DV. */
+  protected def deleteDVFile(tablePath: String, addFile: AddFile): Unit = {
+    assert(addFile.deletionVector != null)
+    val dvPath = addFile.deletionVector.absolutePath(new Path(tablePath))
+    FileUtils.delete(new File(dvPath.toString))
+  }
+
+  /**
+   * Creates a [[DeletionVectorDescriptor]] from an [[RoaringBitmapArray]]
+   */
+  protected def writeDV(
+      log: DeltaLog,
+      bitmapArray: RoaringBitmapArray): DeletionVectorDescriptor = {
+    val dvFileId = UUID.randomUUID()
+    withDVWriter(log, dvFileId) { writer =>
+      val range = 
writer.write(serializeRoaringBitmapArrayWithDefaultFormat(bitmapArray))
+      DeletionVectorDescriptor.onDiskWithRelativePath(
+        id = dvFileId,
+        sizeInBytes = range.length,
+        cardinality = bitmapArray.cardinality,
+        offset = Some(range.offset))
+    }
+  }
+}
+// spotless:on
diff --git 
a/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeltaColumnMappingTestUtils.scala
 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeltaColumnMappingTestUtils.scala
new file mode 100644
index 0000000000..68c47b42bb
--- /dev/null
+++ 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeltaColumnMappingTestUtils.scala
@@ -0,0 +1,487 @@
+/*
+ * 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.delta
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.{Column, Dataset}
+import org.apache.spark.sql.catalyst.TableIdentifier
+import org.apache.spark.sql.catalyst.analysis.UnresolvedAttribute
+import org.apache.spark.sql.catalyst.catalog.ExternalCatalogUtils
+import org.apache.spark.sql.catalyst.expressions.Attribute
+import org.apache.spark.sql.delta.actions.{Protocol, TableFeatureProtocolUtils}
+import org.apache.spark.sql.delta.schema.SchemaUtils
+import org.apache.spark.sql.delta.sources.DeltaSQLConf
+import org.apache.spark.sql.delta.test.DeltaColumnMappingSelectedTestMixin
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{AtomicType, StructField, StructType}
+
+import org.apache.hadoop.fs.Path
+
+import java.io.File
+
+import scala.collection.mutable
+
+// spotless:off
+trait DeltaColumnMappingTestUtilsBase extends SharedSparkSession {
+
+  import testImplicits._
+
+  protected def columnMappingMode: String = NoMapping.name
+
+  private val PHYSICAL_NAME_REGEX =
+    
"col-[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}".r
+
+  implicit class PhysicalNameString(s: String) {
+    def phy(deltaLog: DeltaLog): String = {
+      PHYSICAL_NAME_REGEX
+        .findFirstIn(s)
+        .getOrElse(getPhysicalName(s, deltaLog))
+    }
+  }
+
+  protected def columnMappingEnabled: Boolean = {
+    columnMappingModeString != "none"
+  }
+
+  protected def columnMappingModeString: String = {
+    
spark.conf.getOption(DeltaConfigs.COLUMN_MAPPING_MODE.defaultTablePropertyKey)
+      .getOrElse("none")
+  }
+
+  /**
+   * Check if two schemas are equal ignoring column mapping metadata
+   * @param schema1 Schema
+   * @param schema2 Schema
+   */
+  protected def assertEqual(schema1: StructType, schema2: StructType): Unit = {
+    if (columnMappingEnabled) {
+      assert(
+        DeltaColumnMapping.dropColumnMappingMetadata(schema1) ==
+        DeltaColumnMapping.dropColumnMappingMetadata(schema2)
+      )
+    } else {
+      assert(schema1 == schema2)
+    }
+  }
+
+  /**
+   * Check if two table configurations are equal ignoring column mapping 
metadata
+   * @param config1 Table config
+   * @param config2 Table config
+   */
+  protected def assertEqual(
+      config1: Map[String, String],
+      config2: Map[String, String]): Unit = {
+    if (columnMappingEnabled) {
+      assert(dropColumnMappingConfigurations(config1) == 
dropColumnMappingConfigurations(config2))
+    } else {
+      assert(config1 == config2)
+    }
+  }
+
+  /**
+   * Check if a partition with specific values exists.
+   * Handles both column mapped and non-mapped cases
+   * @param partCol Partition column name
+   * @param partValue Partition value
+   * @param deltaLog DeltaLog
+   */
+  protected def assertPartitionWithValueExists(
+      partCol: String,
+      partValue: String,
+      deltaLog: DeltaLog): Unit = {
+    assert(getPartitionFilePathsWithValue(partCol, partValue, 
deltaLog).nonEmpty)
+  }
+
+  /**
+   * Assert partition exists in an array of set of partition names/paths
+   * @param partCol Partition column name
+   * @param deltaLog Delta log
+   * @param inputFiles Input files to scan for DF
+   */
+  protected def assertPartitionExists(
+      partCol: String,
+      deltaLog: DeltaLog,
+      inputFiles: Array[String]): Unit = {
+    val physicalName = partCol.phy(deltaLog)
+    val allFiles = deltaLog.update().allFiles.collect()
+    // NOTE: inputFiles are *not* URL-encoded.
+    val filesWithPartitions = inputFiles.map { f =>
+      allFiles.filter { af =>
+        f.contains(af.toPath.toString)
+      }.flatMap(_.partitionValues.keys).toSet
+    }
+    assert(filesWithPartitions.forall(p => p.count(_ == physicalName) > 0))
+    // for non-column mapped mode, we can check the file paths as well
+    if (!columnMappingEnabled) {
+      assert(inputFiles.forall(path => path.contains(s"$physicalName=")),
+          s"${inputFiles.toSeq.mkString("\n")}\ndidn't contain partition 
columns $physicalName")
+    }
+  }
+
+  /**
+   * Load Deltalog from path
+   * @param pathOrIdentifier Location
+   * @param isIdentifier Whether the previous argument is a metastore 
identifier
+   * @return
+   */
+  protected def loadDeltaLog(pathOrIdentifier: String, isIdentifier: Boolean = 
false): DeltaLog = {
+    if (isIdentifier) {
+      DeltaLog.forTable(spark, TableIdentifier(pathOrIdentifier))
+    } else {
+      DeltaLog.forTable(spark, pathOrIdentifier)
+    }
+  }
+
+  /**
+   * Convert a (nested) column string to sequence of name parts
+   * @param col Column string
+   * @return Sequence of parts
+   */
+  protected def columnNameToParts(col: String): Seq[String] = {
+    UnresolvedAttribute.parseAttributeName(col)
+  }
+
+  /**
+   * Get partition file paths for a specific partition value
+   * @param partCol Logical or physical partition name
+   * @param partValue Partition value
+   * @param deltaLog DeltaLog
+   * @return List of paths
+   */
+  protected def getPartitionFilePathsWithValue(
+      partCol: String,
+      partValue: String,
+      deltaLog: DeltaLog): Array[String] = {
+    getPartitionFilePaths(partCol, deltaLog).getOrElse(partValue, Array.empty)
+  }
+
+  /**
+   * Get the partition value for null
+   */
+  protected def nullPartitionValue: String = {
+    if (columnMappingEnabled) {
+      null
+    } else {
+      ExternalCatalogUtils.DEFAULT_PARTITION_NAME
+    }
+  }
+
+  /**
+   * Get partition file paths grouped by partition value
+   * @param partCol Logical or physical partition name
+   * @param deltaLog DeltaLog
+   * @return Partition value to paths
+   */
+  protected def getPartitionFilePaths(
+      partCol: String,
+      deltaLog: DeltaLog): Map[String, Array[String]] = {
+    if (columnMappingEnabled) {
+      val colName = partCol.phy(deltaLog)
+      deltaLog.update().allFiles.collect()
+        .groupBy(_.partitionValues(colName))
+        .mapValues(_.map(deltaLog.dataPath.toUri.getPath + "/" + _.path)).toMap
+    } else {
+      val partColEscaped = s"${ExternalCatalogUtils.escapePathName(partCol)}"
+      val dataPath = new File(deltaLog.dataPath.toUri.getPath)
+      dataPath.listFiles().filter(_.getName.startsWith(s"$partColEscaped="))
+        .groupBy(_.getName.split("=").last).mapValues(_.map(_.getPath)).toMap
+    }
+  }
+
+  /**
+   * Group a list of input file paths by partition key-value pair w.r.t. delta 
log
+   * @param inputFiles Input file paths
+   * @param deltaLog Delta log
+   * @return A mapped array each with the corresponding partition keys
+   */
+  protected def groupInputFilesByPartition(
+      inputFiles: Array[String],
+      deltaLog: DeltaLog): Map[(String, String), Array[String]] = {
+    if (columnMappingEnabled) {
+      val allFiles = deltaLog.update().allFiles.collect()
+      val grouped = inputFiles.flatMap { f =>
+        allFiles.find {
+          af => f.contains(af.toPath.toString)
+        }.head.partitionValues.map(entry => (f, entry))
+      }.groupBy(_._2)
+      grouped.mapValues(_.map(_._1)).toMap
+    } else {
+      inputFiles.groupBy(p => {
+        val nameParts = new Path(p).getParent.getName.split("=")
+        (nameParts(0), nameParts(1))
+      })
+    }
+  }
+
+  /**
+   * Drop column mapping configurations from Map
+   * @param configuration Table configuration
+   * @return Configuration
+   */
+  protected def dropColumnMappingConfigurations(
+      configuration: Map[String, String]): Map[String, String] = {
+    configuration - DeltaConfigs.COLUMN_MAPPING_MODE.key - 
DeltaConfigs.COLUMN_MAPPING_MAX_ID.key
+  }
+
+  /**
+   * Drop column mapping configurations from Dataset (e.g. sql("SHOW 
TBLPROPERTIES t1")
+   * @param configs Table configuration
+   * @return Configuration Dataset
+   */
+  protected def dropColumnMappingConfigurations(
+      configs: Dataset[(String, String)]): Dataset[(String, String)] = {
+    spark.createDataset(configs.collect().filter(p =>
+      !Seq(
+        DeltaConfigs.COLUMN_MAPPING_MAX_ID.key,
+        DeltaConfigs.COLUMN_MAPPING_MODE.key
+      ).contains(p._1)
+    ))
+  }
+
+  /** Return KV pairs of Protocol-related stuff for checking the result of 
DESCRIBE TABLE. */
+  protected def buildProtocolProps(snapshot: Snapshot): Seq[(String, String)] 
= {
+    val mergedConf =
+      DeltaConfigs.mergeGlobalConfigs(spark.sessionState.conf, 
snapshot.metadata.configuration)
+    val metadata = snapshot.metadata.copy(configuration = mergedConf)
+    var props = Seq(
+      (Protocol.MIN_READER_VERSION_PROP,
+        Protocol.forNewTable(spark, Some(metadata)).minReaderVersion.toString),
+      (Protocol.MIN_WRITER_VERSION_PROP,
+        Protocol.forNewTable(spark, Some(metadata)).minWriterVersion.toString))
+    if (snapshot.protocol.supportsReaderFeatures || 
snapshot.protocol.supportsWriterFeatures) {
+      props ++=
+        Protocol.minProtocolComponentsFromAutomaticallyEnabledFeatures(
+          spark, metadata, snapshot.protocol)
+          ._3
+          .map(f => (
+            s"${TableFeatureProtocolUtils.FEATURE_PROP_PREFIX}${f.name}",
+            TableFeatureProtocolUtils.FEATURE_PROP_SUPPORTED))
+    }
+    props
+  }
+
+  /**
+   * Convert (nested) column name string into physical name with reference 
from DeltaLog
+   * If target field does not have physical name, display name is returned
+   * @param col Logical column name
+   * @param deltaLog Reference DeltaLog
+   * @return Physical column name
+   */
+  protected def getPhysicalName(col: String, deltaLog: DeltaLog): String = {
+    val nameParts = UnresolvedAttribute.parseAttributeName(col)
+    val realSchema = deltaLog.update().schema
+    getPhysicalName(nameParts, realSchema)
+  }
+
+  protected def getPhysicalName(col: String, schema: StructType): String = {
+    val nameParts = UnresolvedAttribute.parseAttributeName(col)
+    getPhysicalName(nameParts, schema)
+  }
+
+  protected def getPhysicalName(nameParts: Seq[String], schema: StructType): 
String = {
+    SchemaUtils.findNestedFieldIgnoreCase(schema, nameParts, 
includeCollections = true)
+      .map(DeltaColumnMapping.getPhysicalName)
+      .get
+  }
+
+  protected def withColumnMappingConf(mode: String)(f: => Any): Any = {
+    withSQLConf(DeltaConfigs.COLUMN_MAPPING_MODE.defaultTablePropertyKey -> 
mode) {
+      f
+    }
+  }
+
+  protected def withMaxColumnIdConf(maxId: String)(f: => Any): Any = {
+    withSQLConf(DeltaConfigs.COLUMN_MAPPING_MAX_ID.defaultTablePropertyKey -> 
maxId) {
+      f
+    }
+  }
+
+  /**
+   * Gets the physical names of a path. This is used for converting column 
paths in stats schema,
+   * so it's ok to not support MapType and ArrayType.
+   */
+  def getPhysicalPathForStats(path: Seq[String], schema: StructType): 
Option[Seq[String]] = {
+    if (path.isEmpty) return Some(Seq.empty)
+    val field = schema.fields.find(_.name.equalsIgnoreCase(path.head))
+    field match {
+      case Some(f @ StructField(_, _: AtomicType, _, _ )) =>
+        if (path.size == 1) Some(Seq(DeltaColumnMapping.getPhysicalName(f))) 
else None
+      case Some(f @ StructField(_, st: StructType, _, _)) =>
+        val tail = getPhysicalPathForStats(path.tail, st)
+        tail.map(DeltaColumnMapping.getPhysicalName(f) +: _)
+      case _ =>
+        None
+    }
+  }
+
+   /**
+   * Convert (nested) column name string into physical name.
+   * Ignore parts of special paths starting with:
+   *  1. stats columns: minValues, maxValues, numRecords
+   *  2. stats df: stats_parsed
+   *  3. partition values: partitionValues_parsed, partitionValues
+   * @param col Logical column name (e.g. a.b.c)
+   * @param schema Reference schema with metadata
+   * @return Unresolved attribute with physical name paths
+   */
+  protected def convertColumnNameToAttributeWithPhysicalName(
+      col: String,
+      schema: StructType): UnresolvedAttribute = {
+    val parts = UnresolvedAttribute.parseAttributeName(col)
+    val shouldIgnoreFirstPart = Set(
+      "minValues",
+      "maxValues",
+      "numRecords",
+      Checkpoints.STRUCT_PARTITIONS_COL_NAME,
+      "partitionValues")
+    val shouldIgnoreSecondPart = Set(Checkpoints.STRUCT_STATS_COL_NAME, 
"stats")
+    val physical = if (shouldIgnoreFirstPart.contains(parts.head)) {
+      parts.head +: getPhysicalPathForStats(parts.tail, 
schema).getOrElse(parts.tail)
+    } else if (shouldIgnoreSecondPart.contains(parts.head)) {
+      parts.take(2) ++ getPhysicalPathForStats(parts.slice(2, parts.length), 
schema)
+          .getOrElse(parts.slice(2, parts.length))
+    } else {
+      getPhysicalPathForStats(parts, schema).getOrElse(parts)
+    }
+    UnresolvedAttribute(physical)
+  }
+
+  /**
+   * Convert a list of (nested) stats columns into physical name with 
reference from DeltaLog
+   * @param columns Logical columns
+   * @param deltaLog Reference DeltaLog
+   * @return Physical columns
+   */
+  protected def convertToPhysicalColumns(
+      columns: Seq[Column],
+      deltaLog: DeltaLog): Seq[Column] = {
+    val schema = deltaLog.update().schema
+    columns.map { col =>
+      val newExpr = col.expr.transform {
+        case a: Attribute =>
+          convertColumnNameToAttributeWithPhysicalName(a.name, schema)
+      }
+      Column(newExpr)
+    }
+  }
+
+  /**
+   * Standard CONVERT TO DELTA
+   * @param tableOrPath String
+   */
+  protected def convertToDelta(tableOrPath: String): Unit = {
+    sql(s"CONVERT TO DELTA $tableOrPath")
+  }
+
+  /**
+   * Force enable streaming read (with possible data loss) on column mapping 
enabled table with
+   * drop / rename schema changes.
+   */
+  protected def withStreamingReadOnColumnMappingTableEnabled(f: => Unit): Unit 
= {
+    if (columnMappingEnabled) {
+      withSQLConf(DeltaSQLConf
+        
.DELTA_STREAMING_UNSAFE_READ_ON_INCOMPATIBLE_COLUMN_MAPPING_SCHEMA_CHANGES.key 
-> "true") {
+        f
+      }
+    } else {
+      f
+    }
+  }
+
+}
+
+trait DeltaColumnMappingTestUtils extends DeltaColumnMappingTestUtilsBase
+
+/**
+ * Include this trait to enable Id column mapping mode for a suite
+ */
+trait DeltaColumnMappingEnableIdMode extends SharedSparkSession
+  with DeltaColumnMappingTestUtils
+  with DeltaColumnMappingSelectedTestMixin {
+
+  protected override def columnMappingMode: String = IdMapping.name
+
+  protected override def sparkConf: SparkConf =
+    
super.sparkConf.set(DeltaConfigs.COLUMN_MAPPING_MODE.defaultTablePropertyKey, 
"id")
+
+  /**
+   * CONVERT TO DELTA blocked in id mode
+   */
+  protected override def convertToDelta(tableOrPath: String): Unit =
+    throw DeltaErrors.convertToDeltaWithColumnMappingNotSupported(
+      DeltaColumnMappingMode(columnMappingModeString)
+    )
+}
+
+/**
+ * Include this trait to enable Name column mapping mode for a suite
+ */
+trait DeltaColumnMappingEnableNameMode extends SharedSparkSession
+  with DeltaColumnMappingTestUtils
+  with DeltaColumnMappingSelectedTestMixin {
+
+  protected override def columnMappingMode: String = NameMapping.name
+
+  protected override def sparkConf: SparkConf =
+    
super.sparkConf.set(DeltaConfigs.COLUMN_MAPPING_MODE.defaultTablePropertyKey, 
columnMappingMode)
+
+  /**
+   * CONVERT TO DELTA can be possible under name mode in tests
+   */
+  protected override def convertToDelta(tableOrPath: String): Unit = {
+    withColumnMappingConf("none") {
+      super.convertToDelta(tableOrPath)
+    }
+
+    val (deltaPath, deltaLog) =
+      if (tableOrPath.contains("parquet") && tableOrPath.contains("`")) {
+        // parquet.`PATH`
+        val plainPath = tableOrPath.split('.').last.drop(1).dropRight(1)
+        (s"delta.`$plainPath`", DeltaLog.forTable(spark, plainPath))
+      } else {
+        (tableOrPath, DeltaLog.forTable(spark, TableIdentifier(tableOrPath)))
+      }
+
+    val tableReaderVersion = 
deltaLog.unsafeVolatileSnapshot.protocol.minReaderVersion
+    val tableWriterVersion = 
deltaLog.unsafeVolatileSnapshot.protocol.minWriterVersion
+    val requiredReaderVersion = if (tableWriterVersion >=
+      TableFeatureProtocolUtils.TABLE_FEATURES_MIN_WRITER_VERSION) {
+      // If the writer version of the table supports table features, we need to
+      // bump the reader version to table features to enable column mapping.
+      TableFeatureProtocolUtils.TABLE_FEATURES_MIN_READER_VERSION
+    } else {
+      ColumnMappingTableFeature.minReaderVersion
+    }
+    val readerVersion = 
spark.conf.get(DeltaSQLConf.DELTA_PROTOCOL_DEFAULT_READER_VERSION).max(
+      requiredReaderVersion)
+    val writerVersion = 
spark.conf.get(DeltaSQLConf.DELTA_PROTOCOL_DEFAULT_WRITER_VERSION).max(
+      ColumnMappingTableFeature.minWriterVersion)
+
+    val properties = mutable.ListBuffer(DeltaConfigs.COLUMN_MAPPING_MODE.key 
-> "name")
+    if (tableReaderVersion < readerVersion) {
+      properties += DeltaConfigs.MIN_READER_VERSION.key -> 
readerVersion.toString
+    }
+    if (tableWriterVersion < writerVersion) {
+      properties += DeltaConfigs.MIN_WRITER_VERSION.key -> 
writerVersion.toString
+    }
+    val propertiesStr = properties.map(kv => s"'${kv._1}' = 
'${kv._2}'").mkString(", ")
+    sql(s"ALTER TABLE $deltaPath SET TBLPROPERTIES ($propertiesStr)")
+  }
+
+}
+// spotless:on
diff --git 
a/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeltaExcludedBySparkVersionTestMixinShims.scala
 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeltaExcludedBySparkVersionTestMixinShims.scala
new file mode 100644
index 0000000000..baa85d0489
--- /dev/null
+++ 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeltaExcludedBySparkVersionTestMixinShims.scala
@@ -0,0 +1,45 @@
+/*
+ * 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.delta
+
+import org.apache.spark.sql.QueryTest
+
+// spotless:off
+trait DeltaExcludedBySparkVersionTestMixinShims extends QueryTest {
+  /**
+   * Tests that are meant for Delta compiled against Spark Latest Release 
only. Executed since this
+   * is the Spark Latest Release shim.
+   */
+  protected def testSparkLatestOnly(
+      testName: String, testTags: org.scalatest.Tag*)
+      (testFun: => Any)
+      (implicit pos: org.scalactic.source.Position): Unit = {
+    test(testName, testTags: _*)(testFun)(pos)
+  }
+
+  /**
+   * Tests that are meant for Delta compiled against Spark Master Release 
only. Ignored since this
+   * is the Spark Latest Release shim.
+   */
+  protected def testSparkMasterOnly(
+      testName: String, testTags: org.scalatest.Tag*)
+      (testFun: => Any)
+      (implicit pos: org.scalactic.source.Position): Unit = {
+    ignore(testName, testTags: _*)(testFun)(pos)
+  }
+}
+// spotless:on
diff --git 
a/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeltaMetricsUtils.scala
 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeltaMetricsUtils.scala
new file mode 100644
index 0000000000..e1513f431d
--- /dev/null
+++ 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeltaMetricsUtils.scala
@@ -0,0 +1,210 @@
+/*
+ * 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.delta
+
+import org.apache.spark.sql.delta.actions.{AddCDCFile, AddFile, CommitInfo, 
RemoveFile}
+
+import org.scalatest.Assertions._
+
+import scala.collection.mutable.ArrayBuffer
+
+/** Various helper methods to for metric tests. */
+object DeltaMetricsUtils {
+
+  /**
+   * Get operation metrics of the last operation of a table.
+   *
+   * @param table
+   *   The Delta table to query
+   * @return
+   *   The operation metrics of the last command.
+   */
+  def getLastOperationMetrics(table: io.delta.tables.DeltaTable): Map[String, 
Long] = {
+    table
+      .history()
+      .select("operationMetrics")
+      .take(1)
+      .head
+      .getMap(0)
+      .asInstanceOf[Map[String, String]]
+      .mapValues(_.toLong)
+      .toMap
+  }
+
+  def getLastOperationMetrics(tableName: String): Map[String, Long] = {
+    getLastOperationMetrics(io.delta.tables.DeltaTable.forName(tableName))
+  }
+
+  /**
+   * Assert that metrics of a Delta operation have the expected values.
+   *
+   * @param expectedMetrics
+   *   The expected metrics the values of which to check.
+   * @param operationMetrics
+   *   The operation metrics that were collected from Delta log.
+   */
+  def checkOperationMetrics(
+      expectedMetrics: Map[String, Long],
+      operationMetrics: Map[String, Long]): Unit = {
+    val sep = System.lineSeparator() * 2
+    val failMessages = expectedMetrics
+      .flatMap {
+        case (metric, expectedValue) =>
+          // Check missing metrics.
+          var errMsg = if (!operationMetrics.contains(metric)) {
+            Some(s"""The recorded operation metrics does not contain metric: 
$metric"
+                    | ExpectedMetrics = $expectedMetrics
+                    | ActualMetrics = $operationMetrics
+                    |""".stripMargin)
+          } else {
+            None
+          }
+
+          // Check negative values.
+          errMsg = errMsg.orElse {
+            if (operationMetrics(metric) < 0) {
+              Some(s"Invalid non-positive value for metric $metric: 
${operationMetrics(metric)}")
+            } else {
+              None
+            }
+          }
+
+          // Check unexpected values.
+          errMsg = errMsg.orElse {
+            if (expectedValue != operationMetrics(metric)) {
+              Some(s"""The recorded metric for $metric does not equal the 
expected value.
+                      | Expected = ${expectedMetrics(metric)}
+                      | Actual = ${operationMetrics(metric)}
+                      | ExpectedMetrics = $expectedMetrics
+                      | ActualMetrics = $operationMetrics
+                      |""".stripMargin)
+            } else {
+              None
+            }
+          }
+          errMsg
+      }
+      .mkString(sep, sep, sep)
+      .trim
+    assert(failMessages.isEmpty)
+  }
+
+  /**
+   * Check that time metrics for a Delta operation are valid.
+   *
+   * @param operationMetrics
+   *   The collected operation metrics from the Delta log.
+   * @param expectedMetrics
+   *   The keys of the expected time metrics. Set to None to check for common 
time metrics.
+   */
+  def checkOperationTimeMetrics(
+      operationMetrics: Map[String, Long],
+      expectedMetrics: Set[String]): Unit = {
+    // Validate that all time metrics exist and have a non-negative value.
+    for (key <- expectedMetrics) {
+      assert(operationMetrics.contains(key), s"Missing operation metric $key")
+      val value: Long = operationMetrics(key)
+      assert(value >= 0, s"Invalid non-positive value for metric $key: $value")
+    }
+
+    // Validate that if 'executionTimeMs' exists, is larger than all other 
time metrics.
+    if (expectedMetrics.contains("executionTimeMs")) {
+      val executionTimeMs = operationMetrics("executionTimeMs")
+      val maxTimeMs =
+        operationMetrics.filterKeys(k => 
expectedMetrics.contains(k)).valuesIterator.max
+      assert(executionTimeMs == maxTimeMs)
+    }
+  }
+
+  /**
+   * Computes the expected operation metrics from the actions in a Delta 
commit.
+   *
+   * @param deltaLog
+   *   The Delta log of the table.
+   * @param version
+   *   The version of the commit.
+   * @return
+   *   A map with the expected operation metrics.
+   */
+  def getOperationMetricsFromCommitActions(deltaLog: DeltaLog, version: Long): 
Map[String, Long] = {
+    val (_, changes) = deltaLog.getChanges(version).next()
+    val commitInfo = changes.collect { case ci: CommitInfo => ci }.head
+    val operationName = commitInfo.operation
+
+    var filesAdded = ArrayBuffer.empty[AddFile]
+    var filesRemoved = ArrayBuffer.empty[RemoveFile]
+    val changeFilesAdded = ArrayBuffer.empty[AddCDCFile]
+    changes.foreach {
+      case a: AddFile => filesAdded.append(a)
+      case r: RemoveFile => filesRemoved.append(r)
+      case c: AddCDCFile => changeFilesAdded.append(c)
+      case _ => // Nothing
+    }
+
+    // Filter-out DV updates from files added and removed.
+    val pathsWithDvUpdate = filesAdded.map(_.path).toSet & 
filesRemoved.map(_.path).toSet
+    filesAdded = filesAdded.filter(a => !pathsWithDvUpdate.contains(a.path))
+    val numFilesAdded = filesAdded.size
+    val numBytesAdded = filesAdded.map(_.size).sum
+
+    filesRemoved = filesRemoved.filter(r => 
!pathsWithDvUpdate.contains(r.path))
+    val numFilesRemoved = filesRemoved.size
+    val numBytesRemoved = filesRemoved.map(_.size.getOrElse(0L)).sum
+
+    val numChangeFilesAdded = changeFilesAdded.size
+
+    operationName match {
+      case "MERGE" =>
+        Map(
+          "numTargetFilesAdded" -> numFilesAdded,
+          "numTargetFilesRemoved" -> numFilesRemoved,
+          "numTargetBytesAdded" -> numBytesAdded,
+          "numTargetBytesRemoved" -> numBytesRemoved,
+          "numTargetChangeFilesAdded" -> numChangeFilesAdded
+        )
+      case "UPDATE" | "DELETE" =>
+        Map(
+          "numAddedFiles" -> numFilesAdded,
+          "numRemovedFiles" -> numFilesRemoved,
+          "numAddedBytes" -> numBytesAdded,
+          "numRemovedBytes" -> numBytesRemoved,
+          "numAddedChangeFiles" -> numChangeFilesAdded
+        )
+      case _ =>
+        throw new UnsupportedOperationException(s"Unsupported operation: 
$operationName")
+    }
+  }
+
+  /**
+   * Checks the provided operation metrics against the actions in a Delta 
commit.
+   *
+   * @param deltaLog
+   *   The Delta log of the table.
+   * @param version
+   *   The version of the commit.
+   * @param operationMetrics
+   *   The operation metrics that were collected from Delta log.
+   */
+  def checkOperationMetricsAgainstCommitActions(
+      deltaLog: DeltaLog,
+      version: Long,
+      operationMetrics: Map[String, Long]): Unit = {
+    checkOperationMetrics(
+      expectedMetrics = getOperationMetricsFromCommitActions(deltaLog, 
version),
+      operationMetrics = operationMetrics)
+  }
+}
diff --git 
a/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeltaTestUtils.scala
 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeltaTestUtils.scala
new file mode 100644
index 0000000000..8b290e4db7
--- /dev/null
+++ 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/DeltaTestUtils.scala
@@ -0,0 +1,635 @@
+/*
+ * 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.delta
+
+import org.apache.spark.{SparkContext, SparkFunSuite, SparkThrowable}
+import org.apache.spark.scheduler.{JobFailed, SparkListener, 
SparkListenerJobEnd, SparkListenerJobStart}
+import org.apache.spark.sql.{AnalysisException, DataFrame, SparkSession}
+import org.apache.spark.sql.catalyst.TableIdentifier
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
+import org.apache.spark.sql.catalyst.util.{quietly, FailFastMode}
+import org.apache.spark.sql.delta.DeltaTestUtils.Plans
+import org.apache.spark.sql.delta.actions._
+import org.apache.spark.sql.delta.commands.cdc.CDCReader
+import org.apache.spark.sql.delta.sources.DeltaSQLConf
+import org.apache.spark.sql.delta.test.DeltaSQLTestUtils
+import org.apache.spark.sql.delta.util.FileNames
+import org.apache.spark.sql.execution._
+import org.apache.spark.sql.execution.aggregate.HashAggregateExec
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.StructType
+import org.apache.spark.sql.util.QueryExecutionListener
+import org.apache.spark.util.Utils
+
+import com.databricks.spark.util.{Log4jUsageLogger, UsageRecord}
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.scala.DefaultScalaModule
+import io.delta.tables.{DeltaTable => IODeltaTable}
+import org.apache.hadoop.fs.{FileStatus, Path}
+import org.scalatest.BeforeAndAfterEach
+
+import java.io.{BufferedReader, File, InputStreamReader}
+import java.nio.charset.StandardCharsets.UTF_8
+import java.util.Locale
+import java.util.concurrent.ConcurrentHashMap
+
+import scala.collection.JavaConverters._
+import scala.collection.concurrent
+import scala.reflect.ClassTag
+import scala.util.matching.Regex
+
+// spotless:off
+trait DeltaTestUtilsBase {
+  import DeltaTestUtils.TableIdentifierOrPath
+
+  final val BOOLEAN_DOMAIN: Seq[Boolean] = Seq(true, false)
+
+  class PlanCapturingListener() extends QueryExecutionListener {
+
+    private[this] var capturedPlans = List.empty[Plans]
+
+    def plans: Seq[Plans] = capturedPlans.reverse
+
+    override def onSuccess(funcName: String, qe: QueryExecution, durationNs: 
Long): Unit = {
+      capturedPlans ::= Plans(
+          qe.analyzed,
+          qe.optimizedPlan,
+          qe.sparkPlan,
+          qe.executedPlan)
+    }
+
+    override def onFailure(
+      funcName: String, qe: QueryExecution, error: Exception): Unit = {}
+  }
+
+  /**
+   * Run a thunk with physical plans for all queries captured and passed into 
a provided buffer.
+   */
+  def withLogicalPlansCaptured[T](
+      spark: SparkSession,
+      optimizedPlan: Boolean)(
+      thunk: => Unit): Seq[LogicalPlan] = {
+    val planCapturingListener = new PlanCapturingListener
+
+    spark.sparkContext.listenerBus.waitUntilEmpty(15000)
+    spark.listenerManager.register(planCapturingListener)
+    try {
+      thunk
+      spark.sparkContext.listenerBus.waitUntilEmpty(15000)
+      planCapturingListener.plans.map { plans =>
+        if (optimizedPlan) plans.optimized else plans.analyzed
+      }
+    } finally {
+      spark.listenerManager.unregister(planCapturingListener)
+    }
+  }
+
+  /**
+   * Run a thunk with physical plans for all queries captured and passed into 
a provided buffer.
+   */
+  def withPhysicalPlansCaptured[T](
+      spark: SparkSession)(
+      thunk: => Unit): Seq[SparkPlan] = {
+    val planCapturingListener = new PlanCapturingListener
+
+    spark.sparkContext.listenerBus.waitUntilEmpty(15000)
+    spark.listenerManager.register(planCapturingListener)
+    try {
+      thunk
+      spark.sparkContext.listenerBus.waitUntilEmpty(15000)
+      planCapturingListener.plans.map(_.sparkPlan)
+    } finally {
+      spark.listenerManager.unregister(planCapturingListener)
+    }
+  }
+
+  /**
+   * Run a thunk with logical and physical plans for all queries captured and 
passed
+   * into a provided buffer.
+   */
+  def withAllPlansCaptured[T](
+      spark: SparkSession)(
+      thunk: => Unit): Seq[Plans] = {
+    val planCapturingListener = new PlanCapturingListener
+
+    spark.sparkContext.listenerBus.waitUntilEmpty(15000)
+    spark.listenerManager.register(planCapturingListener)
+    try {
+      thunk
+      spark.sparkContext.listenerBus.waitUntilEmpty(15000)
+      planCapturingListener.plans
+    } finally {
+      spark.listenerManager.unregister(planCapturingListener)
+    }
+  }
+
+  def countSparkJobs(sc: SparkContext, f: => Unit): Int = {
+    val jobs: concurrent.Map[Int, Long] = new ConcurrentHashMap[Int, 
Long]().asScala
+    val listener = new SparkListener {
+      override def onJobStart(jobStart: SparkListenerJobStart): Unit = {
+        jobs.put(jobStart.jobId, jobStart.stageInfos.map(_.numTasks).sum)
+      }
+      override def onJobEnd(jobEnd: SparkListenerJobEnd): Unit = 
jobEnd.jobResult match {
+        case JobFailed(_) => jobs.remove(jobEnd.jobId)
+        case _ => // On success, do nothing.
+      }
+    }
+    sc.addSparkListener(listener)
+    try {
+      sc.listenerBus.waitUntilEmpty(15000)
+      f
+      sc.listenerBus.waitUntilEmpty(15000)
+    } finally {
+      sc.removeSparkListener(listener)
+    }
+    // Spark will always log a job start/end event even when the job does not 
launch any task.
+    jobs.values.count(_ > 0)
+  }
+
+  /** Filter `usageRecords` by the `opType` tag or field. */
+  def filterUsageRecords(usageRecords: Seq[UsageRecord], opType: String): 
Seq[UsageRecord] = {
+    usageRecords.filter { r =>
+      r.tags.get("opType").contains(opType) || 
r.opType.map(_.typeName).contains(opType)
+    }
+  }
+
+  def collectUsageLogs(opType: String)(f: => Unit): 
collection.Seq[UsageRecord] = {
+    Log4jUsageLogger.track(f).filter { r =>
+      r.metric == "tahoeEvent" &&
+        r.tags.get("opType").contains(opType)
+    }
+  }
+
+  /**
+   * Remove protocol and metadata fields from checksum file of json format
+   */
+  def removeProtocolAndMetadataFromChecksumFile(checksumFilePath : Path): Unit 
= {
+    // scalastyle:off deltahadoopconfiguration
+    val fs = checksumFilePath.getFileSystem(
+      SparkSession.getActiveSession.map(_.sessionState.newHadoopConf()).get
+    )
+    // scalastyle:on deltahadoopconfiguration
+    if (!fs.exists(checksumFilePath)) return
+    val stream = fs.open(checksumFilePath)
+    val reader = new BufferedReader(new InputStreamReader(stream, UTF_8))
+    val content = reader.readLine()
+    stream.close()
+    val mapper = new ObjectMapper()
+    mapper.registerModule(DefaultScalaModule)
+    val map = mapper.readValue(content, classOf[Map[String, String]])
+    val partialContent = 
mapper.writeValueAsString(map.-("protocol").-("metadata")) + "\n"
+    val output = fs.create(checksumFilePath, true)
+    output.write(partialContent.getBytes(UTF_8))
+    output.close()
+  }
+
+  protected def getfindTouchedFilesJobPlans(plans: Seq[Plans]): SparkPlan = {
+    // The expected plan for touched file computation is of the format below.
+    // The data column should be pruned from both leaves.
+    // HashAggregate(output=[count#3463L])
+    // +- HashAggregate(output=[count#3466L])
+    //   +- Project
+    //      +- Filter (isnotnull(count#3454L) AND (count#3454L > 1))
+    //         +- HashAggregate(output=[count#3454L])
+    //            +- HashAggregate(output=[_row_id_#3418L, sum#3468L])
+    //               +- Project [_row_id_#3418L, UDF(_file_name_#3422) AS 
one#3448]
+    //                  +- BroadcastHashJoin [id#3342L], [id#3412L], Inner, 
BuildLeft
+    //                     :- Project [id#3342L]
+    //                     :  +- Filter isnotnull(id#3342L)
+    //                     :     +- FileScan parquet [id#3342L,part#3343L]
+    //                     +- Filter isnotnull(id#3412L)
+    //                        +- Project [...]
+    //                           +- Project [...]
+    //                             +- FileScan parquet [id#3412L,part#3413L]
+    // Note: It can be RDDScanExec instead of FileScan if the source was 
materialized.
+    // We pick the first plan starting from FileScan and ending in 
HashAggregate as a
+    // stable heuristic for the one we want.
+    plans.map(_.executedPlan)
+      .filter {
+        case WholeStageCodegenExec(hash: HashAggregateExec) =>
+          hash.collectLeaves().size == 2 &&
+            hash.collectLeaves()
+              .forall { s =>
+                s.isInstanceOf[FileSourceScanExec] ||
+                  s.isInstanceOf[RDDScanExec]
+              }
+        case _ => false
+      }.head
+  }
+
+  /**
+   * Separate name- from path-based SQL table identifiers.
+   */
+  def getTableIdentifierOrPath(sqlIdentifier: String): TableIdentifierOrPath = 
{
+    // Match: delta.`path`[[ as] alias] or tahoe.`path`[[ as] alias]
+    val pathMatcher: Regex = raw"(?:delta|tahoe)\.`([^`]+)`(?:(?: as)? 
(.+))?".r
+    // Match: db.table[[ as] alias]
+    val qualifiedDbMatcher: Regex = raw"`?([^\.` ]+)`?\.`?([^\.` ]+)`?(?:(?: 
as)? (.+))?".r
+    // Match: table[[ as] alias]
+    val unqualifiedNameMatcher: Regex = raw"([^ ]+)(?:(?: as)? (.+))?".r
+    sqlIdentifier match {
+      case pathMatcher(path, alias) =>
+        TableIdentifierOrPath.Path(path, Option(alias))
+      case qualifiedDbMatcher(dbName, tableName, alias) =>
+        TableIdentifierOrPath.Identifier(TableIdentifier(tableName, 
Some(dbName)), Option(alias))
+      case unqualifiedNameMatcher(tableName, alias) =>
+        TableIdentifierOrPath.Identifier(TableIdentifier(tableName), 
Option(alias))
+    }
+  }
+
+  /**
+   * Produce a DeltaTable instance given a `TableIdentifierOrPath` instance.
+   */
+  def getDeltaTableForIdentifierOrPath(
+      spark: SparkSession,
+      identifierOrPath: TableIdentifierOrPath): IODeltaTable = {
+    identifierOrPath match {
+      case TableIdentifierOrPath.Identifier(id, optionalAlias) =>
+        val table = IODeltaTable.forName(spark, id.unquotedString)
+        optionalAlias.map(table.as(_)).getOrElse(table)
+      case TableIdentifierOrPath.Path(path, optionalAlias) =>
+        val table = IODeltaTable.forPath(spark, path)
+        optionalAlias.map(table.as(_)).getOrElse(table)
+    }
+  }
+
+  @deprecated("Use checkError() instead")
+  protected def errorContains(errMsg: String, str: String): Unit = {
+    
assert(errMsg.toLowerCase(Locale.ROOT).contains(str.toLowerCase(Locale.ROOT)))
+  }
+
+  /**
+   * Helper types to define the expected result of a test case.
+   * Either:
+   * - Success: include an expected value to check, e.g. expected schema or 
result as a DF or rows.
+   * - Failure: an exception is thrown and the caller passes a function to 
check that it matches an
+   *     expected error, typ. `checkError()` or `checkErrorMatchPVals()`.
+   */
+  sealed trait ExpectedResult[-T]
+  object ExpectedResult {
+    case class Success[T](expected: T) extends ExpectedResult[T]
+    case class Failure[T](checkError: SparkThrowable => Unit) extends 
ExpectedResult[T]
+  }
+
+  /** Utility method to check exception `e` is of type `E` or a cause of it is 
of type `E` */
+  def findIfResponsible[E <: Throwable: ClassTag](e: Throwable): Option[E] = e 
match {
+    case culprit: E => Some(culprit)
+    case _ =>
+      val children = Option(e.getCause).iterator ++ e.getSuppressed.iterator
+      children
+        .map(findIfResponsible[E](_))
+        .collectFirst { case Some(culprit) => culprit }
+  }
+
+  def verifyBackfilled(file: FileStatus): Unit = {
+    val unbackfilled = 
file.getPath.getName.matches(FileNames.uuidDeltaFileRegex.toString)
+    assert(!unbackfilled, s"File $file was not backfilled")
+  }
+
+  def verifyUnbackfilled(file: FileStatus): Unit = {
+    val unbackfilled = 
file.getPath.getName.matches(FileNames.uuidDeltaFileRegex.toString)
+    assert(unbackfilled, s"File $file was backfilled")
+  }
+}
+
+trait DeltaCheckpointTestUtils
+  extends DeltaTestUtilsBase { self: SparkFunSuite with SharedSparkSession =>
+
+  def testDifferentCheckpoints(testName: String, quiet: Boolean = false)
+      (f: (CheckpointPolicy.Policy, Option[V2Checkpoint.Format]) => Unit): 
Unit = {
+    test(s"$testName [Checkpoint V1]") {
+      def testFunc(): Unit = {
+        withSQLConf(DeltaConfigs.CHECKPOINT_POLICY.defaultTablePropertyKey ->
+          CheckpointPolicy.Classic.name) {
+          f(CheckpointPolicy.Classic, None)
+        }
+      }
+      if (quiet) quietly { testFunc() } else testFunc()
+    }
+    for (checkpointFormat <- V2Checkpoint.Format.ALL)
+    test(s"$testName [Checkpoint V2, format: ${checkpointFormat.name}]") {
+      def testFunc(): Unit = {
+        withSQLConf(
+          DeltaConfigs.CHECKPOINT_POLICY.defaultTablePropertyKey -> 
CheckpointPolicy.V2.name,
+          DeltaSQLConf.CHECKPOINT_V2_TOP_LEVEL_FILE_FORMAT.key -> 
checkpointFormat.name
+        ) {
+          f(CheckpointPolicy.V2, Some(checkpointFormat))
+        }
+      }
+      if (quiet) quietly { testFunc() } else testFunc()
+    }
+  }
+
+  /**
+   * Helper method to get the dataframe corresponding to the files which has 
the file actions for a
+   * given checkpoint.
+   */
+  def getCheckpointDfForFilesContainingFileActions(
+      log: DeltaLog,
+      checkpointFile: Path): DataFrame = {
+    val ci = CheckpointInstance.apply(checkpointFile)
+    val allCheckpointFiles = log
+        .listFrom(ci.version)
+        .filter(FileNames.isCheckpointFile)
+        .filter(f => CheckpointInstance(f.getPath) == ci)
+        .toSeq
+    val fileActionsFileIndex = ci.format match {
+      case CheckpointInstance.Format.V2 =>
+        val incompleteCheckpointProvider = ci.getCheckpointProvider(log, 
allCheckpointFiles)
+        val df = 
log.loadIndex(incompleteCheckpointProvider.topLevelFileIndex.get, 
Action.logSchema)
+        val sidecarFileStatuses = 
df.as[SingleAction].collect().map(_.unwrap).collect {
+          case sf: SidecarFile => sf
+        }.map(sf => sf.toFileStatus(log.logPath))
+        DeltaLogFileIndex(DeltaLogFileIndex.CHECKPOINT_FILE_FORMAT_PARQUET, 
sidecarFileStatuses)
+      case CheckpointInstance.Format.SINGLE | 
CheckpointInstance.Format.WITH_PARTS =>
+        DeltaLogFileIndex(DeltaLogFileIndex.CHECKPOINT_FILE_FORMAT_PARQUET,
+          allCheckpointFiles.toArray)
+      case _ =>
+        throw new Exception(s"Unexpected checkpoint format for file 
$checkpointFile")
+    }
+    fileActionsFileIndex.files
+      .map(fileStatus => spark.read.parquet(fileStatus.getPath.toString))
+      .reduce(_.union(_))
+  }
+}
+
+object DeltaTestUtils extends DeltaTestUtilsBase {
+
+  sealed trait TableIdentifierOrPath
+  object TableIdentifierOrPath {
+    case class Identifier(id: TableIdentifier, alias: Option[String])
+      extends TableIdentifierOrPath
+    case class Path(path: String, alias: Option[String]) extends 
TableIdentifierOrPath
+  }
+
+  case class Plans(
+      analyzed: LogicalPlan,
+      optimized: LogicalPlan,
+      sparkPlan: SparkPlan,
+      executedPlan: SparkPlan)
+
+  /**
+   * Creates an AddFile that can be used for tests where the exact parameters 
do not matter.
+   */
+  def createTestAddFile(
+      encodedPath: String = "foo",
+      partitionValues: Map[String, String] = Map.empty,
+      size: Long = 1L,
+      modificationTime: Long = 1L,
+      dataChange: Boolean = true,
+      stats: String = "{\"numRecords\": 1}"): AddFile = {
+    AddFile(encodedPath, partitionValues, size, modificationTime, dataChange, 
stats)
+  }
+
+  /**
+   * Extracts the table name and alias (if any) from the given string. 
Correctly handles whitespaces
+   * in table name but doesn't support whitespaces in alias.
+   */
+  def parseTableAndAlias(table: String): (String, Option[String]) = {
+    // Matches 'delta.`path` AS alias' (case insensitive).
+    val deltaPathWithAsAlias = raw"(?i)(delta\.`.+`)(?: AS) (\S+)".r
+    // Matches 'delta.`path` alias'.
+    val deltaPathWithAlias = raw"(delta\.`.+`) (\S+)".r
+    // Matches 'delta.`path`'.
+    val deltaPath = raw"(delta\.`.+`)".r
+    // Matches 'tableName AS alias' (case insensitive).
+    val tableNameWithAsAlias = raw"(?i)(.+)(?: AS) (\S+)".r
+    // Matches 'tableName alias'.
+    val tableNameWithAlias = raw"(.+) (.+)".r
+
+    table match {
+      case deltaPathWithAsAlias(tableName, alias) => tableName -> Some(alias)
+      case deltaPathWithAlias(tableName, alias) => tableName -> Some(alias)
+      case deltaPath(tableName) => tableName -> None
+      case tableNameWithAsAlias(tableName, alias) => tableName -> Some(alias)
+      case tableNameWithAlias(tableName, alias) => tableName -> Some(alias)
+      case tableName => tableName -> None
+    }
+  }
+
+  /**
+   * Implements an ordering where `x < y` iff both reader and writer versions 
of
+   * `x` are strictly less than those of `y`.
+   *
+   * Can be used to conveniently check that this relationship holds in 
tests/assertions
+   * without having to write out the conjunction of the two subconditions 
every time.
+   */
+  case object StrictProtocolOrdering extends PartialOrdering[Protocol] {
+    override def tryCompare(x: Protocol, y: Protocol): Option[Int] = {
+      if (x.minReaderVersion == y.minReaderVersion &&
+        x.minWriterVersion == y.minWriterVersion) {
+        Some(0)
+      } else if (x.minReaderVersion < y.minReaderVersion &&
+        x.minWriterVersion < y.minWriterVersion) {
+        Some(-1)
+      } else if (x.minReaderVersion > y.minReaderVersion &&
+        x.minWriterVersion > y.minWriterVersion) {
+        Some(1)
+      } else {
+        None
+      }
+    }
+
+    override def lteq(x: Protocol, y: Protocol): Boolean =
+      x.minReaderVersion <= y.minReaderVersion && x.minWriterVersion <= 
y.minWriterVersion
+
+    // Just a more readable version of `lteq`.
+    def fulfillsVersionRequirements(actual: Protocol, requirement: Protocol): 
Boolean =
+      lteq(requirement, actual)
+  }
+}
+
+trait DeltaTestUtilsForTempViews
+  extends SharedSparkSession
+  with DeltaTestUtilsBase {
+
+  def testWithTempView(testName: String)(testFun: Boolean => Any): Unit = {
+    Seq(true, false).foreach { isSQLTempView =>
+      val tempViewUsed = if (isSQLTempView) "SQL TempView" else "Dataset 
TempView"
+      test(s"$testName - $tempViewUsed") {
+        withTempView("v") {
+          testFun(isSQLTempView)
+        }
+      }
+    }
+  }
+
+  def testQuietlyWithTempView(testName: String)(testFun: Boolean => Any): Unit 
= {
+    Seq(true, false).foreach { isSQLTempView =>
+      val tempViewUsed = if (isSQLTempView) "SQL TempView" else "Dataset 
TempView"
+      testQuietly(s"$testName - $tempViewUsed") {
+        withTempView("v") {
+          testFun(isSQLTempView)
+        }
+      }
+    }
+  }
+
+  def createTempViewFromTable(
+      tableName: String,
+      isSQLTempView: Boolean,
+      format: Option[String] = None): Unit = {
+    if (isSQLTempView) {
+      sql(s"CREATE OR REPLACE TEMP VIEW v AS SELECT * from $tableName")
+    } else {
+      
spark.read.format(format.getOrElse("delta")).table(tableName).createOrReplaceTempView("v")
+    }
+  }
+
+  def createTempViewFromSelect(text: String, isSQLTempView: Boolean): Unit = {
+    if (isSQLTempView) {
+      sql(s"CREATE OR REPLACE TEMP VIEW v AS $text")
+    } else {
+      sql(text).createOrReplaceTempView("v")
+    }
+  }
+
+  def testErrorMessageAndClass(
+      isSQLTempView: Boolean,
+      ex: AnalysisException,
+      expectedErrorMsgForSQLTempView: String = null,
+      expectedErrorMsgForDataSetTempView: String = null,
+      expectedErrorClassForSQLTempView: String = null,
+      expectedErrorClassForDataSetTempView: String = null): Unit = {
+    if (isSQLTempView) {
+      if (expectedErrorMsgForSQLTempView != null) {
+        checkError(ex, expectedErrorMsgForSQLTempView)
+      }
+      if (expectedErrorClassForSQLTempView != null) {
+        assert(ex.getErrorClass == expectedErrorClassForSQLTempView)
+      }
+    } else {
+      if (expectedErrorMsgForDataSetTempView != null) {
+        checkError(ex, expectedErrorMsgForDataSetTempView)
+      }
+      if (expectedErrorClassForDataSetTempView != null) {
+        assert(ex.getErrorClass == expectedErrorClassForDataSetTempView, 
ex.getMessage)
+      }
+    }
+  }
+}
+
+/**
+ * Trait collecting helper methods for DML tests e.p. creating a test table 
for each test and
+ * cleaning it up after each test.
+ */
+trait DeltaDMLTestUtils
+  extends DeltaSQLTestUtils
+  with DeltaTestUtilsBase
+  with BeforeAndAfterEach {
+  self: SharedSparkSession =>
+
+  import testImplicits._
+
+  protected var tempDir: File = _
+
+  protected var deltaLog: DeltaLog = _
+
+  protected def tempPath: String = tempDir.getCanonicalPath
+
+  override protected def beforeEach(): Unit = {
+    super.beforeEach()
+    // Using a space in path to provide coverage for special characters.
+    tempDir = Utils.createTempDir(namePrefix = "spark test")
+    deltaLog = DeltaLog.forTable(spark, new Path(tempPath))
+  }
+
+  override protected def afterEach(): Unit = {
+    try {
+      Utils.deleteRecursively(tempDir)
+      DeltaLog.clearCache()
+    } finally {
+      super.afterEach()
+    }
+  }
+
+  protected def append(df: DataFrame, partitionBy: Seq[String] = Nil): Unit = {
+    val dfw = df.write.format("delta").mode("append")
+    if (partitionBy.nonEmpty) {
+      dfw.partitionBy(partitionBy: _*)
+    }
+    dfw.save(tempPath)
+  }
+
+  protected def withKeyValueData(
+      source: Seq[(Int, Int)],
+      target: Seq[(Int, Int)],
+      isKeyPartitioned: Boolean = false,
+      sourceKeyValueNames: (String, String) = ("key", "value"),
+      targetKeyValueNames: (String, String) = ("key", "value"))(
+      thunk: (String, String) => Unit = null): Unit = {
+
+    import testImplicits._
+
+    append(target.toDF(targetKeyValueNames._1, 
targetKeyValueNames._2).coalesce(2),
+      if (isKeyPartitioned) Seq(targetKeyValueNames._1) else Nil)
+    withTempView("source") {
+      source.toDF(sourceKeyValueNames._1, 
sourceKeyValueNames._2).createOrReplaceTempView("source")
+      thunk("source", s"delta.`$tempPath`")
+    }
+  }
+
+  /**
+   * Parse the input JSON data into a dataframe, one row per input element.
+   * Throws an exception on malformed inputs or records that don't comply with 
the provided schema.
+   */
+  protected def readFromJSON(data: Seq[String], schema: StructType = null): 
DataFrame = {
+    if (schema != null) {
+      spark.read
+        .schema(schema)
+        .option("mode", FailFastMode.name)
+        .json(data.toDS)
+    } else {
+      spark.read
+        .option("mode", FailFastMode.name)
+        .json(data.toDS)
+    }
+  }
+
+  protected def readDeltaTable(path: String): DataFrame = {
+    spark.read.format("delta").load(path)
+  }
+
+  protected def getDeltaFileStmt(path: String): String = s"SELECT * FROM 
delta.`$path`"
+
+  /**
+   * Finds the latest operation of the given type that ran on the test table 
and returns the
+   * dataframe with the changes of the corresponding table version.
+   *
+   * @param operation Delta operation name, see [[DeltaOperations]].
+   */
+  protected def getCDCForLatestOperation(deltaLog: DeltaLog, operation: 
String): DataFrame = {
+    val latestOperation = deltaLog.history
+      .getHistory(None)
+      .find(_.operation == operation)
+    assert(latestOperation.nonEmpty, s"Couldn't find a ${operation} operation 
to check CDF")
+
+    val latestOperationVersion = latestOperation.get.version
+    assert(latestOperationVersion.nonEmpty,
+      s"Latest ${operation} operation doesn't have a version associated with 
it")
+
+    CDCReader
+      .changesToBatchDF(
+        deltaLog,
+        latestOperationVersion.get,
+        latestOperationVersion.get,
+        spark)
+      .drop(CDCReader.CDC_COMMIT_TIMESTAMP)
+      .drop(CDCReader.CDC_COMMIT_VERSION)
+  }
+}
+// spotless:on
diff --git 
a/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/FakeFileSystem.scala
 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/FakeFileSystem.scala
new file mode 100644
index 0000000000..1b7d4441e3
--- /dev/null
+++ 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/FakeFileSystem.scala
@@ -0,0 +1,32 @@
+/*
+ * 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.delta
+
+import org.apache.hadoop.fs.RawLocalFileSystem
+
+import java.net.URI
+
+/** A fake file system to test whether session Hadoop configuration will be 
picked up. */
+class FakeFileSystem extends RawLocalFileSystem {
+  override def getScheme: String = FakeFileSystem.scheme
+  override def getUri: URI = FakeFileSystem.uri
+}
+
+object FakeFileSystem {
+  val scheme = "fake"
+  val uri = URI.create(s"$scheme:///")
+}
diff --git 
a/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/test/DeltaColumnMappingSelectedTestMixin.scala
 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/test/DeltaColumnMappingSelectedTestMixin.scala
new file mode 100644
index 0000000000..135dd97bfa
--- /dev/null
+++ 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/test/DeltaColumnMappingSelectedTestMixin.scala
@@ -0,0 +1,76 @@
+/*
+ * 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.delta.test
+
+import org.apache.spark.SparkFunSuite
+import org.apache.spark.sql.delta.DeltaColumnMappingTestUtils
+import org.apache.spark.sql.delta.DeltaConfigs
+
+import org.scalactic.source.Position
+import org.scalatest.Tag
+import org.scalatest.exceptions.TestFailedException
+
+import scala.collection.mutable
+
+// spotless:off
+/**
+ * A trait for selective enabling certain tests to run for column mapping modes
+ */
+trait DeltaColumnMappingSelectedTestMixin extends SparkFunSuite
+  with DeltaSQLTestUtils with DeltaColumnMappingTestUtils {
+
+  protected def runOnlyTests: Seq[String] = Seq()
+
+  /**
+   * If true, will run all tests.
+   * Requires that `runOnlyTests` is empty.
+   */
+  protected def runAllTests: Boolean = false
+
+  private val testsRun: mutable.Set[String] = mutable.Set.empty
+
+  override protected def test(
+      testName: String,
+      testTags: Tag*)(testFun: => Any)(implicit pos: Position): Unit = {
+    require(!runAllTests || runOnlyTests.isEmpty,
+      "If `runAllTests` is true then `runOnlyTests` must be empty")
+
+    if (runAllTests || runOnlyTests.contains(testName)) {
+      super.test(s"$testName - column mapping $columnMappingMode mode", 
testTags: _*) {
+        testsRun.add(testName)
+        withSQLConf(
+          DeltaConfigs.COLUMN_MAPPING_MODE.defaultTablePropertyKey -> 
columnMappingMode) {
+          testFun
+        }
+      }
+    } else {
+      super.ignore(s"$testName - ignored by 
DeltaColumnMappingSelectedTestMixin")(testFun)
+    }
+  }
+
+  override def afterAll(): Unit = {
+    super.afterAll()
+    val missingTests = runOnlyTests.toSet diff testsRun
+    if (missingTests.nonEmpty) {
+      throw new TestFailedException(
+        Some("Not all selected column mapping tests were run. Missing: " +
+          missingTests.mkString(", ")), None, 0)
+    }
+  }
+
+}
+// spotless:on
diff --git 
a/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/test/DeltaExcludedTestMixin.scala
 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/test/DeltaExcludedTestMixin.scala
new file mode 100644
index 0000000000..b166697284
--- /dev/null
+++ 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/test/DeltaExcludedTestMixin.scala
@@ -0,0 +1,40 @@
+/*
+ * 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.delta.test
+
+import org.apache.spark.sql.QueryTest
+
+import org.scalactic.source.Position
+import org.scalatest.Tag
+
+// spotless:off
+trait DeltaExcludedTestMixin extends QueryTest {
+
+  /** Tests to be ignored by the runner. */
+  override def excluded: Seq[String] = Seq.empty
+
+  protected override def test(testName: String, testTags: Tag*)
+    (testFun: => Any)
+    (implicit pos: Position): Unit = {
+    if (excluded.contains(testName)) {
+      super.ignore(testName, testTags: _*)(testFun)
+    } else {
+      super.test(testName, testTags: _*)(testFun)
+    }
+  }
+}
+// spotless:on
diff --git 
a/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/test/DeltaSQLCommandTest.scala
 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/test/DeltaSQLCommandTest.scala
new file mode 100644
index 0000000000..8d1f87089b
--- /dev/null
+++ 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/test/DeltaSQLCommandTest.scala
@@ -0,0 +1,57 @@
+/*
+ * 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.delta.test
+
+import org.apache.gluten.config.VeloxDeltaConfig
+
+import org.apache.spark.SparkConf
+import org.apache.spark.sql.delta.catalog.DeltaCatalog
+import org.apache.spark.sql.internal.{SQLConf, StaticSQLConf}
+import org.apache.spark.sql.test.SharedSparkSession
+
+import io.delta.sql.DeltaSparkSessionExtension
+
+// spotless:off
+/**
+ * A trait for tests that are testing a fully set up SparkSession with all of 
Delta's requirements,
+ * such as the configuration of the DeltaCatalog and the addition of all Delta 
extensions.
+ */
+trait DeltaSQLCommandTest extends SharedSparkSession {
+
+  override protected def sparkConf: SparkConf = {
+    val conf = super.sparkConf
+
+    // Delta.
+    conf.set(StaticSQLConf.SPARK_SESSION_EXTENSIONS.key,
+        classOf[DeltaSparkSessionExtension].getName)
+      .set(SQLConf.V2_SESSION_CATALOG_IMPLEMENTATION.key,
+        classOf[DeltaCatalog].getName)
+
+    // Gluten.
+    conf.set("spark.plugins", "org.apache.gluten.GlutenPlugin")
+      .set("spark.shuffle.manager", 
"org.apache.spark.shuffle.sort.ColumnarShuffleManager")
+      .set("spark.default.parallelism", "1")
+      .set("spark.memory.offHeap.enabled", "true")
+      .set("spark.sql.shuffle.partitions", "5")
+      .set("spark.memory.offHeap.size", "2g")
+      .set("spark.unsafe.exceptionOnMemoryLeak", "true")
+      .set(VeloxDeltaConfig.ENABLE_NATIVE_WRITE.key, "true")
+      .set("spark.databricks.delta.snapshotPartitions", "2")
+      .set("spark.gluten.sql.fallbackUnexpectedMetadataParquet", "true")
+  }
+}
+// spotless:on
diff --git 
a/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/test/DeltaSQLTestUtils.scala
 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/test/DeltaSQLTestUtils.scala
new file mode 100644
index 0000000000..22f4e9fa11
--- /dev/null
+++ 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/test/DeltaSQLTestUtils.scala
@@ -0,0 +1,79 @@
+/*
+ * 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.delta.test
+
+import org.apache.spark.sql.test.SQLTestUtils
+import org.apache.spark.util.Utils
+
+import java.io.File
+
+// spotless:off
+trait DeltaSQLTestUtils extends SQLTestUtils {
+  /**
+   * Override the temp dir/path creation methods from [[SQLTestUtils]] to:
+   * 1. Drop the call to `waitForTasksToFinish` which is a source of flakiness 
due to timeouts
+   *    without clear benefits.
+   * 2. Allow creating paths with special characters for better test coverage.
+   */
+
+  protected val defaultTempDirPrefix: String = "spark%dir%prefix"
+
+  override protected def withTempDir(f: File => Unit): Unit = {
+    withTempDir(prefix = defaultTempDirPrefix)(f)
+  }
+
+  override protected def withTempPaths(numPaths: Int)(f: Seq[File] => Unit): 
Unit = {
+    withTempPaths(numPaths, prefix = defaultTempDirPrefix)(f)
+  }
+
+  override def withTempPath(f: File => Unit): Unit = {
+    withTempPath(prefix = defaultTempDirPrefix)(f)
+  }
+
+  /**
+   * Creates a temporary directory, which is then passed to `f` and will be 
deleted after `f`
+   * returns.
+   */
+  def withTempDir(prefix: String)(f: File => Unit): Unit = {
+    val path = Utils.createTempDir(namePrefix = prefix)
+    try f(path) finally Utils.deleteRecursively(path)
+  }
+
+  /**
+   * Generates a temporary directory path without creating the actual 
directory, which is then
+   * passed to `f` and will be deleted after `f` returns.
+   */
+  def withTempPath(prefix: String)(f: File => Unit): Unit = {
+    val path = Utils.createTempDir(namePrefix = prefix)
+    path.delete()
+    try f(path) finally Utils.deleteRecursively(path)
+  }
+
+  /**
+   * Generates the specified number of temporary directory paths without 
creating the actual
+   * directories, which are then passed to `f` and will be deleted after `f` 
returns.
+   */
+  protected def withTempPaths(numPaths: Int, prefix: String)(f: Seq[File] => 
Unit): Unit = {
+    val files =
+      Seq.fill[File](numPaths)(Utils.createTempDir(namePrefix = 
prefix).getCanonicalFile)
+    files.foreach(_.delete())
+    try f(files) finally {
+      files.foreach(Utils.deleteRecursively)
+    }
+  }
+}
+// spotless:on
diff --git 
a/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/test/DeltaTestImplicits.scala
 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/test/DeltaTestImplicits.scala
new file mode 100644
index 0000000000..f2e7acc695
--- /dev/null
+++ 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/test/DeltaTestImplicits.scala
@@ -0,0 +1,204 @@
+/*
+ * 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.delta.test
+
+import org.apache.spark.sql.{SaveMode, SparkSession}
+import org.apache.spark.sql.catalyst.TableIdentifier
+import org.apache.spark.sql.catalyst.expressions.{Expression, Literal}
+import org.apache.spark.sql.delta.{DeltaLog, OptimisticTransaction, Snapshot}
+import org.apache.spark.sql.delta.DeltaOperations.{ManualUpdate, Operation, 
Write}
+import org.apache.spark.sql.delta.actions.{Action, AddFile, Metadata, Protocol}
+import org.apache.spark.sql.delta.catalog.DeltaTableV2
+import org.apache.spark.sql.delta.commands.optimize.OptimizeMetrics
+import 
org.apache.spark.sql.delta.coordinatedcommits.TableCommitCoordinatorClient
+import org.apache.spark.sql.delta.hooks.AutoCompact
+import org.apache.spark.sql.delta.stats.StatisticsCollection
+import org.apache.spark.util.Clock
+
+import io.delta.storage.commit.{CommitResponse, GetCommitsResponse, 
UpdatedActions}
+import org.apache.hadoop.fs.Path
+
+import java.io.File
+
+// spotless:off
+/**
+ * Additional method definitions for Delta classes that are intended for use 
only in testing.
+ */
+object DeltaTestImplicits {
+  implicit class OptimisticTxnTestHelper(txn: OptimisticTransaction) {
+
+    /** Ensure that the initial commit of a Delta table always contains a 
Metadata action */
+    def commitActions(op: Operation, actions: Action*): Long = {
+      if (txn.readVersion == -1) {
+        val metadataOpt = actions.collectFirst { case m: Metadata => m }
+        val protocolOpt = actions.collectFirst { case p: Protocol => p }
+        val otherActions =
+          actions.filterNot(a => a.isInstanceOf[Metadata] || 
a.isInstanceOf[Protocol])
+        (metadataOpt, protocolOpt) match {
+          case (Some(metadata), Some(protocol)) =>
+            // When both metadata and protocol are explicitly passed, use them.
+            txn.updateProtocol(protocol)
+            // This will auto upgrade any required table features in the 
passed protocol as per
+            // given metadata.
+            txn.updateMetadataForNewTable(metadata)
+          case (Some(metadata), None) =>
+            // When just metadata is passed, use it.
+            // This will auto generate protocol as per metadata.
+            txn.updateMetadataForNewTable(metadata)
+          case (None, Some(protocol)) =>
+            txn.updateProtocol(protocol)
+            txn.updateMetadataForNewTable(Metadata())
+          case (None, None) =>
+            // If neither metadata nor protocol is explicitly passed, then use 
default Metadata and
+            // with the maximum protocol.
+            txn.updateMetadataForNewTable(Metadata())
+            txn.updateProtocol(Action.supportedProtocolVersion())
+        }
+        txn.commit(otherActions, op)
+      } else {
+        txn.commit(actions, op)
+      }
+    }
+
+    def commitManually(actions: Action*): Long = {
+      commitActions(ManualUpdate, actions: _*)
+    }
+
+    def commitWriteAppend(actions: Action*): Long = {
+      commitActions(Write(SaveMode.Append), actions: _*)
+    }
+  }
+
+  /** Add test-only File overloads for DeltaTable.forPath */
+  implicit class DeltaLogObjectTestHelper(deltaLog: DeltaLog.type) {
+    def forTable(spark: SparkSession, dataPath: File): DeltaLog = {
+      DeltaLog.forTable(spark, new Path(dataPath.getCanonicalPath))
+    }
+
+    def forTable(spark: SparkSession, dataPath: File, clock: Clock): DeltaLog 
= {
+      DeltaLog.forTable(spark, new Path(dataPath.getCanonicalPath), clock)
+    }
+  }
+
+  /** Helper class for working with [[TableCommitCoordinatorClient]] */
+  implicit class TableCommitCoordinatorClientTestHelper(
+      tableCommitCoordinatorClient: TableCommitCoordinatorClient) {
+
+    def commit(
+        commitVersion: Long,
+        actions: Iterator[String],
+        updatedActions: UpdatedActions): CommitResponse = {
+      tableCommitCoordinatorClient.commit(
+        commitVersion, actions, updatedActions, tableIdentifierOpt = None)
+    }
+
+    def getCommits(
+        startVersion: Option[Long] = None,
+        endVersion: Option[Long] = None): GetCommitsResponse = {
+      tableCommitCoordinatorClient.getCommits(tableIdentifierOpt = None, 
startVersion, endVersion)
+    }
+
+    def backfillToVersion(
+        version: Long,
+        lastKnownBackfilledVersion: Option[Long] = None): Unit = {
+      tableCommitCoordinatorClient.backfillToVersion(
+        tableIdentifierOpt = None, version, lastKnownBackfilledVersion)
+    }
+  }
+
+
+  /** Helper class for working with [[Snapshot]] */
+  implicit class SnapshotTestHelper(snapshot: Snapshot) {
+    def ensureCommitFilesBackfilled(): Unit = {
+      snapshot.ensureCommitFilesBackfilled(catalogTableOpt = None)
+    }
+  }
+
+  /**
+   * Helper class for working with the most recent snapshot in the deltaLog
+   */
+  implicit class DeltaLogTestHelper(deltaLog: DeltaLog) {
+    def snapshot: Snapshot = {
+      deltaLog.unsafeVolatileSnapshot
+    }
+
+    def checkpoint(): Unit = {
+      deltaLog.checkpoint(snapshot)
+    }
+
+    def checkpointInterval(): Int = {
+      deltaLog.checkpointInterval(snapshot.metadata)
+    }
+
+    def deltaRetentionMillis(): Long = {
+      deltaLog.deltaRetentionMillis(snapshot.metadata)
+    }
+
+    def enableExpiredLogCleanup(): Boolean = {
+      deltaLog.enableExpiredLogCleanup(snapshot.metadata)
+    }
+
+    def upgradeProtocol(newVersion: Protocol): Unit = {
+      upgradeProtocol(deltaLog.unsafeVolatileSnapshot, newVersion)
+    }
+
+    def upgradeProtocol(snapshot: Snapshot, newVersion: Protocol): Unit = {
+      deltaLog.upgradeProtocol(None, snapshot, newVersion)
+    }
+  }
+
+  implicit class DeltaTableV2ObjectTestHelper(dt: DeltaTableV2.type) {
+    /** Convenience overload that omits the cmd arg (which is not helpful in 
tests). */
+    def apply(spark: SparkSession, id: TableIdentifier): DeltaTableV2 =
+      dt.apply(spark, id, "test")
+  }
+
+  implicit class DeltaTableV2TestHelper(deltaTable: DeltaTableV2) {
+    /** For backward compatibility with existing unit tests */
+    def snapshot: Snapshot = deltaTable.initialSnapshot
+  }
+
+  implicit class AutoCompactObjectTestHelper(ac: AutoCompact.type) {
+    private[delta] def compact(
+      spark: SparkSession,
+      deltaLog: DeltaLog,
+      partitionPredicates: Seq[Expression] = Nil,
+      opType: String = AutoCompact.OP_TYPE): Seq[OptimizeMetrics] = {
+      AutoCompact.compact(
+        spark, deltaLog, catalogTable = None,
+        partitionPredicates, opType)
+    }
+  }
+
+  implicit class StatisticsCollectionObjectTestHelper(sc: 
StatisticsCollection.type) {
+
+    /**
+     * This is an implicit helper required for backward compatibility with 
existing
+     * unit tests. It allows to call [[StatisticsCollection.recompute]] 
without a
+     * catalog table and in the actual call, sets it to [[None]].
+     */
+    def recompute(
+      spark: SparkSession,
+      deltaLog: DeltaLog,
+      predicates: Seq[Expression] = Seq(Literal(true)),
+      fileFilter: AddFile => Boolean = af => true): Unit = {
+      StatisticsCollection.recompute(
+        spark, deltaLog, catalogTable = None, predicates, fileFilter)
+    }
+  }
+}
+// spotless:on
diff --git 
a/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/test/ScanReportHelper.scala
 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/test/ScanReportHelper.scala
new file mode 100644
index 0000000000..2744f60fbc
--- /dev/null
+++ 
b/backends-velox/src-delta40/test/scala/org/apache/spark/sql/delta/test/ScanReportHelper.scala
@@ -0,0 +1,165 @@
+/*
+ * 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.delta.test
+
+import org.apache.spark.sql.delta.files.TahoeFileIndex
+import org.apache.spark.sql.delta.metering.ScanReport
+import org.apache.spark.sql.delta.stats.{DataSize, PreparedDeltaFileIndex}
+import org.apache.spark.sql.execution.{FileSourceScanExec, QueryExecution, 
SparkPlan}
+import org.apache.spark.sql.execution.adaptive.AdaptiveSparkPlanHelper
+import org.apache.spark.sql.execution.columnar.InMemoryTableScanExec
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.util.QueryExecutionListener
+
+import scala.util.control.NonFatal
+
+/**
+ * A helper trait used by test classes that want to collect the scans (i.e. 
[[FileSourceScanExec]])
+ * generated by a given input query during query planning.
+ *
+ * This trait exposes a single public API [[getScanReport]].
+ */
+trait ScanReportHelper extends SharedSparkSession with AdaptiveSparkPlanHelper 
{
+
+  import ScanReportHelper._
+
+  /** Collect the scan leaves in the given SparkPlan. */
+  private def collectScans(plan: SparkPlan): Seq[FileSourceScanExec] = {
+    collectWithSubqueries(plan) {
+      case fs: FileSourceScanExec => Seq(fs)
+      case cached: InMemoryTableScanExec => 
collectScans(cached.relation.cacheBuilder.cachedPlan)
+    }.flatten
+  }
+
+  /**
+   * Returns a new [[QueryExecutionListener]] that can be registered to the 
Spark listener bus to
+   * analyse and collect metrics during query execution.
+   *
+   * Specifically, this listener will check for any [[FileSourceScanExec]] 
generated during query
+   * planning, cast them into [[ScanReport]] (helper class to hold useful info 
about the scan), and
+   * append to the singleton [[ScanReportHelper.scans]]
+   */
+  private def getListener(): QueryExecutionListener = {
+    new QueryExecutionListener {
+      override def onSuccess(funcName: String, qe: QueryExecution, durationNs: 
Long): Unit = {
+        try qe.assertAnalyzed()
+        catch {
+          case NonFatal(e) =>
+            logDebug("Not running Delta Metering because the query failed 
during analysis.", e)
+            return
+        }
+
+        val fileScans = collectScans(qe.executedPlan)
+
+        for (scanExec <- fileScans) {
+          scanExec.relation.location match {
+            case deltaTable: PreparedDeltaFileIndex =>
+              val preparedScan = deltaTable.preparedScan
+              // The names of the partition columns that were used as filters 
in this scan.
+              // Convert this to a set first to avoid double-counting 
partition columns that might
+              // appear multiple times.
+              val usedPartitionColumns =
+                
preparedScan.partitionFilters.map(_.references.map(_.name)).flatten.toSet.toSeq
+              val report = ScanReport(
+                tableId = deltaTable.metadata.id,
+                path = deltaTable.path.toString,
+                scanType = "delta-query",
+                deltaDataSkippingType = preparedScan.dataSkippingType.toString,
+                partitionFilters = 
preparedScan.partitionFilters.map(_.sql).toSeq,
+                partitionLikeDataFilters = 
preparedScan.partitionLikeDataFilters.map(_.sql).toSeq,
+                rewrittenPartitionLikeDataFilters =
+                  
preparedScan.rewrittenPartitionLikeDataFilters.map(_.sql).toSeq,
+                dataFilters = preparedScan.dataFilters.map(_.sql).toSeq,
+                unusedFilters = preparedScan.unusedFilters.map(_.sql).toSeq,
+                size = Map(
+                  "total" -> preparedScan.total,
+                  "partition" -> preparedScan.partition,
+                  "scanned" -> preparedScan.scanned),
+                metrics = scanExec.metrics.mapValues(_.value).toMap +
+                  ("scanDurationMs" -> preparedScan.scanDurationMs),
+                annotations = Map.empty,
+                versionScanned = deltaTable.versionScanned,
+                usedPartitionColumns = usedPartitionColumns,
+                numUsedPartitionColumns = usedPartitionColumns.size,
+                allPartitionColumns = deltaTable.metadata.partitionColumns,
+                numAllPartitionColumns = 
deltaTable.metadata.partitionColumns.size,
+                parentFilterOutputRows = None
+              )
+
+              scans += report
+
+            case deltaTable: TahoeFileIndex =>
+              val report = ScanReport(
+                tableId = deltaTable.metadata.id,
+                path = deltaTable.path.toString,
+                scanType = "delta-unknown",
+                partitionFilters = Nil,
+                dataFilters = Nil,
+                partitionLikeDataFilters = Nil,
+                rewrittenPartitionLikeDataFilters = Nil,
+                unusedFilters = Nil,
+                size = Map(
+                  "total" -> DataSize(
+                    bytesCompressed = 
Some(deltaTable.deltaLog.unsafeVolatileSnapshot.sizeInBytes)),
+                  "scanned" -> DataSize(bytesCompressed = 
Some(deltaTable.sizeInBytes))
+                ),
+                metrics = scanExec.metrics.mapValues(_.value).toMap,
+                versionScanned = None,
+                annotations = Map.empty
+              )
+
+              scans += report
+
+            case _ => // ignore
+          }
+        }
+      }
+
+      override def onFailure(funcName: String, qe: QueryExecution, exception: 
Exception): Unit = {}
+    }
+  }
+
+  /** Execute function `f` and return the scans generated during query 
planning */
+  def getScanReport(f: => Unit): Seq[ScanReport] = {
+    synchronized {
+      assert(scans == null, "getScanReport does not support nested 
invocation.")
+      scans = scala.collection.mutable.ArrayBuffer.empty[ScanReport]
+    }
+
+    val listener = getListener()
+    spark.listenerManager.register(listener)
+
+    var result: scala.collection.mutable.ArrayBuffer[ScanReport] = null
+    try {
+      f
+    } finally {
+      spark.sparkContext.listenerBus.waitUntilEmpty(15000)
+      spark.listenerManager.unregister(listener)
+
+      result = scans
+      synchronized {
+        scans = null
+      }
+    }
+
+    result.toSeq
+  }
+}
+
+object ScanReportHelper {
+  @volatile var scans: scala.collection.mutable.ArrayBuffer[ScanReport] = null
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to