feat(sql): add enforceTemporaryPrimaryKeyUniqueness
This commit is contained in:
1 parent
775d8dcb2b
commit
ec13738fca
1 file changed
+169
@@ -1285,3 +1285,172 @@ class DiskExecutor(private val catalog: DiskCatalog) : SequenceAccess {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PRIMARY KEY uniqueness for a temp table, checked by full scan over [rows] (the whole candidate
|
||||
* set post-mutation, not just the newly touched rows - same reasoning as
|
||||
* [kestrel.engine.Executor.enforcePrimaryKeyUniqueness]) since a temp table has no B+Tree to
|
||||
* enforce it structurally the way [DiskTable.rejectDuplicatePrimaryKey] does. Ported 1:1 from
|
||||
* that same in-memory method, [DiskExecutorException] aside.
|
||||
*/
|
||||
private fun enforceTemporaryPrimaryKeyUniqueness(schema: TableSchema, rows: List<Row>) {
|
||||
val indices = schema.primaryKeyIndices
|
||||
if (indices.isEmpty()) return
|
||||
val seen = HashSet<List<Value>>()
|
||||
for (row in rows) {
|
||||
val key = collationFoldedUniquenessKey(schema, indices, indices.map { row.values[it] })
|
||||
if (!seen.add(key)) {
|
||||
val columnList = indices.joinToString(", ") { schema.columns[it].name }
|
||||
throw DiskExecutorException("UNIQUE constraint failed: ${schema.name}($columnList)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** UNIQUE-constraint counterpart of [enforceTemporaryPrimaryKeyUniqueness] - ported 1:1 from [kestrel.engine.Executor.enforceUniqueConstraints]. */
|
||||
private fun enforceTemporaryUniqueConstraints(schema: TableSchema, rows: List<Row>) {
|
||||
for (unique in schema.uniqueConstraints) {
|
||||
val columnIndices = unique.columns.map { schema.columnIndex(it)!! }
|
||||
val seen = HashSet<List<Value>>()
|
||||
for (row in rows) {
|
||||
val rawKey = columnIndices.map { row.values[it] }
|
||||
if (rawKey.any { it is Value.NullValue }) continue
|
||||
if (!seen.add(collationFoldedUniquenessKey(schema, columnIndices, rawKey))) {
|
||||
val label = unique.name?.let { " $it" } ?: ""
|
||||
val columnList = unique.columns.joinToString(", ")
|
||||
throw DiskExecutorException("UNIQUE constraint$label failed: ${schema.name}($columnList)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately duplicated from [kestrel.engine.Executor.validateUniqueDefinitions] (same
|
||||
* reasoning as [coerceForColumn]): only checks that every referenced column exists, mirroring
|
||||
* the in-memory engine exactly - it does not reject a constraint naming the same column twice
|
||||
* (`UNIQUE (a, a)`), because the in-memory engine does not either.
|
||||
*/
|
||||
private fun validateUniqueDefinitions(statement: CreateTableStatement) {
|
||||
for (unique in statement.uniqueConstraints) {
|
||||
for (columnName in unique.columns) {
|
||||
if (statement.columns.none { it.name.equals(columnName, ignoreCase = true) }) {
|
||||
throw DiskExecutorException("UNIQUE constraint refers to unknown column: $columnName")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A table-level `PRIMARY KEY (a, b, ...)` implies NOT NULL on every listed column, exactly
|
||||
* like the inline `col TYPE PRIMARY KEY` spelling already does. Deliberately duplicated from
|
||||
* [kestrel.engine.Executor.applyCompositePrimaryKey] (same reasoning as [coerceForColumn]):
|
||||
* this executor reuses the in-memory engine's AST/types but must stay free-standing so a
|
||||
* change to the in-memory engine's error handling can never silently affect the disk-backed
|
||||
* path or vice versa.
|
||||
*/
|
||||
private fun applyCompositePrimaryKey(statement: CreateTableStatement): List<Column> {
|
||||
if (statement.compositePrimaryKey.isEmpty()) return statement.columns
|
||||
val declared = statement.compositePrimaryKey.map { it.lowercase() }.toSet()
|
||||
if (declared.size != statement.compositePrimaryKey.size) {
|
||||
throw DiskExecutorException("table '${statement.tableName}' declares a duplicate column in its PRIMARY KEY")
|
||||
}
|
||||
for (name in statement.compositePrimaryKey) {
|
||||
if (statement.columns.none { it.name.equals(name, ignoreCase = true) }) {
|
||||
throw DiskExecutorException("PRIMARY KEY refers to unknown column: $name")
|
||||
}
|
||||
}
|
||||
return statement.columns.map { column ->
|
||||
if (column.name.lowercase() in declared) column.copy(notNull = true) else column
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeDropTable(statement: DropTableStatement): StatementResult {
|
||||
if (catalog.temporaryTableOrNull(statement.tableName) != null) {
|
||||
catalog.dropTemporaryTable(statement.tableName)
|
||||
} else {
|
||||
catalog.dropTable(statement.tableName, cascade = statement.mode == DropMode.Cascade)
|
||||
}
|
||||
return DropTableResult(statement.tableName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliberately NOT in [isDdl] - like DELETE, TRUNCATE's row-clearing is fully covered by the
|
||||
* storage engine's own WAL/2PL transaction, so it may run inside an explicit `BEGIN` and roll
|
||||
* back cleanly with everything else that transaction did. `RESTART IDENTITY` is the one
|
||||
* exception: [DiskCatalog.alterSequence] always opens and commits its own transaction (mirroring
|
||||
* [DiskCatalog.nextValue]'s already-non-transactional sequence position - see that method's own
|
||||
* doc), so a sequence restarted here stays restarted even if the enclosing statement transaction
|
||||
* later rolls back. This is a deliberate, bounded asymmetry with [kestrel.engine.Executor]'s
|
||||
* in-memory engine, where RESTART IDENTITY *is* undone by ROLLBACK because ALTER SEQUENCE there
|
||||
* mutates a map entry covered by the whole-catalog snapshot/restore (see
|
||||
* [kestrel.catalog.Catalog.truncateTables]).
|
||||
*/
|
||||
private fun executeTruncate(statement: TruncateTableStatement): StatementResult {
|
||||
val truncated = withStatementTransaction { txn ->
|
||||
catalog.truncateTables(txn, statement.tableNames, statement.mode == DropMode.Cascade)
|
||||
}
|
||||
if (statement.restartIdentity) {
|
||||
for (name in truncated) restartOwnedSequences(catalog.table(name).schema)
|
||||
}
|
||||
return TruncateTableResult(truncated)
|
||||
}
|
||||
|
||||
/** Ported 1:1 from [kestrel.catalog.Catalog.restartOwnedSequences] - see that method's own doc for why a NEXTVAL DEFAULT is this engine's only signal for "this column is identity-backed". */
|
||||
private fun restartOwnedSequences(schema: TableSchema) {
|
||||
for (column in schema.columns) {
|
||||
val default = column.default
|
||||
if (default is NextValueExpression) {
|
||||
val sequence = catalog.sequenceOrNull(default.sequenceName) ?: continue
|
||||
catalog.alterSequence(default.sequenceName, sequence.definition, restartAt = sequence.definition.startValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeAlterTable(statement: AlterTableStatement): StatementResult {
|
||||
when (val action = statement.action) {
|
||||
is AddColumn -> executeAddColumn(statement.tableName, action.column)
|
||||
is DropColumn -> catalog.dropColumn(statement.tableName, action.columnName, cascade = action.mode == DropMode.Cascade)
|
||||
is RenameColumn -> catalog.renameColumn(statement.tableName, action.oldName, action.newName)
|
||||
is RenameTable -> catalog.renameTable(statement.tableName, action.newName)
|
||||
is AddConstraint -> executeAddConstraint(statement.tableName, action.constraint)
|
||||
is DropConstraint -> catalog.dropConstraint(statement.tableName, action.name, cascade = action.mode == DropMode.Cascade)
|
||||
is ChangeColumnType -> executeChangeColumnType(statement.tableName, action)
|
||||
}
|
||||
val resultName = (statement.action as? RenameTable)?.newName ?: statement.tableName
|
||||
return AlterTableResult(resultName)
|
||||
}
|
||||
|
||||
/** Mirrors [kestrel.engine.Executor.executeAddConstraint] - see that method's own doc. */
|
||||
private fun executeAddConstraint(tableName: String, added: AddedConstraint) {
|
||||
when (added) {
|
||||
is AddedCheck -> executeAddCheckConstraint(tableName, added.check)
|
||||
is AddedUnique -> executeAddUniqueConstraint(tableName, added.unique)
|
||||
is AddedForeignKey -> executeAddForeignKeyConstraint(tableName, added.foreignKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* `ALTER TABLE t ADD CONSTRAINT name CHECK (...)` - mirrors
|
||||
* [kestrel.engine.Executor.executeAddCheckConstraint]: validated exactly like a CREATE TABLE-time
|
||||
* CHECK ([validateCheckDefinitions]: known columns only, no subquery), PLUS every existing row is
|
||||
* evaluated against it up front (under its own probe transaction, mirroring [executeAddColumn]'s
|
||||
* `probeTxn`) before the constraint is installed via [DiskCatalog.addCheckConstraint].
|
||||
*/
|
||||
private fun executeAddCheckConstraint(tableName: String, check: CheckConstraint) {
|
||||
val table = catalog.table(tableName)
|
||||
val schema = table.schema
|
||||
check.name?.let { requireUniqueConstraintName(schema, it) }
|
||||
checkColumns(check.expression, schema)
|
||||
requireNoSubquery(check.expression)
|
||||
val probeTxn = catalog.beginTransaction()
|
||||
runInTransaction(probeTxn) {
|
||||
val subqueries = subqueryRunnerFor(probeTxn)
|
||||
for ((_, row) in table.scan(probeTxn)) {
|
||||
val result = Evaluator(schema, row, subqueries).evaluate(check.expression)
|
||||
if (result is Value.BooleanValue && !result.value) {
|
||||
val label = check.name?.let { " $it" } ?: ""
|
||||
throw DiskExecutorException("CHECK constraint$label failed: an existing row in $tableName violates it")
|
||||
}
|
||||
}
|
||||
}
|
||||
catalog.addCheckConstraint(tableName, check)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user