This is an automated email from the ASF dual-hosted git repository.
dtenedor pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/master by this push:
new 26dd59555221 [SPARK-56853] Improve PATH Tests
26dd59555221 is described below
commit 26dd59555221c9241637eb3c9a8ef91ff6c044bb
Author: Serge Rielau <[email protected]>
AuthorDate: Fri May 15 10:42:43 2026 -0700
[SPARK-56853] Improve PATH Tests
### What changes were proposed in this pull request?
This is a test-only PR that closes coverage gaps identified by a follow-up
QA audit of the SQL Standard PATH feature delivered in SPARK-56489,
SPARK-56501, SPARK-56518, SPARK-56520, SPARK-56605, SPARK-56639, SPARK-56681,
and SPARK-56750. No product code is changed.
New test surfaces:
- **Spark Connect E2E** — `sql/connect/.../SqlPathE2ETestSuite.scala`
(new): `SET PATH` and `current_path()` round-trip over the gRPC client; a
persisted view created under one path resolves its body under the frozen path
even when the invoker switches PATH; `SET PATH` is rejected with
`UNSUPPORTED_FEATURE.SET_PATH_WHEN_DISABLED` over Connect when the feature flag
is off. Previously, `ProtoToParsedPlanTestSuite` pinned `PATH_ENABLED=false`
for analyzer isolation and nothing exercised [...]
- **PySpark API** — `python/pyspark/sql/tests/test_catalog.py`: three new
methods added to `CatalogTestsMixin` (`test_path_current_path_disabled`,
`test_path_set_path_and_current_path`,
`test_path_set_path_rejected_when_disabled`). Because the mixin is shared with
`test_parity_catalog.py`, the same tests run under classic PySpark and under
Connect parity. Previously `pyspark.sql.functions.current_path` had no test
calling it, and `SET PATH` was never exercised from Python.
- **Golden SQL** — `sql-tests/inputs/sql-path.sql` + matching `results/`
and `analyzer-results/` outputs. The file has a TOC and ten labeled sections:
default path observability; SET PATH grammar (literal, DEFAULT_PATH,
SYSTEM_PATH, PATH append, current_schema/current_database, backticks/case);
CURRENT_PATH() and the ANSI no-parens form; the full set of static error
conditions; routine resolution via PATH for scalar AND table functions;
relation resolution via PATH; persisted view fro [...]
- **`COUNT(*) → COUNT(1)` rewrite gate** — three tests in `SetPathSuite`
exercise the path-driven gate: (1) the fixed-point analyzer
(`Analyzer.matchesFunctionName` /
`FunctionResolution.isSessionBeforeBuiltinInPath`); (2) the gate fires ONLY
when a temp count exists (an unrelated temp must not affect the rewrite); (3)
the single-pass-resolver counterpart
(`FunctionResolverUtils.isUnqualifiedCountShadowedByTemp` via
`isNonDistinctCount` / `handleStarInArguments`), gated under `spark.s [...]
- **`ALTER VIEW ... WITH SCHEMA` preserves the frozen path** — new test in
`v1.AlterViewSchemaBindingSuite` asserts that
`generateViewProperties(captureNewPath=false)` keeps the persisted
`VIEW_RESOLUTION_PATH` intact even when the caller's session PATH differs from
the create-time path.
- **Catalyst unit tests** — new `SqlPathFormatSuite` (`toDescribeJson`
valid / malformed payloads, `formatForDisplay` quoting behavior, multi-level
namespaces). `CatalogManagerSuite` is extended with direct
`PathElement.validateNoStaticDuplicates` cases (case sensitivity, repeated
`CurrentSchemaEntry`, literal-vs-`CurrentSchemaEntry` toleration, multi-part
and dot-containing identifier error formatting) plus `serializePathEntries`
round-tripping (incl. multi-level / quoted / space-con [...]
- **PATH-disabled compat for persisted views** — two new tests in
`SQLViewSuite` pin the documented behavior of
`CatalogManager.resolutionPathEntriesForAnalysis` when
`spark.sql.path.enabled=false`: the pinned frozen path is dropped and analysis
falls back to the recorded `viewCatalogAndNamespace`. The fully-qualified body
path keeps working; the unqualified body resolves through the recorded
namespace (or raises `TABLE_OR_VIEW_NOT_FOUND` when nothing in scope matches).
- **Concurrency smoke test** — new `SetPathSuite` case runs `SET PATH`
(alternating between two paths) and unqualified `count(*)` lookups concurrently
for 200 iterations each, validating the in-source claim that
`SessionCatalog.lookupBuiltinOrTempFunction` is intentionally non-synchronized
to avoid lock-order inversion with `CatalogManager.synchronized`.
- **`cloneSession()` propagation matrix** — replaces the in-source `TODO`
in `SetPathSuite` ("audit and pin down clone semantics in a follow-up") with
six tests pinning what does and does not survive `spark.cloneSession()`.
Confirmed: stored `SET PATH`, `USE SCHEMA`, temp views, and temp functions
(because `functionRegistry.clone()` deep-copies them in
`BaseSessionStateBuilder`) propagate; temp variables do not; a child's `SET
PATH` does not leak back to the parent.
- **V2 catalogs in `SET PATH`** — new `SqlPathV2CatalogSuite` registers two
`InMemoryCatalog` instances and verifies first-match resolution for unqualified
tables (id=10 vs id=20 distinguishes which catalog supplied the row) and for
unqualified V2 functions (a tiny `StrLenTimes100` `ScalarFunction[Int]`
returning `s.length * 100` is registered on `pathcat2`, so `strlen('abc')`
returns 3 or 300 depending on path order), plus the negative case where the
unqualified name only lives in a [...]
Explicitly deferred:
- DESCRIBE FUNCTION rendering of the frozen path
(`DescribeFunctionCommandUtils.storedResolutionPathString`). DESCRIBE FUNCTION
needs broader improvements first; will land in a follow-up.
### Why are the changes needed?
A QA audit of the PATH feature found several substantive gaps:
- Spark Connect serialization of `AnalysisContext.resolutionPathEntries`
was entirely untested; a regression dropping pinned entries from the proto plan
would not have been caught.
- The public `current_path()` PySpark function was documented (with a
`+SKIP` doctest) but never called from any test.
- The PATH feature had no `SQLQueryTestSuite` golden file, despite golden
coverage being the project convention for SQL-level features of similar surface
(CURRENT_SCHEMA, variables, identifier clauses).
- The path-driven gate for `COUNT(*) → COUNT(1)` was a behavior change
called out in the SPARK-56750 description but had no test that flipped PATH and
verified the suppression -- in either the fixed-point or the single-pass
resolver.
- `ALTER VIEW ... WITH SCHEMA` preserving the frozen path was undocumented
in tests.
- `PathElement`, `SqlPathFormat`, and `CatalogManager.serializePathEntries`
were only exercised end-to-end via SQL.
- The PATH-disabled read path through a view that already persisted a
frozen path was untested (a forward-compat scenario likely to occur during
rolling upgrades).
- The deadlock-safety claim in `SessionCatalog` about non-synchronized
lookups was unvalidated.
- The `cloneSession()` propagation matrix was flagged as "incidental" with
an in-source TODO.
- Real V2 catalogs in `SET PATH` were not exercised end-to-end (only stub
`cat`/`cat2` for procedures).
These tests pin the documented intent so future changes have to update the
assertions deliberately.
### Does this PR introduce _any_ user-facing change?
No. Test-only changes.
### How was this patch tested?
The new tests are themselves the patch. They all pass locally:
- `build/sbt "connect-client-jvm/testOnly *SqlPathE2ETestSuite"`
- `build/sbt "sql/testOnly org.apache.spark.sql.SQLQueryTestSuite -- -z
sql-path.sql"` (and regenerated via `SPARK_GENERATE_GOLDEN_FILES=1`)
- `build/sbt "sql/testOnly org.apache.spark.sql.SetPathSuite"`
- `build/sbt "sql/testOnly
org.apache.spark.sql.execution.command.v1.AlterViewSchemaBindingSuite"`
- `build/sbt "sql/testOnly *SimpleSQLViewSuite -- -z SPARK-56853"`
- `build/sbt "sql/testOnly *SqlPathV2CatalogSuite"`
- `build/sbt "catalyst/testOnly *SqlPathFormatSuite *CatalogManagerSuite"`
- `./dev/lint-scala` passes (Scalastyle and Scalafmt).
PySpark tests (`test_path_*` in `CatalogTestsMixin` and the Connect parity
variant) are syntactically validated; they are picked up automatically by the
existing `pyspark.sql.tests.test_catalog` and
`pyspark.sql.tests.connect.test_parity_catalog` modules registered in
`dev/sparktestsupport/modules.py` and will run in CI.
### Was this patch authored or co-authored using generative AI tooling?
Yes. Test design and implementation were iterated with coding-assistant
tooling; the author reviewed and owns the final patch.
Generated-by: Cursor with Claude Opus 4.7
Closes #55866 from srielau/SPARK-56853-path-qa-gaps.
Authored-by: Serge Rielau <[email protected]>
Signed-off-by: Daniel Tenedorio <[email protected]>
---
python/pyspark/sql/tests/test_catalog.py | 41 +
.../sql/catalyst/catalog/SqlPathFormatSuite.scala | 98 ++
.../connector/catalog/CatalogManagerSuite.scala | 111 ++
.../spark/sql/connect/SqlPathE2ETestSuite.scala | 97 ++
.../sql-tests/analyzer-results/sql-path.sql.out | 1041 +++++++++++++++++
.../test/resources/sql-tests/inputs/sql-path.sql | 410 +++++++
.../resources/sql-tests/results/sql-path.sql.out | 1202 ++++++++++++++++++++
.../scala/org/apache/spark/sql/SetPathSuite.scala | 273 ++++-
.../sql/connector/SqlPathV2CatalogSuite.scala | 157 +++
.../apache/spark/sql/execution/SQLViewSuite.scala | 100 ++
.../command/v1/AlterViewSchemaBindingSuite.scala | 48 +-
11 files changed, 3572 insertions(+), 6 deletions(-)
diff --git a/python/pyspark/sql/tests/test_catalog.py
b/python/pyspark/sql/tests/test_catalog.py
index 92ffea233215..d832a9ffa7d0 100644
--- a/python/pyspark/sql/tests/test_catalog.py
+++ b/python/pyspark/sql/tests/test_catalog.py
@@ -588,6 +588,47 @@ class CatalogTestsMixin:
spark.sql(f"INSERT INTO {t} VALUES (1)")
spark.catalog.analyzeTable(t, noScan=True)
+ def test_path_current_path_disabled(self):
+ # current_path() is a regular builtin and resolves even when
+ # spark.sql.path.enabled is false. The DataFrame and SQL surfaces must
agree.
+ from pyspark.sql.functions import current_path
+
+ spark = self.spark
+ with self.sql_conf({"spark.sql.path.enabled": False}):
+ sql_form = spark.sql("SELECT current_path()").collect()[0][0]
+ self.assertIsInstance(sql_form, str)
+ self.assertNotEqual(sql_form, "")
+ api_form = spark.range(1).select(current_path()).collect()[0][0]
+ self.assertEqual(sql_form, api_form)
+
+ def test_path_set_path_and_current_path(self):
+ # SET PATH is parsed and applied; current_path() reflects it
+ # over both the SQL and DataFrame surfaces. Restores DEFAULT_PATH on
exit.
+ from pyspark.sql.functions import current_path
+
+ spark = self.spark
+ with self.sql_conf({"spark.sql.path.enabled": True}):
+ try:
+ spark.sql("SET PATH = spark_catalog.default, system.builtin")
+ sql_form = spark.sql("SELECT current_path()").collect()[0][0]
+ self.assertEqual(sql_form,
"spark_catalog.default,system.builtin")
+ api_form =
spark.range(1).select(current_path()).collect()[0][0]
+ self.assertEqual(sql_form, api_form)
+ finally:
+ spark.sql("SET PATH = DEFAULT_PATH")
+
+ def test_path_set_path_rejected_when_disabled(self):
+ # SET PATH must raise UNSUPPORTED_FEATURE.SET_PATH_WHEN_DISABLED
+ # when the feature flag is off (covers both classic and Connect error
paths).
+ spark = self.spark
+ with self.sql_conf({"spark.sql.path.enabled": False}):
+ with self.assertRaises(AnalysisException) as ctx:
+ spark.sql("SET PATH = spark_catalog.default")
+ self.assertEqual(
+ ctx.exception.getCondition(),
+ "UNSUPPORTED_FEATURE.SET_PATH_WHEN_DISABLED",
+ )
+
class CatalogTests(CatalogTestsMixin, ReusedSQLTestCase):
pass
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/catalog/SqlPathFormatSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/catalog/SqlPathFormatSuite.scala
new file mode 100644
index 000000000000..0ed3bcfb1963
--- /dev/null
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/catalyst/catalog/SqlPathFormatSuite.scala
@@ -0,0 +1,98 @@
+/*
+ * 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.catalog
+
+import org.json4s.JsonAST.{JArray, JObject, JString}
+import org.json4s.jackson.JsonMethods.{compact, render}
+
+import org.apache.spark.SparkFunSuite
+
+/**
+ * Unit tests for [[SqlPathFormat]] -- the helper that converts the raw
JSON-array-of-arrays
+ * path stored on view / SQL function metadata into the JSON-object form used
by DESCRIBE
+ * AS JSON and the human-readable form used by DESCRIBE EXTENDED.
+ */
+class SqlPathFormatSuite extends SparkFunSuite {
+
+ private def compactJson(v: JArray): String = compact(render(v))
+
+ test("toDescribeJson: maps each [catalog, ns...] entry to a JSON object") {
+ val stored =
+ """[["spark_catalog","default"],["system","builtin"]]"""
+ val result = SqlPathFormat.toDescribeJson(stored)
+ .getOrElse(fail(s"Expected a JSON value, got None for: $stored"))
+ val expected = JArray(List(
+ JObject("catalog_name" -> JString("spark_catalog"),
+ "namespace" -> JArray(List(JString("default")))),
+ JObject("catalog_name" -> JString("system"),
+ "namespace" -> JArray(List(JString("builtin"))))))
+ assert(compactJson(result.asInstanceOf[JArray]) == compactJson(expected))
+ }
+
+ test("toDescribeJson: multi-level namespace becomes [head, tail...]") {
+ val stored = """[["cat1","db","sub"]]"""
+ val result = SqlPathFormat.toDescribeJson(stored)
+ .getOrElse(fail("Expected a JSON value"))
+ val expected = JArray(List(
+ JObject("catalog_name" -> JString("cat1"),
+ "namespace" -> JArray(List(JString("db"), JString("sub"))))))
+ assert(compactJson(result.asInstanceOf[JArray]) == compactJson(expected))
+ }
+
+ test("toDescribeJson: empty array returns None") {
+ assert(SqlPathFormat.toDescribeJson("[]").isEmpty)
+ }
+
+ test("toDescribeJson: malformed payloads return None") {
+ Seq(
+ "",
+ "not_json",
+ "{}",
+ """{"foo":1}""",
+ """[1, 2, 3]"""
+ ).foreach { payload =>
+ assert(SqlPathFormat.toDescribeJson(payload).isEmpty,
s"payload=$payload")
+ }
+ }
+
+ test("formatForDisplay: renders plain identifiers without backticks") {
+ val json = SqlPathFormat.toDescribeJson(
+ """[["spark_catalog","default"],["system","builtin"]]""")
+ .getOrElse(fail("Expected a JSON value"))
+ val rendered = SqlPathFormat.formatForDisplay(json)
+ .getOrElse(fail("Expected a display string"))
+ assert(rendered == "spark_catalog.default, system.builtin")
+ }
+
+ test("formatForDisplay: backticks identifiers that need quoting") {
+ val json = SqlPathFormat.toDescribeJson(
+ """[["spark_catalog","weird.schema"]]""")
+ .getOrElse(fail("Expected a JSON value"))
+ val rendered = SqlPathFormat.formatForDisplay(json)
+ .getOrElse(fail("Expected a display string"))
+ assert(rendered == "spark_catalog.`weird.schema`")
+ }
+
+ test("formatForDisplay: round-trips multi-level namespaces") {
+ val json = SqlPathFormat.toDescribeJson("""[["cat","db","ns"]]""")
+ .getOrElse(fail("Expected a JSON value"))
+ val rendered = SqlPathFormat.formatForDisplay(json)
+ .getOrElse(fail("Expected a display string"))
+ assert(rendered == "cat.db.ns")
+ }
+}
diff --git
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/CatalogManagerSuite.scala
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/CatalogManagerSuite.scala
index acf86aae1eea..64b2ac91fbd6 100644
---
a/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/CatalogManagerSuite.scala
+++
b/sql/catalyst/src/test/scala/org/apache/spark/sql/connector/catalog/CatalogManagerSuite.scala
@@ -22,9 +22,11 @@ import java.net.URI
import scala.jdk.CollectionConverters._
import org.apache.spark.SparkFunSuite
+import org.apache.spark.sql.AnalysisException
import org.apache.spark.sql.catalyst.analysis.{EmptyFunctionRegistry,
FakeV2SessionCatalog, NoSuchNamespaceException}
import org.apache.spark.sql.catalyst.catalog.{CatalogDatabase, InMemoryCatalog
=> V1InMemoryCatalog, SessionCatalog}
import org.apache.spark.sql.catalyst.plans.SQLHelper
+import
org.apache.spark.sql.connector.catalog.CatalogManager.{CurrentSchemaEntry,
LiteralPathEntry}
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.util.CaseInsensitiveStringMap
@@ -150,6 +152,115 @@ class CatalogManagerSuite extends SparkFunSuite with
SQLHelper {
assert(CatalogManager.deserializePathEntries(payload).isEmpty,
s"payload=$payload")
}
}
+
+ test("serializePathEntries round-trips through deserialize for typical
inputs") {
+ val cases = Seq(
+ Seq(Seq("spark_catalog", "default"), Seq("system", "builtin")),
+ Seq(Seq("system", "session")),
+ Seq.empty[Seq[String]])
+ cases.foreach { entries =>
+ val payload = CatalogManager.serializePathEntries(entries)
+ val parsed = CatalogManager.deserializePathEntries(payload)
+ .getOrElse(fail(s"Expected payload to round-trip: $payload"))
+ assert(parsed === entries, s"Round-trip mismatch for $entries; got
$parsed")
+ }
+ }
+
+ test("serializePathEntries round-trips multi-level and quoted identifiers") {
+ val entries = Seq(
+ Seq("cat", "ns1", "ns2"),
+ Seq("spark_catalog", "sch.with.dots"),
+ Seq("spark_catalog", "schema with spaces"))
+ val payload = CatalogManager.serializePathEntries(entries)
+ val parsed = CatalogManager.deserializePathEntries(payload)
+ .getOrElse(fail(s"Expected payload to round-trip: $payload"))
+ assert(parsed === entries)
+ }
+
+ test("deserializePathEntriesOrFail raises a clear AnalysisException for bad
payloads") {
+ val e = intercept[AnalysisException] {
+ CatalogManager.deserializePathEntriesOrFail(
+ storedPathStr = "{bad-json",
+ objectType = "view",
+ objectName = "default.v_broken")
+ }
+ assert(e.getMessage.contains("Invalid stored SQL path metadata for view"))
+ assert(e.getMessage.contains("default.v_broken"))
+ }
+
+ //
---------------------------------------------------------------------------
+ // Direct unit tests for [[PathElement.validateNoStaticDuplicates]]. The
end-to-end
+ // `SetPathSuite` exercises this via SQL, but the duplicate-detection rules
+ // (literal-vs-literal, current_schema-vs-current_schema, case-sensitivity)
are pure
+ // data and benefit from focused tests close to the implementation.
+ //
---------------------------------------------------------------------------
+
+ private def literalEntry(parts: String*): LiteralPathEntry =
LiteralPathEntry(parts.toSeq)
+
+ test("validateNoStaticDuplicates: no duplicates returns the input
unchanged") {
+ val entries = Seq(
+ literalEntry("spark_catalog", "default"),
+ literalEntry("system", "builtin"),
+ CurrentSchemaEntry)
+ assert(PathElement.validateNoStaticDuplicates(entries, caseSensitive =
false) === entries)
+ }
+
+ test("validateNoStaticDuplicates: duplicate literal under case-insensitive
collation") {
+ val entries = Seq(
+ literalEntry("spark_catalog", "default"),
+ literalEntry("Spark_Catalog", "DEFAULT"))
+ val e = intercept[AnalysisException] {
+ PathElement.validateNoStaticDuplicates(entries, caseSensitive = false)
+ }
+ assert(e.getCondition == "DUPLICATE_SQL_PATH_ENTRY")
+ assert(e.getMessageParameters.get("pathEntry") == "Spark_Catalog.DEFAULT")
+ }
+
+ test("validateNoStaticDuplicates: case-sensitive mode keeps differently
cased entries") {
+ val entries = Seq(
+ literalEntry("spark_catalog", "DEFAULT"),
+ literalEntry("spark_catalog", "default"))
+ assert(PathElement.validateNoStaticDuplicates(entries, caseSensitive =
true) === entries)
+ }
+
+ test("validateNoStaticDuplicates: repeated CurrentSchemaEntry is rejected") {
+ val entries = Seq(CurrentSchemaEntry, CurrentSchemaEntry)
+ val e = intercept[AnalysisException] {
+ PathElement.validateNoStaticDuplicates(entries, caseSensitive = false)
+ }
+ assert(e.getCondition == "DUPLICATE_SQL_PATH_ENTRY")
+ assert(e.getMessageParameters.get("pathEntry") == "current_schema")
+ }
+
+ test("validateNoStaticDuplicates: literal-vs-CurrentSchemaEntry collision is
tolerated") {
+ // The CurrentSchemaEntry marker resolves dynamically against USE SCHEMA,
so a literal
+ // that happens to match the live current schema is intentionally not
flagged here.
+ val entries = Seq(
+ literalEntry("spark_catalog", "default"),
+ CurrentSchemaEntry,
+ literalEntry("system", "builtin"))
+ assert(PathElement.validateNoStaticDuplicates(entries, caseSensitive =
false) === entries)
+ }
+
+ test("validateNoStaticDuplicates: identifier containing a dot is quoted in
the error") {
+ val entries = Seq(
+ literalEntry("spark_catalog", "weird.schema"),
+ literalEntry("spark_catalog", "weird.schema"))
+ val e = intercept[AnalysisException] {
+ PathElement.validateNoStaticDuplicates(entries, caseSensitive = false)
+ }
+ assert(e.getMessageParameters.get("pathEntry") ==
"spark_catalog.`weird.schema`")
+ }
+
+ test("validateNoStaticDuplicates: multi-level namespace duplicate is
flagged") {
+ val entries = Seq(
+ literalEntry("cat", "db", "ns"),
+ literalEntry("cat", "db", "ns"))
+ val e = intercept[AnalysisException] {
+ PathElement.validateNoStaticDuplicates(entries, caseSensitive = false)
+ }
+ assert(e.getMessageParameters.get("pathEntry") == "cat.db.ns")
+ }
}
class DummyCatalog extends CatalogPlugin {
diff --git
a/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/SqlPathE2ETestSuite.scala
b/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/SqlPathE2ETestSuite.scala
new file mode 100644
index 000000000000..88ed1f31c86a
--- /dev/null
+++
b/sql/connect/client/jvm/src/test/scala/org/apache/spark/sql/connect/SqlPathE2ETestSuite.scala
@@ -0,0 +1,97 @@
+/*
+ * 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.connect
+
+import org.apache.spark.sql.AnalysisException
+import org.apache.spark.sql.connect.test.{ConnectFunSuite, RemoteSparkSession,
SQLHelper}
+import org.apache.spark.sql.functions.current_path
+
+/**
+ * End-to-end coverage for the SQL Standard PATH feature over Spark Connect.
+ *
+ * SET PATH and the frozen-path semantics for persisted views / SQL functions
are implemented
+ * entirely server-side, but the analyzer state (`AnalysisContext`) that
carries the pinned path
+ * must survive plan reification across the gRPC boundary. These tests run the
public surface over
+ * a real Connect client so regressions there are caught:
+ * - `SET PATH = ...` is parsed and applied to the session,
+ * - `current_path()` (SQL and the DataFrame builtin) reflects it,
+ * - a persisted view created under one path resolves its body under the
frozen path even when
+ * the invoker switches the session path.
+ */
+class SqlPathE2ETestSuite extends ConnectFunSuite with RemoteSparkSession with
SQLHelper {
+
+ test("SET PATH and current_path() round-trip over Connect") {
+ withSQLConf("spark.sql.path.enabled" -> "true") {
+ try {
+ spark.sql("SET PATH = spark_catalog.default, system.builtin")
+ val sqlPath = spark.sql("SELECT current_path()").head().getString(0)
+ assert(
+ sqlPath == "spark_catalog.default,system.builtin",
+ s"current_path() over Connect should reflect SET PATH; got:
$sqlPath")
+
+ // DataFrame builtin should agree with the SQL form.
+ val apiPath = spark.range(1).select(current_path()).head().getString(0)
+ assert(
+ apiPath == sqlPath,
+ s"functions.current_path() should match SQL current_path(); got:
$apiPath vs $sqlPath")
+ } finally {
+ spark.sql("SET PATH = DEFAULT_PATH")
+ }
+ }
+ }
+
+ test("Persisted view body uses frozen path over Connect") {
+ withSQLConf("spark.sql.path.enabled" -> "true") {
+ withDatabase("connect_path_a", "connect_path_b") {
+ spark.sql("CREATE DATABASE connect_path_a")
+ spark.sql("CREATE DATABASE connect_path_b")
+ spark.sql("CREATE TABLE connect_path_a.frozen_t USING parquet AS
SELECT 1 AS id")
+ spark.sql("CREATE TABLE connect_path_b.frozen_t USING parquet AS
SELECT 2 AS id")
+ withView("default.v_path_connect") {
+ try {
+ // Create the view under PATH=a.
+ spark.sql("SET PATH = spark_catalog.connect_path_a,
system.builtin")
+ spark.sql("CREATE VIEW default.v_path_connect AS SELECT id FROM
frozen_t")
+
+ // Switch the session path to b; bare `frozen_t` now resolves
through b,
+ // but the view's frozen path keeps it pinned to a.
+ spark.sql("SET PATH = spark_catalog.connect_path_b,
system.builtin")
+ val bare = spark.sql("SELECT id FROM frozen_t").head().getInt(0)
+ assert(bare == 2, s"Bare `frozen_t` should follow live PATH=b;
got: $bare")
+ val viaView = spark.sql("SELECT id FROM
default.v_path_connect").head().getInt(0)
+ assert(
+ viaView == 1,
+ s"View body should resolve via the frozen creation-time PATH;
got: $viaView")
+ } finally {
+ spark.sql("SET PATH = DEFAULT_PATH")
+ }
+ }
+ }
+ }
+ }
+
+ test("SET PATH is rejected over Connect when feature is disabled") {
+ withSQLConf("spark.sql.path.enabled" -> "false") {
+ val ex = intercept[AnalysisException] {
+ spark.sql("SET PATH = spark_catalog.default")
+ }
+ assert(
+ ex.getCondition == "UNSUPPORTED_FEATURE.SET_PATH_WHEN_DISABLED",
+ s"Expected SET_PATH_WHEN_DISABLED, got: ${ex.getCondition}")
+ }
+ }
+}
diff --git
a/sql/core/src/test/resources/sql-tests/analyzer-results/sql-path.sql.out
b/sql/core/src/test/resources/sql-tests/analyzer-results/sql-path.sql.out
new file mode 100644
index 000000000000..3a494d1cd3b7
--- /dev/null
+++ b/sql/core/src/test/resources/sql-tests/analyzer-results/sql-path.sql.out
@@ -0,0 +1,1041 @@
+-- Automatically generated by SQLQueryTestSuite
+-- !query
+SELECT current_path()
+-- !query analysis
+Project [current_path() AS current_path()#x]
++- OneRowRelation
+
+
+-- !query
+SET PATH = spark_catalog.default, system.builtin
+-- !query analysis
+SetPathCommand [SchemaInPath(List(spark_catalog, default)),
SchemaInPath(List(system, builtin))]
+
+
+-- !query
+SELECT current_path()
+-- !query analysis
+Project [current_path() AS current_path()#x]
++- OneRowRelation
+
+
+-- !query
+SET PATH = Spark_Catalog.Default, System.Builtin
+-- !query analysis
+SetPathCommand [SchemaInPath(List(Spark_Catalog, Default)),
SchemaInPath(List(System, Builtin))]
+
+
+-- !query
+SELECT current_path()
+-- !query analysis
+Project [current_path() AS current_path()#x]
++- OneRowRelation
+
+
+-- !query
+SET PATH = spark_catalog.`sch.b`, system.builtin
+-- !query analysis
+SetPathCommand [SchemaInPath(List(spark_catalog, sch.b)),
SchemaInPath(List(system, builtin))]
+
+
+-- !query
+SELECT current_path()
+-- !query analysis
+Project [current_path() AS current_path()#x]
++- OneRowRelation
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query analysis
+SetPathCommand [DefaultPath]
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query analysis
+SetPathCommand [DefaultPath]
+
+
+-- !query
+SELECT current_path()
+-- !query analysis
+Project [current_path() AS current_path()#x]
++- OneRowRelation
+
+
+-- !query
+SET PATH = SYSTEM_PATH
+-- !query analysis
+SetPathCommand [SystemPath]
+
+
+-- !query
+SELECT current_path()
+-- !query analysis
+Project [current_path() AS current_path()#x]
++- OneRowRelation
+
+
+-- !query
+SET PATH = spark_catalog.default, system.builtin
+-- !query analysis
+SetPathCommand [SchemaInPath(List(spark_catalog, default)),
SchemaInPath(List(system, builtin))]
+
+
+-- !query
+SET PATH = PATH, system.session
+-- !query analysis
+SetPathCommand [PathRef, SchemaInPath(List(system, session))]
+
+
+-- !query
+SELECT current_path()
+-- !query analysis
+Project [current_path() AS current_path()#x]
++- OneRowRelation
+
+
+-- !query
+USE spark_catalog.default
+-- !query analysis
+SetCatalogAndNamespace
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [default]
+
+
+-- !query
+SET PATH = current_schema, system.builtin
+-- !query analysis
+SetPathCommand [CurrentSchema, SchemaInPath(List(system, builtin))]
+
+
+-- !query
+SELECT current_path()
+-- !query analysis
+Project [current_path() AS current_path()#x]
++- OneRowRelation
+
+
+-- !query
+SET PATH = current_database, system.builtin
+-- !query analysis
+SetPathCommand [CurrentSchema, SchemaInPath(List(system, builtin))]
+
+
+-- !query
+SELECT current_path()
+-- !query analysis
+Project [current_path() AS current_path()#x]
++- OneRowRelation
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query analysis
+SetPathCommand [DefaultPath]
+
+
+-- !query
+SET PATH = spark_catalog.default, system.builtin
+-- !query analysis
+SetPathCommand [SchemaInPath(List(spark_catalog, default)),
SchemaInPath(List(system, builtin))]
+
+
+-- !query
+SELECT CURRENT_PATH = current_path() AS ansi_form_matches
+-- !query analysis
+Project [(current_path() = current_path()) AS ansi_form_matches#x]
++- OneRowRelation
+
+
+-- !query
+SELECT current_path(1)
+-- !query analysis
+org.apache.spark.sql.AnalysisException
+{
+ "errorClass" : "WRONG_NUM_ARGS.WITHOUT_SUGGESTION",
+ "sqlState" : "42605",
+ "messageParameters" : {
+ "actualNum" : "1",
+ "docroot" : "https://spark.apache.org/docs/latest",
+ "expectedNum" : "0",
+ "functionName" : "`current_path`"
+ },
+ "queryContext" : [ {
+ "objectType" : "",
+ "objectName" : "",
+ "startIndex" : 8,
+ "stopIndex" : 22,
+ "fragment" : "current_path(1)"
+ } ]
+}
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query analysis
+SetPathCommand [DefaultPath]
+
+
+-- !query
+SET PATH = spark_catalog.default, spark_catalog.default
+-- !query analysis
+org.apache.spark.sql.AnalysisException
+{
+ "errorClass" : "DUPLICATE_SQL_PATH_ENTRY",
+ "sqlState" : "42732",
+ "messageParameters" : {
+ "pathEntry" : "spark_catalog.default"
+ }
+}
+
+
+-- !query
+SET PATH = spark_catalog.DEFAULT, spark_catalog.default
+-- !query analysis
+org.apache.spark.sql.AnalysisException
+{
+ "errorClass" : "DUPLICATE_SQL_PATH_ENTRY",
+ "sqlState" : "42732",
+ "messageParameters" : {
+ "pathEntry" : "spark_catalog.default"
+ }
+}
+
+
+-- !query
+SET PATH = DEFAULT_PATH, system.builtin
+-- !query analysis
+org.apache.spark.sql.AnalysisException
+{
+ "errorClass" : "DUPLICATE_SQL_PATH_ENTRY",
+ "sqlState" : "42732",
+ "messageParameters" : {
+ "pathEntry" : "system.builtin"
+ }
+}
+
+
+-- !query
+SET PATH = SYSTEM_PATH, SYSTEM_PATH
+-- !query analysis
+org.apache.spark.sql.AnalysisException
+{
+ "errorClass" : "DUPLICATE_SQL_PATH_ENTRY",
+ "sqlState" : "42732",
+ "messageParameters" : {
+ "pathEntry" : "system.builtin"
+ }
+}
+
+
+-- !query
+SET PATH = current_database, current_schema
+-- !query analysis
+org.apache.spark.sql.AnalysisException
+{
+ "errorClass" : "DUPLICATE_SQL_PATH_ENTRY",
+ "sqlState" : "42732",
+ "messageParameters" : {
+ "pathEntry" : "current_schema"
+ }
+}
+
+
+-- !query
+SET PATH = my_schema_no_catalog
+-- !query analysis
+org.apache.spark.sql.catalyst.parser.ParseException
+{
+ "errorClass" : "INVALID_SQL_PATH_SCHEMA_REFERENCE",
+ "sqlState" : "42601",
+ "messageParameters" : {
+ "qualifiedName" : "my_schema_no_catalog"
+ }
+}
+
+
+-- !query
+CREATE SCHEMA sql_path_routines
+-- !query analysis
+CreateNamespace false
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [sql_path_routines]
+
+
+-- !query
+CREATE FUNCTION sql_path_routines.pick() RETURNS INT RETURN 7
+-- !query analysis
+CreateSQLFunctionCommand spark_catalog.sql_path_routines.pick, INT, 7, false,
false, false, false
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_routines, spark_catalog.default,
system.builtin
+-- !query analysis
+SetPathCommand [SchemaInPath(List(spark_catalog, sql_path_routines)),
SchemaInPath(List(spark_catalog, default)), SchemaInPath(List(system, builtin))]
+
+
+-- !query
+SELECT pick()
+-- !query analysis
+Project [spark_catalog.sql_path_routines.pick() AS
spark_catalog.sql_path_routines.pick()#x]
++- Project
+ +- OneRowRelation
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query analysis
+SetPathCommand [DefaultPath]
+
+
+-- !query
+CREATE FUNCTION sql_path_routines.pick_tvf()
+RETURNS TABLE(val INT)
+RETURN SELECT 7 AS val
+-- !query analysis
+CreateSQLFunctionCommand spark_catalog.sql_path_routines.pick_tvf, val INT,
SELECT 7 AS val, true, false, false, false
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_routines, spark_catalog.default,
system.builtin
+-- !query analysis
+SetPathCommand [SchemaInPath(List(spark_catalog, sql_path_routines)),
SchemaInPath(List(spark_catalog, default)), SchemaInPath(List(system, builtin))]
+
+
+-- !query
+SELECT * FROM pick_tvf()
+-- !query analysis
+Project [val#x]
++- SQLFunctionNode spark_catalog.sql_path_routines.pick_tvf
+ +- SubqueryAlias pick_tvf
+ +- Project [cast(val#x as int) AS val#x]
+ +- Project [7 AS val#x]
+ +- OneRowRelation
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query analysis
+SetPathCommand [DefaultPath]
+
+
+-- !query
+CREATE SCHEMA sql_path_routines_b
+-- !query analysis
+CreateNamespace false
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [sql_path_routines_b]
+
+
+-- !query
+CREATE FUNCTION sql_path_routines_b.pick() RETURNS INT RETURN 11
+-- !query analysis
+CreateSQLFunctionCommand spark_catalog.sql_path_routines_b.pick, INT, 11,
false, false, false, false
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_routines, spark_catalog.sql_path_routines_b,
system.builtin
+-- !query analysis
+SetPathCommand [SchemaInPath(List(spark_catalog, sql_path_routines)),
SchemaInPath(List(spark_catalog, sql_path_routines_b)),
SchemaInPath(List(system, builtin))]
+
+
+-- !query
+SELECT pick() AS from_first_schema
+-- !query analysis
+Project [spark_catalog.sql_path_routines.pick() AS from_first_schema#x]
++- Project
+ +- OneRowRelation
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_routines_b, spark_catalog.sql_path_routines,
system.builtin
+-- !query analysis
+SetPathCommand [SchemaInPath(List(spark_catalog, sql_path_routines_b)),
SchemaInPath(List(spark_catalog, sql_path_routines)), SchemaInPath(List(system,
builtin))]
+
+
+-- !query
+SELECT pick() AS from_first_schema
+-- !query analysis
+Project [spark_catalog.sql_path_routines_b.pick() AS from_first_schema#x]
++- Project
+ +- OneRowRelation
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query analysis
+SetPathCommand [DefaultPath]
+
+
+-- !query
+SET PATH = spark_catalog.default, system.builtin
+-- !query analysis
+SetPathCommand [SchemaInPath(List(spark_catalog, default)),
SchemaInPath(List(system, builtin))]
+
+
+-- !query
+SELECT pick()
+-- !query analysis
+org.apache.spark.sql.AnalysisException
+{
+ "errorClass" : "UNRESOLVED_ROUTINE",
+ "sqlState" : "42883",
+ "messageParameters" : {
+ "routineName" : "`pick`",
+ "searchPath" : "[`spark_catalog`.`default`, `system`.`builtin`]"
+ },
+ "queryContext" : [ {
+ "objectType" : "",
+ "objectName" : "",
+ "startIndex" : 8,
+ "stopIndex" : 13,
+ "fragment" : "pick()"
+ } ]
+}
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query analysis
+SetPathCommand [DefaultPath]
+
+
+-- !query
+DROP FUNCTION sql_path_routines.pick
+-- !query analysis
+DropFunctionCommand spark_catalog.sql_path_routines.pick, false, false
+
+
+-- !query
+DROP FUNCTION sql_path_routines.pick_tvf
+-- !query analysis
+DropFunctionCommand spark_catalog.sql_path_routines.pick_tvf, false, false
+
+
+-- !query
+DROP FUNCTION sql_path_routines_b.pick
+-- !query analysis
+DropFunctionCommand spark_catalog.sql_path_routines_b.pick, false, false
+
+
+-- !query
+DROP SCHEMA sql_path_routines
+-- !query analysis
+DropNamespace false, false
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [sql_path_routines]
+
+
+-- !query
+DROP SCHEMA sql_path_routines_b
+-- !query analysis
+DropNamespace false, false
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [sql_path_routines_b]
+
+
+-- !query
+CREATE SCHEMA sql_path_relations_a
+-- !query analysis
+CreateNamespace false
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [sql_path_relations_a]
+
+
+-- !query
+CREATE SCHEMA sql_path_relations_b
+-- !query analysis
+CreateNamespace false
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [sql_path_relations_b]
+
+
+-- !query
+CREATE TABLE sql_path_relations_a.tbl USING parquet AS SELECT 1 AS id
+-- !query analysis
+CreateDataSourceTableAsSelectCommand
`spark_catalog`.`sql_path_relations_a`.`tbl`, ErrorIfExists, [id]
+ +- Project [1 AS id#x]
+ +- OneRowRelation
+
+
+-- !query
+CREATE TABLE sql_path_relations_b.tbl USING parquet AS SELECT 2 AS id
+-- !query analysis
+CreateDataSourceTableAsSelectCommand
`spark_catalog`.`sql_path_relations_b`.`tbl`, ErrorIfExists, [id]
+ +- Project [2 AS id#x]
+ +- OneRowRelation
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_relations_a,
spark_catalog.sql_path_relations_b, system.builtin
+-- !query analysis
+SetPathCommand [SchemaInPath(List(spark_catalog, sql_path_relations_a)),
SchemaInPath(List(spark_catalog, sql_path_relations_b)),
SchemaInPath(List(system, builtin))]
+
+
+-- !query
+SELECT id FROM tbl AS from_first_schema
+-- !query analysis
+Project [id#x]
++- SubqueryAlias from_first_schema
+ +- SubqueryAlias spark_catalog.sql_path_relations_a.tbl
+ +- Relation spark_catalog.sql_path_relations_a.tbl[id#x] parquet
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_relations_b,
spark_catalog.sql_path_relations_a, system.builtin
+-- !query analysis
+SetPathCommand [SchemaInPath(List(spark_catalog, sql_path_relations_b)),
SchemaInPath(List(spark_catalog, sql_path_relations_a)),
SchemaInPath(List(system, builtin))]
+
+
+-- !query
+SELECT id FROM tbl AS from_first_schema
+-- !query analysis
+Project [id#x]
++- SubqueryAlias from_first_schema
+ +- SubqueryAlias spark_catalog.sql_path_relations_b.tbl
+ +- Relation spark_catalog.sql_path_relations_b.tbl[id#x] parquet
+
+
+-- !query
+SET PATH = spark_catalog.default, system.builtin
+-- !query analysis
+SetPathCommand [SchemaInPath(List(spark_catalog, default)),
SchemaInPath(List(system, builtin))]
+
+
+-- !query
+SELECT id FROM tbl
+-- !query analysis
+org.apache.spark.sql.catalyst.ExtendedAnalysisException
+{
+ "errorClass" : "TABLE_OR_VIEW_NOT_FOUND",
+ "sqlState" : "42P01",
+ "messageParameters" : {
+ "relationName" : "`tbl`",
+ "searchPath" : "[`spark_catalog`.`default`, `system`.`builtin`]"
+ },
+ "queryContext" : [ {
+ "objectType" : "",
+ "objectName" : "",
+ "startIndex" : 16,
+ "stopIndex" : 18,
+ "fragment" : "tbl"
+ } ]
+}
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query analysis
+SetPathCommand [DefaultPath]
+
+
+-- !query
+DROP TABLE sql_path_relations_a.tbl
+-- !query analysis
+DropTable false, false
++- ResolvedIdentifier V2SessionCatalog(spark_catalog), sql_path_relations_a.tbl
+
+
+-- !query
+DROP TABLE sql_path_relations_b.tbl
+-- !query analysis
+DropTable false, false
++- ResolvedIdentifier V2SessionCatalog(spark_catalog), sql_path_relations_b.tbl
+
+
+-- !query
+DROP SCHEMA sql_path_relations_a
+-- !query analysis
+DropNamespace false, false
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [sql_path_relations_a]
+
+
+-- !query
+DROP SCHEMA sql_path_relations_b
+-- !query analysis
+DropNamespace false, false
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [sql_path_relations_b]
+
+
+-- !query
+CREATE SCHEMA sql_path_views_a
+-- !query analysis
+CreateNamespace false
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [sql_path_views_a]
+
+
+-- !query
+CREATE SCHEMA sql_path_views_b
+-- !query analysis
+CreateNamespace false
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [sql_path_views_b]
+
+
+-- !query
+CREATE TABLE sql_path_views_a.frozen_t USING parquet AS SELECT 1 AS id
+-- !query analysis
+CreateDataSourceTableAsSelectCommand
`spark_catalog`.`sql_path_views_a`.`frozen_t`, ErrorIfExists, [id]
+ +- Project [1 AS id#x]
+ +- OneRowRelation
+
+
+-- !query
+CREATE TABLE sql_path_views_b.frozen_t USING parquet AS SELECT 2 AS id
+-- !query analysis
+CreateDataSourceTableAsSelectCommand
`spark_catalog`.`sql_path_views_b`.`frozen_t`, ErrorIfExists, [id]
+ +- Project [2 AS id#x]
+ +- OneRowRelation
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_views_a, system.builtin
+-- !query analysis
+SetPathCommand [SchemaInPath(List(spark_catalog, sql_path_views_a)),
SchemaInPath(List(system, builtin))]
+
+
+-- !query
+CREATE VIEW default.v_path_frozen AS SELECT id FROM frozen_t
+-- !query analysis
+CreateViewCommand `spark_catalog`.`default`.`v_path_frozen`, SELECT id FROM
frozen_t, false, false, PersistedView, COMPENSATION, true
+ +- Project [id#x]
+ +- SubqueryAlias spark_catalog.sql_path_views_a.frozen_t
+ +- Relation spark_catalog.sql_path_views_a.frozen_t[id#x] parquet
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_views_b, system.builtin
+-- !query analysis
+SetPathCommand [SchemaInPath(List(spark_catalog, sql_path_views_b)),
SchemaInPath(List(system, builtin))]
+
+
+-- !query
+SELECT id FROM frozen_t AS bare_lookup_uses_live_path
+-- !query analysis
+Project [id#x]
++- SubqueryAlias bare_lookup_uses_live_path
+ +- SubqueryAlias spark_catalog.sql_path_views_b.frozen_t
+ +- Relation spark_catalog.sql_path_views_b.frozen_t[id#x] parquet
+
+
+-- !query
+SELECT id FROM default.v_path_frozen AS view_body_uses_frozen_path
+-- !query analysis
+Project [id#x]
++- SubqueryAlias view_body_uses_frozen_path
+ +- SubqueryAlias spark_catalog.default.v_path_frozen
+ +- View (`spark_catalog`.`default`.`v_path_frozen`, [id#x])
+ +- Project [cast(id#x as int) AS id#x]
+ +- Project [id#x]
+ +- SubqueryAlias spark_catalog.sql_path_views_a.frozen_t
+ +- Relation spark_catalog.sql_path_views_a.frozen_t[id#x]
parquet
+
+
+-- !query
+USE spark_catalog.sql_path_views_a
+-- !query analysis
+SetCatalogAndNamespace
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [sql_path_views_a]
+
+
+-- !query
+CREATE VIEW sql_path_views_a.v_ctx AS
+SELECT current_schema() AS cs, current_path() AS cp
+-- !query analysis
+CreateViewCommand `spark_catalog`.`sql_path_views_a`.`v_ctx`, SELECT
current_schema() AS cs, current_path() AS cp, false, false, PersistedView,
COMPENSATION, true
+ +- Project [current_schema() AS cs#x, current_path() AS cp#x]
+ +- OneRowRelation
+
+
+-- !query
+USE spark_catalog.sql_path_views_b
+-- !query analysis
+SetCatalogAndNamespace
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [sql_path_views_b]
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query analysis
+SetPathCommand [DefaultPath]
+
+
+-- !query
+SELECT cs, cp FROM sql_path_views_a.v_ctx
+-- !query analysis
+Project [cs#x, cp#x]
++- SubqueryAlias spark_catalog.sql_path_views_a.v_ctx
+ +- View (`spark_catalog`.`sql_path_views_a`.`v_ctx`, [cs#x, cp#x])
+ +- Project [cast(cs#x as string) AS cs#x, cast(cp#x as string) AS cp#x]
+ +- Project [current_schema() AS cs#x, current_path() AS cp#x]
+ +- OneRowRelation
+
+
+-- !query
+USE spark_catalog.default
+-- !query analysis
+SetCatalogAndNamespace
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [default]
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query analysis
+SetPathCommand [DefaultPath]
+
+
+-- !query
+DROP VIEW default.v_path_frozen
+-- !query analysis
+DropTableCommand `spark_catalog`.`default`.`v_path_frozen`, false, true, false
+
+
+-- !query
+DROP VIEW sql_path_views_a.v_ctx
+-- !query analysis
+DropTableCommand `spark_catalog`.`sql_path_views_a`.`v_ctx`, false, true, false
+
+
+-- !query
+DROP TABLE sql_path_views_a.frozen_t
+-- !query analysis
+DropTable false, false
++- ResolvedIdentifier V2SessionCatalog(spark_catalog),
sql_path_views_a.frozen_t
+
+
+-- !query
+DROP TABLE sql_path_views_b.frozen_t
+-- !query analysis
+DropTable false, false
++- ResolvedIdentifier V2SessionCatalog(spark_catalog),
sql_path_views_b.frozen_t
+
+
+-- !query
+DROP SCHEMA sql_path_views_a
+-- !query analysis
+DropNamespace false, false
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [sql_path_views_a]
+
+
+-- !query
+DROP SCHEMA sql_path_views_b
+-- !query analysis
+DropNamespace false, false
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [sql_path_views_b]
+
+
+-- !query
+CREATE SCHEMA sql_path_fn_a
+-- !query analysis
+CreateNamespace false
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [sql_path_fn_a]
+
+
+-- !query
+CREATE SCHEMA sql_path_fn_b
+-- !query analysis
+CreateNamespace false
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [sql_path_fn_b]
+
+
+-- !query
+CREATE TABLE sql_path_fn_a.frozen_t USING parquet AS SELECT 10 AS id
+-- !query analysis
+CreateDataSourceTableAsSelectCommand
`spark_catalog`.`sql_path_fn_a`.`frozen_t`, ErrorIfExists, [id]
+ +- Project [10 AS id#x]
+ +- OneRowRelation
+
+
+-- !query
+CREATE TABLE sql_path_fn_b.frozen_t USING parquet AS SELECT 20 AS id
+-- !query analysis
+CreateDataSourceTableAsSelectCommand
`spark_catalog`.`sql_path_fn_b`.`frozen_t`, ErrorIfExists, [id]
+ +- Project [20 AS id#x]
+ +- OneRowRelation
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_fn_a, system.builtin
+-- !query analysis
+SetPathCommand [SchemaInPath(List(spark_catalog, sql_path_fn_a)),
SchemaInPath(List(system, builtin))]
+
+
+-- !query
+CREATE FUNCTION default.frozen_fn()
+RETURNS INT
+RETURN (SELECT MAX(id) FROM frozen_t)
+-- !query analysis
+CreateSQLFunctionCommand spark_catalog.default.frozen_fn, INT, (SELECT MAX(id)
FROM frozen_t), false, false, false, false
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_fn_b, system.builtin
+-- !query analysis
+SetPathCommand [SchemaInPath(List(spark_catalog, sql_path_fn_b)),
SchemaInPath(List(system, builtin))]
+
+
+-- !query
+SELECT MAX(id) FROM frozen_t AS bare_lookup_uses_live_path
+-- !query analysis
+Aggregate [max(id#x) AS max(id)#x]
++- SubqueryAlias bare_lookup_uses_live_path
+ +- SubqueryAlias spark_catalog.sql_path_fn_b.frozen_t
+ +- Relation spark_catalog.sql_path_fn_b.frozen_t[id#x] parquet
+
+
+-- !query
+SELECT default.frozen_fn() AS scalar_body_uses_frozen_path
+-- !query analysis
+Project [spark_catalog.default.frozen_fn() AS scalar_body_uses_frozen_path#x]
+: +- Aggregate [max(id#x) AS max(id)#x]
+: +- SubqueryAlias spark_catalog.sql_path_fn_a.frozen_t
+: +- Relation spark_catalog.sql_path_fn_a.frozen_t[id#x] parquet
++- Project
+ +- OneRowRelation
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_fn_a, system.builtin
+-- !query analysis
+SetPathCommand [SchemaInPath(List(spark_catalog, sql_path_fn_a)),
SchemaInPath(List(system, builtin))]
+
+
+-- !query
+CREATE FUNCTION default.frozen_tvf()
+RETURNS TABLE(id INT)
+RETURN SELECT MAX(id) AS id FROM frozen_t
+-- !query analysis
+CreateSQLFunctionCommand spark_catalog.default.frozen_tvf, id INT, SELECT
MAX(id) AS id FROM frozen_t, true, false, false, false
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_fn_b, system.builtin
+-- !query analysis
+SetPathCommand [SchemaInPath(List(spark_catalog, sql_path_fn_b)),
SchemaInPath(List(system, builtin))]
+
+
+-- !query
+SELECT * FROM default.frozen_tvf() AS table_body_uses_frozen_path
+-- !query analysis
+Project [id#x]
++- SubqueryAlias table_body_uses_frozen_path
+ +- SQLFunctionNode spark_catalog.default.frozen_tvf
+ +- SubqueryAlias frozen_tvf
+ +- Project [cast(id#x as int) AS id#x]
+ +- Aggregate [max(id#x) AS id#x]
+ +- SubqueryAlias spark_catalog.sql_path_fn_a.frozen_t
+ +- Relation spark_catalog.sql_path_fn_a.frozen_t[id#x]
parquet
+
+
+-- !query
+USE spark_catalog.sql_path_fn_a
+-- !query analysis
+SetCatalogAndNamespace
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [sql_path_fn_a]
+
+
+-- !query
+CREATE FUNCTION sql_path_fn_a.f_ctx()
+RETURNS STRING
+RETURN concat(current_schema(), '::', current_path())
+-- !query analysis
+CreateSQLFunctionCommand spark_catalog.sql_path_fn_a.f_ctx, STRING,
concat(current_schema(), '::', current_path()), false, false, false, false
+
+
+-- !query
+USE spark_catalog.sql_path_fn_b
+-- !query analysis
+SetCatalogAndNamespace
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [sql_path_fn_b]
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query analysis
+SetPathCommand [DefaultPath]
+
+
+-- !query
+SELECT sql_path_fn_a.f_ctx() AS invoker_context
+-- !query analysis
+Project [spark_catalog.sql_path_fn_a.f_ctx() AS invoker_context#x]
++- Project
+ +- OneRowRelation
+
+
+-- !query
+USE spark_catalog.default
+-- !query analysis
+SetCatalogAndNamespace
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [default]
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query analysis
+SetPathCommand [DefaultPath]
+
+
+-- !query
+DROP FUNCTION default.frozen_fn
+-- !query analysis
+DropFunctionCommand spark_catalog.default.frozen_fn, false, false
+
+
+-- !query
+DROP FUNCTION default.frozen_tvf
+-- !query analysis
+DropFunctionCommand spark_catalog.default.frozen_tvf, false, false
+
+
+-- !query
+DROP FUNCTION sql_path_fn_a.f_ctx
+-- !query analysis
+DropFunctionCommand spark_catalog.sql_path_fn_a.f_ctx, false, false
+
+
+-- !query
+DROP TABLE sql_path_fn_a.frozen_t
+-- !query analysis
+DropTable false, false
++- ResolvedIdentifier V2SessionCatalog(spark_catalog), sql_path_fn_a.frozen_t
+
+
+-- !query
+DROP TABLE sql_path_fn_b.frozen_t
+-- !query analysis
+DropTable false, false
++- ResolvedIdentifier V2SessionCatalog(spark_catalog), sql_path_fn_b.frozen_t
+
+
+-- !query
+DROP SCHEMA sql_path_fn_a
+-- !query analysis
+DropNamespace false, false
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [sql_path_fn_a]
+
+
+-- !query
+DROP SCHEMA sql_path_fn_b
+-- !query analysis
+DropNamespace false, false
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [sql_path_fn_b]
+
+
+-- !query
+SET spark.sql.defaultPath = system.session, system.builtin
+-- !query analysis
+SetCommand (spark.sql.defaultPath,Some(system.session, system.builtin))
+
+
+-- !query
+SET PATH = system.builtin, system.session
+-- !query analysis
+SetPathCommand [SchemaInPath(List(system, builtin)), SchemaInPath(List(system,
session))]
+
+
+-- !query
+SELECT current_path() AS explicit_set_path_wins_over_conf
+-- !query analysis
+Project [current_path() AS explicit_set_path_wins_over_conf#x]
++- OneRowRelation
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query analysis
+SetPathCommand [DefaultPath]
+
+
+-- !query
+RESET spark.sql.defaultPath
+-- !query analysis
+ResetCommand spark.sql.defaultPath
+
+
+-- !query
+SET spark.sql.defaultPath = system.session, system.builtin, current_schema
+-- !query analysis
+SetCommand (spark.sql.defaultPath,Some(system.session, system.builtin,
current_schema))
+
+
+-- !query
+USE spark_catalog.default
+-- !query analysis
+SetCatalogAndNamespace
++- ResolvedNamespace V2SessionCatalog(spark_catalog), [default]
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query analysis
+SetPathCommand [DefaultPath]
+
+
+-- !query
+SELECT current_path() AS default_path_expands_to_conf
+-- !query analysis
+Project [current_path() AS default_path_expands_to_conf#x]
++- OneRowRelation
+
+
+-- !query
+RESET spark.sql.defaultPath
+-- !query analysis
+ResetCommand spark.sql.defaultPath
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query analysis
+SetPathCommand [DefaultPath]
+
+
+-- !query
+SET spark.sql.defaultPath = this is not a path
+-- !query analysis
+org.apache.spark.SparkIllegalArgumentException
+{
+ "errorClass" : "INVALID_CONF_VALUE.REQUIREMENT",
+ "sqlState" : "22022",
+ "messageParameters" : {
+ "confName" : "spark.sql.defaultPath",
+ "confRequirement" : "The value must be empty or a comma-separated SET PATH
element list (same grammar as SET PATH, except PATH is not allowed).",
+ "confValue" : "this is not a path"
+ }
+}
+
+
+-- !query
+SET spark.sql.defaultPath = PATH, system.builtin
+-- !query analysis
+org.apache.spark.SparkIllegalArgumentException
+{
+ "errorClass" : "INVALID_CONF_VALUE.REQUIREMENT",
+ "sqlState" : "22022",
+ "messageParameters" : {
+ "confName" : "spark.sql.defaultPath",
+ "confRequirement" : "The value must be empty or a comma-separated SET PATH
element list (same grammar as SET PATH, except PATH is not allowed).",
+ "confValue" : "PATH, system.builtin"
+ }
+}
+
+
+-- !query
+SET spark.sql.path.enabled = false
+-- !query analysis
+SetCommand (spark.sql.path.enabled,Some(false))
+
+
+-- !query
+SELECT current_path() IS NOT NULL AS has_path
+-- !query analysis
+Project [isnotnull(current_path()) AS has_path#x]
++- OneRowRelation
+
+
+-- !query
+SET PATH = spark_catalog.default
+-- !query analysis
+org.apache.spark.sql.AnalysisException
+{
+ "errorClass" : "UNSUPPORTED_FEATURE.SET_PATH_WHEN_DISABLED",
+ "sqlState" : "0A000",
+ "messageParameters" : {
+ "config" : "spark.sql.path.enabled"
+ }
+}
diff --git a/sql/core/src/test/resources/sql-tests/inputs/sql-path.sql
b/sql/core/src/test/resources/sql-tests/inputs/sql-path.sql
new file mode 100644
index 000000000000..e9d1d149e7fa
--- /dev/null
+++ b/sql/core/src/test/resources/sql-tests/inputs/sql-path.sql
@@ -0,0 +1,410 @@
+-- ============================================================================
+-- SQL Standard PATH golden coverage
+-- ============================================================================
+--
+-- This file is the readable, SQL-level reference for what the PATH feature
+-- does. It is the primary place to look up "how does SET PATH behave when
+-- I write ..." before reaching for the Scala unit suites. Tests that need
+-- features not expressible in pure SQL (multi-threaded execution, session
+-- cloning, view-metadata inspection, Connect/PySpark plumbing) live in the
+-- matching Scala / Python suites.
+--
+-- Table of Contents
+-- -----------------
+-- 1. Default path observability (no SET PATH issued)
+-- 2. SET PATH grammar
+-- 2.1 Literal schema entries; case preservation; backtick quoting
+-- 2.2 DEFAULT_PATH shortcut
+-- 2.3 SYSTEM_PATH shortcut
+-- 2.4 PATH keyword (append to live path)
+-- 2.5 current_schema / current_database shortcuts
+-- 3. CURRENT_PATH() builtin
+-- 3.1 ANSI no-parens form equals current_path()
+-- 3.2 Argument-count validation
+-- 4. Static error conditions at SET PATH
+-- 4.1 Literal duplicate
+-- 4.2 DEFAULT_PATH expansion duplicate
+-- 4.3 SYSTEM_PATH expansion duplicate
+-- 4.4 current_database vs current_schema cross-alias duplicate
+-- 4.5 Single-part schema reference rejected
+-- 5. Routine resolution via PATH
+-- 5.1 Persistent scalar function follows PATH
+-- 5.2 Persistent table function follows PATH
+-- 5.3 First-match ordering across two schemas on PATH
+-- 5.4 Unqualified miss when schema is not on PATH
+-- 6. Relation resolution via PATH
+-- 6.1 Table resolved via PATH; first-match ordering
+-- 6.2 Unqualified miss when schema is not on PATH
+-- 7. Persisted view frozen-path behavior
+-- 7.1 View body resolves via creation-time PATH (not invoker PATH)
+-- 7.2 current_schema / current_path in view body use invoker context
+-- 8. SQL function frozen-path behavior
+-- 8.1 Scalar function body resolves via creation-time PATH
+-- 8.2 Table function body resolves via creation-time PATH
+-- 8.3 current_schema / current_path in function body use invoker context
+-- 9. DEFAULT_PATH conf (spark.sql.defaultPath)
+-- 9.1 Explicit SET PATH overrides the conf
+-- 9.2 SET PATH = DEFAULT_PATH expands to the conf value
+-- 9.3 Invalid conf value rejected
+-- 10. PATH disabled
+-- 10.1 current_path() still resolves (regular builtin)
+-- 10.2 SET PATH itself is rejected
+-- ============================================================================
+
+--SET spark.sql.path.enabled=true
+
+
+-- ============================================================================
+-- 1. Default path observability (no SET PATH issued)
+-- ============================================================================
+
+-- The session was opened with PATH enabled and no `SET PATH` issued, so the
+-- effective path is the spark-builtin default ordering with current_schema in
+-- the catalog slot.
+SELECT current_path();
+
+
+-- ============================================================================
+-- 2. SET PATH grammar
+-- ============================================================================
+
+-- 2.1 Literal schema entries; case preservation; backtick quoting
-------------
+
+SET PATH = spark_catalog.default, system.builtin;
+SELECT current_path();
+
+-- Case is preserved exactly as typed.
+SET PATH = Spark_Catalog.Default, System.Builtin;
+SELECT current_path();
+
+-- Backtick-quoted identifiers that contain dots round-trip with quoting.
+SET PATH = spark_catalog.`sch.b`, system.builtin;
+SELECT current_path();
+
+-- Multi-level namespace (3+ parts) is accepted by the grammar. The stored
entry
+-- is verified at the Scala layer (SetPathSuite) because the session catalog
+-- only supports single-part namespaces, so calling current_path() while a
+-- multi-level entry is on the path would surface that catalog limitation
+-- rather than the PATH grammar property under test here.
+
+SET PATH = DEFAULT_PATH;
+
+
+-- 2.2 DEFAULT_PATH shortcut
---------------------------------------------------
+
+SET PATH = DEFAULT_PATH;
+SELECT current_path();
+
+
+-- 2.3 SYSTEM_PATH shortcut
----------------------------------------------------
+
+SET PATH = SYSTEM_PATH;
+SELECT current_path();
+
+
+-- 2.4 PATH keyword (append to live path)
--------------------------------------
+
+SET PATH = spark_catalog.default, system.builtin;
+SET PATH = PATH, system.session;
+SELECT current_path();
+
+
+-- 2.5 current_schema / current_database shortcuts
-----------------------------
+
+USE spark_catalog.default;
+SET PATH = current_schema, system.builtin;
+SELECT current_path();
+
+-- current_database is a SQL alias for current_schema.
+SET PATH = current_database, system.builtin;
+SELECT current_path();
+
+SET PATH = DEFAULT_PATH;
+
+
+-- ============================================================================
+-- 3. CURRENT_PATH() builtin
+-- ============================================================================
+
+-- 3.1 ANSI no-parens form equals current_path() ------------------------------
+
+SET PATH = spark_catalog.default, system.builtin;
+SELECT CURRENT_PATH = current_path() AS ansi_form_matches;
+
+
+-- 3.2 Argument-count validation ----------------------------------------------
+
+SELECT current_path(1);
+
+SET PATH = DEFAULT_PATH;
+
+
+-- ============================================================================
+-- 4. Static error conditions at SET PATH
+-- ============================================================================
+
+-- 4.1 Literal duplicate
-------------------------------------------------------
+
+SET PATH = spark_catalog.default, spark_catalog.default;
+
+-- Case-insensitive duplicate is still flagged.
+SET PATH = spark_catalog.DEFAULT, spark_catalog.default;
+
+
+-- 4.2 DEFAULT_PATH expansion duplicate
----------------------------------------
+
+-- DEFAULT_PATH already contains system.builtin; listing it again is a
duplicate
+-- after expansion.
+SET PATH = DEFAULT_PATH, system.builtin;
+
+
+-- 4.3 SYSTEM_PATH expansion duplicate
-----------------------------------------
+
+SET PATH = SYSTEM_PATH, SYSTEM_PATH;
+
+
+-- 4.4 current_database vs current_schema cross-alias duplicate
----------------
+
+SET PATH = current_database, current_schema;
+
+
+-- 4.5 Single-part schema reference rejected
-----------------------------------
+
+SET PATH = my_schema_no_catalog;
+
+
+-- ============================================================================
+-- 5. Routine resolution via PATH
+-- ============================================================================
+
+-- 5.1 Persistent scalar function follows PATH
---------------------------------
+
+CREATE SCHEMA sql_path_routines;
+CREATE FUNCTION sql_path_routines.pick() RETURNS INT RETURN 7;
+SET PATH = spark_catalog.sql_path_routines, spark_catalog.default,
system.builtin;
+SELECT pick();
+SET PATH = DEFAULT_PATH;
+
+
+-- 5.2 Persistent table function follows PATH
----------------------------------
+
+CREATE FUNCTION sql_path_routines.pick_tvf()
+RETURNS TABLE(val INT)
+RETURN SELECT 7 AS val;
+SET PATH = spark_catalog.sql_path_routines, spark_catalog.default,
system.builtin;
+SELECT * FROM pick_tvf();
+SET PATH = DEFAULT_PATH;
+
+
+-- 5.3 First-match ordering across two schemas on PATH ------------------------
+
+CREATE SCHEMA sql_path_routines_b;
+CREATE FUNCTION sql_path_routines_b.pick() RETURNS INT RETURN 11;
+
+SET PATH = spark_catalog.sql_path_routines, spark_catalog.sql_path_routines_b,
system.builtin;
+SELECT pick() AS from_first_schema;
+SET PATH = spark_catalog.sql_path_routines_b, spark_catalog.sql_path_routines,
system.builtin;
+SELECT pick() AS from_first_schema;
+SET PATH = DEFAULT_PATH;
+
+
+-- 5.4 Unqualified miss when schema is not on PATH
-----------------------------
+
+SET PATH = spark_catalog.default, system.builtin;
+SELECT pick();
+
+-- Cleanup section 5.
+SET PATH = DEFAULT_PATH;
+DROP FUNCTION sql_path_routines.pick;
+DROP FUNCTION sql_path_routines.pick_tvf;
+DROP FUNCTION sql_path_routines_b.pick;
+DROP SCHEMA sql_path_routines;
+DROP SCHEMA sql_path_routines_b;
+
+
+-- ============================================================================
+-- 6. Relation resolution via PATH
+-- ============================================================================
+
+CREATE SCHEMA sql_path_relations_a;
+CREATE SCHEMA sql_path_relations_b;
+CREATE TABLE sql_path_relations_a.tbl USING parquet AS SELECT 1 AS id;
+CREATE TABLE sql_path_relations_b.tbl USING parquet AS SELECT 2 AS id;
+
+-- 6.1 First-match ordering
----------------------------------------------------
+
+SET PATH = spark_catalog.sql_path_relations_a,
spark_catalog.sql_path_relations_b, system.builtin;
+SELECT id FROM tbl AS from_first_schema;
+SET PATH = spark_catalog.sql_path_relations_b,
spark_catalog.sql_path_relations_a, system.builtin;
+SELECT id FROM tbl AS from_first_schema;
+
+
+-- 6.2 Unqualified miss when schema is not on PATH
-----------------------------
+
+SET PATH = spark_catalog.default, system.builtin;
+SELECT id FROM tbl;
+
+-- Cleanup section 6.
+SET PATH = DEFAULT_PATH;
+DROP TABLE sql_path_relations_a.tbl;
+DROP TABLE sql_path_relations_b.tbl;
+DROP SCHEMA sql_path_relations_a;
+DROP SCHEMA sql_path_relations_b;
+
+
+-- ============================================================================
+-- 7. Persisted view frozen-path behavior
+-- ============================================================================
+
+CREATE SCHEMA sql_path_views_a;
+CREATE SCHEMA sql_path_views_b;
+CREATE TABLE sql_path_views_a.frozen_t USING parquet AS SELECT 1 AS id;
+CREATE TABLE sql_path_views_b.frozen_t USING parquet AS SELECT 2 AS id;
+
+-- 7.1 View body resolves via creation-time PATH (not invoker PATH)
------------
+
+SET PATH = spark_catalog.sql_path_views_a, system.builtin;
+CREATE VIEW default.v_path_frozen AS SELECT id FROM frozen_t;
+
+-- Flip the live PATH; the view body's unqualified `frozen_t` must still
+-- resolve through the schema captured at CREATE VIEW (sql_path_views_a, id=1).
+-- A bare query against `frozen_t` from the session follows the LIVE PATH and
+-- returns the other table's row (id=2).
+SET PATH = spark_catalog.sql_path_views_b, system.builtin;
+SELECT id FROM frozen_t AS bare_lookup_uses_live_path;
+SELECT id FROM default.v_path_frozen AS view_body_uses_frozen_path;
+
+
+-- 7.2 current_schema / current_path in view body use invoker context
----------
+
+USE spark_catalog.sql_path_views_a;
+CREATE VIEW sql_path_views_a.v_ctx AS
+SELECT current_schema() AS cs, current_path() AS cp;
+
+USE spark_catalog.sql_path_views_b;
+SET PATH = DEFAULT_PATH;
+-- The view body re-evaluates current_schema() / current_path() on every
+-- invocation against the INVOKER's context, not the creator's. The result
+-- here must reflect sql_path_views_b (the invoker), not sql_path_views_a
+-- (the creator's schema at CREATE VIEW).
+SELECT cs, cp FROM sql_path_views_a.v_ctx;
+
+-- Cleanup section 7.
+USE spark_catalog.default;
+SET PATH = DEFAULT_PATH;
+DROP VIEW default.v_path_frozen;
+DROP VIEW sql_path_views_a.v_ctx;
+DROP TABLE sql_path_views_a.frozen_t;
+DROP TABLE sql_path_views_b.frozen_t;
+DROP SCHEMA sql_path_views_a;
+DROP SCHEMA sql_path_views_b;
+
+
+-- ============================================================================
+-- 8. SQL function frozen-path behavior
+-- ============================================================================
+
+CREATE SCHEMA sql_path_fn_a;
+CREATE SCHEMA sql_path_fn_b;
+CREATE TABLE sql_path_fn_a.frozen_t USING parquet AS SELECT 10 AS id;
+CREATE TABLE sql_path_fn_b.frozen_t USING parquet AS SELECT 20 AS id;
+
+-- 8.1 Scalar function body resolves via creation-time PATH
--------------------
+
+SET PATH = spark_catalog.sql_path_fn_a, system.builtin;
+CREATE FUNCTION default.frozen_fn()
+RETURNS INT
+RETURN (SELECT MAX(id) FROM frozen_t);
+
+SET PATH = spark_catalog.sql_path_fn_b, system.builtin;
+SELECT MAX(id) FROM frozen_t AS bare_lookup_uses_live_path;
+SELECT default.frozen_fn() AS scalar_body_uses_frozen_path;
+
+
+-- 8.2 Table function body resolves via creation-time PATH
---------------------
+
+SET PATH = spark_catalog.sql_path_fn_a, system.builtin;
+CREATE FUNCTION default.frozen_tvf()
+RETURNS TABLE(id INT)
+RETURN SELECT MAX(id) AS id FROM frozen_t;
+
+SET PATH = spark_catalog.sql_path_fn_b, system.builtin;
+SELECT * FROM default.frozen_tvf() AS table_body_uses_frozen_path;
+
+
+-- 8.3 current_schema / current_path in function body use invoker context -----
+
+USE spark_catalog.sql_path_fn_a;
+CREATE FUNCTION sql_path_fn_a.f_ctx()
+RETURNS STRING
+RETURN concat(current_schema(), '::', current_path());
+
+USE spark_catalog.sql_path_fn_b;
+SET PATH = DEFAULT_PATH;
+-- Like 7.2: current_schema() / current_path() in a SQL function body bind to
+-- the INVOKER's context, not the creator's.
+SELECT sql_path_fn_a.f_ctx() AS invoker_context;
+
+-- Cleanup section 8.
+USE spark_catalog.default;
+SET PATH = DEFAULT_PATH;
+DROP FUNCTION default.frozen_fn;
+DROP FUNCTION default.frozen_tvf;
+DROP FUNCTION sql_path_fn_a.f_ctx;
+DROP TABLE sql_path_fn_a.frozen_t;
+DROP TABLE sql_path_fn_b.frozen_t;
+DROP SCHEMA sql_path_fn_a;
+DROP SCHEMA sql_path_fn_b;
+
+
+-- ============================================================================
+-- 9. DEFAULT_PATH conf (spark.sql.defaultPath)
+-- ============================================================================
+--
+-- The conf's RHS is captured as a raw string by the SQL `SET key = value`
+-- form; keywords like `current_schema` and shortcut tokens like `SYSTEM_PATH`
+-- must be written WITHOUT backticks so the conf's SET-PATH-grammar validator
+-- recognizes them as path tokens rather than 1-part quoted identifiers.
+
+-- 9.1 Explicit SET PATH overrides the conf
------------------------------------
+
+SET spark.sql.defaultPath = system.session, system.builtin;
+SET PATH = system.builtin, system.session;
+SELECT current_path() AS explicit_set_path_wins_over_conf;
+SET PATH = DEFAULT_PATH;
+RESET spark.sql.defaultPath;
+
+
+-- 9.2 SET PATH = DEFAULT_PATH expands to the conf value
-----------------------
+
+SET spark.sql.defaultPath = system.session, system.builtin, current_schema;
+USE spark_catalog.default;
+SET PATH = DEFAULT_PATH;
+SELECT current_path() AS default_path_expands_to_conf;
+RESET spark.sql.defaultPath;
+SET PATH = DEFAULT_PATH;
+
+
+-- 9.3 Invalid conf value rejected at SET time
---------------------------------
+
+SET spark.sql.defaultPath = this is not a path;
+
+-- The PATH keyword is not allowed in the conf value (it would create a cycle).
+SET spark.sql.defaultPath = PATH, system.builtin;
+
+
+-- ============================================================================
+-- 10. PATH disabled
+-- ============================================================================
+
+SET spark.sql.path.enabled = false;
+
+
+-- 10.1 current_path() still resolves (regular builtin)
------------------------
+
+SELECT current_path() IS NOT NULL AS has_path;
+
+
+-- 10.2 SET PATH itself is rejected
--------------------------------------------
+
+SET PATH = spark_catalog.default;
diff --git a/sql/core/src/test/resources/sql-tests/results/sql-path.sql.out
b/sql/core/src/test/resources/sql-tests/results/sql-path.sql.out
new file mode 100644
index 000000000000..52d01ccb80ba
--- /dev/null
+++ b/sql/core/src/test/resources/sql-tests/results/sql-path.sql.out
@@ -0,0 +1,1202 @@
+-- Automatically generated by SQLQueryTestSuite
+-- !query
+SELECT current_path()
+-- !query schema
+struct<current_path():string>
+-- !query output
+system.builtin,system.session,spark_catalog.default
+
+
+-- !query
+SET PATH = spark_catalog.default, system.builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT current_path()
+-- !query schema
+struct<current_path():string>
+-- !query output
+spark_catalog.default,system.builtin
+
+
+-- !query
+SET PATH = Spark_Catalog.Default, System.Builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT current_path()
+-- !query schema
+struct<current_path():string>
+-- !query output
+Spark_Catalog.Default,System.Builtin
+
+
+-- !query
+SET PATH = spark_catalog.`sch.b`, system.builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT current_path()
+-- !query schema
+struct<current_path():string>
+-- !query output
+spark_catalog.`sch.b`,system.builtin
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT current_path()
+-- !query schema
+struct<current_path():string>
+-- !query output
+system.builtin,system.session,spark_catalog.default
+
+
+-- !query
+SET PATH = SYSTEM_PATH
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT current_path()
+-- !query schema
+struct<current_path():string>
+-- !query output
+system.builtin,system.session
+
+
+-- !query
+SET PATH = spark_catalog.default, system.builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = PATH, system.session
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT current_path()
+-- !query schema
+struct<current_path():string>
+-- !query output
+spark_catalog.default,system.builtin,system.session
+
+
+-- !query
+USE spark_catalog.default
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = current_schema, system.builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT current_path()
+-- !query schema
+struct<current_path():string>
+-- !query output
+spark_catalog.default,system.builtin
+
+
+-- !query
+SET PATH = current_database, system.builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT current_path()
+-- !query schema
+struct<current_path():string>
+-- !query output
+spark_catalog.default,system.builtin
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = spark_catalog.default, system.builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT CURRENT_PATH = current_path() AS ansi_form_matches
+-- !query schema
+struct<ansi_form_matches:boolean>
+-- !query output
+true
+
+
+-- !query
+SELECT current_path(1)
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.sql.AnalysisException
+{
+ "errorClass" : "WRONG_NUM_ARGS.WITHOUT_SUGGESTION",
+ "sqlState" : "42605",
+ "messageParameters" : {
+ "actualNum" : "1",
+ "docroot" : "https://spark.apache.org/docs/latest",
+ "expectedNum" : "0",
+ "functionName" : "`current_path`"
+ },
+ "queryContext" : [ {
+ "objectType" : "",
+ "objectName" : "",
+ "startIndex" : 8,
+ "stopIndex" : 22,
+ "fragment" : "current_path(1)"
+ } ]
+}
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = spark_catalog.default, spark_catalog.default
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.sql.AnalysisException
+{
+ "errorClass" : "DUPLICATE_SQL_PATH_ENTRY",
+ "sqlState" : "42732",
+ "messageParameters" : {
+ "pathEntry" : "spark_catalog.default"
+ }
+}
+
+
+-- !query
+SET PATH = spark_catalog.DEFAULT, spark_catalog.default
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.sql.AnalysisException
+{
+ "errorClass" : "DUPLICATE_SQL_PATH_ENTRY",
+ "sqlState" : "42732",
+ "messageParameters" : {
+ "pathEntry" : "spark_catalog.default"
+ }
+}
+
+
+-- !query
+SET PATH = DEFAULT_PATH, system.builtin
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.sql.AnalysisException
+{
+ "errorClass" : "DUPLICATE_SQL_PATH_ENTRY",
+ "sqlState" : "42732",
+ "messageParameters" : {
+ "pathEntry" : "system.builtin"
+ }
+}
+
+
+-- !query
+SET PATH = SYSTEM_PATH, SYSTEM_PATH
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.sql.AnalysisException
+{
+ "errorClass" : "DUPLICATE_SQL_PATH_ENTRY",
+ "sqlState" : "42732",
+ "messageParameters" : {
+ "pathEntry" : "system.builtin"
+ }
+}
+
+
+-- !query
+SET PATH = current_database, current_schema
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.sql.AnalysisException
+{
+ "errorClass" : "DUPLICATE_SQL_PATH_ENTRY",
+ "sqlState" : "42732",
+ "messageParameters" : {
+ "pathEntry" : "current_schema"
+ }
+}
+
+
+-- !query
+SET PATH = my_schema_no_catalog
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.sql.catalyst.parser.ParseException
+{
+ "errorClass" : "INVALID_SQL_PATH_SCHEMA_REFERENCE",
+ "sqlState" : "42601",
+ "messageParameters" : {
+ "qualifiedName" : "my_schema_no_catalog"
+ }
+}
+
+
+-- !query
+CREATE SCHEMA sql_path_routines
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE FUNCTION sql_path_routines.pick() RETURNS INT RETURN 7
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_routines, spark_catalog.default,
system.builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT pick()
+-- !query schema
+struct<spark_catalog.sql_path_routines.pick():int>
+-- !query output
+7
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE FUNCTION sql_path_routines.pick_tvf()
+RETURNS TABLE(val INT)
+RETURN SELECT 7 AS val
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_routines, spark_catalog.default,
system.builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT * FROM pick_tvf()
+-- !query schema
+struct<val:int>
+-- !query output
+7
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE SCHEMA sql_path_routines_b
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE FUNCTION sql_path_routines_b.pick() RETURNS INT RETURN 11
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_routines, spark_catalog.sql_path_routines_b,
system.builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT pick() AS from_first_schema
+-- !query schema
+struct<from_first_schema:int>
+-- !query output
+7
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_routines_b, spark_catalog.sql_path_routines,
system.builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT pick() AS from_first_schema
+-- !query schema
+struct<from_first_schema:int>
+-- !query output
+11
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = spark_catalog.default, system.builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT pick()
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.sql.AnalysisException
+{
+ "errorClass" : "UNRESOLVED_ROUTINE",
+ "sqlState" : "42883",
+ "messageParameters" : {
+ "routineName" : "`pick`",
+ "searchPath" : "[`spark_catalog`.`default`, `system`.`builtin`]"
+ },
+ "queryContext" : [ {
+ "objectType" : "",
+ "objectName" : "",
+ "startIndex" : 8,
+ "stopIndex" : 13,
+ "fragment" : "pick()"
+ } ]
+}
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP FUNCTION sql_path_routines.pick
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP FUNCTION sql_path_routines.pick_tvf
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP FUNCTION sql_path_routines_b.pick
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP SCHEMA sql_path_routines
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP SCHEMA sql_path_routines_b
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE SCHEMA sql_path_relations_a
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE SCHEMA sql_path_relations_b
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE TABLE sql_path_relations_a.tbl USING parquet AS SELECT 1 AS id
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE TABLE sql_path_relations_b.tbl USING parquet AS SELECT 2 AS id
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_relations_a,
spark_catalog.sql_path_relations_b, system.builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT id FROM tbl AS from_first_schema
+-- !query schema
+struct<id:int>
+-- !query output
+1
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_relations_b,
spark_catalog.sql_path_relations_a, system.builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT id FROM tbl AS from_first_schema
+-- !query schema
+struct<id:int>
+-- !query output
+2
+
+
+-- !query
+SET PATH = spark_catalog.default, system.builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT id FROM tbl
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.sql.catalyst.ExtendedAnalysisException
+{
+ "errorClass" : "TABLE_OR_VIEW_NOT_FOUND",
+ "sqlState" : "42P01",
+ "messageParameters" : {
+ "relationName" : "`tbl`",
+ "searchPath" : "[`spark_catalog`.`default`, `system`.`builtin`]"
+ },
+ "queryContext" : [ {
+ "objectType" : "",
+ "objectName" : "",
+ "startIndex" : 16,
+ "stopIndex" : 18,
+ "fragment" : "tbl"
+ } ]
+}
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP TABLE sql_path_relations_a.tbl
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP TABLE sql_path_relations_b.tbl
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP SCHEMA sql_path_relations_a
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP SCHEMA sql_path_relations_b
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE SCHEMA sql_path_views_a
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE SCHEMA sql_path_views_b
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE TABLE sql_path_views_a.frozen_t USING parquet AS SELECT 1 AS id
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE TABLE sql_path_views_b.frozen_t USING parquet AS SELECT 2 AS id
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_views_a, system.builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE VIEW default.v_path_frozen AS SELECT id FROM frozen_t
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_views_b, system.builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT id FROM frozen_t AS bare_lookup_uses_live_path
+-- !query schema
+struct<id:int>
+-- !query output
+2
+
+
+-- !query
+SELECT id FROM default.v_path_frozen AS view_body_uses_frozen_path
+-- !query schema
+struct<id:int>
+-- !query output
+1
+
+
+-- !query
+USE spark_catalog.sql_path_views_a
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE VIEW sql_path_views_a.v_ctx AS
+SELECT current_schema() AS cs, current_path() AS cp
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+USE spark_catalog.sql_path_views_b
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT cs, cp FROM sql_path_views_a.v_ctx
+-- !query schema
+struct<cs:string,cp:string>
+-- !query output
+sql_path_views_b
system.builtin,system.session,spark_catalog.sql_path_views_b
+
+
+-- !query
+USE spark_catalog.default
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP VIEW default.v_path_frozen
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP VIEW sql_path_views_a.v_ctx
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP TABLE sql_path_views_a.frozen_t
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP TABLE sql_path_views_b.frozen_t
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP SCHEMA sql_path_views_a
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP SCHEMA sql_path_views_b
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE SCHEMA sql_path_fn_a
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE SCHEMA sql_path_fn_b
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE TABLE sql_path_fn_a.frozen_t USING parquet AS SELECT 10 AS id
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE TABLE sql_path_fn_b.frozen_t USING parquet AS SELECT 20 AS id
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_fn_a, system.builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE FUNCTION default.frozen_fn()
+RETURNS INT
+RETURN (SELECT MAX(id) FROM frozen_t)
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_fn_b, system.builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT MAX(id) FROM frozen_t AS bare_lookup_uses_live_path
+-- !query schema
+struct<max(id):int>
+-- !query output
+20
+
+
+-- !query
+SELECT default.frozen_fn() AS scalar_body_uses_frozen_path
+-- !query schema
+struct<scalar_body_uses_frozen_path:int>
+-- !query output
+10
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_fn_a, system.builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE FUNCTION default.frozen_tvf()
+RETURNS TABLE(id INT)
+RETURN SELECT MAX(id) AS id FROM frozen_t
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = spark_catalog.sql_path_fn_b, system.builtin
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT * FROM default.frozen_tvf() AS table_body_uses_frozen_path
+-- !query schema
+struct<id:int>
+-- !query output
+10
+
+
+-- !query
+USE spark_catalog.sql_path_fn_a
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+CREATE FUNCTION sql_path_fn_a.f_ctx()
+RETURNS STRING
+RETURN concat(current_schema(), '::', current_path())
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+USE spark_catalog.sql_path_fn_b
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT sql_path_fn_a.f_ctx() AS invoker_context
+-- !query schema
+struct<invoker_context:string>
+-- !query output
+sql_path_fn_b::system.builtin,system.session,spark_catalog.sql_path_fn_b
+
+
+-- !query
+USE spark_catalog.default
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP FUNCTION default.frozen_fn
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP FUNCTION default.frozen_tvf
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP FUNCTION sql_path_fn_a.f_ctx
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP TABLE sql_path_fn_a.frozen_t
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP TABLE sql_path_fn_b.frozen_t
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP SCHEMA sql_path_fn_a
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+DROP SCHEMA sql_path_fn_b
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET spark.sql.defaultPath = system.session, system.builtin
+-- !query schema
+struct<key:string,value:string>
+-- !query output
+spark.sql.defaultPath system.session, system.builtin
+
+
+-- !query
+SET PATH = system.builtin, system.session
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT current_path() AS explicit_set_path_wins_over_conf
+-- !query schema
+struct<explicit_set_path_wins_over_conf:string>
+-- !query output
+system.builtin,system.session
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+RESET spark.sql.defaultPath
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET spark.sql.defaultPath = system.session, system.builtin, current_schema
+-- !query schema
+struct<key:string,value:string>
+-- !query output
+spark.sql.defaultPath system.session, system.builtin, current_schema
+
+
+-- !query
+USE spark_catalog.default
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SELECT current_path() AS default_path_expands_to_conf
+-- !query schema
+struct<default_path_expands_to_conf:string>
+-- !query output
+system.session,system.builtin,spark_catalog.default
+
+
+-- !query
+RESET spark.sql.defaultPath
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET PATH = DEFAULT_PATH
+-- !query schema
+struct<>
+-- !query output
+
+
+
+-- !query
+SET spark.sql.defaultPath = this is not a path
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.SparkIllegalArgumentException
+{
+ "errorClass" : "INVALID_CONF_VALUE.REQUIREMENT",
+ "sqlState" : "22022",
+ "messageParameters" : {
+ "confName" : "spark.sql.defaultPath",
+ "confRequirement" : "The value must be empty or a comma-separated SET PATH
element list (same grammar as SET PATH, except PATH is not allowed).",
+ "confValue" : "this is not a path"
+ }
+}
+
+
+-- !query
+SET spark.sql.defaultPath = PATH, system.builtin
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.SparkIllegalArgumentException
+{
+ "errorClass" : "INVALID_CONF_VALUE.REQUIREMENT",
+ "sqlState" : "22022",
+ "messageParameters" : {
+ "confName" : "spark.sql.defaultPath",
+ "confRequirement" : "The value must be empty or a comma-separated SET PATH
element list (same grammar as SET PATH, except PATH is not allowed).",
+ "confValue" : "PATH, system.builtin"
+ }
+}
+
+
+-- !query
+SET spark.sql.path.enabled = false
+-- !query schema
+struct<key:string,value:string>
+-- !query output
+spark.sql.path.enabled false
+
+
+-- !query
+SELECT current_path() IS NOT NULL AS has_path
+-- !query schema
+struct<has_path:boolean>
+-- !query output
+true
+
+
+-- !query
+SET PATH = spark_catalog.default
+-- !query schema
+struct<>
+-- !query output
+org.apache.spark.sql.AnalysisException
+{
+ "errorClass" : "UNSUPPORTED_FEATURE.SET_PATH_WHEN_DISABLED",
+ "sqlState" : "0A000",
+ "messageParameters" : {
+ "config" : "spark.sql.path.enabled"
+ }
+}
diff --git a/sql/core/src/test/scala/org/apache/spark/sql/SetPathSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/SetPathSuite.scala
index 18b9f6b6f3b7..245398a4694e 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/SetPathSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/SetPathSuite.scala
@@ -20,6 +20,7 @@ package org.apache.spark.sql
import org.apache.spark.SparkIllegalArgumentException
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{IntegerType, LongType}
/**
* Tests for SET PATH command and session path management.
@@ -503,11 +504,110 @@ class SetPathSuite extends SharedSparkSession {
}
}
- // TODO: cloneSession() constructs a new CatalogManager per forked session
and
- // explicitly copies only the stored session path via copySessionPathFrom.
- // Other CatalogManager state propagation (current catalog/namespace,
registered
- // catalogs) on clone is currently incidental -- audit and pin down the
intended
- // semantics in a follow-up.
+ // --- cloneSession() propagation matrix
--------------------------------------
+ // The cloned session is built via `BaseSessionStateBuilder` from a parent
+ // `SessionState`. Per-component hand-offs on clone:
+ // - `SessionCatalog.copyStateTo` copies `currentDb` and `tempViews`,
+ // - `CatalogManager.copySessionPathFrom` copies the stored `_sessionPath`,
+ // - `functionRegistry.clone()` and `tableFunctionRegistry.clone()` copy
+ // temporary functions.
+ // What is NOT propagated:
+ // - the temp variable registry (new `TempVariableManager` per session),
+ // - the `CatalogManager` current-catalog / current-namespace (re-read from
+ // conf defaults in the child),
+ // - the registered v2 `catalogs` map (lazy-loaded per session).
+ // The tests below pin this observed behavior so any future change has to
+ // update the assertions.
+
+ test("cloneSession: stored SET PATH propagates to the child session") {
+ withPathEnabled {
+ sql("SET PATH = spark_catalog.default, system.builtin")
+ try {
+ val child = spark.cloneSession()
+ val entries = pathEntries(
+ child.sql("SELECT current_path()").collect().head.getString(0))
+ assert(entries === Seq("spark_catalog.default", "system.builtin"),
+ s"Cloned session should inherit stored SET PATH; got: $entries")
+ } finally {
+ sql("SET PATH = DEFAULT_PATH")
+ }
+ }
+ }
+
+ test("cloneSession: USE SCHEMA on the parent propagates to the child") {
+ sql("CREATE SCHEMA IF NOT EXISTS path_clone_use")
+ try {
+ sql("USE spark_catalog.path_clone_use")
+ val child = spark.cloneSession()
+ val childDb = child.sql("SELECT current_database()").head().getString(0)
+ assert(childDb == "path_clone_use",
+ s"Cloned session should inherit the parent's current schema; got:
$childDb")
+ } finally {
+ sql("USE spark_catalog.default")
+ sql("DROP SCHEMA IF EXISTS path_clone_use")
+ }
+ }
+
+ test("cloneSession: temp views on the parent propagate to the child") {
+ sql("CREATE TEMPORARY VIEW path_clone_view AS SELECT 1 AS c")
+ try {
+ val child = spark.cloneSession()
+ checkAnswer(child.sql("SELECT c FROM path_clone_view"), Row(1))
+ } finally {
+ sql("DROP VIEW IF EXISTS path_clone_view")
+ }
+ }
+
+ test("cloneSession: temp functions on the parent propagate to the child
(cloned " +
+ "functionRegistry)") {
+ sql("CREATE TEMPORARY FUNCTION path_clone_fn() RETURNS INT RETURN 42")
+ try {
+ val child = spark.cloneSession()
+ checkAnswer(child.sql("SELECT path_clone_fn()"), Row(42))
+ // Snapshot semantics: dropping in the parent must not affect the
already-cloned child.
+ sql("DROP TEMPORARY FUNCTION path_clone_fn")
+ checkAnswer(child.sql("SELECT path_clone_fn()"), Row(42))
+ } finally {
+ sql("DROP TEMPORARY FUNCTION IF EXISTS path_clone_fn")
+ }
+ }
+
+ test("cloneSession: temp variables on the parent are NOT propagated to the
child") {
+ sql("DECLARE OR REPLACE VARIABLE path_clone_var INT DEFAULT 7")
+ try {
+ val child = spark.cloneSession()
+ val e = intercept[AnalysisException] {
+ child.sql("SELECT path_clone_var").collect()
+ }
+ // Either UNRESOLVED_VARIABLE or UNRESOLVED_COLUMN; both confirm the
variable
+ // did not survive the clone.
+ assert(
+ e.getCondition == "UNRESOLVED_VARIABLE" ||
+ e.getCondition.startsWith("UNRESOLVED_COLUMN"),
+ s"Temp variables should NOT propagate to the clone; got:
${e.getCondition}")
+ } finally {
+ sql("DROP TEMPORARY VARIABLE IF EXISTS path_clone_var")
+ }
+ }
+
+ test("cloneSession: child SET PATH does not leak back to the parent") {
+ withPathEnabled {
+ sql("SET PATH = spark_catalog.default, system.builtin")
+ try {
+ val child = spark.cloneSession()
+ child.sql("SET PATH = system.session, system.builtin")
+ val parentEntries = pathEntries(currentPath())
+ assert(parentEntries === Seq("spark_catalog.default",
"system.builtin"),
+ s"Child SET PATH must not affect the parent; parent got:
$parentEntries")
+ val childEntries = pathEntries(
+ child.sql("SELECT current_path()").collect().head.getString(0))
+ assert(childEntries === Seq("system.session", "system.builtin"),
+ s"Child SET PATH should be visible only in the child; child got:
$childEntries")
+ } finally {
+ sql("SET PATH = DEFAULT_PATH")
+ }
+ }
+ }
// --- Resolution tests: verify SET PATH affects actual table/function
lookup ---
@@ -786,6 +886,169 @@ class SetPathSuite extends SharedSparkSession {
}
}
+ test("path-driven COUNT(*) rewrite gate: temp count shadowing builtin under
SET PATH " +
+ "(session-first) suppresses the * -> 1 rewrite") {
+ // `Analyzer.matchesFunctionName` consults
+ // `FunctionResolution.isSessionBeforeBuiltinInPath` to decide whether
COUNT(*) is the
+ // builtin (eligible for the COUNT(*) -> COUNT(1) shortcut) or a
user-defined override.
+ // Default `sessionFunctionResolutionOrder` is "second", so creating a
temp count while
+ // the default PATH is in effect passes the security check. Once SET PATH
puts
+ // `system.session` before `system.builtin`, the rewrite must be
suppressed and the
+ // star expansion must reach the temp `count`.
+ withPathEnabled {
+ sql("CREATE TEMPORARY FUNCTION count(x INT) RETURNS INT RETURN x + 100")
+ try {
+ // PATH still has builtin first: count(*) rewrites to count(1), which
resolves to
+ // the builtin count and returns the row count of the input (1).
+ checkAnswer(sql("SELECT count(*) FROM VALUES (1) AS t(a)"), Row(1))
+
+ // Put session before builtin via SET PATH. The rewrite gate now
reports
+ // `isSessionBeforeBuiltinInPath = true` AND a temp count exists, so
the
+ // analyzer must NOT collapse `count(*)` to `count(1)`. The `*` then
expands
+ // against the table's single column to `count(a)`, which resolves
through
+ // the temp under the live path: 1 + 100 = 101.
+ sql("SET PATH = system.session, system.builtin")
+ checkAnswer(sql("SELECT count(*) FROM VALUES (1) AS t(a)"), Row(101))
+ } finally {
+ sql("SET PATH = DEFAULT_PATH")
+ sql("DROP TEMPORARY FUNCTION IF EXISTS count")
+ }
+ }
+ }
+
+ test("path-driven COUNT(*) rewrite gate: rewrite still applies for unrelated
builtins") {
+ // The gate fires ONLY when a temp function with the same unqualified
+ // name as the builtin exists. A temp with a different name must not
affect the
+ // COUNT(*) -> COUNT(1) shortcut even when session is searched before
builtin.
+ withPathEnabled {
+ sql("CREATE TEMPORARY FUNCTION my_helper(x INT) RETURNS INT RETURN x +
1")
+ try {
+ sql("SET PATH = system.session, system.builtin")
+ // No temp `count` exists; the rewrite still fires and the builtin row
counter
+ // returns the row count of the input (3).
+ checkAnswer(sql("SELECT count(*) FROM VALUES (1), (2), (3) AS t(a)"),
Row(3))
+ } finally {
+ sql("SET PATH = DEFAULT_PATH")
+ sql("DROP TEMPORARY FUNCTION IF EXISTS my_helper")
+ }
+ }
+ }
+
+ test("path-driven COUNT(*) rewrite gate: single-pass resolver suppresses the
rewrite " +
+ "under SET PATH (session-first)") {
+ // The single-pass resolver mirrors the fixed-point gate via
+ // `FunctionResolverUtils.isUnqualifiedCountShadowedByTemp`, which is
wired into
+ // `isNonDistinctCount` and consulted by `handleStarInArguments`.
+ //
+ // Setup (`CREATE TEMPORARY FUNCTION`, `SET PATH`) and execution (Dataset
collect via
+ // checkAnswer, which inserts a `DeserializeToObject` node the single-pass
analyzer
+ // does not yet support) are run under the fixed-point analyzer; only the
actual
+ // count(*) analysis is run under the single-pass analyzer, and we assert
against the
+ // analyzed plan's output schema. The builtin count returns BIGINT
(rewrite applied);
+ // the temp count(INT) returns INT (rewrite suppressed and the star
expansion routes
+ // through the temp), so the schema's first-field dataType tells us which
branch fired.
+ withPathEnabled {
+ sql("CREATE TEMPORARY FUNCTION count(x INT) RETURNS INT RETURN x + 100")
+ try {
+ val countStarSql = "SELECT count(*) FROM VALUES (1) AS t(a)"
+
+ // PATH builtin-first: the single-pass gate reports
+ // `isUnqualifiedCountShadowedByTemp = false`, the shortcut fires, and
the analyzed
+ // output is the BIGINT builtin count.
+ withSQLConf(SQLConf.ANALYZER_SINGLE_PASS_RESOLVER_ENABLED.key ->
"true") {
+ val tpe =
spark.sql(countStarSql).queryExecution.analyzed.schema.head.dataType
+ assert(tpe == LongType,
+ s"Expected BIGINT (builtin count rewrite); got: $tpe")
+ }
+
+ sql("SET PATH = system.session, system.builtin")
+
+ // PATH session-first: the gate reports true, the rewrite is
suppressed, the star
+ // expands against `a`, and the temp count(INT) wins; analyzed output
is INT.
+ withSQLConf(SQLConf.ANALYZER_SINGLE_PASS_RESOLVER_ENABLED.key ->
"true") {
+ val tpe =
spark.sql(countStarSql).queryExecution.analyzed.schema.head.dataType
+ assert(tpe == IntegerType,
+ s"Expected INT (temp count; rewrite suppressed); got: $tpe")
+ }
+ } finally {
+ sql("SET PATH = DEFAULT_PATH")
+ sql("DROP TEMPORARY FUNCTION IF EXISTS count")
+ }
+ }
+ }
+
+ test("PATH enabled: concurrent SET PATH and unqualified lookups do not
deadlock") {
+ // SessionCatalog.lookupBuiltinOrTempFunction is intentionally NOT
+ // synchronized on SessionCatalog because the path-driven kinds provider
acquires
+ // CatalogManager.synchronized, and another thread holding that lock can
call back
+ // into SessionCatalog (e.g. via setCurrentNamespace). This test hammers
both sides
+ // concurrently: one thread flips SET PATH while another performs
unqualified
+ // function lookups that go through the kinds provider. Within the budget
we should
+ // observe no deadlock and no spurious analysis failures.
+ withPathEnabled {
+ val budget = 200
+ val iterations = new java.util.concurrent.atomic.AtomicInteger(0)
+ val barrier = new java.util.concurrent.CyclicBarrier(2)
+ val errors = new java.util.concurrent.ConcurrentLinkedQueue[Throwable]()
+
+ val setterThread = new Thread(() => {
+ try {
+ barrier.await()
+ var i = 0
+ while (i < budget && errors.isEmpty) {
+ if ((i % 2) == 0) {
+ sql("SET PATH = spark_catalog.default, system.builtin")
+ } else {
+ sql("SET PATH = system.builtin, system.session,
spark_catalog.default")
+ }
+ i += 1
+ }
+ } catch {
+ case t: Throwable => errors.add(t)
+ }
+ }, "SetPathSuite-setter")
+
+ val lookupThread = new Thread(() => {
+ try {
+ barrier.await()
+ var i = 0
+ while (i < budget && errors.isEmpty) {
+ // Forces unqualified function resolution against the live PATH
and triggers
+ // the session-kinds provider on the catalog-manager side.
+ val n = sql("SELECT count(*) FROM VALUES (1), (2), (3) AS t(a)")
+ .head().getLong(0)
+ assert(n == 3L, s"unexpected count: $n at iteration $i")
+ iterations.incrementAndGet()
+ i += 1
+ }
+ } catch {
+ case t: Throwable => errors.add(t)
+ }
+ }, "SetPathSuite-lookup")
+
+ setterThread.start()
+ lookupThread.start()
+
+ // Generous join: 30s is plenty for 200 cheap queries on either side and
gives a
+ // clear failure signal if the implementation regresses into a deadlock.
+ val joinMillis = 30000L
+ setterThread.join(joinMillis)
+ lookupThread.join(joinMillis)
+
+ assert(!setterThread.isAlive,
+ "SET PATH thread did not finish; potential deadlock between
SessionCatalog and " +
+ "CatalogManager synchronized blocks.")
+ assert(!lookupThread.isAlive,
+ "Lookup thread did not finish; potential deadlock between
SessionCatalog and " +
+ "CatalogManager synchronized blocks.")
+ assert(errors.isEmpty,
+ s"Concurrent lookups raised unexpected errors:
${errors.toArray.mkString("; ")}")
+ assert(iterations.get() > 0,
+ "Lookup thread never completed a query; suspect contention or
deadlock.")
+ sql("SET PATH = DEFAULT_PATH")
+ }
+ }
+
test("DEFAULT_PATH conf: duplicate entries are tolerated (first-match
resolution)") {
// Lookup uses first-match resolution, so redundant entries on
DEFAULT_PATH are dead code
// rather than an error. (Contrast with SET PATH, which still rejects
static duplicates as
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/connector/SqlPathV2CatalogSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/connector/SqlPathV2CatalogSuite.scala
new file mode 100644
index 000000000000..9e365c720266
--- /dev/null
+++
b/sql/core/src/test/scala/org/apache/spark/sql/connector/SqlPathV2CatalogSuite.scala
@@ -0,0 +1,157 @@
+/*
+ * 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.connector
+
+import java.util.Collections
+
+import org.apache.spark.sql.{AnalysisException, Row}
+import org.apache.spark.sql.catalyst.InternalRow
+import org.apache.spark.sql.connector.catalog.{Identifier, InMemoryCatalog,
SupportsNamespaces}
+import org.apache.spark.sql.connector.catalog.functions.{ScalarFunction,
UnboundFunction}
+import org.apache.spark.sql.internal.SQLConf
+import org.apache.spark.sql.test.SharedSparkSession
+import org.apache.spark.sql.types.{DataType, IntegerType, StringType}
+
+/**
+ * End-to-end coverage of [[SQLConf.PATH_ENABLED]] resolution through
non-session V2 catalogs.
+ *
+ * Other path tests live in `SetPathSuite` (session catalog) and
`ProcedureSuite`
+ * (procedures via CALL). This suite specifically exercises:
+ * - unqualified table resolution across two V2 catalogs in SET PATH,
+ * - first-match ordering when both catalogs hold the same name,
+ * - unqualified V2 function resolution across two V2 catalogs in SET PATH,
+ * - the negative case where the unqualified name only lives in a catalog
+ * that is NOT on the path.
+ */
+class SqlPathV2CatalogSuite extends SharedSparkSession {
+
+ private val emptyProps: java.util.Map[String, String] =
Collections.emptyMap()
+
+ override def beforeAll(): Unit = {
+ super.beforeAll()
+ spark.conf.set("spark.sql.catalog.pathcat",
classOf[InMemoryCatalog].getName)
+ spark.conf.set("spark.sql.catalog.pathcat2",
classOf[InMemoryCatalog].getName)
+ }
+
+ override def afterAll(): Unit = {
+ try {
+ spark.sessionState.catalogManager.reset()
+ spark.sessionState.conf.unsetConf("spark.sql.catalog.pathcat")
+ spark.sessionState.conf.unsetConf("spark.sql.catalog.pathcat2")
+ } finally {
+ super.afterAll()
+ }
+ }
+
+ private def v2Catalog(name: String): InMemoryCatalog =
+
spark.sessionState.catalogManager.catalog(name).asInstanceOf[InMemoryCatalog]
+
+ private def createV2Namespace(catalog: String, ns: String): Unit = {
+ v2Catalog(catalog).asInstanceOf[SupportsNamespaces]
+ .createNamespace(Array(ns), emptyProps)
+ }
+
+ private def addV2Function(
+ catalog: String,
+ ns: String,
+ name: String,
+ fn: UnboundFunction): Unit = {
+ v2Catalog(catalog).createFunction(Identifier.of(Array(ns), name), fn)
+ }
+
+ test("V2 catalogs on SET PATH: unqualified table follows first match") {
+ withSQLConf(SQLConf.PATH_ENABLED.key -> "true") {
+ // pathcat and pathcat2 each have a namespace `ns` and a table
`path_v2_t` with
+ // different contents, so we can tell which catalog supplied the row.
+ createV2Namespace("pathcat", "ns")
+ createV2Namespace("pathcat2", "ns")
+ sql("CREATE TABLE pathcat.ns.path_v2_t (id INT) USING foo")
+ sql("INSERT INTO pathcat.ns.path_v2_t VALUES (10)")
+ sql("CREATE TABLE pathcat2.ns.path_v2_t (id INT) USING foo")
+ sql("INSERT INTO pathcat2.ns.path_v2_t VALUES (20)")
+
+ try {
+ sql("SET PATH = pathcat.ns, pathcat2.ns, system.builtin")
+ checkAnswer(sql("SELECT id FROM path_v2_t"), Row(10))
+
+ sql("SET PATH = pathcat2.ns, pathcat.ns, system.builtin")
+ checkAnswer(sql("SELECT id FROM path_v2_t"), Row(20))
+ } finally {
+ sql("SET PATH = DEFAULT_PATH")
+ sql("DROP TABLE IF EXISTS pathcat.ns.path_v2_t")
+ sql("DROP TABLE IF EXISTS pathcat2.ns.path_v2_t")
+ }
+ }
+ }
+
+ test("V2 catalogs on SET PATH: unqualified table only in a non-path catalog
is not found") {
+ withSQLConf(SQLConf.PATH_ENABLED.key -> "true") {
+ createV2Namespace("pathcat", "ns_only_here")
+ sql("CREATE TABLE pathcat.ns_only_here.hidden_t (id INT) USING foo")
+ try {
+ // Path does not include pathcat.ns_only_here; bare `hidden_t` must
not resolve.
+ sql("SET PATH = pathcat2.ns, system.builtin")
+ val e = intercept[AnalysisException] {
+ sql("SELECT id FROM hidden_t").collect()
+ }
+ assert(e.getCondition == "TABLE_OR_VIEW_NOT_FOUND" ||
+ e.getMessage.contains("TABLE_OR_VIEW_NOT_FOUND"),
+ s"Expected TABLE_OR_VIEW_NOT_FOUND; got: ${e.getCondition}:
${e.getMessage}")
+ } finally {
+ sql("SET PATH = DEFAULT_PATH")
+ sql("DROP TABLE IF EXISTS pathcat.ns_only_here.hidden_t")
+ }
+ }
+ }
+
+ test("V2 catalogs on SET PATH: unqualified function follows first match") {
+ withSQLConf(SQLConf.PATH_ENABLED.key -> "true") {
+ // Two V2 catalogs each register a `strlen` function under the same name
but with
+ // distinguishable return values: pathcat returns the true length,
pathcat2 returns
+ // the length times 100. The result distinguishes which catalog supplied
the
+ // function for the same argument, so swapping the path order must
change the row.
+ createV2Namespace("pathcat", "fns")
+ createV2Namespace("pathcat2", "fns")
+ addV2Function("pathcat", "fns", "strlen", StrLen(StrLenDefault))
+ addV2Function("pathcat2", "fns", "strlen", StrLen(StrLenTimes100))
+ try {
+ sql("SET PATH = pathcat.fns, pathcat2.fns, system.builtin")
+ checkAnswer(sql("SELECT strlen('abc')"), Row(3))
+
+ sql("SET PATH = pathcat2.fns, pathcat.fns, system.builtin")
+ checkAnswer(sql("SELECT strlen('abc')"), Row(300))
+ } finally {
+ sql("SET PATH = DEFAULT_PATH")
+ v2Catalog("pathcat").clearFunctions()
+ v2Catalog("pathcat2").clearFunctions()
+ }
+ }
+ }
+}
+
+/**
+ * A small distinguishable companion to `StrLenDefault` (in
`DataSourceV2FunctionSuite.scala`):
+ * returns `s.length * 100` so V2-function resolution tests across catalogs
can verify which
+ * catalog supplied the function from the result row alone.
+ */
+case object StrLenTimes100 extends ScalarFunction[Int] {
+ override def inputTypes(): Array[DataType] = Array(StringType)
+ override def resultType(): DataType = IntegerType
+ override def name(): String = "strlen_times_100"
+ override def produceResult(input: InternalRow): Int =
input.getString(0).length * 100
+}
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/SQLViewSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/SQLViewSuite.scala
index f6ace55849d2..3fb54d7c43d5 100644
--- a/sql/core/src/test/scala/org/apache/spark/sql/execution/SQLViewSuite.scala
+++ b/sql/core/src/test/scala/org/apache/spark/sql/execution/SQLViewSuite.scala
@@ -1453,6 +1453,106 @@ abstract class SQLViewSuite extends QueryTest {
}
}
+ test("SPARK-56853: stored view path is ignored when PATH is disabled at read
time") {
+ // A view created with PATH enabled persists two things in metadata: the
frozen
+ // resolution path AND the creator session's current catalog+namespace at
CREATE
+ // VIEW time (the view's `viewCatalogAndNamespace` property). If the
reader's
+ // session has `spark.sql.path.enabled=false`, the pinned entries are
intentionally
+ // dropped (`CatalogManager.resolutionPathEntriesForAnalysis`); the view
body's
+ // unqualified references fall back to that captured catalog+namespace,
which is
+ // the creator's USE state at CREATE time -- NOT the schema the view
physically
+ // lives in (the two coincide below only because the test runs
+ // `USE spark_catalog.compat_view_b` before CREATE VIEW). Verify both
directions:
+ // - fully-qualified bodies keep working (qualification doesn't depend
on PATH),
+ // - unqualified bodies that relied on the frozen path now resolve via
the
+ // captured viewCatalogAndNamespace.
+ withDatabase("compat_view_a", "compat_view_b") {
+ sql("CREATE DATABASE compat_view_a")
+ sql("CREATE DATABASE compat_view_b")
+ withTable(
+ "compat_view_a.compat_t",
+ "compat_view_b.compat_t") {
+ sql("CREATE TABLE compat_view_a.compat_t USING parquet AS SELECT 1 AS
id")
+ sql("CREATE TABLE compat_view_b.compat_t USING parquet AS SELECT 2 AS
id")
+ withView(
+ "compat_view_b.v_unq_path",
+ "compat_view_b.v_fq_path") {
+ // Create both views with USE compat_view_b in effect so the stored
+ // viewCatalogAndNamespace points at compat_view_b, then SET PATH=a
so the
+ // frozen path pins compat_view_a.
+ withSQLConf(PATH_ENABLED.key -> "true") {
+ try {
+ sql("USE spark_catalog.compat_view_b")
+ sql("SET PATH = spark_catalog.compat_view_a, system.builtin")
+ sql(
+ """
+ |CREATE VIEW compat_view_b.v_unq_path AS
+ |SELECT id FROM compat_t
+ |""".stripMargin)
+ sql(
+ """
+ |CREATE VIEW compat_view_b.v_fq_path AS
+ |SELECT id FROM spark_catalog.compat_view_a.compat_t
+ |""".stripMargin)
+ } finally {
+ sql("SET PATH = DEFAULT_PATH")
+ sql("USE spark_catalog.default")
+ }
+ }
+
+ // Now read with PATH disabled. The fully-qualified view body is
independent of
+ // PATH and must keep returning rows from compat_view_a. The
unqualified-body view
+ // drops its frozen-path pin and falls back to
viewCatalogAndNamespace
+ // (compat_view_b), so unqualified `compat_t` resolves to
compat_view_b.compat_t.
+ withSQLConf(PATH_ENABLED.key -> "false") {
+ checkAnswer(sql("SELECT id FROM compat_view_b.v_fq_path"), Row(1))
+ checkAnswer(sql("SELECT id FROM compat_view_b.v_unq_path"), Row(2))
+ }
+ }
+ }
+ }
+ }
+
+ test("SPARK-56853: stored view path with no fallback target fails clearly
when PATH is off") {
+ // Same shape as the previous test, but the captured
`viewCatalogAndNamespace`
+ // (the creator's USE state at CREATE VIEW time -- set here via
+ // `USE spark_catalog.compat_home_only`) does NOT contain the unqualified
name.
+ // Under PATH disabled the analyzer cannot fall back anywhere, so the
lookup
+ // must raise TABLE_OR_VIEW_NOT_FOUND against that captured
catalog+namespace.
+ withDatabase("compat_home_only", "compat_referenced") {
+ sql("CREATE DATABASE compat_home_only")
+ sql("CREATE DATABASE compat_referenced")
+ withTable("compat_referenced.only_here") {
+ sql("CREATE TABLE compat_referenced.only_here USING parquet AS SELECT
7 AS id")
+ withView("compat_home_only.v_unq_home") {
+ withSQLConf(PATH_ENABLED.key -> "true") {
+ try {
+ sql("USE spark_catalog.compat_home_only")
+ sql("SET PATH = spark_catalog.compat_referenced, system.builtin")
+ sql(
+ """
+ |CREATE VIEW compat_home_only.v_unq_home AS
+ |SELECT id FROM only_here
+ |""".stripMargin)
+ } finally {
+ sql("SET PATH = DEFAULT_PATH")
+ sql("USE spark_catalog.default")
+ }
+ }
+
+ withSQLConf(PATH_ENABLED.key -> "false") {
+ val e = intercept[AnalysisException] {
+ sql("SELECT id FROM compat_home_only.v_unq_home").collect()
+ }
+ assert(e.getCondition == "TABLE_OR_VIEW_NOT_FOUND" ||
+ e.getMessage.contains("TABLE_OR_VIEW_NOT_FOUND"),
+ s"Expected TABLE_OR_VIEW_NOT_FOUND; got: ${e.getCondition}:
${e.getMessage}")
+ }
+ }
+ }
+ }
+ }
+
// Regression guard: frozen resolution path must not leak into
CURRENT_SCHEMA/CURRENT_PATH.
test("SPARK-56639: current_schema/current_path in persisted view use invoker
context") {
withSQLConf(PATH_ENABLED.key -> "true") {
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v1/AlterViewSchemaBindingSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v1/AlterViewSchemaBindingSuite.scala
index 39e6e708403a..6b1174856529 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v1/AlterViewSchemaBindingSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/execution/command/v1/AlterViewSchemaBindingSuite.scala
@@ -17,7 +17,53 @@
package org.apache.spark.sql.execution.command.v1
+import org.apache.spark.sql.catalyst.TableIdentifier
+import org.apache.spark.sql.connector.catalog.CatalogManager
import org.apache.spark.sql.execution.command
+import org.apache.spark.sql.internal.SQLConf
class AlterViewSchemaBindingSuite
- extends command.AlterViewSchemaBindingSuiteBase with ViewCommandSuiteBase
+ extends command.AlterViewSchemaBindingSuiteBase with ViewCommandSuiteBase {
+
+ test("ALTER VIEW ... WITH SCHEMA preserves the frozen SQL path") {
+ // `generateViewProperties(captureNewPath = false)` is the documented
behavior for
+ // ALTER VIEW WITH SCHEMA: the view's body resolution path must stay
pinned to the
+ // create-time PATH, not the caller's current PATH. This test creates the
view under
+ // PATH=a, then runs ALTER VIEW WITH SCHEMA EVOLUTION under PATH=b, and
asserts that
+ // the persisted VIEW_RESOLUTION_PATH still reflects PATH=a.
+ withSQLConf(SQLConf.PATH_ENABLED.key -> "true") {
+ val viewName = "v_path_preserved_on_alter"
+ val view = s"$catalog.$namespace.$viewName"
+ sql(s"CREATE SCHEMA IF NOT EXISTS $catalog.alter_view_path_a")
+ try {
+ sql(s"SET PATH = $catalog.alter_view_path_a, system.builtin")
+ sql(s"CREATE VIEW $view AS SELECT 1 AS x")
+ val pathAfterCreate = spark.sessionState.catalog
+ .getTableMetadata(TableIdentifier(viewName, Some(namespace)))
+ .viewStoredResolutionPath
+ .getOrElse(fail("Expected the view to persist a frozen SQL path"))
+ val parsedCreate =
CatalogManager.deserializePathEntries(pathAfterCreate)
+ .getOrElse(fail(s"Expected a valid serialized path, got:
$pathAfterCreate"))
+ assert(parsedCreate.contains(Seq(catalog, "alter_view_path_a")),
+ s"Frozen path should include alter_view_path_a; got: $parsedCreate")
+
+ // Switch the live PATH to something else and run ALTER VIEW WITH
SCHEMA.
+ // The captureNewPath = false code path must NOT overwrite the frozen
path.
+ sql(s"SET PATH = $catalog.default, system.builtin")
+ sql(s"ALTER VIEW $view WITH SCHEMA EVOLUTION")
+
+ val pathAfterAlter = spark.sessionState.catalog
+ .getTableMetadata(TableIdentifier(viewName, Some(namespace)))
+ .viewStoredResolutionPath
+ .getOrElse(fail("Frozen SQL path was dropped by ALTER VIEW WITH
SCHEMA"))
+ assert(pathAfterAlter == pathAfterCreate,
+ s"ALTER VIEW WITH SCHEMA must preserve the frozen path. " +
+ s"Before: $pathAfterCreate; after: $pathAfterAlter")
+ } finally {
+ sql("SET PATH = DEFAULT_PATH")
+ sql(s"DROP VIEW IF EXISTS $view")
+ sql(s"DROP SCHEMA IF EXISTS $catalog.alter_view_path_a")
+ }
+ }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]