From ba965cd05730fd12dc8b515b1ecbeae2d76fc9b3 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 16:09:05 +0200 Subject: [PATCH] feat(catalog): extend TableSchema with ForeignKeyAction --- .../kotlin/kestrel/catalog/TableSchema.kt | 128 +++++++++++++++++- .../kestrel/catalog/TriggerDefinition.kt | 25 ++++ .../kotlin/kestrel/catalog/ViewDefinition.kt | 5 + 3 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/kestrel/catalog/TriggerDefinition.kt create mode 100644 src/main/kotlin/kestrel/catalog/ViewDefinition.kt diff --git a/src/main/kotlin/kestrel/catalog/TableSchema.kt b/src/main/kotlin/kestrel/catalog/TableSchema.kt index 25c6a81..de4fbc2 100644 --- a/src/main/kotlin/kestrel/catalog/TableSchema.kt +++ b/src/main/kotlin/kestrel/catalog/TableSchema.kt @@ -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) { +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, + val referencedTable: String, + val referencedColumns: List, + 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) + +class TableSchema( + val name: String, + val columns: List, + val foreignKeys: List = emptyList(), + val checks: List = emptyList(), + val uniqueConstraints: List = 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 = 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 = columns.withIndex().associate { (index, column) -> column.name.lowercase() to index } @@ -19,5 +110,36 @@ class TableSchema(val name: String, val columns: List) { 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 get() = + compositePrimaryKey.ifEmpty { columns.filter { it.primaryKey }.map { it.name } } + + val primaryKeyIndices: List 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> { + val groups = mutableListOf>() + 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): 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 } + } } diff --git a/src/main/kotlin/kestrel/catalog/TriggerDefinition.kt b/src/main/kotlin/kestrel/catalog/TriggerDefinition.kt new file mode 100644 index 0000000..089b705 --- /dev/null +++ b/src/main/kotlin/kestrel/catalog/TriggerDefinition.kt @@ -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 +) diff --git a/src/main/kotlin/kestrel/catalog/ViewDefinition.kt b/src/main/kotlin/kestrel/catalog/ViewDefinition.kt new file mode 100644 index 0000000..be7e0e5 --- /dev/null +++ b/src/main/kotlin/kestrel/catalog/ViewDefinition.kt @@ -0,0 +1,5 @@ +package kestrel.catalog + +import kestrel.sql.ast.QueryStatement + +data class ViewDefinition(val name: String, val query: QueryStatement)