Skip to content

Commit e275073

Browse files
authored
fix(datagrid): carry identity columns through so a new row pre-fills DEFAULT (#2589)
* fix(datagrid): carry identity columns through so a new row pre-fills DEFAULT * fix(datagrid): keep the non-writable column set across tab switches and cached reruns
1 parent 38802cb commit e275073

38 files changed

Lines changed: 569 additions & 222 deletions

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4747
- Crash loop on every launch after resizing a column on a database with a long file path, with iCloud sync on. (#2575)
4848
- SSH Agent auth prompting for a private key passphrase instead of reporting that the agent was never reached. (#2583)
4949
- "SSH password rejected" on an SSH connection that has no password, when the server offers no keyboard-interactive.
50+
- NULL pre-filled into an identity column on Add Row, so the insert failed on PostgreSQL. (#2588)
51+
- No "Default" in a cell's Set Value menu for an identity column. (#2588)
52+
- Duplicate Row copying an identity column that is not the primary key. (#2588)
53+
- Editing a `GENERATED ALWAYS AS IDENTITY` cell, which the server rejects on save. (#2588)
54+
- Generated and identity columns editable again after a tab switch or a refresh that reused cached metadata.
55+
- A new row of nothing but server-assigned columns silently dropped from the save.
56+
- `OVERRIDING SYSTEM VALUE` and `setval` in a SQL export of a SQL Server database.
57+
- PGlite treated as a generic SQL dialect, so `$$` bodies split at their inner semicolons.
5058

5159
## [0.69.0] - 2026-08-27
5260

Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ extension MSSQLPluginDriver {
109109
isPrimaryKey: isPk,
110110
defaultValue: defaultValue,
111111
extra: isIdentity ? "IDENTITY" : nil,
112+
identityKind: isIdentity ? .always : nil,
112113
isGenerated: isComputed
113114
)
114115
}
@@ -312,6 +313,7 @@ extension MSSQLPluginDriver {
312313
isPrimaryKey: isPk,
313314
defaultValue: defaultValue,
314315
extra: isIdentity ? "IDENTITY" : nil,
316+
identityKind: isIdentity ? .always : nil,
315317
isGenerated: isComputed
316318
)
317319
columnsByTable[tableName, default: []].append(col)

Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ internal func mysqlColumnIsGenerated(extra: String?) -> Bool {
2828
return trimmed == "VIRTUAL" || trimmed == "PERSISTENT"
2929
}
3030

31+
/// AUTO_INCREMENT, from the same `Extra` value, because MySQL leaves `COLUMN_DEFAULT` null for such
32+
/// a column exactly as PostgreSQL does for an identity column.
33+
///
34+
/// It is `byDefault` rather than `always`: MySQL accepts an explicit value and only allocates the
35+
/// next one when the column is omitted or given NULL.
36+
internal func mysqlIdentityKind(extra: String?) -> IdentityKind? {
37+
guard let extra, extra.uppercased().contains("AUTO_INCREMENT") else { return nil }
38+
return .byDefault
39+
}
40+
3141
/// The kind, from the same `Extra` value. MariaDB 10.1 and older spell stored as "PERSISTENT".
3242
internal func mysqlGenerationKind(extra: String?) -> GenerationKind? {
3343
guard let extra, mysqlColumnIsGenerated(extra: extra) else { return nil }

Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
273273
charset: charset,
274274
collation: collation == "NULL" ? nil : collation,
275275
comment: comment?.isEmpty == false ? comment : nil,
276+
identityKind: mysqlIdentityKind(extra: extra),
276277
isGenerated: mysqlColumnIsGenerated(extra: extra),
277278
allowedValues: allowedValues,
278279
generationExpression: generationExpressions[name],
@@ -387,6 +388,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
387388
charset: charset,
388389
collation: collation == "NULL" ? nil : collation,
389390
comment: comment?.isEmpty == false ? comment : nil,
391+
identityKind: mysqlIdentityKind(extra: extra),
390392
isGenerated: mysqlColumnIsGenerated(extra: extra),
391393
allowedValues: allowedValues
392394
)

Plugins/SQLExportPlugin/SQLExportPlugin.swift

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -371,13 +371,18 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send
371371
}
372372
}
373373

374-
for table in sortedTables where optionValue(table, at: 2) && table.tableType != "view" {
375-
let columns = columnsByTable[node(for: table).identifier] ?? []
376-
for column in columns where column.isIdentity {
377-
let setval = renderIdentitySetval(
378-
table: table, columnName: column.name, dataSource: dataSource)
379-
try fileHandle.write(contentsOf: "\(setval)\n".toUTF8Data())
380-
emittedAnything = true
374+
/// `setval` and `pg_get_serial_sequence` are PostgreSQL's own, so the sequence is only
375+
/// rewound on PostgreSQL. Every other engine reports its identity columns the same way and
376+
/// would take the statement as a syntax error.
377+
if SqlDialect.from(databaseTypeId: dataSource.databaseTypeId) == .postgres {
378+
for table in sortedTables where optionValue(table, at: 2) && table.tableType != "view" {
379+
let columns = columnsByTable[node(for: table).identifier] ?? []
380+
for column in columns where column.isIdentity {
381+
let setval = renderIdentitySetval(
382+
table: table, columnName: column.name, dataSource: dataSource)
383+
try fileHandle.write(contentsOf: "\(setval)\n".toUTF8Data())
384+
emittedAnything = true
385+
}
381386
}
382387
}
383388

@@ -479,7 +484,8 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send
479484
var rowBatch: [[PluginCellValue]] = []
480485

481486
let generatedColumnNames = Set(columnInfo.filter { $0.isGenerated }.map { $0.name })
482-
let usesOverridingSystemValue = columnInfo.contains { $0.identityKind == .always }
487+
let usesOverridingSystemValue = SqlDialect.from(databaseTypeId: dataSource.databaseTypeId) == .postgres
488+
&& columnInfo.contains { $0.identityKind == .always }
483489
let tableRef = qualifiedRef(
484490
schema: table.databaseName, table: table.name, dataSource: dataSource)
485491

Plugins/TableProPluginKit/SqlDialect.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ public enum SqlDialect: String, Sendable, CaseIterable {
88

99
public static func from(databaseTypeId: String) -> SqlDialect {
1010
switch databaseTypeId {
11-
case "PostgreSQL", "Redshift", "Greenplum", "AlloyDB", "Citus", "CockroachDB":
11+
case "PostgreSQL", "Redshift", "Greenplum", "AlloyDB", "Citus", "CockroachDB", "PGlite":
1212
return .postgres
1313
case "MySQL", "MariaDB":
1414
return .mysql

TablePro/Core/ChangeTracking/DataChangeManager.swift

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,14 +101,15 @@ final class DataChangeManager: ChangeManaging {
101101
columns: [String],
102102
primaryKeyColumns: [String],
103103
databaseType: DatabaseType,
104+
generatedColumns: Set<String>,
104105
triggerReload: Bool = true
105106
) {
106107
self.tableName = tableName
107108
self.schemaName = schemaName
108109
self.columns = columns
109110
self.primaryKeyColumns = primaryKeyColumns
110111
self.databaseType = databaseType
111-
self.generatedColumns = []
112+
self.generatedColumns = generatedColumns
112113

113114
pending.clear()
114115
undoManagerProvider?()?.removeAllActions(withTarget: self)
@@ -491,12 +492,19 @@ final class DataChangeManager: ChangeManaging {
491492
pending.snapshot(primaryKeyColumns: primaryKeyColumns, columns: columns)
492493
}
493494

494-
func restoreState(from state: TabChangeSnapshot, tableName: String, schemaName: String? = nil, databaseType: DatabaseType) {
495+
func restoreState(
496+
from state: TabChangeSnapshot,
497+
tableName: String,
498+
schemaName: String? = nil,
499+
databaseType: DatabaseType,
500+
generatedColumns: Set<String>
501+
) {
495502
self.tableName = tableName
496503
self.schemaName = schemaName
497504
self.columns = state.columns
498505
self.primaryKeyColumns = state.primaryKeyColumns
499506
self.databaseType = databaseType
507+
self.generatedColumns = generatedColumns
500508
pending.restore(from: state)
501509
self.hasChanges = !pending.isEmpty
502510
}

TablePro/Core/ChangeTracking/SQLStatementGenerator.swift

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ struct SQLStatementGenerator {
196196
}
197197
}
198198

199-
guard !nonDefaultColumns.isEmpty else { return nil }
199+
guard !nonDefaultColumns.isEmpty else { return allDefaultsInsertStatement() }
200200

201201
let columnList = nonDefaultColumns.joined(separator: ", ")
202202
let placeholders = placeholderParts.joined(separator: ", ")
@@ -207,6 +207,25 @@ struct SQLStatementGenerator {
207207
return ParameterizedStatement(sql: sql, parameters: bindParameters)
208208
}
209209

210+
/// A row whose every column the server fills in names no column at all, which is legal SQL and
211+
/// has its own spelling per engine. Returning nothing instead dropped the row from the batch
212+
/// while the rest of the save committed and reported success, so a new row in a table of
213+
/// nothing but an identity column and defaults vanished without a word.
214+
private func allDefaultsInsertStatement() -> ParameterizedStatement? {
215+
switch SqlDialect.from(databaseTypeId: databaseType.rawValue) {
216+
case .postgres, .sqlite:
217+
return ParameterizedStatement(
218+
sql: "INSERT INTO \(qualifiedTableName) DEFAULT VALUES", parameters: []
219+
)
220+
case .mysql:
221+
return ParameterizedStatement(
222+
sql: "INSERT INTO \(qualifiedTableName) () VALUES ()", parameters: []
223+
)
224+
default:
225+
return nil
226+
}
227+
}
228+
210229
func insertStatement(columns insertColumns: [String], values: [PluginCellValue])
211230
-> ParameterizedStatement?
212231
{

TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift

Lines changed: 82 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,66 @@ extension QueryExecutionCoordinator {
9595
return nil
9696
}
9797

98+
struct ResolvedDisplayMetadata {
99+
var columnDefaults: [String: String?] = [:]
100+
var columnForeignKeys: [String: ForeignKeyInfo] = [:]
101+
var columnEnumValues: [String: [String]] = [:]
102+
var columnNullable: [String: Bool] = [:]
103+
var columnComments: [String: String] = [:]
104+
var columnIdentity: [String: IdentityKind] = [:]
105+
var generatedColumns: Set<String> = []
106+
var foreignKeysFetched = false
107+
}
108+
109+
/// A rerun answered from cache carries no metadata of its own, so it inherits what the tab
110+
/// already holds. That includes the non-writable set, which `configureForTable` clears on every
111+
/// execution and only a schema fetch refills.
112+
private func resolveDisplayMetadata(
113+
metadata: ParsedSchemaMetadata?,
114+
existingTabId: UUID,
115+
columns: [String],
116+
columnTypes: [ColumnType],
117+
tabIndex: Int,
118+
tableName: String?
119+
) -> ResolvedDisplayMetadata {
120+
var resolved = ResolvedDisplayMetadata()
121+
for (index, colType) in columnTypes.enumerated() {
122+
if case .enumType(_, let values) = colType, let vals = values, index < columns.count {
123+
resolved.columnEnumValues[columns[index]] = vals
124+
}
125+
}
126+
127+
if let metadata {
128+
resolved.columnDefaults = metadata.columnDefaults
129+
resolved.columnForeignKeys = metadata.columnForeignKeys ?? [:]
130+
resolved.columnNullable = metadata.columnNullable
131+
resolved.columnComments = metadata.columnComments
132+
resolved.columnIdentity = metadata.columnIdentity
133+
resolved.generatedColumns = metadata.generatedColumns
134+
resolved.foreignKeysFetched = metadata.columnForeignKeys != nil
135+
for (col, vals) in metadata.columnEnumValues {
136+
resolved.columnEnumValues[col] = vals
137+
}
138+
} else {
139+
let existing = parent.tabSessionRegistry.tableRows(for: existingTabId)
140+
resolved.columnDefaults = existing.columnDefaults
141+
resolved.columnForeignKeys = existing.columnForeignKeys
142+
resolved.columnNullable = existing.columnNullable
143+
resolved.columnComments = existing.columnComments
144+
resolved.columnIdentity = existing.columnIdentity
145+
resolved.generatedColumns = existing.generatedColumns
146+
resolved.foreignKeysFetched = existing.foreignKeysFetched
147+
for (col, vals) in existing.columnEnumValues where resolved.columnEnumValues[col] == nil {
148+
resolved.columnEnumValues[col] = vals
149+
}
150+
}
151+
152+
if resolved.columnForeignKeys.isEmpty, !resolved.foreignKeysFetched, let tableName {
153+
resolved.columnForeignKeys = prefetchedForeignKeys(tabIndex: tabIndex, tableName: tableName) ?? [:]
154+
}
155+
return resolved
156+
}
157+
98158
func applyPhase1Result( // swiftlint:disable:this function_parameter_count
99159
tabId: UUID,
100160
columns: [String],
@@ -138,54 +198,28 @@ extension QueryExecutionCoordinator {
138198
}
139199

140200
let existingTabId = parent.tabManager.tabs[idx].id
141-
var columnEnumValues: [String: [String]] = [:]
142-
var columnDefaults: [String: String?] = [:]
143-
var columnForeignKeys: [String: ForeignKeyInfo] = [:]
144-
var columnNullable: [String: Bool] = [:]
145-
var columnComments: [String: String] = [:]
146-
for (index, colType) in columnTypes.enumerated() {
147-
if case .enumType(_, let values) = colType, let vals = values, index < columns.count {
148-
columnEnumValues[columns[index]] = vals
149-
}
150-
}
151-
152-
var foreignKeysFetched = false
153-
154-
if let metadata {
155-
columnDefaults = metadata.columnDefaults
156-
columnForeignKeys = metadata.columnForeignKeys ?? [:]
157-
columnNullable = metadata.columnNullable
158-
columnComments = metadata.columnComments
159-
foreignKeysFetched = metadata.columnForeignKeys != nil
160-
for (col, vals) in metadata.columnEnumValues {
161-
columnEnumValues[col] = vals
162-
}
163-
} else {
164-
let existing = parent.tabSessionRegistry.tableRows(for: existingTabId)
165-
columnDefaults = existing.columnDefaults
166-
columnForeignKeys = existing.columnForeignKeys
167-
columnNullable = existing.columnNullable
168-
columnComments = existing.columnComments
169-
foreignKeysFetched = existing.foreignKeysFetched
170-
for (col, vals) in existing.columnEnumValues where columnEnumValues[col] == nil {
171-
columnEnumValues[col] = vals
172-
}
173-
}
174-
175-
if columnForeignKeys.isEmpty, !foreignKeysFetched, let tableName {
176-
columnForeignKeys = prefetchedForeignKeys(tabIndex: idx, tableName: tableName) ?? [:]
177-
}
201+
let resolved = resolveDisplayMetadata(
202+
metadata: metadata,
203+
existingTabId: existingTabId,
204+
columns: columns,
205+
columnTypes: columnTypes,
206+
tabIndex: idx,
207+
tableName: tableName
208+
)
209+
let generatedColumns = resolved.generatedColumns
178210

179211
let newTableRows = TableRows.from(
180212
queryRows: rows,
181213
columns: columns,
182214
columnTypes: columnTypes,
183-
columnDefaults: columnDefaults,
184-
columnForeignKeys: columnForeignKeys,
185-
columnEnumValues: columnEnumValues,
186-
columnNullable: columnNullable,
187-
columnComments: columnComments,
188-
foreignKeysFetched: foreignKeysFetched
215+
columnDefaults: resolved.columnDefaults,
216+
columnForeignKeys: resolved.columnForeignKeys,
217+
columnEnumValues: resolved.columnEnumValues,
218+
columnNullable: resolved.columnNullable,
219+
columnComments: resolved.columnComments,
220+
columnIdentity: resolved.columnIdentity,
221+
generatedColumns: generatedColumns,
222+
foreignKeysFetched: resolved.foreignKeysFetched
189223
)
190224
let previousTableName = parent.tabManager.tabs[idx].tableContext.tableName
191225
parent.flushBufferToActiveResult(tabId: existingTabId, pinnedOnly: true)
@@ -262,7 +296,8 @@ extension QueryExecutionCoordinator {
262296
schemaName: parent.tabManager.tabs[idx].tableContext.schemaName,
263297
columns: columns,
264298
primaryKeyColumns: resolvedPKs,
265-
databaseType: conn.type
299+
databaseType: conn.type,
300+
generatedColumns: generatedColumns
266301
)
267302
}
268303

@@ -499,7 +534,9 @@ extension QueryExecutionCoordinator {
499534
columnDefaults: parsed.columnDefaults,
500535
columnForeignKeys: parsed.columnForeignKeys,
501536
columnNullable: parsed.columnNullable,
502-
columnComments: parsed.columnComments
537+
columnComments: parsed.columnComments,
538+
columnIdentity: parsed.columnIdentity,
539+
generatedColumns: parsed.generatedColumns
503540
)
504541
}
505542

0 commit comments

Comments
 (0)