Skip to content

Commit 730d375

Browse files
cloud-fanyaooqinn
authored andcommitted
[SPARK-55756][SQL][TESTS] Add --DEBUG directive for golden file test framework
### What changes were proposed in this pull request? Add a `--DEBUG` marker directive for the SQL golden file test framework (`SQLQueryTestSuite`). When placed on its own line before a query in an input `.sql` file, it enables a focused debug mode: - **Selective execution**: Only `--DEBUG`-marked queries and setup commands (CREATE TABLE, INSERT, SET, etc.) are executed; all other queries are skipped. - **Full error details**: Failed queries print the complete stacktrace to the console. - **Golden comparison**: Results are still compared against the golden file so you can verify correctness. - **Safety net**: The test always fails at the end with a reminder to remove `--DEBUG` markers before committing. - **DataFrame access**: Documentation guides users to set a breakpoint in `runDebugQueries` to inspect the `DataFrame` instance for ad-hoc plan analysis. Example usage in an input file: ```sql CREATE TABLE t (id INT, val INT) USING parquet; INSERT INTO t VALUES (1, 10), (2, 20); -- this query is skipped in debug mode SELECT count(*) FROM t; -- this is the query I'm debugging --DEBUG SELECT sum(val) OVER (ORDER BY id) FROM t; ``` Example console output when running the test: ``` === DEBUG: Query #3 === SQL: SELECT sum(val) OVER (ORDER BY id) FROM t Golden answer: matches ``` When the debug query fails: ``` === DEBUG: Query #3 === SQL: SELECT sum(val) OVER (ORDER BY id) FROM t org.apache.spark.sql.AnalysisException: [ERROR_CLASS] ... at org.apache.spark.sql.catalyst.analysis... at ... Golden answer: matches ``` ### Why are the changes needed? Debugging golden file test failures is currently painful: 1. You must run all queries even if only one needs debugging. 2. Error output is minimal (just the error class/message), with no stacktrace. 3. There is no way to access the `DataFrame` instance for plan inspection. This change addresses all three issues with a simple, zero-config marker that can be temporarily added during development. ### Does this PR introduce _any_ user-facing change? No. This is a test infrastructure improvement only. ### How was this patch tested? - Manually tested with `--DEBUG` markers on both passing and failing queries in `inline-table.sql`. - Verified backward compatibility: tests pass normally when no `--DEBUG` markers are present. - Verified debug mode output includes full stacktraces, golden answer comparison, and the safety-fail message. ### Was this patch authored or co-authored using generative AI tooling? Yes. cursor Closes #54554 from cloud-fan/golden. Authored-by: Wenchen Fan <wenchen@databricks.com> Signed-off-by: Kent Yao <kentyao@microsoft.com>
1 parent 959cc95 commit 730d375

2 files changed

Lines changed: 176 additions & 17 deletions

File tree

sql/core/src/test/scala/org/apache/spark/sql/SQLQueryTestHelper.scala

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -400,11 +400,16 @@ trait SQLQueryTestHelper extends SQLConfHelper with Logging {
400400
protected def splitCommentsAndCodes(input: String): (Array[String], Array[String]) =
401401
input.split("\n").partition { line =>
402402
val newLine = line.trim
403-
newLine.startsWith("--") && !newLine.startsWith("--QUERY-DELIMITER")
403+
newLine.startsWith("--") && !newLine.startsWith("--QUERY-DELIMITER") &&
404+
newLine != "--DEBUG"
404405
}
405406

406-
protected def getQueries(code: Array[String], comments: Array[String],
407-
allTestCases: Seq[TestCase]): Seq[String] = {
407+
/**
408+
* Parses queries from code lines and returns each query paired with a Boolean indicating
409+
* whether it was preceded by a --DEBUG marker.
410+
*/
411+
protected def getQueriesWithDebugFlag(code: Array[String], comments: Array[String],
412+
allTestCases: Seq[TestCase]): Seq[(String, Boolean)] = {
408413
def splitWithSemicolon(seq: Seq[String]) = {
409414
seq.mkString("\n").split("(?<=[^\\\\]);")
410415
}
@@ -450,10 +455,18 @@ trait SQLQueryTestHelper extends SQLConfHelper with Logging {
450455
splitWithSemicolon(allCode.toImmutableArraySeq).toSeq
451456
}
452457

453-
// List of SQL queries to run
454-
tempQueries.map(_.trim).filter(_ != "")
455-
// Fix misplacement when comment is at the end of the query.
456-
.map(_.split("\n").filterNot(_.startsWith("--")).mkString("\n")).map(_.trim).filter(_ != "")
458+
// Detect --DEBUG markers before stripping comment lines from each query.
459+
tempQueries.map(_.trim).filter(_ != "").map { query =>
460+
val lines = query.split("\n")
461+
val isDebug = lines.exists(_.trim == "--DEBUG")
462+
val cleanedQuery = lines.filterNot(_.startsWith("--")).mkString("\n").trim
463+
(cleanedQuery, isDebug)
464+
}.filter(_._1 != "")
465+
}
466+
467+
protected def getQueries(code: Array[String], comments: Array[String],
468+
allTestCases: Seq[TestCase]): Seq[String] = {
469+
getQueriesWithDebugFlag(code, comments, allTestCases).map(_._1)
457470
}
458471

459472
protected def getSparkSettings(comments: Array[String]): Array[(String, String)] = {

sql/core/src/test/scala/org/apache/spark/sql/SQLQueryTestSuite.scala

Lines changed: 156 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
package org.apache.spark.sql
1919

20-
import java.io.File
20+
import java.io.{File, PrintWriter, StringWriter}
2121
import java.net.URI
2222
import java.nio.file.Files
2323
import java.util.Locale
@@ -84,6 +84,16 @@ import org.apache.spark.util.Utils
8484
* times, each time picks one config set from each dimension, until all the combinations are
8585
* tried. For example, if dimension 1 has 2 lines, dimension 2 has 3 lines, this testing file
8686
* will be run 6 times (cartesian product).
87+
* 6. A line with --DEBUG (on its own line before a query) marks that query for debug mode.
88+
* When any --DEBUG marker is present, the test enters debug mode:
89+
* - Commands (CREATE TABLE, INSERT, DROP, SET, etc.) are always executed automatically.
90+
* - Only --DEBUG-marked non-command queries are executed; all others are skipped.
91+
* - For failed queries, the full error stacktrace is printed to the console.
92+
* - Query results are still compared against the golden file.
93+
* - The test always fails at the end with a reminder to remove --DEBUG markers.
94+
* To inspect the DataFrame interactively, set a breakpoint in `runDebugQueries` at the
95+
* line where `localSparkSession.sql(sql)` is called, then evaluate the DataFrame in the
96+
* debugger (e.g., `df.queryExecution.analyzed`, `df.explain(true)`).
8797
*
8898
* For example:
8999
* {{{
@@ -92,6 +102,17 @@ import org.apache.spark.util.Utils
92102
* select current_date;
93103
* }}}
94104
*
105+
* To debug a specific query, add --DEBUG before it:
106+
* {{{
107+
* CREATE TABLE t (id INT, val INT) USING parquet;
108+
* INSERT INTO t VALUES (1, 10), (2, 20);
109+
* -- this query is skipped in debug mode
110+
* SELECT count(*) FROM t;
111+
* -- this is the query I'm debugging
112+
* --DEBUG
113+
* SELECT sum(val) OVER (ORDER BY id) FROM t;
114+
* }}}
115+
*
95116
* The format for golden result files look roughly like:
96117
* {{{
97118
* -- some header information
@@ -232,15 +253,18 @@ class SQLQueryTestSuite extends QueryTest with SharedSparkSession with SQLHelper
232253
protected def runSqlTestCase(testCase: TestCase, listTestCases: Seq[TestCase]): Unit = {
233254
val input = Files.readString(new File(testCase.inputFile).toPath)
234255
val (comments, code) = splitCommentsAndCodes(input)
235-
val queries = getQueries(code, comments, listTestCases)
256+
val queriesWithDebug = getQueriesWithDebugFlag(code, comments, listTestCases)
236257
val settings = getSparkSettings(comments)
258+
val debugMode = queriesWithDebug.exists(_._2) && !testCase.isInstanceOf[AnalyzerTest]
237259

238-
if (regenerateGoldenFiles) {
239-
runQueries(queries, testCase, settings.toImmutableArraySeq)
260+
if (debugMode && !regenerateGoldenFiles) {
261+
runDebugQueries(queriesWithDebug, testCase, settings.toImmutableArraySeq)
262+
} else if (regenerateGoldenFiles) {
263+
runQueries(queriesWithDebug.map(_._1), testCase, settings.toImmutableArraySeq)
240264
} else {
241265
val configSets = getSparkConfigDimensions(comments)
242266
runQueriesWithSparkConfigDimensions(
243-
queries, testCase, settings, configSets)
267+
queriesWithDebug.map(_._1), testCase, settings, configSets)
244268
}
245269
}
246270

@@ -287,12 +311,13 @@ class SQLQueryTestSuite extends QueryTest with SharedSparkSession with SQLHelper
287311
}
288312
}
289313

290-
protected def runQueries(
291-
queries: Seq[String],
314+
/**
315+
* Creates and configures a local SparkSession for running test queries.
316+
* Handles UDF/UDTF registration, SQL config, and TPCDS table setup.
317+
*/
318+
private def setupLocalSession(
292319
testCase: TestCase,
293-
sparkConfigSet: Seq[(String, String)]): Unit = {
294-
// Create a local SparkSession to have stronger isolation between different test cases.
295-
// This does not isolate catalog changes.
320+
sparkConfigSet: Seq[(String, String)]): SparkSession = {
296321
val localSparkSession = spark.newSession()
297322

298323
testCase match {
@@ -337,6 +362,127 @@ class SQLQueryTestSuite extends QueryTest with SharedSparkSession with SQLHelper
337362
}
338363
}
339364

365+
localSparkSession
366+
}
367+
368+
/**
369+
* Runs queries in debug mode. Only commands and --DEBUG-marked queries are executed.
370+
* Failed debug queries print the full error stacktrace. Results are compared against
371+
* the golden file. The test always fails at the end to prevent accidental commits.
372+
*
373+
* To inspect the DataFrame interactively, set a breakpoint at the line where
374+
* `localSparkSession.sql(sql)` is called inside this method, then evaluate the DataFrame
375+
* in the debugger (e.g., `df.queryExecution.analyzed`, `df.explain(true)`).
376+
*/
377+
protected def runDebugQueries(
378+
queriesWithDebug: Seq[(String, Boolean)],
379+
testCase: TestCase,
380+
sparkConfigSet: Seq[(String, String)]): Unit = {
381+
val localSparkSession = setupLocalSession(testCase, sparkConfigSet)
382+
val lowercaseTestCase = testCase.name.toLowerCase(Locale.ROOT)
383+
val segmentsPerQuery = 3
384+
385+
val goldenFileExists = new File(testCase.resultFile).exists()
386+
val segments = if (goldenFileExists) {
387+
Files.readString(new File(testCase.resultFile).toPath).split("-- !query.*\n")
388+
} else {
389+
Array.empty[String]
390+
}
391+
392+
queriesWithDebug.zipWithIndex.foreach { case ((sql, isDebug), i) =>
393+
val isCommand = try {
394+
localSparkSession.sessionState.sqlParser.parsePlan(sql).isInstanceOf[Command]
395+
} catch {
396+
case _: ParseException => false
397+
}
398+
399+
if (isCommand) {
400+
localSparkSession.sql(sql).collect()
401+
} else if (isDebug) {
402+
// Capture exception stacktrace if the query fails.
403+
var exceptionTrace: Option[String] = None
404+
val (schema, result) = handleExceptions {
405+
try {
406+
// Set a breakpoint here and evaluate `localSparkSession.sql(sql)` to get the
407+
// DataFrame for ad-hoc debugging (e.g., df.queryExecution.analyzed).
408+
getNormalizedQueryExecutionResult(localSparkSession, sql)
409+
} catch {
410+
case e: Throwable =>
411+
val sw = new StringWriter()
412+
e.printStackTrace(new PrintWriter(sw))
413+
exceptionTrace = Some(sw.toString)
414+
throw e
415+
}
416+
}
417+
val output = ExecutionOutput(
418+
sql = sql,
419+
schema = Some(schema),
420+
output = normalizeTestResults(result.mkString("\n")))
421+
422+
// Build consolidated debug output.
423+
val debugOutput = new StringBuilder()
424+
debugOutput.append(s"\n=== DEBUG: Query #$i ===")
425+
debugOutput.append(s"\nSQL: $sql")
426+
exceptionTrace.foreach { trace =>
427+
debugOutput.append(s"\n$trace")
428+
}
429+
430+
// Compare against golden file and append result.
431+
var mismatch: Option[String] = None
432+
if (goldenFileExists &&
433+
segments.length > segmentsPerQuery * i + segmentsPerQuery) {
434+
val expected = ExecutionOutput(
435+
segments(segmentsPerQuery * i + 1).trim,
436+
Some(segments(segmentsPerQuery * i + 2).trim),
437+
normalizeTestResults(segments(segmentsPerQuery * i + 3)))
438+
439+
if (expected.sql != output.sql) {
440+
debugOutput.append("\nGolden answer: no matching entry " +
441+
"(SQL text differs, likely a new or moved query)")
442+
} else if (expected.schema != output.schema) {
443+
mismatch = Some(s"Schema did not match for query #$i\n${expected.sql}")
444+
debugOutput.append(s"\nGolden answer: schema mismatch")
445+
debugOutput.append(s"\n Expected: ${expected.schema.getOrElse("")}")
446+
debugOutput.append(s"\n Actual: ${output.schema.getOrElse("")}")
447+
} else if (expected.output != output.output) {
448+
mismatch = Some(s"Result did not match for query #$i\n${expected.sql}")
449+
debugOutput.append(s"\nGolden answer: result mismatch")
450+
debugOutput.append(s"\n Expected:\n${expected.output}")
451+
debugOutput.append(s"\n Actual:\n${output.output}")
452+
} else {
453+
debugOutput.append("\nGolden answer: matches")
454+
}
455+
} else {
456+
debugOutput.append("\nGolden answer: no golden file to compare")
457+
}
458+
459+
// scalastyle:off println
460+
println(debugOutput.toString)
461+
// scalastyle:on println
462+
463+
mismatch.foreach(fail(_))
464+
}
465+
}
466+
467+
if (requireTPCDSCases.contains(lowercaseTestCase)) {
468+
tpcDSTableNamesToSchemas.foreach { case (name: String, _: String) =>
469+
localSparkSession.sql(s"DROP TABLE IF EXISTS $name")
470+
}
471+
}
472+
473+
fail("Test is in debug mode. " +
474+
"Remove --DEBUG markers from the input file before committing.")
475+
}
476+
477+
protected def runQueries(
478+
queries: Seq[String],
479+
testCase: TestCase,
480+
sparkConfigSet: Seq[(String, String)]): Unit = {
481+
// Create a local SparkSession to have stronger isolation between different test cases.
482+
// This does not isolate catalog changes.
483+
val localSparkSession = setupLocalSession(testCase, sparkConfigSet)
484+
val lowercaseTestCase = testCase.name.toLowerCase(Locale.ROOT)
485+
340486
// Run the SQL queries preparing them for comparison.
341487
val outputs: Seq[QueryTestOutput] = queries.map { sql =>
342488
testCase match {

0 commit comments

Comments
 (0)