Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2105,21 +2105,11 @@ class Analyzer(
* This is used for special syntax transformations (e.g., COUNT(*) -> COUNT(1)) that
* should only apply to builtin functions, not to user-defined functions.
*
* When the effective SQL PATH puts `system.session` before `system.builtin`, temp
* functions shadow builtins, so an unqualified name that matches a temp function
* should NOT be treated as builtin.
* Mirrors function resolution precedence, including SQL PATH shadowing for unqualified names
* and `spark.sql.legacy.persistentCatalogFirst` for two-part `builtin.name` references.
*/
private def matchesFunctionName(nameParts: Seq[String], expectedName: String): Boolean = {
if (!FunctionResolution.isUnqualifiedOrBuiltinFunctionName(nameParts, expectedName)) {
return false
}
if (nameParts.size == 1 && functionResolution.isSessionBeforeBuiltinInPath) {
val v1Catalog = catalogManager.v1SessionCatalog
!v1Catalog.isTemporaryFunction(FunctionIdentifier(nameParts.head))
} else {
true
}
}
private def matchesFunctionName(nameParts: Seq[String], expectedName: String): Boolean =
functionResolution.functionNameResolvesToBuiltin(nameParts, expectedName)

/**
* Expands the matching attribute.*'s in `child`'s output.
Expand All @@ -2135,6 +2125,13 @@ class Analyzer(
// the same qualification.
f0.copy(arguments = Seq(Literal(1)))
case f1: UnresolvedFunction if containsStar(f1.arguments) =>
// A routed SQL/JSON constructor (json_array(*)) forbids a bare `*`; reject it rather than
// expand below. A nested star (json_array(array(*))) is expanded bottom-up before we get
// here, and count(*) is rewritten above -- so only a bare `*` reaches this guard.
if (functionResolution.resolvesToStarDisallowedJsonConstructor(f1.nameParts)) {
throw QueryCompilationErrors.invalidStarUsageError(
s"expression `${f1.prettyName}`", extractStar(f1.arguments))
}
// SPECIAL CASE: We want to block count(tblName.*) because in spark, count(tblName.*) will
// be expanded while count(*) will be converted to count(1). They will produce different
// results and confuse users if there are any null values. For count(t1.*, t2.*), it is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1025,7 +1025,13 @@ object FunctionRegistry {
expression[SchemaOfJson]("schema_of_json"),
expression[LengthOfJsonArray]("json_array_length"),
expression[JsonObjectKeys]("json_object_keys"),
expression[JsonTypeof]("json_typeof")
expression[JsonTypeof]("json_typeof"),
// Built-in forms of the SQL:2016 JSON constructor and path functions, resolved for plain calls
// that carry no SQL/JSON clauses (the dedicated grammar handles clause-bearing syntax).
expressionBuilder("json_value", JsonValueExpressionBuilder),
expressionBuilder("json_query", JsonQueryExpressionBuilder),
expressionBuilder("json_exists", JsonExistsExpressionBuilder),
expressionBuilder("json_array", JsonArrayExpressionBuilder)
)

private def variantExpressions: Seq[FunctionRegistryEntry] = Seq(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,22 +76,6 @@ class FunctionResolution(
nameParts.length == 3 &&
nameParts.head.equalsIgnoreCase(CatalogManager.SYSTEM_CATALOG_NAME)

/**
* True iff `system.session` is searched before `system.builtin` in the effective SQL PATH.
*
* Drives the `count(*) -> count(1)` rewrite (which must skip transformation when a temp
* `count` shadows the builtin) and the `SessionCatalog` security check that blocks creating
* a temp function with a builtin's name. Reads the live PATH via `CatalogManager` and
* applies the same kinds extraction that drives `SessionCatalog`'s fast-path provider, so
* the predicate stays in sync with the lookup loop's actual order. Uses the consolidated
* snapshot helper (SPARK-56939) so the (catalog, namespace, path) triple is observed
* atomically.
*/
def isSessionBeforeBuiltinInPath: Boolean = {
catalogManager.sessionFunctionKindsForUnqualifiedResolution().headOption
.contains(org.apache.spark.sql.catalyst.catalog.SessionCatalog.Temp)
}

/**
* Produces the ordered list of candidate names for resolution. Expansion happens in two cases:
*
Expand Down Expand Up @@ -404,6 +388,87 @@ class FunctionResolution(
}
}

/**
* Returns whether an unqualified function name reaches `system.builtin` before any temp or
* persistent function in the effective SQL PATH. When a temp or persistent function shadows the
* builtin, special-syntax rewrites (e.g. `count(*) -> count(1)`) must not fire, since the name no

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (P1): This path-aware count gate is unreachable for an unqualified SQL count(*): AstBuilder has already replaced the Star with Literal(1). With a persistent count(x INT) = x + 100 before system.builtin, SELECT count(*) FROM VALUES (7) AS t(a) consequently returns 101 instead of expanding the user routine's argument to a and returning 107. Please leave the Star unresolved in the parser, normalize to count(1) only after these ownership checks select the builtin, and update the parser/SetPath tests with a non-1 input so they distinguish the two paths.

Recommended change: Move unqualified count(*) normalization from AstBuilder to the ownership-aware analyzer paths.

Why this works: Preserve UnresolvedStar through parsing, then convert it to Literal(1) only when functionNameResolvesToBuiltin confirms that Spark's builtin count owns the call; otherwise allow ordinary star expansion for the selected routine.

Scope: AstBuilder, fixed-point and single-pass count-star handling, parser expectations, and SetPathSuite coverage.

Compatibility: Builtin count(*) keeps its existing semantics, while user-defined count routines on SQL PATH receive the arguments ordinary function resolution specifies.

Risks: Changing the unresolved parse shape can expose ordering differences between the two analyzers. Qualified stars and the legacy single-table-star count configuration must retain their existing validation.

Constraints: Preserve distinct-count and qualified-function behavior. Use a non-1 and nullable input in regression coverage so count(1), count(column), and star expansion remain distinguishable.

Success: Builtin count(*) still counts rows, while a persistent count routine before system.builtin receives the expanded input column in both analyzer modes.

* longer refers to Spark's builtin.
*/
def unqualifiedFunctionResolvesToBuiltinBeforeAnyShadow(functionName: String): Boolean = {
// Walk the PATH in order and stop at the first entry that owns the name. The default order puts
// system.builtin first, so the common case returns on the first entry with no catalog lookup;
// only a custom PATH that lists a persistent catalog ahead of system.builtin reaches the probe
// below (one lookup per such preceding entry, recomputed on each call -- not cached).
sqlResolutionPathEntriesForAnalysis.foreach { pathEntry =>
val candidate = pathEntry :+ functionName
FunctionResolution.sessionNamespaceKind(candidate) match {
case Some(org.apache.spark.sql.catalyst.catalog.SessionCatalog.Builtin) =>
return true
case Some(org.apache.spark.sql.catalyst.catalog.SessionCatalog.Temp) =>
if (v1SessionCatalog.isTemporaryFunction(FunctionIdentifier(functionName))) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking (P1): This raw isTemporaryFunction check does not mirror actual resolution inside a stored view: SessionCatalog.handleViewContext hides a temporary function unless the view recorded it. A view whose frozen PATH starts with system.session can therefore see a temp shadow here, skip the JSON star guard, then hide that temp during resolution and fall through to system.builtin.json_array, accepting an expanded * that the builtin should reject. Please use the same view-aware session lookup as resolveScalarFunctionByIdentifier and add a stored-view regression with an unrelated temp function created after the view.

Recommended change: Make the builtin-ownership probe reuse SessionCatalog's stored-view-aware temporary-function visibility semantics.

Why this works: Replace the raw registry existence check with a side-effect-free lookup that applies AnalysisContext.referredTempFunctionNames exactly as actual scalar resolution does.

Scope: FunctionResolution's system.session branch plus focused catalyst and SQL stored-view coverage.

Compatibility: This preserves normal session lookup behavior and only restores builtin-only syntax handling when a temporary function is not visible to the stored view.

Risks: The probe must not build or execute a function expression merely to test ownership. Scalar and table-function visibility must remain consistent with the actual resolver path.

Constraints: Honor the view's recorded temporary-function names and frozen SQL PATH. Keep fixed-point and single-pass analyzer ownership decisions aligned.

Success: For stored views, the precheck and actual resolver choose the same first visible owner, and builtin JSON star syntax is rejected even when an unrelated invisible temp function exists.

return false
}
case None =>
if (persistentFunctionExists(candidate)) {
return false
}
}
}
false
}

/**
* Returns true when a function reference resolves to the system built-in with the requested name.
* This mirrors [[resolveFunction]] for special parser/analyzer rewrites that must run only for
* Spark's built-ins. In particular, two-part `builtin.name` is not always a system built-in:
* with `spark.sql.legacy.persistentCatalogFirst=true`, an existing persistent
* `current_catalog.builtin.name` takes precedence.
*/
def functionNameResolvesToBuiltin(nameParts: Seq[String], expectedName: String): Boolean = {
if (!FunctionRegistry.functionSet.contains(
FunctionRegistry.builtinFunctionIdentifier(expectedName)) ||
!FunctionResolution.isUnqualifiedOrBuiltinFunctionName(nameParts, expectedName)) {
return false
}
nameParts.length match {
case 1 =>
unqualifiedFunctionResolvesToBuiltinBeforeAnyShadow(nameParts.head)
case 2 =>
conf.prioritizeSystemCatalog || !persistentFunctionExists(nameParts)
case 3 =>
true
case _ =>
false
}
}

private val starDisallowedJsonConstructors =
Set("json_array", "json_exists", "json_query", "json_value")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This set has to stay in sync with two other lists, the four expressionBuilder(...) registrations in FunctionRegistry and the four routed grammar branches in AstBuilder. A future fifth clause-free constructor added to the grammar + registry but not here would silently lose the bare-* rejection (it'd fall through to normal star expansion).

Please consider deriving this from a shared constant.


/** True if `nameParts` resolves to a built-in SQL/JSON constructor that forbids bare `*`. */
def resolvesToStarDisallowedJsonConstructor(nameParts: Seq[String]): Boolean =
starDisallowedJsonConstructors.exists(functionNameResolvesToBuiltin(nameParts, _))

private def persistentFunctionExists(nameParts: Seq[String]): Boolean = {
try {
// Expand through the view's frozen catalog/namespace exactly as `resolveFunctionCandidate`
// does, so the shadow probe queries the same catalog the real resolver would inside a view.
relationResolution.expandIdentifier(nameParts) match {
case CatalogAndIdentifier(catalog, ident) =>
catalog.asFunctionCatalog.functionExists(ident)
case _ =>
false
}
} catch {
case _: NoSuchFunctionException
| _: NoSuchNamespaceException
| _: CatalogNotFoundException =>
false
case e: AnalysisException if e.getCondition == "FORBIDDEN_OPERATION" =>
false
}
}

/**
* Determines the type/location of a function (builtin, temporary, persistent, etc.).
* This is used by the LookupFunctions analyzer rule for early validation and optimization.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,14 @@

package org.apache.spark.sql.catalyst.analysis.resolver

import org.apache.spark.sql.catalyst.FunctionIdentifier
import org.apache.spark.sql.catalyst.analysis.{
FunctionResolution,
ResolvedStar,
Star,
UnresolvedFunction,
UnresolvedStar
}
import org.apache.spark.sql.catalyst.expressions.Literal
import org.apache.spark.sql.catalyst.expressions.{Expression, Literal}
import org.apache.spark.sql.errors.QueryCompilationErrors
import org.apache.spark.sql.internal.SQLConf

Expand Down Expand Up @@ -57,12 +56,19 @@ trait FunctionResolverUtils {
*/
protected def handleStarInArguments(
unresolvedFunction: UnresolvedFunction): UnresolvedFunction = {
val functionContainsStarInArguments = unresolvedFunction.arguments.exists {
val functionContainsDirectStarInArguments = unresolvedFunction.arguments.exists {
case _: Star => true
case _ => false
}

if (!functionContainsStarInArguments) {
if (functionContainsDirectStarInArguments &&
functionResolution.resolvesToStarDisallowedJsonConstructor(unresolvedFunction.nameParts)) {
// Only a bare `*` argument is rejected in a JSON constructor; a star nested in another
// expression (json_array(array(*))) is expanded there and count(*) is rewritten to count(1),
// so both stay valid arguments.
throw QueryCompilationErrors.invalidStarUsageError(
s"expression `${unresolvedFunction.prettyName}`", extractStar(unresolvedFunction.arguments))
} else if (!functionContainsDirectStarInArguments) {
unresolvedFunction
} else if (isNonDistinctCount(unresolvedFunction) &&
hasSingleSimpleStarArgument(unresolvedFunction)) {
Expand Down Expand Up @@ -92,30 +98,16 @@ trait FunctionResolverUtils {
case _ => false
}

private def extractStar(expressions: Seq[Expression]): Seq[Star] =
expressions.collect { case s: Star => s }

/**
* Method used to determine whether the given function is non-distinct `count` function,
* with optional normalization.
*/
private def isNonDistinctCount(unresolvedFunction: UnresolvedFunction): Boolean = {
!unresolvedFunction.isDistinct &&
isCount(unresolvedFunction) &&
!isUnqualifiedCountShadowedByTemp(unresolvedFunction)
}

/**
* Keep single-pass behavior aligned with fixed-point: when PATH puts system.session before
* system.builtin and a temp `count` exists, unqualified `count(*)` must not be rewritten to
* `count(1)`.
*/
private def isUnqualifiedCountShadowedByTemp(unresolvedFunction: UnresolvedFunction): Boolean = {
unresolvedFunction.nameParts.length == 1 &&
functionResolution.isSessionBeforeBuiltinInPath &&
functionResolution.catalogManager.v1SessionCatalog
.isTemporaryFunction(FunctionIdentifier(unresolvedFunction.nameParts.head))
}

private def isCount(unresolvedFunction: UnresolvedFunction): Boolean = {
FunctionResolution.isUnqualifiedOrBuiltinFunctionName(unresolvedFunction.nameParts, "count")
functionResolution.functionNameResolvesToBuiltin(unresolvedFunction.nameParts, "count")
}

/**
Expand All @@ -127,7 +119,6 @@ trait FunctionResolverUtils {
private def normalizeCountExpression(
unresolvedFunction: UnresolvedFunction): UnresolvedFunction = {
unresolvedFunction.copy(
nameParts = Seq("count"),
arguments = Seq(Literal(1)),
filter = unresolvedFunction.filter
)
Expand All @@ -141,7 +132,7 @@ trait FunctionResolverUtils {
private def assertSingleTableStarNotInCountFunction(
unresolvedFunction: UnresolvedFunction): Unit = {
if (!conf.allowStarWithSingleTableIdentifierInCount &&
isCount(unresolvedFunction) &&
functionResolution.functionNameResolvesToBuiltin(unresolvedFunction.nameParts, "count") &&
unresolvedFunction.arguments.length == 1) {
unresolvedFunction.arguments.head match {
case star: UnresolvedStar if scopes.current.isStarQualifiedByTable(star) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,8 @@ class ResolverGuard(
true
// JSON
case _: GetJsonObject | _: JsonTuple | _: JsonToStructs | _: StructsToJson |
_: SchemaOfJson | _: JsonObjectKeys | _: LengthOfJsonArray =>
_: SchemaOfJson | _: JsonObjectKeys | _: LengthOfJsonArray | _: JsonArray |
_: JsonExists | _: JsonQuery | _: JsonValue =>
true
// CSV
case _: SchemaOfCsv | _: StructsToCsv | _: CsvToStructs =>
Expand Down
Loading