feat(cli): extend Repl with endsInsideOpenBlock
This commit is contained in:
1 parent
b22144cdfb
commit
9387689ade
1 file changed
+188
-14
@@ -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)
|
||||
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")
|
||||
|
||||
Reference in new issue
Block a user