diff --git a/src/main/kotlin/kestrel/store/sql/DiskCatalog.kt b/src/main/kotlin/kestrel/store/sql/DiskCatalog.kt index 0626214..a75fe70 100644 --- a/src/main/kotlin/kestrel/store/sql/DiskCatalog.kt +++ b/src/main/kotlin/kestrel/store/sql/DiskCatalog.kt @@ -168,3 +168,164 @@ class DiskCatalog private constructor( engine.commit(txn) } + /** + * Re-derives a view's [QueryStatement] from its stored `CREATE VIEW ... AS ...;` text using the + * same [Lexer]/[Parser] pair the REPL and every other SQL entry point already go through - no + * bespoke AST codec, see [ViewDirectoryEntry]'s own KDoc for why. + */ + private fun parseViewQuery(entry: ViewDirectoryEntry): QueryStatement { + val statements = Parser(Lexer(entry.statementText).tokenize()).parseProgram() + val statement = statements.singleOrNull() as? CreateViewStatement + ?: throw DiskCatalogException("corrupt view directory entry for ${entry.viewName}") + return statement.query + } + + /** + * Re-derives a trigger's [TriggerDefinition] from its stored `CREATE TRIGGER ...;` text - same + * "reuse the real Lexer/Parser instead of a bespoke codec" trick [parseViewQuery] uses for a + * whole query. + */ + private fun parseTriggerDefinition(entry: TriggerDirectoryEntry): TriggerDefinition { + val statements = Parser(Lexer(entry.statementText).tokenize()).parseProgram() + val statement = statements.singleOrNull() as? CreateTriggerStatement + ?: throw DiskCatalogException("corrupt trigger directory entry for ${entry.triggerName}") + return TriggerDefinition( + statement.triggerName, statement.timing, statement.event, statement.tableName, statement.whenClause, statement.body + ) + } + + /** + * Re-derives a CHECK constraint's [Expression] from its stored text (see [CheckConstraintEntry]) + * by wrapping it back into a throwaway, never-executed `SELECT () FROM ...` and pulling the + * expression back out of the parsed projection - the same "reuse the real Lexer/Parser instead of + * a bespoke codec" trick [parseViewQuery] uses for a whole query. The FROM target is never + * resolved against this or any catalog, so its name is arbitrary. + */ + private fun parseCheckExpressionText(text: String): Expression { + val statements = Parser(Lexer("SELECT ($text) FROM check_constraint_placeholder;").tokenize()).parseProgram() + val statement = statements.singleOrNull() as? SelectStatement + ?: throw DiskCatalogException("corrupt CHECK constraint expression: $text") + val projection = statement.projection as? ExpressionProjection + ?: throw DiskCatalogException("corrupt CHECK constraint expression: $text") + return projection.items.single().expression + } + + private fun openTableFromEntry(entry: TableDirectoryEntry, keyBox: TableKeyBox): DiskTable { + val heap = engine.openHeap(entry.dataHeaderPageId) + val primaryIndex = entry.primaryKeyIndexRootPageId?.let { engine.openIndex(it) } + val schema = TableSchema( + entry.tableName, entry.columns, + foreignKeys = entry.foreignKeys, + uniqueConstraints = entry.uniqueConstraints, + compositePrimaryKey = entry.compositePrimaryKey, + checks = entry.checks.map { CheckConstraint(it.name, parseCheckExpressionText(it.expressionText)) } + ) + val table = DiskTable(schema, heap, primaryIndex, mutableListOf()) { txnId -> + persistEntry(keyBox.value, txnId) + } + for (secondary in entry.secondaryIndexes) { + val columnIndices = secondary.columnNames.map { columnName -> + schema.columnIndex(columnName) + ?: throw DiskCatalogException("directory entry for ${entry.tableName} references unknown column $columnName") + } + val index = engine.openIndex(secondary.rootPageId) + table.attachSecondaryIndex(SecondaryIndexHandle(secondary.name, secondary.columnNames, columnIndices, index, secondary.unique)) + } + return table + } + + fun beginTransaction(): Long = engine.beginTransaction() + fun commit(txnId: Long) = engine.commit(txnId) + fun abort(txnId: Long) = engine.abort(txnId) + fun savepoint(txnId: Long): Long = engine.savepoint(txnId) + fun rollbackToSavepoint(txnId: Long, savepointLsn: Long) = engine.rollbackToSavepoint(txnId, savepointLsn) + fun discardSavepoint(txnId: Long, savepointLsn: Long) = engine.discardSavepoint(txnId, savepointLsn) + + val tableNames: List get() = rowsByName.values.map { it.entry.tableName } + temporaryTables.values.map { it.schema.name } + + /** + * Total pages ever allocated in the backing data file, including any now sitting on the free + * list after a `DROP TABLE`/`DROP INDEX` (see [DiskTable.destroyStorage]/[DiskTable.dropSecondaryIndexByName]) - + * exposed so a caller (chiefly a test) can confirm a later `CREATE TABLE`/`CREATE INDEX` reused + * that reclaimed space instead of only ever growing the file, the same proof-by-counter + * [DiskTable]'s own `scanCount`/`pointLookupCount`/etc. give for index pushdown. + */ + fun allocatedPageCount(): Long = engine.allocatedPageCount() + + fun table(name: String): DiskTable { + val key = name.lowercase() + rowsByName[key]?.let { return it.table } + if (viewsByName.containsKey(key)) throw DiskCatalogException("cannot modify view: $name") + throw DiskCatalogException("no such table: $name") + } + + fun tableOrNull(name: String): DiskTable? = rowsByName[name.lowercase()]?.table + + /** + * `compositePrimaryKey` mirrors [kestrel.catalog.TableSchema.compositePrimaryKey]: a + * table-level `PRIMARY KEY (a, b, ...)`, as an alternative to an inline `col TYPE PRIMARY KEY` + * tracked by [Column.primaryKey]. Marking the listed columns NOT NULL (like the in-memory + * [kestrel.engine.Executor] does for the same clause) is the caller's responsibility - this + * catalog only records schema/storage, it does not rewrite the SQL-level column definitions. + */ + fun createTable( + name: String, + columns: List, + compositePrimaryKey: List = emptyList(), + uniqueConstraints: List = emptyList(), + checks: List = emptyList(), + foreignKeys: List = emptyList() + ): DiskTable { + val key = name.lowercase() + if (rowsByName.containsKey(key)) throw DiskCatalogException("table '$name' already exists") + if (viewsByName.containsKey(key)) throw DiskCatalogException("a view named '$name' already exists") + if (temporaryTables.containsKey(key)) throw DiskCatalogException("a temporary table named '$name' already exists") + if (columns.count { it.primaryKey } > 1 || + (compositePrimaryKey.isNotEmpty() && columns.any { it.primaryKey }) + ) { + throw DiskCatalogException("table '$name' declares more than one primary key") + } + if (compositePrimaryKey.isNotEmpty()) { + val declared = compositePrimaryKey.map { it.lowercase() }.toSet() + if (declared.size != compositePrimaryKey.size) { + throw DiskCatalogException("table '$name' declares a duplicate column in its PRIMARY KEY") + } + for (columnName in compositePrimaryKey) { + if (columns.none { it.name.equals(columnName, ignoreCase = true) }) { + throw DiskCatalogException("PRIMARY KEY refers to unknown column: $columnName") + } + } + } + for (unique in uniqueConstraints) { + for (columnName in unique.columns) { + if (columns.none { it.name.equals(columnName, ignoreCase = true) }) { + throw DiskCatalogException("UNIQUE constraint refers to unknown column: $columnName") + } + } + } + val hasPrimaryKey = columns.any { it.primaryKey } || compositePrimaryKey.isNotEmpty() + + val heap = engine.createHeap() + val primaryIndex = if (hasPrimaryKey) engine.createIndex() else null + val entry = TableDirectoryEntry( + name, columns, heap.headerPageId, primaryIndex?.rootPageId, emptyList(), + compositePrimaryKey, uniqueConstraints, checks.map { CheckConstraintEntry.from(it) }, foreignKeys + ) + + val txn = engine.beginTransaction() + val rid = directory.insert(txn, taggedTable(entry)) + engine.commit(txn) + + val schema = TableSchema( + name, columns, + foreignKeys = foreignKeys, + uniqueConstraints = uniqueConstraints, + compositePrimaryKey = compositePrimaryKey, + checks = checks + ) + val keyBox = TableKeyBox(key) + val table = DiskTable(schema, heap, primaryIndex, mutableListOf()) { txnId -> persistEntry(keyBox.value, txnId) } + rowsByName[key] = DirectoryRow(rid, entry, table, keyBox) + return table + } +