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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Structure tab: modify existing tables (add, modify, drop columns, indexes, foreign keys, primary keys)

## [0.33.0] - 2026-04-19

### Added
Expand Down
20 changes: 20 additions & 0 deletions Packages/TableProCore/Sources/TableProPluginKit/DriverPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ public protocol DriverPlugin: TableProPlugin {
static var isDownloadable: Bool { get }
static var postConnectActions: [PostConnectAction] { get }
static var parameterStyle: ParameterStyle { get }
static var supportsDropDatabase: Bool { get }

// Schema editing granularity
static var supportsAddColumn: Bool { get }
static var supportsModifyColumn: Bool { get }
static var supportsDropColumn: Bool { get }
static var supportsRenameColumn: Bool { get }
static var supportsAddIndex: Bool { get }
static var supportsDropIndex: Bool { get }
static var supportsModifyPrimaryKey: Bool { get }
}

public extension DriverPlugin {
Expand Down Expand Up @@ -113,4 +123,14 @@ public extension DriverPlugin {
static var parameterStyle: ParameterStyle { .questionMark }
static var isDownloadable: Bool { false }
static var postConnectActions: [PostConnectAction] { [] }
static var supportsDropDatabase: Bool { false }

// Schema editing granularity
static var supportsAddColumn: Bool { true }
static var supportsModifyColumn: Bool { true }
static var supportsDropColumn: Bool { true }
static var supportsRenameColumn: Bool { false }
static var supportsAddIndex: Bool { true }
static var supportsDropIndex: Bool { true }
static var supportsModifyPrimaryKey: Bool { true }
}
15 changes: 15 additions & 0 deletions Plugins/CassandraDriverPlugin/CassandraPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1269,6 +1269,21 @@ internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sen
try await switchDatabase(to: schema)
}

// MARK: - ALTER TABLE DDL

func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? {
"ALTER TABLE \(qualifiedTableName(table)) ADD \(quoteIdentifier(column.name)) \(column.dataType)"
}

func generateDropColumnSQL(table: String, columnName: String) -> String? {
"ALTER TABLE \(qualifiedTableName(table)) DROP \(quoteIdentifier(columnName))"
}

private func qualifiedTableName(_ table: String) -> String {
let ks = resolveKeyspace(nil)
return "\(quoteIdentifier(ks)).\(quoteIdentifier(table))"
}

// MARK: - Private Helpers

private func resolveKeyspace(_ schema: String?) -> String {
Expand Down
33 changes: 33 additions & 0 deletions Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1320,6 +1320,39 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
return "'\(escapeStringLiteral(value))'"
}

// MARK: - ALTER TABLE DDL

func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? {
"ALTER TABLE \(quoteIdentifier(table)) ADD COLUMN \(clickhouseColumnDefinition(column))"
}

func generateModifyColumnSQL(table: String, oldColumn: PluginColumnDefinition, newColumn: PluginColumnDefinition) -> String? {
let tableName = quoteIdentifier(table)
var stmts: [String] = []
if oldColumn.name != newColumn.name {
stmts.append("ALTER TABLE \(tableName) RENAME COLUMN \(quoteIdentifier(oldColumn.name)) TO \(quoteIdentifier(newColumn.name))")
}
if oldColumn.dataType != newColumn.dataType || oldColumn.isNullable != newColumn.isNullable
|| oldColumn.defaultValue != newColumn.defaultValue || oldColumn.comment != newColumn.comment {
stmts.append("ALTER TABLE \(tableName) MODIFY COLUMN \(clickhouseColumnDefinition(newColumn))")
}
return stmts.isEmpty ? nil : stmts.joined(separator: ";\n")
}

func generateDropColumnSQL(table: String, columnName: String) -> String? {
"ALTER TABLE \(quoteIdentifier(table)) DROP COLUMN \(quoteIdentifier(columnName))"
}

func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? {
let cols = index.columns.map { quoteIdentifier($0) }.joined(separator: ", ")
let indexType = index.indexType ?? "minmax"
return "ALTER TABLE \(quoteIdentifier(table)) ADD INDEX \(quoteIdentifier(index.name)) (\(cols)) TYPE \(indexType) GRANULARITY 1"
}

func generateDropIndexSQL(table: String, indexName: String) -> String? {
"ALTER TABLE \(quoteIdentifier(table)) DROP INDEX \(quoteIdentifier(indexName))"
}

// MARK: - TLS Delegate

private class InsecureTLSDelegate: NSObject, URLSessionDelegate {
Expand Down
76 changes: 76 additions & 0 deletions Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1315,6 +1315,82 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
return def
}

private func qualifiedTableName(_ table: String) -> String {
"\(quoteIdentifier(_currentSchema)).\(quoteIdentifier(table))"
}

// MARK: - ALTER TABLE DDL

func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? {
let qt = qualifiedTableName(table)
let colDef = duckdbColumnDefinition(column, inlinePK: false)
return "ALTER TABLE \(qt) ADD COLUMN \(colDef)"
}

func generateModifyColumnSQL(table: String, oldColumn: PluginColumnDefinition, newColumn: PluginColumnDefinition) -> String? {
let qt = qualifiedTableName(table)
var stmts: [String] = []

if oldColumn.name != newColumn.name {
stmts.append("ALTER TABLE \(qt) RENAME COLUMN \(quoteIdentifier(oldColumn.name)) TO \(quoteIdentifier(newColumn.name))")
}

let colName = quoteIdentifier(newColumn.name)

if oldColumn.dataType.uppercased() != newColumn.dataType.uppercased() {
stmts.append("ALTER TABLE \(qt) ALTER COLUMN \(colName) TYPE \(newColumn.dataType)")
}

if oldColumn.isNullable != newColumn.isNullable {
let clause = newColumn.isNullable ? "DROP NOT NULL" : "SET NOT NULL"
stmts.append("ALTER TABLE \(qt) ALTER COLUMN \(colName) \(clause)")
}

if oldColumn.defaultValue != newColumn.defaultValue {
if let defaultValue = newColumn.defaultValue {
stmts.append("ALTER TABLE \(qt) ALTER COLUMN \(colName) SET DEFAULT \(duckdbDefaultValue(defaultValue))")
} else {
stmts.append("ALTER TABLE \(qt) ALTER COLUMN \(colName) DROP DEFAULT")
}
}

return stmts.isEmpty ? nil : stmts.joined(separator: ";\n")
}

func generateDropColumnSQL(table: String, columnName: String) -> String? {
"ALTER TABLE \(qualifiedTableName(table)) DROP COLUMN \(quoteIdentifier(columnName))"
}

func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? {
duckdbIndexDefinition(index, qualifiedTable: qualifiedTableName(table))
}

func generateDropIndexSQL(table: String, indexName: String) -> String? {
"DROP INDEX \(quoteIdentifier(indexName))"
}

func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? {
"ALTER TABLE \(qualifiedTableName(table)) ADD \(duckdbForeignKeyDefinition(fk))"
}

func generateDropForeignKeySQL(table: String, constraintName: String) -> String? {
"ALTER TABLE \(qualifiedTableName(table)) DROP CONSTRAINT \(quoteIdentifier(constraintName))"
}

func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String], constraintName: String?) -> [String]? {
let qt = qualifiedTableName(table)
var stmts: [String] = []
if !oldColumns.isEmpty {
let name = constraintName.map { quoteIdentifier($0) } ?? "/* unknown constraint */"
stmts.append("ALTER TABLE \(qt) DROP CONSTRAINT \(name)")
}
if !newColumns.isEmpty {
let cols = newColumns.map { quoteIdentifier($0) }.joined(separator: ", ")
stmts.append("ALTER TABLE \(qt) ADD PRIMARY KEY (\(cols))")
}
return stmts.isEmpty ? nil : stmts
}

private static let indexColumnsRegex = try? NSRegularExpression(
pattern: #"ON\s+(?:(?:"[^"]*"|[^\s(]+)\s*\.\s*)*(?:"[^"]*"|[^\s(]+)\s*\(([^)]+)\)"#,
options: .caseInsensitive
Expand Down
84 changes: 84 additions & 0 deletions Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1707,6 +1707,90 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
return def
}

// MARK: - ALTER TABLE DDL

private func mssqlQualifiedTable(_ table: String) -> String {
"\(quoteIdentifier(_currentSchema)).\(quoteIdentifier(table))"
}

func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? {
"ALTER TABLE \(mssqlQualifiedTable(table)) ADD \(mssqlColumnDefinition(column, inlinePK: false))"
}

func generateModifyColumnSQL(table: String, oldColumn: PluginColumnDefinition, newColumn: PluginColumnDefinition) -> String? {
let qt = mssqlQualifiedTable(table)
var stmts: [String] = []
let needsTypeChange = oldColumn.dataType != newColumn.dataType || oldColumn.isNullable != newColumn.isNullable
let defaultChanged = oldColumn.defaultValue != newColumn.defaultValue

// Rename column first so subsequent statements reference the correct name
if oldColumn.name != newColumn.name {
let escapedPath = "\(escapeStringLiteral(_currentSchema)).\(escapeStringLiteral(table)).\(escapeStringLiteral(oldColumn.name))"
stmts.append("EXEC sp_rename '\(escapedPath)', '\(escapeStringLiteral(newColumn.name))', 'COLUMN'")
}

let colName = quoteIdentifier(newColumn.name)

// Drop existing default constraint before ALTER COLUMN or default change
if (defaultChanged || needsTypeChange) && oldColumn.defaultValue != nil {
let objectId = escapeStringLiteral("\(_currentSchema).\(table)")
stmts.append("""
DECLARE @dfName NVARCHAR(256); \
SELECT @dfName = dc.name FROM sys.default_constraints dc \
JOIN sys.columns c ON dc.parent_column_id = c.column_id AND dc.parent_object_id = c.object_id \
WHERE c.name = '\(escapeStringLiteral(newColumn.name))' \
AND dc.parent_object_id = OBJECT_ID('\(objectId)'); \
IF @dfName IS NOT NULL EXEC('ALTER TABLE \(qt) DROP CONSTRAINT [' + @dfName + ']')
""")
}

if needsTypeChange {
let nullable = newColumn.isNullable ? "NULL" : "NOT NULL"
stmts.append("ALTER TABLE \(qt) ALTER COLUMN \(colName) \(newColumn.dataType) \(nullable)")
}

if defaultChanged, let defaultValue = newColumn.defaultValue {
stmts.append("ALTER TABLE \(qt) ADD DEFAULT \(mssqlDefaultValue(defaultValue)) FOR \(colName)")
}

return stmts.isEmpty ? nil : stmts.joined(separator: ";\n")
}

func generateDropColumnSQL(table: String, columnName: String) -> String? {
"ALTER TABLE \(mssqlQualifiedTable(table)) DROP COLUMN \(quoteIdentifier(columnName))"
}

func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? {
mssqlIndexDefinition(index, qualifiedTable: mssqlQualifiedTable(table))
}

func generateDropIndexSQL(table: String, indexName: String) -> String? {
"DROP INDEX \(quoteIdentifier(indexName)) ON \(mssqlQualifiedTable(table))"
}

func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? {
"ALTER TABLE \(mssqlQualifiedTable(table)) ADD \(mssqlForeignKeyDefinition(fk))"
}

func generateDropForeignKeySQL(table: String, constraintName: String) -> String? {
"ALTER TABLE \(mssqlQualifiedTable(table)) DROP CONSTRAINT \(quoteIdentifier(constraintName))"
}

func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String], constraintName: String?) -> [String]? {
let qt = mssqlQualifiedTable(table)
var stmts: [String] = []
if !oldColumns.isEmpty {
let name = constraintName.map { quoteIdentifier($0) } ?? "/* unknown constraint */"
stmts.append("ALTER TABLE \(qt) DROP CONSTRAINT \(name)")
}
if !newColumns.isEmpty {
let cols = newColumns.map { quoteIdentifier($0) }.joined(separator: ", ")
let pkName = constraintName.map { quoteIdentifier($0) } ?? quoteIdentifier("PK_\(table)")
stmts.append("ALTER TABLE \(qt) ADD CONSTRAINT \(pkName) PRIMARY KEY (\(cols))")
}
return stmts.isEmpty ? nil : stmts
}

private func stripMSSQLOffsetFetch(from query: String) -> String {
let ns = query.uppercased() as NSString
let len = ns.length
Expand Down
47 changes: 47 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,53 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
buildForeignKeyDefinitionSQL(fk)
}

// MARK: - ALTER TABLE DDL

func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? {
"ALTER TABLE \(quoteIdentifier(table)) ADD COLUMN \(buildColumnDefinitionSQL(column))"
}

func generateModifyColumnSQL(table: String, oldColumn: PluginColumnDefinition, newColumn: PluginColumnDefinition) -> String? {
let tableName = quoteIdentifier(table)
if oldColumn.name != newColumn.name {
return "ALTER TABLE \(tableName) CHANGE COLUMN \(quoteIdentifier(oldColumn.name)) \(buildColumnDefinitionSQL(newColumn))"
}
return "ALTER TABLE \(tableName) MODIFY COLUMN \(buildColumnDefinitionSQL(newColumn))"
}

func generateDropColumnSQL(table: String, columnName: String) -> String? {
"ALTER TABLE \(quoteIdentifier(table)) DROP COLUMN \(quoteIdentifier(columnName))"
}

func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? {
"ALTER TABLE \(quoteIdentifier(table)) ADD \(buildIndexDefinitionSQL(index))"
}

func generateDropIndexSQL(table: String, indexName: String) -> String? {
"ALTER TABLE \(quoteIdentifier(table)) DROP INDEX \(quoteIdentifier(indexName))"
}

func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? {
"ALTER TABLE \(quoteIdentifier(table)) ADD \(buildForeignKeyDefinitionSQL(fk))"
}

func generateDropForeignKeySQL(table: String, constraintName: String) -> String? {
"ALTER TABLE \(quoteIdentifier(table)) DROP FOREIGN KEY \(quoteIdentifier(constraintName))"
}

func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String], constraintName: String?) -> [String]? {
let tableName = quoteIdentifier(table)
var stmts: [String] = []
if !oldColumns.isEmpty {
stmts.append("ALTER TABLE \(tableName) DROP PRIMARY KEY")
}
if !newColumns.isEmpty {
let cols = newColumns.map { quoteIdentifier($0) }.joined(separator: ", ")
stmts.append("ALTER TABLE \(tableName) ADD PRIMARY KEY (\(cols))")
}
return stmts.isEmpty ? nil : stmts
}

// MARK: - Column Reorder DDL

func generateMoveColumnSQL(table: String, column: PluginColumnDefinition, afterColumn: String?) -> String? {
Expand Down
Loading
Loading