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

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


The following commit(s) were added to refs/heads/main by this push:
     new b71a64ddc2 Spark: [3/N] Support sending referenced-by to all endpoints 
(#13979)
b71a64ddc2 is described below

commit b71a64ddc28e4b39e07cec71cd12bc5ed293f8bb
Author: Prashant Singh <[email protected]>
AuthorDate: Tue Nov 3 14:47:21 2026 -0800

    Spark: [3/N] Support sending referenced-by to all endpoints (#13979)
    
    * Spark: Send the referenced-by view chain through SparkCatalog
    
    Resolve the chain of views a relation was reached through and pass it to the
    catalog as a LoadContext, so a REST catalog can forward it to the server.
    
    SparkSupportsLoadContext extends the loading contract with context-carrying
    overloads of loadTable and loadView, including the time-travel forms, and
    SparkCatalog and SparkSessionCatalog implement it. ResolveViews records the
    chain while resolving nested views, with UnResolvedRelationFromView 
carrying it
    through analysis. Cross-catalog view references fail rather than silently
    dropping the chain.
    
    Gated behind spark.sql.iceberg.referenced-by-enabled, off by default.
    
    * Spark: Move referenced-by context to Spark 4.2
    
    Generated-by: Codex
    
    * Spark: Fix ViewUtil Scala import grouping
    
    Generated-by: Codex
    
    * Spark 4.2: Implement contextual loadTable in TestSparkCatalog
    
    TestSparkCatalog only overrode loadTable(Identifier), so once the read
    path started resolving through loadRelation -> loadTable(Identifier,
    LoadContext), the double was no longer consulted and lookups fell
    through to the real catalog.
    
    Override the contextual variant to delegate to the existing one. The
    double ignores the context; TestReferencedByViewChain covers the
    referenced-by behaviour against real catalogs.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
    
    * Spark 4.2: Restore explicit referenced-by view resolution
    
    Restore the Spark 4.1-style analyzer path for referenced-by propagation in 
Spark 4.2. ResolveViews now expands Iceberg views, carries the view chain 
through UnResolvedRelationFromView, and loads nested relations with an explicit 
LoadContext instead of using SQLConf-backed view metadata.
    
    Generated-by: Codex
    
    * Core, Spark: Address referenced-by review feedback
    
    Preserve existing cross-catalog view behavior by dropping catalog-relative 
referenced-by entries that cannot be represented for the target catalog. Bypass 
CachingCatalog for non-empty LoadContext loads so referenced-by reaches 
catalogs such as REST, and add regression coverage for cache-enabled Spark 
reads, time-travel views, and cross-catalog view references.
    
    Generated-by: Codex
    
    * Spark 4.2: Gate referenced-by view resolution
    
    Add a default-off Spark SQL flag for referenced-by view-chain propagation. 
When disabled, Spark keeps its native view resolution behavior, including 
cross-catalog views. When enabled, Iceberg uses the explicit analyzer path and 
rejects cross-catalog chains because LoadContext carries catalog-relative 
identifiers.
    
    Generated-by: Codex
    
    * Spark 4.2: Use resolved view identity for referenced-by
    
    Build the referenced-by view chain from the catalog and identifier Spark 
resolved when loading the view. Keep view metadata current catalog and 
namespace only for resolving unqualified references inside the view SQL.
    
    Generated-by: Codex
    
    * Spark 4.2: Address referenced-by review feedback
    
    Keep the view chain on Spark's pre-execution table refresh by binding
    it to the catalog of relations resolved through a view, so a table read
    through a view is never reloaded as direct access. Convert view time
    travel with Spark's TimeTravelSpec so string and integer timestamps work
    and pinned snapshots are not refreshed. Leave V1 and delegating tables
    read through a view to Spark, and alias view-resolved tables so
    table-qualified columns resolve. Remove loadRelation overrides that
    duplicate BaseCatalog.
    
    * Spark 4.2: Resolve referenced-by views in a dedicated rule
    
    Move referenced-by view expansion out of ResolveViews into
    ResolveReferencedByViews, which does nothing when referenced-by is
    disabled. ResolveViews is back to its main behavior.
    
    With referenced-by enabled, BaseCatalog.loadRelation returns tables
    only, so Spark resolves tables itself and leaves Iceberg views
    unresolved for the new rule. This removes the hint-batch registration,
    which Spark's Hive session state ignores, and keeps a table read to a
    single load. Expanded views are wrapped in Spark's View node so cycle
    detection and cache invalidation keep working, and parse, streaming and
    not-found errors match Spark's.
    
    LoadContext gains equals and hashCode so cached plans over a view are
    reused by later queries.
    
    SparkSessionCatalog fails a load with a referenced-by chain when its
    Iceberg catalog cannot pass the context, instead of dropping it.
    
    Generated-by: Claude Code
    
    * Core, Spark 4.2: Bound referenced-by view depth and canonicalize 
contextual loads
    
    Fail referenced-by view expansion past spark.sql.view.maxNestedViewDepth
    with the same error Spark raises when referenced-by is disabled, so a
    view cycle no longer overflows the stack during analysis.
    
    CachingCatalog now sends contextual loads the same canonical identifier
    as cached loads, so a table read through a view and read directly reach
    the delegate under one name when the cache is case-insensitive.
    
    Drop two Spark tests covered by more specific ones.
    
    Generated-by: Claude Code
    
    * Spark 4.2: Expand referenced-by views through SessionCatalog.getRelation
    
    Referenced-by view expansion now uses the same Spark path as when the
    flag is off, so view columns bind by name, every view schema mode is
    honored, and the view text is parsed with the SQL configs stored at
    CREATE VIEW. Previously columns were bound by position, which returned
    wrong data after the underlying columns were reordered.
    
    Tests that should behave the same with the flag on or off now run with
    both settings and assert the expected outcome directly.
    
    Generated-by: Claude Code
    
    ---------
    
    Co-authored-by: Prashant Singh <[email protected]>
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
---
 .../org/apache/iceberg/catalog/LoadContext.java    |  19 +
 .../java/org/apache/iceberg/CachingCatalog.java    |  10 +
 .../apache/iceberg/hadoop/TestCachingCatalog.java  |  66 ++
 .../extensions/IcebergSparkSessionExtensions.scala |   2 +
 .../analysis/LoadContextTableCatalog.scala         |  59 ++
 .../analysis/ResolveReferencedByViews.scala        | 256 +++++++
 .../spark/sql/catalyst/analysis/ResolveViews.scala |   3 +-
 .../logical/views/UnResolvedRelationFromView.scala |  50 ++
 .../extensions/TestReferencedByViewChain.java      | 788 +++++++++++++++++++++
 .../java/org/apache/iceberg/spark/BaseCatalog.java |   7 +
 .../org/apache/iceberg/spark/SparkCatalog.java     |  41 +-
 .../apache/iceberg/spark/SparkSQLProperties.java   |   5 +
 .../apache/iceberg/spark/SparkSessionCatalog.java  |  68 +-
 .../iceberg/spark/SparkSupportsLoadContext.java    |  78 ++
 .../spark/sql/catalyst/analysis/ViewUtil.scala     |  82 +++
 .../iceberg/spark/TestSparkSessionCatalog.java     |  45 ++
 .../iceberg/spark/source/TestSparkCatalog.java     |   6 +
 17 files changed, 1573 insertions(+), 12 deletions(-)

diff --git a/api/src/main/java/org/apache/iceberg/catalog/LoadContext.java 
b/api/src/main/java/org/apache/iceberg/catalog/LoadContext.java
index af31a37282..dce80095b8 100644
--- a/api/src/main/java/org/apache/iceberg/catalog/LoadContext.java
+++ b/api/src/main/java/org/apache/iceberg/catalog/LoadContext.java
@@ -60,6 +60,25 @@ public final class LoadContext {
     return EMPTY;
   }
 
+  @Override
+  public boolean equals(Object other) {
+    if (this == other) {
+      return true;
+    }
+
+    if (other == null || getClass() != other.getClass()) {
+      return false;
+    }
+
+    LoadContext that = (LoadContext) other;
+    return referencedBy.equals(that.referencedBy);
+  }
+
+  @Override
+  public int hashCode() {
+    return referencedBy.hashCode();
+  }
+
   public static final class Builder {
     private List<TableIdentifier> referencedBy;
 
diff --git a/core/src/main/java/org/apache/iceberg/CachingCatalog.java 
b/core/src/main/java/org/apache/iceberg/CachingCatalog.java
index 4ee6d61c09..cf0c0836d8 100644
--- a/core/src/main/java/org/apache/iceberg/CachingCatalog.java
+++ b/core/src/main/java/org/apache/iceberg/CachingCatalog.java
@@ -29,6 +29,7 @@ import java.util.Locale;
 import java.util.Map;
 import java.util.concurrent.atomic.AtomicBoolean;
 import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.LoadContext;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.exceptions.AlreadyExistsException;
@@ -168,6 +169,15 @@ public class CachingCatalog implements Catalog {
     return table;
   }
 
+  @Override
+  public Table loadTable(TableIdentifier ident, LoadContext context) {
+    if (context == null || context.referencedBy().isEmpty()) {
+      return loadTable(ident);
+    }
+
+    return catalog.loadTable(canonicalizeIdentifier(ident), context);
+  }
+
   @Override
   public boolean dropTable(TableIdentifier ident, boolean purge) {
     boolean dropped = catalog.dropTable(ident, purge);
diff --git 
a/core/src/test/java/org/apache/iceberg/hadoop/TestCachingCatalog.java 
b/core/src/test/java/org/apache/iceberg/hadoop/TestCachingCatalog.java
index 1c36c1acea..4a8953b5f9 100644
--- a/core/src/test/java/org/apache/iceberg/hadoop/TestCachingCatalog.java
+++ b/core/src/test/java/org/apache/iceberg/hadoop/TestCachingCatalog.java
@@ -31,6 +31,7 @@ import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
+import org.apache.hadoop.conf.Configuration;
 import org.apache.iceberg.BaseMetadataTable;
 import org.apache.iceberg.CachingCatalog;
 import org.apache.iceberg.CatalogProperties;
@@ -40,6 +41,7 @@ import org.apache.iceberg.Snapshot;
 import org.apache.iceberg.Table;
 import org.apache.iceberg.TestableCachingCatalog;
 import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.LoadContext;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.exceptions.NoSuchTableException;
@@ -275,6 +277,42 @@ public class TestCachingCatalog extends 
HadoopTableTestBase {
     
assertThat(catalog.remainingAgeFor(tableIdent)).get().isEqualTo(HALF_OF_EXPIRATION);
   }
 
+  @Test
+  void contextualLoadBypassesTableCache() throws IOException {
+    ContextTrackingHadoopCatalog underlyingCatalog = 
contextTrackingHadoopCatalog();
+    TestableCachingCatalog catalog =
+        TestableCachingCatalog.wrap(underlyingCatalog, EXPIRATION_TTL, ticker);
+    Namespace namespace = Namespace.of("db", "ns1", "ns2");
+    TableIdentifier tableIdent = TableIdentifier.of(namespace, "tbl");
+    TableIdentifier viewIdent = TableIdentifier.of(namespace, "view");
+    Table cachedTable = catalog.createTable(tableIdent, SCHEMA, SPEC, 
ImmutableMap.of());
+
+    LoadContext context = 
LoadContext.builder().referencedBy(Lists.newArrayList(viewIdent)).build();
+    Table contextualTable = catalog.loadTable(tableIdent, context);
+
+    assertThat(contextualTable).isNotSameAs(cachedTable);
+    assertThat(catalog.cache().asMap()).containsEntry(tableIdent, cachedTable);
+    assertThat(underlyingCatalog.loadContexts()).hasSize(1);
+    
assertThat(underlyingCatalog.loadContexts().get(0).referencedBy()).containsExactly(viewIdent);
+  }
+
+  @Test
+  void contextualLoadUsesCanonicalIdentifierWhenCaseInsensitive() throws 
IOException {
+    ContextTrackingHadoopCatalog underlyingCatalog = 
contextTrackingHadoopCatalog();
+    Catalog catalog = CachingCatalog.wrap(underlyingCatalog, false, 
EXPIRATION_TTL.toMillis());
+    Namespace namespace = Namespace.of("db");
+    TableIdentifier tableIdent = TableIdentifier.of(namespace, "tbl");
+    catalog.createTable(tableIdent, SCHEMA, SPEC, ImmutableMap.of());
+
+    LoadContext context =
+        LoadContext.builder()
+            .referencedBy(Lists.newArrayList(TableIdentifier.of(namespace, 
"view")))
+            .build();
+    catalog.loadTable(TableIdentifier.of(Namespace.of("DB"), "TBL"), context);
+
+    
assertThat(underlyingCatalog.loadIdentifiers()).containsExactly(tableIdent);
+  }
+
   @Test
   public void testCacheExpirationEagerlyRemovesMetadataTables() throws 
IOException {
     TestableCachingCatalog catalog =
@@ -441,4 +479,32 @@ public class TestCachingCatalog extends 
HadoopTableTestBase {
         .map(type -> TableIdentifier.parse(tableIdent + "." + 
type.name().toLowerCase(Locale.ROOT)))
         .toArray(TableIdentifier[]::new);
   }
+
+  private ContextTrackingHadoopCatalog contextTrackingHadoopCatalog() {
+    ContextTrackingHadoopCatalog catalog = new ContextTrackingHadoopCatalog();
+    catalog.setConf(new Configuration());
+    catalog.initialize(
+        "hadoop", ImmutableMap.of(CatalogProperties.WAREHOUSE_LOCATION, 
tempDir.getAbsolutePath()));
+    return catalog;
+  }
+
+  private static class ContextTrackingHadoopCatalog extends HadoopCatalog {
+    private final List<TableIdentifier> loadIdentifiers = Lists.newArrayList();
+    private final List<LoadContext> loadContexts = Lists.newArrayList();
+
+    @Override
+    public Table loadTable(TableIdentifier identifier, LoadContext context) {
+      loadIdentifiers.add(identifier);
+      loadContexts.add(context);
+      return super.loadTable(identifier);
+    }
+
+    private List<TableIdentifier> loadIdentifiers() {
+      return loadIdentifiers;
+    }
+
+    private List<LoadContext> loadContexts() {
+      return loadContexts;
+    }
+  }
 }
diff --git 
a/spark/v4.2/spark-extensions/src/main/scala/org/apache/iceberg/spark/extensions/IcebergSparkSessionExtensions.scala
 
b/spark/v4.2/spark-extensions/src/main/scala/org/apache/iceberg/spark/extensions/IcebergSparkSessionExtensions.scala
index 81824e05e9..f8532b1296 100644
--- 
a/spark/v4.2/spark-extensions/src/main/scala/org/apache/iceberg/spark/extensions/IcebergSparkSessionExtensions.scala
+++ 
b/spark/v4.2/spark-extensions/src/main/scala/org/apache/iceberg/spark/extensions/IcebergSparkSessionExtensions.scala
@@ -21,6 +21,7 @@ package org.apache.iceberg.spark.extensions
 import org.apache.spark.sql.SparkSessionExtensions
 import org.apache.spark.sql.catalyst.analysis.CheckViews
 import org.apache.spark.sql.catalyst.analysis.ResolveBranch
+import org.apache.spark.sql.catalyst.analysis.ResolveReferencedByViews
 import org.apache.spark.sql.catalyst.analysis.ResolveViews
 import org.apache.spark.sql.catalyst.optimizer.ReplaceStaticInvoke
 import 
org.apache.spark.sql.catalyst.parser.extensions.IcebergSparkSqlExtensionsParser
@@ -33,6 +34,7 @@ class IcebergSparkSessionExtensions extends 
(SparkSessionExtensions => Unit) {
     extensions.injectParser { case (_, parser) => new 
IcebergSparkSqlExtensionsParser(parser) }
 
     // analyzer extensions
+    extensions.injectResolutionRule { spark => ResolveReferencedByViews(spark) 
}
     extensions.injectResolutionRule { spark => ResolveViews(spark) }
     extensions.injectPostHocResolutionRule { spark => ResolveBranch(spark) }
     extensions.injectCheckRule(_ => CheckViews)
diff --git 
a/spark/v4.2/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/LoadContextTableCatalog.scala
 
b/spark/v4.2/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/LoadContextTableCatalog.scala
new file mode 100644
index 0000000000..4c5d4bcaf7
--- /dev/null
+++ 
b/spark/v4.2/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/LoadContextTableCatalog.scala
@@ -0,0 +1,59 @@
+/*
+ * 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.iceberg.catalog.LoadContext
+import org.apache.iceberg.spark.SparkSupportsLoadContext
+import org.apache.spark.sql.connector.catalog.Identifier
+import org.apache.spark.sql.connector.catalog.Table
+import org.apache.spark.sql.connector.catalog.TableCatalog
+import org.apache.spark.sql.connector.catalog.TableChange
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+
+/**
+ * Catalog for a table read through a view, so that reloads keep the view 
chain. Spark's table
+ * version refresh before execution reloads tables by identifier only, using 
the relation's
+ * catalog. Only read operations are supported.
+ */
+private[analysis] case class LoadContextTableCatalog(
+    delegate: TableCatalog with SparkSupportsLoadContext,
+    context: LoadContext)
+    extends TableCatalog {
+
+  override def name(): String = delegate.name()
+
+  override def initialize(name: String, options: CaseInsensitiveStringMap): 
Unit = {}
+
+  override def loadTable(ident: Identifier): Table = delegate.loadTable(ident, 
context)
+
+  override def invalidateTable(ident: Identifier): Unit = 
delegate.invalidateTable(ident)
+
+  override def listTables(namespace: Array[String]): Array[Identifier] =
+    delegate.listTables(namespace)
+
+  override def alterTable(ident: Identifier, changes: TableChange*): Table =
+    throw new UnsupportedOperationException(s"Cannot alter table $ident 
through a view relation")
+
+  override def dropTable(ident: Identifier): Boolean =
+    throw new UnsupportedOperationException(s"Cannot drop table $ident through 
a view relation")
+
+  override def renameTable(oldIdent: Identifier, newIdent: Identifier): Unit =
+    throw new UnsupportedOperationException(
+      s"Cannot rename table $oldIdent through a view relation")
+}
diff --git 
a/spark/v4.2/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveReferencedByViews.scala
 
b/spark/v4.2/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveReferencedByViews.scala
new file mode 100644
index 0000000000..d096437564
--- /dev/null
+++ 
b/spark/v4.2/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveReferencedByViews.scala
@@ -0,0 +1,256 @@
+/*
+ * 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.iceberg.catalog.LoadContext
+import org.apache.iceberg.spark.SparkSupportsLoadContext
+import org.apache.spark.sql.SparkSession
+import 
org.apache.spark.sql.catalyst.analysis.ResolveReferencedByViews.CheckedForView
+import org.apache.spark.sql.catalyst.expressions.SubqueryExpression
+import org.apache.spark.sql.catalyst.plans.logical.{View => ViewNode}
+import org.apache.spark.sql.catalyst.plans.logical.LogicalPlan
+import org.apache.spark.sql.catalyst.plans.logical.SubqueryAlias
+import 
org.apache.spark.sql.catalyst.plans.logical.views.UnResolvedRelationFromView
+import org.apache.spark.sql.catalyst.rules.Rule
+import org.apache.spark.sql.catalyst.trees.TreeNodeTag
+import org.apache.spark.sql.connector.catalog.CatalogManager
+import org.apache.spark.sql.connector.catalog.CatalogPlugin
+import org.apache.spark.sql.connector.catalog.CatalogV2Implicits._
+import org.apache.spark.sql.connector.catalog.DelegatingTable
+import org.apache.spark.sql.connector.catalog.Identifier
+import org.apache.spark.sql.connector.catalog.LookupCatalog
+import org.apache.spark.sql.connector.catalog.TableCatalog
+import org.apache.spark.sql.connector.catalog.V1Table
+import org.apache.spark.sql.connector.catalog.View
+import org.apache.spark.sql.errors.QueryCompilationErrors
+import org.apache.spark.sql.execution.datasources.v2.DataSourceV2Relation
+
+/**
+ * Expands Iceberg views when referenced-by is enabled, so that the tables and 
views they read are
+ * loaded with the referencing view chain.
+ *
+ * Spark 4.2 expands views itself without passing the view identity to the 
catalog, so with
+ * referenced-by enabled Iceberg catalogs leave views unresolved for this rule.
+ */
+case class ResolveReferencedByViews(spark: SparkSession)
+    extends Rule[LogicalPlan]
+    with LookupCatalog {
+
+  protected lazy val catalogManager: CatalogManager = 
spark.sessionState.catalogManager
+
+  override def apply(plan: LogicalPlan): LogicalPlan = {
+    if (ViewUtil.referencedByEnabled) {
+      resolveWithReferencedBy(plan)
+    } else {
+      plan
+    }
+  }
+
+  private def resolveWithReferencedBy(plan: LogicalPlan): LogicalPlan = plan 
resolveOperators {
+    case u @ UnresolvedRelation(nameParts, _, _) if isTempView(nameParts) =>
+      u
+
+    case u @ UnresolvedRelation(CatalogAndIdentifier(catalog, ident), _, true)
+        if u.getTagValue(CheckedForView).isEmpty =>
+      ViewUtil.loadView(catalog, ident).foreach { _ =>
+        throw 
QueryCompilationErrors.permanentViewNotSupportedByStreamingReadingAPIError(
+          ident.quoted)
+      }
+      markCheckedForView(u)
+
+    case u @ UnresolvedRelation(CatalogAndIdentifier(catalog, ident), options, 
_)
+        if !options.containsKey(UnresolvedRelation.REQUIRED_WRITE_PRIVILEGES) 
&&
+          u.getTagValue(CheckedForView).isEmpty =>
+      ViewUtil
+        .loadView(catalog, ident)
+        .map(createViewRelation(catalog, ident, _))
+        .getOrElse(markCheckedForView(u))
+
+    case u @ UnResolvedRelationFromView(
+          tableParts @ CatalogAndIdentifier(catalog, tableIdent),
+          _,
+          _,
+          viewChain,
+          options,
+          isStreaming,
+          timeTravelVersion,
+          timeTravelTimestamp) if timeTravelTimestamp.forall(_.resolved) =>
+      val referencedBy = ViewUtil.buildReferencedByChain(viewChain, 
catalog.name())
+      val context = LoadContext.builder().referencedBy(referencedBy).build()
+      val timeTravelSpec =
+        TimeTravelSpec.create(timeTravelTimestamp, timeTravelVersion, 
conf.sessionLocalTimeZone)
+      try {
+        val table = ViewUtil.loadTable(catalog, tableIdent, context, 
timeTravelSpec)
+        table match {
+          case _: V1Table | _: DelegatingTable =>
+            val relation = markCheckedForView(UnresolvedRelation(tableParts, 
options, isStreaming))
+            if (timeTravelSpec.isEmpty) {
+              relation
+            } else {
+              RelationTimeTravel(relation, timeTravelTimestamp, 
timeTravelVersion)
+            }
+
+          case _ =>
+            val relationCatalog = catalog match {
+              case contextual: TableCatalog with SparkSupportsLoadContext
+                  if timeTravelSpec.isEmpty =>
+                LoadContextTableCatalog(contextual, context)
+              case _ => catalog
+            }
+            SubqueryAlias(
+              tableIdent.toQualifiedNameParts(catalog),
+              DataSourceV2Relation.create(
+                table,
+                Some(relationCatalog),
+                Some(tableIdent),
+                options,
+                timeTravelSpec))
+        }
+      } catch {
+        case _: NoSuchTableException =>
+          ViewUtil
+            .loadView(catalog, tableIdent, context)
+            .map { view =>
+              if (viewChain.size >= conf.maxNestedViewDepth) {
+                throw 
QueryCompilationErrors.viewDepthExceedsMaxResolutionDepthError(
+                  tableIdent.toQualifiedNameParts(catalog),
+                  conf.maxNestedViewDepth,
+                  u)
+              }
+              createViewRelation(catalog, tableIdent, view, viewChain)
+            }
+            .getOrElse(throw tableOrViewNotFound(u))
+      }
+  }
+
+  private def createViewRelation(
+      catalog: CatalogPlugin,
+      ident: Identifier,
+      view: View,
+      existingChain: Seq[Seq[String]] = Seq.empty): LogicalPlan = {
+    val desc = V1Table.toCatalogTable(catalog, ident, view)
+    val viewQueryCatalogAndNamespace = queryCatalogAndNamespace(catalog, 
ident, view)
+    val viewChain = existingChain :+ ident.toQualifiedNameParts(catalog)
+    catalogManager.v1SessionCatalog.getRelation(desc) transformDown { case v: 
ViewNode =>
+      v.copy(child = rewriteIdentifiers(v.child, viewQueryCatalogAndNamespace, 
viewChain))
+    }
+  }
+
+  private def queryCatalogAndNamespace(
+      catalog: CatalogPlugin,
+      ident: Identifier,
+      view: View): Seq[String] = {
+    val currentCatalog = 
Option(view.currentCatalog()).filter(_.nonEmpty).getOrElse(catalog.name())
+    val currentNamespace = 
Option(view.currentNamespace()).map(_.toIndexedSeq).getOrElse {
+      ident.namespace().toIndexedSeq
+    }
+    currentCatalog +: currentNamespace
+  }
+
+  private def rewriteIdentifiers(
+      plan: LogicalPlan,
+      catalogAndNamespace: Seq[String],
+      viewChain: Seq[Seq[String]]): LogicalPlan = {
+    qualifyTableIdentifiers(
+      qualifyFunctionIdentifiers(CTESubstitution.apply(plan), 
catalogAndNamespace),
+      catalogAndNamespace,
+      viewChain)
+  }
+
+  private def qualifyFunctionIdentifiers(
+      plan: LogicalPlan,
+      catalogAndNamespace: Seq[String]): LogicalPlan = plan 
transformExpressions {
+    case u @ UnresolvedFunction(Seq(name), _, _, _, _, _, _) =>
+      if (!isBuiltinFunction(name)) {
+        u.copy(nameParts = catalogAndNamespace :+ name)
+      } else {
+        u
+      }
+    case u @ UnresolvedFunction(parts, _, _, _, _, _, _) if 
!isCatalog(parts.head) =>
+      u.copy(nameParts = catalogAndNamespace.head +: parts)
+  }
+
+  private def qualifyTableIdentifiers(
+      child: LogicalPlan,
+      catalogAndNamespace: Seq[String],
+      viewChain: Seq[Seq[String]]): LogicalPlan = {
+    child transform {
+      case UnresolvedRelation(parts, options, isStreaming) =>
+        val qualifiedTableId = ViewUtil.qualifyParts(parts, 
catalogAndNamespace, isCatalog)
+        UnResolvedRelationFromView(
+          qualifiedTableId,
+          parts,
+          catalogAndNamespace,
+          viewChain,
+          options,
+          isStreaming)
+      case RelationTimeTravel(
+            UnresolvedRelation(parts, options, isStreaming),
+            timestampOpt,
+            versionOpt) =>
+        val qualifiedTableId = ViewUtil.qualifyParts(parts, 
catalogAndNamespace, isCatalog)
+        UnResolvedRelationFromView(
+          qualifiedTableId,
+          parts,
+          catalogAndNamespace,
+          viewChain,
+          options,
+          isStreaming,
+          timeTravelVersion = versionOpt,
+          timeTravelTimestamp = timestampOpt)
+      case other =>
+        other.transformExpressions { case subquery: SubqueryExpression =>
+          subquery.withNewPlan(
+            qualifyTableIdentifiers(subquery.plan, catalogAndNamespace, 
viewChain))
+        }
+    }
+  }
+
+  private def tableOrViewNotFound(relation: UnResolvedRelationFromView): 
Throwable = {
+    val catalogAndNamespace = relation.viewCatalogAndNamespace
+    val searchPath = catalogManager
+      .sqlResolutionPathEntries(catalogAndNamespace.head, 
catalogAndNamespace.tail)
+      .map(QueryCompilationErrors.toSQLId)
+    QueryCompilationErrors.tableOrViewNotFoundWithSearchPath(
+      relation.nameParts,
+      searchPath,
+      relation.origin)
+  }
+
+  private def markCheckedForView(relation: UnresolvedRelation): 
UnresolvedRelation = {
+    relation.setTagValue(CheckedForView, ())
+    relation
+  }
+
+  private def isTempView(nameParts: Seq[String]): Boolean = {
+    catalogManager.v1SessionCatalog.isTempView(nameParts)
+  }
+
+  private def isCatalog(name: String): Boolean = {
+    catalogManager.isCatalogRegistered(name)
+  }
+
+  private def isBuiltinFunction(name: String): Boolean = {
+    catalogManager.v1SessionCatalog.isBuiltinFunction(name)
+  }
+}
+
+object ResolveReferencedByViews {
+  private val CheckedForView = 
TreeNodeTag[Unit]("iceberg.referenced-by.checked-for-view")
+}
diff --git 
a/spark/v4.2/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveViews.scala
 
b/spark/v4.2/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveViews.scala
index 4945c7ea56..fac9cb6ec6 100644
--- 
a/spark/v4.2/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveViews.scala
+++ 
b/spark/v4.2/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/analysis/ResolveViews.scala
@@ -31,7 +31,8 @@ import org.apache.spark.sql.connector.catalog.LookupCatalog
  *
  * In Spark 4.2, view relations are expanded through 
`RelationCatalog.loadRelation`, so Spark owns
  * query parsing, identifier resolution, and schema-mode application. This 
rule only converts the
- * remaining command targets to `ResolvedV2View` for Iceberg command planning.
+ * remaining command targets to `ResolvedV2View` for Iceberg command planning. 
With referenced-by
+ * enabled, Iceberg views in queries are expanded by 
`ResolveReferencedByViews` instead.
  */
 case class ResolveViews(spark: SparkSession) extends Rule[LogicalPlan] with 
LookupCatalog {
   protected lazy val catalogManager: CatalogManager = 
spark.sessionState.catalogManager
diff --git 
a/spark/v4.2/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/views/UnResolvedRelationFromView.scala
 
b/spark/v4.2/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/views/UnResolvedRelationFromView.scala
new file mode 100644
index 0000000000..28820a8168
--- /dev/null
+++ 
b/spark/v4.2/spark-extensions/src/main/scala/org/apache/spark/sql/catalyst/plans/logical/views/UnResolvedRelationFromView.scala
@@ -0,0 +1,50 @@
+/*
+ * 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.plans.logical.views
+
+import org.apache.spark.sql.catalyst.expressions.Attribute
+import org.apache.spark.sql.catalyst.expressions.Expression
+import org.apache.spark.sql.catalyst.plans.logical.LeafNode
+import 
org.apache.spark.sql.connector.catalog.CatalogV2Implicits.MultipartIdentifierHelper
+import org.apache.spark.sql.util.CaseInsensitiveStringMap
+
+case class UnResolvedRelationFromView(
+    tableMultipartIdentifier: Seq[String],
+    nameParts: Seq[String],
+    viewCatalogAndNamespace: Seq[String],
+    viewChain: Seq[Seq[String]],
+    options: CaseInsensitiveStringMap = CaseInsensitiveStringMap.empty(),
+    override val isStreaming: Boolean = false,
+    timeTravelVersion: Option[String] = None,
+    timeTravelTimestamp: Option[Expression] = None)
+    extends LeafNode {
+
+  override def output: Seq[Attribute] = Nil
+
+  override lazy val resolved: Boolean = false
+
+  def tableName: String = tableMultipartIdentifier.quoted
+
+  def viewName: String = viewChain.map(_.quoted).mkString(" -> ")
+
+  override def simpleString(maxFields: Int): String = {
+    s"'UnResolvedRelationFromView [table=$tableName, views=$viewName, " +
+      s"${if (isStreaming) "streaming=true, " else ""}options=$options]"
+  }
+}
diff --git 
a/spark/v4.2/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestReferencedByViewChain.java
 
b/spark/v4.2/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestReferencedByViewChain.java
new file mode 100644
index 0000000000..cc0d7b8189
--- /dev/null
+++ 
b/spark/v4.2/spark-extensions/src/test/java/org/apache/iceberg/spark/extensions/TestReferencedByViewChain.java
@@ -0,0 +1,788 @@
+/*
+ * 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.iceberg.spark.extensions;
+
+import static org.apache.iceberg.TestHelpers.waitUntilAfter;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.net.InetAddress;
+import java.nio.file.Path;
+import java.sql.Timestamp;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+import org.apache.iceberg.CatalogProperties;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.catalog.LoadContext;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.SupportsNamespaces;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.catalog.ViewCatalog;
+import org.apache.iceberg.inmemory.InMemoryCatalog;
+import org.apache.iceberg.spark.Spark3Util;
+import org.apache.iceberg.spark.SparkCatalog;
+import org.apache.iceberg.spark.SparkSQLProperties;
+import org.apache.iceberg.spark.SparkSchemaUtil;
+import org.apache.iceberg.spark.SparkSessionCatalog;
+import org.apache.iceberg.spark.SparkTestHelperBase;
+import org.apache.iceberg.spark.TestBase;
+import org.apache.iceberg.spark.source.HasIcebergCatalog;
+import org.apache.iceberg.spark.source.SimpleRecord;
+import org.apache.iceberg.types.Types;
+import org.apache.iceberg.view.View;
+import org.apache.spark.SparkThrowable;
+import org.apache.spark.sql.AnalysisException;
+import org.apache.spark.sql.Dataset;
+import org.apache.spark.sql.Row;
+import org.apache.spark.sql.SparkSession;
+import org.apache.spark.sql.catalyst.analysis.NoSuchTableException;
+import org.apache.spark.sql.connector.catalog.CatalogPlugin;
+import org.assertj.core.api.ThrowableAssert.ThrowingCallable;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+public class TestReferencedByViewChain extends SparkTestHelperBase {
+
+  private static final String CATALOG_NAME = "ref_test_catalog";
+  private static final String OTHER_CATALOG_NAME = "other_ref_test_catalog";
+  private static final Namespace NAMESPACE = Namespace.of("default");
+  private static final Namespace VIEW_DEFAULT_NAMESPACE = 
Namespace.of("view_defaults");
+  private static final String TABLE_NAME = "test_table";
+  private static final String OTHER_TABLE_NAME = "other_table";
+  private static final String VIEW_DEFAULT_TABLE_NAME = "view_default_table";
+  private static final String VIEW_DEFAULT_VIEW_NAME = "view_default_view";
+  private static final String SESSION_CATALOG_NAME = "spark_catalog";
+  private static final String PARQUET_TABLE_NAME = "parquet_table";
+  private static final String PARQUET_VIEW_NAME = "parquet_view";
+  private static final String SCHEMA_TABLE_NAME = "schema_table";
+
+  @TempDir private static Path warehouse;
+
+  private static SparkSession spark;
+
+  public static class ContextTrackingCatalog extends InMemoryCatalog {
+    public static final List<CapturedContext> CAPTURED = new 
CopyOnWriteArrayList<>();
+    public static final List<CapturedContext> CAPTURED_VIEWS = new 
CopyOnWriteArrayList<>();
+
+    public static class CapturedContext {
+      public final TableIdentifier tableIdentifier;
+      public final List<TableIdentifier> referencedBy;
+
+      CapturedContext(TableIdentifier tableIdentifier, List<TableIdentifier> 
referencedBy) {
+        this.tableIdentifier = tableIdentifier;
+        this.referencedBy = referencedBy;
+      }
+    }
+
+    public static void clearCaptured() {
+      CAPTURED.clear();
+      CAPTURED_VIEWS.clear();
+    }
+
+    @Override
+    public Table loadTable(TableIdentifier identifier) {
+      CAPTURED.add(new CapturedContext(identifier, Collections.emptyList()));
+      return super.loadTable(identifier);
+    }
+
+    @Override
+    public Table loadTable(TableIdentifier identifier, LoadContext context) {
+      CAPTURED.add(new CapturedContext(identifier, referencedBy(context)));
+      return super.loadTable(identifier);
+    }
+
+    @Override
+    public View loadView(TableIdentifier identifier) {
+      CAPTURED_VIEWS.add(new CapturedContext(identifier, 
Collections.emptyList()));
+      return super.loadView(identifier);
+    }
+
+    @Override
+    public View loadView(TableIdentifier identifier, LoadContext context) {
+      CAPTURED_VIEWS.add(new CapturedContext(identifier, 
referencedBy(context)));
+      return super.loadView(identifier);
+    }
+
+    private static List<TableIdentifier> referencedBy(LoadContext context) {
+      return context != null ? context.referencedBy() : 
Collections.emptyList();
+    }
+  }
+
+  @BeforeAll
+  static void startSpark() {
+    spark =
+        SparkSession.builder()
+            .master("local[2]")
+            .config("spark.driver.host", 
InetAddress.getLoopbackAddress().getHostAddress())
+            .config(TestBase.DISABLE_UI)
+            .config("spark.sql.extensions", 
IcebergSparkSessionExtensions.class.getName())
+            .config("spark.sql.catalog." + CATALOG_NAME, 
SparkCatalog.class.getName())
+            .config(
+                "spark.sql.catalog." + CATALOG_NAME + "." + 
CatalogProperties.CATALOG_IMPL,
+                ContextTrackingCatalog.class.getName())
+            .config("spark.sql.catalog." + CATALOG_NAME + 
".default-namespace", "default")
+            .config("spark.sql.catalog." + CATALOG_NAME + ".cache-enabled", 
"true")
+            .config("spark.sql.catalog." + OTHER_CATALOG_NAME, 
SparkCatalog.class.getName())
+            .config(
+                "spark.sql.catalog." + OTHER_CATALOG_NAME + "." + 
CatalogProperties.CATALOG_IMPL,
+                ContextTrackingCatalog.class.getName())
+            .config("spark.sql.catalog." + OTHER_CATALOG_NAME + 
".default-namespace", "default")
+            .config("spark.sql.catalog." + OTHER_CATALOG_NAME + 
".cache-enabled", "false")
+            .config(
+                "spark.sql.catalog." + SESSION_CATALOG_NAME, 
SparkSessionCatalog.class.getName())
+            .config(
+                "spark.sql.catalog." + SESSION_CATALOG_NAME + "." + 
CatalogProperties.CATALOG_IMPL,
+                InMemoryCatalog.class.getName())
+            .config("spark.sql.catalog." + SESSION_CATALOG_NAME + 
".cache-enabled", "false")
+            .config("spark.sql.warehouse.dir", warehouse.toString())
+            .config("spark.sql.defaultCatalog", CATALOG_NAME)
+            // Cover Hive-enabled sessions, whose analyzer is built separately 
from the default one
+            .config("spark.hadoop.hive.metastore.uris", "")
+            .config(
+                "spark.hadoop.javax.jdo.option.ConnectionURL",
+                "jdbc:derby:memory:" + UUID.randomUUID() + ";create=true")
+            .enableHiveSupport()
+            .getOrCreate();
+
+    spark.sql(String.format("CREATE NAMESPACE IF NOT EXISTS %s.%s", 
CATALOG_NAME, NAMESPACE));
+    spark.sql(
+        String.format(
+            "CREATE NAMESPACE IF NOT EXISTS %s.%s", CATALOG_NAME, 
VIEW_DEFAULT_NAMESPACE));
+    spark.sql(String.format("CREATE NAMESPACE IF NOT EXISTS %s.%s", 
OTHER_CATALOG_NAME, NAMESPACE));
+    ((SupportsNamespaces) Spark3Util.loadIcebergCatalog(spark, 
SESSION_CATALOG_NAME))
+        .createNamespace(NAMESPACE);
+  }
+
+  @AfterAll
+  static void stopSpark() {
+    if (spark != null) {
+      spark.stop();
+      spark = null;
+    }
+  }
+
+  @BeforeEach
+  void before() {
+    spark.sql(String.format("USE %s.%s", CATALOG_NAME, NAMESPACE));
+    spark.sql(String.format("CREATE TABLE IF NOT EXISTS %s (id INT, data 
STRING)", TABLE_NAME));
+    appendRecords(TABLE_NAME);
+    spark.sql(String.format("REFRESH TABLE %s", TABLE_NAME));
+
+    ContextTrackingCatalog.clearCaptured();
+  }
+
+  @AfterEach
+  void after() {
+    spark.conf().unset(SparkSQLProperties.REFERENCED_BY_ENABLED);
+    spark.sql(String.format("USE %s.%s", CATALOG_NAME, NAMESPACE));
+    spark.sql("DROP VIEW IF EXISTS simple_view");
+    spark.sql("DROP VIEW IF EXISTS view_a");
+    spark.sql("DROP VIEW IF EXISTS view_b");
+    spark.sql("DROP VIEW IF EXISTS view_c");
+    spark.sql("DROP VIEW IF EXISTS time_travel_view");
+    spark.sql("DROP VIEW IF EXISTS cross_view");
+    spark.sql("DROP VIEW IF EXISTS shadow_v");
+    spark.sql(String.format("DROP VIEW IF EXISTS %s", VIEW_DEFAULT_VIEW_NAME));
+    spark.sql(String.format("DROP TABLE IF EXISTS %s", TABLE_NAME));
+    spark.sql(String.format("DROP TABLE IF EXISTS %s", SCHEMA_TABLE_NAME));
+    spark.sql(
+        String.format(
+            "DROP TABLE IF EXISTS %s.%s", VIEW_DEFAULT_NAMESPACE, 
VIEW_DEFAULT_TABLE_NAME));
+    spark.sql(
+        String.format(
+            "DROP TABLE IF EXISTS %s.%s.%s", OTHER_CATALOG_NAME, NAMESPACE, 
OTHER_TABLE_NAME));
+    viewCatalog(SESSION_CATALOG_NAME).dropView(TableIdentifier.of(NAMESPACE, 
PARQUET_VIEW_NAME));
+    spark.sql(
+        String.format(
+            "DROP TABLE IF EXISTS %s.%s.%s", SESSION_CATALOG_NAME, NAMESPACE, 
PARQUET_TABLE_NAME));
+    ContextTrackingCatalog.clearCaptured();
+  }
+
+  @Test
+  void referencedByIsDisabledByDefault() {
+    createView("simple_view", String.format("SELECT id FROM %s", TABLE_NAME));
+    ContextTrackingCatalog.clearCaptured();
+
+    List<Row> result = spark.sql("SELECT * FROM simple_view").collectAsList();
+    assertThat(result).hasSize(5);
+
+    assertThat(ContextTrackingCatalog.CAPTURED)
+        .allSatisfy(captured -> assertThat(captured.referencedBy).isEmpty());
+    assertThat(ContextTrackingCatalog.CAPTURED_VIEWS)
+        .allSatisfy(captured -> assertThat(captured.referencedBy).isEmpty());
+  }
+
+  @Test
+  void singleViewPassesViewIdentifierInContext() {
+    setReferencedByEnabled(true);
+    createView("simple_view", String.format("SELECT id FROM %s", TABLE_NAME));
+    spark.sql(String.format("REFRESH TABLE %s", TABLE_NAME));
+    ContextTrackingCatalog.clearCaptured();
+
+    List<Row> result = spark.sql("SELECT * FROM simple_view").collectAsList();
+    assertThat(result).hasSize(5);
+
+    assertThat(capturedLoadsOf(TableIdentifier.of(NAMESPACE, TABLE_NAME)))
+        .isNotEmpty()
+        .allSatisfy(
+            captured ->
+                assertThat(captured.referencedBy)
+                    .containsExactly(TableIdentifier.of(NAMESPACE, 
"simple_view")));
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void viewBodyResolvesTableQualifiedColumns(boolean referencedByEnabled) {
+    setReferencedByEnabled(referencedByEnabled);
+    createView("view_a", String.format("SELECT %s.id FROM %s", TABLE_NAME, 
TABLE_NAME));
+    createView(
+        "view_b",
+        String.format(
+            "SELECT %s.%s.%s.id FROM %s.%s.%s",
+            CATALOG_NAME, NAMESPACE, TABLE_NAME, CATALOG_NAME, NAMESPACE, 
TABLE_NAME));
+
+    assertThat(spark.sql("SELECT * FROM view_a").collectAsList()).hasSize(5);
+    assertThat(spark.sql("SELECT * FROM view_b").collectAsList()).hasSize(5);
+  }
+
+  @Test
+  void referencedByUsesResolvedViewIdentifier() {
+    setReferencedByEnabled(true);
+    spark.sql(
+        String.format(
+            "CREATE TABLE %s.%s (id INT, data STRING)",
+            VIEW_DEFAULT_NAMESPACE, VIEW_DEFAULT_TABLE_NAME));
+    appendRecords(String.format("%s.%s", VIEW_DEFAULT_NAMESPACE, 
VIEW_DEFAULT_TABLE_NAME));
+    spark.sql(
+        String.format("REFRESH TABLE %s.%s", VIEW_DEFAULT_NAMESPACE, 
VIEW_DEFAULT_TABLE_NAME));
+
+    String viewSql = String.format("SELECT id FROM %s", 
VIEW_DEFAULT_TABLE_NAME);
+    String schemaSql =
+        String.format("SELECT id FROM %s.%s", VIEW_DEFAULT_NAMESPACE, 
VIEW_DEFAULT_TABLE_NAME);
+    viewCatalog(CATALOG_NAME)
+        .buildView(TableIdentifier.of(NAMESPACE, VIEW_DEFAULT_VIEW_NAME))
+        .withQuery("spark", viewSql)
+        .withDefaultNamespace(VIEW_DEFAULT_NAMESPACE)
+        .withDefaultCatalog(CATALOG_NAME)
+        .withSchema(SparkSchemaUtil.convert(spark.sql(schemaSql).schema()))
+        .create();
+    ContextTrackingCatalog.clearCaptured();
+
+    List<Row> result =
+        spark.sql(String.format("SELECT * FROM %s", 
VIEW_DEFAULT_VIEW_NAME)).collectAsList();
+    assertThat(result).hasSize(5);
+
+    assertCapturedTableChain(
+        ContextTrackingCatalog.CAPTURED,
+        VIEW_DEFAULT_NAMESPACE,
+        VIEW_DEFAULT_TABLE_NAME,
+        VIEW_DEFAULT_VIEW_NAME);
+  }
+
+  @Test
+  void nestedViewChainAccumulatesContext() {
+    setReferencedByEnabled(true);
+    createView("view_a", String.format("SELECT id, data FROM %s", TABLE_NAME));
+    createView("view_b", "SELECT id FROM view_a WHERE id <= 3");
+    createView("view_c", "SELECT id FROM view_b WHERE id > 1");
+    ContextTrackingCatalog.clearCaptured();
+
+    List<Object[]> result = rowsToJava(spark.sql("SELECT * FROM 
view_c").collectAsList());
+    assertThat(result).hasSize(2).containsExactlyInAnyOrder(new Object[] {2}, 
new Object[] {3});
+
+    assertCapturedTableChain(
+        ContextTrackingCatalog.CAPTURED, TABLE_NAME, "view_c", "view_b", 
"view_a");
+    assertCapturedTableChain(ContextTrackingCatalog.CAPTURED_VIEWS, "view_a", 
"view_c", "view_b");
+  }
+
+  @Test
+  void timeTravelViewPassesViewIdentifierInContext() throws Exception {
+    setReferencedByEnabled(true);
+    long snapshotId = Spark3Util.loadIcebergTable(spark, 
TABLE_NAME).currentSnapshot().snapshotId();
+    createView(
+        "time_travel_view",
+        String.format("SELECT id FROM %s VERSION AS OF %d", TABLE_NAME, 
snapshotId));
+    ContextTrackingCatalog.clearCaptured();
+
+    List<Row> result = spark.sql("SELECT * FROM 
time_travel_view").collectAsList();
+    assertThat(result).hasSize(5);
+
+    assertCapturedTableChain(ContextTrackingCatalog.CAPTURED, TABLE_NAME, 
"time_travel_view");
+  }
+
+  @Test
+  void crossCatalogViewAccessDoesNotFailWhenReferencedByIsDisabled() {
+    createCrossCatalogView();
+    ContextTrackingCatalog.clearCaptured();
+
+    List<Row> result = spark.sql("SELECT * FROM cross_view").collectAsList();
+    assertThat(result).hasSize(5);
+
+    assertThat(capturedLoadsOf(TableIdentifier.of(NAMESPACE, 
OTHER_TABLE_NAME)))
+        .isNotEmpty()
+        .allSatisfy(captured -> assertThat(captured.referencedBy).isEmpty());
+  }
+
+  @Test
+  void crossCatalogViewAccessFailsWhenReferencedByIsEnabled() {
+    setReferencedByEnabled(true);
+    createCrossCatalogView();
+
+    assertThatThrownBy(() -> spark.sql("SELECT * FROM 
cross_view").collectAsList())
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessage(
+            "Cross-catalog view references are not supported with 
referenced-by enabled. "
+                + "Views from catalogs [%s] cannot reference entities in 
catalog [%s]",
+            CATALOG_NAME, OTHER_CATALOG_NAME);
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void versionTimeTravelViewReadsPinnedSnapshot(boolean referencedByEnabled) 
throws Exception {
+    setReferencedByEnabled(referencedByEnabled);
+    long snapshotId = Spark3Util.loadIcebergTable(spark, 
TABLE_NAME).currentSnapshot().snapshotId();
+    createView(
+        "time_travel_view",
+        String.format("SELECT id FROM %s VERSION AS OF %d", TABLE_NAME, 
snapshotId));
+    appendRecords(TABLE_NAME);
+
+    List<Row> result = spark.sql("SELECT * FROM 
time_travel_view").collectAsList();
+    assertThat(result).hasSize(5);
+  }
+
+  @Test
+  void timestampStringTimeTravelView() throws Exception {
+    setReferencedByEnabled(true);
+    long asOfMillis = waitUntilAfter(currentSnapshotMillis());
+    String asOf = new Timestamp(asOfMillis).toString();
+    createView(
+        "time_travel_view",
+        String.format("SELECT id FROM %s TIMESTAMP AS OF '%s'", TABLE_NAME, 
asOf));
+    waitUntilAfter(asOfMillis);
+    appendRecords(TABLE_NAME);
+    ContextTrackingCatalog.clearCaptured();
+
+    List<Row> result = spark.sql("SELECT * FROM 
time_travel_view").collectAsList();
+    assertThat(result).hasSize(5);
+
+    assertCapturedTableChain(ContextTrackingCatalog.CAPTURED, TABLE_NAME, 
"time_travel_view");
+  }
+
+  @Test
+  void timestampSecondsTimeTravelView() throws Exception {
+    setReferencedByEnabled(true);
+    long asOfSeconds = 
TimeUnit.MILLISECONDS.toSeconds(currentSnapshotMillis()) + 1;
+    createView(
+        "time_travel_view",
+        String.format("SELECT id FROM %s TIMESTAMP AS OF %d", TABLE_NAME, 
asOfSeconds));
+    waitUntilAfter(TimeUnit.SECONDS.toMillis(asOfSeconds));
+    appendRecords(TABLE_NAME);
+    ContextTrackingCatalog.clearCaptured();
+
+    List<Row> result = spark.sql("SELECT * FROM 
time_travel_view").collectAsList();
+    assertThat(result).hasSize(5);
+
+    assertCapturedTableChain(ContextTrackingCatalog.CAPTURED, TABLE_NAME, 
"time_travel_view");
+  }
+
+  @Test
+  void timestampExpressionTimeTravelView() {
+    setReferencedByEnabled(true);
+    createView(
+        "time_travel_view",
+        String.format("SELECT id FROM %s TIMESTAMP AS OF current_timestamp()", 
TABLE_NAME));
+    ContextTrackingCatalog.clearCaptured();
+
+    List<Row> result = spark.sql("SELECT * FROM 
time_travel_view").collectAsList();
+    assertThat(result).hasSize(5);
+
+    assertCapturedTableChain(ContextTrackingCatalog.CAPTURED, TABLE_NAME, 
"time_travel_view");
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void directReadOfNonIcebergTableInSessionCatalog(boolean 
referencedByEnabled) {
+    setReferencedByEnabled(referencedByEnabled);
+    String parquetTable = createSessionCatalogParquetTable();
+
+    assertThat(spark.sql(String.format("SELECT * FROM %s", 
parquetTable)).count()).isEqualTo(3);
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void viewReadsNonIcebergTableInSessionCatalog(boolean referencedByEnabled) {
+    setReferencedByEnabled(referencedByEnabled);
+    String parquetTable = createSessionCatalogParquetTable();
+    createView(
+        SESSION_CATALOG_NAME, PARQUET_VIEW_NAME, String.format("SELECT id FROM 
%s", parquetTable));
+
+    assertThat(
+            spark
+                .sql(
+                    String.format(
+                        "SELECT * FROM %s.%s.%s",
+                        SESSION_CATALOG_NAME, NAMESPACE, PARQUET_VIEW_NAME))
+                .count())
+        .isEqualTo(3);
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void directTableReadDoesNotLoadView(boolean referencedByEnabled) {
+    setReferencedByEnabled(referencedByEnabled);
+    spark.sql(String.format("SELECT * FROM %s", TABLE_NAME)).collectAsList();
+
+    assertThat(ContextTrackingCatalog.CAPTURED_VIEWS).isEmpty();
+  }
+
+  @Test
+  void directTableReadLoadsTableAsOftenAsWhenReferencedByIsDisabled() {
+    String otherTable = String.format("%s.%s.%s", OTHER_CATALOG_NAME, 
NAMESPACE, OTHER_TABLE_NAME);
+    spark.sql(String.format("CREATE TABLE %s (id INT, data STRING)", 
otherTable));
+    appendRecords(otherTable);
+    TableIdentifier ident = TableIdentifier.of(NAMESPACE, OTHER_TABLE_NAME);
+
+    ContextTrackingCatalog.clearCaptured();
+    spark.sql(String.format("SELECT * FROM %s", otherTable)).collectAsList();
+    int expectedLoads = capturedLoadsOf(ident).size();
+
+    setReferencedByEnabled(true);
+    ContextTrackingCatalog.clearCaptured();
+    spark.sql(String.format("SELECT * FROM %s", otherTable)).collectAsList();
+
+    assertThat(expectedLoads).isPositive();
+    assertThat(capturedLoadsOf(ident)).hasSize(expectedLoads);
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void tempViewShadowsIcebergView(boolean referencedByEnabled) {
+    setReferencedByEnabled(referencedByEnabled);
+    createView("shadow_v", String.format("SELECT id FROM %s", TABLE_NAME));
+    spark.sql("CREATE TEMPORARY VIEW shadow_v AS SELECT 'x' AS temp_col");
+    try {
+      List<String> columns =
+          spark.sql("DESCRIBE TABLE shadow_v").collectAsList().stream()
+              .map(row -> row.getString(0))
+              .collect(Collectors.toList());
+
+      assertThat(columns).contains("temp_col").doesNotContain("id");
+    } finally {
+      spark.catalog().dropTempView("shadow_v");
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void cachedViewIsReusedByLaterQuery(boolean referencedByEnabled) {
+    setReferencedByEnabled(referencedByEnabled);
+    createView("simple_view", String.format("SELECT id FROM %s", TABLE_NAME));
+    Dataset<Row> cached = spark.sql("SELECT * FROM simple_view");
+    cached.cache();
+    try {
+      cached.count();
+      Dataset<Row> query = spark.sql("SELECT * FROM simple_view");
+
+      
assertThat(query.queryExecution().withCachedData().toString()).contains("InMemoryRelation");
+    } finally {
+      cached.unpersist();
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void streamingReadOfViewFails(boolean referencedByEnabled) {
+    setReferencedByEnabled(referencedByEnabled);
+    createView("simple_view", String.format("SELECT id FROM %s", TABLE_NAME));
+
+    assertThatThrownBy(() -> spark.readStream().table("simple_view"))
+        .isInstanceOf(AnalysisException.class)
+        .hasMessageContaining(
+            "is a permanent view, which is not supported by streaming reading 
API");
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void deleteFromViewFails(boolean referencedByEnabled) {
+    setReferencedByEnabled(referencedByEnabled);
+    createView("simple_view", String.format("SELECT id FROM %s", TABLE_NAME));
+
+    assertFailsWithCondition(
+        () -> spark.sql("DELETE FROM simple_view WHERE id = 1"), 
"TABLE_OR_VIEW_NOT_FOUND");
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void createOrReplaceViewWithCycleFails(boolean referencedByEnabled) {
+    setReferencedByEnabled(referencedByEnabled);
+    createView("view_a", String.format("SELECT id FROM %s", TABLE_NAME));
+    createView("view_b", "SELECT id FROM view_a");
+
+    assertFailsWithCondition(
+        () -> spark.sql("CREATE OR REPLACE VIEW view_a AS SELECT id FROM 
view_b"),
+        "RECURSIVE_VIEW");
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void viewCycleFails(boolean referencedByEnabled) {
+    setReferencedByEnabled(referencedByEnabled);
+    createViewWithoutAnalysis("view_a", "SELECT id FROM view_b");
+    createViewWithoutAnalysis("view_b", "SELECT id FROM view_a");
+
+    assertFailsWithCondition(
+        () -> spark.sql("SELECT * FROM view_a").collectAsList(), 
"VIEW_EXCEED_MAX_NESTED_DEPTH");
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void alterViewPropertiesUncachesView(boolean referencedByEnabled) {
+    setReferencedByEnabled(referencedByEnabled);
+    createView("simple_view", String.format("SELECT id FROM %s", TABLE_NAME));
+    spark.catalog().cacheTable("simple_view");
+
+    spark.sql("ALTER VIEW simple_view SET TBLPROPERTIES ('key' = 'value')");
+
+    assertThat(spark.catalog().isCached("simple_view")).isFalse();
+  }
+
+  @Test
+  void viewCommandsMatchReferencedByDisabled() {
+    createView("simple_view", String.format("SELECT id FROM %s", TABLE_NAME));
+
+    for (String command : List.of("DESCRIBE TABLE simple_view", "SHOW CREATE 
TABLE simple_view")) {
+      setReferencedByEnabled(false);
+      List<Row> expected = spark.sql(command).collectAsList();
+
+      setReferencedByEnabled(true);
+      
assertThat(spark.sql(command).collectAsList()).as(command).isEqualTo(expected);
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void invalidViewSqlFails(boolean referencedByEnabled) {
+    setReferencedByEnabled(referencedByEnabled);
+    createViewWithoutAnalysis("simple_view", "invalid SQL");
+
+    assertFailsWithCondition(
+        () -> spark.sql("SELECT * FROM simple_view").collectAsList(), 
"PARSE_SYNTAX_ERROR");
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void viewReadingMissingTableFails(boolean referencedByEnabled) {
+    setReferencedByEnabled(referencedByEnabled);
+    createViewWithoutAnalysis("simple_view", "SELECT id FROM non_existing");
+
+    assertFailsWithCondition(
+        () -> spark.sql("SELECT * FROM simple_view").collectAsList(), 
"TABLE_OR_VIEW_NOT_FOUND");
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void viewColumnsBindByName(boolean referencedByEnabled) {
+    setReferencedByEnabled(referencedByEnabled);
+    createSchemaTable();
+    spark.sql(String.format("CREATE VIEW simple_view AS SELECT * FROM %s", 
SCHEMA_TABLE_NAME));
+    spark.sql(String.format("ALTER TABLE %s ALTER COLUMN b FIRST", 
SCHEMA_TABLE_NAME));
+
+    assertThat(rowsToJava(spark.sql("SELECT * FROM 
simple_view").collectAsList()))
+        .containsExactlyInAnyOrder(new Object[] {1, 10}, new Object[] {2, 20});
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void schemaEvolutionViewFailsAfterSchemaChange(boolean referencedByEnabled) {
+    setReferencedByEnabled(referencedByEnabled);
+    createSchemaTable();
+    spark.sql(
+        String.format(
+            "CREATE VIEW simple_view WITH SCHEMA EVOLUTION AS SELECT * FROM 
%s",
+            SCHEMA_TABLE_NAME));
+    spark.sql(String.format("ALTER TABLE %s ADD COLUMN c INT", 
SCHEMA_TABLE_NAME));
+
+    assertFailsWithCondition(
+        () -> spark.sql("SELECT * FROM simple_view").collectAsList(), 
"FAILED_UPDATE_VIEW_SCHEMA");
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void typeEvolutionViewFailsAfterTypeChange(boolean referencedByEnabled) {
+    setReferencedByEnabled(referencedByEnabled);
+    createSchemaTable();
+    spark.sql(
+        String.format(
+            "CREATE VIEW simple_view WITH SCHEMA TYPE EVOLUTION AS SELECT * 
FROM %s",
+            SCHEMA_TABLE_NAME));
+    spark.sql(String.format("ALTER TABLE %s ALTER COLUMN a TYPE BIGINT", 
SCHEMA_TABLE_NAME));
+
+    assertFailsWithCondition(
+        () -> spark.sql("SELECT * FROM simple_view").collectAsList(), 
"FAILED_UPDATE_VIEW_SCHEMA");
+  }
+
+  @ParameterizedTest
+  @ValueSource(booleans = {false, true})
+  void viewTextParsesWithStoredSqlConfigs(boolean referencedByEnabled) {
+    setReferencedByEnabled(referencedByEnabled);
+    createSchemaTable();
+    spark.conf().set("spark.sql.ansi.doubleQuotedIdentifiers", "true");
+    try {
+      spark.sql(
+          String.format("CREATE VIEW simple_view AS SELECT \"a\" FROM %s", 
SCHEMA_TABLE_NAME));
+    } finally {
+      spark.conf().unset("spark.sql.ansi.doubleQuotedIdentifiers");
+    }
+
+    assertThat(rowsToJava(spark.sql("SELECT * FROM 
simple_view").collectAsList()))
+        .containsExactlyInAnyOrder(new Object[] {1}, new Object[] {2});
+  }
+
+  private void createSchemaTable() {
+    spark.sql(String.format("CREATE TABLE %s (a INT, b INT)", 
SCHEMA_TABLE_NAME));
+    spark.sql(String.format("INSERT INTO %s VALUES (1, 10), (2, 20)", 
SCHEMA_TABLE_NAME));
+  }
+
+  private void setReferencedByEnabled(boolean enabled) {
+    spark.conf().set(SparkSQLProperties.REFERENCED_BY_ENABLED, 
String.valueOf(enabled));
+  }
+
+  private void assertFailsWithCondition(ThrowingCallable action, String 
condition) {
+    assertThatThrownBy(action)
+        .isInstanceOf(SparkThrowable.class)
+        .hasMessageContaining("[" + condition + "]");
+  }
+
+  private String createSessionCatalogParquetTable() {
+    String parquetTable =
+        String.format("%s.%s.%s", SESSION_CATALOG_NAME, NAMESPACE, 
PARQUET_TABLE_NAME);
+    spark.sql(String.format("CREATE TABLE %s (id INT) USING parquet", 
parquetTable));
+    spark.sql(String.format("INSERT INTO %s VALUES (1), (2), (3)", 
parquetTable));
+    return parquetTable;
+  }
+
+  private long currentSnapshotMillis() throws Exception {
+    return Spark3Util.loadIcebergTable(spark, 
TABLE_NAME).currentSnapshot().timestampMillis();
+  }
+
+  private List<ContextTrackingCatalog.CapturedContext> 
capturedLoadsOf(TableIdentifier ident) {
+    return capturedLoadsOf(ContextTrackingCatalog.CAPTURED, ident);
+  }
+
+  private List<ContextTrackingCatalog.CapturedContext> capturedLoadsOf(
+      List<ContextTrackingCatalog.CapturedContext> captures, TableIdentifier 
ident) {
+    return captures.stream()
+        .filter(captured -> captured.tableIdentifier.equals(ident))
+        .collect(Collectors.toList());
+  }
+
+  private void createView(String viewName, String sql) {
+    createView(CATALOG_NAME, viewName, sql);
+  }
+
+  private void createView(String catalogName, String viewName, String sql) {
+    createView(catalogName, viewName, sql, 
SparkSchemaUtil.convert(spark.sql(sql).schema()));
+  }
+
+  private void createViewWithoutAnalysis(String viewName, String sql) {
+    createView(
+        CATALOG_NAME,
+        viewName,
+        sql,
+        new Schema(Types.NestedField.optional(1, "id", 
Types.IntegerType.get())));
+  }
+
+  private void createView(String catalogName, String viewName, String sql, 
Schema schema) {
+    viewCatalog(catalogName)
+        .buildView(TableIdentifier.of(NAMESPACE, viewName))
+        .withQuery("spark", sql)
+        .withDefaultNamespace(NAMESPACE)
+        .withDefaultCatalog(catalogName)
+        .withSchema(schema)
+        .create();
+  }
+
+  private void createCrossCatalogView() {
+    String otherTable = String.format("%s.%s.%s", OTHER_CATALOG_NAME, 
NAMESPACE, OTHER_TABLE_NAME);
+    spark.sql(String.format("CREATE TABLE %s (id INT, data STRING)", 
otherTable));
+    appendRecords(otherTable);
+    spark.sql(String.format("REFRESH TABLE %s", otherTable));
+    createView("cross_view", String.format("SELECT id FROM %s", otherTable));
+    spark.sql(String.format("REFRESH TABLE %s", otherTable));
+  }
+
+  private void appendRecords(String tableName) {
+    try {
+      List<SimpleRecord> records =
+          IntStream.rangeClosed(1, 5)
+              .mapToObj(i -> new SimpleRecord(i, String.valueOf(i)))
+              .collect(Collectors.toList());
+      Dataset<Row> df = spark.createDataFrame(records, SimpleRecord.class);
+      df.writeTo(tableName).append();
+    } catch (NoSuchTableException e) {
+      throw new RuntimeException(e);
+    }
+  }
+
+  private void assertCapturedTableChain(
+      List<ContextTrackingCatalog.CapturedContext> captures,
+      String targetName,
+      String... expectedViewNames) {
+    assertCapturedTableChain(captures, NAMESPACE, targetName, 
expectedViewNames);
+  }
+
+  private void assertCapturedTableChain(
+      List<ContextTrackingCatalog.CapturedContext> captures,
+      Namespace targetNamespace,
+      String targetName,
+      String... expectedViewNames) {
+    List<ContextTrackingCatalog.CapturedContext> matching =
+        capturedLoadsOf(captures, TableIdentifier.of(targetNamespace, 
targetName)).stream()
+            .filter(captured -> !captured.referencedBy.isEmpty())
+            .collect(Collectors.toList());
+
+    assertThat(matching).isNotEmpty();
+    assertThat(matching.get(0).referencedBy)
+        .containsExactly(
+            Arrays.stream(expectedViewNames)
+                .map(name -> TableIdentifier.of(NAMESPACE, name))
+                .toArray(TableIdentifier[]::new));
+  }
+
+  private ViewCatalog viewCatalog(String catalogName) {
+    CatalogPlugin catalogPlugin = 
spark.sessionState().catalogManager().catalog(catalogName);
+    assertThat(catalogPlugin).isInstanceOf(HasIcebergCatalog.class);
+    ViewCatalog viewCatalog = ((HasIcebergCatalog) 
catalogPlugin).icebergViewCatalog();
+    assertThat(viewCatalog).isNotNull();
+    return viewCatalog;
+  }
+}
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/BaseCatalog.java 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/BaseCatalog.java
index c2a43306ce..df9a62e609 100644
--- a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/BaseCatalog.java
+++ b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/BaseCatalog.java
@@ -25,6 +25,7 @@ import org.apache.iceberg.spark.source.HasIcebergCatalog;
 import org.apache.iceberg.util.PropertyUtil;
 import org.apache.spark.sql.catalyst.analysis.NoSuchTableException;
 import org.apache.spark.sql.catalyst.analysis.NoSuchViewException;
+import org.apache.spark.sql.catalyst.analysis.ViewUtil;
 import org.apache.spark.sql.connector.catalog.Identifier;
 import org.apache.spark.sql.connector.catalog.ProcedureCatalog;
 import org.apache.spark.sql.connector.catalog.Relation;
@@ -108,6 +109,12 @@ abstract class BaseCatalog
     try {
       return loadTable(ident);
     } catch (NoSuchTableException e) {
+      if (ViewUtil.referencedByEnabled()) {
+        // Don't return views: Spark's own view expansion can't pass the 
referenced-by chain to the
+        // catalog. ResolveReferencedByViews expands Iceberg views that are 
left unresolved instead.
+        throw e;
+      }
+
       try {
         return loadView(ident);
       } catch (NoSuchViewException viewException) {
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java
index a0bdfea817..45f44fa8e9 100644
--- a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java
+++ b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkCatalog.java
@@ -44,6 +44,7 @@ import org.apache.iceberg.TableUtil;
 import org.apache.iceberg.Transaction;
 import org.apache.iceberg.UnboundPartitionSpec;
 import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.LoadContext;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.SupportsNamespaces;
 import org.apache.iceberg.catalog.TableIdentifier;
@@ -126,7 +127,7 @@ import org.slf4j.LoggerFactory;
  *
  * <p>
  */
-public class SparkCatalog extends BaseCatalog {
+public class SparkCatalog extends BaseCatalog implements 
SparkSupportsLoadContext {
 
   private static final Logger LOG = 
LoggerFactory.getLogger(SparkCatalog.class);
   private static final Set<String> DEFAULT_NS_KEYS = 
ImmutableSet.of(TableCatalog.PROP_OWNER);
@@ -194,17 +195,34 @@ public class SparkCatalog extends BaseCatalog {
 
   @Override
   public Table loadTable(Identifier ident) throws NoSuchTableException {
-    return load(ident, null /* no time travel */);
+    return loadTable(ident, LoadContext.empty());
+  }
+
+  @Override
+  public Table loadTable(Identifier ident, LoadContext context) throws 
NoSuchTableException {
+    return load(ident, null /* no time travel */, context);
   }
 
   @Override
   public Table loadTable(Identifier ident, String version) throws 
NoSuchTableException {
-    return load(ident, TimeTravel.version(version));
+    return loadTable(ident, version, LoadContext.empty());
+  }
+
+  @Override
+  public Table loadTable(Identifier ident, String version, LoadContext context)
+      throws NoSuchTableException {
+    return load(ident, TimeTravel.version(version), context);
   }
 
   @Override
   public Table loadTable(Identifier ident, long timestampMicros) throws 
NoSuchTableException {
-    return load(ident, TimeTravel.timestampMicros(timestampMicros));
+    return loadTable(ident, timestampMicros, LoadContext.empty());
+  }
+
+  @Override
+  public Table loadTable(Identifier ident, long timestampMicros, LoadContext 
context)
+      throws NoSuchTableException {
+    return load(ident, TimeTravel.timestampMicros(timestampMicros), context);
   }
 
   @Override
@@ -691,9 +709,15 @@ public class SparkCatalog extends BaseCatalog {
 
   @Override
   public View loadView(Identifier ident) throws NoSuchViewException {
+    return loadView(ident, LoadContext.empty());
+  }
+
+  @Override
+  public View loadView(Identifier ident, LoadContext context) throws 
NoSuchViewException {
     if (null != asViewCatalog) {
       try {
-        org.apache.iceberg.view.View icebergView = 
asViewCatalog.loadView(buildIdentifier(ident));
+        org.apache.iceberg.view.View icebergView =
+            asViewCatalog.loadView(buildIdentifier(ident), context);
         return SparkView.toView(catalogName, icebergView);
       } catch (org.apache.iceberg.exceptions.NoSuchViewException e) {
         throw new NoSuchViewException(ident);
@@ -1055,13 +1079,14 @@ public class SparkCatalog extends BaseCatalog {
     }
   }
 
-  private Table load(Identifier ident, TimeTravel timeTravel) throws 
NoSuchTableException {
+  private Table load(Identifier ident, TimeTravel timeTravel, LoadContext 
context)
+      throws NoSuchTableException {
     if (isPathIdentifier(ident)) {
       return loadPath((PathIdentifier) ident, timeTravel);
     }
 
     try {
-      org.apache.iceberg.Table table = 
icebergCatalog.loadTable(buildIdentifier(ident));
+      org.apache.iceberg.Table table = 
icebergCatalog.loadTable(buildIdentifier(ident), context);
       return SparkTable.create(table, timeTravel);
 
     } catch (org.apache.iceberg.exceptions.NoSuchTableException e) {
@@ -1074,7 +1099,7 @@ public class SparkCatalog extends BaseCatalog {
       TableIdentifier namespaceAsIdent = 
buildIdentifier(namespaceToIdentifier(ident.namespace()));
       org.apache.iceberg.Table table;
       try {
-        table = icebergCatalog.loadTable(namespaceAsIdent);
+        table = icebergCatalog.loadTable(namespaceAsIdent, context);
       } catch (Exception ignored) {
         // the namespace does not identify a table, so it cannot be a table 
with a snapshot selector
         // throw an exception for the original identifier
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkSQLProperties.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkSQLProperties.java
index 428644ecfc..84ef44dca5 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkSQLProperties.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkSQLProperties.java
@@ -107,6 +107,11 @@ public class SparkSQLProperties {
   // Rely on identifier fields as a PRIMARY KEY constraint for query 
optimization (not enforced)
   public static final String IDENTIFIER_FIELDS_RELY = 
"spark.sql.iceberg.identifier-fields-rely";
 
+  // Controls whether to send referenced-by view chains while loading 
relations; requires the
+  // Iceberg SQL extensions, which expand Iceberg views when this is enabled
+  public static final String REFERENCED_BY_ENABLED = 
"spark.sql.iceberg.referenced-by.enabled";
+  public static final boolean REFERENCED_BY_ENABLED_DEFAULT = false;
+
   // Prefix for custom snapshot properties
   public static final String SNAPSHOT_PROPERTY_PREFIX = 
"spark.sql.iceberg.snapshot-property.";
 
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkSessionCatalog.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkSessionCatalog.java
index f86e897bcf..37c3fd2a2f 100644
--- 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkSessionCatalog.java
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkSessionCatalog.java
@@ -27,6 +27,7 @@ import org.apache.hadoop.hive.conf.HiveConf;
 import org.apache.iceberg.CatalogProperties;
 import org.apache.iceberg.CatalogUtil;
 import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.LoadContext;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.exceptions.AlreadyExistsException;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
@@ -68,12 +69,13 @@ import org.apache.spark.sql.util.CaseInsensitiveStringMap;
  */
 public class SparkSessionCatalog<
         T extends TableCatalog & FunctionCatalog & SupportsNamespaces & 
ViewCatalog>
-    extends BaseCatalog implements CatalogExtension {
+    extends BaseCatalog implements CatalogExtension, SparkSupportsLoadContext {
 
   private String catalogName = null;
   private TableCatalog icebergCatalog = null;
   private StagingTableCatalog asStagingCatalog = null;
   private ViewCatalog asViewCatalog = null;
+  private SparkSupportsLoadContext asLoadContextCatalog = null;
   private T sessionCatalog = null;
   private boolean createParquetAsIceberg = false;
   private boolean createAvroAsIceberg = false;
@@ -198,7 +200,17 @@ public class SparkSessionCatalog<
 
   @Override
   public Table loadTable(Identifier ident) throws NoSuchTableException {
+    return loadTable(ident, LoadContext.empty());
+  }
+
+  @Override
+  public Table loadTable(Identifier ident, LoadContext context) throws 
NoSuchTableException {
     try {
+      if (asLoadContextCatalog != null) {
+        return asLoadContextCatalog.loadTable(ident, context);
+      }
+
+      checkLoadContextSupported("table", ident, context);
       return icebergCatalog.loadTable(ident);
     } catch (NoSuchTableException e) {
       return getSessionCatalog().loadTable(ident);
@@ -207,7 +219,18 @@ public class SparkSessionCatalog<
 
   @Override
   public Table loadTable(Identifier ident, String version) throws 
NoSuchTableException {
+    return loadTable(ident, version, LoadContext.empty());
+  }
+
+  @Override
+  public Table loadTable(Identifier ident, String version, LoadContext context)
+      throws NoSuchTableException {
     try {
+      if (asLoadContextCatalog != null) {
+        return asLoadContextCatalog.loadTable(ident, version, context);
+      }
+
+      checkLoadContextSupported("table", ident, context);
       return icebergCatalog.loadTable(ident, version);
     } catch (NoSuchTableException e) {
       return getSessionCatalog().loadTable(ident, version);
@@ -216,7 +239,18 @@ public class SparkSessionCatalog<
 
   @Override
   public Table loadTable(Identifier ident, long timestamp) throws 
NoSuchTableException {
+    return loadTable(ident, timestamp, LoadContext.empty());
+  }
+
+  @Override
+  public Table loadTable(Identifier ident, long timestamp, LoadContext context)
+      throws NoSuchTableException {
     try {
+      if (asLoadContextCatalog != null) {
+        return asLoadContextCatalog.loadTable(ident, timestamp, context);
+      }
+
+      checkLoadContextSupported("table", ident, context);
       return icebergCatalog.loadTable(ident, timestamp);
     } catch (NoSuchTableException e) {
       return getSessionCatalog().loadTable(ident, timestamp);
@@ -426,6 +460,10 @@ public class SparkSessionCatalog<
       this.asViewCatalog = (ViewCatalog) icebergCatalog;
     }
 
+    if (icebergCatalog instanceof SparkSupportsLoadContext) {
+      this.asLoadContextCatalog = (SparkSupportsLoadContext) icebergCatalog;
+    }
+
     this.createParquetAsIceberg = options.getBoolean("parquet-enabled", 
createParquetAsIceberg);
     this.createAvroAsIceberg = options.getBoolean("avro-enabled", 
createAvroAsIceberg);
     this.createOrcAsIceberg = options.getBoolean("orc-enabled", 
createOrcAsIceberg);
@@ -594,15 +632,39 @@ public class SparkSessionCatalog<
 
   @Override
   public View loadView(Identifier ident) throws NoSuchViewException {
-    if (null != asViewCatalog && asViewCatalog.viewExists(ident)) {
+    return loadView(ident, LoadContext.empty());
+  }
+
+  @Override
+  public View loadView(Identifier ident, LoadContext context) throws 
NoSuchViewException {
+    if (null != asViewCatalog && null != asLoadContextCatalog) {
+      try {
+        return asLoadContextCatalog.loadView(ident, context);
+      } catch (NoSuchViewException ignored) {
+        // not an Iceberg view, so check the session catalog below
+      }
+    } else if (null != asViewCatalog && asViewCatalog.viewExists(ident)) {
+      checkLoadContextSupported("view", ident, context);
       return asViewCatalog.loadView(ident);
-    } else if (isViewCatalog() && getSessionCatalog().viewExists(ident)) {
+    }
+
+    if (isViewCatalog() && getSessionCatalog().viewExists(ident)) {
       return getSessionCatalog().loadView(ident);
     }
 
     throw new NoSuchViewException(ident);
   }
 
+  private void checkLoadContextSupported(
+      String relationType, Identifier ident, LoadContext context) {
+    if (!context.referencedBy().isEmpty()) {
+      throw new UnsupportedOperationException(
+          String.format(
+              "Cannot load %s %s with referenced-by: %s does not support load 
context",
+              relationType, ident, icebergCatalog.getClass().getName()));
+    }
+  }
+
   @Override
   public View createView(Identifier ident, View view)
       throws ViewAlreadyExistsException, NoSuchNamespaceException {
diff --git 
a/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkSupportsLoadContext.java
 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkSupportsLoadContext.java
new file mode 100644
index 0000000000..76f2a12487
--- /dev/null
+++ 
b/spark/v4.2/spark/src/main/java/org/apache/iceberg/spark/SparkSupportsLoadContext.java
@@ -0,0 +1,78 @@
+/*
+ * 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.iceberg.spark;
+
+import org.apache.iceberg.catalog.LoadContext;
+import org.apache.spark.sql.catalyst.analysis.NoSuchTableException;
+import org.apache.spark.sql.catalyst.analysis.NoSuchViewException;
+import org.apache.spark.sql.connector.catalog.Identifier;
+import org.apache.spark.sql.connector.catalog.Table;
+import org.apache.spark.sql.connector.catalog.View;
+
+/**
+ * A Spark Catalog API extension for loading tables and views with additional 
load context. Extends
+ * the loading contract to accept a {@link LoadContext} that carries the view 
reference chain and
+ * other contextual information.
+ */
+public interface SparkSupportsLoadContext {
+
+  /**
+   * Load a table with additional load context.
+   *
+   * @param identifier a table identifier
+   * @param context the load context
+   * @return instance of {@link Table} referred by the identifier
+   * @throws NoSuchTableException if the table does not exist
+   */
+  Table loadTable(Identifier identifier, LoadContext context) throws 
NoSuchTableException;
+
+  /**
+   * Load a table at a specific version with additional load context.
+   *
+   * @param identifier a table identifier
+   * @param version the version string
+   * @param context the load context
+   * @return instance of {@link Table} referred by the identifier at the 
specified version
+   * @throws NoSuchTableException if the table does not exist
+   */
+  Table loadTable(Identifier identifier, String version, LoadContext context)
+      throws NoSuchTableException;
+
+  /**
+   * Load a table at a specific timestamp with additional load context.
+   *
+   * @param identifier a table identifier
+   * @param timestamp the timestamp in microseconds
+   * @param context the load context
+   * @return instance of {@link Table} referred by the identifier as of the 
specified timestamp
+   * @throws NoSuchTableException if the table does not exist
+   */
+  Table loadTable(Identifier identifier, long timestamp, LoadContext context)
+      throws NoSuchTableException;
+
+  /**
+   * Load a view with additional load context.
+   *
+   * @param identifier a view identifier
+   * @param context the load context
+   * @return instance of {@link View} referred by the identifier
+   * @throws NoSuchViewException if the view does not exist
+   */
+  View loadView(Identifier identifier, LoadContext context) throws 
NoSuchViewException;
+}
diff --git 
a/spark/v4.2/spark/src/main/scala/org/apache/spark/sql/catalyst/analysis/ViewUtil.scala
 
b/spark/v4.2/spark/src/main/scala/org/apache/spark/sql/catalyst/analysis/ViewUtil.scala
index e7a7060097..cff270eb46 100644
--- 
a/spark/v4.2/spark/src/main/scala/org/apache/spark/sql/catalyst/analysis/ViewUtil.scala
+++ 
b/spark/v4.2/spark/src/main/scala/org/apache/spark/sql/catalyst/analysis/ViewUtil.scala
@@ -18,20 +18,28 @@
  */
 package org.apache.spark.sql.catalyst.analysis
 
+import org.apache.iceberg.catalog.LoadContext
+import org.apache.iceberg.catalog.TableIdentifier
 import org.apache.iceberg.spark.Spark3Util
+import org.apache.iceberg.spark.SparkSQLProperties
+import org.apache.iceberg.spark.SparkSupportsLoadContext
 import org.apache.iceberg.spark.source.HasIcebergCatalog
 import org.apache.iceberg.spark.source.SparkView
 import org.apache.iceberg.view.{View => IcebergView}
 import org.apache.spark.sql.connector.catalog.CatalogPlugin
+import org.apache.spark.sql.connector.catalog.CatalogV2Util
 import org.apache.spark.sql.connector.catalog.Identifier
+import org.apache.spark.sql.connector.catalog.Table
 import org.apache.spark.sql.connector.catalog.TableCatalog
 import org.apache.spark.sql.connector.catalog.V1View
 import org.apache.spark.sql.connector.catalog.View
 import org.apache.spark.sql.connector.catalog.ViewCatalog
 import org.apache.spark.sql.errors.QueryCompilationErrors
+import org.apache.spark.sql.internal.SQLConf
 import scala.jdk.CollectionConverters._
 
 object ViewUtil {
+
   val RESERVED_PROPERTIES: Seq[String] =
     Seq(
       TableCatalog.PROP_COMMENT,
@@ -62,6 +70,17 @@ object ViewUtil {
     case _ => None
   }
 
+  def loadView(catalog: CatalogPlugin, ident: Identifier, context: 
LoadContext): Option[View] =
+    catalog match {
+      case supportsLoadContext: SparkSupportsLoadContext =>
+        try {
+          Option(supportsLoadContext.loadView(ident, 
context)).filter(isIcebergView)
+        } catch {
+          case _: NoSuchViewException => None
+        }
+      case _ => loadView(catalog, ident)
+    }
+
   def loadIcebergView(catalog: CatalogPlugin, ident: Identifier): 
Option[IcebergView] =
     catalog match {
       case catalogWithIceberg: HasIcebergCatalog =>
@@ -73,6 +92,69 @@ object ViewUtil {
         None
     }
 
+  /**
+   * Build the referenced-by view chain from fully qualified view identifier 
parts.
+   * Entries must belong to the same catalog as the loaded target.
+   */
+  def buildReferencedByChain(
+      viewChain: Seq[Seq[String]],
+      targetCatalogName: String): java.util.List[TableIdentifier] = {
+    viewChain.foreach { parts =>
+      require(
+        parts.size >= 3,
+        s"View chain entry must be fully qualified [catalog, namespace..., 
name], got: " +
+          parts.mkString("."))
+    }
+
+    val crossCatalogViews = viewChain.filter(parts => 
!parts.headOption.contains(targetCatalogName))
+    if (crossCatalogViews.nonEmpty) {
+      throw new IllegalStateException(
+        s"Cross-catalog view references are not supported with referenced-by 
enabled. " +
+          s"Views from catalogs 
[${crossCatalogViews.map(_.head).distinct.mkString(", ")}] " +
+          s"cannot reference entities in catalog [$targetCatalogName]")
+    }
+
+    val viewIdentifiers = viewChain.map { parts =>
+      TableIdentifier.of(parts.tail: _*)
+    }
+
+    viewIdentifiers.asJava
+  }
+
+  def qualifyParts(
+      parts: Seq[String],
+      catalogAndNamespace: Seq[String],
+      isCatalog: String => Boolean): Seq[String] = {
+    parts match {
+      case Seq(name) => catalogAndNamespace :+ name
+      case _ if !isCatalog(parts.head) => catalogAndNamespace.head +: parts
+      case _ => parts
+    }
+  }
+
+  def loadTable(
+      catalog: CatalogPlugin,
+      ident: Identifier,
+      context: LoadContext,
+      timeTravelSpec: Option[TimeTravelSpec]): Table = {
+    catalog match {
+      case supportsLoadContext: SparkSupportsLoadContext =>
+        timeTravelSpec match {
+          case Some(AsOfVersion(version)) => 
supportsLoadContext.loadTable(ident, version, context)
+          case Some(AsOfTimestamp(micros)) => 
supportsLoadContext.loadTable(ident, micros, context)
+          case None => supportsLoadContext.loadTable(ident, context)
+        }
+      case _ => CatalogV2Util.getTable(catalog, ident, timeTravelSpec)
+    }
+  }
+
+  def referencedByEnabled: Boolean = {
+    java.lang.Boolean.parseBoolean(
+      SQLConf.get.getConfString(
+        SparkSQLProperties.REFERENCED_BY_ENABLED,
+        SparkSQLProperties.REFERENCED_BY_ENABLED_DEFAULT.toString))
+  }
+
   def isIcebergViewCatalog(catalog: CatalogPlugin): Boolean = {
     catalog.isInstanceOf[ViewCatalog] && 
catalog.isInstanceOf[HasIcebergCatalog]
   }
diff --git 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/TestSparkSessionCatalog.java
 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/TestSparkSessionCatalog.java
index 61b0eaa15d..a7373fcdae 100644
--- 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/TestSparkSessionCatalog.java
+++ 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/TestSparkSessionCatalog.java
@@ -32,6 +32,8 @@ import static org.mockito.Mockito.withSettings;
 
 import java.nio.file.Path;
 import java.util.Collections;
+import java.util.List;
+import org.apache.iceberg.catalog.LoadContext;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.exceptions.AlreadyExistsException;
 import org.apache.spark.sql.catalyst.analysis.NoSuchNamespaceException;
@@ -631,6 +633,49 @@ public class TestSparkSessionCatalog extends TestBase {
     verify(icebergViewCatalog, never()).createOrReplaceView(viewIdent, 
replacement);
   }
 
+  @Test
+  void loadTableWithReferencedByFailsWhenCatalogCannotPassContext() {
+    Identifier ident = Identifier.of(new String[] {"default"}, "table");
+    TableCatalog icebergCatalog = icebergCatalogWithViews();
+    SparkSessionCatalog<?> catalog = catalogWithViews(icebergCatalog, 
sessionCatalogWithViews());
+    LoadContext context =
+        
LoadContext.builder().referencedBy(List.of(TableIdentifier.of("default", 
"view"))).build();
+
+    assertThatThrownBy(() -> catalog.loadTable(ident, context))
+        .isInstanceOf(UnsupportedOperationException.class)
+        .hasMessage(
+            "Cannot load table default.table with referenced-by: %s does not 
support load context",
+            icebergCatalog.getClass().getName());
+  }
+
+  @Test
+  void loadTableWithoutReferencedByUsesCatalogWithoutContext() throws 
NoSuchTableException {
+    Identifier ident = Identifier.of(new String[] {"default"}, "table");
+    TableCatalog icebergCatalog = icebergCatalogWithViews();
+    SparkSessionCatalog<?> catalog = catalogWithViews(icebergCatalog, 
sessionCatalogWithViews());
+
+    catalog.loadTable(ident, LoadContext.empty());
+
+    verify(icebergCatalog).loadTable(ident);
+  }
+
+  @Test
+  void loadViewWithReferencedByDoesNotCheckExistenceWithoutContext() throws 
NoSuchViewException {
+    Identifier ident = Identifier.of(new String[] {"default"}, "view");
+    TableCatalog icebergCatalog =
+        mock(
+            TableCatalog.class,
+            withSettings().extraInterfaces(ViewCatalog.class, 
SparkSupportsLoadContext.class));
+    SparkSessionCatalog<?> catalog = catalogWithViews(icebergCatalog, 
sessionCatalogWithViews());
+    LoadContext context =
+        
LoadContext.builder().referencedBy(List.of(TableIdentifier.of("default", 
"outer"))).build();
+
+    catalog.loadView(ident, context);
+
+    verify((SparkSupportsLoadContext) icebergCatalog).loadView(ident, context);
+    verify((ViewCatalog) icebergCatalog, never()).viewExists(any());
+  }
+
   private static TableCatalog sessionCatalogWithViews() {
     return mock(
         TableCatalog.class,
diff --git 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCatalog.java
 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCatalog.java
index ce24e173c6..9416535b02 100644
--- 
a/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCatalog.java
+++ 
b/spark/v4.2/spark/src/test/java/org/apache/iceberg/spark/source/TestSparkCatalog.java
@@ -19,6 +19,7 @@
 package org.apache.iceberg.spark.source;
 
 import java.util.Map;
+import org.apache.iceberg.catalog.LoadContext;
 import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
@@ -66,6 +67,11 @@ public class TestSparkCatalog<
     return new SparkTable(table);
   }
 
+  @Override
+  public Table loadTable(Identifier ident, LoadContext context) throws 
NoSuchTableException {
+    return loadTable(ident);
+  }
+
   public static void clearTables() {
     TABLE_MAP.clear();
   }

Reply via email to