Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Structure grid and inspector taking edits the object or engine refuses, such as a materialized view's Type.
- **Delete** and **Duplicate** in a structure row's menu doing nothing on an object that refuses them.
- **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.
- 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
72 changes: 44 additions & 28 deletions TablePro/Core/SchemaTracking/StructureChangeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -401,30 +401,9 @@ final class StructureChangeManager: ObservableObject, ChangeManaging {
private func validate() {
validationErrors.removeAll()

for column in workingColumns {
if !column.isValid {
validationErrors[.column(column.id)] = String(localized: "Column must have a name and a data type")
} else if isStaged(.column(column.id)), introducesNullDefaultOnNotNull(column) {
validationErrors[.column(column.id)] = String(
format: String(localized: "%@ does not allow NULL, so its default cannot be NULL"), column.name
)
}
}

let columnNames = workingColumns.filter { column in
column.isValid && !isColumnPendingDeletion(column.id)
}.map { $0.name }
let duplicateColumns = Dictionary(grouping: columnNames, by: { $0 })
.filter { $0.value.count > 1 }
.map { $0.key }

for duplicate in duplicateColumns {
for column in workingColumns.filter({ $0.name == duplicate && !isColumnPendingDeletion($0.id) }) {
validationErrors[.column(column.id)] = String(
format: String(localized: "Duplicate column name: %@"), duplicate
)
}
}
let keptColumns = columnsAfterSave
validateColumns(keptColumns)
let columnNames = keptColumns.map(\.name)

for index in workingIndexes where isStaged(.index(index.id)) && !index.isValid {
validationErrors[.index(index.id)] = String(localized: "Index must have a name and at least one column")
Expand Down Expand Up @@ -502,10 +481,47 @@ final class StructureChangeManager: ObservableObject, ChangeManaging {
}
}

for columnName in workingPrimaryKey {
if !columnNames.contains(columnName) {
validationErrors[.primaryKey] = String(
format: String(localized: "Primary key references a column that does not exist: %@"), columnName
/// Checked only when this save changes the key, as the index and foreign key rows are. A
/// rename leaves the loaded key naming the old spelling, and every engine's `RENAME COLUMN`
/// carries the key over itself; dropping a key column is the database's to allow or refuse.
for columnName in workingPrimaryKey where isStaged(.primaryKey) && !namesAColumn(columnName, in: columnNames) {
validationErrors[.primaryKey] = String(
format: String(localized: "Primary key references a column that does not exist: %@"), columnName
)
}
}

/// Every column the table keeps after this save, whatever state its name and type are in.
private var columnsAfterSave: [EditableColumnDefinition] {
workingColumns.filter { !isColumnPendingDeletion($0.id) }
}

/// Only a column this save adds or changes is held to being complete.
///
/// An untouched column is the database's own, and a typeless SQLite column or an empty MongoDB
/// field name is no reason to refuse an edit made somewhere else. A duplicate name blocks only
/// when the save put one of its columns there; an untouched pair stays the database's to judge.
/// Every name the table will hold is compared, a blank one it was read with included, while a
/// blank row still to be named is incomplete rather than a duplicate.
private func validateColumns(_ keptColumns: [EditableColumnDefinition]) {
let loadedColumns = Dictionary(currentColumns.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
for column in keptColumns where isStaged(.column(column.id)) {
if column.isIncomplete(over: loadedColumns[column.id]) {
validationErrors[.column(column.id)] = String(localized: "Column must have a name and a data type")
} else if introducesNullDefaultOnNotNull(column) {
validationErrors[.column(column.id)] = String(
format: String(localized: "%@ does not allow NULL, so its default cannot be NULL"), column.name
)
}
}

let savablyNamed = keptColumns.filter { $0.hasSavableName(over: loadedColumns[$0.id]) }
let sameNamed = Dictionary(grouping: savablyNamed, by: \.name)
for (name, columns) in sameNamed where columns.count > 1 {
guard columns.contains(where: { isStaged(.column($0.id)) }) else { continue }
for column in columns {
validationErrors[.column(column.id)] = String(
format: String(localized: "Duplicate column name: %@"), name
)
}
}
Expand Down
30 changes: 27 additions & 3 deletions TablePro/Models/Schema/ColumnDefinition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -198,10 +198,34 @@ struct EditableColumnDefinition: Hashable, Codable, Identifiable {
defaultValue = nil
}

var hasName: Bool { !name.trimmingCharacters(in: .whitespaces).isEmpty }

var hasDataType: Bool { !dataType.trimmingCharacters(in: .whitespaces).isEmpty }

/// Check if this definition is valid (not a placeholder)
var isValid: Bool {
!name.trimmingCharacters(in: .whitespaces).isEmpty &&
!dataType.trimmingCharacters(in: .whitespaces).isEmpty
var isValid: Bool { hasName && hasDataType }

/// Whether this column, as edited, lacks something a save has to write.
///
/// A column the save adds is written from nothing, so it needs a name and a type. A loaded one
/// only has to keep what it had. SQLite reports a column declared without a type as type `''`,
/// and SQLite and MongoDB both hold a column or field whose name is empty, so an edit is never
/// asked for a name or a type the column did not have when it was read.
func isIncomplete(over loaded: EditableColumnDefinition?) -> Bool {
!hasSavableName(over: loaded) || !hasSavableDataType(over: loaded)
}

/// Whether the name this column is saved under is one the table holds.
///
/// A blank name is a real name when the column was read with one: SQLite keeps `""` and `" "`
/// as two columns, and renaming one onto the other fails with "duplicate column name". A blank
/// name on a column that is new, or that had a name when it was read, is still to be filled in.
func hasSavableName(over loaded: EditableColumnDefinition?) -> Bool {
hasName || loaded?.hasName == false
}

func hasSavableDataType(over loaded: EditableColumnDefinition?) -> Bool {
hasDataType || loaded?.hasDataType == false
}

/// Create from existing ColumnInfo
Expand Down
Loading
Loading