From fe1008672765f268cff1b6d54e18a7e974b5b08f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 30 Mar 2026 12:41:09 +0700 Subject: [PATCH 01/18] feat: add Create Table foundation (plugin protocol + tab infrastructure) - Add PluginCreateTableDefinition type and generateCreateTableSQL protocol method - Implement MySQL CREATE TABLE SQL generation with full column/index/FK support - Add TabType.createTable with sidebar context menu entry point - Add coordinator.createTable() action and editor routing (placeholder UI) --- .../MySQLDriverPlugin/MySQLPluginDriver.swift | 131 ++++++++++++++++++ .../PluginDatabaseDriver.swift | 2 + Plugins/TableProPluginKit/SchemaTypes.swift | 35 +++++ .../Core/Plugins/PluginDriverAdapter.swift | 4 + .../Infrastructure/SessionStateFactory.swift | 4 + TablePro/Models/Query/QueryTab.swift | 13 +- .../Models/Schema/CreateTableOptions.swift | 15 ++ .../Main/Child/MainEditorContentView.swift | 11 ++ ...ainContentCoordinator+SidebarActions.swift | 7 + .../Views/Sidebar/SidebarContextMenu.swift | 5 + 10 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 TablePro/Models/Schema/CreateTableOptions.swift diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 1b2e9fa39..e8b8cec9c 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -605,6 +605,137 @@ 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)) + } + + if !definition.primaryKeyColumns.isEmpty { + let pkCols = definition.primaryKeyColumns.map { quoteIdentifier($0) }.joined(separator: ", ") + parts.append("PRIMARY KEY (\(pkCols))") + } + + 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/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/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..36f1cd3b0 --- /dev/null +++ b/TablePro/Models/Schema/CreateTableOptions.swift @@ -0,0 +1,15 @@ +// +// CreateTableOptions.swift +// TablePro +// +// Table-level options for CREATE TABLE generation. +// + +import Foundation + +struct CreateTableOptions: Hashable { + var engine: String? + var charset: String? + var collation: String? + var ifNotExists: Bool = false +} diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index bf815934c..2089a2275 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -172,6 +172,8 @@ struct MainEditorContentView: View { queryTabContent(tab: tab) case .table: tableTabContent(tab: tab) + case .createTable: + createTablePlaceholder } } @@ -547,6 +549,15 @@ struct MainEditorContentView: View { ) } + // MARK: - Create Table Placeholder + + private var createTablePlaceholder: some View { + Text("Create Table - Coming Soon") + .font(.title2) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + // MARK: - Empty State private var emptyStateView: some View { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index 7c5865380..da31f03e7 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -10,6 +10,13 @@ import Foundation import UniformTypeIdentifiers extension MainContentCoordinator { + // MARK: - Table Operations + + func createTable() { + guard !safeModeLevel.blocksAllWrites else { return } + tabManager.addCreateTableTab(databaseName: connection.database) + } + // MARK: - View Operations func createView() { diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index b2ef06d66..1c8f24ada 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?.createTable() + } + .disabled(isReadOnly) + Button("Create New View...") { coordinator?.createView() } From e195aa9fb11f60d10a444cf357ea4caf74590aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 30 Mar 2026 12:52:30 +0700 Subject: [PATCH 02/18] feat: implement generateCreateTableSQL for all remaining plugins PostgreSQL, SQLite, SQL Server, ClickHouse, and DuckDB each generate database-specific CREATE TABLE DDL with proper quoting, type mapping, auto-increment syntax, and table options. --- .../ClickHousePlugin.swift | 58 +++++++++++ Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift | 92 +++++++++++++++++ Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 88 +++++++++++++++++ .../PostgreSQLPluginDriver.swift | 99 +++++++++++++++++++ Plugins/SQLiteDriverPlugin/SQLitePlugin.swift | 65 ++++++++++++ 5 files changed, 402 insertions(+) diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index bf8d2dac9..da452ba54 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -1112,6 +1112,64 @@ 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()" + } + + if let comment = definition.comment, !comment.isEmpty { + sql += "\nCOMMENT '\(escapeStringLiteral(comment))'" + } + + 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..cf0397e7f 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 = definition.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..cb6370e7b 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 = definition.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/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 3e4fc4d05..b3b974f0a 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 = definition.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 From 08f3bc2eba2524cd72245cc03ad87634a0876250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 30 Mar 2026 12:57:20 +0700 Subject: [PATCH 03/18] feat: add CreateTableView with full DataGridView integration - 597-line CreateTableView with table name field, DB-specific options, segmented tabs (Columns/Indexes/ForeignKeys/SQL Preview), and DataGridView with inline editing, type picker, and dropdowns - Live SQL preview tab with generated CREATE TABLE DDL - Create action: execute SQL, refresh sidebar, open new table tab - Wire up routing, command actions, and sidebar context menu --- .../Main/Child/MainEditorContentView.swift | 14 +- ...ainContentCoordinator+SidebarActions.swift | 10 +- .../Main/MainContentCommandActions.swift | 4 + .../Views/Sidebar/SidebarContextMenu.swift | 2 +- .../Views/Structure/CreateTableView.swift | 597 ++++++++++++++++++ 5 files changed, 614 insertions(+), 13 deletions(-) create mode 100644 TablePro/Views/Structure/CreateTableView.swift diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 2089a2275..91c108b96 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -173,7 +173,10 @@ struct MainEditorContentView: View { case .table: tableTabContent(tab: tab) case .createTable: - createTablePlaceholder + CreateTableView( + connection: connection, + coordinator: coordinator + ) } } @@ -549,15 +552,6 @@ struct MainEditorContentView: View { ) } - // MARK: - Create Table Placeholder - - private var createTablePlaceholder: some View { - Text("Create Table - Coming Soon") - .font(.title2) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - // MARK: - Empty State private var emptyStateView: some View { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index da31f03e7..225703ff9 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -12,9 +12,15 @@ import UniformTypeIdentifiers extension MainContentCoordinator { // MARK: - Table Operations - func createTable() { + func createNewTable() { guard !safeModeLevel.blocksAllWrites else { return } - tabManager.addCreateTableTab(databaseName: connection.database) + + let payload = EditorTabPayload( + connectionId: connection.id, + tabType: .createTable, + databaseName: connection.database + ) + WindowOpener.shared.openNativeTab(payload) } // MARK: - View Operations 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/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index 1c8f24ada..6f72bf376 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -50,7 +50,7 @@ struct SidebarContextMenu: View { var body: some View { Button("Create New Table...") { - coordinator?.createTable() + coordinator?.createNewTable() } .disabled(isReadOnly) diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift new file mode 100644 index 000000000..09551bfcd --- /dev/null +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -0,0 +1,597 @@ +// +// 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: String, CaseIterable { + case columns = "Columns" + case indexes = "Indexes" + case foreignKeys = "Foreign Keys" + case sqlPreview = "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 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) { + tableNameBar + Divider() + tabPicker + Divider() + tabContent + Divider() + actionBar + } + .onAppear { + if structureChangeManager.workingColumns.isEmpty { + structureChangeManager.addNewColumn() + } + } + } + + // MARK: - Table Name Bar + + private var tableNameBar: 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) + + Text("Engine:") + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .foregroundStyle(.secondary) + Picker("", selection: $tableOptions.engine) { + ForEach(CreateTableOptions.engines, id: \.self) { engine in + Text(engine).tag(engine) + } + } + .labelsHidden() + .frame(width: 100) + + Text("Charset:") + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .foregroundStyle(.secondary) + Picker("", selection: $tableOptions.charset) { + ForEach(CreateTableOptions.charsets, id: \.self) { cs in + Text(cs).tag(cs) + } + } + .labelsHidden() + .frame(width: 100) + + Text("Collation:") + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .foregroundStyle(.secondary) + Picker("", selection: $tableOptions.collation) { + ForEach(CreateTableOptions.collations[tableOptions.charset] ?? [], id: \.self) { col in + Text(col).tag(col) + } + } + .labelsHidden() + .frame(width: 180) + } + + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .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: - Tab Picker + + private var availableTabs: [CreateTableTab] { + var tabs = CreateTableTab.allCases + if !connection.type.supportsForeignKeys { + tabs = tabs.filter { $0 != .foreignKeys } + } + return tabs + } + + private var tabPicker: some View { + HStack { + Spacer() + Picker("", selection: $selectedTab) { + ForEach(availableTabs, id: \.self) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + .labelsHidden() + Spacer() + } + .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 + ) + + 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: { row in + structureChangeManager.getVisualState(for: row, tab: structureTab) + }, + 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 { + VStack(spacing: 0) { + HStack { + Spacer() + Button(action: copyPreviewSQL) { + Label("Copy", systemImage: "doc.on.doc") + } + .buttonStyle(.bordered) + .disabled(previewSQL.isEmpty) + } + .padding() + .background(Color(nsColor: .controlBackgroundColor)) + + Divider() + + 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 { + ScrollView { + Text(previewSQL) + .font(.system(.body, design: .monospaced)) + .textSelection(.enabled) + .padding() + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(Color(nsColor: .textBackgroundColor)) + } + } + .onAppear { generatePreviewSQL() } + .onChange(of: structureChangeManager.reloadVersion) { generatePreviewSQL() } + } + + private func copyPreviewSQL() { + guard !previewSQL.isEmpty else { return } + ClipboardService.shared.writeText(previewSQL) + } + + // MARK: - Action Bar + + private var actionBar: some View { + HStack { + if let error = errorMessage { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + Text(error) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .foregroundStyle(.red) + .lineLimit(1) + } + + Spacer() + + Button("Cancel") { + NSApp.keyWindow?.close() + } + + Button(isCreating ? String(localized: "Creating...") : String(localized: "Create Table")) { + createTable() + } + .buttonStyle(.borderedProminent) + .disabled(tableName.isEmpty || structureChangeManager.workingColumns.isEmpty || isCreating) + .keyboardShortcut(.return, modifiers: .command) + } + .padding(12) + .background(Color(nsColor: .controlBackgroundColor)) + } + + // 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.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.autoIncrement = value.uppercased() == "YES" || value == "1" + case 5: 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 } + + let pluginDriver = (DatabaseManager.shared.driver(for: connection.id) as? PluginDriverAdapter)?.schemaPluginDriver + let quote: (String) -> String = pluginDriver?.quoteIdentifier ?? { name in + let escaped = name.replacingOccurrences(of: "\"", with: "\"\"") + return "\"\(escaped)\"" + } + let escape: (String) -> String = pluginDriver?.escapeStringLiteral ?? { value in + value.replacingOccurrences(of: "'", with: "''") + } + + var parts: [String] = [] + + // Column definitions + for col in columns { + var def = " \(quote(col.name)) \(col.dataType)" + if col.unsigned { def += " UNSIGNED" } + if !col.isNullable { def += " NOT NULL" } + if col.autoIncrement { def += " AUTO_INCREMENT" } + if let defaultVal = col.defaultValue, !defaultVal.isEmpty { + if isDefaultExpression(defaultVal) { + def += " DEFAULT \(defaultVal)" + } else { + def += " DEFAULT '\(escape(defaultVal))'" + } + } + if let onUpdate = col.onUpdate, !onUpdate.isEmpty { + def += " ON UPDATE \(onUpdate)" + } + if let comment = col.comment, !comment.isEmpty { + def += " COMMENT '\(escape(comment))'" + } + parts.append(def) + } + + // Primary key from columns marked as PK + let pkColumns = columns.filter { $0.isPrimaryKey } + if !pkColumns.isEmpty { + let pkNames = pkColumns.map { quote($0.name) }.joined(separator: ", ") + parts.append(" PRIMARY KEY (\(pkNames))") + } + + // Indexes + let indexes = structureChangeManager.workingIndexes.filter { !$0.name.isEmpty && !$0.columns.isEmpty } + for idx in indexes { + let idxCols = idx.columns.map { quote($0) }.joined(separator: ", ") + let unique = idx.isUnique ? "UNIQUE " : "" + parts.append(" \(unique)INDEX \(quote(idx.name)) (\(idxCols))") + } + + // Foreign keys + let foreignKeys = structureChangeManager.workingForeignKeys.filter { + !$0.name.isEmpty && !$0.columns.isEmpty && !$0.referencedTable.isEmpty && !$0.referencedColumns.isEmpty + } + for fk in foreignKeys { + let fkCols = fk.columns.map { quote($0) }.joined(separator: ", ") + let refCols = fk.referencedColumns.map { quote($0) }.joined(separator: ", ") + var constraint = " CONSTRAINT \(quote(fk.name)) FOREIGN KEY (\(fkCols))" + constraint += " REFERENCES \(quote(fk.referencedTable)) (\(refCols))" + if fk.onDelete != .noAction { + constraint += " ON DELETE \(fk.onDelete.rawValue)" + } + if fk.onUpdate != .noAction { + constraint += " ON UPDATE \(fk.onUpdate.rawValue)" + } + parts.append(constraint) + } + + var sql = "CREATE TABLE \(quote(tableName.isEmpty ? "untitled" : tableName)) (\n" + sql += parts.joined(separator: ",\n") + sql += "\n)" + + // MySQL/MariaDB table options + if showMySQLOptions { + sql += " ENGINE=\(tableOptions.engine)" + sql += " DEFAULT CHARSET=\(tableOptions.charset)" + sql += " COLLATE=\(tableOptions.collation)" + } + + sql += ";" + return sql + } + + private func isDefaultExpression(_ value: String) -> Bool { + let upper = value.uppercased() + return upper == "NULL" + || upper == "CURRENT_TIMESTAMP" + || upper == "NOW()" + || upper == "TRUE" + || upper == "FALSE" + || upper.hasPrefix("CURRENT_") + || Int64(value) != nil + || Double(value) != nil + } + + // 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") + return + } + + isCreating = true + errorMessage = nil + + Task { + 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) + + // Close this window and open the new table + if let coordinator { + coordinator.openTableTab(tableName) + } + NSApp.keyWindow?.close() + } catch { + Self.logger.error("Create table failed: \(error.localizedDescription, privacy: .public)") + errorMessage = error.localizedDescription + isCreating = false + } + } + } +} From ee8b636f6384d07254c2d22ba8771657fad50ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 30 Mar 2026 13:00:33 +0700 Subject: [PATCH 04/18] fix: remove references to non-existent schema/comment fields on PluginCreateTableDefinition --- Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift | 4 ---- Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift | 2 +- Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 2 +- Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift | 2 +- 4 files changed, 3 insertions(+), 7 deletions(-) diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index da452ba54..b28ea9179 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -1135,10 +1135,6 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable { sql += "\nORDER BY tuple()" } - if let comment = definition.comment, !comment.isEmpty { - sql += "\nCOMMENT '\(escapeStringLiteral(comment))'" - } - return sql + ";" } diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index cf0397e7f..d09800b7b 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -1136,7 +1136,7 @@ final class DuckDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { guard !definition.columns.isEmpty else { return nil } - let schema = definition.schema ?? _currentSchema + let schema = _currentSchema let qualifiedTable = "\(quoteIdentifier(schema)).\(quoteIdentifier(definition.tableName))" let pkColumns = definition.columns.filter { $0.isPrimaryKey } let inlinePK = pkColumns.count == 1 diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index cb6370e7b..4299f181e 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -1433,7 +1433,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { guard !definition.columns.isEmpty else { return nil } - let schema = definition.schema ?? _currentSchema + let schema = _currentSchema let qualifiedTable = "\(quoteIdentifier(schema)).\(quoteIdentifier(definition.tableName))" let pkColumns = definition.columns.filter { $0.isPrimaryKey } let inlinePK = pkColumns.count == 1 diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index b3b974f0a..972e984fa 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -774,7 +774,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { guard !definition.columns.isEmpty else { return nil } - let schema = definition.schema ?? _currentSchema + let schema = _currentSchema let qualifiedTable = "\(quoteIdentifier(schema)).\(quoteIdentifier(definition.tableName))" let pkColumns = definition.columns.filter { $0.isPrimaryKey } let inlinePK = pkColumns.count == 1 From 46d9eed329344858dddb8163b18ab416011a7fc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 30 Mar 2026 13:03:54 +0700 Subject: [PATCH 05/18] fix: add static engines/charsets/collations to CreateTableOptions --- .../Models/Schema/CreateTableOptions.swift | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/TablePro/Models/Schema/CreateTableOptions.swift b/TablePro/Models/Schema/CreateTableOptions.swift index 36f1cd3b0..5689c78d8 100644 --- a/TablePro/Models/Schema/CreateTableOptions.swift +++ b/TablePro/Models/Schema/CreateTableOptions.swift @@ -12,4 +12,29 @@ struct CreateTableOptions: Hashable { var charset: String? var collation: String? 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"], + ] } From 065813bab0e6c4f3e8ed80e5b1d8ae291b2c41e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 30 Mar 2026 13:06:33 +0700 Subject: [PATCH 06/18] fix: unwrap optional table options in SQL preview --- TablePro/Views/Structure/CreateTableView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index 09551bfcd..38127c8e2 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -527,9 +527,9 @@ struct CreateTableView: View { // MySQL/MariaDB table options if showMySQLOptions { - sql += " ENGINE=\(tableOptions.engine)" - sql += " DEFAULT CHARSET=\(tableOptions.charset)" - sql += " COLLATE=\(tableOptions.collation)" + if let engine = tableOptions.engine { sql += " ENGINE=\(engine)" } + if let charset = tableOptions.charset { sql += " DEFAULT CHARSET=\(charset)" } + if let collation = tableOptions.collation { sql += " COLLATE=\(collation)" } } sql += ";" From bb00cbd1f7289a7818041baed41475e475b76426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 30 Mar 2026 13:09:16 +0700 Subject: [PATCH 07/18] fix: auto-add PRIMARY KEY for AUTO_INCREMENT columns --- Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift | 10 +++++++--- TablePro/Views/Structure/CreateTableView.swift | 7 +++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index e8b8cec9c..bcffd5f61 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -617,9 +617,13 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { parts.append(buildColumnDefinitionSQL(column)) } - if !definition.primaryKeyColumns.isEmpty { - let pkCols = definition.primaryKeyColumns.map { quoteIdentifier($0) }.joined(separator: ", ") - parts.append("PRIMARY KEY (\(pkCols))") + 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 { diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index 38127c8e2..6284713bd 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -488,8 +488,11 @@ struct CreateTableView: View { parts.append(def) } - // Primary key from columns marked as PK - let pkColumns = columns.filter { $0.isPrimaryKey } + // Primary key: columns marked as PK, or AUTO_INCREMENT columns (MySQL requires a key) + var pkColumns = columns.filter { $0.isPrimaryKey } + if pkColumns.isEmpty { + pkColumns = columns.filter { $0.autoIncrement } + } if !pkColumns.isEmpty { let pkNames = pkColumns.map { quote($0.name) }.joined(separator: ", ") parts.append(" PRIMARY KEY (\(pkNames))") From ba4a5caf0f0dda65f06a6b057a0f68ef40028c24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 30 Mar 2026 13:12:49 +0700 Subject: [PATCH 08/18] feat: add Primary Key column to Create Table grid Add .primaryKey to StructureColumnField enum and StructureRowProvider. CreateTableView passes additionalFields: [.primaryKey] so PK column appears in create mode without affecting existing Structure tab. --- .../StructureColumnField.swift | 2 ++ .../Views/Structure/CreateTableView.swift | 11 +++++--- .../Structure/StructureRowProvider.swift | 25 ++++++++++++++----- 3 files changed, 28 insertions(+), 10 deletions(-) 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/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index 6284713bd..ea1c0c4e8 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -182,7 +182,8 @@ struct CreateTableView: View { let provider = StructureRowProvider( changeManager: structureChangeManager, tab: structureTab, - databaseType: connection.type + databaseType: connection.type, + additionalFields: [.primaryKey] ) return DataGridView( @@ -327,7 +328,8 @@ struct CreateTableView: View { 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.comment = value.isEmpty ? nil : value + case 4: column.isPrimaryKey = value.uppercased() == "YES" || value == "1" + case 5: column.comment = value.isEmpty ? nil : value default: break } } else { @@ -336,8 +338,9 @@ struct CreateTableView: View { 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.autoIncrement = value.uppercased() == "YES" || value == "1" - case 5: column.comment = 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 } } 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 From adf53d3ed61347cb500fa1609bbd9df66e57e62a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 30 Mar 2026 13:17:54 +0700 Subject: [PATCH 09/18] fix: convert create-table tab to table tab after successful creation --- TablePro/Views/Structure/CreateTableView.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index ea1c0c4e8..d0f9dfd94 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -588,11 +588,17 @@ struct CreateTableView: View { NotificationCenter.default.post(name: .refreshData, object: nil) - // Close this window and open the new table - if let coordinator { + // Convert the create-table tab to a regular table tab showing the new table + if let coordinator, + let tabIndex = coordinator.tabManager.selectedTabIndex { + coordinator.tabManager.tabs[tabIndex].tabType = .table + coordinator.tabManager.tabs[tabIndex].tableName = tableName + coordinator.tabManager.tabs[tabIndex].showStructure = false + coordinator.tabManager.tabs[tabIndex].hasUserInteraction = false + coordinator.tabManager.tabs[tabIndex].isEditable = true + coordinator.tabManager.tabs[tabIndex].query = "" coordinator.openTableTab(tableName) } - NSApp.keyWindow?.close() } catch { Self.logger.error("Create table failed: \(error.localizedDescription, privacy: .public)") errorMessage = error.localizedDescription From 7124249efc4717589522da8c9a09f146944fe5b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 30 Mar 2026 13:20:04 +0700 Subject: [PATCH 10/18] fix: open create table inline when no tabs exist --- .../MainContentCoordinator+SidebarActions.swift | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift index 225703ff9..f15439c9b 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SidebarActions.swift @@ -15,12 +15,16 @@ extension MainContentCoordinator { func createNewTable() { guard !safeModeLevel.blocksAllWrites else { return } - let payload = EditorTabPayload( - connectionId: connection.id, - tabType: .createTable, - databaseName: connection.database - ) - WindowOpener.shared.openNativeTab(payload) + 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 From b2f8060b04cbc2f13ea8438cf755981b9a3fe7ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 30 Mar 2026 13:24:36 +0700 Subject: [PATCH 11/18] fix: use DDLTextView for syntax-highlighted SQL preview --- TablePro/Views/Structure/CreateTableView.swift | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index d0f9dfd94..fe23bb6f1 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -243,14 +243,7 @@ struct CreateTableView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - ScrollView { - Text(previewSQL) - .font(.system(.body, design: .monospaced)) - .textSelection(.enabled) - .padding() - .frame(maxWidth: .infinity, alignment: .leading) - } - .background(Color(nsColor: .textBackgroundColor)) + DDLTextView(ddl: previewSQL, fontSize: .constant(13)) } } .onAppear { generatePreviewSQL() } From 619bef8da9325ed5c0f8444bed587d02c6a3eba3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 30 Mar 2026 13:37:50 +0700 Subject: [PATCH 12/18] fix: address code review issues for Create Table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use plugin driver's generateCreateTableSQL instead of hardcoded MySQL syntax — now generates correct DDL for all 6 databases - Make CreateTableOptions fields non-optional with defaults (InnoDB, utf8mb4) — fixes SwiftUI Picker tag type mismatch - Reset isCreating with defer on both success and failure paths - Remove orphaned openTableTab + in-place tab mutation; just call openTableTab which handles tab replacement naturally - Add CHANGELOG entries under [Unreleased] --- CHANGELOG.md | 6 + .../Models/Schema/CreateTableOptions.swift | 8 +- .../Views/Structure/CreateTableView.swift | 138 +++++++----------- 3 files changed, 63 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55206f1ef..ab03ec5d8 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 + ## [0.26.0] - 2026-03-29 ### Added diff --git a/TablePro/Models/Schema/CreateTableOptions.swift b/TablePro/Models/Schema/CreateTableOptions.swift index 5689c78d8..f8b5149e3 100644 --- a/TablePro/Models/Schema/CreateTableOptions.swift +++ b/TablePro/Models/Schema/CreateTableOptions.swift @@ -8,9 +8,9 @@ import Foundation struct CreateTableOptions: Hashable { - var engine: String? - var charset: String? - var collation: String? + var engine: String = "InnoDB" + var charset: String = "utf8mb4" + var collation: String = "utf8mb4_unicode_ci" var ifNotExists: Bool = false static let engines = [ @@ -24,7 +24,7 @@ struct CreateTableOptions: Hashable { "euckr", "gb2312", "gbk", "sjis" ] - static let collations: [String?: [String]] = [ + static let collations: [String: [String]] = [ "utf8mb4": [ "utf8mb4_unicode_ci", "utf8mb4_general_ci", "utf8mb4_bin", "utf8mb4_0900_ai_ci", "utf8mb4_unicode_520_ci" diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index fe23bb6f1..1b4212152 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -451,88 +451,63 @@ struct CreateTableView: View { let columns = structureChangeManager.workingColumns.filter { !$0.name.isEmpty && !$0.dataType.isEmpty } guard !columns.isEmpty else { return nil } - let pluginDriver = (DatabaseManager.shared.driver(for: connection.id) as? PluginDriverAdapter)?.schemaPluginDriver - let quote: (String) -> String = pluginDriver?.quoteIdentifier ?? { name in - let escaped = name.replacingOccurrences(of: "\"", with: "\"\"") - return "\"\(escaped)\"" - } - let escape: (String) -> String = pluginDriver?.escapeStringLiteral ?? { value in - value.replacingOccurrences(of: "'", with: "''") - } - - var parts: [String] = [] - - // Column definitions - for col in columns { - var def = " \(quote(col.name)) \(col.dataType)" - if col.unsigned { def += " UNSIGNED" } - if !col.isNullable { def += " NOT NULL" } - if col.autoIncrement { def += " AUTO_INCREMENT" } - if let defaultVal = col.defaultValue, !defaultVal.isEmpty { - if isDefaultExpression(defaultVal) { - def += " DEFAULT \(defaultVal)" - } else { - def += " DEFAULT '\(escape(defaultVal))'" - } - } - if let onUpdate = col.onUpdate, !onUpdate.isEmpty { - def += " ON UPDATE \(onUpdate)" - } - if let comment = col.comment, !comment.isEmpty { - def += " COMMENT '\(escape(comment))'" - } - parts.append(def) - } - - // Primary key: columns marked as PK, or AUTO_INCREMENT columns (MySQL requires a key) - var pkColumns = columns.filter { $0.isPrimaryKey } + var pkColumns = columns.filter { $0.isPrimaryKey }.map(\.name) if pkColumns.isEmpty { - pkColumns = columns.filter { $0.autoIncrement } - } - if !pkColumns.isEmpty { - let pkNames = pkColumns.map { quote($0.name) }.joined(separator: ", ") - parts.append(" PRIMARY KEY (\(pkNames))") + pkColumns = columns.filter { $0.autoIncrement }.map(\.name) } - // Indexes - let indexes = structureChangeManager.workingIndexes.filter { !$0.name.isEmpty && !$0.columns.isEmpty } - for idx in indexes { - let idxCols = idx.columns.map { quote($0) }.joined(separator: ", ") - let unique = idx.isUnique ? "UNIQUE " : "" - parts.append(" \(unique)INDEX \(quote(idx.name)) (\(idxCols))") - } + 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 + ) - // Foreign keys - let foreignKeys = structureChangeManager.workingForeignKeys.filter { - !$0.name.isEmpty && !$0.columns.isEmpty && !$0.referencedTable.isEmpty && !$0.referencedColumns.isEmpty - } - for fk in foreignKeys { - let fkCols = fk.columns.map { quote($0) }.joined(separator: ", ") - let refCols = fk.referencedColumns.map { quote($0) }.joined(separator: ", ") - var constraint = " CONSTRAINT \(quote(fk.name)) FOREIGN KEY (\(fkCols))" - constraint += " REFERENCES \(quote(fk.referencedTable)) (\(refCols))" - if fk.onDelete != .noAction { - constraint += " ON DELETE \(fk.onDelete.rawValue)" - } - if fk.onUpdate != .noAction { - constraint += " ON UPDATE \(fk.onUpdate.rawValue)" - } - parts.append(constraint) - } + let pluginDriver = (DatabaseManager.shared.driver(for: connection.id) as? PluginDriverAdapter)?.schemaPluginDriver + return pluginDriver?.generateCreateTableSQL(definition: definition) + } - var sql = "CREATE TABLE \(quote(tableName.isEmpty ? "untitled" : tableName)) (\n" - sql += parts.joined(separator: ",\n") - sql += "\n)" + 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 + ) + } - // MySQL/MariaDB table options - if showMySQLOptions { - if let engine = tableOptions.engine { sql += " ENGINE=\(engine)" } - if let charset = tableOptions.charset { sql += " DEFAULT CHARSET=\(charset)" } - if let collation = tableOptions.collation { sql += " COLLATE=\(collation)" } - } + private func toPluginIndexDefinition(_ index: EditableIndexDefinition) -> PluginIndexDefinition { + PluginIndexDefinition( + name: index.name, + columns: index.columns, + isUnique: index.isUnique, + indexType: index.type.rawValue + ) + } - sql += ";" - return sql + 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 + ) } private func isDefaultExpression(_ value: String) -> Bool { @@ -560,6 +535,7 @@ struct CreateTableView: View { errorMessage = nil Task { + defer { isCreating = false } do { guard let driver = DatabaseManager.shared.driver(for: connection.id) else { throw NSError( @@ -581,21 +557,13 @@ struct CreateTableView: View { NotificationCenter.default.post(name: .refreshData, object: nil) - // Convert the create-table tab to a regular table tab showing the new table - if let coordinator, - let tabIndex = coordinator.tabManager.selectedTabIndex { - coordinator.tabManager.tabs[tabIndex].tabType = .table - coordinator.tabManager.tabs[tabIndex].tableName = tableName - coordinator.tabManager.tabs[tabIndex].showStructure = false - coordinator.tabManager.tabs[tabIndex].hasUserInteraction = false - coordinator.tabManager.tabs[tabIndex].isEditable = true - coordinator.tabManager.tabs[tabIndex].query = "" + // Replace create-table tab with the new table + if let coordinator { coordinator.openTableTab(tableName) } } catch { Self.logger.error("Create table failed: \(error.localizedDescription, privacy: .public)") errorMessage = error.localizedDescription - isCreating = false } } } From 36ed7008d7ff62a81c79e2baa81d40aaba49ba20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 30 Mar 2026 13:51:51 +0700 Subject: [PATCH 13/18] refactor: align CreateTableView with native macOS tab layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove footer action bar and Cancel button (tab pattern, not sheet) - Move Create button + Add/Delete buttons into toolbar row alongside segmented picker: [+ -] [Columns|Indexes|FKs|SQL] [Create Table] - Replace inline error with .alert dialog - Remove extra Dividers (3 → 2, matching TableStructureView) - Use consistent .padding() throughout (was mixed 10/12/16) - Remove .background on config bar (match Structure tab default) --- .../Views/Structure/CreateTableView.swift | 97 ++++++++----------- 1 file changed, 41 insertions(+), 56 deletions(-) diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index 1b4212152..4eb8ad20a 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -31,6 +31,7 @@ struct CreateTableView: View { @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 @@ -50,24 +51,27 @@ struct CreateTableView: View { var body: some View { VStack(spacing: 0) { - tableNameBar + configBar Divider() - tabPicker + toolbar Divider() tabContent - Divider() - actionBar } .onAppear { if structureChangeManager.workingColumns.isEmpty { structureChangeManager.addNewColumn() } } + .alert(String(localized: "Create Table Failed"), isPresented: $showError) { + Button("OK") {} + } message: { + Text(errorMessage ?? "") + } } - // MARK: - Table Name Bar + // MARK: - Config Bar - private var tableNameBar: some View { + private var configBar: some View { HStack(spacing: 12) { Text("Table Name:") .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .medium)) @@ -116,9 +120,7 @@ struct CreateTableView: View { Spacer() } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(Color(nsColor: .controlBackgroundColor)) + .padding() .onChange(of: tableOptions.charset) { _, newCharset in if let first = CreateTableOptions.collations[newCharset]?.first { tableOptions.collation = first @@ -130,7 +132,7 @@ struct CreateTableView: View { connection.type == .mysql || connection.type == .mariadb } - // MARK: - Tab Picker + // MARK: - Toolbar private var availableTabs: [CreateTableTab] { var tabs = CreateTableTab.allCases @@ -140,9 +142,26 @@ struct CreateTableView: View { return tabs } - private var tabPicker: some View { - HStack { + private var isGridTab: Bool { + selectedTab != .sqlPreview + } + + private var toolbar: some View { + HStack(spacing: 8) { + Button(action: addNewRow) { + Image(systemName: "plus") + } + .help(String(localized: "Add Row")) + .disabled(!isGridTab) + + Button(action: { handleDeleteRows(selectedRows) }) { + Image(systemName: "minus") + } + .help(String(localized: "Delete Selected")) + .disabled(!isGridTab || selectedRows.isEmpty) + Spacer() + Picker("", selection: $selectedTab) { ForEach(availableTabs, id: \.self) { tab in Text(tab.rawValue).tag(tab) @@ -150,7 +169,15 @@ struct CreateTableView: View { } .pickerStyle(.segmented) .labelsHidden() + Spacer() + + Button(isCreating ? String(localized: "Creating...") : String(localized: "Create Table")) { + createTable() + } + .buttonStyle(.borderedProminent) + .disabled(tableName.isEmpty || structureChangeManager.workingColumns.isEmpty || isCreating) + .keyboardShortcut(.return, modifiers: .command) } .padding() } @@ -229,7 +256,6 @@ struct CreateTableView: View { .disabled(previewSQL.isEmpty) } .padding() - .background(Color(nsColor: .controlBackgroundColor)) Divider() @@ -255,36 +281,6 @@ struct CreateTableView: View { ClipboardService.shared.writeText(previewSQL) } - // MARK: - Action Bar - - private var actionBar: some View { - HStack { - if let error = errorMessage { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.red) - Text(error) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .foregroundStyle(.red) - .lineLimit(1) - } - - Spacer() - - Button("Cancel") { - NSApp.keyWindow?.close() - } - - Button(isCreating ? String(localized: "Creating...") : String(localized: "Create Table")) { - createTable() - } - .buttonStyle(.borderedProminent) - .disabled(tableName.isEmpty || structureChangeManager.workingColumns.isEmpty || isCreating) - .keyboardShortcut(.return, modifiers: .command) - } - .padding(12) - .background(Color(nsColor: .controlBackgroundColor)) - } - // MARK: - Cell Editing private func handleCellEdit(_ row: Int, _ column: Int, _ value: String?) { @@ -510,24 +506,13 @@ struct CreateTableView: View { ) } - private func isDefaultExpression(_ value: String) -> Bool { - let upper = value.uppercased() - return upper == "NULL" - || upper == "CURRENT_TIMESTAMP" - || upper == "NOW()" - || upper == "TRUE" - || upper == "FALSE" - || upper.hasPrefix("CURRENT_") - || Int64(value) != nil - || Double(value) != nil - } - // 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 } @@ -557,13 +542,13 @@ struct CreateTableView: View { NotificationCenter.default.post(name: .refreshData, object: nil) - // Replace create-table tab with the new table if let coordinator { coordinator.openTableTab(tableName) } } catch { Self.logger.error("Create table failed: \(error.localizedDescription, privacy: .public)") errorMessage = error.localizedDescription + showError = true } } } From 59a0e40d9f778e0f90a9eb4da2b33001a29618be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 30 Mar 2026 13:57:13 +0700 Subject: [PATCH 14/18] fix: address CreateTableView UI/UX issues - Window title shows "Create Table" instead of "SQL Query" - Create Table button uses blue accent color instead of connection tint - Remove green "inserted" row highlighting (all rows are new in create mode, highlighting everything is noise) - Add subtle background to config bar for visual separation from toolbar --- TablePro/Views/Main/MainContentView.swift | 4 +++- TablePro/Views/Structure/CreateTableView.swift | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) 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/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index 4eb8ad20a..11948e280 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -57,6 +57,7 @@ struct CreateTableView: View { Divider() tabContent } + .navigationTitle(String(localized: "Create Table")) .onAppear { if structureChangeManager.workingColumns.isEmpty { structureChangeManager.addNewColumn() @@ -121,6 +122,7 @@ struct CreateTableView: View { Spacer() } .padding() + .background(Color(nsColor: .controlBackgroundColor)) .onChange(of: tableOptions.charset) { _, newCharset in if let first = CreateTableOptions.collations[newCharset]?.first { tableOptions.collation = first @@ -176,6 +178,7 @@ struct CreateTableView: View { createTable() } .buttonStyle(.borderedProminent) + .tint(.accentColor) .disabled(tableName.isEmpty || structureChangeManager.workingColumns.isEmpty || isCreating) .keyboardShortcut(.return, modifiers: .command) } @@ -228,9 +231,7 @@ struct CreateTableView: View { onAddRow: { addNewRow() }, onUndoInsert: nil, onFilterColumn: nil, - getVisualState: { row in - structureChangeManager.getVisualState(for: row, tab: structureTab) - }, + getVisualState: nil, dropdownColumns: provider.dropdownColumns, typePickerColumns: provider.typePickerColumns, connectionId: connection.id, From 162e60f21b6e421d97068012338e2f0c455fdc71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 30 Mar 2026 13:59:34 +0700 Subject: [PATCH 15/18] fix: equalize +/- button sizes with fixed frame --- TablePro/Views/Structure/CreateTableView.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index 11948e280..65fed3955 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -152,12 +152,14 @@ struct CreateTableView: 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) From 377d312439e4b87fbb4cf6f10ef5050e4f694c31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 30 Mar 2026 14:02:21 +0700 Subject: [PATCH 16/18] fix: remove redundant Copy button row from SQL Preview tab --- .../Views/Structure/CreateTableView.swift | 19 +------------------ 1 file changed, 1 insertion(+), 18 deletions(-) diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index 65fed3955..089f160d6 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -249,19 +249,7 @@ struct CreateTableView: View { // MARK: - SQL Preview private var sqlPreviewView: some View { - VStack(spacing: 0) { - HStack { - Spacer() - Button(action: copyPreviewSQL) { - Label("Copy", systemImage: "doc.on.doc") - } - .buttonStyle(.bordered) - .disabled(previewSQL.isEmpty) - } - .padding() - - Divider() - + Group { if previewSQL.isEmpty { VStack(spacing: 8) { Image(systemName: "doc.plaintext") @@ -279,11 +267,6 @@ struct CreateTableView: View { .onChange(of: structureChangeManager.reloadVersion) { generatePreviewSQL() } } - private func copyPreviewSQL() { - guard !previewSQL.isEmpty else { return } - ClipboardService.shared.writeText(previewSQL) - } - // MARK: - Cell Editing private func handleCellEdit(_ row: Int, _ column: Int, _ value: String?) { From b1d1675b2b0bb3f95cd80bd81d1660a62915b1dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 30 Mar 2026 14:04:38 +0700 Subject: [PATCH 17/18] fix: use native Picker labels instead of separate Text + labelsHidden --- .../Views/Structure/CreateTableView.swift | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/TablePro/Views/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index 089f160d6..a797eca9e 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -85,38 +85,26 @@ struct CreateTableView: View { Divider() .frame(height: 20) - Text("Engine:") - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .foregroundStyle(.secondary) - Picker("", selection: $tableOptions.engine) { + Picker("Engine:", selection: $tableOptions.engine) { ForEach(CreateTableOptions.engines, id: \.self) { engine in Text(engine).tag(engine) } } - .labelsHidden() - .frame(width: 100) + .fixedSize() - Text("Charset:") - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .foregroundStyle(.secondary) - Picker("", selection: $tableOptions.charset) { + Picker("Charset:", selection: $tableOptions.charset) { ForEach(CreateTableOptions.charsets, id: \.self) { cs in Text(cs).tag(cs) } } - .labelsHidden() - .frame(width: 100) + .fixedSize() - Text("Collation:") - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .foregroundStyle(.secondary) - Picker("", selection: $tableOptions.collation) { + Picker("Collation:", selection: $tableOptions.collation) { ForEach(CreateTableOptions.collations[tableOptions.charset] ?? [], id: \.self) { col in Text(col).tag(col) } } - .labelsHidden() - .frame(width: 180) + .fixedSize() } Spacer() From 8bfce27df34685d8ae6864d1b13f042fd2cee47b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Mon, 30 Mar 2026 14:22:23 +0700 Subject: [PATCH 18/18] fix: address final review issues for Create Table - Add missing collations for cp1251, big5, euckr, gb2312, gbk, sjis - SQL preview updates on table name and options changes - Localize tab labels with String(localized:) via displayName - Add MySQL CREATE TABLE test suite (10 tests) - Add "Creating a New Table" section to docs/features/table-structure.mdx --- .../Models/Schema/CreateTableOptions.swift | 6 + .../Views/Structure/CreateTableView.swift | 23 ++- .../Plugins/MySQLCreateTableTests.swift | 183 ++++++++++++++++++ docs/features/table-structure.mdx | 16 ++ 4 files changed, 222 insertions(+), 6 deletions(-) create mode 100644 TableProTests/Plugins/MySQLCreateTableTests.swift diff --git a/TablePro/Models/Schema/CreateTableOptions.swift b/TablePro/Models/Schema/CreateTableOptions.swift index f8b5149e3..2cea3da73 100644 --- a/TablePro/Models/Schema/CreateTableOptions.swift +++ b/TablePro/Models/Schema/CreateTableOptions.swift @@ -36,5 +36,11 @@ struct CreateTableOptions: Hashable { "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/Structure/CreateTableView.swift b/TablePro/Views/Structure/CreateTableView.swift index a797eca9e..d9c7f2f22 100644 --- a/TablePro/Views/Structure/CreateTableView.swift +++ b/TablePro/Views/Structure/CreateTableView.swift @@ -11,11 +11,20 @@ import os import SwiftUI import TableProPluginKit -private enum CreateTableTab: String, CaseIterable { - case columns = "Columns" - case indexes = "Indexes" - case foreignKeys = "Foreign Keys" - case sqlPreview = "SQL Preview" +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 { @@ -156,7 +165,7 @@ struct CreateTableView: View { Picker("", selection: $selectedTab) { ForEach(availableTabs, id: \.self) { tab in - Text(tab.rawValue).tag(tab) + Text(tab.displayName).tag(tab) } } .pickerStyle(.segmented) @@ -253,6 +262,8 @@ struct CreateTableView: View { } .onAppear { generatePreviewSQL() } .onChange(of: structureChangeManager.reloadVersion) { generatePreviewSQL() } + .onChange(of: tableName) { generatePreviewSQL() } + .onChange(of: tableOptions) { generatePreviewSQL() } } // MARK: - Cell Editing 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