feat(catalog): extend TableSchema with ForeignKeyAction

This commit is contained in:
dualfroz committed 2026-09-01 16:09:05 +02:00
1 parent 92084073c8
commit ba965cd057
3 files changed
+155 -3

No files matched your search

+125 -3
View File
@@ -1,15 +1,106 @@
package kestrel.catalog
import kestrel.sql.ast.Expression
import kestrel.storage.Collation
import kestrel.storage.ColumnType
data class Column(
val name: String,
val type: ColumnType,
val notNull: Boolean,
val primaryKey: Boolean
val primaryKey: Boolean,
val precision: Int? = null,
val scale: Int? = null,
/**
* A constant-only DEFAULT expression (validated by the executor to contain no column
* reference, subquery, or aggregate), used to fill a column omitted from an INSERT's column
* list and to backfill existing rows when the column is added later via ALTER TABLE.
*/
val default: Expression? = null,
/**
* `GENERATED ALWAYS AS (expression) STORED` - recomputed from this row's OTHER column values
* on every INSERT/UPDATE (the executor rejects an explicit value for this column outright,
* matching standard SQL). Mutually exclusive with [default] and never itself references
* another GENERATED column (validated by the executor at CREATE TABLE time, both engines) -
* see [kestrel.engine.Executor.validateGeneratedDefinitions].
*/
val generated: Expression? = null,
/**
* `col TYPE COLLATE name` - the collating sequence a bare reference to this column compares
* with when the query itself writes no explicit `COLLATE` (see [kestrel.sql.ast.CollateExpression],
* which always wins over this when present). Threaded through [kestrel.engine.ColumnResolver.declaredCollation]
* by every resolver built directly over a table's own schema (both engines' row/EXCLUDED/MERGE
* resolvers, [kestrel.engine.SelectRunner]'s per-source column lookup) so `WHERE`/`IN`/`BETWEEN`/
* a searched-`CASE` operand honor it automatically - a resolver with no such schema to consult
* (a set operation's output row, a trigger's NEW/OLD side falling through to its own schema
* notwithstanding) defaults to [Collation.Binary] via [kestrel.engine.ColumnResolver]'s own
* default method, exactly like a column never had one declared.
*/
val collation: Collation = Collation.Binary
)
class TableSchema(val name: String, val columns: List<Column>) {
enum class ForeignKeyAction { NoAction, Restrict, Cascade, SetNull }
val ForeignKeyAction.sqlKeyword: String
get() = when (this) {
ForeignKeyAction.NoAction -> "NO ACTION"
ForeignKeyAction.Restrict -> "RESTRICT"
ForeignKeyAction.Cascade -> "CASCADE"
ForeignKeyAction.SetNull -> "SET NULL"
}
/**
* `referencedColumns` is required to carry an enforced uniqueness guarantee in the referenced
* table as a group - either it is exactly the table's PRIMARY KEY (single-column or composite),
* or it is named (as an exact set) by one of that table's [UniqueConstraint]s. Those are the only
* two ways this engine guarantees a set of columns has no duplicate value combination to safely
* match a foreign key against. `columns`/`referencedColumns` always have the same size (validated
* at CREATE TABLE time) and are positionally paired: `columns[i]` corresponds to
* `referencedColumns[i]`.
*/
data class ForeignKeyConstraint(
val columns: List<String>,
val referencedTable: String,
val referencedColumns: List<String>,
val onDelete: ForeignKeyAction = ForeignKeyAction.NoAction,
val onUpdate: ForeignKeyAction = ForeignKeyAction.NoAction,
/** `CONSTRAINT name FOREIGN KEY (...)` - see [CheckConstraint.name]/[UniqueConstraint.name]'s own doc. */
val name: String? = null
)
data class CheckConstraint(val name: String?, val expression: Expression)
/**
* A UNIQUE constraint independent of the table's PRIMARY KEY. `columns` holds one entry for an
* inline `col TYPE UNIQUE` column definition, or several for a table-level `UNIQUE (a, b, ...)`.
* Per standard SQL, a row where any of these columns is NULL is exempt from the check (NULLs
* are never considered duplicates of each other).
*/
data class UniqueConstraint(val name: String?, val columns: List<String>)
class TableSchema(
val name: String,
val columns: List<Column>,
val foreignKeys: List<ForeignKeyConstraint> = emptyList(),
val checks: List<CheckConstraint> = emptyList(),
val uniqueConstraints: List<UniqueConstraint> = emptyList(),
/**
* Table-level `PRIMARY KEY (a, b, ...)`. Empty when the table instead declares its PRIMARY KEY
* inline on a single column (`col TYPE PRIMARY KEY`, tracked by [Column.primaryKey]) - see
* [primaryKeyColumns], which unifies both spellings into one canonical list.
*/
val compositePrimaryKey: List<String> = emptyList(),
/**
* `CREATE TEMP TABLE`/`CREATE TEMPORARY TABLE` - a table exactly like any other at runtime
* (same constraints, indexes, triggers all work), except [kestrel.persistence.SqlWriter] never
* writes it (or its rows, or any index/trigger defined on it) to a checkpoint or the redo log,
* so it does not survive a process restart, matching Postgres. Every place in [Catalog] that
* rebuilds a [TableSchema] from an existing one's fields (ADD/DROP/RENAME COLUMN, RENAME TABLE,
* a referencing table's FOREIGN KEY cleanup) must carry this flag forward explicitly - there is
* no `copy()` to do it automatically, since this stays a plain class, not a data class.
*/
val temporary: Boolean = false
) {
private val indexByName: Map<String, Int> =
columns.withIndex().associate { (index, column) -> column.name.lowercase() to index }
@@ -19,5 +110,36 @@ class TableSchema(val name: String, val columns: List<Column>) {
fun column(columnName: String): Column? = columnIndex(columnName)?.let { columns[it] }
val primaryKeyIndex: Int? get() = columns.indexOfFirst { it.primaryKey }.takeIf { it >= 0 }
/**
* The table's PRIMARY KEY column names in declared order, regardless of whether it came from
* an inline `col TYPE PRIMARY KEY` (exactly one [Column] with `primaryKey = true`) or a
* table-level `PRIMARY KEY (a, b, ...)` clause ([compositePrimaryKey]). Empty when the table
* has no PRIMARY KEY at all.
*/
val primaryKeyColumns: List<String> get() =
compositePrimaryKey.ifEmpty { columns.filter { it.primaryKey }.map { it.name } }
val primaryKeyIndices: List<Int> get() = primaryKeyColumns.map { columnIndex(it)!! }
/** Convenience for callers that only ever deal with single-column PRIMARY KEYs. */
val primaryKeyIndex: Int? get() = primaryKeyIndices.singleOrNull()
/**
* Every column group in this table with a guaranteed-unique combination of values: the
* PRIMARY KEY (if any, single-column or composite) plus each UNIQUE constraint. A valid
* FOREIGN KEY target must match one of these groups exactly (as a set of names).
*/
fun enforcedUniqueKeyGroups(): List<List<String>> {
val groups = mutableListOf<List<String>>()
if (primaryKeyColumns.isNotEmpty()) groups.add(primaryKeyColumns)
for (unique in uniqueConstraints) groups.add(unique.columns)
return groups
}
/** True when [columnNames], taken together as a set, exactly matches one enforced-unique key group. */
fun hasEnforcedUniqueness(columnNames: List<String>): Boolean {
val target = columnNames.map { it.lowercase() }.toSet()
if (target.size != columnNames.size) return false
return enforcedUniqueKeyGroups().any { group -> group.map { it.lowercase() }.toSet() == target }
}
}
@@ -0,0 +1,25 @@
package kestrel.catalog
import kestrel.sql.ast.Expression
import kestrel.sql.ast.Statement
import kestrel.sql.ast.TriggerEvent
import kestrel.sql.ast.TriggerTiming
/**
* `body` only ever holds INSERT/UPDATE/DELETE statements (enforced by the parser) - see
* [kestrel.sql.ast.CreateTriggerStatement] for why. `tableName` is kept in sync with
* [Catalog.renameTable] the same way [IndexDefinition.tableName] is; it is NOT rewritten when a
* column referenced by [whenClause] or [body] is renamed (unlike CHECK constraints, which use
* [CheckExpressionColumns.rename]) - a documented scope-cut, since [body] statements can each
* target a different table and no expression-rewrite utility for arbitrary nested statements
* exists yet. A rename of a column such a reference depends on simply surfaces as "no such column"
* the next time the trigger fires.
*/
data class TriggerDefinition(
val name: String,
val timing: TriggerTiming,
val event: TriggerEvent,
val tableName: String,
val whenClause: Expression?,
val body: List<Statement>
)
@@ -0,0 +1,5 @@
package kestrel.catalog
import kestrel.sql.ast.QueryStatement
data class ViewDefinition(val name: String, val query: QueryStatement)