Merge pull request #1 from dualfroz/feat/pending-work-2026-09

feat: land pending September work
This commit is contained in:
dualfroz authored and GitHub committed 2026-09-14 03:27:29 +02:00
commit 12da64d9b0
152 files changed
+45825 -413

No files matched your search

+759 -16
View File
@@ -1,53 +1,711 @@
package kestrel.catalog
import kestrel.KestrelException
import kestrel.sql.ast.NextValueExpression
import kestrel.sql.ast.QueryStatement
import kestrel.sql.ast.TriggerEvent
import kestrel.sql.ast.TriggerTiming
import kestrel.storage.Collation
import kestrel.storage.Row
import kestrel.storage.Table
import kestrel.storage.Value
class CatalogSnapshot(
val tables: Map<String, Pair<TableSchema, List<Row>>>,
val indexDefinitions: List<IndexDefinition>
val indexDefinitions: List<IndexDefinition>,
val views: Map<String, ViewDefinition>,
val triggers: Map<String, TriggerDefinition>,
/**
* A reference copy of the map, not a deep copy of each [Sequence] - see [Sequence]'s own doc
* for why that is exactly what makes ROLLBACK undo CREATE/DROP/ALTER SEQUENCE while leaving a
* NEXTVAL advance in place.
*/
val sequences: Map<String, Sequence>
)
class Catalog {
private val tables = linkedMapOf<String, Table>()
private val indexes = linkedMapOf<String, SortedIndex>()
private val views = linkedMapOf<String, ViewDefinition>()
private val triggers = linkedMapOf<String, TriggerDefinition>()
private val sequences = linkedMapOf<String, Sequence>()
fun createTable(schema: TableSchema) {
val key = schema.name.lowercase()
if (tables.containsKey(key)) {
throw KestrelException("table '${schema.name}' already exists")
}
if (views.containsKey(key)) {
throw KestrelException("a view named '${schema.name}' already exists")
}
tables[key] = Table(schema)
}
fun dropTable(tableName: String) {
fun dropTable(tableName: String, cascade: Boolean = false) {
val key = tableName.lowercase()
if (tables.remove(key) == null) {
if (!tables.containsKey(key)) {
if (views.containsKey(key)) {
throw KestrelException("'$tableName' is a view, use DROP VIEW")
}
throw KestrelException("no such table: $tableName")
}
dropDependentViews(tableName, cascade)
dropDependentForeignKeys(tableName, cascade)
tables.remove(key)
indexes.entries.removeAll { it.value.definition.tableName.equals(tableName, ignoreCase = true) }
// A trigger without its table makes no sense to keep around - unlike a FOREIGN KEY
// reference, nothing else depends on it existing, so this always happens, CASCADE or not.
triggers.entries.removeAll { it.value.tableName.equals(tableName, ignoreCase = true) }
}
fun table(tableName: String): Table =
tables[tableName.lowercase()] ?: throw KestrelException("no such table: $tableName")
/**
* Mirrors Postgres semantics: a FOREIGN KEY pointing at [tableName] blocks the drop under
* RESTRICT, while CASCADE only drops the constraint itself (the referencing table and its
* rows survive) - dropping a table never implies dropping every table that happens to
* reference it. A self-referencing FK is excluded because it disappears together with the
* table being dropped, with nothing left dangling in any other table.
*/
private fun dropDependentForeignKeys(tableName: String, cascade: Boolean) {
val referencing = tables.values.filter { table ->
!table.schema.name.equals(tableName, ignoreCase = true) &&
table.schema.foreignKeys.any { it.referencedTable.equals(tableName, ignoreCase = true) }
}
if (referencing.isEmpty()) return
if (!cascade) {
val names = referencing.map { it.schema.name }
throw KestrelException(
"cannot drop '$tableName' because table(s) ${names.joinToString(", ")} have a FOREIGN KEY " +
"referencing it; use CASCADE"
)
}
for (table in referencing) {
val key = table.schema.name.lowercase()
val newSchema = TableSchema(
table.schema.name,
table.schema.columns,
table.schema.foreignKeys.filterNot { it.referencedTable.equals(tableName, ignoreCase = true) },
table.schema.checks,
table.schema.uniqueConstraints,
table.schema.compositePrimaryKey,
table.schema.temporary
)
val replacement = Table(newSchema)
replacement.rows.addAll(table.rows)
tables[key] = replacement
}
}
/**
* TRUNCATE's own dependency handling, deliberately NOT built on [dropDependentForeignKeys] or
* `ForeignKeyEnforcer.cascadeDelete`: wiping every row of [tableNames] makes EVERY row of a
* table with a FOREIGN KEY into any of them dangling at once, not just rows that happened to
* match specific parent rows about to disappear one at a time - so RESTRICT (default) blocks on
* any other table's FOREIGN KEY referencing one of [tableNames] regardless of that FK's own
* `onDelete` action (real TRUNCATE never consults it - there is no per-row delete to run it
* against), and [cascade] means "also truncate the whole referencing table", computed as a
* transitive closure exactly like [dropDependentViews]'s [transitiveViewDependents], not a
* per-row plan/apply cascade. A self-reference (a table whose own FOREIGN KEY points at itself,
* or two requested tables that reference each other) never blocks or duplicates, since every
* requested table starts in the closure before the first pass over `tables` even runs.
*
* Returns every table actually truncated - [tableNames] plus, under CASCADE, every transitive
* referencer - in an unspecified order, so the caller can report and (for RESTART IDENTITY)
* restart sequences for all of them, not just the ones named in the statement.
*/
fun truncateTables(tableNames: List<String>, cascade: Boolean, restartIdentity: Boolean): List<String> {
val requestedKeys = tableNames.map { name ->
val key = name.lowercase()
if (!tables.containsKey(key)) {
if (views.containsKey(key)) throw KestrelException("'$name' is a view, TRUNCATE only applies to tables")
throw KestrelException("no such table: $name")
}
key
}
val closure = truncateClosure(requestedKeys.toCollection(linkedSetOf()), cascade, tableNames)
for (key in closure) {
val table = tables.getValue(key)
table.rows.clear()
if (restartIdentity) restartOwnedSequences(table)
}
for (key in closure) reindexTable(tables.getValue(key).schema.name)
return closure.map { tables.getValue(it).schema.name }
}
private fun truncateClosure(found: LinkedHashSet<String>, cascade: Boolean, requestedNames: List<String>): LinkedHashSet<String> {
var frontier: Set<String> = found
while (true) {
val referencing = tables.values.filter { table ->
table.schema.name.lowercase() !in found &&
table.schema.foreignKeys.any { fk -> fk.referencedTable.lowercase() in frontier }
}
if (referencing.isEmpty()) return found
if (!cascade) {
val names = referencing.map { it.schema.name }
throw KestrelException(
"cannot truncate ${requestedNames.joinToString(", ")} because table(s) " +
"${names.joinToString(", ")} have a FOREIGN KEY referencing them; use CASCADE"
)
}
val next = referencing.map { it.schema.name.lowercase() }.toSet()
found.addAll(next)
frontier = next
}
}
/**
* `ALTER TABLE t ALTER COLUMN col TYPE ...` - replaces an EXISTING column's declared
* [Column.type]/[Column.precision]/[Column.scale] in place, keeping its name/position/
* notNull/default/generated/collation untouched. Unlike [addColumn]'s per-row `valueForRow`
* callback, [newRows] arrives already fully built by [kestrel.engine.Executor] - every row's
* `USING` (or implicit "recompute the column as its own old value") expression evaluated and
* coerced, and every NOT NULL/CHECK re-validated against the NEW schema - BEFORE this is ever
* called, so this method never observes a row that fails validation partway through: either
* all of [newRows] is already known-good and gets installed atomically, or the caller never
* reaches this method at all.
*/
fun changeColumnType(tableName: String, columnName: String, newColumn: Column, newRows: List<Row>) {
val schema = table(tableName).schema
val columnIndex = schema.columnIndex(columnName)
?: throw KestrelException("no such column: $columnName in table $tableName")
val newColumns = schema.columns.toMutableList().also { it[columnIndex] = newColumn }
val newSchema = TableSchema(
schema.name, newColumns, schema.foreignKeys, schema.checks,
schema.uniqueConstraints, schema.compositePrimaryKey, schema.temporary
)
replaceTable(tableName, newSchema, newRows)
}
/**
* RESTART IDENTITY has no explicit "sequence OWNED BY column" catalog entry to consult (Postgres
* has one; this engine does not) - a column counts as identity-backed exactly when its own
* DEFAULT is a bare `NEXTVAL('seq')`, the same structural signal `SqlWriter`/`executeAddColumn`
* already treat as "this column is an auto-increment". A dangling reference to an
* already-dropped sequence (DROP SEQUENCE has no dependent-column check of its own) is silently
* skipped rather than thrown, matching how the rest of this engine only ever discovers that gap
* lazily, at the next NEXTVAL/INSERT.
*/
private fun restartOwnedSequences(table: Table) {
for (column in table.schema.columns) {
val default = column.default
if (default is NextValueExpression) {
val sequence = sequenceOrNull(default.sequenceName) ?: continue
sequences[default.sequenceName.lowercase()] = sequence.altered(sequence.definition, restartAt = sequence.definition.startValue)
}
}
}
/**
* Appends a new column (already validated by the executor) to every existing row.
* [valueForRow] computes the new column's value from each row's OLD (pre-append) values - a
* constant lambda for a plain DEFAULT, or a fresh per-row expression evaluation for GENERATED
* ALWAYS AS, mirroring how INSERT already treats those two cases differently.
*/
fun addColumn(tableName: String, column: Column, valueForRow: (Row) -> Value) {
val table = table(tableName)
val schema = table.schema
if (schema.columnIndex(column.name) != null) {
throw KestrelException("duplicate column name: ${column.name}")
}
val newSchema = TableSchema(
schema.name,
schema.columns + column,
schema.foreignKeys,
schema.checks,
schema.uniqueConstraints,
schema.compositePrimaryKey,
schema.temporary
)
val newRows = table.rows.map { row -> Row(row.values + valueForRow(row)) }
replaceTable(tableName, newSchema, newRows)
}
/**
* RESTRICT (default) rejects the drop if [columnName] is used by another table's FOREIGN KEY,
* this table's own FOREIGN KEY, a UNIQUE constraint, a CHECK constraint, or an index - CASCADE
* removes all of those alongside the column itself. Dropping a PRIMARY KEY column is never
* allowed, with or without CASCADE: this engine has no standalone "DROP CONSTRAINT", so
* redesign the PRIMARY KEY (or drop the table) instead.
*/
fun dropColumn(tableName: String, columnName: String, cascade: Boolean) {
val table = table(tableName)
val schema = table.schema
schema.columnIndex(columnName) ?: throw KestrelException("no such column: $columnName in table $tableName")
if (schema.columns.size == 1) {
throw KestrelException("cannot drop $columnName: table $tableName would be left with no columns")
}
if (schema.primaryKeyColumns.any { it.equals(columnName, ignoreCase = true) }) {
throw KestrelException("cannot drop column $columnName because it is part of the PRIMARY KEY of $tableName")
}
// A GENERATED column can only ever read this table's own non-GENERATED columns (never
// another GENERATED one - validateGeneratedDefinitions forbids that chain), so dropping
// columnName orphans any GENERATED column whose expression reads it, exactly like a CHECK
// constraint referencing it - it must be dropped right along with columnName (RESTRICT
// blocks the whole drop instead, same as every other dependent kind below). GENERATED can
// never be a PRIMARY KEY column (Executor.validateGeneratedDefinitions), so there is no
// "cascaded into dropping the PRIMARY KEY" case to guard against here.
val ownGeneratedDependents = schema.columns.filter { column ->
!column.name.equals(columnName, ignoreCase = true) &&
column.generated?.let { CheckExpressionColumns.references(it, columnName) } == true
}
val droppedNames = (ownGeneratedDependents.map { it.name } + columnName).map { it.lowercase() }.toSet()
val droppedIndices = schema.columns.withIndex()
.filter { (_, column) -> column.name.lowercase() in droppedNames }
.map { it.index }
.toSet()
val referencingForeignKeys = tables.values.filter { other ->
!other.schema.name.equals(tableName, ignoreCase = true) &&
other.schema.foreignKeys.any { fk ->
fk.referencedTable.equals(tableName, ignoreCase = true) &&
fk.referencedColumns.any { it.lowercase() in droppedNames }
}
}
val ownForeignKeys = schema.foreignKeys.filter { fk -> fk.columns.any { it.lowercase() in droppedNames } }
val ownUniqueConstraints =
schema.uniqueConstraints.filter { unique -> unique.columns.any { it.lowercase() in droppedNames } }
val ownChecks = schema.checks.filter { check -> droppedNames.any { name -> CheckExpressionColumns.references(check.expression, name) } }
val dependentIndexes = indexes.values.filter {
it.definition.tableName.equals(tableName, ignoreCase = true) &&
it.definition.columnNames.any { col -> col.lowercase() in droppedNames }
}
if (!cascade) {
val reasons = mutableListOf<String>()
if (ownGeneratedDependents.isNotEmpty()) {
reasons.add(
"column(s) ${ownGeneratedDependents.joinToString(", ") { it.name }} are GENERATED columns that depend on it"
)
}
if (referencingForeignKeys.isNotEmpty()) {
reasons.add(
"table(s) ${referencingForeignKeys.joinToString(", ") { it.schema.name }} have a FOREIGN KEY referencing it"
)
}
if (ownForeignKeys.isNotEmpty()) reasons.add("it is used by one of its own FOREIGN KEY constraints")
if (ownUniqueConstraints.isNotEmpty()) reasons.add("it is used by a UNIQUE constraint")
if (ownChecks.isNotEmpty()) reasons.add("it is used by a CHECK constraint")
if (dependentIndexes.isNotEmpty()) {
reasons.add("index(es) ${dependentIndexes.joinToString(", ") { it.definition.name }} are defined on it")
}
if (reasons.isNotEmpty()) {
throw KestrelException(
"cannot drop column $columnName from $tableName because ${reasons.joinToString("; ")}; use CASCADE"
)
}
}
for (other in referencingForeignKeys) {
val key = other.schema.name.lowercase()
val newSchema = TableSchema(
other.schema.name,
other.schema.columns,
other.schema.foreignKeys.filterNot { fk ->
fk.referencedTable.equals(tableName, ignoreCase = true) &&
fk.referencedColumns.any { it.lowercase() in droppedNames }
},
other.schema.checks,
other.schema.uniqueConstraints,
other.schema.compositePrimaryKey,
other.schema.temporary
)
val replacement = Table(newSchema)
replacement.rows.addAll(other.rows)
tables[key] = replacement
}
for (index in dependentIndexes) indexes.remove(index.definition.name.lowercase())
val newColumns = schema.columns.filterNot { it.name.lowercase() in droppedNames }
val newForeignKeys = schema.foreignKeys - ownForeignKeys.toSet()
val newUniqueConstraints = schema.uniqueConstraints - ownUniqueConstraints.toSet()
val newChecks = schema.checks - ownChecks.toSet()
val newSchema = TableSchema(schema.name, newColumns, newForeignKeys, newChecks, newUniqueConstraints, schema.compositePrimaryKey, schema.temporary)
val newRows = table.rows.map { row -> Row(row.values.filterIndexed { valueIndex, _ -> valueIndex !in droppedIndices }) }
replaceTable(tableName, newSchema, newRows)
}
/**
* Always fully propagates rather than being rejected the way [dropColumn] can be: the
* identifier is rewritten everywhere it is used by name rather than removed, so nothing
* becomes newly invalid - this table's own PRIMARY KEY/FOREIGN KEY/UNIQUE/CHECK definitions,
* any other table's FOREIGN KEY referencing this column, and any index defined on it.
*/
fun renameColumn(tableName: String, oldName: String, newName: String) {
val table = table(tableName)
val schema = table.schema
if (schema.columnIndex(oldName) == null) {
throw KestrelException("no such column: $oldName in table $tableName")
}
if (!oldName.equals(newName, ignoreCase = true) && schema.columnIndex(newName) != null) {
throw KestrelException("column $newName already exists in table $tableName")
}
val newColumns = schema.columns.map { column ->
val renamed = if (column.name.equals(oldName, ignoreCase = true)) column.copy(name = newName) else column
// A GENERATED column can only ever reference this table's own columns, so renaming one
// of them must rewrite that reference too, exactly like a CHECK constraint's own
// reference to it (below) - otherwise the GENERATED expression would silently keep
// pointing at a name that no longer exists.
renamed.generated?.let { renamed.copy(generated = CheckExpressionColumns.rename(it, oldName, newName)) } ?: renamed
}
val newForeignKeys = schema.foreignKeys.map { fk ->
val selfReference = fk.referencedTable.equals(tableName, ignoreCase = true)
fk.copy(
columns = renameInList(fk.columns, oldName, newName),
referencedColumns = if (selfReference) renameInList(fk.referencedColumns, oldName, newName) else fk.referencedColumns
)
}
val newUniqueConstraints = schema.uniqueConstraints.map { it.copy(columns = renameInList(it.columns, oldName, newName)) }
val newChecks = schema.checks.map { it.copy(expression = CheckExpressionColumns.rename(it.expression, oldName, newName)) }
val newCompositePrimaryKey = renameInList(schema.compositePrimaryKey, oldName, newName)
val newSchema = TableSchema(schema.name, newColumns, newForeignKeys, newChecks, newUniqueConstraints, newCompositePrimaryKey, schema.temporary)
replaceTable(tableName, newSchema, table.rows)
for ((key, other) in tables.entries.toList()) {
if (other.schema.name.equals(tableName, ignoreCase = true)) continue
val touched = other.schema.foreignKeys.any { fk ->
fk.referencedTable.equals(tableName, ignoreCase = true) &&
fk.referencedColumns.any { it.equals(oldName, ignoreCase = true) }
}
if (!touched) continue
val updatedForeignKeys = other.schema.foreignKeys.map { fk ->
if (fk.referencedTable.equals(tableName, ignoreCase = true)) {
fk.copy(referencedColumns = renameInList(fk.referencedColumns, oldName, newName))
} else {
fk
}
}
val updatedSchema = TableSchema(
other.schema.name,
other.schema.columns,
updatedForeignKeys,
other.schema.checks,
other.schema.uniqueConstraints,
other.schema.compositePrimaryKey,
other.schema.temporary
)
val replacement = Table(updatedSchema)
replacement.rows.addAll(other.rows)
tables[key] = replacement
}
for ((key, index) in indexes.entries.toList()) {
if (!index.definition.tableName.equals(tableName, ignoreCase = true)) continue
if (index.definition.columnNames.none { it.equals(oldName, ignoreCase = true) }) continue
val renamedDefinition = index.definition.copy(columnNames = renameInList(index.definition.columnNames, oldName, newName))
val renamed = SortedIndex(renamedDefinition, collationsFor(newSchema, renamedDefinition.columnNames))
reindexOne(tables.getValue(tableName.lowercase()), renamed)
indexes[key] = renamed
}
}
/**
* Renames a table and propagates the identifier everywhere it is used by name, mirroring
* [renameColumn]'s "always fully propagates" contract: this table's own FOREIGN KEY(s) that
* self-reference it, any other table's FOREIGN KEY pointing at it, every index defined on it,
* and every view whose defining query mentions it anywhere (FROM/JOIN, derived tables, CTEs,
* set operations, scalar/EXISTS/IN subqueries - see [RelationRename]).
*/
fun renameTable(oldName: String, newName: String) {
val existing = table(oldName)
if (!oldName.equals(newName, ignoreCase = true)) {
val key = newName.lowercase()
if (tables.containsKey(key)) throw KestrelException("table '$newName' already exists")
if (views.containsKey(key)) throw KestrelException("a view named '$newName' already exists")
}
val selfReferencing = existing.schema.foreignKeys.any { it.referencedTable.equals(oldName, ignoreCase = true) }
val newForeignKeys = if (selfReferencing) {
existing.schema.foreignKeys.map { fk ->
if (fk.referencedTable.equals(oldName, ignoreCase = true)) fk.copy(referencedTable = newName) else fk
}
} else {
existing.schema.foreignKeys
}
val newSchema = TableSchema(
newName,
existing.schema.columns,
newForeignKeys,
existing.schema.checks,
existing.schema.uniqueConstraints,
existing.schema.compositePrimaryKey,
existing.schema.temporary
)
tables.remove(oldName.lowercase())
val replacement = Table(newSchema)
replacement.rows.addAll(existing.rows)
tables[newName.lowercase()] = replacement
for ((key, other) in tables.entries.toList()) {
if (other.schema.name.equals(newName, ignoreCase = true)) continue
val touched = other.schema.foreignKeys.any { it.referencedTable.equals(oldName, ignoreCase = true) }
if (!touched) continue
val updatedForeignKeys = other.schema.foreignKeys.map { fk ->
if (fk.referencedTable.equals(oldName, ignoreCase = true)) fk.copy(referencedTable = newName) else fk
}
val updatedSchema = TableSchema(
other.schema.name,
other.schema.columns,
updatedForeignKeys,
other.schema.checks,
other.schema.uniqueConstraints,
other.schema.compositePrimaryKey,
other.schema.temporary
)
val replacementOther = Table(updatedSchema)
replacementOther.rows.addAll(other.rows)
tables[key] = replacementOther
}
for ((key, index) in indexes.entries.toList()) {
if (!index.definition.tableName.equals(oldName, ignoreCase = true)) continue
val renamedDefinition = index.definition.copy(tableName = newName)
indexes[key] = SortedIndex(renamedDefinition, collationsFor(newSchema, renamedDefinition.columnNames)).also { renamed ->
reindexOne(tables.getValue(newName.lowercase()), renamed)
}
}
for ((key, view) in views.entries.toList()) {
if (!DependencyAnalysis.referencesRelation(view.query, oldName)) continue
views[key] = view.copy(query = RelationRename.rename(view.query, oldName, newName))
}
for ((key, trigger) in triggers.entries.toList()) {
if (!trigger.tableName.equals(oldName, ignoreCase = true)) continue
triggers[key] = trigger.copy(tableName = newName)
}
}
/**
* `ALTER TABLE t ADD CONSTRAINT name FOREIGN KEY (...) REFERENCES ...` - installs a FOREIGN KEY
* onto an already-existing table. Unlike [createTable]'s FOREIGN KEY definitions, this never
* touches existing rows itself: [kestrel.engine.Executor.executeAddConstraint] validates every
* existing row against [foreignKey] (via [kestrel.engine.ForeignKeyEnforcer.validateRow]) BEFORE
* calling this, so by the time the schema actually changes the constraint is already known to
* hold for every row already in the table.
*/
fun addForeignKey(tableName: String, foreignKey: ForeignKeyConstraint) {
val table = table(tableName)
val schema = table.schema
val newSchema = TableSchema(
schema.name, schema.columns, schema.foreignKeys + foreignKey, schema.checks,
schema.uniqueConstraints, schema.compositePrimaryKey, schema.temporary
)
replaceTable(tableName, newSchema, table.rows)
}
/** [addForeignKey]'s counterpart for `ADD CONSTRAINT name UNIQUE (...)` - see its own doc for the validate-before-install contract. */
fun addUniqueConstraint(tableName: String, unique: UniqueConstraint) {
val table = table(tableName)
val schema = table.schema
val newSchema = TableSchema(
schema.name, schema.columns, schema.foreignKeys, schema.checks,
schema.uniqueConstraints + unique, schema.compositePrimaryKey, schema.temporary
)
replaceTable(tableName, newSchema, table.rows)
}
/** [addForeignKey]'s counterpart for `ADD CONSTRAINT name CHECK (...)` - see its own doc for the validate-before-install contract. */
fun addCheckConstraint(tableName: String, check: CheckConstraint) {
val table = table(tableName)
val schema = table.schema
val newSchema = TableSchema(
schema.name, schema.columns, schema.foreignKeys, schema.checks + check,
schema.uniqueConstraints, schema.compositePrimaryKey, schema.temporary
)
replaceTable(tableName, newSchema, table.rows)
}
/**
* `ALTER TABLE t DROP CONSTRAINT name [RESTRICT | CASCADE]` - finds a named CHECK, UNIQUE, or
* FOREIGN KEY constraint on [tableName] (case-insensitive) and removes it. A PRIMARY KEY is
* never a match: it has no name to compare against (see [dropColumn]'s own doc on why
* redesigning the PRIMARY KEY, or dropping the table, is the only way to get rid of one), so a
* name that happens to target one simply falls through to "no such constraint" like any other
* unknown name. Only a UNIQUE constraint has cross-table fallout worth a RESTRICT/CASCADE
* decision - see [dropUniqueConstraint].
*/
fun dropConstraint(tableName: String, name: String, cascade: Boolean) {
val table = table(tableName)
val schema = table.schema
val foreignKey = schema.foreignKeys.find { it.name?.equals(name, ignoreCase = true) == true }
val check = schema.checks.find { it.name?.equals(name, ignoreCase = true) == true }
val unique = schema.uniqueConstraints.find { it.name?.equals(name, ignoreCase = true) == true }
when {
foreignKey != null -> {
val newSchema = TableSchema(
schema.name, schema.columns, schema.foreignKeys - foreignKey, schema.checks,
schema.uniqueConstraints, schema.compositePrimaryKey, schema.temporary
)
replaceTable(tableName, newSchema, table.rows)
}
check != null -> {
val newSchema = TableSchema(
schema.name, schema.columns, schema.foreignKeys, schema.checks - check,
schema.uniqueConstraints, schema.compositePrimaryKey, schema.temporary
)
replaceTable(tableName, newSchema, table.rows)
}
unique != null -> dropUniqueConstraint(tableName, unique, cascade)
else -> throw KestrelException("no such constraint: $name in table $tableName")
}
}
/**
* A FOREIGN KEY's `referencedColumns` must exactly match one of the parent's enforced-unique
* key groups ([TableSchema.enforcedUniqueKeyGroups] - PRIMARY KEY or a UNIQUE constraint) to
* have been accepted in the first place, so dropping a UNIQUE constraint can silently invalidate
* any FOREIGN KEY (on this table itself, via a self-reference, or on any other table) that leans
* on exactly this column set for its uniqueness guarantee - UNLESS another remaining group
* (another UNIQUE constraint over the same columns, or the PRIMARY KEY) still covers it, in
* which case nothing actually depends on THIS particular constraint continuing to exist.
* RESTRICT (default) blocks the drop when a real dependent exists; CASCADE drops that FOREIGN
* KEY right along with it, mirroring [dropDependentForeignKeys].
*/
private fun dropUniqueConstraint(tableName: String, unique: UniqueConstraint, cascade: Boolean) {
val schema = table(tableName).schema
val remainingUniques = schema.uniqueConstraints - unique
val targetSet = unique.columns.map { it.lowercase() }.toSet()
val stillEnforced = TableSchema(
schema.name, schema.columns, schema.foreignKeys, schema.checks, remainingUniques,
schema.compositePrimaryKey, schema.temporary
).enforcedUniqueKeyGroups().any { group -> group.map { it.lowercase() }.toSet() == targetSet }
fun dependsOnTarget(fk: ForeignKeyConstraint) =
fk.referencedTable.equals(tableName, ignoreCase = true) &&
fk.referencedColumns.map { it.lowercase() }.toSet() == targetSet
val dependents = if (stillEnforced) emptyList() else tables.values.filter { other -> other.schema.foreignKeys.any(::dependsOnTarget) }
if (!cascade && dependents.isNotEmpty()) {
val names = dependents.map { it.schema.name }.distinct()
throw KestrelException(
"cannot drop constraint ${unique.name} because table(s) ${names.joinToString(", ")} have a " +
"FOREIGN KEY referencing it; use CASCADE"
)
}
for (other in dependents) {
val key = other.schema.name.lowercase()
val newSchema = TableSchema(
other.schema.name, other.schema.columns, other.schema.foreignKeys.filterNot(::dependsOnTarget),
other.schema.checks, other.schema.uniqueConstraints, other.schema.compositePrimaryKey, other.schema.temporary
)
val replacement = Table(newSchema)
replacement.rows.addAll(other.rows)
tables[key] = replacement
}
// Re-fetched: a self-referencing FOREIGN KEY on tableName itself may have just been rewritten
// above (dependents is not restricted to OTHER tables - a self-reference is a real dependent
// here, unlike dropDependentForeignKeys' table-drop case where it disappears for free).
val current = table(tableName)
val newSchema = TableSchema(
current.schema.name, current.schema.columns, current.schema.foreignKeys, current.schema.checks,
current.schema.uniqueConstraints - unique, current.schema.compositePrimaryKey, current.schema.temporary
)
replaceTable(tableName, newSchema, current.rows)
}
private fun renameInList(names: List<String>, oldName: String, newName: String): List<String> =
names.map { if (it.equals(oldName, ignoreCase = true)) newName else it }
private fun replaceTable(tableName: String, newSchema: TableSchema, rows: List<Row>) {
val key = tableName.lowercase()
val replacement = Table(newSchema)
replacement.rows.addAll(rows)
tables[key] = replacement
}
fun table(tableName: String): Table {
val key = tableName.lowercase()
tables[key]?.let { return it }
if (views.containsKey(key)) {
throw KestrelException("cannot modify view: $tableName")
}
throw KestrelException("no such table: $tableName")
}
fun tableOrNull(tableName: String): Table? = tables[tableName.lowercase()]
val tableNames: List<String> get() = tables.values.map { it.schema.name }
fun createView(name: String, query: QueryStatement, orReplace: Boolean) {
val key = name.lowercase()
if (tables.containsKey(key)) {
throw KestrelException("a table named '$name' already exists")
}
if (!orReplace && views.containsKey(key)) {
throw KestrelException("view '$name' already exists")
}
views[key] = ViewDefinition(name, query)
}
fun dropView(name: String, cascade: Boolean = false) {
val key = name.lowercase()
if (!views.containsKey(key)) {
if (tables.containsKey(key)) {
throw KestrelException("'$name' is a table, use DROP TABLE")
}
throw KestrelException("no such view: $name")
}
dropDependentViews(name, cascade)
views.remove(key)
}
/**
* Computes the full transitive closure of views depending on [name] (directly or
* through other views) before touching anything, so a "diamond" - two different
* dependents that both end up pulling in the same further-removed view - can't
* make a later removal fail with "no such view" because an earlier one already
* deleted it.
*/
private fun dropDependentViews(name: String, cascade: Boolean) {
val closure = transitiveViewDependents(name.lowercase())
if (closure.isEmpty()) return
if (!cascade) {
val names = closure.mapNotNull { views[it]?.name }
throw KestrelException(
"cannot drop '$name' because view(s) ${names.joinToString(", ")} depend on it; use CASCADE"
)
}
for (key in closure) views.remove(key)
}
private fun transitiveViewDependents(rootKey: String): Set<String> {
val found = linkedSetOf<String>()
var frontier = setOf(rootKey)
while (true) {
val next = views.values
.filter { it.name.lowercase() != rootKey && it.name.lowercase() !in found }
.filter { view -> frontier.any { DependencyAnalysis.referencesRelation(view.query, it) } }
.map { it.name.lowercase() }
.toSet()
if (next.isEmpty()) return found
found.addAll(next)
frontier = next
}
}
fun viewOrNull(name: String): ViewDefinition? = views[name.lowercase()]
val viewNames: List<String> get() = views.values.map { it.name }
fun createIndex(definition: IndexDefinition) {
val key = definition.name.lowercase()
if (indexes.containsKey(key)) {
throw KestrelException("index '${definition.name}' already exists")
}
require(definition.columnNames.isNotEmpty()) { "index must cover at least one column" }
val table = table(definition.tableName)
if (table.schema.columnIndex(definition.columnName) == null) {
throw KestrelException("no such column: ${definition.columnName} in table ${definition.tableName}")
for (columnName in definition.columnNames) {
if (table.schema.columnIndex(columnName) == null) {
throw KestrelException("no such column: $columnName in table ${definition.tableName}")
}
}
val index = SortedIndex(definition)
indexes[key] = index
// Populated before registering in [indexes] so that a UNIQUE index created over a table
// with pre-existing duplicate values fails cleanly, without leaving a half-built index
// behind for later statements to trip over.
val index = SortedIndex(definition, collationsFor(table.schema, definition.columnNames))
reindexOne(table, index)
indexes[key] = index
}
fun dropIndex(indexName: String) {
@@ -56,9 +714,9 @@ class Catalog {
}
}
fun indexOn(tableName: String, columnName: String): SortedIndex? = indexes.values.firstOrNull {
it.definition.tableName.equals(tableName, ignoreCase = true) &&
it.definition.columnName.equals(columnName, ignoreCase = true)
/** Every index defined on [tableName], single-column or composite, in creation order. */
fun indexesOn(tableName: String): List<SortedIndex> = indexes.values.filter {
it.definition.tableName.equals(tableName, ignoreCase = true)
}
val indexNames: List<String> get() = indexes.values.map { it.definition.name }
@@ -72,21 +730,100 @@ class Catalog {
.forEach { reindexOne(table, it) }
}
/**
* Fires in [name]'s creation order for a given table/timing/event, matching the order
* multiple triggers on the same event fire in Postgres/SQLite.
*/
fun createTrigger(definition: TriggerDefinition) {
val key = definition.name.lowercase()
if (triggers.containsKey(key)) {
throw KestrelException("trigger '${definition.name}' already exists")
}
table(definition.tableName)
triggers[key] = definition
}
fun dropTrigger(name: String) {
if (triggers.remove(name.lowercase()) == null) {
throw KestrelException("no such trigger: $name")
}
}
fun triggersOn(tableName: String, timing: TriggerTiming, event: TriggerEvent): List<TriggerDefinition> =
triggers.values.filter {
it.tableName.equals(tableName, ignoreCase = true) && it.timing == timing && it.event == event
}
fun hasTriggers(tableName: String): Boolean = triggers.values.any { it.tableName.equals(tableName, ignoreCase = true) }
val triggerNames: List<String> get() = triggers.values.map { it.name }
fun triggerDefinitions(): List<TriggerDefinition> = triggers.values.toList()
/**
* `name` must not already be a sequence (redundant CREATE); unlike tables/views, sequence
* names live in their own namespace here (Postgres puts them in the same relation namespace as
* tables, but this engine has no need for that overlap check to matter for anything it
* implements).
*/
fun createSequence(definition: SequenceDefinition) {
val key = definition.name.lowercase()
if (sequences.containsKey(key)) {
throw KestrelException("sequence '${definition.name}' already exists")
}
sequences[key] = Sequence(definition)
}
fun dropSequence(name: String) {
if (sequences.remove(name.lowercase()) == null) {
throw KestrelException("no such sequence: $name")
}
}
/**
* Always installs a brand new [Sequence] instance (via [Sequence.altered]) rather than mutating
* the existing one in place - see [Sequence]'s own doc for why that is what makes ALTER SEQUENCE
* (unlike NEXTVAL) transactional.
*/
fun alterSequence(name: String, newDefinition: SequenceDefinition, restartAt: Long?) {
val key = name.lowercase()
val existing = sequences[key] ?: throw KestrelException("no such sequence: $name")
sequences[key] = existing.altered(newDefinition, restartAt)
}
fun sequence(name: String): Sequence = sequenceOrNull(name) ?: throw KestrelException("no such sequence: $name")
fun sequenceOrNull(name: String): Sequence? = sequences[name.lowercase()]
val sequenceNames: List<String> get() = sequences.values.map { it.definition.name }
fun sequenceDefinitions(): List<SequenceDefinition> = sequences.values.map { it.definition }
fun snapshot(): CatalogSnapshot = CatalogSnapshot(
tables = tables.mapValues { (_, table) -> table.schema to table.rows.toList() },
indexDefinitions = indexes.values.map { it.definition }
indexDefinitions = indexes.values.map { it.definition },
views = views.toMap(),
triggers = triggers.toMap(),
sequences = sequences.toMap()
)
fun restore(snapshot: CatalogSnapshot) {
tables.clear()
indexes.clear()
views.clear()
triggers.clear()
sequences.clear()
views.putAll(snapshot.views)
triggers.putAll(snapshot.triggers)
sequences.putAll(snapshot.sequences)
for ((key, image) in snapshot.tables) {
val table = Table(image.first)
table.rows.addAll(image.second)
tables[key] = table
}
for (definition in snapshot.indexDefinitions) {
val index = SortedIndex(definition)
val schema = tables.getValue(definition.tableName.lowercase()).schema
val index = SortedIndex(definition, collationsFor(schema, definition.columnNames))
indexes[definition.name.lowercase()] = index
reindexOne(tables.getValue(definition.tableName.lowercase()), index)
}
@@ -94,7 +831,13 @@ class Catalog {
private fun reindexOne(table: Table, index: SortedIndex) {
index.clear()
val columnIndex = table.schema.columnIndex(index.definition.columnName)!!
table.rows.forEachIndexed { rowIndex, row -> index.add(row.values[columnIndex], rowIndex) }
val columnIndices = index.definition.columnNames.map { table.schema.columnIndex(it)!! }
table.rows.forEachIndexed { rowIndex, row ->
index.add(columnIndices.map { row.values[it] }, rowIndex)
}
}
/** One [Collation] per entry of [columnNames], in order - [Collation.Binary] for any name [schema] cannot resolve. */
private fun collationsFor(schema: TableSchema, columnNames: List<String>): List<Collation> =
columnNames.map { schema.column(it)?.collation ?: Collation.Binary }
}
@@ -0,0 +1,97 @@
package kestrel.catalog
import kestrel.sql.ast.BetweenExpression
import kestrel.sql.ast.BinaryExpression
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.CurrentValueExpression
import kestrel.sql.ast.ExistsExpression
import kestrel.sql.ast.Expression
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.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.UnaryExpression
import kestrel.sql.ast.WindowExpression
/**
* Walks a CHECK constraint's expression looking for (or renaming) a plain column reference by
* name. Used by [Catalog] to decide whether dropping a column must also drop a dependent CHECK
* (`ALTER TABLE ... DROP COLUMN`) and to keep a CHECK in sync when a column is renamed
* (`ALTER TABLE ... RENAME COLUMN`). The subquery and window-function variants are handled only to
* keep the `when` exhaustive: `Executor.requireNoSubquery` already rejects any CHECK expression
* containing a subquery, an aggregate, or a window function at CREATE TABLE time, so those branches
* are unreachable in practice.
*/
internal object CheckExpressionColumns {
fun references(expression: Expression, columnName: String): Boolean = when (expression) {
is ColumnExpression -> expression.name.equals(columnName, ignoreCase = true)
is LiteralExpression -> false
is BinaryExpression -> references(expression.left, columnName) || references(expression.right, columnName)
is UnaryExpression -> references(expression.operand, columnName)
is CallExpression -> expression.arguments.any { references(it, columnName) }
is FunctionExpression -> expression.argument?.let { references(it, columnName) } ?: false
is IsNullExpression -> references(expression.operand, columnName)
is InExpression -> references(expression.operand, columnName) || expression.values.any { references(it, columnName) }
is BetweenExpression ->
references(expression.operand, columnName) ||
references(expression.low, columnName) ||
references(expression.high, columnName)
is LikeExpression -> references(expression.operand, columnName) || references(expression.pattern, columnName)
is CaseExpression ->
(expression.operand?.let { references(it, columnName) } ?: false) ||
expression.branches.any { references(it.condition, columnName) || references(it.result, columnName) } ||
(expression.elseExpression?.let { references(it, columnName) } ?: false)
is CastExpression -> references(expression.operand, columnName)
is CollateExpression -> references(expression.operand, columnName)
is ScalarSubqueryExpression, is ExistsExpression, is InSubqueryExpression, is WindowExpression,
is NextValueExpression, is CurrentValueExpression, is MergeActionExpression, is ParameterExpression -> false
}
fun rename(expression: Expression, oldName: String, newName: String): Expression = when (expression) {
is ColumnExpression ->
if (expression.name.equals(oldName, ignoreCase = true)) expression.copy(name = newName) else expression
is LiteralExpression -> expression
is BinaryExpression -> expression.copy(
left = rename(expression.left, oldName, newName),
right = rename(expression.right, oldName, newName)
)
is UnaryExpression -> expression.copy(operand = rename(expression.operand, oldName, newName))
is CallExpression -> expression.copy(arguments = expression.arguments.map { rename(it, oldName, newName) })
is FunctionExpression -> expression.copy(argument = expression.argument?.let { rename(it, oldName, newName) })
is IsNullExpression -> expression.copy(operand = rename(expression.operand, oldName, newName))
is InExpression -> expression.copy(
operand = rename(expression.operand, oldName, newName),
values = expression.values.map { rename(it, oldName, newName) }
)
is BetweenExpression -> expression.copy(
operand = rename(expression.operand, oldName, newName),
low = rename(expression.low, oldName, newName),
high = rename(expression.high, oldName, newName)
)
is LikeExpression -> expression.copy(
operand = rename(expression.operand, oldName, newName),
pattern = rename(expression.pattern, oldName, newName)
)
is CaseExpression -> expression.copy(
operand = expression.operand?.let { rename(it, oldName, newName) },
branches = expression.branches.map {
it.copy(condition = rename(it.condition, oldName, newName), result = rename(it.result, oldName, newName))
},
elseExpression = expression.elseExpression?.let { rename(it, oldName, newName) }
)
is CastExpression -> expression.copy(operand = rename(expression.operand, oldName, newName))
is CollateExpression -> expression.copy(operand = rename(expression.operand, oldName, newName))
is ScalarSubqueryExpression, is ExistsExpression, is InSubqueryExpression, is WindowExpression,
is NextValueExpression, is CurrentValueExpression, is MergeActionExpression, is ParameterExpression -> expression
}
}
@@ -0,0 +1,123 @@
package kestrel.catalog
import kestrel.sql.ast.BetweenExpression
import kestrel.sql.ast.BinaryExpression
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.CommonTableExpression
import kestrel.sql.ast.CurrentValueExpression
import kestrel.sql.ast.ExistsExpression
import kestrel.sql.ast.Expression
import kestrel.sql.ast.ExpressionProjection
import kestrel.sql.ast.FrameBound
import kestrel.sql.ast.FromItem
import kestrel.sql.ast.FunctionExpression
import kestrel.sql.ast.InExpression
import kestrel.sql.ast.InSubqueryExpression
import kestrel.sql.ast.IsNullExpression
import kestrel.sql.ast.JoinClause
import kestrel.sql.ast.LikeExpression
import kestrel.sql.ast.LiteralExpression
import kestrel.sql.ast.MergeActionExpression
import kestrel.sql.ast.NextValueExpression
import kestrel.sql.ast.ParameterExpression
import kestrel.sql.ast.Projection
import kestrel.sql.ast.QueryStatement
import kestrel.sql.ast.ScalarSubqueryExpression
import kestrel.sql.ast.SelectStatement
import kestrel.sql.ast.SetOperationStatement
import kestrel.sql.ast.StarProjection
import kestrel.sql.ast.SubquerySource
import kestrel.sql.ast.TableSource
import kestrel.sql.ast.UnaryExpression
import kestrel.sql.ast.WindowExpression
import kestrel.sql.ast.WithQuery
/**
* Walks a view's defining query looking for a relation name anywhere it could appear:
* FROM/JOIN targets, derived tables, CTE bodies, set-operation branches, and every
* subquery expression (scalar/EXISTS/IN) nested inside WHERE/HAVING/projection/GROUP BY.
* Used by [Catalog] to find what would break (RESTRICT) or need to cascade (CASCADE)
* when a table or view is dropped.
*/
internal object DependencyAnalysis {
fun referencesRelation(query: QueryStatement, name: String): Boolean = when (query) {
is SelectStatement -> referencesInSelect(query, name)
is SetOperationStatement -> referencesRelation(query.left, name) || referencesRelation(query.right, name)
is WithQuery -> query.ctes.any { referencesInCte(it, name) } || referencesRelation(query.body, name)
}
private fun referencesInCte(cte: CommonTableExpression, name: String): Boolean =
referencesRelation(cte.query, name)
private fun referencesInSelect(select: SelectStatement, name: String): Boolean {
if (referencesFromItem(select.from, name)) return true
if (select.joins.any { referencesJoin(it, name) }) return true
if (select.where?.let { referencesInExpression(it, name) } == true) return true
if (select.groupBy.any { referencesInExpression(it, name) }) return true
if (select.having?.let { referencesInExpression(it, name) } == true) return true
if (select.distinctOn.any { referencesInExpression(it, name) }) return true
return referencesInProjection(select.projection, name)
}
private fun referencesJoin(join: JoinClause, name: String): Boolean =
referencesFromItem(join.from, name) || (join.on?.let { referencesInExpression(it, name) } == true)
private fun referencesFromItem(item: FromItem, name: String): Boolean = when (item) {
is TableSource -> item.name.equals(name, ignoreCase = true)
is SubquerySource -> referencesRelation(item.select, name)
}
private fun referencesInProjection(projection: Projection, name: String): Boolean = when (projection) {
is StarProjection -> false
is ExpressionProjection -> projection.items.any { referencesInExpression(it.expression, name) }
}
private fun referencesInExpression(expression: Expression, name: String): Boolean = when (expression) {
is ScalarSubqueryExpression -> referencesRelation(expression.select, name)
is ExistsExpression -> referencesRelation(expression.select, name)
is InSubqueryExpression ->
referencesRelation(expression.select, name) || referencesInExpression(expression.operand, name)
is BinaryExpression -> referencesInExpression(expression.left, name) || referencesInExpression(expression.right, name)
is UnaryExpression -> referencesInExpression(expression.operand, name)
is CallExpression -> expression.arguments.any { referencesInExpression(it, name) }
is IsNullExpression -> referencesInExpression(expression.operand, name)
is InExpression ->
referencesInExpression(expression.operand, name) || expression.values.any { referencesInExpression(it, name) }
is BetweenExpression ->
referencesInExpression(expression.operand, name) ||
referencesInExpression(expression.low, name) ||
referencesInExpression(expression.high, name)
is LikeExpression ->
referencesInExpression(expression.operand, name) || referencesInExpression(expression.pattern, name)
is CaseExpression ->
(expression.operand?.let { referencesInExpression(it, name) } ?: false) ||
expression.branches.any {
referencesInExpression(it.condition, name) || referencesInExpression(it.result, name)
} ||
(expression.elseExpression?.let { referencesInExpression(it, name) } ?: false)
is CastExpression -> referencesInExpression(expression.operand, name)
is CollateExpression -> referencesInExpression(expression.operand, name)
is FunctionExpression ->
(expression.argument?.let { referencesInExpression(it, name) } ?: false) ||
(expression.separator?.let { referencesInExpression(it, name) } ?: false) ||
expression.orderBy.any { referencesInExpression(it.expression, name) } ||
(expression.filter?.let { referencesInExpression(it, name) } ?: false)
is WindowExpression ->
(expression.argument?.let { referencesInExpression(it, name) } ?: false) ||
(expression.offset?.let { referencesInExpression(it, name) } ?: false) ||
(expression.defaultValue?.let { referencesInExpression(it, name) } ?: false) ||
(expression.frame?.start?.let { referencesInFrameBound(it, name) } ?: false) ||
(expression.frame?.end?.let { referencesInFrameBound(it, name) } ?: false) ||
expression.partitionBy.any { referencesInExpression(it, name) } ||
expression.orderBy.any { referencesInExpression(it.expression, name) }
is ColumnExpression, is LiteralExpression, is NextValueExpression, is CurrentValueExpression,
is MergeActionExpression, is ParameterExpression -> false
}
private fun referencesInFrameBound(bound: FrameBound, name: String): Boolean =
bound.offset?.let { referencesInExpression(it, name) } ?: false
}
@@ -0,0 +1,182 @@
package kestrel.catalog
import kestrel.sql.ast.BetweenExpression
import kestrel.sql.ast.BinaryExpression
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.CommonTableExpression
import kestrel.sql.ast.CubeSpec
import kestrel.sql.ast.CurrentValueExpression
import kestrel.sql.ast.ExistsExpression
import kestrel.sql.ast.Expression
import kestrel.sql.ast.ExpressionProjection
import kestrel.sql.ast.FrameBound
import kestrel.sql.ast.FromItem
import kestrel.sql.ast.FunctionExpression
import kestrel.sql.ast.GroupingSetsSpec
import kestrel.sql.ast.GroupingSpec
import kestrel.sql.ast.InExpression
import kestrel.sql.ast.InSubqueryExpression
import kestrel.sql.ast.IsNullExpression
import kestrel.sql.ast.LikeExpression
import kestrel.sql.ast.LiteralExpression
import kestrel.sql.ast.MergeActionExpression
import kestrel.sql.ast.NextValueExpression
import kestrel.sql.ast.ParameterExpression
import kestrel.sql.ast.Projection
import kestrel.sql.ast.QueryStatement
import kestrel.sql.ast.RollupSpec
import kestrel.sql.ast.ScalarSubqueryExpression
import kestrel.sql.ast.SelectStatement
import kestrel.sql.ast.SetOperationStatement
import kestrel.sql.ast.StarProjection
import kestrel.sql.ast.SubquerySource
import kestrel.sql.ast.TableSource
import kestrel.sql.ast.UnaryExpression
import kestrel.sql.ast.WindowExpression
import kestrel.sql.ast.WithQuery
/**
* Rewrites every [TableSource] named [oldName] to [newName] throughout a view's defining query -
* FROM/JOIN targets, derived tables, CTE bodies, set-operation branches, and every subquery
* expression (scalar/EXISTS/IN) nested inside WHERE/HAVING/projection/GROUP BY/ORDER BY. Used by
* [Catalog] to keep views working after `ALTER TABLE ... RENAME TO`.
*
* A CTE whose own name equals [oldName] shadows the real table for the rest of that WITH clause
* (same rule SQL itself uses to resolve the name): once such a CTE is seen, later CTEs and the
* body are left untouched, because a bare reference to that name from there on means the CTE, not
* the table being renamed - renaming it there would silently change what the query means.
*/
internal object RelationRename {
fun rename(query: QueryStatement, oldName: String, newName: String): QueryStatement = when (query) {
is SelectStatement -> renameInSelect(query, oldName, newName)
is SetOperationStatement -> query.copy(
left = rename(query.left, oldName, newName),
right = rename(query.right, oldName, newName)
)
is WithQuery -> renameInWith(query, oldName, newName)
}
private fun renameInWith(query: WithQuery, oldName: String, newName: String): WithQuery {
val newCtes = mutableListOf<CommonTableExpression>()
var shadowed = false
for (cte in query.ctes) {
newCtes.add(if (shadowed) cte else cte.copy(query = rename(cte.query, oldName, newName)))
if (cte.name.equals(oldName, ignoreCase = true)) shadowed = true
}
val newBody = if (shadowed) query.body else rename(query.body, oldName, newName)
return query.copy(ctes = newCtes, body = newBody)
}
private fun renameInSelect(select: SelectStatement, oldName: String, newName: String): SelectStatement =
select.copy(
from = renameFromItem(select.from, oldName, newName),
joins = select.joins.map { join ->
join.copy(
from = renameFromItem(join.from, oldName, newName),
on = join.on?.let { renameInExpression(it, oldName, newName) }
)
},
where = select.where?.let { renameInExpression(it, oldName, newName) },
groupBy = select.groupBy.map { renameInExpression(it, oldName, newName) },
groupingSpec = select.groupingSpec?.let { renameInGroupingSpec(it, oldName, newName) },
having = select.having?.let { renameInExpression(it, oldName, newName) },
projection = renameInProjection(select.projection, oldName, newName),
orderBy = select.orderBy?.let { orderBy ->
orderBy.copy(keys = orderBy.keys.map { it.copy(expression = renameInExpression(it.expression, oldName, newName)) })
},
distinctOn = select.distinctOn.map { renameInExpression(it, oldName, newName) }
)
/**
* `groupBy` itself only ever carries the union of a [GroupingSpec]'s expressions (see
* [SelectStatement.groupBy]'s doc), never the spec's own per-set structure - so renaming that
* union alone would leave a stale table name buried inside a subquery nested in
* [RollupSpec]/[CubeSpec]/[GroupingSetsSpec], the one place [GroupAggregation] actually reads for
* execution. Kept in exact lockstep with `groupBy`'s own rewrite above.
*/
private fun renameInGroupingSpec(spec: GroupingSpec, oldName: String, newName: String): GroupingSpec = when (spec) {
is RollupSpec -> spec.copy(expressions = spec.expressions.map { renameInExpression(it, oldName, newName) })
is CubeSpec -> spec.copy(expressions = spec.expressions.map { renameInExpression(it, oldName, newName) })
is GroupingSetsSpec -> spec.copy(sets = spec.sets.map { set -> set.map { renameInExpression(it, oldName, newName) } })
}
private fun renameFromItem(item: FromItem, oldName: String, newName: String): FromItem = when (item) {
is TableSource -> if (item.name.equals(oldName, ignoreCase = true)) item.copy(name = newName) else item
is SubquerySource -> item.copy(select = rename(item.select, oldName, newName))
}
private fun renameInProjection(projection: Projection, oldName: String, newName: String): Projection = when (projection) {
is StarProjection -> projection
is ExpressionProjection ->
projection.copy(items = projection.items.map { it.copy(expression = renameInExpression(it.expression, oldName, newName)) })
}
private fun renameInExpression(expression: Expression, oldName: String, newName: String): Expression = when (expression) {
is ScalarSubqueryExpression -> expression.copy(select = renameInSelect(expression.select, oldName, newName))
is ExistsExpression -> expression.copy(select = renameInSelect(expression.select, oldName, newName))
is InSubqueryExpression -> expression.copy(
operand = renameInExpression(expression.operand, oldName, newName),
select = renameInSelect(expression.select, oldName, newName)
)
is BinaryExpression -> expression.copy(
left = renameInExpression(expression.left, oldName, newName),
right = renameInExpression(expression.right, oldName, newName)
)
is UnaryExpression -> expression.copy(operand = renameInExpression(expression.operand, oldName, newName))
is CallExpression -> expression.copy(arguments = expression.arguments.map { renameInExpression(it, oldName, newName) })
is IsNullExpression -> expression.copy(operand = renameInExpression(expression.operand, oldName, newName))
is InExpression -> expression.copy(
operand = renameInExpression(expression.operand, oldName, newName),
values = expression.values.map { renameInExpression(it, oldName, newName) }
)
is BetweenExpression -> expression.copy(
operand = renameInExpression(expression.operand, oldName, newName),
low = renameInExpression(expression.low, oldName, newName),
high = renameInExpression(expression.high, oldName, newName)
)
is LikeExpression -> expression.copy(
operand = renameInExpression(expression.operand, oldName, newName),
pattern = renameInExpression(expression.pattern, oldName, newName)
)
is CaseExpression -> expression.copy(
operand = expression.operand?.let { renameInExpression(it, oldName, newName) },
branches = expression.branches.map {
it.copy(
condition = renameInExpression(it.condition, oldName, newName),
result = renameInExpression(it.result, oldName, newName)
)
},
elseExpression = expression.elseExpression?.let { renameInExpression(it, oldName, newName) }
)
is CastExpression -> expression.copy(operand = renameInExpression(expression.operand, oldName, newName))
is CollateExpression -> expression.copy(operand = renameInExpression(expression.operand, oldName, newName))
is FunctionExpression -> expression.copy(
argument = expression.argument?.let { renameInExpression(it, oldName, newName) },
separator = expression.separator?.let { renameInExpression(it, oldName, newName) },
orderBy = expression.orderBy.map { it.copy(expression = renameInExpression(it.expression, oldName, newName)) },
filter = expression.filter?.let { renameInExpression(it, oldName, newName) }
)
is WindowExpression -> expression.copy(
argument = expression.argument?.let { renameInExpression(it, oldName, newName) },
partitionBy = expression.partitionBy.map { renameInExpression(it, oldName, newName) },
orderBy = expression.orderBy.map { it.copy(expression = renameInExpression(it.expression, oldName, newName)) },
offset = expression.offset?.let { renameInExpression(it, oldName, newName) },
defaultValue = expression.defaultValue?.let { renameInExpression(it, oldName, newName) },
frame = expression.frame?.let { frame ->
frame.copy(
start = renameInFrameBound(frame.start, oldName, newName),
end = renameInFrameBound(frame.end, oldName, newName)
)
}
)
is ColumnExpression, is LiteralExpression, is NextValueExpression, is CurrentValueExpression,
is MergeActionExpression, is ParameterExpression -> expression
}
private fun renameInFrameBound(bound: FrameBound, oldName: String, newName: String): FrameBound =
bound.copy(offset = bound.offset?.let { renameInExpression(it, oldName, newName) })
}
@@ -0,0 +1,76 @@
package kestrel.catalog
import kestrel.KestrelException
/**
* Mirrors Postgres' own internal sequence representation (`pg_sequences.last_value`/`is_called`)
* rather than "the next value to hand out" directly, because the two ALTER SEQUENCE behaviors this
* engine supports need genuinely different arithmetic: a plain `ALTER SEQUENCE ... INCREMENT BY n`
* (no RESTART) must recompute the NEXT value from the LAST one actually returned using the NEW
* increment (so incrementing by 100 right after NEXTVAL returned 1 gives 101, not "whatever was
* precomputed under the old increment"), while `RESTART [WITH n]` must instead make the very next
* NEXTVAL return `n` directly, bypassing that arithmetic entirely for one call - exactly the same
* shape as a fresh sequence's first-ever NEXTVAL returning `START WITH` untouched. [called] `false`
* captures both of those "return this value as-is, don't advance from it" cases uniformly.
*
* [value]/[called] live entirely in plain `var`s deliberately NOT covered by [Catalog]'s
* snapshot/restore copying of its `sequences` map entries - mirrors Postgres: a ROLLBACK undoes
* `CREATE`/`DROP`/`ALTER SEQUENCE` (the catalog entry itself, since [Catalog.restore] reinstalls
* whichever [Sequence] *instance* a snapshot captured a reference to) but never undoes a NEXTVAL
* advance, even one made by the very transaction being rolled back - the whole point of a sequence
* is to hand out values that never repeat, even across an aborted transaction, so two concurrent
* transactions can both call NEXTVAL without either blocking on the other's eventual
* commit/rollback decision. [Catalog.alterSequence] relies on this same identity trick in reverse:
* it always installs a brand new [Sequence] instance (see [altered]) rather than mutating this one
* in place, so ALTER SEQUENCE (unlike NEXTVAL) rolls back cleanly too.
*/
class Sequence(val definition: SequenceDefinition, initialValue: Long = definition.startValue, initialCalled: Boolean = false) {
private var value: Long = initialValue
private var called: Boolean = initialCalled
/** What the next call to [next] would return, without consuming it - used by [kestrel.persistence.SqlWriter] to dump a sequence's current position so a reload resumes from there instead of restarting at [SequenceDefinition.startValue]. */
fun peekNext(): Long = if (called) advance(value) else value
fun next(): Long = peekNext().also {
value = it
called = true
}
/**
* The raw `(value, called)` pair this instance currently holds, with no arithmetic and so no way
* to throw - unlike [peekNext], which may run [advance] and hit an exhausted-without-CYCLE bound.
* Used by [kestrel.store.sql.DiskCatalog] to persist a NEXTVAL advance (or an ALTER SEQUENCE's
* resulting position) exactly as it stands, rather than one step further ahead: computing that
* next step eagerly - right after a [next] call that legitimately landed exactly on
* [kestrel.catalog.SequenceDefinition.maxValue]/[SequenceDefinition.minValue] - would spuriously
* raise the "exhausted" exception a whole [next] call early, before anything ever actually asked
* for a value beyond the bound.
*/
fun snapshotPosition(): Pair<Long, Boolean> = value to called
/**
* Builds the [Sequence] `ALTER SEQUENCE` installs in place of this one: [newDefinition] takes
* over immediately. With [restartAt] given (Postgres' `RESTART [WITH n]`, resolved to
* [SequenceDefinition.startValue] by the caller for a bare `RESTART`), the next NEXTVAL returns
* exactly that value; otherwise this sequence's own last-returned value and "has it ever been
* called" flag both carry over unchanged, so the next NEXTVAL keeps advancing from where this
* one left off - just using [newDefinition]'s (possibly different) increment/bounds.
*/
fun altered(newDefinition: SequenceDefinition, restartAt: Long? = null): Sequence =
if (restartAt != null) Sequence(newDefinition, restartAt, initialCalled = false)
else Sequence(newDefinition, value, called)
private fun advance(previous: Long): Long {
val ascending = definition.incrementBy > 0
val candidate = runCatching { Math.addExact(previous, definition.incrementBy) }.getOrNull()
val exhausted = candidate == null ||
(ascending && candidate > definition.maxValue) ||
(!ascending && candidate < definition.minValue)
if (!exhausted) return candidate!!
if (!definition.cycle) {
val bound = if (ascending) "maximum value (${definition.maxValue})" else "minimum value (${definition.minValue})"
throw KestrelException("sequence '${definition.name}' has reached its $bound and does not CYCLE")
}
return if (ascending) definition.minValue else definition.maxValue
}
}
@@ -0,0 +1,26 @@
package kestrel.catalog
/**
* A sequence's static configuration - separate from [Sequence]'s mutable counter so that a
* `CREATE SEQUENCE`/`ALTER SEQUENCE`/`DROP SEQUENCE` (all fully transactional, unlike a NEXTVAL
* advance - see [Sequence]'s own doc) can be represented and rolled back like any other catalog
* object, by simply installing or removing an immutable value rather than mutating shared state.
*
* [startValue] is never changed by `ALTER SEQUENCE` (Postgres has no way to do this either) - it is
* only ever the value a bare `RESTART` (no `WITH n`) resets the counter back to, recorded once at
* `CREATE SEQUENCE` time and carried forward unchanged by every later `ALTER SEQUENCE` (see
* [Sequence.altered]).
*/
data class SequenceDefinition(
val name: String,
val incrementBy: Long,
val minValue: Long,
val maxValue: Long,
val startValue: Long,
val cycle: Boolean
) {
init {
require(incrementBy != 0L) { "sequence '$name' INCREMENT BY cannot be zero" }
require(minValue <= maxValue) { "sequence '$name' MINVALUE ($minValue) must not exceed MAXVALUE ($maxValue)" }
}
}
+107 -11
View File
@@ -2,27 +2,123 @@ package kestrel.catalog
import java.util.NavigableMap
import java.util.TreeMap
import kestrel.KestrelException
import kestrel.storage.Collation
import kestrel.storage.Value
import kestrel.storage.ValueOrdering
data class IndexDefinition(val name: String, val tableName: String, val columnName: String)
data class IndexDefinition(
val name: String,
val tableName: String,
val columnNames: List<String>,
val unique: Boolean = false
) {
val columnDisplayName: String get() = columnNames.joinToString(", ")
}
class SortedIndex(val definition: IndexDefinition) {
private val entries: TreeMap<Value, MutableList<Int>> = TreeMap(ValueOrdering)
/**
* A key used to navigate [SortedIndex.entries]. Every key actually stored in the map is
* [Exact] with exactly `definition.columnNames.size` values. [Low]/[High] only ever appear as
* *boundary* arguments to `tailMap`/`headMap` when a caller supplies fewer values than the
* index has columns (an equality prefix over the leading columns, or a range bound on a
* trailing column while earlier columns are pinned by equality) - they tell [IndexKeyOrdering]
* to treat the omitted trailing columns as -infinity/+infinity, which brackets exactly the
* stored keys that share the given prefix without needing a real sentinel [Value] for every
* column type (there's no universal "biggest TEXT" or "biggest DECIMAL").
*/
private sealed class IndexKey(val values: List<Value>) {
class Exact(values: List<Value>) : IndexKey(values)
class Low(values: List<Value>) : IndexKey(values)
class High(values: List<Value>) : IndexKey(values)
}
/**
* [collations] carries one [Collation] per indexed column, in the same order as
* [IndexDefinition.columnNames] - a plain-[Collation.Binary] entry for a column with no declared
* `COLLATE` (see [kestrel.catalog.Column.collation]), matching this engine's pre-COLLATE ordering
* exactly. Threading it through this comparator (rather than always comparing bytes/`String`
* order via the plain [ValueOrdering.compare]) is what makes an equality/range lookup through
* this index (see [SortedIndex.equalTo]/[SortedIndex.rangeOf], both driven entirely by this
* comparator) agree with what a full-scan `WHERE` filter through [kestrel.engine.Evaluator]
* would decide for the same column - without it, `WHERE name = 'BOB'` on a `NOCASE`-declared,
* indexed `name` column would silently miss a stored `'bob'` row once
* [kestrel.engine.IndexAccessPlanner] chooses to use the index, a real (not just incomplete)
* correctness bug rather than a documented scope-cut.
*/
private class IndexKeyOrdering(private val collations: List<Collation>) : Comparator<IndexKey> {
override fun compare(left: IndexKey, right: IndexKey): Int {
val common = minOf(left.values.size, right.values.size)
for (i in 0 until common) {
val cmp = ValueOrdering.compare(left.values[i], right.values[i], collations[i])
if (cmp != 0) return cmp
}
if (left.values.size == right.values.size) return 0
// Sizes differ only when comparing a real (Exact, full-length) stored key against a
// boundary that carries fewer values than the index has columns; the shorter side's
// kind decides whether the missing tail reads as -infinity (Low) or +infinity (High).
return if (left.values.size < right.values.size) {
when (left) { is IndexKey.Low -> -1; is IndexKey.High -> 1; is IndexKey.Exact -> -1 }
} else {
when (right) { is IndexKey.Low -> 1; is IndexKey.High -> -1; is IndexKey.Exact -> 1 }
}
}
}
class SortedIndex(
val definition: IndexDefinition,
collations: List<Collation> = List(definition.columnNames.size) { Collation.Binary }
) {
private val entries: TreeMap<IndexKey, MutableList<Int>> = TreeMap(IndexKeyOrdering(collations))
fun clear() = entries.clear()
fun add(key: Value, rowIndex: Int) {
if (key is Value.NullValue) return
entries.getOrPut(key) { mutableListOf() }.add(rowIndex)
/**
* NULLs are exempt from the uniqueness check and from lookups entirely, mirroring the
* standard SQL UNIQUE constraint (many NULLs never collide with each other or with anything
* else). For a composite key that means ANY component being NULL takes the whole row out of
* the index, not just the NULL column - a partial composite key is not a meaningful sort key.
*/
fun add(key: List<Value>, rowIndex: Int) {
require(key.size == definition.columnNames.size) { "index key arity mismatch for ${definition.name}" }
if (key.any { it is Value.NullValue }) return
val wrapped = IndexKey.Exact(key)
if (definition.unique && entries[wrapped]?.isNotEmpty() == true) {
throw KestrelException("UNIQUE constraint failed: ${definition.tableName}(${definition.columnDisplayName})")
}
entries.getOrPut(wrapped) { mutableListOf() }.add(rowIndex)
}
fun equalTo(key: Value): List<Int> = entries[key]?.toList() ?: emptyList()
/** Exact lookup; [key] must supply a value for every indexed column. */
fun equalTo(key: List<Value>): List<Int> = entries[IndexKey.Exact(key)]?.toList() ?: emptyList()
fun rangeOf(lower: Value?, lowerInclusive: Boolean, upper: Value?, upperInclusive: Boolean): List<Int> {
var view: NavigableMap<Value, MutableList<Int>> = entries
if (lower != null) view = view.tailMap(lower, lowerInclusive)
if (upper != null) view = view.headMap(upper, upperInclusive)
/**
* Range/prefix scan. [lower]/[upper] may each supply fewer values than the index has
* columns - the missing trailing columns are then left unconstrained (a pure equality
* prefix scan passes the same, full prefix, as both [lower] and [upper] with both bounds
* inclusive).
*/
fun rangeOf(lower: List<Value>?, lowerInclusive: Boolean, upper: List<Value>?, upperInclusive: Boolean): List<Int> {
var view: NavigableMap<IndexKey, MutableList<Int>> = entries
if (lower != null) view = view.tailMap(lowerBoundaryKey(lower, lowerInclusive), lowerInclusive)
if (upper != null) view = view.headMap(upperBoundaryKey(upper, upperInclusive), upperInclusive)
return view.values.flatten()
}
/**
* A shorter-than-full lower boundary must still admit stored keys whose extra trailing
* columns tie on the given prefix when the bound is inclusive (so it reads as -infinity,
* [IndexKey.Low]); an exclusive lower bound must reject those same tying keys entirely (so
* it reads as +infinity, [IndexKey.High]) - otherwise e.g. `age > 50` on a composite
* (age, name) index would let `age = 50` rows back in through the unconstrained `name` tail.
*/
private fun lowerBoundaryKey(values: List<Value>, inclusive: Boolean): IndexKey {
if (values.size == definition.columnNames.size) return IndexKey.Exact(values)
return if (inclusive) IndexKey.Low(values) else IndexKey.High(values)
}
/** Mirror image of [lowerBoundaryKey]: an inclusive upper bound reads as +infinity, an exclusive one as -infinity. */
private fun upperBoundaryKey(values: List<Value>, inclusive: Boolean): IndexKey {
if (values.size == definition.columnNames.size) return IndexKey.Exact(values)
return if (inclusive) IndexKey.High(values) else IndexKey.Low(values)
}
}
+125 -3
View File
@@ -1,15 +1,106 @@
package kestrel.catalog
import kestrel.sql.ast.Expression
import kestrel.storage.Collation
import kestrel.storage.ColumnType
data class Column(
val name: String,
val type: ColumnType,
val notNull: Boolean,
val primaryKey: Boolean
val primaryKey: Boolean,
val precision: Int? = null,
val scale: Int? = null,
/**
* A constant-only DEFAULT expression (validated by the executor to contain no column
* reference, subquery, or aggregate), used to fill a column omitted from an INSERT's column
* list and to backfill existing rows when the column is added later via ALTER TABLE.
*/
val default: Expression? = null,
/**
* `GENERATED ALWAYS AS (expression) STORED` - recomputed from this row's OTHER column values
* on every INSERT/UPDATE (the executor rejects an explicit value for this column outright,
* matching standard SQL). Mutually exclusive with [default] and never itself references
* another GENERATED column (validated by the executor at CREATE TABLE time, both engines) -
* see [kestrel.engine.Executor.validateGeneratedDefinitions].
*/
val generated: Expression? = null,
/**
* `col TYPE COLLATE name` - the collating sequence a bare reference to this column compares
* with when the query itself writes no explicit `COLLATE` (see [kestrel.sql.ast.CollateExpression],
* which always wins over this when present). Threaded through [kestrel.engine.ColumnResolver.declaredCollation]
* by every resolver built directly over a table's own schema (both engines' row/EXCLUDED/MERGE
* resolvers, [kestrel.engine.SelectRunner]'s per-source column lookup) so `WHERE`/`IN`/`BETWEEN`/
* a searched-`CASE` operand honor it automatically - a resolver with no such schema to consult
* (a set operation's output row, a trigger's NEW/OLD side falling through to its own schema
* notwithstanding) defaults to [Collation.Binary] via [kestrel.engine.ColumnResolver]'s own
* default method, exactly like a column never had one declared.
*/
val collation: Collation = Collation.Binary
)
class TableSchema(val name: String, val columns: List<Column>) {
enum class ForeignKeyAction { NoAction, Restrict, Cascade, SetNull }
val ForeignKeyAction.sqlKeyword: String
get() = when (this) {
ForeignKeyAction.NoAction -> "NO ACTION"
ForeignKeyAction.Restrict -> "RESTRICT"
ForeignKeyAction.Cascade -> "CASCADE"
ForeignKeyAction.SetNull -> "SET NULL"
}
/**
* `referencedColumns` is required to carry an enforced uniqueness guarantee in the referenced
* table as a group - either it is exactly the table's PRIMARY KEY (single-column or composite),
* or it is named (as an exact set) by one of that table's [UniqueConstraint]s. Those are the only
* two ways this engine guarantees a set of columns has no duplicate value combination to safely
* match a foreign key against. `columns`/`referencedColumns` always have the same size (validated
* at CREATE TABLE time) and are positionally paired: `columns[i]` corresponds to
* `referencedColumns[i]`.
*/
data class ForeignKeyConstraint(
val columns: List<String>,
val referencedTable: String,
val referencedColumns: List<String>,
val onDelete: ForeignKeyAction = ForeignKeyAction.NoAction,
val onUpdate: ForeignKeyAction = ForeignKeyAction.NoAction,
/** `CONSTRAINT name FOREIGN KEY (...)` - see [CheckConstraint.name]/[UniqueConstraint.name]'s own doc. */
val name: String? = null
)
data class CheckConstraint(val name: String?, val expression: Expression)
/**
* A UNIQUE constraint independent of the table's PRIMARY KEY. `columns` holds one entry for an
* inline `col TYPE UNIQUE` column definition, or several for a table-level `UNIQUE (a, b, ...)`.
* Per standard SQL, a row where any of these columns is NULL is exempt from the check (NULLs
* are never considered duplicates of each other).
*/
data class UniqueConstraint(val name: String?, val columns: List<String>)
class TableSchema(
val name: String,
val columns: List<Column>,
val foreignKeys: List<ForeignKeyConstraint> = emptyList(),
val checks: List<CheckConstraint> = emptyList(),
val uniqueConstraints: List<UniqueConstraint> = emptyList(),
/**
* Table-level `PRIMARY KEY (a, b, ...)`. Empty when the table instead declares its PRIMARY KEY
* inline on a single column (`col TYPE PRIMARY KEY`, tracked by [Column.primaryKey]) - see
* [primaryKeyColumns], which unifies both spellings into one canonical list.
*/
val compositePrimaryKey: List<String> = emptyList(),
/**
* `CREATE TEMP TABLE`/`CREATE TEMPORARY TABLE` - a table exactly like any other at runtime
* (same constraints, indexes, triggers all work), except [kestrel.persistence.SqlWriter] never
* writes it (or its rows, or any index/trigger defined on it) to a checkpoint or the redo log,
* so it does not survive a process restart, matching Postgres. Every place in [Catalog] that
* rebuilds a [TableSchema] from an existing one's fields (ADD/DROP/RENAME COLUMN, RENAME TABLE,
* a referencing table's FOREIGN KEY cleanup) must carry this flag forward explicitly - there is
* no `copy()` to do it automatically, since this stays a plain class, not a data class.
*/
val temporary: Boolean = false
) {
private val indexByName: Map<String, Int> =
columns.withIndex().associate { (index, column) -> column.name.lowercase() to index }
@@ -19,5 +110,36 @@ class TableSchema(val name: String, val columns: List<Column>) {
fun column(columnName: String): Column? = columnIndex(columnName)?.let { columns[it] }
val primaryKeyIndex: Int? get() = columns.indexOfFirst { it.primaryKey }.takeIf { it >= 0 }
/**
* The table's PRIMARY KEY column names in declared order, regardless of whether it came from
* an inline `col TYPE PRIMARY KEY` (exactly one [Column] with `primaryKey = true`) or a
* table-level `PRIMARY KEY (a, b, ...)` clause ([compositePrimaryKey]). Empty when the table
* has no PRIMARY KEY at all.
*/
val primaryKeyColumns: List<String> get() =
compositePrimaryKey.ifEmpty { columns.filter { it.primaryKey }.map { it.name } }
val primaryKeyIndices: List<Int> get() = primaryKeyColumns.map { columnIndex(it)!! }
/** Convenience for callers that only ever deal with single-column PRIMARY KEYs. */
val primaryKeyIndex: Int? get() = primaryKeyIndices.singleOrNull()
/**
* Every column group in this table with a guaranteed-unique combination of values: the
* PRIMARY KEY (if any, single-column or composite) plus each UNIQUE constraint. A valid
* FOREIGN KEY target must match one of these groups exactly (as a set of names).
*/
fun enforcedUniqueKeyGroups(): List<List<String>> {
val groups = mutableListOf<List<String>>()
if (primaryKeyColumns.isNotEmpty()) groups.add(primaryKeyColumns)
for (unique in uniqueConstraints) groups.add(unique.columns)
return groups
}
/** True when [columnNames], taken together as a set, exactly matches one enforced-unique key group. */
fun hasEnforcedUniqueness(columnNames: List<String>): Boolean {
val target = columnNames.map { it.lowercase() }.toSet()
if (target.size != columnNames.size) return false
return enforcedUniqueKeyGroups().any { group -> group.map { it.lowercase() }.toSet() == target }
}
}
@@ -0,0 +1,25 @@
package kestrel.catalog
import kestrel.sql.ast.Expression
import kestrel.sql.ast.Statement
import kestrel.sql.ast.TriggerEvent
import kestrel.sql.ast.TriggerTiming
/**
* `body` only ever holds INSERT/UPDATE/DELETE statements (enforced by the parser) - see
* [kestrel.sql.ast.CreateTriggerStatement] for why. `tableName` is kept in sync with
* [Catalog.renameTable] the same way [IndexDefinition.tableName] is; it is NOT rewritten when a
* column referenced by [whenClause] or [body] is renamed (unlike CHECK constraints, which use
* [CheckExpressionColumns.rename]) - a documented scope-cut, since [body] statements can each
* target a different table and no expression-rewrite utility for arbitrary nested statements
* exists yet. A rename of a column such a reference depends on simply surfaces as "no such column"
* the next time the trigger fires.
*/
data class TriggerDefinition(
val name: String,
val timing: TriggerTiming,
val event: TriggerEvent,
val tableName: String,
val whenClause: Expression?,
val body: List<Statement>
)
@@ -0,0 +1,5 @@
package kestrel.catalog
import kestrel.sql.ast.QueryStatement
data class ViewDefinition(val name: String, val query: QueryStatement)
+17 -1
View File
@@ -2,9 +2,11 @@ package kestrel.cli
import java.io.File
import kestrel.persistence.Persistence
import kestrel.store.sql.DiskCatalog
fun main(arguments: Array<String>) {
var dataDir: File? = null
var diskDir: File? = null
var scriptPath: String? = null
var index = 0
while (index < arguments.size) {
@@ -16,13 +18,27 @@ fun main(arguments: Array<String>) {
argument.startsWith("--data=") -> {
dataDir = File(argument.substringAfter('=')); index += 1
}
argument == "--disk" -> {
diskDir = File(arguments[index + 1]); index += 2
}
argument.startsWith("--disk=") -> {
diskDir = File(argument.substringAfter('=')); index += 1
}
else -> {
scriptPath = argument; index += 1
}
}
}
if (dataDir != null && diskDir != null) {
System.err.println("Error: --data (in-memory engine, text WAL) and --disk (disk-backed engine) are mutually exclusive")
return
}
val persistence = dataDir?.let { Persistence(it) }
val repl = Repl(persistence = persistence)
val diskCatalog = diskDir?.let {
it.mkdirs()
DiskCatalog.open(File(it, "kestrel.data"), File(it, "kestrel.wal"))
}
val repl = Repl(persistence = persistence, diskCatalog = diskCatalog)
val reader = if (scriptPath != null) File(scriptPath).bufferedReader() else System.`in`.bufferedReader()
reader.use { repl.run(it) }
}
+288 -29
View File
@@ -4,42 +4,89 @@ import java.io.BufferedReader
import kestrel.KestrelException
import kestrel.LexException
import kestrel.catalog.Catalog
import kestrel.catalog.ForeignKeyAction
import kestrel.catalog.sqlKeyword
import kestrel.engine.AlterSequenceResult
import kestrel.engine.AlterTableResult
import kestrel.engine.BeginResult
import kestrel.engine.CommitResult
import kestrel.engine.CreateIndexResult
import kestrel.engine.CreateSequenceResult
import kestrel.engine.CreateTableResult
import kestrel.engine.CreateTriggerResult
import kestrel.engine.CreateViewResult
import kestrel.engine.DeallocateResult
import kestrel.engine.DeleteResult
import kestrel.engine.DropIndexResult
import kestrel.engine.DropSequenceResult
import kestrel.engine.DropTableResult
import kestrel.engine.DropTriggerResult
import kestrel.engine.DropViewResult
import kestrel.engine.ExplainResult
import kestrel.engine.Executor
import kestrel.engine.InsertResult
import kestrel.engine.MergeResult
import kestrel.engine.PrepareResult
import kestrel.engine.ReleaseSavepointResult
import kestrel.engine.RollbackResult
import kestrel.engine.RollbackToSavepointResult
import kestrel.engine.SavepointResult
import kestrel.engine.SelectResult
import kestrel.engine.StatementResult
import kestrel.engine.TruncateTableResult
import kestrel.engine.UpdateResult
import kestrel.persistence.Persistence
import kestrel.persistence.SqlWriter
import kestrel.sql.ast.AlterTableStatement
import kestrel.sql.ast.BeginStatement
import kestrel.sql.ast.CommitStatement
import kestrel.sql.ast.CreateIndexStatement
import kestrel.sql.ast.CreateTableStatement
import kestrel.sql.ast.DeleteStatement
import kestrel.sql.ast.DropIndexStatement
import kestrel.sql.ast.DropTableStatement
import kestrel.sql.ast.ExecuteStatement
import kestrel.sql.ast.InsertStatement
import kestrel.sql.ast.MergeStatement
import kestrel.sql.ast.ReleaseSavepointStatement
import kestrel.sql.ast.RollbackStatement
import kestrel.sql.ast.RollbackToSavepointStatement
import kestrel.sql.ast.SavepointStatement
import kestrel.sql.ast.Statement
import kestrel.sql.ast.TruncateTableStatement
import kestrel.sql.ast.UpdateStatement
import kestrel.sql.lexer.Lexer
import kestrel.storage.displayWith
import kestrel.sql.lexer.Token
import kestrel.sql.lexer.TokenType
import kestrel.sql.parser.Parser
import kestrel.store.sql.DiskCatalog
import kestrel.store.sql.DiskExecutor
/**
* `diskCatalog` swaps the storage backend for every statement from the in-memory [Catalog]/
* [Executor] to the real disk-backed [DiskCatalog]/[DiskExecutor] pair (pager/WAL/B+Tree) - the two
* engines are mutually exclusive, never blended in one session, and [persistence] (the in-memory
* engine's own text-based WAL replay) makes no sense once statements are already durable on disk.
*/
class Repl(
private val output: Appendable = System.out,
private val errorOutput: Appendable = System.err,
private val persistence: Persistence? = null
private val persistence: Persistence? = null,
private val diskCatalog: DiskCatalog? = null
) {
private val catalog = Catalog()
private val executor = Executor(catalog)
private val diskExecutor = diskCatalog?.let { DiskExecutor(it) }
private val pending = StringBuilder()
private var pendingCommit: MutableList<String>? = null
private val savepointMarks = mutableListOf<Pair<String, Int>>()
private var replaying = false
init {
require(persistence == null || diskCatalog == null) {
"Repl cannot combine text-based Persistence with a disk-backed DiskCatalog - pick one storage backend"
}
if (persistence != null) {
val restored = persistence.load()
if (restored.isNotBlank()) {
@@ -63,6 +110,11 @@ class Repl(
}
private fun finish() {
if (diskExecutor != null) {
if (diskExecutor.inTransaction) diskExecutor.execute(RollbackStatement)
diskCatalog?.close()
return
}
if (executor.inTransaction) executor.execute(RollbackStatement)
pendingCommit = null
persistence?.checkpoint(SqlWriter.dump(catalog))
@@ -89,12 +141,40 @@ class Repl(
return
}
val meaningful = tokens.dropLast(1)
if (meaningful.isNotEmpty() && meaningful.last().type == TokenType.Semicolon) {
if (meaningful.isNotEmpty() && meaningful.last().type == TokenType.Semicolon && !endsInsideOpenBlock(meaningful)) {
executeBuffer(pending.toString())
pending.setLength(0)
}
}
/**
* A trigger body's `BEGIN ... END` wraps statements that each end in their own `;`, so the
* naive "buffer ends in a semicolon" check above would fire mid-body, on the first inner
* statement, well before the trigger definition is actually complete - unlike every other
* statement this REPL buffers line by line, a trigger body is the only one with `;` nested
* inside it. Distinguishing a trigger-body `BEGIN` from a bare `BEGIN`/`BEGIN TRANSACTION`
* (which never takes a matching `END`) only needs one fact: a fresh top-level statement's
* first token is always either the very start of the buffer or right after a `;` - a
* trigger-body `BEGIN` is never in that position, since `FOR EACH ROW` (or a `WHEN` clause)
* always precedes it. `CASE ... END` nests on the same stack since it needs the exact same
* "don't fire on an inner `;`" protection for a `CASE` that spans multiple typed lines.
*/
private fun endsInsideOpenBlock(tokens: List<Token>): Boolean {
var depth = 0
for (index in tokens.indices) {
when (tokens[index].type) {
TokenType.Case -> depth++
TokenType.Begin -> {
val previous = tokens.getOrNull(index - 1)?.type
if (previous != null && previous != TokenType.Semicolon) depth++
}
TokenType.End -> if (depth > 0) depth--
else -> Unit
}
}
return depth > 0
}
private fun flushPending() {
if (pending.isNotBlank()) {
executeBuffer(pending.toString())
@@ -111,46 +191,162 @@ class Repl(
}
for (statement in statements) {
try {
val result = executor.execute(statement)
// EXECUTE is resolved to its concrete, parameter-free statement HERE, once, rather
// than inside Executor.execute's own dispatch - so recordForPersistence below sees
// (and durably logs) the same substituted INSERT/UPDATE/DELETE/SELECT that actually
// ran, not the literal `EXECUTE name(...)` text. That literal text is meaningless to
// replay anyway: a freshly reopened session has no PREPARE-d statement named `name`
// (see PrepareStatement's own doc - prepared statements are session-scoped, never
// persisted). Resolving exactly once also matters for correctness, not just tidiness:
// an EXECUTE argument could itself be a volatile expression, so resolving twice
// (once to run it, once again to decide what to persist) would evaluate it twice.
val effective = if (diskExecutor == null && statement is ExecuteStatement) {
executor.resolveExecute(statement)
} else {
statement
}
// Computed BEFORE execute() runs, not after - DROP TABLE/RENAME TABLE/DROP INDEX
// mutate (or remove) the very catalog entry this asks about, so checking afterward
// could never again tell a table that WAS temporary apart from one that never
// existed at all. CREATE TABLE is the one case checked from the statement itself
// rather than the catalog, since the table it names does not exist yet either way.
val temporaryTargets = if (diskExecutor == null) temporaryTableTargets(effective) else emptySet()
val result = if (diskExecutor != null) diskExecutor.execute(statement) else executor.execute(effective)
printResult(result)
if (!replaying) recordForPersistence(statement)
if (diskExecutor == null && !replaying) recordForPersistence(effective, temporaryTargets)
} catch (exception: KestrelException) {
printError(exception.message)
}
}
}
private fun recordForPersistence(statement: Statement) {
/**
* Every table name [statement] itself creates/drops/alters/indexes/reads-and-writes-as-its-own-
* target that is currently a TEMP table (see [kestrel.catalog.TableSchema.temporary]) -
* deliberately NOT a general "every table this statement's body could possibly touch" scan
* (an INSERT's SELECT source, a MERGE's USING source, a view/trigger's query body): those are
* read-only references that either replay fine (the source table still exists after reload) or
* fail loudly with "no such table" if it does not, which is an acceptable, honest failure mode -
* see [kestrel.persistence.SqlWriter.dump]'s own note on the same tradeoff for CREATE VIEW.
*/
private fun temporaryTableTargets(statement: Statement): Set<String> = when (statement) {
is CreateTableStatement -> if (statement.temporary) setOf(statement.tableName) else emptySet()
is DropTableStatement -> temporaryOf(statement.tableName)
is TruncateTableStatement -> statement.tableNames.filter { isTemporaryTable(it) }.toSet()
is AlterTableStatement -> temporaryOf(statement.tableName)
is CreateIndexStatement -> temporaryOf(statement.tableName)
is DropIndexStatement -> catalog.indexDefinitions()
.firstOrNull { it.name.equals(statement.indexName, ignoreCase = true) }
?.let { temporaryOf(it.tableName) } ?: emptySet()
is InsertStatement -> temporaryOf(statement.tableName)
is UpdateStatement -> temporaryOf(statement.tableName)
is DeleteStatement -> temporaryOf(statement.tableName)
is MergeStatement -> temporaryOf(statement.targetTable)
else -> emptySet()
}
private fun isTemporaryTable(tableName: String): Boolean = catalog.tableOrNull(tableName)?.schema?.temporary == true
private fun temporaryOf(tableName: String): Set<String> = if (isTemporaryTable(tableName)) setOf(tableName) else emptySet()
private fun recordForPersistence(statement: Statement, temporaryTargets: Set<String>) {
when (statement) {
is BeginStatement -> pendingCommit = mutableListOf()
is BeginStatement -> {
pendingCommit = mutableListOf()
savepointMarks.clear()
}
is CommitStatement -> {
pendingCommit?.let { persistence?.appendWal(it) }
pendingCommit = null
savepointMarks.clear()
}
is RollbackStatement -> pendingCommit = null
else -> if (SqlWriter.canPersist(statement)) {
val sql = SqlWriter.write(statement)
val buffer = pendingCommit
if (buffer != null) buffer.add(sql) else persistence?.appendWal(listOf(sql))
is RollbackStatement -> {
pendingCommit = null
savepointMarks.clear()
}
is SavepointStatement -> {
pendingCommit?.let { savepointMarks.add(statement.name to it.size) }
}
is ReleaseSavepointStatement -> {
val index = savepointMarks.indexOfLast { it.first.equals(statement.name, ignoreCase = true) }
while (savepointMarks.size > index) savepointMarks.removeAt(savepointMarks.size - 1)
}
is RollbackToSavepointStatement -> {
val index = savepointMarks.indexOfLast { it.first.equals(statement.name, ignoreCase = true) }
val mark = savepointMarks[index].second
pendingCommit?.let { buffer -> while (buffer.size > mark) buffer.removeAt(buffer.size - 1) }
while (savepointMarks.size > index + 1) savepointMarks.removeAt(savepointMarks.size - 1)
}
// A TRUNCATE naming both TEMP and regular tables in one statement is the only
// persistable statement that can target more than one table (every other case above
// fully skips or fully persists) - the regular ones still need their truncation durably
// logged even though the TEMP ones must not appear in the text at all, since after
// reload a TEMP table's own CREATE never replayed and "TRUNCATE TABLE that_name" would
// otherwise fail with "no such table" for a name that was only ever supposed to vanish
// quietly.
is TruncateTableStatement -> {
val persistedNames = statement.tableNames.filterNot { it in temporaryTargets }
if (persistedNames.isNotEmpty()) {
appendToWal(SqlWriter.write(statement.copy(tableNames = persistedNames)))
}
}
else -> if (temporaryTargets.isEmpty() && SqlWriter.canPersist(statement)) {
appendToWal(SqlWriter.write(statement))
}
}
}
private fun appendToWal(sql: String) {
val buffer = pendingCommit
if (buffer != null) buffer.add(sql) else persistence?.appendWal(listOf(sql))
}
private fun printResult(result: StatementResult) {
if (replaying) return
when (result) {
is SelectResult -> output.appendLine(TableRenderer.render(result))
is InsertResult -> output.appendLine("${rowsWord(result.count)} inserted")
is UpdateResult -> output.appendLine("${rowsWord(result.count)} updated")
is DeleteResult -> output.appendLine("${rowsWord(result.count)} deleted")
is InsertResult -> {
output.appendLine("${rowsWord(result.count)} inserted")
result.returning?.let { output.appendLine(TableRenderer.render(it)) }
}
is UpdateResult -> {
output.appendLine("${rowsWord(result.count)} updated")
result.returning?.let { output.appendLine(TableRenderer.render(it)) }
}
is DeleteResult -> {
output.appendLine("${rowsWord(result.count)} deleted")
result.returning?.let { output.appendLine(TableRenderer.render(it)) }
}
is CreateTableResult -> output.appendLine("Table ${result.tableName} created")
is DropTableResult -> output.appendLine("Table ${result.tableName} dropped")
is TruncateTableResult -> output.appendLine("Table(s) ${result.tableNames.joinToString(", ")} truncated")
is MergeResult -> {
output.appendLine(
"${rowsWord(result.insertedCount)} inserted, ${rowsWord(result.updatedCount)} updated, " +
"${rowsWord(result.deletedCount)} deleted"
)
result.returning?.let { output.appendLine(TableRenderer.render(it)) }
}
is AlterTableResult -> output.appendLine("Table ${result.tableName} altered")
is CreateIndexResult -> output.appendLine("Index ${result.indexName} created")
is DropIndexResult -> output.appendLine("Index ${result.indexName} dropped")
is CreateViewResult -> output.appendLine("View ${result.viewName} created")
is DropViewResult -> output.appendLine("View ${result.viewName} dropped")
is CreateTriggerResult -> output.appendLine("Trigger ${result.triggerName} created")
is DropTriggerResult -> output.appendLine("Trigger ${result.triggerName} dropped")
is CreateSequenceResult -> output.appendLine("Sequence ${result.sequenceName} created")
is DropSequenceResult -> output.appendLine("Sequence ${result.sequenceName} dropped")
is AlterSequenceResult -> output.appendLine("Sequence ${result.sequenceName} altered")
is ExplainResult -> result.plan.forEach { output.appendLine(it) }
BeginResult -> output.appendLine("Transaction started")
CommitResult -> output.appendLine("Transaction committed")
RollbackResult -> output.appendLine("Transaction rolled back")
is SavepointResult -> output.appendLine("Savepoint ${result.name} created")
is ReleaseSavepointResult -> output.appendLine("Savepoint ${result.name} released")
is RollbackToSavepointResult -> output.appendLine("Rolled back to savepoint ${result.name}")
is PrepareResult -> output.appendLine("Prepared statement ${result.name} created")
is DeallocateResult ->
output.appendLine(if (result.all) "All prepared statements deallocated" else "Prepared statement ${result.name} deallocated")
}
}
@@ -158,14 +354,15 @@ class Repl(
val parts = command.split(Regex("\\s+"), limit = 2)
when (parts[0]) {
".exit", ".quit" -> return true
".tables" -> {
val names = catalog.tableNames
if (names.isEmpty()) output.appendLine("(no tables)") else names.forEach { output.appendLine(it) }
}
".indexes" -> {
val names = catalog.indexNames
if (names.isEmpty()) output.appendLine("(no indexes)") else names.forEach { output.appendLine(it) }
}
".tables" -> printNames(tableNames(), "(no tables)")
".indexes" -> printNames(indexNames(), "(no indexes)")
".views" -> printNames(viewNames(), "(no views)")
".triggers" -> printNames(triggerNames(), "(no triggers)")
".sequences" -> printNames(sequenceNames(), "(no sequences)")
".prepared" -> printNames(
diskExecutor?.preparedStatementNames ?: executor.preparedStatementNames,
"(no prepared statements)"
)
".schema" -> {
if (parts.size < 2 || parts[1].isBlank()) {
printError("usage: .schema <table>")
@@ -178,25 +375,87 @@ class Repl(
return false
}
private fun printNames(names: List<String>, emptyMessage: String) {
if (names.isEmpty()) output.appendLine(emptyMessage) else names.forEach { output.appendLine(it) }
}
private fun tableNames(): List<String> = diskCatalog?.tableNames ?: catalog.tableNames
private fun viewNames(): List<String> = diskCatalog?.viewNames ?: catalog.viewNames
private fun triggerNames(): List<String> = diskCatalog?.triggerNames ?: catalog.triggerNames
private fun sequenceNames(): List<String> = diskCatalog?.sequenceNames ?: catalog.sequenceNames
private fun indexNames(): List<String> {
val disk = diskCatalog ?: return catalog.indexNames
return disk.tableNames.flatMap { disk.table(it).secondaryIndexes.map { index -> index.name } }
}
/** Every `CREATE [UNIQUE] INDEX ...;` line for [tableName], one engine or the other - never both. */
private fun indexLines(tableName: String): List<String> {
val disk = diskCatalog
if (disk != null) {
val table = disk.tableOrNull(tableName) ?: return emptyList()
return table.secondaryIndexes.map { index ->
val uniqueKeyword = if (index.unique) "UNIQUE " else ""
"CREATE ${uniqueKeyword}INDEX ${index.name} ON $tableName (${index.columnNames.joinToString(", ")});"
}
}
return catalog.indexDefinitions()
.filter { it.tableName.equals(tableName, ignoreCase = true) }
.map { index ->
val uniqueKeyword = if (index.unique) "UNIQUE " else ""
"CREATE ${uniqueKeyword}INDEX ${index.name} ON $tableName (${index.columnDisplayName});"
}
}
/** Every `CREATE TRIGGER ...` block for [tableName], one engine or the other - never both. */
private fun triggerLines(tableName: String): List<String> {
val definitions = diskCatalog?.triggerDefinitions() ?: catalog.triggerDefinitions()
return definitions
.filter { it.tableName.equals(tableName, ignoreCase = true) }
.map { SqlWriter.triggerText(it) }
}
private fun printSchema(tableName: String) {
val table = catalog.tableOrNull(tableName)
if (table == null) {
val schema = diskCatalog?.tableOrNull(tableName)?.schema
?: diskCatalog?.temporaryTableOrNull(tableName)?.schema
?: catalog.tableOrNull(tableName)?.schema
if (schema == null) {
printError("no such table: $tableName")
return
}
val schema = table.schema
val builder = StringBuilder()
builder.appendLine("CREATE TABLE ${schema.name} (")
schema.columns.forEachIndexed { index, column ->
val lines = mutableListOf<String>()
schema.columns.forEach { column ->
val constraints = buildString {
if (column.primaryKey) append(" PRIMARY KEY")
if (column.notNull && !column.primaryKey) append(" NOT NULL")
column.default?.let { append(" DEFAULT ${SqlWriter.expressionText(it)}") }
}
val comma = if (index < schema.columns.size - 1) "," else ""
builder.appendLine(" ${column.name} ${column.type.displayName}$constraints$comma")
lines.add(" ${column.name} ${column.type.displayWith(column.precision, column.scale)}$constraints")
}
builder.append(");")
if (schema.compositePrimaryKey.isNotEmpty()) {
lines.add(" PRIMARY KEY (${schema.compositePrimaryKey.joinToString(", ")})")
}
schema.foreignKeys.forEach { fk ->
val onDelete = if (fk.onDelete != ForeignKeyAction.NoAction) " ON DELETE ${fk.onDelete.sqlKeyword}" else ""
val onUpdate = if (fk.onUpdate != ForeignKeyAction.NoAction) " ON UPDATE ${fk.onUpdate.sqlKeyword}" else ""
lines.add(
" FOREIGN KEY (${fk.columns.joinToString(", ")}) REFERENCES ${fk.referencedTable} " +
"(${fk.referencedColumns.joinToString(", ")})$onDelete$onUpdate"
)
}
schema.checks.forEach { check ->
lines.add(" CHECK (${SqlWriter.expressionText(check.expression)})")
}
schema.uniqueConstraints.forEach { unique ->
lines.add(" UNIQUE (${unique.columns.joinToString(", ")})")
}
builder.append(lines.joinToString(",\n"))
builder.append("\n);")
output.appendLine(builder.toString())
indexLines(schema.name).forEach { output.appendLine(it) }
triggerLines(schema.name).forEach { output.appendLine(it) }
}
private fun printError(message: String?) {
@@ -31,6 +31,8 @@ object TableRenderer {
is Value.RealValue -> value.value.toString()
is Value.TextValue -> value.value
is Value.BooleanValue -> value.value.toString()
is Value.DecimalValue -> value.value.toPlainString()
is Value.DateValue -> value.value.toString()
is Value.NullValue -> "NULL"
}
@@ -0,0 +1,147 @@
package kestrel.engine
import java.math.BigDecimal
import java.math.MathContext
import java.math.RoundingMode
import kestrel.KestrelException
import kestrel.sql.ast.ColumnExpression
import kestrel.sql.ast.Expression
import kestrel.sql.ast.OrderKey
import kestrel.storage.Collation
import kestrel.storage.Value
import kestrel.storage.ValueOrdering
import kestrel.storage.typeName
/**
* COUNT/SUM/AVG/MIN/MAX over a fixed list of rows - the one piece of arithmetic shared by grouped
* aggregation ([GroupAggregation], which collapses a whole group into one row) and windowed
* aggregation (a [kestrel.sql.ast.WindowExpression] with [kestrel.sql.ast.WindowFunctionKind.Aggregate],
* which instead computes the same thing once per partition/frame and keeps every row). Both callers
* hand in their own `evaluateRow` so the argument expression is resolved the same way it would be
* anywhere else in that context (correlated outer columns, subqueries, and so on).
*/
internal object AggregateFunctions {
fun compute(
name: String,
argument: Expression?,
star: Boolean,
rows: List<List<Value>>,
evaluateRow: (Expression, List<Value>) -> Value,
separator: Expression? = null,
orderBy: List<OrderKey> = emptyList(),
distinct: Boolean = false,
declaredCollationOf: (ColumnExpression) -> Collation? = { null }
): Value {
val upperName = name.uppercase()
if (upperName == "COUNT") {
if (star) return Value.IntegerValue(rows.size.toLong())
val arg = argument ?: return Value.IntegerValue(rows.size.toLong())
val nonNull = rows.map { evaluateRow(arg, it) }.filter { it !is Value.NullValue }
val count = if (distinct) nonNull.distinctByCollation(resolveCollation(arg, declaredCollationOf)).size else nonNull.size
return Value.IntegerValue(count.toLong())
}
if (upperName == "STRING_AGG") return stringAgg(argument, separator, orderBy, rows, evaluateRow, declaredCollationOf)
val arg = argument ?: throw KestrelException("aggregate function $name requires an argument")
val nonNullValues = rows.map { evaluateRow(arg, it) }.filter { it !is Value.NullValue }
val values = if (distinct) nonNullValues.distinctByCollation(resolveCollation(arg, declaredCollationOf)) else nonNullValues
return when (upperName) {
"SUM" -> sum(values)
"AVG" -> average(values)
"MIN" -> values.minWithOrNull(ValueOrdering) ?: Value.NullValue
"MAX" -> values.maxWithOrNull(ValueOrdering) ?: Value.NullValue
else -> throw KestrelException("unknown function: $name")
}
}
/**
* NULL [argument] values are skipped (standard STRING_AGG behavior, matching how SUM/AVG/MIN/MAX
* above already drop NULLs before combining). With no [orderBy], values keep [rows]' own order -
* for a GROUP BY aggregate that is source/scan order, which is exactly what every other order-
* sensitive-but-unordered SQL construct in this engine already falls back to. [separator] is
* evaluated once, against whichever surviving row ends up first after any [orderBy] sort - it is
* expected to be a per-call constant (a literal in every realistic query), so which row supplies it
* only matters if someone writes a genuinely row-varying separator expression, an edge case no SQL
* engine defines cleanly either.
*/
private fun stringAgg(
argument: Expression?,
separator: Expression?,
orderBy: List<OrderKey>,
rows: List<List<Value>>,
evaluateRow: (Expression, List<Value>) -> Value,
declaredCollationOf: (ColumnExpression) -> Collation? = { null }
): Value {
val valueArgument = argument ?: throw KestrelException("STRING_AGG requires a value argument")
val separatorArgument = separator ?: throw KestrelException("STRING_AGG requires a separator argument")
var entries = rows.mapNotNull { row ->
val value = evaluateRow(valueArgument, row)
if (value is Value.NullValue) null else row to text(value, "STRING_AGG")
}
if (entries.isEmpty()) return Value.NullValue
if (orderBy.isNotEmpty()) {
val comparator = Comparator<Pair<List<Value>, String>> { first, second ->
var result = 0
for (key in orderBy) {
val comparison = compareOrderKeyValues(
evaluateRow(key.expression, first.first),
evaluateRow(key.expression, second.first),
key,
declaredCollationOf
)
if (comparison != 0) {
result = comparison
break
}
}
result
}
entries = entries.sortedWith(comparator)
}
val separatorText = text(evaluateRow(separatorArgument, entries.first().first), "STRING_AGG")
return Value.TextValue(entries.joinToString(separatorText) { it.second })
}
private fun text(value: Value, function: String): String = when (value) {
is Value.TextValue -> value.value
else -> throw KestrelException("function $function requires a TEXT argument but got ${value.typeName}")
}
private fun sum(values: List<Value>): Value {
if (values.isEmpty()) return Value.NullValue
if (values.all { it is Value.IntegerValue }) {
return Value.IntegerValue(values.sumOf { (it as Value.IntegerValue).value })
}
if (isExactDecimalMix(values)) {
return Value.DecimalValue(values.fold(BigDecimal.ZERO) { acc, value -> acc + asBigDecimal(value) })
}
return Value.RealValue(values.sumOf { numeric(it) })
}
private fun average(values: List<Value>): Value {
if (values.isEmpty()) return Value.NullValue
if (isExactDecimalMix(values)) {
val total = values.fold(BigDecimal.ZERO) { acc, value -> acc + asBigDecimal(value) }
return Value.DecimalValue(total.divide(BigDecimal(values.size), MathContext(DECIMAL_AVERAGE_PRECISION, RoundingMode.HALF_UP)))
}
return Value.RealValue(values.sumOf { numeric(it) } / values.size)
}
/** SUM/AVG stay exact (BigDecimal) rather than falling back to Double when a DECIMAL value is involved. */
private fun isExactDecimalMix(values: List<Value>): Boolean =
values.any { it is Value.DecimalValue } && values.all { it is Value.DecimalValue || it is Value.IntegerValue }
private fun asBigDecimal(value: Value): BigDecimal = when (value) {
is Value.DecimalValue -> value.value
is Value.IntegerValue -> BigDecimal(value.value)
else -> throw KestrelException("aggregate requires numeric operands but got ${value.typeName}")
}
private fun numeric(value: Value): Double = when (value) {
is Value.IntegerValue -> value.value.toDouble()
is Value.RealValue -> value.value
is Value.DecimalValue -> value.value.toDouble()
else -> throw KestrelException("aggregate requires numeric operands but got ${value.typeName}")
}
private const val DECIMAL_AVERAGE_PRECISION = 20
}
@@ -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
}
+413 -13
View File
@@ -1,40 +1,345 @@
package kestrel.engine
import java.math.BigDecimal
import java.math.RoundingMode
import java.time.LocalDate
import kestrel.KestrelException
import kestrel.catalog.TableSchema
import kestrel.engine.function.ScalarFunctions
import kestrel.sql.ast.BetweenExpression
import kestrel.sql.ast.BinaryExpression
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.ExistsExpression
import kestrel.sql.ast.Expression
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.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.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.Value
import kestrel.storage.typeName
fun interface ColumnResolver {
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) {
constructor(schema: TableSchema?, row: Row?) : this(schema?.let { active ->
ColumnResolver { expression ->
val index = active.columnIndex(expression.name)
?: throw KestrelException("no such column: ${expression.name}")
row!!.values[index]
}
})
fun interface SubqueryRunner {
fun run(select: SelectStatement, outer: ColumnResolver?): SelectResult
}
/**
* 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)
?: throw KestrelException("no such column: ${expression.name}")
return row!!.values[index]
}
override fun declaredCollation(expression: ColumnExpression): Collation? =
active.column(expression.name)?.collation
}
},
subqueries,
sequences
)
fun evaluate(expression: Expression): Value = when (expression) {
is LiteralExpression -> expression.value
is ColumnExpression -> evaluateColumn(expression)
is UnaryExpression -> evaluateUnary(expression)
is CollateExpression -> evaluate(expression.operand)
is BinaryExpression -> evaluateBinary(expression)
is FunctionExpression -> throw KestrelException(
"aggregate function ${expression.name} is not allowed here"
)
is WindowExpression -> throw KestrelException(
"window function ${expression.name} is not allowed here"
)
is CallExpression -> evaluateCall(expression)
is IsNullExpression -> evaluateIsNull(expression)
is InExpression -> evaluateIn(expression)
is BetweenExpression -> evaluateBetween(expression)
is LikeExpression -> evaluateLike(expression)
is CaseExpression -> evaluateCase(expression)
is ScalarSubqueryExpression -> evaluateScalarSubquery(expression)
is ExistsExpression -> evaluateExists(expression)
is InSubqueryExpression -> evaluateInSubquery(expression)
is CastExpression -> evaluateCast(expression)
is NextValueExpression -> evaluateNextValue(expression)
is CurrentValueExpression -> evaluateCurrentValue(expression)
is MergeActionExpression -> Value.TextValue(mergeAction ?: throw KestrelException("MERGE_ACTION() is not allowed here"))
is ParameterExpression -> throw KestrelException(
"parameter reference \$${expression.index} is only valid inside a PREPARE-d statement, resolved by EXECUTE"
)
}
private fun evaluateNextValue(expression: NextValueExpression): Value = Value.IntegerValue(
(sequences ?: throw KestrelException("NEXTVAL is not allowed here")).nextValue(expression.sequenceName)
)
private fun evaluateCurrentValue(expression: CurrentValueExpression): Value = Value.IntegerValue(
(sequences ?: throw KestrelException("CURRVAL is not allowed here")).currentValue(expression.sequenceName)
)
private fun runSubquery(select: SelectStatement): SelectResult =
(subqueries ?: throw KestrelException("subqueries are not allowed here")).run(select, resolver)
private fun evaluateScalarSubquery(expression: ScalarSubqueryExpression): Value {
val result = runSubquery(expression.select)
if (result.columns.size != 1) {
throw KestrelException("scalar subquery must return exactly one column but returned ${result.columns.size}")
}
return when {
result.rows.isEmpty() -> Value.NullValue
result.rows.size > 1 -> throw KestrelException("scalar subquery returned more than one row")
else -> result.rows.single().single()
}
}
private fun evaluateExists(expression: ExistsExpression): Value =
Value.BooleanValue(runSubquery(expression.select).rows.isNotEmpty())
private fun evaluateInSubquery(expression: InSubqueryExpression): Value {
val operand = evaluate(expression.operand)
if (operand is Value.NullValue) return Value.NullValue
val collation = collationOf(expression.operand) ?: Collation.Binary
val result = runSubquery(expression.select)
if (result.columns.size != 1) {
throw KestrelException("IN subquery must return exactly one column but returned ${result.columns.size}")
}
var sawNull = false
for (row in result.rows) {
val candidate = row.single()
if (candidate is Value.NullValue) {
sawNull = true
continue
}
if (compareOperands(operand, candidate, collation) == 0) return Value.BooleanValue(!expression.negated)
}
return if (sawNull) Value.NullValue else Value.BooleanValue(expression.negated)
}
private fun evaluateCast(expression: CastExpression): Value {
val operand = evaluate(expression.operand)
if (operand is Value.NullValue) return Value.NullValue
return when (expression.targetType) {
ColumnType.Integer -> castToInteger(operand)
ColumnType.Real -> castToReal(operand)
ColumnType.Text -> Value.TextValue(castToText(operand))
ColumnType.Boolean -> castToBoolean(operand)
ColumnType.Decimal -> castToDecimal(operand, expression.precision, expression.scale)
ColumnType.Date -> castToDate(operand)
}
}
private fun castToInteger(value: Value): Value = Value.IntegerValue(
when (value) {
is Value.IntegerValue -> value.value
is Value.RealValue -> value.value.toLong()
is Value.DecimalValue -> value.value.toLong()
is Value.DateValue -> value.value.toEpochDay()
is Value.TextValue -> value.value.trim().toLongOrNull()
?: throw KestrelException("cannot cast '${value.value}' to INTEGER")
else -> throw KestrelException("cannot cast ${value.typeName} to INTEGER")
}
)
private fun castToReal(value: Value): Value = Value.RealValue(
when (value) {
is Value.IntegerValue -> value.value.toDouble()
is Value.RealValue -> value.value
is Value.DecimalValue -> value.value.toDouble()
is Value.TextValue -> value.value.trim().toDoubleOrNull()
?: throw KestrelException("cannot cast '${value.value}' to REAL")
else -> throw KestrelException("cannot cast ${value.typeName} to REAL")
}
)
private fun castToText(value: Value): String = when (value) {
is Value.IntegerValue -> value.value.toString()
is Value.RealValue -> value.value.toString()
is Value.DecimalValue -> value.value.toPlainString()
is Value.BooleanValue -> value.value.toString()
is Value.DateValue -> value.value.toString()
is Value.TextValue -> value.value
else -> throw KestrelException("cannot cast ${value.typeName} to TEXT")
}
private fun castToBoolean(value: Value): Value = Value.BooleanValue(
when (value) {
is Value.BooleanValue -> value.value
is Value.IntegerValue -> value.value != 0L
is Value.TextValue -> when (value.value.trim().lowercase()) {
"true" -> true
"false" -> false
else -> throw KestrelException("cannot cast '${value.value}' to BOOLEAN")
}
else -> throw KestrelException("cannot cast ${value.typeName} to BOOLEAN")
}
)
private fun castToDecimal(value: Value, precision: Int?, scale: Int?): Value {
val raw = when (value) {
is Value.DecimalValue -> value.value
is Value.IntegerValue -> BigDecimal(value.value)
is Value.RealValue -> BigDecimal.valueOf(value.value)
is Value.TextValue -> value.value.trim().toBigDecimalOrNull()
?: throw KestrelException("cannot cast '${value.value}' to DECIMAL")
else -> throw KestrelException("cannot cast ${value.typeName} to DECIMAL")
}
return Value.DecimalValue(DecimalColumns.normalize(raw, precision, scale, "CAST"))
}
private fun castToDate(value: Value): Value = Value.DateValue(
when (value) {
is Value.DateValue -> value.value
is Value.IntegerValue -> LocalDate.ofEpochDay(value.value)
is Value.TextValue -> runCatching { LocalDate.parse(value.value.trim()) }.getOrElse {
throw KestrelException("cannot cast '${value.value}' to DATE")
}
else -> throw KestrelException("cannot cast ${value.typeName} to DATE")
}
)
private fun evaluateCall(expression: CallExpression): Value {
if (expression.name.equals("GROUPING", ignoreCase = true)) {
throw KestrelException("GROUPING() can only be used in the SELECT list, HAVING, or ORDER BY of a query with GROUP BY")
}
val function = ScalarFunctions.lookup(expression.name)
?: throw KestrelException("unknown function: ${expression.name}")
return function.apply(expression.arguments.map { evaluate(it) })
}
private fun evaluateIsNull(expression: IsNullExpression): Value {
val isNull = evaluate(expression.operand).isNull
return Value.BooleanValue(if (expression.negated) !isNull else isNull)
}
private fun evaluateIn(expression: InExpression): Value {
val operand = evaluate(expression.operand)
if (operand is Value.NullValue) return Value.NullValue
val collation = collationOf(expression.operand) ?: Collation.Binary
var sawNull = false
for (candidateExpression in expression.values) {
val candidate = evaluate(candidateExpression)
if (candidate is Value.NullValue) {
sawNull = true
continue
}
if (compareOperands(operand, candidate, collation) == 0) return Value.BooleanValue(!expression.negated)
}
return if (sawNull) Value.NullValue else Value.BooleanValue(expression.negated)
}
private fun evaluateBetween(expression: BetweenExpression): Value {
val operand = evaluate(expression.operand)
val low = evaluate(expression.low)
val high = evaluate(expression.high)
if (operand is Value.NullValue || low is Value.NullValue || high is Value.NullValue) return Value.NullValue
val withinRange = compareOperands(operand, low, pairCollation(expression.operand, expression.low)) >= 0 &&
compareOperands(operand, high, pairCollation(expression.operand, expression.high)) <= 0
return Value.BooleanValue(if (expression.negated) !withinRange else withinRange)
}
private fun evaluateLike(expression: LikeExpression): Value {
val operand = evaluate(expression.operand)
val pattern = evaluate(expression.pattern)
if (operand is Value.NullValue || pattern is Value.NullValue) return Value.NullValue
if (operand !is Value.TextValue || pattern !is Value.TextValue) {
throw KestrelException("LIKE requires text operands but got ${operand.typeName} and ${pattern.typeName}")
}
val matches = likeToRegex(pattern.value).matches(operand.value)
return Value.BooleanValue(if (expression.negated) !matches else matches)
}
private fun likeToRegex(pattern: String): Regex {
val builder = StringBuilder("(?s)")
for (character in pattern) {
when (character) {
'%' -> builder.append(".*")
'_' -> builder.append(".")
else -> builder.append(Regex.escape(character.toString()))
}
}
return Regex(builder.toString())
}
private fun evaluateCase(expression: CaseExpression): Value {
val operand = expression.operand?.let { evaluate(it) }
for (branch in expression.branches) {
val matched = if (operand != null) {
if (operand is Value.NullValue) {
false
} else {
val candidate = evaluate(branch.condition)
candidate !is Value.NullValue &&
compareOperands(operand, candidate, pairCollation(expression.operand!!, branch.condition)) == 0
}
} else {
asBooleanOrNull(evaluate(branch.condition)) == true
}
if (matched) return evaluate(branch.result)
}
return expression.elseExpression?.let { evaluate(it) } ?: Value.NullValue
}
private fun evaluateColumn(expression: ColumnExpression): Value {
@@ -49,6 +354,7 @@ class Evaluator(private val resolver: ColumnResolver? = null) {
UnaryOperator.Negate -> when (operand) {
is Value.IntegerValue -> Value.IntegerValue(-operand.value)
is Value.RealValue -> Value.RealValue(-operand.value)
is Value.DecimalValue -> Value.DecimalValue(-operand.value)
else -> throw KestrelException("unary minus requires a numeric operand but got ${operand.typeName}")
}
UnaryOperator.Not -> when (operand) {
@@ -68,7 +374,7 @@ class Evaluator(private val resolver: ColumnResolver? = null) {
return when (operator) {
BinaryOperator.Add, BinaryOperator.Subtract,
BinaryOperator.Multiply, BinaryOperator.Divide -> evaluateArithmetic(operator, left, right)
else -> evaluateComparison(operator, left, right)
else -> evaluateComparison(operator, left, right, pairCollation(expression.left, expression.right))
}
}
@@ -98,6 +404,12 @@ class Evaluator(private val resolver: ColumnResolver? = null) {
private fun evaluateArithmetic(operator: BinaryOperator, left: Value, right: Value): Value {
if (left is Value.NullValue || right is Value.NullValue) return Value.NullValue
if (left is Value.DateValue || right is Value.DateValue) {
return evaluateDateArithmetic(operator, left, right)
}
if (left is Value.DecimalValue || right is Value.DecimalValue) {
return evaluateDecimalArithmetic(operator, left, right)
}
if (!isNumeric(left) || !isNumeric(right)) {
throw KestrelException("arithmetic requires numeric operands but got ${left.typeName} and ${right.typeName}")
}
@@ -133,9 +445,9 @@ class Evaluator(private val resolver: ColumnResolver? = null) {
)
}
private fun evaluateComparison(operator: BinaryOperator, left: Value, right: Value): Value {
private fun evaluateComparison(operator: BinaryOperator, left: Value, right: Value, collation: Collation): Value {
if (left is Value.NullValue || right is Value.NullValue) return Value.NullValue
val ordering = compareOperands(left, right)
val ordering = compareOperands(left, right, collation)
val result = when (operator) {
BinaryOperator.Equal -> ordering == 0
BinaryOperator.NotEqual -> ordering != 0
@@ -148,14 +460,59 @@ class Evaluator(private val resolver: ColumnResolver? = null) {
return Value.BooleanValue(result)
}
private fun compareOperands(left: Value, right: Value): Int = when {
/**
* [collation] only ever changes the outcome for two [Value.TextValue]s - every other pairing
* ignores it, matching SQLite (COLLATE is meaningless outside of text comparison). Defaults to
* [Collation.Binary] (this engine's behavior before COLLATE existed) at every call site that
* has no [CollateExpression] to resolve one from.
*/
private fun compareOperands(left: Value, right: Value, collation: Collation = Collation.Binary): Int = when {
left is Value.IntegerValue && right is Value.IntegerValue -> left.value.compareTo(right.value)
left is Value.DecimalValue || right is Value.DecimalValue -> {
val a = decimalOf(left) ?: throw KestrelException("cannot compare ${left.typeName} with ${right.typeName}")
val b = decimalOf(right) ?: throw KestrelException("cannot compare ${left.typeName} with ${right.typeName}")
a.compareTo(b)
}
isNumeric(left) && isNumeric(right) -> asDouble(left).compareTo(asDouble(right))
left is Value.TextValue && right is Value.TextValue -> left.value.compareTo(right.value)
left is Value.TextValue && right is Value.TextValue -> collation.compare(left.value, right.value)
left is Value.BooleanValue && right is Value.BooleanValue -> left.value.compareTo(right.value)
left is Value.DateValue && right is Value.DateValue -> left.value.compareTo(right.value)
else -> throw KestrelException("cannot compare ${left.typeName} with ${right.typeName}")
}
/** The [Collation] an explicit `COLLATE` on [expression] itself names, or `null` when it is not a [CollateExpression] at all. */
private fun explicitCollationOf(expression: Expression): Collation? = (expression as? CollateExpression)?.collation
/**
* [expression]'s own declared `COLLATE` (see [kestrel.catalog.Column.collation]) when it is a
* bare [ColumnExpression] whose [resolver] reports one - `null` for anything else, including a
* column that never declared one at all ([ColumnResolver.declaredCollation] reporting
* [Collation.Binary], its class default, is treated the exact same as reporting nothing: a
* column that never opted into a non-default sequence should not out-rank an operand on the
* *other* side of a comparison that did, purely by virtue of appearing on the left - see
* [pairCollation]).
*/
private fun columnDeclaredCollationOf(expression: Expression): Collation? =
(expression as? ColumnExpression)?.let { resolver?.declaredCollation(it) }?.takeIf { it != Collation.Binary }
/**
* The [Collation] [expression] compares with on its own, combining both of [pairCollation]'s
* tiers into one - used where there is only a single operand to consult
* ([evaluateIn]/[evaluateInSubquery]'s list/subquery operand), which can still tell "no COLLATE
* here at all" apart from "explicitly COLLATE BINARY" (both [explicitCollationOf] and
* [columnDeclaredCollationOf] return `null` only for the former).
*/
private fun collationOf(expression: Expression): Collation? = explicitCollationOf(expression) ?: columnDeclaredCollationOf(expression)
/**
* Delegates to the shared [kestrel.engine.pairCollation] (SQLite's two-tier binary-comparison
* rule) bound to this evaluator's own [resolver] - see that function's doc for the precedence
* rule itself and why it must stay two separate passes rather than one combined
* [collationOf]-per-side check.
*/
private fun pairCollation(left: Expression, right: Expression): Collation =
kestrel.engine.pairCollation(left, right) { resolver?.declaredCollation(it) }
private fun isNumeric(value: Value): Boolean = value is Value.IntegerValue || value is Value.RealValue
private fun asDouble(value: Value): Double = when (value) {
@@ -163,4 +520,47 @@ class Evaluator(private val resolver: ColumnResolver? = null) {
is Value.RealValue -> value.value
else -> error("not a numeric value")
}
private fun decimalOf(value: Value): BigDecimal? = when (value) {
is Value.DecimalValue -> value.value
is Value.IntegerValue -> BigDecimal(value.value)
is Value.RealValue -> BigDecimal.valueOf(value.value)
else -> null
}
private fun evaluateDateArithmetic(operator: BinaryOperator, left: Value, right: Value): Value = when {
left is Value.DateValue && right is Value.DateValue -> when (operator) {
BinaryOperator.Subtract -> Value.IntegerValue(left.value.toEpochDay() - right.value.toEpochDay())
else -> throw KestrelException("DATE ${operator.symbol} DATE is not supported; only subtraction (day difference) is")
}
left is Value.DateValue && right is Value.IntegerValue -> when (operator) {
BinaryOperator.Add -> Value.DateValue(left.value.plusDays(right.value))
BinaryOperator.Subtract -> Value.DateValue(left.value.minusDays(right.value))
else -> throw KestrelException("DATE only supports + and - with an INTEGER day offset")
}
left is Value.IntegerValue && right is Value.DateValue && operator == BinaryOperator.Add ->
Value.DateValue(right.value.plusDays(left.value))
else -> throw KestrelException("arithmetic requires numeric operands but got ${left.typeName} and ${right.typeName}")
}
private fun evaluateDecimalArithmetic(operator: BinaryOperator, left: Value, right: Value): Value {
val a = decimalOf(left) ?: throw KestrelException("arithmetic requires numeric operands but got ${left.typeName} and ${right.typeName}")
val b = decimalOf(right) ?: throw KestrelException("arithmetic requires numeric operands but got ${left.typeName} and ${right.typeName}")
return Value.DecimalValue(
when (operator) {
BinaryOperator.Add -> a + b
BinaryOperator.Subtract -> a - b
BinaryOperator.Multiply -> a * b
BinaryOperator.Divide -> {
if (b.signum() == 0) throw KestrelException("division by zero")
a.divide(b, maxOf(a.scale(), b.scale(), MIN_DIVISION_SCALE), RoundingMode.HALF_UP)
}
else -> error("unreachable arithmetic operator")
}
)
}
private companion object {
const val MIN_DIVISION_SCALE = 10
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,207 @@
package kestrel.engine
import kestrel.KestrelException
import kestrel.catalog.Catalog
import kestrel.catalog.ForeignKeyAction
import kestrel.catalog.TableSchema
import kestrel.storage.Row
import kestrel.storage.Table
import kestrel.storage.Value
/**
* Runtime enforcement for FOREIGN KEY constraints: existence checks on INSERT/UPDATE of the
* referencing (child) table, and ON DELETE/ON UPDATE actions on the referenced (parent) table.
* `referencedColumns` is always a column group with an enforced uniqueness guarantee in the
* parent (its PRIMARY KEY, single-column or composite, or a UNIQUE constraint), so cascades track
* a whole key tuple (`List<Value>`, one entry per column, positionally paired with `columns`) at
* a time rather than a single value - this is what lets a single-column FK and a multi-column FK
* share every code path below unchanged.
*
* Cascades are computed as a plan first and only mutate afterwards (see [CascadeStep]) so that a
* RESTRICT violation discovered in one child table can never leave an earlier, unrelated child
* table half-cascaded - either the whole cascade applies, or nothing does.
*/
object ForeignKeyEnforcer {
/**
* [uncommittedSiblings] covers a self-referencing FK (`referencedTable == schema.name`, e.g. an
* employee/manager hierarchy) inside a multi-row INSERT/UPDATE statement: those sibling rows
* are being validated as part of the very same statement and aren't in the catalog's table yet,
* so without this a later row couldn't reference an earlier row from the same batch.
*
* Follows MATCH SIMPLE (the SQL default): if *any* column of the FK is NULL in this row, the
* whole constraint is skipped for that row - not just the null column.
*/
fun validateRow(
catalog: Catalog,
schema: TableSchema,
values: List<Value>,
uncommittedSiblings: List<Row> = emptyList()
) {
for (fk in schema.foreignKeys) {
val columnIndices = fk.columns.map { schema.columnIndex(it)!! }
val key = columnIndices.map { values[it] }
if (key.any { it is Value.NullValue }) continue
val referenced = catalog.tableOrNull(fk.referencedTable)
?: throw KestrelException(
"FOREIGN KEY constraint failed: table '${fk.referencedTable}' referenced by " +
"${schema.name}(${fk.columns.joinToString(", ")}) does not exist"
)
val refIndices = fk.referencedColumns.map { referenced.schema.columnIndex(it)!! }
val siblings = if (fk.referencedTable.equals(schema.name, ignoreCase = true)) uncommittedSiblings else emptyList()
val exists = referenced.rows.any { refIndices.map { idx -> it.values[idx] } == key } ||
siblings.any { refIndices.map { idx -> it.values[idx] } == key }
if (!exists) {
throw KestrelException(
"FOREIGN KEY constraint failed: ${schema.name}(${fk.columns.joinToString(", ")}) has no " +
"matching row in ${fk.referencedTable}(${fk.referencedColumns.joinToString(", ")})"
)
}
}
}
private sealed interface CascadeStep {
data class Delete(val table: Table, val rows: List<Row>, val next: List<CascadeStep>) : CascadeStep
data class SetToNull(val table: Table, val columnIndices: List<Int>, val rows: List<Row>) : CascadeStep
data class SetTo(val table: Table, val columnIndices: List<Int>, val rows: List<Row>, val values: List<Value>) : CascadeStep
}
fun cascadeDelete(catalog: Catalog, parentTableName: String, deletedRows: List<Row>) {
applySteps(catalog, planDelete(catalog, parentTableName, deletedRows))
}
/**
* [referencedColumns] is the full column group whose values changed together (a PRIMARY KEY or
* UNIQUE key group) and [changedPairs] holds the old/new key tuple for every row of the parent
* table, positionally aligned with [referencedColumns] - a matching FK may declare its own
* `referencedColumns` in a different order, so [planUpdate] re-projects each tuple by name.
*/
fun cascadeUpdate(
catalog: Catalog,
parentTableName: String,
referencedColumns: List<String>,
changedPairs: List<Pair<List<Value>, List<Value>>>
) {
applySteps(catalog, planUpdate(catalog, parentTableName, referencedColumns, changedPairs))
}
private fun planDelete(catalog: Catalog, parentTableName: String, deletedRows: List<Row>): List<CascadeStep> {
if (deletedRows.isEmpty()) return emptyList()
val parent = catalog.table(parentTableName)
val steps = mutableListOf<CascadeStep>()
for (childName in catalog.tableNames) {
val child = catalog.table(childName)
for (fk in child.schema.foreignKeys) {
if (!fk.referencedTable.equals(parentTableName, ignoreCase = true)) continue
val refIndices = fk.referencedColumns.map { parent.schema.columnIndex(it)!! }
val deletedKeys = deletedRows.map { row -> refIndices.map { row.values[it] } }.toHashSet()
val childColumnIndices = fk.columns.map { child.schema.columnIndex(it)!! }
val matches = child.rows.filter { row -> childColumnIndices.map { row.values[it] } in deletedKeys }
if (matches.isEmpty()) continue
when (fk.onDelete) {
ForeignKeyAction.Cascade ->
steps.add(CascadeStep.Delete(child, matches, planDelete(catalog, child.schema.name, matches)))
ForeignKeyAction.SetNull -> {
requireNullable(child.schema, childColumnIndices, "ON DELETE SET NULL")
steps.add(CascadeStep.SetToNull(child, childColumnIndices, matches))
}
ForeignKeyAction.Restrict, ForeignKeyAction.NoAction -> throw KestrelException(
"FOREIGN KEY constraint failed: ${matches.size} row(s) in ${child.schema.name} still " +
"reference $parentTableName (use ON DELETE CASCADE or remove them first)"
)
}
}
}
return steps
}
private fun planUpdate(
catalog: Catalog,
parentTableName: String,
referencedColumns: List<String>,
changedPairs: List<Pair<List<Value>, List<Value>>>
): List<CascadeStep> {
val changes = changedPairs.filter { (old, new) -> old != new }
if (changes.isEmpty()) return emptyList()
val steps = mutableListOf<CascadeStep>()
for (childName in catalog.tableNames) {
val child = catalog.table(childName)
for (fk in child.schema.foreignKeys) {
if (!fk.referencedTable.equals(parentTableName, ignoreCase = true)) continue
if (fk.referencedColumns.size != referencedColumns.size) continue
// Re-project the tuple by name: this FK's referencedColumns may list the same key
// group in a different order than the one changedPairs was built from.
val positions = fk.referencedColumns.map { refCol ->
referencedColumns.indexOfFirst { it.equals(refCol, ignoreCase = true) }
}
if (positions.any { it == -1 }) continue
val childColumnIndices = fk.columns.map { child.schema.columnIndex(it)!! }
for ((oldTuple, newTuple) in changes) {
val oldKey = positions.map { oldTuple[it] }
val newKey = positions.map { newTuple[it] }
if (oldKey == newKey) continue
val matches = child.rows.filter { row -> childColumnIndices.map { row.values[it] } == oldKey }
if (matches.isEmpty()) continue
when (fk.onUpdate) {
ForeignKeyAction.Cascade ->
steps.add(CascadeStep.SetTo(child, childColumnIndices, matches, newKey))
ForeignKeyAction.SetNull -> {
requireNullable(child.schema, childColumnIndices, "ON UPDATE SET NULL")
steps.add(CascadeStep.SetToNull(child, childColumnIndices, matches))
}
ForeignKeyAction.Restrict, ForeignKeyAction.NoAction -> throw KestrelException(
"FOREIGN KEY constraint failed: ${matches.size} row(s) in ${child.schema.name} still " +
"reference $parentTableName (use ON UPDATE CASCADE)"
)
}
}
}
}
return steps
}
private fun applySteps(catalog: Catalog, steps: List<CascadeStep>) {
for (step in steps) {
when (step) {
is CascadeStep.Delete -> {
step.table.rows.removeAll(step.rows)
catalog.reindexTable(step.table.schema.name)
applySteps(catalog, step.next)
}
is CascadeStep.SetToNull -> {
val nulls = step.columnIndices.map { Value.NullValue }
replaceRows(step.table, step.rows) { row -> withColumns(row, step.columnIndices, nulls) }
catalog.reindexTable(step.table.schema.name)
}
is CascadeStep.SetTo -> {
replaceRows(step.table, step.rows) { row -> withColumns(row, step.columnIndices, step.values) }
catalog.reindexTable(step.table.schema.name)
}
}
}
}
private fun requireNullable(schema: TableSchema, columnIndices: List<Int>, action: String) {
for (columnIndex in columnIndices) {
val column = schema.columns[columnIndex]
if (column.notNull) {
throw KestrelException(
"FOREIGN KEY constraint failed: cannot $action on NOT NULL column ${schema.name}.${column.name}"
)
}
}
}
private fun withColumns(row: Row, columnIndices: List<Int>, values: List<Value>): Row {
val result = row.values.toMutableList()
columnIndices.forEachIndexed { position, columnIndex -> result[columnIndex] = values[position] }
return Row(result)
}
private fun replaceRows(table: Table, targets: List<Row>, transform: (Row) -> Row) {
val targetSet = targets.toHashSet()
val updated = table.rows.map { row -> if (row in targetSet) transform(row) else row }
table.rows.clear()
table.rows.addAll(updated)
}
}
@@ -1,54 +1,188 @@
package kestrel.engine
import kestrel.KestrelException
import kestrel.sql.ast.BetweenExpression
import kestrel.sql.ast.BinaryExpression
import kestrel.sql.ast.CallExpression
import kestrel.sql.ast.CaseBranch
import kestrel.sql.ast.CaseExpression
import kestrel.sql.ast.CastExpression
import kestrel.sql.ast.Expression
import kestrel.sql.ast.ExpressionProjection
import kestrel.sql.ast.FunctionExpression
import kestrel.sql.ast.InExpression
import kestrel.sql.ast.IsNullExpression
import kestrel.sql.ast.LikeExpression
import kestrel.sql.ast.LiteralExpression
import kestrel.sql.ast.OrderKey
import kestrel.sql.ast.SelectItem
import kestrel.sql.ast.SelectStatement
import kestrel.sql.ast.UnaryExpression
import kestrel.storage.Collation
import kestrel.storage.Value
import kestrel.storage.ValueOrdering
import kestrel.storage.typeName
class GroupAggregation(
private val resolveIndex: (kestrel.sql.ast.ColumnExpression) -> Int,
private val evaluateRow: (Expression, List<Value>) -> Value,
private val label: (SelectItem, Int) -> String
private val label: (SelectItem, Int) -> String,
private val subqueries: SubqueryRunner? = null,
/** A bare grouping column's declared `COLLATE` (see [kestrel.catalog.Column.collation]) - defaults to reporting none, matching every [ColumnResolver] built before this existed. */
private val declaredCollationOf: (kestrel.sql.ast.ColumnExpression) -> Collation? = { null }
) {
fun aggregate(statement: SelectStatement, orderExpression: Expression?, combined: List<List<Value>>): SelectResult {
val groups = LinkedHashMap<List<Value>, MutableList<List<Value>>>()
for (row in combined) {
val key = statement.groupBy.map { evaluateRow(it, row) }
groups.getOrPut(key) { mutableListOf() }.add(row)
}
var groupRows: List<List<List<Value>>> =
if (statement.groupBy.isEmpty() && groups.isEmpty()) listOf(emptyList()) else groups.values.toList()
/**
* One row of the eventual result, before projection: the underlying source rows that fell into
* it, and the grouping-set expressions that were "active" (present, not rolled up away) for it.
* For an ordinary `GROUP BY` (no [kestrel.sql.ast.GroupingSpec]) there is exactly one grouping set
* per query - [activeExpressions] is always the full `GROUP BY` list, so every column reads
* normally and `GROUPING(...)` (see [substituteGroupingAwareCalls]) always answers 0, matching
* plain SQL. [rows] is empty only for the grand-total group of a set with zero expressions over a
* zero-row input - the same "ungrouped aggregate over nothing" case a bare `SELECT COUNT(*)` with
* no rows at all already has to handle.
*/
private data class LogicalGroup(val rows: List<List<Value>>, val activeExpressions: List<Expression>)
fun aggregate(statement: SelectStatement, orderKeys: List<OrderKey>, combined: List<List<Value>>): SelectResult {
val allGroupingColumns = statement.groupBy
val sets = GroupingSetExpansion.setsFor(statement.groupingSpec, statement.groupBy)
var logicalGroups = sets.flatMap { groupsForSet(it, combined) }
statement.having?.let { having ->
groupRows = groupRows.filter { isTrue(evaluateInGroup(having, it)) }
logicalGroups = logicalGroups.filter { isTrue(evaluateInGroup(having, it, allGroupingColumns)) }
}
orderExpression?.let { expression ->
val comparator = Comparator<List<List<Value>>> { first, second ->
ValueOrdering.compare(evaluateInGroup(expression, first), evaluateInGroup(expression, second))
if (orderKeys.isNotEmpty()) {
val comparator = Comparator<LogicalGroup> { first, second ->
var result = 0
for (key in orderKeys) {
val comparison = compareOrderKeyValues(
evaluateInGroup(key.expression, first, allGroupingColumns),
evaluateInGroup(key.expression, second, allGroupingColumns),
key,
declaredCollationOf
)
if (comparison != 0) {
result = comparison
break
}
}
result
}
groupRows = groupRows.sortedWith(if (statement.orderBy!!.descending) comparator.reversed() else comparator)
logicalGroups = logicalGroups.sortedWith(comparator)
}
statement.limit?.let {
if (it < 0) throw KestrelException("LIMIT must not be negative")
groupRows = groupRows.take(it)
statement.offset?.let { if (it < 0) throw KestrelException("OFFSET must not be negative") }
statement.limit?.let { if (it < 0) throw KestrelException("LIMIT must not be negative") }
// DISTINCT can still collapse two separate groups into the same output row (e.g.
// `SELECT DISTINCT dept FROM t GROUP BY dept, sub_dept`) - LIMIT/OFFSET are deferred past
// dedup below so they count distinct output rows, not pre-dedup groups.
if (!statement.distinct && (statement.offset != null || statement.limit != null)) {
logicalGroups = logicalGroups.drop(statement.offset ?: 0)
statement.limit?.let { logicalGroups = logicalGroups.take(it) }
}
val projection = statement.projection as ExpressionProjection
val labels = projection.items.mapIndexed { index, item -> label(item, index) }
val projected = groupRows.map { group -> projection.items.map { evaluateInGroup(it.expression, group) } }
var projected = logicalGroups.map { group -> projection.items.map { evaluateInGroup(it.expression, group, allGroupingColumns) } }
if (statement.distinct) {
val itemExpressions = projection.items.map { it.expression }
projected = projected.distinctBy { row -> collationFoldedKey(row, itemExpressions, declaredCollationOf) }
if (statement.offset != null || statement.limit != null) {
projected = projected.drop(statement.offset ?: 0)
statement.limit?.let { projected = projected.take(it) }
}
}
return SelectResult(labels, projected)
}
private fun evaluateInGroup(expression: Expression, group: List<List<Value>>): Value {
val substituted = substituteAggregates(expression, group)
val resolver = ColumnResolver { columnExpression -> group.first()[resolveIndex(columnExpression)] }
return Evaluator(resolver).evaluate(substituted)
/**
* Groups [combined] by [setExpressions] alone (not the query's full grouping-column union) -
* exactly the single-set logic this class always had, just parameterized per set instead of
* always using `statement.groupBy`. A set with zero expressions over zero source rows still
* produces the one grand-total group (`combined.isEmpty()` means the inner loop never ran, so
* `buckets` is genuinely empty rather than holding a single `emptyList()`-keyed bucket) - every
* *other* set, or this same set over a non-empty input, groups normally.
*/
private fun groupsForSet(setExpressions: List<Expression>, combined: List<List<Value>>): List<LogicalGroup> {
val buckets = LinkedHashMap<List<Value>, MutableList<List<Value>>>()
for (row in combined) {
val key = setExpressions.map { evaluateRow(it, row) }
val bucketKey = collationFoldedKey(key, setExpressions, declaredCollationOf)
buckets.getOrPut(bucketKey) { mutableListOf() }.add(row)
}
return if (setExpressions.isEmpty() && buckets.isEmpty()) {
listOf(LogicalGroup(emptyList(), setExpressions))
} else {
buckets.values.map { LogicalGroup(it, setExpressions) }
}
}
private fun evaluateInGroup(expression: Expression, group: LogicalGroup, allGroupingColumns: List<Expression>): Value {
val nulled = substituteInactiveGroupingColumns(expression, group.activeExpressions, allGroupingColumns)
val substituted = substituteAggregates(nulled, group.rows)
// Only reached for a bare reference to one of `group.activeExpressions` (anything else was
// just replaced with NULL above), and a group with any active expression at all is never
// empty - it was built from at least one matching source row - so `.first()` cannot throw.
val resolver = object : ColumnResolver {
override fun resolve(columnExpression: kestrel.sql.ast.ColumnExpression): Value =
group.rows.first()[resolveIndex(columnExpression)]
override fun declaredCollation(columnExpression: kestrel.sql.ast.ColumnExpression): Collation? =
declaredCollationOf(columnExpression)
}
return Evaluator(resolver, subqueries).evaluate(substituted)
}
/**
* Replaces every grouping-column expression not in [activeExpressions] with a NULL literal (SQL's
* rule for a ROLLUP/CUBE/GROUPING SETS super-aggregate row: a collapsed column reads as NULL
* wherever it is used outside an aggregate's own argument) and every `GROUPING(...)` call with its
* literal bitmask. Stops at a [FunctionExpression] without recursing into its argument - an
* aggregate always sees real per-row values regardless of which columns this particular row
* collapsed, which is exactly what makes `SUM(amount)` correct on the grand-total row too.
*/
private fun substituteInactiveGroupingColumns(
expression: Expression,
activeExpressions: List<Expression>,
allGroupingColumns: List<Expression>
): Expression {
if (expression in allGroupingColumns && expression !in activeExpressions) {
return LiteralExpression(Value.NullValue)
}
fun recurse(child: Expression) = substituteInactiveGroupingColumns(child, activeExpressions, allGroupingColumns)
return when (expression) {
is BinaryExpression -> BinaryExpression(expression.operator, recurse(expression.left), recurse(expression.right))
is UnaryExpression -> UnaryExpression(expression.operator, recurse(expression.operand))
is CallExpression ->
if (expression.name.equals("GROUPING", ignoreCase = true)) {
LiteralExpression(computeGrouping(expression, activeExpressions))
} else {
CallExpression(expression.name, expression.arguments.map(::recurse))
}
is IsNullExpression -> IsNullExpression(recurse(expression.operand), expression.negated)
is InExpression -> InExpression(recurse(expression.operand), expression.values.map(::recurse), expression.negated)
is BetweenExpression -> BetweenExpression(
recurse(expression.operand), recurse(expression.low), recurse(expression.high), expression.negated
)
is LikeExpression -> LikeExpression(recurse(expression.operand), recurse(expression.pattern), expression.negated)
is CaseExpression -> CaseExpression(
expression.operand?.let(::recurse),
expression.branches.map { CaseBranch(recurse(it.condition), recurse(it.result)) },
expression.elseExpression?.let(::recurse)
)
is CastExpression -> CastExpression(recurse(expression.operand), expression.targetType, expression.precision, expression.scale)
else -> expression
}
}
/**
* `GROUPING(e1, ..., en)` per the SQL standard: an integer whose bits, most significant first, are
* 1 for each `ei` collapsed away in this row's grouping set and 0 for each one still present -
* `computeAggregate`'s own arguments never reach this ([FunctionExpression] is not
* [CallExpression]), so there is no ambiguity with a real aggregate named similarly.
*/
private fun computeGrouping(call: CallExpression, activeExpressions: List<Expression>): Value {
if (call.arguments.isEmpty()) throw KestrelException("GROUPING requires at least one argument")
var bits = 0L
for (argument in call.arguments) {
bits = (bits shl 1) or if (argument in activeExpressions) 0L else 1L
}
return Value.IntegerValue(bits)
}
private fun substituteAggregates(expression: Expression, group: List<List<Value>>): Expression = when (expression) {
@@ -59,46 +193,52 @@ class GroupAggregation(
substituteAggregates(expression.right, group)
)
is UnaryExpression -> UnaryExpression(expression.operator, substituteAggregates(expression.operand, group))
is CallExpression -> CallExpression(expression.name, expression.arguments.map { substituteAggregates(it, group) })
is IsNullExpression -> IsNullExpression(substituteAggregates(expression.operand, group), expression.negated)
is InExpression -> InExpression(
substituteAggregates(expression.operand, group),
expression.values.map { substituteAggregates(it, group) },
expression.negated
)
is BetweenExpression -> BetweenExpression(
substituteAggregates(expression.operand, group),
substituteAggregates(expression.low, group),
substituteAggregates(expression.high, group),
expression.negated
)
is LikeExpression -> LikeExpression(
substituteAggregates(expression.operand, group),
substituteAggregates(expression.pattern, group),
expression.negated
)
is CaseExpression -> CaseExpression(
expression.operand?.let { substituteAggregates(it, group) },
expression.branches.map {
CaseBranch(substituteAggregates(it.condition, group), substituteAggregates(it.result, group))
},
expression.elseExpression?.let { substituteAggregates(it, group) }
)
is CastExpression -> CastExpression(
substituteAggregates(expression.operand, group),
expression.targetType,
expression.precision,
expression.scale
)
else -> expression
}
/**
* `FILTER (WHERE ...)` narrows the rows this one aggregate call sees to those matching its own
* predicate, evaluated per source row exactly like a `WHERE` clause would be - independent of
* every other aggregate in the same SELECT list, which each see [group] unfiltered (or filtered by
* their own, different, FILTER clause) rather than this one's rows removed for everybody.
*/
private fun computeAggregate(function: FunctionExpression, group: List<List<Value>>): Value {
val name = function.name.uppercase()
if (name == "COUNT") {
if (function.star) return Value.IntegerValue(group.size.toLong())
val argument = function.argument ?: return Value.IntegerValue(group.size.toLong())
val count = group.count { evaluateRow(argument, it) !is Value.NullValue }
return Value.IntegerValue(count.toLong())
}
val argument = function.argument
?: throw KestrelException("aggregate function ${function.name} requires an argument")
val values = group.map { evaluateRow(argument, it) }.filter { it !is Value.NullValue }
return when (name) {
"SUM" -> sum(values)
"AVG" -> average(values)
"MIN" -> values.minWithOrNull(ValueOrdering) ?: Value.NullValue
"MAX" -> values.maxWithOrNull(ValueOrdering) ?: Value.NullValue
else -> throw KestrelException("unknown function: ${function.name}")
}
}
private fun sum(values: List<Value>): Value {
if (values.isEmpty()) return Value.NullValue
if (values.all { it is Value.IntegerValue }) {
return Value.IntegerValue(values.sumOf { (it as Value.IntegerValue).value })
}
return Value.RealValue(values.sumOf { numeric(it) })
}
private fun average(values: List<Value>): Value {
if (values.isEmpty()) return Value.NullValue
return Value.RealValue(values.sumOf { numeric(it) } / values.size)
}
private fun numeric(value: Value): Double = when (value) {
is Value.IntegerValue -> value.value.toDouble()
is Value.RealValue -> value.value
else -> throw KestrelException("aggregate requires numeric operands but got ${value.typeName}")
val filtered = function.filter?.let { predicate -> group.filter { isTrue(evaluateRow(predicate, it)) } } ?: group
return AggregateFunctions.compute(
function.name, function.argument, function.star, filtered, evaluateRow,
function.separator, function.orderBy, function.distinct, declaredCollationOf
)
}
private fun isTrue(value: Value): Boolean = value is Value.BooleanValue && value.value
@@ -0,0 +1,42 @@
package kestrel.engine
import kestrel.KestrelException
import kestrel.sql.ast.CubeSpec
import kestrel.sql.ast.Expression
import kestrel.sql.ast.GroupingSetsSpec
import kestrel.sql.ast.GroupingSpec
import kestrel.sql.ast.RollupSpec
/**
* Turns a [GroupingSpec] into the concrete list of grouping sets [GroupAggregation] groups by - one
* full pass over the source rows per set, later unioned together. `null` (an ordinary `GROUP BY a, b`
* or no `GROUP BY` at all) is exactly one implicit set, [groupBy] itself - this is what lets
* [GroupAggregation] treat the plain case as a special case of the general one instead of a separate
* code path.
*/
object GroupingSetExpansion {
/** CUBE(e1..en) is 2^n sets; capped well short of overflow or a runaway allocation from a typo'd column list. */
private const val MAX_CUBE_COLUMNS = 16
fun setsFor(spec: GroupingSpec?, groupBy: List<Expression>): List<List<Expression>> = when (spec) {
null -> listOf(groupBy)
is RollupSpec -> rollupSets(spec.expressions)
is CubeSpec -> cubeSets(spec.expressions)
is GroupingSetsSpec -> spec.sets
}
/** ROLLUP(e1, e2, e3) -> {e1,e2,e3}, {e1,e2}, {e1}, {} - each prefix of the declared order, longest first. */
private fun rollupSets(expressions: List<Expression>): List<List<Expression>> =
(expressions.size downTo 0).map { expressions.subList(0, it) }
/** CUBE(e1, e2, e3) -> every one of the 2^3 subsets, largest first; membership of expression i is bit i of a descending bitmask. */
private fun cubeSets(expressions: List<Expression>): List<List<Expression>> {
if (expressions.size > MAX_CUBE_COLUMNS) {
throw KestrelException("CUBE supports at most $MAX_CUBE_COLUMNS expressions")
}
val subsetCount = 1 shl expressions.size
return (subsetCount - 1 downTo 0).map { mask ->
expressions.filterIndexed { index, _ -> (mask shr index) and 1 == 1 }
}
}
}
@@ -1,6 +1,7 @@
package kestrel.engine
import kestrel.catalog.Catalog
import kestrel.catalog.SortedIndex
import kestrel.catalog.TableSchema
import kestrel.sql.ast.BinaryExpression
import kestrel.sql.ast.BinaryOperator
@@ -14,6 +15,9 @@ sealed class PrimaryAccess {
class IndexScan(val indexName: String, val description: String, val rowIndices: List<Int>) : PrimaryAccess()
}
/** An [IndexScan] candidate together with how many of the index's columns it actually pinned down, used to pick the most selective option among several usable indexes. */
private class ScoredScan(val boundColumns: Int, val scan: PrimaryAccess.IndexScan)
class IndexAccessPlanner(private val catalog: Catalog) {
fun plan(tableName: String, alias: String, schema: TableSchema, predicate: Expression?): PrimaryAccess {
if (predicate == null) return PrimaryAccess.FullScan
@@ -22,32 +26,75 @@ class IndexAccessPlanner(private val catalog: Catalog) {
val simple = asColumnPredicate(conjunct, alias, schema) ?: continue
byColumn.getOrPut(simple.first) { mutableListOf() }.add(simple.second to simple.third)
}
for ((columnName, predicates) in byColumn) {
val index = catalog.indexOn(tableName, columnName) ?: continue
val equal = predicates.firstOrNull { it.first == BinaryOperator.Equal }
if (equal != null) {
val candidates = index.equalTo(equal.second).sorted()
return PrimaryAccess.IndexScan(index.definition.name, "$columnName = ${literalText(equal.second)}", candidates)
}
var lower: Value? = null
var lowerInclusive = false
var upper: Value? = null
var upperInclusive = false
val parts = mutableListOf<String>()
for ((operator, literal) in predicates) {
when (operator) {
BinaryOperator.Greater -> { lower = literal; lowerInclusive = false; parts.add("$columnName > ${literalText(literal)}") }
BinaryOperator.GreaterEqual -> { lower = literal; lowerInclusive = true; parts.add("$columnName >= ${literalText(literal)}") }
BinaryOperator.Less -> { upper = literal; upperInclusive = false; parts.add("$columnName < ${literalText(literal)}") }
BinaryOperator.LessEqual -> { upper = literal; upperInclusive = true; parts.add("$columnName <= ${literalText(literal)}") }
else -> Unit
}
}
if (lower == null && upper == null) continue
val candidates = index.rangeOf(lower, lowerInclusive, upper, upperInclusive).sorted()
return PrimaryAccess.IndexScan(index.definition.name, parts.joinToString(" AND "), candidates)
return catalog.indexesOn(tableName)
.mapNotNull { index -> planIndex(index, byColumn) }
.maxByOrNull { it.boundColumns }
?.scan
?: PrimaryAccess.FullScan
}
/**
* Tries to serve [byColumn] from a single [index], preferring (in order): an exact match
* that binds every column of the index by equality; a range scan (<, <=, >, >=) on the
* column right after the longest leading equality run (works even with an empty run, i.e.
* a plain range on the index's very first column); or a pure equality prefix scan that
* binds a leading run of columns with the rest left entirely free. A composite index whose
* leading columns aren't bound at all (e.g. only its second column appears in the WHERE
* clause) is skipped - it cannot narrow the scan, since the index is sorted by its first
* column first.
*/
private fun planIndex(index: SortedIndex, byColumn: Map<String, List<Pair<BinaryOperator, Value>>>): ScoredScan? {
val definition = index.definition
val columns = definition.columnNames
val equalityValues = mutableListOf<Value>()
val descriptionParts = mutableListOf<String>()
for (columnName in columns) {
val equal = byColumn[columnName]?.firstOrNull { it.first == BinaryOperator.Equal } ?: break
equalityValues.add(equal.second)
descriptionParts.add("$columnName = ${literalText(equal.second)}")
}
return PrimaryAccess.FullScan
if (equalityValues.size == columns.size) {
val candidates = index.equalTo(equalityValues).sorted()
return ScoredScan(columns.size, PrimaryAccess.IndexScan(definition.name, descriptionParts.joinToString(" AND "), candidates))
}
val nextColumn = columns[equalityValues.size]
val rangePredicates = byColumn[nextColumn]?.filter { it.first != BinaryOperator.Equal }.orEmpty()
if (rangePredicates.isNotEmpty()) {
val scan = rangeScan(index, nextColumn, rangePredicates, prefix = equalityValues.toList(), descriptionParts)
if (scan != null) return scan
}
if (equalityValues.isEmpty()) return null
// A pure equality prefix: every bound column matched by "=", nothing further constrained.
val candidates = index.rangeOf(equalityValues, true, equalityValues, true).sorted()
return ScoredScan(equalityValues.size, PrimaryAccess.IndexScan(definition.name, descriptionParts.joinToString(" AND "), candidates))
}
private fun rangeScan(
index: SortedIndex,
columnName: String,
predicates: List<Pair<BinaryOperator, Value>>,
prefix: List<Value>,
descriptionPrefix: List<String>
): ScoredScan? {
var lowerValue: Value? = null
var lowerInclusive = false
var upperValue: Value? = null
var upperInclusive = false
val parts = descriptionPrefix.toMutableList()
for ((operator, literal) in predicates) {
when (operator) {
BinaryOperator.Greater -> { lowerValue = literal; lowerInclusive = false; parts.add("$columnName > ${literalText(literal)}") }
BinaryOperator.GreaterEqual -> { lowerValue = literal; lowerInclusive = true; parts.add("$columnName >= ${literalText(literal)}") }
BinaryOperator.Less -> { upperValue = literal; upperInclusive = false; parts.add("$columnName < ${literalText(literal)}") }
BinaryOperator.LessEqual -> { upperValue = literal; upperInclusive = true; parts.add("$columnName <= ${literalText(literal)}") }
else -> Unit
}
}
if (lowerValue == null && upperValue == null) return null
val lower = lowerValue?.let { prefix + it }
val upper = upperValue?.let { prefix + it }
val candidates = index.rangeOf(lower, lowerInclusive, upper, upperInclusive).sorted()
return ScoredScan(prefix.size + 1, PrimaryAccess.IndexScan(index.definition.name, parts.joinToString(" AND "), candidates))
}
private fun asColumnPredicate(
@@ -95,6 +142,8 @@ class IndexAccessPlanner(private val catalog: Catalog) {
is Value.RealValue -> value.value.toString()
is Value.TextValue -> "'${value.value}'"
is Value.BooleanValue -> value.value.toString()
is Value.DecimalValue -> value.value.toPlainString()
is Value.DateValue -> value.value.toString()
is Value.NullValue -> "NULL"
}
@@ -0,0 +1,49 @@
package kestrel.engine
import kestrel.sql.ast.CollateExpression
import kestrel.sql.ast.ColumnExpression
import kestrel.sql.ast.OrderKey
import kestrel.storage.Collation
import kestrel.storage.Value
import kestrel.storage.ValueOrdering
/**
* Compares two already-evaluated ORDER BY key values, shared by every sort in both the in-memory
* and disk engines (top-level ORDER BY, GROUP BY's own ORDER BY, STRING_AGG's inner ORDER BY, and
* a window function's `OVER (... ORDER BY ...)`) so `NULLS FIRST`/`NULLS LAST` behaves identically
* everywhere an [OrderKey] is honored.
*
* `NULLS FIRST`/`NULLS LAST` is an explicit override: when either side is NULL, [OrderKey.descending]
* plays no part - only [OrderKey.nullsFirst] decides, matching the SQL standard (unlike a plain
* value comparison, negating the whole result for DESC would also flip which end NULLs land on,
* which is not what an explicit NULLS clause means). When [OrderKey.nullsFirst] is unset (`null`,
* meaning the query never wrote `NULLS FIRST`/`LAST`), it defaults to `!descending` - the exact
* behavior every ORDER BY in this engine had before this clause existed, since [ValueOrdering]
* ranks NULL as the smallest value and the old code just negated that for DESC.
*
* [key]'s own [OrderKey.expression] also decides which [Collation] two [Value.TextValue]s are
* compared with, in the same two-tier precedence [kestrel.engine.Evaluator] uses for a binary
* comparison: (1) an explicit `COLLATE` - `ORDER BY name COLLATE NOCASE` reaches here as a plain
* [OrderKey] whose expression is a [CollateExpression]; (2) failing that, a bare [ColumnExpression]
* key's own declared `COLLATE` (see [kestrel.catalog.Column.collation]), reported by
* [declaredCollationOf] - `{ null }` by default so every pre-existing call site (one with no
* schema to consult, such as a UNION/INTERSECT/EXCEPT's synthetic output row) keeps behaving
* exactly as before; (3) [Collation.Binary] otherwise.
*/
fun compareOrderKeyValues(
left: Value,
right: Value,
key: OrderKey,
declaredCollationOf: (ColumnExpression) -> Collation? = { null }
): Int {
val leftNull = left is Value.NullValue
val rightNull = right is Value.NullValue
if (leftNull || rightNull) {
if (leftNull && rightNull) return 0
val nullsFirst = key.nullsFirst ?: !key.descending
return if (leftNull == nullsFirst) -1 else 1
}
val collation = resolveCollation(key.expression, declaredCollationOf)
val comparison = ValueOrdering.compare(left, right, collation)
return if (key.descending) -comparison else comparison
}
@@ -0,0 +1,371 @@
package kestrel.engine
import kestrel.KestrelException
import kestrel.sql.ast.Assignment
import kestrel.sql.ast.BetweenExpression
import kestrel.sql.ast.BinaryExpression
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.ConflictAction
import kestrel.sql.ast.ConflictDoNothing
import kestrel.sql.ast.ConflictDoUpdate
import kestrel.sql.ast.CubeSpec
import kestrel.sql.ast.CurrentValueExpression
import kestrel.sql.ast.DeleteStatement
import kestrel.sql.ast.ExistsExpression
import kestrel.sql.ast.Expression
import kestrel.sql.ast.ExpressionProjection
import kestrel.sql.ast.FrameBound
import kestrel.sql.ast.FromItem
import kestrel.sql.ast.FunctionExpression
import kestrel.sql.ast.GroupingSetsSpec
import kestrel.sql.ast.GroupingSpec
import kestrel.sql.ast.InExpression
import kestrel.sql.ast.InSubqueryExpression
import kestrel.sql.ast.InsertStatement
import kestrel.sql.ast.IsNullExpression
import kestrel.sql.ast.LikeExpression
import kestrel.sql.ast.LiteralExpression
import kestrel.sql.ast.MergeActionExpression
import kestrel.sql.ast.MergeMatchedDelete
import kestrel.sql.ast.MergeMatchedUpdate
import kestrel.sql.ast.MergeNotMatchedInsert
import kestrel.sql.ast.MergeStatement
import kestrel.sql.ast.MergeWhenClause
import kestrel.sql.ast.NextValueExpression
import kestrel.sql.ast.OnConflictClause
import kestrel.sql.ast.OrderBy
import kestrel.sql.ast.ParameterExpression
import kestrel.sql.ast.PreparableStatement
import kestrel.sql.ast.Projection
import kestrel.sql.ast.QueryStatement
import kestrel.sql.ast.RollupSpec
import kestrel.sql.ast.ScalarSubqueryExpression
import kestrel.sql.ast.SelectStatement
import kestrel.sql.ast.SetOperationStatement
import kestrel.sql.ast.StarProjection
import kestrel.sql.ast.SubquerySource
import kestrel.sql.ast.TableSource
import kestrel.sql.ast.UnaryExpression
import kestrel.sql.ast.UpdateStatement
import kestrel.sql.ast.WindowExpression
import kestrel.sql.ast.WithQuery
import kestrel.storage.Value
/**
* Resolves `$1`, `$2`, ... [ParameterExpression] placeholders inside a [PreparableStatement] -
* the only place they are ever actually turned into real values. Walks exactly the shape
* [kestrel.catalog.RelationRename]/[kestrel.catalog.DependencyAnalysis] already walk for a view's
* defining query (FROM/JOIN targets, derived tables, CTE bodies, set-operation branches, every
* subquery nested in WHERE/HAVING/projection/GROUP BY/ORDER BY/window frames), plus the parts only
* a top-level DML statement has that a view's body never does: INSERT's VALUES rows and ON CONFLICT
* clause, UPDATE's SET assignments, and MERGE's USING source/ON condition/WHEN clauses - since a
* prepared statement can be any [PreparableStatement] kind, not just a SELECT.
* [kestrel.engine.Executor.resolveExecute] is the only caller.
*/
internal object ParameterSubstitution {
/** Highest 1-based parameter index anywhere in [statement] - 0 if it has none. EXECUTE must supply exactly this many arguments. */
fun parameterCount(statement: PreparableStatement): Int = when (statement) {
is QueryStatement -> maxIndexInQuery(statement)
is InsertStatement -> maxOf(
statement.rows.maxOfOrNull { row -> row.maxOfOrNull { it?.let(::maxIndexInExpression) ?: 0 } ?: 0 } ?: 0,
statement.onConflict?.let(::maxIndexInOnConflict) ?: 0,
statement.returning?.let(::maxIndexInProjection) ?: 0
)
is UpdateStatement -> maxOf(
statement.assignments.maxOfOrNull { it.value?.let(::maxIndexInExpression) ?: 0 } ?: 0,
statement.where?.let(::maxIndexInExpression) ?: 0,
statement.returning?.let(::maxIndexInProjection) ?: 0
)
is DeleteStatement -> maxOf(
statement.where?.let(::maxIndexInExpression) ?: 0,
statement.returning?.let(::maxIndexInProjection) ?: 0
)
is MergeStatement -> maxOf(
maxIndexInFromItem(statement.source),
maxIndexInExpression(statement.onCondition),
statement.whenClauses.maxOfOrNull(::maxIndexInWhenClause) ?: 0,
statement.returning?.let(::maxIndexInProjection) ?: 0
)
}
fun substitute(statement: PreparableStatement, boundValues: List<Value>): PreparableStatement = when (statement) {
is QueryStatement -> substituteQuery(statement, boundValues)
is InsertStatement -> statement.copy(
rows = statement.rows.map { row -> row.map { it?.let { expression -> substituteExpression(expression, boundValues) } } },
onConflict = statement.onConflict?.let { substituteOnConflict(it, boundValues) },
returning = statement.returning?.let { substituteProjection(it, boundValues) }
)
is UpdateStatement -> statement.copy(
assignments = substituteAssignments(statement.assignments, boundValues),
where = statement.where?.let { substituteExpression(it, boundValues) },
returning = statement.returning?.let { substituteProjection(it, boundValues) }
)
is DeleteStatement -> statement.copy(
where = statement.where?.let { substituteExpression(it, boundValues) },
returning = statement.returning?.let { substituteProjection(it, boundValues) }
)
is MergeStatement -> statement.copy(
source = substituteFromItem(statement.source, boundValues),
onCondition = substituteExpression(statement.onCondition, boundValues),
whenClauses = statement.whenClauses.map { substituteWhenClause(it, boundValues) },
returning = statement.returning?.let { substituteProjection(it, boundValues) }
)
}
private fun substituteAssignments(assignments: List<Assignment>, boundValues: List<Value>): List<Assignment> =
assignments.map { it.copy(value = it.value?.let { value -> substituteExpression(value, boundValues) }) }
private fun substituteOnConflict(clause: OnConflictClause, boundValues: List<Value>): OnConflictClause =
clause.copy(action = substituteConflictAction(clause.action, boundValues))
private fun substituteConflictAction(action: ConflictAction, boundValues: List<Value>): ConflictAction = when (action) {
ConflictDoNothing -> action
is ConflictDoUpdate -> action.copy(
assignments = substituteAssignments(action.assignments, boundValues),
where = action.where?.let { substituteExpression(it, boundValues) }
)
}
private fun substituteWhenClause(clause: MergeWhenClause, boundValues: List<Value>): MergeWhenClause = when (clause) {
is MergeMatchedUpdate -> clause.copy(
condition = clause.condition?.let { substituteExpression(it, boundValues) },
assignments = substituteAssignments(clause.assignments, boundValues)
)
is MergeMatchedDelete -> clause.copy(condition = clause.condition?.let { substituteExpression(it, boundValues) })
is MergeNotMatchedInsert -> clause.copy(
condition = clause.condition?.let { substituteExpression(it, boundValues) },
values = clause.values.map { it?.let { expression -> substituteExpression(expression, boundValues) } }
)
}
private fun substituteQuery(query: QueryStatement, boundValues: List<Value>): QueryStatement = when (query) {
is SelectStatement -> substituteSelect(query, boundValues)
is SetOperationStatement -> query.copy(
left = substituteQuery(query.left, boundValues),
right = substituteQuery(query.right, boundValues),
orderBy = query.orderBy?.let { substituteOrderBy(it, boundValues) }
)
is WithQuery -> query.copy(
ctes = query.ctes.map { it.copy(query = substituteQuery(it.query, boundValues)) },
body = substituteQuery(query.body, boundValues)
)
}
private fun substituteSelect(select: SelectStatement, boundValues: List<Value>): SelectStatement = select.copy(
from = substituteFromItem(select.from, boundValues),
joins = select.joins.map { join ->
join.copy(
from = substituteFromItem(join.from, boundValues),
on = join.on?.let { substituteExpression(it, boundValues) }
)
},
where = select.where?.let { substituteExpression(it, boundValues) },
groupBy = select.groupBy.map { substituteExpression(it, boundValues) },
groupingSpec = select.groupingSpec?.let { substituteGroupingSpec(it, boundValues) },
having = select.having?.let { substituteExpression(it, boundValues) },
projection = substituteProjection(select.projection, boundValues),
orderBy = select.orderBy?.let { substituteOrderBy(it, boundValues) },
distinctOn = select.distinctOn.map { substituteExpression(it, boundValues) }
)
private fun substituteGroupingSpec(spec: GroupingSpec, boundValues: List<Value>): GroupingSpec = when (spec) {
is RollupSpec -> spec.copy(expressions = spec.expressions.map { substituteExpression(it, boundValues) })
is CubeSpec -> spec.copy(expressions = spec.expressions.map { substituteExpression(it, boundValues) })
is GroupingSetsSpec -> spec.copy(sets = spec.sets.map { set -> set.map { substituteExpression(it, boundValues) } })
}
private fun substituteFromItem(item: FromItem, boundValues: List<Value>): FromItem = when (item) {
is TableSource -> item
is SubquerySource -> item.copy(select = substituteQuery(item.select, boundValues))
}
private fun substituteProjection(projection: Projection, boundValues: List<Value>): Projection = when (projection) {
StarProjection -> projection
is ExpressionProjection ->
projection.copy(items = projection.items.map { it.copy(expression = substituteExpression(it.expression, boundValues)) })
}
private fun substituteOrderBy(orderBy: OrderBy, boundValues: List<Value>): OrderBy =
orderBy.copy(keys = orderBy.keys.map { it.copy(expression = substituteExpression(it.expression, boundValues)) })
private fun substituteExpression(expression: Expression, boundValues: List<Value>): Expression = when (expression) {
is ParameterExpression -> LiteralExpression(boundValueAt(expression.index, boundValues))
is ScalarSubqueryExpression -> expression.copy(select = substituteSelect(expression.select, boundValues))
is ExistsExpression -> expression.copy(select = substituteSelect(expression.select, boundValues))
is InSubqueryExpression -> expression.copy(
operand = substituteExpression(expression.operand, boundValues),
select = substituteSelect(expression.select, boundValues)
)
is BinaryExpression -> expression.copy(
left = substituteExpression(expression.left, boundValues),
right = substituteExpression(expression.right, boundValues)
)
is UnaryExpression -> expression.copy(operand = substituteExpression(expression.operand, boundValues))
is CallExpression -> expression.copy(arguments = expression.arguments.map { substituteExpression(it, boundValues) })
is IsNullExpression -> expression.copy(operand = substituteExpression(expression.operand, boundValues))
is InExpression -> expression.copy(
operand = substituteExpression(expression.operand, boundValues),
values = expression.values.map { substituteExpression(it, boundValues) }
)
is BetweenExpression -> expression.copy(
operand = substituteExpression(expression.operand, boundValues),
low = substituteExpression(expression.low, boundValues),
high = substituteExpression(expression.high, boundValues)
)
is LikeExpression -> expression.copy(
operand = substituteExpression(expression.operand, boundValues),
pattern = substituteExpression(expression.pattern, boundValues)
)
is CaseExpression -> expression.copy(
operand = expression.operand?.let { substituteExpression(it, boundValues) },
branches = expression.branches.map {
it.copy(condition = substituteExpression(it.condition, boundValues), result = substituteExpression(it.result, boundValues))
},
elseExpression = expression.elseExpression?.let { substituteExpression(it, boundValues) }
)
is CastExpression -> expression.copy(operand = substituteExpression(expression.operand, boundValues))
is CollateExpression -> expression.copy(operand = substituteExpression(expression.operand, boundValues))
is FunctionExpression -> expression.copy(
argument = expression.argument?.let { substituteExpression(it, boundValues) },
separator = expression.separator?.let { substituteExpression(it, boundValues) },
orderBy = expression.orderBy.map { it.copy(expression = substituteExpression(it.expression, boundValues)) },
filter = expression.filter?.let { substituteExpression(it, boundValues) }
)
is WindowExpression -> expression.copy(
argument = expression.argument?.let { substituteExpression(it, boundValues) },
partitionBy = expression.partitionBy.map { substituteExpression(it, boundValues) },
orderBy = expression.orderBy.map { it.copy(expression = substituteExpression(it.expression, boundValues)) },
offset = expression.offset?.let { substituteExpression(it, boundValues) },
defaultValue = expression.defaultValue?.let { substituteExpression(it, boundValues) },
frame = expression.frame?.let { frame ->
frame.copy(
start = substituteFrameBound(frame.start, boundValues),
end = substituteFrameBound(frame.end, boundValues)
)
}
)
is ColumnExpression, is LiteralExpression, is NextValueExpression, is CurrentValueExpression,
is MergeActionExpression -> expression
}
private fun substituteFrameBound(bound: FrameBound, boundValues: List<Value>): FrameBound =
bound.copy(offset = bound.offset?.let { substituteExpression(it, boundValues) })
private fun boundValueAt(index: Int, boundValues: List<Value>): Value =
boundValues.getOrNull(index - 1) ?: throw KestrelException("no value supplied for parameter \$$index")
// --- parameterCount's twin walk: same shape as substitute above, but only tracks the max index seen. ---
private fun maxIndexInAssignments(assignments: List<Assignment>): Int =
assignments.maxOfOrNull { it.value?.let(::maxIndexInExpression) ?: 0 } ?: 0
private fun maxIndexInOnConflict(clause: OnConflictClause): Int = when (val action = clause.action) {
ConflictDoNothing -> 0
is ConflictDoUpdate -> maxOf(maxIndexInAssignments(action.assignments), action.where?.let(::maxIndexInExpression) ?: 0)
}
private fun maxIndexInWhenClause(clause: MergeWhenClause): Int = when (clause) {
is MergeMatchedUpdate -> maxOf(
clause.condition?.let(::maxIndexInExpression) ?: 0,
maxIndexInAssignments(clause.assignments)
)
is MergeMatchedDelete -> clause.condition?.let(::maxIndexInExpression) ?: 0
is MergeNotMatchedInsert -> maxOf(
clause.condition?.let(::maxIndexInExpression) ?: 0,
clause.values.maxOfOrNull { it?.let(::maxIndexInExpression) ?: 0 } ?: 0
)
}
private fun maxIndexInQuery(query: QueryStatement): Int = when (query) {
is SelectStatement -> maxIndexInSelect(query)
is SetOperationStatement -> maxOf(
maxIndexInQuery(query.left),
maxIndexInQuery(query.right),
query.orderBy?.let(::maxIndexInOrderBy) ?: 0
)
is WithQuery -> maxOf(
query.ctes.maxOfOrNull { maxIndexInQuery(it.query) } ?: 0,
maxIndexInQuery(query.body)
)
}
private fun maxIndexInSelect(select: SelectStatement): Int = maxOf(
maxIndexInFromItem(select.from),
select.joins.maxOfOrNull { join -> maxOf(maxIndexInFromItem(join.from), join.on?.let(::maxIndexInExpression) ?: 0) } ?: 0,
select.where?.let(::maxIndexInExpression) ?: 0,
select.groupBy.maxOfOrNull(::maxIndexInExpression) ?: 0,
select.groupingSpec?.let(::maxIndexInGroupingSpec) ?: 0,
select.having?.let(::maxIndexInExpression) ?: 0,
maxIndexInProjection(select.projection),
select.orderBy?.let(::maxIndexInOrderBy) ?: 0,
select.distinctOn.maxOfOrNull(::maxIndexInExpression) ?: 0
)
private fun maxIndexInGroupingSpec(spec: GroupingSpec): Int = when (spec) {
is RollupSpec -> spec.expressions.maxOfOrNull(::maxIndexInExpression) ?: 0
is CubeSpec -> spec.expressions.maxOfOrNull(::maxIndexInExpression) ?: 0
is GroupingSetsSpec -> spec.sets.maxOfOrNull { set -> set.maxOfOrNull(::maxIndexInExpression) ?: 0 } ?: 0
}
private fun maxIndexInFromItem(item: FromItem): Int = when (item) {
is TableSource -> 0
is SubquerySource -> maxIndexInQuery(item.select)
}
private fun maxIndexInProjection(projection: Projection): Int = when (projection) {
StarProjection -> 0
is ExpressionProjection -> projection.items.maxOfOrNull { maxIndexInExpression(it.expression) } ?: 0
}
private fun maxIndexInOrderBy(orderBy: OrderBy): Int = orderBy.keys.maxOfOrNull { maxIndexInExpression(it.expression) } ?: 0
private fun maxIndexInExpression(expression: Expression): Int = when (expression) {
is ParameterExpression -> expression.index
is ScalarSubqueryExpression -> maxIndexInSelect(expression.select)
is ExistsExpression -> maxIndexInSelect(expression.select)
is InSubqueryExpression -> maxOf(maxIndexInExpression(expression.operand), maxIndexInSelect(expression.select))
is BinaryExpression -> maxOf(maxIndexInExpression(expression.left), maxIndexInExpression(expression.right))
is UnaryExpression -> maxIndexInExpression(expression.operand)
is CallExpression -> expression.arguments.maxOfOrNull(::maxIndexInExpression) ?: 0
is IsNullExpression -> maxIndexInExpression(expression.operand)
is InExpression -> maxOf(
maxIndexInExpression(expression.operand),
expression.values.maxOfOrNull(::maxIndexInExpression) ?: 0
)
is BetweenExpression -> maxOf(
maxIndexInExpression(expression.operand),
maxIndexInExpression(expression.low),
maxIndexInExpression(expression.high)
)
is LikeExpression -> maxOf(maxIndexInExpression(expression.operand), maxIndexInExpression(expression.pattern))
is CaseExpression -> maxOf(
expression.operand?.let(::maxIndexInExpression) ?: 0,
expression.branches.maxOfOrNull { maxOf(maxIndexInExpression(it.condition), maxIndexInExpression(it.result)) } ?: 0,
expression.elseExpression?.let(::maxIndexInExpression) ?: 0
)
is CastExpression -> maxIndexInExpression(expression.operand)
is CollateExpression -> maxIndexInExpression(expression.operand)
is FunctionExpression -> maxOf(
expression.argument?.let(::maxIndexInExpression) ?: 0,
expression.separator?.let(::maxIndexInExpression) ?: 0,
expression.orderBy.maxOfOrNull { maxIndexInExpression(it.expression) } ?: 0,
expression.filter?.let(::maxIndexInExpression) ?: 0
)
is WindowExpression -> maxOf(
expression.argument?.let(::maxIndexInExpression) ?: 0,
expression.partitionBy.maxOfOrNull(::maxIndexInExpression) ?: 0,
expression.orderBy.maxOfOrNull { maxIndexInExpression(it.expression) } ?: 0,
expression.offset?.let(::maxIndexInExpression) ?: 0,
expression.defaultValue?.let(::maxIndexInExpression) ?: 0,
expression.frame?.start?.offset?.let(::maxIndexInExpression) ?: 0,
expression.frame?.end?.offset?.let(::maxIndexInExpression) ?: 0
)
is ColumnExpression, is LiteralExpression, is NextValueExpression, is CurrentValueExpression,
is MergeActionExpression -> 0
}
}
File diff suppressed because it is too large. Load diff
@@ -6,20 +6,46 @@ sealed interface StatementResult
data class SelectResult(val columns: List<String>, val rows: List<List<Value>>) : StatementResult
data class InsertResult(val count: Int) : StatementResult
data class InsertResult(val count: Int, val returning: SelectResult? = null) : StatementResult
data class UpdateResult(val count: Int) : StatementResult
data class UpdateResult(val count: Int, val returning: SelectResult? = null) : StatementResult
data class DeleteResult(val count: Int) : StatementResult
data class DeleteResult(val count: Int, val returning: SelectResult? = null) : StatementResult
data class CreateTableResult(val tableName: String) : StatementResult
data class DropTableResult(val tableName: String) : StatementResult
/** [tableNames] is every table actually truncated - the statement's own list plus, under CASCADE, every transitive FOREIGN KEY referencer added to it. */
data class TruncateTableResult(val tableNames: List<String>) : StatementResult
data class MergeResult(
val insertedCount: Int,
val updatedCount: Int,
val deletedCount: Int,
val returning: SelectResult? = null
) : StatementResult
data class AlterTableResult(val tableName: String) : StatementResult
data class CreateIndexResult(val indexName: String) : StatementResult
data class DropIndexResult(val indexName: String) : StatementResult
data class CreateViewResult(val viewName: String) : StatementResult
data class DropViewResult(val viewName: String) : StatementResult
data class CreateTriggerResult(val triggerName: String) : StatementResult
data class DropTriggerResult(val triggerName: String) : StatementResult
data class CreateSequenceResult(val sequenceName: String) : StatementResult
data class DropSequenceResult(val sequenceName: String) : StatementResult
data class AlterSequenceResult(val sequenceName: String) : StatementResult
data class ExplainResult(val plan: List<String>) : StatementResult
data object BeginResult : StatementResult
@@ -27,3 +53,14 @@ data object BeginResult : StatementResult
data object CommitResult : StatementResult
data object RollbackResult : StatementResult
data class SavepointResult(val name: String) : StatementResult
data class ReleaseSavepointResult(val name: String) : StatementResult
data class RollbackToSavepointResult(val name: String) : StatementResult
data class PrepareResult(val name: String) : StatementResult
/** [name] is `null` exactly when [all] is `true` (`DEALLOCATE ALL`), mirroring [kestrel.sql.ast.DeallocateStatement]. */
data class DeallocateResult(val name: String?, val all: Boolean) : StatementResult
@@ -0,0 +1,39 @@
package kestrel.engine
import kestrel.KestrelException
import kestrel.catalog.TableSchema
import kestrel.sql.ast.ColumnExpression
import kestrel.storage.Collation
import kestrel.storage.Row
import kestrel.storage.Value
/**
* Binds `NEW`/`OLD` for the one row a firing trigger is currently processing, against [schema] -
* always the trigger's OWN table (the one named in its `ON <table>`), never whatever table a
* nested body statement happens to target. [newRow] is null for a DELETE trigger, [oldRow] is null
* for an INSERT trigger; both are non-null only inside an UPDATE trigger.
*/
class TriggerRowContext(private val schema: TableSchema, private val newRow: Row?, private val oldRow: Row?) {
fun matches(expression: ColumnExpression): Boolean {
val qualifier = expression.qualifier ?: return false
return (qualifier.equals("new", ignoreCase = true) && newRow != null) ||
(qualifier.equals("old", ignoreCase = true) && oldRow != null)
}
fun resolve(expression: ColumnExpression): Value {
val row = if (expression.qualifier!!.equals("new", ignoreCase = true)) newRow!! else oldRow!!
val index = schema.columnIndex(expression.name)
?: throw KestrelException("no such column: ${expression.name}")
return row.values[index]
}
/** Column-existence check with no row required, for validating a reference before it is ever fired. */
fun checkColumnExists(expression: ColumnExpression) {
if (schema.columnIndex(expression.name) == null) {
throw KestrelException("no such column: ${expression.name}")
}
}
/** `NEW.col`/`OLD.col` reads the same table's schema either way, so its declared COLLATE never depends on which side [expression] names. */
fun declaredCollation(expression: ColumnExpression): Collation? = schema.column(expression.name)?.collation
}
@@ -0,0 +1,251 @@
package kestrel.engine.function
import java.math.RoundingMode
import java.time.LocalDate
import kestrel.KestrelException
import kestrel.storage.Value
import kestrel.storage.ValueOrdering
import kestrel.storage.typeName
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.pow
import kotlin.math.roundToLong
class ScalarFunction(
val name: String,
private val minArgs: Int,
private val maxArgs: Int,
private val nullPropagating: Boolean,
private val body: (List<Value>) -> Value
) {
fun apply(arguments: List<Value>): Value {
if (arguments.size < minArgs || arguments.size > maxArgs) {
val expected = if (minArgs == maxArgs) "$minArgs" else "$minArgs to $maxArgs"
throw KestrelException("function $name expects $expected argument(s) but got ${arguments.size}")
}
if (nullPropagating && arguments.any { it.isNull }) return Value.NullValue
return body(arguments)
}
}
object ScalarFunctions {
private val registry: Map<String, ScalarFunction> = buildRegistry()
fun lookup(name: String): ScalarFunction? = registry[name.uppercase()]
private fun buildRegistry(): Map<String, ScalarFunction> {
val functions = mutableListOf<ScalarFunction>()
fun register(
name: String,
minArgs: Int,
maxArgs: Int = minArgs,
nullPropagating: Boolean = true,
body: (List<Value>) -> Value
) {
functions.add(ScalarFunction(name, minArgs, maxArgs, nullPropagating, body))
}
register("UPPER", 1) { args -> Value.TextValue(text(args[0], "UPPER").uppercase()) }
register("LOWER", 1) { args -> Value.TextValue(text(args[0], "LOWER").lowercase()) }
register("LENGTH", 1) { args -> Value.IntegerValue(text(args[0], "LENGTH").length.toLong()) }
register("TRIM", 1) { args -> Value.TextValue(text(args[0], "TRIM").trim()) }
register("LTRIM", 1) { args -> Value.TextValue(text(args[0], "LTRIM").trimStart()) }
register("RTRIM", 1) { args -> Value.TextValue(text(args[0], "RTRIM").trimEnd()) }
register("SUBSTR", 2, 3) { args -> substr(args) }
register("SUBSTRING", 2, 3) { args -> substr(args) }
register("REPLACE", 3) { args ->
Value.TextValue(text(args[0], "REPLACE").replace(text(args[1], "REPLACE"), text(args[2], "REPLACE")))
}
register("CONCAT", 2, Int.MAX_VALUE) { args -> Value.TextValue(args.joinToString("") { text(it, "CONCAT") }) }
register("REVERSE", 1) { args -> Value.TextValue(text(args[0], "REVERSE").reversed()) }
register("LEFT", 2) { args -> Value.TextValue(edge(args, "LEFT", fromStart = true)) }
register("RIGHT", 2) { args -> Value.TextValue(edge(args, "RIGHT", fromStart = false)) }
register("ABS", 1) { args -> absoluteValue(args[0]) }
register("ROUND", 1, 2) { args -> round(args) }
register("CEIL", 1) { args -> Value.IntegerValue(ceilValue(args[0])) }
register("CEILING", 1) { args -> Value.IntegerValue(ceilValue(args[0])) }
register("FLOOR", 1) { args -> Value.IntegerValue(floorValue(args[0])) }
register("COALESCE", 1, Int.MAX_VALUE, nullPropagating = false) { args ->
args.firstOrNull { !it.isNull } ?: Value.NullValue
}
register("IFNULL", 2, nullPropagating = false) { args -> if (!args[0].isNull) args[0] else args[1] }
register("NULLIF", 2, nullPropagating = false) { args ->
if (!args[0].isNull && !args[1].isNull && ValueOrdering.compare(args[0], args[1]) == 0) {
Value.NullValue
} else {
args[0]
}
}
register("TYPEOF", 1, nullPropagating = false) { args -> Value.TextValue(args[0].typeName) }
register("CURRENT_DATE", 0, nullPropagating = false) { Value.DateValue(LocalDate.now()) }
register("YEAR", 1) { args -> Value.IntegerValue(date(args[0], "YEAR").year.toLong()) }
register("MONTH", 1) { args -> Value.IntegerValue(date(args[0], "MONTH").monthValue.toLong()) }
register("DAY", 1) { args -> Value.IntegerValue(date(args[0], "DAY").dayOfMonth.toLong()) }
register("DAYOFWEEK", 1) { args -> Value.IntegerValue(date(args[0], "DAYOFWEEK").dayOfWeek.value.toLong()) }
register("DATE_ADD", 2) { args -> Value.DateValue(date(args[0], "DATE_ADD").plusDays(integer(args[1], "DATE_ADD"))) }
register("DATE_SUB", 2) { args -> Value.DateValue(date(args[0], "DATE_SUB").minusDays(integer(args[1], "DATE_SUB"))) }
register("DATEDIFF", 2) { args ->
Value.IntegerValue(date(args[0], "DATEDIFF").toEpochDay() - date(args[1], "DATEDIFF").toEpochDay())
}
register("REPEAT", 2) { args ->
val count = integer(args[1], "REPEAT")
Value.TextValue(if (count <= 0) "" else text(args[0], "REPEAT").repeat(count.toInt()))
}
register("LPAD", 2, 3) { args -> pad(args, "LPAD", left = true) }
register("RPAD", 2, 3) { args -> pad(args, "RPAD", left = false) }
register("INSTR", 2) { args -> Value.IntegerValue((text(args[0], "INSTR").indexOf(text(args[1], "INSTR")) + 1).toLong()) }
register("POWER", 2) { args -> power(args[0], args[1]) }
register("MOD", 2) { args -> mod(args[0], args[1]) }
register("SIGN", 1) { args -> Value.IntegerValue(sign(args[0]).toLong()) }
register("TRUNC", 1, 2) { args -> trunc(args) }
// Any NULL argument already short-circuited to NULL above (nullPropagating, the default),
// so by the time the body runs every argument is non-NULL and args is non-empty (min 2) -
// maxWith/minWith can never return null here despite the nullable Comparator.compare contract.
register("GREATEST", 2, Int.MAX_VALUE) { args -> args.maxWith(ValueOrdering) }
register("LEAST", 2, Int.MAX_VALUE) { args -> args.minWith(ValueOrdering) }
return functions.associateBy { it.name }
}
private fun text(value: Value, function: String): String = when (value) {
is Value.TextValue -> value.value
else -> throw KestrelException("function $function requires a TEXT argument but got ${value.typeName}")
}
private fun date(value: Value, function: String): LocalDate = when (value) {
is Value.DateValue -> value.value
else -> throw KestrelException("function $function requires a DATE argument but got ${value.typeName}")
}
private fun integer(value: Value, function: String): Long = when (value) {
is Value.IntegerValue -> value.value
else -> throw KestrelException("function $function requires an INTEGER argument but got ${value.typeName}")
}
private fun number(value: Value, function: String): Double = when (value) {
is Value.IntegerValue -> value.value.toDouble()
is Value.RealValue -> value.value
else -> throw KestrelException("function $function requires a numeric argument but got ${value.typeName}")
}
private fun absoluteValue(value: Value): Value = when (value) {
is Value.IntegerValue -> Value.IntegerValue(abs(value.value))
is Value.RealValue -> Value.RealValue(abs(value.value))
is Value.DecimalValue -> Value.DecimalValue(value.value.abs())
else -> throw KestrelException("function ABS requires a numeric argument but got ${value.typeName}")
}
private fun ceilValue(value: Value): Long = when (value) {
is Value.DecimalValue -> value.value.setScale(0, RoundingMode.CEILING).toLong()
else -> ceil(number(value, "CEIL")).toLong()
}
private fun floorValue(value: Value): Long = when (value) {
is Value.DecimalValue -> value.value.setScale(0, RoundingMode.FLOOR).toLong()
else -> floor(number(value, "FLOOR")).toLong()
}
private fun substr(args: List<Value>): Value {
val source = text(args[0], "SUBSTR")
val requestedStart = (args[1] as? Value.IntegerValue)?.value
?: throw KestrelException("function SUBSTR requires an INTEGER start position")
val start = (if (requestedStart > 0) requestedStart - 1 else 0).coerceAtMost(source.length.toLong()).toInt()
val length = if (args.size == 3) {
(args[2] as? Value.IntegerValue)?.value
?: throw KestrelException("function SUBSTR requires an INTEGER length")
} else {
null
}
val end = if (length != null) (start + length.coerceAtLeast(0)).coerceAtMost(source.length.toLong()).toInt() else source.length
return Value.TextValue(if (start >= end) "" else source.substring(start, end))
}
/** `LEFT(str, n)`/`RIGHT(str, n)`: the first/last `n` characters, clamped to `[0, str.length]` (a non-positive `n` is empty, an `n` past the string's length is the whole string). */
private fun edge(args: List<Value>, function: String, fromStart: Boolean): String {
val source = text(args[0], function)
val count = integer(args[1], function).coerceIn(0, source.length.toLong()).toInt()
return if (fromStart) source.substring(0, count) else source.substring(source.length - count)
}
/**
* MySQL-compatible LPAD/RPAD: the result is always exactly [target length][Value.IntegerValue]
* characters (never longer) - a source already at or past that length is truncated to it (from
* the start, regardless of which side would have been padded), and a source shorter than it is
* padded by cycling [padString] (default a single space) until the target length is reached.
*/
private fun pad(args: List<Value>, function: String, left: Boolean): Value {
val source = text(args[0], function)
val targetLength = integer(args[1], function)
if (targetLength <= 0) return Value.TextValue("")
val length = targetLength.toInt()
if (length <= source.length) return Value.TextValue(source.substring(0, length))
val padString = if (args.size == 3) text(args[2], function) else " "
if (padString.isEmpty()) throw KestrelException("function $function requires a non-empty pad string")
val needed = length - source.length
val padding = buildString {
while (this.length < needed) append(padString)
}.substring(0, needed)
return Value.TextValue(if (left) padding + source else source + padding)
}
/** Two INTEGER operands with a non-negative exponent stay exact (repeated Long multiplication); anything else - a negative exponent or a REAL operand on either side - falls back to `Math.pow`. */
private fun power(base: Value, exponent: Value): Value {
if (base is Value.IntegerValue && exponent is Value.IntegerValue && exponent.value >= 0) {
var result = 1L
repeat(exponent.value.toInt()) { result *= base.value }
return Value.IntegerValue(result)
}
return Value.RealValue(number(base, "POWER").pow(number(exponent, "POWER")))
}
/** Two INTEGER operands use exact `Long` remainder; a REAL operand on either side uses floating-point remainder instead. */
private fun mod(dividend: Value, divisor: Value): Value {
if (dividend is Value.IntegerValue && divisor is Value.IntegerValue) {
if (divisor.value == 0L) throw KestrelException("division by zero")
return Value.IntegerValue(dividend.value % divisor.value)
}
val divisorNumber = number(divisor, "MOD")
if (divisorNumber == 0.0) throw KestrelException("division by zero")
return Value.RealValue(number(dividend, "MOD") % divisorNumber)
}
private fun round(args: List<Value>): Value {
val digits = if (args.size == 2) {
(args[1] as? Value.IntegerValue)?.value?.toInt()
?: throw KestrelException("function ROUND requires an INTEGER digit count")
} else {
0
}
if (args[0] is Value.IntegerValue && digits >= 0) return args[0]
val decimal = args[0] as? Value.DecimalValue
if (decimal != null) return Value.DecimalValue(decimal.value.setScale(digits, RoundingMode.HALF_UP))
val value = number(args[0], "ROUND")
val factor = 10.0.pow(digits)
return Value.RealValue((value * factor).roundToLong() / factor)
}
private fun sign(value: Value): Int = when (value) {
is Value.IntegerValue -> if (value.value > 0) 1 else if (value.value < 0) -1 else 0
is Value.RealValue -> if (value.value > 0) 1 else if (value.value < 0) -1 else 0
is Value.DecimalValue -> value.value.signum()
else -> throw KestrelException("function SIGN requires a numeric argument but got ${value.typeName}")
}
/** Same digit-count/type handling as [round], but truncates toward zero (`RoundingMode.DOWN`) instead of rounding half-up. */
private fun trunc(args: List<Value>): Value {
val digits = if (args.size == 2) {
(args[1] as? Value.IntegerValue)?.value?.toInt()
?: throw KestrelException("function TRUNC requires an INTEGER digit count")
} else {
0
}
if (args[0] is Value.IntegerValue && digits >= 0) return args[0]
val decimal = args[0] as? Value.DecimalValue
if (decimal != null) return Value.DecimalValue(decimal.value.setScale(digits, RoundingMode.DOWN))
val value = number(args[0], "TRUNC")
val factor = 10.0.pow(digits)
val scaled = value * factor
return Value.RealValue((if (scaled >= 0) floor(scaled) else ceil(scaled)) / factor)
}
}
+586 -22
View File
@@ -2,61 +2,309 @@ package kestrel.persistence
import java.math.BigDecimal
import kestrel.catalog.Catalog
import kestrel.catalog.CheckConstraint
import kestrel.catalog.Column
import kestrel.catalog.ForeignKeyAction
import kestrel.catalog.ForeignKeyConstraint
import kestrel.catalog.Sequence
import kestrel.catalog.SequenceDefinition
import kestrel.catalog.UniqueConstraint
import kestrel.catalog.TriggerDefinition
import kestrel.catalog.sqlKeyword
import kestrel.sql.ast.AddColumn
import kestrel.sql.ast.AddConstraint
import kestrel.sql.ast.AddedCheck
import kestrel.sql.ast.AddedConstraint
import kestrel.sql.ast.AddedForeignKey
import kestrel.sql.ast.AddedUnique
import kestrel.sql.ast.AlterTableStatement
import kestrel.sql.ast.Assignment
import kestrel.sql.ast.BetweenExpression
import kestrel.sql.ast.BinaryExpression
import kestrel.sql.ast.CallExpression
import kestrel.sql.ast.CaseExpression
import kestrel.sql.ast.CastExpression
import kestrel.sql.ast.ChangeColumnType
import kestrel.sql.ast.CollateExpression
import kestrel.sql.ast.ColumnExpression
import kestrel.sql.ast.ConflictDoNothing
import kestrel.sql.ast.ConflictDoUpdate
import kestrel.sql.ast.AlterSequenceStatement
import kestrel.sql.ast.CreateIndexStatement
import kestrel.sql.ast.CreateSequenceStatement
import kestrel.sql.ast.CreateTableStatement
import kestrel.sql.ast.CreateTriggerStatement
import kestrel.sql.ast.CreateViewStatement
import kestrel.sql.ast.CubeSpec
import kestrel.sql.ast.CurrentValueExpression
import kestrel.sql.ast.DeleteStatement
import kestrel.sql.ast.DropColumn
import kestrel.sql.ast.DropConstraint
import kestrel.sql.ast.DropIndexStatement
import kestrel.sql.ast.DropMode
import kestrel.sql.ast.DropSequenceStatement
import kestrel.sql.ast.DropTableStatement
import kestrel.sql.ast.DropTriggerStatement
import kestrel.sql.ast.DropViewStatement
import kestrel.sql.ast.ExistsExpression
import kestrel.sql.ast.Expression
import kestrel.sql.ast.ExpressionProjection
import kestrel.sql.ast.FrameBound
import kestrel.sql.ast.FrameBoundType
import kestrel.sql.ast.FrameExclude
import kestrel.sql.ast.FrameUnit
import kestrel.sql.ast.FromItem
import kestrel.sql.ast.FunctionExpression
import kestrel.sql.ast.GroupingSetsSpec
import kestrel.sql.ast.GroupingSpec
import kestrel.sql.ast.InExpression
import kestrel.sql.ast.InSubqueryExpression
import kestrel.sql.ast.InsertStatement
import kestrel.sql.ast.IsNullExpression
import kestrel.sql.ast.JoinType
import kestrel.sql.ast.LikeExpression
import kestrel.sql.ast.LiteralExpression
import kestrel.sql.ast.MergeActionExpression
import kestrel.sql.ast.ParameterExpression
import kestrel.sql.ast.MergeMatchedDelete
import kestrel.sql.ast.MergeMatchedUpdate
import kestrel.sql.ast.MergeNotMatchedInsert
import kestrel.sql.ast.MergeStatement
import kestrel.sql.ast.MergeWhenClause
import kestrel.sql.ast.NextValueExpression
import kestrel.sql.ast.OnConflictClause
import kestrel.sql.ast.OrderBy
import kestrel.sql.ast.OrderKey
import kestrel.sql.ast.Projection
import kestrel.sql.ast.QueryStatement
import kestrel.sql.ast.RenameColumn
import kestrel.sql.ast.RenameTable
import kestrel.sql.ast.RollupSpec
import kestrel.sql.ast.ScalarSubqueryExpression
import kestrel.sql.ast.SelectStatement
import kestrel.sql.ast.SetOperationStatement
import kestrel.sql.ast.SetOperator
import kestrel.sql.ast.StarProjection
import kestrel.sql.ast.Statement
import kestrel.sql.ast.SubquerySource
import kestrel.sql.ast.TableSource
import kestrel.sql.ast.TriggerEvent
import kestrel.sql.ast.TriggerTiming
import kestrel.sql.ast.TruncateTableStatement
import kestrel.sql.ast.UnaryExpression
import kestrel.sql.ast.UpdateStatement
import kestrel.sql.ast.WindowExpression
import kestrel.sql.ast.WithQuery
import kestrel.storage.Collation
import kestrel.storage.Value
import kestrel.storage.displayWith
object SqlWriter {
fun expressionText(expression: Expression): String = expression(expression)
fun canPersist(statement: Statement): Boolean = statement is CreateTableStatement ||
statement is DropTableStatement ||
statement is TruncateTableStatement ||
statement is AlterTableStatement ||
statement is CreateIndexStatement ||
statement is DropIndexStatement ||
statement is CreateViewStatement ||
statement is DropViewStatement ||
statement is CreateTriggerStatement ||
statement is DropTriggerStatement ||
statement is CreateSequenceStatement ||
statement is DropSequenceStatement ||
statement is AlterSequenceStatement ||
statement is InsertStatement ||
statement is UpdateStatement ||
statement is DeleteStatement
statement is DeleteStatement ||
statement is MergeStatement
fun write(statement: Statement): String = when (statement) {
is CreateTableStatement -> "CREATE TABLE ${statement.tableName} (" +
statement.columns.joinToString(", ") { columnDefinition(it) } + ");"
is DropTableStatement -> "DROP TABLE ${statement.tableName};"
is CreateTableStatement -> "CREATE ${if (statement.temporary) "TEMPORARY " else ""}TABLE ${statement.tableName} (" +
createTableBodyText(
statement.columns,
statement.foreignKeys,
statement.checks,
statement.uniqueConstraints,
statement.compositePrimaryKey
) + ");"
is DropTableStatement -> "DROP TABLE ${statement.tableName}${dropModeText(statement.mode)};"
is TruncateTableStatement -> writeTruncate(statement)
is AlterTableStatement -> alterTableText(statement)
is CreateIndexStatement ->
"CREATE INDEX ${statement.indexName} ON ${statement.tableName} (${statement.columnName});"
"CREATE ${if (statement.unique) "UNIQUE " else ""}INDEX ${statement.indexName} ON " +
"${statement.tableName} (${statement.columnNames.joinToString(", ")});"
is DropIndexStatement -> "DROP INDEX ${statement.indexName};"
is CreateViewStatement ->
"CREATE ${if (statement.orReplace) "OR REPLACE " else ""}VIEW ${statement.viewName} AS " +
"${queryText(statement.query)};"
is DropViewStatement -> "DROP VIEW ${statement.viewName}${dropModeText(statement.mode)};"
is CreateTriggerStatement -> triggerText(
statement.triggerName, statement.timing, statement.event, statement.tableName, statement.whenClause, statement.body
)
is DropTriggerStatement -> "DROP TRIGGER ${statement.triggerName};"
is CreateSequenceStatement -> createSequenceText(statement)
is DropSequenceStatement -> "DROP SEQUENCE ${statement.sequenceName};"
is AlterSequenceStatement -> alterSequenceText(statement)
is InsertStatement -> writeInsert(statement)
is UpdateStatement -> writeUpdate(statement)
is DeleteStatement -> writeDelete(statement)
is MergeStatement -> writeMerge(statement)
else -> throw IllegalArgumentException("statement is not persistable")
}
fun triggerText(definition: TriggerDefinition): String =
triggerText(definition.name, definition.timing, definition.event, definition.tableName, definition.whenClause, definition.body)
private fun triggerText(
name: String,
timing: TriggerTiming,
event: TriggerEvent,
tableName: String,
whenClause: Expression?,
body: List<Statement>
): String = buildString {
append("CREATE TRIGGER $name ")
append(if (timing == TriggerTiming.Before) "BEFORE" else "AFTER")
append(" ")
append(
when (event) {
TriggerEvent.Insert -> "INSERT"
TriggerEvent.Update -> "UPDATE"
TriggerEvent.Delete -> "DELETE"
}
)
append(" ON $tableName FOR EACH ROW")
whenClause?.let { append(" WHEN ${expression(it)}") }
append(" BEGIN ")
for (statement in body) {
append(write(statement))
append(" ")
}
append("END;")
}
private fun createSequenceText(statement: CreateSequenceStatement): String = buildString {
append("CREATE SEQUENCE ${statement.sequenceName}")
statement.incrementBy?.let { append(" INCREMENT BY $it") }
statement.minValue?.let { append(" MINVALUE $it") }
statement.maxValue?.let { append(" MAXVALUE $it") }
statement.startValue?.let { append(" START WITH $it") }
if (statement.cycle) append(" CYCLE")
append(";")
}
private fun alterSequenceText(statement: AlterSequenceStatement): String = buildString {
append("ALTER SEQUENCE ${statement.sequenceName}")
statement.incrementBy?.let { append(" INCREMENT BY $it") }
when {
statement.clearMinValue -> append(" NO MINVALUE")
statement.minValue != null -> append(" MINVALUE ${statement.minValue}")
}
when {
statement.clearMaxValue -> append(" NO MAXVALUE")
statement.maxValue != null -> append(" MAXVALUE ${statement.maxValue}")
}
if (statement.restart) {
append(" RESTART")
statement.restartWith?.let { append(" WITH $it") }
}
when (statement.cycle) {
true -> append(" CYCLE")
false -> append(" NO CYCLE")
null -> Unit
}
append(";")
}
/**
* Bakes the sequence's CURRENT position into `START WITH`, rather than dumping
* [SequenceDefinition.startValue] as written at CREATE time, so a reload resumes NEXTVAL
* exactly where this session left off instead of reissuing already-handed-out values - see
* [Sequence]'s own doc on why the counter (unlike the CREATE/DROP/ALTER SEQUENCE structure
* every other part of [dump] is free to just replay literally) needs this special treatment.
*/
private fun sequenceDumpText(sequence: Sequence): String {
val definition = sequence.definition
val cycle = if (definition.cycle) " CYCLE" else ""
return "CREATE SEQUENCE ${definition.name} INCREMENT BY ${definition.incrementBy} " +
"MINVALUE ${definition.minValue} MAXVALUE ${definition.maxValue} START WITH ${sequence.peekNext()}$cycle;"
}
fun dump(catalog: Catalog): String {
val builder = StringBuilder()
// Sequences are dumped FIRST, strictly before every table - a table's own column DEFAULT
// can read `NEXTVAL('seq')` (see kestrel.engine.Executor.executeCreateTable's eager
// `catalog.sequence(...)` existence check), so replaying that CREATE TABLE on reload would
// fail with "no such sequence" if the sequence it names did not already exist yet.
for (definition in catalog.sequenceDefinitions()) {
builder.append("${sequenceDumpText(catalog.sequence(definition.name))}\n")
}
for (tableName in catalog.tableNames) {
val table = catalog.table(tableName)
// A TEMP/TEMPORARY table (see kestrel.catalog.TableSchema.temporary) is session-scoped by
// definition - dumping it (or its rows below) into a checkpoint would make it survive a
// reload, which is exactly what "temporary" promises it never does.
if (table.schema.temporary) continue
builder.append("CREATE TABLE ${table.schema.name} (")
builder.append(table.schema.columns.joinToString(", ") { columnDefinition(it) })
builder.append(
createTableBodyText(
table.schema.columns,
table.schema.foreignKeys,
table.schema.checks,
table.schema.uniqueConstraints,
table.schema.compositePrimaryKey
)
)
builder.append(");\n")
for (row in table.rows) {
builder.append("INSERT INTO ${table.schema.name} VALUES (")
builder.append(row.values.joinToString(", ") { literal(it) })
// A GENERATED column's position is dumped as the literal `DEFAULT` keyword rather
// than its already-computed value - the executor rejects an explicit value for a
// GENERATED column outright (see Executor.rejectGeneratedAssignment), and `DEFAULT`
// here triggers the exact same fresh-recompute path an omitted column would, which
// reproduces the same value on replay since the expression is a pure function of
// this row's other (literal, already-dumped) columns.
builder.append(
row.values.mapIndexed { index, value ->
if (table.schema.columns[index].generated != null) "DEFAULT" else literal(value)
}.joinToString(", ")
)
builder.append(");\n")
}
}
for (definition in catalog.indexDefinitions()) {
builder.append("CREATE INDEX ${definition.name} ON ${definition.tableName} (${definition.columnName});\n")
// An index defined on a TEMP table never gets a CREATE TABLE to attach to above, so
// dumping it would replay as "no such table" - skip it right along with its table.
if (catalog.tableOrNull(definition.tableName)?.schema?.temporary == true) continue
builder.append(
"CREATE ${if (definition.unique) "UNIQUE " else ""}INDEX ${definition.name} ON " +
"${definition.tableName} (${definition.columnDisplayName});\n"
)
}
// A VIEW whose query reads a TEMP table is NOT specially excluded here (unlike the index/
// trigger cases above, its dependency can be buried arbitrarily deep in a join/subquery, not
// one flat tableName field) - dumping it and replaying it against a reload that never
// recreated that TEMP table fails loudly with "no such table", the same honest failure mode
// this engine already accepts for e.g. a prepared statement that does not survive reopen,
// rather than silently materializing an empty or wrong view.
for (viewName in catalog.viewNames) {
val view = catalog.viewOrNull(viewName)!!
builder.append("CREATE VIEW ${view.name} AS ${queryText(view.query)};\n")
}
// Triggers are dumped LAST, strictly after every row above has already been inserted as
// plain data - a trigger created before its table's INSERTs would re-fire (duplicating an
// AFTER trigger's side effects, or re-running a BEFORE trigger's validation) the moment
// this very dump is replayed on reload, since replay re-executes each of these statements
// for real. Creating the trigger only once the table is already fully populated means
// those historical rows never pass through it, while every INSERT/UPDATE/DELETE a user
// issues after reload still does.
for (definition in catalog.triggerDefinitions()) {
// Same reasoning as the index skip above: a trigger ON a TEMP table has no table left to
// attach to after reload.
if (catalog.tableOrNull(definition.tableName)?.schema?.temporary == true) continue
builder.append("${triggerText(definition)}\n")
}
return builder.toString()
}
@@ -64,9 +312,23 @@ object SqlWriter {
private fun writeInsert(statement: InsertStatement): String {
val columns = statement.columns?.let { " (${it.joinToString(", ")})" } ?: ""
val rows = statement.rows.joinToString(", ") { row ->
"(" + row.joinToString(", ") { expression(it) } + ")"
"(" + row.joinToString(", ") { it?.let { expression(it) } ?: "DEFAULT" } + ")"
}
return "INSERT INTO ${statement.tableName}$columns VALUES $rows;"
val onConflict = statement.onConflict?.let { " ${onConflictText(it)}" } ?: ""
return "INSERT INTO ${statement.tableName}$columns VALUES $rows$onConflict;"
}
private fun onConflictText(clause: OnConflictClause): String {
val target = if (clause.targetColumns.isEmpty()) "" else " (${clause.targetColumns.joinToString(", ")})"
val action = when (val action = clause.action) {
ConflictDoNothing -> "DO NOTHING"
is ConflictDoUpdate -> {
val assignments = action.assignments.joinToString(", ") { assignment(it) }
val where = action.where?.let { " WHERE ${expression(it)}" } ?: ""
"DO UPDATE SET $assignments$where"
}
}
return "ON CONFLICT$target $action"
}
private fun writeUpdate(statement: UpdateStatement): String {
@@ -75,38 +337,338 @@ object SqlWriter {
return "UPDATE ${statement.tableName} SET $assignments$where;"
}
private fun writeTruncate(statement: TruncateTableStatement): String {
val identity = if (statement.restartIdentity) " RESTART IDENTITY" else ""
return "TRUNCATE TABLE ${statement.tableNames.joinToString(", ")}$identity${dropModeText(statement.mode)};"
}
private fun writeDelete(statement: DeleteStatement): String {
val where = statement.where?.let { " WHERE ${expression(it)}" } ?: ""
return "DELETE FROM ${statement.tableName}$where;"
}
private fun assignment(assignment: Assignment): String =
"${assignment.columnName} = ${expression(assignment.value)}"
private fun columnDefinition(column: Column): String {
val constraint = when {
column.primaryKey -> " PRIMARY KEY"
column.notNull -> " NOT NULL"
else -> ""
}
return "${column.name} ${column.type.displayName}$constraint"
/**
* Persisted literally like every other DML statement (see [writeInsert]/[writeUpdate]): by the
* time this redo-log entry replays, every earlier statement - including whatever populated a
* table-shaped `USING` source - has already replayed too, so re-running the exact same MERGE
* text against the freshly rebuilt catalog reproduces the same result deterministically.
*/
private fun writeMerge(statement: MergeStatement): String {
val targetAlias = statement.targetAlias?.let { " AS $it" } ?: ""
val whenClauses = statement.whenClauses.joinToString(" ") { mergeWhenClauseText(it) }
return "MERGE INTO ${statement.targetTable}$targetAlias USING ${fromItemText(statement.source)} " +
"ON ${expression(statement.onCondition)} $whenClauses;"
}
private fun mergeWhenClauseText(clause: MergeWhenClause): String {
val condition = clause.condition?.let { " AND ${expression(it)}" } ?: ""
return when (clause) {
is MergeMatchedUpdate ->
"WHEN MATCHED$condition THEN UPDATE SET ${clause.assignments.joinToString(", ") { assignment(it) }}"
is MergeMatchedDelete -> "WHEN MATCHED$condition THEN DELETE"
is MergeNotMatchedInsert -> {
val columns = clause.columns?.let { " (${it.joinToString(", ")})" } ?: ""
val values = clause.values.joinToString(", ") { it?.let { value -> expression(value) } ?: "DEFAULT" }
"WHEN NOT MATCHED$condition THEN INSERT$columns VALUES ($values)"
}
}
}
private fun assignment(assignment: Assignment): String =
"${assignment.columnName} = ${assignment.value?.let { expression(it) } ?: "DEFAULT"}"
private fun columnDefinition(column: Column): String = buildString {
append(column.name)
append(" ")
append(column.type.displayWith(column.precision, column.scale))
when {
column.primaryKey -> append(" PRIMARY KEY")
column.notNull -> append(" NOT NULL")
}
if (column.collation != Collation.Binary) append(" COLLATE ${column.collation.sqlName}")
column.default?.let { append(" DEFAULT ${expression(it)}") }
column.generated?.let { append(" GENERATED ALWAYS AS (${expression(it)}) STORED") }
}
private fun alterTableText(statement: AlterTableStatement): String {
val action = when (val action = statement.action) {
is AddColumn -> "ADD COLUMN ${columnDefinition(action.column)}"
is DropColumn -> "DROP COLUMN ${action.columnName}${dropModeText(action.mode)}"
is RenameColumn -> "RENAME COLUMN ${action.oldName} TO ${action.newName}"
is RenameTable -> "RENAME TO ${action.newName}"
is AddConstraint -> "ADD ${addedConstraintText(action.constraint)}"
is DropConstraint -> "DROP CONSTRAINT ${action.name}${dropModeText(action.mode)}"
is ChangeColumnType -> {
val using = action.using?.let { " USING ${expression(it)}" } ?: ""
"ALTER COLUMN ${action.columnName} TYPE ${action.newType.displayWith(action.precision, action.scale)}$using"
}
}
return "ALTER TABLE ${statement.tableName} $action;"
}
private fun addedConstraintText(constraint: AddedConstraint): String = when (constraint) {
is AddedCheck -> constraintText(constraint.check.name, "CHECK (${expression(constraint.check.expression)})")
is AddedUnique -> constraintText(constraint.unique.name, "UNIQUE (${constraint.unique.columns.joinToString(", ")})")
is AddedForeignKey -> constraintText(constraint.foreignKey.name, foreignKeyText(constraint.foreignKey))
}
private fun constraintText(name: String?, body: String): String =
if (name != null) "CONSTRAINT $name $body" else body
private fun createTableBodyText(
columns: List<Column>,
foreignKeys: List<ForeignKeyConstraint>,
checks: List<CheckConstraint>,
uniqueConstraints: List<UniqueConstraint>,
compositePrimaryKey: List<String> = emptyList()
): String {
val parts = columns.map { columnDefinition(it) } +
(if (compositePrimaryKey.isNotEmpty()) listOf("PRIMARY KEY (${compositePrimaryKey.joinToString(", ")})") else emptyList()) +
foreignKeys.map { constraintText(it.name, foreignKeyText(it)) } +
checks.map { constraintText(it.name, "CHECK (${expression(it.expression)})") } +
uniqueConstraints.map { constraintText(it.name, "UNIQUE (${it.columns.joinToString(", ")})") }
return parts.joinToString(", ")
}
private fun foreignKeyText(fk: ForeignKeyConstraint): String = buildString {
append("FOREIGN KEY (${fk.columns.joinToString(", ")}) REFERENCES ${fk.referencedTable} (${fk.referencedColumns.joinToString(", ")})")
if (fk.onDelete != ForeignKeyAction.NoAction) append(" ON DELETE ${fk.onDelete.sqlKeyword}")
if (fk.onUpdate != ForeignKeyAction.NoAction) append(" ON UPDATE ${fk.onUpdate.sqlKeyword}")
}
private fun dropModeText(mode: DropMode): String = if (mode == DropMode.Cascade) " CASCADE" else ""
private fun expression(expression: Expression): String = when (expression) {
is LiteralExpression -> literal(expression.value)
is ColumnExpression -> expression.qualifier?.let { "$it.${expression.name}" } ?: expression.name
is FunctionExpression ->
"${expression.name}(${if (expression.star) "*" else expression.argument?.let { expression(it) } ?: ""})"
is FunctionExpression -> "${expression.name}(${functionArgumentsText(expression)})${filterClauseText(expression)}"
is BinaryExpression ->
"(${expression(expression.left)} ${expression.operator.symbol} ${expression(expression.right)})"
is UnaryExpression -> "(${expression.operator.symbol} ${expression(expression.operand)})"
is CallExpression -> "${expression.name}(${expression.arguments.joinToString(", ") { expression(it) }})"
is IsNullExpression ->
"(${expression(expression.operand)} IS ${if (expression.negated) "NOT " else ""}NULL)"
is InExpression ->
"(${expression(expression.operand)} ${if (expression.negated) "NOT " else ""}IN (${expression.values.joinToString(", ") { expression(it) }}))"
is BetweenExpression ->
"(${expression(expression.operand)} ${if (expression.negated) "NOT " else ""}BETWEEN ${expression(expression.low)} AND ${expression(expression.high)})"
is LikeExpression ->
"(${expression(expression.operand)} ${if (expression.negated) "NOT " else ""}LIKE ${expression(expression.pattern)})"
is CaseExpression -> buildString {
append("CASE ")
expression.operand?.let { append(expression(it)); append(' ') }
expression.branches.forEach { append("WHEN ${expression(it.condition)} THEN ${expression(it.result)} ") }
expression.elseExpression?.let { append("ELSE ${expression(it)} ") }
append("END")
}
is ScalarSubqueryExpression -> "(${selectText(expression.select)})"
is ExistsExpression -> "EXISTS (${selectText(expression.select)})"
is InSubqueryExpression ->
"(${expression(expression.operand)} ${if (expression.negated) "NOT " else ""}IN (${selectText(expression.select)}))"
is CastExpression ->
"CAST(${expression(expression.operand)} AS ${expression.targetType.displayWith(expression.precision, expression.scale)})"
is CollateExpression -> "(${expression(expression.operand)} COLLATE ${expression.collation.sqlName})"
is WindowExpression -> "${expression.name}(${windowArgumentsText(expression)}) OVER (${overClauseText(expression)})"
is NextValueExpression -> "NEXTVAL(${literal(Value.TextValue(expression.sequenceName))})"
is CurrentValueExpression -> "CURRVAL(${literal(Value.TextValue(expression.sequenceName))})"
// Unreachable in practice: writeMerge never emits statement.returning at all (RETURNING is
// dropped from every persisted DML statement - see writeInsert/writeUpdate/writeDelete's own
// callers), so MERGE_ACTION() - only ever legal inside a MERGE's RETURNING - never reaches
// this writer. Kept here only to keep `when` exhaustive.
is MergeActionExpression -> "MERGE_ACTION()"
// Never actually reaches here in practice - canPersist excludes PREPARE/EXECUTE/DEALLOCATE
// outright (see its own doc), and resolveExecute always substitutes every ParameterExpression
// for a literal before the concrete INSERT/UPDATE/DELETE/SELECT it resolved to is ever handed
// to this writer. Kept only so `when` stays exhaustive, and so a `$1` typed directly into a
// persistable statement outside PREPARE (structurally legal, just meaningless - Evaluator
// throws on it) still round-trips its own text faithfully rather than being silently mangled.
is ParameterExpression -> "\$${expression.index}"
}
/** STRING_AGG's `separator`/`orderBy` and DISTINCT are the only [FunctionExpression] fields beyond `argument`/`star` - every other aggregate falls through the `?: ""` exactly as before. */
private fun functionArgumentsText(function: FunctionExpression): String {
if (function.star) return "*"
val distinctPrefix = if (function.distinct) "DISTINCT " else ""
val value = function.argument?.let { expression(it) } ?: ""
val separator = function.separator ?: return "$distinctPrefix$value"
val orderBy = if (function.orderBy.isNotEmpty()) {
" ORDER BY " + function.orderBy.joinToString(", ") { key -> expression(key.expression) + orderKeySuffix(key) }
} else ""
return "$distinctPrefix$value, ${expression(separator)}$orderBy"
}
/** `DESC`/`NULLS FIRST`/`NULLS LAST` are omitted when unspecified, so a plain ascending key with the engine's own default null ordering dumps exactly as written, matching every other optional-clause writer in this file. */
private fun orderKeySuffix(key: OrderKey): String {
val direction = if (key.descending) " DESC" else ""
val nulls = when (key.nullsFirst) {
true -> " NULLS FIRST"
false -> " NULLS LAST"
null -> ""
}
return direction + nulls
}
/** Without this, a view with `COUNT(x) FILTER (WHERE ...)` would dump as plain `COUNT(x)` and silently drop the predicate on reopen. */
private fun filterClauseText(function: FunctionExpression): String =
function.filter?.let { " FILTER (WHERE ${expression(it)})" } ?: ""
private fun windowArgumentsText(window: WindowExpression): String {
if (window.star) return "*"
return listOfNotNull(window.argument, window.offset, window.defaultValue).joinToString(", ") { expression(it) }
}
private fun overClauseText(window: WindowExpression): String {
val parts = mutableListOf<String>()
if (window.partitionBy.isNotEmpty()) {
parts.add("PARTITION BY " + window.partitionBy.joinToString(", ") { expression(it) })
}
if (window.orderBy.isNotEmpty()) {
parts.add(
"ORDER BY " + window.orderBy.joinToString(", ") { key -> expression(key.expression) + orderKeySuffix(key) }
)
}
window.frame?.let { frame ->
val unit = when (frame.unit) {
FrameUnit.Rows -> "ROWS"
FrameUnit.Range -> "RANGE"
FrameUnit.Groups -> "GROUPS"
}
val exclude = when (frame.exclude) {
FrameExclude.NoOthers -> ""
FrameExclude.CurrentRow -> " EXCLUDE CURRENT ROW"
FrameExclude.Group -> " EXCLUDE GROUP"
FrameExclude.Ties -> " EXCLUDE TIES"
}
parts.add("$unit BETWEEN ${frameBoundText(frame.start)} AND ${frameBoundText(frame.end)}$exclude")
}
return parts.joinToString(" ")
}
private fun frameBoundText(bound: FrameBound): String = when (bound.type) {
FrameBoundType.UnboundedPreceding -> "UNBOUNDED PRECEDING"
FrameBoundType.UnboundedFollowing -> "UNBOUNDED FOLLOWING"
FrameBoundType.CurrentRow -> "CURRENT ROW"
FrameBoundType.Preceding -> "${expression(bound.offset!!)} PRECEDING"
FrameBoundType.Following -> "${expression(bound.offset!!)} FOLLOWING"
}
private fun queryText(query: QueryStatement): String = when (query) {
is SelectStatement -> selectText(query)
is SetOperationStatement -> setOperationText(query)
is WithQuery -> withQueryText(query)
}
private fun setOperationText(statement: SetOperationStatement): String = buildString {
append(queryText(statement.left))
append(" ")
append(setOperatorKeyword(statement.operator))
append(" ")
append(queryText(statement.right))
statement.orderBy?.let { append(orderByText(it)) }
statement.limit?.let { append(" LIMIT $it") }
statement.offset?.let { append(" OFFSET $it") }
}
private fun setOperatorKeyword(operator: SetOperator): String = when (operator) {
SetOperator.Union -> "UNION"
SetOperator.UnionAll -> "UNION ALL"
SetOperator.Intersect -> "INTERSECT"
SetOperator.IntersectAll -> "INTERSECT ALL"
SetOperator.Except -> "EXCEPT"
SetOperator.ExceptAll -> "EXCEPT ALL"
}
private fun withQueryText(query: WithQuery): String = buildString {
append("WITH ")
if (query.recursive) append("RECURSIVE ")
append(query.ctes.joinToString(", ") { cte ->
val columns = cte.columns?.let { "(${it.joinToString(", ")})" } ?: ""
"${cte.name}$columns AS (${queryText(cte.query)})"
})
append(" ")
append(queryText(query.body))
}
private fun selectText(select: SelectStatement): String = buildString {
append("SELECT ")
if (select.distinct) {
append("DISTINCT ")
if (select.distinctOn.isNotEmpty()) {
append("ON (")
append(select.distinctOn.joinToString(", ") { expression(it) })
append(") ")
}
}
append(projectionText(select.projection))
append(" FROM ")
append(fromItemText(select.from))
for (join in select.joins) {
append(
when (join.type) {
JoinType.Left -> " LEFT JOIN "
JoinType.Right -> " RIGHT JOIN "
JoinType.Full -> " FULL JOIN "
JoinType.Inner -> " INNER JOIN "
JoinType.Cross -> " CROSS JOIN "
}
)
append(fromItemText(join.from))
join.on?.let { append(" ON "); append(expression(it)) }
}
select.where?.let { append(" WHERE "); append(expression(it)) }
if (select.groupingSpec != null || select.groupBy.isNotEmpty()) {
append(" GROUP BY ")
append(groupByText(select.groupingSpec, select.groupBy))
}
select.having?.let { append(" HAVING "); append(expression(it)) }
select.orderBy?.let { append(orderByText(it)) }
select.limit?.let { append(" LIMIT $it") }
select.offset?.let { append(" OFFSET $it") }
}
private fun groupByText(spec: GroupingSpec?, groupBy: List<Expression>): String = when (spec) {
null -> groupBy.joinToString(", ") { expression(it) }
is RollupSpec -> "ROLLUP (" + spec.expressions.joinToString(", ") { expression(it) } + ")"
is CubeSpec -> "CUBE (" + spec.expressions.joinToString(", ") { expression(it) } + ")"
is GroupingSetsSpec -> "GROUPING SETS (" + spec.sets.joinToString(", ") { groupingSetElementText(it) } + ")"
}
private fun groupingSetElementText(set: List<Expression>): String =
if (set.isEmpty()) "()" else "(" + set.joinToString(", ") { expression(it) } + ")"
private fun orderByText(orderBy: OrderBy): String =
" ORDER BY " + orderBy.keys.joinToString(", ") { key -> expression(key.expression) + orderKeySuffix(key) }
private fun fromItemText(item: FromItem): String = when (item) {
is TableSource -> item.name + (item.alias?.let { " AS $it" } ?: "")
is SubquerySource -> (if (item.lateral) "LATERAL " else "") + "(${queryText(item.select)}) AS ${item.alias}"
}
private fun projectionText(projection: Projection): String = when (projection) {
is StarProjection -> "*"
is ExpressionProjection -> projection.items.joinToString(", ") { item ->
val base = expression(item.expression)
item.alias?.let { "$base AS $it" } ?: base
}
}
/**
* DECIMAL/DATE values render as `CAST('...' AS type)` (not a bare quoted string)
* because column coercion is strict everywhere else in this engine (no implicit
* TEXT-to-DECIMAL/DATE on INSERT/UPDATE) - the CAST is what actually converts the
* text back on replay. The re-cast uses the value's own scale (already the column's
* declared scale, since it can only have gotten here via coercion or CAST) with a
* generous precision, so it reproduces the exact BigDecimal rather than rounding
* through a Double the way a bare numeric literal would.
*/
private fun literal(value: Value): String = when (value) {
is Value.IntegerValue -> value.value.toString()
is Value.RealValue -> realText(value.value)
is Value.TextValue -> "'" + value.value.replace("'", "''") + "'"
is Value.BooleanValue -> value.value.toString()
is Value.DecimalValue ->
"CAST('${value.value.toPlainString()}' AS DECIMAL($LITERAL_DECIMAL_PRECISION, ${value.value.scale()}))"
is Value.DateValue -> "CAST('${value.value}' AS DATE)"
is Value.NullValue -> "NULL"
}
@@ -114,4 +676,6 @@ object SqlWriter {
val plain = value.toString()
return if (plain.contains('E') || plain.contains('e')) BigDecimal(value).toPlainString() else plain
}
private const val LITERAL_DECIMAL_PRECISION = 38
}
+529 -19
View File
@@ -1,49 +1,359 @@
package kestrel.sql.ast
import kestrel.catalog.CheckConstraint
import kestrel.catalog.Column
import kestrel.catalog.ForeignKeyConstraint
import kestrel.catalog.UniqueConstraint
import kestrel.storage.Collation
import kestrel.storage.ColumnType
import kestrel.storage.Value
sealed interface Statement
data class CreateTableStatement(val tableName: String, val columns: List<Column>) : Statement
data class CreateTableStatement(
val tableName: String,
val columns: List<Column>,
val foreignKeys: List<ForeignKeyConstraint> = emptyList(),
val checks: List<CheckConstraint> = emptyList(),
val uniqueConstraints: List<UniqueConstraint> = emptyList(),
val compositePrimaryKey: List<String> = emptyList(),
/** `CREATE TEMP TABLE`/`CREATE TEMPORARY TABLE` - see [kestrel.catalog.TableSchema.temporary]. */
val temporary: Boolean = false
) : Statement
data class DropTableStatement(val tableName: String) : Statement
enum class DropMode { Restrict, Cascade }
data class DropTableStatement(val tableName: String, val mode: DropMode = DropMode.Restrict) : Statement
/**
* `TRUNCATE TABLE t1 [, t2, ...] [RESTART IDENTITY | CONTINUE IDENTITY] [CASCADE | RESTRICT]`.
* Unlike a WHERE-less DELETE, TRUNCATE never fires row-level triggers and never runs a FOREIGN KEY's
* own ON DELETE action row by row - see [kestrel.catalog.Catalog.truncateTables]/
* [kestrel.store.sql.DiskCatalog.truncateTables] for why [mode] means something structurally
* different here than it does for [DropTableStatement]/[DropViewStatement].
*/
data class TruncateTableStatement(
val tableNames: List<String>,
val restartIdentity: Boolean = false,
val mode: DropMode = DropMode.Restrict
) : Statement
data class CreateIndexStatement(
val indexName: String,
val tableName: String,
val columnName: String
val columnNames: List<String>,
val unique: Boolean = false
) : Statement
data class DropIndexStatement(val indexName: String) : Statement
sealed interface AlterTableAction
data class AddColumn(val column: Column) : AlterTableAction
data class DropColumn(val columnName: String, val mode: DropMode = DropMode.Restrict) : AlterTableAction
data class RenameColumn(val oldName: String, val newName: String) : AlterTableAction
data class RenameTable(val newName: String) : AlterTableAction
/**
* `ALTER TABLE t ALTER [COLUMN] col TYPE newtype [USING expression]` (Postgres syntax). [using],
* when given, is evaluated per existing row against that row's OLD values (exactly like an UPDATE
* SET assignment) and the result coerced into [newType]/[precision]/[scale]; when omitted it
* defaults to a bare reference to [columnName] itself - "recompute this column as its own old
* value, coerced to the new type" - so a widening change (INTEGER -> DECIMAL, say) needs no
* explicit `USING` at all, matching Postgres' own default behavior.
*/
data class ChangeColumnType(
val columnName: String,
val newType: ColumnType,
val precision: Int? = null,
val scale: Int? = null,
val using: Expression? = null
) : AlterTableAction
/**
* `ALTER TABLE t ADD CONSTRAINT name CHECK (...) | UNIQUE (...) | FOREIGN KEY (...) REFERENCES ...` -
* the constraint's own name lives inside [AddedCheck.check]/[AddedUnique.unique]/[AddedForeignKey.foreignKey]
* (each already carries a `name: String?`, shared with the identical table-level `CONSTRAINT name` clause
* a `CREATE TABLE` can declare inline), not as a separate field here.
*/
sealed interface AddedConstraint
data class AddedCheck(val check: CheckConstraint) : AddedConstraint
data class AddedUnique(val unique: UniqueConstraint) : AddedConstraint
data class AddedForeignKey(val foreignKey: ForeignKeyConstraint) : AddedConstraint
data class AddConstraint(val constraint: AddedConstraint) : AlterTableAction
/** `ALTER TABLE t DROP CONSTRAINT name [RESTRICT | CASCADE]` - see [kestrel.catalog.Catalog.dropConstraint]. */
data class DropConstraint(val name: String, val mode: DropMode = DropMode.Restrict) : AlterTableAction
data class AlterTableStatement(val tableName: String, val action: AlterTableAction) : Statement
data class CreateViewStatement(
val viewName: String,
val query: QueryStatement,
val orReplace: Boolean
) : Statement
data class DropViewStatement(val viewName: String, val mode: DropMode = DropMode.Restrict) : Statement
data class InsertStatement(
val tableName: String,
val columns: List<String>?,
val rows: List<List<Expression>>
) : Statement
val rows: List<List<Expression?>>,
val onConflict: OnConflictClause? = null,
val returning: Projection? = null
) : PreparableStatement
/** Empty [targetColumns] means "any unique or primary key violation" (bare `ON CONFLICT DO ...`), matching Postgres. */
data class OnConflictClause(val targetColumns: List<String>, val action: ConflictAction)
sealed interface ConflictAction
data object ConflictDoNothing : ConflictAction
data class ConflictDoUpdate(val assignments: List<Assignment>, val where: Expression?) : ConflictAction
/**
* The statement kinds [PrepareStatement] can wrap: a SELECT/set-operation/CTE ([QueryStatement])
* or a data-mutating [InsertStatement]/[UpdateStatement]/[DeleteStatement]/[MergeStatement] - the
* same "any DML or query, never DDL/transaction control" boundary Postgres' own PREPARE draws.
* Declared as its own sealed interface (rather than checking `is QueryStatement || is InsertStatement
* || ...` by hand at every call site) so the compiler enforces [ParameterSubstitution]'s `when` stays
* exhaustive as new statement kinds are added.
*/
sealed interface PreparableStatement : Statement
sealed interface QueryStatement : Statement, PreparableStatement
data class SelectStatement(
val projection: Projection,
val tableName: String,
val tableAlias: String? = null,
val from: FromItem,
val joins: List<JoinClause> = emptyList(),
val where: Expression? = null,
/** For [RollupSpec]/[CubeSpec]/[GroupingSetsSpec], this is the deduplicated union of every expression across every one of [groupingSpec]'s grouping sets (order = first appearance) - NOT the flat comma list SQL text would suggest. Every existing consumer that only needs "which expressions are legal grouping columns" (validation, dependency analysis, rename propagation, the plain-`GROUP BY a, b` write-back path) reads this field and needs no awareness of [groupingSpec] at all; only [kestrel.engine.GroupAggregation], which needs the actual per-set breakdown, reads [groupingSpec]. */
val groupBy: List<Expression> = emptyList(),
val groupingSpec: GroupingSpec? = null,
val having: Expression? = null,
val orderBy: OrderBy? = null,
val limit: Int? = null
) : Statement
val limit: Int? = null,
/** `LIMIT`/`OFFSET` may be written in either order (matching Postgres) - applied together, after ORDER BY/DISTINCT dedup and before projection, skipping this many rows before [limit] (if any) caps what remains. */
val offset: Int? = null,
/**
* `true` for both `SELECT DISTINCT ...` and `SELECT DISTINCT ON (...) ...` - `false` (the
* default, also written explicitly as `SELECT ALL ...`) means every source row that survives
* WHERE/GROUP BY/HAVING produces its own output row. See [distinctOn] for what distinguishes
* the two `true` forms from each other.
*/
val distinct: Boolean = false,
/**
* Empty for plain `SELECT DISTINCT` (dedup by the *entire* output row, evaluated after
* projection - see [kestrel.engine.SelectRunner]'s `Plan.projectRows`). Non-empty for
* `SELECT DISTINCT ON (expr, ...) ...`: keeps only the first source row per distinct
* combination of these expressions, evaluated the same way `GROUP BY` expressions are
* (pre-projection, against the joined/filtered row) - never resolved against a `SELECT`-list
* alias, matching Postgres' own grammar where `DISTINCT ON (...)` is parsed before the select
* list even exists. "First" means first in `ORDER BY` order when one is given (which is why the
* parser requires `ORDER BY`, if present at all, to start with exactly these expressions - the
* whole point of `DISTINCT ON` is picking a deterministic row per group), otherwise first in
* scan/join order. Deliberately rejected outright when combined with `GROUP BY` or aggregates -
* unlike plain [distinct], there is no unsurprising way to define "first row of a group" once
* the underlying rows have already been collapsed into aggregate groups.
*/
val distinctOn: List<Expression> = emptyList()
) : QueryStatement
data class DeleteStatement(val tableName: String, val where: Expression?) : Statement
/**
* Distinguishes `GROUP BY ROLLUP (...)` / `CUBE (...)` / `GROUPING SETS (...)` from an ordinary
* `GROUP BY a, b, ...` (represented by `groupingSpec == null`, the only case before this existed).
* [kestrel.engine.GroupingSetExpansion] turns any of these into the concrete list of grouping sets
* [kestrel.engine.GroupAggregation] actually groups by - one full pass over the source rows per set,
* unioned together, so a single query reports subtotals (and, for [Rollup]/[Cube]/an explicit `()`
* element, a grand total) alongside whatever detail rows survive. Mixing plain columns with
* ROLLUP/CUBE in the same `GROUP BY` list (SQL allows this, producing a cross product of sets) is a
* scope cut - the parser only accepts one of these forms standing alone as the entire clause.
*/
sealed interface GroupingSpec
data class RollupSpec(val expressions: List<Expression>) : GroupingSpec
data class CubeSpec(val expressions: List<Expression>) : GroupingSpec
/** Each element of [sets] is one grouping set as written; an empty inner list is the `()` grand-total set. */
data class GroupingSetsSpec(val sets: List<List<Expression>>) : GroupingSpec
enum class SetOperator { Union, UnionAll, Intersect, IntersectAll, Except, ExceptAll }
data class SetOperationStatement(
val operator: SetOperator,
val left: QueryStatement,
val right: QueryStatement,
val orderBy: OrderBy? = null,
val limit: Int? = null,
val offset: Int? = null
) : QueryStatement
data class CommonTableExpression(
val name: String,
val columns: List<String>?,
val query: QueryStatement
)
data class WithQuery(
val recursive: Boolean,
val ctes: List<CommonTableExpression>,
val body: QueryStatement
) : QueryStatement
sealed interface FromItem
data class TableSource(val name: String, val alias: String? = null) : FromItem
data class SubquerySource(val select: QueryStatement, val alias: String, val lateral: Boolean = false) : FromItem
data class DeleteStatement(
val tableName: String,
val where: Expression?,
val returning: Projection? = null
) : PreparableStatement
data class UpdateStatement(
val tableName: String,
val assignments: List<Assignment>,
val where: Expression?
val where: Expression?,
val returning: Projection? = null
) : PreparableStatement
/**
* `MERGE INTO target [AS targetAlias] USING source ON onCondition whenClauses...`. [source] is the
* same [FromItem] a `FROM`/`USING`-shaped clause anywhere else in this grammar produces (a bare
* table name or a derived-table subquery, carrying its own alias), reused rather than inventing a
* MERGE-specific source type. [whenClauses] preserves the exact order they were written in - see
* [kestrel.engine.Executor]'s executeMerge for why that order (not a MATCHED-then-NOT-MATCHED split)
* is what standard MERGE semantics actually require: for a given source row, at most one category
* (MATCHED or NOT MATCHED) can ever apply, so evaluating the whole list in written order and
* skipping clauses of the other category naturally reproduces "first matching WHEN clause wins".
* Use from inside a trigger body is deliberately unsupported (the parser's trigger-body grammar only
* ever accepts INSERT/UPDATE/DELETE, a structural scope cut, not a runtime check). [returning], when
* present, projects one result row per action MERGE actually took (insert/update/delete), in the
* order those actions ran - see [kestrel.engine.Executor.evaluateMergeReturning].
*/
data class MergeStatement(
val targetTable: String,
val targetAlias: String?,
val source: FromItem,
val onCondition: Expression,
val whenClauses: List<MergeWhenClause>,
val returning: Projection? = null
) : PreparableStatement
sealed interface MergeWhenClause {
val condition: Expression?
}
/** WHEN MATCHED [AND condition] THEN UPDATE SET assignments... - `assignments` targets are always unqualified target columns, matching plain UPDATE. */
data class MergeMatchedUpdate(override val condition: Expression?, val assignments: List<Assignment>) : MergeWhenClause
/** WHEN MATCHED [AND condition] THEN DELETE */
data class MergeMatchedDelete(override val condition: Expression?) : MergeWhenClause
/**
* WHEN NOT MATCHED [AND condition] THEN INSERT [(columns)] VALUES (values). `columns == null` means
* every target column in schema order, exactly like a column-list-less plain INSERT. Unlike an
* ordinary INSERT, [values] is always exactly one row (MERGE inserts at most one row per
* unmatched source row) and can only reference the source side - there is no target row yet.
*/
data class MergeNotMatchedInsert(override val condition: Expression?, val columns: List<String>?, val values: List<Expression?>) : MergeWhenClause
data class ExplainStatement(val select: QueryStatement) : Statement
enum class TriggerTiming { Before, After }
enum class TriggerEvent { Insert, Update, Delete }
/**
* `body` is restricted by the parser to INSERT/UPDATE/DELETE statements (matching SQLite's own
* trigger-body grammar) - those are the only statement kinds with a side effect worth firing, and
* the only ones [kestrel.engine.Executor] knows how to run with a [kestrel.engine.TriggerRowContext]
* bound. `whenClause`, when present, is evaluated once per candidate row before [body] runs at all;
* a row for which it is false or NULL never fires this trigger.
*/
data class CreateTriggerStatement(
val triggerName: String,
val timing: TriggerTiming,
val event: TriggerEvent,
val tableName: String,
val whenClause: Expression?,
val body: List<Statement>
) : Statement
data class ExplainStatement(val select: SelectStatement) : Statement
data class DropTriggerStatement(val triggerName: String) : Statement
/**
* `incrementBy`/`minValue`/`maxValue`/`startValue` are already fully resolved by the parser's
* defaulting rules (see `Parser.parseSequenceOptions`) - `null` only ever means "not written by the
* user", never "no bound"; [kestrel.engine.Executor.executeCreateSequence] is what turns a `null`
* into Postgres' own ascending/descending default (MINVALUE 1 / MAXVALUE Long.MAX_VALUE and the
* reverse for a negative [incrementBy]) and validates the combination.
*/
data class CreateSequenceStatement(
val sequenceName: String,
val incrementBy: Long? = null,
val minValue: Long? = null,
val maxValue: Long? = null,
val startValue: Long? = null,
val cycle: Boolean = false
) : Statement
data class DropSequenceStatement(val sequenceName: String) : Statement
/**
* Every field besides [sequenceName] is "not mentioned in this ALTER" when `null`/`false` -
* [clearMinValue]/[clearMaxValue] (Postgres' `NO MINVALUE`/`NO MAXVALUE`) are tracked separately
* from [minValue]/[maxValue] because, unlike CREATE SEQUENCE, "not mentioned" and "explicitly
* cleared back to the type default" are different instructions on an ALTER: the former leaves
* today's bound untouched, the latter recomputes it from [incrementBy]'s sign exactly like a fresh
* CREATE SEQUENCE with no bound written at all would. [restart] with a `null` [restartWith] is a
* bare `RESTART` (reset to the sequence's original `START WITH`, unaffected by any earlier ALTER);
* [restart] `false` means no RESTART clause at all, leaving the counter wherever NEXTVAL last left it.
*/
data class AlterSequenceStatement(
val sequenceName: String,
val incrementBy: Long? = null,
val minValue: Long? = null,
val clearMinValue: Boolean = false,
val maxValue: Long? = null,
val clearMaxValue: Boolean = false,
val restart: Boolean = false,
val restartWith: Long? = null,
val cycle: Boolean? = null
) : Statement
/**
* `PREPARE name AS <target>` - parses and stores [target] under [name] without running it.
* [kestrel.engine.Executor] keeps prepared statements in a session-scoped registry OUTSIDE
* [kestrel.catalog.Catalog] (unlike CREATE VIEW/TRIGGER/SEQUENCE) - a deliberate choice, not an
* oversight: they carry no data of their own (only an AST plus, at EXECUTE time, caller-supplied
* argument values), so BEGIN/ROLLBACK's catalog snapshot/restore has nothing meaningful to undo for
* them, and a name PREPARE-d before a transaction started should not vanish just because that
* transaction later rolled back. See [kestrel.engine.ParameterSubstitution] for how `$1`, `$2`, ...
* inside [target] are resolved once an EXECUTE actually supplies values.
*/
data class PrepareStatement(val name: String, val target: PreparableStatement) : Statement
/**
* `EXECUTE name(arg, arg, ...)` (or `EXECUTE name` for a statement with no parameters) -
* [kestrel.engine.Executor.resolveExecute] looks [name] up, evaluates each [arguments] expression
* as a constant (no column/row context exists to resolve against), and substitutes the results for
* every [ParameterExpression] in the prepared statement before it actually runs - so whatever this
* resolves to is always one of [PreparableStatement]'s own concrete kinds, never `EXECUTE` itself.
*/
data class ExecuteStatement(val name: String, val arguments: List<Expression> = emptyList()) : Statement
/** `DEALLOCATE name` removes one prepared statement; `DEALLOCATE ALL` ([all] `true`, [name] `null`) removes every one. */
data class DeallocateStatement(val name: String?, val all: Boolean = false) : Statement
data object BeginStatement : Statement
@@ -51,15 +361,22 @@ data object CommitStatement : Statement
data object RollbackStatement : Statement
data class Assignment(val columnName: String, val value: Expression)
data class SavepointStatement(val name: String) : Statement
enum class JoinType { Inner, Left }
data class ReleaseSavepointStatement(val name: String) : Statement
data class RollbackToSavepointStatement(val name: String) : Statement
/** `value == null` means the assignment was written as `col = DEFAULT` - the column's declared DEFAULT (or NULL without one), evaluated fresh per row rather than carrying a literal at parse time. */
data class Assignment(val columnName: String, val value: Expression?)
enum class JoinType { Inner, Left, Right, Full, Cross }
/** [on] is null exactly for [JoinType.Cross] - a CROSS JOIN has no condition, matching every pairing. */
data class JoinClause(
val type: JoinType,
val tableName: String,
val alias: String?,
val on: Expression
val from: FromItem,
val on: Expression?
)
sealed interface Projection
@@ -70,7 +387,16 @@ data class ExpressionProjection(val items: List<SelectItem>) : Projection
data class SelectItem(val expression: Expression, val alias: String?)
data class OrderBy(val expression: Expression, val descending: Boolean)
/**
* [nullsFirst] is `null` when the SQL text had no explicit `NULLS FIRST`/`NULLS LAST` - every
* comparator consuming this (see [kestrel.engine.compareOrderKeyValues]) then falls back to
* `!descending`, preserving this engine's original convention (NULL as the smallest value
* regardless of direction) from before this syntax existed. `true`/`false` here always means an
* explicit clause was present, independent of [descending].
*/
data class OrderKey(val expression: Expression, val descending: Boolean, val nullsFirst: Boolean? = null)
data class OrderBy(val keys: List<OrderKey>)
sealed interface Expression
@@ -78,10 +404,175 @@ data class LiteralExpression(val value: Value) : Expression
data class ColumnExpression(val name: String, val qualifier: String? = null) : Expression
/**
* [separator] and [orderBy] exist only for `STRING_AGG(value, separator [ORDER BY ...])` - every
* other aggregate (COUNT/SUM/AVG/MIN/MAX) leaves both null/empty. Kept on the shared node rather than
* a dedicated `StringAggExpression` variant so STRING_AGG rides every existing [FunctionExpression]
* validator (GROUP BY membership, CHECK/DEFAULT aggregate bans, subquery bans, ...) for free instead
* of needing a twelfth near-parallel `when` branch across the codebase.
*
* [distinct] is `COUNT(DISTINCT x)`/`SUM(DISTINCT x)`/`AVG(DISTINCT x)`/`MIN(DISTINCT x)`/
* `MAX(DISTINCT x)` - never true together with [star] or alongside [separator] (the parser rejects
* `DISTINCT` on STRING_AGG's own call shape and on `*`), and never true on a windowed aggregate (the
* parser rejects `DISTINCT ... OVER (...)` outright, matching Postgres - there is no per-row windowed
* notion of "distinct so far").
*
* [filter] is `aggregate_name(args) FILTER (WHERE condition)` - a per-row predicate evaluated against
* the same group the aggregate would otherwise see in full, restricting which rows reach the aggregate
* without needing a separate `CASE WHEN ... END` inside every argument. Never set on STRING_AGG (its
* own call shape returns before this field would ever be populated) or on a windowed aggregate (the
* parser rejects `FILTER` together with `OVER`, matching how [distinct] is rejected there too - there
* is no per-row windowed notion of a group to filter).
*/
data class FunctionExpression(
val name: String,
val argument: Expression?,
val star: Boolean
val star: Boolean,
val separator: Expression? = null,
val orderBy: List<OrderKey> = emptyList(),
val distinct: Boolean = false,
val filter: Expression? = null
) : Expression
data class CallExpression(val name: String, val arguments: List<Expression>) : Expression
/**
* `NEXTVAL('seq')`/`CURRVAL('seq')`. Deliberately their own [Expression] variants rather than
* folding into [CallExpression] like every other function call - [sequenceName] is a name resolved
* once at parse time (the parser requires the argument to literally be a string literal, not an
* arbitrary expression), never a per-row value, which keeps every existing [CallExpression]-shaped
* validator (`Executor.validateConstantDefault`, `CheckExpressionColumns`, `GroupAggregation`, ...)
* untouched by NEXTVAL's side effect instead of needing a special case bolted onto each one. See
* [kestrel.engine.Executor.executeInsertInner]/`executeUpdateInner` for where these two are actually
* allowed to evaluate - CHECK constraints, trigger `WHEN` clauses, and SELECT/WHERE all reject them.
*/
data class NextValueExpression(val sequenceName: String) : Expression
data class CurrentValueExpression(val sequenceName: String) : Expression
/**
* `MERGE_ACTION()` - takes no arguments (the parser rejects any), so unlike [CallExpression] it
* carries no per-call state and is a `data object` rather than a `data class`. Only meaningful inside
* a [MergeStatement]'s own [MergeStatement.returning] projection, where it evaluates to `'INSERT'`,
* `'UPDATE'`, or `'DELETE'` depending on which action a given RETURNING row came from - see
* [kestrel.engine.Executor.evaluateMergeReturning] for where that per-row action is actually bound.
* Every other [Expression] consumer (CHECK constraints, WHERE, trigger bodies, plain INSERT/UPDATE/
* DELETE RETURNING, ...) treats it as structurally harmless - no column reference to check, nothing
* to rename, nothing to depend on - exactly like [NextValueExpression]/[CurrentValueExpression] do,
* and instead rejects it the same way they reject NEXTVAL/CURRVAL: by never handing the
* [kestrel.engine.Evaluator] that runs it the per-row action string it needs, so evaluation itself
* throws.
*/
data object MergeActionExpression : Expression
/**
* `$1`, `$2`, ... - a positional parameter placeholder, 1-based to match the SQL text. Only ever
* produced by the parser inside a [PrepareStatement]'s [PrepareStatement.target] (or, harmlessly,
* anywhere else an [Expression] is grammatically legal, since the parser has no context at parse
* time to reject it elsewhere) - [kestrel.engine.ParameterSubstitution] replaces every occurrence
* with a [LiteralExpression] before the surrounding statement actually runs, so by evaluation time
* ([kestrel.engine.Evaluator]) one reaching [kestrel.engine.Evaluator.evaluate] unsubstituted is
* always a usage error (typed outside of PREPARE/EXECUTE entirely) and throws there, exactly like
* [MergeActionExpression] throws when evaluated outside a MERGE's own RETURNING.
*/
data class ParameterExpression(val index: Int) : Expression
data class IsNullExpression(val operand: Expression, val negated: Boolean) : Expression
data class InExpression(val operand: Expression, val values: List<Expression>, val negated: Boolean) : Expression
data class BetweenExpression(
val operand: Expression,
val low: Expression,
val high: Expression,
val negated: Boolean
) : Expression
data class LikeExpression(val operand: Expression, val pattern: Expression, val negated: Boolean) : Expression
data class CaseExpression(
val operand: Expression?,
val branches: List<CaseBranch>,
val elseExpression: Expression?
) : Expression
data class CaseBranch(val condition: Expression, val result: Expression)
data class ScalarSubqueryExpression(val select: SelectStatement) : Expression
data class ExistsExpression(val select: SelectStatement) : Expression
data class InSubqueryExpression(
val operand: Expression,
val select: SelectStatement,
val negated: Boolean
) : Expression
data class CastExpression(
val operand: Expression,
val targetType: ColumnType,
val precision: Int? = null,
val scale: Int? = null
) : Expression
enum class WindowFunctionKind { RowNumber, Rank, DenseRank, Aggregate, Lag, Lead, FirstValue, LastValue, Ntile }
/** Where a [FrameBound] sits relative to the current row; ordered exactly as SQL requires a frame's start to precede its end. */
enum class FrameBoundType { UnboundedPreceding, Preceding, CurrentRow, Following, UnboundedFollowing }
/** `UNBOUNDED PRECEDING`/`CURRENT ROW`/`UNBOUNDED FOLLOWING` carry no [offset]; `N PRECEDING`/`N FOLLOWING` evaluate it per row. */
data class FrameBound(val type: FrameBoundType, val offset: Expression? = null)
/**
* [Rows] counts physical row positions (`ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING` = the row before
* and after, regardless of ties). [Range] measures distance in the value of a single ORDER BY column
* (numeric or DATE) - `RANGE BETWEEN 5 PRECEDING AND 5 FOLLOWING` on `ORDER BY amount` includes every
* row whose `amount` is within 5 of the current row's, ties included, however many physical rows that
* is. [Groups] counts whole peer groups (ties on the OVER's ORDER BY) instead of rows or values -
* `GROUPS BETWEEN 1 PRECEDING AND 1 FOLLOWING` includes every row in the peer group before the
* current one, the current peer group, and the one after, however many rows each group has. Unlike
* [Range] it works with any ORDER BY (zero, one, or many columns, any type), not just a single
* numeric/DATE column.
*/
enum class FrameUnit { Rows, Range, Groups }
/**
* `EXCLUDE` narrows a frame after its [FrameBound]s are resolved, by removing rows tied to the
* current row on the OVER's ORDER BY. [NoOthers] (the default - no `EXCLUDE` clause at all) removes
* nothing. [CurrentRow] removes only the current row itself. [Group] removes the current row's
* entire peer group, including the current row. [Ties] removes every *other* row in the peer group
* but keeps the current row - the one exclusion mode where the current row's own value always still
* counts. With no ORDER BY the whole partition is one peer group, matching the same convention
* already used for the frame's own `CURRENT ROW` bound and for the implicit default frame.
*/
enum class FrameExclude { NoOthers, CurrentRow, Group, Ties }
/** `ROWS|RANGE BETWEEN <start> AND <end> [EXCLUDE ...]`. */
data class WindowFrame(val unit: FrameUnit, val start: FrameBound, val end: FrameBound, val exclude: FrameExclude = FrameExclude.NoOthers)
/**
* `name(...) OVER (PARTITION BY ... ORDER BY ... [ROWS BETWEEN ... AND ...])`. [RowNumber]/[Rank]/
* [DenseRank] never take an argument (always `argument == null && star == false`); [Aggregate]
* reuses the same COUNT/SUM/AVG/MIN/MAX vocabulary as [FunctionExpression] (including COUNT(*) via
* [star]) but computes per row over a partition/frame instead of collapsing the whole group into
* one row. [Lag]/[Lead] read [argument] from a row [offset] rows before/after the current one
* (default 1), falling back to [defaultValue] (or NULL) past the partition's edge - both are `null`
* for every other kind. [FirstValue]/[LastValue] read [argument] from the first/last row of the
* current frame; [Ntile] treats [argument] as the bucket count and ignores [offset]/[defaultValue].
* [frame] is only meaningful for [Aggregate]/[FirstValue]/[LastValue] - `null` means the default SQL
* frame (`RANGE UNBOUNDED PRECEDING AND CURRENT ROW` with an ORDER BY, the whole partition without
* one); every other kind rejects an explicit [frame] outright since it would not change their result.
*/
data class WindowExpression(
val name: String,
val kind: WindowFunctionKind,
val argument: Expression?,
val star: Boolean,
val partitionBy: List<Expression>,
val orderBy: List<OrderKey>,
val offset: Expression? = null,
val defaultValue: Expression? = null,
val frame: WindowFrame? = null
) : Expression
data class BinaryExpression(
@@ -92,6 +583,25 @@ data class BinaryExpression(
data class UnaryExpression(val operator: UnaryOperator, val operand: Expression) : Expression
/**
* `expr COLLATE name` (SQL:1999) - overrides the text-comparison sequence used for [operand]
* wherever it participates in a comparison (`=`/`<>`/`<`/`<=`/`>`/`>=`, `IN`, `BETWEEN`, a
* searched-`CASE`'s operand match) - see [kestrel.engine.Evaluator]'s own comparison helpers, the
* only place a [Collation] other than [Collation.Binary] changes behavior this way. Evaluating
* [operand] itself is unaffected ([kestrel.engine.Evaluator.evaluate] just recurses into it) -
* COLLATE only ever changes how two already-evaluated values are ordered against each other, never
* what value an expression itself produces. Always wins over a column's own declared COLLATE (a
* `col TYPE COLLATE name` on its [kestrel.catalog.Column], resolved automatically for a bare
* [ColumnExpression] with no [CollateExpression] of its own via
* [kestrel.engine.ColumnResolver.declaredCollation]) - explicit beats declared, exactly like
* SQLite. `ORDER BY`/window `OVER (... ORDER BY ...)` (see [kestrel.engine.compareOrderKeyValues])
* only ever honor an explicit [CollateExpression] on the sort key, never a bare column's declared
* COLLATE - a documented, narrower scope cut than before: sorting a `COLLATE NOCASE` column still
* needs `ORDER BY col COLLATE NOCASE` spelled out, only comparisons pick the declared one up for
* free.
*/
data class CollateExpression(val operand: Expression, val collation: Collation) : Expression
enum class BinaryOperator(val symbol: String) {
Add("+"), Subtract("-"), Multiply("*"), Divide("/"),
Equal("="), NotEqual("<>"), Less("<"), LessEqual("<="),
Binary file not shown.
+104 -5
View File
@@ -3,14 +3,28 @@ package kestrel.sql.lexer
enum class TokenType {
Identifier, IntLiteral, RealLiteral, TextLiteral,
Create, Table, Insert, Into, Values, Select, From, Where,
Order, By, Asc, Desc, Limit, Delete, Update, Set, Drop,
Order, By, Asc, Desc, Limit, Offset, Delete, Update, Set, Drop, Truncate, Identity, Continue,
Alter, Add, Column, Rename, To, Default,
And, Or, Not, Null, True, False, Primary, Key, As,
Index, On, Inner, Left, Outer, Join, Group, Having,
Begin, Commit, Rollback, Transaction, Explain,
Integer, Text, Boolean, Real,
Index, On, Inner, Left, Right, Full, Cross, Outer, Join, Lateral, Group, Having, Over, Partition,
Rows, Row, Range, Groups, Preceding, Following, Unbounded, Current, Exclude, Ties, Others,
Begin, Commit, Rollback, Transaction, Explain, Savepoint, Release,
Conflict, Nothing, Do, Returning,
Trigger, Before, After, For, Each,
Is, In, Between, Like, Case, When, Then, Else, End, Exists, View, Replace,
Union, All, Intersect, Except, With, Recursive, Distinct,
Cast, Cascade, Restrict, References, Foreign, Check, No, Action, Unique, Constraint,
Sequence, Increment, Minvalue, Maxvalue, Start, Cycle, Restart,
Merge, Using, Matched,
Prepare, Execute, Deallocate,
Temp, Temporary,
Collate,
Integer, Text, Boolean, Real, Decimal, Date,
Equal, NotEqual, Less, LessEqual, Greater, GreaterEqual,
Plus, Minus, Star, Slash, Dot,
LeftParen, RightParen, Comma, Semicolon,
/** `$1`, `$2`, ... - see [kestrel.sql.ast.ParameterExpression]. [Token.intValue] carries the 1-based index. */
Parameter,
EndOfInput
}
@@ -38,10 +52,20 @@ internal val keywords: Map<String, TokenType> = mapOf(
"ASC" to TokenType.Asc,
"DESC" to TokenType.Desc,
"LIMIT" to TokenType.Limit,
"OFFSET" to TokenType.Offset,
"DELETE" to TokenType.Delete,
"UPDATE" to TokenType.Update,
"SET" to TokenType.Set,
"DROP" to TokenType.Drop,
"TRUNCATE" to TokenType.Truncate,
"IDENTITY" to TokenType.Identity,
"CONTINUE" to TokenType.Continue,
"ALTER" to TokenType.Alter,
"ADD" to TokenType.Add,
"COLUMN" to TokenType.Column,
"RENAME" to TokenType.Rename,
"TO" to TokenType.To,
"DEFAULT" to TokenType.Default,
"AND" to TokenType.And,
"OR" to TokenType.Or,
"NOT" to TokenType.Not,
@@ -55,17 +79,92 @@ internal val keywords: Map<String, TokenType> = mapOf(
"ON" to TokenType.On,
"INNER" to TokenType.Inner,
"LEFT" to TokenType.Left,
"RIGHT" to TokenType.Right,
"FULL" to TokenType.Full,
"CROSS" to TokenType.Cross,
"OUTER" to TokenType.Outer,
"JOIN" to TokenType.Join,
"LATERAL" to TokenType.Lateral,
"GROUP" to TokenType.Group,
"HAVING" to TokenType.Having,
"OVER" to TokenType.Over,
"PARTITION" to TokenType.Partition,
"ROWS" to TokenType.Rows,
"ROW" to TokenType.Row,
"RANGE" to TokenType.Range,
"GROUPS" to TokenType.Groups,
"PRECEDING" to TokenType.Preceding,
"FOLLOWING" to TokenType.Following,
"UNBOUNDED" to TokenType.Unbounded,
"CURRENT" to TokenType.Current,
"EXCLUDE" to TokenType.Exclude,
"TIES" to TokenType.Ties,
"OTHERS" to TokenType.Others,
"BEGIN" to TokenType.Begin,
"COMMIT" to TokenType.Commit,
"ROLLBACK" to TokenType.Rollback,
"TRANSACTION" to TokenType.Transaction,
"EXPLAIN" to TokenType.Explain,
"SAVEPOINT" to TokenType.Savepoint,
"RELEASE" to TokenType.Release,
"CONFLICT" to TokenType.Conflict,
"NOTHING" to TokenType.Nothing,
"DO" to TokenType.Do,
"RETURNING" to TokenType.Returning,
"TRIGGER" to TokenType.Trigger,
"BEFORE" to TokenType.Before,
"AFTER" to TokenType.After,
"FOR" to TokenType.For,
"EACH" to TokenType.Each,
"INTEGER" to TokenType.Integer,
"TEXT" to TokenType.Text,
"BOOLEAN" to TokenType.Boolean,
"REAL" to TokenType.Real
"REAL" to TokenType.Real,
"IS" to TokenType.Is,
"IN" to TokenType.In,
"BETWEEN" to TokenType.Between,
"LIKE" to TokenType.Like,
"CASE" to TokenType.Case,
"WHEN" to TokenType.When,
"THEN" to TokenType.Then,
"ELSE" to TokenType.Else,
"END" to TokenType.End,
"EXISTS" to TokenType.Exists,
"VIEW" to TokenType.View,
"REPLACE" to TokenType.Replace,
"UNION" to TokenType.Union,
"ALL" to TokenType.All,
"INTERSECT" to TokenType.Intersect,
"EXCEPT" to TokenType.Except,
"WITH" to TokenType.With,
"RECURSIVE" to TokenType.Recursive,
"DISTINCT" to TokenType.Distinct,
"CAST" to TokenType.Cast,
"CASCADE" to TokenType.Cascade,
"RESTRICT" to TokenType.Restrict,
"DECIMAL" to TokenType.Decimal,
"DATE" to TokenType.Date,
"REFERENCES" to TokenType.References,
"FOREIGN" to TokenType.Foreign,
"CHECK" to TokenType.Check,
"NO" to TokenType.No,
"ACTION" to TokenType.Action,
"UNIQUE" to TokenType.Unique,
"CONSTRAINT" to TokenType.Constraint,
"SEQUENCE" to TokenType.Sequence,
"INCREMENT" to TokenType.Increment,
"MINVALUE" to TokenType.Minvalue,
"MAXVALUE" to TokenType.Maxvalue,
"START" to TokenType.Start,
"CYCLE" to TokenType.Cycle,
"RESTART" to TokenType.Restart,
"MERGE" to TokenType.Merge,
"USING" to TokenType.Using,
"MATCHED" to TokenType.Matched,
"PREPARE" to TokenType.Prepare,
"EXECUTE" to TokenType.Execute,
"DEALLOCATE" to TokenType.Deallocate,
"TEMP" to TokenType.Temp,
"TEMPORARY" to TokenType.Temporary,
"COLLATE" to TokenType.Collate
)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,54 @@
package kestrel.storage
/**
* A text comparison sequence, selected by the `COLLATE` operator (`expr COLLATE name`) - see
* [kestrel.sql.ast.CollateExpression]. Only the three built-in sequences SQLite ships are
* supported (no `CREATE COLLATION`, no user-defined comparator); [Binary] is what every text
* comparison already used before COLLATE existed, so it stays the default everywhere a caller
* does not thread a collation through at all.
*/
enum class Collation(val sqlName: String) {
/** Plain UTF-16 code-unit order - [String.compareTo] - identical to this engine's pre-COLLATE behavior. */
Binary("BINARY"),
/**
* Case-insensitive, but only for ASCII `A-Z`/`a-z` - matching SQLite's own NOCASE exactly
* (not full Unicode case folding, which would depend on locale for a handful of characters
* like Turkish "I"). Folds both operands to uppercase ASCII before comparing.
*/
NoCase("NOCASE"),
/**
* Ignores trailing ASCII space characters, matching SQLite's built-in RTRIM collation.
* Leading and interior whitespace still participates in the comparison.
*/
Rtrim("RTRIM");
fun compare(left: String, right: String): Int = canonicalize(left).compareTo(canonicalize(right))
/**
* [value] rewritten so that plain [String.equals]/[String.hashCode]/[Comparable.compareTo] on
* the result matches this collation's own [compare] - shared by [compare] itself and by every
* equality-based dedup (`GROUP BY`, `DISTINCT`, an aggregate's own `DISTINCT`) that needs a
* hashable key rather than a pairwise comparison. [Binary] returns [value] unchanged, so folding
* a value under it and using the result as a key is indistinguishable from the pre-COLLATE
* behavior of comparing/hashing the value directly.
*/
fun canonicalize(value: String): String = when (this) {
Binary -> value
NoCase -> foldAsciiCase(value)
Rtrim -> value.trimEnd(' ')
}
private fun foldAsciiCase(value: String): String {
val builder = StringBuilder(value.length)
for (character in value) {
builder.append(if (character in 'a'..'z') character.uppercaseChar() else character)
}
return builder.toString()
}
companion object {
fun fromName(name: String): Collation? = entries.find { it.sqlName.equals(name, ignoreCase = true) }
}
}
@@ -0,0 +1,28 @@
package kestrel.storage
import java.math.BigDecimal
import java.math.RoundingMode
import kestrel.KestrelException
/**
* Shared DECIMAL(precision, scale) rules used both when a value is coerced into a
* DECIMAL column (INSERT/UPDATE) and when it is produced by an explicit CAST. Kept as
* a single place so both call sites agree on rounding mode and overflow behavior.
*/
object DecimalColumns {
const val DEFAULT_PRECISION = 18
const val DEFAULT_SCALE = 2
fun normalize(raw: BigDecimal, precision: Int?, scale: Int?, context: String): BigDecimal {
val effectiveScale = scale ?: DEFAULT_SCALE
val effectivePrecision = precision ?: DEFAULT_PRECISION
val rounded = raw.setScale(effectiveScale, RoundingMode.HALF_UP)
val digits = rounded.unscaledValue().abs().toString().length
if (digits > effectivePrecision) {
throw KestrelException(
"$context: $rounded has more than $effectivePrecision digits for DECIMAL($effectivePrecision, $effectiveScale)"
)
}
return rounded
}
}
+39 -1
View File
@@ -1,7 +1,10 @@
package kestrel.storage
import java.math.BigDecimal
import java.time.LocalDate
enum class ColumnType {
Integer, Text, Boolean, Real;
Integer, Text, Boolean, Real, Decimal, Date;
val displayName: String
get() = when (this) {
@@ -9,6 +12,8 @@ enum class ColumnType {
Text -> "TEXT"
Boolean -> "BOOLEAN"
Real -> "REAL"
Decimal -> "DECIMAL"
Date -> "DATE"
}
companion object {
@@ -17,11 +22,20 @@ enum class ColumnType {
"TEXT" -> Text
"BOOLEAN" -> Boolean
"REAL" -> Real
"DECIMAL" -> Decimal
"DATE" -> Date
else -> null
}
}
}
/** Renders a type with its DECIMAL(precision[, scale]) parameters, if any were declared. */
fun ColumnType.displayWith(precision: Int?, scale: Int?): String = when {
this != ColumnType.Decimal || precision == null -> displayName
scale == null -> "$displayName($precision)"
else -> "$displayName($precision, $scale)"
}
sealed class Value {
abstract val type: ColumnType?
@@ -41,6 +55,30 @@ sealed class Value {
override val type get() = ColumnType.Boolean
}
/**
* Arbitrary-precision fixed-point number. Equality and hashing compare by numeric
* value (via [BigDecimal.compareTo]) rather than by representation, so `1.5` and
* `1.50` are the same value for GROUP BY/DISTINCT/UNION dedup purposes, matching
* standard SQL DECIMAL semantics instead of Java's stricter [BigDecimal.equals].
*/
class DecimalValue(val value: BigDecimal) : Value() {
override val type get() = ColumnType.Decimal
override fun equals(other: Any?): Boolean =
other is DecimalValue && value.compareTo(other.value) == 0
override fun hashCode(): Int {
val canonical = if (value.signum() == 0) BigDecimal.ZERO else value.stripTrailingZeros()
return canonical.hashCode()
}
override fun toString(): String = "DecimalValue(value=$value)"
}
data class DateValue(val value: LocalDate) : Value() {
override val type get() = ColumnType.Date
}
object NullValue : Value() {
override val type: ColumnType? get() = null
}
@@ -1,14 +1,26 @@
package kestrel.storage
import java.math.BigDecimal
object ValueOrdering : Comparator<Value> {
override fun compare(left: Value, right: Value): Int = when {
override fun compare(left: Value, right: Value): Int = compare(left, right, Collation.Binary)
/**
* Same ordering as the plain two-argument [compare], except two [Value.TextValue]s are
* compared through [collation] instead of always using [String.compareTo] - see
* [kestrel.engine.compareOrderKeyValues], the only caller that ever passes a non-[Collation.Binary]
* value (an explicit `ORDER BY ... COLLATE name`).
*/
fun compare(left: Value, right: Value, collation: Collation): Int = when {
left is Value.NullValue && right is Value.NullValue -> 0
left is Value.NullValue -> -1
right is Value.NullValue -> 1
left is Value.IntegerValue && right is Value.IntegerValue -> left.value.compareTo(right.value)
left is Value.DecimalValue || right is Value.DecimalValue -> compareAsDecimal(left, right)
isNumeric(left) && isNumeric(right) -> asDouble(left).compareTo(asDouble(right))
left is Value.TextValue && right is Value.TextValue -> left.value.compareTo(right.value)
left is Value.TextValue && right is Value.TextValue -> collation.compare(left.value, right.value)
left is Value.BooleanValue && right is Value.BooleanValue -> left.value.compareTo(right.value)
left is Value.DateValue && right is Value.DateValue -> left.value.compareTo(right.value)
else -> rank(left).compareTo(rank(right))
}
@@ -20,11 +32,27 @@ object ValueOrdering : Comparator<Value> {
else -> 0.0
}
/** Falls back to [rank] ordering (never throws) when one side isn't itself numeric. */
private fun compareAsDecimal(left: Value, right: Value): Int {
val a = asBigDecimalOrNull(left) ?: return rank(left).compareTo(rank(right))
val b = asBigDecimalOrNull(right) ?: return rank(left).compareTo(rank(right))
return a.compareTo(b)
}
private fun asBigDecimalOrNull(value: Value): BigDecimal? = when (value) {
is Value.DecimalValue -> value.value
is Value.IntegerValue -> BigDecimal(value.value)
is Value.RealValue -> BigDecimal.valueOf(value.value)
else -> null
}
private fun rank(value: Value): Int = when (value) {
is Value.NullValue -> 0
is Value.BooleanValue -> 1
is Value.IntegerValue -> 2
is Value.RealValue -> 2
is Value.TextValue -> 3
is Value.DecimalValue -> 2
is Value.DateValue -> 3
is Value.TextValue -> 4
}
}
@@ -0,0 +1,392 @@
package kestrel.store.btree
import kestrel.KestrelException
import kestrel.store.codec.KeyCodec
import kestrel.store.page.Page
import kestrel.store.page.PageStore
class BTreeCorruptionException(message: String) : KestrelException(message)
/**
* A disk-backed B+Tree mapping order-preserving byte keys (see [KeyCodec]) to
* row pointers. Nodes live on [Page]s fetched through a [BufferPool]; leaves
* are linked for ordered range scans. Insert splits an overflowing node and
* propagates a promoted separator key upward, growing the root when needed.
* Delete removes the entry and, when a node drops below
* [UNDERFLOW_THRESHOLD], redistributes an entry from a sibling with spare
* capacity or merges with one, shrinking the root when it is left with a
* single child.
*/
class BPlusTree private constructor(private val bufferPool: PageStore, rootPageId: Long) {
companion object {
private const val UNDERFLOW_THRESHOLD = Page.PAGE_SIZE / 4
fun create(bufferPool: PageStore): BPlusTree {
val page = bufferPool.newPage()
NodeCodec.writeTo(page, BTreeNode.Leaf(page.id, mutableListOf(), mutableListOf(), NodeCodec.NO_PAGE))
bufferPool.unpinPage(page.id, true)
return BPlusTree(bufferPool, page.id)
}
fun open(bufferPool: PageStore, rootPageId: Long): BPlusTree = BPlusTree(bufferPool, rootPageId)
}
var rootPageId: Long = rootPageId
private set
fun search(key: ByteArray): Long? {
var pageId = rootPageId
while (true) {
val (_, node) = fetch(pageId)
when (node) {
is BTreeNode.Internal -> {
val nextId = node.children[childIndexFor(node.keys, key)]
bufferPool.unpinPage(pageId, false)
pageId = nextId
}
is BTreeNode.Leaf -> {
val idx = lowerBound(node.keys, key)
val result = if (idx < node.keys.size && KeyCodec.compare(node.keys[idx], key) == 0) {
node.values[idx]
} else {
null
}
bufferPool.unpinPage(pageId, false)
return result
}
}
}
}
fun insert(key: ByteArray, value: Long) {
val split = insertRecursive(rootPageId, key, value)
if (split != null) {
val newRoot = bufferPool.newPage()
NodeCodec.writeTo(
newRoot,
BTreeNode.Internal(newRoot.id, mutableListOf(split.promotedKey), mutableListOf(rootPageId, split.newPageId))
)
bufferPool.unpinPage(newRoot.id, true)
rootPageId = newRoot.id
}
}
fun delete(key: ByteArray): Boolean {
val found = deleteRecursive(rootPageId, key)
val (_, rootNode) = fetch(rootPageId)
if (rootNode is BTreeNode.Internal && rootNode.keys.isEmpty()) {
val onlyChild = rootNode.children[0]
bufferPool.unpinPage(rootPageId, false)
bufferPool.deletePage(rootPageId)
rootPageId = onlyChild
} else {
bufferPool.unpinPage(rootPageId, false)
}
return found
}
/** Every page belonging to this tree: every internal node and every leaf, reached by a full traversal from the root. */
fun allPageIds(): List<Long> {
val ids = mutableListOf<Long>()
val pending = ArrayDeque<Long>()
pending.add(rootPageId)
while (pending.isNotEmpty()) {
val pageId = pending.removeFirst()
ids.add(pageId)
val (_, node) = fetch(pageId)
bufferPool.unpinPage(pageId, false)
if (node is BTreeNode.Internal) pending.addAll(node.children)
}
return ids
}
/** Ordered scan over `[from, to]` (or `[from, to)` when [toInclusive] is false). Null bounds are open. */
fun rangeScan(from: ByteArray? = null, to: ByteArray? = null, toInclusive: Boolean = false): Iterator<Pair<ByteArray, Long>> {
val startLeafId = leftmostLeafFor(from)
return iterator {
var currentLeafId = startLeafId
var firstLeaf = true
outer@ while (currentLeafId != NodeCodec.NO_PAGE) {
val (_, leaf) = fetch(currentLeafId)
leaf as BTreeNode.Leaf
var index = if (firstLeaf && from != null) lowerBound(leaf.keys, from) else 0
firstLeaf = false
while (index < leaf.keys.size) {
val currentKey = leaf.keys[index]
if (to != null) {
val cmp = KeyCodec.compare(currentKey, to)
if (cmp > 0 || (cmp == 0 && !toInclusive)) {
bufferPool.unpinPage(currentLeafId, false)
break@outer
}
}
yield(currentKey to leaf.values[index])
index++
}
val next = leaf.nextLeaf
bufferPool.unpinPage(currentLeafId, false)
currentLeafId = next
}
}
}
private fun leftmostLeafFor(from: ByteArray?): Long {
var pageId = rootPageId
while (true) {
val (_, node) = fetch(pageId)
when (node) {
is BTreeNode.Internal -> {
val idx = if (from == null) 0 else childIndexFor(node.keys, from)
val nextId = node.children[idx]
bufferPool.unpinPage(pageId, false)
pageId = nextId
}
is BTreeNode.Leaf -> {
bufferPool.unpinPage(pageId, false)
return pageId
}
}
}
}
private data class SplitResult(val promotedKey: ByteArray, val newPageId: Long)
private fun insertRecursive(pageId: Long, key: ByteArray, value: Long): SplitResult? {
val (page, node) = fetch(pageId)
var dirty = false
val result: SplitResult?
when (node) {
is BTreeNode.Leaf -> {
val idx = lowerBound(node.keys, key)
if (idx < node.keys.size && KeyCodec.compare(node.keys[idx], key) == 0) {
node.values[idx] = value
} else {
node.keys.add(idx, key)
node.values.add(idx, value)
}
dirty = true
result = if (NodeCodec.fitsInPage(node)) {
NodeCodec.writeTo(page, node)
null
} else {
splitLeaf(page, node)
}
}
is BTreeNode.Internal -> {
val idx = childIndexFor(node.keys, key)
val childSplit = insertRecursive(node.children[idx], key, value)
if (childSplit == null) {
result = null
} else {
node.keys.add(idx, childSplit.promotedKey)
node.children.add(idx + 1, childSplit.newPageId)
dirty = true
result = if (NodeCodec.fitsInPage(node)) {
NodeCodec.writeTo(page, node)
null
} else {
splitInternal(page, node)
}
}
}
}
bufferPool.unpinPage(pageId, dirty)
return result
}
private fun splitLeaf(page: Page, leaf: BTreeNode.Leaf): SplitResult {
val mid = leaf.keys.size / 2
val rightKeys = ArrayList(leaf.keys.subList(mid, leaf.keys.size))
val rightValues = ArrayList(leaf.values.subList(mid, leaf.values.size))
val leftKeys = ArrayList(leaf.keys.subList(0, mid))
val leftValues = ArrayList(leaf.values.subList(0, mid))
val rightPage = bufferPool.newPage()
val rightNode = BTreeNode.Leaf(rightPage.id, rightKeys, rightValues, leaf.nextLeaf)
val leftNode = BTreeNode.Leaf(page.id, leftKeys, leftValues, rightPage.id)
NodeCodec.writeTo(page, leftNode)
NodeCodec.writeTo(rightPage, rightNode)
bufferPool.unpinPage(rightPage.id, true)
return SplitResult(rightKeys[0], rightPage.id)
}
private fun splitInternal(page: Page, node: BTreeNode.Internal): SplitResult {
val mid = node.keys.size / 2
val promotedKey = node.keys[mid]
val leftKeys = ArrayList(node.keys.subList(0, mid))
val leftChildren = ArrayList(node.children.subList(0, mid + 1))
val rightKeys = ArrayList(node.keys.subList(mid + 1, node.keys.size))
val rightChildren = ArrayList(node.children.subList(mid + 1, node.children.size))
val rightPage = bufferPool.newPage()
val rightNode = BTreeNode.Internal(rightPage.id, rightKeys, rightChildren)
val leftNode = BTreeNode.Internal(page.id, leftKeys, leftChildren)
NodeCodec.writeTo(page, leftNode)
NodeCodec.writeTo(rightPage, rightNode)
bufferPool.unpinPage(rightPage.id, true)
return SplitResult(promotedKey, rightPage.id)
}
private fun deleteRecursive(pageId: Long, key: ByteArray): Boolean {
val (page, node) = fetch(pageId)
val found: Boolean
when (node) {
is BTreeNode.Leaf -> {
val idx = lowerBound(node.keys, key)
found = idx < node.keys.size && KeyCodec.compare(node.keys[idx], key) == 0
if (found) {
node.keys.removeAt(idx)
node.values.removeAt(idx)
NodeCodec.writeTo(page, node)
}
bufferPool.unpinPage(pageId, found)
}
is BTreeNode.Internal -> {
val idx = childIndexFor(node.keys, key)
val childId = node.children[idx]
found = deleteRecursive(childId, key)
if (found) {
val (_, childNode) = fetch(childId)
val underflow = isUnderflowNode(childNode)
bufferPool.unpinPage(childId, false)
if (underflow) {
rebalanceChild(node, idx)
NodeCodec.writeTo(page, node)
}
}
bufferPool.unpinPage(pageId, found)
}
}
return found
}
private fun rebalanceChild(parent: BTreeNode.Internal, idx: Int) {
val leftIdx = idx - 1
val rightIdx = idx + 1
if (leftIdx >= 0) {
val (leftPage, leftNode) = fetch(parent.children[leftIdx])
if (canLend(leftNode)) {
borrowFromLeft(parent, idx, leftPage, leftNode)
bufferPool.unpinPage(leftNode.pageId, true)
return
}
bufferPool.unpinPage(leftNode.pageId, false)
}
if (rightIdx < parent.children.size) {
val (rightPage, rightNode) = fetch(parent.children[rightIdx])
if (canLend(rightNode)) {
borrowFromRight(parent, idx, rightPage, rightNode)
bufferPool.unpinPage(rightNode.pageId, true)
return
}
bufferPool.unpinPage(rightNode.pageId, false)
}
if (leftIdx >= 0) mergeChildren(parent, leftIdx) else mergeChildren(parent, idx)
}
private fun canLend(node: BTreeNode): Boolean = when (node) {
is BTreeNode.Leaf -> node.keys.size > 1
is BTreeNode.Internal -> node.keys.size > 1
}
private fun borrowFromLeft(parent: BTreeNode.Internal, idx: Int, leftPage: Page, leftNode: BTreeNode) {
val (childPage, childNode) = fetch(parent.children[idx])
when {
leftNode is BTreeNode.Leaf && childNode is BTreeNode.Leaf -> {
val lastIdx = leftNode.keys.size - 1
val borrowedKey = leftNode.keys.removeAt(lastIdx)
val borrowedValue = leftNode.values.removeAt(lastIdx)
childNode.keys.add(0, borrowedKey)
childNode.values.add(0, borrowedValue)
parent.keys[idx - 1] = childNode.keys[0]
}
leftNode is BTreeNode.Internal && childNode is BTreeNode.Internal -> {
val movedChild = leftNode.children.removeAt(leftNode.children.size - 1)
val separatorDown = leftNode.keys.removeAt(leftNode.keys.size - 1)
childNode.children.add(0, movedChild)
childNode.keys.add(0, parent.keys[idx - 1])
parent.keys[idx - 1] = separatorDown
}
else -> throw BTreeCorruptionException("sibling type mismatch during redistribution")
}
NodeCodec.writeTo(leftPage, leftNode)
NodeCodec.writeTo(childPage, childNode)
bufferPool.unpinPage(childNode.pageId, true)
}
private fun borrowFromRight(parent: BTreeNode.Internal, idx: Int, rightPage: Page, rightNode: BTreeNode) {
val (childPage, childNode) = fetch(parent.children[idx])
when {
rightNode is BTreeNode.Leaf && childNode is BTreeNode.Leaf -> {
val borrowedKey = rightNode.keys.removeAt(0)
val borrowedValue = rightNode.values.removeAt(0)
childNode.keys.add(borrowedKey)
childNode.values.add(borrowedValue)
parent.keys[idx] = rightNode.keys[0]
}
rightNode is BTreeNode.Internal && childNode is BTreeNode.Internal -> {
val movedChild = rightNode.children.removeAt(0)
val separatorDown = rightNode.keys.removeAt(0)
childNode.children.add(movedChild)
childNode.keys.add(parent.keys[idx])
parent.keys[idx] = separatorDown
}
else -> throw BTreeCorruptionException("sibling type mismatch during redistribution")
}
NodeCodec.writeTo(rightPage, rightNode)
NodeCodec.writeTo(childPage, childNode)
bufferPool.unpinPage(childNode.pageId, true)
}
private fun mergeChildren(parent: BTreeNode.Internal, leftIdx: Int) {
val leftId = parent.children[leftIdx]
val rightId = parent.children[leftIdx + 1]
val (leftPage, leftNode) = fetch(leftId)
val (_, rightNode) = fetch(rightId)
when {
leftNode is BTreeNode.Leaf && rightNode is BTreeNode.Leaf -> {
leftNode.keys.addAll(rightNode.keys)
leftNode.values.addAll(rightNode.values)
leftNode.nextLeaf = rightNode.nextLeaf
}
leftNode is BTreeNode.Internal && rightNode is BTreeNode.Internal -> {
leftNode.keys.add(parent.keys[leftIdx])
leftNode.keys.addAll(rightNode.keys)
leftNode.children.addAll(rightNode.children)
}
else -> throw BTreeCorruptionException("sibling type mismatch during merge")
}
NodeCodec.writeTo(leftPage, leftNode)
bufferPool.unpinPage(leftId, true)
bufferPool.unpinPage(rightId, false)
bufferPool.deletePage(rightId)
parent.keys.removeAt(leftIdx)
parent.children.removeAt(leftIdx + 1)
}
private fun isUnderflowNode(node: BTreeNode): Boolean = NodeCodec.serialize(node).size < UNDERFLOW_THRESHOLD
private fun fetch(pageId: Long): Pair<Page, BTreeNode> {
val page = bufferPool.fetchPage(pageId)
return page to NodeCodec.readFrom(page)
}
private fun lowerBound(keys: List<ByteArray>, key: ByteArray): Int {
var lo = 0
var hi = keys.size
while (lo < hi) {
val mid = (lo + hi) ushr 1
if (KeyCodec.compare(keys[mid], key) < 0) lo = mid + 1 else hi = mid
}
return lo
}
private fun childIndexFor(keys: List<ByteArray>, key: ByteArray): Int {
var lo = 0
var hi = keys.size
while (lo < hi) {
val mid = (lo + hi) ushr 1
if (KeyCodec.compare(keys[mid], key) <= 0) lo = mid + 1 else hi = mid
}
return lo
}
}
@@ -0,0 +1,107 @@
package kestrel.store.btree
import kestrel.store.codec.ByteReader
import kestrel.store.codec.ByteWriter
import kestrel.store.codec.CodecException
import kestrel.store.page.Page
sealed class BTreeNode {
abstract val pageId: Long
class Leaf(
override val pageId: Long,
val keys: MutableList<ByteArray>,
val values: MutableList<Long>,
var nextLeaf: Long
) : BTreeNode()
/** `children.size` is always `keys.size + 1`: `children[0]` holds keys below `keys[0]`. */
class Internal(
override val pageId: Long,
val keys: MutableList<ByteArray>,
val children: MutableList<Long>
) : BTreeNode()
}
/** Serializes [BTreeNode]s onto/from a fixed-size [Page]. */
object NodeCodec {
private const val TYPE_LEAF = 0
private const val TYPE_INTERNAL = 1
const val NO_PAGE = -1L
fun serialize(node: BTreeNode): ByteArray {
val writer = ByteWriter(Page.PAGE_SIZE)
when (node) {
is BTreeNode.Leaf -> {
writer.writeByte(TYPE_LEAF)
writeCount(writer, node.keys.size)
writer.writeFixedLong(node.nextLeaf)
for (i in node.keys.indices) {
writer.writeLengthPrefixed(node.keys[i])
writer.writeFixedLong(node.values[i])
}
}
is BTreeNode.Internal -> {
writer.writeByte(TYPE_INTERNAL)
writeCount(writer, node.keys.size)
writer.writeFixedLong(node.children[0])
for (i in node.keys.indices) {
writer.writeLengthPrefixed(node.keys[i])
writer.writeFixedLong(node.children[i + 1])
}
}
}
return writer.toByteArray()
}
fun writeTo(page: Page, node: BTreeNode) {
val bytes = serialize(node)
if (bytes.size > Page.PAGE_SIZE) {
throw CodecException("btree node ${node.pageId} serializes to ${bytes.size} bytes, exceeds page size")
}
page.data.fill(0)
System.arraycopy(bytes, 0, page.data, 0, bytes.size)
page.isDirty = true
}
fun deserialize(pageId: Long, data: ByteArray): BTreeNode {
val reader = ByteReader(data)
val type = reader.readByte()
val count = readCount(reader)
return when (type) {
TYPE_LEAF -> {
val nextLeaf = reader.readFixedLong()
val keys = ArrayList<ByteArray>(count)
val values = ArrayList<Long>(count)
repeat(count) {
keys.add(reader.readLengthPrefixed())
values.add(reader.readFixedLong())
}
BTreeNode.Leaf(pageId, keys, values, nextLeaf)
}
TYPE_INTERNAL -> {
val firstChild = reader.readFixedLong()
val keys = ArrayList<ByteArray>(count)
val children = ArrayList<Long>(count + 1)
children.add(firstChild)
repeat(count) {
keys.add(reader.readLengthPrefixed())
children.add(reader.readFixedLong())
}
BTreeNode.Internal(pageId, keys, children)
}
else -> throw CodecException("unknown btree node type $type")
}
}
fun readFrom(page: Page): BTreeNode = deserialize(page.id, page.data)
fun fitsInPage(node: BTreeNode): Boolean = serialize(node).size <= Page.PAGE_SIZE
private fun writeCount(writer: ByteWriter, count: Int) {
writer.writeByte((count ushr 8) and 0xFF)
writer.writeByte(count and 0xFF)
}
private fun readCount(reader: ByteReader): Int = (reader.readByte() shl 8) or reader.readByte()
}
@@ -0,0 +1,113 @@
package kestrel.store.codec
import kestrel.KestrelException
class CodecException(message: String) : KestrelException(message)
/** Growable little-endian-free byte sink. Multi-byte integers are written big-endian. */
class ByteWriter(initialCapacity: Int = 32) {
private var buffer = ByteArray(initialCapacity)
private var size = 0
val length: Int get() = size
private fun ensure(extra: Int) {
if (size + extra <= buffer.size) return
var newCapacity = buffer.size * 2
while (newCapacity < size + extra) newCapacity *= 2
buffer = buffer.copyOf(newCapacity)
}
fun writeByte(value: Int): ByteWriter {
ensure(1)
buffer[size++] = value.toByte()
return this
}
fun writeBytes(bytes: ByteArray): ByteWriter {
ensure(bytes.size)
System.arraycopy(bytes, 0, buffer, size, bytes.size)
size += bytes.size
return this
}
fun writeFixedLong(value: Long): ByteWriter {
ensure(8)
for (i in 7 downTo 0) {
buffer[size++] = ((value ushr (i * 8)) and 0xFF).toByte()
}
return this
}
fun writeFixedDouble(value: Double): ByteWriter = writeFixedLong(java.lang.Double.doubleToLongBits(value))
/** Unsigned LEB128 varint. */
fun writeVarInt(value: Long): ByteWriter {
var remaining = value
while (true) {
val byte = (remaining and 0x7F).toInt()
remaining = remaining ushr 7
if (remaining == 0L) {
writeByte(byte)
return this
}
writeByte(byte or 0x80)
}
}
/** Zigzag-encoded signed varint, safe for negative numbers. */
fun writeZigZag(value: Long): ByteWriter = writeVarInt((value shl 1) xor (value shr 63))
fun writeLengthPrefixed(bytes: ByteArray): ByteWriter {
writeVarInt(bytes.size.toLong())
writeBytes(bytes)
return this
}
fun toByteArray(): ByteArray = buffer.copyOf(size)
}
class ByteReader(private val data: ByteArray, private var position: Int = 0) {
val remaining: Int get() = data.size - position
fun hasRemaining(): Boolean = position < data.size
fun readByte(): Int {
if (position >= data.size) throw CodecException("unexpected end of buffer")
return data[position++].toInt() and 0xFF
}
fun readBytes(count: Int): ByteArray {
if (position + count > data.size) throw CodecException("unexpected end of buffer")
val slice = data.copyOfRange(position, position + count)
position += count
return slice
}
fun readFixedLong(): Long {
var result = 0L
repeat(8) { result = (result shl 8) or readByte().toLong() }
return result
}
fun readFixedDouble(): Double = java.lang.Double.longBitsToDouble(readFixedLong())
fun readVarInt(): Long {
var result = 0L
var shift = 0
while (true) {
val byte = readByte()
result = result or ((byte.toLong() and 0x7F) shl shift)
if (byte and 0x80 == 0) return result
shift += 7
if (shift > 63) throw CodecException("varint too long")
}
}
fun readZigZag(): Long {
val encoded = readVarInt()
return (encoded ushr 1) xor -(encoded and 1)
}
fun readLengthPrefixed(): ByteArray = readBytes(readVarInt().toInt())
}
@@ -0,0 +1,14 @@
package kestrel.store.codec
import java.util.zip.CRC32C
/** Castagnoli CRC-32 checksums used to detect corruption of on-disk pages and records. */
object Crc32C {
fun of(bytes: ByteArray): Int = of(bytes, 0, bytes.size)
fun of(bytes: ByteArray, offset: Int, length: Int): Int {
val crc = CRC32C()
crc.update(bytes, offset, length)
return crc.value.toInt()
}
}
@@ -0,0 +1,214 @@
package kestrel.store.codec
import java.math.BigDecimal
import java.math.BigInteger
import java.time.LocalDate
import kestrel.storage.Value
/**
* Order-preserving byte encoding for index keys: unsigned lexicographic
* comparison of the encoded bytes agrees with SQL value ordering, so a
* B+Tree can use plain `ByteArray` comparison instead of decoding values.
*
* Numeric values sort by type tag first, then by magnitude: same-type
* comparisons (INTEGER-INTEGER, REAL-REAL, DECIMAL-DECIMAL) are exact, but a
* mixed INTEGER/REAL/DECIMAL comparison is ordered by tag rather than by
* numeric value. Index columns are schema-typed in practice, so this does not
* affect single-column indexes; it is a deliberate scope cut versus the full
* cross-type semantics of [kestrel.storage.ValueOrdering].
*
* DECIMAL uses a normalized "scientific notation" encoding: a sign byte, an
* offset-binary exponent (so larger magnitude sorts later), then the
* significant digits (trailing zeros stripped, one mapped byte per digit,
* terminated by a byte no digit can produce). Two decimals compare equal
* under this scheme exactly when [BigDecimal.compareTo] says they are equal
* (`1.5` and `1.50` included), and it holds arbitrary precision - unlike
* REAL's key encoding this never rounds through a `Double`. Negative values
* bit-complement the whole magnitude encoding, which reverses byte-order
* comparison so a more-negative number still sorts first.
*/
object KeyCodec {
private const val TAG_NULL = 0
private const val TAG_BOOLEAN = 1
private const val TAG_INTEGER = 2
private const val TAG_REAL = 3
private const val TAG_TEXT = 4
private const val TAG_DECIMAL = 5
private const val TAG_DATE = 6
private const val TEXT_ESCAPE_NUL = 0xFF
private const val TEXT_TERMINATOR_HIGH = 0x00
private const val TEXT_TERMINATOR_LOW = 0x00
private const val DECIMAL_SIGN_NEGATIVE = 0
private const val DECIMAL_SIGN_ZERO = 1
private const val DECIMAL_SIGN_POSITIVE = 2
private const val DECIMAL_DIGIT_TERMINATOR = 0
fun encodeKey(values: List<Value>): ByteArray {
val writer = ByteWriter()
for (value in values) encodeInto(writer, value)
return writer.toByteArray()
}
fun encode(value: Value): ByteArray {
val writer = ByteWriter()
encodeInto(writer, value)
return writer.toByteArray()
}
fun decodeKey(bytes: ByteArray): List<Value> {
val reader = ByteReader(bytes)
val result = ArrayList<Value>()
while (reader.hasRemaining()) {
result.add(decodeOne(reader))
}
return result
}
/** Unsigned lexicographic comparison, matching how the bytes are compared on disk. */
fun compare(a: ByteArray, b: ByteArray): Int {
val n = minOf(a.size, b.size)
for (i in 0 until n) {
val diff = (a[i].toInt() and 0xFF) - (b[i].toInt() and 0xFF)
if (diff != 0) return diff
}
return a.size - b.size
}
private fun encodeInto(writer: ByteWriter, value: Value) {
when (value) {
Value.NullValue -> writer.writeByte(TAG_NULL)
is Value.BooleanValue -> {
writer.writeByte(TAG_BOOLEAN)
writer.writeByte(if (value.value) 1 else 0)
}
is Value.IntegerValue -> {
writer.writeByte(TAG_INTEGER)
writer.writeFixedLong(value.value xor Long.MIN_VALUE)
}
is Value.RealValue -> {
writer.writeByte(TAG_REAL)
writer.writeFixedLong(encodeOrderedDouble(value.value))
}
is Value.TextValue -> {
writer.writeByte(TAG_TEXT)
writeEscapedText(writer, value.value)
}
is Value.DecimalValue -> {
writer.writeByte(TAG_DECIMAL)
writeDecimalMagnitudeSign(writer, value.value)
}
is Value.DateValue -> {
writer.writeByte(TAG_DATE)
writer.writeFixedLong(value.value.toEpochDay() xor Long.MIN_VALUE)
}
}
}
private fun decodeOne(reader: ByteReader): Value = when (val tag = reader.readByte()) {
TAG_NULL -> Value.NullValue
TAG_BOOLEAN -> Value.BooleanValue(reader.readByte() != 0)
TAG_INTEGER -> Value.IntegerValue(reader.readFixedLong() xor Long.MIN_VALUE)
TAG_REAL -> Value.RealValue(decodeOrderedDouble(reader.readFixedLong()))
TAG_TEXT -> Value.TextValue(readEscapedText(reader))
TAG_DECIMAL -> Value.DecimalValue(readDecimalMagnitudeSign(reader))
TAG_DATE -> Value.DateValue(LocalDate.ofEpochDay(reader.readFixedLong() xor Long.MIN_VALUE))
else -> throw CodecException("unknown key tag $tag")
}
private fun writeDecimalMagnitudeSign(writer: ByteWriter, decimal: BigDecimal) {
when {
decimal.signum() == 0 -> writer.writeByte(DECIMAL_SIGN_ZERO)
decimal.signum() > 0 -> {
writer.writeByte(DECIMAL_SIGN_POSITIVE)
writeDecimalMagnitude(writer, decimal, invert = false)
}
else -> {
writer.writeByte(DECIMAL_SIGN_NEGATIVE)
writeDecimalMagnitude(writer, decimal.negate(), invert = true)
}
}
}
private fun writeDecimalMagnitude(writer: ByteWriter, magnitude: BigDecimal, invert: Boolean) {
val canonical = magnitude.stripTrailingZeros()
val digits = canonical.unscaledValue().toString()
val exponent = (digits.length - canonical.scale()).toLong()
val body = ByteWriter()
body.writeFixedLong(exponent xor Long.MIN_VALUE)
for (digit in digits) body.writeByte((digit - '0') + 1)
body.writeByte(DECIMAL_DIGIT_TERMINATOR)
val bytes = body.toByteArray()
writer.writeBytes(if (invert) ByteArray(bytes.size) { (bytes[it].toInt() xor 0xFF).toByte() } else bytes)
}
private fun readDecimalMagnitudeSign(reader: ByteReader): BigDecimal = when (val sign = reader.readByte()) {
DECIMAL_SIGN_ZERO -> BigDecimal.ZERO
DECIMAL_SIGN_POSITIVE -> readDecimalMagnitude(reader, invert = false)
DECIMAL_SIGN_NEGATIVE -> readDecimalMagnitude(reader, invert = true).negate()
else -> throw CodecException("unknown decimal sign tag $sign")
}
private fun readDecimalMagnitude(reader: ByteReader, invert: Boolean): BigDecimal {
fun nextByte(): Int {
val raw = reader.readByte()
return if (invert) raw xor 0xFF else raw
}
var exponentBits = 0L
repeat(8) { exponentBits = (exponentBits shl 8) or (nextByte().toLong() and 0xFF) }
val exponent = exponentBits xor Long.MIN_VALUE
val digits = StringBuilder()
while (true) {
val next = nextByte()
if (next == DECIMAL_DIGIT_TERMINATOR) break
digits.append('0' + (next - 1))
}
if (digits.isEmpty()) return BigDecimal.ZERO
val unscaled = BigInteger(digits.toString())
val scale = (digits.length - exponent).toInt()
return BigDecimal(unscaled, scale)
}
private fun encodeOrderedDouble(value: Double): Long {
val bits = java.lang.Double.doubleToLongBits(value)
return if (bits < 0) bits.inv() else bits xor Long.MIN_VALUE
}
private fun decodeOrderedDouble(transformed: Long): Double {
val bits = if (transformed < 0) transformed xor Long.MIN_VALUE else transformed.inv()
return java.lang.Double.longBitsToDouble(bits)
}
private fun writeEscapedText(writer: ByteWriter, text: String) {
for (byte in text.toByteArray(Charsets.UTF_8)) {
val unsigned = byte.toInt() and 0xFF
if (unsigned == 0x00) {
writer.writeByte(0x00)
writer.writeByte(TEXT_ESCAPE_NUL)
} else {
writer.writeByte(unsigned)
}
}
writer.writeByte(TEXT_TERMINATOR_HIGH)
writer.writeByte(TEXT_TERMINATOR_LOW)
}
private fun readEscapedText(reader: ByteReader): String {
val out = ByteWriter()
while (true) {
val byte = reader.readByte()
if (byte != 0x00) {
out.writeByte(byte)
continue
}
val next = reader.readByte()
if (next == TEXT_ESCAPE_NUL) {
out.writeByte(0x00)
} else {
break
}
}
return String(out.toByteArray(), Charsets.UTF_8)
}
}
@@ -0,0 +1,103 @@
package kestrel.store.codec
import java.math.BigDecimal
import java.math.BigInteger
import java.time.LocalDate
import kestrel.storage.Value
/**
* Encodes a row (list of [Value]) as a self-describing byte payload: a leading
* null bitmap followed by a type-tagged, variable-length payload for every
* non-null column. Used to store row bodies on disk pages, independent of the
* order-preserving key encoding in [KeyCodec].
*/
object TupleCodec {
private const val TAG_BOOLEAN = 1
private const val TAG_INTEGER = 2
private const val TAG_REAL = 3
private const val TAG_TEXT = 4
private const val TAG_DECIMAL = 5
private const val TAG_DATE = 6
fun encode(values: List<Value>): ByteArray {
val writer = ByteWriter()
writer.writeVarInt(values.size.toLong())
writer.writeBytes(nullBitmap(values))
for (value in values) {
if (value.isNull) continue
when (value) {
is Value.BooleanValue -> {
writer.writeByte(TAG_BOOLEAN)
writer.writeByte(if (value.value) 1 else 0)
}
is Value.IntegerValue -> {
writer.writeByte(TAG_INTEGER)
writer.writeZigZag(value.value)
}
is Value.RealValue -> {
writer.writeByte(TAG_REAL)
writer.writeFixedDouble(value.value)
}
is Value.TextValue -> {
writer.writeByte(TAG_TEXT)
writer.writeLengthPrefixed(value.value.toByteArray(Charsets.UTF_8))
}
is Value.DecimalValue -> {
writer.writeByte(TAG_DECIMAL)
writer.writeZigZag(value.value.scale().toLong())
writer.writeLengthPrefixed(value.value.unscaledValue().toByteArray())
}
is Value.DateValue -> {
writer.writeByte(TAG_DATE)
writer.writeZigZag(value.value.toEpochDay())
}
Value.NullValue -> Unit
}
}
return writer.toByteArray()
}
fun decode(bytes: ByteArray): List<Value> {
val reader = ByteReader(bytes)
val columnCount = reader.readVarInt().toInt()
val bitmapBytes = reader.readBytes(bitmapSize(columnCount))
val result = ArrayList<Value>(columnCount)
for (index in 0 until columnCount) {
if (isNullBit(bitmapBytes, index)) {
result.add(Value.NullValue)
continue
}
result.add(
when (val tag = reader.readByte()) {
TAG_BOOLEAN -> Value.BooleanValue(reader.readByte() != 0)
TAG_INTEGER -> Value.IntegerValue(reader.readZigZag())
TAG_REAL -> Value.RealValue(reader.readFixedDouble())
TAG_TEXT -> Value.TextValue(String(reader.readLengthPrefixed(), Charsets.UTF_8))
TAG_DECIMAL -> {
val scale = reader.readZigZag().toInt()
val unscaled = BigInteger(reader.readLengthPrefixed())
Value.DecimalValue(BigDecimal(unscaled, scale))
}
TAG_DATE -> Value.DateValue(LocalDate.ofEpochDay(reader.readZigZag()))
else -> throw CodecException("unknown tuple tag $tag")
}
)
}
return result
}
private fun bitmapSize(columnCount: Int): Int = (columnCount + 7) / 8
private fun nullBitmap(values: List<Value>): ByteArray {
val bitmap = ByteArray(bitmapSize(values.size))
for (index in values.indices) {
if (values[index].isNull) {
bitmap[index / 8] = (bitmap[index / 8].toInt() or (1 shl (index % 8))).toByte()
}
}
return bitmap
}
private fun isNullBit(bitmap: ByteArray, index: Int): Boolean =
(bitmap[index / 8].toInt() shr (index % 8)) and 1 == 1
}
@@ -0,0 +1,71 @@
package kestrel.store.engine
import kestrel.store.page.Page
import kestrel.store.page.PageStore
import kestrel.store.txn.TransactionManager
/**
* A [PageStore] decorator that watches every page a single logical operation
* touches and turns whatever ends up dirty into [TransactionManager.logUpdate]
* calls, without [kestrel.store.heap.HeapFile] or [kestrel.store.btree.BPlusTree]
* knowing they are being logged - they were written against [PageStore] with
* no notion of a transaction.
*
* One instance is meant to wrap exactly one logical operation (an insert, a
* delete, ...): the first [fetchPage] or [newPage] for a given page id
* captures its "before" image (an all-zero page for a freshly allocated one,
* since that is the state [kestrel.store.wal.RecoveryManager] would undo
* back to), and [finish] compares that snapshot against whatever the
* delegate's live [Page] holds once the operation is done, logging one WAL
* record per page whose bytes actually changed. [onTouch] is called only for
* those actually-logged pages, once [finish] has decided which ones they
* are: a page a caller merely read must never be reported, because its
* cached bytes can be dirty with an earlier, already-committed transaction's
* write that just has not been flushed yet, and [kestrel.store.wal.RecoveryManager]
* never touches such a page on disk during rollback, so invalidating it in
* the buffer pool would silently discard real data instead of undoing
* anything.
*/
class LoggingPageStore(
private val delegate: PageStore,
private val txnManager: TransactionManager,
private val txnId: Long,
private val onTouch: (Long) -> Unit = {}
) : PageStore {
private val liveByPageId = LinkedHashMap<Long, Page>()
private val beforeImages = HashMap<Long, ByteArray>()
override fun fetchPage(pageId: Long): Page {
val page = delegate.fetchPage(pageId)
track(pageId, page)
return page
}
override fun newPage(): Page {
val page = delegate.newPage()
beforeImages[page.id] = ByteArray(Page.PAGE_SIZE)
track(page.id, page)
return page
}
override fun unpinPage(pageId: Long, markDirty: Boolean) = delegate.unpinPage(pageId, markDirty)
override fun deletePage(pageId: Long) = delegate.deletePage(pageId)
/** Logs one update record per page whose bytes differ from when it was first touched. */
fun finish() {
for ((pageId, page) in liveByPageId) {
val before = beforeImages.getValue(pageId)
val after = page.data
if (!before.contentEquals(after)) {
txnManager.logUpdate(txnId, pageId, before, after.copyOf())
onTouch(pageId)
}
}
}
private fun track(pageId: Long, page: Page) {
liveByPageId.putIfAbsent(pageId, page)
beforeImages.putIfAbsent(pageId, page.data.copyOf())
}
}
@@ -0,0 +1,168 @@
package kestrel.store.engine
import kestrel.store.page.BufferPool
import kestrel.store.page.DiskManager
import kestrel.store.page.Page
import kestrel.store.txn.TransactionManager
import kestrel.store.wal.RecoveryManager
import kestrel.store.wal.WriteAheadLog
import java.io.File
/**
* Top-level facade tying the disk manager, buffer pool, write-ahead log and
* transaction manager into one crash-safe engine: [open] always runs
* [RecoveryManager.recover] first, so any table or index reopened through it
* sees the post-recovery, consistent state whether the previous process
* committed cleanly or crashed mid-transaction.
*
* [commit] and [abort] are the only way transactions started here should be
* resolved: besides delegating to [TransactionManager], they keep every
* [TransactionalIndex]'s in-memory root pointer and the [TouchedPageTracker]
* in sync, which a caller driving [TransactionManager] directly would have
* to reproduce by hand.
*/
class StorageEngine private constructor(
private val diskManager: DiskManager,
private val bufferPool: BufferPool,
private val wal: WriteAheadLog,
private val txnManager: TransactionManager
) : AutoCloseable {
companion object {
fun open(dataFile: File, walFile: File, poolCapacity: Int = 64): StorageEngine {
val diskManager = DiskManager(dataFile)
val wal = WriteAheadLog(walFile)
RecoveryManager(wal, diskManager).recover()
val bufferPool = BufferPool(diskManager, poolCapacity)
val txnManager = TransactionManager(wal, diskManager)
return StorageEngine(diskManager, bufferPool, wal, txnManager)
}
}
private val tracker = TouchedPageTracker()
private val indexes = mutableListOf<TransactionalIndex>()
private var nextResourceId = 0L
private val savepointLsnsByTxn = HashMap<Long, MutableList<Long>>()
fun beginTransaction(): Long = txnManager.begin()
fun commit(txnId: Long) {
txnManager.commit(txnId)
for (index in indexes) index.forgetTransaction(txnId)
tracker.forget(txnId)
forgetSavepoints(txnId)
}
fun abort(txnId: Long) {
txnManager.abort(txnId)
for (index in indexes) index.rollback(txnId)
for (pageId in tracker.forget(txnId)) bufferPool.invalidate(pageId)
forgetSavepoints(txnId)
}
/**
* Establishes a savepoint on [txnId]: returns a token [rollbackToSavepoint] can later undo back to,
* capturing every open index's current root pointer alongside the write-ahead log position so a
* later rollback can undo B+Tree structural changes (splits, merges, shrinks) too, not just page
* bytes.
*/
fun savepoint(txnId: Long): Long {
val lsn = txnManager.currentLsn(txnId)
for (index in indexes) index.snapshotRootAt(lsn)
savepointLsnsByTxn.getOrPut(txnId) { mutableListOf() }.add(lsn)
return lsn
}
/**
* Undoes every write [txnId] made after [savepointLsn] (from [savepoint]) without resolving the
* transaction - it remains open, holds every lock it already had, and can be written to, committed,
* fully rolled back, or rolled back to another savepoint afterward. Unlike [abort], the savepoint
* itself is not forgotten: [restoreRootAt]-style rollback to the same savepoint again later is safe
* because [kestrel.store.wal.RecoveryManager.rollbackTo] rewrites the undo chain with compensation
* records rather than physically deleting anything.
*/
fun rollbackToSavepoint(txnId: Long, savepointLsn: Long) {
txnManager.rollbackTo(txnId, savepointLsn)
for (index in indexes) index.restoreRootAt(savepointLsn)
for (pageId in tracker.peek(txnId)) bufferPool.invalidate(pageId)
}
/** Drops a savepoint that can no longer be rolled back to (RELEASE, or superseded by an earlier rollback). */
fun discardSavepoint(txnId: Long, savepointLsn: Long) {
for (index in indexes) index.discardSavepoint(savepointLsn)
savepointLsnsByTxn[txnId]?.remove(savepointLsn)
}
private fun forgetSavepoints(txnId: Long) {
val lsns = savepointLsnsByTxn.remove(txnId) ?: return
for (lsn in lsns) for (index in indexes) index.discardSavepoint(lsn)
}
fun createHeap(): TransactionalHeap = TransactionalHeap.create(bufferPool, txnManager, tracker)
fun openHeap(headerPageId: Long): TransactionalHeap = TransactionalHeap.open(bufferPool, txnManager, tracker, headerPageId)
fun createIndex(): TransactionalIndex {
val index = TransactionalIndex.create(bufferPool, txnManager, tracker, allocateResourceId())
indexes.add(index)
return index
}
fun openIndex(rootPageId: Long): TransactionalIndex {
val index = TransactionalIndex.open(bufferPool, txnManager, tracker, allocateResourceId(), rootPageId)
indexes.add(index)
return index
}
/** Writes every dirty cached page back to the data file without closing the engine. */
fun flush() = bufferPool.flushAll()
/** Total pages ever allocated in the backing data file, including ones now sitting on the free list. */
fun allocatedPageCount(): Long = diskManager.allocatedPageCount()
/**
* Allocates a page dedicated to content that is never covered by [beginTransaction]/[commit]/
* [abort] - see [writeRawPage]'s own doc for why [kestrel.store.sql.DiskCatalog] needs that for
* a SEQUENCE's live counter. Never reused as a heap or index page: [DiskManager] hands out page
* ids from one shared counter, so a raw page can never collide with one [createHeap]/[createIndex]
* allocates.
*/
fun allocateRawPage(): Long = diskManager.allocatePage()
/**
* Writes [bytes] (padded with zeros to a full page) straight to [diskManager], bypassing the
* buffer pool, write-ahead log and lock manager entirely - deliberately not participatory in
* [commit]/[abort] undo. [kestrel.store.sql.DiskCatalog] uses this exclusively for a SEQUENCE's
* NEXTVAL counter, whose whole point - like Postgres' own sequences - is to keep handing out
* values that never repeat even across an aborted transaction, so it must survive a ROLLBACK
* that would otherwise undo it if it were written through the normal transactional path. A page
* allocated by [allocateRawPage] is never fetched through [bufferPool] by anything else, so there
* is no cache to invalidate here the way [BufferPool.invalidate] does for [RecoveryManager]'s own
* direct writes.
*/
fun writeRawPage(pageId: Long, bytes: ByteArray) {
require(bytes.size <= Page.PAGE_SIZE) { "raw page payload of ${bytes.size} bytes exceeds the ${Page.PAGE_SIZE}-byte page size" }
val page = Page(pageId)
System.arraycopy(bytes, 0, page.data, 0, bytes.size)
diskManager.writePage(page)
}
/** Reads back a page written by [writeRawPage], straight from [diskManager]. */
fun readRawPage(pageId: Long): ByteArray = diskManager.readPage(pageId).data
/**
* Frees a page allocated by [allocateRawPage] back to the shared [DiskManager] free list - used
* by `DROP SEQUENCE` once its counter page will never be read again. Bypasses the buffer pool
* like every other raw-page operation: nothing ever caches a raw page through [bufferPool], so
* there is no frame to invalidate the way [BufferPool.deletePage] would for a heap or index page.
*/
fun freeRawPage(pageId: Long) = diskManager.freePage(pageId)
override fun close() {
bufferPool.flushAll()
wal.close()
diskManager.close()
}
/** Negative so it never collides with a page id, which [DiskManager] hands out starting at zero. */
private fun allocateResourceId(): Long = -(++nextResourceId)
}
@@ -0,0 +1,25 @@
package kestrel.store.engine
/**
* Remembers every page id a still-active transaction has touched across
* however many [TransactionalHeap] or [TransactionalIndex] operations it
* performs, so [StorageEngine.abort] can invalidate the buffer pool's cached
* copies of exactly those pages once the underlying rollback has restored
* their before-images on disk.
*/
class TouchedPageTracker {
private val pagesByTxn = HashMap<Long, MutableSet<Long>>()
fun record(txnId: Long, pageId: Long) {
pagesByTxn.getOrPut(txnId) { LinkedHashSet() }.add(pageId)
}
fun forget(txnId: Long): Set<Long> = pagesByTxn.remove(txnId) ?: emptySet()
/**
* Every page [txnId] has touched so far, without forgetting them - used after a savepoint
* rollback, which (unlike [forget]'s callers) does not resolve the transaction, so pages touched
* before the savepoint may still be written again later in the same transaction.
*/
fun peek(txnId: Long): Set<Long> = pagesByTxn[txnId] ?: emptySet()
}
@@ -0,0 +1,87 @@
package kestrel.store.engine
import kestrel.store.heap.HeapFile
import kestrel.store.heap.RecordId
import kestrel.store.page.PageStore
import kestrel.store.txn.TransactionManager
/**
* A [HeapFile] made crash-safe and transactional: every write acquires an
* exclusive lock on [headerPageId] (the whole table is one lock resource -
* a deliberately coarse granularity, ahead of page-level locking) and is
* wrapped in a fresh [LoggingPageStore] per call so its page mutations reach
* the write-ahead log before [TransactionManager.commit] makes them durable.
* Reads take a shared lock on the same resource, so a reader never observes
* a partially applied write from another transaction under strict 2PL.
*/
class TransactionalHeap private constructor(
private val bufferPool: PageStore,
private val txnManager: TransactionManager,
private val tracker: TouchedPageTracker,
val headerPageId: Long
) {
companion object {
fun create(bufferPool: PageStore, txnManager: TransactionManager, tracker: TouchedPageTracker): TransactionalHeap {
val heap = HeapFile.create(bufferPool)
return TransactionalHeap(bufferPool, txnManager, tracker, heap.headerPageId)
}
fun open(
bufferPool: PageStore,
txnManager: TransactionManager,
tracker: TouchedPageTracker,
headerPageId: Long
): TransactionalHeap = TransactionalHeap(bufferPool, txnManager, tracker, headerPageId)
}
fun insert(txnId: Long, bytes: ByteArray): RecordId {
txnManager.acquireExclusive(txnId, headerPageId)
val logging = loggingStoreFor(txnId)
val rid = HeapFile.open(logging, headerPageId).insert(bytes)
logging.finish()
return rid
}
fun delete(txnId: Long, rid: RecordId) {
txnManager.acquireExclusive(txnId, headerPageId)
val logging = loggingStoreFor(txnId)
HeapFile.open(logging, headerPageId).delete(rid)
logging.finish()
}
fun update(txnId: Long, rid: RecordId, bytes: ByteArray): RecordId {
txnManager.acquireExclusive(txnId, headerPageId)
val logging = loggingStoreFor(txnId)
val newRid = HeapFile.open(logging, headerPageId).update(rid, bytes)
logging.finish()
return newRid
}
/**
* Frees every page this heap owns (header and every data page) back to the shared
* [kestrel.store.page.DiskManager] free list - used when the owning table is dropped, since
* nothing will ever open this [headerPageId] again. Not part of the crash-recoverable log the
* way [insert]/[delete]/[update] are: like [kestrel.store.btree.BPlusTree]'s own page frees on
* merge/root-shrink (see [kestrel.store.engine.LoggingPageStore.deletePage]), an abort after
* this runs cannot un-free the pages - acceptable because the sole caller ([DiskCatalog][kestrel.store.sql.DiskCatalog]'s
* `dropTable`) only calls this once the directory row removal in the same transaction has
* already succeeded, with nothing left afterward that could still fail.
*/
fun destroy(txnId: Long) {
txnManager.acquireExclusive(txnId, headerPageId)
for (pageId in HeapFile.open(bufferPool, headerPageId).allPageIds()) bufferPool.deletePage(pageId)
}
fun get(txnId: Long, rid: RecordId): ByteArray {
txnManager.acquireShared(txnId, headerPageId)
return HeapFile.open(bufferPool, headerPageId).get(rid)
}
fun scan(txnId: Long): List<Pair<RecordId, ByteArray>> {
txnManager.acquireShared(txnId, headerPageId)
return HeapFile.open(bufferPool, headerPageId).scan().toList()
}
private fun loggingStoreFor(txnId: Long): LoggingPageStore =
LoggingPageStore(bufferPool, txnManager, txnId) { pageId -> tracker.record(txnId, pageId) }
}
@@ -0,0 +1,137 @@
package kestrel.store.engine
import kestrel.store.btree.BPlusTree
import kestrel.store.page.PageStore
import kestrel.store.txn.TransactionManager
/**
* A [BPlusTree] made crash-safe and transactional, following the same
* per-call [LoggingPageStore] pattern as [TransactionalHeap]. Locking uses
* [lockResourceId] rather than the tree's own root page id: [BPlusTree.delete]
* can shrink the tree and free its former root back to the shared page free
* list, so that id can later be reallocated to an unrelated page - using it
* as a lock resource would risk two unrelated structures serializing against
* each other by coincidence. [StorageEngine] hands out a disjoint id (see
* `nextResourceId`) precisely to avoid that.
*
* A root split or shrink also moves the tree's root pointer itself, which is
* plain in-memory engine state, not something [kestrel.store.wal.WriteAheadLog]
* records - rolling a transaction back restores page *bytes* but has no way
* to know it must also restore [rootPageId]. [rollback] does that: it
* remembers, on a transaction's first write, the root as of just before that
* transaction touched the tree, and restores it on abort.
*/
class TransactionalIndex private constructor(
private val bufferPool: PageStore,
private val txnManager: TransactionManager,
private val tracker: TouchedPageTracker,
private val lockResourceId: Long,
initialRootPageId: Long
) {
companion object {
fun create(
bufferPool: PageStore,
txnManager: TransactionManager,
tracker: TouchedPageTracker,
lockResourceId: Long
): TransactionalIndex {
val tree = BPlusTree.create(bufferPool)
return TransactionalIndex(bufferPool, txnManager, tracker, lockResourceId, tree.rootPageId)
}
fun open(
bufferPool: PageStore,
txnManager: TransactionManager,
tracker: TouchedPageTracker,
lockResourceId: Long,
rootPageId: Long
): TransactionalIndex = TransactionalIndex(bufferPool, txnManager, tracker, lockResourceId, rootPageId)
}
var rootPageId: Long = initialRootPageId
private set
private val rootBeforeTxn = HashMap<Long, Long>()
private val rootAtSavepoint = HashMap<Long, Long>()
fun insert(txnId: Long, key: ByteArray, value: Long) {
txnManager.acquireExclusive(txnId, lockResourceId)
rootBeforeTxn.putIfAbsent(txnId, rootPageId)
val logging = loggingStoreFor(txnId)
val tree = BPlusTree.open(logging, rootPageId)
tree.insert(key, value)
rootPageId = tree.rootPageId
logging.finish()
}
fun delete(txnId: Long, key: ByteArray): Boolean {
txnManager.acquireExclusive(txnId, lockResourceId)
rootBeforeTxn.putIfAbsent(txnId, rootPageId)
val logging = loggingStoreFor(txnId)
val tree = BPlusTree.open(logging, rootPageId)
val found = tree.delete(key)
rootPageId = tree.rootPageId
logging.finish()
return found
}
/**
* Frees every page this tree owns back to the shared [kestrel.store.page.DiskManager] free
* list - used when the owning index (or its whole table) is dropped. See
* [TransactionalHeap.destroy]'s own doc for why this is deliberately not undo-safe: the same
* accepted tradeoff [kestrel.store.btree.BPlusTree] already makes for a merge or root-shrink's
* own page frees.
*/
fun destroy(txnId: Long) {
txnManager.acquireExclusive(txnId, lockResourceId)
for (pageId in BPlusTree.open(bufferPool, rootPageId).allPageIds()) bufferPool.deletePage(pageId)
}
fun search(txnId: Long, key: ByteArray): Long? {
txnManager.acquireShared(txnId, lockResourceId)
return BPlusTree.open(bufferPool, rootPageId).search(key)
}
fun rangeScan(
txnId: Long,
from: ByteArray? = null,
to: ByteArray? = null,
toInclusive: Boolean = false
): List<Pair<ByteArray, Long>> {
txnManager.acquireShared(txnId, lockResourceId)
return BPlusTree.open(bufferPool, rootPageId).rangeScan(from, to, toInclusive).asSequence().toList()
}
/** Restores the root pointer as of before [txnId]'s first write. Called by [StorageEngine.abort]. */
fun rollback(txnId: Long) {
rootBeforeTxn.remove(txnId)?.let { rootPageId = it }
}
/** Drops the pending rollback snapshot once [txnId] is durably resolved. Called by [StorageEngine.commit]. */
fun forgetTransaction(txnId: Long) {
rootBeforeTxn.remove(txnId)
}
/**
* Snapshots the root pointer under [savepointLsn] (a transaction's [TransactionManager.currentLsn]
* at the moment a SAVEPOINT is established), so a later [restoreRootAt] can undo whatever root
* moves a split, merge or shrink made afterward - the same problem [rootBeforeTxn] solves for a
* whole transaction, scoped to a savepoint instead. Called by [StorageEngine.savepoint].
*/
fun snapshotRootAt(savepointLsn: Long) {
rootAtSavepoint[savepointLsn] = rootPageId
}
/** Restores the root pointer captured by [snapshotRootAt]. Called by [StorageEngine.rollbackToSavepoint]. */
fun restoreRootAt(savepointLsn: Long) {
rootPageId = rootAtSavepoint.getValue(savepointLsn)
}
/** Drops a snapshot once its savepoint can no longer be rolled back to. Called by [StorageEngine.discardSavepoint]. */
fun discardSavepoint(savepointLsn: Long) {
rootAtSavepoint.remove(savepointLsn)
}
private fun loggingStoreFor(txnId: Long): LoggingPageStore =
LoggingPageStore(bufferPool, txnManager, txnId) { pageId -> tracker.record(txnId, pageId) }
}
@@ -0,0 +1,139 @@
package kestrel.store.heap
import kestrel.KestrelException
import kestrel.store.page.Page
import kestrel.store.page.PageStore
class HeapFileException(message: String) : KestrelException(message)
/**
* A heap-organized collection of variable-length records spanning a singly
* linked chain of [HeapPage]s. The chain head lives in a dedicated header
* page whose id ([headerPageId]) callers persist to reopen the same heap
* later, mirroring how [kestrel.store.btree.BPlusTree] exposes its root page
* id. Insert walks the chain looking for a page with enough free space,
* newest first, before allocating and linking a fresh page; this reuses
* space freed by deletes ahead of growing the file.
*/
class HeapFile private constructor(private val bufferPool: PageStore, val headerPageId: Long) {
companion object {
fun create(bufferPool: PageStore): HeapFile {
val header = bufferPool.newPage()
writeFirstDataPage(header, HeapPage.NO_PAGE)
bufferPool.unpinPage(header.id, true)
return HeapFile(bufferPool, header.id)
}
fun open(bufferPool: PageStore, headerPageId: Long): HeapFile = HeapFile(bufferPool, headerPageId)
private fun writeFirstDataPage(header: Page, pageId: Long) {
for (i in 0 until 8) header.data[i] = (pageId ushr (8 * (7 - i))).toByte()
header.isDirty = true
}
private fun readFirstDataPage(header: Page): Long {
var result = 0L
for (i in 0 until 8) result = (result shl 8) or (header.data[i].toLong() and 0xFF)
return result
}
}
fun insert(bytes: ByteArray): RecordId {
if (bytes.size > HeapPage.MAX_RECORD_SIZE) {
throw HeapFileException("record of ${bytes.size} bytes exceeds max ${HeapPage.MAX_RECORD_SIZE}")
}
val header = bufferPool.fetchPage(headerPageId)
val firstPageId = readFirstDataPage(header)
var scanId = firstPageId
while (scanId != HeapPage.NO_PAGE) {
val page = bufferPool.fetchPage(scanId)
val slotId = HeapPage.insert(page, bytes)
if (slotId != null) {
bufferPool.unpinPage(scanId, true)
bufferPool.unpinPage(headerPageId, false)
return RecordId(scanId, slotId)
}
val next = HeapPage.nextPageId(page)
bufferPool.unpinPage(scanId, false)
scanId = next
}
val newPage = bufferPool.newPage()
HeapPage.init(newPage, firstPageId)
val slotId = HeapPage.insert(newPage, bytes)
?: throw HeapFileException("record of ${bytes.size} bytes does not fit in an empty page")
bufferPool.unpinPage(newPage.id, true)
writeFirstDataPage(header, newPage.id)
bufferPool.unpinPage(headerPageId, true)
return RecordId(newPage.id, slotId)
}
fun get(rid: RecordId): ByteArray {
val page = bufferPool.fetchPage(rid.pageId)
try {
return HeapPage.get(page, rid.slotId)
} finally {
bufferPool.unpinPage(rid.pageId, false)
}
}
fun delete(rid: RecordId) {
val page = bufferPool.fetchPage(rid.pageId)
HeapPage.delete(page, rid.slotId)
bufferPool.unpinPage(rid.pageId, true)
}
/**
* Replaces the record at [rid]. When [bytes] fits within the slot's
* current capacity it is overwritten in place and [rid] is returned
* unchanged; otherwise the old slot is deleted and the record is
* re-inserted, possibly moving to a different page, and the new
* [RecordId] is returned. Callers that index this heap must update any
* stored [RecordId] with the return value.
*/
fun update(rid: RecordId, bytes: ByteArray): RecordId {
val page = bufferPool.fetchPage(rid.pageId)
val capacity = HeapPage.lengthOf(page, rid.slotId)
if (bytes.size <= capacity) {
HeapPage.overwrite(page, rid.slotId, bytes)
bufferPool.unpinPage(rid.pageId, true)
return rid
}
HeapPage.delete(page, rid.slotId)
bufferPool.unpinPage(rid.pageId, true)
return insert(bytes)
}
/** Every page belonging to this heap: [headerPageId] plus every page in the data-page chain. */
fun allPageIds(): List<Long> {
val ids = mutableListOf(headerPageId)
val header = bufferPool.fetchPage(headerPageId)
var pageId = readFirstDataPage(header)
bufferPool.unpinPage(headerPageId, false)
while (pageId != HeapPage.NO_PAGE) {
val page = bufferPool.fetchPage(pageId)
val next = HeapPage.nextPageId(page)
bufferPool.unpinPage(pageId, false)
ids.add(pageId)
pageId = next
}
return ids
}
/** Iterates every live record in page-chain order. Deleted slots are skipped. */
fun scan(): Sequence<Pair<RecordId, ByteArray>> = sequence {
val header = bufferPool.fetchPage(headerPageId)
var pageId = readFirstDataPage(header)
bufferPool.unpinPage(headerPageId, false)
while (pageId != HeapPage.NO_PAGE) {
val page = bufferPool.fetchPage(pageId)
val count = HeapPage.slotCount(page)
val next = HeapPage.nextPageId(page)
val liveBytes = (0 until count)
.filterNot { HeapPage.isDeleted(page, it) }
.map { RecordId(pageId, it) to HeapPage.get(page, it) }
bufferPool.unpinPage(pageId, false)
for (entry in liveBytes) yield(entry)
pageId = next
}
}
}
@@ -0,0 +1,159 @@
package kestrel.store.heap
import kestrel.KestrelException
import kestrel.store.page.Page
class HeapPageException(message: String) : KestrelException(message)
/**
* Slotted-page layout for heap-organized storage.
*
* ```
* [ header (12B) | slot directory, grows down | free space | tuple bytes, grow up ]
* ```
*
* The header holds the id of the next page in the heap's page chain, the
* slot count, and the offset where tuple bytes currently begin. Each slot
* directory entry is `[offset:u16][length:u16]`; a length of
* [TOMBSTONE_LENGTH] marks a deleted slot. Deleting a record only tombstones
* its slot so the [RecordId] stays stable; [compact] later reclaims the
* space by repacking live tuples against the end of the page.
*/
object HeapPage {
private const val HEADER_SIZE = 12
private const val SLOT_SIZE = 4
private const val TOMBSTONE_LENGTH = 0xFFFF
const val NO_PAGE = -1L
val MAX_RECORD_SIZE: Int = Page.PAGE_SIZE - HEADER_SIZE - SLOT_SIZE
fun init(page: Page, nextPageId: Long = NO_PAGE) {
page.data.fill(0)
writeLong(page, 0, nextPageId)
writeShort(page, 8, 0)
writeShort(page, 10, Page.PAGE_SIZE)
page.isDirty = true
}
fun nextPageId(page: Page): Long = readLong(page, 0)
fun setNextPageId(page: Page, pageId: Long) {
writeLong(page, 0, pageId)
page.isDirty = true
}
fun slotCount(page: Page): Int = readShort(page, 8)
fun freeSpaceOffset(page: Page): Int = readShort(page, 10)
fun freeBytes(page: Page): Int = freeSpaceOffset(page) - directoryEnd(page)
fun isDeleted(page: Page, slotId: Int): Boolean = lengthAt(page, slotId) == TOMBSTONE_LENGTH
fun lengthOf(page: Page, slotId: Int): Int {
val length = lengthAt(page, slotId)
if (length == TOMBSTONE_LENGTH) throw HeapPageException("slot $slotId is deleted")
return length
}
fun get(page: Page, slotId: Int): ByteArray {
val entry = slotEntryOffset(page, slotId)
val length = readShort(page, entry + 2)
if (length == TOMBSTONE_LENGTH) throw HeapPageException("slot $slotId is deleted")
val offset = readShort(page, entry)
return page.data.copyOfRange(offset, offset + length)
}
/** Returns the new slot id, or null if [bytes] does not fit even after [compact]. */
fun insert(page: Page, bytes: ByteArray): Int? {
require(bytes.size <= MAX_RECORD_SIZE) { "record of ${bytes.size} bytes exceeds page capacity" }
if (!fits(page, bytes.size)) {
compact(page)
if (!fits(page, bytes.size)) return null
}
val slotId = slotCount(page)
val newOffset = freeSpaceOffset(page) - bytes.size
System.arraycopy(bytes, 0, page.data, newOffset, bytes.size)
writeSlotEntry(page, slotId, newOffset, bytes.size)
writeShort(page, 8, slotId + 1)
writeShort(page, 10, newOffset)
page.isDirty = true
return slotId
}
fun delete(page: Page, slotId: Int) {
val entry = slotEntryOffset(page, slotId)
if (readShort(page, entry + 2) == TOMBSTONE_LENGTH) throw HeapPageException("slot $slotId is already deleted")
writeShort(page, entry, 0)
writeShort(page, entry + 2, TOMBSTONE_LENGTH)
page.isDirty = true
}
/** Overwrites a slot in place. [bytes] must be no larger than the slot's current length. */
fun overwrite(page: Page, slotId: Int, bytes: ByteArray) {
val entry = slotEntryOffset(page, slotId)
val length = readShort(page, entry + 2)
if (length == TOMBSTONE_LENGTH) throw HeapPageException("slot $slotId is deleted")
require(bytes.size <= length) { "overwrite of ${bytes.size} bytes must not grow slot $slotId (capacity $length)" }
val offset = readShort(page, entry)
System.arraycopy(bytes, 0, page.data, offset, bytes.size)
writeShort(page, entry + 2, bytes.size)
page.isDirty = true
}
/** Repacks live tuples against the end of the page, discarding tombstoned bytes. */
fun compact(page: Page) {
val count = slotCount(page)
val live = ArrayList<Pair<Int, ByteArray>>(count)
for (slotId in 0 until count) {
val entry = slotEntryOffset(page, slotId)
val length = readShort(page, entry + 2)
if (length == TOMBSTONE_LENGTH) continue
val offset = readShort(page, entry)
live.add(slotId to page.data.copyOfRange(offset, offset + length))
}
var writeOffset = Page.PAGE_SIZE
for ((slotId, bytes) in live) {
writeOffset -= bytes.size
System.arraycopy(bytes, 0, page.data, writeOffset, bytes.size)
writeSlotEntry(page, slotId, writeOffset, bytes.size)
}
writeShort(page, 10, writeOffset)
page.isDirty = true
}
private fun fits(page: Page, size: Int): Boolean = directoryEnd(page) + SLOT_SIZE + size <= freeSpaceOffset(page)
private fun directoryEnd(page: Page): Int = HEADER_SIZE + slotCount(page) * SLOT_SIZE
private fun lengthAt(page: Page, slotId: Int): Int = readShort(page, slotEntryOffset(page, slotId) + 2)
private fun slotEntryOffset(page: Page, slotId: Int): Int {
if (slotId !in 0 until slotCount(page)) throw HeapPageException("slot $slotId out of range")
return HEADER_SIZE + slotId * SLOT_SIZE
}
private fun writeSlotEntry(page: Page, slotId: Int, offset: Int, length: Int) {
val entry = HEADER_SIZE + slotId * SLOT_SIZE
writeShort(page, entry, offset)
writeShort(page, entry + 2, length)
}
private fun readShort(page: Page, at: Int): Int =
((page.data[at].toInt() and 0xFF) shl 8) or (page.data[at + 1].toInt() and 0xFF)
private fun writeShort(page: Page, at: Int, value: Int) {
page.data[at] = (value ushr 8).toByte()
page.data[at + 1] = value.toByte()
}
private fun readLong(page: Page, at: Int): Long {
var result = 0L
for (i in 0 until 8) result = (result shl 8) or (page.data[at + i].toLong() and 0xFF)
return result
}
private fun writeLong(page: Page, at: Int, value: Long) {
for (i in 0 until 8) page.data[at + i] = (value ushr (8 * (7 - i))).toByte()
}
}
@@ -0,0 +1,16 @@
package kestrel.store.heap
/**
* Identifies a record's slot within a heap page. Stable across compaction:
* only the slot directory entry moves, never the (pageId, slotId) pair, so a
* B+Tree entry can point at a [RecordId] without tracking page-internal
* offsets.
*/
data class RecordId(val pageId: Long, val slotId: Int) {
/** Packs into a single [Long] so it can be stored as a B+Tree leaf value. */
fun encode(): Long = (pageId shl 16) or (slotId.toLong() and 0xFFFF)
companion object {
fun decode(encoded: Long): RecordId = RecordId(encoded ushr 16, (encoded and 0xFFFF).toInt())
}
}
@@ -0,0 +1,133 @@
package kestrel.store.page
import kestrel.KestrelException
class BufferPoolExhaustedException(message: String) : KestrelException(message)
/**
* Fixed-capacity page cache in front of a [DiskManager], using clock
* (second-chance) replacement: each frame has a reference bit set on access,
* and eviction sweeps frames clearing bits before reclaiming an unpinned,
* unreferenced frame. Dirty pages are written back on eviction or explicit
* flush.
*/
class BufferPool(private val diskManager: DiskManager, private val capacity: Int) : PageStore {
init {
require(capacity > 0) { "buffer pool capacity must be positive" }
}
private val frames = arrayOfNulls<Page>(capacity)
private val referenceBit = BooleanArray(capacity)
private val pageTable = HashMap<Long, Int>()
private val freeFrames = ArrayDeque((0 until capacity).toList())
private var clockHand = 0
override fun fetchPage(pageId: Long): Page {
pageTable[pageId]?.let { idx ->
val page = frames[idx]!!
page.pinCount++
referenceBit[idx] = true
return page
}
val idx = allocateFrame()
val page = diskManager.readPage(pageId)
page.pinCount = 1
frames[idx] = page
pageTable[pageId] = idx
referenceBit[idx] = true
return page
}
override fun newPage(): Page {
val idx = allocateFrame()
val pageId = diskManager.allocatePage()
val page = Page(pageId)
page.pinCount = 1
page.isDirty = true
frames[idx] = page
pageTable[pageId] = idx
referenceBit[idx] = true
return page
}
override fun unpinPage(pageId: Long, markDirty: Boolean) {
val idx = pageTable[pageId] ?: return
val page = frames[idx]!!
if (markDirty) page.isDirty = true
if (page.pinCount > 0) page.pinCount--
}
fun flushPage(pageId: Long) {
val idx = pageTable[pageId] ?: return
flushFrame(idx)
}
fun flushAll() {
for (idx in frames.indices) {
if (frames[idx] != null) flushFrame(idx)
}
}
/** Removes a page from the cache and the backing file. Caller must not hold a pin on it. */
override fun deletePage(pageId: Long) {
dropFromCache(pageId)
diskManager.freePage(pageId)
}
/**
* Drops a page from the cache without writing it back or freeing it on
* disk, so the next [fetchPage] re-reads whatever is currently on disk.
* Used after [kestrel.store.wal.RecoveryManager] restores a before-image
* directly through the [DiskManager], bypassing this cache: without an
* invalidation the pool would keep serving the stale, rolled-back copy.
*/
fun invalidate(pageId: Long) {
dropFromCache(pageId)
}
private fun dropFromCache(pageId: Long) {
val idx = pageTable[pageId] ?: return
val page = frames[idx]!!
check(page.pinCount == 0) { "cannot drop pinned page $pageId from the cache" }
pageTable.remove(pageId)
frames[idx] = null
referenceBit[idx] = false
freeFrames.addLast(idx)
}
fun isPinned(pageId: Long): Boolean = pageTable[pageId]?.let { frames[it]?.pinCount ?: 0 > 0 } ?: false
private fun flushFrame(idx: Int) {
val page = frames[idx] ?: return
if (page.isDirty) {
diskManager.writePage(page)
page.isDirty = false
}
}
private fun allocateFrame(): Int {
if (freeFrames.isNotEmpty()) return freeFrames.removeFirst()
var sweeps = 0
val maxSweeps = 2 * capacity + 1
while (sweeps <= maxSweeps) {
val idx = clockHand
clockHand = (clockHand + 1) % capacity
val page = frames[idx]
if (page == null) return idx
if (page.pinCount > 0) {
sweeps++
continue
}
if (referenceBit[idx]) {
referenceBit[idx] = false
sweeps++
continue
}
if (page.isDirty) diskManager.writePage(page)
pageTable.remove(page.id)
frames[idx] = null
return idx
}
throw BufferPoolExhaustedException("no unpinned frame available to evict (capacity=$capacity)")
}
}
@@ -0,0 +1,132 @@
package kestrel.store.page
import kestrel.KestrelException
import kestrel.store.codec.ByteReader
import kestrel.store.codec.ByteWriter
import kestrel.store.codec.Crc32C
import java.io.File
import java.io.RandomAccessFile
class CorruptPageException(message: String) : KestrelException(message)
/**
* Owns a single heap file of fixed-size pages. Every physical slot is stored
* as `[4-byte CRC32C][PAGE_SIZE payload]` so torn or corrupted writes are
* detected on read. Slot 0 is reserved for a metadata header (page count and
* the head of the free list); logical page ids are 0-based and map to
* physical slot `id + 1`. Freed pages are threaded into a singly linked free
* list stored inline in the freed page's own bytes, so allocation reuses
* space instead of only ever growing the file.
*/
class DiskManager(private val file: File) : AutoCloseable {
companion object {
private const val MAGIC = 0x4B535452 // "KSTR"
private const val SLOT_SIZE = 4 + Page.PAGE_SIZE
private const val HEADER_SLOT = 0L
private const val NO_PAGE = -1L
}
private val raf = RandomAccessFile(file, "rw")
private var pageCount: Long
private var freeListHead: Long
init {
if (raf.length() == 0L) {
pageCount = 0
freeListHead = NO_PAGE
writeHeader()
} else {
val header = readSlot(HEADER_SLOT)
val reader = ByteReader(header)
val magic = reader.readFixedLong().toInt()
if (magic != MAGIC) throw CorruptPageException("bad header magic in ${file.path}")
pageCount = reader.readFixedLong()
freeListHead = reader.readFixedLong()
}
}
fun allocatedPageCount(): Long = pageCount
fun allocatePage(): Long {
if (freeListHead != NO_PAGE) {
val pageId = freeListHead
val payload = readSlot(physicalSlot(pageId))
freeListHead = ByteReader(payload).readFixedLong()
writeHeader()
return pageId
}
val pageId = pageCount
pageCount += 1
writeHeader()
return pageId
}
fun freePage(pageId: Long) {
val payload = ByteWriter(Page.PAGE_SIZE)
payload.writeFixedLong(freeListHead)
val padded = payload.toByteArray().copyOf(Page.PAGE_SIZE)
writeSlot(physicalSlot(pageId), padded)
freeListHead = pageId
writeHeader()
}
fun readPage(pageId: Long): Page {
require(pageId in 0 until pageCount) { "page $pageId is not allocated" }
val page = Page(pageId)
val payload = readSlot(physicalSlot(pageId))
System.arraycopy(payload, 0, page.data, 0, Page.PAGE_SIZE)
return page
}
fun writePage(page: Page) {
writeSlot(physicalSlot(page.id), page.data)
}
fun fsync() {
raf.fd.sync()
}
override fun close() {
fsync()
raf.close()
}
private fun physicalSlot(pageId: Long): Long = pageId + 1
private fun writeHeader() {
val writer = ByteWriter(Page.PAGE_SIZE)
writer.writeFixedLong(MAGIC.toLong())
writer.writeFixedLong(pageCount)
writer.writeFixedLong(freeListHead)
val padded = writer.toByteArray().copyOf(Page.PAGE_SIZE)
writeSlot(HEADER_SLOT, padded)
}
private fun writeSlot(slot: Long, payload: ByteArray) {
require(payload.size == Page.PAGE_SIZE) { "payload must be exactly ${Page.PAGE_SIZE} bytes" }
val checksum = Crc32C.of(payload)
raf.seek(slot * SLOT_SIZE)
raf.write(checksum ushr 24)
raf.write(checksum ushr 16)
raf.write(checksum ushr 8)
raf.write(checksum)
raf.write(payload)
}
private fun readSlot(slot: Long): ByteArray {
raf.seek(slot * SLOT_SIZE)
val checksumBytes = ByteArray(4)
raf.readFully(checksumBytes)
val expectedChecksum = ((checksumBytes[0].toInt() and 0xFF) shl 24) or
((checksumBytes[1].toInt() and 0xFF) shl 16) or
((checksumBytes[2].toInt() and 0xFF) shl 8) or
(checksumBytes[3].toInt() and 0xFF)
val payload = ByteArray(Page.PAGE_SIZE)
raf.readFully(payload)
val actualChecksum = Crc32C.of(payload)
if (actualChecksum != expectedChecksum) {
throw CorruptPageException("checksum mismatch at slot $slot in ${file.path}")
}
return payload
}
}
@@ -0,0 +1,17 @@
package kestrel.store.page
/** A fixed-size in-memory buffer for one on-disk page, tracked by the buffer pool. */
class Page(val id: Long) {
companion object {
const val PAGE_SIZE = 4096
}
val data: ByteArray = ByteArray(PAGE_SIZE)
var isDirty: Boolean = false
var pinCount: Int = 0
fun reset() {
data.fill(0)
isDirty = false
}
}
@@ -0,0 +1,16 @@
package kestrel.store.page
/**
* The page-level surface [kestrel.store.heap.HeapFile] and
* [kestrel.store.btree.BPlusTree] depend on, extracted from [BufferPool] so a
* transactional caller can substitute a decorator (see
* [kestrel.store.engine.LoggingPageStore]) that observes every page touched
* during an operation without either data structure knowing it is being
* logged.
*/
interface PageStore {
fun fetchPage(pageId: Long): Page
fun newPage(): Page
fun unpinPage(pageId: Long, markDirty: Boolean)
fun deletePage(pageId: Long)
}
@@ -0,0 +1,187 @@
package kestrel.store.sql
import kestrel.catalog.Column
import kestrel.catalog.ForeignKeyAction
import kestrel.catalog.ForeignKeyConstraint
import kestrel.catalog.UniqueConstraint
import kestrel.persistence.SqlWriter
import kestrel.sql.ast.Expression
import kestrel.sql.ast.ExpressionProjection
import kestrel.sql.ast.SelectStatement
import kestrel.sql.lexer.Lexer
import kestrel.sql.parser.Parser
import kestrel.store.codec.ByteReader
import kestrel.store.codec.ByteWriter
import kestrel.store.codec.CodecException
import kestrel.storage.Collation
import kestrel.storage.ColumnType
/**
* Encodes a [TableDirectoryEntry] as a self-contained byte payload so it can
* live as a single row in [DiskCatalog]'s directory heap. Unlike
* [kestrel.store.codec.TupleCodec] this is not a generic row format: it is
* shaped exactly around the fields a directory entry has, including nested
* column and index-definition lists. A column's [Column.default] and
* [Column.generated] are each stored as rendered SQL text and re-parsed through
* a throwaway `SELECT (<expr>) FROM ...` (via the same [writeDefault]/[readDefault]
* helpers - the wire shape is identical, an optional expression rendered as text)
* the same way [DiskCatalog.parseCheckExpressionText] resolves a
* [CheckConstraintEntry] - the alternative, a bespoke binary codec for
* [Expression], would have to mirror the whole AST hierarchy for what is
* always a constant-only (DEFAULT) or own-table-column-only (GENERATED)
* expression in practice. [Column.collation] itself needs no such trick - a plain byte, its
* [kestrel.storage.Collation] enum ordinal - since it is never anything but one of the three
* built-in sequences.
*/
object DirectoryEntryCodec {
private const val NO_PAGE = -1L
private const val NO_PRECISION = -1L
fun encode(entry: TableDirectoryEntry): ByteArray {
val writer = ByteWriter()
writeString(writer, entry.tableName)
writer.writeVarInt(entry.columns.size.toLong())
for (column in entry.columns) {
writeString(writer, column.name)
writeString(writer, column.type.displayName)
writer.writeByte(if (column.notNull) 1 else 0)
writer.writeByte(if (column.primaryKey) 1 else 0)
writer.writeZigZag(column.precision?.toLong() ?: NO_PRECISION)
writer.writeZigZag(column.scale?.toLong() ?: NO_PRECISION)
writeOptionalExpression(writer, column.default)
writeOptionalExpression(writer, column.generated)
writer.writeByte(column.collation.ordinal)
}
writer.writeFixedLong(entry.dataHeaderPageId)
writer.writeFixedLong(entry.primaryKeyIndexRootPageId ?: NO_PAGE)
writer.writeVarInt(entry.secondaryIndexes.size.toLong())
for (index in entry.secondaryIndexes) {
writeString(writer, index.name)
writer.writeVarInt(index.columnNames.size.toLong())
for (columnName in index.columnNames) writeString(writer, columnName)
writer.writeFixedLong(index.rootPageId)
writer.writeByte(if (index.unique) 1 else 0)
}
writer.writeVarInt(entry.compositePrimaryKey.size.toLong())
for (columnName in entry.compositePrimaryKey) writeString(writer, columnName)
writer.writeVarInt(entry.uniqueConstraints.size.toLong())
for (unique in entry.uniqueConstraints) {
writer.writeByte(if (unique.name != null) 1 else 0)
unique.name?.let { writeString(writer, it) }
writer.writeVarInt(unique.columns.size.toLong())
for (columnName in unique.columns) writeString(writer, columnName)
}
writer.writeVarInt(entry.checks.size.toLong())
for (check in entry.checks) {
writer.writeByte(if (check.name != null) 1 else 0)
check.name?.let { writeString(writer, it) }
writeString(writer, check.expressionText)
}
writer.writeVarInt(entry.foreignKeys.size.toLong())
for (fk in entry.foreignKeys) {
writer.writeByte(if (fk.name != null) 1 else 0)
fk.name?.let { writeString(writer, it) }
writer.writeVarInt(fk.columns.size.toLong())
for (columnName in fk.columns) writeString(writer, columnName)
writeString(writer, fk.referencedTable)
writer.writeVarInt(fk.referencedColumns.size.toLong())
for (columnName in fk.referencedColumns) writeString(writer, columnName)
writer.writeByte(fk.onDelete.ordinal)
writer.writeByte(fk.onUpdate.ordinal)
}
return writer.toByteArray()
}
fun decode(bytes: ByteArray): TableDirectoryEntry {
val reader = ByteReader(bytes)
val tableName = readString(reader)
val columnCount = reader.readVarInt().toInt()
val columns = ArrayList<Column>(columnCount)
repeat(columnCount) {
val name = readString(reader)
val type = ColumnType.fromKeyword(readString(reader))
?: throw CodecException("unknown column type in directory entry for table $tableName")
val notNull = reader.readByte() != 0
val primaryKey = reader.readByte() != 0
val precision = reader.readZigZag().takeIf { it != NO_PRECISION }?.toInt()
val scale = reader.readZigZag().takeIf { it != NO_PRECISION }?.toInt()
val default = readOptionalExpression(reader, name, "DEFAULT")
val generated = readOptionalExpression(reader, name, "GENERATED")
val collation = Collation.entries[reader.readByte()]
columns.add(Column(name, type, notNull, primaryKey, precision, scale, default, generated, collation))
}
val dataHeaderPageId = reader.readFixedLong()
val primaryKeyIndexRootPageId = reader.readFixedLong().takeIf { it != NO_PAGE }
val indexCount = reader.readVarInt().toInt()
val secondaryIndexes = ArrayList<SecondaryIndexEntry>(indexCount)
repeat(indexCount) {
val name = readString(reader)
val columnNameCount = reader.readVarInt().toInt()
val columnNames = ArrayList<String>(columnNameCount)
repeat(columnNameCount) { columnNames.add(readString(reader)) }
val rootPageId = reader.readFixedLong()
val unique = reader.readByte() != 0
secondaryIndexes.add(SecondaryIndexEntry(name, columnNames, rootPageId, unique))
}
val compositePrimaryKeyCount = reader.readVarInt().toInt()
val compositePrimaryKey = ArrayList<String>(compositePrimaryKeyCount)
repeat(compositePrimaryKeyCount) { compositePrimaryKey.add(readString(reader)) }
val uniqueConstraintCount = reader.readVarInt().toInt()
val uniqueConstraints = ArrayList<UniqueConstraint>(uniqueConstraintCount)
repeat(uniqueConstraintCount) {
val name = if (reader.readByte() != 0) readString(reader) else null
val columnCount = reader.readVarInt().toInt()
val columns = ArrayList<String>(columnCount)
repeat(columnCount) { columns.add(readString(reader)) }
uniqueConstraints.add(UniqueConstraint(name, columns))
}
val checkCount = reader.readVarInt().toInt()
val checks = ArrayList<CheckConstraintEntry>(checkCount)
repeat(checkCount) {
val name = if (reader.readByte() != 0) readString(reader) else null
val expressionText = readString(reader)
checks.add(CheckConstraintEntry(name, expressionText))
}
val foreignKeyCount = reader.readVarInt().toInt()
val foreignKeys = ArrayList<ForeignKeyConstraint>(foreignKeyCount)
repeat(foreignKeyCount) {
val name = if (reader.readByte() != 0) readString(reader) else null
val columnCount = reader.readVarInt().toInt()
val columns = ArrayList<String>(columnCount)
repeat(columnCount) { columns.add(readString(reader)) }
val referencedTable = readString(reader)
val referencedColumnCount = reader.readVarInt().toInt()
val referencedColumns = ArrayList<String>(referencedColumnCount)
repeat(referencedColumnCount) { referencedColumns.add(readString(reader)) }
val onDelete = ForeignKeyAction.values()[reader.readByte()]
val onUpdate = ForeignKeyAction.values()[reader.readByte()]
foreignKeys.add(ForeignKeyConstraint(columns, referencedTable, referencedColumns, onDelete, onUpdate, name))
}
return TableDirectoryEntry(
tableName, columns, dataHeaderPageId, primaryKeyIndexRootPageId, secondaryIndexes,
compositePrimaryKey, uniqueConstraints, checks, foreignKeys
)
}
private fun writeString(writer: ByteWriter, text: String) {
writer.writeLengthPrefixed(text.toByteArray(Charsets.UTF_8))
}
private fun readString(reader: ByteReader): String = String(reader.readLengthPrefixed(), Charsets.UTF_8)
private fun writeOptionalExpression(writer: ByteWriter, expression: Expression?) {
writer.writeByte(if (expression != null) 1 else 0)
expression?.let { writeString(writer, SqlWriter.expressionText(it)) }
}
private fun readOptionalExpression(reader: ByteReader, columnName: String, label: String): Expression? {
if (reader.readByte() == 0) return null
val text = readString(reader)
val statements = Parser(Lexer("SELECT ($text) FROM directory_entry_placeholder;").tokenize()).parseProgram()
val statement = statements.singleOrNull() as? SelectStatement
?: throw CodecException("corrupt $label expression for column $columnName: $text")
val projection = statement.projection as? ExpressionProjection
?: throw CodecException("corrupt $label expression for column $columnName: $text")
return projection.items.single().expression
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,201 @@
package kestrel.store.sql
import kestrel.catalog.ForeignKeyAction
import kestrel.catalog.TableSchema
import kestrel.store.heap.RecordId
import kestrel.storage.Row
import kestrel.storage.Value
/**
* Disk-backed counterpart of [kestrel.engine.ForeignKeyEnforcer]: existence checks on INSERT/UPDATE
* of the referencing (child) table, and ON DELETE/ON UPDATE actions on the referenced (parent)
* table, operating on [DiskCatalog]/[DiskTable] within an active disk transaction instead of the
* in-memory engine's `Table.rows` ArrayList. Deliberately duplicated rather than shared - same
* reasoning as every other validation [DiskExecutor] duplicates (see
* [DiskExecutor.validateUniqueDefinitions]).
*
* [validateRow]'s existence check is a full [DiskTable.scan] of the referenced table, exactly
* mirroring the in-memory engine's own full scan of `Table.rows` - a documented performance
* compromise (not correctness), same category as CTE materialization or the disk executor's UNIQUE
* constraint scan: a real index-driven point lookup here would need to first prove the referenced
* columns line up with the PRIMARY KEY or a secondary index before choosing a plan, which is scope
* this pass does not take on.
*/
internal object DiskForeignKeyEnforcer {
/**
* [uncommittedSiblings] covers a self-referencing FK (`referencedTable == schema.name`, e.g. an
* employee/manager hierarchy) inside a multi-row INSERT/UPDATE statement: those sibling rows are
* being validated as part of the very same statement and have not been written to [txn] yet, so
* without this a later row could not reference an earlier row from the same batch (or vice
* versa - order within the batch must not matter).
*
* Follows MATCH SIMPLE (the SQL default): if *any* column of the FK is NULL in this row, the
* whole constraint is skipped for that row - not just the null column.
*/
fun validateRow(
catalog: DiskCatalog,
txn: Long,
schema: TableSchema,
values: List<Value>,
uncommittedSiblings: List<Row> = emptyList()
) {
for (fk in schema.foreignKeys) {
val columnIndices = fk.columns.map { schema.columnIndex(it)!! }
val key = columnIndices.map { values[it] }
if (key.any { it is Value.NullValue }) continue
val referenced = catalog.tableOrNull(fk.referencedTable)
?: throw DiskExecutorException(
"FOREIGN KEY constraint failed: table '${fk.referencedTable}' referenced by " +
"${schema.name}(${fk.columns.joinToString(", ")}) does not exist"
)
val refIndices = fk.referencedColumns.map { referenced.schema.columnIndex(it)!! }
val siblings = if (fk.referencedTable.equals(schema.name, ignoreCase = true)) uncommittedSiblings else emptyList()
val exists = referenced.scan(txn).any { (_, row) -> refIndices.map { idx -> row.values[idx] } == key } ||
siblings.any { row -> refIndices.map { idx -> row.values[idx] } == key }
if (!exists) {
throw DiskExecutorException(
"FOREIGN KEY constraint failed: ${schema.name}(${fk.columns.joinToString(", ")}) has no " +
"matching row in ${fk.referencedTable}(${fk.referencedColumns.joinToString(", ")})"
)
}
}
}
private sealed interface CascadeStep {
data class Delete(val table: DiskTable, val rows: List<Pair<RecordId, Row>>, val next: List<CascadeStep>) : CascadeStep
data class SetToNull(val table: DiskTable, val columnIndices: List<Int>, val rows: List<Pair<RecordId, Row>>) : CascadeStep
data class SetTo(val table: DiskTable, val columnIndices: List<Int>, val rows: List<Pair<RecordId, Row>>, val values: List<Value>) : CascadeStep
}
fun cascadeDelete(catalog: DiskCatalog, txn: Long, parentTableName: String, deletedRows: List<Row>) {
applySteps(txn, planDelete(catalog, txn, parentTableName, deletedRows))
}
/**
* [referencedColumns] is the full column group whose values changed together (a PRIMARY KEY or
* UNIQUE key group) and [changedPairs] holds the old/new key tuple for every row of the parent
* table, positionally aligned with [referencedColumns] - a matching FK may declare its own
* `referencedColumns` in a different order, so [planUpdate] re-projects each tuple by name.
*/
fun cascadeUpdate(
catalog: DiskCatalog,
txn: Long,
parentTableName: String,
referencedColumns: List<String>,
changedPairs: List<Pair<List<Value>, List<Value>>>
) {
applySteps(txn, planUpdate(catalog, txn, parentTableName, referencedColumns, changedPairs))
}
private fun planDelete(catalog: DiskCatalog, txn: Long, parentTableName: String, deletedRows: List<Row>): List<CascadeStep> {
if (deletedRows.isEmpty()) return emptyList()
val parent = catalog.table(parentTableName)
val steps = mutableListOf<CascadeStep>()
for (childName in catalog.tableNames) {
val child = catalog.table(childName)
for (fk in child.schema.foreignKeys) {
if (!fk.referencedTable.equals(parentTableName, ignoreCase = true)) continue
val refIndices = fk.referencedColumns.map { parent.schema.columnIndex(it)!! }
val deletedKeys = deletedRows.map { row -> refIndices.map { row.values[it] } }.toHashSet()
val childColumnIndices = fk.columns.map { child.schema.columnIndex(it)!! }
val matches = child.scan(txn).filter { (_, row) -> childColumnIndices.map { row.values[it] } in deletedKeys }
if (matches.isEmpty()) continue
when (fk.onDelete) {
ForeignKeyAction.Cascade ->
steps.add(CascadeStep.Delete(child, matches, planDelete(catalog, txn, child.schema.name, matches.map { it.second })))
ForeignKeyAction.SetNull -> {
requireNullable(child.schema, childColumnIndices, "ON DELETE SET NULL")
steps.add(CascadeStep.SetToNull(child, childColumnIndices, matches))
}
ForeignKeyAction.Restrict, ForeignKeyAction.NoAction -> throw DiskExecutorException(
"FOREIGN KEY constraint failed: ${matches.size} row(s) in ${child.schema.name} still " +
"reference $parentTableName (use ON DELETE CASCADE or remove them first)"
)
}
}
}
return steps
}
private fun planUpdate(
catalog: DiskCatalog,
txn: Long,
parentTableName: String,
referencedColumns: List<String>,
changedPairs: List<Pair<List<Value>, List<Value>>>
): List<CascadeStep> {
val changes = changedPairs.filter { (old, new) -> old != new }
if (changes.isEmpty()) return emptyList()
val steps = mutableListOf<CascadeStep>()
for (childName in catalog.tableNames) {
val child = catalog.table(childName)
for (fk in child.schema.foreignKeys) {
if (!fk.referencedTable.equals(parentTableName, ignoreCase = true)) continue
if (fk.referencedColumns.size != referencedColumns.size) continue
// Re-project the tuple by name: this FK's referencedColumns may list the same key
// group in a different order than the one changedPairs was built from.
val positions = fk.referencedColumns.map { refCol ->
referencedColumns.indexOfFirst { it.equals(refCol, ignoreCase = true) }
}
if (positions.any { it == -1 }) continue
val childColumnIndices = fk.columns.map { child.schema.columnIndex(it)!! }
for ((oldTuple, newTuple) in changes) {
val oldKey = positions.map { oldTuple[it] }
val newKey = positions.map { newTuple[it] }
if (oldKey == newKey) continue
val matches = child.scan(txn).filter { (_, row) -> childColumnIndices.map { row.values[it] } == oldKey }
if (matches.isEmpty()) continue
when (fk.onUpdate) {
ForeignKeyAction.Cascade ->
steps.add(CascadeStep.SetTo(child, childColumnIndices, matches, newKey))
ForeignKeyAction.SetNull -> {
requireNullable(child.schema, childColumnIndices, "ON UPDATE SET NULL")
steps.add(CascadeStep.SetToNull(child, childColumnIndices, matches))
}
ForeignKeyAction.Restrict, ForeignKeyAction.NoAction -> throw DiskExecutorException(
"FOREIGN KEY constraint failed: ${matches.size} row(s) in ${child.schema.name} still " +
"reference $parentTableName (use ON UPDATE CASCADE)"
)
}
}
}
}
return steps
}
private fun applySteps(txn: Long, steps: List<CascadeStep>) {
for (step in steps) {
when (step) {
is CascadeStep.Delete -> {
for ((rid, _) in step.rows) step.table.delete(txn, rid)
applySteps(txn, step.next)
}
is CascadeStep.SetToNull -> {
val nulls = step.columnIndices.map { Value.NullValue }
for ((rid, row) in step.rows) step.table.update(txn, rid, withColumns(row, step.columnIndices, nulls))
}
is CascadeStep.SetTo -> {
for ((rid, row) in step.rows) step.table.update(txn, rid, withColumns(row, step.columnIndices, step.values))
}
}
}
}
private fun requireNullable(schema: TableSchema, columnIndices: List<Int>, action: String) {
for (columnIndex in columnIndices) {
val column = schema.columns[columnIndex]
if (column.notNull) {
throw DiskExecutorException(
"FOREIGN KEY constraint failed: cannot $action on NOT NULL column ${schema.name}.${column.name}"
)
}
}
}
private fun withColumns(row: Row, columnIndices: List<Int>, values: List<Value>): Row {
val result = row.values.toMutableList()
columnIndices.forEachIndexed { position, columnIndex -> result[columnIndex] = values[position] }
return Row(result)
}
}
@@ -0,0 +1,238 @@
package kestrel.store.sql
import kestrel.catalog.TableSchema
import kestrel.sql.ast.BinaryExpression
import kestrel.sql.ast.BinaryOperator
import kestrel.sql.ast.ColumnExpression
import kestrel.sql.ast.Expression
import kestrel.sql.ast.LiteralExpression
import kestrel.storage.Value
/**
* How [DiskExecutor] should fetch candidate rows for a WHERE predicate on a
* [DiskTable]: a primary-key point/range lookup, a non-unique secondary-index
* lookup, or (when nothing in the predicate is indexed) a full heap scan.
* Mirrors [kestrel.engine.IndexAccessPlanner]'s role for the in-memory
* engine, but returns disk-backed lookup parameters instead of row indices,
* since [DiskTable] exposes [DiskTable.findByPrimaryKey]/[DiskTable.rangeByPrimaryKey]/
* [DiskTable.findBySecondary] directly rather than an in-memory [kestrel.catalog.SortedIndex].
*
* Whichever plan is chosen only NARROWS the candidate set - [DiskExecutor]
* still evaluates the full WHERE predicate against every candidate
* afterwards, exactly like the in-memory engine's `primaryRows()` +
* `combined.filter { ... }` split. This keeps the planner safe to extend
* (it never has to prove it captured every conjunct) and correct even for a
* predicate with extra conjuncts beyond the indexed column, e.g.
* `WHERE id = 5 AND status = 'active'`.
*/
sealed class DiskAccessPlan {
data object FullScan : DiskAccessPlan()
class PrimaryKeyPoint(val key: Value) : DiskAccessPlan()
class PrimaryKeyRange(
val lower: Value?,
val lowerInclusive: Boolean,
val upper: Value?,
val upperInclusive: Boolean
) : DiskAccessPlan()
/**
* Equality on every column of a table-level composite PRIMARY KEY, e.g.
* `WHERE order_id = 1 AND line_no = 2` against `PRIMARY KEY (order_id, line_no)`. [keys] is
* ordered to match [kestrel.catalog.TableSchema.primaryKeyColumns], the order [DiskTable]'s
* composite key encoding expects. A partial match (equality on only some of the composite
* PRIMARY KEY's columns) is deliberately not planned as a prefix range scan - that scope cut
* falls back to [Secondary]/[FullScan] like any other unindexable predicate.
*/
class CompositePrimaryKeyPoint(val keys: List<Value>) : DiskAccessPlan()
/**
* Range predicate on the LAST column of a table-level composite PRIMARY KEY, after an
* equality prefix on every column before it, e.g. `WHERE order_id = 1 AND line_no > 2` against
* `PRIMARY KEY (order_id, line_no)`. [prefix] holds the equality values bound so far, in
* [kestrel.catalog.TableSchema.primaryKeyColumns] order (one shorter than the full column
* count, since the ranged column itself is excluded). Unlike [kestrel.store.sql.DiskAccessPlan.SecondaryRange],
* which may range on a column with further index columns still unconstrained after it (leaning
* on the secondary entry's own RID suffix to bound the ambiguity), a PRIMARY KEY entry has no
* such suffix - filling in [prefix] plus the range's own bound value must therefore always
* produce a COMPLETE, full-arity key, otherwise an inclusive upper bound could not distinguish
* "this exact prefix, nothing more" from every real row extending past it with further PRIMARY
* KEY columns. Ranging on any column before the last one - with columns after it left
* unconstrained - is deliberately not planned for this reason; that scope cut falls back to
* [Secondary]/[SecondaryRange]/[FullScan] like any other unindexable predicate.
*/
class CompositePrimaryKeyRange(
val prefix: List<Value>,
val lower: Value?,
val lowerInclusive: Boolean,
val upper: Value?,
val upperInclusive: Boolean
) : DiskAccessPlan()
/**
* Equality on every column of a secondary index, single-column or composite - [keys] is
* ordered to match [SecondaryIndexHandle.columnNames]. A partial match (equality on only some
* of a composite index's columns) is deliberately not planned as a prefix range scan, the same
* scope cut as [CompositePrimaryKeyPoint] - it falls back to another index or [FullScan].
*/
class Secondary(val indexName: String, val keys: List<Value>) : DiskAccessPlan()
/**
* Range predicate (`<`, `<=`, `>`, `>=`, or both bounds together) on the column right after the
* longest leading equality run of a secondary index, single-column or composite - mirrors
* [kestrel.engine.IndexAccessPlanner]'s combined "prefix + range" logic for the in-memory
* engine's [kestrel.catalog.SortedIndex]-backed indexes. [prefix] holds the equality values
* bound so far, in [SecondaryIndexHandle.columnNames] order - empty for a single-column index,
* or for a composite index whose very first column is the one being ranged (no column can be
* skipped over unbound: an index sorted by its first column first cannot narrow a range on its
* second column without first pinning the first one down). Columns past the ranged one are left
* entirely unconstrained, the same restriction [CompositePrimaryKeyPoint] documents for equality.
*/
class SecondaryRange(
val indexName: String,
val prefix: List<Value>,
val lower: Value?,
val lowerInclusive: Boolean,
val upper: Value?,
val upperInclusive: Boolean
) : DiskAccessPlan()
}
class DiskIndexAccessPlanner {
/**
* Picks the most selective single access path for [predicate] over
* [schema]'s primary key and [secondaryIndexes]. Preference order: an
* equality match on the primary key (a single-row point lookup, the most
* selective possible plan), then a range predicate on the primary key,
* then (for a table-level composite primary key) an equality prefix on
* every column but the last plus a range on that last column, then an
* equality match on any secondary index, then a prefix-equality
* (possibly empty) plus a range predicate on the column right after it for any
* secondary index, then [DiskAccessPlan.FullScan] - equality always
* outranks a range on the very same column, since a range visits at least as many
* rows as the point it would otherwise narrow to.
* A composite predicate naming more than one indexable column (e.g. both
* the primary key and an indexed column) always prefers the primary key,
* since a point lookup can never examine more rows than any other plan.
*/
fun plan(schema: TableSchema, secondaryIndexes: List<SecondaryIndexHandle>, predicate: Expression?, alias: String? = null): DiskAccessPlan {
if (predicate == null) return DiskAccessPlan.FullScan
val byColumn = LinkedHashMap<String, MutableList<Pair<BinaryOperator, Value>>>()
for (conjunct in conjuncts(predicate)) {
val simple = asColumnPredicate(conjunct, alias, schema) ?: continue
byColumn.getOrPut(simple.first) { mutableListOf() }.add(simple.second to simple.third)
}
val pkColumnName = schema.primaryKeyIndex?.let { schema.columns[it].name }
if (pkColumnName != null) {
val predicates = byColumn[pkColumnName]
if (predicates != null) {
val equal = predicates.firstOrNull { it.first == BinaryOperator.Equal }
if (equal != null) return DiskAccessPlan.PrimaryKeyPoint(equal.second)
val range = predicates.filter { it.first != BinaryOperator.Equal }
if (range.isNotEmpty()) return rangePlan(range)
}
}
val pkColumns = schema.primaryKeyColumns
if (pkColumns.size > 1) {
val equalities = pkColumns.map { name -> byColumn[name]?.firstOrNull { it.first == BinaryOperator.Equal } }
if (equalities.all { it != null }) return DiskAccessPlan.CompositePrimaryKeyPoint(equalities.map { it!!.second })
val prefix = mutableListOf<Value>()
for (name in pkColumns) {
val equal = byColumn[name]?.firstOrNull { it.first == BinaryOperator.Equal } ?: break
prefix.add(equal.second)
}
if (prefix.size == pkColumns.size - 1) {
val range = byColumn[pkColumns.last()]?.filter { it.first != BinaryOperator.Equal }.orEmpty()
if (range.isNotEmpty()) {
val bounds = rangeBounds(range)
return DiskAccessPlan.CompositePrimaryKeyRange(prefix.toList(), bounds.lower, bounds.lowerInclusive, bounds.upper, bounds.upperInclusive)
}
}
}
for (handle in secondaryIndexes) {
val equalities = handle.columnNames.map { name -> byColumn[name]?.firstOrNull { it.first == BinaryOperator.Equal } }
if (equalities.all { it != null }) return DiskAccessPlan.Secondary(handle.name, equalities.map { it!!.second })
}
for (handle in secondaryIndexes) {
val prefix = mutableListOf<Value>()
for (columnName in handle.columnNames) {
val equal = byColumn[columnName]?.firstOrNull { it.first == BinaryOperator.Equal } ?: break
prefix.add(equal.second)
}
if (prefix.size == handle.columnNames.size) continue // full equality already tried above
val nextColumn = handle.columnNames[prefix.size]
val range = byColumn[nextColumn]?.filter { it.first != BinaryOperator.Equal }.orEmpty()
if (range.isEmpty()) continue
val bounds = rangeBounds(range)
return DiskAccessPlan.SecondaryRange(handle.name, prefix.toList(), bounds.lower, bounds.lowerInclusive, bounds.upper, bounds.upperInclusive)
}
return DiskAccessPlan.FullScan
}
private class RangeBounds(val lower: Value?, val lowerInclusive: Boolean, val upper: Value?, val upperInclusive: Boolean)
private fun rangeBounds(predicates: List<Pair<BinaryOperator, Value>>): RangeBounds {
var lowerValue: Value? = null
var lowerInclusive = false
var upperValue: Value? = null
var upperInclusive = false
for ((operator, literal) in predicates) {
when (operator) {
BinaryOperator.Greater -> { lowerValue = literal; lowerInclusive = false }
BinaryOperator.GreaterEqual -> { lowerValue = literal; lowerInclusive = true }
BinaryOperator.Less -> { upperValue = literal; upperInclusive = false }
BinaryOperator.LessEqual -> { upperValue = literal; upperInclusive = true }
else -> Unit
}
}
return RangeBounds(lowerValue, lowerInclusive, upperValue, upperInclusive)
}
private fun rangePlan(predicates: List<Pair<BinaryOperator, Value>>): DiskAccessPlan {
val bounds = rangeBounds(predicates)
return DiskAccessPlan.PrimaryKeyRange(bounds.lower, bounds.lowerInclusive, bounds.upper, bounds.upperInclusive)
}
private fun asColumnPredicate(
expression: Expression,
alias: String?,
schema: TableSchema
): Triple<String, BinaryOperator, Value>? {
if (expression !is BinaryExpression) return null
val operator = expression.operator
if (operator !in RANGE_OPERATORS) return null
val left = expression.left
val right = expression.right
fun matches(column: ColumnExpression): String? {
if (column.qualifier != null && alias != null && !column.qualifier.equals(alias, ignoreCase = true)) return null
return if (schema.columnIndex(column.name) != null) column.name else null
}
if (left is ColumnExpression && right is LiteralExpression && right.value !is Value.NullValue) {
val name = matches(left) ?: return null
return Triple(name, operator, right.value)
}
if (right is ColumnExpression && left is LiteralExpression && left.value !is Value.NullValue) {
val name = matches(right) ?: return null
return Triple(name, flip(operator), left.value)
}
return null
}
private fun conjuncts(expression: Expression): List<Expression> =
if (expression is BinaryExpression && expression.operator == BinaryOperator.And) {
conjuncts(expression.left) + conjuncts(expression.right)
} else {
listOf(expression)
}
private fun flip(operator: BinaryOperator): BinaryOperator = when (operator) {
BinaryOperator.Less -> BinaryOperator.Greater
BinaryOperator.LessEqual -> BinaryOperator.GreaterEqual
BinaryOperator.Greater -> BinaryOperator.Less
BinaryOperator.GreaterEqual -> BinaryOperator.LessEqual
else -> operator
}
private companion object {
val RANGE_OPERATORS = setOf(
BinaryOperator.Equal, BinaryOperator.Less, BinaryOperator.LessEqual,
BinaryOperator.Greater, BinaryOperator.GreaterEqual
)
}
}
@@ -0,0 +1,511 @@
package kestrel.store.sql
import kestrel.KestrelException
import kestrel.catalog.TableSchema
import kestrel.engine.collationFoldedUniquenessKey
import kestrel.store.codec.ByteWriter
import kestrel.store.codec.KeyCodec
import kestrel.store.codec.TupleCodec
import kestrel.store.engine.TransactionalHeap
import kestrel.store.engine.TransactionalIndex
import kestrel.store.heap.RecordId
import kestrel.storage.Row
import kestrel.storage.Value
class DiskTableException(message: String) : KestrelException(message)
/**
* A live secondary index attached to a [DiskTable]: the B+Tree plus which column(s) it is keyed
* on - [columnNames]/[columnIndices] have more than one entry for a composite (multi-column)
* index, in the order the index was declared. [unique] mirrors a `CREATE UNIQUE INDEX` - see
* [DiskTable.rejectDuplicateUniqueSecondary].
*/
class SecondaryIndexHandle(
val name: String,
val columnNames: List<String>,
val columnIndices: List<Int>,
val index: TransactionalIndex,
val unique: Boolean = false
)
/**
* The disk-resident counterpart of [kestrel.storage.Table]: a schema-aware
* row store backed by a [TransactionalHeap] for the row bodies, an optional
* unique [TransactionalIndex] on the primary key, and any number of
* non-unique secondary [TransactionalIndex]es - all driven by the same
* [kestrel.storage.Value]/[TableSchema] types the in-memory SQL engine uses,
* so this is the storage backend a future Catalog/Executor integration would
* sit on top of, not a parallel data model needing translation.
*
* [kestrel.store.btree.BPlusTree] only ever stores one value per key, so a
* non-unique secondary index cannot key its B+Tree entries by the indexed
* value(s) alone: two rows with the same value(s) would silently overwrite
* each other. Every secondary index instead keys on `encodeKey(values) ++ rid`
* (one or more columns, via [kestrel.store.codec.KeyCodec.encodeKey] - the
* same order-preserving multi-value encoding a composite PRIMARY KEY uses), a
* composite that is unique per row (see [compositeKey]); an equality lookup
* becomes a range scan bounded by that encoding's smallest and largest possible
* suffix (see [equalRangeFor]). This is the standard way an index built on a
* unique-key structure supports duplicates (e.g. how InnoDB appends the
* primary key to secondary index entries).
*
* Every mutating operation may change a primary or secondary index's root
* page (a B+Tree split or merge) purely in the caller process's memory - see
* [kestrel.store.engine.TransactionalIndex]'s own documentation. [onRootsChanged]
* is how a [DiskTable] tells its owning [DiskCatalog] to persist that new
* root into the directory row for this table, in the same transaction as the
* data write, so a fresh process reopening the catalog later sees the
* correct, committed root rather than the one recorded at table-creation time.
*/
class DiskTable internal constructor(
schema: TableSchema,
private val heap: TransactionalHeap,
private var primaryIndex: TransactionalIndex?,
private val secondaryIndexHandles: MutableList<SecondaryIndexHandle>,
private val onRootsChanged: (txnId: Long) -> Unit
) {
/**
* Mutable so `ALTER TABLE` (see [replaceSchemaMetadata]/[renameColumnAndIndexes]/[rewriteRows])
* can swap in a new [TableSchema] after CREATE TABLE - private setter because every mutation
* needs to happen alongside bookkeeping (secondary index column realignment, directory
* persistence) that only this class and [DiskCatalog] (its sole caller for these methods,
* same module) know how to do correctly.
*/
var schema: TableSchema = schema
private set
val headerPageId: Long get() = heap.headerPageId
val secondaryIndexes: List<SecondaryIndexHandle> get() = secondaryIndexHandles.toList()
/**
* Access counters, incremented by [scan]/[findByPrimaryKey]/[rangeByPrimaryKey]/
* [findBySecondary]. Exists so a caller (chiefly [DiskIndexAccessPlanner]-driven
* code and its tests) can confirm an index lookup actually happened instead of
* a full heap scan - correctness alone cannot distinguish the two, since both
* return the same rows.
*/
var scanCount: Int = 0; private set
var pointLookupCount: Int = 0; private set
var rangeScanCount: Int = 0; private set
var secondaryLookupCount: Int = 0; private set
internal fun attachSecondaryIndex(handle: SecondaryIndexHandle) {
secondaryIndexHandles.add(handle)
}
internal fun dropSecondaryIndexByName(name: String, txnId: Long) {
val handle = secondaryIndexHandles.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: return
secondaryIndexHandles.remove(handle)
handle.index.destroy(txnId)
}
/**
* Frees this table's own storage - its heap and every attached index (primary and secondary) -
* back to the shared page free list. Called by `DROP TABLE` once the directory row itself is
* already gone, since nothing will ever reopen this [DiskTable] again.
*/
internal fun destroyStorage(txnId: Long) {
for (handle in secondaryIndexHandles) handle.index.destroy(txnId)
primaryIndex?.destroy(txnId)
heap.destroy(txnId)
}
/**
* Metadata-only schema swap used by `ALTER TABLE ... RENAME TO` (this table's own name/
* self-referencing FK) and, on an unrelated table, by `RENAME TO`/`RENAME COLUMN`/`DROP COLUMN`
* rewriting a FOREIGN KEY that points at the table being altered - none of these change this
* table's own column count or positions, so no row rewrite or index realignment is needed.
*/
internal fun replaceSchemaMetadata(newSchema: TableSchema) {
schema = newSchema
}
/**
* `ALTER TABLE ... RENAME COLUMN`: swaps in [newSchema] (column list already renamed by the
* caller) and renames [oldName] to [newName] wherever it appears in a secondary index's
* [SecondaryIndexHandle.columnNames] - the index's [SecondaryIndexHandle.columnIndices] stay
* exactly the same, since a pure rename never moves a column's position.
*/
internal fun renameColumnAndIndexes(newSchema: TableSchema, oldName: String, newName: String) {
schema = newSchema
for (i in secondaryIndexHandles.indices) {
val handle = secondaryIndexHandles[i]
if (handle.columnNames.none { it.equals(oldName, ignoreCase = true) }) continue
val renamedNames = handle.columnNames.map { if (it.equals(oldName, ignoreCase = true)) newName else it }
secondaryIndexHandles[i] = SecondaryIndexHandle(handle.name, renamedNames, handle.columnIndices, handle.index, handle.unique)
}
}
/**
* `ALTER TABLE ... ADD/DROP COLUMN`: rewrites every row's stored bytes through [transform] and
* swaps in [newSchema] (already reflecting the added/removed column). Every surviving secondary
* index's [SecondaryIndexHandle.columnIndices] is recomputed fresh from [newSchema] right after
* the swap (mirrors [kestrel.catalog.Catalog.reindexOne] always recomputing positions from
* names rather than trusting a cached one) - required for DROP COLUMN, where every column after
* the dropped one shifts down by one position; a no-op for ADD COLUMN, which only ever appends
* at the end. Reuses the ordinary [update] for each row (not a hand-rolled unindex/reindex pair)
* so a row relocated to a new [RecordId] by a growing/shrinking body - see
* [kestrel.store.heap.HeapFile.update] - keeps every index entry correctly pointed at it,
* exactly like a normal SQL `UPDATE` already guarantees. The caller is responsible for having
* already dropped (via [dropSecondaryIndexByName]) any index that depends on a column being
* removed - a survivor's columns are always still present in [newSchema] by the time this runs.
*/
internal fun rewriteRows(txnId: Long, newSchema: TableSchema, transform: (Row) -> Row) {
val existing = rawScan(txnId)
schema = newSchema
for (i in secondaryIndexHandles.indices) {
val handle = secondaryIndexHandles[i]
val realignedIndices = handle.columnNames.map {
newSchema.columnIndex(it) ?: throw DiskTableException("index ${handle.name} references unknown column $it")
}
secondaryIndexHandles[i] = SecondaryIndexHandle(handle.name, handle.columnNames, realignedIndices, handle.index, handle.unique)
}
for ((rid, row) in existing) update(txnId, rid, transform(row))
}
fun insert(txnId: Long, row: Row): RecordId {
requireArity(row)
val pkValues = primaryKeyValuesOf(row)
rejectDuplicatePrimaryKey(txnId, pkValues)
rejectDuplicateUniqueSecondary(txnId, row, excludeRid = null)
rejectDuplicateUniqueConstraints(txnId, row, excludeRid = null)
val roots = currentRoots()
val rid = heap.insert(txnId, TupleCodec.encode(row.values))
indexRow(txnId, rid, row, pkValues)
persistIfRootsChanged(txnId, roots)
return rid
}
fun get(txnId: Long, rid: RecordId): Row = Row(TupleCodec.decode(heap.get(txnId, rid)))
fun scan(txnId: Long): List<Pair<RecordId, Row>> {
scanCount++
return rawScan(txnId)
}
private fun rawScan(txnId: Long): List<Pair<RecordId, Row>> =
heap.scan(txnId).map { (rid, bytes) -> rid to Row(TupleCodec.decode(bytes)) }
fun delete(txnId: Long, rid: RecordId) {
val row = get(txnId, rid)
val roots = currentRoots()
unindexRow(txnId, rid, row)
heap.delete(txnId, rid)
persistIfRootsChanged(txnId, roots)
}
fun update(txnId: Long, rid: RecordId, newRow: Row): RecordId {
requireArity(newRow)
val oldRow = get(txnId, rid)
val newPk = primaryKeyValuesOf(newRow)
val oldPk = primaryKeyValuesOf(oldRow)
if (newPk != oldPk) rejectDuplicatePrimaryKey(txnId, newPk)
rejectDuplicateUniqueSecondary(txnId, newRow, excludeRid = rid)
rejectDuplicateUniqueConstraints(txnId, newRow, excludeRid = rid)
val roots = currentRoots()
unindexRow(txnId, rid, oldRow)
val newRid = heap.update(txnId, rid, TupleCodec.encode(newRow.values))
indexRow(txnId, newRid, newRow, newPk)
persistIfRootsChanged(txnId, roots)
return newRid
}
/** Convenience for a single-column PRIMARY KEY; delegates to the general, composite-capable overload below. */
fun findByPrimaryKey(txnId: Long, key: Value): Pair<RecordId, Row>? = findByPrimaryKey(txnId, listOf(key))
/**
* Point lookup by the table's PRIMARY KEY, whether inline-single-column or table-level
* composite - [keys] must be given in [TableSchema.primaryKeyColumns] order. [KeyCodec.encodeKey]
* concatenates each value's own order-preserving encoding, so a single-element list here
* produces byte-identical output to [KeyCodec.encode] on that same value, keeping this the
* one lookup path both PRIMARY KEY shapes share. [keys] is folded through
* [encodePrimaryKey] first, so a search literal on a `COLLATE NOCASE`/`RTRIM`-declared PRIMARY
* KEY column finds a case-/trailing-space-variant row the same way a plain `=` comparison does.
*/
fun findByPrimaryKey(txnId: Long, keys: List<Value>): Pair<RecordId, Row>? {
pointLookupCount++
val index = primaryIndex ?: throw DiskTableException("table ${schema.name} has no primary key index")
val ridBits = index.search(txnId, encodePrimaryKey(keys)) ?: return null
val rid = RecordId.decode(ridBits)
return rid to get(txnId, rid)
}
/**
* [DiskIndexAccessPlanner] plans a range against either a single-column PRIMARY KEY ([prefix]
* empty, see [DiskAccessPlan.PrimaryKeyRange]'s own doc) or the LAST column of a table-level
* composite one, after an equality prefix on every column before it ([prefix] non-empty, see
* [DiskAccessPlan.CompositePrimaryKeyRange]'s own doc) - either way, [prefix] plus [lower]/
* [upper] always names every PRIMARY KEY column, so [encodePrimaryKey]'s own per-position
* collation folding ([kestrel.engine.collationFoldedUniquenessKey]) applies directly and always
* produces a COMPLETE, full-arity key: unlike [rangeBySecondary], no RID-suffix bracketing
* trick is needed here, since a PRIMARY KEY entry is a bare `encodeKey(values)` with nothing
* after it to disambiguate.
*/
fun rangeByPrimaryKey(
txnId: Long,
lower: Value? = null,
lowerInclusive: Boolean = true,
upper: Value? = null,
upperInclusive: Boolean = true,
prefix: List<Value> = emptyList()
): List<Pair<RecordId, Row>> {
rangeScanCount++
val index = primaryIndex ?: throw DiskTableException("table ${schema.name} has no primary key index")
fun encodeBound(value: Value): ByteArray =
KeyCodec.encodeKey(collationFoldedUniquenessKey(schema, schema.primaryKeyIndices, prefix + value))
val lowerBytes = lower?.let(::encodeBound)?.let { if (lowerInclusive) it else strictlyAfter(it) }
val upperBytes = upper?.let(::encodeBound)
return index.rangeScan(txnId, lowerBytes, upperBytes, toInclusive = upperInclusive).map { (_, ridBits) ->
val rid = RecordId.decode(ridBits)
rid to get(txnId, rid)
}
}
/** Convenience for a single-column secondary index; delegates to the general, composite-capable overload below. */
fun findBySecondary(txnId: Long, indexName: String, key: Value): List<Pair<RecordId, Row>> =
findBySecondary(txnId, indexName, listOf(key))
/**
* Equality lookup on a secondary index, whether single-column or composite - [keys] must be
* given in [SecondaryIndexHandle.columnNames] order. A single-element list here produces a
* byte-identical prefix to the single-[Value] overload, keeping this the one lookup path both
* shapes share (mirrors [findByPrimaryKey]). [keys] is folded per-column through
* [equalRangeFor], so equality against a declared-collation column finds a case-/trailing-
* space-variant row instead of missing it.
*/
fun findBySecondary(txnId: Long, indexName: String, keys: List<Value>): List<Pair<RecordId, Row>> {
secondaryLookupCount++
val handle = secondaryIndexHandles.firstOrNull { it.name.equals(indexName, ignoreCase = true) }
?: throw DiskTableException("no such index: $indexName on table ${schema.name}")
val (lower, upper) = equalRangeFor(handle.columnIndices, keys)
return handle.index.rangeScan(txnId, lower, upper, toInclusive = true).map { (_, ridBits) ->
val rid = RecordId.decode(ridBits)
rid to get(txnId, rid)
}
}
/**
* [DiskIndexAccessPlanner] plans a range against a secondary index's column right after
* [prefix]'s equality run (see [kestrel.store.sql.DiskAccessPlan.SecondaryRange]'s own doc) -
* [prefix] is empty for a single-column index or a composite one ranged on its very first
* column. Unlike [rangeByPrimaryKey] (whose B+Tree entries are bare `encodeKey(values)`, no RID
* suffix, since a PRIMARY KEY is unique), a secondary index entry is `encodeKey(values) ++ rid`
* (see the class doc), so a bound naming just [lower]/[upper]'s own encoded bytes would sit
* strictly *before* every real entry sharing that value (any real key, being longer, sorts
* after its own prefix) - reusing [equalRangeFor]'s (all-zero-suffix, all-one-suffix) pair for
* `prefix + value` sidesteps this: the all-zero-suffix bound is a valid inclusive lower
* bound for "this value or greater" (it sits at or before every RID-suffixed entry of that
* exact value), and the all-one-suffix bound is a valid inclusive upper bound for "this value
* or less" (it sits at or after every RID-suffixed entry of that exact value) - swapped for the
* opposite (exclusive) side of each bound, so e.g. `> X` starts scanning right after the last
* possible entry of exactly `X`. When [prefix] is non-empty and one side has no explicit bound
* (e.g. `WHERE a = 5 AND b < 10`, no lower bound on `b`), that side falls back to
* [equalRangeFor]'s own bound for [prefix] alone, so the scan still stays within the `a = 5`
* group instead of running off into rows from a smaller/larger `a` - [prefix]'s own encoding is
* a byte-string prefix of every real entry sharing it (each value's encoding is self-delimited),
* so this bound is exact regardless of how many values [prefix] holds relative to the index's
* full column count.
*/
fun rangeBySecondary(
txnId: Long,
indexName: String,
prefix: List<Value> = emptyList(),
lower: Value? = null,
lowerInclusive: Boolean = true,
upper: Value? = null,
upperInclusive: Boolean = true
): List<Pair<RecordId, Row>> {
rangeScanCount++
val handle = secondaryIndexHandles.firstOrNull { it.name.equals(indexName, ignoreCase = true) }
?: throw DiskTableException("no such index: $indexName on table ${schema.name}")
val prefixBounds = if (prefix.isNotEmpty()) equalRangeFor(handle.columnIndices, prefix) else null
val lowerBytes = if (lower != null) {
val (inclusiveBound, exclusiveBound) = equalRangeFor(handle.columnIndices, prefix + lower)
if (lowerInclusive) inclusiveBound else exclusiveBound
} else {
prefixBounds?.first
}
val upperBytes = if (upper != null) {
val (exclusiveBound, inclusiveBound) = equalRangeFor(handle.columnIndices, prefix + upper)
if (upperInclusive) inclusiveBound else exclusiveBound
} else {
prefixBounds?.second
}
return handle.index.rangeScan(txnId, lowerBytes, upperBytes, toInclusive = true).map { (_, ridBits) ->
val rid = RecordId.decode(ridBits)
rid to get(txnId, rid)
}
}
/**
* When [handle] is [SecondaryIndexHandle.unique], a table with pre-existing duplicate values
* in that column must fail cleanly (like `CREATE UNIQUE INDEX` does in the in-memory engine)
* rather than silently building an index that lies about uniqueness - [seen] catches that
* before any B+Tree entry is written, folded through each column's declared `COLLATE` (see
* [collationFoldedUniquenessKey]) so e.g. two pre-existing rows `'Bob'`/`'bob'` under a
* `NOCASE`-declared column are caught here just like [rejectDuplicateUniqueConstraints]
* already catches them for a declarative `UNIQUE`. The B+Tree entry itself ([compositeKey])
* is ALSO folded per-column through the same declared collation, so a value inserted *after*
* this backfill is caught too, by [rejectDuplicateUniqueSecondary]'s own range scan.
*/
internal fun backfillSecondaryIndex(txnId: Long, handle: SecondaryIndexHandle) {
val seen = if (handle.unique) HashSet<List<Value>>() else null
for ((rid, row) in scan(txnId)) {
val values = handle.columnIndices.map { row.values[it] }
if (values.any { it.isNull }) continue
if (seen != null && !seen.add(collationFoldedUniquenessKey(schema, handle.columnIndices, values))) {
throw DiskTableException("UNIQUE constraint failed: ${schema.name}.${handle.columnNames.joinToString(", ")}")
}
handle.index.insert(txnId, compositeKey(handle.columnIndices, values, rid), rid.encode())
}
}
fun snapshotDirectoryEntry(): TableDirectoryEntry = TableDirectoryEntry(
tableName = schema.name,
columns = schema.columns,
dataHeaderPageId = heap.headerPageId,
primaryKeyIndexRootPageId = primaryIndex?.rootPageId,
secondaryIndexes = secondaryIndexHandles.map { SecondaryIndexEntry(it.name, it.columnNames, it.index.rootPageId, it.unique) },
compositePrimaryKey = schema.compositePrimaryKey,
uniqueConstraints = schema.uniqueConstraints,
checks = schema.checks.map { CheckConstraintEntry.from(it) }
)
private fun indexRow(txnId: Long, rid: RecordId, row: Row, pkValues: List<Value>?) {
primaryIndex?.let { index -> if (pkValues != null && pkValues.none { it.isNull }) index.insert(txnId, encodePrimaryKey(pkValues), rid.encode()) }
for (handle in secondaryIndexHandles) {
val values = handle.columnIndices.map { row.values[it] }
if (values.none { it.isNull }) handle.index.insert(txnId, compositeKey(handle.columnIndices, values, rid), rid.encode())
}
}
private fun unindexRow(txnId: Long, rid: RecordId, row: Row) {
primaryIndex?.let { index ->
val pkValues = primaryKeyValuesOf(row)
if (pkValues != null && pkValues.none { it.isNull }) index.delete(txnId, encodePrimaryKey(pkValues))
}
for (handle in secondaryIndexHandles) {
val values = handle.columnIndices.map { row.values[it] }
if (values.none { it.isNull }) handle.index.delete(txnId, compositeKey(handle.columnIndices, values, rid))
}
}
private fun rejectDuplicatePrimaryKey(txnId: Long, pkValues: List<Value>?) {
val index = primaryIndex ?: return
if (pkValues == null || pkValues.any { it.isNull }) return
if (index.search(txnId, encodePrimaryKey(pkValues)) != null) {
throw DiskTableException("UNIQUE constraint failed: ${schema.name}.${schema.primaryKeyColumns.joinToString(", ")}")
}
}
/**
* Rejects [row] if any [SecondaryIndexHandle.unique] index would end up with two rows sharing
* a non-null tuple of values across its column(s). Searches the index directly (like
* [rejectDuplicatePrimaryKey]) rather than through [findBySecondary], so this internal check
* never inflates [secondaryLookupCount]. [excludeRid] is the row being updated (so it does not
* collide with its own prior entry); `null` on insert. [equalRangeFor] folds [values] per
* column through its declared collation, so this range scan is collation-aware exactly like
* the B+Tree entries [compositeKey] wrote in the first place.
*/
private fun rejectDuplicateUniqueSecondary(txnId: Long, row: Row, excludeRid: RecordId?) {
for (handle in secondaryIndexHandles) {
if (!handle.unique) continue
val values = handle.columnIndices.map { row.values[it] }
if (values.any { it.isNull }) continue
val (lower, upper) = equalRangeFor(handle.columnIndices, values)
val conflict = handle.index.rangeScan(txnId, lower, upper, toInclusive = true).any { (_, ridBits) ->
RecordId.decode(ridBits) != excludeRid
}
if (conflict) throw DiskTableException("UNIQUE constraint failed: ${schema.name}.${handle.columnNames.joinToString(", ")}")
}
}
/**
* [TableSchema.uniqueConstraints] have no backing index (unlike a `CREATE UNIQUE INDEX`), so
* they are checked with a full heap scan per write - the same approach [kestrel.engine.Executor]
* uses for the in-memory engine, and the only one that generalizes to a constraint spanning more
* than one column without inventing a composite-key index format. A row where any constrained
* column is NULL is exempt, per standard SQL (NULLs never collide with each other). Every
* comparison is folded through [collationFoldedUniquenessKey], so this scan - unlike
* [rejectDuplicateUniqueSecondary]'s B+Tree range scan - is fully collation-correct on every
* call, not just at index-creation time: there is no backing structure whose physical byte
* order would need to change to keep up.
*/
private fun rejectDuplicateUniqueConstraints(txnId: Long, row: Row, excludeRid: RecordId?) {
if (schema.uniqueConstraints.isEmpty()) return
val rows = rawScan(txnId)
for (unique in schema.uniqueConstraints) {
val indices = unique.columns.map { schema.columnIndex(it)!! }
val rawKey = indices.map { row.values[it] }
if (rawKey.any { it.isNull }) continue
val key = collationFoldedUniquenessKey(schema, indices, rawKey)
val conflict = rows.any { (candidateRid, candidateRow) ->
candidateRid != excludeRid && collationFoldedUniquenessKey(schema, indices, indices.map { candidateRow.values[it] }) == key
}
if (conflict) {
val label = unique.name?.let { " $it" } ?: ""
throw DiskTableException("UNIQUE constraint$label failed: ${schema.name}(${unique.columns.joinToString(", ")})")
}
}
}
/** `null` when the table has no PRIMARY KEY at all; one value per [TableSchema.primaryKeyColumns] otherwise. */
private fun primaryKeyValuesOf(row: Row): List<Value>? {
val indices = schema.primaryKeyIndices
if (indices.isEmpty()) return null
return indices.map { row.values[it] }
}
private fun requireArity(row: Row) {
if (row.values.size != schema.columns.size) {
throw DiskTableException("table ${schema.name} expects ${schema.columns.size} values but got ${row.values.size}")
}
for ((index, column) in schema.columns.withIndex()) {
if (column.notNull && row.values[index].isNull) {
throw DiskTableException("NOT NULL constraint failed: ${schema.name}.${column.name}")
}
}
}
private fun currentRoots(): List<Long> =
listOfNotNull(primaryIndex?.rootPageId) + secondaryIndexHandles.map { it.index.rootPageId }
private fun persistIfRootsChanged(txnId: Long, before: List<Long>) {
if (before != currentRoots()) onRootsChanged(txnId)
}
/**
* A secondary index's B+Tree key is `encodeKey(values) ++ rid` (see the class doc) - [values]
* is folded per-position through [columnIndices]' own declared collation first (mirrors
* [encodePrimaryKey]), so a `NOCASE`/`RTRIM`-declared column's index physically groups
* case-/trailing-space-variant values together instead of ordering/comparing them by raw bytes.
*/
private fun compositeKey(columnIndices: List<Int>, values: List<Value>, rid: RecordId): ByteArray =
ByteWriter().writeBytes(KeyCodec.encodeKey(collationFoldedUniquenessKey(schema, columnIndices, values)))
.writeFixedLong(rid.encode()).toByteArray()
private fun equalRangeFor(columnIndices: List<Int>, values: List<Value>): Pair<ByteArray, ByteArray> {
val prefix = KeyCodec.encodeKey(collationFoldedUniquenessKey(schema, columnIndices, values))
val lower = ByteWriter().writeBytes(prefix).writeFixedLong(0L).toByteArray()
val upper = ByteWriter().writeBytes(prefix).writeFixedLong(-1L).toByteArray()
return lower to upper
}
/**
* [KeyCodec.encodeKey] on [values] (in [TableSchema.primaryKeyColumns] order) after folding
* each position through that column's own declared collation - shared by every PRIMARY KEY
* B+Tree operation ([indexRow]/[unindexRow]/[findByPrimaryKey]/[rejectDuplicatePrimaryKey]) so
* insertion and lookup always agree on what bytes a given value encodes to.
*/
private fun encodePrimaryKey(values: List<Value>): ByteArray =
KeyCodec.encodeKey(collationFoldedUniquenessKey(schema, schema.primaryKeyIndices, values))
/**
* Smallest key ordered strictly after [bytes]. Correct for the fixed-width
* encodings (INTEGER, REAL, BOOLEAN): appending a byte cannot be
* overtaken by another same-type key, since two distinct fixed-width
* encodings always differ within their common, unextended length. TEXT
* range queries should prefer an inclusive lower bound instead.
*/
private fun strictlyAfter(bytes: ByteArray): ByteArray = bytes + byteArrayOf(0)
}
@@ -0,0 +1,21 @@
package kestrel.store.sql
/**
* The durable, on-disk description of one SEQUENCE's static configuration - mirrors
* [kestrel.catalog.SequenceDefinition] field-for-field, plus [positionPageId]: the raw page (see
* [kestrel.store.engine.StorageEngine.allocateRawPage]) that holds its live counter. Deliberately
* split from the counter itself for the same reason [kestrel.catalog.Sequence] separates
* [kestrel.catalog.SequenceDefinition] from its own mutable `value`/`called` fields - CREATE/DROP/
* ALTER SEQUENCE go through the normal transactional directory heap (see [DiskCatalog]) so they roll
* back like any other DDL, but a NEXTVAL advance must never be undone by a ROLLBACK, so it lives on
* its own page written outside any transaction (see [StorageEngine.writeRawPage][kestrel.store.engine.StorageEngine.writeRawPage]).
*/
data class SequenceDirectoryEntry(
val name: String,
val incrementBy: Long,
val minValue: Long,
val maxValue: Long,
val startValue: Long,
val cycle: Boolean,
val positionPageId: Long
)
@@ -0,0 +1,38 @@
package kestrel.store.sql
import kestrel.store.codec.ByteReader
import kestrel.store.codec.ByteWriter
/**
* Encodes a [SequenceDirectoryEntry] as a self-contained byte payload, mirroring
* [DirectoryEntryCodec]/[ViewDirectoryEntryCodec]. Every numeric field uses [ByteWriter.writeZigZag]
* rather than [ByteWriter.writeFixedLong] because MINVALUE/MAXVALUE/START WITH routinely hold
* negative values (a descending sequence's default MAXVALUE is `-1`, and either bound can be
* [Long.MIN_VALUE]/[Long.MAX_VALUE]) - zigzag keeps the common case (small increments, page ids)
* compact while still round-tripping the full `Long` range.
*/
object SequenceDirectoryEntryCodec {
fun encode(entry: SequenceDirectoryEntry): ByteArray {
val writer = ByteWriter()
writer.writeLengthPrefixed(entry.name.toByteArray(Charsets.UTF_8))
writer.writeZigZag(entry.incrementBy)
writer.writeZigZag(entry.minValue)
writer.writeZigZag(entry.maxValue)
writer.writeZigZag(entry.startValue)
writer.writeByte(if (entry.cycle) 1 else 0)
writer.writeZigZag(entry.positionPageId)
return writer.toByteArray()
}
fun decode(bytes: ByteArray): SequenceDirectoryEntry {
val reader = ByteReader(bytes)
val name = String(reader.readLengthPrefixed(), Charsets.UTF_8)
val incrementBy = reader.readZigZag()
val minValue = reader.readZigZag()
val maxValue = reader.readZigZag()
val startValue = reader.readZigZag()
val cycle = reader.readByte() != 0
val positionPageId = reader.readZigZag()
return SequenceDirectoryEntry(name, incrementBy, minValue, maxValue, startValue, cycle, positionPageId)
}
}
@@ -0,0 +1,68 @@
package kestrel.store.sql
import kestrel.catalog.CheckConstraint
import kestrel.catalog.Column
import kestrel.catalog.ForeignKeyConstraint
import kestrel.catalog.UniqueConstraint
import kestrel.persistence.SqlWriter
/**
* One secondary index recorded against a table's directory entry - [columnNames] has more than
* one entry for a composite (multi-column) index. [unique] mirrors a `CREATE UNIQUE INDEX`:
* [DiskTable] rejects an insert/update that would give two rows the same non-null tuple of
* values across [columnNames], the same way it already does for the primary key.
*/
data class SecondaryIndexEntry(val name: String, val columnNames: List<String>, val rootPageId: Long, val unique: Boolean = false)
/**
* The durable form of a [CheckConstraint]: its expression rendered to SQL text by
* [SqlWriter.expressionText] rather than a bespoke expression codec - mirrors [ViewDirectoryEntry]
* storing a view's query as `CREATE VIEW ... AS ...;` text, for the same reason (a CHECK expression
* can nest arbitrarily deep, so a dedicated binary codec would have to mirror the whole [kestrel.sql.ast.Expression]
* hierarchy). [DiskCatalog] re-derives the [Expression][kestrel.sql.ast.Expression] on load by parsing
* it back through the same [kestrel.sql.lexer.Lexer]/[kestrel.sql.parser.Parser] pair every other SQL
* entry point uses.
*/
data class CheckConstraintEntry(val name: String?, val expressionText: String) {
companion object {
fun from(check: CheckConstraint): CheckConstraintEntry = CheckConstraintEntry(check.name, SqlWriter.expressionText(check.expression))
}
}
/**
* The durable, on-disk description of one table: its schema plus every page
* id needed to reopen its storage after a process restart. [DiskCatalog]
* keeps one of these per table in its directory heap, re-encoding it (via
* [DirectoryEntryCodec]) whenever a page id it references changes - most
* often [primaryKeyIndexRootPageId] or a [SecondaryIndexEntry.rootPageId]
* after a B+Tree split or merge moves the tree's root.
*/
data class TableDirectoryEntry(
val tableName: String,
val columns: List<Column>,
val dataHeaderPageId: Long,
val primaryKeyIndexRootPageId: Long?,
val secondaryIndexes: List<SecondaryIndexEntry>,
/**
* A table-level `PRIMARY KEY (a, b, ...)`, empty when the table instead declares its
* PRIMARY KEY inline on a single [Column] (tracked by [Column.primaryKey]) - mirrors
* [kestrel.catalog.TableSchema.compositePrimaryKey].
*/
val compositePrimaryKey: List<String> = emptyList(),
/**
* Table-level or inline `UNIQUE`/`UNIQUE (a, b, ...)` constraints, mirroring
* [kestrel.catalog.TableSchema.uniqueConstraints]. Unlike [secondaryIndexes] these are not
* backed by a B+Tree - [DiskTable] enforces them with a full heap scan per write, exactly like
* [kestrel.engine.Executor] does for the in-memory engine, which is what lets a single
* constraint span more than one column without a composite-key index format.
*/
val uniqueConstraints: List<UniqueConstraint> = emptyList(),
/** Table-level or column-level `CHECK (...)` constraints, mirroring [kestrel.catalog.TableSchema.checks]. */
val checks: List<CheckConstraintEntry> = emptyList(),
/**
* Column-level `REFERENCES ...` or table-level `FOREIGN KEY (...) REFERENCES ...` constraints,
* mirroring [kestrel.catalog.TableSchema.foreignKeys]. Stored as plain typed fields (unlike
* [CheckConstraintEntry]) since [ForeignKeyConstraint] holds no arbitrary expression to render.
*/
val foreignKeys: List<ForeignKeyConstraint> = emptyList()
)
@@ -0,0 +1,12 @@
package kestrel.store.sql
/**
* The durable, on-disk description of one trigger: its name plus the exact `CREATE TRIGGER ...;`
* text needed to rebuild its [kestrel.catalog.TriggerDefinition] after a process restart - mirrors
* [ViewDirectoryEntry]'s own reasoning: a trigger's WHEN clause and body can be arbitrarily deep AST
* (any INSERT/UPDATE/DELETE, each with its own WHERE/SET/VALUES), and [kestrel.persistence.SqlWriter.
* triggerText] already knows how to render any of that back to text, so re-parsing it with the normal
* [kestrel.sql.lexer.Lexer]/[kestrel.sql.parser.Parser] pair on [DiskCatalog.open] reuses
* already-tested machinery instead of a second, trigger-specific AST codec.
*/
data class TriggerDirectoryEntry(val triggerName: String, val statementText: String)
@@ -0,0 +1,21 @@
package kestrel.store.sql
import kestrel.store.codec.ByteReader
import kestrel.store.codec.ByteWriter
/** Encodes a [TriggerDirectoryEntry] as a self-contained byte payload, mirroring [ViewDirectoryEntryCodec]. */
object TriggerDirectoryEntryCodec {
fun encode(entry: TriggerDirectoryEntry): ByteArray {
val writer = ByteWriter()
writer.writeLengthPrefixed(entry.triggerName.toByteArray(Charsets.UTF_8))
writer.writeLengthPrefixed(entry.statementText.toByteArray(Charsets.UTF_8))
return writer.toByteArray()
}
fun decode(bytes: ByteArray): TriggerDirectoryEntry {
val reader = ByteReader(bytes)
val triggerName = String(reader.readLengthPrefixed(), Charsets.UTF_8)
val statementText = String(reader.readLengthPrefixed(), Charsets.UTF_8)
return TriggerDirectoryEntry(triggerName, statementText)
}
}
@@ -0,0 +1,12 @@
package kestrel.store.sql
/**
* The durable, on-disk description of one view: its name plus the exact `CREATE VIEW ... AS ...;`
* text needed to rebuild its [kestrel.sql.ast.QueryStatement] after a process restart. Unlike
* [TableDirectoryEntry] this stores rendered SQL text rather than a structured payload - a view's
* defining query can be arbitrarily deep AST (joins, subqueries, set operations, CTEs), and
* [kestrel.persistence.SqlWriter] already knows how to render any of that back to text, so
* re-parsing that text with the normal [kestrel.sql.lexer.Lexer]/[kestrel.sql.parser.Parser] pair
* on [DiskCatalog.open] reuses already-tested machinery instead of a second, view-specific AST codec.
*/
data class ViewDirectoryEntry(val viewName: String, val statementText: String)
@@ -0,0 +1,21 @@
package kestrel.store.sql
import kestrel.store.codec.ByteReader
import kestrel.store.codec.ByteWriter
/** Encodes a [ViewDirectoryEntry] as a self-contained byte payload, mirroring [DirectoryEntryCodec]. */
object ViewDirectoryEntryCodec {
fun encode(entry: ViewDirectoryEntry): ByteArray {
val writer = ByteWriter()
writer.writeLengthPrefixed(entry.viewName.toByteArray(Charsets.UTF_8))
writer.writeLengthPrefixed(entry.statementText.toByteArray(Charsets.UTF_8))
return writer.toByteArray()
}
fun decode(bytes: ByteArray): ViewDirectoryEntry {
val reader = ByteReader(bytes)
val viewName = String(reader.readLengthPrefixed(), Charsets.UTF_8)
val statementText = String(reader.readLengthPrefixed(), Charsets.UTF_8)
return ViewDirectoryEntry(viewName, statementText)
}
}
@@ -0,0 +1,127 @@
package kestrel.store.txn
import kestrel.KestrelException
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
/**
* A resource-level shared/exclusive lock table for strict two-phase locking:
* transactions acquire locks as they go and are expected to release
* everything at once, via [releaseAll], on commit or abort.
*
* Blocked requests are detected for deadlock *before* they wait: acquiring a
* lock builds the tentative wait-for edges from the requester to the
* resource's current holders and walks the existing wait-for graph (formed
* by every other transaction currently blocked) to see whether any of those
* holders already, transitively, waits on the requester. This avoids ever
* parking a thread that could not possibly be woken. What it does not do is
* enforce waiter ordering, so a steady stream of compatible shared requests
* can starve a waiting exclusive request - a real risk this scope accepts in
* exchange for a single shared [java.util.concurrent.locks.Condition]
* instead of a priority queue.
*/
class LockManager {
enum class LockMode { SHARED, EXCLUSIVE }
class DeadlockException(message: String) : KestrelException(message)
private data class Waiter(val txnId: Long, val mode: LockMode)
private class ResourceLock {
val holders = LinkedHashMap<Long, LockMode>()
val waiters = ArrayList<Waiter>()
}
private val guard = ReentrantLock()
private val condition = guard.newCondition()
private val resources = HashMap<Long, ResourceLock>()
private val locksHeldByTxn = HashMap<Long, MutableSet<Long>>()
fun acquireShared(txnId: Long, resourceId: Long) = acquire(txnId, resourceId, LockMode.SHARED)
fun acquireExclusive(txnId: Long, resourceId: Long) = acquire(txnId, resourceId, LockMode.EXCLUSIVE)
fun release(txnId: Long, resourceId: Long) {
guard.withLock {
val lock = resources[resourceId] ?: return
if (lock.holders.remove(txnId) != null) {
locksHeldByTxn[txnId]?.remove(resourceId)
condition.signalAll()
}
}
}
/** Releases every lock [txnId] holds. Called once, on commit or abort, under strict 2PL. */
fun releaseAll(txnId: Long) {
guard.withLock {
val held = locksHeldByTxn.remove(txnId) ?: return
for (resourceId in held) {
resources[resourceId]?.holders?.remove(txnId)
}
condition.signalAll()
}
}
/** Snapshot of current holders, e.g. for a `pg_locks`-style inspection view. */
fun holders(resourceId: Long): Map<Long, LockMode> = guard.withLock {
resources[resourceId]?.holders?.toMap() ?: emptyMap()
}
fun waiters(resourceId: Long): List<Long> = guard.withLock {
resources[resourceId]?.waiters?.map { it.txnId } ?: emptyList()
}
private fun acquire(txnId: Long, resourceId: Long, mode: LockMode) {
guard.withLock {
val lock = resources.getOrPut(resourceId) { ResourceLock() }
val current = lock.holders[txnId]
if (current == LockMode.EXCLUSIVE || current == mode) return@withLock
if (!canGrant(lock, txnId, mode)) {
val blockers = lock.holders.keys.filter { it != txnId }
if (hasCycle(txnId, blockers)) {
throw DeadlockException(
"granting $mode on resource $resourceId to txn $txnId would deadlock with $blockers"
)
}
val waiter = Waiter(txnId, mode)
lock.waiters.add(waiter)
try {
while (!canGrant(lock, txnId, mode)) {
condition.awaitUninterruptibly()
}
} finally {
lock.waiters.remove(waiter)
}
}
lock.holders[txnId] = mode
locksHeldByTxn.getOrPut(txnId) { LinkedHashSet() }.add(resourceId)
}
}
private fun canGrant(lock: ResourceLock, txnId: Long, mode: LockMode): Boolean {
val others = lock.holders.filterKeys { it != txnId }
if (others.isEmpty()) return true
return mode == LockMode.SHARED && others.values.all { it == LockMode.SHARED }
}
/** True if any txn in [from] transitively waits, via the existing wait-for graph, on [target]. */
private fun hasCycle(target: Long, from: List<Long>): Boolean {
val visited = HashSet<Long>()
val stack = ArrayDeque(from)
while (stack.isNotEmpty()) {
val current = stack.removeLast()
if (current == target) return true
if (!visited.add(current)) continue
for (lock in resources.values) {
if (lock.waiters.any { it.txnId == current }) {
for (holderTxn in lock.holders.keys) {
if (holderTxn != current) stack.addLast(holderTxn)
}
}
}
}
return false
}
}
@@ -0,0 +1,84 @@
package kestrel.store.txn
import kestrel.KestrelException
import kestrel.store.page.DiskManager
import kestrel.store.wal.RecoveryManager
import kestrel.store.wal.WriteAheadLog
class TransactionException(message: String) : KestrelException(message)
/**
* Ties [WriteAheadLog], [RecoveryManager] and [LockManager] into a single
* begin/commit/abort surface: a transaction acquires locks as it accesses
* resources, logs a before/after page image for every write, and on abort
* is unwound with the same [RecoveryManager.rollback] logic that undoes an
* unresolved transaction during crash recovery. Every lock a transaction
* holds is released in one shot on commit or abort, per strict 2PL.
*/
class TransactionManager(private val wal: WriteAheadLog, diskManager: DiskManager) {
enum class Status { ACTIVE, COMMITTED, ABORTED }
private val lockManager = LockManager()
private val recoveryManager = RecoveryManager(wal, diskManager)
private val statusByTxn = HashMap<Long, Status>()
fun begin(): Long {
val txnId = wal.beginTransaction()
statusByTxn[txnId] = Status.ACTIVE
return txnId
}
fun acquireShared(txnId: Long, resourceId: Long) {
requireActive(txnId)
lockManager.acquireShared(txnId, resourceId)
}
fun acquireExclusive(txnId: Long, resourceId: Long) {
requireActive(txnId)
lockManager.acquireExclusive(txnId, resourceId)
}
fun logUpdate(txnId: Long, pageId: Long, before: ByteArray, after: ByteArray): Long {
requireActive(txnId)
return wal.logUpdate(txnId, pageId, before, after)
}
/** The LSN a savepoint on [txnId] should capture right now - see [rollbackTo]. */
fun currentLsn(txnId: Long): Long {
requireActive(txnId)
return wal.lastLsnOf(txnId)
}
/**
* Undoes [txnId]'s updates back to a [savepointLsn] earlier captured via [currentLsn], without
* resolving the transaction - unlike [abort], it remains ACTIVE and keeps every lock it holds (2PL
* only releases locks at the transaction's own commit or abort, never at a savepoint boundary, so
* an equally-isolated concurrent transaction can't observe a savepoint rollback happening).
*/
fun rollbackTo(txnId: Long, savepointLsn: Long) {
requireActive(txnId)
recoveryManager.rollbackTo(txnId, savepointLsn)
}
fun commit(txnId: Long) {
requireActive(txnId)
wal.commit(txnId)
lockManager.releaseAll(txnId)
statusByTxn[txnId] = Status.COMMITTED
}
fun abort(txnId: Long) {
requireActive(txnId)
recoveryManager.rollback(txnId)
lockManager.releaseAll(txnId)
statusByTxn[txnId] = Status.ABORTED
}
fun statusOf(txnId: Long): Status = statusByTxn[txnId] ?: throw TransactionException("unknown transaction $txnId")
private fun requireActive(txnId: Long) {
if (statusByTxn[txnId] != Status.ACTIVE) {
throw TransactionException("transaction $txnId is not active")
}
}
}
@@ -0,0 +1,50 @@
package kestrel.store.wal
/**
* A single write-ahead log entry. [Update] carries whole-page before/after
* images rather than a delta, so redoing it is just overwriting a page with
* [Update.after]; undoing it is overwriting with [Update.before]. `prevLsn`
* threads a transaction's updates into a backward-traversable chain, the
* same role it plays in ARIES.
*/
sealed class LogRecord {
class Begin(val txnId: Long) : LogRecord()
class Commit(val txnId: Long) : LogRecord()
class Abort(val txnId: Long) : LogRecord()
class Update(
val txnId: Long,
val prevLsn: Long,
val pageId: Long,
val before: ByteArray,
val after: ByteArray
) : LogRecord()
/**
* A compensation log record (CLR), ARIES terminology for "this page was just
* restored to an earlier image by an undo". Written by [kestrel.store.wal.RecoveryManager.rollbackTo]
* for every [Update] it undoes on the way to a savepoint, so that:
* - forward redo ([kestrel.store.wal.RecoveryManager.recover]) can reproduce the undo's effect
* just like any other logged write, by reapplying [undoImage].
* - a later undo walk (a full [kestrel.store.wal.RecoveryManager.rollback], or a rollback to an
* earlier savepoint) that reaches this record never re-undoes the [Update] it compensates for;
* it jumps straight to [undoNextLsn] instead, which is that [Update]'s own `prevLsn` - the
* transaction's undo chain skips the already-undone range as if it had never happened, exactly
* the property that makes partial rollback safe to repeat and safe to crash through.
* Chains into the transaction's `prevLsn` thread the same way [Update] does, via [prevLsn] - so a
* write logged after a savepoint rollback correctly points back at this CLR, not at the undone
* [Update] it replaced.
*/
class CompensationUpdate(
val txnId: Long,
val prevLsn: Long,
val pageId: Long,
val undoImage: ByteArray,
val undoNextLsn: Long
) : LogRecord()
/** Marks a quiescent checkpoint: written only when no transaction is active. */
class Checkpoint(val activeTxnIds: List<Long>) : LogRecord()
}
@@ -0,0 +1,85 @@
package kestrel.store.wal
import kestrel.KestrelException
import kestrel.store.codec.ByteReader
import kestrel.store.codec.ByteWriter
class WalCorruptionException(message: String) : KestrelException(message)
/** Serializes [LogRecord]s to and from the byte payload stored in each log frame. */
object LogRecordCodec {
private const val TYPE_BEGIN = 1
private const val TYPE_COMMIT = 2
private const val TYPE_ABORT = 3
private const val TYPE_UPDATE = 4
private const val TYPE_CHECKPOINT = 5
private const val TYPE_COMPENSATION_UPDATE = 6
fun encode(record: LogRecord): ByteArray {
val writer = ByteWriter()
when (record) {
is LogRecord.Begin -> {
writer.writeByte(TYPE_BEGIN)
writer.writeFixedLong(record.txnId)
}
is LogRecord.Commit -> {
writer.writeByte(TYPE_COMMIT)
writer.writeFixedLong(record.txnId)
}
is LogRecord.Abort -> {
writer.writeByte(TYPE_ABORT)
writer.writeFixedLong(record.txnId)
}
is LogRecord.Update -> {
writer.writeByte(TYPE_UPDATE)
writer.writeFixedLong(record.txnId)
writer.writeFixedLong(record.prevLsn)
writer.writeFixedLong(record.pageId)
writer.writeLengthPrefixed(record.before)
writer.writeLengthPrefixed(record.after)
}
is LogRecord.Checkpoint -> {
writer.writeByte(TYPE_CHECKPOINT)
writer.writeVarInt(record.activeTxnIds.size.toLong())
for (id in record.activeTxnIds) writer.writeFixedLong(id)
}
is LogRecord.CompensationUpdate -> {
writer.writeByte(TYPE_COMPENSATION_UPDATE)
writer.writeFixedLong(record.txnId)
writer.writeFixedLong(record.prevLsn)
writer.writeFixedLong(record.pageId)
writer.writeLengthPrefixed(record.undoImage)
writer.writeFixedLong(record.undoNextLsn)
}
}
return writer.toByteArray()
}
fun decode(bytes: ByteArray): LogRecord {
val reader = ByteReader(bytes)
return when (val type = reader.readByte()) {
TYPE_BEGIN -> LogRecord.Begin(reader.readFixedLong())
TYPE_COMMIT -> LogRecord.Commit(reader.readFixedLong())
TYPE_ABORT -> LogRecord.Abort(reader.readFixedLong())
TYPE_UPDATE -> LogRecord.Update(
txnId = reader.readFixedLong(),
prevLsn = reader.readFixedLong(),
pageId = reader.readFixedLong(),
before = reader.readLengthPrefixed(),
after = reader.readLengthPrefixed()
)
TYPE_CHECKPOINT -> {
val count = reader.readVarInt().toInt()
LogRecord.Checkpoint((0 until count).map { reader.readFixedLong() })
}
TYPE_COMPENSATION_UPDATE -> LogRecord.CompensationUpdate(
txnId = reader.readFixedLong(),
prevLsn = reader.readFixedLong(),
pageId = reader.readFixedLong(),
undoImage = reader.readLengthPrefixed(),
undoNextLsn = reader.readFixedLong()
)
else -> throw WalCorruptionException("unknown log record type $type")
}
}
}
@@ -0,0 +1,163 @@
package kestrel.store.wal
import kestrel.KestrelException
import kestrel.store.page.DiskManager
import kestrel.store.page.Page
class RecoveryException(message: String) : KestrelException(message)
/**
* ARIES-lite recovery over a [WriteAheadLog] of whole-page physical images.
*
* Because every [LogRecord.Update] carries the exact after-image a page had
* once that write completed, replaying every update from the last
* checkpoint forward in LSN order reproduces the pre-crash contents exactly
* - no page-LSN comparison is needed to make redo idempotent, unlike
* classic ARIES with incremental (delta) logging. The tradeoff is a bigger
* log: every logged write carries a full page image rather than a diff.
*
* Undo walks each unresolved transaction's [LogRecord.Update] chain
* backward via `prevLsn`, restoring the before-image at each step. A crash
* partway through undoing a transaction requires re-running undo for it
* from the start on the next recovery pass ([recover]'s forward redo pass
* reproduces whatever partial undo already happened before the crash, via
* any [LogRecord.CompensationUpdate] records it wrote, and the backward undo
* pass then continues from there) rather than resuming partway through a
* still-in-flight undo.
*
* [rollbackTo] undoes only a *suffix* of a transaction's updates, back to a
* savepoint LSN captured earlier in the same transaction, and writes a
* [LogRecord.CompensationUpdate] for each [LogRecord.Update] it undoes. A
* later full [rollback] (or a rollback to an earlier savepoint) that walks
* back through a CLR does not undo it again - it follows [LogRecord.CompensationUpdate.undoNextLsn]
* straight past the range it already compensated for. This is the classic
* ARIES technique for making undo of undo safe without needing a full CLR
* chain of its own.
*
* Redo only ever targets pages already allocated in [diskManager]; page
* allocation itself is not logged in this scope, so the log can only
* restore content, not resurrect a page directory lost mid-crash.
*
* Because [rollback] restores before-images with plain physical writes
* rather than logging its own compensation records, a transaction that
* finished with an [LogRecord.Abort] before this recovery pass ran already
* has the correct (pre-write) bytes on disk. Blindly redoing every
* [LogRecord.Update] forward in LSN order - as classic ARIES redo does,
* since it always has CLRs to make that safe - would reapply that
* transaction's after-images and resurrect data it rolled back. [recover]
* therefore runs an analysis pass first to learn each transaction's final
* disposition, then skips redoing updates belonging to one that aborted.
*/
class RecoveryManager(private val wal: WriteAheadLog, private val diskManager: DiskManager) {
class Result(val redoneUpdates: Int, val rolledBackTxns: List<Long>)
fun recover(): Result {
val startLsn = if (wal.lastCheckpointLsn == WriteAheadLog.NO_LSN) 0L else wal.lastCheckpointLsn
val history = wal.readFrom(startLsn)
val aborted = HashSet<Long>()
for ((_, record) in history) {
if (record is LogRecord.Abort) aborted.add(record.txnId)
}
val activeTxns = LinkedHashMap<Long, Long>()
var redone = 0
for ((lsn, record) in history) {
when (record) {
is LogRecord.Begin -> activeTxns[record.txnId] = lsn
is LogRecord.Update -> {
activeTxns[record.txnId] = lsn
if (record.txnId !in aborted) {
applyImage(record.pageId, record.after)
redone++
}
}
is LogRecord.CompensationUpdate -> {
activeTxns[record.txnId] = lsn
if (record.txnId !in aborted) {
applyImage(record.pageId, record.undoImage)
redone++
}
}
is LogRecord.Commit -> activeTxns.remove(record.txnId)
is LogRecord.Abort -> activeTxns.remove(record.txnId)
is LogRecord.Checkpoint -> Unit
}
}
val rolledBack = activeTxns.keys.toList()
for (txnId in rolledBack) rollback(txnId, activeTxns.getValue(txnId))
diskManager.fsync()
return Result(redone, rolledBack)
}
/**
* Rolls back a transaction by walking its update chain backward from
* [fromLsn] (defaulting to the tail this [wal] instance has tracked for
* a still-live transaction) and restoring each before-image. Also used
* by [recover] to undo transactions left active by a crash.
*/
fun rollback(txnId: Long, fromLsn: Long = wal.lastLsnOf(txnId)) {
require(fromLsn != WriteAheadLog.NO_LSN) { "transaction $txnId has no recorded updates" }
var lsn = fromLsn
while (lsn != WriteAheadLog.NO_LSN) {
when (val record = wal.readAt(lsn)) {
is LogRecord.Update -> {
applyImage(record.pageId, record.before)
lsn = record.prevLsn
}
is LogRecord.CompensationUpdate -> lsn = record.undoNextLsn
is LogRecord.Begin -> lsn = WriteAheadLog.NO_LSN
else -> throw RecoveryException("unexpected record in undo chain for txn $txnId at lsn $lsn")
}
}
wal.markAborted(txnId)
}
/**
* Undoes [txnId]'s updates back to [savepointLsn] - the LSN [WriteAheadLog.lastLsnOf] reported for
* [txnId] at the moment the savepoint was established - without resolving the transaction: it
* remains active, can accumulate further writes, and can later be committed, fully rolled back, or
* rolled back to another savepoint. Each undone [LogRecord.Update] gets a
* [LogRecord.CompensationUpdate] so this is safe to call again (e.g. a second rollback to the same
* savepoint after more writes) and safe to crash through (see the class doc).
*
* Walking backward from the transaction's current tail, a [LogRecord.CompensationUpdate] already on
* the chain (written by an earlier, deeper savepoint rollback) is skipped via [LogRecord.CompensationUpdate.undoNextLsn]
* rather than undone again - the range it compensates for is already at its pre-savepoint bytes.
*/
fun rollbackTo(txnId: Long, savepointLsn: Long) {
var lsn = wal.lastLsnOf(txnId)
while (lsn != savepointLsn) {
if (lsn == WriteAheadLog.NO_LSN) {
throw RecoveryException("savepoint lsn $savepointLsn is not on transaction $txnId's undo chain")
}
when (val record = wal.readAt(lsn)) {
is LogRecord.Update -> {
applyImage(record.pageId, record.before)
wal.logCompensation(txnId, record.pageId, record.before, record.prevLsn)
lsn = record.prevLsn
}
is LogRecord.CompensationUpdate -> lsn = record.undoNextLsn
is LogRecord.Begin -> throw RecoveryException(
"savepoint lsn $savepointLsn is not on transaction $txnId's undo chain"
)
else -> throw RecoveryException("unexpected record in undo chain for txn $txnId at lsn $lsn")
}
}
}
private fun applyImage(pageId: Long, image: ByteArray) {
if (pageId >= diskManager.allocatedPageCount()) {
throw RecoveryException(
"page $pageId referenced by the log was never allocated; " +
"this recovery scope only redoes updates to already-allocated pages"
)
}
val page = Page(pageId)
System.arraycopy(image, 0, page.data, 0, image.size)
diskManager.writePage(page)
}
}
@@ -0,0 +1,172 @@
package kestrel.store.wal
import kestrel.KestrelException
import kestrel.store.codec.Crc32C
import java.io.File
import java.io.RandomAccessFile
class WalException(message: String) : KestrelException(message)
/**
* Append-only binary log file. Every record is framed as
* `[4-byte length][payload][4-byte CRC32C]`, and a record's LSN is simply
* the file offset its frame starts at, which lets [readAt] seek straight to
* a `prevLsn` during undo instead of needing an in-memory LSN index.
*
* Tracks, for transactions begun through this instance, the LSN of their
* most recent record so [logUpdate] can chain `prevLsn` and callers can look
* up a transaction's tail for rollback. Reconstructing that table for
* transactions that were active before a crash is [RecoveryManager]'s job,
* not this class's - on reopen only `nextTxnId` and [lastCheckpointLsn] are
* recovered by scanning the existing file once.
*/
class WriteAheadLog(file: File) : AutoCloseable {
companion object {
const val NO_LSN = -1L
}
private val raf = RandomAccessFile(file, "rw")
private val lastLsnByTxn = HashMap<Long, Long>()
private var nextTxnId: Long
var lastCheckpointLsn: Long = NO_LSN
private set
init {
var maxTxnId = 0L
var pos = 0L
val end = raf.length()
while (pos < end) {
val (record, next) = readFrameAt(pos)
val txnId = when (record) {
is LogRecord.Begin -> record.txnId
is LogRecord.Update -> record.txnId
is LogRecord.CompensationUpdate -> record.txnId
is LogRecord.Commit -> record.txnId
is LogRecord.Abort -> record.txnId
is LogRecord.Checkpoint -> {
lastCheckpointLsn = pos
0L
}
}
if (txnId > maxTxnId) maxTxnId = txnId
pos = next
}
nextTxnId = maxTxnId + 1
}
fun beginTransaction(): Long {
val txnId = nextTxnId++
val lsn = appendRaw(LogRecord.Begin(txnId))
lastLsnByTxn[txnId] = lsn
return txnId
}
fun logUpdate(txnId: Long, pageId: Long, before: ByteArray, after: ByteArray): Long {
val prevLsn = lastLsnByTxn[txnId] ?: throw WalException("transaction $txnId is not active")
val lsn = appendRaw(LogRecord.Update(txnId, prevLsn, pageId, before, after))
lastLsnByTxn[txnId] = lsn
return lsn
}
/**
* Logs a compensation record for an undo step taken against [txnId] (see
* [LogRecord.CompensationUpdate]) and threads it into the transaction's `prevLsn` chain exactly
* like [logUpdate] does for a forward write, so the transaction's tail always points at the most
* recent record regardless of whether it was a normal write or an undo.
*/
fun logCompensation(txnId: Long, pageId: Long, undoImage: ByteArray, undoNextLsn: Long): Long {
val prevLsn = lastLsnByTxn[txnId] ?: throw WalException("transaction $txnId is not active")
val lsn = appendRaw(LogRecord.CompensationUpdate(txnId, prevLsn, pageId, undoImage, undoNextLsn))
lastLsnByTxn[txnId] = lsn
return lsn
}
fun commit(txnId: Long): Long {
if (txnId !in lastLsnByTxn) throw WalException("transaction $txnId is not active")
val lsn = appendRaw(LogRecord.Commit(txnId))
flush()
lastLsnByTxn.remove(txnId)
return lsn
}
/** Records that [txnId] is resolved after its updates were undone. Used by [RecoveryManager]. */
fun markAborted(txnId: Long): Long {
val lsn = appendRaw(LogRecord.Abort(txnId))
flush()
lastLsnByTxn.remove(txnId)
return lsn
}
/** Requires no transaction to be active (a quiescent checkpoint). */
fun checkpoint(): Long {
check(lastLsnByTxn.isEmpty()) { "checkpoint requires no active transactions" }
val lsn = appendRaw(LogRecord.Checkpoint(emptyList()))
flush()
lastCheckpointLsn = lsn
return lsn
}
fun lastLsnOf(txnId: Long): Long = lastLsnByTxn[txnId] ?: NO_LSN
fun flush() {
raf.fd.sync()
}
/** Sequentially reads every record from [startLsn] to the end of the log. */
fun readFrom(startLsn: Long): List<Pair<Long, LogRecord>> {
val result = ArrayList<Pair<Long, LogRecord>>()
var pos = startLsn
val end = raf.length()
while (pos < end) {
val (record, next) = readFrameAt(pos)
result.add(pos to record)
pos = next
}
return result
}
fun readAt(lsn: Long): LogRecord = readFrameAt(lsn).first
override fun close() {
flush()
raf.close()
}
private fun readFrameAt(lsn: Long): Pair<LogRecord, Long> {
raf.seek(lsn)
val length = readInt()
val payload = ByteArray(length)
raf.readFully(payload)
val expected = readInt()
val actual = Crc32C.of(payload)
if (actual != expected) throw WalCorruptionException("checksum mismatch for log frame at lsn $lsn")
return LogRecordCodec.decode(payload) to raf.filePointer
}
private fun appendRaw(record: LogRecord): Long {
val payload = LogRecordCodec.encode(record)
val lsn = raf.length()
raf.seek(lsn)
writeInt(payload.size)
raf.write(payload)
writeInt(Crc32C.of(payload))
return lsn
}
private fun writeInt(value: Int) {
raf.write(value ushr 24)
raf.write(value ushr 16)
raf.write(value ushr 8)
raf.write(value)
}
private fun readInt(): Int {
val a = raf.read()
val b = raf.read()
val c = raf.read()
val d = raf.read()
if (a < 0 || b < 0 || c < 0 || d < 0) throw WalCorruptionException("truncated log frame")
return (a shl 24) or (b shl 16) or (c shl 8) or d
}
}
+231
View File
@@ -68,6 +68,13 @@ class AggregateTest {
assertEquals(listOf(text("ops"), int(120)), result.rows[1])
}
@Test
fun offsetSkipsGroupsAfterOrderingAggregatedResults() {
seed()
val result = select("SELECT dept, SUM(amount) AS total FROM sales GROUP BY dept ORDER BY dept ASC LIMIT 1 OFFSET 1")
assertEquals(listOf(listOf(text("ops"), int(120))), result.rows)
}
@Test
fun groupByMultipleColumns() {
seed()
@@ -79,6 +86,22 @@ class AggregateTest {
assertEquals(listOf(text("ops"), text("eu"), int(2)), result.rows[2])
}
@Test
fun orderByMultipleColumnsOnGroupedResults() {
seed()
val result = select(
"SELECT dept, region, SUM(amount) AS total FROM sales GROUP BY dept, region ORDER BY dept ASC, region DESC"
)
assertEquals(
listOf(
listOf(text("eng"), text("us"), int(200)),
listOf(text("eng"), text("eu"), int(100)),
listOf(text("ops"), text("eu"), int(120))
),
result.rows
)
}
@Test
fun havingFiltersGroups() {
seed()
@@ -120,4 +143,212 @@ class AggregateTest {
assertEquals(listOf(text("eng"), int(303)), result.rows[0])
assertEquals(listOf(text("ops"), int(122)), result.rows[1])
}
@Test
fun stringAggJoinsWithTheGivenSeparatorInRowOrderWhenThereIsNoOrderByClause() {
run("CREATE TABLE t (label TEXT)")
run("INSERT INTO t VALUES ('c'), ('a'), ('b')")
assertEquals(listOf(listOf(text("c, a, b"))), select("SELECT STRING_AGG(label, ', ') FROM t").rows)
}
@Test
fun stringAggOrderByInsideTheCallSortsIndependentlyOfInsertionOrder() {
run("CREATE TABLE t (label TEXT, rank INTEGER)")
run("INSERT INTO t VALUES ('c', 3), ('a', 1), ('b', 2)")
val result = select("SELECT STRING_AGG(label, '-' ORDER BY rank) FROM t")
assertEquals(listOf(listOf(text("a-b-c"))), result.rows)
}
@Test
fun stringAggOrderByDescendingReversesTheConcatenationOrder() {
run("CREATE TABLE t (label TEXT, rank INTEGER)")
run("INSERT INTO t VALUES ('a', 1), ('b', 2), ('c', 3)")
val result = select("SELECT STRING_AGG(label, '-' ORDER BY rank DESC) FROM t")
assertEquals(listOf(listOf(text("c-b-a"))), result.rows)
}
@Test
fun stringAggSkipsNullValuesButKeepsNonNullOnesInOrder() {
run("CREATE TABLE t (label TEXT)")
run("INSERT INTO t VALUES ('a'), (NULL), ('b')")
assertEquals(listOf(listOf(text("a, b"))), select("SELECT STRING_AGG(label, ', ') FROM t").rows)
}
@Test
fun stringAggOverOnlyNullValuesIsNull() {
run("CREATE TABLE t (label TEXT)")
run("INSERT INTO t VALUES (NULL), (NULL)")
assertEquals(listOf(listOf(Value.NullValue)), select("SELECT STRING_AGG(label, ', ') FROM t").rows)
}
@Test
fun stringAggOverAnEmptyTableIsNull() {
run("CREATE TABLE t (label TEXT)")
assertEquals(listOf(listOf(Value.NullValue)), select("SELECT STRING_AGG(label, ', ') FROM t").rows)
}
@Test
fun stringAggPerGroupWithGroupBy() {
seed()
val result = select("SELECT dept, STRING_AGG(region, '/' ORDER BY region) FROM sales GROUP BY dept ORDER BY dept ASC")
assertEquals(listOf(text("eng"), text("eu/us/us")), result.rows[0])
assertEquals(listOf(text("ops"), text("eu/eu")), result.rows[1])
}
@Test
fun stringAggRejectsANonTextValueArgument() {
seed()
val failure = assertFailsWith<KestrelException> { select("SELECT STRING_AGG(amount, ', ') FROM sales") }
assertEquals("function STRING_AGG requires a TEXT argument but got INTEGER", failure.message)
}
@Test
fun stringAggRejectsANonTextSeparator() {
run("CREATE TABLE t (label TEXT)")
run("INSERT INTO t VALUES ('a')")
assertFailsWith<KestrelException> { select("SELECT STRING_AGG(label, 1) FROM t") }
}
@Test
fun stringAggCannotBeUsedAsAWindowFunction() {
assertFailsWith<ParseException> {
Parser(Lexer("SELECT STRING_AGG(label, ', ') OVER () FROM t").tokenize()).parseProgram()
}
}
@Test
fun stringAggRequiresBothAValueAndASeparatorArgument() {
assertFailsWith<ParseException> {
Parser(Lexer("SELECT STRING_AGG(label) FROM t").tokenize()).parseProgram()
}
}
private fun seedDistinct() {
run("CREATE TABLE t (dept TEXT, amount INTEGER)")
run("INSERT INTO t VALUES ('eng', 100), ('eng', 100), ('eng', 200), ('ops', 100), ('ops', NULL)")
}
@Test
fun countDistinctCountsEachDistinctNonNullValueOnce() {
seedDistinct()
assertEquals(listOf(listOf(int(4))), select("SELECT COUNT(amount) FROM t").rows)
assertEquals(listOf(listOf(int(2))), select("SELECT COUNT(DISTINCT amount) FROM t").rows)
}
@Test
fun sumDistinctAddsEachDistinctValueOnce() {
seedDistinct()
assertEquals(listOf(listOf(int(500))), select("SELECT SUM(amount) FROM t").rows)
assertEquals(listOf(listOf(int(300))), select("SELECT SUM(DISTINCT amount) FROM t").rows)
}
@Test
fun avgDistinctAveragesEachDistinctValueOnce() {
seedDistinct()
assertEquals(listOf(listOf(real(125.0))), select("SELECT AVG(amount) FROM t").rows)
assertEquals(listOf(listOf(real(150.0))), select("SELECT AVG(DISTINCT amount) FROM t").rows)
}
@Test
fun minAndMaxDistinctAreUnaffectedByDeduplication() {
seedDistinct()
val result = select("SELECT MIN(DISTINCT amount) AS lo, MAX(DISTINCT amount) AS hi FROM t")
assertEquals(listOf(int(100), int(200)), result.rows[0])
}
@Test
fun countDistinctIgnoresNulls() {
seedDistinct()
assertEquals(listOf(listOf(int(0))), select("SELECT COUNT(DISTINCT amount) FROM t WHERE dept = 'nope'").rows)
}
@Test
fun countDistinctPerGroupWithGroupBy() {
seedDistinct()
val result = select("SELECT dept, COUNT(DISTINCT amount) FROM t GROUP BY dept ORDER BY dept ASC")
assertEquals(listOf(listOf(text("eng"), int(2)), listOf(text("ops"), int(1))), result.rows)
}
@Test
fun countDistinctStarAndDistinctOverAWindowAreRejectedAtParseTime() {
assertFailsWith<ParseException> { Parser(Lexer("SELECT COUNT(DISTINCT *) FROM t").tokenize()).parseProgram() }
assertFailsWith<ParseException> {
Parser(Lexer("SELECT SUM(DISTINCT amount) OVER () FROM t").tokenize()).parseProgram()
}
}
@Test
fun countStarFilterCountsOnlyRowsMatchingThePredicate() {
seedDistinct()
assertEquals(listOf(listOf(int(5))), select("SELECT COUNT(*) FROM t").rows)
assertEquals(listOf(listOf(int(1))), select("SELECT COUNT(*) FILTER (WHERE amount > 100) FROM t").rows)
}
@Test
fun countColumnFilterCombinesNullSkippingWithThePredicate() {
seedDistinct()
assertEquals(listOf(listOf(int(3))), select("SELECT COUNT(amount) FILTER (WHERE dept = 'eng') FROM t").rows)
assertEquals(listOf(listOf(int(1))), select("SELECT COUNT(amount) FILTER (WHERE dept = 'ops') FROM t").rows)
}
@Test
fun sumFilterSumsOnlyRowsMatchingThePredicate() {
seedDistinct()
assertEquals(listOf(listOf(int(400))), select("SELECT SUM(amount) FILTER (WHERE dept = 'eng') FROM t").rows)
assertEquals(listOf(listOf(int(100))), select("SELECT SUM(amount) FILTER (WHERE dept = 'ops') FROM t").rows)
}
@Test
fun avgMinMaxFilterOperateOnlyOnRowsMatchingThePredicate() {
seedDistinct()
val result = select(
"SELECT AVG(amount) FILTER (WHERE amount >= 150) AS a, " +
"MIN(amount) FILTER (WHERE dept = 'eng') AS lo, " +
"MAX(amount) FILTER (WHERE dept = 'eng') AS hi FROM t"
)
assertEquals(listOf(real(200.0), int(100), int(200)), result.rows[0])
}
@Test
fun filterExcludingEveryRowLeavesCountZeroAndSumAvgMinMaxNull() {
seedDistinct()
val result = select(
"SELECT COUNT(*) FILTER (WHERE dept = 'nope') AS c, " +
"SUM(amount) FILTER (WHERE dept = 'nope') AS s, " +
"AVG(amount) FILTER (WHERE dept = 'nope') AS a FROM t"
)
assertEquals(listOf(int(0), Value.NullValue, Value.NullValue), result.rows[0])
}
@Test
fun filterCombinesWithDistinct() {
seedDistinct()
assertEquals(
listOf(listOf(int(2))),
select("SELECT COUNT(DISTINCT amount) FILTER (WHERE dept = 'eng') FROM t").rows
)
}
@Test
fun filterAppliesIndependentlyPerAggregatePerGroup() {
seedDistinct()
val result = select(
"SELECT dept, COUNT(*) FILTER (WHERE amount > 50) AS matching, COUNT(*) AS total " +
"FROM t GROUP BY dept ORDER BY dept ASC"
)
assertEquals(
listOf(listOf(text("eng"), int(3), int(3)), listOf(text("ops"), int(1), int(2))),
result.rows
)
}
@Test
fun filterIsRejectedOnNonAggregateFunctionsAndOnWindowFunctions() {
assertFailsWith<ParseException> {
Parser(Lexer("SELECT UPPER(dept) FILTER (WHERE amount > 0) FROM t").tokenize()).parseProgram()
}
assertFailsWith<ParseException> {
Parser(Lexer("SELECT SUM(amount) FILTER (WHERE amount > 0) OVER () FROM t").tokenize()).parseProgram()
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,111 @@
package kestrel
import kestrel.catalog.Catalog
import kestrel.engine.Executor
import kestrel.engine.InsertResult
import kestrel.engine.SelectResult
import kestrel.engine.StatementResult
import kestrel.engine.UpdateResult
import kestrel.sql.lexer.Lexer
import kestrel.sql.parser.Parser
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class CheckConstraintTest {
private val catalog = Catalog()
private val executor = Executor(catalog)
private fun run(sql: String): List<StatementResult> =
Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) }
private fun exec(sql: String): StatementResult = run(sql).single()
private fun select(sql: String): SelectResult = exec(sql) as SelectResult
@Test
fun columnLevelCheckAcceptsSatisfyingRows() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, age INTEGER CHECK (age > 0))")
assertEquals(InsertResult(1), exec("INSERT INTO t VALUES (1, 30)"))
}
@Test
fun columnLevelCheckRejectsViolatingRows() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, age INTEGER CHECK (age > 0))")
val error = assertFailsWith<KestrelException> { run("INSERT INTO t VALUES (1, -5)") }
assertTrue(error.message!!.contains("CHECK"))
}
@Test
fun checkPassesWhenExpressionIsUnknownBecauseOfNull() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, age INTEGER CHECK (age > 0))")
// three-valued logic: NULL > 0 is UNKNOWN, not FALSE, so the row is accepted
assertEquals(InsertResult(1), exec("INSERT INTO t VALUES (1, NULL)"))
}
@Test
fun tableLevelCheckCanSpanMultipleColumns() {
run("CREATE TABLE ranges (id INTEGER PRIMARY KEY, low INTEGER, high INTEGER, CHECK (low <= high))")
assertEquals(InsertResult(1), exec("INSERT INTO ranges VALUES (1, 1, 10)"))
assertFailsWith<KestrelException> { run("INSERT INTO ranges VALUES (2, 10, 1)") }
}
@Test
fun multipleCheckConstraintsAllMustBeSatisfied() {
run(
"CREATE TABLE t (id INTEGER PRIMARY KEY, age INTEGER CHECK (age >= 0), " +
"score INTEGER CHECK (score BETWEEN 0 AND 100))"
)
assertEquals(InsertResult(1), exec("INSERT INTO t VALUES (1, 30, 90)"))
assertFailsWith<KestrelException> { run("INSERT INTO t VALUES (2, 30, 150)") }
assertFailsWith<KestrelException> { run("INSERT INTO t VALUES (3, -1, 90)") }
}
@Test
fun checkIsEnforcedOnUpdateToo() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, age INTEGER CHECK (age > 0))")
run("INSERT INTO t VALUES (1, 30)")
assertFailsWith<KestrelException> { run("UPDATE t SET age = -1 WHERE id = 1") }
assertEquals(UpdateResult(1), exec("UPDATE t SET age = 40 WHERE id = 1"))
}
@Test
fun checkRejectsAggregateFunctions() {
assertFailsWith<KestrelException> {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, amount INTEGER CHECK (SUM(amount) > 0))")
}
}
@Test
fun checkRejectsSubqueries() {
run("CREATE TABLE other (id INTEGER PRIMARY KEY)")
assertFailsWith<KestrelException> {
run("CREATE TABLE t (id INTEGER PRIMARY KEY CHECK (id IN (SELECT id FROM other)))")
}
}
@Test
fun checkRejectsUnknownColumn() {
assertFailsWith<KestrelException> {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, CHECK (nope > 0))")
}
}
@Test
fun checkCanReferenceStringAndCallExpressions() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT CHECK (LENGTH(name) > 0))")
assertEquals(InsertResult(1), exec("INSERT INTO t VALUES (1, 'ada')"))
assertFailsWith<KestrelException> { run("INSERT INTO t VALUES (2, '')") }
}
@Test
fun checkConstraintSurvivesRollback() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, age INTEGER CHECK (age > 0))")
run("BEGIN")
run("INSERT INTO t VALUES (1, 30)")
run("ROLLBACK")
assertFailsWith<KestrelException> { run("INSERT INTO t VALUES (1, -5)") }
assertEquals(0, select("SELECT * FROM t").rows.size)
}
}
+184
View File
@@ -0,0 +1,184 @@
package kestrel
import kestrel.catalog.Catalog
import kestrel.engine.Executor
import kestrel.engine.SelectResult
import kestrel.engine.StatementResult
import kestrel.persistence.SqlWriter
import kestrel.sql.ast.Statement
import kestrel.sql.lexer.Lexer
import kestrel.sql.parser.Parser
import kestrel.storage.Value
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* `expr COLLATE BINARY|NOCASE|RTRIM` (SQL:1999), the explicit, expression-level form - see
* [kestrel.ColumnCollationTest] for a column's own declared `COLLATE` (`CREATE TABLE ... col TYPE
* COLLATE name`), which only ever changes what a *bare* column reference compares with and is
* always overridden by an explicit annotation like the one covered here. Covers every comparison
* site [kestrel.engine.Evaluator] threads a resolved [kestrel.storage.Collation] through (`=`/`<>`/
* `<`/`<=`/`>`/`>=`, `IN`, `BETWEEN`, a searched-`CASE`'s operand match) plus every sort that shares
* [kestrel.engine.compareOrderKeyValues] (top-level ORDER BY, a window's `OVER (... ORDER BY
* ...)`), and a persistence round-trip through [SqlWriter]. [kestrel.store.sql.DiskExecutorCollateTest]
* mirrors the comparison and ORDER BY cases against the disk engine, proving the shared
* [kestrel.engine.Evaluator]/[kestrel.engine.compareOrderKeyValues] code is genuinely shared, not
* reimplemented per engine.
*/
class CollateTest {
private val catalog = Catalog()
private val executor = Executor(catalog)
private fun run(sql: String): List<StatementResult> =
Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) }
private fun parseOne(sql: String): Statement = Parser(Lexer(sql).tokenize()).parseProgram().single()
private fun select(sql: String): SelectResult = run(sql).single() as SelectResult
private fun int(value: Long) = Value.IntegerValue(value)
private fun text(value: String) = Value.TextValue(value)
private fun bool(value: Boolean) = Value.BooleanValue(value)
private fun seedNames() {
run("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT)")
run("INSERT INTO people VALUES (1, 'Bob'), (2, 'bob'), (3, 'Alice'), (4, 'alice '), (5, 'Zoe')")
}
@Test
fun equalityWithNocaseIgnoresAsciiCaseButPlainEqualityDoesNot() {
seedNames()
assertEquals(bool(true), select("SELECT name = 'BOB' COLLATE NOCASE FROM people WHERE id = 1").rows.single().single())
assertEquals(bool(false), select("SELECT name = 'BOB' FROM people WHERE id = 1").rows.single().single())
}
@Test
fun explicitCollateOnTheLeftOperandTakesPrecedenceOverTheRight() {
seedNames()
assertEquals(
bool(true),
select("SELECT name COLLATE NOCASE = 'BOB' COLLATE BINARY FROM people WHERE id = 1").rows.single().single()
)
}
@Test
fun explicitCollateOnTheRightOperandAppliesWhenTheLeftHasNone() {
seedNames()
assertEquals(bool(true), select("SELECT 'BOB' = name COLLATE NOCASE FROM people WHERE id = 1").rows.single().single())
}
@Test
fun rtrimIgnoresOnlyTrailingSpacesNotLeadingOrInteriorOnes() {
seedNames()
assertEquals(
bool(true),
select("SELECT name = 'alice' COLLATE RTRIM FROM people WHERE id = 4").rows.single().single()
)
assertEquals(
bool(false),
select("SELECT name = ' alice' COLLATE RTRIM FROM people WHERE id = 4").rows.single().single()
)
}
@Test
fun lessThanAndGreaterThanHonorNocase() {
seedNames()
assertEquals(bool(false), select("SELECT 'APPLE' COLLATE NOCASE < 'apple' FROM people WHERE id = 1").rows.single().single())
assertEquals(bool(true), select("SELECT 'APPLE' COLLATE NOCASE < 'banana' FROM people WHERE id = 1").rows.single().single())
}
@Test
fun orderByCollateNocaseInterleavesUpperAndLowerCaseNamesByAsciiFoldedOrder() {
seedNames()
val result = select("SELECT name FROM people ORDER BY name COLLATE NOCASE, id")
assertEquals(
listOf("Alice", "alice ", "Bob", "bob", "Zoe").map { listOf(text(it)) },
result.rows
)
}
@Test
fun plainOrderByWithoutCollateStaysAsciiCodepointOrder() {
seedNames()
val result = select("SELECT name FROM people ORDER BY name")
assertEquals(
listOf("Alice", "Bob", "Zoe", "alice ", "bob").map { listOf(text(it)) },
result.rows
)
}
@Test
fun windowFunctionOverOrderByHonorsAnExplicitCollateClause() {
seedNames()
val result = select(
"SELECT id, ROW_NUMBER() OVER (ORDER BY name COLLATE NOCASE) AS rn FROM people ORDER BY id"
)
assertEquals(
listOf(
listOf(int(1), int(3)), listOf(int(2), int(4)), listOf(int(3), int(1)),
listOf(int(4), int(2)), listOf(int(5), int(5))
),
result.rows
)
}
@Test
fun inListHonorsTheOperandsExplicitCollate() {
seedNames()
assertEquals(
bool(true),
select("SELECT name COLLATE NOCASE IN ('BOB', 'other') FROM people WHERE id = 1").rows.single().single()
)
assertEquals(
bool(false),
select("SELECT name IN ('BOB', 'other') FROM people WHERE id = 1").rows.single().single()
)
}
@Test
fun betweenHonorsCollateIndependentlyOnEachSide() {
run("CREATE TABLE words (id INTEGER PRIMARY KEY, w TEXT)")
run("INSERT INTO words VALUES (1, 'M')")
assertEquals(
bool(true),
select("SELECT w COLLATE NOCASE BETWEEN 'a' AND 'z' FROM words WHERE id = 1").rows.single().single()
)
assertEquals(
bool(false),
select("SELECT w BETWEEN 'a' AND 'z' FROM words WHERE id = 1").rows.single().single()
)
}
@Test
fun searchedCaseOperandMatchHonorsCollate() {
run("CREATE TABLE words (id INTEGER PRIMARY KEY, w TEXT)")
run("INSERT INTO words VALUES (1, 'YES')")
assertEquals(
listOf(listOf(text("matched"))),
select("SELECT CASE w COLLATE NOCASE WHEN 'yes' THEN 'matched' ELSE 'no' END FROM words").rows
)
assertEquals(
listOf(listOf(text("no"))),
select("SELECT CASE w WHEN 'yes' THEN 'matched' ELSE 'no' END FROM words").rows
)
}
@Test
fun collateOnANonTextValueIsAcceptedButHasNoEffect() {
run("CREATE TABLE nums (id INTEGER PRIMARY KEY, n INTEGER)")
run("INSERT INTO nums VALUES (1, 5)")
assertEquals(bool(true), select("SELECT n COLLATE NOCASE = 5 FROM nums").rows.single().single())
}
@Test
fun collateExpressionInAWhereClauseRoundTripsThroughSqlWriterAndKeepsItsComparisonBehavior() {
seedNames()
val statement = parseOne("UPDATE people SET name = 'MATCHED' WHERE name COLLATE NOCASE = 'BOB'")
val rendered = SqlWriter.write(statement)
executor.execute(parseOne(rendered))
assertEquals(
listOf(listOf(int(1), text("MATCHED")), listOf(int(2), text("MATCHED"))),
select("SELECT id, name FROM people WHERE name = 'MATCHED' ORDER BY id").rows
)
}
}
@@ -0,0 +1,639 @@
package kestrel
import kestrel.catalog.Catalog
import kestrel.engine.ExplainResult
import kestrel.engine.Executor
import kestrel.engine.SelectResult
import kestrel.engine.StatementResult
import kestrel.persistence.SqlWriter
import kestrel.sql.ast.CreateTableStatement
import kestrel.sql.ast.Statement
import kestrel.sql.lexer.Lexer
import kestrel.sql.parser.Parser
import kestrel.storage.Value
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* `col TYPE COLLATE name` - a column's own declared collating sequence (see
* [kestrel.catalog.Column.collation]), which a *bare* reference to it compares with whenever the
* query itself writes no explicit `COLLATE` - see [kestrel.CollateTest] for that explicit,
* expression-level form, which always overrides this one. Threaded through
* [kestrel.engine.ColumnResolver.declaredCollation] into every resolver built directly over a
* table's own schema: plain `WHERE`/`SELECT`/`HAVING` (via [kestrel.engine.SelectRunner]'s
* per-source lookup, including across a JOIN), `INSERT ... ON CONFLICT DO UPDATE` (EXCLUDED-aware),
* `MERGE`'s `ON`/`WHEN` clauses, and a trigger's `NEW`/`OLD`. [kestrel.store.sql.
* DiskExecutorColumnCollationTest] mirrors the core cases against the disk engine, including a
* real file close/reopen (the declared collation is persisted in [kestrel.store.sql.
* DirectoryEntryCodec], not just this engine's text dump).
*/
class ColumnCollationTest {
private val catalog = Catalog()
private val executor = Executor(catalog)
private fun run(sql: String): List<StatementResult> =
Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) }
private fun parseOne(sql: String): Statement = Parser(Lexer(sql).tokenize()).parseProgram().single()
private fun select(sql: String): SelectResult = run(sql).single() as SelectResult
private fun explain(sql: String): List<String> = (run(sql).single() as ExplainResult).plan
private fun int(value: Long) = Value.IntegerValue(value)
private fun text(value: String) = Value.TextValue(value)
private fun bool(value: Boolean) = Value.BooleanValue(value)
private fun seedNames() {
run("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)")
run("INSERT INTO people VALUES (1, 'Bob'), (2, 'bob'), (3, 'Alice'), (4, 'Zoe')")
}
@Test
fun bareEqualityHonorsTheColumnsDeclaredCollationWithoutAnExplicitClause() {
seedNames()
assertEquals(bool(true), select("SELECT name = 'BOB' FROM people WHERE id = 1").rows.single().single())
assertEquals(
listOf(int(1), int(2)),
select("SELECT id FROM people WHERE name = 'BOB' ORDER BY id").rows.map { it.single() }
)
}
@Test
fun explicitCollateInTheQueryOverridesTheColumnsDeclaredCollation() {
seedNames()
assertEquals(bool(false), select("SELECT name COLLATE BINARY = 'BOB' FROM people WHERE id = 1").rows.single().single())
}
@Test
fun explicitCollateOnTheOtherOperandStillOverridesADeclaredCollationOnThisOne() {
seedNames()
// 'BOB' COLLATE BINARY on the right wins over people.name's own declared NOCASE on the left.
assertEquals(bool(false), select("SELECT name = 'BOB' COLLATE BINARY FROM people WHERE id = 1").rows.single().single())
}
@Test
fun columnWithNoDeclaredCollationStaysBinaryByDefault() {
run("CREATE TABLE plain (id INTEGER PRIMARY KEY, name TEXT)")
run("INSERT INTO plain VALUES (1, 'Bob')")
assertEquals(bool(false), select("SELECT name = 'BOB' FROM plain WHERE id = 1").rows.single().single())
}
@Test
fun declaredCollationAppliesInAnInList() {
seedNames()
assertEquals(bool(true), select("SELECT name IN ('BOB', 'other') FROM people WHERE id = 1").rows.single().single())
}
@Test
fun declaredCollationAppliesInBetween() {
run("CREATE TABLE words (id INTEGER PRIMARY KEY, w TEXT COLLATE NOCASE)")
run("INSERT INTO words VALUES (1, 'm')")
assertEquals(bool(true), select("SELECT w BETWEEN 'A' AND 'Z' FROM words").rows.single().single())
}
@Test
fun declaredCollationAppliesToASearchedCaseOperandMatch() {
run("CREATE TABLE words (id INTEGER PRIMARY KEY, w TEXT COLLATE NOCASE)")
run("INSERT INTO words VALUES (1, 'YES')")
assertEquals(
listOf(listOf(text("matched"))),
select("SELECT CASE w WHEN 'yes' THEN 'matched' ELSE 'no' END FROM words").rows
)
}
@Test
fun declaredCollationAppliesToDefaultOrderBySortOrder() {
seedNames()
// No explicit `ORDER BY name COLLATE NOCASE` needed - the column's own declared NOCASE
// already interleaves case. 'Bob' (id 1) and 'bob' (id 2) compare equal under NOCASE, so
// the sort (stable) keeps them in their original scan-order relative position.
val result = select("SELECT name FROM people ORDER BY name")
assertEquals(listOf("Alice", "Bob", "bob", "Zoe").map { listOf(text(it)) }, result.rows)
}
@Test
fun explicitCollateInOrderByOverridesTheColumnsDeclaredCollation() {
seedNames()
val result = select("SELECT name FROM people ORDER BY name COLLATE BINARY")
assertEquals(listOf("Alice", "Bob", "Zoe", "bob").map { listOf(text(it)) }, result.rows)
}
@Test
fun declaredCollationAppliesInAWindowFunctionsOrderBy() {
seedNames()
val result = select("SELECT name, ROW_NUMBER() OVER (ORDER BY name) AS rn FROM people ORDER BY rn")
assertEquals(
listOf("Alice" to 1L, "Bob" to 2L, "bob" to 3L, "Zoe" to 4L).map { (name, rn) -> listOf(text(name), int(rn)) },
result.rows
)
}
@Test
fun declaredCollationAppliesInGroupBysOwnOrderBy() {
seedNames()
// GROUP BY itself now also dedups through name's declared NOCASE (closing the scope cut this
// test used to document), so 'Bob' and 'bob' collapse into a single group - the group keeps
// whichever row it saw first in scan order ('Bob', id 1). Only three groups remain, and the
// ordering of those groups (GroupAggregation's own ORDER BY handling) is still under test.
val result = select("SELECT name FROM people GROUP BY name ORDER BY name")
assertEquals(listOf("Alice", "Bob", "Zoe").map { listOf(text(it)) }, result.rows)
}
@Test
fun groupByDedupsCaseVariantsThroughTheColumnsDeclaredCollation() {
seedNames()
val result = select("SELECT name, COUNT(*) FROM people GROUP BY name ORDER BY name")
assertEquals(
listOf("Alice" to 1L, "Bob" to 2L, "Zoe" to 1L).map { (name, count) -> listOf(text(name), int(count)) },
result.rows
)
}
@Test
fun groupByWithNoDeclaredCollationStillDedupsByBinaryEquality() {
run("CREATE TABLE plain (id INTEGER PRIMARY KEY, name TEXT)")
run("INSERT INTO plain VALUES (1, 'Bob'), (2, 'bob')")
val result = select("SELECT name, COUNT(*) FROM plain GROUP BY name ORDER BY name")
assertEquals(listOf("Bob" to 1L, "bob" to 1L).map { (name, count) -> listOf(text(name), int(count)) }, result.rows)
}
@Test
fun explicitGroupByCollateOverridesTheColumnsDeclaredCollation() {
seedNames()
val result = select("SELECT name COLLATE BINARY, COUNT(*) FROM people GROUP BY name COLLATE BINARY ORDER BY name COLLATE BINARY")
assertEquals(
listOf("Alice" to 1L, "Bob" to 1L, "Zoe" to 1L, "bob" to 1L).map { (name, count) -> listOf(text(name), int(count)) },
result.rows
)
}
@Test
fun selectDistinctDedupsCaseVariantsThroughTheColumnsDeclaredCollation() {
seedNames()
val result = select("SELECT DISTINCT name FROM people ORDER BY name")
assertEquals(listOf("Alice", "Bob", "Zoe").map { listOf(text(it)) }, result.rows)
}
@Test
fun selectDistinctStarDedupsCaseVariantsThroughTheColumnsDeclaredCollation() {
// A single-column table so DISTINCT *'s whole-row key is driven entirely by name's own
// declared NOCASE - with more columns involved, any other differing column (like people's
// own id) would already make every row distinct regardless of collation.
run("CREATE TABLE tags (label TEXT COLLATE NOCASE)")
run("INSERT INTO tags VALUES ('Bob'), ('bob'), ('Alice')")
val result = select("SELECT DISTINCT * FROM tags ORDER BY label")
assertEquals(listOf("Alice", "Bob").map { listOf(text(it)) }, result.rows)
}
@Test
fun selectDistinctWithNoDeclaredCollationStaysBinary() {
run("CREATE TABLE plain (id INTEGER PRIMARY KEY, name TEXT)")
run("INSERT INTO plain VALUES (1, 'Bob'), (2, 'bob')")
val result = select("SELECT DISTINCT name FROM plain ORDER BY name")
assertEquals(listOf("Bob", "bob").map { listOf(text(it)) }, result.rows)
}
@Test
fun selectDistinctOnDedupsCaseVariantsThroughTheColumnsDeclaredCollation() {
run("CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE, hits INTEGER)")
run("INSERT INTO people VALUES (1, 'Bob', 10), (2, 'bob', 20), (3, 'Alice', 5)")
val result = select("SELECT DISTINCT ON (name) name, hits FROM people ORDER BY name, id")
assertEquals(
listOf("Alice" to 5L, "Bob" to 10L).map { (name, hits) -> listOf(text(name), int(hits)) },
result.rows
)
}
@Test
fun countDistinctDedupsCaseVariantsThroughTheColumnsDeclaredCollation() {
seedNames()
assertEquals(int(3), select("SELECT COUNT(DISTINCT name) FROM people").rows.single().single())
}
/**
* `<>` forces [kestrel.engine.SelectRunner]'s nested-loop join path (only a `BinaryExpression`
* `=` condition can hash-join at all), which reuses the same collation-aware
* [kestrel.engine.Evaluator] as `WHERE`. See the `...InAnEqualityHashJoin` tests below for the
* `=` counterpart, which takes the hash-join fast path instead.
*/
@Test
fun joiningOnColumnsWhereOnlyTheLeftSideDeclaresCollationHonorsItInANonEqualityJoinCondition() {
run("CREATE TABLE left_t (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)")
run("CREATE TABLE right_t (id INTEGER PRIMARY KEY, name TEXT)")
run("INSERT INTO left_t VALUES (1, 'Bob'), (2, 'Zoe')")
run("INSERT INTO right_t VALUES (1, 'BOB')")
assertEquals(
listOf(listOf(int(2))),
select("SELECT left_t.id FROM left_t JOIN right_t ON left_t.name <> right_t.name").rows
)
}
@Test
fun joiningOnColumnsWhereOnlyTheRightSideDeclaresCollationFallsBackToTheRightWhenTheLeftHasNone() {
run("CREATE TABLE left_t (id INTEGER PRIMARY KEY, name TEXT)")
run("CREATE TABLE right_t (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)")
run("INSERT INTO left_t VALUES (1, 'Bob'), (2, 'Zoe')")
run("INSERT INTO right_t VALUES (1, 'BOB')")
assertEquals(
listOf(listOf(int(2))),
select("SELECT left_t.id FROM left_t JOIN right_t ON left_t.name <> right_t.name").rows
)
}
/**
* `=` takes [kestrel.engine.SelectRunner]'s hash-join fast path (`hashJoinKeys`/`executeJoin`),
* which must bucket [Value.TextValue] keys through the exact same [kestrel.engine.pairCollation]
* the nested-loop `<>` tests above resolve through [kestrel.engine.Evaluator] - otherwise the
* "optimization" would silently change which rows match. `right_t`'s undeclared `name` still
* only matches its own literal case ('bob'), so this also proves the fast path is not simply
* ignoring case altogether.
*/
@Test
fun joiningOnColumnsWhereOnlyTheLeftSideDeclaresCollationHonorsItInAnEqualityHashJoin() {
run("CREATE TABLE left_t (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)")
run("CREATE TABLE right_t (id INTEGER PRIMARY KEY, name TEXT)")
run("INSERT INTO left_t VALUES (1, 'Bob'), (2, 'Zoe')")
run("INSERT INTO right_t VALUES (1, 'BOB'), (2, 'bob')")
assertEquals(
listOf(listOf(int(1), int(1)), listOf(int(1), int(2))),
select("SELECT left_t.id, right_t.id FROM left_t JOIN right_t ON left_t.name = right_t.name ORDER BY right_t.id").rows
)
}
@Test
fun joiningOnColumnsWhereOnlyTheRightSideDeclaresCollationHonorsItInAnEqualityHashJoin() {
run("CREATE TABLE left_t (id INTEGER PRIMARY KEY, name TEXT)")
run("CREATE TABLE right_t (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)")
run("INSERT INTO left_t VALUES (1, 'BOB'), (2, 'Zoe')")
run("INSERT INTO right_t VALUES (1, 'Bob')")
assertEquals(
listOf(listOf(int(1))),
select("SELECT left_t.id FROM left_t JOIN right_t ON left_t.name = right_t.name").rows
)
}
@Test
fun explicitCollateOnAnEqualityHashJoinConditionOverridesTheColumnsDeclaredCollation() {
run("CREATE TABLE left_t (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)")
run("CREATE TABLE right_t (id INTEGER PRIMARY KEY, name TEXT)")
run("INSERT INTO left_t VALUES (1, 'Bob')")
run("INSERT INTO right_t VALUES (1, 'BOB')")
assertEquals(
emptyList(),
select("SELECT left_t.id FROM left_t JOIN right_t ON left_t.name COLLATE BINARY = right_t.name").rows
)
}
@Test
fun equalityHashJoinWithNoDeclaredCollationOnEitherSideStaysBinary() {
run("CREATE TABLE left_t (id INTEGER PRIMARY KEY, name TEXT)")
run("CREATE TABLE right_t (id INTEGER PRIMARY KEY, name TEXT)")
run("INSERT INTO left_t VALUES (1, 'Bob')")
run("INSERT INTO right_t VALUES (1, 'BOB')")
assertEquals(
emptyList(),
select("SELECT left_t.id FROM left_t JOIN right_t ON left_t.name = right_t.name").rows
)
}
@Test
fun declaredCollationAppliesInsideAnOnConflictDoUpdateAssignment() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE, was_bob BOOLEAN)")
run("INSERT INTO t VALUES (1, 'Bob', FALSE)")
run("INSERT INTO t (id, name) VALUES (1, 'x') ON CONFLICT (id) DO UPDATE SET was_bob = (name = 'BOB')")
assertEquals(bool(true), select("SELECT was_bob FROM t WHERE id = 1").rows.single().single())
}
@Test
fun declaredCollationAppliesInAMergeOnCondition() {
run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE, hits INTEGER)")
run("CREATE TABLE staging (id INTEGER PRIMARY KEY, name TEXT)")
run("INSERT INTO accounts VALUES (1, 'Bob', 0)")
run("INSERT INTO staging VALUES (2, 'BOB')")
run("MERGE INTO accounts USING staging ON accounts.name = staging.name WHEN MATCHED THEN UPDATE SET hits = hits + 1")
assertEquals(int(1), select("SELECT hits FROM accounts WHERE id = 1").rows.single().single())
}
@Test
fun declaredCollationAppliesInATriggerWhenClauseOnNew() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)")
run("CREATE TABLE hits (id INTEGER PRIMARY KEY, count INTEGER)")
run("INSERT INTO t VALUES (1, 'Bob')")
run("INSERT INTO hits VALUES (1, 0)")
run(
"CREATE TRIGGER bump AFTER UPDATE ON t FOR EACH ROW WHEN NEW.name = 'BOB' " +
"BEGIN UPDATE hits SET count = count + 100 WHERE id = NEW.id; END"
)
run("UPDATE t SET name = 'bob' WHERE id = 1")
assertEquals(int(100), select("SELECT count FROM hits WHERE id = 1").rows.single().single())
}
/**
* `UNION`/`INTERSECT`/`EXCEPT`'s own `ORDER BY` ([kestrel.engine.SelectRunner.
* declaredSetOperationCollation]) - the last named gap left by #66 ("multiKeyComparator-owa
* sciezka UNION/INTERSECT/EXCEPT ORDER BY dalej binarna"). A `SELECT *` branch's declared
* collation is traced back to the real table each output column comes from.
*/
@Test
fun declaredCollationAppliesToASetOperationsOrderByThroughAStarProjection() {
seedNames()
val result = select(
"SELECT * FROM people WHERE id <= 2 UNION ALL SELECT * FROM people WHERE id > 2 ORDER BY name"
)
// Combined pre-sort order is (1,Bob),(2,bob),(3,Alice),(4,Zoe) - NOCASE ranks Bob/bob equal,
// so the stable sort keeps Bob (scanned first) ahead of bob.
assertEquals(
listOf(int(3) to "Alice", int(1) to "Bob", int(2) to "bob", int(4) to "Zoe").map { (id, name) -> listOf(id, text(name)) },
result.rows
)
}
@Test
fun declaredCollationAppliesToASetOperationsOrderByThroughAnAliasedProjection() {
seedNames()
val result = select(
"SELECT name AS n FROM people WHERE id <= 2 UNION ALL SELECT name AS n FROM people WHERE id > 2 ORDER BY n"
)
assertEquals(listOf("Alice", "Bob", "bob", "Zoe").map { listOf(text(it)) }, result.rows)
}
@Test
fun explicitCollateInASetOperationsOrderByOverridesTheColumnsDeclaredCollation() {
seedNames()
val result = select(
"SELECT * FROM people WHERE id <= 2 UNION ALL SELECT * FROM people WHERE id > 2 ORDER BY name COLLATE BINARY"
)
assertEquals(listOf("Alice", "Bob", "Zoe", "bob"), result.rows.map { (it[1] as Value.TextValue).value })
}
/**
* The declared collation of an output column joined from a second `FROM` source is found past
* the first source's own column count - proves `declaredSetOperationCollation`'s per-source
* offset walk, not just the single-table case above.
*/
@Test
fun declaredCollationAppliesToASetOperationsOrderByAcrossAJoinedSecondTable() {
run("CREATE TABLE ids (id INTEGER PRIMARY KEY)")
run("CREATE TABLE names (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)")
run("INSERT INTO ids VALUES (1), (2), (3), (4)")
run("INSERT INTO names VALUES (1, 'Bob'), (2, 'bob'), (3, 'Alice'), (4, 'Zoe')")
val result = select(
"SELECT * FROM ids JOIN names ON ids.id = names.id WHERE ids.id <= 2 " +
"UNION ALL SELECT * FROM ids JOIN names ON ids.id = names.id WHERE ids.id > 2 " +
"ORDER BY name"
)
assertEquals(listOf("Alice", "Bob", "bob", "Zoe"), result.rows.map { (it[2] as Value.TextValue).value })
}
/**
* Still-open scope cut (see [kestrel.engine.SelectRunner.declaredSetOperationCollation]'s own
* doc comment): a set operation's left branch reading through a derived table rather than a
* real table directly reports no declared collation, so its ORDER BY stays binary even though
* the underlying table declares NOCASE - symmetric with the pre-existing view/CTE scope cut for
* a plain SELECT's own ORDER BY.
*/
@Test
fun declaredCollationDoesNotReachThroughADerivedTableInASetOperationsLeftBranch() {
seedNames()
val result = select(
"SELECT * FROM (SELECT * FROM people) sub WHERE id <= 2 " +
"UNION ALL SELECT * FROM (SELECT * FROM people) sub2 WHERE id > 2 " +
"ORDER BY name"
)
assertEquals(listOf("Alice", "Bob", "Zoe", "bob"), result.rows.map { (it[1] as Value.TextValue).value })
}
/**
* `UNION`/`INTERSECT`/`EXCEPT`'s own multiset dedup ([kestrel.engine.SelectRunner.
* runSetOperation]) - the largest piece of the scope-cut #67/#68 left open ("UNION/CTE dedup ...
* dalej uzywaja bajtowej rownosci"). Reuses [kestrel.engine.SelectRunner.declaredSetOperationCollation],
* the same resolver the ORDER BY tests above already exercise, but now folded into the dedup key
* itself rather than only the comparator.
*/
@Test
fun plainUnionDedupsCaseVariantsThroughTheColumnsDeclaredCollation() {
seedNames()
val result = select("SELECT name FROM people WHERE id = 1 UNION SELECT name FROM people WHERE id = 2")
assertEquals(listOf(listOf(text("Bob"))), result.rows)
}
@Test
fun plainUnionWithNoDeclaredCollationKeepsCaseVariantsDistinct() {
run("CREATE TABLE plain2 (id INTEGER PRIMARY KEY, name TEXT)")
run("INSERT INTO plain2 VALUES (1, 'Bob'), (2, 'bob')")
val result = select("SELECT name FROM plain2 WHERE id = 1 UNION SELECT name FROM plain2 WHERE id = 2")
assertEquals(listOf(listOf(text("Bob")), listOf(text("bob"))), result.rows)
}
@Test
fun intersectMatchesCaseVariantsThroughTheColumnsDeclaredCollation() {
seedNames()
val result = select("SELECT name FROM people WHERE id = 1 INTERSECT SELECT name FROM people WHERE id = 2")
assertEquals(listOf(listOf(text("Bob"))), result.rows)
}
@Test
fun exceptExcludesCaseVariantsThroughTheColumnsDeclaredCollation() {
seedNames()
val result = select("SELECT name FROM people WHERE id IN (1, 4) EXCEPT SELECT name FROM people WHERE id = 2")
assertEquals(listOf(listOf(text("Zoe"))), result.rows)
}
/**
* `INTERSECT ALL`/`EXCEPT ALL` preserve duplicate counts through [kestrel.engine.SelectRunner.
* multisetFilter], which grouped its right-hand side by raw `Value` equality before this session
* - proving the count itself (not just presence/absence) is now grouped through the declared
* collation: 3 case-variant 'Bob' rows on the left, 2 on the right, min(3,2)=2 kept.
*/
@Test
fun intersectAllMatchesCaseVariantsThroughTheColumnsDeclaredCollationAndPreservesDuplicateCounts() {
run("CREATE TABLE dup (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)")
run("INSERT INTO dup VALUES (1, 'Bob'), (2, 'Bob'), (3, 'bob'), (4, 'Zoe')")
val result = select(
"SELECT name FROM dup WHERE id IN (1, 2, 3) INTERSECT ALL SELECT name FROM dup WHERE id IN (2, 3)"
)
assertEquals(listOf(listOf(text("Bob")), listOf(text("Bob"))), result.rows)
}
/**
* A recursive CTE's own fixed-point dedup ([kestrel.engine.SelectRunner.buildCteScope]), folded
* through [kestrel.engine.SelectRunner.declaredRecursiveCteCollation] resolved against the
* anchor (base) term - mirrors [declaredSetOperationCollation] but for the base/recursive-term
* split instead of a set operation's left/right. `steps` chains 'Bob' -> 'Carol' -> 'bob'; without
* collation-aware dedup 'bob' would be treated as a new row (it differs from 'Bob' byte-for-byte)
* and the walk would keep going instead of recognizing it as the already-visited start node.
*/
@Test
fun recursiveCteWithUnionDedupsCaseVariantsThroughTheColumnsDeclaredCollation() {
run("CREATE TABLE origin (name TEXT COLLATE NOCASE)")
run("INSERT INTO origin VALUES ('Bob')")
run("CREATE TABLE steps (name TEXT COLLATE NOCASE, next_name TEXT COLLATE NOCASE)")
run("INSERT INTO steps VALUES ('Bob', 'Carol'), ('Carol', 'bob')")
val result = select(
"WITH RECURSIVE walk(name) AS (" +
"SELECT name FROM origin " +
"UNION " +
"SELECT steps.next_name FROM steps JOIN walk ON steps.name = walk.name" +
") SELECT name FROM walk ORDER BY name"
)
assertEquals(listOf(listOf(text("Bob")), listOf(text("Carol"))), result.rows)
}
@Test
fun createTableColumnCollationRoundTripsThroughSqlWriter() {
val createStatement = parseOne("CREATE TABLE people2 (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)") as CreateTableStatement
val rendered = SqlWriter.write(createStatement)
check("COLLATE NOCASE" in rendered) { "expected COLLATE NOCASE in rendered CREATE TABLE: $rendered" }
executor.execute(parseOne(rendered))
run("INSERT INTO people2 VALUES (1, 'Bob')")
assertEquals(bool(true), select("SELECT name = 'BOB' FROM people2").rows.single().single())
}
/**
* A real correctness bug, not a documented scope-cut: [kestrel.catalog.SortedIndex] used to
* sort/compare its keys with plain byte order regardless of the indexed column's own declared
* `COLLATE` (see [kestrel.catalog.Catalog.collationsFor]/[kestrel.catalog.IndexKeyOrdering]),
* so once [kestrel.engine.IndexAccessPlanner] chose to serve `WHERE name = 'BOB'` from an index
* on a `NOCASE`-declared `name` column, the lookup silently missed a stored `'bob'` row - unlike
* every other collation gap in this codebase (all documented scope-cuts, never wrong answers),
* this one returned fewer rows than a full scan of the exact same table/predicate would, because
* [kestrel.engine.SelectRunner.materialize]'s post-scan `WHERE` filter only re-checks candidates
* the chosen access path already produced - it cannot recover a row the index never handed back.
*/
@Test
fun indexedEqualityHonorsTheColumnsDeclaredCollation() {
seedNames()
val scanned = select("SELECT id FROM people WHERE name = 'BOB' ORDER BY id").rows
run("CREATE INDEX idxName ON people (name)")
val plan = explain("EXPLAIN SELECT id FROM people WHERE name = 'BOB'")
assertTrue(plan.any { it.contains("Index Scan using idxName") }, "expected index scan, got $plan")
val indexed = select("SELECT id FROM people WHERE name = 'BOB' ORDER BY id").rows
assertEquals(scanned, indexed)
assertEquals(listOf(listOf(int(1)), listOf(int(2))), indexed)
}
/** Mirrors [indexedEqualityHonorsTheColumnsDeclaredCollation] for a trailing-space collation instead of case. */
@Test
fun indexedEqualityHonorsAnRtrimDeclaredCollation() {
run("CREATE TABLE tags (id INTEGER PRIMARY KEY, tag TEXT COLLATE RTRIM)")
run("INSERT INTO tags VALUES (1, 'a'), (2, 'a '), (3, 'b')")
run("CREATE INDEX idxTag ON tags (tag)")
val plan = explain("EXPLAIN SELECT id FROM tags WHERE tag = 'a'")
assertTrue(plan.any { it.contains("Index Scan using idxTag") }, "expected index scan, got $plan")
assertEquals(listOf(listOf(int(1)), listOf(int(2))), select("SELECT id FROM tags WHERE tag = 'a' ORDER BY id").rows)
}
/**
* A composite index's [kestrel.catalog.IndexKeyOrdering] carries one [kestrel.storage.Collation]
* per column position - `first` is `NOCASE` (matches both rows) but `last` stays plain `BINARY`
* (only `'Smith'` exactly matches), so only row 1 should survive both conjuncts. This only passes
* if each column position is folded through its OWN collation independently rather than, say, the
* whole composite key sharing a single collation.
*/
@Test
fun compositeIndexEqualityHonorsPerColumnDeclaredCollationIndependently() {
run("CREATE TABLE contacts (id INTEGER PRIMARY KEY, first TEXT COLLATE NOCASE, last TEXT)")
run("INSERT INTO contacts VALUES (1, 'Bob', 'Smith'), (2, 'bob', 'SMITH')")
run("CREATE INDEX idxContact ON contacts (first, last)")
val plan = explain("EXPLAIN SELECT id FROM contacts WHERE first = 'BOB' AND last = 'Smith'")
assertTrue(plan.any { it.contains("Index Scan using idxContact") }, "got $plan")
assertEquals(
listOf(listOf(int(1))),
select("SELECT id FROM contacts WHERE first = 'BOB' AND last = 'Smith'").rows
)
}
/**
* An explicit `COLLATE` at the query level overrides the column's own declared collation (see
* [kestrel.engine.pairCollation]) - [kestrel.engine.IndexAccessPlanner.asColumnPredicate] only
* recognizes a bare [kestrel.sql.ast.ColumnExpression], so a `COLLATE`-wrapped operand is simply
* excluded from pushdown rather than being (wrongly) served by an index built for the column's
* OWN collation. Proves that safety net: falling back to `Seq Scan` here, not silently pushing
* the query's `BINARY` override through a `NOCASE`-sorted index.
*/
@Test
fun explicitCollateOverrideOnAnIndexedColumnFallsBackToASeqScanInsteadOfMisusingTheIndex() {
seedNames()
run("CREATE INDEX idxName ON people (name)")
val plan = explain("EXPLAIN SELECT id FROM people WHERE name COLLATE BINARY = 'BOB'")
assertTrue(plan.any { it.contains("Seq Scan") }, "expected seq scan fallback, got $plan")
assertEquals(emptyList(), select("SELECT id FROM people WHERE name COLLATE BINARY = 'BOB'").rows)
}
/** Control negative: an index over a column with no declared `COLLATE` behaves exactly as before this session. */
@Test
fun indexedEqualityWithNoDeclaredCollationStaysBinary() {
run("CREATE TABLE plain3 (id INTEGER PRIMARY KEY, name TEXT)")
run("INSERT INTO plain3 VALUES (1, 'Bob'), (2, 'bob')")
run("CREATE INDEX idxPlain3 ON plain3 (name)")
val plan = explain("EXPLAIN SELECT id FROM plain3 WHERE name = 'Bob'")
assertTrue(plan.any { it.contains("Index Scan using idxPlain3") }, "got $plan")
assertEquals(listOf(listOf(int(1))), select("SELECT id FROM plain3 WHERE name = 'Bob'").rows)
}
/**
* PRIMARY KEY uniqueness ([kestrel.engine.Executor.enforcePrimaryKeyUniqueness]) folded through
* [kestrel.engine.collationFoldedUniquenessKey] - 'Bob'/'bob' now collide under a NOCASE-declared
* PRIMARY KEY exactly like they already do for a plain equality comparison.
*/
@Test
fun primaryKeyUniquenessHonorsTheColumnsDeclaredCollation() {
run("CREATE TABLE pk1 (name TEXT COLLATE NOCASE PRIMARY KEY)")
run("INSERT INTO pk1 VALUES ('Bob')")
assertFailsWith<KestrelException> { run("INSERT INTO pk1 VALUES ('bob')") }
}
/** [kestrel.engine.Executor.enforceUniqueConstraints], the declarative `UNIQUE` counterpart of the PK test above. */
@Test
fun uniqueConstraintHonorsTheColumnsDeclaredCollation() {
run("CREATE TABLE u1 (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE UNIQUE)")
run("INSERT INTO u1 VALUES (1, 'Bob')")
assertFailsWith<KestrelException> { run("INSERT INTO u1 VALUES (2, 'bob')") }
}
/**
* `CREATE UNIQUE INDEX` ([kestrel.engine.Executor.enforceUniqueIndexes]) checked against
* pre-existing rows before the index is registered - two rows already differing only by case
* under a `NOCASE`-declared column must now be rejected, matching what inserting either of them
* fresh under an already-`UNIQUE`-constrained column would do.
*/
@Test
fun createUniqueIndexHonorsTheColumnsDeclaredCollationAgainstPreexistingRows() {
run("CREATE TABLE u2 (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)")
run("INSERT INTO u2 VALUES (1, 'Bob'), (2, 'bob')")
assertFailsWith<KestrelException> { run("CREATE UNIQUE INDEX idxU2 ON u2 (name)") }
}
/**
* [kestrel.engine.Executor.enforceUniqueIndexes] guarding an ordinary `INSERT` against an
* ALREADY-REGISTERED `CREATE UNIQUE INDEX` - distinct from
* [createUniqueIndexHonorsTheColumnsDeclaredCollationAgainstPreexistingRows]'s backfill-time
* check: here the index already exists and a fresh case-variant row is what must be rejected,
* BEFORE the row is appended to the table (see `enforceUniqueIndexes`'s own doc comment on why
* it does not just rely on [kestrel.catalog.SortedIndex.add]'s own, likewise collation-aware,
* uniqueness check to catch this instead).
*/
@Test
fun uniqueIndexHonorsTheColumnsDeclaredCollationOnInsertAfterCreation() {
run("CREATE TABLE u5 (id INTEGER PRIMARY KEY, name TEXT COLLATE NOCASE)")
run("CREATE UNIQUE INDEX idxU5 ON u5 (name)")
run("INSERT INTO u5 VALUES (1, 'Bob')")
assertFailsWith<KestrelException> { run("INSERT INTO u5 VALUES (2, 'bob')") }
// Proves enforceUniqueIndexes itself rejected the row BEFORE table.rows.addAll ran, not
// merely that some exception surfaced - u5 has no trigger, so withStatementAtomicity takes
// no catalog snapshot here and could not have rolled back a row that got appended first.
assertEquals(listOf(listOf(int(1))), select("SELECT id FROM u5").rows)
}
/** Control negative: a `UNIQUE` column with no declared `COLLATE` behaves exactly as before this session. */
@Test
fun uniqueConstraintWithNoDeclaredCollationStaysBinary() {
run("CREATE TABLE u3 (id INTEGER PRIMARY KEY, name TEXT UNIQUE)")
run("INSERT INTO u3 VALUES (1, 'Bob')")
run("INSERT INTO u3 VALUES (2, 'bob')")
assertEquals(2, select("SELECT id FROM u3").rows.size)
}
}
@@ -0,0 +1,199 @@
package kestrel
import kestrel.catalog.Catalog
import kestrel.engine.ExplainResult
import kestrel.engine.Executor
import kestrel.engine.SelectResult
import kestrel.engine.StatementResult
import kestrel.sql.lexer.Lexer
import kestrel.sql.parser.Parser
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/** Covers multi-column `CREATE [UNIQUE] INDEX idx ON t (a, b, ...)`: parsing, uniqueness enforcement over the whole tuple, and query-planner pushdown (full match, equality prefix, and range-after-prefix scans). */
class CompositeIndexTest {
private val catalog = Catalog()
private val executor = Executor(catalog)
private fun run(sql: String): List<StatementResult> =
Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) }
private fun select(sql: String): SelectResult = run(sql).single() as SelectResult
private fun explain(sql: String): List<String> = (run(sql).single() as ExplainResult).plan
private fun ids(result: SelectResult): List<Long> = result.rows.map { (it.single() as kestrel.storage.Value.IntegerValue).value }
private fun seedPairs() {
run("CREATE TABLE pairs (id INTEGER PRIMARY KEY, a INTEGER, b INTEGER)")
run(
"INSERT INTO pairs VALUES " +
"(1, 1, 10), (2, 1, 20), (3, 1, 30), (4, 2, 10), (5, 2, 20), (6, 3, 10)"
)
}
private fun seedRanked() {
run("CREATE TABLE ranked (id INTEGER PRIMARY KEY, age INTEGER, name TEXT)")
run(
"INSERT INTO ranked VALUES " +
"(1, 30, 'amy'), (2, 30, 'bob'), (3, 40, 'cy'), (4, 40, 'dan'), (5, 50, 'eve')"
)
}
private fun seedSales() {
run("CREATE TABLE sales (id INTEGER PRIMARY KEY, region TEXT, amount INTEGER, note TEXT)")
run(
"INSERT INTO sales VALUES " +
"(1, 'EU', 50, 'x'), (2, 'EU', 100, 'y'), (3, 'EU', 150, 'z'), (4, 'EU', 200, 'w'), " +
"(5, 'US', 150, 'v'), (6, 'US', 300, 'u')"
)
}
@Test
fun parsesMultiColumnCreateIndexAndCreateUniqueIndex() {
run("CREATE TABLE t (a INTEGER, b INTEGER, c INTEGER)")
run("CREATE INDEX idx ON t (a, b, c)")
run("CREATE UNIQUE INDEX uidx ON t (b, a)")
val schema = explain("EXPLAIN SELECT a FROM t").joinToString("\n")
assertTrue(schema.isNotEmpty())
}
@Test
fun compositeUniqueIndexRejectsDuplicateTupleButAllowsPartialReuse() {
seedPairs()
run("CREATE UNIQUE INDEX uidx ON pairs (a, b)")
// (1, 10) already exists as row 1; same 'a' with a different 'b', or same 'b' with a
// different 'a', must both be allowed - only the exact tuple collides.
run("INSERT INTO pairs VALUES (7, 1, 99)")
run("INSERT INTO pairs VALUES (8, 9, 10)")
assertFailsWith<KestrelException> { run("INSERT INTO pairs VALUES (9, 1, 10)") }
}
@Test
fun compositeUniqueIndexRejectsDuplicateTupleOnUpdate() {
seedPairs()
run("CREATE UNIQUE INDEX uidx ON pairs (a, b)")
assertFailsWith<KestrelException> { run("UPDATE pairs SET a = 1, b = 20 WHERE id = 1") }
}
@Test
fun compositeUniqueIndexAllowsMultipleNullsInEitherColumn() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, a INTEGER, b INTEGER)")
run("CREATE UNIQUE INDEX uidx ON t (a, b)")
run("INSERT INTO t VALUES (1, NULL, 5), (2, NULL, 5), (3, 5, NULL), (4, 5, NULL)")
assertEquals(4, select("SELECT id FROM t").rows.size)
}
@Test
fun creatingACompositeUniqueIndexOverExistingDuplicatesFailsWithoutLeavingHalfState() {
seedPairs()
run("INSERT INTO pairs VALUES (7, 1, 10)")
assertFailsWith<KestrelException> { run("CREATE UNIQUE INDEX uidx ON pairs (a, b)") }
run("CREATE INDEX uidx ON pairs (a, b)")
}
@Test
fun indexOnUnknownColumnInCompositeListIsRejected() {
seedPairs()
val error = assertFailsWith<KestrelException> { run("CREATE INDEX bad ON pairs (a, missing)") }
assertTrue(error.message!!.contains("missing"), error.message)
}
@Test
fun plannerUsesCompositeIndexForFullEqualityMatch() {
seedPairs()
val scanned = select("SELECT id FROM pairs WHERE a = 1 AND b = 20").rows
run("CREATE INDEX idx ON pairs (a, b)")
val plan = explain("EXPLAIN SELECT id FROM pairs WHERE a = 1 AND b = 20")
assertTrue(plan.any { it.contains("Index Scan using idx") }, "got $plan")
val indexed = select("SELECT id FROM pairs WHERE a = 1 AND b = 20").rows
assertEquals(scanned, indexed)
assertEquals(listOf(2L), ids(select("SELECT id FROM pairs WHERE a = 1 AND b = 20")))
}
@Test
fun plannerUsesCompositeIndexAsEqualityPrefixScan() {
seedPairs()
val scanned = select("SELECT id FROM pairs WHERE a = 1").rows
run("CREATE INDEX idx ON pairs (a, b)")
val plan = explain("EXPLAIN SELECT id FROM pairs WHERE a = 1")
assertTrue(plan.any { it.contains("Index Scan using idx") }, "got $plan")
val indexed = select("SELECT id FROM pairs WHERE a = 1").rows
assertEquals(scanned, indexed)
assertEquals(listOf(1L, 2L, 3L), ids(select("SELECT id FROM pairs WHERE a = 1")))
}
@Test
fun plannerIgnoresCompositeIndexWhenOnlyTrailingColumnIsBound() {
seedPairs()
run("CREATE INDEX idx ON pairs (a, b)")
val plan = explain("EXPLAIN SELECT id FROM pairs WHERE b = 10")
assertTrue(plan.any { it.contains("Seq Scan on pairs") }, "got $plan")
assertFalse(plan.any { it.contains("Index Scan") }, "got $plan")
}
@Test
fun plannerUsesCompositeIndexForRangeOnLeadingColumnAlone() {
seedRanked()
val scanned = select("SELECT id FROM ranked WHERE age > 30").rows
run("CREATE INDEX idxAgeName ON ranked (age, name)")
val plan = explain("EXPLAIN SELECT id FROM ranked WHERE age > 30")
assertTrue(plan.any { it.contains("Index Scan using idxAgeName") }, "got $plan")
val indexed = select("SELECT id FROM ranked WHERE age > 30").rows
assertEquals(scanned, indexed)
assertEquals(listOf(3L, 4L, 5L), ids(select("SELECT id FROM ranked WHERE age > 30")))
}
@Test
fun plannerUsesCompositeIndexForTwoSidedRangeOnLeadingColumn() {
seedRanked()
val scanned = select("SELECT id FROM ranked WHERE age > 30 AND age < 50").rows
run("CREATE INDEX idxAgeName ON ranked (age, name)")
val indexed = select("SELECT id FROM ranked WHERE age > 30 AND age < 50").rows
assertEquals(scanned, indexed)
assertEquals(listOf(3L, 4L), ids(select("SELECT id FROM ranked WHERE age > 30 AND age < 50")))
}
@Test
fun plannerUsesCompositeIndexForEqualityPrefixPlusRangeOnNextColumnWithFreeTail() {
seedSales()
val scanned = select("SELECT id FROM sales WHERE region = 'EU' AND amount > 100").rows
run("CREATE INDEX idxRegionAmount ON sales (region, amount, note)")
val plan = explain("EXPLAIN SELECT id FROM sales WHERE region = 'EU' AND amount > 100")
assertTrue(plan.any { it.contains("Index Scan using idxRegionAmount") }, "got $plan")
val indexed = select("SELECT id FROM sales WHERE region = 'EU' AND amount > 100").rows
assertEquals(scanned, indexed)
// Must exclude the US row with a bigger amount (300) and the EU row at exactly 100.
assertEquals(listOf(3L, 4L), ids(select("SELECT id FROM sales WHERE region = 'EU' AND amount > 100")))
}
@Test
fun plannerUsesCompositeIndexForEqualityPrefixPlusInclusiveUpperBound() {
seedSales()
run("CREATE INDEX idxRegionAmount ON sales (region, amount, note)")
val indexed = ids(select("SELECT id FROM sales WHERE region = 'EU' AND amount <= 100"))
assertEquals(listOf(1L, 2L), indexed)
}
@Test
fun plannerPrefersTheMoreSelectiveCompositeIndexOverASingleColumnIndex() {
seedSales()
run("CREATE INDEX idxAmount ON sales (amount)")
run("CREATE INDEX idxRegionAmount ON sales (region, amount)")
val plan = explain("EXPLAIN SELECT id FROM sales WHERE region = 'EU' AND amount = 100")
assertTrue(plan.any { it.contains("Index Scan using idxRegionAmount") }, "got $plan")
assertFalse(plan.any { it.contains("idxAmount") }, "got $plan")
}
@Test
fun dropCompositeIndexRemovesItFromPlanner() {
seedPairs()
run("CREATE INDEX idx ON pairs (a, b)")
run("DROP INDEX idx")
val plan = explain("EXPLAIN SELECT id FROM pairs WHERE a = 1 AND b = 20")
assertTrue(plan.any { it.contains("Seq Scan on pairs") }, "got $plan")
}
}
+316
View File
@@ -0,0 +1,316 @@
package kestrel
import kestrel.catalog.Catalog
import kestrel.engine.Executor
import kestrel.engine.InsertResult
import kestrel.engine.SelectResult
import kestrel.engine.StatementResult
import kestrel.persistence.SqlWriter
import kestrel.sql.ast.CreateTableStatement
import kestrel.sql.ast.Statement
import kestrel.sql.lexer.Lexer
import kestrel.sql.parser.Parser
import kestrel.storage.Value
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* Multi-column PRIMARY KEY (`PRIMARY KEY (a, b, ...)`) and multi-column FOREIGN KEY
* (`FOREIGN KEY (a, b) REFERENCES parent(x, y)`), the last documented "not yet supported" gap
* left in TableSchema/ForeignKeyEnforcer after single-column UNIQUE landed.
*/
class CompositeKeyTest {
private val catalog = Catalog()
private val executor = Executor(catalog)
private fun run(sql: String): List<StatementResult> =
Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) }
private fun exec(sql: String): StatementResult = run(sql).single()
private fun parseOne(sql: String): Statement = Parser(Lexer(sql).tokenize()).parseProgram().single()
private fun select(sql: String): SelectResult = exec(sql) as SelectResult
private fun int(value: Long) = Value.IntegerValue(value)
// ---- parsing ----
@Test
fun parsesTableLevelCompositePrimaryKey() {
val statement = parseOne(
"CREATE TABLE line_items (order_id INTEGER, sku TEXT, qty INTEGER, PRIMARY KEY (order_id, sku))"
) as CreateTableStatement
assertEquals(listOf("order_id", "sku"), statement.compositePrimaryKey)
assertTrue(statement.columns.none { it.primaryKey })
}
@Test
fun parsesTableLevelCompositeForeignKey() {
val statement = parseOne(
"CREATE TABLE t (a INTEGER, b INTEGER, " +
"FOREIGN KEY (a, b) REFERENCES parent (x, y) ON DELETE CASCADE)"
) as CreateTableStatement
val fk = statement.foreignKeys.single()
assertEquals(listOf("a", "b"), fk.columns)
assertEquals("parent", fk.referencedTable)
assertEquals(listOf("x", "y"), fk.referencedColumns)
}
@Test
fun mismatchedForeignKeyColumnCountIsRejectedAtParseTime() {
assertFailsWith<Exception> {
parseOne("CREATE TABLE t (a INTEGER, b INTEGER, FOREIGN KEY (a, b) REFERENCES parent (x))")
}
}
// ---- CREATE TABLE validation ----
@Test
fun declaringBothInlineAndTableLevelPrimaryKeyIsRejected() {
assertFailsWith<KestrelException> {
run("CREATE TABLE t (a INTEGER PRIMARY KEY, b INTEGER, PRIMARY KEY (a, b))")
}
}
@Test
fun duplicateColumnWithinCompositePrimaryKeyIsRejected() {
assertFailsWith<KestrelException> {
run("CREATE TABLE t (a INTEGER, b INTEGER, PRIMARY KEY (a, a))")
}
}
@Test
fun compositePrimaryKeyReferringToUnknownColumnIsRejected() {
assertFailsWith<KestrelException> {
run("CREATE TABLE t (a INTEGER, b INTEGER, PRIMARY KEY (a, missing))")
}
}
@Test
fun compositePrimaryKeyImpliesNotNullOnEveryListedColumn() {
run("CREATE TABLE t (a INTEGER, b INTEGER, PRIMARY KEY (a, b))")
assertFailsWith<KestrelException> { run("INSERT INTO t VALUES (1, NULL)") }
assertFailsWith<KestrelException> { run("INSERT INTO t VALUES (NULL, 1)") }
}
@Test
fun foreignKeyMustReferenceAnExactEnforcedUniqueGroupNotAnArbitraryPair() {
run("CREATE TABLE parent (a INTEGER, b INTEGER, c INTEGER, PRIMARY KEY (a, b))")
assertFailsWith<KestrelException> {
run("CREATE TABLE child (x INTEGER, y INTEGER, FOREIGN KEY (x, y) REFERENCES parent (b, c))")
}
}
@Test
fun foreignKeyMayReferenceACompositePrimaryKeyInAnyColumnOrder() {
run("CREATE TABLE parent (a INTEGER, b INTEGER, PRIMARY KEY (a, b))")
run("CREATE TABLE child (x INTEGER, y INTEGER, FOREIGN KEY (x, y) REFERENCES parent (b, a))")
}
// ---- composite PRIMARY KEY uniqueness ----
@Test
fun compositePrimaryKeyRejectsAnExactDuplicateCombination() {
run("CREATE TABLE line_items (order_id INTEGER, sku TEXT, qty INTEGER, PRIMARY KEY (order_id, sku))")
run("INSERT INTO line_items VALUES (1, 'A', 3)")
assertFailsWith<KestrelException> { run("INSERT INTO line_items VALUES (1, 'A', 5)") }
}
@Test
fun compositePrimaryKeyAllowsDifferentCombinationsSharingOneColumnValue() {
run("CREATE TABLE line_items (order_id INTEGER, sku TEXT, qty INTEGER, PRIMARY KEY (order_id, sku))")
run("INSERT INTO line_items VALUES (1, 'A', 3)")
val result = exec("INSERT INTO line_items VALUES (1, 'B', 3)")
assertEquals(InsertResult(1), result)
}
@Test
fun updateThatWouldCollideWithAnotherRowsCompositePrimaryKeyIsRejected() {
run("CREATE TABLE line_items (order_id INTEGER, sku TEXT, qty INTEGER, PRIMARY KEY (order_id, sku))")
run("INSERT INTO line_items VALUES (1, 'A', 3), (1, 'B', 5)")
assertFailsWith<KestrelException> { run("UPDATE line_items SET sku = 'A' WHERE sku = 'B'") }
}
// ---- composite FOREIGN KEY: existence checks ----
private fun seedOrdersAndLineItems() {
run("CREATE TABLE orders (region TEXT, order_no INTEGER, PRIMARY KEY (region, order_no))")
run(
"CREATE TABLE line_items (" +
"region TEXT, order_no INTEGER, sku TEXT, qty INTEGER, " +
"FOREIGN KEY (region, order_no) REFERENCES orders (region, order_no) " +
"ON DELETE CASCADE ON UPDATE CASCADE)"
)
run("INSERT INTO orders VALUES ('eu', 1), ('us', 1)")
}
@Test
fun compositeForeignKeyRejectsAPartialMatch() {
seedOrdersAndLineItems()
// ('eu', 1) and ('us', 1) both exist individually but not as the pair ('us', 2).
assertFailsWith<KestrelException> {
run("INSERT INTO line_items VALUES ('us', 2, 'SKU1', 3)")
}
}
@Test
fun compositeForeignKeyAcceptsAFullMatch() {
seedOrdersAndLineItems()
val result = exec("INSERT INTO line_items VALUES ('eu', 1, 'SKU1', 3)")
assertEquals(InsertResult(1), result)
}
@Test
fun compositeForeignKeyMatchSimpleSkipsCheckWhenAnyLocalColumnIsNull() {
seedOrdersAndLineItems()
val result = exec("INSERT INTO line_items VALUES ('eu', NULL, 'SKU1', 3)")
assertEquals(InsertResult(1), result)
}
@Test
fun compositeForeignKeyOnDeleteCascadeRemovesMatchingChildren() {
seedOrdersAndLineItems()
run("INSERT INTO line_items VALUES ('eu', 1, 'SKU1', 3), ('us', 1, 'SKU2', 4)")
run("DELETE FROM orders WHERE region = 'eu'")
val remaining = select("SELECT sku FROM line_items")
assertEquals(listOf(listOf(Value.TextValue("SKU2"))), remaining.rows)
}
@Test
fun compositeForeignKeyOnUpdateCascadePropagatesBothColumnsTogether() {
seedOrdersAndLineItems()
run("INSERT INTO line_items VALUES ('eu', 1, 'SKU1', 3)")
run("UPDATE orders SET order_no = 7 WHERE region = 'eu'")
val updated = select("SELECT region, order_no FROM line_items WHERE sku = 'SKU1'")
assertEquals(listOf(listOf(Value.TextValue("eu"), int(7))), updated.rows)
}
@Test
fun compositeForeignKeyOnDeleteRestrictBlocksByDefaultWithoutCascade() {
run("CREATE TABLE orders (region TEXT, order_no INTEGER, PRIMARY KEY (region, order_no))")
run(
"CREATE TABLE line_items (region TEXT, order_no INTEGER, sku TEXT, " +
"FOREIGN KEY (region, order_no) REFERENCES orders (region, order_no))"
)
run("INSERT INTO orders VALUES ('eu', 1)")
run("INSERT INTO line_items VALUES ('eu', 1, 'SKU1')")
assertFailsWith<KestrelException> { run("DELETE FROM orders WHERE region = 'eu'") }
}
@Test
fun compositeForeignKeyOnDeleteSetNullNullsEveryLocalColumn() {
run("CREATE TABLE orders (region TEXT, order_no INTEGER, PRIMARY KEY (region, order_no))")
run(
"CREATE TABLE line_items (region TEXT, order_no INTEGER, sku TEXT, " +
"FOREIGN KEY (region, order_no) REFERENCES orders (region, order_no) ON DELETE SET NULL)"
)
run("INSERT INTO orders VALUES ('eu', 1)")
run("INSERT INTO line_items VALUES ('eu', 1, 'SKU1')")
run("DELETE FROM orders WHERE region = 'eu'")
val row = select("SELECT region, order_no FROM line_items WHERE sku = 'SKU1'").rows.single()
assertEquals(listOf(Value.NullValue, Value.NullValue), row)
}
@Test
fun rollbackAfterCompositeCascadeDeleteRestoresBothTables() {
seedOrdersAndLineItems()
run("INSERT INTO line_items VALUES ('eu', 1, 'SKU1', 3)")
run("BEGIN")
run("DELETE FROM orders WHERE region = 'eu'")
run("ROLLBACK")
assertEquals(2, select("SELECT * FROM orders").rows.size)
assertEquals(1, select("SELECT * FROM line_items").rows.size)
}
// ---- composite FOREIGN KEY referencing a composite UNIQUE (not the PRIMARY KEY) ----
@Test
fun compositeForeignKeyMayReferenceACompositeUniqueConstraintInsteadOfThePrimaryKey() {
run(
"CREATE TABLE products (id INTEGER PRIMARY KEY, brand TEXT, model TEXT, UNIQUE (brand, model))"
)
run(
"CREATE TABLE reviews (id INTEGER PRIMARY KEY, brand TEXT, model TEXT, stars INTEGER, " +
"FOREIGN KEY (brand, model) REFERENCES products (brand, model))"
)
run("INSERT INTO products VALUES (1, 'acme', 'x100')")
val result = exec("INSERT INTO reviews VALUES (1, 'acme', 'x100', 5)")
assertEquals(InsertResult(1), result)
assertFailsWith<KestrelException> {
run("INSERT INTO reviews VALUES (2, 'acme', 'missing', 4)")
}
}
// ---- self-referencing composite FOREIGN KEY ----
@Test
fun selfReferencingCompositeForeignKeySeedableInASingleMultiRowInsert() {
run(
"CREATE TABLE edges (" +
"tenant TEXT, node INTEGER, parent_tenant TEXT, parent_node INTEGER, " +
"PRIMARY KEY (tenant, node), " +
"FOREIGN KEY (parent_tenant, parent_node) REFERENCES edges (tenant, node))"
)
// The root's parent columns are NULL (no parent); child references the root, all seeded
// together in one INSERT so the child must see its not-yet-committed sibling.
run(
"INSERT INTO edges VALUES " +
"('t1', 1, NULL, NULL), ('t1', 2, 't1', 1), ('t1', 3, 't1', 2)"
)
assertEquals(3, select("SELECT * FROM edges").rows.size)
}
// ---- SqlWriter round-trip ----
@Test
fun createTableWithCompositePrimaryKeyRoundTripsThroughSqlWriter() {
val statement = parseOne(
"CREATE TABLE line_items (order_id INTEGER, sku TEXT, qty INTEGER, PRIMARY KEY (order_id, sku))"
)
run(SqlWriter.write(statement))
run("INSERT INTO line_items VALUES (1, 'A', 3)")
assertFailsWith<KestrelException> { run("INSERT INTO line_items VALUES (1, 'A', 9)") }
}
@Test
fun createTableWithCompositeForeignKeyRoundTripsThroughSqlWriter() {
run(SqlWriter.write(parseOne("CREATE TABLE orders (region TEXT, order_no INTEGER, PRIMARY KEY (region, order_no))")))
run(
SqlWriter.write(
parseOne(
"CREATE TABLE line_items (region TEXT, order_no INTEGER, sku TEXT, " +
"FOREIGN KEY (region, order_no) REFERENCES orders (region, order_no) ON DELETE CASCADE)"
)
)
)
run("INSERT INTO orders VALUES ('eu', 1)")
run("INSERT INTO line_items VALUES ('eu', 1, 'SKU1')")
run("DELETE FROM orders WHERE region = 'eu'")
assertTrue(select("SELECT * FROM line_items").rows.isEmpty())
}
@Test
fun dumpAndReplayPreservesCompositePrimaryKeyAndForeignKey() {
seedOrdersAndLineItems()
run("INSERT INTO line_items VALUES ('eu', 1, 'SKU1', 3)")
val dump = SqlWriter.dump(catalog)
val freshCatalog = Catalog()
val freshExecutor = Executor(freshCatalog)
Parser(Lexer(dump).tokenize()).parseProgram().forEach { freshExecutor.execute(it) }
assertFailsWith<KestrelException> {
Parser(Lexer("INSERT INTO orders VALUES ('eu', 1)").tokenize()).parseProgram()
.forEach { freshExecutor.execute(it) }
}
Parser(Lexer("DELETE FROM orders WHERE region = 'eu'").tokenize()).parseProgram()
.forEach { freshExecutor.execute(it) }
val remaining = freshExecutor.execute(
Parser(Lexer("SELECT * FROM line_items").tokenize()).parseProgram().single()
) as SelectResult
assertTrue(remaining.rows.isEmpty())
}
}
@@ -0,0 +1,254 @@
package kestrel
import kestrel.catalog.Catalog
import kestrel.engine.ExplainResult
import kestrel.engine.Executor
import kestrel.engine.SelectResult
import kestrel.engine.StatementResult
import kestrel.sql.lexer.Lexer
import kestrel.sql.parser.Parser
import kestrel.storage.Value
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class CompoundQueryTest {
private val catalog = Catalog()
private val executor = Executor(catalog)
private fun run(sql: String): List<StatementResult> =
Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) }
private fun select(sql: String): SelectResult = run(sql).single() as SelectResult
private fun int(value: Long) = Value.IntegerValue(value)
private fun text(value: String) = Value.TextValue(value)
private fun seed() {
run("CREATE TABLE dual (x INTEGER)")
run("INSERT INTO dual VALUES (1)")
run("CREATE TABLE t1 (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
run("CREATE TABLE t2 (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
run("INSERT INTO t1 VALUES (1, 'ada'), (2, 'linus')")
run("INSERT INTO t2 VALUES (2, 'linus'), (3, 'grace')")
}
@Test
fun unionRemovesDuplicateRows() {
seed()
val result = select("SELECT name FROM t1 UNION SELECT name FROM t2 ORDER BY name")
assertEquals(listOf(listOf(text("ada")), listOf(text("grace")), listOf(text("linus"))), result.rows)
}
@Test
fun unionAllKeepsDuplicateRows() {
seed()
val result = select("SELECT name FROM t1 UNION ALL SELECT name FROM t2 ORDER BY name")
assertEquals(
listOf(listOf(text("ada")), listOf(text("grace")), listOf(text("linus")), listOf(text("linus"))),
result.rows
)
}
@Test
fun intersectReturnsRowsPresentInBothQueries() {
seed()
val result = select("SELECT name FROM t1 INTERSECT SELECT name FROM t2")
assertEquals(listOf(listOf(text("linus"))), result.rows)
}
@Test
fun exceptReturnsRowsOnlyInTheLeftQuery() {
seed()
val result = select("SELECT name FROM t1 EXCEPT SELECT name FROM t2")
assertEquals(listOf(listOf(text("ada"))), result.rows)
}
@Test
fun intersectAllKeepsDuplicatesUpToTheMinimumCountInEitherSide() {
run("CREATE TABLE bag_a (v INTEGER)")
run("CREATE TABLE bag_b (v INTEGER)")
run("INSERT INTO bag_a VALUES (1), (1), (1), (2)")
run("INSERT INTO bag_b VALUES (1), (1), (2), (2), (3)")
val result = select("SELECT v FROM bag_a INTERSECT ALL SELECT v FROM bag_b ORDER BY v")
assertEquals(listOf(listOf(int(1)), listOf(int(1)), listOf(int(2))), result.rows)
}
@Test
fun exceptAllKeepsLeftDuplicatesNotConsumedByTheRightSide() {
run("CREATE TABLE bag_a (v INTEGER)")
run("CREATE TABLE bag_b (v INTEGER)")
run("INSERT INTO bag_a VALUES (1), (1), (1), (2)")
run("INSERT INTO bag_b VALUES (1), (1), (2), (2), (3)")
val result = select("SELECT v FROM bag_a EXCEPT ALL SELECT v FROM bag_b ORDER BY v")
assertEquals(listOf(listOf(int(1))), result.rows)
}
@Test
fun mismatchedColumnCountsFail() {
seed()
assertFailsWith<KestrelException> {
select("SELECT id, name FROM t1 UNION SELECT id FROM t2")
}
}
@Test
fun compoundOperatorsChainLeftAssociatively() {
seed()
run("CREATE TABLE t3 (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
run("INSERT INTO t3 VALUES (2, 'linus')")
val result = select("SELECT name FROM t1 UNION ALL SELECT name FROM t2 INTERSECT SELECT name FROM t3 ORDER BY name")
assertEquals(listOf(listOf(text("linus"))), result.rows)
}
@Test
fun orderByAndLimitApplyToTheWholeCompoundResult() {
seed()
val result = select("SELECT name FROM t1 UNION ALL SELECT name FROM t2 ORDER BY name DESC LIMIT 2")
assertEquals(listOf(listOf(text("linus")), listOf(text("linus"))), result.rows)
}
@Test
fun offsetSkipsRowsOfTheWholeCompoundResultBeforeLimitApplies() {
seed()
val result = select("SELECT name FROM t1 UNION ALL SELECT name FROM t2 ORDER BY name DESC LIMIT 2 OFFSET 1")
assertEquals(listOf(listOf(text("linus")), listOf(text("grace"))), result.rows)
}
@Test
fun compoundQueryWorksAsDerivedTableInFrom() {
seed()
val result = select("SELECT count(*) FROM (SELECT name FROM t1 UNION SELECT name FROM t2) AS u")
assertEquals(listOf(listOf(int(3))), result.rows)
}
@Test
fun compoundQueryWorksAsViewBody() {
seed()
run("CREATE VIEW combined AS SELECT name FROM t1 UNION SELECT name FROM t2")
val result = select("SELECT name FROM combined ORDER BY name")
assertEquals(listOf(listOf(text("ada")), listOf(text("grace")), listOf(text("linus"))), result.rows)
run("INSERT INTO t1 VALUES (4, 'margaret')")
val refreshed = select("SELECT name FROM combined ORDER BY name")
assertEquals(4, refreshed.rows.size)
}
@Test
fun explainDescribesCompoundQuery() {
seed()
val plan = (run("EXPLAIN SELECT name FROM t1 UNION SELECT name FROM t2").single() as ExplainResult).plan
assertTrue(plan.isNotEmpty())
assertTrue(plan.any { it.contains("Union") })
}
@Test
fun withClauseMakesACteAvailableInFrom() {
seed()
run("WITH recent AS (SELECT id, name FROM t1) SELECT name FROM recent WHERE id = 1")
val result = select("WITH recent AS (SELECT id, name FROM t1) SELECT name FROM recent WHERE id = 1")
assertEquals(listOf(listOf(text("ada"))), result.rows)
}
@Test
fun laterCteCanReferenceAnEarlierCteInTheSameWithClause() {
seed()
val result = select(
"WITH first_cte AS (SELECT id, name FROM t1), second_cte AS (SELECT name FROM first_cte WHERE id = 2) " +
"SELECT name FROM second_cte"
)
assertEquals(listOf(listOf(text("linus"))), result.rows)
}
@Test
fun cteWithExplicitColumnListRenamesOutputColumns() {
seed()
val result = select("WITH renamed(label) AS (SELECT name FROM t1) SELECT label FROM renamed ORDER BY label")
assertEquals(listOf(listOf(text("ada")), listOf(text("linus"))), result.rows)
}
@Test
fun cteColumnListSizeMustMatchQueryColumnCount() {
seed()
assertFailsWith<KestrelException> {
select("WITH bad(a, b, c) AS (SELECT id FROM t1) SELECT * FROM bad")
}
}
@Test
fun recursiveCteGeneratesABoundedSequence() {
seed()
val result = select(
"WITH RECURSIVE counter(n) AS (" +
"SELECT 1 FROM dual " +
"UNION ALL " +
"SELECT n + 1 FROM counter WHERE n < 5" +
") SELECT n FROM counter ORDER BY n"
)
assertEquals((1..5).map { listOf(int(it.toLong())) }, result.rows)
}
@Test
fun recursiveCteAccumulatesARunningTotal() {
seed()
val result = select(
"WITH RECURSIVE sums(n, total) AS (" +
"SELECT 1, 1 FROM dual " +
"UNION ALL " +
"SELECT n + 1, total + n + 1 FROM sums WHERE n < 5" +
") SELECT total FROM sums WHERE n = 5"
)
assertEquals(listOf(listOf(int(15))), result.rows)
}
@Test
fun recursiveCteWithUnionDeduplicatesAndTerminatesOnACycle() {
seed()
run("CREATE TABLE edges (from_node INTEGER, to_node INTEGER)")
run("INSERT INTO edges VALUES (1, 2), (2, 3), (3, 1), (3, 4)")
val result = select(
"WITH RECURSIVE reachable(node) AS (" +
"SELECT 1 FROM dual " +
"UNION " +
"SELECT edges.to_node FROM edges JOIN reachable ON edges.from_node = reachable.node" +
") SELECT node FROM reachable ORDER BY node"
)
assertEquals(listOf(1L, 2L, 3L, 4L).map { listOf(int(it)) }, result.rows)
}
@Test
fun recursiveCteWithoutSelfReferenceBehavesAsAPlainCompoundQuery() {
seed()
val result = select(
"WITH RECURSIVE notActuallyRecursive AS (SELECT 1 AS n FROM dual UNION ALL SELECT 2 FROM dual) " +
"SELECT n FROM notActuallyRecursive ORDER BY n"
)
assertEquals(listOf(listOf(int(1)), listOf(int(2))), result.rows)
}
@Test
fun recursiveCteReferencingItselfOnBothSidesFails() {
seed()
assertFailsWith<KestrelException> {
select(
"WITH RECURSIVE bad(n) AS (" +
"SELECT n FROM bad UNION ALL SELECT n FROM bad" +
") SELECT n FROM bad"
)
}
}
@Test
fun nonTerminatingRecursiveCteFailsInsteadOfLoopingForever() {
seed()
assertFailsWith<KestrelException> {
select(
"WITH RECURSIVE counter(n) AS (" +
"SELECT 1 FROM dual " +
"UNION ALL " +
"SELECT n + 1 FROM counter" +
") SELECT n FROM counter"
)
}
}
}
+114
View File
@@ -0,0 +1,114 @@
package kestrel
import kestrel.catalog.Catalog
import kestrel.engine.Executor
import kestrel.engine.SelectResult
import kestrel.engine.StatementResult
import kestrel.sql.lexer.Lexer
import kestrel.sql.parser.Parser
import kestrel.storage.Value
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class DerivedTableTest {
private val catalog = Catalog()
private val executor = Executor(catalog)
private fun run(sql: String): List<StatementResult> =
Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) }
private fun select(sql: String): SelectResult = run(sql).single() as SelectResult
private fun int(value: Long) = Value.IntegerValue(value)
private fun text(value: String) = Value.TextValue(value)
private fun seedUsers() {
run("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER, dept TEXT)")
run(
"INSERT INTO users VALUES " +
"(1, 'ada', 36, 'eng'), (2, 'linus', 54, 'eng'), (3, 'grace', 85, 'navy'), (4, 'alan', 41, NULL)"
)
}
@Test
fun selectsFromASimpleDerivedTable() {
seedUsers()
val result = select("SELECT s.name FROM (SELECT id, name FROM users WHERE age >= 40) AS s ORDER BY s.id ASC")
assertEquals(listOf(text("linus"), text("grace"), text("alan")), result.rows.map { it.single() })
}
@Test
fun derivedTableOverAnAggregateQueryWorks() {
seedUsers()
val result = select(
"SELECT d.dept, d.n FROM (SELECT dept, COUNT(*) AS n FROM users GROUP BY dept) AS d " +
"WHERE d.n > 1 ORDER BY d.dept ASC"
)
assertEquals(listOf(listOf(text("eng"), int(2))), result.rows)
}
@Test
fun derivedTableCanBeJoinedWithARealTable() {
seedUsers()
run("CREATE TABLE bonuses (dept TEXT, amount INTEGER)")
run("INSERT INTO bonuses VALUES ('eng', 1000), ('navy', 500)")
val result = select(
"SELECT d.dept, b.amount FROM (SELECT dept, COUNT(*) AS n FROM users GROUP BY dept) AS d " +
"JOIN bonuses AS b ON d.dept = b.dept ORDER BY d.dept ASC"
)
assertEquals(listOf(listOf(text("eng"), int(1000)), listOf(text("navy"), int(500))), result.rows)
}
@Test
fun twoDerivedTablesCanBeJoinedTogether() {
seedUsers()
val result = select(
"SELECT a.dept, a.n FROM (SELECT dept, COUNT(*) AS n FROM users GROUP BY dept) AS a " +
"JOIN (SELECT dept, COUNT(*) AS n FROM users GROUP BY dept) AS b ON a.dept = b.dept " +
"WHERE a.n = b.n ORDER BY a.dept ASC"
)
assertEquals(listOf(listOf(text("eng"), int(2)), listOf(text("navy"), int(1))), result.rows)
}
@Test
fun derivedTableColumnsAreScopedToItsAliasNotTheUnderlyingTableName() {
seedUsers()
assertFailsWith<KestrelException> { run("SELECT users.name FROM (SELECT id, name FROM users) AS s") }
}
@Test
fun derivedTableIsReEvaluatedEachQueryReflectingLiveData() {
seedUsers()
val before = select("SELECT COUNT(*) AS n FROM (SELECT id FROM users WHERE dept = 'eng') AS e")
assertEquals(listOf(listOf(int(2))), before.rows)
run("INSERT INTO users VALUES (5, 'margaret', 61, 'eng')")
val after = select("SELECT COUNT(*) AS n FROM (SELECT id FROM users WHERE dept = 'eng') AS e")
assertEquals(listOf(listOf(int(3))), after.rows)
}
@Test
fun derivedTableCanContainACorrelatedSubqueryOfItsOwn() {
seedUsers()
val result = select(
"SELECT s.name FROM (SELECT id, name, dept FROM users u WHERE age > " +
"(SELECT AVG(age) FROM users u2 WHERE u2.dept = u.dept)) AS s ORDER BY s.id ASC"
)
assertEquals(listOf(text("linus")), result.rows.map { it.single() })
}
@Test
fun explainDescribesADerivedTableScan() {
seedUsers()
val explain = run("EXPLAIN SELECT s.name FROM (SELECT id, name FROM users) AS s").single() as kestrel.engine.ExplainResult
assertEquals(true, explain.plan.any { it.contains("Seq Scan on s") })
}
@Test
fun creatingAViewFromADerivedTableWorks() {
seedUsers()
run("CREATE VIEW dept_counts AS SELECT d.dept, d.n FROM (SELECT dept, COUNT(*) AS n FROM users GROUP BY dept) AS d")
val result = select("SELECT dept, n FROM dept_counts WHERE n > 1")
assertEquals(listOf(listOf(text("eng"), int(2))), result.rows)
}
}
+182
View File
@@ -0,0 +1,182 @@
package kestrel
import kestrel.catalog.Catalog
import kestrel.engine.Executor
import kestrel.engine.ExplainResult
import kestrel.engine.SelectResult
import kestrel.engine.StatementResult
import kestrel.sql.lexer.Lexer
import kestrel.sql.parser.Parser
import kestrel.storage.Value
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class DistinctTest {
private val catalog = Catalog()
private val executor = Executor(catalog)
private fun run(sql: String): List<StatementResult> =
Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) }
private fun select(sql: String): SelectResult = run(sql).single() as SelectResult
private fun int(value: Long) = Value.IntegerValue(value)
private fun text(value: String) = Value.TextValue(value)
private fun seed() {
run("CREATE TABLE sales (dept TEXT, region TEXT, amount INTEGER)")
run(
"INSERT INTO sales VALUES " +
"('eng', 'eu', 100), ('eng', 'us', 200), ('eng', 'eu', 100), " +
"('ops', 'eu', 50), ('ops', 'eu', 70), ('ops', 'eu', 50)"
)
}
@Test
fun distinctRemovesDuplicateOutputRows() {
seed()
val result = select("SELECT DISTINCT dept FROM sales ORDER BY dept")
assertEquals(listOf(listOf(text("eng")), listOf(text("ops"))), result.rows)
}
@Test
fun distinctConsidersTheWholeProjectedRow() {
seed()
val result = select("SELECT DISTINCT dept, region FROM sales ORDER BY dept, region")
assertEquals(
listOf(listOf(text("eng"), text("eu")), listOf(text("eng"), text("us")), listOf(text("ops"), text("eu"))),
result.rows
)
}
@Test
fun selectAllIsTheDefaultAndKeepsDuplicates() {
seed()
assertEquals(6, select("SELECT ALL dept FROM sales").rows.size)
assertEquals(6, select("SELECT dept FROM sales").rows.size)
}
@Test
fun distinctStarDedupsOnEveryColumn() {
seed()
val result = select("SELECT DISTINCT * FROM sales ORDER BY dept, region, amount")
assertEquals(4, result.rows.size)
}
@Test
fun distinctWithLimitCountsDistinctRowsNotSourceRows() {
seed()
val result = select("SELECT DISTINCT dept FROM sales ORDER BY dept LIMIT 1")
assertEquals(listOf(listOf(text("eng"))), result.rows)
}
@Test
fun distinctWithOffsetSkipsDistinctRowsNotSourceRows() {
seed()
val result = select("SELECT DISTINCT dept FROM sales ORDER BY dept LIMIT 1 OFFSET 1")
assertEquals(listOf(listOf(text("ops"))), result.rows)
}
@Test
fun distinctCombinesWithGroupBy() {
run("CREATE TABLE t (a INTEGER, b INTEGER)")
run("INSERT INTO t VALUES (1, 10), (1, 20), (2, 10)")
// GROUP BY a, b produces three distinct groups; DISTINCT on the projected `a` alone
// collapses the two groups sharing a=1 down to one output row.
val result = select("SELECT DISTINCT a FROM t GROUP BY a, b ORDER BY a")
assertEquals(listOf(listOf(int(1)), listOf(int(2))), result.rows)
}
@Test
fun distinctOnKeepsFirstRowPerGroupInOrderByOrder() {
seed()
val result = select("SELECT DISTINCT ON (dept) dept, amount FROM sales ORDER BY dept, amount DESC")
assertEquals(listOf(listOf(text("eng"), int(200)), listOf(text("ops"), int(70))), result.rows)
}
@Test
fun distinctOnWithoutOrderByUsesScanOrder() {
run("CREATE TABLE t (k TEXT, v INTEGER)")
run("INSERT INTO t VALUES ('a', 1), ('a', 2), ('b', 3)")
val result = select("SELECT DISTINCT ON (k) k, v FROM t")
assertEquals(listOf(listOf(text("a"), int(1)), listOf(text("b"), int(3))), result.rows)
}
@Test
fun distinctOnStarProjectsEveryColumnOfTheKeptRow() {
seed()
val result = select("SELECT DISTINCT ON (dept) * FROM sales ORDER BY dept, amount DESC")
assertEquals(listOf("dept", "region", "amount"), result.columns)
assertEquals(listOf(text("eng"), text("us"), int(200)), result.rows[0])
assertEquals(listOf(text("ops"), text("eu"), int(70)), result.rows[1])
}
@Test
fun distinctOnRequiresOrderByToStartWithItsOwnExpressions() {
seed()
assertFailsWith<KestrelException> {
select("SELECT DISTINCT ON (dept) dept, amount FROM sales ORDER BY amount")
}
}
@Test
fun distinctOnPermitsAnOrderByThatOnlyMatchesItsLeadingExpressions() {
seed()
val result = select("SELECT DISTINCT ON (dept) dept, amount FROM sales ORDER BY dept, region, amount DESC")
assertEquals(2, result.rows.size)
}
@Test
fun distinctOnWithNoOrderByAtAllIsAllowed() {
seed()
val result = select("SELECT DISTINCT ON (dept) dept FROM sales")
assertEquals(2, result.rows.size)
}
@Test
fun distinctOnRejectsGroupBy() {
seed()
assertFailsWith<KestrelException> {
select("SELECT DISTINCT ON (dept) dept, SUM(amount) FROM sales GROUP BY dept")
}
}
@Test
fun distinctOnRejectsAnUnknownColumn() {
seed()
assertFailsWith<KestrelException> {
select("SELECT DISTINCT ON (nope) dept FROM sales")
}
}
@Test
fun distinctOnRejectsAWindowFunction() {
seed()
assertFailsWith<KestrelException> {
select("SELECT DISTINCT ON (ROW_NUMBER() OVER (ORDER BY amount)) dept FROM sales")
}
}
@Test
fun explainShowsUniqueForDistinct() {
seed()
val plan = (run("EXPLAIN SELECT DISTINCT dept FROM sales").single() as ExplainResult).plan
assertEquals("Unique", plan.first())
}
@Test
fun explainShowsUniqueDistinctOnForDistinctOn() {
seed()
val plan = (run("EXPLAIN SELECT DISTINCT ON (dept) dept FROM sales ORDER BY dept").single() as ExplainResult).plan
assertEquals("Unique (DISTINCT ON)", plan.first())
}
@Test
fun explainShowsLimitOutermostAndOffsetJustInside() {
seed()
val plan = (run("EXPLAIN SELECT dept FROM sales ORDER BY dept LIMIT 5 OFFSET 2").single() as ExplainResult).plan
assertEquals("Limit 5", plan[0])
assertEquals(" Offset 2", plan[1])
}
}
+454
View File
@@ -1,12 +1,22 @@
package kestrel
import java.math.BigDecimal
import java.time.LocalDate
import kestrel.catalog.Column
import kestrel.catalog.TableSchema
import kestrel.engine.Evaluator
import kestrel.sql.ast.BetweenExpression
import kestrel.sql.ast.BinaryExpression
import kestrel.sql.ast.BinaryOperator
import kestrel.sql.ast.CallExpression
import kestrel.sql.ast.CaseBranch
import kestrel.sql.ast.CaseExpression
import kestrel.sql.ast.CastExpression
import kestrel.sql.ast.ColumnExpression
import kestrel.sql.ast.Expression
import kestrel.sql.ast.InExpression
import kestrel.sql.ast.IsNullExpression
import kestrel.sql.ast.LikeExpression
import kestrel.sql.ast.LiteralExpression
import kestrel.sql.ast.UnaryExpression
import kestrel.sql.ast.UnaryOperator
@@ -106,4 +116,448 @@ class EvaluatorTest {
fun logicalOperatorOnNumberFails() {
assertFailsWith<KestrelException> { evalConstant(bin(BinaryOperator.And, intLit(1), trueLit)) }
}
@Test
fun stringFunctionsTransformText() {
assertEquals(Value.TextValue("ADA"), evalConstant(CallExpression("UPPER", listOf(textLit("ada")))))
assertEquals(Value.TextValue("ada"), evalConstant(CallExpression("LOWER", listOf(textLit("ADA")))))
assertEquals(Value.IntegerValue(3), evalConstant(CallExpression("LENGTH", listOf(textLit("ada")))))
assertEquals(Value.TextValue("ada"), evalConstant(CallExpression("TRIM", listOf(textLit(" ada ")))))
}
@Test
fun substrHandlesTwoAndThreeArgumentForms() {
assertEquals(
Value.TextValue("ell"),
evalConstant(CallExpression("SUBSTR", listOf(textLit("hello"), intLit(2), intLit(3))))
)
assertEquals(Value.TextValue("llo"), evalConstant(CallExpression("SUBSTR", listOf(textLit("hello"), intLit(3)))))
assertEquals(Value.TextValue(""), evalConstant(CallExpression("SUBSTR", listOf(textLit("hello"), intLit(99)))))
}
@Test
fun replaceAndConcat() {
assertEquals(
Value.TextValue("hexxo"),
evalConstant(CallExpression("REPLACE", listOf(textLit("hello"), textLit("l"), textLit("x"))))
)
assertEquals(
Value.TextValue("ab-cd"),
evalConstant(CallExpression("CONCAT", listOf(textLit("ab"), textLit("-"), textLit("cd"))))
)
}
@Test
fun numericFunctionsComputeCorrectly() {
assertEquals(Value.IntegerValue(4), evalConstant(CallExpression("ABS", listOf(intLit(-4)))))
assertEquals(Value.RealValue(4.0), evalConstant(CallExpression("ABS", listOf(realLit(-4.0)))))
assertEquals(Value.IntegerValue(3), evalConstant(CallExpression("CEIL", listOf(realLit(2.1)))))
assertEquals(Value.IntegerValue(2), evalConstant(CallExpression("FLOOR", listOf(realLit(2.9)))))
assertEquals(Value.RealValue(2.35), evalConstant(CallExpression("ROUND", listOf(realLit(2.346), intLit(2)))))
assertEquals(Value.IntegerValue(5), evalConstant(CallExpression("ROUND", listOf(intLit(5)))))
}
@Test
fun nullHandlingFunctionsSeeRawNulls() {
assertEquals(Value.IntegerValue(7), evalConstant(CallExpression("COALESCE", listOf(nullLit, nullLit, intLit(7)))))
assertEquals(Value.NullValue, evalConstant(CallExpression("COALESCE", listOf(nullLit, nullLit))))
assertEquals(Value.IntegerValue(1), evalConstant(CallExpression("IFNULL", listOf(intLit(1), intLit(2)))))
assertEquals(Value.IntegerValue(2), evalConstant(CallExpression("IFNULL", listOf(nullLit, intLit(2)))))
assertEquals(Value.NullValue, evalConstant(CallExpression("NULLIF", listOf(intLit(5), intLit(5)))))
assertEquals(Value.IntegerValue(5), evalConstant(CallExpression("NULLIF", listOf(intLit(5), intLit(6)))))
assertEquals(Value.TextValue("NULL"), evalConstant(CallExpression("TYPEOF", listOf(nullLit))))
assertEquals(Value.TextValue("INTEGER"), evalConstant(CallExpression("TYPEOF", listOf(intLit(1)))))
}
@Test
fun mostFunctionsPropagateNullArguments() {
assertEquals(Value.NullValue, evalConstant(CallExpression("UPPER", listOf(nullLit))))
assertEquals(Value.NullValue, evalConstant(CallExpression("ABS", listOf(nullLit))))
}
@Test
fun functionArityIsEnforced() {
assertFailsWith<KestrelException> { evalConstant(CallExpression("UPPER", listOf(textLit("a"), textLit("b")))) }
assertFailsWith<KestrelException> { evalConstant(CallExpression("SUBSTR", listOf(textLit("a")))) }
}
@Test
fun unknownFunctionFails() {
assertFailsWith<KestrelException> { evalConstant(CallExpression("NOPE", listOf(intLit(1)))) }
}
@Test
fun currentDateReturnsTodaysWallClockDate() {
assertEquals(Value.DateValue(LocalDate.now()), evalConstant(CallExpression("CURRENT_DATE", emptyList())))
}
@Test
fun dateComponentFunctionsExtractYearMonthDay() {
val date = dateLit(LocalDate.of(2024, 3, 15))
assertEquals(Value.IntegerValue(2024), evalConstant(CallExpression("YEAR", listOf(date))))
assertEquals(Value.IntegerValue(3), evalConstant(CallExpression("MONTH", listOf(date))))
assertEquals(Value.IntegerValue(15), evalConstant(CallExpression("DAY", listOf(date))))
assertEquals(Value.IntegerValue(5), evalConstant(CallExpression("DAYOFWEEK", listOf(date))))
}
@Test
fun dateComponentFunctionsPropagateNullAndRejectNonDateArguments() {
assertEquals(Value.NullValue, evalConstant(CallExpression("YEAR", listOf(nullLit))))
assertFailsWith<KestrelException> { evalConstant(CallExpression("MONTH", listOf(intLit(1)))) }
}
@Test
fun dateAddAndDateSubShiftByWholeDaysInEitherDirection() {
val date = dateLit(LocalDate.of(2024, 3, 15))
assertEquals(
Value.DateValue(LocalDate.of(2024, 3, 25)),
evalConstant(CallExpression("DATE_ADD", listOf(date, intLit(10))))
)
assertEquals(
Value.DateValue(LocalDate.of(2024, 3, 5)),
evalConstant(CallExpression("DATE_SUB", listOf(date, intLit(10))))
)
// Crossing a month boundary exercises java.time's own carry logic rather than hand-rolled arithmetic.
assertEquals(
Value.DateValue(LocalDate.of(2024, 4, 2)),
evalConstant(CallExpression("DATE_ADD", listOf(date, intLit(18))))
)
assertEquals(
Value.DateValue(LocalDate.of(2024, 3, 9)),
evalConstant(CallExpression("DATE_ADD", listOf(date, intLit(-6))))
)
}
@Test
fun datediffReturnsTheSignedDayCountBetweenTwoDates() {
val later = dateLit(LocalDate.of(2024, 3, 20))
val earlier = dateLit(LocalDate.of(2024, 3, 15))
assertEquals(Value.IntegerValue(5), evalConstant(CallExpression("DATEDIFF", listOf(later, earlier))))
assertEquals(Value.IntegerValue(-5), evalConstant(CallExpression("DATEDIFF", listOf(earlier, later))))
assertEquals(Value.IntegerValue(0), evalConstant(CallExpression("DATEDIFF", listOf(later, later))))
}
@Test
fun repeatBuildsTheStringNTimesAndTreatsNonPositiveCountsAsEmpty() {
assertEquals(Value.TextValue("ababab"), evalConstant(CallExpression("REPEAT", listOf(textLit("ab"), intLit(3)))))
assertEquals(Value.TextValue(""), evalConstant(CallExpression("REPEAT", listOf(textLit("ab"), intLit(0)))))
assertEquals(Value.TextValue(""), evalConstant(CallExpression("REPEAT", listOf(textLit("ab"), intLit(-1)))))
}
@Test
fun lpadAndRpadPadToTheTargetLengthOrTruncateWhenAlreadyLongEnough() {
assertEquals(Value.TextValue("--ab"), evalConstant(CallExpression("LPAD", listOf(textLit("ab"), intLit(4), textLit("-")))))
assertEquals(Value.TextValue("ab--"), evalConstant(CallExpression("RPAD", listOf(textLit("ab"), intLit(4), textLit("-")))))
assertEquals(Value.TextValue(" ab"), evalConstant(CallExpression("LPAD", listOf(textLit("ab"), intLit(4)))))
assertEquals(Value.TextValue("he"), evalConstant(CallExpression("LPAD", listOf(textLit("hello"), intLit(2), textLit("-")))))
assertEquals(Value.TextValue("he"), evalConstant(CallExpression("RPAD", listOf(textLit("hello"), intLit(2), textLit("-")))))
assertEquals(Value.TextValue(""), evalConstant(CallExpression("LPAD", listOf(textLit("hello"), intLit(0), textLit("-")))))
}
@Test
fun lpadCyclesAMultiCharacterPadStringAndTrimsItToExactlyFitTheTargetLength() {
assertEquals(Value.TextValue("xyxab"), evalConstant(CallExpression("LPAD", listOf(textLit("ab"), intLit(5), textLit("xy")))))
}
@Test
fun padFunctionsRejectAnEmptyPadStringWhenPaddingIsActuallyNeeded() {
assertFailsWith<KestrelException> {
evalConstant(CallExpression("LPAD", listOf(textLit("ab"), intLit(4), textLit(""))))
}
}
@Test
fun instrReturnsAOneBasedPositionOrZeroWhenNotFound() {
assertEquals(Value.IntegerValue(3), evalConstant(CallExpression("INSTR", listOf(textLit("hello"), textLit("llo")))))
assertEquals(Value.IntegerValue(1), evalConstant(CallExpression("INSTR", listOf(textLit("hello"), textLit("h")))))
assertEquals(Value.IntegerValue(0), evalConstant(CallExpression("INSTR", listOf(textLit("hello"), textLit("z")))))
}
@Test
fun powerStaysExactForTwoIntegersButFallsBackToRealOtherwise() {
assertEquals(Value.IntegerValue(1024), evalConstant(CallExpression("POWER", listOf(intLit(2), intLit(10)))))
assertEquals(Value.IntegerValue(1), evalConstant(CallExpression("POWER", listOf(intLit(5), intLit(0)))))
assertEquals(Value.RealValue(0.25), evalConstant(CallExpression("POWER", listOf(intLit(2), intLit(-2)))))
assertEquals(Value.RealValue(6.25), evalConstant(CallExpression("POWER", listOf(realLit(2.5), intLit(2)))))
}
@Test
fun modComputesRemainderAndRejectsDivisionByZero() {
assertEquals(Value.IntegerValue(1), evalConstant(CallExpression("MOD", listOf(intLit(7), intLit(3)))))
assertEquals(Value.IntegerValue(-1), evalConstant(CallExpression("MOD", listOf(intLit(-7), intLit(3)))))
assertEquals(Value.RealValue(0.5), evalConstant(CallExpression("MOD", listOf(realLit(2.5), intLit(1)))))
assertFailsWith<KestrelException> { evalConstant(CallExpression("MOD", listOf(intLit(1), intLit(0)))) }
}
@Test
fun reverseFlipsTheCharactersOfAString() {
assertEquals(Value.TextValue("cba"), evalConstant(CallExpression("REVERSE", listOf(textLit("abc")))))
assertEquals(Value.TextValue(""), evalConstant(CallExpression("REVERSE", listOf(textLit("")))))
assertFailsWith<KestrelException> { evalConstant(CallExpression("REVERSE", listOf(intLit(1)))) }
}
@Test
fun leftAndRightTakeCharactersFromEachEndAndClampToTheStringsLength() {
assertEquals(Value.TextValue("hel"), evalConstant(CallExpression("LEFT", listOf(textLit("hello"), intLit(3)))))
assertEquals(Value.TextValue("llo"), evalConstant(CallExpression("RIGHT", listOf(textLit("hello"), intLit(3)))))
assertEquals(Value.TextValue("hello"), evalConstant(CallExpression("LEFT", listOf(textLit("hello"), intLit(100)))))
assertEquals(Value.TextValue("hello"), evalConstant(CallExpression("RIGHT", listOf(textLit("hello"), intLit(100)))))
assertEquals(Value.TextValue(""), evalConstant(CallExpression("LEFT", listOf(textLit("hello"), intLit(0)))))
assertEquals(Value.TextValue(""), evalConstant(CallExpression("RIGHT", listOf(textLit("hello"), intLit(-1)))))
}
@Test
fun signReturnsMinusOneZeroOrOneAcrossEveryNumericType() {
assertEquals(Value.IntegerValue(1), evalConstant(CallExpression("SIGN", listOf(intLit(5)))))
assertEquals(Value.IntegerValue(-1), evalConstant(CallExpression("SIGN", listOf(intLit(-5)))))
assertEquals(Value.IntegerValue(0), evalConstant(CallExpression("SIGN", listOf(intLit(0)))))
assertEquals(Value.IntegerValue(1), evalConstant(CallExpression("SIGN", listOf(realLit(0.1)))))
assertEquals(Value.IntegerValue(-1), evalConstant(CallExpression("SIGN", listOf(realLit(-0.1)))))
val negativeDecimal = LiteralExpression(Value.DecimalValue(BigDecimal("-3.50")))
assertEquals(Value.IntegerValue(-1), evalConstant(CallExpression("SIGN", listOf(negativeDecimal))))
assertFailsWith<KestrelException> { evalConstant(CallExpression("SIGN", listOf(textLit("x")))) }
}
@Test
fun truncCutsDigitsTowardZeroWithoutRoundingUnlikeRound() {
assertEquals(Value.IntegerValue(4), evalConstant(CallExpression("TRUNC", listOf(intLit(4)))))
assertEquals(Value.RealValue(1.9), evalConstant(CallExpression("TRUNC", listOf(realLit(1.99), intLit(1)))))
assertEquals(Value.RealValue(-1.9), evalConstant(CallExpression("TRUNC", listOf(realLit(-1.99), intLit(1)))))
val decimal = LiteralExpression(Value.DecimalValue(BigDecimal("1.999")))
assertEquals(
Value.DecimalValue(BigDecimal("1.99")),
evalConstant(CallExpression("TRUNC", listOf(decimal, intLit(2))))
)
// TRUNC never rounds up, exactly the case where it must differ from ROUND's HALF_UP default.
assertEquals(Value.RealValue(1.0), evalConstant(CallExpression("TRUNC", listOf(realLit(1.9)))))
assertEquals(Value.RealValue(2.0), evalConstant(CallExpression("ROUND", listOf(realLit(1.9)))))
}
@Test
fun greatestAndLeastPickTheOrderingExtremeAcrossMixedNumericTypes() {
assertEquals(
Value.IntegerValue(7),
evalConstant(CallExpression("GREATEST", listOf(intLit(3), intLit(7), intLit(1))))
)
assertEquals(
Value.IntegerValue(1),
evalConstant(CallExpression("LEAST", listOf(intLit(3), intLit(7), intLit(1))))
)
assertEquals(
Value.RealValue(7.5),
evalConstant(CallExpression("GREATEST", listOf(intLit(3), realLit(7.5), intLit(1))))
)
assertEquals(Value.TextValue("banana"), evalConstant(CallExpression("GREATEST", listOf(textLit("apple"), textLit("banana")))))
}
@Test
fun greatestAndLeastPropagateNullAndRequireAtLeastTwoArguments() {
assertEquals(Value.NullValue, evalConstant(CallExpression("GREATEST", listOf(intLit(1), nullLit))))
assertEquals(Value.NullValue, evalConstant(CallExpression("LEAST", listOf(nullLit, intLit(1)))))
assertFailsWith<KestrelException> { evalConstant(CallExpression("GREATEST", listOf(intLit(1)))) }
}
@Test
fun functionRequiringTextRejectsWrongType() {
assertFailsWith<KestrelException> { evalConstant(CallExpression("UPPER", listOf(intLit(1)))) }
}
@Test
fun isNullDetectsNullness() {
assertEquals(Value.BooleanValue(true), evalConstant(IsNullExpression(nullLit, negated = false)))
assertEquals(Value.BooleanValue(false), evalConstant(IsNullExpression(intLit(1), negated = false)))
assertEquals(Value.BooleanValue(true), evalConstant(IsNullExpression(intLit(1), negated = true)))
assertEquals(Value.BooleanValue(false), evalConstant(IsNullExpression(nullLit, negated = true)))
}
@Test
fun inMatchesAnyListMember() {
assertEquals(
Value.BooleanValue(true),
evalConstant(InExpression(intLit(2), listOf(intLit(1), intLit(2), intLit(3)), negated = false))
)
assertEquals(
Value.BooleanValue(false),
evalConstant(InExpression(intLit(9), listOf(intLit(1), intLit(2)), negated = false))
)
assertEquals(
Value.BooleanValue(false),
evalConstant(InExpression(intLit(2), listOf(intLit(1), intLit(2)), negated = true))
)
}
@Test
fun inWithNullOperandIsNull() {
assertEquals(Value.NullValue, evalConstant(InExpression(nullLit, listOf(intLit(1)), negated = false)))
}
@Test
fun inWithUnmatchedNullInListIsNullNotFalse() {
assertEquals(
Value.NullValue,
evalConstant(InExpression(intLit(9), listOf(intLit(1), nullLit), negated = false))
)
}
@Test
fun betweenIsInclusiveOnBothEnds() {
assertEquals(Value.BooleanValue(true), evalConstant(BetweenExpression(intLit(18), intLit(18), intLit(65), negated = false)))
assertEquals(Value.BooleanValue(true), evalConstant(BetweenExpression(intLit(65), intLit(18), intLit(65), negated = false)))
assertEquals(Value.BooleanValue(false), evalConstant(BetweenExpression(intLit(17), intLit(18), intLit(65), negated = false)))
assertEquals(Value.BooleanValue(true), evalConstant(BetweenExpression(intLit(17), intLit(18), intLit(65), negated = true)))
}
@Test
fun likeSupportsPercentAndUnderscoreWildcards() {
assertEquals(Value.BooleanValue(true), evalConstant(LikeExpression(textLit("hello"), textLit("h%"), negated = false)))
assertEquals(Value.BooleanValue(true), evalConstant(LikeExpression(textLit("hello"), textLit("h_llo"), negated = false)))
assertEquals(Value.BooleanValue(false), evalConstant(LikeExpression(textLit("hello"), textLit("world"), negated = false)))
assertEquals(Value.BooleanValue(false), evalConstant(LikeExpression(textLit("hello"), textLit("h%"), negated = true)))
}
@Test
fun likeEscapesRegexMetacharactersInPattern() {
assertEquals(Value.BooleanValue(true), evalConstant(LikeExpression(textLit("a.b"), textLit("a.b"), negated = false)))
assertEquals(Value.BooleanValue(false), evalConstant(LikeExpression(textLit("axb"), textLit("a.b"), negated = false)))
}
@Test
fun searchedCaseReturnsFirstMatchingBranch() {
val expression = CaseExpression(
operand = null,
branches = listOf(
CaseBranch(bin(BinaryOperator.Less, intLit(10), intLit(5)), textLit("small")),
CaseBranch(bin(BinaryOperator.GreaterEqual, intLit(10), intLit(5)), textLit("big"))
),
elseExpression = textLit("none")
)
assertEquals(Value.TextValue("big"), evalConstant(expression))
}
@Test
fun simpleCaseComparesOperandAgainstBranches() {
val expression = CaseExpression(
operand = intLit(2),
branches = listOf(CaseBranch(intLit(1), textLit("one")), CaseBranch(intLit(2), textLit("two"))),
elseExpression = textLit("other")
)
assertEquals(Value.TextValue("two"), evalConstant(expression))
}
@Test
fun caseFallsThroughToElseWhenNoBranchMatches() {
val expression = CaseExpression(
operand = null,
branches = listOf(CaseBranch(falseLit, textLit("nope"))),
elseExpression = textLit("fallback")
)
assertEquals(Value.TextValue("fallback"), evalConstant(expression))
}
@Test
fun caseWithoutElseYieldsNullWhenNoBranchMatches() {
val expression = CaseExpression(operand = null, branches = listOf(CaseBranch(falseLit, textLit("nope"))), elseExpression = null)
assertEquals(Value.NullValue, evalConstant(expression))
}
private fun cast(operand: Expression, type: ColumnType, precision: Int? = null, scale: Int? = null): Expression =
CastExpression(operand, type, precision, scale)
@Test
fun castTextToIntegerRealAndBoolean() {
assertEquals(Value.IntegerValue(42), evalConstant(cast(textLit("42"), ColumnType.Integer)))
assertEquals(Value.RealValue(3.5), evalConstant(cast(textLit("3.5"), ColumnType.Real)))
assertEquals(Value.BooleanValue(true), evalConstant(cast(textLit("true"), ColumnType.Boolean)))
assertEquals(Value.BooleanValue(false), evalConstant(cast(textLit("FALSE"), ColumnType.Boolean)))
}
@Test
fun castTextToDecimalAppliesScale() {
val result = evalConstant(cast(textLit("19.999"), ColumnType.Decimal, 10, 2)) as Value.DecimalValue
assertEquals("20.00", result.value.toPlainString())
}
@Test
fun castTextToDateParsesIsoFormat() {
val result = evalConstant(cast(textLit("2024-01-15"), ColumnType.Date))
assertEquals(Value.DateValue(LocalDate.of(2024, 1, 15)), result)
}
@Test
fun castNumbersAndDatesToText() {
assertEquals(Value.TextValue("42"), evalConstant(cast(intLit(42), ColumnType.Text)))
assertEquals(
Value.TextValue("2024-01-15"),
evalConstant(cast(LiteralExpression(Value.DateValue(LocalDate.of(2024, 1, 15))), ColumnType.Text))
)
}
@Test
fun castOfNullIsAlwaysNull() {
assertEquals(Value.NullValue, evalConstant(cast(nullLit, ColumnType.Integer)))
assertEquals(Value.NullValue, evalConstant(cast(nullLit, ColumnType.Decimal)))
}
@Test
fun castRejectsUnparseableText() {
assertFailsWith<KestrelException> { evalConstant(cast(textLit("not a number"), ColumnType.Integer)) }
assertFailsWith<KestrelException> { evalConstant(cast(textLit("not a date"), ColumnType.Date)) }
assertFailsWith<KestrelException> { evalConstant(cast(textLit("nope"), ColumnType.Boolean)) }
}
@Test
fun decimalOverflowBeyondDeclaredPrecisionFails() {
assertFailsWith<KestrelException> { evalConstant(cast(textLit("123456"), ColumnType.Decimal, 5, 0)) }
}
@Test
fun decimalArithmeticIsExactUnlikeDoubles() {
val a = cast(textLit("0.1"), ColumnType.Decimal, 10, 2)
val b = cast(textLit("0.2"), ColumnType.Decimal, 10, 2)
val sum = evalConstant(bin(BinaryOperator.Add, a, b)) as Value.DecimalValue
assertEquals("0.30", sum.value.toPlainString())
}
@Test
fun decimalEqualityIgnoresTrailingZeroScale() {
val fifteenTenths = LiteralExpression(Value.DecimalValue(BigDecimal("1.50")))
val fifteen = LiteralExpression(Value.DecimalValue(BigDecimal("1.5")))
assertEquals(Value.BooleanValue(true), evalConstant(bin(BinaryOperator.Equal, fifteenTenths, fifteen)))
}
@Test
fun decimalDivisionByZeroFails() {
val a = cast(textLit("1"), ColumnType.Decimal, 10, 2)
val zero = cast(textLit("0"), ColumnType.Decimal, 10, 2)
assertFailsWith<KestrelException> { evalConstant(bin(BinaryOperator.Divide, a, zero)) }
}
@Test
fun decimalComparesCorrectlyAgainstIntegerAndReal() {
val decimal = LiteralExpression(Value.DecimalValue(BigDecimal("19.99")))
assertEquals(Value.BooleanValue(true), evalConstant(bin(BinaryOperator.Less, decimal, intLit(20))))
assertEquals(Value.BooleanValue(true), evalConstant(bin(BinaryOperator.Greater, decimal, realLit(19.98))))
}
private fun dateLit(value: LocalDate): Expression = LiteralExpression(Value.DateValue(value))
@Test
fun addingAndSubtractingIntegerDaysFromADate() {
val start = dateLit(LocalDate.of(2024, 1, 1))
assertEquals(Value.DateValue(LocalDate.of(2024, 1, 6)), evalConstant(bin(BinaryOperator.Add, start, intLit(5))))
assertEquals(Value.DateValue(LocalDate.of(2023, 12, 27)), evalConstant(bin(BinaryOperator.Subtract, start, intLit(5))))
}
@Test
fun subtractingTwoDatesYieldsIntegerDayDifference() {
val later = dateLit(LocalDate.of(2024, 1, 10))
val earlier = dateLit(LocalDate.of(2024, 1, 1))
assertEquals(Value.IntegerValue(9), evalConstant(bin(BinaryOperator.Subtract, later, earlier)))
}
@Test
fun multiplyingADateFails() {
val start = dateLit(LocalDate.of(2024, 1, 1))
assertFailsWith<KestrelException> { evalConstant(bin(BinaryOperator.Multiply, start, intLit(2))) }
}
}
+84
View File
@@ -77,6 +77,72 @@ class ExecutorTest {
assertEquals(listOf(text("grace")), result.rows[0])
}
@Test
fun offsetSkipsLeadingRowsWithoutALimit() {
seedUsers()
val result = select("SELECT name FROM users ORDER BY age ASC OFFSET 1")
assertEquals(listOf(listOf(text("linus")), listOf(text("grace"))), result.rows)
}
@Test
fun limitAndOffsetTogetherPageThroughTheOrderedResult() {
seedUsers()
val result = select("SELECT name FROM users ORDER BY age ASC LIMIT 1 OFFSET 1")
assertEquals(listOf(listOf(text("linus"))), result.rows)
}
@Test
fun offsetPastTheEndOfTheResultReturnsNoRows() {
seedUsers()
val result = select("SELECT name FROM users ORDER BY age ASC OFFSET 100")
assertEquals(emptyList<List<Value>>(), result.rows)
}
@Test
fun offsetWrittenBeforeLimitStillAppliesLimitAfterTheSkip() {
seedUsers()
val result = select("SELECT name FROM users ORDER BY age ASC OFFSET 1 LIMIT 1")
assertEquals(listOf(listOf(text("linus"))), result.rows)
}
@Test
fun orderByMultipleColumnsBreaksTiesWithTheSecondKey() {
run("CREATE TABLE staff (id INTEGER PRIMARY KEY, dept TEXT NOT NULL, name TEXT NOT NULL)")
run(
"INSERT INTO staff VALUES " +
"(1, 'eng', 'guido'), (2, 'eng', 'ada'), (3, 'ops', 'grace'), (4, 'eng', 'linus')"
)
val result = select("SELECT dept, name FROM staff ORDER BY dept ASC, name ASC")
assertEquals(
listOf(
listOf(text("eng"), text("ada")),
listOf(text("eng"), text("guido")),
listOf(text("eng"), text("linus")),
listOf(text("ops"), text("grace"))
),
result.rows
)
}
@Test
fun orderByMultipleColumnsCanMixAscendingAndDescending() {
run("CREATE TABLE staff (id INTEGER PRIMARY KEY, dept TEXT NOT NULL, name TEXT NOT NULL)")
run(
"INSERT INTO staff VALUES " +
"(1, 'eng', 'guido'), (2, 'eng', 'ada'), (3, 'ops', 'grace'), (4, 'eng', 'linus')"
)
val result = select("SELECT dept, name FROM staff ORDER BY dept ASC, name DESC")
assertEquals(
listOf(
listOf(text("eng"), text("linus")),
listOf(text("eng"), text("guido")),
listOf(text("eng"), text("ada")),
listOf(text("ops"), text("grace"))
),
result.rows
)
}
@Test
fun projectionSupportsExpressionsAndAliases() {
seedUsers()
@@ -197,4 +263,22 @@ class ExecutorTest {
val result = select("SELECT name FROM users WHERE age > 40 AND age < 80")
assertEquals(listOf(listOf(text("linus"))), result.rows)
}
@Test
fun temporaryTableSupportsFullCrudJustLikeARegularTable() {
run("CREATE TEMP TABLE scratch (id INTEGER PRIMARY KEY, note TEXT NOT NULL)")
run("INSERT INTO scratch VALUES (1, 'first'), (2, 'second')")
run("UPDATE scratch SET note = 'updated' WHERE id = 1")
run("DELETE FROM scratch WHERE id = 2")
val result = select("SELECT id, note FROM scratch")
assertEquals(listOf(listOf(int(1), text("updated"))), result.rows)
}
@Test
fun temporaryTableEnforcesItsOwnPrimaryKeyAndCanBeIndexed() {
run("CREATE TEMP TABLE scratch (id INTEGER PRIMARY KEY, tag TEXT)")
run("CREATE INDEX scratchTag ON scratch (tag)")
run("INSERT INTO scratch VALUES (1, 'a')")
assertFailsWith<KestrelException> { run("INSERT INTO scratch VALUES (1, 'b')") }
}
}
@@ -0,0 +1,325 @@
package kestrel
import java.math.BigDecimal
import java.time.LocalDate
import kestrel.catalog.Catalog
import kestrel.engine.Executor
import kestrel.engine.ExplainResult
import kestrel.engine.SelectResult
import kestrel.engine.StatementResult
import kestrel.sql.lexer.Lexer
import kestrel.sql.parser.Parser
import kestrel.storage.Value
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class ExpressionLanguageTest {
private val catalog = Catalog()
private val executor = Executor(catalog)
private fun run(sql: String): List<StatementResult> =
Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) }
private fun select(sql: String): SelectResult = run(sql).single() as SelectResult
private fun int(value: Long) = Value.IntegerValue(value)
private fun text(value: String) = Value.TextValue(value)
private fun seedUsers() {
run("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER, bio TEXT)")
run(
"INSERT INTO users VALUES " +
"(1, 'ada', 36, 'mathematician'), (2, 'linus', 54, NULL), " +
"(3, 'grace', 85, 'rear admiral')"
)
}
@Test
fun scalarFunctionInProjection() {
seedUsers()
val result = select("SELECT UPPER(name) FROM users ORDER BY id ASC")
assertEquals(listOf("upper"), result.columns)
assertEquals(listOf(text("ADA"), text("LINUS"), text("GRACE")), result.rows.map { it[0] })
}
@Test
fun scalarFunctionInWhereClause() {
seedUsers()
val result = select("SELECT name FROM users WHERE LENGTH(name) > 4 ORDER BY name ASC")
assertEquals(listOf(listOf(text("grace")), listOf(text("linus"))), result.rows)
}
@Test
fun scalarFunctionUsableInUpdateAssignment() {
seedUsers()
run("UPDATE users SET name = UPPER(name) WHERE id = 1")
val result = select("SELECT name FROM users WHERE id = 1")
assertEquals(listOf(listOf(text("ADA"))), result.rows)
}
@Test
fun nestedFunctionCalls() {
seedUsers()
val result = select("SELECT SUBSTR(UPPER(name), 1, 1) FROM users WHERE id = 1")
assertEquals(listOf(listOf(text("A"))), result.rows)
}
@Test
fun isNullFiltersRowsWithMissingValues() {
seedUsers()
val result = select("SELECT name FROM users WHERE bio IS NULL")
assertEquals(listOf(listOf(text("linus"))), result.rows)
val negated = select("SELECT COUNT(*) FROM users WHERE bio IS NOT NULL")
assertEquals(listOf(listOf(int(2))), negated.rows)
}
@Test
fun inClauseMatchesAgainstLiteralSet() {
seedUsers()
val result = select("SELECT name FROM users WHERE age IN (36, 85) ORDER BY age ASC")
assertEquals(listOf(listOf(text("ada")), listOf(text("grace"))), result.rows)
}
@Test
fun notInExcludesMatchingRows() {
seedUsers()
val result = select("SELECT name FROM users WHERE age NOT IN (36, 85)")
assertEquals(listOf(listOf(text("linus"))), result.rows)
}
@Test
fun betweenSelectsInclusiveRange() {
seedUsers()
val result = select("SELECT name FROM users WHERE age BETWEEN 40 AND 90 ORDER BY age ASC")
assertEquals(listOf(listOf(text("linus")), listOf(text("grace"))), result.rows)
}
@Test
fun likeMatchesWildcardPattern() {
seedUsers()
val result = select("SELECT name FROM users WHERE bio LIKE '%admiral' ")
assertEquals(listOf(listOf(text("grace"))), result.rows)
}
@Test
fun caseExpressionInProjection() {
seedUsers()
val result = select(
"SELECT name, CASE WHEN age < 50 THEN 'young' WHEN age < 80 THEN 'mid' ELSE 'senior' END " +
"FROM users ORDER BY id ASC"
)
assertEquals(
listOf(
listOf(text("ada"), text("young")),
listOf(text("linus"), text("mid")),
listOf(text("grace"), text("senior"))
),
result.rows
)
}
@Test
fun caseExpressionUsableInWhereClause() {
seedUsers()
val result = select(
"SELECT name FROM users WHERE CASE WHEN age > 40 THEN TRUE ELSE FALSE END ORDER BY name ASC"
)
assertEquals(listOf(listOf(text("grace")), listOf(text("linus"))), result.rows)
}
@Test
fun combiningPredicatesWithLogicalOperators() {
seedUsers()
val result = select(
"SELECT name FROM users WHERE age BETWEEN 30 AND 60 AND name LIKE 'a%'"
)
assertEquals(listOf(listOf(text("ada"))), result.rows)
}
@Test
fun aggregateOfScalarFunctionResult() {
seedUsers()
val result = select("SELECT SUM(LENGTH(name)) FROM users")
assertEquals(listOf(listOf(int(3 + 5 + 5))), result.rows)
}
@Test
fun groupByWithFunctionOnGroupedColumn() {
run("CREATE TABLE t (category TEXT, amount INTEGER)")
run("INSERT INTO t VALUES ('Eng', 10), ('eng', 20), ('Ops', 5)")
val result = select("SELECT LOWER(category), SUM(amount) FROM t GROUP BY LOWER(category) ORDER BY LOWER(category) ASC")
assertEquals(
listOf(listOf(text("eng"), int(30)), listOf(text("ops"), int(5))),
result.rows
)
}
@Test
fun scalarFunctionNotAllowedAsAggregateGroupingViolation() {
seedUsers()
assertFailsWith<KestrelException> {
select("SELECT UPPER(name), COUNT(*) FROM users GROUP BY age")
}
}
@Test
fun newPredicateFormsCoexistWithIndexPushdownOnOtherConjuncts() {
seedUsers()
run("CREATE INDEX idxId ON users (id)")
val plan = (run("EXPLAIN SELECT name FROM users WHERE id = 1 AND age BETWEEN 1 AND 100").single() as ExplainResult).plan
assertTrue(plan.any { it.contains("Index Scan") }, "expected an index scan but was: $plan")
val result = select("SELECT name FROM users WHERE id = 1 AND age BETWEEN 1 AND 100")
assertEquals(listOf(listOf(text("ada"))), result.rows)
}
@Test
fun unknownFunctionNameReportsClearError() {
seedUsers()
assertFailsWith<KestrelException> { select("SELECT NOSUCHFUNC(name) FROM users") }
}
private fun decimal(value: String) = Value.DecimalValue(BigDecimal(value))
private fun date(value: String) = Value.DateValue(LocalDate.parse(value))
@Test
fun decimalColumnCoercesIntegerAndRealButRejectsBareText() {
run("CREATE TABLE orders (id INTEGER PRIMARY KEY, amount DECIMAL(10, 2))")
run("INSERT INTO orders VALUES (1, 5), (2, 5.5)")
val result = select("SELECT amount FROM orders ORDER BY id ASC")
assertEquals(listOf(listOf(decimal("5.00")), listOf(decimal("5.50"))), result.rows)
assertFailsWith<KestrelException> { run("INSERT INTO orders VALUES (3, '5.00')") }
}
@Test
fun dateColumnRequiresExplicitCastFromText() {
run("CREATE TABLE events (id INTEGER PRIMARY KEY, happened DATE)")
assertFailsWith<KestrelException> { run("INSERT INTO events VALUES (1, '2024-01-01')") }
run("INSERT INTO events VALUES (1, CAST('2024-01-01' AS DATE))")
val result = select("SELECT happened FROM events")
assertEquals(listOf(listOf(date("2024-01-01"))), result.rows)
}
@Test
fun decimalSumAndAverageStayExactAcrossGroupBy() {
run("CREATE TABLE orders (id INTEGER PRIMARY KEY, dept TEXT, amount DECIMAL(10, 2))")
run(
"INSERT INTO orders VALUES " +
"(1, 'eng', 10.10), (2, 'eng', 10.20), (3, 'eng', 10.30), (4, 'ops', 1.11)"
)
val result = select("SELECT dept, SUM(amount), AVG(amount) FROM orders GROUP BY dept ORDER BY dept ASC")
assertEquals(
listOf(
listOf(text("eng"), decimal("30.60"), decimal("10.20")),
listOf(text("ops"), decimal("1.11"), decimal("1.11"))
),
result.rows
)
}
@Test
fun dateArithmeticEndToEndThroughSelectAndWhere() {
run("CREATE TABLE events (id INTEGER PRIMARY KEY, happened DATE)")
run(
"INSERT INTO events VALUES " +
"(1, CAST('2024-01-01' AS DATE)), (2, CAST('2024-06-01' AS DATE))"
)
val projected = select("SELECT happened + 7 FROM events ORDER BY id ASC")
assertEquals(listOf(listOf(date("2024-01-08")), listOf(date("2024-06-08"))), projected.rows)
val filtered = select(
"SELECT id FROM events WHERE happened > CAST('2024-03-01' AS DATE)"
)
assertEquals(listOf(listOf(int(2))), filtered.rows)
}
@Test
fun castExpressionWorksInProjectionAndWhereAgainstOrdinaryColumns() {
seedUsers()
val projected = select("SELECT CAST(age AS TEXT) FROM users WHERE id = 1")
assertEquals(listOf(listOf(text("36"))), projected.rows)
val filtered = select("SELECT name FROM users WHERE CAST(age AS REAL) > 40.0 ORDER BY name ASC")
assertEquals(listOf(listOf(text("grace")), listOf(text("linus"))), filtered.rows)
}
@Test
fun dateComponentFunctionsEndToEndThroughSelectAndWhere() {
run("CREATE TABLE events (id INTEGER PRIMARY KEY, happened DATE)")
run(
"INSERT INTO events VALUES " +
"(1, CAST('2024-03-15' AS DATE)), (2, CAST('2023-11-02' AS DATE))"
)
val projected = select("SELECT YEAR(happened), MONTH(happened), DAY(happened) FROM events ORDER BY id ASC")
assertEquals(
listOf(listOf(int(2024), int(3), int(15)), listOf(int(2023), int(11), int(2))),
projected.rows
)
val filtered = select("SELECT id FROM events WHERE YEAR(happened) = 2023")
assertEquals(listOf(listOf(int(2))), filtered.rows)
val today = select("SELECT CURRENT_DATE() FROM events WHERE id = 1")
assertEquals(listOf(listOf(date(LocalDate.now().toString()))), today.rows)
}
@Test
fun dateAddDateSubAndDatediffEndToEndThroughSelectAndWhere() {
run("CREATE TABLE events (id INTEGER PRIMARY KEY, happened DATE)")
run(
"INSERT INTO events VALUES " +
"(1, CAST('2024-03-15' AS DATE)), (2, CAST('2024-03-20' AS DATE))"
)
val shifted = select(
"SELECT DATE_ADD(happened, 5), DATE_SUB(happened, 5) FROM events ORDER BY id ASC"
)
assertEquals(
listOf(
listOf(date("2024-03-20"), date("2024-03-10")),
listOf(date("2024-03-25"), date("2024-03-15"))
),
shifted.rows
)
val diff = select(
"SELECT DATEDIFF((SELECT happened FROM events WHERE id = 2), happened) FROM events WHERE id = 1"
)
assertEquals(listOf(listOf(int(5))), diff.rows)
val filtered = select("SELECT id FROM events WHERE DATE_ADD(happened, 5) = CAST('2024-03-25' AS DATE)")
assertEquals(listOf(listOf(int(2))), filtered.rows)
}
@Test
fun repeatLpadAndRpadEndToEndThroughSelectAndUpdate() {
run("CREATE TABLE codes (id INTEGER PRIMARY KEY, code TEXT)")
run("INSERT INTO codes VALUES (1, '7'), (2, '42')")
val padded = select("SELECT id, LPAD(code, 4, '0') FROM codes ORDER BY id ASC")
assertEquals(listOf(listOf(int(1), text("0007")), listOf(int(2), text("0042"))), padded.rows)
run("UPDATE codes SET code = RPAD(code, 5, '-') WHERE id = 1")
assertEquals(listOf(listOf(text("7----"))), select("SELECT code FROM codes WHERE id = 1").rows)
val separator = select("SELECT REPEAT('-', 3) FROM codes WHERE id = 2")
assertEquals(listOf(listOf(text("---"))), separator.rows)
}
@Test
fun signTruncGreatestAndLeastEndToEndThroughSelectWhereAndUpdate() {
run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER, limitAmount INTEGER, ratio REAL)")
run("INSERT INTO accounts VALUES (1, -50, 100, 0.0), (2, 75, 40, 0.0), (3, 0, 0, 0.0)")
val signs = select("SELECT id, SIGN(balance) FROM accounts ORDER BY id ASC")
assertEquals(listOf(listOf(int(1), int(-1)), listOf(int(2), int(1)), listOf(int(3), int(0))), signs.rows)
val capped = select("SELECT id, LEAST(balance, limitAmount) FROM accounts ORDER BY id ASC")
assertEquals(listOf(listOf(int(1), int(-50)), listOf(int(2), int(40)), listOf(int(3), int(0))), capped.rows)
val overLimit = select("SELECT id FROM accounts WHERE GREATEST(balance, 0) > limitAmount")
assertEquals(listOf(listOf(int(2))), overLimit.rows)
run("UPDATE accounts SET ratio = TRUNC(balance / 3.0) WHERE id = 2")
assertEquals(listOf(listOf(Value.RealValue(25.0))), select("SELECT ratio FROM accounts WHERE id = 2").rows)
}
}
+228
View File
@@ -0,0 +1,228 @@
package kestrel
import kestrel.catalog.Catalog
import kestrel.engine.DeleteResult
import kestrel.engine.Executor
import kestrel.engine.InsertResult
import kestrel.engine.SelectResult
import kestrel.engine.StatementResult
import kestrel.engine.UpdateResult
import kestrel.sql.lexer.Lexer
import kestrel.sql.parser.Parser
import kestrel.storage.Value
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class ForeignKeyTest {
private val catalog = Catalog()
private val executor = Executor(catalog)
private fun run(sql: String): List<StatementResult> =
Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) }
private fun exec(sql: String): StatementResult = run(sql).single()
private fun select(sql: String): SelectResult = exec(sql) as SelectResult
private fun int(value: Long) = Value.IntegerValue(value)
private fun text(value: String) = Value.TextValue(value)
private fun seedDepartmentsAndEmployees() {
run("CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
run(
"CREATE TABLE employees (" +
"id INTEGER PRIMARY KEY, name TEXT NOT NULL, dept_id INTEGER REFERENCES departments(id))"
)
run("INSERT INTO departments VALUES (1, 'eng'), (2, 'ops')")
}
@Test
fun insertRejectsForeignKeyViolation() {
seedDepartmentsAndEmployees()
val error = assertFailsWith<KestrelException> {
run("INSERT INTO employees VALUES (1, 'ada', 99)")
}
assertTrue(error.message!!.contains("FOREIGN KEY"))
}
@Test
fun insertAllowsNullForeignKeyValue() {
seedDepartmentsAndEmployees()
val result = exec("INSERT INTO employees VALUES (1, 'ada', NULL)")
assertEquals(InsertResult(1), result)
}
@Test
fun insertSucceedsWhenReferencedRowExists() {
seedDepartmentsAndEmployees()
val result = exec("INSERT INTO employees VALUES (1, 'ada', 1)")
assertEquals(InsertResult(1), result)
}
@Test
fun updateRejectsForeignKeyViolationOnChildColumn() {
seedDepartmentsAndEmployees()
run("INSERT INTO employees VALUES (1, 'ada', 1)")
assertFailsWith<KestrelException> {
run("UPDATE employees SET dept_id = 99 WHERE id = 1")
}
}
@Test
fun foreignKeyRequiresReferencedTableToExist() {
assertFailsWith<KestrelException> {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, x INTEGER REFERENCES missing(id))")
}
}
@Test
fun foreignKeyRequiresReferencedColumnToBeAPrimaryKey() {
run("CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT)")
assertFailsWith<KestrelException> {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, x TEXT REFERENCES departments(name))")
}
}
@Test
fun tableLevelForeignKeySyntaxIsEquivalentToColumnLevel() {
run("CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT)")
run(
"CREATE TABLE employees (id INTEGER PRIMARY KEY, dept_id INTEGER, " +
"FOREIGN KEY (dept_id) REFERENCES departments(id))"
)
run("INSERT INTO departments VALUES (1, 'eng')")
assertEquals(InsertResult(1), exec("INSERT INTO employees VALUES (1, 1)"))
assertFailsWith<KestrelException> { run("INSERT INTO employees VALUES (2, 99)") }
}
@Test
fun deleteIsRestrictedByDefaultWhenChildrenReferenceTheRow() {
seedDepartmentsAndEmployees()
run("INSERT INTO employees VALUES (1, 'ada', 1)")
val error = assertFailsWith<KestrelException> { run("DELETE FROM departments WHERE id = 1") }
assertTrue(error.message!!.contains("FOREIGN KEY"))
assertEquals(1, select("SELECT * FROM departments WHERE id = 1").rows.size)
}
@Test
fun deleteWithoutReferencingChildrenSucceeds() {
seedDepartmentsAndEmployees()
assertEquals(DeleteResult(1), exec("DELETE FROM departments WHERE id = 2"))
}
private fun seedCascadeDepartments() {
run("CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
run(
"CREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT NOT NULL, " +
"dept_id INTEGER REFERENCES departments(id) ON DELETE CASCADE ON UPDATE CASCADE)"
)
run("INSERT INTO departments VALUES (1, 'eng'), (2, 'ops')")
run("INSERT INTO employees VALUES (1, 'ada', 1), (2, 'linus', 1), (3, 'grace', 2)")
}
@Test
fun onDeleteCascadeRemovesReferencingChildren() {
seedCascadeDepartments()
assertEquals(DeleteResult(1), exec("DELETE FROM departments WHERE id = 1"))
val remaining = select("SELECT name FROM employees ORDER BY id")
assertEquals(listOf(listOf(text("grace"))), remaining.rows)
}
@Test
fun onUpdateCascadeChangesChildForeignKeyWhenParentPrimaryKeyChanges() {
seedCascadeDepartments()
run("UPDATE departments SET id = 10 WHERE id = 1")
val depts = select("SELECT dept_id FROM employees ORDER BY id")
assertEquals(listOf(listOf(int(10)), listOf(int(10)), listOf(int(2))), depts.rows)
}
@Test
fun onUpdateRestrictByDefaultBlocksPrimaryKeyChangeWhenChildrenReference() {
seedDepartmentsAndEmployees()
run("INSERT INTO employees VALUES (1, 'ada', 1)")
assertFailsWith<KestrelException> { run("UPDATE departments SET id = 99 WHERE id = 1") }
}
private fun seedSetNullDepartments(childNotNull: Boolean) {
run("CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
val childColumn = if (childNotNull) "dept_id INTEGER NOT NULL" else "dept_id INTEGER"
run(
"CREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT NOT NULL, " +
"$childColumn REFERENCES departments(id) ON DELETE SET NULL)"
)
run("INSERT INTO departments VALUES (1, 'eng')")
run("INSERT INTO employees VALUES (1, 'ada', 1)")
}
@Test
fun onDeleteSetNullNullsOutReferencingChildren() {
seedSetNullDepartments(childNotNull = false)
exec("DELETE FROM departments WHERE id = 1")
val row = select("SELECT dept_id FROM employees WHERE id = 1")
assertEquals(listOf(listOf(Value.NullValue)), row.rows)
}
@Test
fun onDeleteSetNullFailsWhenChildColumnIsNotNull() {
seedSetNullDepartments(childNotNull = true)
val error = assertFailsWith<KestrelException> { run("DELETE FROM departments WHERE id = 1") }
assertTrue(error.message!!.contains("NOT NULL"))
}
@Test
fun selfReferencingForeignKeyIsAllowedAndCascadesThroughMultipleLevels() {
run(
"CREATE TABLE employees (id INTEGER PRIMARY KEY, name TEXT NOT NULL, " +
"manager_id INTEGER REFERENCES employees(id) ON DELETE CASCADE)"
)
run(
"INSERT INTO employees VALUES " +
"(1, 'ceo', NULL), (2, 'vp', 1), (3, 'manager', 2), (4, 'ic', 3)"
)
assertEquals(DeleteResult(1), exec("DELETE FROM employees WHERE id = 1"))
val remaining = select("SELECT id FROM employees")
assertEquals(emptyList(), remaining.rows)
}
@Test
fun dropTableIsRestrictedByDefaultWhenAForeignKeyReferencesIt() {
seedDepartmentsAndEmployees()
val error = assertFailsWith<KestrelException> { run("DROP TABLE departments") }
assertTrue(error.message!!.contains("FOREIGN KEY"))
}
@Test
fun dropTableCascadeDropsTheConstraintButKeepsTheChildTableAndItsRows() {
seedDepartmentsAndEmployees()
run("INSERT INTO employees VALUES (1, 'ada', 1)")
exec("DROP TABLE departments CASCADE")
assertEquals(listOf("employees"), catalog.tableNames)
assertEquals(1, select("SELECT * FROM employees").rows.size)
// the constraint is gone, so a now-dangling value is accepted
assertEquals(InsertResult(1), exec("INSERT INTO employees VALUES (2, 'linus', 99)"))
}
@Test
fun rollbackAfterCascadeDeleteRestoresChildRows() {
seedCascadeDepartments()
run("BEGIN")
run("DELETE FROM departments WHERE id = 1")
assertEquals(1, select("SELECT * FROM employees").rows.size)
run("ROLLBACK")
assertEquals(3, select("SELECT * FROM employees").rows.size)
assertEquals(2, select("SELECT * FROM departments").rows.size)
}
@Test
fun rollbackAfterCascadeDropRestoresTheForeignKeyConstraint() {
seedDepartmentsAndEmployees()
run("BEGIN")
run("DROP TABLE departments CASCADE")
run("ROLLBACK")
assertFailsWith<KestrelException> {
run("INSERT INTO employees VALUES (1, 'ada', 99)")
}
}
}
@@ -0,0 +1,316 @@
package kestrel
import kestrel.catalog.Catalog
import kestrel.engine.Executor
import kestrel.engine.MergeResult
import kestrel.engine.SelectResult
import kestrel.engine.StatementResult
import kestrel.sql.lexer.Lexer
import kestrel.sql.parser.Parser
import kestrel.storage.Value
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* `GENERATED ALWAYS AS (expr) STORED` on the in-memory engine - a column whose value is always
* derived from the rest of its own row, never assigned directly, and recomputed on every
* INSERT/UPDATE. See [kestrel.store.sql.DiskExecutorGeneratedColumnTest] for the disk engine.
*/
class GeneratedColumnTest {
private val catalog = Catalog()
private val executor = Executor(catalog)
private fun run(sql: String): List<StatementResult> =
Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) }
private fun exec(sql: String): StatementResult = run(sql).single()
private fun select(sql: String): SelectResult = exec(sql) as SelectResult
private fun int(value: Long) = Value.IntegerValue(value)
// ---- computed on INSERT ----
@Test
fun generatedColumnIsComputedWhenOmittedFromInsert() {
run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)")
run("INSERT INTO t (a) VALUES (5)")
assertEquals(listOf(listOf(int(5), int(10))), select("SELECT a, b FROM t").rows)
}
@Test
fun generatedColumnIsComputedWhenOmittedFromPositionalInsertColumnList() {
run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)")
run("INSERT INTO t (a, b) VALUES (5, DEFAULT)")
assertEquals(listOf(listOf(int(5), int(10))), select("SELECT a, b FROM t").rows)
}
@Test
fun insertRejectsAnExplicitValueForAGeneratedColumn() {
run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)")
val error = assertFailsWith<KestrelException> { run("INSERT INTO t (a, b) VALUES (5, 99)") }
assertTrue(error.message!!.contains("GENERATED"))
}
@Test
fun generatedColumnRecomputesFreshForEveryRowOfAMultiRowInsert() {
run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)")
run("INSERT INTO t (a) VALUES (1), (2), (3)")
assertEquals(
listOf(listOf(int(1), int(2)), listOf(int(2), int(4)), listOf(int(3), int(6))),
select("SELECT a, b FROM t ORDER BY a").rows
)
}
@Test
fun generatedColumnCanReferenceMultipleOtherColumns() {
run("CREATE TABLE t (price INTEGER, qty INTEGER, total INTEGER GENERATED ALWAYS AS (price * qty) STORED)")
run("INSERT INTO t (price, qty) VALUES (7, 3)")
assertEquals(listOf(listOf(int(21))), select("SELECT total FROM t").rows)
}
// ---- computed on UPDATE ----
@Test
fun updatingADependencyColumnRecomputesTheGeneratedColumn() {
run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)")
run("INSERT INTO t (a) VALUES (5)")
run("UPDATE t SET a = 9")
assertEquals(listOf(listOf(int(18))), select("SELECT b FROM t").rows)
}
@Test
fun updateRejectsAnExplicitValueForAGeneratedColumn() {
run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)")
run("INSERT INTO t (a) VALUES (5)")
val error = assertFailsWith<KestrelException> { run("UPDATE t SET b = 1") }
assertTrue(error.message!!.contains("GENERATED"))
}
@Test
fun updateSetToDefaultOnAGeneratedColumnRecomputesIt() {
run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)")
run("INSERT INTO t (a) VALUES (5)")
run("UPDATE t SET a = 9, b = DEFAULT")
assertEquals(listOf(listOf(int(18))), select("SELECT b FROM t").rows)
}
// ---- ON CONFLICT DO UPDATE ----
@Test
fun onConflictDoUpdateRecomputesTheGeneratedColumn() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)")
run("INSERT INTO t (id, a) VALUES (1, 5)")
run("INSERT INTO t (id, a) VALUES (1, 20) ON CONFLICT (id) DO UPDATE SET a = EXCLUDED.a")
assertEquals(listOf(listOf(int(40))), select("SELECT b FROM t").rows)
}
@Test
fun onConflictDoUpdateRejectsAnExplicitValueForAGeneratedColumn() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)")
run("INSERT INTO t (id, a) VALUES (1, 5)")
assertFailsWith<KestrelException> {
run("INSERT INTO t (id, a) VALUES (1, 20) ON CONFLICT (id) DO UPDATE SET b = 1")
}
}
// ---- MERGE ----
@Test
fun mergeInsertComputesTheGeneratedColumn() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)")
run("CREATE TABLE s (id INTEGER, a INTEGER)")
run("INSERT INTO s VALUES (1, 5)")
val result = exec(
"MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED THEN INSERT (id, a) VALUES (s.id, s.a)"
) as MergeResult
assertEquals(MergeResult(1, 0, 0), result)
assertEquals(listOf(listOf(int(10))), select("SELECT b FROM t").rows)
}
@Test
fun mergeUpdateRecomputesTheGeneratedColumn() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)")
run("CREATE TABLE s (id INTEGER, a INTEGER)")
run("INSERT INTO t (id, a) VALUES (1, 5)")
run("INSERT INTO s VALUES (1, 9)")
exec("MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET a = s.a")
assertEquals(listOf(listOf(int(18))), select("SELECT b FROM t").rows)
}
// ---- ordering with CHECK / NOT NULL ----
@Test
fun checkConstraintSeesTheComputedGeneratedValueNotItsOldOne() {
run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED CHECK (b > 0))")
assertFailsWith<KestrelException> { run("INSERT INTO t (a) VALUES (-1)") }
run("INSERT INTO t (a) VALUES (3)")
assertEquals(listOf(listOf(int(6))), select("SELECT b FROM t").rows)
}
@Test
fun notNullOnAGeneratedColumnRejectsARowWhoseComputedValueIsNull() {
run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a) STORED NOT NULL)")
assertFailsWith<KestrelException> { run("INSERT INTO t (a) VALUES (NULL)") }
}
// ---- validation at CREATE TABLE ----
@Test
fun rejectsGeneratedExpressionReferencingANonexistentColumn() {
val error = assertFailsWith<KestrelException> {
run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (missing) STORED)")
}
assertTrue(error.message!!.contains("no such column"))
}
@Test
fun rejectsGeneratedExpressionContainingAnAggregate() {
assertFailsWith<KestrelException> {
run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (COUNT(a)) STORED)")
}
}
@Test
fun rejectsGeneratedExpressionContainingASubquery() {
run("CREATE TABLE other (x INTEGER)")
assertFailsWith<KestrelException> {
run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS ((SELECT x FROM other)) STORED)")
}
}
@Test
fun rejectsGeneratedExpressionContainingNextval() {
run("CREATE SEQUENCE seq")
assertFailsWith<KestrelException> {
run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (NEXTVAL('seq')) STORED)")
}
}
@Test
fun rejectsAGeneratedColumnReferencingAnotherGeneratedColumn() {
val error = assertFailsWith<KestrelException> {
run(
"CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED, " +
"c INTEGER GENERATED ALWAYS AS (b + 1) STORED)"
)
}
assertTrue(error.message!!.contains("GENERATED"))
}
// ---- RENAME COLUMN / DROP COLUMN interaction ----
@Test
fun renameColumnPropagatesIntoADependentGeneratedExpression() {
run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)")
run("ALTER TABLE t RENAME COLUMN a TO a2")
run("INSERT INTO t (a2) VALUES (5)")
assertEquals(listOf(listOf(int(10))), select("SELECT b FROM t").rows)
}
@Test
fun dropColumnRestrictsWhenAGeneratedColumnDependsOnIt() {
run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)")
val error = assertFailsWith<KestrelException> { run("ALTER TABLE t DROP COLUMN a") }
assertTrue(error.message!!.contains("GENERATED"))
}
@Test
fun dropColumnCascadeRemovesTheDependentGeneratedColumnToo() {
run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED, c TEXT)")
run("INSERT INTO t (a, c) VALUES (5, 'x')")
run("ALTER TABLE t DROP COLUMN a CASCADE")
val result = select("SELECT * FROM t")
assertEquals(listOf("c"), result.columns)
assertEquals(listOf(listOf(text("x"))), result.rows)
}
// ---- ALTER TABLE ADD COLUMN GENERATED ALWAYS AS ----
@Test
fun alterTableAddColumnGeneratedBackfillsEachExistingRowFromItsOwnOtherColumns() {
run("CREATE TABLE t (a INTEGER)")
run("INSERT INTO t (a) VALUES (1), (2), (3)")
run("ALTER TABLE t ADD COLUMN b INTEGER GENERATED ALWAYS AS (a * 2) STORED")
assertEquals(
listOf(listOf(int(1), int(2)), listOf(int(2), int(4)), listOf(int(3), int(6))),
select("SELECT a, b FROM t ORDER BY a ASC").rows
)
}
@Test
fun alterTableAddColumnGeneratedWorksOnAnEmptyTable() {
run("CREATE TABLE t (a INTEGER)")
run("ALTER TABLE t ADD COLUMN b INTEGER GENERATED ALWAYS AS (a * 2) STORED")
run("INSERT INTO t (a) VALUES (5)")
assertEquals(listOf(listOf(int(10))), select("SELECT b FROM t").rows)
}
@Test
fun afterAlterTableAddColumnGeneratedAnInsertStillRejectsAnExplicitValue() {
run("CREATE TABLE t (a INTEGER)")
run("ALTER TABLE t ADD COLUMN b INTEGER GENERATED ALWAYS AS (a * 2) STORED")
val error = assertFailsWith<KestrelException> { run("INSERT INTO t (a, b) VALUES (5, 99)") }
assertTrue(error.message!!.contains("GENERATED"))
}
@Test
fun afterAlterTableAddColumnGeneratedUpdatingTheDependencyColumnRecomputesIt() {
run("CREATE TABLE t (a INTEGER)")
run("INSERT INTO t (a) VALUES (5)")
run("ALTER TABLE t ADD COLUMN b INTEGER GENERATED ALWAYS AS (a * 2) STORED")
run("UPDATE t SET a = 9")
assertEquals(listOf(listOf(int(18))), select("SELECT b FROM t").rows)
}
@Test
fun alterTableAddColumnGeneratedNotNullSkipsTheDefaultRequirementOnANonEmptyTable() {
run("CREATE TABLE t (a INTEGER)")
run("INSERT INTO t (a) VALUES (1), (2)")
// A plain NOT NULL column with no DEFAULT would be rejected here (see the DEFAULT-less
// ADD COLUMN rule tested elsewhere) - GENERATED has its own per-row value to backfill with
// instead, so it needs no DEFAULT at all even though the table already has rows.
run("ALTER TABLE t ADD COLUMN b INTEGER NOT NULL GENERATED ALWAYS AS (a * 2) STORED")
assertEquals(listOf(listOf(int(2)), listOf(int(4))), select("SELECT b FROM t ORDER BY a ASC").rows)
}
@Test
fun alterTableAddColumnGeneratedNotNullRejectsARowWhoseBackfilledValueIsNull() {
run("CREATE TABLE t (a INTEGER)")
run("INSERT INTO t (a) VALUES (NULL)")
val error = assertFailsWith<KestrelException> {
run("ALTER TABLE t ADD COLUMN b INTEGER NOT NULL GENERATED ALWAYS AS (a) STORED")
}
assertTrue(error.message!!.contains("NOT NULL"))
}
@Test
fun alterTableAddColumnGeneratedRejectsReferencingANonexistentColumn() {
run("CREATE TABLE t (a INTEGER)")
val error = assertFailsWith<KestrelException> {
run("ALTER TABLE t ADD COLUMN b INTEGER GENERATED ALWAYS AS (missing) STORED")
}
assertTrue(error.message!!.contains("no such column"))
}
@Test
fun alterTableAddColumnGeneratedRejectsReferencingAnAlreadyExistingGeneratedColumn() {
run("CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED)")
val error = assertFailsWith<KestrelException> {
run("ALTER TABLE t ADD COLUMN c INTEGER GENERATED ALWAYS AS (b + 1) STORED")
}
assertTrue(error.message!!.contains("GENERATED"))
}
@Test
fun alterTableAddColumnGeneratedRejectsAnAggregate() {
run("CREATE TABLE t (a INTEGER)")
assertFailsWith<KestrelException> {
run("ALTER TABLE t ADD COLUMN b INTEGER GENERATED ALWAYS AS (COUNT(a)) STORED")
}
}
private fun text(value: String) = Value.TextValue(value)
}
+213
View File
@@ -0,0 +1,213 @@
package kestrel
import kestrel.catalog.Catalog
import kestrel.engine.Executor
import kestrel.engine.SelectResult
import kestrel.engine.StatementResult
import kestrel.sql.lexer.Lexer
import kestrel.sql.parser.Parser
import kestrel.storage.Value
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* `GROUP BY ROLLUP (...)` / `CUBE (...)` / `GROUPING SETS (...)` and the `GROUPING(...)` function -
* a single query reporting subtotals (and a grand total) at several levels at once instead of a
* separate `UNION ALL` of differently-grouped queries. See [kestrel.sql.ast.GroupingSpec] and
* [kestrel.engine.GroupingSetExpansion] for how the sets are computed and
* [kestrel.engine.GroupAggregation] for how a collapsed column reads as NULL outside an aggregate.
*/
class GroupingSetsTest {
private val catalog = Catalog()
private val executor = Executor(catalog)
private fun run(sql: String): List<StatementResult> =
Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) }
private fun select(sql: String): SelectResult = run(sql).single() as SelectResult
private fun int(value: Long) = Value.IntegerValue(value)
private fun text(value: String) = Value.TextValue(value)
private val NULL = Value.NullValue
private fun seed() {
run("CREATE TABLE sales (dept TEXT, region TEXT, amount INTEGER)")
run(
"INSERT INTO sales VALUES " +
"('eng', 'eu', 100), ('eng', 'us', 200), ('ops', 'eu', 50), ('ops', 'eu', 70)"
)
}
@Test
fun rollupProducesDetailSubtotalsAndAGrandTotal() {
seed()
val result = select(
"SELECT dept, region, SUM(amount) AS total, GROUPING(dept) AS gd, GROUPING(region) AS gr " +
"FROM sales GROUP BY ROLLUP (dept, region) " +
"ORDER BY GROUPING(dept), dept, GROUPING(region), region"
)
assertEquals(listOf("dept", "region", "total", "gd", "gr"), result.columns)
assertEquals(
listOf(
listOf(text("eng"), text("eu"), int(100), int(0), int(0)),
listOf(text("eng"), text("us"), int(200), int(0), int(0)),
listOf(text("eng"), NULL, int(300), int(0), int(1)),
listOf(text("ops"), text("eu"), int(120), int(0), int(0)),
listOf(text("ops"), NULL, int(120), int(0), int(1)),
listOf(NULL, NULL, int(420), int(1), int(1))
),
result.rows
)
}
@Test
fun cubeProducesEverySubsetOfGroupingColumns() {
seed()
val result = select(
"SELECT dept, region, SUM(amount) AS total " +
"FROM sales GROUP BY CUBE (dept, region) " +
"ORDER BY GROUPING(dept), GROUPING(region), dept, region"
)
assertEquals(
listOf(
listOf(text("eng"), text("eu"), int(100)),
listOf(text("eng"), text("us"), int(200)),
listOf(text("ops"), text("eu"), int(120)),
listOf(text("eng"), NULL, int(300)),
listOf(text("ops"), NULL, int(120)),
listOf(NULL, text("eu"), int(220)),
listOf(NULL, text("us"), int(200)),
listOf(NULL, NULL, int(420))
),
result.rows
)
}
@Test
fun explicitGroupingSetsSkipsTheFullDetailSetWhenNotListed() {
seed()
val result = select(
"SELECT dept, region, SUM(amount) AS total " +
"FROM sales GROUP BY GROUPING SETS ((dept), (region), ()) " +
"ORDER BY GROUPING(dept), GROUPING(region), dept, region"
)
assertEquals(
listOf(
listOf(text("eng"), NULL, int(300)),
listOf(text("ops"), NULL, int(120)),
listOf(NULL, text("eu"), int(220)),
listOf(NULL, text("us"), int(200)),
listOf(NULL, NULL, int(420))
),
result.rows
)
}
@Test
fun havingFiltersAcrossEverySetAtOnce() {
seed()
val result = select(
"SELECT dept, SUM(amount) AS total FROM sales GROUP BY ROLLUP (dept) " +
"HAVING SUM(amount) > 150 ORDER BY GROUPING(dept), dept"
)
assertEquals(listOf(listOf(text("eng"), int(300)), listOf(NULL, int(420))), result.rows)
}
@Test
fun plainGroupByAlwaysReportsGroupingAsZero() {
seed()
val result = select("SELECT dept, GROUPING(dept) FROM sales GROUP BY dept ORDER BY dept")
assertEquals(listOf(listOf(text("eng"), int(0)), listOf(text("ops"), int(0))), result.rows)
}
@Test
fun aCollapsedColumnInsideCaseEvaluatesAsNull() {
seed()
val result = select(
"SELECT CASE WHEN dept IS NULL THEN 'ALL DEPARTMENTS' ELSE dept END AS label, SUM(amount) AS total " +
"FROM sales GROUP BY ROLLUP (dept) ORDER BY GROUPING(dept), dept"
)
assertEquals(
listOf(
listOf(text("eng"), int(300)),
listOf(text("ops"), int(120)),
listOf(text("ALL DEPARTMENTS"), int(420))
),
result.rows
)
}
@Test
fun aggregatesInsideTheSameQuerySeeRealPerRowValuesRegardlessOfCollapsedColumns() {
seed()
// COUNT(*) on the grand-total row must count every row (4), not be tripped up by dept/region
// reading as NULL there - the nulling only ever applies outside an aggregate's own argument.
val result = select(
"SELECT dept, COUNT(*) AS n FROM sales GROUP BY ROLLUP (dept) ORDER BY GROUPING(dept), dept"
)
assertEquals(
listOf(listOf(text("eng"), int(2)), listOf(text("ops"), int(2)), listOf(NULL, int(4))),
result.rows
)
}
@Test
fun rollupOverAnEmptyTableStillProducesOneGrandTotalRow() {
run("CREATE TABLE empty_sales (dept TEXT, amount INTEGER)")
val result = select("SELECT dept, COUNT(*), SUM(amount) FROM empty_sales GROUP BY ROLLUP (dept)")
assertEquals(listOf(listOf(NULL, int(0), NULL)), result.rows)
}
@Test
fun groupingFunctionOutsideAGroupedQueryIsRejected() {
seed()
val failure = assertFailsWith<KestrelException> { select("SELECT GROUPING(dept) FROM sales") }
assertTrue(failure.message!!.contains("GROUP BY"), failure.message)
}
@Test
fun groupingFunctionArgumentMustItselfBeAGroupingColumn() {
seed()
val failure = assertFailsWith<KestrelException> {
select("SELECT GROUPING(amount) FROM sales GROUP BY ROLLUP (dept)")
}
assertTrue(failure.message!!.contains("must appear in GROUP BY"), failure.message)
}
@Test
fun groupingFunctionRequiresAtLeastOneArgument() {
seed()
val failure = assertFailsWith<KestrelException> {
select("SELECT GROUPING() FROM sales GROUP BY ROLLUP (dept)")
}
assertTrue(failure.message!!.contains("at least one argument"), failure.message)
}
@Test
fun cubeRejectsTooManyColumns() {
val columns = (1..17).joinToString(", ") { "c$it" }
run("CREATE TABLE wide (${(1..17).joinToString(", ") { "c$it INTEGER" }})")
val failure = assertFailsWith<KestrelException> {
select("SELECT COUNT(*) FROM wide GROUP BY CUBE ($columns)")
}
assertTrue(failure.message!!.contains("CUBE supports at most"), failure.message)
}
@Test
fun explainDescribesTheNumberOfGroupingSets() {
seed()
val plan = (run("EXPLAIN SELECT dept, region, SUM(amount) FROM sales GROUP BY CUBE (dept, region)").single()
as kestrel.engine.ExplainResult).plan
assertTrue(plan.any { it.contains("GroupAggregate") && it.contains("4 grouping sets") }, plan.toString())
}
@Test
fun explainOnAPlainGroupByDoesNotMentionGroupingSets() {
seed()
val plan = (run("EXPLAIN SELECT dept, SUM(amount) FROM sales GROUP BY dept").single()
as kestrel.engine.ExplainResult).plan
assertTrue(plan.any { it == "GroupAggregate keys: dept" }, plan.toString())
}
}
+50
View File
@@ -112,4 +112,54 @@ class IndexTest {
fun droppingUnknownIndexIsRejected() {
assertFailsWith<KestrelException> { run("DROP INDEX ghost") }
}
@Test
fun createUniqueIndexRejectsADuplicateOnInsert() {
seed()
run("CREATE UNIQUE INDEX idxName ON users (name)")
assertFailsWith<KestrelException> { run("INSERT INTO users VALUES (6, 'ada', 21)") }
}
@Test
fun createUniqueIndexRejectsADuplicateOnUpdate() {
seed()
run("CREATE UNIQUE INDEX idxName ON users (name)")
assertFailsWith<KestrelException> { run("UPDATE users SET name = 'ada' WHERE name = 'linus'") }
}
@Test
fun createUniqueIndexAllowsMultipleNulls() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, tag TEXT)")
run("CREATE UNIQUE INDEX idxTag ON t (tag)")
run("INSERT INTO t VALUES (1, NULL), (2, NULL)")
assertEquals(2, select("SELECT id FROM t").rows.size)
}
@Test
fun creatingAUniqueIndexOverExistingDuplicatesFails() {
seed()
run("INSERT INTO users VALUES (6, 'ada', 21)")
assertFailsWith<KestrelException> { run("CREATE UNIQUE INDEX idxName ON users (name)") }
// A failed creation must not leave a half-built index registered under the name.
run("CREATE INDEX idxName ON users (name)")
}
@Test
fun uniqueIndexStillAcceleratesEqualityLookups() {
seed()
run("CREATE UNIQUE INDEX idxName ON users (name)")
val plan = explain("EXPLAIN SELECT id FROM users WHERE name = 'ada'")
assertTrue(plan.any { it.contains("Index Scan using idxName") }, "expected index scan, got $plan")
}
@Test
fun rollbackAfterAFailedInsertLeavesTheUniqueIndexUsable() {
seed()
run("CREATE UNIQUE INDEX idxName ON users (name)")
run("BEGIN")
assertFailsWith<KestrelException> { run("INSERT INTO users VALUES (6, 'ada', 21)") }
run("ROLLBACK")
run("INSERT INTO users VALUES (6, 'zoe', 21)")
assertEquals(6, select("SELECT id FROM users").rows.size)
}
}
+141
View File
@@ -59,6 +59,110 @@ class JoinTest {
assertEquals(listOf(text("ada"), int(100)), result.rows[3])
}
private fun seedWithOrphanOrder() {
seed()
// References a userId that does not exist, giving RIGHT/FULL JOIN an unmatched right row.
run("INSERT INTO orders VALUES (13, 99, 999)")
}
@Test
fun rightJoinKeepsUnmatchedRightRowsWithNulls() {
seedWithOrphanOrder()
val result = select(
"SELECT u.name, o.total FROM users u RIGHT JOIN orders o ON u.id = o.userId ORDER BY o.id ASC"
)
assertEquals(
listOf(
listOf(text("ada"), int(100)),
listOf(text("ada"), int(40)),
listOf(text("linus"), int(70)),
listOf(Value.NullValue, int(999))
),
result.rows
)
}
@Test
fun rightJoinDropsUnmatchedLeftRows() {
seedWithOrphanOrder()
val result = select("SELECT u.name FROM users u RIGHT JOIN orders o ON u.id = o.userId")
assertTrue(result.rows.none { it == listOf(text("grace")) }, "got ${result.rows}")
}
@Test
fun fullJoinKeepsBothUnmatchedSides() {
seedWithOrphanOrder()
val result = select("SELECT u.name, o.total FROM users u FULL JOIN orders o ON u.id = o.userId")
assertEquals(
setOf(
listOf(text("ada"), int(100)),
listOf(text("ada"), int(40)),
listOf(text("linus"), int(70)),
listOf(text("grace"), Value.NullValue),
listOf(Value.NullValue, int(999))
),
result.rows.toSet()
)
assertEquals(5, result.rows.size)
}
@Test
fun fullJoinHashAndNestedLoopProduceSameResult() {
seedWithOrphanOrder()
val hashed = select(
"SELECT u.name, o.total FROM users u FULL JOIN orders o ON u.id = o.userId"
).rows.toSet()
val nested = select(
"SELECT u.name, o.total FROM users u FULL JOIN orders o ON u.id >= o.userId AND u.id <= o.userId"
).rows.toSet()
assertEquals(hashed, nested)
}
@Test
fun rightJoinPadsWithFullWidthOfAllPrecedingTablesInAChain() {
seed()
run("CREATE TABLE items (orderId INTEGER, label TEXT)")
// orderId 99 matches no `orders` row, so it can only survive via the RIGHT JOIN's own
// unmatched-right padding - proving that padding spans BOTH preceding tables (users AND
// orders), not just orders (the immediate left side of this particular JOIN).
run("INSERT INTO items VALUES (10, 'book'), (99, 'orphanItem')")
val result = select(
"SELECT u.name, o.total, i.label FROM users u " +
"JOIN orders o ON u.id = o.userId " +
"RIGHT JOIN items i ON o.id = i.orderId"
)
assertEquals(
setOf(
listOf(text("ada"), int(100), text("book")),
listOf(Value.NullValue, Value.NullValue, text("orphanItem"))
),
result.rows.toSet()
)
assertEquals(2, result.rows.size)
}
@Test
fun rightAndFullJoinAcceptOptionalOuterKeyword() {
seed()
assertEquals(
select("SELECT u.name FROM users u RIGHT JOIN orders o ON u.id = o.userId").rows.toSet(),
select("SELECT u.name FROM users u RIGHT OUTER JOIN orders o ON u.id = o.userId").rows.toSet()
)
assertEquals(
select("SELECT u.name FROM users u FULL JOIN orders o ON u.id = o.userId").rows.toSet(),
select("SELECT u.name FROM users u FULL OUTER JOIN orders o ON u.id = o.userId").rows.toSet()
)
}
@Test
fun plannerDescribesRightAndFullJoinLabels() {
seed()
val rightNested = explain("EXPLAIN SELECT u.name FROM users u RIGHT JOIN orders o ON u.id < o.userId")
assertTrue(rightNested.any { it.contains("Nested Loop Right") }, "got $rightNested")
val fullHash = explain("EXPLAIN SELECT u.name FROM users u FULL JOIN orders o ON u.id = o.userId")
assertTrue(fullHash.any { it.contains("Hash Join") }, "got $fullHash")
}
@Test
fun hashJoinAndNestedLoopProduceSameResult() {
seed()
@@ -115,4 +219,41 @@ class JoinTest {
select("SELECT id FROM users u JOIN orders o ON u.id = o.userId")
}
}
@Test
fun crossJoinProducesTheFullCartesianProduct() {
run("CREATE TABLE colors (name TEXT)")
run("CREATE TABLE sizes (label TEXT)")
run("INSERT INTO colors VALUES ('red'), ('blue')")
run("INSERT INTO sizes VALUES ('s'), ('m'), ('l')")
val result = select("SELECT colors.name, sizes.label FROM colors CROSS JOIN sizes ORDER BY colors.name, sizes.label")
assertEquals(
listOf(
listOf(text("blue"), text("l")), listOf(text("blue"), text("m")), listOf(text("blue"), text("s")),
listOf(text("red"), text("l")), listOf(text("red"), text("m")), listOf(text("red"), text("s"))
),
result.rows
)
}
@Test
fun crossJoinNeverUsesHashJoinAndIsDescribedAsCrossInExplain() {
run("CREATE TABLE colors (name TEXT)")
run("CREATE TABLE sizes (label TEXT)")
val plan = explain("EXPLAIN SELECT colors.name FROM colors CROSS JOIN sizes")
assertTrue(plan.any { it.contains("Nested Loop Cross") }, "got $plan")
assertTrue(plan.none { it.contains("Hash Join") }, "got $plan")
}
@Test
fun crossJoinChainedWithAFilterActsLikeAnExplicitCartesianJoin() {
seed()
val cross = select(
"SELECT u.name, o.total FROM users u CROSS JOIN orders o WHERE u.id = o.userId ORDER BY o.total ASC"
).rows
val inner = select(
"SELECT u.name, o.total FROM users u JOIN orders o ON u.id = o.userId ORDER BY o.total ASC"
).rows
assertEquals(inner, cross)
}
}
+201
View File
@@ -0,0 +1,201 @@
package kestrel
import kestrel.catalog.Catalog
import kestrel.engine.ExplainResult
import kestrel.engine.Executor
import kestrel.engine.SelectResult
import kestrel.engine.StatementResult
import kestrel.sql.lexer.Lexer
import kestrel.sql.parser.Parser
import kestrel.storage.Value
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class LateralJoinTest {
private val catalog = Catalog()
private val executor = Executor(catalog)
private fun run(sql: String): List<StatementResult> =
Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) }
private fun select(sql: String): SelectResult = run(sql).single() as SelectResult
private fun explain(sql: String): List<String> = (run(sql).single() as ExplainResult).plan
private fun text(value: String) = Value.TextValue(value)
private fun int(value: Long) = Value.IntegerValue(value)
private fun seed() {
run("CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
run("CREATE TABLE employees (id INTEGER PRIMARY KEY, deptId INTEGER, name TEXT, salary INTEGER)")
run("INSERT INTO departments VALUES (1, 'eng'), (2, 'sales')")
run(
"INSERT INTO employees VALUES " +
"(1, 1, 'ada', 300), (2, 1, 'linus', 250), (3, 1, 'grace', 400), " +
"(4, 2, 'sam', 150), (5, 2, 'kay', 200)"
)
}
@Test
fun lateralJoinComputesTopOnePerGroupWhichAPlainJoinCannotExpress() {
seed()
val result = select(
"SELECT d.name, top.name, top.salary FROM departments d " +
"JOIN LATERAL (SELECT e.name, e.salary FROM employees e WHERE e.deptId = d.id " +
"ORDER BY e.salary DESC LIMIT 1) AS top ON TRUE " +
"ORDER BY d.name ASC"
)
assertEquals(
listOf(
listOf(text("eng"), text("grace"), int(400)),
listOf(text("sales"), text("kay"), int(200))
),
result.rows
)
}
@Test
fun lateralJoinWithLimitGreaterThanOneReturnsMultipleRowsPerLeftRow() {
seed()
val result = select(
"SELECT d.name, top.name FROM departments d " +
"JOIN LATERAL (SELECT e.name FROM employees e WHERE e.deptId = d.id " +
"ORDER BY e.salary DESC LIMIT 2) AS top ON TRUE " +
"ORDER BY d.name ASC, top.name ASC"
)
assertEquals(
listOf(
listOf(text("eng"), text("ada")),
listOf(text("eng"), text("grace")),
listOf(text("sales"), text("kay")),
listOf(text("sales"), text("sam"))
),
result.rows
)
}
@Test
fun innerJoinLateralDropsGroupsWithNoMatchingRows() {
seed()
run("INSERT INTO departments VALUES (3, 'empty')")
val result = select(
"SELECT d.name FROM departments d " +
"JOIN LATERAL (SELECT e.name FROM employees e WHERE e.deptId = d.id LIMIT 1) AS top ON TRUE"
)
assertTrue(result.rows.none { it == listOf(text("empty")) })
assertEquals(2, result.rows.size)
}
@Test
fun leftJoinLateralKeepsGroupsWithNoMatchingRowsAsNull() {
seed()
run("INSERT INTO departments VALUES (3, 'empty')")
val result = select(
"SELECT d.name, top.name FROM departments d " +
"LEFT JOIN LATERAL (SELECT e.name FROM employees e WHERE e.deptId = d.id " +
"ORDER BY e.salary DESC LIMIT 1) AS top ON TRUE " +
"ORDER BY d.name ASC"
)
assertEquals(
listOf(
listOf(text("empty"), Value.NullValue),
listOf(text("eng"), text("grace")),
listOf(text("sales"), text("kay"))
),
result.rows
)
}
@Test
fun leftJoinLateralOverAnEmptyBaseTableProducesNoRowsWithoutRunningTheSubquery() {
run("CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
run("CREATE TABLE employees (id INTEGER PRIMARY KEY, deptId INTEGER, name TEXT)")
val result = select(
"SELECT d.name, top.name FROM departments d " +
"LEFT JOIN LATERAL (SELECT e.name FROM employees e WHERE e.deptId = d.id) AS top ON TRUE"
)
assertEquals(emptyList(), result.rows)
}
@Test
fun chainedLateralJoinsSeeEarlierLateralAliases() {
seed()
run("CREATE TABLE dual (x INTEGER)")
run("INSERT INTO dual VALUES (1)")
val result = select(
"SELECT d.name, bonus.amount FROM departments d " +
"JOIN LATERAL (SELECT e.salary FROM employees e WHERE e.deptId = d.id " +
"ORDER BY e.salary DESC LIMIT 1) AS top ON TRUE " +
"JOIN LATERAL (SELECT top.salary / 10 AS amount FROM dual) AS bonus ON TRUE " +
"ORDER BY d.name ASC"
)
assertEquals(listOf(listOf(text("eng"), int(40)), listOf(text("sales"), int(20))), result.rows)
}
@Test
fun lateralSubqueryCannotReferenceATableJoinedLater() {
seed()
assertFailsWith<KestrelException> {
select(
"SELECT * FROM departments d " +
"JOIN LATERAL (SELECT e.name FROM employees e WHERE e.salary = later.salary) AS top ON TRUE " +
"JOIN employees later ON later.deptId = d.id"
)
}
}
@Test
fun lateralKeywordBeforeAPlainTableIsRejected() {
seed()
assertFailsWith<ParseException> {
select("SELECT * FROM departments d JOIN LATERAL employees ON TRUE")
}
}
@Test
fun lateralSubqueryWithStarProjectionResolvesColumnsStructurallyFromItsBaseTable() {
seed()
// The lateral subquery itself uses `SELECT *`, so its output column names come from
// [kestrel.engine.SelectRunner.structuralColumnNames] walking the base table's schema in
// the catalog, not from an executed row - exercised here before any left row exists yet.
val result = select(
"SELECT d.name, top.name, top.salary FROM departments d " +
"JOIN LATERAL (SELECT * FROM employees e WHERE e.deptId = d.id " +
"ORDER BY e.salary DESC LIMIT 1) AS top ON TRUE " +
"ORDER BY d.name ASC"
)
assertEquals(
listOf(listOf(text("eng"), text("grace"), int(400)), listOf(text("sales"), text("kay"), int(200))),
result.rows
)
}
@Test
fun explainAnnotatesTheJoinAsLateralAndNeverUsesHashJoin() {
seed()
val plan = explain(
"EXPLAIN SELECT d.name, top.name FROM departments d " +
"JOIN LATERAL (SELECT e.name FROM employees e WHERE e.deptId = d.id) AS top ON top.name = d.name"
)
assertTrue(plan.any { it.contains("Lateral") }, "got $plan")
assertTrue(plan.none { it.contains("Hash Join") }, "got $plan")
}
@Test
fun crossJoinLateralIsEquivalentToPlainJoinLateralOnTrue() {
seed()
val cross = select(
"SELECT d.name, top.name, top.salary FROM departments d " +
"CROSS JOIN LATERAL (SELECT e.name, e.salary FROM employees e WHERE e.deptId = d.id " +
"ORDER BY e.salary DESC LIMIT 1) AS top ORDER BY d.name ASC"
)
val onTrue = select(
"SELECT d.name, top.name, top.salary FROM departments d " +
"JOIN LATERAL (SELECT e.name, e.salary FROM employees e WHERE e.deptId = d.id " +
"ORDER BY e.salary DESC LIMIT 1) AS top ON TRUE ORDER BY d.name ASC"
)
assertEquals(onTrue.rows, cross.rows)
}
}
+136
View File
@@ -105,4 +105,140 @@ class LexerTest {
fun unexpectedCharacterFails() {
assertFailsWith<LexException> { lex("SELECT @") }
}
@Test
fun predicateKeywordsAreRecognized() {
assertEquals(
listOf(
TokenType.Is, TokenType.In, TokenType.Between, TokenType.Like,
TokenType.Case, TokenType.When, TokenType.Then, TokenType.Else, TokenType.End,
TokenType.EndOfInput
),
types("IS in Between LIKE case WHEN then ELSE end")
)
}
@Test
fun existsKeywordIsRecognizedAndCaseInsensitive() {
assertEquals(listOf(TokenType.Exists, TokenType.Exists, TokenType.EndOfInput), types("EXISTS exists"))
}
@Test
fun viewAndReplaceKeywordsAreRecognizedAndCaseInsensitive() {
assertEquals(
listOf(TokenType.View, TokenType.Replace, TokenType.View, TokenType.Replace, TokenType.EndOfInput),
types("VIEW REPLACE view replace")
)
}
@Test
fun compoundQueryKeywordsAreRecognizedAndCaseInsensitive() {
assertEquals(
listOf(
TokenType.Union, TokenType.All, TokenType.Intersect, TokenType.Except,
TokenType.Union, TokenType.All, TokenType.Intersect, TokenType.Except,
TokenType.EndOfInput
),
types("UNION ALL INTERSECT EXCEPT union all intersect except")
)
}
@Test
fun withAndRecursiveKeywordsAreRecognizedAndCaseInsensitive() {
assertEquals(
listOf(TokenType.With, TokenType.Recursive, TokenType.With, TokenType.Recursive, TokenType.EndOfInput),
types("WITH RECURSIVE with recursive")
)
}
@Test
fun castCascadeRestrictDecimalAndDateKeywordsAreRecognizedAndCaseInsensitive() {
assertEquals(
listOf(
TokenType.Cast, TokenType.Cascade, TokenType.Restrict, TokenType.Decimal, TokenType.Date,
TokenType.Cast, TokenType.Cascade, TokenType.Restrict, TokenType.Decimal, TokenType.Date,
TokenType.EndOfInput
),
types("CAST CASCADE RESTRICT DECIMAL DATE cast cascade restrict decimal date")
)
}
@Test
fun foreignKeyKeywordsAreRecognizedAndCaseInsensitive() {
assertEquals(
listOf(
TokenType.Foreign, TokenType.Key, TokenType.References, TokenType.Check,
TokenType.No, TokenType.Action,
TokenType.Foreign, TokenType.Key, TokenType.References, TokenType.Check,
TokenType.No, TokenType.Action,
TokenType.EndOfInput
),
types("FOREIGN KEY REFERENCES CHECK NO ACTION foreign key references check no action")
)
}
@Test
fun uniqueKeywordIsRecognizedAndCaseInsensitive() {
assertEquals(
listOf(TokenType.Unique, TokenType.Unique, TokenType.EndOfInput),
types("UNIQUE unique")
)
}
@Test
fun prepareExecuteAndDeallocateKeywordsAreRecognizedAndCaseInsensitive() {
assertEquals(
listOf(
TokenType.Prepare, TokenType.Execute, TokenType.Deallocate,
TokenType.Prepare, TokenType.Execute, TokenType.Deallocate,
TokenType.EndOfInput
),
types("PREPARE EXECUTE DEALLOCATE prepare execute deallocate")
)
}
@Test
fun tempAndTemporaryKeywordsAreRecognizedAndCaseInsensitive() {
assertEquals(
listOf(TokenType.Temp, TokenType.Temporary, TokenType.Temp, TokenType.Temporary, TokenType.EndOfInput),
types("TEMP TEMPORARY temp temporary")
)
}
@Test
fun collateKeywordIsRecognizedAndCaseInsensitive() {
assertEquals(
listOf(TokenType.Collate, TokenType.Collate, TokenType.EndOfInput),
types("COLLATE collate")
)
}
@Test
fun parameterTokenCarriesItsOneBasedIndex() {
val token = lex("\$1").first()
assertEquals(TokenType.Parameter, token.type)
assertEquals(1L, token.intValue)
assertEquals("\$1", token.lexeme)
}
@Test
fun parameterTokenSupportsMultipleDigits() {
val token = lex("\$42").first()
assertEquals(TokenType.Parameter, token.type)
assertEquals(42L, token.intValue)
}
@Test
fun consecutiveParametersLexAsSeparateTokens() {
assertEquals(
listOf(TokenType.Parameter, TokenType.Comma, TokenType.Parameter, TokenType.EndOfInput),
types("\$1,\$2")
)
}
@Test
fun dollarSignWithNoFollowingDigitIsALexError() {
assertFailsWith<LexException> { lex("\$") }
assertFailsWith<LexException> { lex("\$abc") }
}
}
+459
View File
@@ -0,0 +1,459 @@
package kestrel
import kestrel.catalog.Catalog
import kestrel.engine.Executor
import kestrel.engine.MergeResult
import kestrel.engine.SelectResult
import kestrel.engine.StatementResult
import kestrel.sql.lexer.Lexer
import kestrel.sql.parser.Parser
import kestrel.storage.Value
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class MergeTest {
private val catalog = Catalog()
private val executor = Executor(catalog)
private fun run(sql: String): List<StatementResult> =
Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) }
private fun exec(sql: String): StatementResult = run(sql).single()
private fun select(sql: String): SelectResult = exec(sql) as SelectResult
private fun int(value: Long) = Value.IntegerValue(value)
private fun text(value: String) = Value.TextValue(value)
private fun setUpAccountsAndStaging() {
run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER NOT NULL)")
run("CREATE TABLE staging (id INTEGER, amount INTEGER)")
}
@Test
fun matchedRowIsUpdated() {
setUpAccountsAndStaging()
run("INSERT INTO accounts VALUES (1, 100)")
run("INSERT INTO staging VALUES (1, 50)")
val result = exec(
"MERGE INTO accounts USING staging ON accounts.id = staging.id " +
"WHEN MATCHED THEN UPDATE SET balance = accounts.balance + staging.amount"
) as MergeResult
assertEquals(MergeResult(0, 1, 0), result)
assertEquals(listOf(listOf(int(150))), select("SELECT balance FROM accounts").rows)
}
@Test
fun unmatchedRowIsInserted() {
setUpAccountsAndStaging()
run("INSERT INTO staging VALUES (7, 30)")
val result = exec(
"MERGE INTO accounts USING staging ON accounts.id = staging.id " +
"WHEN MATCHED THEN UPDATE SET balance = accounts.balance + staging.amount " +
"WHEN NOT MATCHED THEN INSERT (id, balance) VALUES (staging.id, staging.amount)"
) as MergeResult
assertEquals(MergeResult(1, 0, 0), result)
assertEquals(listOf(listOf(int(7), int(30))), select("SELECT id, balance FROM accounts").rows)
}
@Test
fun matchedRowIsDeleted() {
setUpAccountsAndStaging()
run("INSERT INTO accounts VALUES (1, 100)")
run("INSERT INTO staging VALUES (1, 0)")
val result = exec(
"MERGE INTO accounts USING staging ON accounts.id = staging.id WHEN MATCHED THEN DELETE"
) as MergeResult
assertEquals(MergeResult(0, 0, 1), result)
assertEquals(emptyList(), select("SELECT * FROM accounts").rows)
}
@Test
fun aMultiRowMergeInsertsUpdatesAndDeletesInOneStatement() {
setUpAccountsAndStaging()
run("INSERT INTO accounts VALUES (1, 100), (2, 200)")
run("INSERT INTO staging VALUES (1, 10), (2, -999), (3, 5)")
val result = exec(
"MERGE INTO accounts USING staging ON accounts.id = staging.id " +
"WHEN MATCHED AND accounts.balance + staging.amount < 0 THEN DELETE " +
"WHEN MATCHED THEN UPDATE SET balance = accounts.balance + staging.amount " +
"WHEN NOT MATCHED THEN INSERT (id, balance) VALUES (staging.id, staging.amount)"
) as MergeResult
assertEquals(MergeResult(1, 1, 1), result)
assertEquals(
listOf(listOf(int(1), int(110)), listOf(int(3), int(5))),
select("SELECT id, balance FROM accounts ORDER BY id").rows
)
}
@Test
fun firstSatisfiedWhenMatchedClauseWinsInWrittenOrder() {
setUpAccountsAndStaging()
run("INSERT INTO accounts VALUES (1, 100)")
run("INSERT INTO staging VALUES (1, 5)")
exec(
"MERGE INTO accounts USING staging ON accounts.id = staging.id " +
"WHEN MATCHED AND staging.amount > 100 THEN DELETE " +
"WHEN MATCHED AND staging.amount > 0 THEN UPDATE SET balance = 1 " +
"WHEN MATCHED THEN UPDATE SET balance = 2"
)
assertEquals(listOf(listOf(int(1))), select("SELECT balance FROM accounts").rows)
}
@Test
fun firstSatisfiedWhenNotMatchedClauseWinsInWrittenOrder() {
setUpAccountsAndStaging()
run("INSERT INTO staging VALUES (1, 500)")
exec(
"MERGE INTO accounts USING staging ON accounts.id = staging.id " +
"WHEN NOT MATCHED AND staging.amount > 1000 THEN INSERT (id, balance) VALUES (staging.id, 1) " +
"WHEN NOT MATCHED THEN INSERT (id, balance) VALUES (staging.id, 2)"
)
assertEquals(listOf(listOf(int(1), int(2))), select("SELECT id, balance FROM accounts").rows)
}
@Test
fun noSatisfiedWhenClauseLeavesTheRowUntouched() {
setUpAccountsAndStaging()
run("INSERT INTO accounts VALUES (1, 100)")
run("INSERT INTO staging VALUES (1, 5)")
val result = exec(
"MERGE INTO accounts USING staging ON accounts.id = staging.id " +
"WHEN MATCHED AND staging.amount > 1000 THEN UPDATE SET balance = 0"
) as MergeResult
assertEquals(MergeResult(0, 0, 0), result)
assertEquals(listOf(listOf(int(100))), select("SELECT balance FROM accounts").rows)
}
@Test
fun anUnmatchedSourceRowWithNoSatisfiedNotMatchedClauseInsertsNothing() {
setUpAccountsAndStaging()
run("INSERT INTO staging VALUES (1, 5)")
val result = exec(
"MERGE INTO accounts USING staging ON accounts.id = staging.id " +
"WHEN NOT MATCHED AND staging.amount > 1000 THEN INSERT (id, balance) VALUES (staging.id, staging.amount)"
) as MergeResult
assertEquals(MergeResult(0, 0, 0), result)
assertEquals(emptyList(), select("SELECT * FROM accounts").rows)
}
@Test
fun aTargetRowMatchedByTwoSourceRowsCannotBeAffectedTwice() {
setUpAccountsAndStaging()
run("INSERT INTO accounts VALUES (1, 100)")
run("INSERT INTO staging VALUES (1, 5), (1, 10)")
val error = assertFailsWith<KestrelException> {
run(
"MERGE INTO accounts USING staging ON accounts.id = staging.id " +
"WHEN MATCHED THEN UPDATE SET balance = accounts.balance + staging.amount"
)
}
assertTrue(error.message!!.contains("a second time"), error.message)
}
@Test
fun aTargetRowMatchedByTwoSourceRowsIsFineWhenOnlyOneClauseActuallyFires() {
setUpAccountsAndStaging()
run("INSERT INTO accounts VALUES (1, 100)")
run("INSERT INTO staging VALUES (1, 5), (1, 999)")
exec(
"MERGE INTO accounts USING staging ON accounts.id = staging.id " +
"WHEN MATCHED AND staging.amount > 100 THEN UPDATE SET balance = accounts.balance + staging.amount"
)
assertEquals(listOf(listOf(int(1099))), select("SELECT balance FROM accounts").rows)
}
@Test
fun anUnqualifiedColumnPresentOnBothSidesIsAmbiguousInOnCondition() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, v INTEGER)")
run("CREATE TABLE s (id INTEGER, v INTEGER)")
val error = assertFailsWith<KestrelException> {
run("MERGE INTO t USING s ON id = s.id WHEN MATCHED THEN UPDATE SET v = 1")
}
assertTrue(error.message!!.contains("ambiguous"), error.message)
}
@Test
fun aQualifierMatchingNeitherSideFails() {
setUpAccountsAndStaging()
val error = assertFailsWith<KestrelException> {
run("MERGE INTO accounts USING staging ON bogus.id = staging.id WHEN MATCHED THEN DELETE")
}
assertTrue(error.message!!.contains("no such table or alias"), error.message)
}
@Test
fun aWhenNotMatchedClauseCannotReferenceTheTargetTable() {
setUpAccountsAndStaging()
val error = assertFailsWith<KestrelException> {
run(
"MERGE INTO accounts USING staging ON accounts.id = staging.id " +
"WHEN NOT MATCHED AND accounts.balance > 0 THEN INSERT (id, balance) VALUES (staging.id, 1)"
)
}
assertTrue(error.message!!.contains("WHEN NOT MATCHED"), error.message)
}
@Test
fun columnValidationRunsUpFrontEvenWhenNoSourceRowEverReachesTheBadClause() {
setUpAccountsAndStaging()
val error = assertFailsWith<KestrelException> {
run(
"MERGE INTO accounts USING staging ON accounts.id = staging.id " +
"WHEN NOT MATCHED THEN INSERT (id, nope) VALUES (staging.id, 1)"
)
}
assertTrue(error.message!!.contains("no such column"), error.message)
}
@Test
fun theTargetAndSourceCannotShareTheSameNameOrAlias() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY)")
run("CREATE TABLE other (id INTEGER)")
val error = assertFailsWith<KestrelException> {
run("MERGE INTO t USING other AS t ON t.id = t.id WHEN MATCHED THEN DELETE")
}
assertTrue(error.message!!.contains("target and source"), error.message)
}
@Test
fun sourceCanBeADerivedTableSubquery() {
setUpAccountsAndStaging()
run("INSERT INTO staging VALUES (1, 10), (1, 20)")
exec(
"MERGE INTO accounts USING (SELECT id, SUM(amount) AS total FROM staging GROUP BY id) AS agg " +
"ON accounts.id = agg.id " +
"WHEN NOT MATCHED THEN INSERT (id, balance) VALUES (agg.id, agg.total)"
)
assertEquals(listOf(listOf(int(1), int(30))), select("SELECT id, balance FROM accounts").rows)
}
@Test
fun aDefaultKeywordInTheInsertValuesUsesTheColumnDefault() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT DEFAULT 'unlabeled')")
run("CREATE TABLE s (id INTEGER)")
run("INSERT INTO s VALUES (1)")
exec("MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED THEN INSERT (id, label) VALUES (s.id, DEFAULT)")
assertEquals(listOf(listOf(int(1), text("unlabeled"))), select("SELECT id, label FROM t").rows)
}
@Test
fun anOmittedColumnListInsertsIntoEveryColumnInSchemaOrder() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT)")
run("CREATE TABLE s (id INTEGER, label TEXT)")
run("INSERT INTO s VALUES (1, 'a')")
exec("MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED THEN INSERT VALUES (s.id, s.label)")
assertEquals(listOf(listOf(int(1), text("a"))), select("SELECT id, label FROM t").rows)
}
@Test
fun aNextvalDefaultStillAdvancesThroughAMergeInsert() {
run("CREATE SEQUENCE seq")
run("CREATE TABLE t (id INTEGER PRIMARY KEY DEFAULT NEXTVAL('seq'), label TEXT)")
run("CREATE TABLE s (label TEXT)")
run("INSERT INTO s VALUES ('a'), ('b')")
exec("MERGE INTO t USING s ON t.label = s.label WHEN NOT MATCHED THEN INSERT (label) VALUES (s.label)")
assertEquals(
listOf(listOf(int(1), text("a")), listOf(int(2), text("b"))),
select("SELECT id, label FROM t ORDER BY id").rows
)
}
@Test
fun notNullViolationOnAMergeInsertFailsAndInsertsNothing() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT NOT NULL)")
run("CREATE TABLE s (id INTEGER)")
run("INSERT INTO s VALUES (1)")
assertFailsWith<KestrelException> {
run("MERGE INTO t USING s ON t.id = s.id WHEN NOT MATCHED THEN INSERT (id) VALUES (s.id)")
}
assertEquals(emptyList(), select("SELECT * FROM t").rows)
}
@Test
fun aForeignKeyViolationOnAMergeUpdateIsRejected() {
run("CREATE TABLE departments (id INTEGER PRIMARY KEY)")
run("CREATE TABLE employees (id INTEGER PRIMARY KEY, dept_id INTEGER REFERENCES departments(id))")
run("CREATE TABLE moves (id INTEGER, new_dept INTEGER)")
run("INSERT INTO departments VALUES (1)")
run("INSERT INTO employees VALUES (1, 1)")
run("INSERT INTO moves VALUES (1, 999)")
val error = assertFailsWith<KestrelException> {
run("MERGE INTO employees USING moves ON employees.id = moves.id WHEN MATCHED THEN UPDATE SET dept_id = moves.new_dept")
}
assertTrue(error.message!!.contains("FOREIGN KEY"), error.message)
assertEquals(listOf(listOf(int(1))), select("SELECT dept_id FROM employees").rows)
}
@Test
fun rowTriggersFireForEachMergeDrivenAction() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT)")
run("CREATE TABLE s (id INTEGER, label TEXT)")
run("CREATE TABLE log (event TEXT)")
run("INSERT INTO t VALUES (1, 'old'), (2, 'gone')")
run("INSERT INTO s VALUES (1, 'new'), (2, 'new'), (3, 'fresh')")
run("CREATE TRIGGER log_insert AFTER INSERT ON t FOR EACH ROW BEGIN INSERT INTO log VALUES (CONCAT('insert:', NEW.label)); END")
run("CREATE TRIGGER log_update AFTER UPDATE ON t FOR EACH ROW BEGIN INSERT INTO log VALUES (CONCAT('update:', NEW.label)); END")
run("CREATE TRIGGER log_delete AFTER DELETE ON t FOR EACH ROW BEGIN INSERT INTO log VALUES (CONCAT('delete:', OLD.label)); END")
exec(
"MERGE INTO t USING s ON t.id = s.id " +
"WHEN MATCHED AND s.id = 2 THEN DELETE " +
"WHEN MATCHED THEN UPDATE SET label = s.label " +
"WHEN NOT MATCHED THEN INSERT (id, label) VALUES (s.id, s.label)"
)
assertEquals(
setOf(text("update:new"), text("delete:gone"), text("insert:fresh")),
select("SELECT event FROM log").rows.map { it.single() }.toSet()
)
}
@Test
fun rollingBackATransactionUndoesEveryEffectOfAMerge() {
setUpAccountsAndStaging()
run("INSERT INTO accounts VALUES (1, 100)")
run("INSERT INTO staging VALUES (1, 50), (2, 20)")
run("BEGIN")
exec(
"MERGE INTO accounts USING staging ON accounts.id = staging.id " +
"WHEN MATCHED THEN UPDATE SET balance = accounts.balance + staging.amount " +
"WHEN NOT MATCHED THEN INSERT (id, balance) VALUES (staging.id, staging.amount)"
)
run("ROLLBACK")
assertEquals(listOf(listOf(int(1), int(100))), select("SELECT id, balance FROM accounts").rows)
}
@Test
fun anAliasedTargetAndSourceCanBeReferencedByTheirAlias() {
setUpAccountsAndStaging()
run("INSERT INTO accounts VALUES (1, 100)")
run("INSERT INTO staging VALUES (1, 25)")
exec(
"MERGE INTO accounts AS acc USING staging AS s ON acc.id = s.id " +
"WHEN MATCHED THEN UPDATE SET balance = acc.balance + s.amount"
)
assertEquals(listOf(listOf(int(125))), select("SELECT balance FROM accounts").rows)
}
@Test
fun mergeRequiresAtLeastOneWhenClause() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY)")
run("CREATE TABLE s (id INTEGER)")
assertFailsWith<KestrelException> { run("MERGE INTO t USING s ON t.id = s.id") }
}
@Test
fun mergingIntoAnUnknownTableFails() {
run("CREATE TABLE s (id INTEGER)")
assertFailsWith<KestrelException> {
run("MERGE INTO nope USING s ON nope.id = s.id WHEN MATCHED THEN DELETE")
}
}
@Test
fun returningStarProjectsEveryColumnOfEveryActionedRow() {
setUpAccountsAndStaging()
run("INSERT INTO accounts VALUES (1, 100)")
run("INSERT INTO staging VALUES (1, 10), (2, 5)")
val result = exec(
"MERGE INTO accounts USING staging ON accounts.id = staging.id " +
"WHEN MATCHED THEN UPDATE SET balance = accounts.balance + staging.amount " +
"WHEN NOT MATCHED THEN INSERT (id, balance) VALUES (staging.id, staging.amount) " +
"RETURNING *"
) as MergeResult
assertEquals(listOf("id", "balance"), result.returning!!.columns)
assertEquals(
listOf(listOf(int(1), int(110)), listOf(int(2), int(5))),
result.returning!!.rows
)
}
@Test
fun returningEvaluatesExpressionsAgainstTheAffectedRow() {
setUpAccountsAndStaging()
run("INSERT INTO accounts VALUES (1, 100)")
run("INSERT INTO staging VALUES (1, 10)")
val result = exec(
"MERGE INTO accounts USING staging ON accounts.id = staging.id " +
"WHEN MATCHED THEN UPDATE SET balance = accounts.balance + staging.amount " +
"RETURNING balance * 2 AS doubled"
) as MergeResult
assertEquals(listOf("doubled"), result.returning!!.columns)
assertEquals(listOf(listOf(int(220))), result.returning!!.rows)
}
@Test
fun returningIncludesThePreDeletionImageOfADeletedRow() {
setUpAccountsAndStaging()
run("INSERT INTO accounts VALUES (1, 100)")
run("INSERT INTO staging VALUES (1, 0)")
val result = exec(
"MERGE INTO accounts USING staging ON accounts.id = staging.id WHEN MATCHED THEN DELETE RETURNING id, balance"
) as MergeResult
assertEquals(listOf(listOf(int(1), int(100))), result.returning!!.rows)
assertEquals(emptyList(), select("SELECT * FROM accounts").rows)
}
@Test
fun mergeActionLabelsEachReturnedRowByWhichClauseFired() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY, label TEXT)")
run("CREATE TABLE s (id INTEGER, label TEXT)")
run("INSERT INTO t VALUES (1, 'old'), (2, 'gone')")
run("INSERT INTO s VALUES (1, 'new'), (2, 'new'), (3, 'fresh')")
val result = exec(
"MERGE INTO t USING s ON t.id = s.id " +
"WHEN MATCHED AND s.id = 2 THEN DELETE " +
"WHEN MATCHED THEN UPDATE SET label = s.label " +
"WHEN NOT MATCHED THEN INSERT (id, label) VALUES (s.id, s.label) " +
"RETURNING id, MERGE_ACTION() AS kind"
) as MergeResult
assertEquals(
setOf(listOf(int(1), text("UPDATE")), listOf(int(2), text("DELETE")), listOf(int(3), text("INSERT"))),
result.returning!!.rows.toSet()
)
}
@Test
fun noRowMatchingAnyClauseReturnsAnEmptyReturningResultNotANullOne() {
setUpAccountsAndStaging()
run("INSERT INTO accounts VALUES (1, 100)")
run("INSERT INTO staging VALUES (1, 5)")
val result = exec(
"MERGE INTO accounts USING staging ON accounts.id = staging.id " +
"WHEN MATCHED AND staging.amount > 1000 THEN UPDATE SET balance = 0 " +
"RETURNING *"
) as MergeResult
assertEquals(emptyList(), result.returning!!.rows)
}
@Test
fun mergeActionIsRejectedOutsideOfAMergesOwnReturningClause() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY)")
run("INSERT INTO t VALUES (1)")
val error = assertFailsWith<KestrelException> { select("SELECT MERGE_ACTION() FROM t") }
assertTrue(error.message!!.contains("MERGE_ACTION"), error.message)
}
@Test
fun mergeActionInAPlainInsertReturningIsRejectedOnceARowActuallyReturns() {
run("CREATE TABLE t (id INTEGER PRIMARY KEY)")
val error = assertFailsWith<KestrelException> {
run("INSERT INTO t VALUES (1) RETURNING MERGE_ACTION()")
}
assertTrue(error.message!!.contains("MERGE_ACTION"), error.message)
}
@Test
fun returningColumnValidationRunsUpFrontEvenWhenNoSourceRowEverReachesAMatchingClause() {
setUpAccountsAndStaging()
val error = assertFailsWith<KestrelException> {
run(
"MERGE INTO accounts USING staging ON accounts.id = staging.id " +
"WHEN MATCHED THEN DELETE RETURNING nope"
)
}
assertTrue(error.message!!.contains("no such column"), error.message)
}
}
+117
View File
@@ -0,0 +1,117 @@
package kestrel
import kestrel.catalog.Catalog
import kestrel.engine.Executor
import kestrel.engine.SelectResult
import kestrel.engine.StatementResult
import kestrel.sql.lexer.Lexer
import kestrel.sql.parser.Parser
import kestrel.storage.Value
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* `ORDER BY expr [ASC|DESC] [NULLS FIRST|NULLS LAST]` on the in-memory engine, covering every
* comparator that shares [kestrel.engine.compareOrderKeyValues]: plain top-level ORDER BY, a
* GROUP BY's own ORDER BY (over already-aggregated rows), a window function's `OVER (... ORDER BY
* ...)`, and STRING_AGG's internal ORDER BY. [kestrel.store.sql.DiskExecutorOrderByNullsTest] mirrors
* the plain-ORDER-BY and window cases against the disk engine.
*/
class OrderByNullsTest {
private val catalog = Catalog()
private val executor = Executor(catalog)
private fun run(sql: String): List<StatementResult> =
Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) }
private fun select(sql: String): SelectResult = run(sql).single() as SelectResult
private fun int(value: Long) = Value.IntegerValue(value)
private fun text(value: String) = Value.TextValue(value)
/** ids 2 and 4 are NULL; the rest ascend 10/20/30 out of id order so ASC/DESC actually reorder them. */
private fun seedScored() {
run("CREATE TABLE scored (id INTEGER PRIMARY KEY, score INTEGER)")
run("INSERT INTO scored VALUES (1, 10), (2, NULL), (3, 30), (4, NULL), (5, 20)")
}
@Test
fun ascendingOrderByDefaultsToNullsFirstButNullsLastOverridesIt() {
seedScored()
assertEquals(
listOf(2, 4, 1, 5, 3).map { listOf(int(it.toLong())) },
select("SELECT id FROM scored ORDER BY score").rows
)
assertEquals(
listOf(1, 5, 3, 2, 4).map { listOf(int(it.toLong())) },
select("SELECT id FROM scored ORDER BY score NULLS LAST").rows
)
}
@Test
fun descendingOrderByDefaultsToNullsLastButNullsFirstOverridesIt() {
seedScored()
assertEquals(
listOf(3, 5, 1, 2, 4).map { listOf(int(it.toLong())) },
select("SELECT id FROM scored ORDER BY score DESC").rows
)
assertEquals(
listOf(2, 4, 3, 5, 1).map { listOf(int(it.toLong())) },
select("SELECT id FROM scored ORDER BY score DESC NULLS FIRST").rows
)
}
@Test
fun nullsLastOnASecondaryKeyOnlyAffectsThatKeyInMultiColumnOrderBy() {
run("CREATE TABLE grouped2 (id INTEGER PRIMARY KEY, grp TEXT, score INTEGER)")
run("INSERT INTO grouped2 VALUES (1, 'a', NULL), (2, 'a', 5), (3, 'b', NULL), (4, 'b', 1)")
val result = select("SELECT id FROM grouped2 ORDER BY grp, score NULLS LAST")
assertEquals(listOf(2, 1, 4, 3).map { listOf(int(it.toLong())) }, result.rows)
}
@Test
fun explicitNullsLastControlsWhereAnAllNullAggregateGroupSortsRelativeToOthers() {
run("CREATE TABLE g (id INTEGER PRIMARY KEY, dept TEXT, bonus INTEGER)")
run("INSERT INTO g VALUES (1, 'x', 100), (2, 'x', NULL), (3, 'y', NULL)")
assertEquals(
listOf(listOf(text("y"), Value.NullValue), listOf(text("x"), int(100))),
select("SELECT dept, MAX(bonus) FROM g GROUP BY dept ORDER BY MAX(bonus)").rows
)
assertEquals(
listOf(listOf(text("x"), int(100)), listOf(text("y"), Value.NullValue)),
select("SELECT dept, MAX(bonus) FROM g GROUP BY dept ORDER BY MAX(bonus) NULLS LAST").rows
)
}
@Test
fun windowFunctionOrderByHonorsAnExplicitNullsLastClause() {
seedScored()
val result = select(
"SELECT id, ROW_NUMBER() OVER (ORDER BY score NULLS LAST) AS rn FROM scored ORDER BY id ASC"
)
assertEquals(
listOf(
listOf(int(1), int(1)), listOf(int(2), int(4)), listOf(int(3), int(3)),
listOf(int(4), int(5)), listOf(int(5), int(2))
),
result.rows
)
}
@Test
fun stringAggInternalOrderByHonorsAnExplicitNullsLastClause() {
run("CREATE TABLE namedScored (id INTEGER PRIMARY KEY, name TEXT, score INTEGER)")
run(
"INSERT INTO namedScored VALUES " +
"(1, 'a', 10), (2, 'b', NULL), (3, 'c', 30), (4, 'd', NULL), (5, 'e', 20)"
)
assertEquals(
listOf(listOf(text("b,d,a,e,c"))),
select("SELECT STRING_AGG(name, ',' ORDER BY score) FROM namedScored").rows
)
assertEquals(
listOf(listOf(text("a,e,c,b,d"))),
select("SELECT STRING_AGG(name, ',' ORDER BY score NULLS LAST) FROM namedScored").rows
)
}
}
File diff suppressed because it is too large. Load diff
+957
View File
@@ -1,11 +1,17 @@
package kestrel
import java.io.BufferedReader
import java.io.File
import java.io.IOException
import java.io.Reader
import java.io.StringReader
import java.nio.file.Files
import kestrel.cli.Repl
import kestrel.persistence.Persistence
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
class PersistenceTest {
@@ -17,6 +23,34 @@ class PersistenceTest {
return output.toString()
}
/**
* Delivers [text] like a normal [StringReader], but throws instead of ever signalling EOF -
* simulating a process that vanished (killed, crashed, power loss) right after its last
* statement ran, never reaching either `.exit` or a graceful end of input. [Repl.run] only
* checkpoints (see [Repl.finish]) on one of those two paths, so this is the one way to drive it
* through every statement in [text] while proving NOTHING beyond the incremental WAL write each
* statement already triggered on its own (via [kestrel.cli.Repl.recordForPersistence]) ever
* makes it to disk - exactly the scenario a TEMP table's persistence exclusion must hold up
* under, not just the far more common clean-`.exit`-then-checkpoint path every other test here
* exercises.
*/
private class CrashingReader(text: String) : Reader() {
private val delegate = StringReader(text)
override fun read(cbuf: CharArray, off: Int, len: Int): Int {
val read = delegate.read(cbuf, off, len)
if (read == -1) throw IOException("simulated crash: process died before EOF")
return read
}
override fun close() = delegate.close()
}
private fun runScriptThatCrashesBeforeItCanCheckpoint(dir: File, script: String) {
val output = StringBuilder()
assertFailsWith<IOException> {
Repl(output, output, Persistence(dir)).run(BufferedReader(CrashingReader(script)))
}
}
@Test
fun dataSurvivesReopenAfterCheckpoint() {
val dir = tempDir()
@@ -74,6 +108,82 @@ class PersistenceTest {
assertTrue(!reopened.contains("2"), "rolled back insert must not persist")
}
@Test
fun aMergeSurvivesReopenWithBothItsUpdateAndItsInsertIntact() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER);
CREATE TABLE staging (id INTEGER, amount INTEGER);
INSERT INTO accounts VALUES (1, 100);
INSERT INTO staging VALUES (1, 10), (2, 5);
MERGE INTO accounts USING staging ON accounts.id = staging.id
WHEN MATCHED THEN UPDATE SET balance = accounts.balance + staging.amount
WHEN NOT MATCHED THEN INSERT (id, balance) VALUES (staging.id, staging.amount);
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT id, balance FROM accounts ORDER BY id ASC;\n.exit\n")
assertContains(reopened, "110")
assertContains(reopened, "5")
}
@Test
fun rollbackToSavepointInsideACommittedTransactionDoesNotPersistTheUndoneInsert() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE t (id INTEGER PRIMARY KEY);
BEGIN;
INSERT INTO t VALUES (111);
SAVEPOINT s1;
INSERT INTO t VALUES (222);
ROLLBACK TO SAVEPOINT s1;
INSERT INTO t VALUES (333);
COMMIT;
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT id FROM t ORDER BY id ASC;\n.exit\n")
assertContains(reopened, "111")
assertContains(reopened, "333")
assertTrue(!reopened.contains("222"), "insert undone by ROLLBACK TO SAVEPOINT must not persist")
}
@Test
fun insertOnConflictDoUpdateSurvivesReopen() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER);
INSERT INTO accounts VALUES (1, 100);
INSERT INTO accounts VALUES (1, 50) ON CONFLICT (id) DO UPDATE SET balance = accounts.balance + excluded.balance;
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT balance FROM accounts WHERE id = 1;\n.exit\n")
assertContains(reopened, "150")
}
@Test
fun walReplayReconstructsAnInsertOnConflictStatementCorrectly() {
val dir = tempDir()
val persistence = Persistence(dir)
persistence.appendWal(
listOf(
"CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER);",
"INSERT INTO accounts VALUES (1, 100);",
"INSERT INTO accounts VALUES (1, 50) ON CONFLICT (id) DO UPDATE SET balance = accounts.balance + excluded.balance;"
)
)
val output = StringBuilder()
Repl(output, output, Persistence(dir)).run("SELECT balance FROM accounts WHERE id = 1;\n.exit\n".reader().buffered())
assertContains(output.toString(), "150")
}
@Test
fun walReplayRecoversWithoutCheckpoint() {
val dir = tempDir()
@@ -89,4 +199,851 @@ class PersistenceTest {
assertContains(output.toString(), "1")
assertContains(output.toString(), "2")
}
@Test
fun viewDefinitionSurvivesReopenAfterCheckpointAndStaysLiveOverNewData() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER);
INSERT INTO users VALUES (1, 'ada', 36), (2, 'linus', 54);
CREATE VIEW adults AS SELECT id, name FROM users WHERE age >= 40;
.exit
""".trimIndent()
)
val afterCheckpoint = runScript(
dir,
"""
SELECT name FROM adults ORDER BY id ASC;
INSERT INTO users VALUES (3, 'grace', 85);
.exit
""".trimIndent()
)
assertContains(afterCheckpoint, "linus")
assertTrue(!afterCheckpoint.contains("ada"), "view predicate must exclude rows under the age threshold")
val afterInsert = runScript(dir, "SELECT name FROM adults ORDER BY id ASC;\n.views\n.exit\n")
assertContains(afterInsert, "grace")
assertContains(afterInsert, "adults")
}
@Test
fun dropViewSurvivesReopenWithoutACheckpoint() {
val dir = tempDir()
val persistence = Persistence(dir)
persistence.appendWal(
listOf(
"CREATE TABLE t (id INTEGER);",
"CREATE VIEW v AS SELECT id FROM t;",
"DROP VIEW v;"
)
)
val output = StringBuilder()
Repl(output, output, Persistence(dir)).run(".views\n.exit\n".reader().buffered())
assertContains(output.toString(), "no views")
}
@Test
fun updatesUsingScalarFunctionsAndPredicatesSurviveReopen() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, bio TEXT);
INSERT INTO users VALUES (1, 'ada', 'mathematician'), (2, 'linus', NULL), (3, 'grace', 'admiral');
UPDATE users SET name = UPPER(name) WHERE bio IS NOT NULL;
DELETE FROM users WHERE name IN ('linus') OR bio LIKE '%admiral';
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT name FROM users ORDER BY id ASC;\n.exit\n")
assertContains(reopened, "ADA")
assertTrue(!reopened.contains("linus"), "row matching NOT IN predicate must stay deleted")
assertTrue(!reopened.contains("grace") && !reopened.contains("GRACE"), "row matching LIKE predicate must stay deleted")
}
@Test
fun decimalAndDateColumnsSurviveReopenAfterCheckpoint() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE orders (id INTEGER PRIMARY KEY, amount DECIMAL(10, 2), placed DATE);
INSERT INTO orders VALUES (1, 19.9, CAST('2024-03-01' AS DATE));
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT amount, placed FROM orders;\n.exit\n")
assertContains(reopened, "19.90")
assertContains(reopened, "2024-03-01")
}
@Test
fun decimalAndDateColumnsSurviveReopenWithoutACheckpointViaWalReplay() {
val dir = tempDir()
val persistence = Persistence(dir)
persistence.appendWal(
listOf(
"CREATE TABLE orders (id INTEGER PRIMARY KEY, amount DECIMAL(10, 2), placed DATE);",
"INSERT INTO orders VALUES (1, CAST('42.5' AS DECIMAL(10, 2)), CAST('2024-03-01' AS DATE));"
)
)
val output = StringBuilder()
Repl(output, output, Persistence(dir)).run("SELECT amount, placed FROM orders;\n.exit\n".reader().buffered())
assertContains(output.toString(), "42.50")
assertContains(output.toString(), "2024-03-01")
}
@Test
fun foreignKeyAndCheckConstraintsSurviveReopenAfterCheckpoint() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT);
CREATE TABLE employees (id INTEGER PRIMARY KEY, dept_id INTEGER REFERENCES departments(id) ON DELETE CASCADE, age INTEGER CHECK (age > 0));
INSERT INTO departments VALUES (1, 'eng');
INSERT INTO employees VALUES (1, 1, 30);
.exit
""".trimIndent()
)
val afterConstraintChecks = runScript(
dir,
"""
INSERT INTO employees VALUES (2, 99, 30);
.exit
""".trimIndent()
)
assertContains(afterConstraintChecks, "Error")
assertContains(afterConstraintChecks, "FOREIGN KEY")
val afterCascade = runScript(
dir,
"""
DELETE FROM departments WHERE id = 1;
SELECT * FROM employees;
.exit
""".trimIndent()
)
assertContains(afterCascade, "0 rows")
}
@Test
fun uniqueConstraintsSurviveReopenAfterCheckpoint() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT UNIQUE, a INTEGER, b INTEGER, UNIQUE (a, b));
INSERT INTO users VALUES (1, 'ada@example.com', 1, 1);
.exit
""".trimIndent()
)
val afterReopen = runScript(
dir,
"""
INSERT INTO users VALUES (2, 'ada@example.com', 2, 2);
.exit
""".trimIndent()
)
assertContains(afterReopen, "Error")
assertContains(afterReopen, "UNIQUE constraint")
val afterCompositeViolation = runScript(
dir,
"""
INSERT INTO users VALUES (3, 'grace@example.com', 1, 1);
.exit
""".trimIndent()
)
assertContains(afterCompositeViolation, "Error")
assertContains(afterCompositeViolation, "UNIQUE constraint")
}
@Test
fun uniqueIndexSurvivesReopenAfterCheckpointAndKeepsEnforcingAndAccelerating() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT);
INSERT INTO users VALUES (1, 'ada@example.com');
CREATE UNIQUE INDEX idx_email ON users (email);
.exit
""".trimIndent()
)
val afterReopen = runScript(
dir,
"""
INSERT INTO users VALUES (2, 'ada@example.com');
EXPLAIN SELECT id FROM users WHERE email = 'ada@example.com';
.schema users
.exit
""".trimIndent()
)
assertContains(afterReopen, "Error")
assertContains(afterReopen, "UNIQUE constraint")
assertContains(afterReopen, "Index Scan using idx_email")
assertContains(afterReopen, "CREATE UNIQUE INDEX idx_email ON users (email);")
}
@Test
fun compositeUniqueIndexSurvivesReopenAfterCheckpointAndKeepsEnforcingAndAccelerating() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE orders (id INTEGER PRIMARY KEY, region TEXT, seq INTEGER);
INSERT INTO orders VALUES (1, 'EU', 1);
CREATE UNIQUE INDEX idx_region_seq ON orders (region, seq);
.exit
""".trimIndent()
)
val afterReopen = runScript(
dir,
"""
INSERT INTO orders VALUES (2, 'EU', 1);
INSERT INTO orders VALUES (3, 'US', 1);
EXPLAIN SELECT id FROM orders WHERE region = 'EU' AND seq = 1;
.schema orders
.exit
""".trimIndent()
)
assertContains(afterReopen, "Error")
assertContains(afterReopen, "UNIQUE constraint")
assertContains(afterReopen, "Index Scan using idx_region_seq")
assertContains(afterReopen, "CREATE UNIQUE INDEX idx_region_seq ON orders (region, seq);")
}
@Test
fun compositePrimaryKeyAndForeignKeySurviveReopenAfterCheckpoint() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE orders (region TEXT, order_no INTEGER, PRIMARY KEY (region, order_no));
CREATE TABLE line_items (region TEXT, order_no INTEGER, sku TEXT, FOREIGN KEY (region, order_no) REFERENCES orders (region, order_no) ON DELETE CASCADE);
INSERT INTO orders VALUES ('eu', 1);
INSERT INTO line_items VALUES ('eu', 1, 'SKU1');
.exit
""".trimIndent()
)
val duplicateRejected = runScript(dir, "INSERT INTO orders VALUES ('eu', 1);\n.exit\n")
assertContains(duplicateRejected, "Error")
assertContains(duplicateRejected, "UNIQUE constraint")
val afterCascade = runScript(
dir,
"""
DELETE FROM orders WHERE region = 'eu';
SELECT * FROM line_items;
.exit
""".trimIndent()
)
assertTrue(!afterCascade.contains("SKU1"), "cascade delete across a composite FK must survive reopen")
}
@Test
fun alterTableAddColumnSurvivesReopenAfterCheckpointAndBackfillsAndDefaultsFutureInserts() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
INSERT INTO users VALUES (1, 'ada'), (2, 'linus');
ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active';
INSERT INTO users (id, name) VALUES (3, 'grace');
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT name, status FROM users ORDER BY id ASC;\n.exit\n")
assertContains(reopened, "ada")
assertContains(reopened, "active")
val statusCount = Regex("active").findAll(reopened).count()
assertEquals(3, statusCount, "all three rows (backfilled and freshly inserted) must carry the DEFAULT")
}
@Test
fun generatedColumnSurvivesReopenAfterCheckpointAndKeepsComputingFutureInserts() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED);
INSERT INTO t (a) VALUES (5);
.exit
""".trimIndent()
)
runScript(dir, "INSERT INTO t (a) VALUES (7);\n.exit\n")
val reopened = runScript(dir, "SELECT b FROM t ORDER BY a ASC;\n.exit\n")
assertContains(reopened, "10")
assertContains(reopened, "14")
}
@Test
fun generatedColumnSurvivesReopenWithoutACheckpointViaWalReplay() {
val dir = tempDir()
val persistence = Persistence(dir)
persistence.appendWal(
listOf(
"CREATE TABLE t (a INTEGER, b INTEGER GENERATED ALWAYS AS (a * 2) STORED);",
"INSERT INTO t (a) VALUES (5);"
)
)
val output = StringBuilder()
Repl(output, output, Persistence(dir)).run("SELECT b FROM t WHERE a = 5;\n.exit\n".reader().buffered())
assertContains(output.toString(), "10")
}
@Test
fun alterTableRenameAndDropColumnSurviveReopenWithoutACheckpointViaWalReplay() {
val dir = tempDir()
val persistence = Persistence(dir)
persistence.appendWal(
listOf(
"CREATE TABLE t (id INTEGER PRIMARY KEY, bio TEXT, scratch TEXT);",
"INSERT INTO t VALUES (1, 'mathematician', 'temp');",
"ALTER TABLE t RENAME COLUMN bio TO biography;",
"ALTER TABLE t DROP COLUMN scratch;"
)
)
val output = StringBuilder()
Repl(output, output, Persistence(dir)).run("SELECT biography FROM t WHERE id = 1;\n.exit\n".reader().buffered())
assertContains(output.toString(), "mathematician")
}
@Test
fun alterTableRenameToSurvivesReopenWithoutACheckpointViaWalReplay() {
val dir = tempDir()
val persistence = Persistence(dir)
persistence.appendWal(
listOf(
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT);",
"INSERT INTO t VALUES (1, 'ada');",
"ALTER TABLE t RENAME TO people;"
)
)
val output = StringBuilder()
Repl(output, output, Persistence(dir)).run("SELECT name FROM people WHERE id = 1;\n.tables\n.exit\n".reader().buffered())
val text = output.toString()
assertContains(text, "ada")
val tableLines = text.lines().map { it.trim() }
assertTrue(tableLines.contains("people"), "renamed table must appear in .tables")
assertTrue(tableLines.none { it.equals("t", ignoreCase = true) }, "old table name must not linger in .tables")
}
@Test
fun alterTableRenameToPropagatesIntoADependentViewAcrossACheckpointReopen() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE departments (id INTEGER PRIMARY KEY, name TEXT);
INSERT INTO departments VALUES (1, 'eng');
CREATE VIEW v AS SELECT id, name FROM departments;
ALTER TABLE departments RENAME TO divisions;
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT * FROM v;\n.exit\n")
assertContains(reopened, "eng")
}
@Test
fun stringAggViewSurvivesReopenAfterCheckpoint() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE tags (item TEXT, label TEXT);
INSERT INTO tags VALUES ('widget', 'blue'), ('widget', 'small'), ('gadget', 'red');
CREATE VIEW item_labels AS SELECT item, STRING_AGG(label, ', ' ORDER BY label) AS labels FROM tags GROUP BY item;
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT item, labels FROM item_labels ORDER BY item;\n.exit\n")
assertContains(reopened, "blue, small")
assertContains(reopened, "red")
}
/**
* `COUNT(DISTINCT ...)`'s `distinct` flag is a field on [kestrel.sql.ast.FunctionExpression] just
* like STRING_AGG's `separator`/`orderBy` above - this proves [kestrel.persistence.SqlWriter]
* actually dumps the `DISTINCT` keyword back out (not just the wrapped argument), so a view built
* on it does not silently turn into a plain, non-deduplicating `COUNT` after reopen.
*/
@Test
fun countDistinctViewSurvivesReopenAfterCheckpoint() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE sales (dept TEXT, amount INTEGER);
INSERT INTO sales VALUES ('eng', 100), ('eng', 100), ('eng', 200), ('ops', 100), ('ops', 100);
CREATE VIEW dept_distinct_amounts AS SELECT dept, COUNT(DISTINCT amount) AS n FROM sales GROUP BY dept;
.exit
""".trimIndent()
)
val reopened = runScript(
dir,
"SELECT n FROM dept_distinct_amounts WHERE dept = 'eng';\n" +
"SELECT n FROM dept_distinct_amounts WHERE dept = 'ops';\n.exit\n"
)
assertContains(reopened, "| 2 |")
assertContains(reopened, "| 1 |")
}
/**
* `FILTER (WHERE ...)`'s predicate is a field on [kestrel.sql.ast.FunctionExpression] just like
* STRING_AGG's `separator`/`orderBy` and COUNT's `distinct` above - this proves
* [kestrel.persistence.SqlWriter] actually dumps the `FILTER (WHERE ...)` clause back out, so a
* view built on it does not silently turn into an unfiltered aggregate after reopen.
*/
@Test
fun filteredAggregateViewSurvivesReopenAfterCheckpoint() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE sales (dept TEXT, amount INTEGER);
INSERT INTO sales VALUES ('eng', 100), ('eng', 200), ('ops', 100), ('ops', 100);
CREATE VIEW dept_high_value_counts AS SELECT dept, COUNT(*) FILTER (WHERE amount > 100) AS n FROM sales GROUP BY dept;
.exit
""".trimIndent()
)
val reopened = runScript(
dir,
"SELECT n FROM dept_high_value_counts WHERE dept = 'eng';\n" +
"SELECT n FROM dept_high_value_counts WHERE dept = 'ops';\n.exit\n"
)
assertContains(reopened, "| 1 |")
assertContains(reopened, "| 0 |")
}
/**
* [kestrel.catalog.RelationRename] rewrites a renamed table's name everywhere it could appear in a
* dependent view's stored query, including inside a nested subquery - this proves that recursion
* now also reaches a subquery sitting inside an aggregate's `FILTER (WHERE ...)` predicate
* specifically (a [kestrel.sql.ast.FunctionExpression] field distinct from `argument`, easy to miss
* when only `argument` gets rewritten), not just a subquery in WHERE/projection.
*/
@Test
fun renameTablePropagatesIntoAViewsAggregateFilterSubquery() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE items (id INTEGER PRIMARY KEY, amount INTEGER);
INSERT INTO items VALUES (1, 100), (2, 200);
CREATE TABLE config (id INTEGER PRIMARY KEY, threshold INTEGER);
INSERT INTO config VALUES (1, 150);
CREATE VIEW joined AS SELECT COUNT(*) FILTER (WHERE amount > (SELECT threshold FROM config WHERE id = 1)) AS n FROM items;
ALTER TABLE config RENAME TO settings;
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT n FROM joined;\n.exit\n")
assertContains(reopened, "| 1 |")
}
/**
* [kestrel.catalog.RelationRename] rewrites a renamed table's name everywhere it could appear in a
* dependent view's stored query, including inside a nested subquery - this proves that recursion
* now also reaches a subquery sitting inside STRING_AGG's `separator` argument specifically (a
* [kestrel.sql.ast.FunctionExpression] field distinct from `argument`, easy to miss when only
* `argument` gets rewritten), not just a subquery in WHERE/projection.
*/
@Test
fun renameTablePropagatesIntoAViewsStringAggSeparatorSubquery() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE items (id INTEGER PRIMARY KEY, label TEXT);
INSERT INTO items VALUES (1, 'b'), (2, 'a');
CREATE TABLE config (id INTEGER PRIMARY KEY, sep TEXT);
INSERT INTO config VALUES (1, ',');
CREATE VIEW joined AS SELECT STRING_AGG(label, (SELECT sep FROM config WHERE id = 1) ORDER BY label) AS labels FROM items;
ALTER TABLE config RENAME TO settings;
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT labels FROM joined;\n.exit\n")
assertContains(reopened, "a,b")
}
@Test
fun distinctViewSurvivesReopenAfterCheckpoint() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE sales (dept TEXT);
INSERT INTO sales VALUES ('eng'), ('eng'), ('ops');
CREATE VIEW depts AS SELECT DISTINCT dept FROM sales;
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT dept FROM depts ORDER BY dept;\n.exit\n")
assertContains(reopened, "2 rows")
assertContains(reopened, "eng")
assertContains(reopened, "ops")
}
@Test
fun limitAndOffsetViewSurvivesReopenAfterCheckpoint() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE sales (dept TEXT, amount INTEGER);
INSERT INTO sales VALUES ('eng', 300), ('eng', 200), ('ops', 100);
CREATE VIEW second_highest AS SELECT dept, amount FROM sales ORDER BY amount DESC LIMIT 1 OFFSET 1;
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT dept, amount FROM second_highest;\n.exit\n")
assertContains(reopened, "1 row")
assertContains(reopened, "200")
assertTrue(!reopened.contains("300"), "amount=300 was skipped by OFFSET and must not survive")
}
@Test
fun distinctOnViewSurvivesReopenAfterCheckpoint() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE sales (dept TEXT, amount INTEGER);
INSERT INTO sales VALUES ('eng', 100), ('eng', 200), ('ops', 50);
CREATE VIEW top_per_dept AS SELECT DISTINCT ON (dept) dept, amount FROM sales ORDER BY dept, amount DESC;
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT dept, amount FROM top_per_dept ORDER BY dept;\n.exit\n")
assertContains(reopened, "2 rows")
assertContains(reopened, "200")
assertTrue(!reopened.contains("100"), "amount=100 lost the DISTINCT ON tie-break and must not survive")
}
/**
* Mirrors [renameTablePropagatesIntoAViewsStringAggSeparatorSubquery] for the newly added
* [kestrel.sql.ast.SelectStatement.distinctOn] field - [kestrel.catalog.RelationRename] must walk
* into it exactly like it already does for `groupBy`/`having`, or a subquery hiding inside
* `DISTINCT ON (...)` would silently keep pointing at a table name that no longer exists after
* reopen (the rewritten AST, not the original text, is what SqlWriter dumps on checkpoint).
*/
@Test
fun renameTablePropagatesIntoAViewsDistinctOnSubquery() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE items (dept TEXT, amount INTEGER);
INSERT INTO items VALUES ('eng', 100), ('eng', 200), ('ops', 50);
CREATE TABLE config (id INTEGER PRIMARY KEY, threshold INTEGER);
INSERT INTO config VALUES (1, 0);
CREATE VIEW top_per_dept AS SELECT DISTINCT ON (dept, (SELECT threshold FROM config WHERE id = 1)) dept, amount FROM items ORDER BY dept, (SELECT threshold FROM config WHERE id = 1), amount DESC;
ALTER TABLE config RENAME TO settings;
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT dept, amount FROM top_per_dept ORDER BY dept;\n.exit\n")
assertContains(reopened, "2 rows")
assertContains(reopened, "200")
assertContains(reopened, "50")
}
/**
* A trigger created before its table's data would refire on every reload (SqlWriter.dump
* intentionally writes CREATE TRIGGER only after every table's rows) - this proves reopening
* does NOT duplicate the audit row the trigger already produced during the first session,
* while a genuinely new INSERT after reopening still fires it normally.
*/
@Test
fun triggerCreationIsDumpedAfterDataSoReloadDoesNotRefireForAlreadyPersistedRows() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE orders (id INTEGER PRIMARY KEY, amount INTEGER);
CREATE TABLE audit (order_id INTEGER);
CREATE TRIGGER log_insert AFTER INSERT ON orders FOR EACH ROW BEGIN INSERT INTO audit VALUES (NEW.id); END;
INSERT INTO orders VALUES (1, 100);
.exit
""".trimIndent()
)
val afterFirstReopen = runScript(dir, "SELECT order_id FROM audit;\n.exit\n")
assertContains(afterFirstReopen, "1 row")
val afterSecondReopen = runScript(
dir,
"INSERT INTO orders VALUES (2, 200);\nSELECT order_id FROM audit ORDER BY order_id;\n.exit\n"
)
assertContains(afterSecondReopen, "2 rows")
}
/**
* The dumped `CREATE SEQUENCE` must bake in the sequence's CURRENT position (via `START WITH`),
* not the one it was originally created with - otherwise a checkpoint reopen would reissue
* already-handed-out values, breaking the one guarantee a sequence exists to provide.
*/
@Test
fun sequencePositionSurvivesReopenAfterCheckpointInsteadOfRestarting() {
val dir = tempDir()
runScript(
dir,
"""
CREATE SEQUENCE seq;
CREATE TABLE t (id INTEGER);
INSERT INTO t VALUES (NEXTVAL('seq')), (NEXTVAL('seq'));
.exit
""".trimIndent()
)
val reopened = runScript(dir, "INSERT INTO t VALUES (NEXTVAL('seq'));\nSELECT id FROM t WHERE id = 3;\n.exit\n")
// A restart-from-scratch bug would reissue 1 as the third value (1, 2, 1) instead of
// continuing to 3 - this query would then find zero rows instead of one.
assertContains(reopened, "1 row")
}
@Test
fun sequenceCreationAndNextvalDefaultSurviveReopenWithoutACheckpointViaWalReplay() {
val dir = tempDir()
val persistence = Persistence(dir)
persistence.appendWal(
listOf(
"CREATE SEQUENCE order_ids;",
"CREATE TABLE orders (id INTEGER DEFAULT NEXTVAL('order_ids'), item TEXT);",
"INSERT INTO orders (item) VALUES ('a');"
)
)
val output = StringBuilder()
Repl(output, output, Persistence(dir)).run(
"INSERT INTO orders (item) VALUES ('b');\nSELECT item FROM orders WHERE id = 1;\nSELECT item FROM orders WHERE id = 2;\n.exit\n"
.reader().buffered()
)
val text = output.toString()
assertContains(text, "a")
assertContains(text, "b")
assertEquals(2, text.lines().count { it.trim() == "1 row" })
}
@Test
fun dropSequenceSurvivesReopenAfterCheckpoint() {
val dir = tempDir()
runScript(
dir,
"""
CREATE SEQUENCE seq;
DROP SEQUENCE seq;
.exit
""".trimIndent()
)
val reopened = runScript(dir, ".sequences\n.exit\n")
assertContains(reopened, "no sequences")
}
/**
* PREPARE/EXECUTE themselves are session-scoped and never written to the WAL (see
* PrepareStatement's own doc) - but an INSERT run THROUGH EXECUTE is real data and must survive
* a reopen exactly like one typed literally would. This is the whole reason
* [kestrel.cli.Repl.executeBuffer] persists EXECUTE's RESOLVED statement instead of its literal
* `EXECUTE name(...)` text: that literal text would be meaningless to replay against a fresh
* session that never re-ran the PREPARE.
*/
@Test
fun executeOfAPreparedInsertPersistsTheResolvedRowAcrossReopen() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER);
PREPARE addUser AS INSERT INTO users VALUES ($1, $2, $3);
EXECUTE addUser(1, 'ada', 36);
EXECUTE addUser(2, 'linus', 54);
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT name, age FROM users ORDER BY id ASC;\n.exit\n")
assertContains(reopened, "ada")
assertContains(reopened, "linus")
}
@Test
fun preparedStatementsDoNotSurviveReopen() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
PREPARE addUser AS INSERT INTO users VALUES ($1, $2);
.exit
""".trimIndent()
)
val reopened = runScript(dir, "EXECUTE addUser(1, 'ada');\n.exit\n")
assertContains(reopened, "Error: no such prepared statement: addUser")
}
@Test
fun temporaryTableDoesNotSurviveReopenAfterCheckpointButARegularTableAlongsideItDoes() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE regular (id INTEGER PRIMARY KEY, note TEXT);
CREATE TEMP TABLE scratch (id INTEGER PRIMARY KEY, note TEXT);
INSERT INTO regular VALUES (1, 'keepsMe');
INSERT INTO scratch VALUES (1, 'losesMe');
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT note FROM regular;\nSELECT note FROM scratch;\n.exit\n")
assertContains(reopened, "keepsMe")
assertContains(reopened, "Error: no such table: scratch")
assertTrue(!reopened.contains("losesMe"), "a TEMP table's rows must not survive reopen")
}
@Test
fun renamingATemporaryTableKeepsItTemporarySoTheRenamedNameAlsoDoesNotSurviveReopen() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TEMP TABLE scratch (id INTEGER PRIMARY KEY);
ALTER TABLE scratch RENAME TO renamedScratch;
INSERT INTO renamedScratch VALUES (1);
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT id FROM renamedScratch;\n.exit\n")
assertContains(reopened, "Error: no such table: renamedScratch")
}
@Test
fun addColumnAndDropColumnOnATemporaryTableKeepItTemporaryAcrossReopen() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TEMP TABLE scratch (id INTEGER PRIMARY KEY, a INTEGER);
ALTER TABLE scratch ADD COLUMN b TEXT;
ALTER TABLE scratch DROP COLUMN a;
INSERT INTO scratch VALUES (1, 'x');
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT id FROM scratch;\n.exit\n")
assertContains(reopened, "Error: no such table: scratch")
}
@Test
fun renamingAColumnOnATemporaryTableKeepsItTemporaryAcrossReopen() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TEMP TABLE scratch (id INTEGER PRIMARY KEY, a INTEGER);
ALTER TABLE scratch RENAME COLUMN a TO b;
INSERT INTO scratch VALUES (1, 2);
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT id FROM scratch;\n.exit\n")
assertContains(reopened, "Error: no such table: scratch")
}
@Test
fun aTemporaryTablesForeignKeyBeingDroppedByAnUnrelatedCascadeKeepsItTemporaryAcrossReopen() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TABLE parent (id INTEGER PRIMARY KEY);
CREATE TEMP TABLE child (id INTEGER PRIMARY KEY, parentId INTEGER, FOREIGN KEY (parentId) REFERENCES parent(id));
DROP TABLE parent CASCADE;
INSERT INTO child VALUES (1, NULL);
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT id FROM child;\n.exit\n")
assertContains(reopened, "Error: no such table: child")
}
@Test
fun anIndexOnATemporaryTableNeverPersists() {
val dir = tempDir()
runScript(
dir,
"""
CREATE TEMP TABLE scratch (id INTEGER PRIMARY KEY, tag TEXT);
CREATE INDEX scratchTag ON scratch (tag);
INSERT INTO scratch VALUES (1, 'x');
.exit
""".trimIndent()
)
val reopened = runScript(dir, "SELECT id FROM scratch;\n.indexes\n.exit\n")
assertContains(reopened, "Error: no such table: scratch")
assertTrue(!reopened.contains("scratchTag"), "an index on a TEMP table must not survive reopen either")
}
@Test
fun droppingAnIndexOnATemporaryTableNeverReachesTheIncrementalWalEitherSideOfIt() {
val dir = tempDir()
runScriptThatCrashesBeforeItCanCheckpoint(
dir,
"""
CREATE TEMP TABLE scratch (id INTEGER PRIMARY KEY, tag TEXT);
CREATE INDEX scratchTag ON scratch (tag);
DROP INDEX scratchTag;
INSERT INTO scratch VALUES (1, 'x');
""".trimIndent() + "\n"
)
val reopened = runScript(dir, "SELECT id FROM scratch;\n.indexes\n.exit\n")
assertContains(reopened, "Error: no such table: scratch")
assertTrue(!reopened.contains("scratchTag"))
}
@Test
fun aTemporaryTableNeverReachesTheIncrementalWriteAheadLogEvenBeforeACheckpoint() {
val dir = tempDir()
runScriptThatCrashesBeforeItCanCheckpoint(
dir,
"""
CREATE TABLE regular (id INTEGER PRIMARY KEY);
CREATE TEMP TABLE scratch (id INTEGER PRIMARY KEY);
INSERT INTO regular VALUES (111);
INSERT INTO scratch VALUES (222);
""".trimIndent() + "\n"
)
// No checkpoint ever ran, so this reopen replays raw incremental WAL entries only - if the
// TEMP table's CREATE/INSERT had leaked into that log, replaying "INSERT INTO scratch..."
// would either resurrect it or (if its CREATE alone were skipped but not its INSERT) throw
// "no such table" loudly enough to show up here.
val reopened = runScript(dir, "SELECT id FROM regular;\nSELECT id FROM scratch;\n.exit\n")
assertContains(reopened, "111")
assertContains(reopened, "Error: no such table: scratch")
assertTrue(!reopened.contains("222"), "a TEMP table's row must never reach the WAL, crash or not")
}
@Test
fun aTruncateNamingBothATemporaryAndARegularTableStillPersistsTheRegularTablesTruncationBeforeACheckpoint() {
val dir = tempDir()
runScriptThatCrashesBeforeItCanCheckpoint(
dir,
"""
CREATE TABLE regular (id INTEGER);
CREATE TEMP TABLE scratch (id INTEGER);
INSERT INTO regular VALUES (111), (222);
INSERT INTO scratch VALUES (333);
TRUNCATE TABLE regular, scratch;
""".trimIndent() + "\n"
)
// If the TEMP name were not stripped out of the persisted TRUNCATE text, replaying
// "TRUNCATE TABLE regular, scratch" against a reload where scratch was never recreated would
// throw before truncating ANYTHING (Catalog.truncateTables validates every name up front) -
// silently leaving regular's rows intact instead of empty, since replay errors are swallowed
// (see Repl.printResult's `if (replaying) return`).
val reopened = runScript(dir, "SELECT id FROM regular;\n.exit\n")
assertTrue(!reopened.contains("111") && !reopened.contains("222"), "regular's TRUNCATE must survive the crash-before-checkpoint replay")
}
}
@@ -0,0 +1,237 @@
package kestrel
import kestrel.catalog.Catalog
import kestrel.engine.DeallocateResult
import kestrel.engine.DeleteResult
import kestrel.engine.Executor
import kestrel.engine.InsertResult
import kestrel.engine.PrepareResult
import kestrel.engine.SelectResult
import kestrel.engine.StatementResult
import kestrel.engine.UpdateResult
import kestrel.sql.lexer.Lexer
import kestrel.sql.parser.Parser
import kestrel.storage.Value
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* End-to-end tests for `PREPARE`/`EXECUTE`/`DEALLOCATE` on the in-memory engine: `$1`, `$2`, ...
* positional parameters resolved by [kestrel.engine.ParameterSubstitution], driven through the same
* [Lexer]/[Parser]/[Executor] pipeline real SQL text goes through, not the AST directly.
*/
class PreparedStatementTest {
private val catalog = Catalog()
private val executor = Executor(catalog)
private fun run(sql: String): List<StatementResult> =
Parser(Lexer(sql).tokenize()).parseProgram().map { executor.execute(it) }
private fun select(sql: String): SelectResult = run(sql).single() as SelectResult
private fun seedAccounts() {
run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT NOT NULL, balance INTEGER)")
run("INSERT INTO accounts VALUES (1, 'ada', 100), (2, 'linus', 200), (3, 'grace', 300)")
}
@Test
fun prepareReturnsItsNameAndExecuteRunsAParameterizedSelect() {
seedAccounts()
assertEquals(PrepareResult("byId"), run("PREPARE byId AS SELECT name, balance FROM accounts WHERE id = \$1").single())
val result = select("EXECUTE byId(2)")
assertEquals(listOf("name", "balance"), result.columns)
assertEquals(listOf(listOf(Value.TextValue("linus"), Value.IntegerValue(200))), result.rows)
}
@Test
fun theSamePreparedStatementCanBeExecutedRepeatedlyWithDifferentArguments() {
seedAccounts()
run("PREPARE byId AS SELECT name FROM accounts WHERE id = \$1")
assertEquals(listOf(listOf(Value.TextValue("ada"))), select("EXECUTE byId(1)").rows)
assertEquals(listOf(listOf(Value.TextValue("grace"))), select("EXECUTE byId(3)").rows)
}
@Test
fun executeSubstitutesMultipleParametersPositionally() {
seedAccounts()
run("PREPARE inRange AS SELECT name FROM accounts WHERE balance >= \$1 AND balance <= \$2 ORDER BY balance")
assertEquals(
listOf(listOf(Value.TextValue("linus")), listOf(Value.TextValue("grace"))),
select("EXECUTE inRange(150, 350)").rows
)
}
@Test
fun prepareCanWrapInsertAndExecuteInsertsARealRow() {
run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT NOT NULL, balance INTEGER)")
run("PREPARE addAccount AS INSERT INTO accounts VALUES (\$1, \$2, \$3)")
assertEquals(InsertResult(1), run("EXECUTE addAccount(1, 'ada', 100)").single())
assertEquals(
listOf(listOf(Value.IntegerValue(1), Value.TextValue("ada"), Value.IntegerValue(100))),
select("SELECT * FROM accounts").rows
)
assertEquals(InsertResult(1), run("EXECUTE addAccount(2, 'linus', 200)").single())
assertEquals(2, select("SELECT * FROM accounts").rows.size)
}
@Test
fun prepareCanWrapUpdate() {
seedAccounts()
run("PREPARE credit AS UPDATE accounts SET balance = balance + \$1 WHERE id = \$2")
assertEquals(UpdateResult(1), run("EXECUTE credit(50, 1)").single())
assertEquals(listOf(listOf(Value.IntegerValue(150))), select("SELECT balance FROM accounts WHERE id = 1").rows)
}
@Test
fun prepareCanWrapDelete() {
seedAccounts()
run("PREPARE removeAccount AS DELETE FROM accounts WHERE id = \$1")
assertEquals(DeleteResult(1), run("EXECUTE removeAccount(2)").single())
assertEquals(2, select("SELECT * FROM accounts").rows.size)
}
@Test
fun prepareCanWrapMergeAndAParameterReachesTheMatchedUpdateAssignment() {
run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT NOT NULL, balance INTEGER)")
run("CREATE TABLE staging (id INTEGER PRIMARY KEY, name TEXT NOT NULL, balance INTEGER)")
run("INSERT INTO accounts VALUES (1, 'ada', 100)")
run("INSERT INTO staging VALUES (1, 'ada', 500)")
run(
"PREPARE topUp AS MERGE INTO accounts USING staging ON accounts.id = staging.id " +
"WHEN MATCHED THEN UPDATE SET balance = staging.balance + \$1"
)
run("EXECUTE topUp(10)")
assertEquals(listOf(listOf(Value.IntegerValue(510))), select("SELECT balance FROM accounts WHERE id = 1").rows)
// Executed a second time with a DIFFERENT argument to prove substitution recomputes fresh
// every EXECUTE rather than baking in whatever value the first call used.
run("EXECUTE topUp(20)")
assertEquals(listOf(listOf(Value.IntegerValue(520))), select("SELECT balance FROM accounts WHERE id = 1").rows)
}
@Test
fun prepareCanWrapMergeAndAParameterReachesTheNotMatchedInsertValues() {
run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT NOT NULL, balance INTEGER)")
run("CREATE TABLE staging (id INTEGER PRIMARY KEY, name TEXT NOT NULL, balance INTEGER)")
run("INSERT INTO staging VALUES (1, 'ada', 500)")
run(
"PREPARE seedFromStaging AS MERGE INTO accounts USING staging ON accounts.id = staging.id " +
"WHEN NOT MATCHED THEN INSERT VALUES (staging.id, staging.name, staging.balance + \$1)"
)
run("EXECUTE seedFromStaging(10)")
assertEquals(
listOf(listOf(Value.IntegerValue(1), Value.TextValue("ada"), Value.IntegerValue(510))),
select("SELECT * FROM accounts").rows
)
}
@Test
fun prepareCanWrapMergeAndAParameterInTheOnConditionControlsWhetherARowMatches() {
run("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT NOT NULL, balance INTEGER)")
run("CREATE TABLE staging (id INTEGER PRIMARY KEY, name TEXT NOT NULL, balance INTEGER)")
run("INSERT INTO accounts VALUES (1, 'ada', 100)")
run("INSERT INTO staging VALUES (1, 'ada', 500)")
// Only a WHEN MATCHED clause - a source row the ON condition rejects has no NOT MATCHED
// clause to fall into, so it is a true no-op rather than an insert attempt.
run(
"PREPARE conditionalTopUp AS MERGE INTO accounts USING staging " +
"ON accounts.id = staging.id AND staging.balance > \$1 " +
"WHEN MATCHED THEN UPDATE SET balance = staging.balance"
)
run("EXECUTE conditionalTopUp(1000)")
assertEquals(listOf(listOf(Value.IntegerValue(100))), select("SELECT balance FROM accounts WHERE id = 1").rows)
run("EXECUTE conditionalTopUp(10)")
assertEquals(listOf(listOf(Value.IntegerValue(500))), select("SELECT balance FROM accounts WHERE id = 1").rows)
}
@Test
fun executeWithTheWrongNumberOfArgumentsFailsWithAClearMessage() {
seedAccounts()
run("PREPARE byId AS SELECT name FROM accounts WHERE id = \$1")
val tooMany = assertFailsWith<KestrelException> { run("EXECUTE byId(1, 2)") }
assertTrue(tooMany.message!!.contains("expects 1 parameter"), tooMany.message)
assertFailsWith<KestrelException> { run("EXECUTE byId()") }
}
@Test
fun executeOfAnUnknownNameFails() {
assertFailsWith<KestrelException> { run("EXECUTE nope(1)") }
}
@Test
fun preparingTheSameNameTwiceFails() {
seedAccounts()
run("PREPARE byId AS SELECT name FROM accounts WHERE id = \$1")
assertFailsWith<KestrelException> { run("PREPARE byId AS SELECT balance FROM accounts WHERE id = \$1") }
}
@Test
fun deallocateRemovesAPreparedStatementSoExecuteNoLongerFindsIt() {
seedAccounts()
run("PREPARE byId AS SELECT name FROM accounts WHERE id = \$1")
assertEquals(DeallocateResult("byId", all = false), run("DEALLOCATE byId").single())
assertFailsWith<KestrelException> { run("EXECUTE byId(1)") }
}
@Test
fun deallocatingAnUnknownNameFails() {
assertFailsWith<KestrelException> { run("DEALLOCATE nope") }
}
@Test
fun deallocateAllRemovesEveryPreparedStatement() {
seedAccounts()
run("PREPARE p1 AS SELECT name FROM accounts WHERE id = \$1")
run("PREPARE p2 AS SELECT balance FROM accounts WHERE id = \$1")
assertEquals(DeallocateResult(null, all = true), run("DEALLOCATE ALL").single())
assertFailsWith<KestrelException> { run("EXECUTE p1(1)") }
assertFailsWith<KestrelException> { run("EXECUTE p2(1)") }
}
@Test
fun aNameCanBeReparedAfterBeingDeallocated() {
seedAccounts()
run("PREPARE byId AS SELECT name FROM accounts WHERE id = \$1")
run("DEALLOCATE byId")
run("PREPARE byId AS SELECT balance FROM accounts WHERE id = \$1")
assertEquals(listOf(listOf(Value.IntegerValue(100))), select("EXECUTE byId(1)").rows)
}
@Test
fun preparedStatementsAreNotUndoneByRollback() {
seedAccounts()
run("BEGIN")
run("PREPARE byId AS SELECT name FROM accounts WHERE id = \$1")
run("ROLLBACK")
// Session-scoped, deliberately outside the transactional catalog snapshot - see
// PrepareStatement's own doc for why.
assertEquals(listOf(listOf(Value.TextValue("ada"))), select("EXECUTE byId(1)").rows)
}
@Test
fun executeArgumentsMustBeConstantNotColumnReferences() {
seedAccounts()
run("PREPARE byId AS SELECT name FROM accounts WHERE id = \$1")
assertFailsWith<KestrelException> { run("EXECUTE byId(id)") }
}
@Test
fun aParameterInTheSelectListDoesNotNeedToAppearInGroupBy() {
seedAccounts()
run("PREPARE report AS SELECT \$1, COUNT(*) FROM accounts GROUP BY name")
val result = select("EXECUTE report('label')")
assertEquals(3, result.rows.size)
result.rows.forEach { row -> assertEquals(Value.TextValue("label"), row[0]) }
}
@Test
fun aParameterTypedDirectlyOutsidePrepareParsesButOnlyFailsAtEvaluationTime() {
seedAccounts()
// $1 is structurally legal wherever any expression is (no PREPARE context exists at parse
// time to reject it) - GROUP BY validation lets it through exactly like a literal would, and
// it only fails once evaluation actually needs a real value for it.
val exception = assertFailsWith<KestrelException> { run("SELECT \$1, COUNT(*) FROM accounts GROUP BY name") }
assertTrue(exception.message!!.contains("parameter reference"), exception.message)
}
}
+159
View File
@@ -0,0 +1,159 @@
package kestrel
import java.io.File
import java.nio.file.Files
import kestrel.cli.Repl
import kestrel.persistence.Persistence
import kestrel.store.sql.DiskCatalog
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* [Repl] wired to the real disk-backed [DiskCatalog]/[kestrel.store.sql.DiskExecutor] pair instead
* of the in-memory [kestrel.catalog.Catalog]/[kestrel.engine.Executor] - the disk engine had been
* fully built out (B+Tree/pager/WAL/2PL, DDL, DML, transactions, EXPLAIN) across many prior sessions
* but was never reachable from the actual user-facing CLI, only from tests calling it directly. This
* file is the end-to-end proof that a SQL session typed at the REPL really persists through the disk
* engine, not just that the disk engine works in isolation.
*/
class ReplDiskTest {
private fun tempFile(prefix: String): File = Files.createTempFile(prefix, ".db").toFile().also { it.deleteOnExit() }
private fun runOnDisk(dataFile: File, walFile: File, script: String): String {
val output = StringBuilder()
val catalog = DiskCatalog.open(dataFile, walFile)
Repl(output, output, diskCatalog = catalog).run(script.reader().buffered())
return output.toString()
}
@Test
fun gateScenarioRendersOrderedResultThroughTheDiskBackedExecutor() {
val output = runOnDisk(
tempFile("kestrel-repl-data"),
tempFile("kestrel-repl-wal"),
"""
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL, age INTEGER);
INSERT INTO users VALUES (1, 'ada', 36), (2, 'linus', 54), (3, 'grace', 85);
SELECT name, age FROM users WHERE age > 40 ORDER BY age DESC;
.exit
""".trimIndent()
)
assertContains(output, "3 rows inserted")
assertContains(output, "grace")
assertContains(output, "linus")
assertTrue(output.indexOf("grace") < output.indexOf("linus"), "grace should be listed before linus")
}
@Test
fun metaCommandsReflectDiskCatalogStateForEveryObjectKind() {
val output = runOnDisk(
tempFile("kestrel-repl-data"),
tempFile("kestrel-repl-wal"),
"""
CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL);
CREATE UNIQUE INDEX idx_email ON users (email);
CREATE VIEW active_users AS SELECT id FROM users;
CREATE SEQUENCE user_ids;
CREATE TRIGGER log_insert AFTER INSERT ON users FOR EACH ROW BEGIN DELETE FROM users WHERE 1 = 0; END;
.tables
.indexes
.views
.triggers
.sequences
.exit
""".trimIndent()
)
assertTrue(!output.contains("Error:"), output)
assertContains(output, "users")
assertContains(output, "idx_email")
assertContains(output, "active_users")
assertContains(output, "log_insert")
assertContains(output, "user_ids")
}
@Test
fun schemaPrintsColumnsForeignKeyCheckUniqueAndSecondaryIndexFromTheDiskCatalog() {
val output = runOnDisk(
tempFile("kestrel-repl-data"),
tempFile("kestrel-repl-wal"),
"""
CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER NOT NULL CHECK (balance >= 0));
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
account_id INTEGER,
code TEXT,
UNIQUE (code),
FOREIGN KEY (account_id) REFERENCES accounts (id)
);
CREATE INDEX idx_orders_code ON orders (code);
.schema orders
.exit
""".trimIndent()
)
assertContains(output, "CREATE TABLE orders")
assertContains(output, "id INTEGER PRIMARY KEY")
assertContains(output, "FOREIGN KEY (account_id) REFERENCES accounts (id)")
assertContains(output, "UNIQUE (code)")
assertContains(output, "CREATE INDEX idx_orders_code ON orders (code);")
}
@Test
fun dataSchemaAndIndexesSurviveClosingAndReopeningTheDiskCatalog() {
val dataFile = tempFile("kestrel-repl-data")
val walFile = tempFile("kestrel-repl-wal")
runOnDisk(
dataFile,
walFile,
"""
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE UNIQUE INDEX idx_name ON users (name);
INSERT INTO users VALUES (1, 'ada'), (2, 'grace');
.exit
""".trimIndent()
)
val reopened = runOnDisk(
dataFile,
walFile,
"SELECT name FROM users ORDER BY id ASC;\n.indexes\nINSERT INTO users VALUES (3, 'ada');\n.exit\n"
)
assertContains(reopened, "ada")
assertContains(reopened, "grace")
assertContains(reopened, "idx_name")
assertContains(
reopened,
"Error:",
message = "the UNIQUE index on name must still reject a duplicate name (new id, old name) after reopen"
)
}
@Test
fun explicitTransactionLeftOpenAtReplExitIsRolledBackNotSilentlyLost() {
val dataFile = tempFile("kestrel-repl-data")
val walFile = tempFile("kestrel-repl-wal")
runOnDisk(
dataFile,
walFile,
"""
CREATE TABLE t (id INTEGER PRIMARY KEY);
INSERT INTO t VALUES (1);
BEGIN;
INSERT INTO t VALUES (2);
.exit
""".trimIndent()
)
val reopened = runOnDisk(dataFile, walFile, "SELECT id FROM t ORDER BY id ASC;\n.exit\n")
assertContains(reopened, "1")
assertTrue(!reopened.contains("2"), "the uncommitted INSERT inside BEGIN must not survive the abandoned transaction")
}
@Test
fun replRejectsCombiningTextBasedPersistenceWithTheDiskBackedCatalog() {
val persistenceDir = Files.createTempDirectory("kestrel-repl-persist").toFile().also { it.deleteOnExit() }
val catalog = DiskCatalog.open(tempFile("kestrel-repl-data"), tempFile("kestrel-repl-wal"))
assertFailsWith<IllegalArgumentException> {
Repl(persistence = Persistence(persistenceDir), diskCatalog = catalog)
}
}
}
+196
View File
@@ -26,6 +26,22 @@ class ReplTest {
assertTrue(output.indexOf("grace") < output.indexOf("linus"), "grace should be listed before linus")
}
@Test
fun mergePrintsHowManyRowsWereInsertedUpdatedAndDeleted() {
val script = """
CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER);
CREATE TABLE staging (id INTEGER, amount INTEGER);
INSERT INTO accounts VALUES (1, 100), (2, 200);
INSERT INTO staging VALUES (1, 10), (2, -999), (3, 5);
MERGE INTO accounts USING staging ON accounts.id = staging.id
WHEN MATCHED AND accounts.balance + staging.amount < 0 THEN DELETE
WHEN MATCHED THEN UPDATE SET balance = accounts.balance + staging.amount
WHEN NOT MATCHED THEN INSERT (id, balance) VALUES (staging.id, staging.amount);
""".trimIndent()
val output = runScript(script)
assertContains(output, "1 row inserted, 1 row updated, 1 row deleted")
}
@Test
fun metaTablesListsCreatedTables() {
val output = runScript("CREATE TABLE alpha (id INTEGER);\nCREATE TABLE beta (id INTEGER);\n.tables\n")
@@ -41,6 +57,26 @@ class ReplTest {
assertContains(output, "name TEXT NOT NULL")
}
@Test
fun metaSchemaListsUniqueIndexesOnTheTable() {
val output = runScript(
"CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT NOT NULL);\n" +
"CREATE UNIQUE INDEX idx_email ON users (email);\n" +
".schema users\n"
)
assertContains(output, "CREATE UNIQUE INDEX idx_email ON users (email);")
}
@Test
fun metaSchemaListsCompositeIndexesOnTheTable() {
val output = runScript(
"CREATE TABLE orders (id INTEGER PRIMARY KEY, region TEXT, seq INTEGER);\n" +
"CREATE INDEX idx_region_seq ON orders (region, seq);\n" +
".schema orders\n"
)
assertContains(output, "CREATE INDEX idx_region_seq ON orders (region, seq);")
}
@Test
fun parseErrorIsReportedAndReplContinues() {
val output = runScript("SELCT bad;\nCREATE TABLE t (a INTEGER);\n.tables\n")
@@ -54,4 +90,164 @@ class ReplTest {
assertContains(output, "Table seen created")
assertTrue(!output.contains("unseen"), "statements after .exit must not run")
}
/**
* The REPL buffers input line by line and (before this was fixed) treated ANY line ending in
* `;` as "statement complete" - a trigger body's inner `INSERT ... ;` would have tripped that
* check and been parsed as an incomplete CREATE TRIGGER, well before the closing `END;` was
* even typed. Feeding the trigger definition across several `readLine()` calls (rather than as
* one line) is the point of this test.
*/
@Test
fun multiLineCreateTriggerIsNotExecutedPrematurelyOnAnInnerSemicolon() {
val script = """
CREATE TABLE orders (id INTEGER PRIMARY KEY, amount INTEGER);
CREATE TABLE audit (order_id INTEGER);
CREATE TRIGGER log_insert
AFTER INSERT ON orders
FOR EACH ROW
BEGIN
INSERT INTO audit VALUES (NEW.id);
END;
INSERT INTO orders VALUES (1, 100);
SELECT order_id FROM audit;
""".trimIndent()
val output = runScript(script)
assertTrue(!output.contains("Error:"), output)
assertContains(output, "Trigger log_insert created")
assertContains(output, "1 row")
}
@Test
fun metaTriggersListsCreatedTriggers() {
val output = runScript(
"CREATE TABLE t (id INTEGER);\n" +
"CREATE TRIGGER log AFTER INSERT ON t FOR EACH ROW BEGIN DELETE FROM t WHERE 1 = 0; END;\n" +
".triggers\n"
)
assertContains(output, "log")
}
@Test
fun metaSequencesListsCreatedSequencesAndReportsEmpty() {
val empty = runScript(".sequences\n")
assertContains(empty, "no sequences")
val output = runScript("CREATE SEQUENCE seq;\n.sequences\n")
assertContains(output, "Sequence seq created")
assertContains(output, "seq")
}
@Test
fun createAlterAndDropSequencePrintTheirOwnConfirmations() {
val output = runScript(
"""
CREATE SEQUENCE seq;
ALTER SEQUENCE seq RESTART WITH 5;
DROP SEQUENCE seq;
""".trimIndent()
)
assertContains(output, "Sequence seq created")
assertContains(output, "Sequence seq altered")
assertContains(output, "Sequence seq dropped")
}
@Test
fun nextvalDrivenAutoIncrementWorksEndToEndThroughTheRepl() {
val script = """
CREATE SEQUENCE order_ids;
CREATE TABLE orders (id INTEGER PRIMARY KEY DEFAULT NEXTVAL('order_ids'), item TEXT);
INSERT INTO orders (item) VALUES ('a'), ('b');
SELECT id, item FROM orders ORDER BY id;
""".trimIndent()
val output = runScript(script)
assertTrue(!output.contains("Error:"), output)
assertContains(output, "2 rows inserted")
assertContains(output, "2 rows")
}
@Test
fun insertReturningPrintsTheAffectedRowsAsATableAfterTheInsertConfirmation() {
val script = """
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
INSERT INTO users VALUES (1, 'ada') RETURNING id, name;
""".trimIndent()
val output = runScript(script)
assertContains(output, "1 row inserted")
assertTrue(
output.indexOf("1 row inserted") < output.indexOf("ada"),
"the RETURNING table should be printed after the insert confirmation"
)
assertContains(output, "ada")
}
@Test
fun updateAndDeleteReturningAlsoPrintTheirRowsAfterTheirOwnConfirmation() {
val script = """
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
INSERT INTO users VALUES (1, 'ada');
UPDATE users SET name = 'grace' WHERE id = 1 RETURNING name;
DELETE FROM users WHERE id = 1 RETURNING id;
""".trimIndent()
val output = runScript(script)
assertContains(output, "1 row updated")
assertContains(output, "grace")
assertContains(output, "1 row deleted")
}
@Test
fun mergeReturningPrintsTheAffectedRowsAsATableAfterTheMergeConfirmation() {
val script = """
CREATE TABLE accounts (id INTEGER PRIMARY KEY, balance INTEGER);
CREATE TABLE staging (id INTEGER, amount INTEGER);
INSERT INTO accounts VALUES (1, 100);
INSERT INTO staging VALUES (1, 10), (2, 5);
MERGE INTO accounts USING staging ON accounts.id = staging.id
WHEN MATCHED THEN UPDATE SET balance = accounts.balance + staging.amount
WHEN NOT MATCHED THEN INSERT (id, balance) VALUES (staging.id, staging.amount)
RETURNING id, MERGE_ACTION() AS kind;
""".trimIndent()
val output = runScript(script)
assertContains(output, "1 row inserted, 1 row updated, 0 rows deleted")
assertTrue(
output.indexOf("1 row inserted, 1 row updated, 0 rows deleted") < output.indexOf("UPDATE"),
"the RETURNING table should be printed after the merge confirmation"
)
assertContains(output, "UPDATE")
assertContains(output, "INSERT")
}
@Test
fun prepareExecuteAndDeallocatePrintTheirOwnConfirmations() {
val script = """
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
INSERT INTO users VALUES (1, 'ada');
PREPARE byId AS SELECT name FROM users WHERE id = $1;
EXECUTE byId(1);
DEALLOCATE byId;
""".trimIndent()
val output = runScript(script)
assertContains(output, "Prepared statement byId created")
assertContains(output, "ada")
assertContains(output, "Prepared statement byId deallocated")
}
@Test
fun metaPreparedListsAndThenForgetsAPreparedStatementName() {
val before = runScript(
"""
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
PREPARE byId AS SELECT name FROM users WHERE id = $1;
.prepared
""".trimIndent()
)
assertContains(before, "byId")
val after = runScript(
"""
CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT NOT NULL);
.prepared
""".trimIndent()
)
assertContains(after, "no prepared statements")
}
}
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff. Show more