feat: add table renderer and interactive REPL

This commit is contained in:
dualfroz committed 2026-08-26 23:08:41 +02:00
1 parent ca5e3a67ff
commit 1e9218cbab
2 files changed
+252

No files matched your search

+207
View File
@@ -0,0 +1,207 @@
package kestrel.cli
import java.io.BufferedReader
import kestrel.KestrelException
import kestrel.LexException
import kestrel.catalog.Catalog
import kestrel.engine.BeginResult
import kestrel.engine.CommitResult
import kestrel.engine.CreateIndexResult
import kestrel.engine.CreateTableResult
import kestrel.engine.DeleteResult
import kestrel.engine.DropIndexResult
import kestrel.engine.DropTableResult
import kestrel.engine.ExplainResult
import kestrel.engine.Executor
import kestrel.engine.InsertResult
import kestrel.engine.RollbackResult
import kestrel.engine.SelectResult
import kestrel.engine.StatementResult
import kestrel.engine.UpdateResult
import kestrel.persistence.Persistence
import kestrel.persistence.SqlWriter
import kestrel.sql.ast.BeginStatement
import kestrel.sql.ast.CommitStatement
import kestrel.sql.ast.RollbackStatement
import kestrel.sql.ast.Statement
import kestrel.sql.lexer.Lexer
import kestrel.sql.lexer.TokenType
import kestrel.sql.parser.Parser
class Repl(
private val output: Appendable = System.out,
private val errorOutput: Appendable = System.err,
private val persistence: Persistence? = null
) {
private val catalog = Catalog()
private val executor = Executor(catalog)
private val pending = StringBuilder()
private var pendingCommit: MutableList<String>? = null
private var replaying = false
init {
if (persistence != null) {
val restored = persistence.load()
if (restored.isNotBlank()) {
replaying = true
executeBuffer(restored)
replaying = false
}
}
}
fun run(input: BufferedReader) {
while (true) {
val line = input.readLine() ?: break
if (handleLine(line)) {
finish()
return
}
}
flushPending()
finish()
}
private fun finish() {
if (executor.inTransaction) executor.execute(RollbackStatement)
pendingCommit = null
persistence?.checkpoint(SqlWriter.dump(catalog))
}
private fun handleLine(line: String): Boolean {
val trimmed = line.trim()
if (pending.isBlank() && trimmed.startsWith(".")) {
return handleMetaCommand(trimmed)
}
if (pending.isBlank() && trimmed.isEmpty()) return false
pending.append(line).append('\n')
maybeExecute()
return false
}
private fun maybeExecute() {
val tokens = try {
Lexer(pending.toString()).tokenize()
} catch (exception: LexException) {
if (exception.incomplete) return
printError(exception.message)
pending.setLength(0)
return
}
val meaningful = tokens.dropLast(1)
if (meaningful.isNotEmpty() && meaningful.last().type == TokenType.Semicolon) {
executeBuffer(pending.toString())
pending.setLength(0)
}
}
private fun flushPending() {
if (pending.isNotBlank()) {
executeBuffer(pending.toString())
pending.setLength(0)
}
}
private fun executeBuffer(sql: String) {
val statements = try {
Parser(Lexer(sql).tokenize()).parseProgram()
} catch (exception: KestrelException) {
printError(exception.message)
return
}
for (statement in statements) {
try {
val result = executor.execute(statement)
printResult(result)
if (!replaying) recordForPersistence(statement)
} catch (exception: KestrelException) {
printError(exception.message)
}
}
}
private fun recordForPersistence(statement: Statement) {
when (statement) {
is BeginStatement -> pendingCommit = mutableListOf()
is CommitStatement -> {
pendingCommit?.let { persistence?.appendWal(it) }
pendingCommit = null
}
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))
}
}
}
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 CreateTableResult -> output.appendLine("Table ${result.tableName} created")
is DropTableResult -> output.appendLine("Table ${result.tableName} dropped")
is CreateIndexResult -> output.appendLine("Index ${result.indexName} created")
is DropIndexResult -> output.appendLine("Index ${result.indexName} dropped")
is ExplainResult -> result.plan.forEach { output.appendLine(it) }
BeginResult -> output.appendLine("Transaction started")
CommitResult -> output.appendLine("Transaction committed")
RollbackResult -> output.appendLine("Transaction rolled back")
}
}
private fun handleMetaCommand(command: String): Boolean {
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) }
}
".schema" -> {
if (parts.size < 2 || parts[1].isBlank()) {
printError("usage: .schema <table>")
} else {
printSchema(parts[1].trim())
}
}
else -> printError("unknown command: ${parts[0]}")
}
return false
}
private fun printSchema(tableName: String) {
val table = catalog.tableOrNull(tableName)
if (table == 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 constraints = buildString {
if (column.primaryKey) append(" PRIMARY KEY")
if (column.notNull && !column.primaryKey) append(" NOT NULL")
}
val comma = if (index < schema.columns.size - 1) "," else ""
builder.appendLine(" ${column.name} ${column.type.displayName}$constraints$comma")
}
builder.append(");")
output.appendLine(builder.toString())
}
private fun printError(message: String?) {
errorOutput.appendLine("Error: ${message ?: "unknown error"}")
}
private fun rowsWord(count: Int): String = if (count == 1) "1 row" else "$count rows"
}
@@ -0,0 +1,45 @@
package kestrel.cli
import kestrel.engine.SelectResult
import kestrel.storage.Value
object TableRenderer {
fun render(result: SelectResult): String {
val headers = result.columns
val cells = result.rows.map { row -> row.map { formatValue(it) } }
val widths = IntArray(headers.size) { headers[it].length }
for (row in cells) {
for (index in row.indices) {
widths[index] = maxOf(widths[index], row[index].length)
}
}
val separator = buildSeparator(widths)
val builder = StringBuilder()
builder.appendLine(separator)
builder.appendLine(renderRow(headers, widths))
builder.appendLine(separator)
for (row in cells) {
builder.appendLine(renderRow(row, widths))
}
builder.appendLine(separator)
builder.append(rowsWord(cells.size))
return builder.toString()
}
fun formatValue(value: Value): String = when (value) {
is Value.IntegerValue -> value.value.toString()
is Value.RealValue -> value.value.toString()
is Value.TextValue -> value.value
is Value.BooleanValue -> value.value.toString()
is Value.NullValue -> "NULL"
}
private fun renderRow(cells: List<String>, widths: IntArray): String =
cells.mapIndexed { index, cell -> cell.padEnd(widths[index]) }
.joinToString(prefix = "| ", separator = " | ", postfix = " |")
private fun buildSeparator(widths: IntArray): String =
widths.joinToString(prefix = "+", separator = "+", postfix = "+") { "-".repeat(it + 2) }
private fun rowsWord(count: Int): String = if (count == 1) "1 row" else "$count rows"
}