From 6e95317a08b161ceab1bbaedc8f5f832f9d52038 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 31 Aug 2026 23:57:27 +0700 Subject: [PATCH 1/2] fix(datagrid): carry identity columns through so a new row pre-fills DEFAULT --- CHANGELOG.md | 5 + .../MSSQLPluginDriver+Schema.swift | 2 + .../MySQLGeneratedColumnClassification.swift | 10 ++ .../MySQLDriverPlugin/MySQLPluginDriver.swift | 2 + Plugins/SQLExportPlugin/SQLExportPlugin.swift | 22 ++- Plugins/TableProPluginKit/SqlDialect.swift | 2 +- .../QueryExecutionCoordinator+Helpers.swift | 125 +++++++++----- .../Coordinators/RowEditingCoordinator.swift | 18 +-- .../Core/Plugins/PluginDriverAdapter.swift | 8 +- .../Core/Services/Query/QueryExecutor.swift | 15 +- .../Services/Query/RowOperationsManager.swift | 23 ++- TablePro/Models/Query/QueryResult.swift | 7 + TablePro/Models/Query/TableRows.swift | 37 ++++- .../Views/Results/DataGridCoordinator.swift | 11 ++ TablePro/Views/Results/DataGridRowView.swift | 8 +- .../Extensions/DataGridView+Editing.swift | 17 +- .../Services/RowOperationsManagerTests.swift | 153 ++++++++++-------- .../SchemaMetadataGeneratedColumnTests.swift | 42 +++++ .../Utilities/SQL/SQLFileParserTests.swift | 1 + .../Models/Query/TableRowsTests.swift | 67 ++++++++ ...QLGeneratedColumnClassificationTests.swift | 21 +++ docs/features/change-tracking.mdx | 10 +- 22 files changed, 436 insertions(+), 170 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f8504b8d9..5f5a9575b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Crash loop on every launch after resizing a column on a database with a long file path, with iCloud sync on. (#2575) - SSH Agent auth prompting for a private key passphrase instead of reporting that the agent was never reached. (#2583) - "SSH password rejected" on an SSH connection that has no password, when the server offers no keyboard-interactive. +- NULL pre-filled into an identity column on Add Row, so the insert failed on PostgreSQL. (#2588) +- No "Default" in a cell's Set Value menu for an identity column. (#2588) +- Duplicate Row copying an identity column that is not the primary key. (#2588) +- Editing a `GENERATED ALWAYS AS IDENTITY` cell, which the server rejects on save. (#2588) +- `OVERRIDING SYSTEM VALUE` and `setval` in a SQL export of a SQL Server database. ## [0.69.0] - 2026-08-27 diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift index ff1df43d1c..f3af787109 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift @@ -109,6 +109,7 @@ extension MSSQLPluginDriver { isPrimaryKey: isPk, defaultValue: defaultValue, extra: isIdentity ? "IDENTITY" : nil, + identityKind: isIdentity ? .always : nil, isGenerated: isComputed ) } @@ -312,6 +313,7 @@ extension MSSQLPluginDriver { isPrimaryKey: isPk, defaultValue: defaultValue, extra: isIdentity ? "IDENTITY" : nil, + identityKind: isIdentity ? .always : nil, isGenerated: isComputed ) columnsByTable[tableName, default: []].append(col) diff --git a/Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift b/Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift index 2d1f88e7fa..dffca9f439 100644 --- a/Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift +++ b/Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift @@ -28,6 +28,16 @@ internal func mysqlColumnIsGenerated(extra: String?) -> Bool { return trimmed == "VIRTUAL" || trimmed == "PERSISTENT" } +/// AUTO_INCREMENT, from the same `Extra` value, because MySQL leaves `COLUMN_DEFAULT` null for such +/// a column exactly as PostgreSQL does for an identity column. +/// +/// It is `byDefault` rather than `always`: MySQL accepts an explicit value and only allocates the +/// next one when the column is omitted or given NULL. +internal func mysqlIdentityKind(extra: String?) -> IdentityKind? { + guard let extra, extra.uppercased().contains("AUTO_INCREMENT") else { return nil } + return .byDefault +} + /// The kind, from the same `Extra` value. MariaDB 10.1 and older spell stored as "PERSISTENT". internal func mysqlGenerationKind(extra: String?) -> GenerationKind? { guard let extra, mysqlColumnIsGenerated(extra: extra) else { return nil } diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 297f2a5054..ff8375ac1f 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -273,6 +273,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { charset: charset, collation: collation == "NULL" ? nil : collation, comment: comment?.isEmpty == false ? comment : nil, + identityKind: mysqlIdentityKind(extra: extra), isGenerated: mysqlColumnIsGenerated(extra: extra), allowedValues: allowedValues, generationExpression: generationExpressions[name], @@ -387,6 +388,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { charset: charset, collation: collation == "NULL" ? nil : collation, comment: comment?.isEmpty == false ? comment : nil, + identityKind: mysqlIdentityKind(extra: extra), isGenerated: mysqlColumnIsGenerated(extra: extra), allowedValues: allowedValues ) diff --git a/Plugins/SQLExportPlugin/SQLExportPlugin.swift b/Plugins/SQLExportPlugin/SQLExportPlugin.swift index c1592299a4..24f15f07ed 100644 --- a/Plugins/SQLExportPlugin/SQLExportPlugin.swift +++ b/Plugins/SQLExportPlugin/SQLExportPlugin.swift @@ -371,13 +371,18 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send } } - for table in sortedTables where optionValue(table, at: 2) && table.tableType != "view" { - let columns = columnsByTable[node(for: table).identifier] ?? [] - for column in columns where column.isIdentity { - let setval = renderIdentitySetval( - table: table, columnName: column.name, dataSource: dataSource) - try fileHandle.write(contentsOf: "\(setval)\n".toUTF8Data()) - emittedAnything = true + /// `setval` and `pg_get_serial_sequence` are PostgreSQL's own, so the sequence is only + /// rewound on PostgreSQL. Every other engine reports its identity columns the same way and + /// would take the statement as a syntax error. + if SqlDialect.from(databaseTypeId: dataSource.databaseTypeId) == .postgres { + for table in sortedTables where optionValue(table, at: 2) && table.tableType != "view" { + let columns = columnsByTable[node(for: table).identifier] ?? [] + for column in columns where column.isIdentity { + let setval = renderIdentitySetval( + table: table, columnName: column.name, dataSource: dataSource) + try fileHandle.write(contentsOf: "\(setval)\n".toUTF8Data()) + emittedAnything = true + } } } @@ -479,7 +484,8 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send var rowBatch: [[PluginCellValue]] = [] let generatedColumnNames = Set(columnInfo.filter { $0.isGenerated }.map { $0.name }) - let usesOverridingSystemValue = columnInfo.contains { $0.identityKind == .always } + let usesOverridingSystemValue = SqlDialect.from(databaseTypeId: dataSource.databaseTypeId) == .postgres + && columnInfo.contains { $0.identityKind == .always } let tableRef = qualifiedRef( schema: table.databaseName, table: table.name, dataSource: dataSource) diff --git a/Plugins/TableProPluginKit/SqlDialect.swift b/Plugins/TableProPluginKit/SqlDialect.swift index cb1d0950b0..541132b50c 100644 --- a/Plugins/TableProPluginKit/SqlDialect.swift +++ b/Plugins/TableProPluginKit/SqlDialect.swift @@ -8,7 +8,7 @@ public enum SqlDialect: String, Sendable, CaseIterable { public static func from(databaseTypeId: String) -> SqlDialect { switch databaseTypeId { - case "PostgreSQL", "Redshift", "Greenplum", "AlloyDB", "Citus", "CockroachDB": + case "PostgreSQL", "Redshift", "Greenplum", "AlloyDB", "Citus", "CockroachDB", "PGlite": return .postgres case "MySQL", "MariaDB": return .mysql diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index bf9d6ebe36..a79f642659 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -95,6 +95,66 @@ extension QueryExecutionCoordinator { return nil } + struct ResolvedDisplayMetadata { + var columnDefaults: [String: String?] = [:] + var columnForeignKeys: [String: ForeignKeyInfo] = [:] + var columnEnumValues: [String: [String]] = [:] + var columnNullable: [String: Bool] = [:] + var columnComments: [String: String] = [:] + var columnIdentity: [String: IdentityKind] = [:] + var generatedColumns: Set = [] + var foreignKeysFetched = false + } + + /// A rerun answered from cache carries no metadata of its own, so it inherits what the tab + /// already holds. That includes the non-writable set, which `configureForTable` clears on every + /// execution and only a schema fetch refills. + private func resolveDisplayMetadata( + metadata: ParsedSchemaMetadata?, + existingTabId: UUID, + columns: [String], + columnTypes: [ColumnType], + tabIndex: Int, + tableName: String? + ) -> ResolvedDisplayMetadata { + var resolved = ResolvedDisplayMetadata() + for (index, colType) in columnTypes.enumerated() { + if case .enumType(_, let values) = colType, let vals = values, index < columns.count { + resolved.columnEnumValues[columns[index]] = vals + } + } + + if let metadata { + resolved.columnDefaults = metadata.columnDefaults + resolved.columnForeignKeys = metadata.columnForeignKeys ?? [:] + resolved.columnNullable = metadata.columnNullable + resolved.columnComments = metadata.columnComments + resolved.columnIdentity = metadata.columnIdentity + resolved.generatedColumns = metadata.generatedColumns + resolved.foreignKeysFetched = metadata.columnForeignKeys != nil + for (col, vals) in metadata.columnEnumValues { + resolved.columnEnumValues[col] = vals + } + } else { + let existing = parent.tabSessionRegistry.tableRows(for: existingTabId) + resolved.columnDefaults = existing.columnDefaults + resolved.columnForeignKeys = existing.columnForeignKeys + resolved.columnNullable = existing.columnNullable + resolved.columnComments = existing.columnComments + resolved.columnIdentity = existing.columnIdentity + resolved.generatedColumns = existing.generatedColumns + resolved.foreignKeysFetched = existing.foreignKeysFetched + for (col, vals) in existing.columnEnumValues where resolved.columnEnumValues[col] == nil { + resolved.columnEnumValues[col] = vals + } + } + + if resolved.columnForeignKeys.isEmpty, !resolved.foreignKeysFetched, let tableName { + resolved.columnForeignKeys = prefetchedForeignKeys(tabIndex: tabIndex, tableName: tableName) ?? [:] + } + return resolved + } + func applyPhase1Result( // swiftlint:disable:this function_parameter_count tabId: UUID, columns: [String], @@ -138,54 +198,28 @@ extension QueryExecutionCoordinator { } let existingTabId = parent.tabManager.tabs[idx].id - var columnEnumValues: [String: [String]] = [:] - var columnDefaults: [String: String?] = [:] - var columnForeignKeys: [String: ForeignKeyInfo] = [:] - var columnNullable: [String: Bool] = [:] - var columnComments: [String: String] = [:] - for (index, colType) in columnTypes.enumerated() { - if case .enumType(_, let values) = colType, let vals = values, index < columns.count { - columnEnumValues[columns[index]] = vals - } - } - - var foreignKeysFetched = false - - if let metadata { - columnDefaults = metadata.columnDefaults - columnForeignKeys = metadata.columnForeignKeys ?? [:] - columnNullable = metadata.columnNullable - columnComments = metadata.columnComments - foreignKeysFetched = metadata.columnForeignKeys != nil - for (col, vals) in metadata.columnEnumValues { - columnEnumValues[col] = vals - } - } else { - let existing = parent.tabSessionRegistry.tableRows(for: existingTabId) - columnDefaults = existing.columnDefaults - columnForeignKeys = existing.columnForeignKeys - columnNullable = existing.columnNullable - columnComments = existing.columnComments - foreignKeysFetched = existing.foreignKeysFetched - for (col, vals) in existing.columnEnumValues where columnEnumValues[col] == nil { - columnEnumValues[col] = vals - } - } - - if columnForeignKeys.isEmpty, !foreignKeysFetched, let tableName { - columnForeignKeys = prefetchedForeignKeys(tabIndex: idx, tableName: tableName) ?? [:] - } + let resolved = resolveDisplayMetadata( + metadata: metadata, + existingTabId: existingTabId, + columns: columns, + columnTypes: columnTypes, + tabIndex: idx, + tableName: tableName + ) + let generatedColumns = resolved.generatedColumns let newTableRows = TableRows.from( queryRows: rows, columns: columns, columnTypes: columnTypes, - columnDefaults: columnDefaults, - columnForeignKeys: columnForeignKeys, - columnEnumValues: columnEnumValues, - columnNullable: columnNullable, - columnComments: columnComments, - foreignKeysFetched: foreignKeysFetched + columnDefaults: resolved.columnDefaults, + columnForeignKeys: resolved.columnForeignKeys, + columnEnumValues: resolved.columnEnumValues, + columnNullable: resolved.columnNullable, + columnComments: resolved.columnComments, + columnIdentity: resolved.columnIdentity, + generatedColumns: generatedColumns, + foreignKeysFetched: resolved.foreignKeysFetched ) let previousTableName = parent.tabManager.tabs[idx].tableContext.tableName parent.flushBufferToActiveResult(tabId: existingTabId, pinnedOnly: true) @@ -264,6 +298,7 @@ extension QueryExecutionCoordinator { primaryKeyColumns: resolvedPKs, databaseType: conn.type ) + parent.changeManager.setGeneratedColumns(generatedColumns) } recordHistory( @@ -499,7 +534,9 @@ extension QueryExecutionCoordinator { columnDefaults: parsed.columnDefaults, columnForeignKeys: parsed.columnForeignKeys, columnNullable: parsed.columnNullable, - columnComments: parsed.columnComments + columnComments: parsed.columnComments, + columnIdentity: parsed.columnIdentity, + generatedColumns: parsed.generatedColumns ) } diff --git a/TablePro/Core/Coordinators/RowEditingCoordinator.swift b/TablePro/Core/Coordinators/RowEditingCoordinator.swift index 7fa252084f..bb8550b748 100644 --- a/TablePro/Core/Coordinators/RowEditingCoordinator.swift +++ b/TablePro/Core/Coordinators/RowEditingCoordinator.swift @@ -47,18 +47,12 @@ final class RowEditingCoordinator { tab.tableContext.tableName != nil else { return } let tabId = tab.id - let columnDefaults = parent.tabSessionRegistry.tableRows(for: tabId).columnDefaults - let columns = parent.tabSessionRegistry.tableRows(for: tabId).columns parent.dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit() var addResult: RowOperationsManager.AddNewRowResult? parent.mutateActiveTableRows(for: tabId) { rows in - let result = parent.rowOperationsManager.addNewRow( - columns: columns, - columnDefaults: columnDefaults, - tableRows: &rows - ) + let result = parent.rowOperationsManager.addNewRow(tableRows: &rows) addResult = result return result?.delta ?? .none } @@ -70,7 +64,7 @@ final class RowEditingCoordinator { } parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta) - parent.dataTabDelegate?.tableViewCoordinator?.beginEditing(displayRow: result.rowIndex, column: 0) + parent.dataTabDelegate?.tableViewCoordinator?.beginEditingFirstEditableColumn(displayRow: result.rowIndex) } func deleteSelectedRows(indices: Set) { @@ -182,7 +176,6 @@ final class RowEditingCoordinator { } let tabId = tab.id - let columns = parent.tabSessionRegistry.tableRows(for: tabId).columns guard index >= 0, index < parent.tabSessionRegistry.tableRows(for: tabId).count else { return } parent.dataTabDelegate?.tableViewCoordinator?.commitActiveCellEdit() @@ -191,7 +184,6 @@ final class RowEditingCoordinator { parent.mutateActiveTableRows(for: tabId) { rows in let result = parent.rowOperationsManager.duplicateRow( sourceRowIndex: index, - columns: columns, tableRows: &rows ) dupResult = result @@ -205,13 +197,12 @@ final class RowEditingCoordinator { } parent.tabManager.mutate(at: tabIndex) { $0.hasUserInteraction = true } parent.dataTabDelegate?.tableViewCoordinator?.applyDelta(result.delta) - parent.dataTabDelegate?.tableViewCoordinator?.beginEditing(displayRow: result.rowIndex, column: 0) + parent.dataTabDelegate?.tableViewCoordinator?.beginEditingFirstEditableColumn(displayRow: result.rowIndex) } private func duplicateFilteredRow(displayIndex: Int, tab: QueryTab, tabIndex: Int) { let tabId = tab.id let tableRows = parent.tabSessionRegistry.tableRows(for: tabId) - let columns = tableRows.columns guard let storageIndex = DisplayRowMapping.rowIndex( forDisplay: displayIndex, displayIDs: parent.activeGridDisplayIDs, in: tableRows ), storageIndex >= 0, storageIndex < tableRows.count else { return } @@ -222,7 +213,6 @@ final class RowEditingCoordinator { parent.mutateActiveTableRows(for: tabId) { rows in let result = parent.rowOperationsManager.duplicateRow( sourceRowIndex: storageIndex, - columns: columns, tableRows: &rows ) dupResult = result @@ -240,7 +230,7 @@ final class RowEditingCoordinator { let newDisplayIndex = displayCount - 1 guard newDisplayIndex >= 0 else { return } parent.selectionState.indices = [newDisplayIndex] - parent.dataTabDelegate?.tableViewCoordinator?.beginEditing(displayRow: newDisplayIndex, column: 0) + parent.dataTabDelegate?.tableViewCoordinator?.beginEditingFirstEditableColumn(displayRow: newDisplayIndex) } func undoInsertRow(at rowIndex: Int) { diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index df823305d9..7237531653 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -267,6 +267,7 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor charset: col.charset, collation: col.collation, comment: col.comment, + identityKind: col.identityKind, isGenerated: col.isGenerated, allowedValues: col.allowedValues, generationExpression: col.generationExpression, @@ -503,12 +504,7 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor let pluginResult = try await pluginDriver.fetchAllColumns(schema: pluginDriver.currentSchema) var result: [String: [ColumnInfo]] = [:] for (table, cols) in pluginResult { - result[table] = cols.map { col in - ColumnInfo(name: col.name, dataType: col.dataType, isNullable: col.isNullable, - isPrimaryKey: col.isPrimaryKey, defaultValue: col.defaultValue, - extra: col.extra, charset: col.charset, collation: col.collation, comment: col.comment, - allowedValues: col.allowedValues) - } + result[table] = mapPluginColumns(cols) } return result } diff --git a/TablePro/Core/Services/Query/QueryExecutor.swift b/TablePro/Core/Services/Query/QueryExecutor.swift index 68ba73b96a..c2c925ba1d 100644 --- a/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/TablePro/Core/Services/Query/QueryExecutor.swift @@ -26,7 +26,12 @@ struct ParsedSchemaMetadata { let columnForeignKeys: [String: ForeignKeyInfo]? let columnNullable: [String: Bool] let primaryKeyColumns: [String] + /// Columns the app must never write, whether the server computes the value from an expression + /// or allocates it from an identity sequence the column cannot override. A `GENERATED ALWAYS + /// AS IDENTITY` column belongs here for the same reason a stored generated column does: the + /// engine rejects both an explicit INSERT value and an UPDATE of one. let generatedColumns: Set + let columnIdentity: [String: IdentityKind] let approximateRowCount: Int? let columnEnumValues: [String: [String]] let columnComments: [String: String] @@ -183,9 +188,11 @@ final class QueryExecutor { static func parseSchemaMetadata(_ schema: FetchedTableSchema) -> ParsedSchemaMetadata { var defaults: [String: String?] = [:] var nullable: [String: Bool] = [:] + var identity: [String: IdentityKind] = [:] for col in schema.columns { defaults[col.name] = col.defaultValue nullable[col.name] = col.isNullable + identity[col.name] = col.identityKind } var fks: [String: ForeignKeyInfo]? if let foreignKeys = schema.foreignKeys { @@ -212,7 +219,12 @@ final class QueryExecutor { columnForeignKeys: fks, columnNullable: nullable, primaryKeyColumns: schema.columns.filter { $0.isPrimaryKey }.map(\.name), - generatedColumns: Set(schema.columns.filter(\.isGenerated).map(\.name)), + generatedColumns: Set( + schema.columns + .filter { $0.isGenerated || $0.identityKind == .always } + .map(\.name) + ), + columnIdentity: identity, approximateRowCount: schema.approximateRowCount, columnEnumValues: enumValues, columnComments: comments @@ -235,6 +247,7 @@ final class QueryExecutor { columnNullable: nullable, primaryKeyColumns: primaryKeys, generatedColumns: [], + columnIdentity: [:], approximateRowCount: nil, columnEnumValues: [:], columnComments: [:] diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index 50f661cb33..6815ab079c 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -47,17 +47,10 @@ final class RowOperationsManager { self.changeManager = changeManager } - func addNewRow( - columns: [String], - columnDefaults: [String: String?], - tableRows: inout TableRows - ) -> AddNewRowResult? { - let generated = changeManager.generatedColumns + func addNewRow(tableRows: inout TableRows) -> AddNewRowResult? { var newRowValues: [PluginCellValue] = [] - for column in columns { - if generated.contains(column) { - newRowValues.append(.text("__DEFAULT__")) - } else if let defaultValue = columnDefaults[column], defaultValue != nil { + for column in tableRows.columns { + if tableRows.generatedColumns.contains(column) || tableRows.serverAssignsValue(forColumn: column) { newRowValues.append(.text("__DEFAULT__")) } else { newRowValues.append(.null) @@ -74,15 +67,19 @@ final class RowOperationsManager { func duplicateRow( sourceRowIndex: Int, - columns: [String], tableRows: inout TableRows ) -> AddNewRowResult? { guard sourceRowIndex >= 0, sourceRowIndex < tableRows.count else { return nil } var newValues = Array(tableRows.rows[sourceRowIndex].values) - for resetColumn in changeManager.primaryKeyColumns + Array(changeManager.generatedColumns) { - if let index = columns.firstIndex(of: resetColumn), index < newValues.count { + /// An identity column is not always the primary key, and copying its value verbatim is + /// what the server rejects. + let resetColumns = changeManager.primaryKeyColumns + + Array(tableRows.generatedColumns) + + Array(tableRows.columnIdentity.keys) + for resetColumn in resetColumns { + if let index = tableRows.columns.firstIndex(of: resetColumn), index < newValues.count { newValues[index] = .text("__DEFAULT__") } } diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index f33161d61d..0d5e960cb0 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -160,6 +160,11 @@ struct ColumnInfo: Identifiable, Hashable { let charset: String? let collation: String? let comment: String? + /// Set when the server allocates the value from a sequence it owns, which it reports separately + /// from the column default: PostgreSQL leaves `column_default` null for an identity column and + /// puts the generation in `pg_attribute.attidentity`. Dropping it here made every identity + /// column look like a column with no default. + let identityKind: IdentityKind? let isGenerated: Bool let allowedValues: [String]? let generationExpression: String? @@ -175,6 +180,7 @@ struct ColumnInfo: Identifiable, Hashable { charset: String? = nil, collation: String? = nil, comment: String? = nil, + identityKind: IdentityKind? = nil, isGenerated: Bool = false, allowedValues: [String]? = nil, generationExpression: String? = nil, @@ -189,6 +195,7 @@ struct ColumnInfo: Identifiable, Hashable { self.charset = charset self.collation = collation self.comment = comment + self.identityKind = identityKind self.isGenerated = isGenerated self.allowedValues = allowedValues self.generationExpression = generationExpression diff --git a/TablePro/Models/Query/TableRows.swift b/TablePro/Models/Query/TableRows.swift index 49dc73850e..7d677e09bb 100644 --- a/TablePro/Models/Query/TableRows.swift +++ b/TablePro/Models/Query/TableRows.swift @@ -16,6 +16,12 @@ struct TableRows: Sendable { var columnEnumValues: [String: [String]] var columnNullable: [String: Bool] var columnComments: [String: String] + var columnIdentity: [String: IdentityKind] + /// Columns the app must never write. Held here as well as on `DataChangeManager` because the + /// change manager is rebuilt by `configureForTable` on every execution and only the schema + /// fetch refills it, so a rerun that answered from cache left a generated or `GENERATED ALWAYS + /// AS IDENTITY` column writable again. + var generatedColumns: Set var foreignKeysFetched: Bool init( @@ -27,6 +33,8 @@ struct TableRows: Sendable { columnEnumValues: [String: [String]] = [:], columnNullable: [String: Bool] = [:], columnComments: [String: String] = [:], + columnIdentity: [String: IdentityKind] = [:], + generatedColumns: Set = [], foreignKeysFetched: Bool = false ) { self.rows = rows @@ -38,11 +46,24 @@ struct TableRows: Sendable { self.columnEnumValues = columnEnumValues self.columnNullable = columnNullable self.columnComments = columnComments + self.columnIdentity = columnIdentity + self.generatedColumns = generatedColumns self.foreignKeysFetched = foreignKeysFetched } var count: Int { rows.count } + /// Whether leaving the column out of an INSERT makes the server supply the value. + /// + /// A default expression is only one of the two ways that happens. An identity column has no + /// default at all: PostgreSQL reports the generation in `pg_attribute.attidentity` and leaves + /// `column_default` null, so asking about the default alone answers "no" for every identity + /// column and the new row goes out carrying NULL. + func serverAssignsValue(forColumn name: String) -> Bool { + if columnIdentity[name] != nil { return true } + return (columnDefaults[name] ?? nil) != nil + } + func value(at row: Int, column: Int) -> PluginCellValue { guard row >= 0, row < rows.count else { return .null } return rows[row][column] @@ -168,7 +189,9 @@ struct TableRows: Sendable { columnForeignKeys: [String: ForeignKeyInfo]? = nil, columnEnumValues: [String: [String]]? = nil, columnNullable: [String: Bool]? = nil, - columnComments: [String: String]? = nil + columnComments: [String: String]? = nil, + columnIdentity: [String: IdentityKind]? = nil, + generatedColumns: Set? = nil ) -> Delta { var didChange = false if let columnTypes, columnTypes != self.columnTypes { @@ -198,6 +221,14 @@ struct TableRows: Sendable { self.columnComments = columnComments didChange = true } + if let columnIdentity, columnIdentity != self.columnIdentity { + self.columnIdentity = columnIdentity + didChange = true + } + if let generatedColumns, generatedColumns != self.generatedColumns { + self.generatedColumns = generatedColumns + didChange = true + } return didChange ? .columnsReplaced : .none } @@ -210,6 +241,8 @@ struct TableRows: Sendable { columnEnumValues: [String: [String]] = [:], columnNullable: [String: Bool] = [:], columnComments: [String: String] = [:], + columnIdentity: [String: IdentityKind] = [:], + generatedColumns: Set = [], foreignKeysFetched: Bool = false ) -> TableRows { var rows = ContiguousArray() @@ -227,6 +260,8 @@ struct TableRows: Sendable { columnEnumValues: columnEnumValues, columnNullable: columnNullable, columnComments: columnComments, + columnIdentity: columnIdentity, + generatedColumns: generatedColumns, foreignKeysFetched: foreignKeysFetched ) } diff --git a/TablePro/Views/Results/DataGridCoordinator.swift b/TablePro/Views/Results/DataGridCoordinator.swift index d640411bc6..489b5b2cfd 100644 --- a/TablePro/Views/Results/DataGridCoordinator.swift +++ b/TablePro/Views/Results/DataGridCoordinator.swift @@ -1175,6 +1175,17 @@ final class TableViewCoordinator: NSObject, NSTableViewDelegate, NSTableViewData beginCellEdit(row: displayRow, tableColumnIndex: displayCol) } + /// Where the caret goes on a row the user just made. Column 0 is where the row starts, not + /// necessarily where it can be typed into: a table whose first column the server owns, such as + /// an identity or a stored generated column, refuses the edit and opens nothing at all, which + /// reads as Add Row having done nothing. Falls back to column 0 so a row with no editable + /// column at all is still selected and scrolled to. + func beginEditingFirstEditableColumn(displayRow: Int) { + let columnCount = tableRowsProvider().columns.count + let column = (0..= 0 { + let namesWritableColumn = dataColumnIndex >= 0 && dataColumnIndex < tableRows.columns.count + && coordinator.isColumnWritable(tableRows.columns[dataColumnIndex]) + if coordinator.isEditable && namesWritableColumn { let setValueItem = NSMenuItem(title: String(localized: "Set Value"), action: nil, keyEquivalent: "") setValueItem.submenu = buildSetValueMenu(dataColumnIndex: dataColumnIndex, tableRows: tableRows) menu.addItem(setValueItem) @@ -533,8 +535,8 @@ class DataGridRowView: NSTableRowView { setValueMenu.addItem(nullItem) } - let hasDefault = columnName.flatMap({ tableRows.columnDefaults[$0] ?? nil }) != nil - if hasDefault { + let serverAssignsValue = columnName.map { tableRows.serverAssignsValue(forColumn: $0) } ?? false + if serverAssignsValue { let defaultItem = NSMenuItem( title: String(localized: "Default"), action: #selector(setDefaultValue(_:)), keyEquivalent: "") defaultItem.representedObject = dataColumnIndex diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index 88afc4c93e..48a6d35917 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -19,11 +19,7 @@ extension TableViewCoordinator { guard row >= 0, columnIndex >= 0, columnIndex < tableRows.columns.count else { return .blocked } guard !changeManager.isRowDeleted(row) else { return .blocked } - let columnName = tableRows.columns[columnIndex] - if changeManager.generatedColumns.contains(columnName) { return .blocked } - - let immutable = databaseType.map { PluginManager.shared.immutableColumns(for: $0) } ?? [] - if immutable.contains(columnName) { return .blocked } + guard isColumnWritable(tableRows.columns[columnIndex]) else { return .blocked } if columnIndex < tableRows.columnTypes.count { let ct = tableRows.columnTypes[columnIndex] @@ -45,6 +41,17 @@ extension TableViewCoordinator { return .editable(value: value) } + /// Whether the app is allowed to send a value for this column at all, which is a narrower + /// question than whether the cell takes the inline editor: a BLOB cell refuses the editor and + /// still accepts NULL from the Set Value menu. The menu offered its items on a column no + /// statement can carry, so Set NULL on a MongoDB `_id`, a generated column or a + /// `GENERATED ALWAYS AS IDENTITY` column marked the row edited and then wrote nothing. + func isColumnWritable(_ columnName: String) -> Bool { + guard !changeManager.generatedColumns.contains(columnName) else { return false } + let immutable = databaseType.map { PluginManager.shared.immutableColumns(for: $0) } ?? [] + return !immutable.contains(columnName) + } + func canStartInlineEdit(row: Int, columnIndex: Int) -> Bool { if case .editable = editEligibility(row: row, columnIndex: columnIndex) { return true diff --git a/TableProTests/Core/Services/RowOperationsManagerTests.swift b/TableProTests/Core/Services/RowOperationsManagerTests.swift index 59db54e233..506dbfc1ba 100644 --- a/TableProTests/Core/Services/RowOperationsManagerTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerTests.swift @@ -24,21 +24,32 @@ struct RowOperationsManagerTests { return (manager, changeManager) } - private func makeTableRows(rowCount: Int) -> TableRows { + private func makeTableRows( + rowCount: Int, + columnIdentity: [String: IdentityKind] = [:] + ) -> TableRows { let raw = TestFixtures.makeRows(count: rowCount, columns: Self.testColumns) let typed = raw.map { row in row.map(PluginCellValue.fromOptional) } return TableRows.from( queryRows: typed, columns: Self.testColumns, - columnTypes: Self.testColumnTypes + columnTypes: Self.testColumnTypes, + columnIdentity: columnIdentity ) } - private func emptyTableRows() -> TableRows { + private func emptyTableRows( + columnDefaults: [String: String?] = [:], + columnIdentity: [String: IdentityKind] = [:], + generatedColumns: Set = [] + ) -> TableRows { TableRows.from( queryRows: [], columns: Self.testColumns, - columnTypes: Self.testColumnTypes + columnTypes: Self.testColumnTypes, + columnDefaults: columnDefaults, + columnIdentity: columnIdentity, + generatedColumns: generatedColumns ) } @@ -48,11 +59,7 @@ struct RowOperationsManagerTests { var tableRows = makeTableRows(rowCount: 3) let originalCount = tableRows.count - _ = manager.addNewRow( - columns: Self.testColumns, - columnDefaults: [:], - tableRows: &tableRows - ) + _ = manager.addNewRow(tableRows: &tableRows) #expect(tableRows.count == originalCount + 1) } @@ -62,11 +69,7 @@ struct RowOperationsManagerTests { let (manager, _) = makeManager() var tableRows = makeTableRows(rowCount: 5) - let result = manager.addNewRow( - columns: Self.testColumns, - columnDefaults: [:], - tableRows: &tableRows - ) + let result = manager.addNewRow(tableRows: &tableRows) #expect(result != nil) #expect(result?.rowIndex == 5) @@ -82,11 +85,7 @@ struct RowOperationsManagerTests { let (manager, _) = makeManager() var tableRows = makeTableRows(rowCount: 2) - let result = manager.addNewRow( - columns: Self.testColumns, - columnDefaults: [:], - tableRows: &tableRows - ) + let result = manager.addNewRow(tableRows: &tableRows) #expect(result != nil) let newIndex = result!.rowIndex @@ -96,37 +95,58 @@ struct RowOperationsManagerTests { @Test("addNewRow uses DEFAULT marker for columns with defaults") func addNewRowUsesDefaultMarker() { let (manager, _) = makeManager() - var tableRows = emptyTableRows() - let defaults: [String: String?] = [ + var tableRows = emptyTableRows(columnDefaults: [ "id": "auto_increment", "name": nil, "email": "user@example.com", - ] + ]) - let result = manager.addNewRow( - columns: Self.testColumns, - columnDefaults: defaults, - tableRows: &tableRows - ) + let result = manager.addNewRow(tableRows: &tableRows) #expect(result != nil) #expect(result?.values[0] == "__DEFAULT__") #expect(result?.values[2] == "__DEFAULT__") } + /// PostgreSQL leaves `column_default` null for an identity column and reports the generation in + /// `pg_attribute.attidentity`, so a new row went out carrying an explicit NULL that the server + /// refuses: "cannot insert a non-DEFAULT value into column" for GENERATED ALWAYS, and a + /// not-null violation for GENERATED BY DEFAULT. + @Test( + "addNewRow uses DEFAULT marker for an identity column that has no default", + arguments: [IdentityKind.always, IdentityKind.byDefault] + ) + func addNewRowUsesDefaultMarkerForIdentity(kind: IdentityKind) { + let (manager, _) = makeManager() + var tableRows = emptyTableRows(columnIdentity: ["id": kind]) + + let result = manager.addNewRow(tableRows: &tableRows) + + #expect(result?.values[0] == "__DEFAULT__") + #expect(result?.values[1] == .null) + } + + /// Read from the rows rather than from the change manager, which `configureForTable` empties on + /// every execution and only a schema fetch refills. A rerun answered from cache runs no schema + /// fetch, so a generated column pre-filled NULL and then went out in the INSERT. + @Test("addNewRow uses DEFAULT marker for a generated column carried on the rows") + func addNewRowUsesDefaultMarkerForGeneratedColumn() { + let (manager, changeManager) = makeManager() + changeManager.setGeneratedColumns([]) + var tableRows = emptyTableRows(generatedColumns: ["email"]) + + let result = manager.addNewRow(tableRows: &tableRows) + + #expect(result?.values[2] == "__DEFAULT__") + #expect(result?.values[1] == .null) + } + @Test("addNewRow uses nil for columns without defaults") func addNewRowUsesNilForNoDefaults() { let (manager, _) = makeManager() - var tableRows = emptyTableRows() - let defaults: [String: String?] = [ - "id": "auto_increment", - ] + var tableRows = emptyTableRows(columnDefaults: ["id": "auto_increment"]) - let result = manager.addNewRow( - columns: Self.testColumns, - columnDefaults: defaults, - tableRows: &tableRows - ) + let result = manager.addNewRow(tableRows: &tableRows) /// `.null`, not a Swift nil. A column with no default gets an explicit SQL NULL, which is /// what `addNewRow` appends; an absent value and a NULL are different things to the @@ -141,11 +161,7 @@ struct RowOperationsManagerTests { let (manager, changeManager) = makeManager() var tableRows = makeTableRows(rowCount: 2) - let result = manager.addNewRow( - columns: Self.testColumns, - columnDefaults: [:], - tableRows: &tableRows - ) + let result = manager.addNewRow(tableRows: &tableRows) #expect(result != nil) #expect(changeManager.hasChanges) @@ -163,11 +179,7 @@ struct RowOperationsManagerTests { var tableRows = makeTableRows(rowCount: 2) let versionBefore = changeManager.reloadVersion - _ = manager.addNewRow( - columns: Self.testColumns, - columnDefaults: [:], - tableRows: &tableRows - ) + _ = manager.addNewRow(tableRows: &tableRows) #expect(changeManager.reloadVersion == versionBefore) @@ -181,9 +193,9 @@ struct RowOperationsManagerTests { let (manager, _) = makeManager() var tableRows = makeTableRows(rowCount: 2) - let r1 = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) - let r2 = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) - let r3 = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) + let r1 = manager.addNewRow(tableRows: &tableRows) + let r2 = manager.addNewRow(tableRows: &tableRows) + let r3 = manager.addNewRow(tableRows: &tableRows) #expect(tableRows.count == 5) #expect(r1?.rowIndex == 2) @@ -199,7 +211,6 @@ struct RowOperationsManagerTests { let result = manager.duplicateRow( sourceRowIndex: 1, - columns: Self.testColumns, tableRows: &tableRows ) @@ -215,7 +226,6 @@ struct RowOperationsManagerTests { let result = manager.duplicateRow( sourceRowIndex: 0, - columns: Self.testColumns, tableRows: &tableRows ) @@ -228,6 +238,20 @@ struct RowOperationsManagerTests { } } + /// An identity column need not be the primary key, so the primary-key reset alone left one + /// carrying the source row's value and the server rejected the copy. + @Test("duplicateRow sets a non-key identity column to DEFAULT") + func duplicateRowResetsNonKeyIdentityColumn() { + let (manager, _) = makeManager() + var tableRows = makeTableRows(rowCount: 3, columnIdentity: ["email": .always]) + let sourceValues = tableRows.rows[1].values + + let result = manager.duplicateRow(sourceRowIndex: 1, tableRows: &tableRows) + + #expect(result?.values[2] == "__DEFAULT__") + #expect(result?.values[1] == sourceValues[1]) + } + @Test("duplicateRow returns nil for invalid source index") func duplicateRowReturnsNilForInvalidIndex() { let (manager, _) = makeManager() @@ -235,7 +259,6 @@ struct RowOperationsManagerTests { let result = manager.duplicateRow( sourceRowIndex: 10, - columns: Self.testColumns, tableRows: &tableRows ) @@ -262,11 +285,7 @@ struct RowOperationsManagerTests { let (manager, _) = makeManager() var tableRows = makeTableRows(rowCount: 3) - let addResult = manager.addNewRow( - columns: Self.testColumns, - columnDefaults: [:], - tableRows: &tableRows - ) + let addResult = manager.addNewRow(tableRows: &tableRows) #expect(tableRows.count == 4) let result = manager.deleteSelectedRows( @@ -287,7 +306,7 @@ struct RowOperationsManagerTests { let (manager, _) = makeManager() var tableRows = makeTableRows(rowCount: 5) - _ = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) + _ = manager.addNewRow(tableRows: &tableRows) #expect(tableRows.count == 6) let result = manager.deleteSelectedRows( @@ -329,9 +348,9 @@ struct RowOperationsManagerTests { let (manager, _) = makeManager() var tableRows = makeTableRows(rowCount: 2) - _ = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) - _ = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) - _ = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) + _ = manager.addNewRow(tableRows: &tableRows) + _ = manager.addNewRow(tableRows: &tableRows) + _ = manager.addNewRow(tableRows: &tableRows) #expect(tableRows.count == 5) let result = manager.deleteSelectedRows(selectedIndices: [2, 3, 4], tableRows: &tableRows) @@ -350,7 +369,7 @@ struct RowOperationsManagerTests { let (manager, _) = makeManager() var tableRows = makeTableRows(rowCount: 3) - _ = manager.addNewRow(columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows) + _ = manager.addNewRow(tableRows: &tableRows) #expect(tableRows.count == 4) let result = manager.deleteSelectedRows(selectedIndices: [0, 3], tableRows: &tableRows) @@ -381,9 +400,7 @@ struct RowOperationsManagerTests { func deleteRowsRemovesInsertedByStorageIndex() { let (manager, _) = makeManager() var tableRows = makeTableRows(rowCount: 3) - guard let addResult = manager.addNewRow( - columns: Self.testColumns, columnDefaults: [:], tableRows: &tableRows - ) else { + guard let addResult = manager.addNewRow(tableRows: &tableRows) else { Issue.record("addNewRow returned nil") return } @@ -409,11 +426,7 @@ struct RowOperationsManagerTests { let (manager, changeManager) = makeManager() var tableRows = makeTableRows(rowCount: 2) - let result = manager.addNewRow( - columns: Self.testColumns, - columnDefaults: [:], - tableRows: &tableRows - ) + let result = manager.addNewRow(tableRows: &tableRows) #expect(result != nil) let newIndex = result!.rowIndex diff --git a/TableProTests/Core/Services/SchemaMetadataGeneratedColumnTests.swift b/TableProTests/Core/Services/SchemaMetadataGeneratedColumnTests.swift index eac0aa27c4..44cb579873 100644 --- a/TableProTests/Core/Services/SchemaMetadataGeneratedColumnTests.swift +++ b/TableProTests/Core/Services/SchemaMetadataGeneratedColumnTests.swift @@ -7,6 +7,7 @@ // import Foundation +import TableProPluginKit @testable import TablePro import Testing @@ -49,5 +50,46 @@ struct SchemaMetadataGeneratedColumnTests { func columnInfoDefaultsToNotGenerated() { let column = ColumnInfo(name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true) #expect(!column.isGenerated) + #expect(column.identityKind == nil) + } + + /// PostgreSQL reports an identity column with a null `column_default`, so the kind is the only + /// thing that says the server fills the column in. + @Test("Both identity kinds are carried into the parsed metadata") + func identityKindIsParsed() { + let schema = makeSchema([ + ColumnInfo( + name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true, + identityKind: .always + ), + ColumnInfo( + name: "code", dataType: "INT", isNullable: false, isPrimaryKey: false, + identityKind: .byDefault + ), + ColumnInfo(name: "name", dataType: "TEXT", isNullable: true, isPrimaryKey: false) + ]) + + let parsed = QueryExecutor.parseSchemaMetadata(schema) + + #expect(parsed.columnIdentity == ["id": .always, "code": .byDefault]) + } + + /// A GENERATED ALWAYS identity column rejects an explicit INSERT value and an UPDATE alike, so + /// it belongs in the set the statement generator refuses to write, next to a stored generated + /// column. A GENERATED BY DEFAULT one accepts both and must stay writable. + @Test("Only an ALWAYS identity column joins the non-writable set") + func alwaysIdentityIsNotWritable() { + let schema = makeSchema([ + ColumnInfo( + name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true, + identityKind: .always + ), + ColumnInfo( + name: "code", dataType: "INT", isNullable: false, isPrimaryKey: false, + identityKind: .byDefault + ) + ]) + + #expect(QueryExecutor.parseSchemaMetadata(schema).generatedColumns == ["id"]) } } diff --git a/TableProTests/Core/Utilities/SQL/SQLFileParserTests.swift b/TableProTests/Core/Utilities/SQL/SQLFileParserTests.swift index f15167f183..72fbfd3568 100644 --- a/TableProTests/Core/Utilities/SQL/SQLFileParserTests.swift +++ b/TableProTests/Core/Utilities/SQL/SQLFileParserTests.swift @@ -269,6 +269,7 @@ struct SQLFileParserTests { #expect(SqlDialect.from(databaseTypeId: "AlloyDB") == .postgres) #expect(SqlDialect.from(databaseTypeId: "Citus") == .postgres) #expect(SqlDialect.from(databaseTypeId: "CockroachDB") == .postgres) + #expect(SqlDialect.from(databaseTypeId: "PGlite") == .postgres) #expect(SqlDialect.from(databaseTypeId: "MySQL") == .mysql) #expect(SqlDialect.from(databaseTypeId: "MariaDB") == .mysql) #expect(SqlDialect.from(databaseTypeId: "SQLite") == .sqlite) diff --git a/TableProTests/Models/Query/TableRowsTests.swift b/TableProTests/Models/Query/TableRowsTests.swift index fc41a8a658..68161abb6a 100644 --- a/TableProTests/Models/Query/TableRowsTests.swift +++ b/TableProTests/Models/Query/TableRowsTests.swift @@ -714,3 +714,70 @@ struct TableRowsForeignKeysFetchedTests { #expect(table.foreignKeysFetched) } } + +@Suite("TableRows - server-assigned columns") +struct TableRowsServerAssignedValueTests { + private func table( + columnDefaults: [String: String?] = [:], + columnIdentity: [String: IdentityKind] = [:] + ) -> TableRows { + TableRows.from( + queryRows: [], + columns: ["id", "name"], + columnTypes: [], + columnDefaults: columnDefaults, + columnIdentity: columnIdentity + ) + } + + @Test("A column with a default expression is server-assigned") + func defaultExpression() { + #expect(table(columnDefaults: ["id": "nextval('t_id_seq'::regclass)"]).serverAssignsValue(forColumn: "id")) + } + + /// Measured on PostgreSQL 17: an identity column reports `column_default` as null and + /// `atthasdef` as false, so the default alone answers "no" and the new row carries NULL. + @Test("An identity column is server-assigned even with no default", arguments: [ + IdentityKind.always, IdentityKind.byDefault + ]) + func identityWithoutDefault(kind: IdentityKind) { + #expect(table(columnIdentity: ["id": kind]).serverAssignsValue(forColumn: "id")) + } + + /// PostgreSQL optimises `DEFAULT NULL` away, so the catalog reports it exactly like a column + /// that was never given a default, and the app cannot tell the two apart. + @Test("A column whose recorded default is null is not server-assigned") + func explicitNullDefault() { + #expect(!table(columnDefaults: ["id": nil]).serverAssignsValue(forColumn: "id")) + } + + @Test("An unknown column is not server-assigned") + func unknownColumn() { + #expect(!table().serverAssignsValue(forColumn: "missing")) + } +} + +@Suite("TableRows - non-writable columns") +struct TableRowsGeneratedColumnsTests { + /// `DataChangeManager.configureForTable` clears its own set on every execution, and only a + /// schema fetch refills it. A rerun answered from cache runs no schema fetch, so the rows have + /// to carry the set for the change manager to be restored from. + @Test("The factory carries the non-writable set") + func factoryCarriesGeneratedColumns() { + let table = TableRows.from( + queryRows: [], + columns: ["id", "name"], + columnTypes: [], + generatedColumns: ["id"] + ) + #expect(table.generatedColumns == ["id"]) + } + + @Test("Updating the non-writable set reports a change") + func updateReportsChange() { + var table = TableRows.from(queryRows: [], columns: ["id"], columnTypes: []) + #expect(table.updateDisplayMetadata(generatedColumns: ["id"]) == .columnsReplaced) + #expect(table.generatedColumns == ["id"]) + #expect(table.updateDisplayMetadata(generatedColumns: ["id"]) == .none) + } +} diff --git a/TableProTests/Plugins/MySQLGeneratedColumnClassificationTests.swift b/TableProTests/Plugins/MySQLGeneratedColumnClassificationTests.swift index 6acc415a2d..b65e0d134b 100644 --- a/TableProTests/Plugins/MySQLGeneratedColumnClassificationTests.swift +++ b/TableProTests/Plugins/MySQLGeneratedColumnClassificationTests.swift @@ -3,6 +3,7 @@ // TableProTests // +import TableProPluginKit import Testing @Suite("MySQL Generated Column Classification") @@ -64,3 +65,23 @@ struct MySQLGeneratedColumnClassificationTests { #expect(!mysqlColumnIsGenerated(extra: "WITHOUT SYSTEM VERSIONING")) } } + +@Suite("MySQL Identity Classification") +struct MySQLIdentityClassificationTests { + /// MySQL leaves `COLUMN_DEFAULT` null for an AUTO_INCREMENT column, so `Extra` is the only + /// place the allocation is reported and the app read it as a column with no default. + @Test("auto_increment reports BY DEFAULT") + func autoIncrement() { + #expect(mysqlIdentityKind(extra: "auto_increment") == .byDefault) + #expect(mysqlIdentityKind(extra: "AUTO_INCREMENT") == .byDefault) + #expect(mysqlIdentityKind(extra: "auto_increment, INVISIBLE") == .byDefault) + } + + @Test("Everything else reports no identity") + func notIdentity() { + #expect(mysqlIdentityKind(extra: nil) == nil) + #expect(mysqlIdentityKind(extra: "") == nil) + #expect(mysqlIdentityKind(extra: "DEFAULT_GENERATED on update CURRENT_TIMESTAMP") == nil) + #expect(mysqlIdentityKind(extra: "STORED GENERATED") == nil) + } +} diff --git a/docs/features/change-tracking.mdx b/docs/features/change-tracking.mdx index 5e87e493cf..cec444e22e 100644 --- a/docs/features/change-tracking.mdx +++ b/docs/features/change-tracking.mdx @@ -33,16 +33,18 @@ Double-click a cell, or press `Enter` on it. `Enter` commits the edit to the que Cell editor -Right-click an editable cell and open **Set Value** for the common ones without typing: **Empty**, **NULL** on a nullable column, **Default** on a column that has one, and date functions such as `NOW()` or `CURRENT_DATE` on date columns. +Right-click an editable cell and open **Set Value** for the common ones without typing: **Empty**, **NULL** on a nullable column, **Default** on a column the server fills in, and date functions such as `NOW()` or `CURRENT_DATE` on date columns. ## Adding and deleting rows -- **Add Row** (`Cmd+Shift+N`), the **+** button, or **Edit > Add Row**. The row appears at the bottom, columns with a default pre-filled as `DEFAULT`. Later edits to it fold into the INSERT rather than queueing as updates. -- **Duplicate Row** (`Cmd+Shift+D`) on the right-click menu copies a row and resets its primary key to `DEFAULT`, so the database assigns a new one. +- **Add Row** (`Cmd+Shift+N`), the **+** button, or **Edit > Add Row**. The row appears at the bottom with the cursor in its first writable cell. Later edits fold into the INSERT rather than queueing as updates. +- **Duplicate Row** (`Cmd+Shift+D`) on the right-click menu copies a row and resets its primary key and its identity columns to `DEFAULT`, so the database assigns new ones. - **Delete**: select rows by their row numbers (`Shift`-click for a range, `Cmd`-click for separate rows) and press `Delete`. They stay visible with a strikethrough, and saving asks "Delete 5 rows?" before they go. - **Paste** on the right-click menu inserts copied rows as new rows. - **Fill Column** on the header right-click menu writes one value into every loaded row, skipping primary key columns. +A new row starts at `DEFAULT` in every column the server fills in. That covers a column with a default expression, and an identity or `AUTO_INCREMENT` column, which carries no default of its own. The INSERT leaves those columns out, so the server assigns them. + ## When the grid will not edit A tab opened from the sidebar edits its table directly. A query tab edits only when the app can prove the rows came from exactly one table: @@ -59,7 +61,7 @@ To change rows a join returned, open one of its tables from the sidebar and edit Two more shapes look editable and refuse. A column renamed with `AS` cannot be written back, though the rest of the row still can. A query that renames or omits the primary key blocks the save outright, because nothing is left to identify the row by. -Individual columns stay read-only where the server owns them: generated columns, which are computed on write and left out of every INSERT and UPDATE, SQL export included, and columns the driver marks immutable, such as MongoDB's `_id`. A connection set to the Read-Only [safe mode](/features/safe-mode) level edits nothing at all. +Individual columns stay read-only where the server owns them: generated columns, which are computed on write and left out of every INSERT and UPDATE, SQL export included; PostgreSQL `GENERATED ALWAYS AS IDENTITY` and SQL Server `IDENTITY` columns, which the server refuses an explicit value for; and columns the driver marks immutable, such as MongoDB's `_id`. `GENERATED BY DEFAULT AS IDENTITY` and `AUTO_INCREMENT` columns stay editable: type a value to override the sequence, or leave the cell at `DEFAULT`. A connection set to the Read-Only [safe mode](/features/safe-mode) level edits nothing at all. ## Saving From 625a802c22bd303632507bed515a8ab214c1f3b4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 1 Sep 2026 00:30:51 +0700 Subject: [PATCH 2/2] fix(datagrid): keep the non-writable column set across tab switches and cached reruns --- CHANGELOG.md | 3 + .../ChangeTracking/DataChangeManager.swift | 12 +++- .../SQLStatementGenerator.swift | 21 ++++++- .../QueryExecutionCoordinator+Helpers.swift | 4 +- .../MainContentCoordinator+TabSwitch.swift | 4 +- ...ContentCoordinator+TableRowsMutation.swift | 3 +- .../MainContentView+EventHandlers.swift | 3 +- .../AnyChangeManagerTests.swift | 10 +-- .../DataChangeManagerClickHouseTests.swift | 6 +- .../DataChangeManagerExtendedTests.swift | 14 +++-- .../DataChangeManagerTests.swift | 63 ++++++++++++------- .../SQLStatementGeneratorTests.swift | 21 +++++-- .../RowOperationsManagerBinaryCopyTests.swift | 3 +- .../RowOperationsManagerCopyTests.swift | 3 +- .../RowOperationsManagerPasteTests.swift | 3 +- .../Services/RowOperationsManagerTests.swift | 3 +- .../Core/Services/UndoRowIndexTests.swift | 3 +- ...MainContentCoordinatorTabSwitchTests.swift | 5 +- .../Views/Main/TabCloseProtectionTests.swift | 3 +- 19 files changed, 134 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f5a9575b0..84e77244ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - No "Default" in a cell's Set Value menu for an identity column. (#2588) - Duplicate Row copying an identity column that is not the primary key. (#2588) - Editing a `GENERATED ALWAYS AS IDENTITY` cell, which the server rejects on save. (#2588) +- Generated and identity columns editable again after a tab switch or a refresh that reused cached metadata. +- A new row of nothing but server-assigned columns silently dropped from the save. - `OVERRIDING SYSTEM VALUE` and `setval` in a SQL export of a SQL Server database. +- PGlite treated as a generic SQL dialect, so `$$` bodies split at their inner semicolons. ## [0.69.0] - 2026-08-27 diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 0f2cbad239..715df10de4 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -101,6 +101,7 @@ final class DataChangeManager: ChangeManaging { columns: [String], primaryKeyColumns: [String], databaseType: DatabaseType, + generatedColumns: Set, triggerReload: Bool = true ) { self.tableName = tableName @@ -108,7 +109,7 @@ final class DataChangeManager: ChangeManaging { self.columns = columns self.primaryKeyColumns = primaryKeyColumns self.databaseType = databaseType - self.generatedColumns = [] + self.generatedColumns = generatedColumns pending.clear() undoManagerProvider?()?.removeAllActions(withTarget: self) @@ -491,12 +492,19 @@ final class DataChangeManager: ChangeManaging { pending.snapshot(primaryKeyColumns: primaryKeyColumns, columns: columns) } - func restoreState(from state: TabChangeSnapshot, tableName: String, schemaName: String? = nil, databaseType: DatabaseType) { + func restoreState( + from state: TabChangeSnapshot, + tableName: String, + schemaName: String? = nil, + databaseType: DatabaseType, + generatedColumns: Set + ) { self.tableName = tableName self.schemaName = schemaName self.columns = state.columns self.primaryKeyColumns = state.primaryKeyColumns self.databaseType = databaseType + self.generatedColumns = generatedColumns pending.restore(from: state) self.hasChanges = !pending.isEmpty } diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index b61d242c2a..1939382f3c 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -196,7 +196,7 @@ struct SQLStatementGenerator { } } - guard !nonDefaultColumns.isEmpty else { return nil } + guard !nonDefaultColumns.isEmpty else { return allDefaultsInsertStatement() } let columnList = nonDefaultColumns.joined(separator: ", ") let placeholders = placeholderParts.joined(separator: ", ") @@ -207,6 +207,25 @@ struct SQLStatementGenerator { return ParameterizedStatement(sql: sql, parameters: bindParameters) } + /// A row whose every column the server fills in names no column at all, which is legal SQL and + /// has its own spelling per engine. Returning nothing instead dropped the row from the batch + /// while the rest of the save committed and reported success, so a new row in a table of + /// nothing but an identity column and defaults vanished without a word. + private func allDefaultsInsertStatement() -> ParameterizedStatement? { + switch SqlDialect.from(databaseTypeId: databaseType.rawValue) { + case .postgres, .sqlite: + return ParameterizedStatement( + sql: "INSERT INTO \(qualifiedTableName) DEFAULT VALUES", parameters: [] + ) + case .mysql: + return ParameterizedStatement( + sql: "INSERT INTO \(qualifiedTableName) () VALUES ()", parameters: [] + ) + default: + return nil + } + } + func insertStatement(columns insertColumns: [String], values: [PluginCellValue]) -> ParameterizedStatement? { diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index a79f642659..00c3080208 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -296,9 +296,9 @@ extension QueryExecutionCoordinator { schemaName: parent.tabManager.tabs[idx].tableContext.schemaName, columns: columns, primaryKeyColumns: resolvedPKs, - databaseType: conn.type + databaseType: conn.type, + generatedColumns: generatedColumns ) - parent.changeManager.setGeneratedColumns(generatedColumns) } recordHistory( diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift index 934204e6d9..4f91a15c2f 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TabSwitch.swift @@ -100,7 +100,8 @@ extension MainContentCoordinator { from: pendingState, tableName: newTab.tableContext.tableName ?? "", schemaName: newTab.tableContext.schemaName, - databaseType: connection.type + databaseType: connection.type, + generatedColumns: newRows.generatedColumns ) } else { changeManager.configureForTable( @@ -109,6 +110,7 @@ extension MainContentCoordinator { columns: newRows.columns, primaryKeyColumns: newTab.tableContext.primaryKeyColumns, databaseType: connection.type, + generatedColumns: newRows.generatedColumns, triggerReload: false ) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift index 499ce252de..001a25a152 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+TableRowsMutation.swift @@ -122,7 +122,8 @@ extension MainContentCoordinator { schemaName: tab.tableContext.schemaName, columns: resultSet.resultColumns, primaryKeyColumns: tab.tableContext.primaryKeyColumns, - databaseType: connection.type + databaseType: connection.type, + generatedColumns: tabSessionRegistry.tableRows(for: tab.id).generatedColumns ) } diff --git a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift index 6638bbbeb0..f750ea102a 100644 --- a/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift +++ b/TablePro/Views/Main/Extensions/MainContentView+EventHandlers.swift @@ -94,7 +94,8 @@ extension MainContentView { schemaName: tab.tableContext.schemaName, columns: newColumns, primaryKeyColumns: tab.tableContext.primaryKeyColumns, - databaseType: connection.type + databaseType: connection.type, + generatedColumns: coordinator.tabSessionRegistry.tableRows(for: tab.id).generatedColumns ) } diff --git a/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift b/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift index 6047b44675..1f85196441 100644 --- a/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift +++ b/TableProTests/Core/ChangeTracking/AnyChangeManagerTests.swift @@ -18,7 +18,7 @@ struct AnyChangeManagerTests { @Test("DataChangeManager wrapper: hasChanges forwards correctly") func dataManagerHasChangesForwards() { let dataManager = DataChangeManager() - dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], databaseType: .mysql) + dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], databaseType: .mysql, generatedColumns: []) let wrapper = AnyChangeManager(dataManager) #expect(wrapper.hasChanges == false) @@ -32,7 +32,7 @@ struct AnyChangeManagerTests { @Test("DataChangeManager wrapper: reloadVersion forwards correctly") func dataManagerReloadVersionForwards() { let dataManager = DataChangeManager() - dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], databaseType: .mysql) + dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], databaseType: .mysql, generatedColumns: []) let wrapper = AnyChangeManager(dataManager) let initialVersion = wrapper.reloadVersion @@ -44,7 +44,7 @@ struct AnyChangeManagerTests { @Test("isRowDeleted delegates correctly for DataChangeManager") func isRowDeletedDelegatesCorrectly() { let dataManager = DataChangeManager() - dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], databaseType: .mysql) + dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], databaseType: .mysql, generatedColumns: []) let wrapper = AnyChangeManager(dataManager) #expect(wrapper.isRowDeleted(0) == false) @@ -57,7 +57,7 @@ struct AnyChangeManagerTests { @Test("recordCellChange forwards to DataChangeManager") func recordCellChangeForwards() { let dataManager = DataChangeManager() - dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], databaseType: .mysql) + dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], databaseType: .mysql, generatedColumns: []) let wrapper = AnyChangeManager(dataManager) wrapper.recordCellChange(rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "Alice", newValue: "Bob", originalRow: ["1", "Alice"]) @@ -69,7 +69,7 @@ struct AnyChangeManagerTests { @Test("No retain cycle — wrapper can be deallocated") func noRetainCycleOnWrapper() { let dataManager = DataChangeManager() - dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], databaseType: .mysql) + dataManager.configureForTable(tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], databaseType: .mysql, generatedColumns: []) weak var weakWrapper: AnyChangeManager? diff --git a/TableProTests/Core/ChangeTracking/DataChangeManagerClickHouseTests.swift b/TableProTests/Core/ChangeTracking/DataChangeManagerClickHouseTests.swift index 62bf4746ff..865541fb4a 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeManagerClickHouseTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeManagerClickHouseTests.swift @@ -21,7 +21,8 @@ struct DataChangeManagerClickHouseTests { tableName: "events", columns: ["id", "status"], primaryKeyColumns: ["id"], - databaseType: .clickhouse + databaseType: .clickhouse, + generatedColumns: [] ) manager.recordCellChange( @@ -46,7 +47,8 @@ struct DataChangeManagerClickHouseTests { tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) manager.recordCellChange( diff --git a/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift b/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift index feee062a73..f530a83f6a 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeManagerExtendedTests.swift @@ -26,7 +26,8 @@ struct DataChangeManagerExtendedTests { tableName: "test_table", columns: columns, primaryKeyColumns: [pk].compactMap { $0 }, - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) return manager } @@ -257,7 +258,7 @@ struct DataChangeManagerExtendedTests { let state = manager.saveState() manager.clearChanges() #expect(!manager.hasChanges) - manager.restoreState(from: state, tableName: "test_table", databaseType: .mysql) + manager.restoreState(from: state, tableName: "test_table", databaseType: .mysql, generatedColumns: []) #expect(manager.hasChanges) } @@ -267,7 +268,7 @@ struct DataChangeManagerExtendedTests { manager.recordRowDeletion(rowIndex: 2, originalRow: ["3", "Charlie", "c@test.com"]) let state = manager.saveState() manager.clearChanges() - manager.restoreState(from: state, tableName: "test_table", databaseType: .mysql) + manager.restoreState(from: state, tableName: "test_table", databaseType: .mysql, generatedColumns: []) #expect(manager.isRowDeleted(2)) } @@ -280,7 +281,7 @@ struct DataChangeManagerExtendedTests { ) let state = manager.saveState() manager.clearChanges() - manager.restoreState(from: state, tableName: "test_table", databaseType: .mysql) + manager.restoreState(from: state, tableName: "test_table", databaseType: .mysql, generatedColumns: []) #expect(manager.isCellModified(rowIndex: 0, columnIndex: 1)) } @@ -293,7 +294,7 @@ struct DataChangeManagerExtendedTests { ) let state = manager.saveState() manager.clearChanges() - manager.restoreState(from: state, tableName: "test_table", databaseType: .mysql) + manager.restoreState(from: state, tableName: "test_table", databaseType: .mysql, generatedColumns: []) manager.recordCellChange( rowIndex: 0, columnIndex: 2, columnName: "email", oldValue: "a@test.com", newValue: "b@test.com" @@ -306,7 +307,7 @@ struct DataChangeManagerExtendedTests { func emptyStateRoundTrip() { let manager = makeManager() let state = manager.saveState() - manager.restoreState(from: state, tableName: "test_table", databaseType: .mysql) + manager.restoreState(from: state, tableName: "test_table", databaseType: .mysql, generatedColumns: []) #expect(!manager.hasChanges) #expect(manager.changes.isEmpty) } @@ -715,6 +716,7 @@ struct DataChangeManagerExtendedTests { columns: ["a", "b"], primaryKeyColumns: ["a"], databaseType: .mysql, + generatedColumns: [], triggerReload: false ) #expect(manager.reloadVersion == before) diff --git a/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift b/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift index 56d6e667b0..390ad1fb78 100644 --- a/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift +++ b/TableProTests/Core/ChangeTracking/DataChangeManagerTests.swift @@ -31,7 +31,8 @@ struct DataChangeManagerTests { tableName: "users", columns: ["id", "name", "email"], primaryKeyColumns: ["id"], - databaseType: .postgresql + databaseType: .postgresql, + generatedColumns: [] ) #expect(manager.tableName == "users") @@ -64,7 +65,8 @@ struct DataChangeManagerTests { tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) manager.recordCellChange( @@ -80,7 +82,8 @@ struct DataChangeManagerTests { tableName: "products", columns: ["id", "title"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) #expect(!manager.hasChanges) @@ -106,7 +109,8 @@ struct DataChangeManagerTests { tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) manager.recordCellChange( @@ -127,7 +131,8 @@ struct DataChangeManagerTests { tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) manager.recordCellChange( @@ -154,7 +159,8 @@ struct DataChangeManagerTests { tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) manager.recordCellChange( @@ -176,7 +182,8 @@ struct DataChangeManagerTests { tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) manager.recordCellChange( @@ -208,7 +215,8 @@ struct DataChangeManagerTests { tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) manager.recordCellChange( @@ -239,7 +247,8 @@ struct DataChangeManagerTests { tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) manager.recordCellChange( @@ -272,7 +281,8 @@ struct DataChangeManagerTests { tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) manager.recordRowDeletion(rowIndex: 0, originalRow: ["1", "Alice"]) @@ -287,7 +297,8 @@ struct DataChangeManagerTests { tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) manager.recordCellChange( @@ -314,7 +325,8 @@ struct DataChangeManagerTests { tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) manager.recordRowDeletion(rowIndex: 2, originalRow: ["3", "Charlie"]) @@ -332,7 +344,8 @@ struct DataChangeManagerTests { tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) let rows: [(rowIndex: Int, originalRow: [PluginCellValue])] = [ @@ -357,7 +370,8 @@ struct DataChangeManagerTests { tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) manager.recordCellChange( @@ -383,7 +397,8 @@ struct DataChangeManagerTests { tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) manager.recordCellChange( @@ -409,7 +424,8 @@ struct DataChangeManagerTests { tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) manager.recordCellChange( @@ -430,7 +446,8 @@ struct DataChangeManagerTests { tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) manager.recordCellChange( @@ -455,7 +472,8 @@ struct DataChangeManagerTests { tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) manager.recordCellChange( @@ -478,7 +496,8 @@ struct DataChangeManagerTests { tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) manager.recordCellChange( @@ -525,7 +544,8 @@ struct DataChangeManagerTests { tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) let initialVersion = manager.reloadVersion @@ -552,7 +572,8 @@ struct DataChangeManagerTests { tableName: "users", columns: ["id", "name"], primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) manager.recordCellChange( diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift index b73e4ee56b..af9b232530 100644 --- a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorTests.swift @@ -115,9 +115,19 @@ struct SQLStatementGeneratorTests { #expect(stmt.parameters.count == 2) } - @Test("Insert with all __DEFAULT__ returns empty") - func testInsertAllDefaultReturnsEmpty() throws { - let generator = try makeGenerator() + /// This used to expect no statement at all, which dropped the row from the batch while the rest + /// of the save committed and reported success. A table of nothing but an identity column and + /// defaults is exactly the shape that produces it. + @Test( + "Insert with all __DEFAULT__ names no column", + arguments: [ + (DatabaseType.mysql, "() VALUES ()"), + (DatabaseType.postgresql, "DEFAULT VALUES"), + (DatabaseType.sqlite, "DEFAULT VALUES"), + ] + ) + func testInsertAllDefaultNamesNoColumn(databaseType: DatabaseType, expected: String) throws { + let generator = try makeGenerator(databaseType: databaseType) let insertedRowData: [Int: [PluginCellValue]] = [ 0: ["__DEFAULT__", "__DEFAULT__", "__DEFAULT__"] ] @@ -132,7 +142,10 @@ struct SQLStatementGeneratorTests { insertedRowIndices: [0] ) - #expect(statements.isEmpty) + #expect(statements.count == 1) + #expect(statements.first?.sql.hasPrefix("INSERT INTO ") == true) + #expect(statements.first?.sql.hasSuffix(expected) == true) + #expect(statements.first?.parameters.isEmpty == true) } @Test("Insert from cellChanges fallback") diff --git a/TableProTests/Core/Services/RowOperationsManagerBinaryCopyTests.swift b/TableProTests/Core/Services/RowOperationsManagerBinaryCopyTests.swift index c14fa49d73..2f93027480 100644 --- a/TableProTests/Core/Services/RowOperationsManagerBinaryCopyTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerBinaryCopyTests.swift @@ -18,7 +18,8 @@ struct RowOperationsManagerBinaryCopyTests { tableName: "documents", columns: ["id", "payload"], primaryKeyColumns: ["id"], - databaseType: .postgresql + databaseType: .postgresql, + generatedColumns: [] ) let rowOps = RowOperationsManager(changeManager: changeManager) let tableRows = TableRows.from( diff --git a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift index 2203a1a9db..75bce38eb7 100644 --- a/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerCopyTests.swift @@ -45,7 +45,8 @@ struct RowOperationsManagerCopyTests { tableName: "users", columns: Self.defaultColumns, primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) let manager = RowOperationsManager(changeManager: changeManager) return (manager, changeManager) diff --git a/TableProTests/Core/Services/RowOperationsManagerPasteTests.swift b/TableProTests/Core/Services/RowOperationsManagerPasteTests.swift index d0243091dd..00f0f1c1de 100644 --- a/TableProTests/Core/Services/RowOperationsManagerPasteTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerPasteTests.swift @@ -27,7 +27,8 @@ struct RowOperationsManagerPasteTests { tableName: "users", columns: Self.columns, primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) return RowOperationsManager(changeManager: changeManager) } diff --git a/TableProTests/Core/Services/RowOperationsManagerTests.swift b/TableProTests/Core/Services/RowOperationsManagerTests.swift index 506dbfc1ba..dd4854fff3 100644 --- a/TableProTests/Core/Services/RowOperationsManagerTests.swift +++ b/TableProTests/Core/Services/RowOperationsManagerTests.swift @@ -18,7 +18,8 @@ struct RowOperationsManagerTests { tableName: "users", columns: Self.testColumns, primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) let manager = RowOperationsManager(changeManager: changeManager) return (manager, changeManager) diff --git a/TableProTests/Core/Services/UndoRowIndexTests.swift b/TableProTests/Core/Services/UndoRowIndexTests.swift index 2aad3ea2ff..a0e972eef0 100644 --- a/TableProTests/Core/Services/UndoRowIndexTests.swift +++ b/TableProTests/Core/Services/UndoRowIndexTests.swift @@ -36,7 +36,8 @@ struct UndoRowIndexTests { tableName: "users", columns: Self.columns, primaryKeyColumns: ["id"], - databaseType: .sqlite + databaseType: .sqlite, + generatedColumns: [] ) return RowOperationsManager(changeManager: changeManager) } diff --git a/TableProTests/Views/Main/MainContentCoordinatorTabSwitchTests.swift b/TableProTests/Views/Main/MainContentCoordinatorTabSwitchTests.swift index deb592253d..9bf14708c5 100644 --- a/TableProTests/Views/Main/MainContentCoordinatorTabSwitchTests.swift +++ b/TableProTests/Views/Main/MainContentCoordinatorTabSwitchTests.swift @@ -346,6 +346,7 @@ struct MainContentCoordinatorTabSwitchTests { columns: ["id", "name"], primaryKeyColumns: ["id"], databaseType: .mysql, + generatedColumns: [], triggerReload: false ) coordinator.changeManager.recordCellChange( @@ -524,6 +525,7 @@ struct MainContentCoordinatorTabSwitchTests { columns: ["id", "total"], primaryKeyColumns: ["id"], databaseType: .mysql, + generatedColumns: [], triggerReload: false ) coordinator.changeManager.recordCellChange( @@ -846,6 +848,7 @@ struct MainContentCoordinatorTabSwitchTests { columns: ["id", "name"], primaryKeyColumns: ["id"], databaseType: .mysql, + generatedColumns: [], triggerReload: false ) manager.recordCellChange( @@ -861,7 +864,7 @@ struct MainContentCoordinatorTabSwitchTests { let fresh = DataChangeManager() #expect(fresh.hasChanges == false) - fresh.restoreState(from: snapshot, tableName: "users", databaseType: .postgresql) + fresh.restoreState(from: snapshot, tableName: "users", databaseType: .postgresql, generatedColumns: []) #expect(fresh.hasChanges == true) #expect(fresh.tableName == "users") diff --git a/TableProTests/Views/Main/TabCloseProtectionTests.swift b/TableProTests/Views/Main/TabCloseProtectionTests.swift index 45ed1c8dc3..ad72a7f3f9 100644 --- a/TableProTests/Views/Main/TabCloseProtectionTests.swift +++ b/TableProTests/Views/Main/TabCloseProtectionTests.swift @@ -30,7 +30,8 @@ struct TabCloseProtectionTests { tableName: table, columns: Self.columns, primaryKeyColumns: ["id"], - databaseType: .mysql + databaseType: .mysql, + generatedColumns: [] ) coordinator.changeManager.recordRowDeletion(rowIndex: 0, originalRow: Self.originalRow) }