diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b78d75cf..ee41184dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Generated and Expression fields on the column row, with a stored or virtual choice. (#2478) - `check_constraints` and `generation_expression` in the MCP `describe_table` response. - Remote File pane for SQLite, opening a read-only copy of a database that lives on an SSH server. (#2474) +- Rename on a table's right-click menu, editing the row's label in place. (#2482) +- Rename Database and Rename Schema on the sidebar's container rows, where the engine has them. (#2482) ### Changed diff --git a/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift b/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift index 45ec3ccb9..629860ca5 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift @@ -16,6 +16,8 @@ final class BigQueryPlugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "BigQuery" + + static let supportsRenameTable = true static let databaseDisplayName = "Google BigQuery" static let iconName = "bigquery-icon" static let defaultPort = 0 diff --git a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver+Rename.swift b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver+Rename.swift new file mode 100644 index 000000000..4aba88e32 --- /dev/null +++ b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver+Rename.swift @@ -0,0 +1,18 @@ +// +// BigQueryPluginDriver+Rename.swift +// BigQueryDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension BigQueryPluginDriver { + /// The new name is bare and the table stays in its dataset. BigQuery refuses the statement + /// while a streaming buffer is active, which is roughly five hours after the last row streamed + /// in, and for an external table; both come back as the server's own message. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let quoted = quoteIdentifier(name) + let target = schema.map { "\(quoteIdentifier($0)).\(quoted)" } ?? quoted + _ = try await execute(query: "ALTER \(objectType) \(target) RENAME TO \(quoteIdentifier(newName))") + } +} diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index 9faa8ee4c..23d0e280b 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -56,6 +56,8 @@ final class ClickHousePlugin: NSObject, TableProPlugin, DriverPlugin { static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .defaultValue, .comment] static let supportsQueryProgress = true static let supportsDropDatabase = true + static let supportsRenameTable = true + static let supportsRenameDatabase = true static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( identifierQuote: "`", diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Schema.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Schema.swift index 8987c0395..c2d27943f 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Schema.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Schema.swift @@ -322,6 +322,28 @@ extension ClickHousePluginDriver { _ = try await execute(query: "DROP DATABASE `\(escapedName)`") } + /// Both sides are qualified with the same database, so this renames in place. Qualifying them + /// differently is how ClickHouse moves a table, which is a different verb to the user. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let database = schema ?? lock.withLock { _currentDatabase } + let old = qualified(database: database, name: name) + let new = qualified(database: database, name: newName) + _ = try await execute(query: "RENAME TABLE \(old) TO \(new)") + } + + /// Needs the Atomic database engine, the default since 20.10. An Ordinary database refuses, + /// and the server's own message says so. + func renameDatabase(name: String, to newName: String) async throws { + _ = try await execute( + query: "RENAME DATABASE \(quoteIdentifier(name)) TO \(quoteIdentifier(newName))" + ) + } + + private func qualified(database: String?, name: String) -> String { + guard let database, !database.isEmpty else { return quoteIdentifier(name) } + return "\(quoteIdentifier(database)).\(quoteIdentifier(name))" + } + // MARK: - All Tables Metadata func allTablesMetadataSQL(schema: String?) -> String? { diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift index 348e886db..231d72757 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift @@ -14,6 +14,9 @@ final class CloudflareD1Plugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "Cloudflare D1" + + static let supportsRenameTable = true + static let supportsRenameView = false static let databaseDisplayName = "Cloudflare D1" static let iconName = "cloudflare-d1-icon" static let defaultPort = 0 diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver+Rename.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver+Rename.swift new file mode 100644 index 000000000..a5fd32d4b --- /dev/null +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver+Rename.swift @@ -0,0 +1,20 @@ +// +// CloudflareD1PluginDriver+Rename.swift +// CloudflareD1DriverPlugin +// + +import Foundation +import TableProPluginKit + +extension CloudflareD1PluginDriver { + /// SQLite's rules, and SQLite's one rename: `ALTER TABLE` refuses a view. A D1 database is an + /// API object whose edit endpoint accepts only read replication, so its name cannot change. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + guard objectType.uppercased() == "TABLE" else { + throw PluginDriverUnsupportedOperation.renameTable + } + _ = try await execute( + query: "ALTER TABLE \(quoteIdentifier(name)) RENAME TO \(quoteIdentifier(newName))" + ) + } +} diff --git a/Plugins/DamengDriverPlugin/DamengPlugin.swift b/Plugins/DamengDriverPlugin/DamengPlugin.swift index 6ca76bb33..a3de3bb83 100644 --- a/Plugins/DamengDriverPlugin/DamengPlugin.swift +++ b/Plugins/DamengDriverPlugin/DamengPlugin.swift @@ -8,6 +8,8 @@ final class DamengPlugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "Dameng" + + static let supportsRenameTable = true static let databaseDisplayName = "Dameng DM8" static let iconName = "cylinder" static let defaultPort = 5_236 diff --git a/Plugins/DamengDriverPlugin/DamengPluginDriver+Rename.swift b/Plugins/DamengDriverPlugin/DamengPluginDriver+Rename.swift new file mode 100644 index 000000000..24a4bde49 --- /dev/null +++ b/Plugins/DamengDriverPlugin/DamengPluginDriver+Rename.swift @@ -0,0 +1,17 @@ +// +// DamengPluginDriver+Rename.swift +// DamengDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension DamengPluginDriver { + /// Oracle-compatible, so the new name stays bare. Dameng also ships `sp_rename`, which is not + /// used here: the ALTER form is the one its own documentation leads with. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let quoted = quoteIdentifier(name) + let target = schema.map { "\(quoteIdentifier($0)).\(quoted)" } ?? quoted + _ = try await execute(query: "ALTER \(objectType) \(target) RENAME TO \(quoteIdentifier(newName))") + } +} diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index 5eafabcfc..35bcaa264 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -15,6 +15,8 @@ final class DuckDBPlugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "DuckDB" + + static let supportsRenameTable = true static let databaseDisplayName = "DuckDB" static let iconName = "duckdb-icon" static let defaultPort = 9_494 diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPluginDriver+Rename.swift b/Plugins/DuckDBDriverPlugin/DuckDBPluginDriver+Rename.swift new file mode 100644 index 000000000..346d0a8f1 --- /dev/null +++ b/Plugins/DuckDBDriverPlugin/DuckDBPluginDriver+Rename.swift @@ -0,0 +1,17 @@ +// +// DuckDBPluginDriver+Rename.swift +// DuckDBDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension DuckDBPluginDriver { + /// The new name is bare and the object stays in its schema. DuckDB has no `ALTER SCHEMA + /// RENAME` and no database rename at all, so those stay unimplemented. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let quoted = quoteIdentifier(name) + let target = schema.map { "\(quoteIdentifier($0)).\(quoted)" } ?? quoted + _ = try await execute(query: "ALTER \(objectType) \(target) RENAME TO \(quoteIdentifier(newName))") + } +} diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift b/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift index c560d48f8..465575c84 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift @@ -14,6 +14,9 @@ final class LibSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "libSQL" + + static let supportsRenameTable = true + static let supportsRenameView = false static let additionalDatabaseTypeIds = ["Turso"] static let databaseDisplayName = "libSQL / Turso" static let iconName = "libsql-icon" diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver+Rename.swift b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver+Rename.swift new file mode 100644 index 000000000..4a8b1a44f --- /dev/null +++ b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver+Rename.swift @@ -0,0 +1,20 @@ +// +// LibSQLPluginDriver+Rename.swift +// LibSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension LibSQLPluginDriver { + /// SQLite's rules, and SQLite's one rename: `ALTER TABLE` refuses a view. A Turso database + /// name has no libSQL wire operation, which is why `dropDatabase` already refuses too. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + guard objectType.uppercased() == "TABLE" else { + throw PluginDriverUnsupportedOperation.renameTable + } + _ = try await execute( + query: "ALTER TABLE \(quoteIdentifier(name)) RENAME TO \(quoteIdentifier(newName))" + ) + } +} diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 7379700de..04249db1a 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -74,6 +74,8 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "SQL Server" + + static let supportsRenameTable = true static let databaseDisplayName = "SQL Server" static let iconName = "mssql-icon" static let defaultPort = 1433 diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Rename.swift b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Rename.swift new file mode 100644 index 000000000..8da14129e --- /dev/null +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Rename.swift @@ -0,0 +1,27 @@ +// +// MSSQLPluginDriver+Rename.swift +// MSSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension MSSQLPluginDriver { + /// `sp_rename` takes names as string literals rather than identifiers, and the new one must be + /// a single part: passing `schema.new` renames the object to something literally called + /// "schema.new". Its object type argument is what tells the procedure this is not a column. + /// + /// Each half of the old name is bracketed before the two are joined, because `@objname` is + /// parsed as a multipart name: a table legitimately called `quarter.1` would otherwise be read + /// as the object `1` in the schema `quarter`. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let qualified = [schema, name].compactMap { $0 }.map(quoteIdentifier).joined(separator: ".") + _ = try await execute( + query: "EXEC sp_rename \(literal(qualified)), \(literal(newName)), 'OBJECT'" + ) + } + + private func literal(_ value: String) -> String { + "N'\(value.replacingOccurrences(of: "'", with: "''"))'" + } +} diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift b/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift index 3b49a6355..b3efa9bd8 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPlugin.swift @@ -13,6 +13,8 @@ final class MongoDBPlugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "MongoDB" + + static let supportsRenameTable = true static let databaseDisplayName = "MongoDB" static let iconName = "mongodb-icon" static let defaultPort = 27017 diff --git a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift index 54a8847ac..297800cb6 100644 --- a/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift +++ b/Plugins/MongoDBDriverPlugin/MongoDBPluginDriver.swift @@ -635,6 +635,22 @@ final class MongoDBPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) } + /// `renameCollection` runs against `admin` and nowhere else, and it names both sides with the + /// full `database.collection`, so the two halves cannot be quoted or qualified the way a SQL + /// driver's would be. Atlas grants only the same-database form, which is all this offers. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + guard let conn = mongoConnection else { + throw MongoDBPluginError.notConnected + } + let database = schema ?? currentDb + let from = "\"\(escapeJsonString("\(database).\(name)"))\"" + let to = "\"\(escapeJsonString("\(database).\(newName)"))\"" + _ = try await conn.runCommand( + "{\"renameCollection\": \(from), \"to\": \(to)}", + database: "admin" + ) + } + func dropDatabase(name: String) async throws { guard let conn = mongoConnection else { throw MongoDBPluginError.notConnected diff --git a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift index 069cd20d5..9ce414fc7 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift @@ -103,6 +103,7 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { ) static let supportsDropDatabase = true + static let supportsRenameTable = true static let supportsTriggers = true static let supportsRoutines = true static let supportsDatabaseTriggerBrowse = true diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index db438257c..297f2a505 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -703,6 +703,15 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { _ = try await execute(query: "DROP DATABASE `\(escapedName)`") } + /// `RENAME TABLE` rather than `ALTER TABLE ... RENAME TO`, because it is the only form that + /// takes a view, and both sides are qualified with the same schema so the statement cannot + /// move the object anywhere. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let old = MySQLObjectQueries.qualifiedIdentifier(schema: schema, name: name) + let new = MySQLObjectQueries.qualifiedIdentifier(schema: schema, name: newName) + _ = try await execute(query: "RENAME TABLE \(old) TO \(new)") + } + // MARK: - Database Switching func switchDatabase(to database: String) async throws { diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 8e555a042..0f698b023 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -15,6 +15,8 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin, PluginDiagnost static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "Oracle" + + static let supportsRenameTable = true static let databaseDisplayName = "Oracle" static let iconName = "oracle-icon" static let defaultPort = 1_521 diff --git a/Plugins/OracleDriverPlugin/OraclePluginDriver+Rename.swift b/Plugins/OracleDriverPlugin/OraclePluginDriver+Rename.swift new file mode 100644 index 000000000..96027ec20 --- /dev/null +++ b/Plugins/OracleDriverPlugin/OraclePluginDriver+Rename.swift @@ -0,0 +1,19 @@ +// +// OraclePluginDriver+Rename.swift +// OracleDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension OraclePluginDriver { + /// The new name must be bare. A qualified one raises ORA-14047, because Oracle renames in + /// place and has no statement that moves an object between schemas. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let quoted = OracleObjectQueries.quoteIdentifier(name) + let target = schema.map { "\(OracleObjectQueries.quoteIdentifier($0)).\(quoted)" } ?? quoted + _ = try await execute( + query: "ALTER \(objectType) \(target) RENAME TO \(OracleObjectQueries.quoteIdentifier(newName))" + ) + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift index bc705349c..cbb9bcbdf 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift @@ -231,6 +231,31 @@ protocol LibPQBackedDriver: PluginDatabaseDriver { } extension LibPQBackedDriver { + /// The new name must be bare. Every libpq engine here rejects a qualified one, because this + /// statement renames in place and never moves the object; `SET SCHEMA` is the separate verb. + /// + /// It lives on the protocol rather than on `PostgreSQLPluginDriver`, because Redshift and + /// CockroachDB are siblings of that class rather than subclasses: an implementation there + /// leaves both of them declaring the capability with nothing behind it. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let target = "\(quoteIdentifier(schema ?? core.currentSchema)).\(quoteIdentifier(name))" + _ = try await execute(query: "ALTER \(objectType) \(target) RENAME TO \(quoteIdentifier(newName))") + } + + /// Not the database the connection is on: PostgreSQL, Redshift and CockroachDB all answer that + /// with a refusal, so the app keeps the item off a row it is browsing. + func renameDatabase(name: String, to newName: String) async throws { + _ = try await execute( + query: "ALTER DATABASE \(quoteIdentifier(name)) RENAME TO \(quoteIdentifier(newName))" + ) + } + + func renameSchema(name: String, to newName: String) async throws { + _ = try await execute( + query: "ALTER SCHEMA \(quoteIdentifier(name)) RENAME TO \(quoteIdentifier(newName))" + ) + } + func connect() async throws { try await core.connect() } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift index 7144b1470..c6b52b320 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift @@ -83,6 +83,9 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let requiresReconnectForDatabaseSwitch = true static let parameterStyle: ParameterStyle = .dollar static let supportsDropDatabase = true + static let supportsRenameTable = true + static let supportsRenameDatabase = true + static let supportsRenameSchema = true static let supportsDropSchema = true static let supportsTriggers = true static let supportsRoutines = true diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index 7b76a1d41..30f4e26e0 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -37,6 +37,8 @@ final class SQLitePlugin: NSObject, TableProPlugin, DriverPlugin { static let fileExtensions: [String] = ["db", "db3", "s3db", "sl3", "sqlite", "sqlite3", "sqlitedb"] static let brandColorHex = "#003B57" static let supportsDatabaseSwitching = false + static let supportsRenameTable = true + static let supportsRenameView = false static let supportsTriggers = true static let supportsDatabaseTriggerBrowse = true static let supportsTriggerEditing = true @@ -1166,6 +1168,19 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - ALTER TABLE DDL + /// `ALTER TABLE` is the only rename SQLite has and it refuses a view, so a view is turned + /// away here rather than by a message from the engine. From 3.25 the statement rewrites the + /// references to the table in every trigger and view, and from 3.26 in every foreign key, + /// unless `PRAGMA legacy_alter_table` is on. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + guard objectType.uppercased() == "TABLE" else { + throw PluginDriverUnsupportedOperation.renameTable + } + _ = try await execute( + query: "ALTER TABLE \(quoteIdentifier(name)) RENAME TO \(quoteIdentifier(newName))" + ) + } + func generateAddColumnSQL(table: String, column: PluginColumnDefinition) -> String? { let colDef = sqliteColumnDefinition(addableColumn(column), inlinePK: false) return "ALTER TABLE \(quoteIdentifier(table)) ADD COLUMN \(colDef)" diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift index 3c7bf5fad..55a1b4c35 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift @@ -18,6 +18,15 @@ final class SnowflakePlugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "Snowflake" + + static let supportsRenameTable = true + + /// Off, and not because Snowflake refuses: `ALTER DATABASE ... RENAME TO` works and the driver + /// implements it. This tree hangs tables off schemas and draws no database rows at all, so + /// there is nowhere to raise the command from. Turn it back on with the row that reaches it. + static let supportsRenameDatabase = false + + static let supportsRenameSchema = true static let databaseDisplayName = "Snowflake" static let iconName = "snowflake-icon" static let defaultPort = 443 diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver+Rename.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver+Rename.swift new file mode 100644 index 000000000..0c0a4fc24 --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver+Rename.swift @@ -0,0 +1,31 @@ +// +// SnowflakePluginDriver+Rename.swift +// SnowflakeDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension SnowflakePluginDriver { + /// Snowflake accepts a qualified new name and treats it as a move, so both sides are qualified + /// the same way and the statement can only rename in place. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let target = qualifiedName(table: name, schema: schema) + let renamed = qualifiedName(table: newName, schema: schema) + _ = try await execute(query: "ALTER \(objectType) \(target) RENAME TO \(renamed)") + } + + /// Not the database the session is on: the rename succeeds but leaves the session pointing at + /// a name that no longer exists, so the app keeps the item off the row it is browsing. + func renameDatabase(name: String, to newName: String) async throws { + _ = try await execute( + query: "ALTER DATABASE \(quoteIdentifier(name)) RENAME TO \(quoteIdentifier(newName))" + ) + } + + func renameSchema(name: String, to newName: String) async throws { + _ = try await execute( + query: "ALTER SCHEMA \(quoteIdentifier(name)) RENAME TO \(quoteIdentifier(newName))" + ) + } +} diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index 55c2f8306..7a18be51e 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -66,6 +66,10 @@ public protocol DriverPlugin: TableProPlugin { static var parameterStyle: ParameterStyle { get } static var supportsDropDatabase: Bool { get } static var supportsDropSchema: Bool { get } + static var supportsRenameTable: Bool { get } + static var supportsRenameView: Bool { get } + static var supportsRenameDatabase: Bool { get } + static var supportsRenameSchema: Bool { get } static var supportsAddColumn: Bool { get } static var supportsModifyColumn: Bool { get } @@ -152,6 +156,12 @@ public extension DriverPlugin { static var postConnectActions: [PostConnectAction] { [] } static var supportsDropDatabase: Bool { false } static var supportsDropSchema: Bool { false } + static var supportsRenameTable: Bool { false } + /// SQLite's `ALTER TABLE ... RENAME` refuses a view, and the engines built on it inherit that. + /// Everywhere else a view renames the way a table does. + static var supportsRenameView: Bool { supportsRenameTable } + static var supportsRenameDatabase: Bool { false } + static var supportsRenameSchema: Bool { false } static var supportsAddColumn: Bool { true } static var supportsModifyColumn: Bool { true } diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 595a7b453..d1022ecab 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -144,6 +144,15 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func createDatabase(_ request: PluginCreateDatabaseRequest) async throws func dropDatabase(name: String) async throws func dropSchema(name: String) async throws + + /// Renaming runs rather than generating a statement, because for several engines it is not a + /// statement: MongoDB renames a collection through an admin command, SQL Server calls + /// `sp_rename`. The driver also owns the quoting, which differs even between two SQLite + /// builds here, and the rules for the new name: PostgreSQL and Oracle reject a qualified one, + /// Snowflake accepts one and treats it as a move. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws + func renameDatabase(name: String, to newName: String) async throws + func renameSchema(name: String, to newName: String) async throws func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult // Session contexts (optional, switchable session dimensions such as a warehouse or role) @@ -425,6 +434,18 @@ public extension PluginDatabaseDriver { ) } + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + throw PluginDriverUnsupportedOperation.renameTable + } + + func renameDatabase(name: String, to newName: String) async throws { + throw PluginDriverUnsupportedOperation.renameDatabase + } + + func renameSchema(name: String, to newName: String) async throws { + throw PluginDriverUnsupportedOperation.renameSchema + } + func dropDatabase(name: String) async throws { throw NSError(domain: "PluginDatabaseDriver", code: -1, userInfo: [NSLocalizedDescriptionKey: "Drop database is not supported by this driver"]) diff --git a/Plugins/TableProPluginKit/PluginDriverUnsupportedOperation.swift b/Plugins/TableProPluginKit/PluginDriverUnsupportedOperation.swift new file mode 100644 index 000000000..78d326bec --- /dev/null +++ b/Plugins/TableProPluginKit/PluginDriverUnsupportedOperation.swift @@ -0,0 +1,33 @@ +// +// PluginDriverUnsupportedOperation.swift +// TableProPluginKit +// + +import Foundation + +/// A rename the engine has no operation for. +/// +/// The three are separate because they are separate facts about an engine, and one message for +/// all of them would be wrong for most of it. MySQL renames a table and has had no way to rename +/// a database since 5.1.23; Oracle renames a table while its "databases" here are users, which it +/// cannot rename at all; Cassandra can rename neither, because a CQL `ALTER TABLE ... RENAME` +/// renames primary key columns. +/// +/// A driver reaches these only through a default implementation. Where the engine can do the +/// work, its `DriverPlugin` says so and the menu never offers what would throw. +public enum PluginDriverUnsupportedOperation: Error, LocalizedError, Sendable { + case renameTable + case renameDatabase + case renameSchema + + public var errorDescription: String? { + switch self { + case .renameTable: + return String(localized: "This database cannot rename a table") + case .renameDatabase: + return String(localized: "This database cannot be renamed") + case .renameSchema: + return String(localized: "This database cannot rename a schema") + } + } +} diff --git a/Plugins/TeradataDriverPlugin/TeradataPlugin.swift b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift index b82a8e859..a18a876a1 100644 --- a/Plugins/TeradataDriverPlugin/TeradataPlugin.swift +++ b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift @@ -35,6 +35,8 @@ final class TeradataPlugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "Teradata" + + static let supportsRenameTable = true static let databaseDisplayName = "Teradata" static let iconName = "teradata-icon" static let defaultPort = 1_025 diff --git a/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Rename.swift b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Rename.swift new file mode 100644 index 000000000..608d4a9a5 --- /dev/null +++ b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Rename.swift @@ -0,0 +1,21 @@ +// +// TeradataPluginDriver+Rename.swift +// TeradataDriverPlugin +// + +import Foundation +import TableProPluginKit +import TableProTeradataCore + +extension TeradataPluginDriver { + /// Teradata cannot move a table between databases, so the new name is bare and the object + /// keeps its own. Views, macros and procedures each need their own `RENAME` keyword, which is + /// what the object type carries. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let quoted = TeradataSchemaQueries.quoteIdentifier(name) + let target = schema.map { "\(TeradataSchemaQueries.quoteIdentifier($0)).\(quoted)" } ?? quoted + _ = try await execute( + query: "RENAME \(objectType) \(target) TO \(TeradataSchemaQueries.quoteIdentifier(newName))" + ) + } +} diff --git a/Plugins/TrinoDriverPlugin/TrinoPlugin.swift b/Plugins/TrinoDriverPlugin/TrinoPlugin.swift index 02fba93c7..d332caa89 100644 --- a/Plugins/TrinoDriverPlugin/TrinoPlugin.swift +++ b/Plugins/TrinoDriverPlugin/TrinoPlugin.swift @@ -8,6 +8,10 @@ final class TrinoPlugin: NSObject, TableProPlugin, DriverPlugin { static let capabilities: [PluginCapability] = [.databaseDriver] static let databaseTypeId = "Trino" + + static let supportsRenameTable = true + + static let supportsRenameSchema = true static let databaseDisplayName = "Trino" static let iconName = "trino-icon" static let defaultPort = 8_080 diff --git a/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Rename.swift b/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Rename.swift new file mode 100644 index 000000000..967549823 --- /dev/null +++ b/Plugins/TrinoDriverPlugin/TrinoPluginDriver+Rename.swift @@ -0,0 +1,27 @@ +// +// TrinoPluginDriver+Rename.swift +// TrinoDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension TrinoPluginDriver { + /// Whether this works at all is the connector's decision, and many answer "this connector does + /// not support renaming tables". That message is the honest one to show, so nothing here tries + /// to predict it. The new name is bare: Trino renames within a schema and never across a + /// catalog. + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + let quoted = quoteIdentifier(name) + let target = schema.map { "\(quoteIdentifier($0)).\(quoted)" } ?? quoted + _ = try await execute(query: "ALTER \(objectType) \(target) RENAME TO \(quoteIdentifier(newName))") + } + + /// A Trino "database" in the tree is a catalog, which is configuration rather than an object, + /// so only the schema level is renameable. + func renameSchema(name: String, to newName: String) async throws { + _ = try await execute( + query: "ALTER SCHEMA \(quoteIdentifier(name)) RENAME TO \(quoteIdentifier(newName))" + ) + } +} diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 724fe73bc..567feffeb 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -200,6 +200,12 @@ protocol DatabaseDriver: AnyObject, Sendable { func dropSchema(name: String) async throws + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws + + func renameDatabase(name: String, to newName: String) async throws + + func renameSchema(name: String, to newName: String) async throws + func fetchSessionContexts() async throws -> [PluginSessionContext]? func switchSessionContext(id: String, to value: String) async throws @@ -365,6 +371,18 @@ extension DatabaseDriver { userInfo: [NSLocalizedDescriptionKey: "Drop schema is not supported by this driver"]) } + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + throw PluginDriverUnsupportedOperation.renameTable + } + + func renameDatabase(name: String, to newName: String) async throws { + throw PluginDriverUnsupportedOperation.renameDatabase + } + + func renameSchema(name: String, to newName: String) async throws { + throw PluginDriverUnsupportedOperation.renameSchema + } + func createDatabaseFormSpec() async throws -> CreateDatabaseFormSpec? { nil } func fetchSessionContexts() async throws -> [PluginSessionContext]? { nil } diff --git a/TablePro/Core/Database/TableOperationSQLBuilder.swift b/TablePro/Core/Database/TableOperationSQLBuilder.swift index 7240c3f33..9c484cbe8 100644 --- a/TablePro/Core/Database/TableOperationSQLBuilder.swift +++ b/TablePro/Core/Database/TableOperationSQLBuilder.swift @@ -81,22 +81,9 @@ struct TableOperationSQLBuilder { guard let adapter = adapterProvider() else { return "" } return adapter.dropObjectStatement( name: ref.table.name, - objectType: Self.dropKeyword(for: ref.table.type), + objectType: TableObjectKeyword.forDDL(ref.table.type), schema: ref.qualifyingSchema, cascade: options.cascade ) } - - private static func dropKeyword(for type: TableInfo.TableType) -> String { - switch type { - case .view: - return "VIEW" - case .materializedView: - return "MATERIALIZED VIEW" - case .foreignTable: - return "FOREIGN TABLE" - case .table, .systemTable, .partitionedTable, .externalTable: - return "TABLE" - } - } } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index d0317a3fe..df823305d 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -473,6 +473,18 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor try await pluginDriver.dropSchema(name: name) } + func renameTable(name: String, schema: String?, to newName: String, objectType: String) async throws { + try await pluginDriver.renameTable(name: name, schema: schema, to: newName, objectType: objectType) + } + + func renameDatabase(name: String, to newName: String) async throws { + try await pluginDriver.renameDatabase(name: name, to: newName) + } + + func renameSchema(name: String, to newName: String) async throws { + try await pluginDriver.renameSchema(name: name, to: newName) + } + func fetchSessionContexts() async throws -> [PluginSessionContext]? { try await pluginDriver.fetchSessionContexts() } diff --git a/TablePro/Core/Plugins/PluginManager+Registration.swift b/TablePro/Core/Plugins/PluginManager+Registration.swift index 33eb824d4..04081972a 100644 --- a/TablePro/Core/Plugins/PluginManager+Registration.swift +++ b/TablePro/Core/Plugins/PluginManager+Registration.swift @@ -546,6 +546,26 @@ extension PluginManager { .capabilities.supportsDropSchema ?? false } + func supportsRenameTable(for databaseType: DatabaseType) -> Bool { + PluginMetadataRegistry.shared.snapshot(for: databaseType)? + .capabilities.supportsRenameTable ?? false + } + + func supportsRenameView(for databaseType: DatabaseType) -> Bool { + PluginMetadataRegistry.shared.snapshot(for: databaseType)? + .capabilities.supportsRenameView ?? false + } + + func supportsRenameDatabase(for databaseType: DatabaseType) -> Bool { + PluginMetadataRegistry.shared.snapshot(for: databaseType)? + .capabilities.supportsRenameDatabase ?? false + } + + func supportsRenameSchema(for databaseType: DatabaseType) -> Bool { + PluginMetadataRegistry.shared.snapshot(for: databaseType)? + .capabilities.supportsRenameSchema ?? false + } + func autoLimitStyle(for databaseType: DatabaseType) -> AutoLimitStyle { guard let snapshot = PluginMetadataRegistry.shared.snapshot(for: databaseType) else { return .limit diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift new file mode 100644 index 000000000..133670e5e --- /dev/null +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift @@ -0,0 +1,675 @@ +// +// PluginMetadataRegistry+CuratedDefaults.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// What the app knows about a database type before its plugin loads. +/// +/// The primary type ids here are overwritten by `buildMetadataSnapshot` the moment the plugin +/// registers, so these are the pre-load answer for those. For a variant id they are the whole +/// answer: `registerVariant` keeps the curated entry and ignores the plugin's own statics, which +/// is the only reason MariaDB, Redshift, CockroachDB and PGlite can differ from the plugin that +/// drives them. +extension PluginMetadataRegistry { + // swiftlint:disable:next function_body_length + static func curatedDefaults() -> [(typeId: String, snapshot: PluginMetadataSnapshot)] { + let mysqlDialect = SQLDialectDescriptor( + identifierQuote: "`", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", + "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", "ALIAS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "MODIFY", "CHANGE", "COLUMN", "RENAME", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", + "CASE", "WHEN", "THEN", "ELSE", "END", "IF", "IFNULL", "COALESCE", + "UNION", "INTERSECT", "EXCEPT", + "FORCE", "USE", "IGNORE", "STRAIGHT_JOIN", "DUAL", + "SHOW", "DESCRIBE", "EXPLAIN" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", "GROUP_CONCAT", + "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", + "TRIM", "LTRIM", "RTRIM", "REPLACE", + "NOW", "CURDATE", "CURTIME", "DATE", "TIME", "YEAR", "MONTH", "DAY", + "DATE_ADD", "DATE_SUB", "DATEDIFF", "TIMESTAMPDIFF", + "ROUND", "CEIL", "FLOOR", "ABS", "MOD", "POW", "SQRT", + "CAST", "CONVERT" + ], + dataTypes: [ + "INT", "INTEGER", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT", + "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "REAL", + "CHAR", "VARCHAR", "TEXT", "TINYTEXT", "MEDIUMTEXT", "LONGTEXT", + "BLOB", "TINYBLOB", "MEDIUMBLOB", "LONGBLOB", + "DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR", + "ENUM", "SET", "JSON", "BOOL", "BOOLEAN" + ], + tableOptions: [ + "ENGINE=InnoDB", "DEFAULT CHARSET=utf8mb4", "COLLATE=utf8mb4_unicode_ci", + "AUTO_INCREMENT=", "COMMENT=", "ROW_FORMAT=" + ], + regexSyntax: .regexp, + booleanLiteralStyle: .numeric, + likeEscapeStyle: .implicit, + paginationStyle: .limit, + requiresBackslashEscaping: true, + caseSensitivityStyle: .collationDefined + ) + + let mysqlColumnTypes: [String: [String]] = [ + "Integer": ["TINYINT", "SMALLINT", "MEDIUMINT", "INT", "INTEGER", "BIGINT"], + "Float": ["FLOAT", "DOUBLE", "DECIMAL", "NUMERIC", "REAL"], + "String": ["CHAR", "VARCHAR", "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT", "ENUM", "SET"], + "Date": ["DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR"], + "Binary": ["BINARY", "VARBINARY", "TINYBLOB", "BLOB", "MEDIUMBLOB", "LONGBLOB", "BIT"], + "Boolean": ["BOOLEAN", "BOOL"], + "JSON": ["JSON"], + "Spatial": ["GEOMETRY", "POINT", "LINESTRING", "POLYGON"] + ] + + let postgresqlDialect = SQLDialectDescriptor( + identifierQuote: "\"", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", + "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "ILIKE", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "FETCH", "FIRST", "ROWS", "ONLY", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "MODIFY", "COLUMN", "RENAME", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", + "UNION", "INTERSECT", "EXCEPT", + "RETURNING", "WITH", "RECURSIVE", "MATERIALIZED", + "EXPLAIN", "ANALYZE", "VERBOSE", + "WINDOW", "OVER", "PARTITION", + "LATERAL", "ORDINALITY" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", "STRING_AGG", "ARRAY_AGG", + "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", + "TRIM", "LTRIM", "RTRIM", "REPLACE", "SPLIT_PART", + "NOW", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", + "DATE_TRUNC", "EXTRACT", "AGE", "TO_CHAR", "TO_DATE", + "ROUND", "CEIL", "CEILING", "FLOOR", "ABS", "MOD", "POW", "POWER", "SQRT", + "CAST", "TO_NUMBER", "TO_TIMESTAMP", + "JSON_BUILD_OBJECT", "JSON_AGG", "JSONB_BUILD_OBJECT" + ], + dataTypes: [ + "INTEGER", "INT", "SMALLINT", "BIGINT", "SERIAL", "BIGSERIAL", "SMALLSERIAL", + "DECIMAL", "NUMERIC", "REAL", "DOUBLE", "PRECISION", + "CHAR", "CHARACTER", "VARCHAR", "TEXT", + "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL", + "BOOLEAN", "BOOL", "JSON", "JSONB", "UUID", "BYTEA", "ARRAY" + ], + tableOptions: [ + "INHERITS", "PARTITION BY", "TABLESPACE", "WITH", "WITHOUT OIDS" + ], + regexSyntax: .tilde, + booleanLiteralStyle: .truefalse, + likeEscapeStyle: .explicit, + paginationStyle: .limit, + caseSensitivityStyle: .ilikeOperator + ) + + // Redshift ILIKE only folds ASCII, so it uses LOWER on both sides instead. + let redshiftDialect = postgresqlDialect.withCaseSensitivityStyle(.caseFoldFunction) + + let postgresqlColumnTypes: [String: [String]] = [ + "Integer": ["SMALLINT", "INTEGER", "BIGINT", "SERIAL", "BIGSERIAL", "SMALLSERIAL"], + "Float": ["REAL", "DOUBLE PRECISION", "NUMERIC", "DECIMAL", "MONEY"], + "String": ["CHARACTER VARYING", "VARCHAR", "CHARACTER", "CHAR", "TEXT", "NAME"], + "Date": [ + "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL", + "TIME WITH TIME ZONE", "TIMESTAMP WITH TIME ZONE" + ], + "Binary": ["BYTEA"], + "Boolean": ["BOOLEAN"], + "JSON": ["JSON", "JSONB"], + "UUID": ["UUID"], + "Array": ["ARRAY"], + "Network": ["INET", "CIDR", "MACADDR", "MACADDR8"], + "Geometric": ["POINT", "LINE", "LSEG", "BOX", "PATH", "POLYGON", "CIRCLE"], + "Range": ["INT4RANGE", "INT8RANGE", "NUMRANGE", "TSRANGE", "TSTZRANGE", "DATERANGE"], + "Text Search": ["TSVECTOR", "TSQUERY"], + "XML": ["XML"] + ] + + let sqliteDialect = SQLDialectDescriptor( + identifierQuote: "`", + keywords: [ + "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", + "ON", "AND", "OR", "NOT", "IN", "LIKE", "GLOB", "BETWEEN", "AS", + "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", + "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", + "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "TRIGGER", + "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", + "ADD", "COLUMN", "RENAME", + "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", + "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "IFNULL", "NULLIF", + "UNION", "INTERSECT", "EXCEPT", + "AUTOINCREMENT", "WITHOUT", "ROWID", "PRAGMA", + "REPLACE", "ABORT", "FAIL", "IGNORE", "ROLLBACK", + "TEMP", "TEMPORARY", "VACUUM", "EXPLAIN", "QUERY", "PLAN" + ], + functions: [ + "COUNT", "SUM", "AVG", "MAX", "MIN", "GROUP_CONCAT", "TOTAL", + "LENGTH", "SUBSTR", "SUBSTRING", "LOWER", "UPPER", "TRIM", "LTRIM", "RTRIM", + "REPLACE", "INSTR", "PRINTF", + "DATE", "TIME", "DATETIME", "JULIANDAY", "STRFTIME", + "ABS", "ROUND", "RANDOM", + "CAST", "TYPEOF", + "COALESCE", "IFNULL", "NULLIF", "HEX", "QUOTE" + ], + dataTypes: [ + "INTEGER", "REAL", "TEXT", "BLOB", "NUMERIC", + "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT", + "UNSIGNED", "BIG", "INT2", "INT8", + "CHARACTER", "VARCHAR", "VARYING", "NCHAR", "NATIVE", + "NVARCHAR", "CLOB", + "DOUBLE", "PRECISION", "FLOAT", + "DECIMAL", "BOOLEAN", "DATE", "DATETIME" + ], + tableOptions: [ + "WITHOUT ROWID", "STRICT" + ], + regexSyntax: .unsupported, + booleanLiteralStyle: .numeric, + likeEscapeStyle: .explicit, + paginationStyle: .limit, + caseSensitivityStyle: .collationDefined + ) + + let sqliteColumnTypes: [String: [String]] = [ + "Integer": ["INTEGER", "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT"], + "Float": ["REAL", "DOUBLE", "FLOAT", "NUMERIC", "DECIMAL"], + "String": ["TEXT", "VARCHAR", "CHARACTER", "CHAR", "CLOB", "NVARCHAR", "NCHAR"], + "Date": ["DATE", "TIME", "DATETIME", "TIMESTAMP"], + "Binary": ["BLOB"], + "Boolean": ["BOOLEAN"] + ] + + let pgpassField = ConnectionField( + id: "usePgpass", + label: String(localized: "Use Password File"), + defaultValue: "false", + fieldType: .toggle, + section: .authentication, + hidesPassword: true + ) + + let connectionOptionsField = ConnectionField( + id: "connectionOptions", + label: String(localized: "Connection Options"), + placeholder: "--cluster=my-cluster", + fieldType: .text, + section: .advanced + ) + + let awsIAMFields = AWSAuthFields.standard() + [AWSAuthFields.rdsEndpointField()] + + let defaults: [(typeId: String, snapshot: PluginMetadataSnapshot)] = [ + ("MySQL", PluginMetadataSnapshot( + displayName: "MySQL", iconName: "mysql-icon", defaultPort: 3_306, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: false, primaryUrlScheme: "mysql", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["mysql"], postConnectActions: [.selectDatabaseFromLastSession], + brandColorHex: "#FF9500", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: true, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: true, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: false, + supportsForeignKeyDisable: true, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: true, + supportsRenameTable: true, + supportsRenameView: true, + supportsRenameDatabase: false, + supportsRenameSchema: false, + supportsRenameColumn: true, + supportsTriggers: true, + supportsTriggerEditing: true, + supportsCheckConstraints: true, + supportsCheckConstraintEditing: true, + supportsGeneratedColumns: true, + supportsRoutines: true, + supportsDatabaseTriggerBrowse: true, + defaultSSLMode: .preferred + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: ["information_schema", "mysql", "performance_schema", "sys"], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .byDatabase, + structureColumnFields: [ + .name, .type, .nullable, .defaultValue, .generated, .generationExpression, + .onUpdate, .autoIncrement, .comment, .charset, .collation + ] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: mysqlDialect, + statementCompletions: [], + columnTypesByCategory: mysqlColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: awsIAMFields, + category: .relational, + tagline: String(localized: "Most popular open-source SQL database"), + defaultUnixSocketPath: "/var/run/mysqld/mysqld.sock" + ) + )), + ("MariaDB", PluginMetadataSnapshot( + displayName: "MariaDB", iconName: "mariadb-icon", defaultPort: 3_306, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: false, primaryUrlScheme: "mariadb", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["mariadb"], postConnectActions: [.selectDatabaseFromLastSession], + brandColorHex: "#00B4D8", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: true, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: true, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: false, + supportsForeignKeyDisable: true, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: true, + supportsRenameTable: true, + supportsRenameView: true, + supportsRenameDatabase: false, + supportsRenameSchema: false, + supportsRenameColumn: true, + supportsTriggers: true, + supportsTriggerEditing: true, + supportsCheckConstraints: true, + supportsCheckConstraintEditing: true, + supportsGeneratedColumns: true, + supportsRoutines: true, + supportsDatabaseTriggerBrowse: true, + defaultSSLMode: .preferred + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: ["information_schema", "mysql", "performance_schema", "sys"], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .byDatabase, + structureColumnFields: [ + .name, .type, .nullable, .defaultValue, .generated, .generationExpression, + .onUpdate, .autoIncrement, .comment, .charset, .collation + ] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: mysqlDialect, + statementCompletions: [], + columnTypesByCategory: mysqlColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: awsIAMFields, + category: .relational, + tagline: String(localized: "Open-source fork of MySQL"), + defaultUnixSocketPath: "/var/run/mysqld/mysqld.sock" + ) + )), + ("PostgreSQL", PluginMetadataSnapshot( + displayName: "PostgreSQL", iconName: "postgresql-icon", defaultPort: 5_432, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: false, primaryUrlScheme: "postgresql", parameterStyle: .dollar, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["postgresql", "postgres"], + postConnectActions: [.selectSchemaFromLastSession], + brandColorHex: "#336791", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: true, + supportsImport: true, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: true, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: true, + supportsDropDatabase: true, + supportsRenameTable: true, + supportsRenameView: true, + supportsRenameDatabase: true, + supportsRenameSchema: true, + supportsDropSchema: true, + supportsRenameColumn: true, + supportsTriggers: true, + supportsTriggerEditing: true, + supportsCheckConstraints: true, + supportsCheckConstraintEditing: true, + supportsGeneratedColumns: true, + supportsRoutines: true, + supportsDatabaseTriggerBrowse: true, + defaultSSLMode: .preferred + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: [], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .bySchema, + structureColumnFields: [ + .name, .type, .nullable, .defaultValue, .generated, .generationExpression, .comment + ] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: postgresqlDialect, + statementCompletions: [], + columnTypesByCategory: postgresqlColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: [pgpassField, connectionOptionsField] + awsIAMFields, + category: .relational, + tagline: String(localized: "Advanced object-relational SQL"), + defaultUnixSocketPath: "/var/run/postgresql/.s.PGSQL.5432" + ) + )), + ("Redshift", PluginMetadataSnapshot( + displayName: "Redshift", iconName: "redshift-icon", defaultPort: 5_439, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: false, + isDownloadable: false, primaryUrlScheme: "redshift", parameterStyle: .dollar, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["redshift"], + postConnectActions: [.selectSchemaFromLastSession], + brandColorHex: "#205B8E", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: true, + supportsImport: true, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: true, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: true, + supportsDropDatabase: true, + supportsRenameTable: true, + supportsRenameView: true, + supportsRenameDatabase: true, + supportsRenameSchema: true, + supportsDropSchema: true, + defaultSSLMode: .preferred + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: ["padb_harvest"], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .bySchema, + structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: redshiftDialect, + statementCompletions: [], + columnTypesByCategory: postgresqlColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: [pgpassField, connectionOptionsField], + category: .analytical, + tagline: String(localized: "Amazon's columnar warehouse on Postgres") + ) + )), + ("CockroachDB", PluginMetadataSnapshot( + displayName: "CockroachDB", iconName: "cockroachdb-icon", defaultPort: 26_257, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: false, + isDownloadable: false, primaryUrlScheme: "cockroachdb", parameterStyle: .dollar, + navigationModel: .standard, + explainVariants: [ + ExplainVariant( + id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .cockroachText + ), + ExplainVariant( + id: "analyze", + label: "EXPLAIN ANALYZE", + sqlPrefix: "EXPLAIN ANALYZE", + format: .cockroachText + ), + ], + pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["cockroachdb", "cockroach"], + postConnectActions: [.selectSchemaFromLastSession], + brandColorHex: "#6933FF", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: true, + supportsImport: true, + supportsExport: true, + supportsSSH: true, + supportsSSL: true, + supportsCascadeDrop: true, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: true, + supportsDropDatabase: true, + supportsRenameTable: true, + supportsRenameView: true, + supportsRenameDatabase: true, + supportsRenameSchema: true, + supportsDropSchema: true, + supportsAddColumn: false, + supportsModifyColumn: false, + supportsDropColumn: false, + supportsRenameColumn: false, + supportsAddIndex: false, + supportsDropIndex: false, + supportsModifyPrimaryKey: false, + supportsCheckConstraints: true, + supportsCheckConstraintEditing: true, + supportsGeneratedColumns: true, + defaultSSLMode: .preferred + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: ["system"], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .bySchema, + structureColumnFields: [ + .name, .type, .nullable, .defaultValue, .generated, .generationExpression, .comment + ] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: postgresqlDialect, + statementCompletions: [], + columnTypesByCategory: postgresqlColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: [pgpassField, connectionOptionsField], + category: .relational, + tagline: String(localized: "Distributed SQL, PostgreSQL-compatible") + ) + )), + ("PGlite", PluginMetadataSnapshot( + displayName: "PGlite", iconName: "postgresql-icon", defaultPort: 5_432, + requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: false, primaryUrlScheme: "pglite", parameterStyle: .dollar, + navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + supportsHealthMonitor: true, urlSchemes: ["pglite"], + postConnectActions: [.selectSchemaFromLastSession], + brandColorHex: "#F4B942", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .network, supportsDatabaseSwitching: true, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: true, + supportsImport: true, + supportsExport: true, + supportsSSH: false, + supportsSSL: false, + supportsCascadeDrop: true, + supportsForeignKeyDisable: false, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: true, + supportsDropDatabase: true, + supportsRenameTable: true, + supportsRenameView: true, + supportsRenameDatabase: false, + supportsRenameSchema: true, + supportsDropSchema: true, + supportsRenameColumn: true, + supportsTriggers: true, + supportsTriggerEditing: true, + supportsCheckConstraints: true, + supportsCheckConstraintEditing: true, + supportsGeneratedColumns: true, + defaultSSLMode: .disabled, + supportsCloudflareTunnel: false, + supportsConnectionPooling: false + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: [], + systemSchemaNames: [], + fileExtensions: [], + databaseGroupingStrategy: .bySchema, + structureColumnFields: [ + .name, .type, .nullable, .defaultValue, .generated, .generationExpression, .comment + ] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: postgresqlDialect, + statementCompletions: [], + columnTypesByCategory: postgresqlColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + additionalConnectionFields: [], + category: .relational, + tagline: String(localized: "Embedded WASM Postgres over a socket server"), + hidesBuiltInPassword: true, + defaultHost: "127.0.0.1" + ) + )), + ("SQLite", PluginMetadataSnapshot( + displayName: "SQLite", iconName: "sqlite-icon", defaultPort: 0, + requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true, + isDownloadable: false, primaryUrlScheme: "sqlite", parameterStyle: .questionMark, + navigationModel: .standard, explainVariants: [], pathFieldRole: .filePath, + supportsHealthMonitor: false, urlSchemes: ["sqlite"], postConnectActions: [], + brandColorHex: "#003B57", + queryLanguageName: "SQL", editorLanguage: .sql, + connectionMode: .fileBased, supportsDatabaseSwitching: false, + supportsColumnReorder: false, + capabilities: PluginMetadataSnapshot.CapabilityFlags( + supportsSchemaSwitching: false, + supportsImport: true, + supportsExport: true, + supportsSSH: false, + supportsSSL: false, + supportsCascadeDrop: false, + supportsForeignKeyDisable: true, + supportsReadOnlyMode: true, + supportsQueryProgress: false, + requiresReconnectForDatabaseSwitch: false, + supportsDropDatabase: false, + supportsRenameTable: true, + supportsRenameView: false, + supportsRenameDatabase: false, + supportsRenameSchema: false, + supportsModifyColumn: false, + supportsRenameColumn: true, + supportsModifyPrimaryKey: false, + supportsTriggers: true, + supportsTriggerEditing: true, + supportsCheckConstraints: true, + supportsGeneratedColumns: true, + supportsDatabaseTriggerBrowse: true, + supportsCloudflareTunnel: false, + localFilePathField: .database, + supportsRemoteDatabaseFile: true + ), + schema: PluginMetadataSnapshot.SchemaInfo( + defaultSchemaName: "public", + defaultGroupName: "main", + tableEntityName: "Tables", + containerEntityName: "Database", + defaultPrimaryKeyColumn: nil, + immutableColumns: [], + systemDatabaseNames: [], + systemSchemaNames: [], + fileExtensions: ["db", "db3", "s3db", "sl3", "sqlite", "sqlite3", "sqlitedb"], + databaseGroupingStrategy: .flat, + structureColumnFields: [ + .name, .type, .nullable, .defaultValue, .generated, .generationExpression, + .autoIncrement, .comment + ] + ), + editor: PluginMetadataSnapshot.EditorConfig( + sqlDialect: sqliteDialect, + statementCompletions: [], + columnTypesByCategory: sqliteColumnTypes + ), + connection: PluginMetadataSnapshot.ConnectionConfig( + category: .relational, + tagline: String(localized: "Embedded zero-config SQL database") + ) + )) + ] + return defaults + } +} diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 182fae7ce..01f6f10ab 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -47,6 +47,10 @@ struct PluginMetadataSnapshot: Sendable { let supportsQueryProgress: Bool let requiresReconnectForDatabaseSwitch: Bool let supportsDropDatabase: Bool + var supportsRenameTable: Bool = false + var supportsRenameView: Bool = false + var supportsRenameDatabase: Bool = false + var supportsRenameSchema: Bool = false // `var` with defaults so existing call sites compile without passing these fields var supportsDropSchema: Bool = false var supportsAddColumn: Bool = true @@ -334,636 +338,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { registerBuiltInDefaults() } - // swiftlint:disable function_body_length private func registerBuiltInDefaults() { - let mysqlDialect = SQLDialectDescriptor( - identifierQuote: "`", - keywords: [ - "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", - "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "BETWEEN", "AS", "ALIAS", - "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", - "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", - "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", - "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", - "ADD", "MODIFY", "CHANGE", "COLUMN", "RENAME", - "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", - "CASE", "WHEN", "THEN", "ELSE", "END", "IF", "IFNULL", "COALESCE", - "UNION", "INTERSECT", "EXCEPT", - "FORCE", "USE", "IGNORE", "STRAIGHT_JOIN", "DUAL", - "SHOW", "DESCRIBE", "EXPLAIN" - ], - functions: [ - "COUNT", "SUM", "AVG", "MAX", "MIN", "GROUP_CONCAT", - "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", - "TRIM", "LTRIM", "RTRIM", "REPLACE", - "NOW", "CURDATE", "CURTIME", "DATE", "TIME", "YEAR", "MONTH", "DAY", - "DATE_ADD", "DATE_SUB", "DATEDIFF", "TIMESTAMPDIFF", - "ROUND", "CEIL", "FLOOR", "ABS", "MOD", "POW", "SQRT", - "CAST", "CONVERT" - ], - dataTypes: [ - "INT", "INTEGER", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT", - "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "REAL", - "CHAR", "VARCHAR", "TEXT", "TINYTEXT", "MEDIUMTEXT", "LONGTEXT", - "BLOB", "TINYBLOB", "MEDIUMBLOB", "LONGBLOB", - "DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR", - "ENUM", "SET", "JSON", "BOOL", "BOOLEAN" - ], - tableOptions: [ - "ENGINE=InnoDB", "DEFAULT CHARSET=utf8mb4", "COLLATE=utf8mb4_unicode_ci", - "AUTO_INCREMENT=", "COMMENT=", "ROW_FORMAT=" - ], - regexSyntax: .regexp, - booleanLiteralStyle: .numeric, - likeEscapeStyle: .implicit, - paginationStyle: .limit, - requiresBackslashEscaping: true, - caseSensitivityStyle: .collationDefined - ) - - let mysqlColumnTypes: [String: [String]] = [ - "Integer": ["TINYINT", "SMALLINT", "MEDIUMINT", "INT", "INTEGER", "BIGINT"], - "Float": ["FLOAT", "DOUBLE", "DECIMAL", "NUMERIC", "REAL"], - "String": ["CHAR", "VARCHAR", "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT", "ENUM", "SET"], - "Date": ["DATE", "TIME", "DATETIME", "TIMESTAMP", "YEAR"], - "Binary": ["BINARY", "VARBINARY", "TINYBLOB", "BLOB", "MEDIUMBLOB", "LONGBLOB", "BIT"], - "Boolean": ["BOOLEAN", "BOOL"], - "JSON": ["JSON"], - "Spatial": ["GEOMETRY", "POINT", "LINESTRING", "POLYGON"] - ] - - let postgresqlDialect = SQLDialectDescriptor( - identifierQuote: "\"", - keywords: [ - "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", "FULL", - "ON", "USING", "AND", "OR", "NOT", "IN", "LIKE", "ILIKE", "BETWEEN", "AS", - "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", "FETCH", "FIRST", "ROWS", "ONLY", - "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", - "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "DATABASE", "SCHEMA", - "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", - "ADD", "MODIFY", "COLUMN", "RENAME", - "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", "ANY", "SOME", - "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "NULLIF", - "UNION", "INTERSECT", "EXCEPT", - "RETURNING", "WITH", "RECURSIVE", "MATERIALIZED", - "EXPLAIN", "ANALYZE", "VERBOSE", - "WINDOW", "OVER", "PARTITION", - "LATERAL", "ORDINALITY" - ], - functions: [ - "COUNT", "SUM", "AVG", "MAX", "MIN", "STRING_AGG", "ARRAY_AGG", - "CONCAT", "SUBSTRING", "LEFT", "RIGHT", "LENGTH", "LOWER", "UPPER", - "TRIM", "LTRIM", "RTRIM", "REPLACE", "SPLIT_PART", - "NOW", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", - "DATE_TRUNC", "EXTRACT", "AGE", "TO_CHAR", "TO_DATE", - "ROUND", "CEIL", "CEILING", "FLOOR", "ABS", "MOD", "POW", "POWER", "SQRT", - "CAST", "TO_NUMBER", "TO_TIMESTAMP", - "JSON_BUILD_OBJECT", "JSON_AGG", "JSONB_BUILD_OBJECT" - ], - dataTypes: [ - "INTEGER", "INT", "SMALLINT", "BIGINT", "SERIAL", "BIGSERIAL", "SMALLSERIAL", - "DECIMAL", "NUMERIC", "REAL", "DOUBLE", "PRECISION", - "CHAR", "CHARACTER", "VARCHAR", "TEXT", - "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL", - "BOOLEAN", "BOOL", "JSON", "JSONB", "UUID", "BYTEA", "ARRAY" - ], - tableOptions: [ - "INHERITS", "PARTITION BY", "TABLESPACE", "WITH", "WITHOUT OIDS" - ], - regexSyntax: .tilde, - booleanLiteralStyle: .truefalse, - likeEscapeStyle: .explicit, - paginationStyle: .limit, - caseSensitivityStyle: .ilikeOperator - ) - - // Redshift ILIKE only folds ASCII, so it uses LOWER on both sides instead. - let redshiftDialect = postgresqlDialect.withCaseSensitivityStyle(.caseFoldFunction) - - let postgresqlColumnTypes: [String: [String]] = [ - "Integer": ["SMALLINT", "INTEGER", "BIGINT", "SERIAL", "BIGSERIAL", "SMALLSERIAL"], - "Float": ["REAL", "DOUBLE PRECISION", "NUMERIC", "DECIMAL", "MONEY"], - "String": ["CHARACTER VARYING", "VARCHAR", "CHARACTER", "CHAR", "TEXT", "NAME"], - "Date": [ - "DATE", "TIME", "TIMESTAMP", "TIMESTAMPTZ", "INTERVAL", - "TIME WITH TIME ZONE", "TIMESTAMP WITH TIME ZONE" - ], - "Binary": ["BYTEA"], - "Boolean": ["BOOLEAN"], - "JSON": ["JSON", "JSONB"], - "UUID": ["UUID"], - "Array": ["ARRAY"], - "Network": ["INET", "CIDR", "MACADDR", "MACADDR8"], - "Geometric": ["POINT", "LINE", "LSEG", "BOX", "PATH", "POLYGON", "CIRCLE"], - "Range": ["INT4RANGE", "INT8RANGE", "NUMRANGE", "TSRANGE", "TSTZRANGE", "DATERANGE"], - "Text Search": ["TSVECTOR", "TSQUERY"], - "XML": ["XML"] - ] - - let sqliteDialect = SQLDialectDescriptor( - identifierQuote: "`", - keywords: [ - "SELECT", "FROM", "WHERE", "JOIN", "INNER", "LEFT", "RIGHT", "OUTER", "CROSS", - "ON", "AND", "OR", "NOT", "IN", "LIKE", "GLOB", "BETWEEN", "AS", - "ORDER", "BY", "GROUP", "HAVING", "LIMIT", "OFFSET", - "INSERT", "INTO", "VALUES", "UPDATE", "SET", "DELETE", - "CREATE", "ALTER", "DROP", "TABLE", "INDEX", "VIEW", "TRIGGER", - "PRIMARY", "KEY", "FOREIGN", "REFERENCES", "UNIQUE", "CONSTRAINT", - "ADD", "COLUMN", "RENAME", - "NULL", "IS", "ASC", "DESC", "DISTINCT", "ALL", - "CASE", "WHEN", "THEN", "ELSE", "END", "COALESCE", "IFNULL", "NULLIF", - "UNION", "INTERSECT", "EXCEPT", - "AUTOINCREMENT", "WITHOUT", "ROWID", "PRAGMA", - "REPLACE", "ABORT", "FAIL", "IGNORE", "ROLLBACK", - "TEMP", "TEMPORARY", "VACUUM", "EXPLAIN", "QUERY", "PLAN" - ], - functions: [ - "COUNT", "SUM", "AVG", "MAX", "MIN", "GROUP_CONCAT", "TOTAL", - "LENGTH", "SUBSTR", "SUBSTRING", "LOWER", "UPPER", "TRIM", "LTRIM", "RTRIM", - "REPLACE", "INSTR", "PRINTF", - "DATE", "TIME", "DATETIME", "JULIANDAY", "STRFTIME", - "ABS", "ROUND", "RANDOM", - "CAST", "TYPEOF", - "COALESCE", "IFNULL", "NULLIF", "HEX", "QUOTE" - ], - dataTypes: [ - "INTEGER", "REAL", "TEXT", "BLOB", "NUMERIC", - "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT", - "UNSIGNED", "BIG", "INT2", "INT8", - "CHARACTER", "VARCHAR", "VARYING", "NCHAR", "NATIVE", - "NVARCHAR", "CLOB", - "DOUBLE", "PRECISION", "FLOAT", - "DECIMAL", "BOOLEAN", "DATE", "DATETIME" - ], - tableOptions: [ - "WITHOUT ROWID", "STRICT" - ], - regexSyntax: .unsupported, - booleanLiteralStyle: .numeric, - likeEscapeStyle: .explicit, - paginationStyle: .limit, - caseSensitivityStyle: .collationDefined - ) - - let sqliteColumnTypes: [String: [String]] = [ - "Integer": ["INTEGER", "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT"], - "Float": ["REAL", "DOUBLE", "FLOAT", "NUMERIC", "DECIMAL"], - "String": ["TEXT", "VARCHAR", "CHARACTER", "CHAR", "CLOB", "NVARCHAR", "NCHAR"], - "Date": ["DATE", "TIME", "DATETIME", "TIMESTAMP"], - "Binary": ["BLOB"], - "Boolean": ["BOOLEAN"] - ] - - let pgpassField = ConnectionField( - id: "usePgpass", - label: String(localized: "Use Password File"), - defaultValue: "false", - fieldType: .toggle, - section: .authentication, - hidesPassword: true - ) - - let connectionOptionsField = ConnectionField( - id: "connectionOptions", - label: String(localized: "Connection Options"), - placeholder: "--cluster=my-cluster", - fieldType: .text, - section: .advanced - ) - - let awsIAMFields = AWSAuthFields.standard() + [AWSAuthFields.rdsEndpointField()] - - let defaults: [(typeId: String, snapshot: PluginMetadataSnapshot)] = [ - ("MySQL", PluginMetadataSnapshot( - displayName: "MySQL", iconName: "mysql-icon", defaultPort: 3_306, - requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, - isDownloadable: false, primaryUrlScheme: "mysql", parameterStyle: .questionMark, - navigationModel: .standard, explainVariants: [], pathFieldRole: .database, - supportsHealthMonitor: true, urlSchemes: ["mysql"], postConnectActions: [.selectDatabaseFromLastSession], - brandColorHex: "#FF9500", - queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .network, supportsDatabaseSwitching: true, - supportsColumnReorder: true, - capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: false, - supportsImport: true, - supportsExport: true, - supportsSSH: true, - supportsSSL: true, - supportsCascadeDrop: false, - supportsForeignKeyDisable: true, - supportsReadOnlyMode: true, - supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: true, - supportsRenameColumn: true, - supportsTriggers: true, - supportsTriggerEditing: true, - supportsCheckConstraints: true, - supportsCheckConstraintEditing: true, - supportsGeneratedColumns: true, - supportsRoutines: true, - supportsDatabaseTriggerBrowse: true, - defaultSSLMode: .preferred - ), - schema: PluginMetadataSnapshot.SchemaInfo( - defaultSchemaName: "public", - defaultGroupName: "main", - tableEntityName: "Tables", - containerEntityName: "Database", - defaultPrimaryKeyColumn: nil, - immutableColumns: [], - systemDatabaseNames: ["information_schema", "mysql", "performance_schema", "sys"], - systemSchemaNames: [], - fileExtensions: [], - databaseGroupingStrategy: .byDatabase, - structureColumnFields: [ - .name, .type, .nullable, .defaultValue, .generated, .generationExpression, - .onUpdate, .autoIncrement, .comment, .charset, .collation - ] - ), - editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: mysqlDialect, - statementCompletions: [], - columnTypesByCategory: mysqlColumnTypes - ), - connection: PluginMetadataSnapshot.ConnectionConfig( - additionalConnectionFields: awsIAMFields, - category: .relational, - tagline: String(localized: "Most popular open-source SQL database"), - defaultUnixSocketPath: "/var/run/mysqld/mysqld.sock" - ) - )), - ("MariaDB", PluginMetadataSnapshot( - displayName: "MariaDB", iconName: "mariadb-icon", defaultPort: 3_306, - requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, - isDownloadable: false, primaryUrlScheme: "mariadb", parameterStyle: .questionMark, - navigationModel: .standard, explainVariants: [], pathFieldRole: .database, - supportsHealthMonitor: true, urlSchemes: ["mariadb"], postConnectActions: [.selectDatabaseFromLastSession], - brandColorHex: "#00B4D8", - queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .network, supportsDatabaseSwitching: true, - supportsColumnReorder: true, - capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: false, - supportsImport: true, - supportsExport: true, - supportsSSH: true, - supportsSSL: true, - supportsCascadeDrop: false, - supportsForeignKeyDisable: true, - supportsReadOnlyMode: true, - supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: true, - supportsRenameColumn: true, - supportsTriggers: true, - supportsTriggerEditing: true, - supportsCheckConstraints: true, - supportsCheckConstraintEditing: true, - supportsGeneratedColumns: true, - supportsRoutines: true, - supportsDatabaseTriggerBrowse: true, - defaultSSLMode: .preferred - ), - schema: PluginMetadataSnapshot.SchemaInfo( - defaultSchemaName: "public", - defaultGroupName: "main", - tableEntityName: "Tables", - containerEntityName: "Database", - defaultPrimaryKeyColumn: nil, - immutableColumns: [], - systemDatabaseNames: ["information_schema", "mysql", "performance_schema", "sys"], - systemSchemaNames: [], - fileExtensions: [], - databaseGroupingStrategy: .byDatabase, - structureColumnFields: [ - .name, .type, .nullable, .defaultValue, .generated, .generationExpression, - .onUpdate, .autoIncrement, .comment, .charset, .collation - ] - ), - editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: mysqlDialect, - statementCompletions: [], - columnTypesByCategory: mysqlColumnTypes - ), - connection: PluginMetadataSnapshot.ConnectionConfig( - additionalConnectionFields: awsIAMFields, - category: .relational, - tagline: String(localized: "Open-source fork of MySQL"), - defaultUnixSocketPath: "/var/run/mysqld/mysqld.sock" - ) - )), - ("PostgreSQL", PluginMetadataSnapshot( - displayName: "PostgreSQL", iconName: "postgresql-icon", defaultPort: 5_432, - requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, - isDownloadable: false, primaryUrlScheme: "postgresql", parameterStyle: .dollar, - navigationModel: .standard, explainVariants: [], pathFieldRole: .database, - supportsHealthMonitor: true, urlSchemes: ["postgresql", "postgres"], - postConnectActions: [.selectSchemaFromLastSession], - brandColorHex: "#336791", - queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .network, supportsDatabaseSwitching: true, - supportsColumnReorder: false, - capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: true, - supportsImport: true, - supportsExport: true, - supportsSSH: true, - supportsSSL: true, - supportsCascadeDrop: true, - supportsForeignKeyDisable: false, - supportsReadOnlyMode: true, - supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: true, - supportsDropDatabase: true, - supportsDropSchema: true, - supportsRenameColumn: true, - supportsTriggers: true, - supportsTriggerEditing: true, - supportsCheckConstraints: true, - supportsCheckConstraintEditing: true, - supportsGeneratedColumns: true, - supportsRoutines: true, - supportsDatabaseTriggerBrowse: true, - defaultSSLMode: .preferred - ), - schema: PluginMetadataSnapshot.SchemaInfo( - defaultSchemaName: "public", - defaultGroupName: "main", - tableEntityName: "Tables", - containerEntityName: "Database", - defaultPrimaryKeyColumn: nil, - immutableColumns: [], - systemDatabaseNames: [], - systemSchemaNames: [], - fileExtensions: [], - databaseGroupingStrategy: .bySchema, - structureColumnFields: [ - .name, .type, .nullable, .defaultValue, .generated, .generationExpression, .comment - ] - ), - editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: postgresqlDialect, - statementCompletions: [], - columnTypesByCategory: postgresqlColumnTypes - ), - connection: PluginMetadataSnapshot.ConnectionConfig( - additionalConnectionFields: [pgpassField, connectionOptionsField] + awsIAMFields, - category: .relational, - tagline: String(localized: "Advanced object-relational SQL"), - defaultUnixSocketPath: "/var/run/postgresql/.s.PGSQL.5432" - ) - )), - ("Redshift", PluginMetadataSnapshot( - displayName: "Redshift", iconName: "redshift-icon", defaultPort: 5_439, - requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: false, - isDownloadable: false, primaryUrlScheme: "redshift", parameterStyle: .dollar, - navigationModel: .standard, explainVariants: [], pathFieldRole: .database, - supportsHealthMonitor: true, urlSchemes: ["redshift"], - postConnectActions: [.selectSchemaFromLastSession], - brandColorHex: "#205B8E", - queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .network, supportsDatabaseSwitching: true, - supportsColumnReorder: false, - capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: true, - supportsImport: true, - supportsExport: true, - supportsSSH: true, - supportsSSL: true, - supportsCascadeDrop: true, - supportsForeignKeyDisable: false, - supportsReadOnlyMode: true, - supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: true, - supportsDropDatabase: true, - supportsDropSchema: true, - defaultSSLMode: .preferred - ), - schema: PluginMetadataSnapshot.SchemaInfo( - defaultSchemaName: "public", - defaultGroupName: "main", - tableEntityName: "Tables", - containerEntityName: "Database", - defaultPrimaryKeyColumn: nil, - immutableColumns: [], - systemDatabaseNames: ["padb_harvest"], - systemSchemaNames: [], - fileExtensions: [], - databaseGroupingStrategy: .bySchema, - structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment] - ), - editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: redshiftDialect, - statementCompletions: [], - columnTypesByCategory: postgresqlColumnTypes - ), - connection: PluginMetadataSnapshot.ConnectionConfig( - additionalConnectionFields: [pgpassField, connectionOptionsField], - category: .analytical, - tagline: String(localized: "Amazon's columnar warehouse on Postgres") - ) - )), - ("CockroachDB", PluginMetadataSnapshot( - displayName: "CockroachDB", iconName: "cockroachdb-icon", defaultPort: 26_257, - requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: false, - isDownloadable: false, primaryUrlScheme: "cockroachdb", parameterStyle: .dollar, - navigationModel: .standard, - explainVariants: [ - ExplainVariant( - id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .cockroachText - ), - ExplainVariant( - id: "analyze", - label: "EXPLAIN ANALYZE", - sqlPrefix: "EXPLAIN ANALYZE", - format: .cockroachText - ), - ], - pathFieldRole: .database, - supportsHealthMonitor: true, urlSchemes: ["cockroachdb", "cockroach"], - postConnectActions: [.selectSchemaFromLastSession], - brandColorHex: "#6933FF", - queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .network, supportsDatabaseSwitching: true, - supportsColumnReorder: false, - capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: true, - supportsImport: true, - supportsExport: true, - supportsSSH: true, - supportsSSL: true, - supportsCascadeDrop: true, - supportsForeignKeyDisable: false, - supportsReadOnlyMode: true, - supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: true, - supportsDropDatabase: true, - supportsDropSchema: true, - supportsAddColumn: false, - supportsModifyColumn: false, - supportsDropColumn: false, - supportsRenameColumn: false, - supportsAddIndex: false, - supportsDropIndex: false, - supportsModifyPrimaryKey: false, - supportsCheckConstraints: true, - supportsCheckConstraintEditing: true, - supportsGeneratedColumns: true, - defaultSSLMode: .preferred - ), - schema: PluginMetadataSnapshot.SchemaInfo( - defaultSchemaName: "public", - defaultGroupName: "main", - tableEntityName: "Tables", - containerEntityName: "Database", - defaultPrimaryKeyColumn: nil, - immutableColumns: [], - systemDatabaseNames: ["system"], - systemSchemaNames: [], - fileExtensions: [], - databaseGroupingStrategy: .bySchema, - structureColumnFields: [ - .name, .type, .nullable, .defaultValue, .generated, .generationExpression, .comment - ] - ), - editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: postgresqlDialect, - statementCompletions: [], - columnTypesByCategory: postgresqlColumnTypes - ), - connection: PluginMetadataSnapshot.ConnectionConfig( - additionalConnectionFields: [pgpassField, connectionOptionsField], - category: .relational, - tagline: String(localized: "Distributed SQL, PostgreSQL-compatible") - ) - )), - ("PGlite", PluginMetadataSnapshot( - displayName: "PGlite", iconName: "postgresql-icon", defaultPort: 5_432, - requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, - isDownloadable: false, primaryUrlScheme: "pglite", parameterStyle: .dollar, - navigationModel: .standard, explainVariants: [], pathFieldRole: .database, - supportsHealthMonitor: true, urlSchemes: ["pglite"], - postConnectActions: [.selectSchemaFromLastSession], - brandColorHex: "#F4B942", - queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .network, supportsDatabaseSwitching: true, - supportsColumnReorder: false, - capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: true, - supportsImport: true, - supportsExport: true, - supportsSSH: false, - supportsSSL: false, - supportsCascadeDrop: true, - supportsForeignKeyDisable: false, - supportsReadOnlyMode: true, - supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: true, - supportsDropDatabase: true, - supportsDropSchema: true, - supportsRenameColumn: true, - supportsTriggers: true, - supportsTriggerEditing: true, - supportsCheckConstraints: true, - supportsCheckConstraintEditing: true, - supportsGeneratedColumns: true, - defaultSSLMode: .disabled, - supportsCloudflareTunnel: false, - supportsConnectionPooling: false - ), - schema: PluginMetadataSnapshot.SchemaInfo( - defaultSchemaName: "public", - defaultGroupName: "main", - tableEntityName: "Tables", - containerEntityName: "Database", - defaultPrimaryKeyColumn: nil, - immutableColumns: [], - systemDatabaseNames: [], - systemSchemaNames: [], - fileExtensions: [], - databaseGroupingStrategy: .bySchema, - structureColumnFields: [ - .name, .type, .nullable, .defaultValue, .generated, .generationExpression, .comment - ] - ), - editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: postgresqlDialect, - statementCompletions: [], - columnTypesByCategory: postgresqlColumnTypes - ), - connection: PluginMetadataSnapshot.ConnectionConfig( - additionalConnectionFields: [], - category: .relational, - tagline: String(localized: "Embedded WASM Postgres over a socket server"), - hidesBuiltInPassword: true, - defaultHost: "127.0.0.1" - ) - )), - ("SQLite", PluginMetadataSnapshot( - displayName: "SQLite", iconName: "sqlite-icon", defaultPort: 0, - requiresAuthentication: false, supportsForeignKeys: true, supportsSchemaEditing: true, - isDownloadable: false, primaryUrlScheme: "sqlite", parameterStyle: .questionMark, - navigationModel: .standard, explainVariants: [], pathFieldRole: .filePath, - supportsHealthMonitor: false, urlSchemes: ["sqlite"], postConnectActions: [], - brandColorHex: "#003B57", - queryLanguageName: "SQL", editorLanguage: .sql, - connectionMode: .fileBased, supportsDatabaseSwitching: false, - supportsColumnReorder: false, - capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: false, - supportsImport: true, - supportsExport: true, - supportsSSH: false, - supportsSSL: false, - supportsCascadeDrop: false, - supportsForeignKeyDisable: true, - supportsReadOnlyMode: true, - supportsQueryProgress: false, - requiresReconnectForDatabaseSwitch: false, - supportsDropDatabase: false, - supportsModifyColumn: false, - supportsRenameColumn: true, - supportsModifyPrimaryKey: false, - supportsTriggers: true, - supportsTriggerEditing: true, - supportsCheckConstraints: true, - supportsGeneratedColumns: true, - supportsDatabaseTriggerBrowse: true, - supportsCloudflareTunnel: false, - localFilePathField: .database, - supportsRemoteDatabaseFile: true - ), - schema: PluginMetadataSnapshot.SchemaInfo( - defaultSchemaName: "public", - defaultGroupName: "main", - tableEntityName: "Tables", - containerEntityName: "Database", - defaultPrimaryKeyColumn: nil, - immutableColumns: [], - systemDatabaseNames: [], - systemSchemaNames: [], - fileExtensions: ["db", "db3", "s3db", "sl3", "sqlite", "sqlite3", "sqlitedb"], - databaseGroupingStrategy: .flat, - structureColumnFields: [ - .name, .type, .nullable, .defaultValue, .generated, .generationExpression, - .autoIncrement, .comment - ] - ), - editor: PluginMetadataSnapshot.EditorConfig( - sqlDialect: sqliteDialect, - statementCompletions: [], - columnTypesByCategory: sqliteColumnTypes - ), - connection: PluginMetadataSnapshot.ConnectionConfig( - category: .relational, - tagline: String(localized: "Embedded zero-config SQL database") - ) - )) - ] - // swiftlint:enable function_body_length - let allDefaults = defaults + registryPluginDefaults() + let allDefaults = Self.curatedDefaults() + registryPluginDefaults() for entry in allDefaults { snapshots[entry.typeId] = entry.snapshot defaultSnapshots[entry.typeId] = entry.snapshot @@ -1193,6 +569,10 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsQueryProgress: driverType.supportsQueryProgress, requiresReconnectForDatabaseSwitch: driverType.requiresReconnectForDatabaseSwitch, supportsDropDatabase: driverType.supportsDropDatabase, + supportsRenameTable: driverType.supportsRenameTable, + supportsRenameView: driverType.supportsRenameView, + supportsRenameDatabase: driverType.supportsRenameDatabase, + supportsRenameSchema: driverType.supportsRenameSchema, supportsDropSchema: driverType.supportsDropSchema, supportsAddColumn: driverType.supportsAddColumn, supportsModifyColumn: driverType.supportsModifyColumn, diff --git a/TablePro/Core/Services/Query/MetadataConnectionPool.swift b/TablePro/Core/Services/Query/MetadataConnectionPool.swift index 3ac5dc6d9..672a08e09 100644 --- a/TablePro/Core/Services/Query/MetadataConnectionPool.swift +++ b/TablePro/Core/Services/Query/MetadataConnectionPool.swift @@ -76,6 +76,21 @@ final class MetadataConnectionPool { return try await entry.runSerially(body) } + /// Closes only the leases attached to one database, which is what a rename of that database + /// needs: PostgreSQL refuses `ALTER DATABASE ... RENAME` while any backend is connected to it, + /// and an expanded row or a tab that ran a query there leaves one here. + func closeAll(connectionId: UUID, database: String) { + for key in pending.keys + where key.scope.connectionId == connectionId && key.scope.database == database { + pending[key]?.cancel() + pending.removeValue(forKey: key) + } + for key in entries.keys + where key.scope.connectionId == connectionId && key.scope.database == database { + closeOrDeferEntry(forKey: key) + } + } + func closeAll(connectionId: UUID) { for key in pending.keys where key.scope.connectionId == connectionId { pending[key]?.cancel() diff --git a/TablePro/Core/Storage/ColumnLayoutPersister.swift b/TablePro/Core/Storage/ColumnLayoutPersister.swift index 58c3bd364..8e73b1375 100644 --- a/TablePro/Core/Storage/ColumnLayoutPersister.swift +++ b/TablePro/Core/Storage/ColumnLayoutPersister.swift @@ -126,6 +126,54 @@ final class FileColumnLayoutPersister: ColumnLayoutPersisting { syncTracker.markDirty(.settings, id: Self.syncCategory(for: key.storageKey)) } + /// Moves a table's saved widths, order and hidden columns onto its new name. + /// + /// Persisted before either sync marker is written, because `markDeleted` posts a change + /// notification that can start a sync, and a sync reading the old file would put the entry + /// back under the name that has gone. + func rename(from oldKey: ColumnLayoutTableKey, to newKey: ColumnLayoutTableKey) { + var entries = loadEntries(for: oldKey.connectionId) + guard let entry = entries.removeValue(forKey: oldKey.storageKey) else { return } + entries[newKey.storageKey] = entry + cache[oldKey.connectionId] = entries + writeEntries(entries, for: oldKey.connectionId) + syncTracker.markDirty(.settings, id: Self.syncCategory(for: newKey.storageKey)) + syncTracker.markDeleted(.settings, id: Self.syncCategory(for: oldKey.storageKey)) + } + + /// Moves every table's saved layout from one container to another. Same prefix rewrite as the + /// filter store, and for the same reason: the tables that have a layout are whatever the user + /// has opened over the life of the connection, not what is loaded now. + func renameScope( + connectionId: UUID, + fromDatabase: String, + fromSchema: String?, + toDatabase: String, + toSchema: String? + ) { + let oldPrefix = TableScope.storagePrefix( + connectionId: connectionId, database: fromDatabase, schema: fromSchema + ) + let newPrefix = TableScope.storagePrefix( + connectionId: connectionId, database: toDatabase, schema: toSchema + ) + guard oldPrefix != newPrefix else { return } + + var entries = loadEntries(for: connectionId) + let moving = entries.keys.filter { $0.hasPrefix(oldPrefix) } + guard !moving.isEmpty else { return } + for key in moving { + let moved = newPrefix + key.dropFirst(oldPrefix.count) + entries[moved] = entries.removeValue(forKey: key) + } + cache[connectionId] = entries + writeEntries(entries, for: connectionId) + for key in moving { + syncTracker.markDirty(.settings, id: Self.syncCategory(for: newPrefix + key.dropFirst(oldPrefix.count))) + syncTracker.markDeleted(.settings, id: Self.syncCategory(for: key)) + } + } + func clear(for key: ColumnLayoutTableKey) { removeLegacyHidden(for: key) diff --git a/TablePro/Core/Storage/FavoriteDatabasesStorage.swift b/TablePro/Core/Storage/FavoriteDatabasesStorage.swift index f033c1062..7cad9436c 100644 --- a/TablePro/Core/Storage/FavoriteDatabasesStorage.swift +++ b/TablePro/Core/Storage/FavoriteDatabasesStorage.swift @@ -67,6 +67,15 @@ internal final class FavoriteDatabasesStorage { notify(after: mutate { Self.upsert(entry, into: &$0) }, skipSync: true) } + /// A favourite follows its database's new name rather than being dropped, because the tag the + /// user put on it is about the database, not about what it is called. It is synced, so the + /// entry is written before the removal is announced. + internal func rename(database oldName: String, to newName: String, connectionId: UUID) { + guard let existing = favorites(for: connectionId).first(where: { $0.database == oldName }) else { return } + setFavorite(database: newName, environment: existing.environment, connectionId: connectionId) + removeFavorite(database: oldName, connectionId: connectionId) + } + internal func removeFavorite(database: String, connectionId: UUID) { notify(after: mutate { favorites in guard let existing = favorites.first(where: { diff --git a/TablePro/Core/Storage/FilterSettingsStorage.swift b/TablePro/Core/Storage/FilterSettingsStorage.swift index 882c46e4e..edb873cff 100644 --- a/TablePro/Core/Storage/FilterSettingsStorage.swift +++ b/TablePro/Core/Storage/FilterSettingsStorage.swift @@ -255,6 +255,74 @@ final class FilterSettingsStorage { } } + /// Moves a table's saved filters onto its new name. A rename keeps the columns the filters + /// name, so the working set is still valid; leaving it behind would silently drop it. + func renameLastFilters( + from oldTableName: String, + to newTableName: String, + connectionId: UUID, + databaseName: String, + schemaName: String? + ) { + let oldKey = compositeKey( + tableName: oldTableName, connectionId: connectionId, + databaseName: databaseName, schemaName: schemaName + ) + let newKey = compositeKey( + tableName: newTableName, connectionId: connectionId, + databaseName: databaseName, schemaName: schemaName + ) + guard oldKey != newKey else { return } + if let cached = lastFiltersCache.removeValue(forKey: oldKey) { + lastFiltersCache[newKey] = cached + } + let source = fileURL(forKey: oldKey) + let destination = fileURL(forKey: newKey) + ioQueue.async { + guard FileManager.default.fileExists(atPath: source.path) else { return } + try? FileManager.default.removeItem(at: destination) + try? FileManager.default.moveItem(at: source, to: destination) + } + } + + /// Moves every table's saved filters from one container to another, by rewriting the part of + /// each key that names the container. Keyed by prefix rather than by walking the table list, + /// because that list is loaded lazily and a table nobody opened this session still has a file. + func renameScope( + connectionId: UUID, + fromDatabase: String, + fromSchema: String?, + toDatabase: String, + toSchema: String? + ) { + let oldPrefix = TableScope.storagePrefix( + connectionId: connectionId, database: fromDatabase, schema: fromSchema + ) + let newPrefix = TableScope.storagePrefix( + connectionId: connectionId, database: toDatabase, schema: toSchema + ) + guard oldPrefix != newPrefix else { return } + + for key in lastFiltersCache.keys where key.hasPrefix(oldPrefix) { + let moved = newPrefix + key.dropFirst(oldPrefix.count) + lastFiltersCache[moved] = lastFiltersCache.removeValue(forKey: key) + } + + let directory = filterStateDirectory + ioQueue.async { + let fm = FileManager.default + guard let files = try? fm.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil) + else { return } + for file in files where file.pathExtension == "json" { + let key = file.deletingPathExtension().lastPathComponent + guard key.hasPrefix(oldPrefix) else { continue } + let moved = directory.appendingPathComponent("\(newPrefix + key.dropFirst(oldPrefix.count)).json") + try? fm.removeItem(at: moved) + try? fm.moveItem(at: file, to: moved) + } + } + } + func waitForPendingDiskWrites() { ioQueue.sync {} } diff --git a/TablePro/Core/Storage/Preferences/TableScope.swift b/TablePro/Core/Storage/Preferences/TableScope.swift index f31c43e88..6e2560db7 100644 --- a/TablePro/Core/Storage/Preferences/TableScope.swift +++ b/TablePro/Core/Storage/Preferences/TableScope.swift @@ -19,7 +19,20 @@ struct TableScope: Hashable, Codable, Sendable { } var storageComponent: String { - [connectionId.uuidString, database ?? "", schema ?? "", table] + Self.encode([connectionId.uuidString, database ?? "", schema ?? "", table]) + } + + /// Everything a key for this container starts with, so a container that is renamed can move + /// every table's saved state without knowing which tables exist. The list is loaded lazily and + /// a table nobody has opened this session still has settings on disk. + static func storagePrefix(connectionId: UUID, database: String?, schema: String?) -> String { + var parts = [connectionId.uuidString, database ?? ""] + if let schema { parts.append(schema) } + return encode(parts) + "." + } + + private static func encode(_ parts: [String]) -> String { + parts .map { $0.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? $0 } .joined(separator: ".") } diff --git a/TablePro/Core/Storage/RecentTablesStore.swift b/TablePro/Core/Storage/RecentTablesStore.swift index 2f41352fb..2409fb53e 100644 --- a/TablePro/Core/Storage/RecentTablesStore.swift +++ b/TablePro/Core/Storage/RecentTablesStore.swift @@ -85,6 +85,77 @@ final class RecentTablesStore { return updated } + /// A renamed table keeps its position rather than being dropped and re-added, which would look + /// like the user had just opened it. Any stale entry already sitting on the new name is removed + /// first: two entries with one id map to a single cached node and the outline draws neither. + func rename(connectionId: UUID, entry: RecentTableEntry, to newName: String) -> [RecentTableEntry] { + mutate(connectionId: connectionId) { entries in + guard let index = entries.firstIndex(where: { $0.id == entry.id }) else { return false } + let existing = entries[index] + let renamed = RecentTableEntry( + database: existing.database, schema: existing.schema, name: newName, + isView: existing.isView, openedAt: existing.openedAt + ) + entries.removeAll { $0.id == renamed.id } + guard let insertion = entries.firstIndex(where: { $0.id == existing.id }) else { return false } + entries[insertion] = renamed + return true + } + } + + func renameDatabase(connectionId: UUID, from oldName: String, to newName: String) -> [RecentTableEntry] { + mutate(connectionId: connectionId) { entries in + guard entries.contains(where: { $0.database == oldName }) else { return false } + entries = entries.map { entry in + guard entry.database == oldName else { return entry } + return RecentTableEntry( + database: newName, schema: entry.schema, name: entry.name, + isView: entry.isView, openedAt: entry.openedAt + ) + } + return Self.deduplicate(&entries) + } + } + + func renameSchema( + connectionId: UUID, + database: String?, + from oldName: String, + to newName: String + ) -> [RecentTableEntry] { + mutate(connectionId: connectionId) { entries in + guard entries.contains(where: { $0.database == database && $0.schema == oldName }) else { return false } + entries = entries.map { entry in + guard entry.database == database, entry.schema == oldName else { return entry } + return RecentTableEntry( + database: entry.database, schema: newName, name: entry.name, + isView: entry.isView, openedAt: entry.openedAt + ) + } + return Self.deduplicate(&entries) + } + } + + /// Reads, mutates and persists in one place, so a rename lands on disk whether or not the + /// Recent section is on screen. The live list is empty while Show Recent Tables is off, and + /// renaming only that left a dead entry to reappear under the old name when it came back on. + private func mutate( + connectionId: UUID, + _ body: (inout [RecentTableEntry]) -> Bool + ) -> [RecentTableEntry] { + var entries = self.entries(connectionId: connectionId) + guard body(&entries) else { return entries } + persist(entries, connectionId: connectionId) + return entries + } + + @discardableResult + private static func deduplicate(_ entries: inout [RecentTableEntry]) -> Bool { + var seen = Set() + entries = entries.filter { seen.insert($0.id).inserted } + return true + } + func removeEntries(for connectionId: UUID) { defaults.removeObject(forKey: PreferenceKeys.recentTables(connectionId: connectionId).name) defaults.removeObject(forKey: legacyKeyPrefix + connectionId.uuidString) diff --git a/TablePro/Models/Database/ObjectRenameEligibility.swift b/TablePro/Models/Database/ObjectRenameEligibility.swift new file mode 100644 index 000000000..4a0b76f62 --- /dev/null +++ b/TablePro/Models/Database/ObjectRenameEligibility.swift @@ -0,0 +1,61 @@ +// +// ObjectRenameEligibility.swift +// TablePro +// + +import Foundation + +/// Which rows offer Rename. +/// +/// A container is never renamed while the connection is on it, the same rule Drop already +/// applies. Several engines refuse outright, PostgreSQL among them with "the current database +/// cannot be renamed", and the ones that allow it leave the session pointing at a name that no +/// longer exists. Switching away first is the gesture Drop already asks for. +enum ObjectRenameEligibility { + struct Context { + let activeDatabase: String? + let activeSchema: String? + let supportsRenameTable: Bool + let supportsRenameView: Bool + let supportsRenameDatabase: Bool + let supportsRenameSchema: Bool + let isReadOnly: Bool + } + + /// Asked per object kind, not per engine. SQLite's one rename statement refuses a view and + /// the engines built on it inherit that, so offering the item on a view there guarantees a + /// failure alert for something the menu promised. + static func canRename(table: TableInfo, context: Context) -> Bool { + guard !context.isReadOnly else { return false } + switch table.type { + case .systemTable: + return false + case .view, .materializedView: + return context.supportsRenameView + case .table, .foreignTable, .partitionedTable, .externalTable: + return context.supportsRenameTable + } + } + + static func renameable(_ targets: [DatabaseContainerRef], context: Context) -> DatabaseContainerRef? { + guard !context.isReadOnly else { return nil } + /// One at a time. A rename names one new name, so a multi-row selection has nothing to + /// apply, and offering the item over one would silently act on a row the user did not + /// mean. + guard targets.count == 1, let target = targets.first else { return nil } + return isRenameable(target, context: context) ? target : nil + } + + private static func isRenameable(_ target: DatabaseContainerRef, context: Context) -> Bool { + guard !target.isSystem else { return false } + switch target.kind { + case .database: + guard context.supportsRenameDatabase else { return false } + return target.database != context.activeDatabase + case .schema: + guard context.supportsRenameSchema else { return false } + guard target.database == context.activeDatabase else { return true } + return target.schema != context.activeSchema + } + } +} diff --git a/TablePro/Models/UI/SharedSidebarState.swift b/TablePro/Models/UI/SharedSidebarState.swift index f5099313a..2e7cbfcb3 100644 --- a/TablePro/Models/UI/SharedSidebarState.swift +++ b/TablePro/Models/UI/SharedSidebarState.swift @@ -68,6 +68,37 @@ final class SharedSidebarState { recentTables = RecentTablesStore.shared.remove(connectionId: connectionId, entry: entry) } + /// A renamed table keeps its place in Recent. The store is asked directly rather than the live + /// list, because that list is empty while Show Recent Tables is off and the entry is still on + /// disk: renaming only what is on screen left a dead entry to reappear under the old name. + func renameRecentTable(database: String?, schema: String?, from oldName: String, to newName: String) { + let scope = normalizedDatabase(database) + let existing = RecentTablesStore.shared.entries(connectionId: connectionId).first { + $0.database == scope && $0.schema == schema && $0.name == oldName + } + guard let existing else { return } + publish(RecentTablesStore.shared.rename(connectionId: connectionId, entry: existing, to: newName)) + } + + /// Every Recent entry in a renamed container follows it, because the entries are keyed by the + /// container's name and would otherwise all point at one that has gone. + func renameRecentDatabase(from oldName: String, to newName: String) { + publish(RecentTablesStore.shared.renameDatabase( + connectionId: connectionId, from: oldName, to: newName + )) + } + + func renameRecentSchema(database: String?, from oldName: String, to newName: String) { + publish(RecentTablesStore.shared.renameSchema( + connectionId: connectionId, database: normalizedDatabase(database), from: oldName, to: newName + )) + } + + private func publish(_ entries: [RecentTableEntry]) { + guard AppSettingsManager.shared.general.showRecentTables else { return } + recentTables = entries + } + func clearRecentTables(inDatabase database: String?) { recentTables = RecentTablesStore.shared.clear( connectionId: connectionId, database: normalizedDatabase(database) diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Rename.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Rename.swift new file mode 100644 index 000000000..051df1355 --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Rename.swift @@ -0,0 +1,182 @@ +// +// MainContentCoordinator+Rename.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit + +private let renameLogger = Logger(subsystem: "com.TablePro", category: "Rename") + +/// Renaming an object, and moving everything that named it by the old name. +/// +/// It runs at once rather than joining the Truncate and Drop queue. The row's own label is what +/// the user edits, so a queued rename would leave the tree showing a name the server does not +/// have, and every later command on that row would name an object that does not exist. Dropping a +/// database already works this way. +extension MainContentCoordinator { + func renameTable(_ ref: DatabaseTreeTableRef, to newName: String) { + let objectType = TableObjectKeyword.forDDL(ref.table.type) + let oldName = ref.table.name + let schema = ref.qualifyingSchema + + Task { [weak self] in + guard let self else { return } + /// The scope comes from the row, not from whichever database the session is on. + /// `activateThen` switches the browse cursor first but reports no success, so a switch + /// that failed would otherwise leave this running an unqualified statement against the + /// database still in front, renaming a same-named object there. + guard let scope = DatabaseManager.shared.resolvedScope( + database: ref.database, schema: schema, for: connectionId + ) else { + presentRenameFailure(DatabaseError.notConnected) + return + } + guard await authorizeRename( + describing: String( + format: String(localized: "Rename %1$@ to %2$@"), qualifiedLabel(ref), newName + ) + ) else { return } + + do { + let route = DatabaseManager.shared.executionRoute(for: scope) + try await DatabaseManager.shared.withScopedDriver( + scope: scope, route: route, cancellation: .protectedWrite + ) { driver in + try await driver.renameTable( + name: oldName, schema: schema, to: newName, objectType: objectType + ) + } + } catch { + presentRenameFailure(error, object: ref.id) + return + } + adoptTableRename(ref, to: newName) + await refreshTables() + } + } + + func renameContainer(_ ref: DatabaseContainerRef, to newName: String) { + Task { [weak self] in + guard let self else { return } + guard await authorizeRename( + describing: String(format: String(localized: "Rename %1$@ to %2$@"), ref.name, newName) + ) else { return } + + do { + try await performContainerRename(ref, to: newName) + } catch { + presentRenameFailure(error, object: ref.id) + return + } + adoptContainerRename(ref, to: newName) + await DatabaseTreeMetadataService.shared.refreshDatabases( + connectionId: connectionId, + databaseType: connection.type + ) + if ref.kind == .schema, let database = ref.database { + await DatabaseTreeMetadataService.shared.refreshSchemas( + connectionId: connectionId, + database: database + ) + } + } + } + + private func performContainerRename(_ ref: DatabaseContainerRef, to newName: String) async throws { + switch ref.kind { + case .database: + /// Renaming a database runs from a connection that is not on it, which the menu already + /// guarantees by keeping the item off the browsed row. The metadata pool is the other + /// way a backend stays attached to it, and PostgreSQL refuses the statement while one + /// is, so its leases on that database are closed first. + if let database = ref.database { + MetadataConnectionPool.shared.closeAll(connectionId: connectionId, database: database) + } + guard let scope = browseScope else { throw DatabaseError.notConnected } + let route = DatabaseManager.shared.executionRoute(for: scope) + let name = ref.name + try await DatabaseManager.shared.withScopedDriver( + scope: scope, route: route, cancellation: .protectedWrite + ) { driver in + try await driver.renameDatabase(name: name, to: newName) + } + case .schema: + guard let scope = DatabaseManager.shared.resolvedScope( + database: ref.database, schema: nil, for: connectionId + ) else { + throw DatabaseError.notConnected + } + let name = ref.name + try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in + try await driver.renameSchema(name: name, to: newName) + } + } + } + + /// A rename is a schema mutation, so it goes through the same gate as every other one. The + /// menu only hides the item under read-only safe mode; the Alert and Touch ID levels are the + /// gate's to enforce, and the audit record is written from here too. + /// + /// No SQL travels with the request because the driver runs the rename rather than generating a + /// statement, and for MongoDB and SQL Server there is no statement to show. The two names are + /// what the user is being asked to approve, and the description carries both. + private func authorizeRename(describing description: String) async -> Bool { + let decision = await ExecutionGateProvider.shared.authorize( + OperationRequest( + connectionId: connectionId, + databaseType: connection.type, + sql: nil, + kind: .schemaMutation, + caller: .userInterface, + capabilities: .interactiveUser, + operationDescription: description + ) + ) + guard case .authorized = decision else { + if let reason = decision.deniedReason { + AlertHelper.showErrorSheet( + title: String(localized: "Rename Failed"), + message: reason, + window: contentWindow + ) + } + return false + } + return true + } + + private func presentRenameFailure(_ error: Error, object: String = "") { + renameLogger.error( + "Rename failed for \(object, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + AlertHelper.showErrorSheet( + title: String(localized: "Rename Failed"), + message: error.localizedDescription, + window: contentWindow + ) + } + + private func qualifiedLabel(_ ref: DatabaseTreeTableRef) -> String { + guard let schema = ref.qualifyingSchema else { return ref.table.name } + return "\(schema).\(ref.table.name)" + } +} + +/// The `DROP` and `ALTER` keyword for an object kind, in one place because the rename and the drop +/// have to spell the same object the same way. +enum TableObjectKeyword { + static func forDDL(_ type: TableInfo.TableType) -> String { + switch type { + case .view: + return "VIEW" + case .materializedView: + return "MATERIALIZED VIEW" + case .foreignTable: + return "FOREIGN TABLE" + case .table, .systemTable, .partitionedTable, .externalTable: + return "TABLE" + } + } +} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+RenameAdoption.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+RenameAdoption.swift new file mode 100644 index 000000000..3bcfabbfb --- /dev/null +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+RenameAdoption.swift @@ -0,0 +1,242 @@ +// +// MainContentCoordinator+RenameAdoption.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +/// Everything that named the object by its old name, moved onto the new one. +/// +/// A rename is the one destructive-looking operation whose object survives it, so the state the +/// user built around it survives too: the tab stays open on the same rows, its filters and column +/// widths stay applied, and a favourite stays a favourite rather than pointing at a table that no +/// longer exists on every device it synced to. +extension MainContentCoordinator { + func adoptTableRename(_ ref: DatabaseTreeTableRef, to newName: String) { + let database = ref.database ?? browseDatabaseName + let resolvedSchema = DatabaseManager.shared.resolvedSchemaName(ref.qualifyingSchema, for: connectionId) + let identity = TableTabIdentity(ref: ref, browsing: browseDatabaseName, resolvedSchema: resolvedSchema) + + retitleTabs(matching: identity, to: newName) + movePerTableSettings( + from: ref.table.name, to: newName, database: database, schema: resolvedSchema + ) + moveFavorite(ref, to: newName, database: ref.database) + moveRecent(ref, to: newName) + unstagePendingOperations(for: ref) + } + + /// A container's new name has to reach everything keyed by its old one, or the next page, save + /// or reconnect targets something that is gone. A schema is not exempt: its tabs, its queued + /// operations and its per-table settings are all keyed by it too. + func adoptContainerRename(_ ref: DatabaseContainerRef, to newName: String) { + switch ref.kind { + case .database: + guard let oldDatabase = ref.database else { return } + retargetContainer(database: oldDatabase, schema: nil, toDatabase: newName, toSchema: nil) + SharedSidebarState.forConnection(connectionId) + .renameRecentDatabase(from: oldDatabase, to: newName) + FavoriteDatabasesStorage.shared.rename( + database: oldDatabase, to: newName, connectionId: connectionId + ) + retargetDatabaseFilter(from: oldDatabase, to: newName) + retargetSavedConnectionDatabase(from: oldDatabase, to: newName) + retargetBrowseCursor(from: oldDatabase, to: newName) + case .schema: + guard let oldSchema = ref.schema else { return } + let database = ref.database ?? browseDatabaseName + retargetContainer( + database: database, schema: oldSchema, toDatabase: database, toSchema: newName + ) + SharedSidebarState.forConnection(connectionId) + .renameRecentSchema(database: database, from: oldSchema, to: newName) + } + } + + private func retitleTabs(matching identity: TableTabIdentity, to newName: String) { + let browseDatabase = browseDatabaseName + var renamedSelectedTab = false + for index in tabManager.tabs.indices + where tabManager.tabs[index].tableIdentity(browsing: browseDatabase) == identity { + if tabManager.tabs[index].id == tabManager.selectedTabId { renamedSelectedTab = true } + tabManager.mutate(at: index) { tab in + tab.tableContext.tableName = newName + tab.title = newName + } + tabManager.markTabRenamed(tabManager.tabs[index].id) + /// The browse query still names the old table, so the next page, sort or filter would + /// run against a name the server no longer has. + rebuildTableQuery(at: index) + } + /// One change manager serves the whole window and holds the name its statements target, so + /// a save started after the rename would still write to the old one. It moves only when the + /// tab it is serving is one of the tabs that was renamed: comparing its bare name would + /// point `public.orders`'s pending edits at whatever `billing.orders` just became. + guard renamedSelectedTab else { return } + changeManager.tableName = newName + } + + private func movePerTableSettings( + from oldName: String, + to newName: String, + database: String, + schema: String? + ) { + FilterSettingsStorage.shared.renameLastFilters( + from: oldName, + to: newName, + connectionId: connectionId, + databaseName: database, + schemaName: schema + ) + FileColumnLayoutPersister.shared.rename( + from: ColumnLayoutTableKey( + connectionId: connectionId, databaseName: database, schemaName: schema, tableName: oldName + ), + to: ColumnLayoutTableKey( + connectionId: connectionId, databaseName: database, schemaName: schema, tableName: newName + ) + ) + } + + private func moveFavorite(_ ref: DatabaseTreeTableRef, to newName: String, database: String?) { + let storage = FavoriteTablesStorage.shared + guard storage.isFavorite( + name: ref.table.name, schema: ref.schema, database: database, connectionId: connectionId + ) else { return } + storage.removeFavorite( + name: ref.table.name, schema: ref.schema, database: database, connectionId: connectionId + ) + storage.addFavorite( + name: newName, schema: ref.schema, database: database, connectionId: connectionId + ) + } + + private func moveRecent(_ ref: DatabaseTreeTableRef, to newName: String) { + SharedSidebarState.forConnection(connectionId).renameRecentTable( + database: ref.database, schema: ref.schema, from: ref.table.name, to: newName + ) + } + + // MARK: - Containers + + private func retargetContainer( + database: String, + schema: String?, + toDatabase: String, + toSchema: String? + ) { + retargetTabs(database: database, schema: schema, toDatabase: toDatabase, toSchema: toSchema) + retargetPendingOperations( + database: database, schema: schema, toDatabase: toDatabase, toSchema: toSchema + ) + FilterSettingsStorage.shared.renameScope( + connectionId: connectionId, fromDatabase: database, fromSchema: schema, + toDatabase: toDatabase, toSchema: toSchema + ) + FileColumnLayoutPersister.shared.renameScope( + connectionId: connectionId, fromDatabase: database, fromSchema: schema, + toDatabase: toDatabase, toSchema: toSchema + ) + retargetFavoriteTables( + database: database, schema: schema, toDatabase: toDatabase, toSchema: toSchema + ) + } + + private func retargetTabs(database: String, schema: String?, toDatabase: String, toSchema: String?) { + let browseDatabase = browseDatabaseName + for index in tabManager.tabs.indices { + let context = tabManager.tabs[index].tableContext + guard context.resolvedDatabaseName(browsing: browseDatabase) == database else { continue } + if let schema, context.schemaName != schema { continue } + tabManager.mutate(at: index) { tab in + tab.tableContext.databaseName = toDatabase + if schema != nil { tab.tableContext.schemaName = toSchema } + } + rebuildTableQuery(at: index) + } + } + + /// A queued Truncate or Drop names the container it was raised in, so one left behind either + /// misses at Save or, once something takes the old name, reaches the wrong object. + private func retargetPendingOperations( + database: String, + schema: String?, + toDatabase: String, + toSchema: String? + ) { + guard let viewModel = sidebarViewModel else { return } + func moved(_ ref: DatabaseTreeTableRef) -> DatabaseTreeTableRef { + guard ref.database == database else { return ref } + if let schema, ref.qualifyingSchema != schema { return ref } + return DatabaseTreeTableRef( + database: toDatabase, + schema: schema == nil ? ref.schema : toSchema, + table: ref.table + ) + } + let options = viewModel.tableOperationOptions + var movedOptions: [DatabaseTreeTableRef: TableOperationOptions] = [:] + for (ref, value) in options { movedOptions[moved(ref)] = value } + viewModel.pendingTruncates = Set(viewModel.pendingTruncates.map(moved)) + viewModel.pendingDeletes = Set(viewModel.pendingDeletes.map(moved)) + viewModel.tableOperationOptions = movedOptions + } + + private func retargetFavoriteTables( + database: String, + schema: String?, + toDatabase: String, + toSchema: String? + ) { + let storage = FavoriteTablesStorage.shared + for entry in storage.favorites(for: connectionId) where entry.database == database { + if let schema, entry.schema != schema { continue } + storage.removeFavorite( + name: entry.name, schema: entry.schema, database: entry.database, connectionId: connectionId + ) + storage.addFavorite( + name: entry.name, + schema: schema == nil ? entry.schema : toSchema, + database: toDatabase, + connectionId: connectionId + ) + } + } + + private func retargetDatabaseFilter(from oldName: String, to newName: String) { + let state = SharedSidebarState.forConnection(connectionId) + var selected = state.databaseFilterSelected + guard selected.remove(oldName) != nil else { return } + selected.insert(newName) + state.databaseFilterSelected = selected + } + + /// The connection's saved default is what a reconnect and Reopen Last Session both use, so a + /// database renamed out from under it leaves the connection opening onto nothing. + private func retargetSavedConnectionDatabase(from oldName: String, to newName: String) { + guard connection.database == oldName else { return } + var updated = connection + updated.database = newName + ConnectionStorage.shared.updateConnection(updated) + } + + /// Only reachable when another window is browsing the renamed database, because the menu keeps + /// Rename off the container this window is on. + private func retargetBrowseCursor(from oldName: String, to newName: String) { + guard browseDatabaseName == oldName else { return } + Task { await switchContainers(database: newName, schema: nil) } + } + + /// A queued Truncate or Drop against the old name would either miss or, once a new table takes + /// that name, reach the wrong object. The queue is dropped rather than moved, because the + /// confirmation the user gave named the object they were looking at. + private func unstagePendingOperations(for ref: DatabaseTreeTableRef) { + guard let viewModel = sidebarViewModel else { return } + guard viewModel.pendingTruncates.contains(ref) || viewModel.pendingDeletes.contains(ref) else { return } + viewModel.pendingTruncates.remove(ref) + viewModel.pendingDeletes.remove(ref) + viewModel.tableOperationOptions.removeValue(forKey: ref) + } +} diff --git a/TablePro/Views/Sidebar/DatabaseTreeCellView.swift b/TablePro/Views/Sidebar/DatabaseTreeCellView.swift index fa8dd1cd5..07fe25a87 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeCellView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeCellView.swift @@ -6,15 +6,22 @@ import AppKit import SwiftUI -/// One row of the object tree. All the hosting geometry is `SidebarHostingCellView`'s; this only -/// knows how to turn a node into the row it draws. -final class DatabaseTreeCellView: SidebarHostingCellView { +/// One row of the object tree. All the hosting geometry is `SidebarHostingCellView`'s and the +/// rename field is `RenamableSidebarCellView`'s; this only knows how to turn a node into the row +/// it draws, and which glyph that row wears while it is being renamed. +final class DatabaseTreeCellView: RenamableSidebarCellView { + private var renameSymbolName = "tablecells" + + override var editorSymbolName: String { renameSymbolName } + override var editorAccessibilityIdentifier: String { "database-tree-rename-field" } + func configure( node: DatabaseTreeNode, isFavorite: Bool, context: DatabaseTreeRowContext, actions: DatabaseTreeRowActions ) { + renameSymbolName = Self.symbolName(for: node) update(rootView: DatabaseTreeRowView( node: node, isFavorite: isFavorite, @@ -22,4 +29,18 @@ final class DatabaseTreeCellView: SidebarHostingCellView { actions: actions )) } + + private static func symbolName(for node: DatabaseTreeNode) -> String { + switch node.kind { + case .table(let ref), .recentTable(let ref): + return TableRowLogic.iconName(for: ref.table.type) + case .database(let metadata): + return metadata.isSystemDatabase ? "gearshape" : "cylinder" + case .schema: + return "folder" + case .routine, .trigger, .status, .recentSection, .objectKindSection, + .containerObjectKindSection, .hierarchicalSchemaSection, .redisKeysSection, .redisNode: + return "tablecells" + } + } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift index 26c5cdfb2..450791e61 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift @@ -68,6 +68,12 @@ extension DatabaseTreeOutlineCoordinator { activateThen(ref) { [weak self] in self?.viewModel?.batchToggleDelete(refs: targets) } + case .beginRenameTable(let ref, let isRecentRow): + activateThen(ref) { [weak self] in + self?.beginRename(.table(ref), isRecentRow: isRecentRow) + } + case .renameContainer(let ref): + beginRename(.container(ref)) case .toggleFavorite(let ref): toggleFavorite(ref) case .removeRecent(let ref): diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift index 6aca24a66..b9dcc2bc8 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift @@ -53,6 +53,15 @@ extension DatabaseTreeOutlineCoordinator: NSMenuDelegate { supportsDropSchema: PluginManager.shared.supportsDropSchema(for: databaseType), isReadOnly: mainCoordinator?.safeModeLevel.blocksAllWrites ?? false ), + renameEligibility: ObjectRenameEligibility.Context( + activeDatabase: activeDatabase, + activeSchema: activeSchema, + supportsRenameTable: PluginManager.shared.supportsRenameTable(for: databaseType), + supportsRenameView: PluginManager.shared.supportsRenameView(for: databaseType), + supportsRenameDatabase: PluginManager.shared.supportsRenameDatabase(for: databaseType), + supportsRenameSchema: PluginManager.shared.supportsRenameSchema(for: databaseType), + isReadOnly: mainCoordinator?.safeModeLevel.blocksAllWrites ?? false + ), containerEntityName: PluginManager.shared.containerEntityName(for: databaseType), containerEntityNamePlural: PluginManager.shared.containerEntityNamePlural(for: databaseType), schemaEntityName: PluginManager.shared.schemaEntityName(for: databaseType), diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Rename.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Rename.swift new file mode 100644 index 000000000..904deb4bd --- /dev/null +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Rename.swift @@ -0,0 +1,98 @@ +// +// DatabaseTreeOutlineCoordinator+Rename.swift +// TablePro +// + +import AppKit + +/// Renaming a row of the object tree in the cell's own field. +/// +/// The `NSTextFieldDelegate` conformance itself lives on the main declaration, because the +/// callbacks are `@objc` and reach the coordinator rather than this extension. +extension DatabaseTreeOutlineCoordinator { + /// `isRecentRow` is the clicked row, not the object. A table is drawn twice when it is also in + /// Recent, and editing the section row instead would put the field on a row the user did not + /// click, or on no row at all while that section is collapsed. + internal func beginRename(_ target: DatabaseTreeRenameSession.Target, isRecentRow: Bool = false) { + guard let outlineView else { return } + endRename(commit: false) + + let nodeId: String + let name: String + switch target { + case .table(let ref): + nodeId = isRecentRow ? DatabaseTreeNode.recentTableId(ref) : DatabaseTreeNode.tableId(ref) + name = ref.table.name + case .container(let ref): + nodeId = ref.kind == .schema + ? DatabaseTreeNode.schemaId(database: ref.database ?? "", schema: ref.schema ?? "") + : DatabaseTreeNode.databaseId(ref.database ?? "") + name = ref.name + } + + let row = outlineView.row(forItem: nodeCache[nodeId]) + guard row >= 0 else { return } + outlineView.scrollRowToVisible(row) + outlineView.layoutSubtreeIfNeeded() + guard let cell = outlineView.view(atColumn: 0, row: row, makeIfNecessary: true) + as? DatabaseTreeCellView else { return } + + renameSession = DatabaseTreeRenameSession( + target: target, nodeId: nodeId, originalName: name, pendingName: name + ) + cell.beginRename(text: name, delegate: self) + focus(cell) + } + + /// Re-installs a live edit after a reload, which drops every cell view. The typed value is + /// carried across so a refresh from another window does not swallow what the user has entered. + internal func restoreRenameAfterReload() { + guard let session = renameSession else { return } + guard let cell = renameCell(forNodeId: session.nodeId) else { + endRename(commit: false) + return + } + guard !cell.isRenaming else { return } + cell.beginRename(text: session.pendingName ?? session.originalName, delegate: self) + focus(cell) + } + + internal func endRename(commit: Bool) { + guard let session = renameSession else { return } + renameSession = nil + + /// The field is the authority while it exists. `pendingName` is the fallback for the case + /// where the row has already gone, which is the only way there is no field left to ask. + var typed = session.pendingName ?? session.originalName + if let cell = renameCell(forNodeId: session.nodeId) { + typed = cell.endRename() + outlineView?.window?.makeFirstResponder(outlineView) + } + + guard commit, + case .commit(let newName) = RenameNameDecision.decide( + typed: typed, original: session.originalName + ) + else { return } + + switch session.target { + case .table(let ref): + mainCoordinator?.renameTable(ref, to: newName) + case .container(let ref): + mainCoordinator?.renameContainer(ref, to: newName) + } + } + + private func focus(_ cell: DatabaseTreeCellView) { + guard let field = cell.editor else { return } + outlineView?.window?.makeFirstResponder(field) + field.currentEditor()?.selectAll(nil) + } + + private func renameCell(forNodeId nodeId: String) -> DatabaseTreeCellView? { + guard let outlineView, let node = nodeCache[nodeId] else { return nil } + let row = outlineView.row(forItem: node) + guard row >= 0 else { return nil } + return outlineView.view(atColumn: 0, row: row, makeIfNecessary: false) as? DatabaseTreeCellView + } +} diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index a3783375a..13c009b7f 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -10,7 +10,7 @@ import SwiftUI import TableProPluginKit @MainActor -final class DatabaseTreeOutlineCoordinator: NSObject { +final class DatabaseTreeOutlineCoordinator: NSObject, NSTextFieldDelegate { internal weak var outlineView: NSOutlineView? internal let service = DatabaseTreeMetadataService.shared private static let cellIdentifier = NSUserInterfaceItemIdentifier("DatabaseTreeCell") @@ -38,6 +38,9 @@ final class DatabaseTreeOutlineCoordinator: NSObject { /// Whether a routine row shows its signature depends on the other rows in its own section, so /// the label is decided where the section is built and looked up here when the row draws. internal var routineDisplayLabels: [String: String] = [:] + + /// A rename in progress, held as identity only. See `DatabaseTreeOutlineCoordinator+Rename`. + internal var renameSession: DatabaseTreeRenameSession? private var cachedRowContext: DatabaseTreeRowContext? private var cachedRowActions: DatabaseTreeRowActions? private var lastSelection: Set = [] @@ -239,6 +242,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { outlineView.reloadData() applyDesiredExpansion() syncSelectionToModel() + restoreRenameAfterReload() isReloading = false beginObserving() } @@ -647,6 +651,42 @@ extension DatabaseTreeOutlineCoordinator: NSOutlineViewDataSource { } } +extension DatabaseTreeOutlineCoordinator { + // MARK: - NSTextFieldDelegate + + /// The rename editor's callbacks. They live here rather than in the rename extension because + /// they are `@objc` and an extension cannot supply them for the conformance. + internal func controlTextDidChange(_ obj: Notification) { + guard let field = obj.object as? NSTextField else { return } + renameSession?.pendingName = field.stringValue + } + + /// The click-away path, which commits the way Finder and the Xcode navigator do. + internal func controlTextDidEndEditing(_ obj: Notification) { + guard renameSession != nil else { return } + endRename(commit: true) + } + + internal func control( + _ control: NSControl, + textView: NSTextView, + doCommandBy selector: Selector + ) -> Bool { + if selector == #selector(NSResponder.insertNewline(_:)) { + endRename(commit: true) + return true + } + if selector == #selector(NSResponder.cancelOperation(_:)) { + /// `abortEditing` discards the edit without posting `controlTextDidEndEditing`, so the + /// cancel does not immediately arrive back as a commit. + (control as? NSTextField)?.abortEditing() + endRename(commit: false) + return true + } + return false + } +} + extension DatabaseTreeOutlineCoordinator: NSOutlineViewDelegate { func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { guard let node = item as? DatabaseTreeNode else { return nil } diff --git a/TablePro/Views/Sidebar/DatabaseTreeRenameSession.swift b/TablePro/Views/Sidebar/DatabaseTreeRenameSession.swift new file mode 100644 index 000000000..6534d7c25 --- /dev/null +++ b/TablePro/Views/Sidebar/DatabaseTreeRenameSession.swift @@ -0,0 +1,41 @@ +// +// DatabaseTreeRenameSession.swift +// TablePro +// + +import Foundation + +/// What the object tree is renaming. +/// +/// Identity only, no cell and no field: `reloadData()` drops every row and cell view, so a stored +/// reference is a reference to a view that is no longer the row being edited. The cell is +/// re-resolved from the node id on every pass instead. +internal struct DatabaseTreeRenameSession: Equatable { + internal enum Target: Equatable { + case table(DatabaseTreeTableRef) + case container(DatabaseContainerRef) + } + + internal let target: Target + internal let nodeId: String + internal let originalName: String + internal var pendingName: String? +} + +/// Whether a typed name is worth sending to the server. +/// +/// The three answers are separate because two of them are not failures. An unchanged name is the +/// user finishing where they started, and an empty field is a rename they abandoned; neither is +/// worth an alert, and neither should reach a driver that would answer with a syntax error. +internal enum RenameNameDecision: Equatable { + case commit(String) + case unchanged + case discard + + internal static func decide(typed: String, original: String) -> RenameNameDecision { + let trimmed = typed.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return .discard } + guard trimmed != original else { return .unchanged } + return .commit(trimmed) + } +} diff --git a/TablePro/Views/Sidebar/FavoritesOutlineCellView.swift b/TablePro/Views/Sidebar/FavoritesOutlineCellView.swift index 84d221afc..511e7acb0 100644 --- a/TablePro/Views/Sidebar/FavoritesOutlineCellView.swift +++ b/TablePro/Views/Sidebar/FavoritesOutlineCellView.swift @@ -6,86 +6,8 @@ import AppKit import SwiftUI -/// One row of the Favorites list, plus the field that renames it. -/// -/// The rename field is a real subview assigned to `NSTableCellView.textField`, so `NSOutlineView` -/// lays it out through every expand, collapse, scroll and row-height change, and AppKit treats a -/// cell with an edit in progress as in use rather than recycling it. The overlay this replaced was -/// a bare field added to the outline view, positioned by hand from one call site, which left it -/// painted over a neighbouring row after any disclosure change. -internal final class FavoritesOutlineCellView: SidebarHostingCellView { - private var editorField: NSTextField? - private var editorIcon: NSImageView? - - internal private(set) var isRenaming = false - - internal func beginRename(text: String, delegate: any NSTextFieldDelegate) { - let field = makeEditorIfNeeded() - field.stringValue = text - field.delegate = delegate - isRenaming = true - applyContentVisibility() - } - - @discardableResult - internal func endRename() -> String { - guard let field = editorField else { return "" } - let value = field.stringValue - field.delegate = nil - isRenaming = false - applyContentVisibility() - return value - } - - internal var editor: NSTextField? { editorField } - - /// A reload during an edit calls `update(rootView:)` on every visible cell, so the row's own - /// label must not come back over the field the user is typing in. - override internal func applyContentVisibility() { - setHostedContentHidden(isRenaming) - editorField?.isHidden = !isRenaming - editorIcon?.isHidden = !isRenaming - } - - private func makeEditorIfNeeded() -> NSTextField { - if let editorField { return editorField } - - let icon = NSImageView() - icon.image = NSImage(systemSymbolName: "folder", accessibilityDescription: nil) - icon.translatesAutoresizingMaskIntoConstraints = false - icon.isHidden = true - addSubview(icon) - - let field = NSTextField() - field.translatesAutoresizingMaskIntoConstraints = false - field.isBezeled = false - field.drawsBackground = true - field.isEditable = true - field.isSelectable = true - field.focusRingType = .default - field.usesSingleLineMode = true - field.lineBreakMode = .byTruncatingTail - field.font = .systemFont(ofSize: NSFont.systemFontSize) - (field.cell as? NSTextFieldCell)?.isScrollable = true - field.isHidden = true - field.setAccessibilityIdentifier("favorites-rename-field") - addSubview(field) - - NSLayoutConstraint.activate([ - icon.leadingAnchor.constraint(equalTo: leadingAnchor), - icon.centerYAnchor.constraint(equalTo: centerYAnchor), - icon.widthAnchor.constraint(equalToConstant: 16), - field.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6), - field.trailingAnchor.constraint(equalTo: trailingAnchor), - field.centerYAnchor.constraint(equalTo: centerYAnchor), - ]) - - /// The inherited outlets are what make AppKit colour the field for a selected row and hand - /// it to VoiceOver, so they are set rather than kept as private references. - imageView = icon - textField = field - editorIcon = icon - editorField = field - return field - } +/// One row of the Favorites list. Only folders are renamed here, so the editor keeps one glyph. +internal final class FavoritesOutlineCellView: RenamableSidebarCellView { + override internal var editorSymbolName: String { "folder" } + override internal var editorAccessibilityIdentifier: String { "favorites-rename-field" } } diff --git a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift index f4ee9aa56..e6f98ebc9 100644 --- a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift +++ b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift @@ -25,6 +25,7 @@ internal struct DatabaseTreeMenuContext { internal let importFormats: [ImportFormatOption] internal let maintenanceOperations: [String] internal let dropEligibility: ContainerDropEligibility.Context + internal let renameEligibility: ObjectRenameEligibility.Context internal let containerEntityName: String internal let containerEntityNamePlural: String internal let schemaEntityName: String @@ -60,7 +61,7 @@ internal enum DatabaseTreeMenuSpec { guard let clicked = context.clicked else { return backgroundItems(context) } switch clicked { case .recentTable(let ref): - return tableItems(ref, context: context) + [ + return tableItems(ref, context: context, isRecentRow: true) + [ .separator, .command(String(localized: "Remove from Recent"), .removeRecent(ref)), .command(String(localized: "Clear Recent Tables"), .clearRecents) @@ -83,7 +84,7 @@ internal enum DatabaseTreeMenuSpec { case .containerObjectKindSection(let group): return [.command(String(localized: "Refresh"), .refreshContainerObjectKind(group))] case .hierarchicalSchemaSection(let schema): - return [.command(String(localized: "Refresh"), .refreshHierarchicalSchema(schema))] + return hierarchicalSchemaItems(schema, context: context) case .redisNode(let node): return redisItems(node) case .status, .recentSection, .redisKeysSection: @@ -95,7 +96,8 @@ internal enum DatabaseTreeMenuSpec { private static func tableItems( _ ref: DatabaseTreeTableRef, - context: DatabaseTreeMenuContext + context: DatabaseTreeMenuContext, + isRecentRow: Bool = false ) -> [DatabaseTreeMenuItem] { /// Narrowed to the clicked row's own database, because a queued Truncate or Drop is /// applied by one save against one database. A tree selection can span two of them, and @@ -144,6 +146,9 @@ internal enum DatabaseTreeMenuSpec { guard !context.isReadOnly else { return items } items.append(.separator) + if ObjectRenameEligibility.canRename(table: ref.table, context: context.renameEligibility) { + items.append(.command(String(localized: "Rename"), .beginRenameTable(ref: ref, isRecentRow: isRecentRow))) + } items.append(.command(String(localized: "Create New View…"), .createView)) if SidebarContextMenuLogic.truncateVisible(clickedTable: ref.table) { items.append(.command(String(localized: "Truncate"), .truncateTables(targets: targets, ref: ref))) @@ -222,6 +227,28 @@ internal enum DatabaseTreeMenuSpec { return items } + /// An engine whose tree hangs tables off schemas draws no database rows at all, so its schemas + /// arrive here rather than as `.schema`. Without this the rename an engine declares and + /// implements is unreachable on Snowflake and Trino, which are the two that do. + private static func hierarchicalSchemaItems( + _ schema: String, + context: DatabaseTreeMenuContext + ) -> [DatabaseTreeMenuItem] { + var items: [DatabaseTreeMenuItem] = [ + .command(String(localized: "Refresh"), .refreshHierarchicalSchema(schema)) + ] + let ref = DatabaseContainerRef.schema( + database: context.activeDatabase, + schema: schema, + isSystem: context.systemSchemas.contains(schema) + ) + guard let renameable = ObjectRenameEligibility.renameable([ref], context: context.renameEligibility) + else { return items } + items.append(.separator) + items.append(.command(renameTitle(for: renameable, context: context), .renameContainer(renameable))) + return items + } + // MARK: - Containers private static func containerItems( @@ -262,9 +289,15 @@ internal enum DatabaseTreeMenuSpec { items.append(.separator) items.append(.command(String(localized: "Export…"), .exportContainers(targets))) } - guard !droppable.isEmpty else { return items } + let renameable = ObjectRenameEligibility.renameable(targets, context: context.renameEligibility) + guard renameable != nil || !droppable.isEmpty else { return items } items.append(.separator) - items.append(.command(dropTitle(for: droppable, context: context), .dropContainers(droppable))) + if let renameable { + items.append(.command(renameTitle(for: renameable, context: context), .renameContainer(renameable))) + } + if !droppable.isEmpty { + items.append(.command(dropTitle(for: droppable, context: context), .dropContainers(droppable))) + } return items } @@ -322,6 +355,16 @@ internal enum DatabaseTreeMenuSpec { ).menuTitle } + /// The engine's own word for the container, so the item reads "Rename Keyspace" on Cassandra + /// and "Rename Dataset" on BigQuery. No ellipsis: it opens the row's own field, not a sheet. + private static func renameTitle( + for target: DatabaseContainerRef, + context: DatabaseTreeMenuContext + ) -> String { + let entity = target.kind == .schema ? context.schemaEntityName : context.containerEntityName + return String(format: String(localized: "Rename %@"), entity) + } + private static func copyNamesTitle(count: Int) -> String { count == 1 ? String(localized: "Copy Name") diff --git a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift index fb6714507..66e7013f2 100644 --- a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift +++ b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift @@ -33,6 +33,11 @@ internal enum SidebarMenuCommand: Equatable { /// resolved against whatever the tab in front points at by the time Save runs. case truncateTables(targets: [DatabaseTreeTableRef], ref: DatabaseTreeTableRef) case dropTables(targets: [DatabaseTreeTableRef], ref: DatabaseTreeTableRef) + /// Renaming runs at once rather than joining the queue, because the row's label is what the + /// user edits: a queued rename would leave the tree showing a name the server does not have, + /// and every later command on that row would name an object that does not exist. + case beginRenameTable(ref: DatabaseTreeTableRef, isRecentRow: Bool) + case renameContainer(DatabaseContainerRef) case toggleFavorite(DatabaseTreeTableRef) case removeRecent(DatabaseTreeTableRef) case clearRecents diff --git a/TablePro/Views/Sidebar/RenamableSidebarCellView.swift b/TablePro/Views/Sidebar/RenamableSidebarCellView.swift new file mode 100644 index 000000000..f38d7f93e --- /dev/null +++ b/TablePro/Views/Sidebar/RenamableSidebarCellView.swift @@ -0,0 +1,100 @@ +// +// RenamableSidebarCellView.swift +// TablePro +// + +import AppKit +import SwiftUI + +/// A source list cell whose label can be edited in place. +/// +/// The rename field is a real subview assigned to `NSTableCellView.textField`, so `NSOutlineView` +/// lays it out through every expand, collapse, scroll and row-height change, and AppKit treats a +/// cell with an edit in progress as in use rather than recycling it. The overlay this replaced was +/// a bare field added to the outline view, positioned by hand from one call site, which left it +/// painted over a neighbouring row after any disclosure change. +/// +/// The icon and the accessibility identifier are the subclass's, because the two lists that use +/// this have neither in common: Favorites edits folders and nothing else, while the object tree +/// edits tables, views and schemas, each with its own glyph. +internal class RenamableSidebarCellView: SidebarHostingCellView { + private var editorField: NSTextField? + private var editorIcon: NSImageView? + + internal private(set) var isRenaming = false + + /// Overridden by a cell that draws more than one kind of row. + internal var editorSymbolName: String { "folder" } + internal var editorAccessibilityIdentifier: String { "sidebar-rename-field" } + + internal func beginRename(text: String, delegate: any NSTextFieldDelegate) { + let field = makeEditorIfNeeded() + editorIcon?.image = NSImage(systemSymbolName: editorSymbolName, accessibilityDescription: nil) + field.stringValue = text + field.delegate = delegate + isRenaming = true + applyContentVisibility() + } + + @discardableResult + internal func endRename() -> String { + guard let field = editorField else { return "" } + let value = field.stringValue + field.delegate = nil + isRenaming = false + applyContentVisibility() + return value + } + + internal var editor: NSTextField? { editorField } + + /// A reload during an edit calls `update(rootView:)` on every visible cell, so the row's own + /// label must not come back over the field the user is typing in. + override internal func applyContentVisibility() { + setHostedContentHidden(isRenaming) + editorField?.isHidden = !isRenaming + editorIcon?.isHidden = !isRenaming + } + + private func makeEditorIfNeeded() -> NSTextField { + if let editorField { return editorField } + + let icon = NSImageView() + icon.image = NSImage(systemSymbolName: editorSymbolName, accessibilityDescription: nil) + icon.translatesAutoresizingMaskIntoConstraints = false + icon.isHidden = true + addSubview(icon) + + let field = NSTextField() + field.translatesAutoresizingMaskIntoConstraints = false + field.isBezeled = false + field.drawsBackground = true + field.isEditable = true + field.isSelectable = true + field.focusRingType = .default + field.usesSingleLineMode = true + field.lineBreakMode = .byTruncatingTail + field.font = .systemFont(ofSize: NSFont.systemFontSize) + (field.cell as? NSTextFieldCell)?.isScrollable = true + field.isHidden = true + field.setAccessibilityIdentifier(editorAccessibilityIdentifier) + addSubview(field) + + NSLayoutConstraint.activate([ + icon.leadingAnchor.constraint(equalTo: leadingAnchor), + icon.centerYAnchor.constraint(equalTo: centerYAnchor), + icon.widthAnchor.constraint(equalToConstant: 16), + field.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6), + field.trailingAnchor.constraint(equalTo: trailingAnchor), + field.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + + /// The inherited outlets are what make AppKit colour the field for a selected row and hand + /// it to VoiceOver, so they are set rather than kept as private references. + imageView = icon + textField = field + editorIcon = icon + editorField = field + return field + } +} diff --git a/TableProTests/Models/Database/ObjectRenameEligibilityTests.swift b/TableProTests/Models/Database/ObjectRenameEligibilityTests.swift new file mode 100644 index 000000000..ec34a5beb --- /dev/null +++ b/TableProTests/Models/Database/ObjectRenameEligibilityTests.swift @@ -0,0 +1,130 @@ +// +// ObjectRenameEligibilityTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Object rename eligibility") +struct ObjectRenameEligibilityTests { + private func context( + activeDatabase: String? = "app", + activeSchema: String? = "public", + table: Bool = true, + view: Bool = true, + database: Bool = true, + schema: Bool = true, + isReadOnly: Bool = false + ) -> ObjectRenameEligibility.Context { + ObjectRenameEligibility.Context( + activeDatabase: activeDatabase, + activeSchema: activeSchema, + supportsRenameTable: table, + supportsRenameView: view, + supportsRenameDatabase: database, + supportsRenameSchema: schema, + isReadOnly: isReadOnly + ) + } + + private func table(_ name: String, type: TableInfo.TableType = .table) -> TableInfo { + TableInfo(name: name, type: type, rowCount: nil, schema: "public") + } + + // MARK: - Tables + + @Test("A table on an engine that can rename one offers it") + func tableOffersRename() { + #expect(ObjectRenameEligibility.canRename(table: table("orders"), context: context())) + } + + @Test("An engine with no table rename never offers it") + func engineWithoutRenameNeverOffers() { + #expect(!ObjectRenameEligibility.canRename(table: table("orders"), context: context(table: false))) + } + + @Test("Read-only safe mode hides rename with the rest of the writes") + func readOnlyHidesRename() { + #expect(!ObjectRenameEligibility.canRename(table: table("orders"), context: context(isReadOnly: true))) + } + + /// A system table belongs to the engine, and renaming one breaks the catalogue it is part of. + @Test("A system table is never renameable") + func systemTableIsNotRenameable() { + #expect(!ObjectRenameEligibility.canRename(table: table("pg_stats", type: .systemTable), context: context())) + } + + @Test("A view is renameable where the engine renames one") + func viewIsRenameable() { + #expect(ObjectRenameEligibility.canRename(table: table("active_users", type: .view), context: context())) + } + + /// SQLite's one rename statement refuses a view, and the engines built on it inherit that. + /// Offering the item there guarantees a failure alert for something the menu promised. + @Test("An engine that renames tables but not views omits it on a view") + func viewIsNotRenameableWhereTheEngineRefusesOne() { + #expect(!ObjectRenameEligibility.canRename( + table: table("active_users", type: .view), context: context(view: false) + )) + #expect(ObjectRenameEligibility.canRename(table: table("orders"), context: context(view: false))) + } + + // MARK: - Containers + + @Test("A database the connection is not on is renameable") + func inactiveDatabaseIsRenameable() { + let target = DatabaseContainerRef.database("archive", isSystem: false) + #expect(ObjectRenameEligibility.renameable([target], context: context()) == target) + } + + /// Several engines refuse outright, PostgreSQL among them, and the ones that allow it leave + /// the session pointing at a name that has gone. Drop already asks the user to switch away. + @Test("The database the connection is on is never renameable") + func activeDatabaseIsNotRenameable() { + let target = DatabaseContainerRef.database("app", isSystem: false) + #expect(ObjectRenameEligibility.renameable([target], context: context()) == nil) + } + + @Test("A system database is never renameable") + func systemDatabaseIsNotRenameable() { + let target = DatabaseContainerRef.database("mysql", isSystem: true) + #expect(ObjectRenameEligibility.renameable([target], context: context()) == nil) + } + + @Test("An engine with no database rename never offers it") + func engineWithoutDatabaseRenameNeverOffers() { + let target = DatabaseContainerRef.database("archive", isSystem: false) + #expect(ObjectRenameEligibility.renameable([target], context: context(database: false)) == nil) + } + + @Test("A schema outside the active database is renameable") + func schemaInAnotherDatabaseIsRenameable() { + let target = DatabaseContainerRef.schema(database: "archive", schema: "public", isSystem: false) + #expect(ObjectRenameEligibility.renameable([target], context: context()) == target) + } + + @Test("The schema the connection is on is never renameable") + func activeSchemaIsNotRenameable() { + let target = DatabaseContainerRef.schema(database: "app", schema: "public", isSystem: false) + #expect(ObjectRenameEligibility.renameable([target], context: context()) == nil) + } + + /// A rename names one new name, so a multi-row selection has nothing to apply. Offering the + /// item over one would silently act on a row the user did not mean. + @Test("A selection of several containers offers no rename") + func multipleContainersOfferNoRename() { + let targets = [ + DatabaseContainerRef.database("archive", isSystem: false), + DatabaseContainerRef.database("staging", isSystem: false), + ] + #expect(ObjectRenameEligibility.renameable(targets, context: context()) == nil) + } + + @Test("Read-only safe mode hides container rename too") + func readOnlyHidesContainerRename() { + let target = DatabaseContainerRef.database("archive", isSystem: false) + #expect(ObjectRenameEligibility.renameable([target], context: context(isReadOnly: true)) == nil) + } +} diff --git a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift index f86631b9b..2ef6484fd 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift @@ -30,7 +30,8 @@ struct DatabaseTreeMenuSpecTests { activeSchema: String? = "public", canReachOtherDatabases: Bool = true, canFilterDatabases: Bool = false, - hasDatabaseFilter: Bool = false + hasDatabaseFilter: Bool = false, + supportsRename: Bool = true ) -> DatabaseTreeMenuContext { DatabaseTreeMenuContext( clicked: clicked, @@ -51,6 +52,15 @@ struct DatabaseTreeMenuSpecTests { supportsDropSchema: true, isReadOnly: isReadOnly ), + renameEligibility: ObjectRenameEligibility.Context( + activeDatabase: activeDatabase, + activeSchema: activeSchema, + supportsRenameTable: supportsRename, + supportsRenameView: supportsRename, + supportsRenameDatabase: supportsRename, + supportsRenameSchema: supportsRename, + isReadOnly: isReadOnly + ), containerEntityName: "Database", containerEntityNamePlural: "Databases", schemaEntityName: "Schema", @@ -263,6 +273,80 @@ struct DatabaseTreeMenuSpecTests { #expect(!issued.contains(.dropTables(targets: [clicked, elsewhere], ref: clicked))) } + @Test("A table row offers Rename where the engine can do it") + func tableOffersRename() { + let clicked = tableRef("orders") + let issued = commands(DatabaseTreeMenuSpec.items(for: context(clicked: .table(clicked)))) + + #expect(issued.contains(.beginRenameTable(ref: clicked, isRecentRow: false))) + } + + /// No ellipsis, because it opens the row's own field rather than a sheet. Finder spells its + /// own inline rename the same way. + @Test("Rename carries no ellipsis") + func renameHasNoEllipsis() { + let clicked = tableRef("orders") + let items = DatabaseTreeMenuSpec.items(for: context(clicked: .table(clicked))) + + #expect(titles(items).contains(String(localized: "Rename"))) + } + + /// Omitted rather than dimmed, which is what this menu already does for a Drop the engine + /// cannot perform. + @Test("An engine that cannot rename a table omits the item") + func engineWithoutRenameOmitsTheItem() { + let clicked = tableRef("orders") + let issued = commands(DatabaseTreeMenuSpec.items( + for: context(clicked: .table(clicked), supportsRename: false) + )) + + #expect(!issued.contains(.beginRenameTable(ref: clicked, isRecentRow: false))) + } + + @Test("Read-only safe mode hides Rename with the other writes") + func readOnlyOmitsRename() { + let clicked = tableRef("orders") + let issued = commands(DatabaseTreeMenuSpec.items( + for: context(clicked: .table(clicked), isReadOnly: true) + )) + + #expect(!issued.contains(.beginRenameTable(ref: clicked, isRecentRow: false))) + } + + /// A table drawn twice, once in its section and once under Recent, is one object with two + /// rows. The rename editor belongs on the row that was clicked; opening it on the section row + /// puts the field somewhere the user did not click, or nowhere while that section is collapsed. + @Test("Rename from a Recent row says so, so the editor lands on the clicked row") + func renameFromARecentRowCarriesThatRow() { + let clicked = tableRef("orders") + let issued = commands(DatabaseTreeMenuSpec.items(for: context(clicked: .recentTable(clicked)))) + + #expect(issued.contains(.beginRenameTable(ref: clicked, isRecentRow: true))) + #expect(!issued.contains(.beginRenameTable(ref: clicked, isRecentRow: false))) + } + + /// Snowflake and Trino hang tables off schemas and draw no database rows, so their schemas + /// arrive as a hierarchical section. Both declare and implement a schema rename, and without + /// this the command has no row to be raised from. + @Test("A hierarchical schema row offers Rename") + func hierarchicalSchemaOffersRename() { + let issued = commands(DatabaseTreeMenuSpec.items( + for: context(clicked: .hierarchicalSchemaSection(schema: "reporting")) + )) + let expected = DatabaseContainerRef.schema(database: "app", schema: "reporting", isSystem: false) + + #expect(issued.contains(.renameContainer(expected))) + } + + @Test("A hierarchical schema row omits Rename where the engine has none") + func hierarchicalSchemaWithoutRenameOmitsIt() { + let issued = commands(DatabaseTreeMenuSpec.items( + for: context(clicked: .hierarchicalSchemaSection(schema: "reporting"), supportsRename: false) + )) + + #expect(!issued.contains { if case .renameContainer = $0 { return true } else { return false } }) + } + @Test("The favourite item names the action it will take") func favouriteItemFlipsItsTitle() { let clicked = tableRef("orders") diff --git a/TableProTests/Views/Sidebar/DatabaseTreeRenameTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeRenameTests.swift new file mode 100644 index 000000000..235ecb30d --- /dev/null +++ b/TableProTests/Views/Sidebar/DatabaseTreeRenameTests.swift @@ -0,0 +1,45 @@ +// +// DatabaseTreeRenameTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +/// The three answers `RenameNameDecision` gives are separate because two of them are not failures. +@Suite("Object tree rename") +struct DatabaseTreeRenameTests { + @Test("A new name commits") + func newNameCommits() { + #expect(RenameNameDecision.decide(typed: "invoices", original: "orders") == .commit("invoices")) + } + + /// Finishing where you started is not a rename, and sending it would spend a round trip to + /// have the server rename an object to what it is already called. + @Test("The same name is not a rename") + func unchangedNameDoesNothing() { + #expect(RenameNameDecision.decide(typed: "orders", original: "orders") == .unchanged) + } + + @Test("Whitespace around a name is not part of it") + func surroundingWhitespaceIsTrimmed() { + #expect(RenameNameDecision.decide(typed: " invoices ", original: "orders") == .commit("invoices")) + #expect(RenameNameDecision.decide(typed: " orders ", original: "orders") == .unchanged) + } + + /// Clearing the field is how a rename is abandoned, so it is discarded rather than sent for + /// the server to answer with a syntax error. + @Test("An empty name is discarded") + func emptyNameIsDiscarded() { + #expect(RenameNameDecision.decide(typed: "", original: "orders") == .discard) + #expect(RenameNameDecision.decide(typed: " ", original: "orders") == .discard) + } + + /// A name that differs only in case is a real rename on every engine that folds case, because + /// the stored spelling is what the user sees. + @Test("A name differing only in case is a rename") + func caseOnlyChangeIsARename() { + #expect(RenameNameDecision.decide(typed: "Orders", original: "orders") == .commit("Orders")) + } +} diff --git a/docs/features/table-operations.mdx b/docs/features/table-operations.mdx index 4391291d2..f113d2945 100644 --- a/docs/features/table-operations.mdx +++ b/docs/features/table-operations.mdx @@ -29,6 +29,43 @@ Confirming with **Drop** or **Truncate** stages the operation. The row picks up Dropping is irreversible. On MySQL and MariaDB, truncate also resets the auto-increment counter. Back up important data first. +## Rename + +Renaming happens in the row itself. Choose **Rename** from a table's right-click menu and the label becomes a field: Return commits, Escape leaves the name alone, and clicking elsewhere commits. The item is absent where the engine cannot rename that kind of object. + +The statement runs at once instead of joining the drop and truncate queue, so **Preview SQL** never shows it. Everything bound to the table follows the new name: open tabs keep their rows, filters, sort and column widths, a favourite stays a favourite, and the Recent entry holds its place. A drop or truncate already queued against that table comes back out of the queue. + + + A sidebar table row with its label replaced by an editable text field + A sidebar table row with its label replaced by an editable text field + + +| Database | Rename table | Rename database | Rename schema | +|----------|--------------|-----------------|---------------| +| MySQL | Yes | No | No schemas | +| MariaDB | Yes | No | No schemas | +| PostgreSQL | Yes | Yes | Yes | +| Redshift | Yes | Yes | Yes | +| CockroachDB | Yes | Yes | Yes | +| PGlite | Yes | No | Yes | +| SQLite | Tables, not views | No | No schemas | +| LibSQL | Tables, not views | No | No schemas | +| Cloudflare D1 | Tables, not views | No | No schemas | +| ClickHouse | Yes | Yes | No | +| SQL Server | Yes | No | No | +| Oracle | Yes | No | No | +| Dameng | Yes | No | No | +| DuckDB | Yes | No | No | +| BigQuery | Yes | No | No | +| Snowflake | Yes | No | Yes | +| Trino | Depends on the connector | No | Yes | +| Teradata | Yes | No | No | +| MongoDB | Yes | No | No | + +No other engine has a rename, so the item never appears on Cassandra, DynamoDB, Elasticsearch, etcd, Kafka, Redis, SurrealDB or Beancount. What resembles one there is a copy followed by a delete, and it changes record identity. + +**Rename Database** and **Rename Schema** are absent on the container the connection is browsing. Switch to another one first, then rename the one you left. + ## Maintenance Right-click a table, choose **Maintenance**, and pick an operation. A sheet shows the operation's options and the exact SQL before it runs. diff --git a/docs/images/rename-table-dark.png b/docs/images/rename-table-dark.png new file mode 100644 index 000000000..ae569d1ba Binary files /dev/null and b/docs/images/rename-table-dark.png differ diff --git a/docs/images/rename-table.png b/docs/images/rename-table.png new file mode 100644 index 000000000..5f3009068 Binary files /dev/null and b/docs/images/rename-table.png differ