This is an automated email from the ASF dual-hosted git repository.
LuciferYang 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 aa5108fe4264 [SPARK-57532][CORE][TEST] Add a test suite for
StringSubstitutor and tidy its default-value length bookkeeping
aa5108fe4264 is described below
commit aa5108fe426437241c96404821d399d87edab2f4
Author: YangJie <[email protected]>
AuthorDate: Wed Jun 24 13:17:55 2026 +0800
[SPARK-57532][CORE][TEST] Add a test suite for StringSubstitutor and tidy
its default-value length bookkeeping
### What changes were proposed in this pull request?
`StringSubstitutor` (added in SPARK-52990) is the hand-rolled `${name}` /
`${name:-default}` interpolation parser behind every error-message format, but
it has no direct test coverage. This PR adds `StringSubstitutorSuite` to
exercise it on its own.
The suite covers basic substitution, no-placeholder/empty/null input,
non-string values, undefined variables (both the throwing default and the
leave-as-is mode), default values (resolved, defaulted, empty, and empty
variable name), substitution that grows the buffer, escaped placeholders under
`preserveEscapes` true/false, nested expansion off/on, cycle detection, an
unclosed placeholder, custom `prefix`/`suffix`/`escape`/`valueDelimiter`, scan
progression past an unresolved placehol [...]
While adding the suite I also made two small production tweaks. First, the
default-value branch of `substitute` computed `newLength` (the scan length
adjusted for the replacement) but then recursed with the original, unadjusted
`length`, leaving `newLength` dead; the resolved-value branch right above it
already recurses with `newLength`, so this makes the two branches symmetric.
That change is behavior-preserving: a default value is always shorter than the
`${name:-default}` placehold [...]
### Why are the changes needed?
`StringSubstitutor` is on the path of all error-message formatting yet was
only tested indirectly. A dedicated suite documents and locks in its behavior,
including the less-obvious cases (escapes, defaults, nesting, cycles, custom
syntax). The `newLength` cleanup removes a confusing dead local, and
`String.valueOf` removes a latent `NullPointerException` on a null resolver
value.
### Does this PR introduce _any_ user-facing change?
No. The `newLength` cleanup is behavior-preserving. The `String.valueOf`
change only affects a `null` resolver value (previously a
`NullPointerException`, now the string `"null"`), which is not reachable from
the sole caller `ErrorClassesJSONReader`: it already maps null message
parameters to `"null"` before constructing the substitutor.
### How was this patch tested?
New unit tests. `build/sbt 'common-utils/testOnly *StringSubstitutorSuite'`
passes (18 cases).
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: Claude Code (Claude Opus 4.8)
Closes #56648 from LuciferYang/SPARK-stringsubstitutor-tests.
Authored-by: YangJie <[email protected]>
Signed-off-by: yangjie01 <[email protected]>
---
.../scala/org/apache/spark/StringSubstitutor.scala | 6 +-
.../org/apache/spark/StringSubstitutorSuite.scala | 160 +++++++++++++++++++++
2 files changed, 164 insertions(+), 2 deletions(-)
diff --git
a/common/utils/src/main/scala/org/apache/spark/StringSubstitutor.scala
b/common/utils/src/main/scala/org/apache/spark/StringSubstitutor.scala
index 6513c7dc7e8e..d8e59270092f 100644
--- a/common/utils/src/main/scala/org/apache/spark/StringSubstitutor.scala
+++ b/common/utils/src/main/scala/org/apache/spark/StringSubstitutor.scala
@@ -88,7 +88,9 @@ class StringSubstitutor(
resolver.get(varName) match {
case Some(value) =>
- var replacement = value.toString
+ // Use String.valueOf so a null value renders as "null"
(matching how the sole
+ // caller, ErrorClassesJSONReader, sanitizes null parameters)
instead of NPEing.
+ var replacement = String.valueOf(value)
if (enableSubstitutionInVariables) {
val newPrior = priorVariables.clone() += varName
val tempBuf = new StringBuilder(replacement)
@@ -109,7 +111,7 @@ class StringSubstitutor(
buf.replace(prefixPos, suffixPos + suffixLen, value)
val changeInLength = value.length - (suffixPos + suffixLen -
prefixPos)
val newLength = length + changeInLength
- substitute(buf, prefixPos + value.length, length,
priorVariables)
+ substitute(buf, prefixPos + value.length, newLength,
priorVariables)
return true
case None =>
if (enableUndefinedVariableException) {
diff --git
a/common/utils/src/test/scala/org/apache/spark/StringSubstitutorSuite.scala
b/common/utils/src/test/scala/org/apache/spark/StringSubstitutorSuite.scala
new file mode 100644
index 000000000000..549e356d1101
--- /dev/null
+++ b/common/utils/src/test/scala/org/apache/spark/StringSubstitutorSuite.scala
@@ -0,0 +1,160 @@
+/*
+ * 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
+
+import org.scalatest.funsuite.AnyFunSuite // scalastyle:ignore funsuite
+
+class StringSubstitutorSuite extends AnyFunSuite { // scalastyle:ignore
funsuite
+
+ test("basic substitution") {
+ val sub = new StringSubstitutor(Map("a" -> "X", "b" -> "Y"))
+ assert(sub.replace("${a}") === "X")
+ assert(sub.replace("pre${a}post") === "preXpost")
+ assert(sub.replace("${a}${b}") === "XY")
+ assert(sub.replace("${a}-${b}") === "X-Y")
+ }
+
+ test("no placeholders, empty and null input") {
+ val sub = new StringSubstitutor(Map("a" -> "X"))
+ assert(sub.replace("hello") === "hello")
+ assert(sub.replace("") === "")
+ assert(sub.replace(null) === null)
+ }
+
+ test("non-string values are rendered with toString") {
+ val sub = new StringSubstitutor(Map("n" -> 42, "b" -> true))
+ assert(sub.replace("${n}/${b}") === "42/true")
+ }
+
+ test("undefined variable throws by default") {
+ val sub = new StringSubstitutor(Map.empty[String, Any])
+ val e = intercept[IllegalArgumentException](sub.replace("${x}"))
+ assert(e.getMessage.contains("x"))
+ }
+
+ test("undefined variable is left as-is when exceptions are disabled") {
+ val sub =
+ new StringSubstitutor(Map.empty[String, Any],
enableUndefinedVariableException = false)
+ assert(sub.replace("a${x}b") === "a${x}b")
+ }
+
+ test("default value is used only when the variable is undefined") {
+ assert(new StringSubstitutor(Map.empty[String, Any]).replace("${x:-def}")
=== "def")
+ assert(new StringSubstitutor(Map("x" -> "X")).replace("${x:-def}") === "X")
+ // Empty variable name: the delimiter is at index 0, so the text after it
is the default.
+ assert(new StringSubstitutor(Map.empty[String,
Any]).replace("${:-fallback}") === "fallback")
+ }
+
+ test("a default-value substitution is followed by a further substitution") {
+ // After a default-value replacement the buffer shifts; this checks the
scan continues
+ // past it and still expands the later ${b}.
+ val sub = new StringSubstitutor(Map("b" -> "Y"))
+ assert(sub.replace("${a:-LONG_DEFAULT}${b}") === "LONG_DEFAULTY")
+ }
+
+ test("a resolved value longer than its placeholder is followed by a further
substitution") {
+ // The replacement is longer than its placeholder (a growing buffer); the
later ${b} must
+ // still be expanded.
+ val sub = new StringSubstitutor(Map("a" -> "LONG_VALUE", "b" -> "Y"))
+ assert(sub.replace("${a}${b}") === "LONG_VALUEY")
+ }
+
+ test("escaped placeholder is not substituted") {
+ // preserveEscapes = true (default): the escape is kept and the
placeholder is not expanded,
+ // but the scan resumes, so a later resolvable placeholder is still
expanded.
+ assert(new StringSubstitutor(Map("a" -> "X")).replace("$${a}") === "$${a}")
+ assert(new StringSubstitutor(Map("b" -> "Y")).replace("$${a}${b}") ===
"$${a}Y")
+ // preserveEscapes = false: the escape is removed and the placeholder is
still not expanded;
+ // again the scan resumes for a later placeholder.
+ assert(new StringSubstitutor(Map("a" -> "X"), preserveEscapes =
false).replace("$${a}")
+ === "${a}")
+ assert(new StringSubstitutor(Map("b" -> "Y"), preserveEscapes =
false).replace("$${a}${b}")
+ === "${a}Y")
+ }
+
+ test("nested references are expanded only when enabled") {
+ val resolver = Map[String, Any]("a" -> "${b}", "b" -> "Y")
+ assert(new StringSubstitutor(resolver).replace("${a}") === "${b}")
+ assert(new StringSubstitutor(resolver, enableSubstitutionInVariables =
true).replace("${a}")
+ === "Y")
+ }
+
+ test("cyclic references are detected when nested expansion is enabled") {
+ val sub = new StringSubstitutor(
+ Map("a" -> "${b}", "b" -> "${a}"), enableSubstitutionInVariables = true)
+ val e = intercept[IllegalStateException](sub.replace("${a}"))
+ assert(e.getMessage.startsWith("Infinite loop in property interpolation
of"))
+ assert(e.getMessage.contains("a") || e.getMessage.contains("b"))
+ }
+
+ test("an unclosed placeholder is left as-is") {
+ val sub = new StringSubstitutor(Map("a" -> "X"))
+ assert(sub.replace("text ${unclosed") === "text ${unclosed")
+ // A resolved placeholder earlier in the string does not stop the scan
from reaching and
+ // leaving the unclosed tail.
+ assert(sub.replace("${a} and ${unclosed") === "X and ${unclosed")
+ }
+
+ test("an empty default value collapses the placeholder to the empty string")
{
+ val sub = new StringSubstitutor(Map.empty[String, Any])
+ assert(sub.replace("prefix${x:-}suffix") === "prefixsuffix")
+ // A later placeholder is still expanded after the empty-default
replacement.
+ assert(sub.replace("${x:-}${y:-Z}") === "Z")
+ }
+
+ test("custom prefix, suffix, escape, and value delimiter") {
+ val sub = new StringSubstitutor(
+ Map("x" -> "X"), prefix = "%{", suffix = "}", escape = '%',
valueDelimiter = "|")
+ assert(sub.replace("%{x}") === "X")
+ assert(sub.replace("pre%{x}post") === "preXpost")
+ // The custom value delimiter supplies the default for an undefined
variable.
+ assert(sub.replace("%{y|def}") === "def")
+ // The custom escape char guards the custom prefix (preserveEscapes
defaults to true).
+ assert(sub.replace("%%{x}") === "%%{x}")
+ }
+
+ test("scan continues past an unresolved placeholder to a later resolved
one") {
+ // With exceptions disabled, an unresolved placeholder is left untouched
but does not stop the
+ // scan from reaching and expanding a later resolvable placeholder.
+ val sub = new StringSubstitutor(Map("x" -> "X"),
enableUndefinedVariableException = false)
+ assert(sub.replace("${undefined} ${x}") === "${undefined} X")
+ }
+
+ test("a null resolver value renders as the string \"null\"") {
+ // The sole caller (ErrorClassesJSONReader) sanitizes a null parameter to
"null", so a null
+ // value is rendered the same way here rather than NPEing on toString or
being treated as
+ // undefined.
+ val resolver = Map[String, Any]("x" -> null)
+ assert(new StringSubstitutor(resolver).replace("${x}") === "null")
+ assert(new StringSubstitutor(resolver).replace("a${x}b") === "anullb")
+ // The variable is resolved (to "null"), so its default value is not used.
+ assert(new StringSubstitutor(resolver).replace("${x:-def}") === "null")
+ }
+
+ test("placeholders inside a default value are not expanded") {
+ // The first '}' closes the placeholder, so the default value is the
literal text up to it and
+ // the inner ${x} is not treated as a nested reference.
+ val sub = new StringSubstitutor(Map("x" -> "X"))
+ assert(sub.replace("${undefined:-${x}}") === "${x}")
+ }
+
+ test("an empty placeholder with no default is undefined") {
+ val e = intercept[IllegalArgumentException](
+ new StringSubstitutor(Map.empty[String, Any]).replace("${}"))
+ assert(e.getMessage.contains("Undefined variable"))
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]