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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ 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)
- 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

Expand Down
2 changes: 2 additions & 0 deletions Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ extension MSSQLPluginDriver {
isPrimaryKey: isPk,
defaultValue: defaultValue,
extra: isIdentity ? "IDENTITY" : nil,
identityKind: isIdentity ? .always : nil,
isGenerated: isComputed
)
}
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
2 changes: 2 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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
)
Expand Down
22 changes: 14 additions & 8 deletions Plugins/SQLExportPlugin/SQLExportPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -371,13 +371,18 @@
}
}

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
}
}
}

Expand Down Expand Up @@ -479,7 +484,8 @@
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)

Expand All @@ -490,7 +496,7 @@
switch element {
case .header(let header):
columns = header.columns
columnTypeNames = header.columnTypeNames ?? []

Check warning on line 499 in Plugins/SQLExportPlugin/SQLExportPlugin.swift

View workflow job for this annotation

GitHub Actions / Build for testing

left side of nil coalescing operator '??' has non-optional type '[String]', so the right side is never used
case .rows(let rows):
for row in rows {
rowBatch.append(row)
Expand Down
2 changes: 1 addition & 1 deletion Plugins/TableProPluginKit/SqlDialect.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions TablePro/Core/ChangeTracking/DataChangeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,15 @@ final class DataChangeManager: ChangeManaging {
columns: [String],
primaryKeyColumns: [String],
databaseType: DatabaseType,
generatedColumns: Set<String>,
triggerReload: Bool = true
) {
self.tableName = tableName
self.schemaName = schemaName
self.columns = columns
self.primaryKeyColumns = primaryKeyColumns
self.databaseType = databaseType
self.generatedColumns = []
self.generatedColumns = generatedColumns

pending.clear()
undoManagerProvider?()?.removeAllActions(withTarget: self)
Expand Down Expand Up @@ -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<String>
) {
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
}
Expand Down
21 changes: 20 additions & 1 deletion TablePro/Core/ChangeTracking/SQLStatementGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: ", ")
Expand All @@ -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?
{
Expand Down
127 changes: 82 additions & 45 deletions TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = []
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],
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -262,7 +296,8 @@ extension QueryExecutionCoordinator {
schemaName: parent.tabManager.tabs[idx].tableContext.schemaName,
columns: columns,
primaryKeyColumns: resolvedPKs,
databaseType: conn.type
databaseType: conn.type,
generatedColumns: generatedColumns
)
}

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

Expand Down
Loading
Loading