diff --git a/CHANGELOG.md b/CHANGELOG.md index 739b268f..483d23d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/Packages/TableProCore/Sources/TableProPluginKit/DriverPlugin.swift b/Packages/TableProCore/Sources/TableProPluginKit/DriverPlugin.swift index fac22212..5cb3e98a 100644 --- a/Packages/TableProCore/Sources/TableProPluginKit/DriverPlugin.swift +++ b/Packages/TableProCore/Sources/TableProPluginKit/DriverPlugin.swift @@ -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 { @@ -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 } } diff --git a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift index 771bbeb3..59a79798 100644 --- a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift +++ b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift @@ -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 { diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index edb7abdc..34eb9ef0 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -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 { diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index 181698de..dd3e0b81 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -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 diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index f2d88c8f..de6c93bf 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -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 diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 70535ac7..f8b61307 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -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? { diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index c9ee9382..536ca4fb 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -776,6 +776,173 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { return (statement: sql, parameters: parameters) } + // MARK: - Create Table DDL + + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { + guard !definition.columns.isEmpty else { return nil } + + let qualifiedTable = oracleQualifiedTable(definition.tableName) + let pkColumns = definition.columns.filter { $0.isPrimaryKey } + let inlinePK = pkColumns.count == 1 + var parts: [String] = definition.columns.map { oracleColumnDefinition($0, inlinePK: inlinePK) } + + if pkColumns.count > 1 { + let pkCols = pkColumns.map { quoteIdentifier($0.name) }.joined(separator: ", ") + parts.append("PRIMARY KEY (\(pkCols))") + } + + for fk in definition.foreignKeys { + parts.append(oracleForeignKeyConstraint(fk)) + } + + var sql = "CREATE TABLE \(qualifiedTable) (\n " + + parts.joined(separator: ",\n ") + + "\n);" + + var indexStatements: [String] = [] + for index in definition.indexes { + indexStatements.append(oracleIndexDefinition(index, qualifiedTable: qualifiedTable)) + } + if !indexStatements.isEmpty { + sql += "\n\n" + indexStatements.joined(separator: ";\n") + ";" + } + + return sql + } + + // MARK: - Definition SQL (clipboard copy) + + func generateColumnDefinitionSQL(column: PluginColumnDefinition) -> String? { + oracleColumnDefinition(column, inlinePK: false) + } + + func generateIndexDefinitionSQL(index: PluginIndexDefinition, tableName: String?) -> String? { + let qualifiedTable = tableName.map { oracleQualifiedTable($0) } ?? "\"table\"" + return oracleIndexDefinition(index, qualifiedTable: qualifiedTable) + } + + func generateForeignKeyDefinitionSQL(fk: PluginForeignKeyDefinition) -> String? { + oracleForeignKeyConstraint(fk) + } + + // MARK: - ALTER TABLE DDL + + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { + let qt = oracleQualifiedTable(table) + let colDef = oracleColumnDefinition(column, inlinePK: false) + return "ALTER TABLE \(qt) ADD (\(colDef))" + } + + func generateModifyColumnSQL(table: String, oldColumn: PluginColumnDefinition, newColumn: PluginColumnDefinition) -> String? { + let qt = oracleQualifiedTable(table) + var stmts: [String] = [] + + if oldColumn.name != newColumn.name { + stmts.append("ALTER TABLE \(qt) RENAME COLUMN \(quoteIdentifier(oldColumn.name)) TO \(quoteIdentifier(newColumn.name))") + } + + var modifyParts: [String] = [] + let colName = quoteIdentifier(newColumn.name) + + let typeChanged = oldColumn.dataType.uppercased() != newColumn.dataType.uppercased() + let nullabilityChanged = oldColumn.isNullable != newColumn.isNullable + let defaultChanged = oldColumn.defaultValue != newColumn.defaultValue + + if typeChanged || nullabilityChanged || defaultChanged { + var def = "\(colName) \(newColumn.dataType.uppercased())" + if let defaultValue = newColumn.defaultValue { + def += " DEFAULT \(oracleDefaultValue(defaultValue))" + } else if defaultChanged { + def += " DEFAULT NULL" + } + if !newColumn.isNullable { + def += " NOT NULL" + } else if nullabilityChanged { + def += " NULL" + } + modifyParts.append(def) + } + + if !modifyParts.isEmpty { + stmts.append("ALTER TABLE \(qt) MODIFY (\(modifyParts.joined(separator: ", ")))") + } + + return stmts.isEmpty ? nil : stmts.joined(separator: ";\n") + } + + func generateDropColumnSQL(table: String, columnName: String) -> String? { + "ALTER TABLE \(oracleQualifiedTable(table)) DROP COLUMN \(quoteIdentifier(columnName))" + } + + func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? { + oracleIndexDefinition(index, qualifiedTable: oracleQualifiedTable(table)) + } + + func generateDropIndexSQL(table: String, indexName: String) -> String? { + "DROP INDEX \(quoteIdentifier(indexName))" + } + + func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? { + "ALTER TABLE \(oracleQualifiedTable(table)) ADD \(oracleForeignKeyConstraint(fk))" + } + + func generateDropForeignKeySQL(table: String, constraintName: String) -> String? { + "ALTER TABLE \(oracleQualifiedTable(table)) DROP CONSTRAINT \(quoteIdentifier(constraintName))" + } + + // MARK: - DDL Helpers + + private func oracleQualifiedTable(_ table: String) -> String { + let schema = _currentSchema ?? config.username.uppercased() + return "\(quoteIdentifier(schema)).\(quoteIdentifier(table))" + } + + private func oracleColumnDefinition(_ col: PluginColumnDefinition, inlinePK: Bool) -> String { + var def = "\(quoteIdentifier(col.name)) \(col.dataType.uppercased())" + if let defaultValue = col.defaultValue { + def += " DEFAULT \(oracleDefaultValue(defaultValue))" + } + if !col.isNullable { + def += " NOT NULL" + } + if inlinePK && col.isPrimaryKey { + def += " PRIMARY KEY" + } + return def + } + + private func oracleDefaultValue(_ value: String) -> String { + let upper = value.uppercased() + if upper == "NULL" || upper == "SYSDATE" || upper == "SYSTIMESTAMP" + || upper == "SYS_GUID()" || upper == "USER" + || value.hasPrefix("'") || Int64(value) != nil || Double(value) != nil { + return value + } + return "'\(escapeStringLiteral(value))'" + } + + private func oracleIndexDefinition(_ index: PluginIndexDefinition, qualifiedTable: String) -> String { + let cols = index.columns.map { quoteIdentifier($0) }.joined(separator: ", ") + let unique = index.isUnique ? "UNIQUE " : "" + return "CREATE \(unique)INDEX \(quoteIdentifier(index.name)) ON \(qualifiedTable) (\(cols))" + } + + private func oracleForeignKeyConstraint(_ fk: PluginForeignKeyDefinition) -> String { + let cols = fk.columns.map { quoteIdentifier($0) }.joined(separator: ", ") + let refCols = fk.referencedColumns.map { quoteIdentifier($0) }.joined(separator: ", ") + let refTable: String + if let schema = fk.referencedSchema, !schema.isEmpty { + refTable = "\(quoteIdentifier(schema)).\(quoteIdentifier(fk.referencedTable))" + } else { + refTable = quoteIdentifier(fk.referencedTable) + } + var def = "CONSTRAINT \(quoteIdentifier(fk.name)) FOREIGN KEY (\(cols)) REFERENCES \(refTable) (\(refCols))" + if fk.onDelete != "NO ACTION" { + def += " ON DELETE \(fk.onDelete)" + } + return def + } + // MARK: - Schema Switching func switchSchema(to schema: String) async throws { diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index ae1e289f..ee69f7f0 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -990,6 +990,88 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { pgForeignKeyDefinition(fk) } + // MARK: - ALTER TABLE DDL + + private func qualifiedTableName(_ table: String) -> String { + "\(quoteIdentifier(_currentSchema)).\(quoteIdentifier(table))" + } + + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { + let qt = qualifiedTableName(table) + let colDef = pgColumnDefinition(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 \(pgDefaultValue(defaultValue))") + } else { + stmts.append("ALTER TABLE \(qt) ALTER COLUMN \(colName) DROP DEFAULT") + } + } + + if let newComment = newColumn.comment, !newComment.isEmpty, newColumn.comment != oldColumn.comment { + stmts.append("COMMENT ON COLUMN \(qt).\(colName) IS '\(escapeLiteral(newComment))'") + } else if oldColumn.comment != nil && (newColumn.comment == nil || newColumn.comment?.isEmpty == true) { + stmts.append("COMMENT ON COLUMN \(qt).\(colName) IS NULL") + } + + 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? { + pgIndexDefinition(index, qualifiedTable: qualifiedTableName(table)) + } + + func generateDropIndexSQL(table: String, indexName: String) -> String? { + "DROP INDEX \(quoteIdentifier(_currentSchema)).\(quoteIdentifier(indexName))" + } + + func generateAddForeignKeySQL(table: String, fk: PluginForeignKeyDefinition) -> String? { + "ALTER TABLE \(qualifiedTableName(table)) ADD \(pgForeignKeyDefinition(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 + } + // MARK: - Helpers private func stripLimitOffset(from query: String) -> String { diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 48345334..2d70e38d 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -972,6 +972,32 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { return def } + // MARK: - ALTER TABLE DDL + + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { + let colDef = sqliteColumnDefinition(column, inlinePK: false) + return "ALTER TABLE \(quoteIdentifier(table)) ADD COLUMN \(colDef)" + } + + func generateModifyColumnSQL(table: String, oldColumn: PluginColumnDefinition, newColumn: PluginColumnDefinition) -> String? { + guard oldColumn.name != newColumn.name else { return nil } + return "ALTER TABLE \(quoteIdentifier(table)) RENAME COLUMN \(quoteIdentifier(oldColumn.name)) TO \(quoteIdentifier(newColumn.name))" + } + + 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 unique = index.isUnique ? "UNIQUE " : "" + return "CREATE \(unique)INDEX \(quoteIdentifier(index.name)) ON \(quoteIdentifier(table)) (\(cols))" + } + + func generateDropIndexSQL(table: String, indexName: String) -> String? { + "DROP INDEX \(quoteIdentifier(indexName))" + } + private func formatDDL(_ ddl: String) -> String { guard ddl.uppercased().hasPrefix("CREATE TABLE") else { return ddl diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index 34198282..4bfee43b 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -55,6 +55,15 @@ public protocol DriverPlugin: TableProPlugin { 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 { @@ -116,4 +125,13 @@ public extension DriverPlugin { 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 } } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index afacce27..1f6f97c4 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -652,7 +652,8 @@ extension PluginMetadataRegistry { supportsReadOnlyMode: true, supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: true + supportsDropDatabase: true, + supportsRenameColumn: true ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "dbo", @@ -700,7 +701,8 @@ extension PluginMetadataRegistry { supportsReadOnlyMode: true, supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: false + supportsDropDatabase: false, + supportsRenameColumn: true ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -757,7 +759,8 @@ extension PluginMetadataRegistry { supportsReadOnlyMode: true, supportsQueryProgress: true, requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: true + supportsDropDatabase: true, + supportsModifyPrimaryKey: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -792,7 +795,20 @@ extension PluginMetadataRegistry { queryLanguageName: "SQL", editorLanguage: .sql, connectionMode: .fileBased, supportsDatabaseSwitching: false, supportsColumnReorder: false, - capabilities: .defaults, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: true, + supportsExport: true, + supportsSSH: false, + supportsSSL: false, + supportsCascadeDrop: false, + supportsForeignKeyDisable: true, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsRenameColumn: true + ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", defaultGroupName: "main", @@ -834,7 +850,11 @@ extension PluginMetadataRegistry { supportsReadOnlyMode: true, supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: true + supportsDropDatabase: true, + supportsModifyColumn: false, + supportsAddIndex: false, + supportsDropIndex: false, + supportsModifyPrimaryKey: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -889,7 +909,11 @@ extension PluginMetadataRegistry { supportsReadOnlyMode: true, supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: true + supportsDropDatabase: true, + supportsModifyColumn: false, + supportsAddIndex: false, + supportsDropIndex: false, + supportsModifyPrimaryKey: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 0e726963..23ab7bb4 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -51,6 +51,14 @@ struct PluginMetadataSnapshot: Sendable { let supportsQueryProgress: Bool let requiresReconnectForDatabaseSwitch: Bool let supportsDropDatabase: Bool + // `var` with defaults so existing call sites compile without passing these fields + var supportsAddColumn: Bool = true + var supportsModifyColumn: Bool = true + var supportsDropColumn: Bool = true + var supportsRenameColumn: Bool = false + var supportsAddIndex: Bool = true + var supportsDropIndex: Bool = true + var supportsModifyPrimaryKey: Bool = true static let defaults = CapabilityFlags( supportsSchemaSwitching: false, @@ -63,7 +71,14 @@ struct PluginMetadataSnapshot: Sendable { supportsReadOnlyMode: true, supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: false + supportsDropDatabase: false, + supportsAddColumn: true, + supportsModifyColumn: true, + supportsDropColumn: true, + supportsRenameColumn: false, + supportsAddIndex: true, + supportsDropIndex: true, + supportsModifyPrimaryKey: true ) } @@ -356,7 +371,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsReadOnlyMode: true, supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: true + supportsDropDatabase: true, + supportsRenameColumn: true ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -398,7 +414,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsReadOnlyMode: true, supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: true + supportsDropDatabase: true, + supportsRenameColumn: true ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -441,7 +458,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsReadOnlyMode: true, supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: true, - supportsDropDatabase: true + supportsDropDatabase: true, + supportsRenameColumn: true ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -530,7 +548,10 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsReadOnlyMode: true, supportsQueryProgress: false, requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: false + supportsDropDatabase: false, + supportsModifyColumn: false, + supportsRenameColumn: true, + supportsModifyPrimaryKey: false ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: "public", @@ -690,7 +711,14 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsReadOnlyMode: driverType.supportsReadOnlyMode, supportsQueryProgress: driverType.supportsQueryProgress, requiresReconnectForDatabaseSwitch: driverType.requiresReconnectForDatabaseSwitch, - supportsDropDatabase: driverType.supportsDropDatabase + supportsDropDatabase: driverType.supportsDropDatabase, + supportsAddColumn: driverType.supportsAddColumn, + supportsModifyColumn: driverType.supportsModifyColumn, + supportsDropColumn: driverType.supportsDropColumn, + supportsRenameColumn: driverType.supportsRenameColumn, + supportsAddIndex: driverType.supportsAddIndex, + supportsDropIndex: driverType.supportsDropIndex, + supportsModifyPrimaryKey: driverType.supportsModifyPrimaryKey ), schema: PluginMetadataSnapshot.SchemaInfo( defaultSchemaName: driverType.defaultSchemaName, diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 76c6bd51..abf69dd5 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -112,6 +112,34 @@ extension DatabaseType { var supportsSchemaEditing: Bool { PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.supportsSchemaEditing ?? true } + + var supportsAddColumn: Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsAddColumn ?? true + } + + var supportsModifyColumn: Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsModifyColumn ?? true + } + + var supportsDropColumn: Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsDropColumn ?? true + } + + var supportsRenameColumn: Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsRenameColumn ?? false + } + + var supportsAddIndex: Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsAddIndex ?? true + } + + var supportsDropIndex: Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsDropIndex ?? true + } + + var supportsModifyPrimaryKey: Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsModifyPrimaryKey ?? true + } } // MARK: - Connection Color diff --git a/TablePro/Views/Structure/StructureGridDelegate.swift b/TablePro/Views/Structure/StructureGridDelegate.swift index 82affb07..0e5e20a4 100644 --- a/TablePro/Views/Structure/StructureGridDelegate.swift +++ b/TablePro/Views/Structure/StructureGridDelegate.swift @@ -94,18 +94,21 @@ final class StructureGridDelegate: DataGridViewDelegate { switch selectedTab { case .columns: + guard connection.type.supportsDropColumn else { return } for row in translated.sorted(by: >) { guard row < structureChangeManager.workingColumns.count else { continue } let column = structureChangeManager.workingColumns[row] structureChangeManager.deleteColumn(id: column.id) } case .indexes: + guard connection.type.supportsDropIndex else { return } for row in translated.sorted(by: >) { guard row < structureChangeManager.workingIndexes.count else { continue } let index = structureChangeManager.workingIndexes[row] structureChangeManager.deleteIndex(id: index.id) } case .foreignKeys: + guard connection.type.supportsForeignKeys else { return } for row in translated.sorted(by: >) { guard row < structureChangeManager.workingForeignKeys.count else { continue } let fk = structureChangeManager.workingForeignKeys[row] @@ -281,10 +284,13 @@ final class StructureGridDelegate: DataGridViewDelegate { func dataGridAddRow() { switch selectedTab { case .columns: + guard connection.type.supportsAddColumn else { return } structureChangeManager.addNewColumn() case .indexes: + guard connection.type.supportsAddIndex else { return } structureChangeManager.addNewIndex() case .foreignKeys: + guard connection.type.supportsForeignKeys else { return } structureChangeManager.addNewForeignKey() case .ddl, .parts: break @@ -369,9 +375,15 @@ final class StructureGridDelegate: DataGridViewDelegate { let menu = NSMenu() let label: String switch selectedTab { - case .columns: label = String(localized: "Add Column") - case .indexes: label = String(localized: "Add Index") - case .foreignKeys: label = String(localized: "Add Foreign Key") + case .columns: + guard connection.type.supportsAddColumn else { return nil } + label = String(localized: "Add Column") + case .indexes: + guard connection.type.supportsAddIndex else { return nil } + label = String(localized: "Add Index") + case .foreignKeys: + guard connection.type.supportsForeignKeys else { return nil } + label = String(localized: "Add Foreign Key") case .ddl, .parts: return nil } diff --git a/docs/databases/cassandra.mdx b/docs/databases/cassandra.mdx index eab2f5cf..485e86d2 100644 --- a/docs/databases/cassandra.mdx +++ b/docs/databases/cassandra.mdx @@ -102,6 +102,6 @@ DESCRIBE TABLE users; **Read timeout**: Include full partition key in WHERE, remove `ALLOW FILTERING`, check cluster health with `nodetool`, use `LIMIT`. -**Limitations**: Multi-DC not configurable, UDFs/UDAs in CQL but not sidebar, counter columns read-only (use editor), large partitions paginated. +**Limitations**: Multi-DC not configurable, UDFs/UDAs in CQL but not sidebar, counter columns read-only (use editor), large partitions paginated. Structure editing supports add and drop column. Other schema changes (modify column type, indexes, primary key) require the CQL editor. **Performance**: Include partition key, avoid `ALLOW FILTERING` in prod, use `LIMIT`, use `TOKEN()` for range scans. diff --git a/docs/databases/clickhouse.mdx b/docs/databases/clickhouse.mdx index c8f048a6..d4d5363d 100644 --- a/docs/databases/clickhouse.mdx +++ b/docs/databases/clickhouse.mdx @@ -210,5 +210,6 @@ ORDER BY sum(bytes_on_disk) DESC; - No foreign keys or multi-statement transactions - No auto-increment; primary key and sorting key are immutable after creation +- Structure editing supports add, modify, and drop columns, plus data-skipping indexes. Foreign keys and primary key changes are not available. - UPDATE/DELETE run as asynchronous background mutations via `ALTER TABLE`. Check progress with `SELECT * FROM system.mutations WHERE is_done = 0` - Designed for batch inserts, not single-row writes diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx index 3f1bece3..94ace432 100644 --- a/docs/features/table-structure.mdx +++ b/docs/features/table-structure.mdx @@ -149,6 +149,22 @@ Supported databases: MySQL, MariaDB, PostgreSQL, SQLite, SQL Server, ClickHouse, Structure modifications alter your database schema. Always backup important data before making changes. +### Database Support + +Not all databases support every ALTER TABLE operation. TablePro disables unsupported actions in the UI. + +| Operation | MySQL / MariaDB | PostgreSQL | SQLite | ClickHouse | SQL Server | DuckDB | Oracle | +|-----------|:-:|:-:|:-:|:-:|:-:|:-:|:-:| +| Add column | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Modify column | Yes | Yes | Rename only | Yes | Yes | Yes | Yes | +| Drop column | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Add / drop index | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Add / drop FK | Yes | Yes | - | - | Yes | Yes | Yes | +| Modify PK | Yes | Yes | - | - | Yes | Yes | Yes | +| Reorder columns | Yes | - | - | - | - | - | - | + +Cassandra supports add and drop column only. MongoDB structure is read-only. Redis, Etcd, and DynamoDB do not have table schemas. + ### Visual Structure Editor #### Adding Columns