/*
 * 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.catalyst.analysis

import org.apache.spark.SparkException
import org.apache.spark.internal.LogKeys.CONFIG
import org.apache.spark.sql.SaveMode
import org.apache.spark.sql.catalyst.{FunctionIdentifier, TableIdentifier}
import org.apache.spark.sql.catalyst.catalog.{CatalogStorageFormat, CatalogTable, CatalogTableType, CatalogUtils, ClusterBySpec, HiveTableRelation}
import org.apache.spark.sql.catalyst.expressions.{Alias, AliasHelper, Attribute}
import org.apache.spark.sql.catalyst.optimizer.ConstantFolding
import org.apache.spark.sql.catalyst.plans.logical._
import org.apache.spark.sql.catalyst.rules.Rule
import org.apache.spark.sql.catalyst.util.{quoteIfNeeded, toPrettySQL, CharVarcharUtils, ResolveDefaultColumns => DefaultCols}
import org.apache.spark.sql.catalyst.util.ResolveDefaultColumns._
import org.apache.spark.sql.connector.catalog.{CatalogExtension, CatalogManager, CatalogPlugin, CatalogV2Util, LookupCatalog, SupportsNamespaces, V1Table, ViewCatalog}
import org.apache.spark.sql.connector.expressions.Transform
import org.apache.spark.sql.errors.{QueryCompilationErrors, QueryExecutionErrors}
import org.apache.spark.sql.execution.command._
import org.apache.spark.sql.execution.datasources.{CreateTable => CreateTableV1, LogicalRelation}
import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Utils
import org.apache.spark.sql.internal.{HiveSerDe, SQLConf}
import org.apache.spark.sql.internal.connector.V1Function
import org.apache.spark.sql.metricview.logical.CreateMetricView
import org.apache.spark.sql.types.{DataType, MetadataBuilder, StringType, StructField, StructType}
import org.apache.spark.util.SparkStringUtils

/**
 * Converts resolved v2 commands to v1 if the catalog is the session catalog. Since the v2 commands
 * are resolved, the referred tables/views/functions are resolved as well. This rule uses qualified
 * identifiers to construct the v1 commands, so that v1 commands do not need to qualify identifiers
 * again, which may lead to inconsistent behavior if the current database is changed in the middle.
 */
class ResolveSessionCatalog(val catalogManager: CatalogManager)
  extends Rule[LogicalPlan] with LookupCatalog with AliasHelper {
  import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._
  import org.apache.spark.sql.connector.catalog.CatalogV2Util._
  import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Implicits._

  override def apply(plan: LogicalPlan): LogicalPlan = plan.resolveOperatorsUp {
    case a @ AddColumns(ResolvedV1TableIdentifier(ident), cols)
        if a.resolved && cols.forall(_.isDefaultValueTypeCoerced) =>
      cols.foreach { c =>
        if (c.name.length > 1) {
          throw QueryCompilationErrors.unsupportedTableOperationError(
            ident, "ADD COLUMN with qualified column")
        }
        if (!c.nullable) {
          throw QueryCompilationErrors.addColumnWithV1TableCannotSpecifyNotNullError()
        }
      }
      // For V1 ALTER TABLE ADD COLUMNS command, the default value expression is hidden in the
      // StructField metadata, and won't be constant folded by the optimizer. Here we
      // manually constant fold it, as exist default needs to be a constant.
      val newCols = cols.map { col =>
        val builder = new MetadataBuilder
        col.comment.foreach(builder.putString("comment", _))
        col.default.foreach { defaultValue =>
          addDefaultValueMetadata(builder, defaultValue, col.colName, col.dataType,
            "ALTER TABLE ADD COLUMNS")
        }
        StructField(col.name.head, col.dataType, nullable = true, builder.build())
      }
      AlterTableAddColumnsCommand(ident, newCols)

    case ReplaceColumns(ResolvedV1TableIdentifier(ident), _) =>
      throw QueryCompilationErrors.unsupportedTableOperationError(ident, "REPLACE COLUMNS")

    case a @ AlterColumns(ResolvedTable(catalog, ident, table: V1Table, _), specs)
        if supportsV1Command(catalog) && a.resolved && specs.forall(_.isDefaultValueTypeCoerced) =>
      if (specs.size > 1) {
        throw QueryCompilationErrors.unsupportedTableOperationError(
          catalog, ident, "ALTER COLUMN in bulk")
      }
      val s = specs.head
      if (s.column.name.length > 1) {
        throw QueryCompilationErrors.unsupportedTableOperationError(
          catalog, ident, "ALTER COLUMN with qualified column")
      }
      if (s.newNullability.isDefined) {
        throw QueryCompilationErrors.unsupportedTableOperationError(
          catalog, ident, "ALTER COLUMN ... SET NOT NULL")
      }
      if (s.newPosition.isDefined) {
        throw QueryCompilationErrors.unsupportedTableOperationError(
          catalog, ident, "ALTER COLUMN ... FIRST | AFTER")
      }
      val builder = new MetadataBuilder
      // Add comment to metadata
      s.newComment.foreach(c => builder.putString("comment", c))
      val colName = s.column.name.head
      val dataType = s.newDataType.getOrElse {
        table.schema.findNestedField(Seq(colName), resolver = conf.resolver)
          .map {
            case (_, StructField(_, st: StringType, _, metadata)) =>
              CharVarcharUtils.getRawType(metadata).getOrElse(st)
            case (_, field) => field.dataType
          }
          .getOrElse {
            throw QueryCompilationErrors.unresolvedColumnError(
              toSQLId(s.column.name), table.schema.fieldNames)
          }
      }
      // For V1 ALTER TABLE ALTER COLUMN command, we only set the CURRENT_DEFAULT metadata.
      // The EXISTS_DEFAULT should be preserved from the original column, as it represents
      // the default value that was in effect when the column was added (used for backfilling).
      s.newDefaultExpression.foreach { defaultValue =>
        // Validate the default value expression
        constantFoldDefaultValue(defaultValue, colName, dataType, "ALTER TABLE ALTER COLUMN")
        builder.putExpression(
          DefaultCols.CURRENT_DEFAULT_COLUMN_METADATA_KEY, defaultValue.originalSQL,
          Some(defaultValue.child))
      }
      if (s.dropDefault) {
        // for legacy reasons, "" means clearing default value
        builder.putString(CURRENT_DEFAULT_COLUMN_METADATA_KEY, "")
      }
      val newColumn = StructField(
        colName,
        dataType,
        nullable = true,
        builder.build())
      AlterTableChangeColumnCommand(table.catalogTable.identifier, colName, newColumn)

    case AlterTableClusterBy(ResolvedTable(catalog, _, table: V1Table, _), clusterBySpecOpt)
        if supportsV1Command(catalog) =>
      val prop = Map(ClusterBySpec.toProperty(table.schema,
        clusterBySpecOpt.getOrElse(ClusterBySpec(Nil)), conf.resolver))
      AlterTableSetPropertiesCommand(table.catalogTable.identifier, prop, isView = false)

    case RenameColumn(ResolvedV1TableIdentifier(ident), _, _) =>
      throw QueryCompilationErrors.unsupportedTableOperationError(ident, "RENAME COLUMN")

    case DropColumns(ResolvedV1TableIdentifier(ident), _, _) =>
      throw QueryCompilationErrors.unsupportedTableOperationError(ident, "DROP COLUMN")

    // V1 and hive tables do not support constraints
    case AddConstraint(ResolvedV1TableIdentifier(ident), _) =>
      throw QueryCompilationErrors.unsupportedTableOperationError(ident, "ADD CONSTRAINT")

    case DropConstraint(ResolvedV1TableIdentifier(ident), _, _, _) =>
      throw QueryCompilationErrors.unsupportedTableOperationError(ident, "DROP CONSTRAINT")

    case a: AddCheckConstraint
        if a.child.exists {
          case _: LogicalRelation => true
          case _: HiveTableRelation => true
          case _ => false
        } =>
      val tableIdent = a.child.collectFirst {
        case l: LogicalRelation => l.catalogTable.get.identifier
        case h: HiveTableRelation => h.tableMeta.identifier
      }.get
      throw QueryCompilationErrors.unsupportedTableOperationError(tableIdent, "ADD CONSTRAINT")

    case SetTableProperties(ResolvedV1TableIdentifier(ident), props) =>
      AlterTableSetPropertiesCommand(ident, props, isView = false)

    case UnsetTableProperties(ResolvedV1TableIdentifier(ident), keys, ifExists) =>
      AlterTableUnsetPropertiesCommand(ident, keys, ifExists, isView = false)

    case SetViewProperties(ResolvedViewIdentifier(ident), props) =>
      AlterTableSetPropertiesCommand(ident, props, isView = true)

    case UnsetViewProperties(ResolvedViewIdentifier(ident), keys, ifExists) =>
      AlterTableUnsetPropertiesCommand(ident, keys, ifExists, isView = true)

    case DescribeNamespace(ResolvedV1Database(db), extended, output) if conf.useV1Command =>
      DescribeDatabaseCommand(db, extended, output)

    case SetNamespaceProperties(ResolvedV1Database(db), properties) if conf.useV1Command =>
      AlterDatabasePropertiesCommand(db, properties)

    case SetNamespaceLocation(ResolvedV1Database(db), location) if conf.useV1Command =>
      if (SparkStringUtils.isEmpty(location)) {
        throw QueryExecutionErrors.invalidEmptyLocationError(location)
      }
      AlterDatabaseSetLocationCommand(db, location)

    case RenameTable(ResolvedV1TableOrViewIdentifier(oldIdent), newName, isView) =>
      AlterTableRenameCommand(oldIdent, newName.asTableIdentifier, isView)

    // Use v1 command to describe (temp) view, as v2 catalog doesn't support view yet.
    case DescribeRelation(
        resolvedChild @ ResolvedV1TableOrViewIdentifier(ident),
        isExtended,
        output) =>
      DescribeTableCommand(resolvedChild, ident, Map.empty, isExtended, output)

    case DescribeTablePartition(
        resolvedChild @ ResolvedV1TableOrViewIdentifier(ident),
        UnresolvedPartitionSpec(spec, _),
        isExtended,
        output) =>
      DescribeTableCommand(resolvedChild, ident, spec, isExtended, output)

    // `DESCRIBE TABLE <view> PARTITION (...)` against a non-session v2 view: the v1 rewrite
    // above is gated on `ResolvedV1TableOrViewIdentifier` (session-only), so non-session v2
    // views fall through. Reject early with the same `FORBIDDEN_OPERATION` v1 raises at
    // runtime in `DescribeTableCommand.describeDetailedPartitionInfo`. Without this rewrite,
    // CheckAnalysis surfaces a generic "Found the unresolved operator" INTERNAL_ERROR
    // because `UnresolvedPartitionSpec` is never resolved on the v2 view path.
    case DescribeTablePartition(rpv: ResolvedPersistentView, _, _, _) =>
      val quoted = (rpv.catalog.name() +: rpv.identifier.asMultipartIdentifier)
        .map(quoteIfNeeded).mkString(".")
      throw QueryCompilationErrors.descPartitionNotAllowedOnView(quoted)

    case DescribeColumn(ResolvedViewIdentifier(ident), column, isExtended, output) =>
      // `ResolvedPersistentView` exposes the view's schema as its `output`, so `ResolveReferences`
      // typically resolves the column to an `Attribute` here. We also accept the legacy
      // `UnresolvedAttribute` form (e.g. the parser referenced a non-existent column whose
      // resolution was skipped) so the rewrite stays robust across analyzer ordering changes.
      // The unwrap logic is shared with the non-session v2 view path in `DataSourceV2Strategy`.
      val nameParts = DescribeColumn.extractColumnNameParts(column)
      DescribeColumnCommand(ident, nameParts, isExtended, output)

    case DescribeColumn(ResolvedV1TableIdentifier(ident), column, isExtended, output) =>
      column match {
        case u: UnresolvedAttribute =>
          throw QueryCompilationErrors.columnNotFoundError(u.name)
        case a: Attribute =>
          DescribeColumnCommand(ident, a.qualifier :+ a.name, isExtended, output)
        case Alias(child, _) =>
          throw QueryCompilationErrors.commandNotSupportNestedColumnError(
            "DESC TABLE COLUMN", toPrettySQL(child))
        case _ =>
          throw SparkException.internalError(s"[BUG] unexpected column expression: $column")
      }

    // For CREATE TABLE [AS SELECT], we should use the v1 command if the catalog is resolved to the
    // session catalog and the table provider is not v2.
    case c @ CreateTable(ResolvedV1Identifier(ident), _, _, tableSpec: TableSpec, _)
        if c.resolved && c.columns.forall(_.isDefaultValueTypeCoerced) =>
      val (storageFormat, provider) = getStorageFormatAndProvider(
        c.tableSpec.provider, tableSpec.options, c.tableSpec.location, c.tableSpec.serde,
        ctas = false)
      if (!isV2Provider(provider)) {
        if (tableSpec.constraints.nonEmpty) {
          throw QueryCompilationErrors.unsupportedTableOperationError(
            ident, "CONSTRAINT")
        }
        // For V1 CREATE TABLE command, the default value expression is hidden in the
        // StructField metadata, and won't be constant folded by the optimizer. Here we
        // manually constant fold it, as exist default needs to be a constant.
        val fields = c.columns.map { col =>
          col.defaultValue.map { defaultValue =>
            val existsDefaultSQL = constantFoldDefaultValue(
              defaultValue, col.name, col.dataType, "CREATE TABLE")
            // toV1Column already sets CURRENT_DEFAULT, so just update EXISTS_DEFAULT
            val field = col.toV1Column
            val newMetadata = new MetadataBuilder().withMetadata(field.metadata)
              .putString(DefaultCols.EXISTS_DEFAULT_COLUMN_METADATA_KEY, existsDefaultSQL).build()
            field.copy(metadata = newMetadata)
          }.getOrElse(col.toV1Column)
        }
        constructV1TableCmd(None, c.tableSpec, ident, StructType(fields), c.partitioning,
          c.ignoreIfExists, storageFormat, provider)
      } else {
        c
      }

    case c @ CreateTableAsSelect(
        ResolvedV1Identifier(ident), _, _, tableSpec: TableSpec, writeOptions, _, _) =>
      val (storageFormat, provider) = getStorageFormatAndProvider(
        c.tableSpec.provider,
        tableSpec.options ++ writeOptions,
        c.tableSpec.location,
        c.tableSpec.serde,
        ctas = true)

      if (!isV2Provider(provider)) {
        if (tableSpec.constraints.nonEmpty) {
          throw QueryCompilationErrors.unsupportedTableOperationError(
            ident, "CONSTRAINT")
        }
        constructV1TableCmd(Some(c.query), c.tableSpec, ident, new StructType, c.partitioning,
          c.ignoreIfExists, storageFormat, provider)
      } else {
        c
      }

    case RefreshTable(ResolvedV1TableOrViewIdentifier(ident)) =>
      RefreshTableCommand(ident)

    // For REPLACE TABLE [AS SELECT], we should fail if the catalog is resolved to the
    // session catalog and the table provider is not v2.
    case c @ ReplaceTable(ResolvedV1Identifier(ident), _, _, _, _) if c.resolved =>
      val provider = c.tableSpec.provider.getOrElse(conf.defaultDataSourceName)
      if (!isV2Provider(provider)) {
        throw QueryCompilationErrors.unsupportedTableOperationError(
          ident, "REPLACE TABLE")
      } else {
        c
      }

    case c @ ReplaceTableAsSelect(ResolvedV1Identifier(ident), _, _, _, _, _, _) =>
      val provider = c.tableSpec.provider.getOrElse(conf.defaultDataSourceName)
      if (!isV2Provider(provider)) {
        throw QueryCompilationErrors.unsupportedTableOperationError(
          ident, "REPLACE TABLE AS SELECT")
      } else {
        c
      }

    // For CREATE TABLE LIKE, use the v1 command if both the target and source are in the session
    // catalog (or a V1-compatible catalog extension). If source is in a different catalog, fall
    // through to the V2 execution path (CreateTableLikeExec via DataSourceV2Strategy).
    case CreateTableLike(
        ResolvedV1Identifier(targetIdent),
        ResolvedV1TableOrViewIdentifier(sourceIdent),
        location, provider, serdeInfo, properties, ifNotExists) =>
      val fileFormat = buildStorageFormatFromSerdeInfo(location, serdeInfo)
      CreateTableLikeCommand(
        targetIdent, sourceIdent, fileFormat, provider, properties, ifNotExists)

    case DropTable(ResolvedV1Identifier(ident), ifExists, purge) if conf.useV1Command =>
      DropTableCommand(ident, ifExists, isView = false, purge = purge)

    // v1 DROP TABLE supports temp view.
    case DropTable(ResolvedIdentifier(FakeSystemCatalog, ident), _, _) =>
      DropTempViewCommand(ident)

    // Session-qualified temp view: same execution path as FakeSystemCatalog DROP VIEW.
    case DropView(ResolvedTempView(ident, _), ifExists) =>
      DropTempViewCommand(ident, ifExists)

    case DropView(DropViewInSessionCatalog(ident), ifExists) =>
      DropTableCommand(ident, ifExists, isView = true, purge = false)

    // ViewCatalog catalogs fall through to `DataSourceV2Strategy`, which routes DROP VIEW to
    // `ViewCatalog.dropView` (this also covers METRIC_VIEW since metric views are persisted
    // through the same ViewCatalog interface). Other non-session catalogs get
    // `MISSING_CATALOG_ABILITY.VIEWS`, matching the error raised from `CheckViewReferences` for
    // CREATE/ALTER VIEW and from the analyzer gate on UnresolvedView.
    case DropView(r @ ResolvedIdentifier(catalog, ident), ifExists)
        if !catalog.isInstanceOf[ViewCatalog] =>
      if (catalog == FakeSystemCatalog) {
        DropTempViewCommand(ident, ifExists)
      } else {
        throw QueryCompilationErrors.missingCatalogViewsAbilityError(catalog)
      }

    case c @ CreateNamespace(DatabaseNameInSessionCatalog(name), _, _) if conf.useV1Command =>
      val comment = c.properties.get(SupportsNamespaces.PROP_COMMENT)
      val location = c.properties.get(SupportsNamespaces.PROP_LOCATION)
      val newProperties = c.properties -- CatalogV2Util.NAMESPACE_RESERVED_PROPERTIES
      if (location.isDefined && location.get.isEmpty) {
        throw QueryExecutionErrors.invalidEmptyLocationError(location.get)
      }
      CreateDatabaseCommand(name, c.ifNotExists, location, comment, newProperties)

    case d @ DropNamespace(ResolvedV1Database(db), _, _) if conf.useV1Command =>
      DropDatabaseCommand(db, d.ifExists, d.cascade)

    case ShowTables(ResolvedV1Database(db), pattern, output) if conf.useV1Command =>
      ShowTablesCommand(Some(db), pattern, output)

    case ShowTablesExtended(
        ResolvedV1Database(db),
        pattern,
        output) =>
      val newOutput = if (conf.getConf(SQLConf.LEGACY_KEEP_COMMAND_OUTPUT_SCHEMA)) {
        output.head.withName("database") +: output.tail
      } else {
        output
      }
      ShowTablesCommand(Some(db), Some(pattern), newOutput, isExtended = true)

    case ShowTablePartition(
        ResolvedTable(catalog, _, table: V1Table, _),
        partitionSpec,
        output) if supportsV1Command(catalog) =>
      val newOutput = if (conf.getConf(SQLConf.LEGACY_KEEP_COMMAND_OUTPUT_SCHEMA)) {
        output.head.withName("database") +: output.tail
      } else {
        output
      }
      val tablePartitionSpec = Option(partitionSpec).map(
        _.asInstanceOf[UnresolvedPartitionSpec].spec)
      ShowTablesCommand(table.catalogTable.identifier.database,
        Some(table.catalogTable.identifier.table), newOutput,
        isExtended = true, tablePartitionSpec)

    // ANALYZE TABLE works on permanent views if the views are cached.
    case AnalyzeTable(ResolvedV1TableOrViewIdentifier(ident), partitionSpec, noScan) =>
      if (partitionSpec.isEmpty) {
        AnalyzeTableCommand(ident, noScan)
      } else {
        AnalyzePartitionCommand(ident, partitionSpec, noScan)
      }

    case AnalyzeTables(ResolvedV1Database(db), noScan) =>
      AnalyzeTablesCommand(Some(db), noScan)

    case AnalyzeColumn(ResolvedV1TableOrViewIdentifier(ident), columnNames, allColumns) =>
      AnalyzeColumnCommand(ident, columnNames, allColumns)

    // V2 catalog doesn't support REPAIR TABLE yet, we must use v1 command here.
    case RepairTable(
        ResolvedV1TableIdentifierInSessionCatalog(ident),
        addPartitions,
        dropPartitions) =>
      RepairTableCommand(ident, addPartitions, dropPartitions)

    // V2 catalog doesn't support LOAD DATA yet, we must use v1 command here.
    case LoadData(
        ResolvedV1TableIdentifierInSessionCatalog(ident),
        path,
        isLocal,
        isOverwrite,
        partition) =>
      LoadDataCommand(
        ident,
        path,
        isLocal,
        isOverwrite,
        partition)

    case ShowCreateTable(ResolvedV1TableOrViewIdentifier(ident), asSerde, output) if asSerde =>
      ShowCreateTableAsSerdeCommand(ident, output)

    // If target is view, force use v1 command
    case ShowCreateTable(ResolvedViewIdentifier(ident), _, output) =>
      ShowCreateTableCommand(ident, output)

    case ShowCreateTable(ResolvedV1TableIdentifier(ident), _, output)
      if conf.useV1Command => ShowCreateTableCommand(ident, output)

    case ShowCreateTable(ResolvedTable(catalog, _, table: V1Table, _), _, output)
        if supportsV1Command(catalog) && DDLUtils.isHiveTable(table.catalogTable) =>
      ShowCreateTableCommand(table.catalogTable.identifier, output)

    case TruncateTable(ResolvedV1TableIdentifier(ident)) =>
      TruncateTableCommand(ident, None)

    case TruncatePartition(ResolvedV1TableIdentifier(ident), partitionSpec) =>
      TruncateTableCommand(
        ident,
        Seq(partitionSpec).asUnresolvedPartitionSpecs.map(_.spec).headOption)

    case ShowPartitions(
        ResolvedV1TableOrViewIdentifier(ident),
        pattern @ (None | Some(UnresolvedPartitionSpec(_, _))), output) =>
      ShowPartitionsCommand(
        ident,
        output,
        pattern.map(_.asInstanceOf[UnresolvedPartitionSpec].spec))

    case ShowColumns(resolvedChild @ ResolvedViewIdentifier(ident), ns, output) =>
      val resolver = conf.resolver
      val db = ns match {
        case Some(nsSeq) if ident.database.exists(!resolver(_, nsSeq.head)) =>
          throw QueryCompilationErrors.showColumnsWithConflictNamespacesError(
            Seq(nsSeq.head), Seq(ident.database.get))
        case _ => ns.map(_.head)
      }
      // Use unqualified table name when namespace comes from FROM clause so analyzer output
      // matches expected format (e.g. "SHOW COLUMNS IN showcolumn4 FROM global_temp").
      val tableNameForCommand =
        if (db.isDefined && ident.database == db) TableIdentifier(ident.table, None) else ident
      ShowColumnsCommand(resolvedChild, db, tableNameForCommand, output)

    case ShowColumns(
        resolvedChild @ ResolvedV1TableIdentifier(ident), ns, output) =>
      val v1TableName = ident
      val resolver = conf.resolver
      val db = ns match {
        case Some(db) if v1TableName.database.exists(!resolver(_, db.head)) =>
          throw QueryCompilationErrors.showColumnsWithConflictNamespacesError(
            Seq(db.head), Seq(v1TableName.database.get))
        case _ => ns.map(_.head)
      }
      ShowColumnsCommand(resolvedChild, db, v1TableName, output)

    // V2 catalog doesn't support RECOVER PARTITIONS yet, we must use v1 command here.
    case RecoverPartitions(ResolvedV1TableIdentifierInSessionCatalog(ident)) =>
      RepairTableCommand(
        ident,
        enableAddPartitions = true,
        enableDropPartitions = false,
        "ALTER TABLE RECOVER PARTITIONS")

    case AddPartitions(ResolvedV1TableIdentifier(ident), partSpecsAndLocs, ifNotExists) =>
      AlterTableAddPartitionCommand(
        ident,
        partSpecsAndLocs.asUnresolvedPartitionSpecs.map(spec => (spec.spec, spec.location)),
        ifNotExists)

    case RenamePartitions(
        ResolvedV1TableIdentifier(ident),
        UnresolvedPartitionSpec(from, _),
        UnresolvedPartitionSpec(to, _)) =>
      AlterTableRenamePartitionCommand(ident, from, to)

    case DropPartitions(
        ResolvedV1TableIdentifier(ident), specs, ifExists, purge) =>
      AlterTableDropPartitionCommand(
        ident,
        specs.asUnresolvedPartitionSpecs.map(_.spec),
        ifExists,
        purge,
        retainData = false)

    // V2 catalog doesn't support setting serde properties yet, we must use v1 command here.
    case SetTableSerDeProperties(
        ResolvedV1TableIdentifierInSessionCatalog(ident),
        serdeClassName,
        serdeProperties,
        partitionSpec) =>
      AlterTableSerDePropertiesCommand(
        ident,
        serdeClassName,
        serdeProperties,
        partitionSpec)

    case SetTableLocation(ResolvedV1TableIdentifier(ident), None, location) =>
      AlterTableSetLocationCommand(ident, None, location)

    // V2 catalog doesn't support setting partition location yet, we must use v1 command here.
    case SetTableLocation(
        ResolvedV1TableIdentifierInSessionCatalog(ident),
        Some(partitionSpec),
        location) =>
      AlterTableSetLocationCommand(ident, Some(partitionSpec), location)

    // The final `_, _` are AlterViewAs.isAnalyzed and referredTempFunctions. We drop both:
    // AlterViewAsCommand is a separate AnalysisOnlyCommand and gets its own markAsAnalyzed pass
    // from HandleSpecialCommand after this rewrite.
    case AlterViewAs(ResolvedViewIdentifier(ident), originalText, query, _, _) =>
      AlterViewAsCommand(ident, originalText, query)

    case AlterViewSchemaBinding(ResolvedViewIdentifier(ident), viewSchemaMode) =>
      AlterViewSchemaBindingCommand(ident, viewSchemaMode)

    // The final `_, _` are CreateView.isAnalyzed and referredTempFunctions. We drop both:
    // CreateViewCommand is a separate AnalysisOnlyCommand and gets its own markAsAnalyzed pass
    // from HandleSpecialCommand after this rewrite.
    case CreateView(CreateViewInSessionCatalog(ident), userSpecifiedColumns, comment,
        collation, properties, originalText, query, allowExisting, replace, viewSchemaMode,
        _, _) =>
      CreateViewCommand(
        name = ident,
        userSpecifiedColumns = userSpecifiedColumns,
        comment = comment,
        collation = collation,
        properties = properties,
        originalText = originalText,
        plan = query,
        allowExisting = allowExisting,
        replace = replace,
        viewType = PersistedView,
        viewSchemaMode = viewSchemaMode)

    // CREATE VIEW ... WITH METRICS on the session catalog -> V1 runnable command. Non-session
    // v2 catalogs leave [[CreateMetricView]] in place for `DataSourceV2Strategy` to dispatch
    // to `CreateV2MetricViewExec`.
    case cm @ CreateMetricView(ResolvedIdentifier(catalog, _), _, _, _, _, _, _)
        if isSessionCatalog(catalog) =>
      CreateMetricViewCommand(
        cm.child,
        cm.userSpecifiedColumns,
        cm.comment,
        cm.properties,
        cm.originalText,
        cm.allowExisting,
        cm.replace)

    // ViewCatalog catalogs are handled by the v2 strategy (enumerates via listViews); we skip
    // the match here so the plan flows through unchanged. Only non-session, non-ViewCatalog
    // catalogs hit the MISSING_CATALOG_ABILITY.VIEWS rejection.
    case ShowViews(ns: ResolvedNamespace, pattern, output)
        if !ns.catalog.isInstanceOf[ViewCatalog] =>
      ns match {
        case ResolvedDatabaseInSessionCatalog(db) => ShowViewsCommand(db, pattern, output)
        case _ =>
          throw QueryCompilationErrors.missingCatalogViewsAbilityError(ns.catalog)
      }

    // If target is view, force use v1 command
    case ShowTableProperties(ResolvedViewIdentifier(ident), propertyKey, output) =>
      ShowTablePropertiesCommand(ident, propertyKey, output)

    case ShowTableProperties(ResolvedV1TableIdentifier(ident), propertyKey, output)
        if conf.useV1Command =>
      ShowTablePropertiesCommand(ident, propertyKey, output)

    case DescribeFunction(ResolvedNonPersistentFunc(_, v1Func: V1Function), extended) =>
      DescribeFunctionCommand(v1Func.info, extended)

    case DescribeFunction(ResolvedPersistentFunc(catalog, _, func), extended) =>
      if (isSessionCatalog(catalog)) {
        DescribeFunctionCommand(func.asInstanceOf[V1Function].info, extended)
      } else {
        throw QueryCompilationErrors.missingCatalogFunctionsAbilityError(catalog)
      }

    case ShowFunctions(
        ResolvedDatabaseInSessionCatalog(db), userScope, systemScope, pattern, output) =>
      ShowFunctionsCommand(db, pattern, userScope, systemScope, output)

    case d @ DropFunction(ResolvedIdentifier(FakeSystemCatalog, ident), _) =>
      // Builtin or temp function - throw appropriate error
      assert(ident.namespace().length == 1)
      val namespace = ident.namespace().head
      if (namespace == CatalogManager.BUILTIN_NAMESPACE) {
        throw QueryCompilationErrors.cannotDropBuiltinFuncError(ident.name())
      } else {
        assert(namespace == CatalogManager.SESSION_NAMESPACE)
        // Temp function - user should use DROP TEMPORARY FUNCTION
        throw QueryCompilationErrors.expectPersistentFuncError(
          ident.name(),
          "DROP FUNCTION",
          Some("Please use DROP TEMPORARY FUNCTION to drop a temporary function."),
          d)
      }

    case DropFunction(ResolvedIdentifier(catalog, ident), ifExists) =>
      if (isSessionCatalog(catalog)) {
        val funcIdentifier = ident.asFunctionIdentifier.copy(catalog = Some(catalog.name))
        DropFunctionCommand(funcIdentifier, ifExists, isTemp = false)
      } else {
        throw QueryCompilationErrors.missingCatalogDropFunctionAbilityError(catalog)
      }

    case RefreshFunction(r @ ResolvedIdentifier(FakeSystemCatalog, ident)) =>
      // Builtin or temp function - throw appropriate error
      assert(ident.namespace().length == 1)
      val namespace = ident.namespace().head
      if (namespace == CatalogManager.BUILTIN_NAMESPACE) {
        throw QueryCompilationErrors.cannotRefreshBuiltInFuncError(ident.name(), r)
      } else {
        assert(namespace == CatalogManager.SESSION_NAMESPACE)
        throw QueryCompilationErrors.cannotRefreshTempFuncError(ident.name(), r)
      }

    case RefreshFunction(ResolvedIdentifier(catalog, ident)) =>
      if (isSessionCatalog(catalog)) {
        val funcIdentifier = ident.asFunctionIdentifier.copy(catalog = Some(catalog.name))
        RefreshFunctionCommand(funcIdentifier)
      } else {
        throw QueryCompilationErrors.missingCatalogRefreshFunctionAbilityError(catalog)
      }

    case CreateFunction(
        CreateFunctionInSessionCatalog(ident), className, resources, ifExists, replace) =>
      CreateFunctionCommand(
        FunctionIdentifier(ident.table, ident.database, ident.catalog),
        className,
        resources,
        false,
        ifExists,
        replace)

    case CreateFunction(ResolvedIdentifier(catalog, _), _, _, _, _) =>
      throw QueryCompilationErrors.missingCatalogCreateFunctionAbilityError(catalog)

    case c @ CreateUserDefinedFunction(
        CreateFunctionInSessionCatalog(ident), _, _, _, _, _, _, _, _, _, _, _, _) =>
      CreateUserDefinedFunctionCommand(
        FunctionIdentifier(ident.table, ident.database, ident.catalog),
        c.inputParamText,
        c.returnTypeText,
        c.exprText,
        c.queryText,
        c.comment,
        c.collation,
        c.isDeterministic,
        c.containsSQL,
        c.language,
        c.isTableFunc,
        isTemp = false,
        c.ignoreIfExists,
        c.replace)

    case CreateUserDefinedFunction(
        ResolvedIdentifier(catalog, _), _, _, _, _, _, _, _, _, _, _, _, _) =>
      throw QueryCompilationErrors.missingCatalogCreateFunctionAbilityError(catalog)
  }

  private def constructV1TableCmd(
      query: Option[LogicalPlan],
      tableSpec: TableSpecBase,
      ident: TableIdentifier,
      tableSchema: StructType,
      partitioning: Seq[Transform],
      ignoreIfExists: Boolean,
      storageFormat: CatalogStorageFormat,
      provider: String): CreateTableV1 = {
    val tableDesc = buildCatalogTable(
      ident, tableSchema, partitioning, tableSpec.properties, provider, tableSpec.location,
      tableSpec.comment, tableSpec.collation, storageFormat, tableSpec.external)
    val mode = if (ignoreIfExists) SaveMode.Ignore else SaveMode.ErrorIfExists
    CreateTableV1(tableDesc, mode, query)
  }

  private def buildStorageFormatFromSerdeInfo(
      location: Option[String],
      maybeSerdeInfo: Option[SerdeInfo]): CatalogStorageFormat = {
    HiveSerDe.buildStorageFormat(
      location,
      maybeSerdeInfo,
      si => QueryCompilationErrors.invalidFileFormatForStoredAsError(si))
  }

  private def getStorageFormatAndProvider(
      provider: Option[String],
      options: Map[String, String],
      location: Option[String],
      maybeSerdeInfo: Option[SerdeInfo],
      ctas: Boolean): (CatalogStorageFormat, String) = {
    val nonHiveStorageFormat = CatalogStorageFormat.empty.copy(
      locationUri = location.map(CatalogUtils.stringToURI),
      properties = options)
    val defaultHiveStorage = HiveSerDe.getDefaultStorage(conf).copy(
      locationUri = location.map(CatalogUtils.stringToURI),
      properties = options)

    if (provider.isDefined) {
      // The parser guarantees that USING and STORED AS/ROW FORMAT won't co-exist.
      if (maybeSerdeInfo.isDefined) {
        throw QueryCompilationErrors.cannotCreateTableWithBothProviderAndSerdeError(
          provider, maybeSerdeInfo)
      }
      (nonHiveStorageFormat, provider.get)
    } else if (maybeSerdeInfo.isDefined) {
      val serdeInfo = maybeSerdeInfo.get
      SerdeInfo.checkSerdePropMerging(serdeInfo.serdeProperties, defaultHiveStorage.properties)
      val storageFormat = if (serdeInfo.storedAs.isDefined) {
        // If `STORED AS fileFormat` is used, infer inputFormat, outputFormat and serde from it.
        HiveSerDe.sourceToSerDe(serdeInfo.storedAs.get) match {
          case Some(hiveSerde) =>
            defaultHiveStorage.copy(
              inputFormat = hiveSerde.inputFormat.orElse(defaultHiveStorage.inputFormat),
              outputFormat = hiveSerde.outputFormat.orElse(defaultHiveStorage.outputFormat),
              // User specified serde takes precedence over the one inferred from file format.
              serde = serdeInfo.serde.orElse(hiveSerde.serde).orElse(defaultHiveStorage.serde),
              properties = serdeInfo.serdeProperties ++ defaultHiveStorage.properties)
          case _ => throw QueryCompilationErrors.invalidFileFormatForStoredAsError(serdeInfo)
        }
      } else {
        defaultHiveStorage.copy(
          inputFormat =
            serdeInfo.formatClasses.map(_.input).orElse(defaultHiveStorage.inputFormat),
          outputFormat =
            serdeInfo.formatClasses.map(_.output).orElse(defaultHiveStorage.outputFormat),
          serde = serdeInfo.serde.orElse(defaultHiveStorage.serde),
          properties = serdeInfo.serdeProperties ++ defaultHiveStorage.properties)
      }
      (storageFormat, DDLUtils.HIVE_PROVIDER)
    } else {
      // If neither USING nor STORED AS/ROW FORMAT is specified, we create native data source
      // tables if:
      //   1. `LEGACY_CREATE_HIVE_TABLE_BY_DEFAULT` is false, or
      //   2. It's a CTAS and `conf.convertCTAS` is true.
      val createHiveTableByDefault = conf.getConf(SQLConf.LEGACY_CREATE_HIVE_TABLE_BY_DEFAULT)
      if (!createHiveTableByDefault || (ctas && conf.convertCTAS)) {
        (nonHiveStorageFormat, conf.defaultDataSourceName)
      } else {
        logWarning(log"A Hive serde table will be created as there is no table provider " +
          log"specified. You can set " +
          log"${MDC(CONFIG, SQLConf.LEGACY_CREATE_HIVE_TABLE_BY_DEFAULT.key)} to false so that " +
          log"native data source table will be created instead.")
        (defaultHiveStorage, DDLUtils.HIVE_PROVIDER)
      }
    }
  }

  private def buildCatalogTable(
      table: TableIdentifier,
      schema: StructType,
      partitioning: Seq[Transform],
      properties: Map[String, String],
      provider: String,
      location: Option[String],
      comment: Option[String],
      collation: Option[String],
      storageFormat: CatalogStorageFormat,
      external: Boolean): CatalogTable = {
    val tableType = if (external || location.isDefined) {
      CatalogTableType.EXTERNAL
    } else {
      CatalogTableType.MANAGED
    }
    val (partitionColumns, maybeBucketSpec, maybeClusterBySpec) = partitioning.convertTransforms

    CatalogTable(
      identifier = table,
      tableType = tableType,
      storage = storageFormat,
      schema = schema,
      provider = Some(provider),
      partitionColumnNames = partitionColumns,
      bucketSpec = maybeBucketSpec,
      properties = properties ++
        maybeClusterBySpec.map(
          clusterBySpec => ClusterBySpec.toProperty(schema, clusterBySpec, conf.resolver)),
      comment = comment,
      collation = collation
    )
  }

  object ResolvedViewIdentifier {
    // Only matches session-catalog persistent views. Non-session-catalog persistent views
    // (produced for `DelegatingTable`) fall through and are picked up by dedicated v2 strategy
    // cases in `DataSourceV2Strategy` -- AlterViewAs, SET/UNSET TBLPROPERTIES, ALTER VIEW ...
    // WITH SCHEMA, RENAME TO, SHOW CREATE TABLE, SHOW TBLPROPERTIES, SHOW COLUMNS, DESCRIBE
    // [COLUMN] all dispatch to v2 view execs that consume `ResolvedPersistentView.info`
    // directly.
    def unapply(resolved: LogicalPlan): Option[TableIdentifier] = resolved match {
      case ResolvedPersistentView(catalog, ident, _) if isSessionCatalog(catalog) =>
        Some(ident.asTableIdentifier.copy(catalog = Some(catalog.name)))

      case ResolvedTempView(ident, _) =>
        // Global temp views have a namespace (e.g. global_temp); preserve it so ALTER VIEW
        // and DROP VIEW find the view. Local temp views are keyed by table name only.
        // system.session.viewName -> use database "session" so SessionCatalog resolves
        // it as a temp view.
        val db = CatalogManager.databaseForSessionQualifiedViewIdentifier(ident.namespace().toSeq)
        Some(TableIdentifier(ident.name(), db))

      case _ => None
    }
  }

  object ResolvedV1TableIdentifier {
    def unapply(resolved: LogicalPlan): Option[TableIdentifier] = resolved match {
      case ResolvedTable(catalog, _, t: V1Table, _) if supportsV1Command(catalog) =>
        Some(t.catalogTable.identifier)
      case _ => None
    }
  }

  object ResolvedV1TableIdentifierInSessionCatalog {
    def unapply(resolved: LogicalPlan): Option[TableIdentifier] = resolved match {
      case ResolvedTable(catalog, _, t: V1Table, _) if isSessionCatalog(catalog) =>
        Some(t.catalogTable.identifier)
      case _ => None
    }
  }

  object ResolvedV1TableOrViewIdentifier {
    def unapply(resolved: LogicalPlan): Option[TableIdentifier] = resolved match {
      case ResolvedV1TableIdentifier(ident) => Some(ident)
      case ResolvedViewIdentifier(ident) => Some(ident)
      case _ => None
    }
  }

  object ResolvedV1Identifier {
    def unapply(resolved: LogicalPlan): Option[TableIdentifier] = resolved match {
      case ResolvedIdentifier(catalog, ident) if supportsV1Command(catalog) =>
        Some(ident.asTableIdentifier.copy(catalog = Some(catalog.name)))
      case _ => None
    }
  }

  private object CreateViewInSessionCatalog
    extends ResolvedIdentifierInSessionCatalog("CREATE", "VIEW")
  private object DropViewInSessionCatalog
    extends ResolvedIdentifierInSessionCatalog("DROP", "VIEW")
  private object CreateFunctionInSessionCatalog
    extends ResolvedIdentifierInSessionCatalog("CREATE", "FUNCTION")

  /**
   * Extractor for resolved identifiers in the session catalog.
   * Rejects multi-part namespaces and builtin namespace targets with appropriate errors.
   *
   * @param statement the SQL statement (e.g. "CREATE", "DROP") for error messages
   * @param objectType the object type (e.g. "FUNCTION", "VIEW") for error messages
   */
  class ResolvedIdentifierInSessionCatalog(statement: String, objectType: String) {
    def unapply(resolved: LogicalPlan): Option[TableIdentifier] = resolved match {
      case ResolvedIdentifier(catalog, ident) if isSessionCatalog(catalog) =>
        if (ident.namespace().length != 1) {
          if (ident.namespace().length >= 1 &&
              ident.namespace().last.equalsIgnoreCase(CatalogManager.BUILTIN_NAMESPACE)) {
            throw QueryCompilationErrors.operationNotAllowedOnBuiltinNamespaceError(
              statement, objectType, ident.name())
          }
          // Pass full identifier (namespace + name) so error shows e.g. "`a`.`b`.`c`"
          throw QueryCompilationErrors
            .requiresSinglePartNamespaceError(ident.namespace().toSeq :+ ident.name())
        }
        Some(TableIdentifier(ident.name(), Some(ident.namespace().head), Some(catalog.name)))
      case _ => None
    }
  }

  private def isV2Provider(provider: String): Boolean = {
    DataSourceV2Utils.getTableProvider(provider, conf).isDefined
  }

  /**
   * Validates and constant-folds a column default value expression for V1 tables.
   * Populates both CURRENT_DEFAULT and EXISTS_DEFAULT in the metadata builder.
   */
  private def addDefaultValueMetadata(
      builder: MetadataBuilder,
      defaultValue: DefaultValueExpression,
      colName: String,
      colType: DataType,
      statementType: String): Unit = {
    val existsDefaultSQL = constantFoldDefaultValue(defaultValue, colName, colType, statementType)
    builder.putExpression(
      DefaultCols.CURRENT_DEFAULT_COLUMN_METADATA_KEY, defaultValue.originalSQL,
      Some(defaultValue.child))
    builder.putString(DefaultCols.EXISTS_DEFAULT_COLUMN_METADATA_KEY, existsDefaultSQL)
  }

  /**
   * Validates and constant-folds a column default value expression for V1 tables.
   * Returns the SQL string of the constant-folded expression for EXISTS_DEFAULT metadata.
   */
  private def constantFoldDefaultValue(
      defaultValue: DefaultValueExpression,
      colName: String,
      colType: DataType,
      statementType: String): String = {
    validateDefaultValueExpr(defaultValue, statementType, colName, Some(colType))
    val fakePlan = Project(Seq(Alias(defaultValue.child, colName)()), OneRowRelation())
    val optimizedPlan = ConstantFolding(DefaultColumnOptimizer.FinishAnalysis(fakePlan))
    val constantFolded = optimizedPlan.collectFirst {
      case Project(Seq(expr), OneRowRelation()) => trimAliases(expr)
    }.get
    if (!constantFolded.foldable) {
      throw QueryCompilationErrors.defaultValueNotConstantError(statementType, colName,
        defaultValue.originalSQL)
    }
    constantFolded.sql
  }

  private object ResolvedV1Database {
    def unapply(resolved: ResolvedNamespace): Option[String] = resolved match {
      case ResolvedNamespace(catalog, _, _) if !supportsV1Command(catalog) => None
      case ResolvedNamespace(_, Seq(), _) =>
        throw QueryCompilationErrors.databaseFromV1SessionCatalogNotSpecifiedError()
      case ResolvedNamespace(_, Seq(dbName), _) => Some(dbName)
      case _ =>
        assert(resolved.namespace.length > 1)
        throw QueryCompilationErrors.nestedDatabaseUnsupportedByV1SessionCatalogError(
          resolved.namespace.map(quoteIfNeeded).mkString("."))
    }
  }

  // Use this object to help match commands that do not have a v2 implementation.
  private object ResolvedDatabaseInSessionCatalog {
    def unapply(resolved: ResolvedNamespace): Option[String] = resolved match {
      case ResolvedNamespace(catalog, _, _) if !isSessionCatalog(catalog) => None
      case ResolvedNamespace(_, Seq(), _) =>
        throw QueryCompilationErrors.databaseFromV1SessionCatalogNotSpecifiedError()
      case ResolvedNamespace(_, Seq(dbName), _) => Some(dbName)
      case _ =>
        assert(resolved.namespace.length > 1)
        throw QueryCompilationErrors.nestedDatabaseUnsupportedByV1SessionCatalogError(
          resolved.namespace.map(quoteIfNeeded).mkString("."))
    }
  }

  private object DatabaseNameInSessionCatalog {
    def unapply(resolved: ResolvedNamespace): Option[String] = resolved match {
      case ResolvedNamespace(catalog, _, _) if !supportsV1Command(catalog) => None
      case ResolvedNamespace(_, Seq(dbName), _) => Some(dbName)
      case _ =>
        assert(resolved.namespace.length > 1)
        throw QueryCompilationErrors.requiresSinglePartNamespaceError(resolved.namespace)
    }
  }

  private def supportsV1Command(catalog: CatalogPlugin): Boolean = {
    isSessionCatalog(catalog) && (
      SQLConf.get.getConf(SQLConf.V2_SESSION_CATALOG_IMPLEMENTATION) == "builtin" ||
        catalog.isInstanceOf[CatalogExtension])
  }

}
