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.KestrelException
|
||||||
import kestrel.LexException
|
import kestrel.LexException
|
||||||
import kestrel.catalog.Catalog
|
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.BeginResult
|
||||||
import kestrel.engine.CommitResult
|
import kestrel.engine.CommitResult
|
||||||
import kestrel.engine.CreateIndexResult
|
import kestrel.engine.CreateIndexResult
|
||||||
|
import kestrel.engine.CreateSequenceResult
|
||||||
import kestrel.engine.CreateTableResult
|
import kestrel.engine.CreateTableResult
|
||||||
|
import kestrel.engine.CreateTriggerResult
|
||||||
|
import kestrel.engine.CreateViewResult
|
||||||
|
import kestrel.engine.DeallocateResult
|
||||||
import kestrel.engine.DeleteResult
|
import kestrel.engine.DeleteResult
|
||||||
import kestrel.engine.DropIndexResult
|
import kestrel.engine.DropIndexResult
|
||||||
|
import kestrel.engine.DropSequenceResult
|
||||||
import kestrel.engine.DropTableResult
|
import kestrel.engine.DropTableResult
|
||||||
|
import kestrel.engine.DropTriggerResult
|
||||||
|
import kestrel.engine.DropViewResult
|
||||||
import kestrel.engine.ExplainResult
|
import kestrel.engine.ExplainResult
|
||||||
import kestrel.engine.Executor
|
import kestrel.engine.Executor
|
||||||
import kestrel.engine.InsertResult
|
import kestrel.engine.InsertResult
|
||||||
|
import kestrel.engine.MergeResult
|
||||||
|
import kestrel.engine.PrepareResult
|
||||||
|
import kestrel.engine.ReleaseSavepointResult
|
||||||
import kestrel.engine.RollbackResult
|
import kestrel.engine.RollbackResult
|
||||||
|
import kestrel.engine.RollbackToSavepointResult
|
||||||
|
import kestrel.engine.SavepointResult
|
||||||
import kestrel.engine.SelectResult
|
import kestrel.engine.SelectResult
|
||||||
import kestrel.engine.StatementResult
|
import kestrel.engine.StatementResult
|
||||||
|
import kestrel.engine.TruncateTableResult
|
||||||
import kestrel.engine.UpdateResult
|
import kestrel.engine.UpdateResult
|
||||||
import kestrel.persistence.Persistence
|
import kestrel.persistence.Persistence
|
||||||
import kestrel.persistence.SqlWriter
|
import kestrel.persistence.SqlWriter
|
||||||
|
import kestrel.sql.ast.AlterTableStatement
|
||||||
import kestrel.sql.ast.BeginStatement
|
import kestrel.sql.ast.BeginStatement
|
||||||
import kestrel.sql.ast.CommitStatement
|
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.RollbackStatement
|
||||||
|
import kestrel.sql.ast.RollbackToSavepointStatement
|
||||||
|
import kestrel.sql.ast.SavepointStatement
|
||||||
import kestrel.sql.ast.Statement
|
import kestrel.sql.ast.Statement
|
||||||
|
import kestrel.sql.ast.TruncateTableStatement
|
||||||
|
import kestrel.sql.ast.UpdateStatement
|
||||||
import kestrel.sql.lexer.Lexer
|
import kestrel.sql.lexer.Lexer
|
||||||
|
import kestrel.storage.displayWith
|
||||||
|
import kestrel.sql.lexer.Token
|
||||||
import kestrel.sql.lexer.TokenType
|
import kestrel.sql.lexer.TokenType
|
||||||
import kestrel.sql.parser.Parser
|
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(
|
class Repl(
|
||||||
private val output: Appendable = System.out,
|
private val output: Appendable = System.out,
|
||||||
private val errorOutput: Appendable = System.err,
|
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 catalog = Catalog()
|
||||||
private val executor = Executor(catalog)
|
private val executor = Executor(catalog)
|
||||||
|
private val diskExecutor = diskCatalog?.let { DiskExecutor(it) }
|
||||||
private val pending = StringBuilder()
|
private val pending = StringBuilder()
|
||||||
private var pendingCommit: MutableList<String>? = null
|
private var pendingCommit: MutableList<String>? = null
|
||||||
|
private val savepointMarks = mutableListOf<Pair<String, Int>>()
|
||||||
private var replaying = false
|
private var replaying = false
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
require(persistence == null || diskCatalog == null) {
|
||||||
|
"Repl cannot combine text-based Persistence with a disk-backed DiskCatalog - pick one storage backend"
|
||||||
|
}
|
||||||
if (persistence != null) {
|
if (persistence != null) {
|
||||||
val restored = persistence.load()
|
val restored = persistence.load()
|
||||||
if (restored.isNotBlank()) {
|
if (restored.isNotBlank()) {
|
||||||
@@ -63,6 +110,11 @@ class Repl(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun finish() {
|
private fun finish() {
|
||||||
|
if (diskExecutor != null) {
|
||||||
|
if (diskExecutor.inTransaction) diskExecutor.execute(RollbackStatement)
|
||||||
|
diskCatalog?.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
if (executor.inTransaction) executor.execute(RollbackStatement)
|
if (executor.inTransaction) executor.execute(RollbackStatement)
|
||||||
pendingCommit = null
|
pendingCommit = null
|
||||||
persistence?.checkpoint(SqlWriter.dump(catalog))
|
persistence?.checkpoint(SqlWriter.dump(catalog))
|
||||||
@@ -89,12 +141,40 @@ class Repl(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
val meaningful = tokens.dropLast(1)
|
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())
|
executeBuffer(pending.toString())
|
||||||
pending.setLength(0)
|
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() {
|
private fun flushPending() {
|
||||||
if (pending.isNotBlank()) {
|
if (pending.isNotBlank()) {
|
||||||
executeBuffer(pending.toString())
|
executeBuffer(pending.toString())
|
||||||
@@ -111,38 +191,132 @@ class Repl(
|
|||||||
}
|
}
|
||||||
for (statement in statements) {
|
for (statement in statements) {
|
||||||
try {
|
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)
|
printResult(result)
|
||||||
if (!replaying) recordForPersistence(statement)
|
if (diskExecutor == null && !replaying) recordForPersistence(effective, temporaryTargets)
|
||||||
} catch (exception: KestrelException) {
|
} catch (exception: KestrelException) {
|
||||||
printError(exception.message)
|
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) {
|
when (statement) {
|
||||||
is BeginStatement -> pendingCommit = mutableListOf()
|
is BeginStatement -> {
|
||||||
|
pendingCommit = mutableListOf()
|
||||||
|
savepointMarks.clear()
|
||||||
|
}
|
||||||
is CommitStatement -> {
|
is CommitStatement -> {
|
||||||
pendingCommit?.let { persistence?.appendWal(it) }
|
pendingCommit?.let { persistence?.appendWal(it) }
|
||||||
pendingCommit = null
|
pendingCommit = null
|
||||||
|
savepointMarks.clear()
|
||||||
}
|
}
|
||||||
is RollbackStatement -> pendingCommit = null
|
is RollbackStatement -> {
|
||||||
else -> if (SqlWriter.canPersist(statement)) {
|
pendingCommit = null
|
||||||
val sql = SqlWriter.write(statement)
|
savepointMarks.clear()
|
||||||
val buffer = pendingCommit
|
}
|
||||||
if (buffer != null) buffer.add(sql) else persistence?.appendWal(listOf(sql))
|
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) {
|
private fun printResult(result: StatementResult) {
|
||||||
if (replaying) return
|
if (replaying) return
|
||||||
when (result) {
|
when (result) {
|
||||||
is SelectResult -> output.appendLine(TableRenderer.render(result))
|
is SelectResult -> output.appendLine(TableRenderer.render(result))
|
||||||
is InsertResult -> output.appendLine("${rowsWord(result.count)} inserted")
|
is InsertResult -> {
|
||||||
is UpdateResult -> output.appendLine("${rowsWord(result.count)} updated")
|
output.appendLine("${rowsWord(result.count)} inserted")
|
||||||
is DeleteResult -> output.appendLine("${rowsWord(result.count)} deleted")
|
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 CreateTableResult -> output.appendLine("Table ${result.tableName} created")
|
||||||
is DropTableResult -> output.appendLine("Table ${result.tableName} dropped")
|
is DropTableResult -> output.appendLine("Table ${result.tableName} dropped")
|
||||||
is CreateIndexResult -> output.appendLine("Index ${result.indexName} created")
|
is CreateIndexResult -> output.appendLine("Index ${result.indexName} created")
|
||||||
|
|||||||
Reference in new issue
Block a user