diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d5434f3e..8723ffa12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Visual Create Table UI with column, index, and foreign key editors (sidebar → "Create New Table...") +- Real-time SQL preview with syntax highlighting for CREATE TABLE DDL +- Multi-database CREATE TABLE support: MySQL, PostgreSQL, SQLite, SQL Server, ClickHouse, DuckDB + ### Fixed - Globe+F (fn+F) fullscreen shortcut not working in SwiftUI lifecycle app diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index bf8d2dac9..b28ea9179 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -1112,6 +1112,60 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { return (converted, paramMap) } + // MARK: - Create Table DDL + + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { + guard !definition.columns.isEmpty else { return nil } + + let tableName = quoteIdentifier(definition.tableName) + let parts: [String] = definition.columns.map { clickhouseColumnDefinition($0) } + + var sql = "CREATE TABLE \(tableName) (\n " + + parts.joined(separator: ",\n ") + + "\n)" + + let engine = definition.engine ?? "MergeTree()" + sql += "\nENGINE = \(engine)" + + let pkColumns = definition.columns.filter { $0.isPrimaryKey } + if !pkColumns.isEmpty { + let orderCols = pkColumns.map { quoteIdentifier($0.name) }.joined(separator: ", ") + sql += "\nORDER BY (\(orderCols))" + } else { + sql += "\nORDER BY tuple()" + } + + return sql + ";" + } + + private func clickhouseColumnDefinition(_ col: PluginColumnDefinition) -> String { + var dataType = col.dataType + if col.isNullable { + let upper = dataType.uppercased() + if !upper.hasPrefix("NULLABLE(") { + dataType = "Nullable(\(dataType))" + } + } + + var def = "\(quoteIdentifier(col.name)) \(dataType)" + if let defaultValue = col.defaultValue { + def += " DEFAULT \(clickhouseDefaultValue(defaultValue))" + } + if let comment = col.comment, !comment.isEmpty { + def += " COMMENT '\(escapeStringLiteral(comment))'" + } + return def + } + + private func clickhouseDefaultValue(_ value: String) -> String { + let upper = value.uppercased() + if upper == "NULL" || upper == "NOW()" || upper == "TODAY()" + || value.hasPrefix("'") || Int64(value) != nil || Double(value) != nil { + return value + } + return "'\(escapeStringLiteral(value))'" + } + // MARK: - TLS Delegate private class InsecureTLSDelegate: NSObject, URLSessionDelegate { diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index 1186e4ca2..d09800b7b 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -1131,6 +1131,98 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return Set(result.rows.compactMap { $0[safe: 0] ?? nil }) } + // MARK: - Create Table DDL + + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { + guard !definition.columns.isEmpty else { return nil } + + let schema = _currentSchema + let qualifiedTable = "\(quoteIdentifier(schema)).\(quoteIdentifier(definition.tableName))" + let pkColumns = definition.columns.filter { $0.isPrimaryKey } + let inlinePK = pkColumns.count == 1 + var parts: [String] = definition.columns.map { duckdbColumnDefinition($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(duckdbForeignKeyDefinition(fk)) + } + + var sql = "CREATE TABLE \(qualifiedTable) (\n " + + parts.joined(separator: ",\n ") + + "\n);" + + var indexStatements: [String] = [] + for index in definition.indexes { + indexStatements.append(duckdbIndexDefinition(index, qualifiedTable: qualifiedTable)) + } + if !indexStatements.isEmpty { + sql += "\n\n" + indexStatements.joined(separator: ";\n") + ";" + } + + return sql + } + + private func duckdbColumnDefinition(_ col: PluginColumnDefinition, inlinePK: Bool) -> String { + var dataType = col.dataType + if col.autoIncrement { + let upper = dataType.uppercased() + if upper == "BIGINT" || upper == "INT8" { + dataType = "BIGSERIAL" + } else { + dataType = "SERIAL" + } + } + + var def = "\(quoteIdentifier(col.name)) \(dataType)" + if !col.autoIncrement { + if col.isNullable { + def += " NULL" + } else { + def += " NOT NULL" + } + } + if let defaultValue = col.defaultValue { + def += " DEFAULT \(duckdbDefaultValue(defaultValue))" + } + if inlinePK && col.isPrimaryKey { + def += " PRIMARY KEY" + } + return def + } + + private func duckdbDefaultValue(_ value: String) -> String { + let upper = value.uppercased() + if upper == "NULL" || upper == "TRUE" || upper == "FALSE" + || upper == "CURRENT_TIMESTAMP" || upper == "NOW()" + || value.hasPrefix("'") || Int64(value) != nil || Double(value) != nil { + return value + } + return "'\(escapeStringLiteral(value))'" + } + + private func duckdbIndexDefinition(_ 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 duckdbForeignKeyDefinition(_ fk: PluginForeignKeyDefinition) -> String { + let cols = fk.columns.map { quoteIdentifier($0) }.joined(separator: ", ") + let refCols = fk.referencedColumns.map { quoteIdentifier($0) }.joined(separator: ", ") + var def = "CONSTRAINT \(quoteIdentifier(fk.name)) FOREIGN KEY (\(cols)) REFERENCES \(quoteIdentifier(fk.referencedTable)) (\(refCols))" + if fk.onDelete != "NO ACTION" { + def += " ON DELETE \(fk.onDelete)" + } + if fk.onUpdate != "NO ACTION" { + def += " ON UPDATE \(fk.onUpdate)" + } + return def + } + 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 10bc1cab0..4299f181e 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -1428,6 +1428,94 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return false } + // MARK: - Create Table DDL + + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { + guard !definition.columns.isEmpty else { return nil } + + let schema = _currentSchema + let qualifiedTable = "\(quoteIdentifier(schema)).\(quoteIdentifier(definition.tableName))" + let pkColumns = definition.columns.filter { $0.isPrimaryKey } + let inlinePK = pkColumns.count == 1 + var parts: [String] = definition.columns.map { mssqlColumnDefinition($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(mssqlForeignKeyDefinition(fk)) + } + + var sql = "CREATE TABLE \(qualifiedTable) (\n " + + parts.joined(separator: ",\n ") + + "\n);" + + var indexStatements: [String] = [] + for index in definition.indexes { + indexStatements.append(mssqlIndexDefinition(index, qualifiedTable: qualifiedTable)) + } + if !indexStatements.isEmpty { + sql += "\n\n" + indexStatements.joined(separator: ";\n") + ";" + } + + return sql + } + + private func mssqlColumnDefinition(_ col: PluginColumnDefinition, inlinePK: Bool) -> String { + var def = "\(quoteIdentifier(col.name)) \(col.dataType)" + if col.autoIncrement { + def += " IDENTITY(1,1)" + } + if col.isNullable { + def += " NULL" + } else { + def += " NOT NULL" + } + if let defaultValue = col.defaultValue { + def += " DEFAULT \(mssqlDefaultValue(defaultValue))" + } + if inlinePK && col.isPrimaryKey { + def += " PRIMARY KEY" + } + return def + } + + private func mssqlDefaultValue(_ value: String) -> String { + let upper = value.uppercased() + if upper == "NULL" || upper == "GETDATE()" || upper == "NEWID()" || upper == "GETUTCDATE()" + || value.hasPrefix("'") || value.hasPrefix("(") || Int64(value) != nil || Double(value) != nil { + return value + } + return "'\(escapeStringLiteral(value))'" + } + + private func mssqlIndexDefinition(_ index: PluginIndexDefinition, qualifiedTable: String) -> String { + let cols = index.columns.map { quoteIdentifier($0) }.joined(separator: ", ") + let unique = index.isUnique ? "UNIQUE " : "" + var def = "CREATE \(unique)INDEX \(quoteIdentifier(index.name)) ON \(qualifiedTable) (\(cols))" + if let type = index.indexType?.uppercased(), type == "CLUSTERED" { + def = "CREATE \(unique)CLUSTERED INDEX \(quoteIdentifier(index.name)) ON \(qualifiedTable) (\(cols))" + } else if let type = index.indexType?.uppercased(), type == "NONCLUSTERED" { + def = "CREATE \(unique)NONCLUSTERED INDEX \(quoteIdentifier(index.name)) ON \(qualifiedTable) (\(cols))" + } + return def + } + + private func mssqlForeignKeyDefinition(_ fk: PluginForeignKeyDefinition) -> String { + let cols = fk.columns.map { quoteIdentifier($0) }.joined(separator: ", ") + let refCols = fk.referencedColumns.map { quoteIdentifier($0) }.joined(separator: ", ") + var def = "CONSTRAINT \(quoteIdentifier(fk.name)) FOREIGN KEY (\(cols)) REFERENCES \(quoteIdentifier(fk.referencedTable)) (\(refCols))" + if fk.onDelete != "NO ACTION" { + def += " ON DELETE \(fk.onDelete)" + } + if fk.onUpdate != "NO ACTION" { + def += " ON UPDATE \(fk.onUpdate)" + } + return def + } + 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 1b2e9fa39..bcffd5f61 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -605,6 +605,141 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { "EXPLAIN \(sql)" } + // MARK: - Create Table DDL + + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { + let tableName = quoteIdentifier(definition.tableName) + let ifNotExists = definition.ifNotExists ? " IF NOT EXISTS" : "" + + var parts: [String] = [] + + for column in definition.columns { + parts.append(buildColumnDefinitionSQL(column)) + } + + var pkCols = definition.primaryKeyColumns + if pkCols.isEmpty { + pkCols = definition.columns.filter { $0.autoIncrement }.map(\.name) + } + if !pkCols.isEmpty { + let quoted = pkCols.map { quoteIdentifier($0) }.joined(separator: ", ") + parts.append("PRIMARY KEY (\(quoted))") + } + + for index in definition.indexes { + parts.append(buildIndexDefinitionSQL(index)) + } + + for fk in definition.foreignKeys { + parts.append(buildForeignKeyDefinitionSQL(fk)) + } + + var sql = "CREATE TABLE\(ifNotExists) \(tableName) (\n" + sql += parts.map { " \($0)" }.joined(separator: ",\n") + sql += "\n)" + + var tableOptions: [String] = [] + if let engine = definition.engine, !engine.isEmpty { + tableOptions.append("ENGINE=\(engine)") + } + if let charset = definition.charset, !charset.isEmpty { + tableOptions.append("DEFAULT CHARSET=\(charset)") + } + if let collation = definition.collation, !collation.isEmpty { + tableOptions.append("COLLATE=\(collation)") + } + + if !tableOptions.isEmpty { + sql += " " + tableOptions.joined(separator: " ") + } + + sql += ";" + return sql + } + + private func buildColumnDefinitionSQL(_ column: PluginColumnDefinition) -> String { + var def = "\(quoteIdentifier(column.name)) \(column.dataType)" + + if column.unsigned { + def += " UNSIGNED" + } + if column.isNullable { + def += " NULL" + } else { + def += " NOT NULL" + } + if let defaultValue = column.defaultValue { + let upper = defaultValue.uppercased() + if upper == "NULL" || upper == "CURRENT_TIMESTAMP" || upper == "CURRENT_TIMESTAMP()" + || defaultValue.hasPrefix("'") { + def += " DEFAULT \(defaultValue)" + } else if Int64(defaultValue) != nil || Double(defaultValue) != nil { + def += " DEFAULT \(defaultValue)" + } else { + def += " DEFAULT '\(escapeStringLiteral(defaultValue))'" + } + } + if column.autoIncrement { + def += " AUTO_INCREMENT" + } + if let onUpdate = column.onUpdate, !onUpdate.isEmpty { + let upper = onUpdate.uppercased() + if upper == "CURRENT_TIMESTAMP" || upper == "CURRENT_TIMESTAMP()" + || upper.hasPrefix("CURRENT_TIMESTAMP(") { + def += " ON UPDATE \(onUpdate)" + } + } + if let comment = column.comment, !comment.isEmpty { + def += " COMMENT '\(escapeStringLiteral(comment))'" + } + + return def + } + + private func buildIndexDefinitionSQL(_ index: PluginIndexDefinition) -> String { + let cols = index.columns.map { quoteIdentifier($0) }.joined(separator: ", ") + var def = "" + + let upperType = index.indexType?.uppercased() ?? "" + if upperType == "FULLTEXT" { + def += "FULLTEXT INDEX" + } else if upperType == "SPATIAL" { + def += "SPATIAL INDEX" + } else if index.isUnique { + def += "UNIQUE INDEX" + } else { + def += "INDEX" + } + + def += " \(quoteIdentifier(index.name)) (\(cols))" + + if upperType == "BTREE" || upperType == "HASH" { + def += " USING \(upperType)" + } + + return def + } + + private func buildForeignKeyDefinitionSQL(_ fk: PluginForeignKeyDefinition) -> String { + let cols = fk.columns.map { quoteIdentifier($0) }.joined(separator: ", ") + let refCols = fk.referencedColumns.map { quoteIdentifier($0) }.joined(separator: ", ") + let refTable = quoteIdentifier(fk.referencedTable) + + var def = "CONSTRAINT \(quoteIdentifier(fk.name)) FOREIGN KEY (\(cols)) REFERENCES \(refTable) (\(refCols))" + + let onDelete = fk.onDelete.uppercased() + if onDelete != "NO ACTION" { + def += " ON DELETE \(onDelete)" + } + + let onUpdate = fk.onUpdate.uppercased() + if onUpdate != "NO ACTION" { + def += " ON UPDATE \(onUpdate)" + } + + return def + } + // MARK: - Column Reorder DDL func generateMoveColumnSQL(table: String, column: PluginColumnDefinition, afterColumn: String?) -> String? { diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 3e4fc4d05..972e984fa 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -769,6 +769,105 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { """ } + // MARK: - Create Table DDL + + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { + guard !definition.columns.isEmpty else { return nil } + + let schema = _currentSchema + let qualifiedTable = "\(quoteIdentifier(schema)).\(quoteIdentifier(definition.tableName))" + let pkColumns = definition.columns.filter { $0.isPrimaryKey } + let inlinePK = pkColumns.count == 1 + var parts: [String] = definition.columns.map { pgColumnDefinition($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(pgForeignKeyDefinition(fk)) + } + + var sql = "CREATE TABLE \(qualifiedTable) (\n " + + parts.joined(separator: ",\n ") + + "\n);" + + var indexStatements: [String] = [] + for index in definition.indexes { + indexStatements.append(pgIndexDefinition(index, qualifiedTable: qualifiedTable)) + } + if !indexStatements.isEmpty { + sql += "\n\n" + indexStatements.joined(separator: ";\n") + ";" + } + + return sql + } + + private func pgColumnDefinition(_ col: PluginColumnDefinition, inlinePK: Bool) -> String { + var dataType = col.dataType + if col.autoIncrement { + let upper = dataType.uppercased() + if upper == "BIGINT" || upper == "INT8" { + dataType = "BIGSERIAL" + } else { + dataType = "SERIAL" + } + } + + var def = "\(quoteIdentifier(col.name)) \(dataType)" + if !col.autoIncrement { + if col.isNullable { + def += " NULL" + } else { + def += " NOT NULL" + } + } + if let defaultValue = col.defaultValue { + def += " DEFAULT \(pgDefaultValue(defaultValue))" + } + if inlinePK && col.isPrimaryKey { + def += " PRIMARY KEY" + } + return def + } + + private func pgDefaultValue(_ value: String) -> String { + let upper = value.uppercased() + if upper == "NULL" || upper == "TRUE" || upper == "FALSE" + || upper == "CURRENT_TIMESTAMP" || upper == "NOW()" + || value.hasPrefix("'") || Int64(value) != nil || Double(value) != nil + || upper.hasSuffix("::REGCLASS") { + return value + } + return "'\(escapeLiteral(value))'" + } + + private func pgIndexDefinition(_ index: PluginIndexDefinition, qualifiedTable: String) -> String { + let cols = index.columns.map { quoteIdentifier($0) }.joined(separator: ", ") + let unique = index.isUnique ? "UNIQUE " : "" + var def = "CREATE \(unique)INDEX \(quoteIdentifier(index.name)) ON \(qualifiedTable)" + if let type = index.indexType?.uppercased(), + ["BTREE", "HASH", "GIN", "GIST", "BRIN"].contains(type) { + def += " USING \(type.lowercased())" + } + def += " (\(cols))" + return def + } + + private func pgForeignKeyDefinition(_ fk: PluginForeignKeyDefinition) -> String { + let cols = fk.columns.map { quoteIdentifier($0) }.joined(separator: ", ") + let refCols = fk.referencedColumns.map { quoteIdentifier($0) }.joined(separator: ", ") + var def = "CONSTRAINT \(quoteIdentifier(fk.name)) FOREIGN KEY (\(cols)) REFERENCES \(quoteIdentifier(fk.referencedTable)) (\(refCols))" + if fk.onDelete != "NO ACTION" { + def += " ON DELETE \(fk.onDelete)" + } + if fk.onUpdate != "NO ACTION" { + def += " ON UPDATE \(fk.onUpdate)" + } + return def + } + // MARK: - Helpers private func stripLimitOffset(from query: String) -> String { diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 8b51ab557..3f5e48868 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -780,6 +780,71 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { return result.trimmingCharacters(in: .whitespacesAndNewlines) } + // MARK: - Create Table DDL + + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { + guard !definition.columns.isEmpty else { return nil } + + let tableName = quoteIdentifier(definition.tableName) + let pkColumns = definition.columns.filter { $0.isPrimaryKey } + let inlinePK = pkColumns.count == 1 + var parts: [String] = definition.columns.map { sqliteColumnDefinition($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(sqliteForeignKeyDefinition(fk)) + } + + let sql = "CREATE TABLE \(tableName) (\n " + + parts.joined(separator: ",\n ") + + "\n);" + + return sql + } + + private func sqliteColumnDefinition(_ col: PluginColumnDefinition, inlinePK: Bool) -> String { + var def = "\(quoteIdentifier(col.name)) \(col.dataType)" + if inlinePK && col.isPrimaryKey { + def += " PRIMARY KEY" + if col.autoIncrement { + def += " AUTOINCREMENT" + } + } + if !col.isNullable { + def += " NOT NULL" + } + if let defaultValue = col.defaultValue { + def += " DEFAULT \(sqliteDefaultValue(defaultValue))" + } + return def + } + + private func sqliteDefaultValue(_ value: String) -> String { + let upper = value.uppercased() + if upper == "NULL" || upper == "CURRENT_TIMESTAMP" || upper == "CURRENT_DATE" || upper == "CURRENT_TIME" + || value.hasPrefix("'") || Int64(value) != nil || Double(value) != nil { + return value + } + return "'\(escapeStringLiteral(value))'" + } + + private func sqliteForeignKeyDefinition(_ fk: PluginForeignKeyDefinition) -> String { + let cols = fk.columns.map { quoteIdentifier($0) }.joined(separator: ", ") + let refCols = fk.referencedColumns.map { quoteIdentifier($0) }.joined(separator: ", ") + var def = "FOREIGN KEY (\(cols)) REFERENCES \(quoteIdentifier(fk.referencedTable)) (\(refCols))" + if fk.onDelete != "NO ACTION" { + def += " ON DELETE \(fk.onDelete)" + } + if fk.onUpdate != "NO ACTION" { + def += " ON UPDATE \(fk.onUpdate)" + } + return def + } + private func formatDDL(_ ddl: String) -> String { guard ddl.uppercased().hasPrefix("CREATE TABLE") else { return ddl diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 14b816c6f..39aeb3fed 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -99,6 +99,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func generateDropForeignKeySQL(table: String, constraintName: String) -> String? func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String], constraintName: String?) -> [String]? func generateMoveColumnSQL(table: String, column: PluginColumnDefinition, afterColumn: String?) -> String? + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? // Table operations (optional — return nil to use app-level fallback) func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? @@ -227,6 +228,7 @@ public extension PluginDatabaseDriver { func generateDropForeignKeySQL(table: String, constraintName: String) -> String? { nil } func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String], constraintName: String?) -> [String]? { nil } func generateMoveColumnSQL(table: String, column: PluginColumnDefinition, afterColumn: String?) -> String? { nil } + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { nil } func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String]? { nil } func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { nil } diff --git a/Plugins/TableProPluginKit/SchemaTypes.swift b/Plugins/TableProPluginKit/SchemaTypes.swift index d861b4866..e74e26975 100644 --- a/Plugins/TableProPluginKit/SchemaTypes.swift +++ b/Plugins/TableProPluginKit/SchemaTypes.swift @@ -87,3 +87,38 @@ public struct PluginForeignKeyDefinition: Sendable { self.onUpdate = onUpdate } } + +/// Full table definition for CREATE TABLE DDL generation +public struct PluginCreateTableDefinition: Sendable { + public let tableName: String + public let columns: [PluginColumnDefinition] + public let indexes: [PluginIndexDefinition] + public let foreignKeys: [PluginForeignKeyDefinition] + public let primaryKeyColumns: [String] + public let engine: String? + public let charset: String? + public let collation: String? + public let ifNotExists: Bool + + public init( + tableName: String, + columns: [PluginColumnDefinition], + indexes: [PluginIndexDefinition] = [], + foreignKeys: [PluginForeignKeyDefinition] = [], + primaryKeyColumns: [String] = [], + engine: String? = nil, + charset: String? = nil, + collation: String? = nil, + ifNotExists: Bool = false + ) { + self.tableName = tableName + self.columns = columns + self.indexes = indexes + self.foreignKeys = foreignKeys + self.primaryKeyColumns = primaryKeyColumns + self.engine = engine + self.charset = charset + self.collation = collation + self.ifNotExists = ifNotExists + } +} diff --git a/Plugins/TableProPluginKit/StructureColumnField.swift b/Plugins/TableProPluginKit/StructureColumnField.swift index 8f168223e..6502ba307 100644 --- a/Plugins/TableProPluginKit/StructureColumnField.swift +++ b/Plugins/TableProPluginKit/StructureColumnField.swift @@ -5,6 +5,7 @@ public enum StructureColumnField: String, Sendable, CaseIterable { case type case nullable case defaultValue + case primaryKey case autoIncrement case comment @@ -14,6 +15,7 @@ public enum StructureColumnField: String, Sendable, CaseIterable { case .type: String(localized: "Type") case .nullable: String(localized: "Nullable") case .defaultValue: String(localized: "Default") + case .primaryKey: String(localized: "Primary Key") case .autoIncrement: String(localized: "Auto Inc") case .comment: String(localized: "Comment") } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 6a1dbeb12..0086343d8 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -338,6 +338,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { pluginDriver.generateMoveColumnSQL(table: table, column: column, afterColumn: afterColumn) } + func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { + pluginDriver.generateCreateTableSQL(definition: definition) + } + // MARK: - Table Operations func truncateTableStatements(table: String, schema: String?, cascade: Bool) -> [String] { diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index b81ded567..ba76be53c 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -88,6 +88,10 @@ enum SessionStateFactory { databaseName: payload.databaseName ?? connection.database, sourceFileURL: payload.sourceFileURL ) + case .createTable: + tabMgr.addCreateTableTab( + databaseName: payload.databaseName ?? connection.database + ) } } else if payload?.isNewTab == true { tabMgr.addTab(databaseName: payload?.databaseName ?? connection.database) diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 263b52615..a0caa05ed 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -14,6 +14,7 @@ import TableProPluginKit enum TabType: Equatable, Codable, Hashable { case query // SQL editor tab case table // Direct table view tab + case createTable // Create new table tab } /// Minimal representation of a tab for persistence @@ -412,7 +413,7 @@ struct QueryTab: Identifiable, Equatable { self.isExecuting = false self.tableName = tableName self.primaryKeyColumn = nil - self.isEditable = tabType == .table // Table tabs are editable by default + self.isEditable = tabType == .table self.isView = false self.databaseName = "" self.showStructure = false @@ -628,6 +629,16 @@ final class QueryTabManager { selectedTabId = newTab.id } + func addCreateTableTab(databaseName: String = "") { + let tabTitle = String(localized: "Create Table") + var newTab = QueryTab(title: tabTitle, tabType: .createTable) + newTab.databaseName = databaseName + newTab.isEditable = false + newTab.hasUserInteraction = true + tabs.append(newTab) + selectedTabId = newTab.id + } + func addPreviewTableTab( tableName: String, databaseType: DatabaseType = .mysql, diff --git a/TablePro/Models/Schema/CreateTableOptions.swift b/TablePro/Models/Schema/CreateTableOptions.swift new file mode 100644 index 000000000..2cea3da73 --- /dev/null +++ b/TablePro/Models/Schema/CreateTableOptions.swift @@ -0,0 +1,46 @@ +// +// CreateTableOptions.swift +// TablePro +// +// Table-level options for CREATE TABLE generation. +// + +import Foundation + +struct CreateTableOptions: Hashable { + var engine: String = "InnoDB" + var charset: String = "utf8mb4" + var collation: String = "utf8mb4_unicode_ci" + var ifNotExists: Bool = false + + static let engines = [ + "InnoDB", "MyISAM", "MEMORY", "CSV", "ARCHIVE", + "BLACKHOLE", "MERGE", "FEDERATED", "NDB" + ] + + static let charsets = [ + "utf8mb4", "utf8mb3", "utf8", "latin1", "ascii", + "binary", "utf16", "utf32", "cp1251", "big5", + "euckr", "gb2312", "gbk", "sjis" + ] + + static let collations: [String: [String]] = [ + "utf8mb4": [ + "utf8mb4_unicode_ci", "utf8mb4_general_ci", "utf8mb4_bin", + "utf8mb4_0900_ai_ci", "utf8mb4_unicode_520_ci" + ], + "utf8mb3": ["utf8mb3_unicode_ci", "utf8mb3_general_ci", "utf8mb3_bin"], + "utf8": ["utf8_unicode_ci", "utf8_general_ci", "utf8_bin"], + "latin1": ["latin1_swedish_ci", "latin1_general_ci", "latin1_bin"], + "ascii": ["ascii_general_ci", "ascii_bin"], + "binary": ["binary"], + "utf16": ["utf16_unicode_ci", "utf16_general_ci", "utf16_bin"], + "utf32": ["utf32_unicode_ci", "utf32_general_ci", "utf32_bin"], + "cp1251": ["cp1251_general_ci", "cp1251_ukrainian_ci", "cp1251_bin"], + "big5": ["big5_chinese_ci", "big5_bin"], + "euckr": ["euckr_korean_ci", "euckr_bin"], + "gb2312": ["gb2312_chinese_ci", "gb2312_bin"], + "gbk": ["gbk_chinese_ci", "gbk_bin"], + "sjis": ["sjis_japanese_ci", "sjis_bin"], + ] +} diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index bf815934c..91c108b96 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -172,6 +172,11 @@ struct MainEditorContentView: View { queryTabContent(tab: tab) case .table: tableTabContent(tab: tab) + case .createTable: + CreateTableView( + connection: connection, + coordinator: coordinator + ) } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index 7c5865380..f15439c9b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -10,6 +10,23 @@ import Foundation import UniformTypeIdentifiers extension MainContentCoordinator { + // MARK: - Table Operations + + func createNewTable() { + guard !safeModeLevel.blocksAllWrites else { return } + + if tabManager.tabs.isEmpty { + tabManager.addCreateTableTab(databaseName: connection.database) + } else { + let payload = EditorTabPayload( + connectionId: connection.id, + tabType: .createTable, + databaseName: connection.database + ) + WindowOpener.shared.openNativeTab(payload) + } + } + // MARK: - View Operations func createView() { diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 64f8607c6..288b1d8ca 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -415,6 +415,10 @@ final class MainContentCommandActions { coordinator?.createView() } + func createNewTable() { + coordinator?.createNewTable() + } + // MARK: - Tab Navigation (Group A — Called Directly) func selectTab(number: Int) { diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index 3b97735a7..f4983dee1 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -600,7 +600,9 @@ struct MainContentView: View { /// Update window title, proxy icon, and dirty dot based on the selected tab. private func updateWindowTitleAndFileState() { let selectedTab = tabManager.selectedTab - if let fileURL = selectedTab?.sourceFileURL { + if selectedTab?.tabType == .createTable { + windowTitle = String(localized: "Create Table") + } else if let fileURL = selectedTab?.sourceFileURL { windowTitle = fileURL.deletingPathExtension().lastPathComponent } else { let langName = PluginManager.shared.queryLanguageName(for: connection.type) diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index b2ef06d66..6f72bf376 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -49,6 +49,11 @@ struct SidebarContextMenu: View { } var body: some View { + Button("Create New Table...") { + coordinator?.createNewTable() + } + .disabled(isReadOnly) + Button("Create New View...") { coordinator?.createView() } diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift new file mode 100644 index 000000000..d9c7f2f22 --- /dev/null +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -0,0 +1,540 @@ +// +// CreateTableView.swift +// TablePro +// +// Self-contained view for creating a new database table. +// Uses StructureChangeManager and DataGridView for column/index/FK editing. +// + +import AppKit +import os +import SwiftUI +import TableProPluginKit + +private enum CreateTableTab: CaseIterable { + case columns + case indexes + case foreignKeys + case sqlPreview + + var displayName: String { + switch self { + case .columns: String(localized: "Columns") + case .indexes: String(localized: "Indexes") + case .foreignKeys: String(localized: "Foreign Keys") + case .sqlPreview: String(localized: "SQL Preview") + } + } +} + +struct CreateTableView: View { + private static let logger = Logger(subsystem: "com.TablePro", category: "CreateTableView") + + let connection: DatabaseConnection + var coordinator: MainContentCoordinator? + + @State private var structureChangeManager = StructureChangeManager() + @State private var wrappedChangeManager: AnyChangeManager + @State private var tableName = "" + @State private var tableOptions = CreateTableOptions() + @State private var selectedTab: CreateTableTab = .columns + @State private var isCreating = false + @State private var errorMessage: String? + @State private var showError = false + @State private var previewSQL = "" + + // DataGridView state + @State private var selectedRows: Set = [] + @State private var sortState = SortState() + @State private var editingCell: CellPosition? + @State private var columnLayout = ColumnLayoutState() + + init(connection: DatabaseConnection, coordinator: MainContentCoordinator?) { + self.connection = connection + self.coordinator = coordinator + + let manager = StructureChangeManager() + _structureChangeManager = State(wrappedValue: manager) + _wrappedChangeManager = State(wrappedValue: AnyChangeManager(structureManager: manager)) + } + + var body: some View { + VStack(spacing: 0) { + configBar + Divider() + toolbar + Divider() + tabContent + } + .navigationTitle(String(localized: "Create Table")) + .onAppear { + if structureChangeManager.workingColumns.isEmpty { + structureChangeManager.addNewColumn() + } + } + .alert(String(localized: "Create Table Failed"), isPresented: $showError) { + Button("OK") {} + } message: { + Text(errorMessage ?? "") + } + } + + // MARK: - Config Bar + + private var configBar: some View { + HStack(spacing: 12) { + Text("Table Name:") + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .medium)) + + TextField("Enter table name", text: $tableName) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 300) + + if showMySQLOptions { + Divider() + .frame(height: 20) + + Picker("Engine:", selection: $tableOptions.engine) { + ForEach(CreateTableOptions.engines, id: \.self) { engine in + Text(engine).tag(engine) + } + } + .fixedSize() + + Picker("Charset:", selection: $tableOptions.charset) { + ForEach(CreateTableOptions.charsets, id: \.self) { cs in + Text(cs).tag(cs) + } + } + .fixedSize() + + Picker("Collation:", selection: $tableOptions.collation) { + ForEach(CreateTableOptions.collations[tableOptions.charset] ?? [], id: \.self) { col in + Text(col).tag(col) + } + } + .fixedSize() + } + + Spacer() + } + .padding() + .background(Color(nsColor: .controlBackgroundColor)) + .onChange(of: tableOptions.charset) { _, newCharset in + if let first = CreateTableOptions.collations[newCharset]?.first { + tableOptions.collation = first + } + } + } + + private var showMySQLOptions: Bool { + connection.type == .mysql || connection.type == .mariadb + } + + // MARK: - Toolbar + + private var availableTabs: [CreateTableTab] { + var tabs = CreateTableTab.allCases + if !connection.type.supportsForeignKeys { + tabs = tabs.filter { $0 != .foreignKeys } + } + return tabs + } + + private var isGridTab: Bool { + selectedTab != .sqlPreview + } + + private var toolbar: some View { + HStack(spacing: 8) { + Button(action: addNewRow) { + Image(systemName: "plus") + .frame(width: 16, height: 16) + } + .help(String(localized: "Add Row")) + .disabled(!isGridTab) + + Button(action: { handleDeleteRows(selectedRows) }) { + Image(systemName: "minus") + .frame(width: 16, height: 16) + } + .help(String(localized: "Delete Selected")) + .disabled(!isGridTab || selectedRows.isEmpty) + + Spacer() + + Picker("", selection: $selectedTab) { + ForEach(availableTabs, id: \.self) { tab in + Text(tab.displayName).tag(tab) + } + } + .pickerStyle(.segmented) + .labelsHidden() + + Spacer() + + Button(isCreating ? String(localized: "Creating...") : String(localized: "Create Table")) { + createTable() + } + .buttonStyle(.borderedProminent) + .tint(.accentColor) + .disabled(tableName.isEmpty || structureChangeManager.workingColumns.isEmpty || isCreating) + .keyboardShortcut(.return, modifiers: .command) + } + .padding() + } + + // MARK: - Tab Content + + @ViewBuilder + private var tabContent: some View { + switch selectedTab { + case .columns, .indexes, .foreignKeys: + structureGrid + case .sqlPreview: + sqlPreviewView + } + } + + // MARK: - Structure Grid + + private var structureTab: StructureTab { + switch selectedTab { + case .columns: return .columns + case .indexes: return .indexes + case .foreignKeys: return .foreignKeys + case .sqlPreview: return .columns + } + } + + private var structureGrid: some View { + let provider = StructureRowProvider( + changeManager: structureChangeManager, + tab: structureTab, + databaseType: connection.type, + additionalFields: [.primaryKey] + ) + + return DataGridView( + rowProvider: provider.asInMemoryProvider(), + changeManager: wrappedChangeManager, + isEditable: true, + onRefresh: nil, + onCellEdit: handleCellEdit, + onDeleteRows: handleDeleteRows, + onCopyRows: nil, + onPasteRows: nil, + onUndo: handleUndo, + onRedo: handleRedo, + onSort: nil, + onAddRow: { addNewRow() }, + onUndoInsert: nil, + onFilterColumn: nil, + getVisualState: nil, + dropdownColumns: provider.dropdownColumns, + typePickerColumns: provider.typePickerColumns, + connectionId: connection.id, + databaseType: connection.type, + onMoveRow: nil, + selectedRowIndices: $selectedRows, + sortState: $sortState, + editingCell: $editingCell, + columnLayout: $columnLayout + ) + } + + // MARK: - SQL Preview + + private var sqlPreviewView: some View { + Group { + if previewSQL.isEmpty { + VStack(spacing: 8) { + Image(systemName: "doc.plaintext") + .font(.largeTitle) + .foregroundStyle(.secondary) + Text("Add columns to see the CREATE TABLE statement") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + DDLTextView(ddl: previewSQL, fontSize: .constant(13)) + } + } + .onAppear { generatePreviewSQL() } + .onChange(of: structureChangeManager.reloadVersion) { generatePreviewSQL() } + .onChange(of: tableName) { generatePreviewSQL() } + .onChange(of: tableOptions) { generatePreviewSQL() } + } + + // MARK: - Cell Editing + + private func handleCellEdit(_ row: Int, _ column: Int, _ value: String?) { + guard column >= 0 else { return } + + switch structureTab { + case .columns: + guard row < structureChangeManager.workingColumns.count else { return } + var col = structureChangeManager.workingColumns[row] + updateColumn(&col, at: column, with: value ?? "") + structureChangeManager.updateColumn(id: col.id, with: col) + + case .indexes: + guard row < structureChangeManager.workingIndexes.count else { return } + var idx = structureChangeManager.workingIndexes[row] + updateIndex(&idx, at: column, with: value ?? "") + structureChangeManager.updateIndex(id: idx.id, with: idx) + + case .foreignKeys: + guard row < structureChangeManager.workingForeignKeys.count else { return } + var fk = structureChangeManager.workingForeignKeys[row] + updateForeignKey(&fk, at: column, with: value ?? "") + structureChangeManager.updateForeignKey(id: fk.id, with: fk) + + default: + break + } + } + + private func updateColumn(_ column: inout EditableColumnDefinition, at index: Int, with value: String) { + if connection.type == .clickhouse { + switch index { + case 0: column.name = value + case 1: column.dataType = value + case 2: column.isNullable = value.uppercased() == "YES" || value == "1" + case 3: column.defaultValue = value.isEmpty ? nil : value + case 4: column.isPrimaryKey = value.uppercased() == "YES" || value == "1" + case 5: column.comment = value.isEmpty ? nil : value + default: break + } + } else { + switch index { + case 0: column.name = value + case 1: column.dataType = value + case 2: column.isNullable = value.uppercased() == "YES" || value == "1" + case 3: column.defaultValue = value.isEmpty ? nil : value + case 4: column.isPrimaryKey = value.uppercased() == "YES" || value == "1" + case 5: column.autoIncrement = value.uppercased() == "YES" || value == "1" + case 6: column.comment = value.isEmpty ? nil : value + default: break + } + } + } + + private func updateIndex(_ index: inout EditableIndexDefinition, at colIndex: Int, with value: String) { + switch colIndex { + case 0: index.name = value + case 1: index.columns = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + case 2: + if let indexType = EditableIndexDefinition.IndexType(rawValue: value.uppercased()) { + index.type = indexType + } + case 3: index.isUnique = value.uppercased() == "YES" || value == "1" + default: break + } + } + + private func updateForeignKey(_ fk: inout EditableForeignKeyDefinition, at index: Int, with value: String) { + switch index { + case 0: fk.name = value + case 1: fk.columns = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + case 2: fk.referencedTable = value + case 3: fk.referencedColumns = value.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } + case 4: + if let action = EditableForeignKeyDefinition.ReferentialAction(rawValue: value.uppercased()) { + fk.onDelete = action + } + case 5: + if let action = EditableForeignKeyDefinition.ReferentialAction(rawValue: value.uppercased()) { + fk.onUpdate = action + } + default: break + } + } + + // MARK: - Row Operations + + private func handleDeleteRows(_ rows: Set) { + switch structureTab { + case .columns: + for row in rows.sorted(by: >) { + guard row < structureChangeManager.workingColumns.count else { continue } + let column = structureChangeManager.workingColumns[row] + structureChangeManager.deleteColumn(id: column.id) + } + case .indexes: + for row in rows.sorted(by: >) { + guard row < structureChangeManager.workingIndexes.count else { continue } + let index = structureChangeManager.workingIndexes[row] + structureChangeManager.deleteIndex(id: index.id) + } + case .foreignKeys: + for row in rows.sorted(by: >) { + guard row < structureChangeManager.workingForeignKeys.count else { continue } + let fk = structureChangeManager.workingForeignKeys[row] + structureChangeManager.deleteForeignKey(id: fk.id) + } + default: + break + } + + let newCount: Int + switch structureTab { + case .columns: newCount = structureChangeManager.workingColumns.count + case .indexes: newCount = structureChangeManager.workingIndexes.count + case .foreignKeys: newCount = structureChangeManager.workingForeignKeys.count + default: newCount = 0 + } + + if newCount > 0 { + let maxRow = rows.max() ?? 0 + let minRow = rows.min() ?? 0 + if maxRow < newCount { + selectedRows = [maxRow] + } else if minRow > 0 { + selectedRows = [minRow - 1] + } else { + selectedRows = [0] + } + } else { + selectedRows.removeAll() + } + } + + private func addNewRow() { + switch structureTab { + case .columns: + structureChangeManager.addNewColumn() + case .indexes: + structureChangeManager.addNewIndex() + case .foreignKeys: + structureChangeManager.addNewForeignKey() + default: + break + } + } + + private func handleUndo() { + structureChangeManager.undo() + } + + private func handleRedo() { + structureChangeManager.redo() + } + + // MARK: - SQL Generation + + private func generatePreviewSQL() { + let sql = buildCreateTableSQL() + previewSQL = sql ?? "" + } + + private func buildCreateTableSQL() -> String? { + let columns = structureChangeManager.workingColumns.filter { !$0.name.isEmpty && !$0.dataType.isEmpty } + guard !columns.isEmpty else { return nil } + + var pkColumns = columns.filter { $0.isPrimaryKey }.map(\.name) + if pkColumns.isEmpty { + pkColumns = columns.filter { $0.autoIncrement }.map(\.name) + } + + let definition = PluginCreateTableDefinition( + tableName: tableName.isEmpty ? "untitled" : tableName, + columns: columns.map { toPluginColumnDefinition($0) }, + indexes: structureChangeManager.workingIndexes + .filter { !$0.name.isEmpty && !$0.columns.isEmpty } + .map { toPluginIndexDefinition($0) }, + foreignKeys: structureChangeManager.workingForeignKeys + .filter { !$0.name.isEmpty && !$0.columns.isEmpty && !$0.referencedTable.isEmpty } + .map { toPluginForeignKeyDefinition($0) }, + primaryKeyColumns: pkColumns, + engine: showMySQLOptions ? tableOptions.engine : nil, + charset: showMySQLOptions ? tableOptions.charset : nil, + collation: showMySQLOptions ? tableOptions.collation : nil, + ifNotExists: tableOptions.ifNotExists + ) + + let pluginDriver = (DatabaseManager.shared.driver(for: connection.id) as? PluginDriverAdapter)?.schemaPluginDriver + return pluginDriver?.generateCreateTableSQL(definition: definition) + } + + private func toPluginColumnDefinition(_ col: EditableColumnDefinition) -> PluginColumnDefinition { + PluginColumnDefinition( + name: col.name, + dataType: col.dataType, + isNullable: col.isNullable, + defaultValue: col.defaultValue, + isPrimaryKey: col.isPrimaryKey, + autoIncrement: col.autoIncrement, + comment: col.comment, + unsigned: col.unsigned, + onUpdate: col.onUpdate + ) + } + + private func toPluginIndexDefinition(_ index: EditableIndexDefinition) -> PluginIndexDefinition { + PluginIndexDefinition( + name: index.name, + columns: index.columns, + isUnique: index.isUnique, + indexType: index.type.rawValue + ) + } + + private func toPluginForeignKeyDefinition(_ fk: EditableForeignKeyDefinition) -> PluginForeignKeyDefinition { + PluginForeignKeyDefinition( + name: fk.name, + columns: fk.columns, + referencedTable: fk.referencedTable, + referencedColumns: fk.referencedColumns, + onDelete: fk.onDelete.rawValue, + onUpdate: fk.onUpdate.rawValue + ) + } + + // MARK: - Create Table + + private func createTable() { + guard !tableName.isEmpty else { return } + guard let sql = buildCreateTableSQL() else { + errorMessage = String(localized: "Add at least one column with a name and type") + showError = true + return + } + + isCreating = true + errorMessage = nil + + Task { + defer { isCreating = false } + do { + guard let driver = DatabaseManager.shared.driver(for: connection.id) else { + throw NSError( + domain: "CreateTableView", code: -1, + userInfo: [NSLocalizedDescriptionKey: String(localized: "Not connected to database")] + ) + } + + _ = try await driver.execute(query: sql) + + QueryHistoryManager.shared.recordQuery( + query: sql, + connectionId: connection.id, + databaseName: connection.database, + executionTime: 0, + rowCount: 0, + wasSuccessful: true + ) + + NotificationCenter.default.post(name: .refreshData, object: nil) + + if let coordinator { + coordinator.openTableTab(tableName) + } + } catch { + Self.logger.error("Create table failed: \(error.localizedDescription, privacy: .public)") + errorMessage = error.localizedDescription + showError = true + } + } + } +} diff --git a/TablePro/Views/Structure/StructureRowProvider.swift b/TablePro/Views/Structure/StructureRowProvider.swift index 000ce0562..6215eee15 100644 --- a/TablePro/Views/Structure/StructureRowProvider.swift +++ b/TablePro/Views/Structure/StructureRowProvider.swift @@ -13,18 +13,20 @@ import TableProPluginKit @MainActor final class StructureRowProvider { private static let canonicalFieldOrder: [StructureColumnField] = [ - .name, .type, .nullable, .defaultValue, .autoIncrement, .comment + .name, .type, .nullable, .defaultValue, .primaryKey, .autoIncrement, .comment ] private let changeManager: StructureChangeManager private let tab: StructureTab private let databaseType: DatabaseType + private let additionalFields: Set // Computed properties that match InMemoryRowProvider interface var rows: [[String?]] { switch tab { case .columns: - let fields = PluginManager.shared.structureColumnFields(for: databaseType) + let pluginFields = Set(PluginManager.shared.structureColumnFields(for: databaseType)) + let fields = pluginFields.union(additionalFields) let ordered = Self.canonicalFieldOrder.filter { fields.contains($0) } return changeManager.workingColumns.map { column in ordered.map { field -> String? in @@ -33,6 +35,7 @@ final class StructureRowProvider { case .type: column.dataType case .nullable: column.isNullable ? "YES" : "NO" case .defaultValue: column.defaultValue ?? "" + case .primaryKey: column.isPrimaryKey ? "YES" : "NO" case .autoIncrement: column.autoIncrement ? "YES" : "NO" case .comment: column.comment ?? "" } @@ -66,7 +69,8 @@ final class StructureRowProvider { var columns: [String] { switch tab { case .columns: - let fields = PluginManager.shared.structureColumnFields(for: databaseType) + let pluginFields = Set(PluginManager.shared.structureColumnFields(for: databaseType)) + let fields = pluginFields.union(additionalFields) let ordered = Self.canonicalFieldOrder.filter { fields.contains($0) } return ordered.map { $0.displayName } case .indexes: @@ -99,10 +103,12 @@ final class StructureRowProvider { var dropdownColumns: Set { switch tab { case .columns: - let fields = PluginManager.shared.structureColumnFields(for: databaseType) + let pluginFields = Set(PluginManager.shared.structureColumnFields(for: databaseType)) + let fields = pluginFields.union(additionalFields) let ordered = Self.canonicalFieldOrder.filter { fields.contains($0) } var result: Set = [] if let i = ordered.firstIndex(of: .nullable) { result.insert(i) } + if let i = ordered.firstIndex(of: .primaryKey) { result.insert(i) } if let i = ordered.firstIndex(of: .autoIncrement) { result.insert(i) } return result case .indexes: @@ -118,7 +124,8 @@ final class StructureRowProvider { var typePickerColumns: Set { switch tab { case .columns: - let fields = PluginManager.shared.structureColumnFields(for: databaseType) + let pluginFields = Set(PluginManager.shared.structureColumnFields(for: databaseType)) + let fields = pluginFields.union(additionalFields) let ordered = Self.canonicalFieldOrder.filter { fields.contains($0) } if let i = ordered.firstIndex(of: .type) { return [i] } return [] @@ -131,10 +138,16 @@ final class StructureRowProvider { rows.count } - init(changeManager: StructureChangeManager, tab: StructureTab, databaseType: DatabaseType = .mysql) { + init( + changeManager: StructureChangeManager, + tab: StructureTab, + databaseType: DatabaseType = .mysql, + additionalFields: Set = [] + ) { self.changeManager = changeManager self.tab = tab self.databaseType = databaseType + self.additionalFields = additionalFields } // MARK: - InMemoryRowProvider-compatible methods diff --git a/TableProTests/Plugins/MySQLCreateTableTests.swift b/TableProTests/Plugins/MySQLCreateTableTests.swift new file mode 100644 index 000000000..25b6579dd --- /dev/null +++ b/TableProTests/Plugins/MySQLCreateTableTests.swift @@ -0,0 +1,183 @@ +// +// MySQLCreateTableTests.swift +// TableProTests +// +// Tests for MySQL generateCreateTableSQL implementation. +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import MySQLDriverPlugin + +@Suite("MySQL CREATE TABLE SQL Generation") +struct MySQLCreateTableTests { + private func makeDriver() -> MySQLPluginDriver { + MySQLPluginDriver() + } + + @Test("basic table with single column") + func basicSingleColumn() { + let driver = makeDriver() + let definition = PluginCreateTableDefinition( + tableName: "users", + columns: [ + PluginColumnDefinition(name: "id", dataType: "INT", isNullable: false) + ] + ) + + let sql = driver.generateCreateTableSQL(definition: definition) + #expect(sql != nil) + #expect(sql!.contains("CREATE TABLE `users`")) + #expect(sql!.contains("`id` INT NOT NULL")) + } + + @Test("empty columns returns nil") + func emptyColumns() { + let driver = makeDriver() + let definition = PluginCreateTableDefinition(tableName: "empty", columns: []) + #expect(driver.generateCreateTableSQL(definition: definition) == nil) + } + + @Test("auto increment adds PRIMARY KEY") + func autoIncrementPK() { + let driver = makeDriver() + let definition = PluginCreateTableDefinition( + tableName: "posts", + columns: [ + PluginColumnDefinition(name: "id", dataType: "BIGINT", isNullable: false, autoIncrement: true), + PluginColumnDefinition(name: "title", dataType: "VARCHAR(255)", isNullable: false) + ] + ) + + let sql = driver.generateCreateTableSQL(definition: definition)! + #expect(sql.contains("AUTO_INCREMENT")) + #expect(sql.contains("PRIMARY KEY (`id`)")) + } + + @Test("explicit primary key columns") + func explicitPrimaryKey() { + let driver = makeDriver() + let definition = PluginCreateTableDefinition( + tableName: "composite", + columns: [ + PluginColumnDefinition(name: "user_id", dataType: "INT", isNullable: false), + PluginColumnDefinition(name: "role_id", dataType: "INT", isNullable: false) + ], + primaryKeyColumns: ["user_id", "role_id"] + ) + + let sql = driver.generateCreateTableSQL(definition: definition)! + #expect(sql.contains("PRIMARY KEY (`user_id`, `role_id`)")) + } + + @Test("table options: engine, charset, collation") + func tableOptions() { + let driver = makeDriver() + let definition = PluginCreateTableDefinition( + tableName: "t", + columns: [PluginColumnDefinition(name: "id", dataType: "INT")], + engine: "MyISAM", + charset: "latin1", + collation: "latin1_swedish_ci" + ) + + let sql = driver.generateCreateTableSQL(definition: definition)! + #expect(sql.contains("ENGINE=MyISAM")) + #expect(sql.contains("DEFAULT CHARSET=latin1")) + #expect(sql.contains("COLLATE=latin1_swedish_ci")) + } + + @Test("IF NOT EXISTS flag") + func ifNotExists() { + let driver = makeDriver() + let definition = PluginCreateTableDefinition( + tableName: "t", + columns: [PluginColumnDefinition(name: "id", dataType: "INT")], + ifNotExists: true + ) + + let sql = driver.generateCreateTableSQL(definition: definition)! + #expect(sql.contains("CREATE TABLE IF NOT EXISTS")) + } + + @Test("column with UNSIGNED, DEFAULT, COMMENT") + func fullColumnDefinition() { + let driver = makeDriver() + let definition = PluginCreateTableDefinition( + tableName: "products", + columns: [ + PluginColumnDefinition( + name: "price", + dataType: "DECIMAL(10,2)", + isNullable: false, + defaultValue: "0.00", + comment: "Product price", + unsigned: true + ) + ] + ) + + let sql = driver.generateCreateTableSQL(definition: definition)! + #expect(sql.contains("UNSIGNED")) + #expect(sql.contains("NOT NULL")) + #expect(sql.contains("COMMENT")) + } + + @Test("index generation") + func indexGeneration() { + let driver = makeDriver() + let definition = PluginCreateTableDefinition( + tableName: "t", + columns: [ + PluginColumnDefinition(name: "email", dataType: "VARCHAR(255)") + ], + indexes: [ + PluginIndexDefinition(name: "idx_email", columns: ["email"], isUnique: true) + ] + ) + + let sql = driver.generateCreateTableSQL(definition: definition)! + #expect(sql.contains("UNIQUE INDEX `idx_email` (`email`)")) + } + + @Test("foreign key generation") + func foreignKeyGeneration() { + let driver = makeDriver() + let definition = PluginCreateTableDefinition( + tableName: "orders", + columns: [ + PluginColumnDefinition(name: "user_id", dataType: "INT", isNullable: false) + ], + foreignKeys: [ + PluginForeignKeyDefinition( + name: "fk_user", + columns: ["user_id"], + referencedTable: "users", + referencedColumns: ["id"], + onDelete: "CASCADE", + onUpdate: "NO ACTION" + ) + ] + ) + + let sql = driver.generateCreateTableSQL(definition: definition)! + #expect(sql.contains("CONSTRAINT `fk_user` FOREIGN KEY (`user_id`)")) + #expect(sql.contains("REFERENCES `users` (`id`)")) + #expect(sql.contains("ON DELETE CASCADE")) + } + + @Test("backtick in table name is escaped") + func backtickEscaping() { + let driver = makeDriver() + let definition = PluginCreateTableDefinition( + tableName: "my`table", + columns: [PluginColumnDefinition(name: "col`name", dataType: "INT")] + ) + + let sql = driver.generateCreateTableSQL(definition: definition)! + #expect(sql.contains("`my``table`")) + #expect(sql.contains("`col``name`")) + } +} diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx index cc237f6d4..4d796ad3c 100644 --- a/docs/features/table-structure.mdx +++ b/docs/features/table-structure.mdx @@ -114,6 +114,22 @@ Click a table in the sidebar, then click the **Structure** tab. Or right-click a Select all with `Cmd+A` and copy with `Cmd+C`. Useful for recreating tables, documenting schemas, or version control. +## Creating a New Table + +Right-click in the sidebar and select **Create New Table...**. A visual editor opens with: + +- **Table Name** field and database-specific options (Engine, Charset, Collation for MySQL/MariaDB) +- **Columns tab** - define columns with name, type, nullable, default, primary key, auto increment, and comment +- **Indexes tab** - add indexes with type (BTREE, HASH, FULLTEXT, SPATIAL) and uniqueness +- **Foreign Keys tab** - define relationships with referenced tables, ON DELETE/ON UPDATE actions +- **SQL Preview tab** - live-generated CREATE TABLE DDL with syntax highlighting + +Click **Create Table** (or `Cmd+Return`) to execute. The new table appears in the sidebar immediately. + + +Supported databases: MySQL, MariaDB, PostgreSQL, SQLite, SQL Server, ClickHouse, and DuckDB. Each generates database-specific DDL syntax. + + ## Modifying Structure