Skip to content

Commit b9f8a11

Browse files
petermaxleecloud-fan
authored andcommitted
[SPARK-16866][SQL] Infrastructure for file-based SQL end-to-end tests
## What changes were proposed in this pull request? This patch introduces SQLQueryTestSuite, a basic framework for end-to-end SQL test cases defined in spark/sql/core/src/test/resources/sql-tests. This is a more standard way to test SQL queries end-to-end in different open source database systems, because it is more manageable to work with files. This is inspired by HiveCompatibilitySuite, but simplified for general Spark SQL tests. Once this is merged, I can work towards porting SQLQuerySuite over, and eventually also move the existing HiveCompatibilitySuite to use this framework. Unlike HiveCompatibilitySuite, SQLQueryTestSuite compares both the output schema and the output data (in string form). When there is a mismatch, the error message looks like the following: ``` [info] - blacklist.sql !!! IGNORED !!! [info] - number-format.sql *** FAILED *** (2 seconds, 405 milliseconds) [info] Expected "...147483648 -214748364[8]", but got "...147483648 -214748364[9]" Result should match for query #1 (SQLQueryTestSuite.scala:171) [info] org.scalatest.exceptions.TestFailedException: [info] at org.scalatest.Assertions$class.newAssertionFailedException(Assertions.scala:495) [info] at org.scalatest.FunSuite.newAssertionFailedException(FunSuite.scala:1555) [info] at org.scalatest.Assertions$class.assertResult(Assertions.scala:1171) ``` ## How was this patch tested? This is a test infrastructure change. Author: petermaxlee <[email protected]> Closes apache#14472 from petermaxlee/SPARK-16866.
1 parent 0578ff9 commit b9f8a11

File tree

6 files changed

+267
-38
lines changed

6 files changed

+267
-38
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- This is a query file that has been blacklisted.
2+
-- It includes a query that should crash Spark.
3+
-- If the test case is run, the whole suite would fail.
4+
some random not working query that should crash Spark.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
-- Verifies how we parse numbers
2+
3+
-- parse as ints
4+
select 1, -1;
5+
6+
-- parse as longs
7+
select 2147483648, -2147483649;
8+
9+
-- parse as decimals
10+
select 9223372036854775808, -9223372036854775809;
11+
12+
-- various floating point (decimal) formats
13+
select 0.3, -0.8, .5, -.18, 0.1111;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
-- Automatically generated by org.apache.spark.sql.SQLQueryTestSuite
2+
-- Number of queries: 4
3+
4+
5+
-- !query 0
6+
select 1, -1
7+
-- !query 0 schema
8+
struct<1:int,(-1):int>
9+
-- !query 0 output
10+
1 -1
11+
12+
13+
-- !query 1
14+
select 2147483648, -2147483649
15+
-- !query 1 schema
16+
struct<2147483648:bigint,(-2147483649):bigint>
17+
-- !query 1 output
18+
2147483648 -2147483649
19+
20+
21+
-- !query 2
22+
select 9223372036854775808, -9223372036854775809
23+
-- !query 2 schema
24+
struct<9223372036854775808:decimal(19,0),(-9223372036854775809):decimal(19,0)>
25+
-- !query 2 output
26+
9223372036854775808 -9223372036854775809
27+
28+
29+
-- !query 3
30+
select 0.3, -0.8, .5, -.18, 0.1111
31+
-- !query 3 schema
32+
struct<0.3:decimal(1,1),(-0.8):decimal(1,1),0.5:decimal(1,1),(-0.18):decimal(2,2),0.1111:decimal(4,4)>
33+
-- !query 3 output
34+
0.3 -0.8 0.5 -0.18 0.1111

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

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1368,42 +1368,6 @@ class SQLQuerySuite extends QueryTest with SharedSQLContext {
13681368
)
13691369
}
13701370

1371-
test("Floating point number format") {
1372-
checkAnswer(
1373-
sql("SELECT 0.3"), Row(BigDecimal(0.3))
1374-
)
1375-
1376-
checkAnswer(
1377-
sql("SELECT -0.8"), Row(BigDecimal(-0.8))
1378-
)
1379-
1380-
checkAnswer(
1381-
sql("SELECT .5"), Row(BigDecimal(0.5))
1382-
)
1383-
1384-
checkAnswer(
1385-
sql("SELECT -.18"), Row(BigDecimal(-0.18))
1386-
)
1387-
}
1388-
1389-
test("Auto cast integer type") {
1390-
checkAnswer(
1391-
sql(s"SELECT ${Int.MaxValue + 1L}"), Row(Int.MaxValue + 1L)
1392-
)
1393-
1394-
checkAnswer(
1395-
sql(s"SELECT ${Int.MinValue - 1L}"), Row(Int.MinValue - 1L)
1396-
)
1397-
1398-
checkAnswer(
1399-
sql("SELECT 9223372036854775808"), Row(new java.math.BigDecimal("9223372036854775808"))
1400-
)
1401-
1402-
checkAnswer(
1403-
sql("SELECT -9223372036854775809"), Row(new java.math.BigDecimal("-9223372036854775809"))
1404-
)
1405-
}
1406-
14071371
test("Test to check we can apply sign to expression") {
14081372

14091373
checkAnswer(
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package org.apache.spark.sql
19+
20+
import java.io.File
21+
import java.util.{Locale, TimeZone}
22+
23+
import org.apache.spark.sql.catalyst.rules.RuleExecutor
24+
import org.apache.spark.sql.catalyst.util.{fileToString, stringToFile}
25+
import org.apache.spark.sql.test.SharedSQLContext
26+
27+
/**
28+
* End-to-end test cases for SQL queries.
29+
*
30+
* Each case is loaded from a file in "spark/sql/core/src/test/resources/sql-tests/inputs".
31+
* Each case has a golden result file in "spark/sql/core/src/test/resources/sql-tests/results".
32+
*
33+
* To re-generate golden files, run:
34+
* {{{
35+
* SPARK_GENERATE_GOLDEN_FILES=1 build/sbt "sql/test-only *SQLQueryTestSuite"
36+
* }}}
37+
*
38+
* The format for input files is simple:
39+
* 1. A list of SQL queries separated by semicolon.
40+
* 2. Lines starting with -- are treated as comments and ignored.
41+
*
42+
* For example:
43+
* {{{
44+
* -- this is a comment
45+
* select 1, -1;
46+
* select current_date;
47+
* }}}
48+
*
49+
* The format for golden result files look roughly like:
50+
* {{{
51+
* -- some header information
52+
*
53+
* -- !query 0
54+
* select 1, -1
55+
* -- !query 0 schema
56+
* struct<...schema...>
57+
* -- !query 0 output
58+
* ... data row 1 ...
59+
* ... data row 2 ...
60+
* ...
61+
*
62+
* -- !query 1
63+
* ...
64+
* }}}
65+
*/
66+
class SQLQueryTestSuite extends QueryTest with SharedSQLContext {
67+
68+
private val regenerateGoldenFiles: Boolean = System.getenv("SPARK_GENERATE_GOLDEN_FILES") == "1"
69+
70+
private val baseResourcePath = {
71+
// If regenerateGoldenFiles is true, we must be running this in SBT and we use hard-coded
72+
// relative path. Otherwise, we use classloader's getResource to find the location.
73+
if (regenerateGoldenFiles) {
74+
java.nio.file.Paths.get("src", "test", "resources", "sql-tests").toFile
75+
} else {
76+
val res = getClass.getClassLoader.getResource("sql-tests")
77+
new File(res.getFile)
78+
}
79+
}
80+
81+
private val inputFilePath = new File(baseResourcePath, "inputs").getAbsolutePath
82+
private val goldenFilePath = new File(baseResourcePath, "results").getAbsolutePath
83+
84+
/** List of test cases to ignore, in lower cases. */
85+
private val blackList = Set(
86+
"blacklist.sql" // Do NOT remove this one. It is here to test the blacklist functionality.
87+
)
88+
89+
// Create all the test cases.
90+
listTestCases().foreach(createScalaTestCase)
91+
92+
/** A test case. */
93+
private case class TestCase(name: String, inputFile: String, resultFile: String)
94+
95+
/** A single SQL query's output. */
96+
private case class QueryOutput(sql: String, schema: String, output: String) {
97+
def toString(queryIndex: Int): String = {
98+
// We are explicitly not using multi-line string due to stripMargin removing "|" in output.
99+
s"-- !query $queryIndex\n" +
100+
sql + "\n" +
101+
s"-- !query $queryIndex schema\n" +
102+
schema + "\n" +
103+
s"-- !query $queryIndex output\n" +
104+
output
105+
}
106+
}
107+
108+
private def createScalaTestCase(testCase: TestCase): Unit = {
109+
if (blackList.contains(testCase.name.toLowerCase)) {
110+
// Create a test case to ignore this case.
111+
ignore(testCase.name) { /* Do nothing */ }
112+
} else {
113+
// Create a test case to run this case.
114+
test(testCase.name) { runTest(testCase) }
115+
}
116+
}
117+
118+
/** Run a test case. */
119+
private def runTest(testCase: TestCase): Unit = {
120+
val input = fileToString(new File(testCase.inputFile))
121+
122+
// List of SQL queries to run
123+
val queries: Seq[String] = {
124+
val cleaned = input.split("\n").filterNot(_.startsWith("--")).mkString("\n")
125+
// note: this is not a robust way to split queries using semicolon, but works for now.
126+
cleaned.split("(?<=[^\\\\]);").map(_.trim).filter(_ != "").toSeq
127+
}
128+
129+
// Run the SQL queries preparing them for comparison.
130+
val outputs: Seq[QueryOutput] = queries.map { sql =>
131+
val df = spark.sql(sql)
132+
// We might need to do some query canonicalization in the future.
133+
QueryOutput(
134+
sql = sql,
135+
schema = df.schema.catalogString,
136+
output = df.queryExecution.hiveResultString().mkString("\n"))
137+
}
138+
139+
if (regenerateGoldenFiles) {
140+
// Again, we are explicitly not using multi-line string due to stripMargin removing "|".
141+
val goldenOutput = {
142+
s"-- Automatically generated by ${getClass.getName}\n" +
143+
s"-- Number of queries: ${outputs.size}\n\n\n" +
144+
outputs.zipWithIndex.map{case (qr, i) => qr.toString(i)}.mkString("\n\n\n") + "\n"
145+
}
146+
stringToFile(new File(testCase.resultFile), goldenOutput)
147+
}
148+
149+
// Read back the golden file.
150+
val expectedOutputs: Seq[QueryOutput] = {
151+
val goldenOutput = fileToString(new File(testCase.resultFile))
152+
val segments = goldenOutput.split("-- !query.+\n")
153+
154+
// each query has 3 segments, plus the header
155+
assert(segments.size == outputs.size * 3 + 1,
156+
s"Expected ${outputs.size * 3 + 1} blocks in result file but got ${segments.size}. " +
157+
s"Try regenerate the result files.")
158+
Seq.tabulate(outputs.size) { i =>
159+
QueryOutput(
160+
sql = segments(i * 3 + 1).trim,
161+
schema = segments(i * 3 + 2).trim,
162+
output = segments(i * 3 + 3).trim
163+
)
164+
}
165+
}
166+
167+
// Compare results.
168+
assertResult(expectedOutputs.size, s"Number of queries should be ${expectedOutputs.size}") {
169+
outputs.size
170+
}
171+
172+
outputs.zip(expectedOutputs).zipWithIndex.foreach { case ((output, expected), i) =>
173+
assertResult(expected.sql, s"SQL query should match for query #$i") { output.sql }
174+
assertResult(expected.schema, s"Schema should match for query #$i") { output.schema }
175+
assertResult(expected.output, s"Result should match for query #$i") { output.output }
176+
}
177+
}
178+
179+
private def listTestCases(): Seq[TestCase] = {
180+
listFilesRecursively(new File(inputFilePath)).map { file =>
181+
val resultFile = file.getAbsolutePath.replace(inputFilePath, goldenFilePath) + ".out"
182+
TestCase(file.getName, file.getAbsolutePath, resultFile)
183+
}
184+
}
185+
186+
/** Returns all the files (not directories) in a directory, recursively. */
187+
private def listFilesRecursively(path: File): Seq[File] = {
188+
val (dirs, files) = path.listFiles().partition(_.isDirectory)
189+
files ++ dirs.flatMap(listFilesRecursively)
190+
}
191+
192+
private val originalTimeZone = TimeZone.getDefault
193+
private val originalLocale = Locale.getDefault
194+
195+
override def beforeAll(): Unit = {
196+
super.beforeAll()
197+
// Timezone is fixed to America/Los_Angeles for those timezone sensitive tests (timestamp_*)
198+
TimeZone.setDefault(TimeZone.getTimeZone("America/Los_Angeles"))
199+
// Add Locale setting
200+
Locale.setDefault(Locale.US)
201+
RuleExecutor.resetTime()
202+
}
203+
204+
override def afterAll(): Unit = {
205+
try {
206+
TimeZone.setDefault(originalTimeZone)
207+
Locale.setDefault(originalLocale)
208+
209+
// For debugging dump some statistics about how much time was spent in various optimizer rules
210+
logWarning(RuleExecutor.dumpTimeSpent())
211+
} finally {
212+
super.afterAll()
213+
}
214+
}
215+
}

sql/hive/src/test/scala/org/apache/spark/sql/catalyst/LogicalPlanToSQLSuite.scala

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,7 @@ class LogicalPlanToSQLSuite extends SQLBuilderTest with SQLTestUtils {
4141
import testImplicits._
4242

4343
// Used for generating new query answer files by saving
44-
private val regenerateGoldenFiles: Boolean =
45-
Option(System.getenv("SPARK_GENERATE_GOLDEN_FILES")) == Some("1")
44+
private val regenerateGoldenFiles: Boolean = System.getenv("SPARK_GENERATE_GOLDEN_FILES") == "1"
4645
private val goldenSQLPath = "src/test/resources/sqlgen/"
4746

4847
protected override def beforeAll(): Unit = {

0 commit comments

Comments
 (0)