diff --git a/lang/src/org/partiql/lang/SqlException.kt b/lang/src/org/partiql/lang/SqlException.kt index 2b8100b51a..096e11c5eb 100644 --- a/lang/src/org/partiql/lang/SqlException.kt +++ b/lang/src/org/partiql/lang/SqlException.kt @@ -34,12 +34,10 @@ import org.partiql.lang.util.propertyValueMapOf * * @param message the message for this exception * @param errorCode the error code for this exception - * @param errorContextArg context for this error, contains details like line & column number among other attributes. + * @param errorContext context for this error, includes details like line & character offsets, among others. + * @param internal True to indicate that this exception a bug in ParitQL itself. False to indicate it was caused by + * incorrect usage of APIs or invalid end-user queries. * @param cause for this exception - * - * @constructor a custom error [message], the [errorCode], error context as a [propertyValueMap] and optional [cause] creates an - * [SqlException]. This is the constructor for the second configuration explained above. - * */ open class SqlException( override var message: String, @@ -83,7 +81,7 @@ open class SqlException( * * * ErrorCategory is one of `Lexer Error`, `Parser Error`, `Runtime Error` * * ErrorLocation is the line and column where the error occurred - * * Errormessatge is the **generated** error message + * * ErrorMessage is the **generated** error message * * * TODO: Prepend to the auto-generated message the file name. @@ -92,6 +90,10 @@ open class SqlException( fun generateMessage(): String = "${errorCategory(errorCode)}: ${errorLocation(errorContext)}: ${errorMessage(errorCode, errorContext)}" + /** Same as [generateMessage] but without the location. */ + fun generateMessageNoLocation(): String = + "${errorCategory(errorCode)}: ${errorMessage(errorCode, errorContext)}" + private fun errorMessage(errorCode: ErrorCode?, propertyValueMap: PropertyValueMap?): String = errorCode?.getErrorMessage(propertyValueMap) ?: UNKNOWN diff --git a/lang/src/org/partiql/lang/planner/EvaluatorOptions.kt b/lang/src/org/partiql/lang/planner/EvaluatorOptions.kt new file mode 100644 index 0000000000..b4b88f9ed3 --- /dev/null +++ b/lang/src/org/partiql/lang/planner/EvaluatorOptions.kt @@ -0,0 +1,85 @@ +package org.partiql.lang.planner + +import org.partiql.lang.eval.ProjectionIterationBehavior +import org.partiql.lang.eval.ThunkOptions +import org.partiql.lang.eval.TypedOpBehavior +import org.partiql.lang.eval.TypingMode +import java.time.ZoneOffset + +/* + +Differences between CompilerOptions and PlannerOptions: + +- There is no EvaluatorOptions equivalent for CompileOptions.visitorTransformMode since the planner always runs some basic + normalization and variable resolution passes *before* the customer can inject their own transforms. +- There is no EvaluatorOptions equivalent for CompileOptions.thunkReturnTypeAssertions since PlannerPipeline does not +support the static type inferencer (yet). +- PlannerOptions.allowUndefinedVariables is new. +- PlannerOptions has no equivalent for CompileOptions.undefinedVariableBehavior -- this was added for backward +compatibility on behalf of a customer we don't have anymore. Internal bug number is IONSQL-134. + */ + +/** + * Specifies options that effect the behavior of the PartiQL physical plan evaluator. + * + * @param defaultTimezoneOffset Default timezone offset to be used when TIME WITH TIME ZONE does not explicitly + * specify the time zone. Defaults to [ZoneOffset.UTC]. + */ +@Suppress("DataClassPrivateConstructor") +data class EvaluatorOptions private constructor ( + val projectionIteration: ProjectionIterationBehavior = ProjectionIterationBehavior.FILTER_MISSING, + val thunkOptions: ThunkOptions = ThunkOptions.standard(), + val typingMode: TypingMode = TypingMode.LEGACY, + val typedOpBehavior: TypedOpBehavior = TypedOpBehavior.LEGACY, + val defaultTimezoneOffset: ZoneOffset = ZoneOffset.UTC +) { + companion object { + + /** + * Creates a java style builder that will choose the default values for any unspecified options. + */ + @JvmStatic + fun builder() = Builder() + + /** + * Creates a java style builder that will clone the [EvaluatorOptions] passed to the constructor. + */ + @JvmStatic + fun builder(options: EvaluatorOptions) = Builder(options) + + /** + * Kotlin style builder that will choose the default values for any unspecified options. + */ + fun build(block: Builder.() -> Unit) = Builder().apply(block).build() + + /** + * Kotlin style builder that will clone the [EvaluatorOptions] passed to the constructor. + */ + fun build(options: EvaluatorOptions, block: Builder.() -> Unit) = Builder(options).apply(block).build() + + /** + * Creates a [EvaluatorOptions] instance with the standard values for use by the legacy AST compiler. + */ + @JvmStatic + fun standard() = Builder().build() + } + + /** + * Builds a [EvaluatorOptions] instance. + */ + class Builder(private var options: EvaluatorOptions = EvaluatorOptions()) { + + fun projectionIteration(value: ProjectionIterationBehavior) = set { copy(projectionIteration = value) } + fun typingMode(value: TypingMode) = set { copy(typingMode = value) } + fun typedOpBehavior(value: TypedOpBehavior) = set { copy(typedOpBehavior = value) } + fun thunkOptions(value: ThunkOptions) = set { copy(thunkOptions = value) } + fun defaultTimezoneOffset(value: ZoneOffset) = set { copy(defaultTimezoneOffset = value) } + + private inline fun set(block: EvaluatorOptions.() -> EvaluatorOptions): Builder { + options = block(options) + return this + } + + fun build() = options + } +} diff --git a/lang/src/org/partiql/lang/planner/PlannerPipeline.kt b/lang/src/org/partiql/lang/planner/PlannerPipeline.kt new file mode 100644 index 0000000000..9bb64fbf49 --- /dev/null +++ b/lang/src/org/partiql/lang/planner/PlannerPipeline.kt @@ -0,0 +1,413 @@ +/* + * Copyright 2019 Amazon.com, Inc. or its affiliates. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at: + * + * http://aws.amazon.com/apache2.0/ + * + * or in the "license" file accompanying this file. This file 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.partiql.lang.planner + +import com.amazon.ion.IonSystem +import org.partiql.lang.SqlException +import org.partiql.lang.ast.SourceLocationMeta +import org.partiql.lang.domains.PartiqlAst +import org.partiql.lang.domains.PartiqlPhysical +import org.partiql.lang.errors.Problem +import org.partiql.lang.errors.ProblemCollector +import org.partiql.lang.errors.Property +import org.partiql.lang.eval.ExprFunction +import org.partiql.lang.eval.ExprValueFactory +import org.partiql.lang.eval.Expression +import org.partiql.lang.eval.ThunkReturnTypeAssertions +import org.partiql.lang.eval.builtins.createBuiltinFunctions +import org.partiql.lang.eval.builtins.storedprocedure.StoredProcedure +import org.partiql.lang.planner.transforms.PlanningProblemDetails +import org.partiql.lang.planner.transforms.normalize +import org.partiql.lang.planner.transforms.toLogicalPlan +import org.partiql.lang.planner.transforms.toPhysicalPlan +import org.partiql.lang.planner.transforms.toResolvedPlan +import org.partiql.lang.syntax.Parser +import org.partiql.lang.syntax.SqlParser +import org.partiql.lang.syntax.SyntaxException +import org.partiql.lang.types.CustomType + +/* +Differences between CompilerPipeline and PlannerPipeline: + +- CompilerPipeline's ProcessingSteps work only on AST. PlannerPipeline does not depend on the AST at all. The intent +is to free customers from the need to manipulate or even be aware of the AST to the extent possible. PlannerPipeline +will eventually only support working on resolved logical plans and later, but not yet. +- PlannerPipeline does not yet to work with static types. + +Why not add an option to enable the planner to be used with CompilerPipeline? Some fundamental differences: +- global bindings are (eventually) a GlobalBindings instance instead of a Bindings (globalTypeBindings). + - (ResolutionResult, returned from GlobalBindings, likely will eventually include a StaticType.) +- there is more complexity than is needed (i.e. AST processing steps) +*/ + +/** + * [PlannerPipeline] is the main interface for planning and compiling PartiQL queries into instances of [Expression] + * which can be executed. + * + * This class was originally derived from [org.partiql.lang.CompilerPipeline], which is the main compiler entry point + * for the legacy AST compiler. The main difference is that the logical and physical plans have taken the place of + * PartiQL's AST, and that after parsing several passes over the AST are performed: + * + * - It is transformed into a logical plan + * - Variables are resolved. + * - It is converted into a physical plan. + * + * In the future additional passes will exist to include optimizations like predicate & projection push-down, and + * will be extensible by customers who can include their own optimizations. + * + * Two basic scenarios for using this interface: + * + * 1. You want to plan and compile a query once, but don't care about re-using the plan across process instances. In + * this scenario, simply use the [planAndCompile] function to obtain an instance of [Expression] that can be + * invoked directly to evaluate a query. + * 2. You want to plan a query once and share a planned query across process boundaries. In this scenario, use + * [plan] to perform query planning and obtain an instance of [PartiqlPhysical.Statement] which can be serialized to + * Ion text or binary format. On the other side of the process boundary, use [compile] to turn the + * [PartiqlPhysical.Statement] query plan into an [Expression]. Compilation itself should be relatively fast. + * + * The provided builder companion creates an instance of [PlannerPipeline] that is NOT thread safe and should NOT be + * used to compile queries concurrently. If used in a multithreaded application, use one instance of [PlannerPipeline] + * per thread. + */ +interface PlannerPipeline { + val valueFactory: ExprValueFactory + + /** + * Plans a query only but does not compile it. + * + * - Parses the specified SQL string, producing an AST. + * - Converts the AST to a logical plan. + * - Resolves all global and local variables in the logical plan, assigning unique indexes to local variables + * and calling [GlobalBindings.resolve] of [globalBindings] to obtain PartiQL-service specific unique identifiers + * of global values such as tables, and optionally converts undefined variables to dynamic lookups. + * - Converts the AST to a physical plan with `(impl default)` operators. + * + * @param query The text of the SQL statement or expression to be planned. + * @return [PassResult.Success] containing an instance of [PartiqlPhysical.Statement] and any applicable warnings + * if planning was successful or [PassResult.Error] if not. + */ + fun plan(query: String): PassResult + + /** + * Compiles the previously planned [PartiqlPhysical.Statement] instance. + * + * @param physcialPlan The physical query plan. + * @return [PassResult.Success] containing an instance of [PartiqlPhysical.Statement] and any applicable warnings + * if compilation was successful or [PassResult.Error] if not. + */ + fun compile(physcialPlan: PartiqlPhysical.Plan): PassResult + + /** + * Plans and compiles a query. + * + * @param query The text of the SQL statement or expression to be planned and compiled. + * @return [PassResult.Success] containing an instance of [PartiqlPhysical.Statement] and any applicable warnings + * if compiling and planning was successful or [PassResult.Error] if not. + */ + fun planAndCompile(query: String): PassResult = + when (val planResult = plan(query)) { + is PassResult.Error -> PassResult.Error(planResult.errors) + is PassResult.Success -> { + when (val compileResult = compile(planResult.result)) { + is PassResult.Error -> compileResult + is PassResult.Success -> PassResult.Success( + compileResult.result, + // Need to include any warnings that may have been discovered during planning. + planResult.warnings + compileResult.warnings + ) + } + } + } + + @Suppress("DeprecatedCallableAddReplaceWith", "DEPRECATION") + companion object { + private const val WARNING = "WARNING: PlannerPipeline is EXPERIMENTAL and has incomplete language support! " + + "For production use, see org.partiql.lang.CompilerPipeline which is stable and supports all PartiQL " + + "features." + + /** Kotlin style builder for [PlannerPipeline]. If calling from Java instead use [builder]. */ + @Deprecated(WARNING) + fun build(ion: IonSystem, block: Builder.() -> Unit) = build(ExprValueFactory.standard(ion), block) + + /** Kotlin style builder for [PlannerPipeline]. If calling from Java instead use [builder]. */ + @Deprecated(WARNING) + fun build(valueFactory: ExprValueFactory, block: Builder.() -> Unit) = Builder(valueFactory).apply(block).build() + + /** Fluent style builder. If calling from Kotlin instead use the [build] method. */ + @JvmStatic + @Deprecated(WARNING) + fun builder(ion: IonSystem): Builder = builder(ExprValueFactory.standard(ion)) + + /** Fluent style builder. If calling from Kotlin instead use the [build] method. */ + @JvmStatic + @Deprecated(WARNING) + fun builder(valueFactory: ExprValueFactory): Builder = Builder(valueFactory) + + /** Returns an implementation of [PlannerPipeline] with all properties set to their defaults. */ + @JvmStatic + @Deprecated(WARNING) + fun standard(ion: IonSystem): PlannerPipeline = standard(ExprValueFactory.standard(ion)) + + /** Returns an implementation of [PlannerPipeline] with all properties set to their defaults. */ + @JvmStatic + @Deprecated(WARNING) + fun standard(valueFactory: ExprValueFactory): PlannerPipeline = builder(valueFactory).build() + } + + /** + * An implementation of the builder pattern for instances of [PlannerPipeline]. The created instance of + * [PlannerPipeline] is NOT thread safe and should NOT be used to compile queries concurrently. If used in a + * multithreaded application, use one instance of [PlannerPipeline] per thread. + */ + class Builder(val valueFactory: ExprValueFactory) { + private var parser: Parser? = null + private var evaluatorOptions: EvaluatorOptions? = null + private val customFunctions: MutableMap = HashMap() + private var customDataTypes: List = listOf() + private val customProcedures: MutableMap = HashMap() + private var globalBindings: GlobalBindings = emptyGlobalBindings() + private var allowUndefinedVariables: Boolean = false + private var enableLegacyExceptionHandling: Boolean = false + + /** + * Specifies the [Parser] to be used to turn an PartiQL query into an instance of [PartiqlAst]. + * The default is [SqlParser]. + */ + fun sqlParser(p: Parser): Builder = this.apply { + parser = p + } + + /** + * Options affecting evaluation-time behavior. The default is [EvaluatorOptions.standard]. + */ + fun evaluatorOptions(options: EvaluatorOptions): Builder = this.apply { + evaluatorOptions = options + } + + /** + * A nested builder for compilation options. The default is [EvaluatorOptions.standard]. + * + * Avoid the use of this overload if calling from Java and instead use the overload accepting an instance + * of [EvaluatorOptions]. + * + * There is no need to call [Builder.build] when using this method. + */ + fun evaluatorOptions(block: EvaluatorOptions.Builder.() -> Unit): Builder = + evaluatorOptions(EvaluatorOptions.build(block)) + + /** + * Add a custom function which will be callable by the compiled queries. + * + * Functions added here will replace any built-in function with the same name. + */ + fun addFunction(function: ExprFunction): Builder = this.apply { + customFunctions[function.signature.name] = function + } + + /** + * Add custom types to CAST/IS operators to. + * + * Built-in types will take precedence over custom types in case of a name collision. + */ + fun customDataTypes(customTypes: List) = this.apply { + customDataTypes = customTypes + } + + /** + * Add a custom stored procedure which will be callable by the compiled queries. + * + * Stored procedures added here will replace any built-in procedure with the same name. + */ + fun addProcedure(procedure: StoredProcedure): Builder = this.apply { + customProcedures[procedure.signature.name] = procedure + } + + /** + * Adds the [GlobalBindings] for global variables. + * + * [globalBindings] is queried during query planning to fetch database schema information. + */ + fun globalBindings(bindings: GlobalBindings): Builder = this.apply { + this.globalBindings = bindings + } + + /** + * Sets a flag indicating if undefined variables are allowed. + * + * When allowed, undefined variables are rewritten to dynamic lookups. This is intended to provide a migration + * path for legacy PartiQL customers who depend on dynamic lookup of undefined variables to use the query + * planner & phys. algebra. New customers should not enable this. + */ + fun allowUndefinedVariables(allow: Boolean = true): Builder = this.apply { + this.allowUndefinedVariables = allow + } + + /** + * Prevents [SqlException] that occur during compilation from being converted into [Problem]s. + * + * This is for compatibility with the legacy unit test suite, which hasn't been updated to handle + * [Problem]s yet. + */ + internal fun enableLegacyExceptionHandling(): Builder = this.apply { + enableLegacyExceptionHandling = true + } + + /** Builds the actual implementation of [PlannerPipeline]. */ + fun build(): PlannerPipeline { + val compileOptionsToUse = evaluatorOptions ?: EvaluatorOptions.standard() + + when (compileOptionsToUse.thunkOptions.thunkReturnTypeAssertions) { + ThunkReturnTypeAssertions.DISABLED -> { /* intentionally blank */ } + ThunkReturnTypeAssertions.ENABLED -> error( + "TODO: Support ThunkReturnTypeAssertions.ENABLED " + + "need a static type pass first)" + ) + } + + val builtinFunctions = createBuiltinFunctions(valueFactory) + // TODO: uncomment when DynamicLookupExprFunction exists +// val builtinFunctions = createBuiltinFunctions(valueFactory) + DynamicLookupExprFunction() + val builtinFunctionsMap = builtinFunctions.associateBy { + it.signature.name + } + + // customFunctions must be on the right side of + here to ensure that they overwrite any + // built-in functions with the same name. + val allFunctionsMap = builtinFunctionsMap + customFunctions + return PlannerPipelineImpl( + valueFactory = valueFactory, + parser = parser ?: SqlParser(valueFactory.ion, this.customDataTypes), + evaluatorOptions = compileOptionsToUse, + functions = allFunctionsMap, + customDataTypes = customDataTypes, + procedures = customProcedures, + globalBindings = globalBindings, + allowUndefinedVariables = allowUndefinedVariables, + enableLegacyExceptionHandling = enableLegacyExceptionHandling + ) + } + } +} + +internal class PlannerPipelineImpl( + override val valueFactory: ExprValueFactory, + private val parser: Parser, + val evaluatorOptions: EvaluatorOptions, + val functions: Map, + val customDataTypes: List, + val procedures: Map, + val globalBindings: GlobalBindings, + val allowUndefinedVariables: Boolean, + val enableLegacyExceptionHandling: Boolean +) : PlannerPipeline { + + init { + when (evaluatorOptions.thunkOptions.thunkReturnTypeAssertions) { + ThunkReturnTypeAssertions.DISABLED -> { + /** intentionally blank. */ + } + ThunkReturnTypeAssertions.ENABLED -> + // Need a type inferencer pass on resolved logical algebra to support this. + TODO("Support for EvaluatorOptions.thunkReturnTypeAsserts == ThunkReturnTypeAssertions.ENABLED") + } + } + + val customTypedOpParameters = customDataTypes.map { customType -> + (customType.aliases + customType.name).map { alias -> + Pair(alias.toLowerCase(), customType.typedOpParameter) + } + }.flatten().toMap() + + override fun plan(query: String): PassResult { + val ast = try { + parser.parseAstStatement(query) + } catch (ex: SyntaxException) { + val problem = Problem( + SourceLocationMeta( + ex.errorContext[Property.LINE_NUMBER]?.longValue() ?: -1, + ex.errorContext[Property.COLUMN_NUMBER]?.longValue() ?: -1 + ), + PlanningProblemDetails.ParseError(ex.generateMessageNoLocation()) + ) + return PassResult.Error(listOf(problem)) + } + // Now run the AST thru each pass until we arrive at the physical algebra. + + // Normalization--synthesizes any unspecified `AS` aliases, converts `SELECT *` to `SELECT f.*[, ...]` ... + val normalizedAst = ast.normalize() + + // ast -> logical plan + val logicalPlan = normalizedAst.toLogicalPlan() + + // logical plan -> resolved logical plan + val problemHandler = ProblemCollector() + val resolvedLogicalPlan = logicalPlan.toResolvedPlan(problemHandler, globalBindings, allowUndefinedVariables) + // If there are unresolved variables after attempting to resolve variables, then we can't proceed. + if (problemHandler.hasErrors) { + return PassResult.Error(problemHandler.problems) + } + + // Possible future passes: + // - type checking and inferencing? + // - constant folding + // - common sub-expression removal + // - push down predicates & projections on top of their scan nodes. + // - customer supplied rewrites of resolved logical plan. + + // resolved logical plan -> physical plan. + // this will give all relational operators `(impl default)`. + val physicalPlan = resolvedLogicalPlan.toPhysicalPlan() + + // Future work: invoke passes to choose relational operator implementations other than `(impl default)`. + // Future work: fully push down predicates and projections into their physical read operators. + // Future work: customer supplied rewrites of phsyical plan + + // If we reach this far, we're successful. If there were any problems at all, they were just warnings. + return PassResult.Success(physicalPlan, problemHandler.problems) + } + + override fun compile(physcialPlan: PartiqlPhysical.Plan): PassResult { + TODO("uncomment the code below in the PR introducing the plan evaluator") +// val compiler = PhysicalExprToThunkConverterImpl( +// valueFactory = valueFactory, +// functions = functions, +// customTypedOpParameters = customTypedOpParameters, +// procedures = procedures, +// evaluatorOptions = evaluatorOptions +// ) +// +// val expression = when { +// enableLegacyExceptionHandling -> compiler.compile(physcialPlan) +// else -> { +// // Legacy exception handling is disabled, convert any [SqlException] into a Problem and return +// // PassResult.Error. +// try { +// compiler.compile(physcialPlan) +// } catch (e: SqlException) { +// val problem = Problem( +// SourceLocationMeta( +// e.errorContext[Property.LINE_NUMBER]?.longValue() ?: -1, +// e.errorContext[Property.COLUMN_NUMBER]?.longValue() ?: -1 +// ), +// PlanningProblemDetails.CompileError(e.generateMessageNoLocation()) +// ) +// return PassResult.Error(listOf(problem)) +// } +// } +// } +// +// return PassResult.Success(expression, listOf()) + } +} diff --git a/lang/test/org/partiql/lang/planner/PlannerPipelineSmokeTests.kt b/lang/test/org/partiql/lang/planner/PlannerPipelineSmokeTests.kt new file mode 100644 index 0000000000..153d952267 --- /dev/null +++ b/lang/test/org/partiql/lang/planner/PlannerPipelineSmokeTests.kt @@ -0,0 +1,81 @@ +package org.partiql.lang.planner + +import com.amazon.ion.system.IonSystemBuilder +import com.amazon.ionelement.api.ionInt +import com.amazon.ionelement.api.ionString +import com.amazon.ionelement.api.toIonValue +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.partiql.lang.ION +import org.partiql.lang.domains.PartiqlPhysical +import org.partiql.lang.planner.transforms.PLAN_VERSION_NUMBER +import org.partiql.lang.planner.transforms.PlanningProblemDetails +import org.partiql.lang.util.SexpAstPrettyPrinter + +/** + * Query planning primarily consists of AST traversals and rewrites. Each of those are thoroughly tested separately, + * but it is still good to have a simple "smoke test" for the planner pipeline. + */ +class PlannerPipelineSmokeTests { + private val ion = IonSystemBuilder.standard().build() + + @Suppress("DEPRECATION") + private fun createPlannerPipelineForTest(allowUndefinedVariables: Boolean) = PlannerPipeline.build(ion) { + allowUndefinedVariables(allowUndefinedVariables) + globalBindings(createFakeGlobalBindings("Customer" to "fake_uid_for_Customer")) + } + + @Test + fun `happy path`() { + val pipeline = createPlannerPipelineForTest(allowUndefinedVariables = true) + val result = pipeline.plan("SELECT c.* FROM Customer AS c WHERE c.primaryKey = 42") + + result as PassResult.Success + println(SexpAstPrettyPrinter.format(result.result.toIonElement().asAnyElement().toIonValue(ION))) + + assertEquals( + result, + PassResult.Success( + result = PartiqlPhysical.build { + plan( + stmt = query( + bindingsToValues( + exp = struct(structFields(localId(0))), + query = filter( + i = impl("default"), + predicate = eq( + operands0 = path( + localId(0), + pathExpr(lit(ionString("primaryKey")), caseInsensitive()) + ), + operands1 = lit(ionInt(42)) + ), + source = scan( + i = impl("default"), + expr = globalId("Customer", "fake_uid_for_Customer"), + asDecl = varDecl(0) + ) + ) + ) + ), + locals = listOf(localVariable("c", 0)), + version = PLAN_VERSION_NUMBER.toLong() + ) + }, + warnings = emptyList() + ) + ) + } + + @Test + fun `undefined variable`() { + val qp = createPlannerPipelineForTest(allowUndefinedVariables = false) + val result = qp.plan("SELECT undefined.* FROM Customer AS c") + assertEquals( + PassResult.Error( + listOf(problem(1, 8, PlanningProblemDetails.UndefinedVariable("undefined", caseSensitive = false))) + ), + result + ) + } +}