Compare commits
300
Commits
wave1-page
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12da64d9b0 | ||
|
|
e475c44e83 | ||
|
|
6db05fc798 | ||
|
|
37aecd85a1 | ||
|
|
84ad621ea7 | ||
|
|
dbdffe9b7b | ||
|
|
7a5b975887 | ||
|
|
02d98fbdf0 | ||
|
|
a76bab5159 | ||
|
|
45745b9dec | ||
|
|
550d2a7791 | ||
|
|
9cf076599d | ||
|
|
9071d0d3c8 | ||
|
|
acedd5f727 | ||
|
|
dc19ec6444 | ||
|
|
bacc234c9f | ||
|
|
e4195113f1 | ||
|
|
ed8fc6576d | ||
|
|
48693b00ab | ||
|
|
9e4e76b945 | ||
|
|
aaa90ad4ab | ||
|
|
decfe2c8d7 | ||
|
|
22f982501c | ||
|
|
197e8c974d | ||
|
|
085763bb52 | ||
|
|
bac0b33caf | ||
|
|
8533598961 | ||
|
|
5d49c4c96d | ||
|
|
613cb1ecef | ||
|
|
f5cf5f187c | ||
|
|
c538b9f421 | ||
|
|
c971037db6 | ||
|
|
d32d2a4a08 | ||
|
|
63cb6f5d6a | ||
|
|
2501e873a3 | ||
|
|
aece1cdc22 | ||
|
|
e5569c3dc1 | ||
|
|
743ca28b0c | ||
|
|
71ee4dfabc | ||
|
|
fa16fcb0a0 | ||
|
|
a592b5d26d | ||
|
|
c49338826f | ||
|
|
ae50dd1aa7 | ||
|
|
8858671825 | ||
|
|
b50f24109e | ||
|
|
0b14ca4caa | ||
|
|
9c2ffa9e47 | ||
|
|
5d31d6daa0 | ||
|
|
e0a11b123c | ||
|
|
97944a7894 | ||
|
|
4023f5c64d | ||
|
|
9f5f27a88d | ||
|
|
83424e2d8e | ||
|
|
f1061c5709 | ||
|
|
4c1ba85a21 | ||
|
|
972d428c84 | ||
|
|
514c8ee9bb | ||
|
|
161fc029bd | ||
|
|
108a2845bc | ||
|
|
aae2ff034b | ||
|
|
820f4fef7e | ||
|
|
b35dadfe6f | ||
|
|
7bed867433 | ||
|
|
d699a4d4f1 | ||
|
|
19e84498c6 | ||
|
|
ffb95375fe | ||
|
|
fae2af0764 | ||
|
|
7cf0ce3d90 | ||
|
|
f45390a3d2 | ||
|
|
97acea8145 | ||
|
|
65424efff3 | ||
|
|
910c0761fa | ||
|
|
17a9f92eef | ||
|
|
f92883fe98 | ||
|
|
5543856fad | ||
|
|
577fcdcc9a | ||
|
|
5570b006ab | ||
|
|
64d0aa2e0a | ||
|
|
78150274a1 | ||
|
|
4c6fb19610 | ||
|
|
65b95acb55 | ||
|
|
eeaf0b08ee | ||
|
|
b4524fcffb | ||
|
|
a46897c93d | ||
|
|
bfd93c3811 | ||
|
|
c90e6c12e1 | ||
|
|
5199293348 | ||
|
|
d7cc593892 | ||
|
|
18deaa3ca0 | ||
|
|
03072818c2 | ||
|
|
4682431a80 | ||
|
|
0f54d1fe15 | ||
|
|
e1a2106130 | ||
|
|
464777ed2c | ||
|
|
628235ed19 | ||
|
|
e4bac803db | ||
|
|
3390dd4444 | ||
|
|
7739e7114a | ||
|
|
a03794d3c9 | ||
|
|
310a2f10dc | ||
|
|
b5f8b44728 | ||
|
|
074f8aabbe | ||
|
|
d7c5c965a7 | ||
|
|
b512c2c5c0 | ||
|
|
7db8418dca | ||
|
|
4f671d1043 | ||
|
|
d08db6f834 | ||
|
|
9e44181f8f | ||
|
|
a85575538e | ||
|
|
e18d5cdc16 | ||
|
|
3adb4b1eda | ||
|
|
11a53d1ec0 | ||
|
|
061bc535e2 | ||
|
|
6c074cc16b | ||
|
|
965cc2974d | ||
|
|
118d58728f | ||
|
|
69d45e0362 | ||
|
|
9c87f22cbb | ||
|
|
d882371038 | ||
|
|
8a381f8703 | ||
|
|
33a0168223 | ||
|
|
22f6ce2ed4 | ||
|
|
327e8f2431 | ||
|
|
2377d79672 | ||
|
|
6556fa2316 | ||
|
|
40197f5dc9 | ||
|
|
742eeef126 | ||
|
|
7cbdfb2a0e | ||
|
|
b05e8b2e37 | ||
|
|
e8d6bf0db5 | ||
|
|
1e0560573b | ||
|
|
4a817d0837 | ||
|
|
e0058b8af2 | ||
|
|
1798f78918 | ||
|
|
f9ab4ff6c5 | ||
|
|
4e1d76296f | ||
|
|
98a7fe635e | ||
|
|
587771b525 | ||
|
|
641dd74a09 | ||
|
|
0cddb29e0e | ||
|
|
00f08c1bca | ||
|
|
9af294b2b7 | ||
|
|
e1b4fd3b4c | ||
|
|
f3a0059bc3 | ||
|
|
90139e824b | ||
|
|
795ba03620 | ||
|
|
5eedad6404 | ||
|
|
1614395134 | ||
|
|
e748bac1ad | ||
|
|
bdf167469e | ||
|
|
eef86e62ef | ||
|
|
eb56dc4b87 | ||
|
|
9f5328b55e | ||
|
|
f0b4061393 | ||
|
|
5ead4b7d84 | ||
|
|
b870a00b06 | ||
|
|
2a6dad7aee | ||
|
|
1024cd60b0 | ||
|
|
459530ab7c | ||
|
|
f58df29ed4 | ||
|
|
415047ac02 | ||
|
|
b832700fb4 | ||
|
|
68e2600e76 | ||
|
|
4216c1c5eb | ||
|
|
f8d57f5621 | ||
|
|
d53bea481c | ||
|
|
575cb89ab4 | ||
|
|
45ca433d69 | ||
|
|
c481849908 | ||
|
|
e178d560e2 | ||
|
|
945d7a9be0 | ||
|
|
ade22da256 | ||
|
|
9037d01c7e | ||
|
|
1f7f95e92c | ||
|
|
142f8274ed | ||
|
|
b74fda3e6a | ||
|
|
c3624d7062 | ||
|
|
66d6a4ac5d | ||
|
|
76dd40dcba | ||
|
|
5525189a92 | ||
|
|
91db4cd952 | ||
|
|
0cd7aef8fe | ||
|
|
f617dcc77e | ||
|
|
0ee81dcf82 | ||
|
|
aee0fc649f | ||
|
|
441b6073ee | ||
|
|
cb73b8d3f9 | ||
|
|
d3a1880792 | ||
|
|
de2cc8854d | ||
|
|
bb57f5ac91 | ||
|
|
f6037d6772 | ||
|
|
b2e64dc4f5 | ||
|
|
cf9a017913 | ||
|
|
b67ac3386a | ||
|
|
e012659bde | ||
|
|
28783ebb75 | ||
|
|
0eff2e8e8b | ||
|
|
ab24de18a4 | ||
|
|
d5bb145d4c | ||
|
|
faad477a32 | ||
|
|
ec13738fca | ||
|
|
775d8dcb2b | ||
|
|
f477f22e5b | ||
|
|
c51129b8b1 | ||
|
|
daf6ce803f | ||
|
|
dff226d846 | ||
|
|
73672c0184 | ||
|
|
f0dee2aee0 | ||
|
|
1334b9331c | ||
|
|
d402392182 | ||
|
|
93a2e94559 | ||
|
|
d6d8dadb3d | ||
|
|
dafa4f8940 | ||
|
|
3972e07d37 | ||
|
|
eb27ffd7dc | ||
|
|
249b3e03fd | ||
|
|
46914bb67c | ||
|
|
c1e885046f | ||
|
|
b5ae9f27f4 | ||
|
|
5240cc6dee | ||
|
|
a1ae100c72 | ||
|
|
93957baf2a | ||
|
|
7afad5575e | ||
|
|
69148dda4b | ||
|
|
1a40301a34 | ||
|
|
17660d9087 | ||
|
|
f45e7f0db3 | ||
|
|
38be0b0cf8 | ||
|
|
fccb54cd70 | ||
|
|
b54f41d3b5 | ||
|
|
3e72df475e | ||
|
|
439f1b384d | ||
|
|
6d68777852 | ||
|
|
6303c8d538 | ||
|
|
7a2c8037e2 | ||
|
|
1f216ebd5d | ||
|
|
89c9c7e6ca | ||
|
|
81653b5d5a | ||
|
|
b362403ac5 | ||
|
|
c2329a388e | ||
|
|
4e101e46eb | ||
|
|
48fa9202a4 | ||
|
|
63bc1c01ba | ||
|
|
cfabe95271 | ||
|
|
bff836d5b9 | ||
|
|
e816d4edf1 | ||
|
|
3eb5cd7fca | ||
|
|
260d649276 | ||
|
|
349b06d470 | ||
|
|
364dd2e564 | ||
|
|
6ff6e4e91e | ||
|
|
16da420d79 | ||
|
|
8c2043edf9 | ||
|
|
02741c8ab5 | ||
|
|
842d0aa223 | ||
|
|
4b3706184a | ||
|
|
98138f80e9 | ||
|
|
16f7b062e1 | ||
|
|
e3811b3be2 | ||
|
|
69d89387ef | ||
|
|
eeaada39b9 | ||
|
|
48cb9dbaba | ||
|
|
2ca0b75e83 | ||
|
|
d6b9462677 | ||
|
|
020a3c09ed | ||
|
|
9e0eb84ecc | ||
|
|
d47b122397 | ||
|
|
4653c74cf9 | ||
|
|
be312cda54 | ||
|
|
660da32304 | ||
|
|
e015633f9f | ||
|
|
68f82b7815 | ||
|
|
1542846ef3 | ||
|
|
66cc015636 | ||
|
|
2a0c532de3 | ||
|
|
469d310982 | ||
|
|
b18f7c6346 | ||
|
|
ebfc2d4d67 | ||
|
|
5508c9d1a4 | ||
|
|
5b69c50fdf | ||
|
|
5ee00ad3c2 | ||
|
|
95120b236f | ||
|
|
644c83f436 | ||
|
|
b165621a2a | ||
|
|
e42f3ed09b | ||
|
|
71d994698a | ||
|
|
aedd88ca70 | ||
|
|
9387689ade | ||
|
|
b22144cdfb | ||
|
|
ba965cd057 | ||
|
|
92084073c8 | ||
|
|
eac99ab3df | ||
|
|
2665166513 | ||
|
|
8a595f4f02 | ||
|
|
af2a4708cf | ||
|
|
db58d7127a | ||
|
|
39d9b61277 | ||
|
|
f5e28b640b | ||
|
|
66483d080e | ||
|
|
4801fc424e |
No files matched your search
@@ -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)" }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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)
|
||||
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
|
||||
}
|
||||
@@ -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 ->
|
||||
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}")
|
||||
row!!.values[index]
|
||||
return row!!.values[index]
|
||||
}
|
||||
})
|
||||
|
||||
override fun declaredCollation(expression: ColumnExpression): Collation? =
|
||||
active.column(expression.name)?.collation
|
||||
}
|
||||
},
|
||||
subqueries,
|
||||
sequences
|
||||
)
|
||||
|
||||
fun evaluate(expression: Expression): Value = when (expression) {
|
||||
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
|
||||
}
|
||||
groupRows = groupRows.sortedWith(if (statement.orderBy!!.descending) comparator.reversed() else comparator)
|
||||
}
|
||||
statement.limit?.let {
|
||||
if (it < 0) throw KestrelException("LIMIT must not be negative")
|
||||
groupRows = groupRows.take(it)
|
||||
result
|
||||
}
|
||||
logicalGroups = logicalGroups.sortedWith(comparator)
|
||||
}
|
||||
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)
|
||||
return catalog.indexesOn(tableName)
|
||||
.mapNotNull { index -> planIndex(index, byColumn) }
|
||||
.maxByOrNull { it.boundColumns }
|
||||
?.scan
|
||||
?: PrimaryAccess.FullScan
|
||||
}
|
||||
var lower: Value? = null
|
||||
|
||||
/**
|
||||
* 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)}")
|
||||
}
|
||||
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 upper: Value? = null
|
||||
var upperValue: Value? = null
|
||||
var upperInclusive = false
|
||||
val parts = mutableListOf<String>()
|
||||
val parts = descriptionPrefix.toMutableList()
|
||||
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)}") }
|
||||
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 (lower == null && upper == null) continue
|
||||
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 PrimaryAccess.IndexScan(index.definition.name, parts.joinToString(" AND "), candidates)
|
||||
}
|
||||
return PrimaryAccess.FullScan
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)}"
|
||||
/**
|
||||
* 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 columnDefinition(column: Column): String {
|
||||
val constraint = when {
|
||||
column.primaryKey -> " PRIMARY KEY"
|
||||
column.notNull -> " NOT NULL"
|
||||
else -> ""
|
||||
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)"
|
||||
}
|
||||
return "${column.name} ${column.type.displayName}$constraint"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -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.
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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))) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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") }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user