From fc7d9c919c88371cfccfe04126284de29ba9a4ac Mon Sep 17 00:00:00 2001 From: MDeev <42983889+MDeev@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:24:17 +0100 Subject: [PATCH 1/2] fix(mysql): report generated columns so SQL export excludes them --- CHANGELOG.md | 1 + .../MySQLGeneratedColumnClassification.swift | 14 +++++++ .../MySQLDriverPlugin/MySQLPluginDriver.swift | 2 + TablePro.xcodeproj/project.pbxproj | 1 + ...QLGeneratedColumnClassificationTests.swift | 39 +++++++++++++++++++ 5 files changed, 57 insertions(+) create mode 100644 Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift create mode 100644 TableProTests/Plugins/MySQLGeneratedColumnClassificationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c61ec2e1d..dc89b8809 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Refreshing a table no longer fails with "Query cancelled" on every second click. (#2021) - Stopping a query no longer shows a red error or records the query as failed in history. - Stopping a MySQL, MariaDB, or Redis query no longer cancels the next one you run. +- SQL export of a MySQL or MariaDB table no longer writes generated columns into the INSERT statements, so the dump imports cleanly instead of failing on the generated column. (#2023) ## [0.63.0] - 2026-08-05 diff --git a/Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift b/Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift new file mode 100644 index 000000000..1088a8424 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift @@ -0,0 +1,14 @@ +// +// MySQLGeneratedColumnClassification.swift +// MySQLDriverPlugin +// + +/// MySQL reports generated columns via the `Extra` column of `SHOW FULL COLUMNS` +/// (and `INFORMATION_SCHEMA.COLUMNS.EXTRA`) as "STORED GENERATED" or "VIRTUAL GENERATED". +/// Plain "DEFAULT_GENERATED" (expression defaults, MySQL 8+) is not a generated column +/// and must not match. +internal func mysqlColumnIsGenerated(extra: String?) -> Bool { + guard let extra else { return false } + let upper = extra.uppercased() + return upper.contains("STORED GENERATED") || upper.contains("VIRTUAL GENERATED") +} diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index bc3d8a078..75c284ae0 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -260,6 +260,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { charset: charset, collation: collation == "NULL" ? nil : collation, comment: comment?.isEmpty == false ? comment : nil, + isGenerated: mysqlColumnIsGenerated(extra: extra), allowedValues: allowedValues ) } @@ -313,6 +314,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { charset: charset, collation: collation == "NULL" ? nil : collation, comment: comment?.isEmpty == false ? comment : nil, + isGenerated: mysqlColumnIsGenerated(extra: extra), allowedValues: allowedValues ) diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index bf34d8cdf..0442b0389 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -538,6 +538,7 @@ isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( MySQLColumnDefinitionSQL.swift, + MySQLGeneratedColumnClassification.swift, MySQLQueryTimeoutStatement.swift, MySQLSocketTimeout.swift, MySQLStatementClassification.swift, diff --git a/TableProTests/Plugins/MySQLGeneratedColumnClassificationTests.swift b/TableProTests/Plugins/MySQLGeneratedColumnClassificationTests.swift new file mode 100644 index 000000000..91e3eb056 --- /dev/null +++ b/TableProTests/Plugins/MySQLGeneratedColumnClassificationTests.swift @@ -0,0 +1,39 @@ +// +// MySQLGeneratedColumnClassificationTests.swift +// TableProTests +// + +import Testing + +@Suite("MySQL Generated Column Classification") +struct MySQLGeneratedColumnClassificationTests { + @Test("STORED GENERATED is generated") + func storedGenerated() { + #expect(mysqlColumnIsGenerated(extra: "STORED GENERATED")) + } + + @Test("VIRTUAL GENERATED is generated") + func virtualGenerated() { + #expect(mysqlColumnIsGenerated(extra: "VIRTUAL GENERATED")) + } + + @Test("Lowercase variants are handled") + func lowercaseVariants() { + #expect(mysqlColumnIsGenerated(extra: "stored generated")) + #expect(mysqlColumnIsGenerated(extra: "virtual generated")) + } + + @Test("DEFAULT_GENERATED expression defaults are not generated columns") + func defaultGenerated() { + #expect(!mysqlColumnIsGenerated(extra: "DEFAULT_GENERATED")) + #expect(!mysqlColumnIsGenerated(extra: "DEFAULT_GENERATED on update CURRENT_TIMESTAMP")) + } + + @Test("Other Extra values are not generated columns") + func otherExtraValues() { + #expect(!mysqlColumnIsGenerated(extra: "auto_increment")) + #expect(!mysqlColumnIsGenerated(extra: "on update CURRENT_TIMESTAMP")) + #expect(!mysqlColumnIsGenerated(extra: "")) + #expect(!mysqlColumnIsGenerated(extra: nil)) + } +} From 1efd5439bc7184f2a64b539d68b65d2b42dff6d2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 5 Aug 2026 19:52:33 +0700 Subject: [PATCH 2/2] fix(datagrid): keep generated columns out of every INSERT and UPDATE (#2023) --- CHANGELOG.md | 4 +- ...ckHouseGeneratedColumnClassification.swift | 16 ++ .../ClickHousePluginDriver+Schema.swift | 2 + .../MySQLGeneratedColumnClassification.swift | 24 ++- TablePro.xcodeproj/project.pbxproj | 1 + .../ChangeTracking/AnyChangeManager.swift | 8 + .../ChangeTracking/DataChangeManager.swift | 9 + .../SQLStatementGenerator.swift | 15 +- .../QueryExecutionCoordinator+Helpers.swift | 4 + .../Core/Plugins/PluginDriverAdapter.swift | 1 + .../Core/Services/Query/QueryExecutor.swift | 3 + .../Services/Query/RowOperationsManager.swift | 11 +- TablePro/Models/Query/QueryResult.swift | 3 + .../Extensions/DataGridView+Editing.swift | 5 +- ...atementGeneratorGeneratedColumnTests.swift | 163 ++++++++++++++++++ .../SchemaMetadataGeneratedColumnTests.swift | 53 ++++++ ...seGeneratedColumnClassificationTests.swift | 36 ++++ ...QLGeneratedColumnClassificationTests.swift | 27 +++ docs/features/change-tracking.mdx | 4 + 19 files changed, 376 insertions(+), 13 deletions(-) create mode 100644 Plugins/ClickHouseDriverPlugin/ClickHouseGeneratedColumnClassification.swift create mode 100644 TableProTests/Core/ChangeTracking/SQLStatementGeneratorGeneratedColumnTests.swift create mode 100644 TableProTests/Core/Services/SchemaMetadataGeneratedColumnTests.swift create mode 100644 TableProTests/Plugins/ClickHouseGeneratedColumnClassificationTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index dc89b8809..9a3f40377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Refreshing a table no longer fails with "Query cancelled" on every second click. (#2021) - Stopping a query no longer shows a red error or records the query as failed in history. - Stopping a MySQL, MariaDB, or Redis query no longer cancels the next one you run. -- SQL export of a MySQL or MariaDB table no longer writes generated columns into the INSERT statements, so the dump imports cleanly instead of failing on the generated column. (#2023) +- SQL export no longer writes generated columns into the INSERT statements, so the dump imports cleanly. Covers MySQL, MariaDB, and ClickHouse. (#2023) +- Adding or duplicating a row on a table with a generated column now saves. (#2023) +- Generated columns are now read-only in the data grid. (#2023) ## [0.63.0] - 2026-08-05 diff --git a/Plugins/ClickHouseDriverPlugin/ClickHouseGeneratedColumnClassification.swift b/Plugins/ClickHouseDriverPlugin/ClickHouseGeneratedColumnClassification.swift new file mode 100644 index 000000000..4b318af6e --- /dev/null +++ b/Plugins/ClickHouseDriverPlugin/ClickHouseGeneratedColumnClassification.swift @@ -0,0 +1,16 @@ +// +// ClickHouseGeneratedColumnClassification.swift +// ClickHouseDriverPlugin +// + +import Foundation + +/// ClickHouse reports how a column gets its value in `system.columns.default_kind`. +/// MATERIALIZED and ALIAS columns are computed by the server and reject a written +/// value, so they must stay out of any INSERT. DEFAULT columns take a default when +/// omitted but remain insertable, and an ordinary column reports an empty kind. +internal func clickhouseColumnIsGenerated(defaultKind: String?) -> Bool { + guard let defaultKind else { return false } + let kind = defaultKind.trimmingCharacters(in: .whitespaces).uppercased() + return kind == "MATERIALIZED" || kind == "ALIAS" +} diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Schema.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Schema.swift index 7dbd7bc4d..8987c0395 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Schema.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Schema.swift @@ -72,6 +72,7 @@ extension ClickHousePluginDriver { defaultValue: defaultValue, extra: extra, comment: (comment?.isEmpty == false) ? comment : nil, + isGenerated: clickhouseColumnIsGenerated(defaultKind: defaultKind), allowedValues: EnumValueParser.parseClickHouseEnum(from: ClickHousePluginDriver.unwrapTypeWrappers(dataType)) ) } @@ -133,6 +134,7 @@ extension ClickHousePluginDriver { defaultValue: defaultValue, extra: extra, comment: (comment?.isEmpty == false) ? comment : nil, + isGenerated: clickhouseColumnIsGenerated(defaultKind: defaultKind), allowedValues: EnumValueParser.parseClickHouseEnum(from: ClickHousePluginDriver.unwrapTypeWrappers(dataType)) ) columnsByTable[tableName, default: []].append(colInfo) diff --git a/Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift b/Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift index 1088a8424..af3053afd 100644 --- a/Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift +++ b/Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift @@ -3,12 +3,26 @@ // MySQLDriverPlugin // -/// MySQL reports generated columns via the `Extra` column of `SHOW FULL COLUMNS` -/// (and `INFORMATION_SCHEMA.COLUMNS.EXTRA`) as "STORED GENERATED" or "VIRTUAL GENERATED". -/// Plain "DEFAULT_GENERATED" (expression defaults, MySQL 8+) is not a generated column -/// and must not match. +import Foundation + +/// MySQL and MariaDB report generated columns through the `Extra` column of +/// `SHOW FULL COLUMNS` and `INFORMATION_SCHEMA.COLUMNS.EXTRA`. +/// +/// MySQL, and MariaDB from 10.2, report "STORED GENERATED" or "VIRTUAL +/// GENERATED" and may append further attributes: MySQL separates them with a +/// space ("STORED GENERATED INVISIBLE"), MariaDB with a comma ("STORED +/// GENERATED, INVISIBLE"), so the marker is matched as a substring. MariaDB +/// 10.1 and older instead report the whole value as bare "VIRTUAL" or +/// "PERSISTENT", and never combine it with another attribute. +/// +/// "DEFAULT_GENERATED" is a MySQL 8 expression default, not a generated column, +/// and stays insertable. internal func mysqlColumnIsGenerated(extra: String?) -> Bool { guard let extra else { return false } let upper = extra.uppercased() - return upper.contains("STORED GENERATED") || upper.contains("VIRTUAL GENERATED") + if upper.contains("STORED GENERATED") || upper.contains("VIRTUAL GENERATED") { + return true + } + let trimmed = upper.trimmingCharacters(in: .whitespaces) + return trimmed == "VIRTUAL" || trimmed == "PERSISTENT" } diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 0442b0389..1424b5378 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -516,6 +516,7 @@ membershipExceptions = ( ClickHouseCapabilities.swift, ClickHouseCredentials.swift, + ClickHouseGeneratedColumnClassification.swift, ClickHouseTableOperations.swift, ); target = 5ABCC5A62F43856700EAF3FC /* TableProTests */; diff --git a/TablePro/Core/ChangeTracking/AnyChangeManager.swift b/TablePro/Core/ChangeTracking/AnyChangeManager.swift index 67a7c12bc..9ca4f262a 100644 --- a/TablePro/Core/ChangeTracking/AnyChangeManager.swift +++ b/TablePro/Core/ChangeTracking/AnyChangeManager.swift @@ -9,6 +9,7 @@ protocol ChangeManaging: AnyObject { var canRedo: Bool { get } var rowChanges: [RowChange] { get } var insertedRowIndices: Set { get } + var generatedColumns: Set { get } func isRowDeleted(_ rowIndex: Int) -> Bool func recordCellChange( rowIndex: Int, @@ -22,6 +23,12 @@ protocol ChangeManaging: AnyObject { func undoRowInsertion(rowIndex: Int) } +/// Only the data grid tracks server-computed columns; the structure and +/// inspector grids edit schema definitions, where the concept does not apply. +extension ChangeManaging { + var generatedColumns: Set { [] } +} + @Observable @MainActor final class AnyChangeManager { @@ -32,6 +39,7 @@ final class AnyChangeManager { var canRedo: Bool { wrapped.canRedo } var rowChanges: [RowChange] { wrapped.rowChanges } var insertedRowIndices: Set { wrapped.insertedRowIndices } + var generatedColumns: Set { wrapped.generatedColumns } func isRowDeleted(_ rowIndex: Int) -> Bool { wrapped.isRowDeleted(rowIndex) diff --git a/TablePro/Core/ChangeTracking/DataChangeManager.swift b/TablePro/Core/ChangeTracking/DataChangeManager.swift index 52ad92c35..fb6240ef5 100644 --- a/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -55,6 +55,9 @@ final class DataChangeManager: ChangeManaging { var primaryKeyColumns: [String] = [] /// First PK column, for contexts that need a single column (paste, filters) var primaryKeyColumn: String? { primaryKeyColumns.first } + /// Columns the server computes. They reject any written value, so they are + /// never editable and never appear in a generated INSERT or UPDATE. + var generatedColumns: Set = [] var databaseType: DatabaseType? var pluginDriver: (any PluginDatabaseDriver)? @@ -105,6 +108,7 @@ final class DataChangeManager: ChangeManaging { self.columns = columns self.primaryKeyColumns = primaryKeyColumns self.databaseType = databaseType + self.generatedColumns = [] pending.clear() undoManagerProvider?()?.removeAllActions(withTarget: self) @@ -119,6 +123,10 @@ final class DataChangeManager: ChangeManaging { self.primaryKeyColumns = primaryKeyColumns } + func setGeneratedColumns(_ generatedColumns: Set) { + self.generatedColumns = generatedColumns + } + // MARK: - Change Tracking func recordCellChange( @@ -433,6 +441,7 @@ final class DataChangeManager: ChangeManaging { columns: columns, primaryKeyColumns: primaryKeyColumns, databaseType: databaseType, + generatedColumns: generatedColumns, dialect: PluginManager.shared.sqlDialect(for: databaseType), quoteIdentifier: pluginDriver?.quoteIdentifier ) diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index fc208d518..1d3ed9143 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -23,6 +23,9 @@ struct SQLStatementGenerator { let tableName: String let columns: [String] let primaryKeyColumns: [String] + /// Server-computed columns. They reject any written value, so they are + /// dropped from every INSERT and UPDATE this generator produces. + let generatedColumns: Set let databaseType: DatabaseType let parameterStyle: ParameterStyle private let quoteIdentifierFn: (String) -> String @@ -32,6 +35,7 @@ struct SQLStatementGenerator { columns: [String], primaryKeyColumns: [String], databaseType: DatabaseType, + generatedColumns: Set = [], parameterStyle: ParameterStyle? = nil, dialect: SQLDialectDescriptor? = nil, quoteIdentifier: ((String) -> String)? = nil @@ -39,6 +43,7 @@ struct SQLStatementGenerator { self.tableName = tableName self.columns = columns self.primaryKeyColumns = primaryKeyColumns + self.generatedColumns = generatedColumns self.databaseType = databaseType self.parameterStyle = parameterStyle ?? Self.defaultParameterStyle(for: databaseType) if let quoteIdentifier { @@ -143,6 +148,7 @@ struct SQLStatementGenerator { guard index < columns.count else { continue } let columnName = columns[index] + guard !generatedColumns.contains(columnName) else { continue } nonDefaultColumns.append(quoteIdentifierFn(columnName)) @@ -222,7 +228,9 @@ struct SQLStatementGenerator { { guard !change.cellChanges.isEmpty else { return nil } - let nonDefaultChanges = change.cellChanges.filter { $0.newValue != .text("__DEFAULT__") } + let nonDefaultChanges = change.cellChanges.filter { + $0.newValue != .text("__DEFAULT__") && !generatedColumns.contains($0.columnName) + } guard !nonDefaultChanges.isEmpty else { return nil } @@ -252,8 +260,11 @@ struct SQLStatementGenerator { func generateUpdateSQL(for change: RowChange) -> ParameterizedStatement? { guard !change.cellChanges.isEmpty else { return nil } + let writableChanges = change.cellChanges.filter { !generatedColumns.contains($0.columnName) } + guard !writableChanges.isEmpty else { return nil } + var parameters: [Any?] = [] - let setClauses = change.cellChanges.map { cellChange -> String in + let setClauses = writableChanges.map { cellChange -> String in switch cellChange.newValue { case .text(let s) where s == "__DEFAULT__": return "\(quoteIdentifierFn(cellChange.columnName)) = DEFAULT" diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index 11f66928e..fbb70a214 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -373,6 +373,10 @@ extension QueryExecutionCoordinator { parent.changeManager.setPrimaryKeyColumns(parsed.primaryKeyColumns) } + if parent.tabManager.selectedTabId == tabId { + parent.changeManager.setGeneratedColumns(parsed.generatedColumns) + } + let refreshed = isActiveTab(tabId) if refreshed { parent.dataTabDelegate?.tableViewCoordinator?.refreshForeignKeyColumns() diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 7feffeee0..15a8d54dd 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -236,6 +236,7 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { charset: col.charset, collation: col.collation, comment: col.comment, + isGenerated: col.isGenerated, allowedValues: col.allowedValues ) } diff --git a/TablePro/Core/Services/Query/QueryExecutor.swift b/TablePro/Core/Services/Query/QueryExecutor.swift index b739328e0..014c27c36 100644 --- a/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/TablePro/Core/Services/Query/QueryExecutor.swift @@ -26,6 +26,7 @@ struct ParsedSchemaMetadata { let columnForeignKeys: [String: ForeignKeyInfo]? let columnNullable: [String: Bool] let primaryKeyColumns: [String] + let generatedColumns: Set let approximateRowCount: Int? let columnEnumValues: [String: [String]] let columnComments: [String: String] @@ -186,6 +187,7 @@ final class QueryExecutor { columnForeignKeys: fks, columnNullable: nullable, primaryKeyColumns: schema.columns.filter { $0.isPrimaryKey }.map(\.name), + generatedColumns: Set(schema.columns.filter(\.isGenerated).map(\.name)), approximateRowCount: schema.approximateRowCount, columnEnumValues: enumValues, columnComments: comments @@ -207,6 +209,7 @@ final class QueryExecutor { columnForeignKeys: nil, columnNullable: nullable, primaryKeyColumns: primaryKeys, + generatedColumns: [], approximateRowCount: nil, columnEnumValues: [:], columnComments: [:] diff --git a/TablePro/Core/Services/Query/RowOperationsManager.swift b/TablePro/Core/Services/Query/RowOperationsManager.swift index cc694fa6f..e02b86a2a 100644 --- a/TablePro/Core/Services/Query/RowOperationsManager.swift +++ b/TablePro/Core/Services/Query/RowOperationsManager.swift @@ -52,9 +52,12 @@ final class RowOperationsManager { columnDefaults: [String: String?], tableRows: inout TableRows ) -> AddNewRowResult? { + let generated = changeManager.generatedColumns var newRowValues: [PluginCellValue] = [] for column in columns { - if let defaultValue = columnDefaults[column], defaultValue != nil { + if generated.contains(column) { + newRowValues.append(.text("__DEFAULT__")) + } else if let defaultValue = columnDefaults[column], defaultValue != nil { newRowValues.append(.text("__DEFAULT__")) } else { newRowValues.append(.null) @@ -78,9 +81,9 @@ final class RowOperationsManager { var newValues = Array(tableRows.rows[sourceRowIndex].values) - for pkColumn in changeManager.primaryKeyColumns { - if let pkIndex = columns.firstIndex(of: pkColumn), pkIndex < newValues.count { - newValues[pkIndex] = .text("__DEFAULT__") + for resetColumn in changeManager.primaryKeyColumns + Array(changeManager.generatedColumns) { + if let index = 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 6f9125227..80d2f1257 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -133,6 +133,7 @@ struct ColumnInfo: Identifiable, Hashable { let charset: String? let collation: String? let comment: String? + let isGenerated: Bool let allowedValues: [String]? init( @@ -145,6 +146,7 @@ struct ColumnInfo: Identifiable, Hashable { charset: String? = nil, collation: String? = nil, comment: String? = nil, + isGenerated: Bool = false, allowedValues: [String]? = nil ) { self.name = name @@ -156,6 +158,7 @@ struct ColumnInfo: Identifiable, Hashable { self.charset = charset self.collation = collation self.comment = comment + self.isGenerated = isGenerated self.allowedValues = allowedValues } } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index deda02317..01ea1d80b 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -19,8 +19,11 @@ 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(tableRows.columns[columnIndex]) { return .blocked } + if immutable.contains(columnName) { return .blocked } if columnIndex < tableRows.columnTypes.count { let ct = tableRows.columnTypes[columnIndex] diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorGeneratedColumnTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorGeneratedColumnTests.swift new file mode 100644 index 000000000..cd4152e3c --- /dev/null +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorGeneratedColumnTests.swift @@ -0,0 +1,163 @@ +// +// SQLStatementGeneratorGeneratedColumnTests.swift +// TableProTests +// +// A server-computed column rejects any written value, so it must never reach +// an INSERT or an UPDATE no matter which path builds the statement. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@Suite("SQL Statement Generator generated columns") +struct SQLStatementGeneratorGeneratedColumnTests { + private func makeGenerator( + generatedColumns: Set = ["full_name"] + ) throws -> SQLStatementGenerator { + try SQLStatementGenerator( + tableName: "users", + columns: ["id", "name", "full_name"], + primaryKeyColumns: ["id"], + databaseType: .mysql, + generatedColumns: generatedColumns, + dialect: nil + ) + } + + @Test("Insert from stored row data omits a generated column") + func insertFromStoredDataOmitsGeneratedColumn() throws { + let generator = try makeGenerator() + let changes: [RowChange] = [ + RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + ] + + let statements = generator.generateStatements( + from: changes, + insertedRowData: [0: ["1", "John", "John Doe"]], + deletedRowIndices: [], + insertedRowIndices: [0] + ) + + let statement = try #require(statements.first) + #expect(!statement.sql.contains("full_name")) + #expect(statement.sql.contains("`name`")) + #expect(statement.parameters.count == 2) + } + + @Test("Insert from cell changes omits a generated column") + func insertFromCellChangesOmitsGeneratedColumn() throws { + let generator = try makeGenerator() + let changes: [RowChange] = [ + RowChange( + rowIndex: 0, + type: .insert, + cellChanges: [ + CellChange(rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: .null, newValue: "John"), + CellChange( + rowIndex: 0, + columnIndex: 2, + columnName: "full_name", + oldValue: .null, + newValue: "John Doe" + ) + ], + originalRow: nil + ) + ] + + let statements = generator.generateStatements( + from: changes, + insertedRowData: [:], + deletedRowIndices: [], + insertedRowIndices: [0] + ) + + let statement = try #require(statements.first) + #expect(!statement.sql.contains("full_name")) + #expect(statement.sql.contains("`name`")) + } + + @Test("Update drops a generated column from the SET clause") + func updateDropsGeneratedColumn() throws { + let generator = try makeGenerator() + let changes: [RowChange] = [ + RowChange( + rowIndex: 0, + type: .update, + cellChanges: [ + CellChange(rowIndex: 0, columnIndex: 1, columnName: "name", oldValue: "John", newValue: "Johnny"), + CellChange( + rowIndex: 0, + columnIndex: 2, + columnName: "full_name", + oldValue: "John Doe", + newValue: "Johnny Doe" + ) + ], + originalRow: ["1", "John", "John Doe"] + ) + ] + + let statements = generator.generateStatements( + from: changes, + insertedRowData: [:], + deletedRowIndices: [], + insertedRowIndices: [] + ) + + let statement = try #require(statements.first) + #expect(!statement.sql.contains("full_name")) + #expect(statement.sql.contains("`name` =")) + } + + @Test("An update touching only a generated column produces no statement") + func updateOfOnlyGeneratedColumnProducesNothing() throws { + let generator = try makeGenerator() + let changes: [RowChange] = [ + RowChange( + rowIndex: 0, + type: .update, + cellChanges: [ + CellChange( + rowIndex: 0, + columnIndex: 2, + columnName: "full_name", + oldValue: "John Doe", + newValue: "Johnny Doe" + ) + ], + originalRow: ["1", "John", "John Doe"] + ) + ] + + let statements = generator.generateStatements( + from: changes, + insertedRowData: [:], + deletedRowIndices: [], + insertedRowIndices: [] + ) + + #expect(statements.isEmpty) + } + + @Test("A table with no generated columns is unaffected") + func tableWithoutGeneratedColumnsIsUnaffected() throws { + let generator = try makeGenerator(generatedColumns: []) + let changes: [RowChange] = [ + RowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + ] + + let statements = generator.generateStatements( + from: changes, + insertedRowData: [0: ["1", "John", "John Doe"]], + deletedRowIndices: [], + insertedRowIndices: [0] + ) + + let statement = try #require(statements.first) + #expect(statement.sql.contains("full_name")) + #expect(statement.parameters.count == 3) + } +} diff --git a/TableProTests/Core/Services/SchemaMetadataGeneratedColumnTests.swift b/TableProTests/Core/Services/SchemaMetadataGeneratedColumnTests.swift new file mode 100644 index 000000000..eac0aa27c --- /dev/null +++ b/TableProTests/Core/Services/SchemaMetadataGeneratedColumnTests.swift @@ -0,0 +1,53 @@ +// +// SchemaMetadataGeneratedColumnTests.swift +// TableProTests +// +// The generated-column flag has to survive the driver-to-app schema parse, or +// the grid and the statement generator never learn a column is computed. +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor @Suite("Schema metadata generated columns") +struct SchemaMetadataGeneratedColumnTests { + private func makeSchema(_ columns: [ColumnInfo]) -> FetchedTableSchema { + FetchedTableSchema(columns: columns, foreignKeys: nil, approximateRowCount: nil) + } + + @Test("A generated column is carried into the parsed metadata") + func generatedColumnIsParsed() { + let schema = makeSchema([ + ColumnInfo(name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true), + ColumnInfo(name: "name", dataType: "TEXT", isNullable: true, isPrimaryKey: false), + ColumnInfo( + name: "full_name", + dataType: "TEXT", + isNullable: true, + isPrimaryKey: false, + isGenerated: true + ) + ]) + + let parsed = QueryExecutor.parseSchemaMetadata(schema) + + #expect(parsed.generatedColumns == ["full_name"]) + #expect(parsed.primaryKeyColumns == ["id"]) + } + + @Test("A table with no generated columns parses to an empty set") + func noGeneratedColumns() { + let schema = makeSchema([ + ColumnInfo(name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true) + ]) + + #expect(QueryExecutor.parseSchemaMetadata(schema).generatedColumns.isEmpty) + } + + @Test("ColumnInfo defaults to not generated") + func columnInfoDefaultsToNotGenerated() { + let column = ColumnInfo(name: "id", dataType: "INT", isNullable: false, isPrimaryKey: true) + #expect(!column.isGenerated) + } +} diff --git a/TableProTests/Plugins/ClickHouseGeneratedColumnClassificationTests.swift b/TableProTests/Plugins/ClickHouseGeneratedColumnClassificationTests.swift new file mode 100644 index 000000000..822505688 --- /dev/null +++ b/TableProTests/Plugins/ClickHouseGeneratedColumnClassificationTests.swift @@ -0,0 +1,36 @@ +// +// ClickHouseGeneratedColumnClassificationTests.swift +// TableProTests +// + +import Testing + +@Suite("ClickHouse Generated Column Classification") +struct ClickHouseGeneratedColumnClassificationTests { + @Test("MATERIALIZED columns are generated") + func materialized() { + #expect(clickhouseColumnIsGenerated(defaultKind: "MATERIALIZED")) + } + + @Test("ALIAS columns are generated") + func alias() { + #expect(clickhouseColumnIsGenerated(defaultKind: "ALIAS")) + } + + @Test("DEFAULT columns stay insertable") + func defaultKind() { + #expect(!clickhouseColumnIsGenerated(defaultKind: "DEFAULT")) + } + + @Test("An ordinary column reports no kind") + func ordinaryColumn() { + #expect(!clickhouseColumnIsGenerated(defaultKind: "")) + #expect(!clickhouseColumnIsGenerated(defaultKind: nil)) + } + + @Test("Case and padding do not change the classification") + func caseAndPadding() { + #expect(clickhouseColumnIsGenerated(defaultKind: "materialized")) + #expect(clickhouseColumnIsGenerated(defaultKind: " ALIAS ")) + } +} diff --git a/TableProTests/Plugins/MySQLGeneratedColumnClassificationTests.swift b/TableProTests/Plugins/MySQLGeneratedColumnClassificationTests.swift index 91e3eb056..6acc415a2 100644 --- a/TableProTests/Plugins/MySQLGeneratedColumnClassificationTests.swift +++ b/TableProTests/Plugins/MySQLGeneratedColumnClassificationTests.swift @@ -36,4 +36,31 @@ struct MySQLGeneratedColumnClassificationTests { #expect(!mysqlColumnIsGenerated(extra: "")) #expect(!mysqlColumnIsGenerated(extra: nil)) } + + @Test("An invisible generated column is still generated on MySQL") + func mysqlInvisibleCombination() { + #expect(mysqlColumnIsGenerated(extra: "VIRTUAL GENERATED INVISIBLE")) + #expect(mysqlColumnIsGenerated(extra: "STORED GENERATED INVISIBLE")) + } + + @Test("MariaDB separates combined attributes with a comma") + func mariadbInvisibleCombination() { + #expect(mysqlColumnIsGenerated(extra: "VIRTUAL GENERATED, INVISIBLE")) + #expect(mysqlColumnIsGenerated(extra: "STORED GENERATED, INVISIBLE")) + } + + @Test("MariaDB 10.1 and older report a bare marker") + func mariadbLegacyMarkers() { + #expect(mysqlColumnIsGenerated(extra: "VIRTUAL")) + #expect(mysqlColumnIsGenerated(extra: "PERSISTENT")) + #expect(mysqlColumnIsGenerated(extra: "persistent")) + } + + @Test("MariaDB values that only look like a bare marker are not generated") + func mariadbNonGeneratedValues() { + #expect(!mysqlColumnIsGenerated(extra: "on update current_timestamp()")) + #expect(!mysqlColumnIsGenerated(extra: "auto_increment, INVISIBLE")) + #expect(!mysqlColumnIsGenerated(extra: "INVISIBLE")) + #expect(!mysqlColumnIsGenerated(extra: "WITHOUT SYSTEM VERSIONING")) + } } diff --git a/docs/features/change-tracking.mdx b/docs/features/change-tracking.mdx index 27ed3eb31..a75073702 100644 --- a/docs/features/change-tracking.mdx +++ b/docs/features/change-tracking.mdx @@ -14,6 +14,10 @@ Changes are queued in memory, not applied immediately. Edit cells, insert rows, **Edit cells**: double-click a cell to edit (see [Data Grid](/features/data-grid)). Changes queue immediately. Changing a value back to its original removes it from the queue. Columns the driver marks immutable, such as MongoDB's `_id`, cannot be edited. + +Generated columns are read-only: MySQL and MariaDB `STORED` and `VIRTUAL` columns, PostgreSQL `GENERATED ALWAYS AS ... STORED` columns, and ClickHouse `MATERIALIZED` and `ALIAS` columns. The server computes them and rejects any value you send, so TablePro leaves them out of every INSERT and UPDATE it writes, including SQL export. + + **Add rows**: click **+ Add** in the status bar or press `Cmd+Shift+N`. A new row appears at the bottom of the grid; columns with default values are pre-filled with `DEFAULT`. Edits to cells in a new row fold into the INSERT, not tracked as separate updates. **Delete rows**: select rows and press `Delete`. Deleted rows show a strikethrough and stay visible until you save. Saving asks you to confirm before the rows are permanently deleted.