Skip to content

Commit 3cdb54c

Browse files
datlechinclaude
andcommitted
fix(datagrid): save NULL when a date or number cell is cleared
An emptied cell was staged as an empty string whatever the column type, so MySQL without strict mode stored a cleared DATETIME as the zero date. Typed text is now read for its column: a type with no empty value stages NULL, and a NOT NULL column of such a type refuses the edit. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent aa5fe81 commit 3cdb54c

20 files changed

Lines changed: 898 additions & 51 deletions

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4141
- Smaller updates: about 6 MB from any of the last five releases and 21 MB from older ones, down from 34 MB.
4242
- Several statements run together on PostgreSQL from iPhone and iPad commit one at a time, and a failure keeps the ones before it. (#3399)
4343
- MySQL dumps write TIMESTAMP values in UTC and turn off foreign key checks while they load, as mysqldump does.
44+
- Set Value > Empty and Set EMPTY offered only on columns that can hold an empty value, not on dates, numbers or booleans.
45+
- Clearing a date, number or boolean cell in a NOT NULL column keeps the stored value instead of saving an empty string.
46+
- Clearing a Redis key's TTL in the grid removes its expiry.
4447

4548
### Removed
4649

@@ -120,6 +123,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
120123

121124
- GitHub Copilot language server pinned to a reviewed version and checksum instead of the latest npm release.
122125
- One unreadable setting, or one unknown AI provider, resetting its whole settings group to defaults.
126+
- Clearing a date, number or boolean cell saving an empty string, stored as `0000-00-00 00:00:00` on MySQL, instead of NULL.
127+
- Pasting a copied NULL into a date, number or boolean cell writing the text NULL, stored as a zero date or 0 on MySQL.
128+
- Empty line in pasted cells moving the values below it up a row, or cutting off the columns after it.
123129

124130
### Security
125131

‎TablePro/Core/Services/ColumnType.swift‎

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
// ColumnType.swift
33
// TablePro
44
//
5-
// Column type metadata for type-aware formatting and display.
6-
// Driver-specific type mapping lives in each plugin; this enum is display-only.
5+
// Column type metadata for type-aware formatting, display and editing.
6+
// Driver-specific type mapping lives in each plugin.
77
//
88

99
import Foundation
@@ -163,6 +163,42 @@ enum ColumnType: Equatable, Sendable {
163163
}
164164
}
165165

166+
/// Whether `''` is a value of this type, so an emptied cell can be written as one.
167+
///
168+
/// A date, number, boolean, JSON document, geometry or array has no empty value. Sent `''`,
169+
/// MySQL without strict mode stores its zero value (`0000-00-00 00:00:00`, `0`), SQL Server
170+
/// stores `1900-01-01` or `0`, SQLite keeps the text, and a strict MySQL or PostgreSQL refuses
171+
/// the save. An empty SET is the empty set, and an enum has an empty value only when one of
172+
/// its labels is the empty string.
173+
var hasEmptyValue: Bool {
174+
switch self {
175+
case .text(let rawType):
176+
return !Self.nonCharacterTextTypes.contains(Self.baseTypeName(rawType))
177+
case .set, .blob:
178+
return true
179+
case .enumType(_, let values):
180+
return values?.contains("") ?? false
181+
case .date, .timestamp, .datetime, .integer, .decimal, .boolean, .json, .spatial, .array:
182+
return false
183+
}
184+
}
185+
186+
/// Types the classifier files under `.text`, either by name or because nothing else matched,
187+
/// that hold no empty string either.
188+
private static let nonCharacterTextTypes: Set<String> = [
189+
"YEAR", "UUID", "TIMEUUID", "UNIQUEIDENTIFIER",
190+
"INET", "CIDR", "MACADDR", "MACADDR8", "IPV4", "IPV6", "INTERVAL"
191+
]
192+
193+
/// `Nullable(UUID)`, `year(4)` and `INTERVAL DAY TO SECOND` all name their type in the first word.
194+
private static func baseTypeName(_ rawType: String?) -> String {
195+
guard var name = rawType?.uppercased() else { return "" }
196+
while let wrapper = ["NULLABLE(", "LOWCARDINALITY("].first(where: { name.hasPrefix($0) }) {
197+
name.removeFirst(wrapper.count)
198+
}
199+
return String(name.prefix { $0.isLetter || $0.isNumber || $0 == "_" })
200+
}
201+
166202
/// The element type of an array column, if this is one
167203
var arrayElement: ColumnType? {
168204
switch self {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
//
2+
// PluginCellValue+EnteredText.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import TableProPluginKit
8+
9+
internal extension PluginCellValue {
10+
/// What text entered for a column means as a value.
11+
///
12+
/// Emptying a cell whose type has no empty value asks for no value, which is NULL. Writing `''`
13+
/// there turned a cleared `deleted_at` into `0000-00-00 00:00:00` on MySQL, so the row still
14+
/// read as deleted. A text column keeps `''`, which is a real value there.
15+
static func entered(_ text: String?, columnType: ColumnType?) -> PluginCellValue {
16+
guard let text else { return .null }
17+
guard text.isEmpty, let columnType, !columnType.hasEmptyValue else { return .text(text) }
18+
return .null
19+
}
20+
}

‎TablePro/Models/Query/TableRows.swift‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,30 @@ struct TableRows: Sendable {
9494
return !ColumnDefaultLiteral.isNull(defaultValue)
9595
}
9696

97+
/// The column's type with the labels its catalog declared, which a result set alone never carries.
98+
func editingColumnType(at index: Int) -> ColumnType? {
99+
guard columnTypes.indices.contains(index) else { return nil }
100+
let columnType = columnTypes[index]
101+
guard columns.indices.contains(index),
102+
let labels = columnEnumValues[columns[index]], !labels.isEmpty else { return columnType }
103+
return columnType.withAllowedValues(labels)
104+
}
105+
106+
/// What text entered for a column stages, or nil when the column holds neither it nor NULL.
107+
///
108+
/// An emptied date in a NOT NULL column has no value to write. MySQL turns NULL there into the
109+
/// current time when `explicit_defaults_for_timestamp` is off, and `''` into the zero date, so
110+
/// the edit is refused rather than staged.
111+
func enteredValue(_ text: String?, forColumn index: Int) -> PluginCellValue? {
112+
let value = PluginCellValue.entered(text, columnType: editingColumnType(at: index))
113+
guard value.isNull, refusesNull(columnAt: index) else { return value }
114+
return nil
115+
}
116+
117+
func refusesNull(columnAt index: Int) -> Bool {
118+
columns.indices.contains(index) && columnNullable[columns[index]] == false
119+
}
120+
97121
func value(at row: Int, column: Int) -> PluginCellValue {
98122
guard row >= 0, row < rows.count else { return .null }
99123
return rows[row][column]

‎TablePro/Models/UI/MultiRowEditState.swift‎

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ struct FieldEditState: Identifiable {
4444
/// Save never writes.
4545
var isServerOwned: Bool = false
4646

47+
/// False for a NOT NULL column. Nil from the schema reads as nullable, as the grid's menus do.
48+
var isNullable = true
49+
4750
/// The value already differs from the loaded schema because the edit is recorded elsewhere.
4851
var hasCommittedEdit: Bool = false
4952

@@ -130,6 +133,7 @@ final class MultiRowEditState: ObservableObject {
130133
@Published private(set) var absentCells: [Set<Int>] = []
131134
@Published private(set) var columns: [String] = []
132135
@Published private(set) var columnTypes: [ColumnType] = []
136+
private var nonNullableColumns: Set<String> = []
133137

134138
var hasEdits: Bool {
135139
fields.contains { $0.hasEdit }
@@ -146,6 +150,7 @@ final class MultiRowEditState: ObservableObject {
146150
primaryKeyColumns: Set<String> = [],
147151
foreignKeyColumns: Set<String> = [],
148152
serverOwnedColumns: Set<String> = [],
153+
nonNullableColumns: Set<String> = [],
149154
displayFormats: [ValueDisplayFormat?] = [],
150155
binaryColumns: Set<Int> = []
151156
) {
@@ -159,6 +164,7 @@ final class MultiRowEditState: ObservableObject {
159164
self.absentCells = absentCells
160165
self.columns = columns
161166
self.columnTypes = columnTypes
167+
self.nonNullableColumns = nonNullableColumns
162168

163169
var newFields: [FieldEditState] = []
164170

@@ -228,6 +234,7 @@ final class MultiRowEditState: ObservableObject {
228234
isPrimaryKey: primaryKeyColumns.contains(columnName),
229235
isForeignKey: foreignKeyColumns.contains(columnName),
230236
isServerOwned: serverOwnedColumns.contains(columnName),
237+
isNullable: !nonNullableColumns.contains(columnName),
231238
originalValue: originalValue,
232239
hasMultipleValues: hasMultipleValues,
233240
pendingValue: pendingValue,
@@ -337,13 +344,22 @@ final class MultiRowEditState: ObservableObject {
337344
guard index < fields.count else { return }
338345
let hadPendingEdit = fields[index].hasEdit
339346
let original = fields[index].originalValue
340-
let pending = Self.resolvePendingValue(value, original: original, isJson: fields[index].isJson)
347+
var pending = Self.resolvePendingValue(value, original: original, isJson: fields[index].isJson)
348+
var staged = pending.map { PluginCellValue.entered($0, columnType: fields[index].columnTypeEnum) }
349+
/// A NOT NULL column whose type has no empty value holds nothing an emptied field could
350+
/// mean, so the field goes back to its stored value, as the grid's cell does.
351+
if staged == .null, !fields[index].isNullable {
352+
pending = nil
353+
staged = nil
354+
}
341355
fields[index].pendingValue = pending
342356
fields[index].isPendingNull = false
343357
fields[index].isPendingDefault = false
344358
fields[index].isPendingRemoval = false
345-
if pending != nil {
346-
onFieldChanged?(index, PluginCellValue.fromOptional(pending), .typing)
359+
/// The field keeps its empty text while the change it stages is NULL: swapping the editor
360+
/// for the pending NULL pill would take the field away in the middle of typing a new value.
361+
if let staged {
362+
onFieldChanged?(index, staged, .typing)
347363
} else if hadPendingEdit {
348364
/// `originalValue` is nil for three different situations, and only one of them is a
349365
/// value: a stored NULL, a selection whose rows do not agree, and a field the rows do
@@ -391,7 +407,9 @@ final class MultiRowEditState: ObservableObject {
391407
updateField(at: columnIndex, value: value)
392408
return
393409
}
394-
onDetachedFieldChanged?(columnIndex, PluginCellValue.fromOptional(value), rowIDs)
410+
let staged = PluginCellValue.entered(value, columnType: columnTypes[safe: columnIndex])
411+
if staged.isNull, columns[safe: columnIndex].map(nonNullableColumns.contains) == true { return }
412+
onDetachedFieldChanged?(columnIndex, staged, rowIDs)
395413
}
396414

397415
private static func resolvePendingValue(_ value: String?, original: String?, isJson: Bool) -> String? {
@@ -497,6 +515,7 @@ final class MultiRowEditState: ObservableObject {
497515
absentCells = []
498516
columns = []
499517
columnTypes = []
518+
nonNullableColumns = []
500519
}
501520

502521
/// Get all edited fields with their new values

‎TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarSave.swift‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ extension MainContentCoordinator {
4040
guard !editedFields.isEmpty else { return [] }
4141

4242
let tableRows = tabSessionRegistry.tableRows(for: tab.id)
43+
/// Typed text is read for its column, as the grid reads a cell, and text a NOT NULL column can
44+
/// hold neither as itself nor as NULL writes nothing. An explicit NULL stays NULL.
45+
let writes: [(field: InspectorFieldEdit, value: PluginCellValue)] = editedFields.compactMap { field in
46+
guard !field.removesField, let text = field.newValue else { return (field, .null) }
47+
return tableRows.enteredValue(text, forColumn: field.columnIndex).map { (field, $0) }
48+
}
49+
guard !writes.isEmpty else { return [] }
4350
let displayIDs = activeGridDisplayIDs
4451
let changes: [RowChange] = selectionState.indices.sorted().compactMap { rowIndex -> RowChange? in
4552
guard let resolvedRow = DisplayRowMapping.row(
@@ -51,15 +58,15 @@ extension MainContentCoordinator {
5158
return RowChange(
5259
rowID: resolvedRow.id,
5360
type: .update,
54-
cellChanges: editedFields.map { field in
61+
cellChanges: writes.map { field, newValue in
5562
let oldValue: PluginCellValue = field.columnIndex < originalRow.count
5663
? originalRow[field.columnIndex]
5764
: .null
5865
return CellChange(
5966
columnIndex: field.columnIndex,
6067
columnName: field.columnName,
6168
oldValue: oldValue,
62-
newValue: field.removesField ? .null : PluginCellValue.fromOptional(field.newValue),
69+
newValue: newValue,
6370
oldIsAbsent: resolvedRow.isAbsent(field.columnIndex),
6471
newIsAbsent: field.removesField
6572
)

‎TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -180,12 +180,7 @@ extension MainContentView {
180180
}
181181
let allRows = selectedRows.map { Array($0.values) }
182182

183-
var columnTypes = tableRows.columnTypes
184-
for (i, col) in tableRows.columns.enumerated() where i < columnTypes.count {
185-
if let values = tableRows.columnEnumValues[col], !values.isEmpty {
186-
columnTypes[i] = columnTypes[i].withAllowedValues(values)
187-
}
188-
}
183+
let columnTypes = tableRows.columnTypes.indices.compactMap { tableRows.editingColumnType(at: $0) }
189184

190185
if !changeManager.hasChanges {
191186
trailingPaneState.inspector.editState.clearEdits()
@@ -219,6 +214,7 @@ extension MainContentView {
219214
primaryKeyColumns: pkColumns,
220215
foreignKeyColumns: fkColumns,
221216
serverOwnedColumns: tableRows.generatedColumns.union(changeManager.unwritableColumns(among: tableRows.columns)),
217+
nonNullableColumns: Set(tableRows.columnNullable.compactMap { $0.value ? nil : $0.key }),
222218
displayFormats: inspectorDisplayFormats(for: tab, columns: tableRows.columns, types: tableRows.columnTypes),
223219
binaryColumns: MultiRowEditState.binaryColumns(in: allRows)
224220
)

‎TablePro/Views/Results/DataGridRowView.swift‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import AppKit
77
import Combine
8+
import TableProPluginKit
89

910
@MainActor
1011
class DataGridRowView: NSTableRowView {
@@ -690,9 +691,12 @@ class DataGridRowView: NSTableRowView {
690691
&& coordinator.isColumnWritable(tableRows.columns[dataColumnIndex])
691692
guard coordinator.isEditable, namesWritableColumn else { return }
692693

693-
let setValueItem = NSMenuItem(title: String(localized: "Set Value"), action: nil, keyEquivalent: "")
694-
setValueItem.submenu = buildSetValueMenu(dataColumnIndex: dataColumnIndex, tableRows: tableRows)
695-
menu.addItem(setValueItem)
694+
let setValueMenu = buildSetValueMenu(dataColumnIndex: dataColumnIndex, tableRows: tableRows)
695+
if !setValueMenu.items.isEmpty {
696+
let setValueItem = NSMenuItem(title: String(localized: "Set Value"), action: nil, keyEquivalent: "")
697+
setValueItem.submenu = setValueMenu
698+
menu.addItem(setValueItem)
699+
}
696700

697701
guard coordinator.supportsFieldRemoval,
698702
coordinator.displayRow(at: rowIndex)?.isAbsent(dataColumnIndex) == false else { return }
@@ -703,14 +707,18 @@ class DataGridRowView: NSTableRowView {
703707
menu.addItem(removeFieldItem)
704708
}
705709

706-
private func buildSetValueMenu(dataColumnIndex: Int, tableRows: TableRows) -> NSMenu {
710+
/// Empty is offered only where `''` is a value of the column's type, the way NULL is offered
711+
/// only where the column takes it. On a date it wrote the server's zero date.
712+
func buildSetValueMenu(dataColumnIndex: Int, tableRows: TableRows) -> NSMenu {
707713
let setValueMenu = NSMenu()
708714

709-
let emptyItem = NSMenuItem(
710-
title: String(localized: "Empty"), action: #selector(setEmptyValue(_:)), keyEquivalent: "")
711-
emptyItem.representedObject = dataColumnIndex
712-
emptyItem.target = self
713-
setValueMenu.addItem(emptyItem)
715+
if tableRows.editingColumnType(at: dataColumnIndex)?.hasEmptyValue ?? true {
716+
let emptyItem = NSMenuItem(
717+
title: String(localized: "Empty"), action: #selector(setEmptyValue(_:)), keyEquivalent: "")
718+
emptyItem.representedObject = dataColumnIndex
719+
emptyItem.target = self
720+
setValueMenu.addItem(emptyItem)
721+
}
714722

715723
let columnName = dataColumnIndex < tableRows.columns.count
716724
? tableRows.columns[dataColumnIndex]
@@ -839,9 +847,10 @@ class DataGridRowView: NSTableRowView {
839847
coordinator?.removeField(row: rowIndex, columnIndex: columnIndex)
840848
}
841849

850+
/// Written as `''` itself rather than read as typed text, which would turn it into NULL.
842851
@objc private func setEmptyValue(_ sender: NSMenuItem) {
843852
guard let columnIndex = sender.representedObject as? Int else { return }
844-
coordinator?.setCellValueAtColumn("", at: rowIndex, columnIndex: columnIndex)
853+
coordinator?.commitTypedCellEdit(row: rowIndex, columnIndex: columnIndex, newValue: .text(""))
845854
}
846855

847856
@objc private func setDefaultValue(_ sender: NSMenuItem) {

‎TablePro/Views/Results/Extensions/DataGridView+CellCommit.swift‎

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,21 @@ import TableProPluginKit
1010
private let cellCommitLogger = Logger(subsystem: "com.TablePro", category: "DataGrid")
1111

1212
extension TableViewCoordinator {
13+
/// Every caller hands over text a person entered, so it is read for the column it lands in:
14+
/// an emptied date, number or boolean is NULL, an emptied text cell is `''`. A NOT NULL column
15+
/// that can hold neither keeps its value, and the beep says the edit was refused.
1316
func commitCellEdit(row: Int, columnIndex: Int, newValue: String?) {
14-
commitTypedCellEdit(row: row, columnIndex: columnIndex, newValue: PluginCellValue.fromOptional(newValue))
17+
if !commitEnteredText(newValue, row: row, columnIndex: columnIndex) {
18+
NSSound.beep()
19+
}
20+
}
21+
22+
/// False when the column holds neither the text nor NULL in its place, and nothing was staged.
23+
@discardableResult
24+
func commitEnteredText(_ text: String?, row: Int, columnIndex: Int) -> Bool {
25+
guard let value = tableRowsProvider().enteredValue(text, forColumn: columnIndex) else { return false }
26+
commitTypedCellEdit(row: row, columnIndex: columnIndex, newValue: value)
27+
return true
1528
}
1629

1730
func commitTypedCellEdit(row: Int, columnIndex: Int, newValue typedNewValue: PluginCellValue) {
@@ -42,7 +55,11 @@ extension TableViewCoordinator {
4255
/// A detached editor outlives a sort, a value filter and a display-format change, none of which
4356
/// close it, and a display row names a different record after any of them.
4457
func commitCellEdit(rowID: RowID, columnIndex: Int, newValue: String?) {
45-
commitTypedCellEdit(rowID: rowID, columnIndex: columnIndex, newValue: PluginCellValue.fromOptional(newValue))
58+
guard let value = tableRowsProvider().enteredValue(newValue, forColumn: columnIndex) else {
59+
NSSound.beep()
60+
return
61+
}
62+
commitTypedCellEdit(rowID: rowID, columnIndex: columnIndex, newValue: value)
4663
}
4764

4865
func commitTypedCellEdit(rowID: RowID, columnIndex: Int, newValue typedNewValue: PluginCellValue) {

0 commit comments

Comments
 (0)