feat(engine): extend CollationEquality with resolveCollation

This commit is contained in:
dualfroz committed 2026-09-01 17:04:09 +02:00
1 parent 71d994698a
commit e42f3ed09b
2 files changed
+175 -5

No files matched your search

@@ -0,0 +1,94 @@
package kestrel.engine
import kestrel.catalog.TableSchema
import kestrel.sql.ast.CollateExpression
import kestrel.sql.ast.ColumnExpression
import kestrel.sql.ast.Expression
import kestrel.storage.Collation
import kestrel.storage.Value
/**
* Resolves which [Collation] governs equality/ordering for [expression]'s own value, with the same
* two-tier precedence [compareOrderKeyValues] already uses for `ORDER BY`: an explicit `COLLATE` on
* [expression] itself wins; failing that, a bare [ColumnExpression]'s own declared `COLLATE` (see
* [kestrel.catalog.Column.collation]), reported by [declaredCollationOf]; [Collation.Binary]
* otherwise. Shared by [compareOrderKeyValues] and every equality-based dedup below so both sides of
* the COLLATE story (ordering and equality) agree on which collation a given expression uses.
*/
fun resolveCollation(expression: Expression, declaredCollationOf: (ColumnExpression) -> Collation?): Collation =
(expression as? CollateExpression)?.collation
?: (expression as? ColumnExpression)?.let(declaredCollationOf)
?: Collation.Binary
/**
* Folds [values] (one grouping/`DISTINCT` key, whose entries line up positionally with
* [expressions]) into a key usable as a [Map]/[Set] bucket or a `distinctBy` selector: a
* [Value.TextValue] under a collation resolved (via [resolveCollation]) to anything but
* [Collation.Binary] is rewritten through [Collation.canonicalize] first, so e.g. `'Bob'` and
* `'bob'` land in the same bucket under a `NOCASE`-declared column, while every other value keeps
* its own `equals`/`hashCode` - a call site with no declared collation anywhere behaves exactly as
* it did before this function existed, since [Collation.Binary]'s [Collation.canonicalize] is the
* identity.
*/
fun collationFoldedKey(
values: List<Value>,
expressions: List<Expression>,
declaredCollationOf: (ColumnExpression) -> Collation?
): List<Value> = values.mapIndexed { index, value ->
if (value !is Value.TextValue) value
else Value.TextValue(resolveCollation(expressions[index], declaredCollationOf).canonicalize(value.value))
}
/**
* [collationFoldedKey]'s counterpart for a PRIMARY KEY / `UNIQUE` constraint / `UNIQUE` index
* uniqueness check, where the "expression" naming each key column is always just that column
* itself - a bare index into [schema], not an arbitrary [Expression] tree - so the collation to
* apply is always [kestrel.catalog.Column.collation] directly rather than something resolved
* through [resolveCollation]'s explicit-`COLLATE`-wins rule (a `PRIMARY KEY`/`UNIQUE` column list
* has no expressions to attach an explicit `COLLATE` to in the first place). Used by
* [kestrel.engine.Executor]'s in-memory `enforcePrimaryKeyUniqueness`/`enforceUniqueConstraints`/
* `enforceUniqueIndexes`, [kestrel.store.sql.DiskTable]'s heap-scanned
* `rejectDuplicateUniqueConstraints`/`backfillSecondaryIndex`, and (via
* [kestrel.store.sql.DiskTable]'s `encodePrimaryKey`/`compositeKey`/`equalRangeFor`) the disk
* engine's own B+Tree-backed PRIMARY KEY and `CREATE UNIQUE INDEX` key encoding itself - folding
* only ever changes what bytes a [kestrel.store.codec.KeyCodec] index key encodes to, never the
* row values a [kestrel.store.heap.RecordId] resolves to, so it is safe for lookup/ordering
* without corrupting any stored row.
*/
fun collationFoldedUniquenessKey(schema: TableSchema, columnIndices: List<Int>, values: List<Value>): List<Value> =
values.mapIndexed { position, value ->
if (value !is Value.TextValue) value
else Value.TextValue(schema.columns[columnIndices[position]].collation.canonicalize(value.value))
}
/**
* Single-column counterpart of [collationFoldedKey], for an aggregate's own `DISTINCT` dedup
* (`COUNT`/`SUM`/`AVG`/`MIN`/`MAX(DISTINCT ...)`), which has already reduced its argument down to a
* flat list of values rather than whole rows.
*/
fun List<Value>.distinctByCollation(collation: Collation): List<Value> =
distinctBy { value -> if (value is Value.TextValue) collation.canonicalize(value.value) else value }
/**
* SQLite's own two-tier rule for a binary comparison's collating sequence, in decreasing
* precedence: (1) an explicit `COLLATE` on either operand, left first; (2) either operand's own
* declared column `COLLATE` (via [declaredCollationOf], with [Collation.Binary] treated the same
* as "no declaration" so it never out-ranks the *other* side purely by appearing first - see
* [resolveCollation]'s own doc for the identical reasoning), left first; (3) [Collation.Binary].
* Kept as two separate passes (not one combined per-side [resolveCollation] check) so an explicit
* `COLLATE` on the *right* operand still outranks the *left* operand's own declared column
* collation. Shared by [kestrel.engine.Evaluator]'s `=`/`<>`/`BETWEEN`/`CASE` comparisons and the
* hash-join fast path in [kestrel.engine.SelectRunner] (`hashJoinKeys`/`executeJoin`), so a
* `JOIN ... ON a.x = b.x` bucket lookup agrees with what evaluating the exact same condition
* row-by-row through [kestrel.engine.Evaluator] would have decided.
*/
fun pairCollation(
left: Expression,
right: Expression,
declaredCollationOf: (ColumnExpression) -> Collation?
): Collation {
fun explicitOf(expression: Expression) = (expression as? CollateExpression)?.collation
fun declaredOf(expression: Expression) =
(expression as? ColumnExpression)?.let(declaredCollationOf)?.takeIf { it != Collation.Binary }
return explicitOf(left) ?: explicitOf(right) ?: declaredOf(left) ?: declaredOf(right) ?: Collation.Binary
}
+81 -5
View File
@@ -1,36 +1,112 @@
package kestrel.engine package kestrel.engine
import java.math.BigDecimal
import java.math.RoundingMode
import java.time.LocalDate
import kestrel.KestrelException import kestrel.KestrelException
import kestrel.catalog.TableSchema import kestrel.catalog.TableSchema
import kestrel.engine.function.ScalarFunctions
import kestrel.sql.ast.BetweenExpression
import kestrel.sql.ast.BinaryExpression import kestrel.sql.ast.BinaryExpression
import kestrel.sql.ast.BinaryOperator import kestrel.sql.ast.BinaryOperator
import kestrel.sql.ast.CallExpression
import kestrel.sql.ast.CaseExpression
import kestrel.sql.ast.CastExpression
import kestrel.sql.ast.CollateExpression
import kestrel.sql.ast.ColumnExpression import kestrel.sql.ast.ColumnExpression
import kestrel.sql.ast.ExistsExpression
import kestrel.sql.ast.Expression import kestrel.sql.ast.Expression
import kestrel.sql.ast.FunctionExpression import kestrel.sql.ast.FunctionExpression
import kestrel.sql.ast.InExpression
import kestrel.sql.ast.InSubqueryExpression
import kestrel.sql.ast.IsNullExpression
import kestrel.sql.ast.LikeExpression
import kestrel.sql.ast.CurrentValueExpression
import kestrel.sql.ast.LiteralExpression import kestrel.sql.ast.LiteralExpression
import kestrel.sql.ast.MergeActionExpression
import kestrel.sql.ast.NextValueExpression
import kestrel.sql.ast.ParameterExpression
import kestrel.sql.ast.ScalarSubqueryExpression
import kestrel.sql.ast.SelectStatement
import kestrel.sql.ast.UnaryExpression import kestrel.sql.ast.UnaryExpression
import kestrel.sql.ast.UnaryOperator import kestrel.sql.ast.UnaryOperator
import kestrel.sql.ast.WindowExpression
import kestrel.storage.Collation
import kestrel.storage.ColumnType
import kestrel.storage.DecimalColumns
import kestrel.storage.Row import kestrel.storage.Row
import kestrel.storage.Value import kestrel.storage.Value
import kestrel.storage.typeName import kestrel.storage.typeName
fun interface ColumnResolver { fun interface ColumnResolver {
fun resolve(expression: ColumnExpression): Value fun resolve(expression: ColumnExpression): Value
/**
* The [Collation] [expression] was declared with in its `CREATE TABLE ... COLLATE name` (its
* [kestrel.catalog.Column.collation]), or `null` when this resolver has no such column to
* report one for - a computed/derived row (a set operation's output, a LATERAL subquery's own
* result), an ambiguous reference across more than one source, or simply a resolver that never
* bothered to override this default. [Evaluator.collationOf] only ever calls this for a bare
* [ColumnExpression] with no [kestrel.sql.ast.CollateExpression] of its own - an explicit
* `COLLATE` in the query always wins over whatever this returns. A default method (not the SAM
* member) so every existing lambda-shaped `ColumnResolver { expression -> ... }` construction
* keeps compiling unchanged and simply reports "no declared collation" - only the resolvers
* built directly over a [kestrel.catalog.TableSchema] bother to override it.
*/
fun declaredCollation(expression: ColumnExpression): Collation? = null
} }
class Evaluator(private val resolver: ColumnResolver? = null) { fun interface SubqueryRunner {
constructor(schema: TableSchema?, row: Row?) : this(schema?.let { active -> fun run(select: SelectStatement, outer: ColumnResolver?): SelectResult
ColumnResolver { expression -> }
/**
* Bridges NEXTVAL/CURRVAL evaluation to whichever [kestrel.catalog.Catalog] and per-session state
* actually own sequence counters - kept as its own interface (not a `fun interface` like
* [ColumnResolver]/[SubqueryRunner]) because it needs two independent operations, not one.
* [kestrel.engine.Executor] is the only implementation; every other [Evaluator] construction site
* (SELECT projections, WHERE clauses read-side, CHECK constraints, trigger `WHEN` clauses) omits it,
* so [evaluateNextValue]/[evaluateCurrentValue] throw there exactly like a missing [SubqueryRunner]
* already does for a subquery - deliberately: a volatile, side-effecting call being evaluated an
* engine-implementation-defined number of times (once for filtering, again for projecting the same
* row, ...) is exactly the ambiguity real databases warn about, so this engine only ever allows it
* where a row's assignment is evaluated exactly once by construction (see
* [kestrel.engine.Executor.executeInsertInner]/`executeUpdateInner`).
*/
interface SequenceAccess {
fun nextValue(sequenceName: String): Long
fun currentValue(sequenceName: String): Long
}
class Evaluator(
private val resolver: ColumnResolver? = null,
private val subqueries: SubqueryRunner? = null,
private val sequences: SequenceAccess? = null,
/** Bound only by [kestrel.engine.Executor.evaluateMergeReturning]'s per-row [Evaluator] - see [MergeActionExpression]'s own doc for why every other call site leaves this null and lets MERGE_ACTION() throw. */
private val mergeAction: String? = null
) {
constructor(schema: TableSchema?, row: Row?, subqueries: SubqueryRunner? = null, sequences: SequenceAccess? = null) : this(
schema?.let { active ->
object : ColumnResolver {
override fun resolve(expression: ColumnExpression): Value {
val index = active.columnIndex(expression.name) val index = active.columnIndex(expression.name)
?: throw KestrelException("no such column: ${expression.name}") ?: throw KestrelException("no such column: ${expression.name}")
row!!.values[index] return row!!.values[index]
} }
})
override fun declaredCollation(expression: ColumnExpression): Collation? =
active.column(expression.name)?.collation
}
},
subqueries,
sequences
)
fun evaluate(expression: Expression): Value = when (expression) { fun evaluate(expression: Expression): Value = when (expression) {
is LiteralExpression -> expression.value is LiteralExpression -> expression.value
is ColumnExpression -> evaluateColumn(expression) is ColumnExpression -> evaluateColumn(expression)
is UnaryExpression -> evaluateUnary(expression) is UnaryExpression -> evaluateUnary(expression)
is CollateExpression -> evaluate(expression.operand)
is BinaryExpression -> evaluateBinary(expression) is BinaryExpression -> evaluateBinary(expression)
is FunctionExpression -> throw KestrelException( is FunctionExpression -> throw KestrelException(
"aggregate function ${expression.name} is not allowed here" "aggregate function ${expression.name} is not allowed here"