Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +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 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

Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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))
)
}
Expand Down Expand Up @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//
// MySQLGeneratedColumnClassification.swift
// MySQLDriverPlugin
//

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()
if upper.contains("STORED GENERATED") || upper.contains("VIRTUAL GENERATED") {
return true
}
let trimmed = upper.trimmingCharacters(in: .whitespaces)
return trimmed == "VIRTUAL" || trimmed == "PERSISTENT"
}
2 changes: 2 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
Expand Down Expand Up @@ -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
)

Expand Down
2 changes: 2 additions & 0 deletions TablePro.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,7 @@
membershipExceptions = (
ClickHouseCapabilities.swift,
ClickHouseCredentials.swift,
ClickHouseGeneratedColumnClassification.swift,
ClickHouseTableOperations.swift,
);
target = 5ABCC5A62F43856700EAF3FC /* TableProTests */;
Expand All @@ -538,6 +539,7 @@
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
MySQLColumnDefinitionSQL.swift,
MySQLGeneratedColumnClassification.swift,
MySQLQueryTimeoutStatement.swift,
MySQLSocketTimeout.swift,
MySQLStatementClassification.swift,
Expand Down
8 changes: 8 additions & 0 deletions TablePro/Core/ChangeTracking/AnyChangeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ protocol ChangeManaging: AnyObject {
var canRedo: Bool { get }
var rowChanges: [RowChange] { get }
var insertedRowIndices: Set<Int> { get }
var generatedColumns: Set<String> { get }
func isRowDeleted(_ rowIndex: Int) -> Bool
func recordCellChange(
rowIndex: Int,
Expand All @@ -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<String> { [] }
}

@Observable
@MainActor
final class AnyChangeManager {
Expand All @@ -32,6 +39,7 @@ final class AnyChangeManager {
var canRedo: Bool { wrapped.canRedo }
var rowChanges: [RowChange] { wrapped.rowChanges }
var insertedRowIndices: Set<Int> { wrapped.insertedRowIndices }
var generatedColumns: Set<String> { wrapped.generatedColumns }

func isRowDeleted(_ rowIndex: Int) -> Bool {
wrapped.isRowDeleted(rowIndex)
Expand Down
9 changes: 9 additions & 0 deletions TablePro/Core/ChangeTracking/DataChangeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = []
var databaseType: DatabaseType?
var pluginDriver: (any PluginDatabaseDriver)?

Expand Down Expand Up @@ -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)
Expand All @@ -119,6 +123,10 @@ final class DataChangeManager: ChangeManaging {
self.primaryKeyColumns = primaryKeyColumns
}

func setGeneratedColumns(_ generatedColumns: Set<String>) {
self.generatedColumns = generatedColumns
}

// MARK: - Change Tracking

func recordCellChange(
Expand Down Expand Up @@ -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
)
Expand Down
15 changes: 13 additions & 2 deletions TablePro/Core/ChangeTracking/SQLStatementGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>
let databaseType: DatabaseType
let parameterStyle: ParameterStyle
private let quoteIdentifierFn: (String) -> String
Expand All @@ -32,13 +35,15 @@ struct SQLStatementGenerator {
columns: [String],
primaryKeyColumns: [String],
databaseType: DatabaseType,
generatedColumns: Set<String> = [],
parameterStyle: ParameterStyle? = nil,
dialect: SQLDialectDescriptor? = nil,
quoteIdentifier: ((String) -> String)? = nil
) throws {
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 {
Expand Down Expand Up @@ -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))

Expand Down Expand Up @@ -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 }

Expand Down Expand Up @@ -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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions TablePro/Core/Plugins/PluginDriverAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable {
charset: col.charset,
collation: col.collation,
comment: col.comment,
isGenerated: col.isGenerated,
allowedValues: col.allowedValues
)
}
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Core/Services/Query/QueryExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ struct ParsedSchemaMetadata {
let columnForeignKeys: [String: ForeignKeyInfo]?
let columnNullable: [String: Bool]
let primaryKeyColumns: [String]
let generatedColumns: Set<String>
let approximateRowCount: Int?
let columnEnumValues: [String: [String]]
let columnComments: [String: String]
Expand Down Expand Up @@ -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
Expand All @@ -207,6 +209,7 @@ final class QueryExecutor {
columnForeignKeys: nil,
columnNullable: nullable,
primaryKeyColumns: primaryKeys,
generatedColumns: [],
approximateRowCount: nil,
columnEnumValues: [:],
columnComments: [:]
Expand Down
11 changes: 7 additions & 4 deletions TablePro/Core/Services/Query/RowOperationsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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__")
}
}

Expand Down
3 changes: 3 additions & 0 deletions TablePro/Models/Query/QueryResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ struct ColumnInfo: Identifiable, Hashable {
let charset: String?
let collation: String?
let comment: String?
let isGenerated: Bool
let allowedValues: [String]?

init(
Expand All @@ -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
Expand All @@ -156,6 +158,7 @@ struct ColumnInfo: Identifiable, Hashable {
self.charset = charset
self.collation = collation
self.comment = comment
self.isGenerated = isGenerated
self.allowedValues = allowedValues
}
}
Expand Down
5 changes: 4 additions & 1 deletion TablePro/Views/Results/Extensions/DataGridView+Editing.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading