Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **New Trigger** offered on a materialized view.
- Structure tab refusing every save on a SQLite, libSQL or Cloudflare D1 table with a column that has no declared type.
- Structure tab refusing to save a renamed or dropped primary key column.
- Structure tab refusing to save an index or check constraint named like one deleted in the same save.
- Structure tab refusing every save on a SQLite table with two check constraints of one name.
- Primary key lost on MySQL and MariaDB when a save replaced the `PRIMARY` index row and changed a column.
- Structure tab save failing partway when index or check constraint renames swapped names or took a freed one.
- Clustered index lost on SQL Server when a duplicate of it replaced the original.
- Structure tab accepting a check constraint named like another in a different letter case.
- Compressed dump named `.GZ` rather than `.gz` reaching the parser still compressed.
- **SQL** offered as an import format on MongoDB.
- **Save** permanently dim on a Custom provider for an OpenAI-compatible server that wants no API key.
Expand Down
10 changes: 10 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLCreateTableSQL.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,16 @@ internal func mysqlModifyIndexSQL(
+ "ADD \(mysqlIndexDefinitionSQL(newIndex))"
}

/// `PRIMARY`, in any case, names the primary key and nothing else: MariaDB 13 refuses
/// `ADD INDEX primary (b)` with ERROR 1280. Refused before the save, because a save that splits a
/// replacement of the key's row drops the key first and then cannot add anything back.
internal func mysqlReservedIndexNameRefusal(for index: PluginIndexDefinition) -> String? {
guard index.name.caseInsensitiveCompare("PRIMARY") == .orderedSame else { return nil }
return String(
localized: "PRIMARY is the primary key's name, and no other index can take it. Rename the index, or change the key on the Columns tab."
)
}

/// `CONSTRAINT name` is optional in MySQL's grammar and the server invents one when it is left out,
/// so a blank name writes no clause rather than `CONSTRAINT `` `` ``, which is a syntax error.
internal func mysqlForeignKeyDefinitionSQL(_ foreignKey: PluginForeignKeyDefinition) -> String {
Expand Down
3 changes: 2 additions & 1 deletion Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1057,7 +1057,8 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
func schemaOperationRefusal(_ operation: PluginSchemaOperation) -> String? {
guard case .addIndex(let index) = operation else { return nil }
let identity = serverIdentity
return MySQLFunctionalKeyParts.refusal(for: index, banner: identity.banner, flavor: identity.flavor)
return mysqlReservedIndexNameRefusal(for: index)
?? MySQLFunctionalKeyParts.refusal(for: index, banner: identity.banner, flavor: identity.flavor)
}

func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? {
Expand Down
3 changes: 2 additions & 1 deletion TablePro/Core/SchemaTracking/SchemaOperationRefusal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ internal enum SchemaOperationRefusal {
case .addIndex(let index):
return driver.schemaOperationRefusal(.addIndex(index.toPlugin()))
case .modifyIndex(let old, let new):
return driver.schemaOperationRefusal(.modifyIndex(old: old.toPlugin(), new: new.toPlugin()))
return driver.schemaOperationRefusal(.dropIndex(old.toPlugin()))
?? driver.schemaOperationRefusal(.modifyIndex(old: old.toPlugin(), new: new.toPlugin()))
?? driver.schemaOperationRefusal(.addIndex(new.toPlugin()))
case .deleteIndex(let index):
return driver.schemaOperationRefusal(.dropIndex(index.toPlugin()))
Expand Down
121 changes: 117 additions & 4 deletions TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ struct SchemaStatementGenerator {
func generate(changes: [SchemaChange]) throws -> [SchemaStatement] {
var statements: [SchemaStatement] = []

let refusals = changes.lazy.compactMap { SchemaOperationRefusal.reason(for: $0, driver: pluginDriver) }
let refusals = Self.replacingIndexesInPlace(changes).lazy.compactMap {
SchemaOperationRefusal.reason(for: $0, driver: pluginDriver)
}
if let reason = refusals.first {
throw SchemaOperationRefusedError(reason: reason)
}
Expand All @@ -70,7 +72,7 @@ struct SchemaStatementGenerator {

// MARK: - Dependency Ordering

private func sortByDependency(_ changes: [SchemaChange]) -> [SchemaChange] {
private func sortByDependency(_ staged: [SchemaChange]) -> [SchemaChange] {
// Execution order for safety:
// 1. Drop foreign keys first (includes modify FK, which requires drop+recreate)
// 2. Drop indexes (a modified index drops here and is added at 6, unless the driver
Expand All @@ -80,6 +82,10 @@ struct SchemaStatementGenerator {
// 5. Modify primary key
// 6. Add indexes
// 7. Add foreign keys
// Every drop of an index or a check constraint runs before any add or rename of one, and a
// rename or add runs after the rename that frees its name. The structure editor counts a
// deleted or renamed row's name as free on that basis.
let changes = Self.replacingIndexesInPlace(staged)

var constraintDeletes: [SchemaChange] = []
var constraintModifies: [SchemaChange] = []
Expand Down Expand Up @@ -144,8 +150,83 @@ struct SchemaStatementGenerator {
}
}

return constraintDeletes + constraintModifies + fkDeletes + indexDeletes + columnDeletes
+ columnModifies + columnAdds + pkChanges + indexAdds + fkAdds + constraintAdds
let constraintHandoffs = Self.inNameOrder(constraintModifies)
let indexHandoffs = Self.inNameOrder(indexAdds)

return constraintDeletes + constraintHandoffs.drops + constraintHandoffs.ordered + fkDeletes
+ indexDeletes + indexHandoffs.drops + columnDeletes + columnModifies + columnAdds + pkChanges
+ indexHandoffs.ordered + fkAdds + constraintAdds
}

/// Orders changes that each give up one name and take another in a single step, a rename or a
/// one-statement index replacement, so that none takes a name before the change holding it has
/// let it go. Staging order is kept wherever nothing forces another.
///
/// Changes that hold each other's names in a cycle, such as two indexes trading names, cannot run
/// whole in any order, so each is split into a drop, returned to run with the other drops, and an
/// add that then waits for nothing. Measured on MariaDB 13.0.2: renaming `c` to `b` before `b` to
/// `a` fails with ERROR 1061 or 1826 after the drops in the same save have run, and a swap fails
/// at its first `ALTER TABLE`.
private static func inNameOrder(_ changes: [SchemaChange]) -> (drops: [SchemaChange], ordered: [SchemaChange]) {
var pending = changes
var drops: [SchemaChange] = []
while true {
let handoff = orderedByNameHandoff(pending)
let halves = handoff.cyclic.compactMap(splitIntoDropAndAdd)
guard !halves.isEmpty else { return (drops, handoff.ordered + handoff.cyclic) }
drops += halves.map(\.drop)
pending = handoff.ordered + handoff.cyclic.filter { splitIntoDropAndAdd($0) == nil } + halves.map(\.add)
}
}

private static func orderedByNameHandoff(
_ changes: [SchemaChange]
) -> (ordered: [SchemaChange], cyclic: [SchemaChange]) {
var pending = changes
var ordered: [SchemaChange] = []
while let next = pending.indices.first(where: { !takesAHeldName(at: $0, in: pending) }) {
ordered.append(pending.remove(at: next))
}
return (ordered, pending)
}

/// Names compare without regard to case, as MySQL, MariaDB and SQLite resolve index and check
/// constraint names. On PostgreSQL, which keeps `c` and `C` apart, that only adds an ordering
/// nothing needed.
private static func takesAHeldName(at position: Int, in pending: [SchemaChange]) -> Bool {
guard let taken = nameHandoff(of: pending[position]).taken?.lowercased() else { return false }
return pending.indices.contains { other in
other != position && nameHandoff(of: pending[other]).released?.lowercased() == taken
}
}

private static func nameHandoff(of change: SchemaChange) -> (released: String?, taken: String?) {
switch change {
case .modifyIndex(let old, let new):
return (old.name, new.name)
case .modifyCheckConstraint(let old, let new):
return (old.name, new.name)
case .addIndex(let index):
return (nil, index.name)
case .addCheckConstraint(let constraint):
return (nil, constraint.name)
case .addColumn, .modifyColumn, .deleteColumn, .deleteIndex, .addForeignKey, .modifyForeignKey,
.deleteForeignKey, .modifyPrimaryKey, .deleteCheckConstraint:
return (nil, nil)
}
}

private static func splitIntoDropAndAdd(_ change: SchemaChange) -> (drop: SchemaChange, add: SchemaChange)? {
switch change {
case .modifyIndex(let old, let new):
return (.deleteIndex(old), .addIndex(new))
case .modifyCheckConstraint(let old, let new):
return (.deleteCheckConstraint(old), .addCheckConstraint(new))
case .addColumn, .modifyColumn, .deleteColumn, .addIndex, .deleteIndex, .addForeignKey,
.modifyForeignKey, .deleteForeignKey, .modifyPrimaryKey, .addCheckConstraint,
.deleteCheckConstraint:
return nil
}
}

private static func changesColumns(_ change: SchemaChange) -> Bool {
Expand All @@ -158,6 +239,38 @@ struct SchemaStatementGenerator {
}
}

/// An index deleted and another added under its name in the same save replace that index, which
/// is what `.modifyIndex` describes. Each add takes the first unpaired delete of its name, so a
/// second pass finds nothing left to pair.
///
/// Paired, the save asks the driver about a replacement rather than a drop and an unrelated add,
/// and a driver that replaces an index in one statement does so: MySQL and MariaDB run
/// `DROP INDEX idx_a, ADD INDEX idx_a (…)` as one `ALTER TABLE`, and keep the old index when the
/// server refuses the new one. Everywhere else it splits back into the drop and the add.
static func replacingIndexesInPlace(_ changes: [SchemaChange]) -> [SchemaChange] {
var unpairedDrops: [EditableIndexDefinition] = changes.compactMap { change in
guard case .deleteIndex(let index) = change else { return nil }
return index
}
var replaced: [UUID: EditableIndexDefinition] = [:]
for case .addIndex(let added) in changes {
guard let position = unpairedDrops.firstIndex(where: { $0.name == added.name }) else { continue }
replaced[added.id] = unpairedDrops.remove(at: position)
}
let pairedDropIDs = Set(replaced.values.map(\.id))
return changes.compactMap { change in
switch change {
case .deleteIndex(let dropped) where pairedDropIDs.contains(dropped.id):
return nil
case .addIndex(let added):
guard let dropped = replaced[added.id] else { return change }
return .modifyIndex(old: dropped, new: added)
default:
return change
}
}
}

// MARK: - Statement Generation

private func generateStatements(for change: SchemaChange) throws -> [SchemaStatement] {
Expand Down
Loading
Loading