feat(sql): add SequenceDirectoryEntryCodec, encode

This commit is contained in:
dualfroz committed 2026-09-07 23:11:43 +02:00
1 parent b74fda3e6a
commit 142f8274ed
6 files changed
+172

No files matched your search

@@ -0,0 +1,38 @@
package kestrel.store.sql
import kestrel.store.codec.ByteReader
import kestrel.store.codec.ByteWriter
/**
* Encodes a [SequenceDirectoryEntry] as a self-contained byte payload, mirroring
* [DirectoryEntryCodec]/[ViewDirectoryEntryCodec]. Every numeric field uses [ByteWriter.writeZigZag]
* rather than [ByteWriter.writeFixedLong] because MINVALUE/MAXVALUE/START WITH routinely hold
* negative values (a descending sequence's default MAXVALUE is `-1`, and either bound can be
* [Long.MIN_VALUE]/[Long.MAX_VALUE]) - zigzag keeps the common case (small increments, page ids)
* compact while still round-tripping the full `Long` range.
*/
object SequenceDirectoryEntryCodec {
fun encode(entry: SequenceDirectoryEntry): ByteArray {
val writer = ByteWriter()
writer.writeLengthPrefixed(entry.name.toByteArray(Charsets.UTF_8))
writer.writeZigZag(entry.incrementBy)
writer.writeZigZag(entry.minValue)
writer.writeZigZag(entry.maxValue)
writer.writeZigZag(entry.startValue)
writer.writeByte(if (entry.cycle) 1 else 0)
writer.writeZigZag(entry.positionPageId)
return writer.toByteArray()
}
fun decode(bytes: ByteArray): SequenceDirectoryEntry {
val reader = ByteReader(bytes)
val name = String(reader.readLengthPrefixed(), Charsets.UTF_8)
val incrementBy = reader.readZigZag()
val minValue = reader.readZigZag()
val maxValue = reader.readZigZag()
val startValue = reader.readZigZag()
val cycle = reader.readByte() != 0
val positionPageId = reader.readZigZag()
return SequenceDirectoryEntry(name, incrementBy, minValue, maxValue, startValue, cycle, positionPageId)
}
}
@@ -0,0 +1,68 @@
package kestrel.store.sql
import kestrel.catalog.CheckConstraint
import kestrel.catalog.Column
import kestrel.catalog.ForeignKeyConstraint
import kestrel.catalog.UniqueConstraint
import kestrel.persistence.SqlWriter
/**
* One secondary index recorded against a table's directory entry - [columnNames] has more than
* one entry for a composite (multi-column) index. [unique] mirrors a `CREATE UNIQUE INDEX`:
* [DiskTable] rejects an insert/update that would give two rows the same non-null tuple of
* values across [columnNames], the same way it already does for the primary key.
*/
data class SecondaryIndexEntry(val name: String, val columnNames: List<String>, val rootPageId: Long, val unique: Boolean = false)
/**
* The durable form of a [CheckConstraint]: its expression rendered to SQL text by
* [SqlWriter.expressionText] rather than a bespoke expression codec - mirrors [ViewDirectoryEntry]
* storing a view's query as `CREATE VIEW ... AS ...;` text, for the same reason (a CHECK expression
* can nest arbitrarily deep, so a dedicated binary codec would have to mirror the whole [kestrel.sql.ast.Expression]
* hierarchy). [DiskCatalog] re-derives the [Expression][kestrel.sql.ast.Expression] on load by parsing
* it back through the same [kestrel.sql.lexer.Lexer]/[kestrel.sql.parser.Parser] pair every other SQL
* entry point uses.
*/
data class CheckConstraintEntry(val name: String?, val expressionText: String) {
companion object {
fun from(check: CheckConstraint): CheckConstraintEntry = CheckConstraintEntry(check.name, SqlWriter.expressionText(check.expression))
}
}
/**
* The durable, on-disk description of one table: its schema plus every page
* id needed to reopen its storage after a process restart. [DiskCatalog]
* keeps one of these per table in its directory heap, re-encoding it (via
* [DirectoryEntryCodec]) whenever a page id it references changes - most
* often [primaryKeyIndexRootPageId] or a [SecondaryIndexEntry.rootPageId]
* after a B+Tree split or merge moves the tree's root.
*/
data class TableDirectoryEntry(
val tableName: String,
val columns: List<Column>,
val dataHeaderPageId: Long,
val primaryKeyIndexRootPageId: Long?,
val secondaryIndexes: List<SecondaryIndexEntry>,
/**
* A table-level `PRIMARY KEY (a, b, ...)`, empty when the table instead declares its
* PRIMARY KEY inline on a single [Column] (tracked by [Column.primaryKey]) - mirrors
* [kestrel.catalog.TableSchema.compositePrimaryKey].
*/
val compositePrimaryKey: List<String> = emptyList(),
/**
* Table-level or inline `UNIQUE`/`UNIQUE (a, b, ...)` constraints, mirroring
* [kestrel.catalog.TableSchema.uniqueConstraints]. Unlike [secondaryIndexes] these are not
* backed by a B+Tree - [DiskTable] enforces them with a full heap scan per write, exactly like
* [kestrel.engine.Executor] does for the in-memory engine, which is what lets a single
* constraint span more than one column without a composite-key index format.
*/
val uniqueConstraints: List<UniqueConstraint> = emptyList(),
/** Table-level or column-level `CHECK (...)` constraints, mirroring [kestrel.catalog.TableSchema.checks]. */
val checks: List<CheckConstraintEntry> = emptyList(),
/**
* Column-level `REFERENCES ...` or table-level `FOREIGN KEY (...) REFERENCES ...` constraints,
* mirroring [kestrel.catalog.TableSchema.foreignKeys]. Stored as plain typed fields (unlike
* [CheckConstraintEntry]) since [ForeignKeyConstraint] holds no arbitrary expression to render.
*/
val foreignKeys: List<ForeignKeyConstraint> = emptyList()
)
@@ -0,0 +1,12 @@
package kestrel.store.sql
/**
* The durable, on-disk description of one trigger: its name plus the exact `CREATE TRIGGER ...;`
* text needed to rebuild its [kestrel.catalog.TriggerDefinition] after a process restart - mirrors
* [ViewDirectoryEntry]'s own reasoning: a trigger's WHEN clause and body can be arbitrarily deep AST
* (any INSERT/UPDATE/DELETE, each with its own WHERE/SET/VALUES), and [kestrel.persistence.SqlWriter.
* triggerText] already knows how to render any of that back to text, so re-parsing it with the normal
* [kestrel.sql.lexer.Lexer]/[kestrel.sql.parser.Parser] pair on [DiskCatalog.open] reuses
* already-tested machinery instead of a second, trigger-specific AST codec.
*/
data class TriggerDirectoryEntry(val triggerName: String, val statementText: String)
@@ -0,0 +1,21 @@
package kestrel.store.sql
import kestrel.store.codec.ByteReader
import kestrel.store.codec.ByteWriter
/** Encodes a [TriggerDirectoryEntry] as a self-contained byte payload, mirroring [ViewDirectoryEntryCodec]. */
object TriggerDirectoryEntryCodec {
fun encode(entry: TriggerDirectoryEntry): ByteArray {
val writer = ByteWriter()
writer.writeLengthPrefixed(entry.triggerName.toByteArray(Charsets.UTF_8))
writer.writeLengthPrefixed(entry.statementText.toByteArray(Charsets.UTF_8))
return writer.toByteArray()
}
fun decode(bytes: ByteArray): TriggerDirectoryEntry {
val reader = ByteReader(bytes)
val triggerName = String(reader.readLengthPrefixed(), Charsets.UTF_8)
val statementText = String(reader.readLengthPrefixed(), Charsets.UTF_8)
return TriggerDirectoryEntry(triggerName, statementText)
}
}
@@ -0,0 +1,12 @@
package kestrel.store.sql
/**
* The durable, on-disk description of one view: its name plus the exact `CREATE VIEW ... AS ...;`
* text needed to rebuild its [kestrel.sql.ast.QueryStatement] after a process restart. Unlike
* [TableDirectoryEntry] this stores rendered SQL text rather than a structured payload - a view's
* defining query can be arbitrarily deep AST (joins, subqueries, set operations, CTEs), and
* [kestrel.persistence.SqlWriter] already knows how to render any of that back to text, so
* re-parsing that text with the normal [kestrel.sql.lexer.Lexer]/[kestrel.sql.parser.Parser] pair
* on [DiskCatalog.open] reuses already-tested machinery instead of a second, view-specific AST codec.
*/
data class ViewDirectoryEntry(val viewName: String, val statementText: String)
@@ -0,0 +1,21 @@
package kestrel.store.sql
import kestrel.store.codec.ByteReader
import kestrel.store.codec.ByteWriter
/** Encodes a [ViewDirectoryEntry] as a self-contained byte payload, mirroring [DirectoryEntryCodec]. */
object ViewDirectoryEntryCodec {
fun encode(entry: ViewDirectoryEntry): ByteArray {
val writer = ByteWriter()
writer.writeLengthPrefixed(entry.viewName.toByteArray(Charsets.UTF_8))
writer.writeLengthPrefixed(entry.statementText.toByteArray(Charsets.UTF_8))
return writer.toByteArray()
}
fun decode(bytes: ByteArray): ViewDirectoryEntry {
val reader = ByteReader(bytes)
val viewName = String(reader.readLengthPrefixed(), Charsets.UTF_8)
val statementText = String(reader.readLengthPrefixed(), Charsets.UTF_8)
return ViewDirectoryEntry(viewName, statementText)
}
}