feat(cli): extend Repl with endsInsideOpenBlock

This commit is contained in:
dualfroz committed 2026-09-01 16:15:54 +02:00
1 parent b22144cdfb
commit 9387689ade
1 file changed
+188 -14
+188 -14
View File
@@ -4,42 +4,89 @@ import java.io.BufferedReader
import kestrel.KestrelException
import kestrel.LexException
import kestrel.catalog.Catalog
import kestrel.catalog.ForeignKeyAction
import kestrel.catalog.sqlKeyword
import kestrel.engine.AlterSequenceResult
import kestrel.engine.AlterTableResult
import kestrel.engine.BeginResult
import kestrel.engine.CommitResult
import kestrel.engine.CreateIndexResult
import kestrel.engine.CreateSequenceResult
import kestrel.engine.CreateTableResult
import kestrel.engine.CreateTriggerResult
import kestrel.engine.CreateViewResult
import kestrel.engine.DeallocateResult
import kestrel.engine.DeleteResult
import kestrel.engine.DropIndexResult
import kestrel.engine.DropSequenceResult
import kestrel.engine.DropTableResult
import kestrel.engine.DropTriggerResult
import kestrel.engine.DropViewResult
import kestrel.engine.ExplainResult
import kestrel.engine.Executor
import kestrel.engine.InsertResult
import kestrel.engine.MergeResult
import kestrel.engine.PrepareResult
import kestrel.engine.ReleaseSavepointResult
import kestrel.engine.RollbackResult
import kestrel.engine.RollbackToSavepointResult
import kestrel.engine.SavepointResult
import kestrel.engine.SelectResult
import kestrel.engine.StatementResult
import kestrel.engine.TruncateTableResult
import kestrel.engine.UpdateResult
import kestrel.persistence.Persistence
import kestrel.persistence.SqlWriter
import kestrel.sql.ast.AlterTableStatement
import kestrel.sql.ast.BeginStatement
import kestrel.sql.ast.CommitStatement
import kestrel.sql.ast.CreateIndexStatement
import kestrel.sql.ast.CreateTableStatement
import kestrel.sql.ast.DeleteStatement
import kestrel.sql.ast.DropIndexStatement
import kestrel.sql.ast.DropTableStatement
import kestrel.sql.ast.ExecuteStatement
import kestrel.sql.ast.InsertStatement
import kestrel.sql.ast.MergeStatement
import kestrel.sql.ast.ReleaseSavepointStatement
import kestrel.sql.ast.RollbackStatement
import kestrel.sql.ast.RollbackToSavepointStatement
import kestrel.sql.ast.SavepointStatement
import kestrel.sql.ast.Statement
import kestrel.sql.ast.TruncateTableStatement
import kestrel.sql.ast.UpdateStatement
import kestrel.sql.lexer.Lexer
import kestrel.storage.displayWith
import kestrel.sql.lexer.Token
import kestrel.sql.lexer.TokenType
import kestrel.sql.parser.Parser
import kestrel.store.sql.DiskCatalog
import kestrel.store.sql.DiskExecutor
/**
* `diskCatalog` swaps the storage backend for every statement from the in-memory [Catalog]/
* [Executor] to the real disk-backed [DiskCatalog]/[DiskExecutor] pair (pager/WAL/B+Tree) - the two
* engines are mutually exclusive, never blended in one session, and [persistence] (the in-memory
* engine's own text-based WAL replay) makes no sense once statements are already durable on disk.
*/
class Repl(
private val output: Appendable = System.out,
private val errorOutput: Appendable = System.err,
private val persistence: Persistence? = null
private val persistence: Persistence? = null,
private val diskCatalog: DiskCatalog? = null
) {
private val catalog = Catalog()
private val executor = Executor(catalog)
private val diskExecutor = diskCatalog?.let { DiskExecutor(it) }
private val pending = StringBuilder()
private var pendingCommit: MutableList<String>? = null
private val savepointMarks = mutableListOf<Pair<String, Int>>()
private var replaying = false
init {
require(persistence == null || diskCatalog == null) {
"Repl cannot combine text-based Persistence with a disk-backed DiskCatalog - pick one storage backend"
}
if (persistence != null) {
val restored = persistence.load()
if (restored.isNotBlank()) {
@@ -63,6 +110,11 @@ class Repl(
}
private fun finish() {
if (diskExecutor != null) {
if (diskExecutor.inTransaction) diskExecutor.execute(RollbackStatement)
diskCatalog?.close()
return
}
if (executor.inTransaction) executor.execute(RollbackStatement)
pendingCommit = null
persistence?.checkpoint(SqlWriter.dump(catalog))
@@ -89,12 +141,40 @@ class Repl(
return
}
val meaningful = tokens.dropLast(1)
if (meaningful.isNotEmpty() && meaningful.last().type == TokenType.Semicolon) {
if (meaningful.isNotEmpty() && meaningful.last().type == TokenType.Semicolon && !endsInsideOpenBlock(meaningful)) {
executeBuffer(pending.toString())
pending.setLength(0)
}
}
/**
* A trigger body's `BEGIN ... END` wraps statements that each end in their own `;`, so the
* naive "buffer ends in a semicolon" check above would fire mid-body, on the first inner
* statement, well before the trigger definition is actually complete - unlike every other
* statement this REPL buffers line by line, a trigger body is the only one with `;` nested
* inside it. Distinguishing a trigger-body `BEGIN` from a bare `BEGIN`/`BEGIN TRANSACTION`
* (which never takes a matching `END`) only needs one fact: a fresh top-level statement's
* first token is always either the very start of the buffer or right after a `;` - a
* trigger-body `BEGIN` is never in that position, since `FOR EACH ROW` (or a `WHEN` clause)
* always precedes it. `CASE ... END` nests on the same stack since it needs the exact same
* "don't fire on an inner `;`" protection for a `CASE` that spans multiple typed lines.
*/
private fun endsInsideOpenBlock(tokens: List<Token>): Boolean {
var depth = 0
for (index in tokens.indices) {
when (tokens[index].type) {
TokenType.Case -> depth++
TokenType.Begin -> {
val previous = tokens.getOrNull(index - 1)?.type
if (previous != null && previous != TokenType.Semicolon) depth++
}
TokenType.End -> if (depth > 0) depth--
else -> Unit
}
}
return depth > 0
}
private fun flushPending() {
if (pending.isNotBlank()) {
executeBuffer(pending.toString())
@@ -111,38 +191,132 @@ class Repl(
}
for (statement in statements) {
try {
val result = executor.execute(statement)
// EXECUTE is resolved to its concrete, parameter-free statement HERE, once, rather
// than inside Executor.execute's own dispatch - so recordForPersistence below sees
// (and durably logs) the same substituted INSERT/UPDATE/DELETE/SELECT that actually
// ran, not the literal `EXECUTE name(...)` text. That literal text is meaningless to
// replay anyway: a freshly reopened session has no PREPARE-d statement named `name`
// (see PrepareStatement's own doc - prepared statements are session-scoped, never
// persisted). Resolving exactly once also matters for correctness, not just tidiness:
// an EXECUTE argument could itself be a volatile expression, so resolving twice
// (once to run it, once again to decide what to persist) would evaluate it twice.
val effective = if (diskExecutor == null && statement is ExecuteStatement) {
executor.resolveExecute(statement)
} else {
statement
}
// Computed BEFORE execute() runs, not after - DROP TABLE/RENAME TABLE/DROP INDEX
// mutate (or remove) the very catalog entry this asks about, so checking afterward
// could never again tell a table that WAS temporary apart from one that never
// existed at all. CREATE TABLE is the one case checked from the statement itself
// rather than the catalog, since the table it names does not exist yet either way.
val temporaryTargets = if (diskExecutor == null) temporaryTableTargets(effective) else emptySet()
val result = if (diskExecutor != null) diskExecutor.execute(statement) else executor.execute(effective)
printResult(result)
if (!replaying) recordForPersistence(statement)
if (diskExecutor == null && !replaying) recordForPersistence(effective, temporaryTargets)
} catch (exception: KestrelException) {
printError(exception.message)
}
}
}
private fun recordForPersistence(statement: Statement) {
/**
* Every table name [statement] itself creates/drops/alters/indexes/reads-and-writes-as-its-own-
* target that is currently a TEMP table (see [kestrel.catalog.TableSchema.temporary]) -
* deliberately NOT a general "every table this statement's body could possibly touch" scan
* (an INSERT's SELECT source, a MERGE's USING source, a view/trigger's query body): those are
* read-only references that either replay fine (the source table still exists after reload) or
* fail loudly with "no such table" if it does not, which is an acceptable, honest failure mode -
* see [kestrel.persistence.SqlWriter.dump]'s own note on the same tradeoff for CREATE VIEW.
*/
private fun temporaryTableTargets(statement: Statement): Set<String> = when (statement) {
is CreateTableStatement -> if (statement.temporary) setOf(statement.tableName) else emptySet()
is DropTableStatement -> temporaryOf(statement.tableName)
is TruncateTableStatement -> statement.tableNames.filter { isTemporaryTable(it) }.toSet()
is AlterTableStatement -> temporaryOf(statement.tableName)
is CreateIndexStatement -> temporaryOf(statement.tableName)
is DropIndexStatement -> catalog.indexDefinitions()
.firstOrNull { it.name.equals(statement.indexName, ignoreCase = true) }
?.let { temporaryOf(it.tableName) } ?: emptySet()
is InsertStatement -> temporaryOf(statement.tableName)
is UpdateStatement -> temporaryOf(statement.tableName)
is DeleteStatement -> temporaryOf(statement.tableName)
is MergeStatement -> temporaryOf(statement.targetTable)
else -> emptySet()
}
private fun isTemporaryTable(tableName: String): Boolean = catalog.tableOrNull(tableName)?.schema?.temporary == true
private fun temporaryOf(tableName: String): Set<String> = if (isTemporaryTable(tableName)) setOf(tableName) else emptySet()
private fun recordForPersistence(statement: Statement, temporaryTargets: Set<String>) {
when (statement) {
is BeginStatement -> pendingCommit = mutableListOf()
is BeginStatement -> {
pendingCommit = mutableListOf()
savepointMarks.clear()
}
is CommitStatement -> {
pendingCommit?.let { persistence?.appendWal(it) }
pendingCommit = null
savepointMarks.clear()
}
is RollbackStatement -> pendingCommit = null
else -> if (SqlWriter.canPersist(statement)) {
val sql = SqlWriter.write(statement)
val buffer = pendingCommit
if (buffer != null) buffer.add(sql) else persistence?.appendWal(listOf(sql))
is RollbackStatement -> {
pendingCommit = null
savepointMarks.clear()
}
is SavepointStatement -> {
pendingCommit?.let { savepointMarks.add(statement.name to it.size) }
}
is ReleaseSavepointStatement -> {
val index = savepointMarks.indexOfLast { it.first.equals(statement.name, ignoreCase = true) }
while (savepointMarks.size > index) savepointMarks.removeAt(savepointMarks.size - 1)
}
is RollbackToSavepointStatement -> {
val index = savepointMarks.indexOfLast { it.first.equals(statement.name, ignoreCase = true) }
val mark = savepointMarks[index].second
pendingCommit?.let { buffer -> while (buffer.size > mark) buffer.removeAt(buffer.size - 1) }
while (savepointMarks.size > index + 1) savepointMarks.removeAt(savepointMarks.size - 1)
}
// A TRUNCATE naming both TEMP and regular tables in one statement is the only
// persistable statement that can target more than one table (every other case above
// fully skips or fully persists) - the regular ones still need their truncation durably
// logged even though the TEMP ones must not appear in the text at all, since after
// reload a TEMP table's own CREATE never replayed and "TRUNCATE TABLE that_name" would
// otherwise fail with "no such table" for a name that was only ever supposed to vanish
// quietly.
is TruncateTableStatement -> {
val persistedNames = statement.tableNames.filterNot { it in temporaryTargets }
if (persistedNames.isNotEmpty()) {
appendToWal(SqlWriter.write(statement.copy(tableNames = persistedNames)))
}
}
else -> if (temporaryTargets.isEmpty() && SqlWriter.canPersist(statement)) {
appendToWal(SqlWriter.write(statement))
}
}
}
private fun appendToWal(sql: String) {
val buffer = pendingCommit
if (buffer != null) buffer.add(sql) else persistence?.appendWal(listOf(sql))
}
private fun printResult(result: StatementResult) {
if (replaying) return
when (result) {
is SelectResult -> output.appendLine(TableRenderer.render(result))
is InsertResult -> output.appendLine("${rowsWord(result.count)} inserted")
is UpdateResult -> output.appendLine("${rowsWord(result.count)} updated")
is DeleteResult -> output.appendLine("${rowsWord(result.count)} deleted")
is InsertResult -> {
output.appendLine("${rowsWord(result.count)} inserted")
result.returning?.let { output.appendLine(TableRenderer.render(it)) }
}
is UpdateResult -> {
output.appendLine("${rowsWord(result.count)} updated")
result.returning?.let { output.appendLine(TableRenderer.render(it)) }
}
is DeleteResult -> {
output.appendLine("${rowsWord(result.count)} deleted")
result.returning?.let { output.appendLine(TableRenderer.render(it)) }
}
is CreateTableResult -> output.appendLine("Table ${result.tableName} created")
is DropTableResult -> output.appendLine("Table ${result.tableName} dropped")
is CreateIndexResult -> output.appendLine("Index ${result.indexName} created")