diff --git a/CHANGELOG.md b/CHANGELOG.md index 7af8d349a..446193966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - The table structure view has a Triggers tab for MySQL, MariaDB, PostgreSQL, SQLite, SQL Server, Oracle, libSQL, and Cloudflare D1. It lists each trigger with its timing and event (plus enabled state where the engine reports it), with a filter field and sortable columns. Selecting a trigger shows its full definition in a read-only syntax-highlighted viewer. (#1695) +- Triggers can be created, edited, and dropped from the Triggers tab. The editor opens the trigger's real definition (for PostgreSQL, its function and trigger together) so nothing is lost on save, and changes run through the usual confirmation and safe-mode checks. (#1695) - Traditional Chinese (繁體中文) language in Settings > General with full UI translation - An Add button in the table status bar inserts a new row at the end of the grid and starts editing it. diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift index 0c079a056..d0dee1fd1 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift @@ -27,6 +27,7 @@ final class CloudflareD1Plugin: NSObject, TableProPlugin, DriverPlugin { static let supportsImport = false static let supportsSchemaEditing = true static let supportsTriggers = true + static let supportsTriggerEditing = true static let databaseGroupingStrategy: GroupingStrategy = .flat static let brandColorHex = "#F6821F" static let urlSchemes: [String] = ["d1"] diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift index 0c0572c3c..5102a2a0d 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift @@ -454,6 +454,20 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable } } + func createTriggerTemplate(table: String, schema: String?) -> String? { + """ + CREATE TRIGGER \(quoteIdentifier("trigger_name")) + AFTER INSERT ON \(quoteIdentifier(table)) + BEGIN + -- INSERT INTO audit ...; + END; + """ + } + + func generateDropTriggerSQL(name: String, table: String, schema: String?) -> String? { + "DROP TRIGGER IF EXISTS \(quoteIdentifier(name))" + } + func fetchTableDDL(table: String, schema: String?) async throws -> String { let safeTable = escapeStringLiteral(table) let query = """ diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift b/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift index a8b424a46..740467fa8 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift @@ -29,6 +29,7 @@ final class LibSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsImport = false static let supportsSchemaEditing = true static let supportsTriggers = true + static let supportsTriggerEditing = true static let supportsDropDatabase = false static let supportsDatabaseSwitching = false static let databaseGroupingStrategy: GroupingStrategy = .flat diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift index 4db194ec3..b3e8fc9db 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift @@ -525,6 +525,22 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } + var supportsTransactionalDDL: Bool { true } + + func createTriggerTemplate(table: String, schema: String?) -> String? { + """ + CREATE TRIGGER \(quoteIdentifier("trigger_name")) + AFTER INSERT ON \(quoteIdentifier(table)) + BEGIN + -- INSERT INTO audit ...; + END; + """ + } + + func generateDropTriggerSQL(name: String, table: String, schema: String?) -> String? { + "DROP TRIGGER IF EXISTS \(quoteIdentifier(name))" + } + func fetchTableDDL(table: String, schema: String?) async throws -> String { let safeTable = escapeStringLiteral(table) let query = """ diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 29ff08a19..83bf5566f 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -151,6 +151,7 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsDropDatabase = true static let supportsTriggers = true + static let supportsTriggerEditing = true func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { MSSQLPluginDriver(config: config) diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift index 1aae8bf2d..5152b680c 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift @@ -243,6 +243,41 @@ extension MSSQLPluginDriver { } } + var triggerEditUsesReplace: Bool { true } + + var supportsTransactionalDDL: Bool { true } + + func createTriggerTemplate(table: String, schema: String?) -> String? { + let resolved = schema ?? _currentSchema + return """ + CREATE OR ALTER TRIGGER \(quoteIdentifier("trigger_name")) + ON \(quoteIdentifier(resolved)).\(quoteIdentifier(table)) + AFTER INSERT + AS + BEGIN + SET NOCOUNT ON; + -- INSERT INTO audit (...) SELECT ... FROM inserted; + END + """ + } + + func fetchTriggerDefinition(name: String, table: String, schema: String?) async throws -> String? { + let esc = (schema ?? _currentSchema).replacingOccurrences(of: "]", with: "]]") + let bracketedName = name.replacingOccurrences(of: "]", with: "]]") + let sql = "SELECT OBJECT_DEFINITION(OBJECT_ID('[\(esc)].[\(bracketedName)]'))" + let result = try await execute(query: sql) + guard let definition = result.rows.first?[safe: 0]?.asText, !definition.isEmpty else { return nil } + guard let range = definition.range(of: "CREATE TRIGGER", options: .caseInsensitive) else { + return definition + } + return definition.replacingCharacters(in: range, with: "CREATE OR ALTER TRIGGER") + } + + func generateDropTriggerSQL(name: String, table: String, schema: String?) -> String? { + let resolved = schema ?? _currentSchema + return "DROP TRIGGER \(quoteIdentifier(resolved)).\(quoteIdentifier(name))" + } + func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { let esc = effectiveSchemaEscaped(schema) let sql = """ diff --git a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift index 765f0906d..21750d801 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift @@ -147,6 +147,7 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsDropDatabase = true static let supportsTriggers = true + static let supportsTriggerEditing = true func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { MySQLPluginDriver(config: config) diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index e775139f0..e92826be0 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -421,6 +421,20 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return triggers } + func createTriggerTemplate(table: String, schema: String?) -> String? { + """ + CREATE TRIGGER \(quoteIdentifier("trigger_name")) BEFORE INSERT + ON \(quoteIdentifier(table)) FOR EACH ROW + BEGIN + -- SET NEW.column = ...; + END + """ + } + + func generateDropTriggerSQL(name: String, table: String, schema: String?) -> String? { + "DROP TRIGGER \(quoteIdentifier(name))" + } + func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] { let dbName = _activeDatabase let escapedDb = dbName.replacingOccurrences(of: "'", with: "''") diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 0d780171f..16a72c072 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -45,6 +45,7 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin, PluginDiagnost static let isDownloadable = true static let supportsTriggers = true + static let supportsTriggerEditing = true static let pathFieldRole: PathFieldRole = .serviceName static let supportsForeignKeyDisable = false static let supportsSchemaSwitching = true @@ -512,6 +513,25 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } + var triggerEditUsesReplace: Bool { true } + + func createTriggerTemplate(table: String, schema: String?) -> String? { + let quotedTable = "\"\(table.replacingOccurrences(of: "\"", with: "\"\""))\"" + return """ + CREATE OR REPLACE TRIGGER \("\"TRIGGER_NAME\"") + BEFORE INSERT ON \(quotedTable) + FOR EACH ROW + BEGIN + -- :NEW.column := ...; + NULL; + END; + """ + } + + func generateDropTriggerSQL(name: String, table: String, schema: String?) -> String? { + "DROP TRIGGER \"\(name.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { let escaped = effectiveSchemaEscaped(schema) let sql = """ diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift index 8eff47c11..6c0a673c8 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift @@ -125,6 +125,7 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let parameterStyle: ParameterStyle = .dollar static let supportsDropDatabase = true static let supportsTriggers = true + static let supportsTriggerEditing = true static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor( identifierQuote: "\"", diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 22adc504a..ed4601d27 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -337,6 +337,70 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { return triggers } + var triggerEditUsesReplace: Bool { true } + + var supportsTransactionalDDL: Bool { true } + + private func qualifiedTable(_ table: String, schema: String?) -> String { + let resolved = schema ?? core.currentSchema + return "\(quoteIdentifier(resolved)).\(quoteIdentifier(table))" + } + + func createTriggerTemplate(table: String, schema: String?) -> String? { + let qualified = qualifiedTable(table, schema: schema) + let fn = qualifiedTable("trigger_function", schema: schema) + return """ + CREATE OR REPLACE FUNCTION \(fn)() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + BEGIN + -- NEW.updated_at := now(); + RETURN NEW; + END; + $function$; + + CREATE OR REPLACE TRIGGER \(quoteIdentifier("trigger_name")) + BEFORE INSERT ON \(qualified) + FOR EACH ROW + EXECUTE FUNCTION \(fn)(); + """ + } + + func fetchTriggerDefinition(name: String, table: String, schema: String?) async throws -> String? { + let resolvedSchema = schema ?? core.currentSchema + let query = """ + SELECT pg_get_functiondef(t.tgfoid), pg_get_triggerdef(t.oid) + FROM pg_catalog.pg_trigger t + JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE t.tgname = '\(escapeLiteral(name))' + AND c.relname = '\(escapeLiteral(table))' + AND n.nspname = '\(escapeLiteral(resolvedSchema))' + AND NOT t.tgisinternal + LIMIT 1 + """ + let result = try await execute(query: query) + guard let row = result.rows.first, row.count >= 2, + let functionDef = row[0].asText, + let triggerDef = row[1].asText else { return nil } + let editableTrigger: String + if triggerDef.range(of: "CREATE CONSTRAINT TRIGGER", options: .caseInsensitive) != nil { + let drop = generateDropTriggerSQL(name: name, table: table, schema: schema) ?? "" + editableTrigger = "\(drop);\n\(triggerDef)" + } else { + editableTrigger = triggerDef.replacingOccurrences( + of: "CREATE TRIGGER ", + with: "CREATE OR REPLACE TRIGGER " + ) + } + return "\(functionDef);\n\n\(editableTrigger);" + } + + func generateDropTriggerSQL(name: String, table: String, schema: String?) -> String? { + "DROP TRIGGER IF EXISTS \(quoteIdentifier(name)) ON \(qualifiedTable(table, schema: schema))" + } + func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] { let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) let query = """ diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index c7643b295..f3ef4b3aa 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -36,6 +36,7 @@ final class SQLitePlugin: NSObject, TableProPlugin, DriverPlugin { static let brandColorHex = "#003B57" static let supportsDatabaseSwitching = false static let supportsTriggers = true + static let supportsTriggerEditing = true static let databaseGroupingStrategy: GroupingStrategy = .flat static let columnTypesByCategory: [String: [String]] = [ "Integer": ["INTEGER", "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT"], @@ -851,6 +852,22 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } } + var supportsTransactionalDDL: Bool { true } + + func createTriggerTemplate(table: String, schema: String?) -> String? { + """ + CREATE TRIGGER \(quoteIdentifier("trigger_name")) + AFTER INSERT ON \(quoteIdentifier(table)) + BEGIN + -- INSERT INTO audit ...; + END; + """ + } + + func generateDropTriggerSQL(name: String, table: String, schema: String?) -> String? { + "DROP TRIGGER IF EXISTS \(quoteIdentifier(name))" + } + func fetchTableDDL(table: String, schema: String?) async throws -> String { let safeTable = escapeStringLiteral(table) let query = """ diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index 5be5cde2a..26ae86e7b 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -24,6 +24,7 @@ public protocol DriverPlugin: TableProPlugin { static var editorLanguage: EditorLanguage { get } static var supportsForeignKeys: Bool { get } static var supportsTriggers: Bool { get } + static var supportsTriggerEditing: Bool { get } static var supportsSchemaEditing: Bool { get } static var supportsDatabaseSwitching: Bool { get } static var supportsSchemaSwitching: Bool { get } @@ -84,6 +85,7 @@ public extension DriverPlugin { static var editorLanguage: EditorLanguage { .sql } static var supportsForeignKeys: Bool { true } static var supportsTriggers: Bool { false } + static var supportsTriggerEditing: Bool { false } static var supportsSchemaEditing: Bool { true } static var supportsDatabaseSwitching: Bool { true } static var supportsSchemaSwitching: Bool { false } diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 30b40acd3..5272c3149 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -184,6 +184,13 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func editViewFallbackTemplate(viewName: String) -> String? func castColumnToText(_ column: String) -> String + // Trigger editing (optional — return nil when unsupported) + func createTriggerTemplate(table: String, schema: String?) -> String? + func fetchTriggerDefinition(name: String, table: String, schema: String?) async throws -> String? + func generateDropTriggerSQL(name: String, table: String, schema: String?) -> String? + var triggerEditUsesReplace: Bool { get } + var supportsTransactionalDDL: Bool { get } + // All-tables metadata SQL (optional — returns nil for non-SQL databases) func allTablesMetadataSQL(schema: String?) -> String? @@ -200,6 +207,12 @@ public extension PluginDatabaseDriver { func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] { [] } + func createTriggerTemplate(table: String, schema: String?) -> String? { nil } + func fetchTriggerDefinition(name: String, table: String, schema: String?) async throws -> String? { nil } + func generateDropTriggerSQL(name: String, table: String, schema: String?) -> String? { nil } + var triggerEditUsesReplace: Bool { false } + var supportsTransactionalDDL: Bool { false } + var supportsSchemas: Bool { false } func fetchSchemas() async throws -> [String] { [] } diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index ef3386b5a..609ea43c8 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -89,6 +89,13 @@ protocol DatabaseDriver: AnyObject { /// Fetch triggers for a specific table func fetchTriggers(table: String) async throws -> [TriggerInfo] + /// Trigger editing hooks (optional — nil when unsupported) + func createTriggerTemplate(table: String) -> String? + func fetchTriggerDefinition(name: String, table: String) async throws -> String? + func generateDropTriggerSQL(name: String, table: String) -> String? + var triggerEditUsesReplace: Bool { get } + var supportsTransactionalDDL: Bool { get } + /// Fetch foreign keys for all tables in the current database/schema in bulk. /// Default implementation falls back to per-table fetchForeignKeys. func fetchAllForeignKeys() async throws -> [String: [ForeignKeyInfo]] @@ -252,6 +259,12 @@ extension DatabaseDriver { func fetchTriggers(table: String) async throws -> [TriggerInfo] { [] } + func createTriggerTemplate(table: String) -> String? { nil } + func fetchTriggerDefinition(name: String, table: String) async throws -> String? { nil } + func generateDropTriggerSQL(name: String, table: String) -> String? { nil } + var triggerEditUsesReplace: Bool { false } + var supportsTransactionalDDL: Bool { false } + func ping() async throws { _ = try await execute(query: "SELECT 1") } diff --git a/TablePro/Core/Database/TriggerEditing.swift b/TablePro/Core/Database/TriggerEditing.swift new file mode 100644 index 000000000..bafdce9c6 --- /dev/null +++ b/TablePro/Core/Database/TriggerEditing.swift @@ -0,0 +1,162 @@ +// +// TriggerEditing.swift +// TablePro +// +// Orchestrates create / edit / drop of triggers through the execution gate +// with per-engine transaction wrapping or rollback-buffer semantics. +// + +import Combine +import Foundation +import os + +enum TriggerEditingError: LocalizedError { + case notConnected + case denied(String) + case dropUnavailable + + var errorDescription: String? { + switch self { + case .notConnected: String(localized: "Not connected to database") + case let .denied(reason): reason + case .dropUnavailable: String(localized: "This database cannot drop triggers") + } + } +} + +enum TriggerApplyStrategy: Equatable { + case transactional(dropFirst: Bool) + case dropThenCreate + case direct + + static func resolve(isEdit: Bool, usesReplace: Bool, transactionalDDL: Bool) -> TriggerApplyStrategy { + let dropFirst = isEdit && !usesReplace + if transactionalDDL { + return .transactional(dropFirst: dropFirst) + } + return dropFirst ? .dropThenCreate : .direct + } +} + +@MainActor +enum TriggerEditing { + private static let logger = Logger(subsystem: "com.TablePro", category: "TriggerEditing") + + static func apply( + connection: DatabaseConnection, + tableName: String, + sql: String, + isEdit: Bool, + originalName: String?, + originalDefinition: String? + ) async throws { + guard let driver = DatabaseManager.shared.driver(for: connection.id) else { + throw TriggerEditingError.notConnected + } + + let decision = await ExecutionGateProvider.shared.authorize( + OperationRequest( + connectionId: connection.id, + databaseType: connection.type, + sql: sql, + kind: .schemaMutation, + caller: .userInterface, + capabilities: .interactiveUser, + operationDescription: isEdit + ? String(localized: "Edit Trigger") + : String(localized: "Create Trigger") + ) + ) + guard case .authorized = decision else { + throw TriggerEditingError.denied(decision.deniedReason ?? String(localized: "Operation not permitted")) + } + + let strategy = TriggerApplyStrategy.resolve( + isEdit: isEdit, + usesReplace: driver.triggerEditUsesReplace, + transactionalDDL: driver.supportsTransactionalDDL + ) + let dropSQL = originalName.flatMap { driver.generateDropTriggerSQL(name: $0, table: tableName) } + + switch strategy { + case let .transactional(dropFirst): + try await runInTransaction(driver: driver, dropSQL: dropFirst ? dropSQL : nil, sql: sql) + case .dropThenCreate: + guard let dropSQL else { throw TriggerEditingError.dropUnavailable } + try await runDropThenCreate(driver: driver, dropSQL: dropSQL, sql: sql, rollback: originalDefinition) + case .direct: + _ = try await driver.execute(query: sql) + } + + recordHistory(sql, connection: connection) + AppCommands.shared.refreshData.send(connection.id) + } + + static func drop(connection: DatabaseConnection, tableName: String, name: String) async throws { + guard let driver = DatabaseManager.shared.driver(for: connection.id) else { + throw TriggerEditingError.notConnected + } + guard let dropSQL = driver.generateDropTriggerSQL(name: name, table: tableName) else { + throw TriggerEditingError.dropUnavailable + } + + let decision = await ExecutionGateProvider.shared.authorize( + OperationRequest( + connectionId: connection.id, + databaseType: connection.type, + sql: dropSQL, + kind: .destructiveQuery, + caller: .userInterface, + capabilities: .interactiveUser, + operationDescription: String(localized: "Drop Trigger") + ) + ) + guard case .authorized = decision else { + throw TriggerEditingError.denied(decision.deniedReason ?? String(localized: "Operation not permitted")) + } + + _ = try await driver.execute(query: dropSQL) + recordHistory(dropSQL, connection: connection) + AppCommands.shared.refreshData.send(connection.id) + } + + static func runInTransaction(driver: DatabaseDriver, dropSQL: String?, sql: String) async throws { + try await driver.beginTransaction() + do { + if let dropSQL { _ = try await driver.execute(query: dropSQL) } + _ = try await driver.execute(query: sql) + try await driver.commitTransaction() + } catch { + try? await driver.rollbackTransaction() + throw error + } + } + + static func runDropThenCreate(driver: DatabaseDriver, dropSQL: String, sql: String, rollback: String?) async throws { + _ = try await driver.execute(query: dropSQL) + do { + _ = try await driver.execute(query: sql) + } catch { + if let rollback { + do { + _ = try await driver.execute(query: rollback) + logger.error("Trigger edit failed; restored original definition: \(error.localizedDescription, privacy: .public)") + } catch let rollbackError { + logger.error("Trigger edit failed and rollback failed, trigger may be missing: edit=\(error.localizedDescription, privacy: .public) rollback=\(rollbackError.localizedDescription, privacy: .public)") + } + } + throw error + } + } + + private static func recordHistory(_ sql: String, connection: DatabaseConnection) { + QueryHistoryManager.shared.recordQuery( + query: sql, + connectionId: connection.id, + databaseName: DatabaseManager.shared.activeDatabaseName(for: connection), + executionTime: 0, + rowCount: 0, + wasSuccessful: true + ) + } +} diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 0febd756f..aab1e408d 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -268,6 +268,22 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { } } + func createTriggerTemplate(table: String) -> String? { + pluginDriver.createTriggerTemplate(table: table, schema: pluginDriver.currentSchema) + } + + func fetchTriggerDefinition(name: String, table: String) async throws -> String? { + try await pluginDriver.fetchTriggerDefinition(name: name, table: table, schema: pluginDriver.currentSchema) + } + + func generateDropTriggerSQL(name: String, table: String) -> String? { + pluginDriver.generateDropTriggerSQL(name: name, table: table, schema: pluginDriver.currentSchema) + } + + var triggerEditUsesReplace: Bool { pluginDriver.triggerEditUsesReplace } + + var supportsTransactionalDDL: Bool { pluginDriver.supportsTransactionalDDL } + func fetchApproximateRowCount(table: String) async throws -> Int? { try await pluginDriver.fetchApproximateRowCount(table: table, schema: pluginDriver.currentSchema) } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index 44993deb7..fb15b9cf0 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -55,6 +55,7 @@ struct PluginMetadataSnapshot: Sendable { var supportsDropIndex: Bool = true var supportsModifyPrimaryKey: Bool = true var supportsTriggers: Bool = false + var supportsTriggerEditing: Bool = false var defaultSSLMode: SSLMode = .disabled var supportsOpportunisticTLS: Bool = true var supportsCloudflareTunnel: Bool = true @@ -451,6 +452,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsDropDatabase: true, supportsRenameColumn: true, supportsTriggers: true, + supportsTriggerEditing: true, defaultSSLMode: .preferred ), schema: PluginMetadataSnapshot.SchemaInfo( @@ -501,6 +503,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsDropDatabase: true, supportsRenameColumn: true, supportsTriggers: true, + supportsTriggerEditing: true, defaultSSLMode: .preferred ), schema: PluginMetadataSnapshot.SchemaInfo( @@ -552,6 +555,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsDropDatabase: true, supportsRenameColumn: true, supportsTriggers: true, + supportsTriggerEditing: true, defaultSSLMode: .preferred ), schema: PluginMetadataSnapshot.SchemaInfo( @@ -714,6 +718,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsRenameColumn: true, supportsModifyPrimaryKey: false, supportsTriggers: true, + supportsTriggerEditing: true, supportsCloudflareTunnel: false ), schema: PluginMetadataSnapshot.SchemaInfo( @@ -900,6 +905,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsDropIndex: driverType.supportsDropIndex, supportsModifyPrimaryKey: driverType.supportsModifyPrimaryKey, supportsTriggers: driverType.supportsTriggers, + supportsTriggerEditing: driverType.supportsTriggerEditing, defaultSSLMode: existingSnapshot?.capabilities.defaultSSLMode ?? .disabled, supportsOpportunisticTLS: existingSnapshot?.capabilities.supportsOpportunisticTLS ?? true, supportsCloudflareTunnel: driverType.supportsSSH, diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index 77a9f29f4..3ed9c661c 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -200,6 +200,10 @@ extension DatabaseType { PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsTriggers ?? false } + var supportsTriggerEditing: Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsTriggerEditing ?? false + } + var supportsSchemaEditing: Bool { PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.supportsSchemaEditing ?? true } diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index e41b47ed1..54d97ae0a 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -294,7 +294,8 @@ struct TableStructureView: View { case .triggers: TriggerDetailView( triggers: triggers, - databaseType: connection.type, + connection: connection, + tableName: tableName, isLoading: !loadedTabs.contains(.triggers), onOpenInEditor: openTriggerInEditor ) diff --git a/TablePro/Views/Structure/TriggerDetailView.swift b/TablePro/Views/Structure/TriggerDetailView.swift index b61a16e91..aeaf5dff8 100644 --- a/TablePro/Views/Structure/TriggerDetailView.swift +++ b/TablePro/Views/Structure/TriggerDetailView.swift @@ -35,22 +35,55 @@ final class TriggerInspectorState { } } +private struct TriggerEditorSheetItem: Identifiable { + let id = UUID() + let mode: TriggerEditorView.Mode + let sql: String +} + struct TriggerDetailView: View { let triggers: [TriggerInfo] - let databaseType: DatabaseType + let connection: DatabaseConnection + let tableName: String let isLoading: Bool let onOpenInEditor: (TriggerInfo) -> Void @State private var state = TriggerInspectorState() + @State private var editorSheet: TriggerEditorSheetItem? + @State private var pendingDelete: TriggerInfo? + @State private var actionError: String? + + private var canEdit: Bool { connection.type.supportsTriggerEditing } var body: some View { if isLoading { ProgressView() .frame(maxWidth: .infinity, maxHeight: .infinity) } else if triggers.isEmpty { + emptyState + } else { + populated + } + } + + private var emptyState: some View { + VStack(spacing: 0) { + if canEdit { + TriggerActionBar(triggers: triggers, state: state, canEdit: canEdit, onNew: newTrigger, onEdit: editTrigger, onDelete: { pendingDelete = $0 }) + Divider() + } EmptyStateView.triggers() .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { + } + .sheet(item: $editorSheet, content: makeEditorSheet(for:)) + } + + private var populated: some View { + VStack(spacing: 0) { + if canEdit { + TriggerActionBar(triggers: triggers, state: state, canEdit: canEdit, onNew: newTrigger, onEdit: editTrigger, onDelete: { pendingDelete = $0 }) + Divider() + } AutosavingVSplitView( autosaveName: "com.TablePro.triggerSplit", topMinimumHeight: 120, @@ -58,12 +91,105 @@ struct TriggerDetailView: View { ) { TriggerListPane(triggers: triggers, state: state) } bottom: { - TriggerDetailPane(triggers: triggers, state: state, databaseType: databaseType, onOpenInEditor: onOpenInEditor) + TriggerDetailPane(triggers: triggers, state: state, databaseType: connection.type, onOpenInEditor: onOpenInEditor) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { state.ensureSelection(triggers) } + .onChange(of: triggers) { _, newTriggers in state.ensureSelection(newTriggers) } + .sheet(item: $editorSheet, content: makeEditorSheet(for:)) + .confirmationDialog( + String(format: String(localized: "Drop trigger “%@”?"), pendingDelete?.name ?? ""), + isPresented: Binding(get: { pendingDelete != nil }, set: { if !$0 { pendingDelete = nil } }), + titleVisibility: .visible + ) { + Button(String(localized: "Drop Trigger"), role: .destructive) { + if let trigger = pendingDelete { performDelete(trigger) } + pendingDelete = nil + } + Button(String(localized: "Cancel"), role: .cancel) { pendingDelete = nil } + } + .alert( + String(localized: "Trigger operation failed"), + isPresented: Binding(get: { actionError != nil }, set: { if !$0 { actionError = nil } }) + ) { + Button(String(localized: "OK"), role: .cancel) { actionError = nil } + } message: { + Text(actionError ?? "") + } + } + + private func makeEditorSheet(for item: TriggerEditorSheetItem) -> some View { + TriggerEditorView( + connection: connection, + tableName: tableName, + mode: item.mode, + initialSQL: item.sql, + onClose: { editorSheet = nil } + ) + } + + private func newTrigger() { + let driver = DatabaseManager.shared.driver(for: connection.id) + let template = driver?.createTriggerTemplate(table: tableName) + ?? "CREATE TRIGGER trigger_name\nAFTER INSERT ON \(tableName)\nBEGIN\nEND;" + editorSheet = TriggerEditorSheetItem(mode: .create, sql: template) + } + + private func editTrigger(_ trigger: TriggerInfo) { + Task { + let driver = DatabaseManager.shared.driver(for: connection.id) + let fetched = try? await driver?.fetchTriggerDefinition(name: trigger.name, table: tableName) + let sql = (fetched ?? nil) ?? trigger.statement + editorSheet = TriggerEditorSheetItem( + mode: .edit(originalName: trigger.name, originalDefinition: trigger.statement), + sql: sql + ) + } + } + + private func performDelete(_ trigger: TriggerInfo) { + Task { + do { + try await TriggerEditing.drop(connection: connection, tableName: tableName, name: trigger.name) + } catch { + actionError = error.localizedDescription + } + } + } +} + +private struct TriggerActionBar: View { + let triggers: [TriggerInfo] + let state: TriggerInspectorState + let canEdit: Bool + let onNew: () -> Void + let onEdit: (TriggerInfo) -> Void + let onDelete: (TriggerInfo) -> Void + + var body: some View { + let selected = state.selectedTrigger(triggers) + HStack(spacing: 8) { + Button(action: onNew) { + Label("New Trigger", systemImage: "plus") + } + Button { + if let selected { onEdit(selected) } + } label: { + Label("Edit", systemImage: "pencil") + } + .disabled(selected == nil) + Button { + if let selected { onDelete(selected) } + } label: { + Label("Delete", systemImage: "trash") } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear { state.ensureSelection(triggers) } - .onChange(of: triggers) { _, newTriggers in state.ensureSelection(newTriggers) } + .disabled(selected == nil) + Spacer() } + .buttonStyle(.borderless) + .padding(.horizontal, 8) + .padding(.vertical, 6) } } diff --git a/TablePro/Views/Structure/TriggerEditorView.swift b/TablePro/Views/Structure/TriggerEditorView.swift new file mode 100644 index 000000000..f7136f8a0 --- /dev/null +++ b/TablePro/Views/Structure/TriggerEditorView.swift @@ -0,0 +1,132 @@ +// +// TriggerEditorView.swift +// TablePro +// +// DDL-first editor sheet for creating and editing triggers. +// + +import CodeEditLanguages +import CodeEditSourceEditor +import SwiftUI +import TableProPluginKit + +struct TriggerEditorView: View { + enum Mode { + case create + case edit(originalName: String, originalDefinition: String) + } + + let connection: DatabaseConnection + let tableName: String + let mode: Mode + let onClose: () -> Void + + @State private var sql: String + @State private var editorState = SourceEditorState() + @State private var editorConfiguration: SourceEditorConfiguration + @State private var isApplying = false + @State private var errorMessage: String? + @Environment(\.colorScheme) private var colorScheme + @AppStorage("structureCodeFontSize") private var fontSize: Double = 13 + + init(connection: DatabaseConnection, tableName: String, mode: Mode, initialSQL: String, onClose: @escaping () -> Void) { + self.connection = connection + self.tableName = tableName + self.mode = mode + self.onClose = onClose + self._sql = State(wrappedValue: initialSQL) + self._editorConfiguration = State(wrappedValue: Self.makeConfiguration(fontSize: 13)) + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + SourceEditor( + $sql, + language: PluginManager.shared.editorLanguage(for: connection.type).treeSitterLanguage, + configuration: editorConfiguration, + state: $editorState + ) + if let errorMessage { + Divider() + Label(errorMessage, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + .font(.callout) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + } + } + .frame(minWidth: 560, idealWidth: 680, minHeight: 360, idealHeight: 460) + .onChange(of: colorScheme) { + editorConfiguration = Self.makeConfiguration(fontSize: fontSize) + } + .onChange(of: fontSize) { _, newSize in + editorConfiguration = Self.makeConfiguration(fontSize: newSize) + } + } + + private var header: some View { + HStack { + Text(isEdit ? "Edit Trigger" : "New Trigger") + .font(.headline) + Spacer() + Button("Cancel", role: .cancel) { onClose() } + .keyboardShortcut(.cancelAction) + Button(isEdit ? "Save" : "Create") { apply() } + .keyboardShortcut(.defaultAction) + .buttonStyle(.borderedProminent) + .disabled(sql.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isApplying) + } + .padding() + } + + private var isEdit: Bool { + if case .edit = mode { return true } + return false + } + + private func apply() { + isApplying = true + errorMessage = nil + let originalName: String? + let originalDefinition: String? + switch mode { + case .create: + originalName = nil + originalDefinition = nil + case let .edit(name, definition): + originalName = name + originalDefinition = definition + } + Task { + defer { isApplying = false } + do { + try await TriggerEditing.apply( + connection: connection, + tableName: tableName, + sql: sql, + isEdit: isEdit, + originalName: originalName, + originalDefinition: originalDefinition + ) + onClose() + } catch { + errorMessage = error.localizedDescription + } + } + } + + private static func makeConfiguration(fontSize: Double) -> SourceEditorConfiguration { + SourceEditorConfiguration( + appearance: .init( + theme: TableProEditorTheme.make(), + font: NSFont.monospacedSystemFont(ofSize: CGFloat(fontSize), weight: .regular), + wrapLines: false + ), + behavior: .init(isEditable: true), + layout: .init(contentInsets: NSEdgeInsets(top: 4, left: 4, bottom: 4, right: 4)), + peripherals: .init(showGutter: true, showMinimap: false, showFoldingRibbon: false) + ) + } +} diff --git a/TableProTests/Core/Database/TriggerInfoMappingTests.swift b/TableProTests/Core/Database/TriggerInfoMappingTests.swift index ed3abbc4c..5a6d46609 100644 --- a/TableProTests/Core/Database/TriggerInfoMappingTests.swift +++ b/TableProTests/Core/Database/TriggerInfoMappingTests.swift @@ -17,12 +17,29 @@ private final class StubTriggerDriver: PluginDatabaseDriver { var serverVersion: String? { nil } var triggersToReturn: [PluginTriggerInfo] = [] + var templateToReturn: String? + var definitionToReturn: String? + var dropToReturn: String? + var editUsesReplace = false + var transactionalDDL = false + var executedQueries: [String] = [] + var throwOnQueryContaining: String? + + func createTriggerTemplate(table: String, schema: String?) -> String? { templateToReturn } + func fetchTriggerDefinition(name: String, table: String, schema: String?) async throws -> String? { definitionToReturn } + func generateDropTriggerSQL(name: String, table: String, schema: String?) -> String? { dropToReturn } + var triggerEditUsesReplace: Bool { editUsesReplace } + var supportsTransactionalDDL: Bool { transactionalDDL } func connect() async throws {} func disconnect() {} func ping() async throws {} func execute(query: String) async throws -> PluginQueryResult { - PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + executedQueries.append(query) + if let marker = throwOnQueryContaining, query.contains(marker) { + throw NSError(domain: "StubTriggerDriver", code: 1) + } + return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } @@ -112,3 +129,116 @@ struct StructureTabTriggersTests { #expect(StructureTab.triggers.displayName == "Triggers") } } + +@Suite("Trigger apply strategy") +struct TriggerApplyStrategyTests { + @Test("MySQL edit drops then recreates (no replace, non-transactional)") + func mysqlEdit() { + #expect(TriggerApplyStrategy.resolve(isEdit: true, usesReplace: false, transactionalDDL: false) == .dropThenCreate) + } + + @Test("Create is always direct or transactional, never drop-first") + func createNeverDropsFirst() { + #expect(TriggerApplyStrategy.resolve(isEdit: false, usesReplace: false, transactionalDDL: false) == .direct) + #expect(TriggerApplyStrategy.resolve(isEdit: false, usesReplace: false, transactionalDDL: true) == .transactional(dropFirst: false)) + } + + @Test("SQLite edit drops first inside a transaction") + func sqliteEdit() { + #expect(TriggerApplyStrategy.resolve(isEdit: true, usesReplace: false, transactionalDDL: true) == .transactional(dropFirst: true)) + } + + @Test("PostgreSQL and SQL Server edits replace in a transaction without a drop") + func replaceTransactionalEdit() { + #expect(TriggerApplyStrategy.resolve(isEdit: true, usesReplace: true, transactionalDDL: true) == .transactional(dropFirst: false)) + } + + @Test("Oracle edit replaces directly (no transactional DDL)") + func oracleEdit() { + #expect(TriggerApplyStrategy.resolve(isEdit: true, usesReplace: true, transactionalDDL: false) == .direct) + } +} + +@Suite("Trigger editing bridge") +struct TriggerEditingBridgeTests { + private func makeAdapter(_ configure: (StubTriggerDriver) -> Void) -> PluginDriverAdapter { + let driver = StubTriggerDriver() + configure(driver) + let connection = DatabaseConnection(name: "Test", type: .postgresql) + return PluginDriverAdapter(connection: connection, pluginDriver: driver) + } + + @Test("Adapter bridges the create-trigger template") + func bridgesTemplate() { + let adapter = makeAdapter { $0.templateToReturn = "CREATE TRIGGER t ..." } + #expect(adapter.createTriggerTemplate(table: "users") == "CREATE TRIGGER t ...") + } + + @Test("Adapter bridges the drop-trigger SQL") + func bridgesDrop() { + let adapter = makeAdapter { $0.dropToReturn = "DROP TRIGGER t" } + #expect(adapter.generateDropTriggerSQL(name: "t", table: "users") == "DROP TRIGGER t") + } + + @Test("Adapter bridges the editable definition") + func bridgesDefinition() async throws { + let adapter = makeAdapter { $0.definitionToReturn = "CREATE OR REPLACE TRIGGER t ..." } + let def = try await adapter.fetchTriggerDefinition(name: "t", table: "users") + #expect(def == "CREATE OR REPLACE TRIGGER t ...") + } + + @Test("Adapter bridges edit-strategy capability flags") + func bridgesFlags() { + let adapter = makeAdapter { + $0.editUsesReplace = true + $0.transactionalDDL = true + } + #expect(adapter.triggerEditUsesReplace) + #expect(adapter.supportsTransactionalDDL) + } +} + +@MainActor +@Suite("Trigger apply execution") +struct TriggerApplyExecutionTests { + private func makeStubAndAdapter() -> (StubTriggerDriver, PluginDriverAdapter) { + let stub = StubTriggerDriver() + let connection = DatabaseConnection(name: "Test", type: .postgresql) + return (stub, PluginDriverAdapter(connection: connection, pluginDriver: stub)) + } + + @Test("Transactional apply runs BEGIN, drop, create, COMMIT in order") + func transactionalSuccess() async throws { + let (stub, adapter) = makeStubAndAdapter() + try await TriggerEditing.runInTransaction(driver: adapter, dropSQL: "DROP TRIGGER t", sql: "CREATE TRIGGER t") + #expect(stub.executedQueries == ["BEGIN", "DROP TRIGGER t", "CREATE TRIGGER t", "COMMIT"]) + } + + @Test("Transactional apply rolls back and does not commit when the create fails") + func transactionalRollback() async { + let (stub, adapter) = makeStubAndAdapter() + stub.throwOnQueryContaining = "CREATE TRIGGER" + await #expect(throws: (any Error).self) { + try await TriggerEditing.runInTransaction(driver: adapter, dropSQL: nil, sql: "CREATE TRIGGER t") + } + #expect(stub.executedQueries.contains("ROLLBACK")) + #expect(!stub.executedQueries.contains("COMMIT")) + } + + @Test("Drop-then-create runs drop then create on success") + func dropThenCreateSuccess() async throws { + let (stub, adapter) = makeStubAndAdapter() + try await TriggerEditing.runDropThenCreate(driver: adapter, dropSQL: "DROP TRIGGER t", sql: "CREATE TRIGGER t", rollback: "RESTORE t") + #expect(stub.executedQueries == ["DROP TRIGGER t", "CREATE TRIGGER t"]) + } + + @Test("Drop-then-create restores the original when the create fails") + func dropThenCreateRollbackBuffer() async { + let (stub, adapter) = makeStubAndAdapter() + stub.throwOnQueryContaining = "CREATE TRIGGER" + await #expect(throws: (any Error).self) { + try await TriggerEditing.runDropThenCreate(driver: adapter, dropSQL: "DROP TRIGGER t", sql: "CREATE TRIGGER t", rollback: "RESTORE t") + } + #expect(stub.executedQueries == ["DROP TRIGGER t", "CREATE TRIGGER t", "RESTORE t"]) + } +} diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx index 0888420aa..7cc09a8e9 100644 --- a/docs/features/table-structure.mdx +++ b/docs/features/table-structure.mdx @@ -159,7 +159,7 @@ The DDL view uses tree-sitter syntax highlighting with line numbers. Use the too ## Triggers Tab -The Triggers tab lists the table's triggers and shows the full definition of the selected one. It is read-only. Use the filter field to narrow the list, and click a column header to sort. +The Triggers tab lists the table's triggers and shows the full definition of the selected one. Use the filter field to narrow the list, and click a column header to sort. | Property | Description | |----------|-------------| @@ -170,6 +170,10 @@ The Triggers tab lists the table's triggers and shows the full definition of the Select a trigger to see its full definition in a syntax-highlighted viewer with **Copy** and **Open in Editor** buttons. The definition is the `CREATE TRIGGER` statement, which includes the orientation (FOR EACH ROW/STATEMENT) and any WHEN condition. MySQL and MariaDB reconstruct it from the body; PostgreSQL uses `pg_get_triggerdef`; SQLite, libSQL, and Cloudflare D1 read the stored statement; SQL Server uses `OBJECT_DEFINITION`. +### Editing Triggers + +Use **New Trigger**, **Edit**, and **Delete** in the Triggers tab toolbar to manage triggers. The editor opens the trigger's actual DDL so the whole definition round-trips without losing any clause. For PostgreSQL it shows both the trigger function and the trigger so you can edit the logic. Saving runs the correct statements for the engine (`CREATE OR REPLACE` / `CREATE OR ALTER`, or drop-and-recreate), wrapped in a transaction where the engine supports transactional DDL, and goes through the same confirmation and safe-mode checks as other schema changes. Oracle creates and drops triggers, but its body is not retrieved, so editing starts from the trigger header. + Available for MySQL, MariaDB, PostgreSQL, SQLite, SQL Server, Oracle, libSQL, and Cloudflare D1. The tab is hidden for databases that do not expose triggers. Oracle shows trigger metadata only; the body is not retrieved.