diff --git a/src/main/kotlin/kestrel/cli/Repl.kt b/src/main/kotlin/kestrel/cli/Repl.kt index 8120ca6..66e5754 100644 --- a/src/main/kotlin/kestrel/cli/Repl.kt +++ b/src/main/kotlin/kestrel/cli/Repl.kt @@ -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? = null + private val savepointMarks = mutableListOf>() 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): 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 = 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 = if (isTemporaryTable(tableName)) setOf(tableName) else emptySet() + + private fun recordForPersistence(statement: Statement, temporaryTargets: Set) { 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")