测试

查询引擎很复杂,很容易在不经意间引入可能导致查询返回不正确结果的细微错误,因此必须有严格的测试。

单元测试

一个很好的起点是为各个运算符和表达式编写单元测试,断言它们对给定的输入产生正确的输出。覆盖到错误的情况也是至关重要的。

下面是一些关于编写单元测试时需要考虑的问题的建议。

  • 如果使用了意外的数据类型会发生什么?例如,在输入的字符串上计算 SUM
  • 测试应该涵盖边缘情况,如对数字数据类型使用最小值和最大值,对浮点类型使用NaN(非数字),以确保它们被正确处理。
  • 需要针对下溢和上溢的场景进行测试。例如,当两个长(64位)整数类型相乘时会发生什么?
  • 测试还应该确保正确处理空值。

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

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))
    }
}

集成测试

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

对查询引擎的集成测试有几种流行的方法。

  • 强制性测试:硬编码的查询和预期结果,要么写成代码,要么存储为包含查询和结果的文件。
  • 比较测试:这种方法包括对另一个(受信任的)查询引擎执行查询,并断言两个查询引擎产生相同的结果。
  • 模糊测试:生成随机运算符和表达式树,以捕获边缘情况,并获得全面的测试覆盖。

模糊测试

查询引擎的大部分复杂性来自于这样一个事实:由于运算符和表达式树的嵌套性质,运算符和表达式可以通过无限的组合,手工编码测试查询不太可能足够全面。

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 的列。这个表达式几乎肯定是无效的(取决于查询引擎的实现),这在使用模糊引擎时是可以预期的。这仍然是有价值的,因为它将测试到一些错误条件,这在手动编写测试时不会被覆盖。

#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()
        }
    }
}

下面是这个代码产生的逻辑查询计划的示例。

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 表达式可以生成返回布尔结果的左表达式和右表达式。然而,只创建正确的计划是有危险的,因为它可能限制测试覆盖率。理想情况下,应该可以用产生具有不同特征的查询计划的规则来配置模糊引擎。

本书的电子版、MOBI和PDF格式也可从 https://leanpub.com/how-query-engines-work 购买。

Copyright © 2020-2022 Grove Enterprises, LLC。保留所有权利。