Testing 测试

查询引擎非常复杂,很容易在无意中引入细微的错误,从而导致查询返回不正确的结果,因此进行严格的测试非常重要。

Unit Testing 单元测试

最好是在第一步是为单个运算符和表达式编写单元测试,用断言来限定给定的输入产生正确的输出。当然错误情况的处理也很重要。

以下是一些在编写单元测试时需要考虑的建议:

  • 如果使用了意外的数据类型会发生什么?例如,对于字符串输入进行 SUM 求和
  • 测试应该涵盖极端情况,例如对数字数据类型使用最小值和最大值,对浮点类型使用 Nan (不是数字),以确保它们能够被正确处理
  • 也应该针对上下溢出的情况进行测试。例如,当两个 long (64位) 整数类型相乘时会发生什么?
  • 测试还应该确保 null 能够被正确处理

在编写这些测试时,重要的是能够使用任意数据构造记录批和列向量,以用作运算符和表达式的输入。下面是这种实用方法的一个示例:

private fun createRecordBatch(schema: Schema,
                              columns: List<List<Any?>>): RecordBatch {

    val rowCount = columns[0].size
    val root = VectorSchemaRoot.create(schema.toArrow(),
                                       RootAllocator(Long.MAX_VALUE))
    root.allocateNew()
    (0 until rowCount).forEach { row ->
        (0 until columns.size).forEach { col ->
            val v = root.getVector(col)
            val value = columns[col][row]
            when (v) {
                is Float4Vector -> v.set(row, value as Float)
                is Float8Vector -> v.set(row, value as Double)
                ...
            }
        }
    }
    root.rowCount = rowCount

    return RecordBatch(schema, root.fieldVectors.map { ArrowFieldVector(it) })
}

下面是针对包含双精度浮点值的两列记录批,计算 “大于等于” (>=) 表达式的单元测试示例。

@Test
fun `gteq doubles`() {

    val schema = Schema(listOf(
            Field("a", ArrowTypes.DoubleType),
            Field("b", ArrowTypes.DoubleType)
    ))

    val a: List<Double> = listOf(0.0, 1.0,
                                 Double.MIN_VALUE, Double.MAX_VALUE, Double.NaN)
    val b = a.reversed()

    val batch = createRecordBatch(schema, listOf(a,b))

    val expr = GtEqExpression(ColumnExpression(0), ColumnExpression(1))
    val result = expr.evaluate(batch)

    assertEquals(a.size, result.size())
    (0 until result.size()).forEach {
        assertEquals(if (a[it] >= b[it]) 1 else 0, result.getValue(it))
    }
}

Integration Testing 集成测试

单元测试就绪后,下一步是编写集成测试,执行多个运算符和表达式组成的查询,并断言它们按预期产生输出。

有几种流行的方法可以对查询引擎进行集成测试:

  • Imperative Testing 命令式测试: 硬编码查询和预期结果,要么写成代码,要么存储位包含查询和结果的文件。
  • Comparative Testing 比较测试: 此方法涉及对另一个(值得信赖的)查询引擎进行查询,并断言两个查询引擎都产生了相同的结果。
  • Fuzzing 模糊: 生成随机运算符和表达式树来捕获边缘特殊情况,并获得全面测试覆盖率。

Fuzzing 模糊

由于运算符和表达式树嵌套的特性,运算符和表达式可以无限组合在一起,查询引擎之所以如此复杂很大程度上都来源于这个事实,手工编码测试查询不可能面面俱到。

模糊测试是一种产生随机输入数据的计数。当应用于查询引擎时,这意味着创建随机查询计划。

下面是一个针对 DataFrame 创建随机表达式的示例。这事一种递归方法,可以产生具有深层嵌套结构的表达式树,因此以最大深度构建机制非常重要。

fun createExpression(input: DataFrame, depth: Int, maxDepth: Int): LogicalExpr {
    return if (depth == maxDepth) {
        // return a leaf node
        when (rand.nextInt(4)) {
            0 -> ColumnIndex(rand.nextInt(input.schema().fields.size))
            1 -> LiteralDouble(rand.nextDouble())
            2 -> LiteralLong(rand.nextLong())
            3 -> LiteralString(randomString(rand.nextInt(64)))
            else -> throw IllegalStateException()
        }
    } else {
        // binary expressions
        val l = createExpression(input, depth+1, maxDepth)
        val r = createExpression(input, depth+1, maxDepth)
        return when (rand.nextInt(8)) {
            0 -> Eq(l, r)
            1 -> Neq(l, r)
            2 -> Lt(l, r)
            3 -> LtEq(l, r)
            4 -> Gt(l, r)
            5 -> GtEq(l, r)
            6 -> And(l, r)
            7 -> Or(l, r)
            else -> throw IllegalStateException()
        }
    }
}

下面是使用此方法生成表达式的示例。请注意,列应用在这里用哈希之后的索引表示,例如 #1 表示索引为 1 的列。几乎可以肯定,此表达式无效(取决于查询引擎的实现),并且在使用 fuzzer 时可以预期到。但这仍然很有价值,因为它将测试错误条件,否则在手动编写测试的时候不一定能够涵盖这些。

#5 > 0.5459397414890019 < 0.3511239641785846 OR 0.9137719758607572 > -6938650321297559787 < #0 AND #3 < #4 AND 'qn0NN' OR '1gS46UuarGz2CdeYDJDEW3Go6ScMmRhA3NgPJWMpgZCcML1Ped8haRxOkM9F' >= -8765295514236902140 < 4303905842995563233 OR 'IAseGJesQMOI5OG4KrkitichlFduZGtjXoNkVQI0Alaf2ELUTTIci' = 0.857970478666058 >= 0.8618195163699196 <= '9jaFR2kDX88qrKCh2BSArLq517cR8u2' OR 0.28624225053564 <= 0.6363627130199404 > 0.19648131921514966 >= -567468767705106376 <= #0 AND 0.6582592932801918 = 'OtJ0ryPUeSJCcMnaLngBDBfIpJ9SbPb6hC5nWqeAP1rWbozfkPjcKdaelzc' >= #0 >= -2876541212976899342 = #4 >= -3694865812331663204 = 'gWkQLswcU' != #3 > 'XiXzKNrwrWnQmr3JYojCVuncW9YaeFc' >= 0.5123788261193981 >= #2

在创建逻辑计划的时候也可以采用类似的方法。

fun createPlan(input: DataFrame,
               depth: Int,
               maxDepth: Int,
               maxExprDepth: Int): DataFrame {

    return if (depth == maxDepth) {
        input
    } else {
        // recursively create an input plan
        val child = createPlan(input, depth+1, maxDepth, maxExprDepth)
        // apply a transformation to the plan
        when (rand.nextInt(2)) {
            0 -> {
                val exprCount = 1.rangeTo(rand.nextInt(1, 5))
                child.project(exprCount.map {
                    createExpression(child, 0, maxExprDepth)
                })
            }
            1 -> child.filter(createExpression(input, 0, maxExprDepth))
            else -> throw IllegalStateException()
        }
    }
}

下面是该diamagnetic生成的逻辑查询计划的示例:

Filter: 'VejBmVBpYp7gHxHIUB6UcGx' OR 0.7762591612853446
  Filter: 'vHGbOKKqR' <= 0.41876514212913307
    Filter: 0.9835090312561898 <= 3342229749483308391
      Filter: -5182478750208008322 < -8012833501302297790
        Filter: 0.3985688976088563 AND #1
          Filter: #5 OR 'WkaZ54spnoI4MBtFpQaQgk'
            Scan: employee.csv; projection=None

这种直接的模糊测试方法大概率将产生无效计划。可以通过添加更多上下文感知来对其加以改进,以减少创建无效逻辑计划和表达式的风险。例如,生成 AND 表达式可以生成左右表达式并产生布尔结果。然而,只创建正确的计划是有风险的,因为它可能会限制测试覆盖率。理想情况下,应该可以为 fuzzer 配置生成具有不同特征查询计划的规则。