-
-
Notifications
You must be signed in to change notification settings - Fork 297
feat(structure): add Triggers tab to Show Structure for MySQL, PostgreSQL, and SQLite #1696
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
dac4060
531b455
4462eef
dec81a5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -287,6 +287,54 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { | |
| return foreignKeys | ||
| } | ||
|
|
||
| func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] { | ||
| let resolvedSchema = schema ?? core.currentSchema | ||
| let schemaLiteral = escapeLiteral(resolvedSchema) | ||
| let tableLiteral = escapeLiteral(table) | ||
| let query = """ | ||
| SELECT | ||
| t.tgname, | ||
| CASE WHEN (t.tgtype & 64) != 0 THEN 'INSTEAD OF' | ||
| WHEN (t.tgtype & 2) != 0 THEN 'BEFORE' | ||
| ELSE 'AFTER' END AS timing, | ||
| CASE WHEN (t.tgtype & 4) != 0 AND (t.tgtype & 8) != 0 AND (t.tgtype & 16) != 0 | ||
| THEN 'INSERT OR UPDATE OR DELETE' | ||
| WHEN (t.tgtype & 4) != 0 AND (t.tgtype & 8) != 0 THEN 'INSERT OR UPDATE' | ||
| WHEN (t.tgtype & 4) != 0 AND (t.tgtype & 16) != 0 THEN 'INSERT OR DELETE' | ||
| WHEN (t.tgtype & 8) != 0 AND (t.tgtype & 16) != 0 THEN 'UPDATE OR DELETE' | ||
| WHEN (t.tgtype & 4) != 0 THEN 'INSERT' | ||
| WHEN (t.tgtype & 8) != 0 THEN 'UPDATE' | ||
| WHEN (t.tgtype & 16) != 0 THEN 'DELETE' | ||
|
Comment on lines
+306
to
+307
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
PostgreSQL's Useful? React with 👍 / 👎. |
||
| WHEN (t.tgtype & 32) != 0 THEN 'TRUNCATE' | ||
| ELSE '' END AS event, | ||
| pg_get_triggerdef(t.oid) AS definition | ||
| 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 c.relname = '\(tableLiteral)' | ||
| AND n.nspname = '\(schemaLiteral)' | ||
| AND NOT t.tgisinternal | ||
| ORDER BY t.tgname | ||
| """ | ||
| let result = try await execute(query: query) | ||
| let triggers: [PluginTriggerInfo] = result.rows.compactMap { row -> PluginTriggerInfo? in | ||
| guard row.count >= 4, | ||
| let name = row[0].asText, | ||
| let timing = row[1].asText, | ||
| let event = row[2].asText, | ||
| let definition = row[3].asText | ||
| else { return nil } | ||
| return PluginTriggerInfo( | ||
| name: name, | ||
| timing: timing, | ||
| event: event, | ||
| statement: definition | ||
| ) | ||
| } | ||
| Self.logger.info("[trigger] postgres fetchTriggers schema=\(resolvedSchema, privacy: .public) table=\(table, privacy: .public) rows=\(result.rows.count) parsed=\(triggers.count)") | ||
| return triggers | ||
| } | ||
|
|
||
| func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] { | ||
| let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) | ||
| let query = """ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| // | ||
| // PluginTriggerInfo.swift | ||
| // TableProPluginKit | ||
| // | ||
| // Transfer type describing a database trigger. | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| public struct PluginTriggerInfo: Codable, Sendable { | ||
| public let name: String | ||
| public let timing: String | ||
| public let event: String | ||
| public let statement: String | ||
|
|
||
| public init( | ||
| name: String, | ||
| timing: String, | ||
| event: String, | ||
| statement: String | ||
| ) { | ||
| self.name = name | ||
| self.timing = timing | ||
| self.event = event | ||
| self.statement = statement | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| // | ||
| // TriggerSQLParser.swift | ||
| // TableProPluginKit | ||
| // | ||
| // Extracts trigger timing and event from a CREATE TRIGGER statement. | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| public enum TriggerSQLParser { | ||
| private static let events: Set<String> = ["INSERT", "UPDATE", "DELETE"] | ||
|
|
||
| public static func timingAndEvent(from sql: String) -> (timing: String, event: String) { | ||
| let upper = sql.uppercased() | ||
| let headerEnd = upper.range(of: " ON ")?.lowerBound ?? upper.endIndex | ||
| let tokens = upper[upper.startIndex..<headerEnd] | ||
| .split(whereSeparator: { $0.isWhitespace || $0 == "," }) | ||
| .map(String.init) | ||
|
|
||
| for index in tokens.indices { | ||
| let token = tokens[index] | ||
| if token == "INSTEAD", index + 2 < tokens.count, tokens[index + 1] == "OF", | ||
| events.contains(tokens[index + 2]) { | ||
| return ("INSTEAD OF", tokens[index + 2]) | ||
| } | ||
| if token == "BEFORE" || token == "AFTER", index + 1 < tokens.count, | ||
| events.contains(tokens[index + 1]) { | ||
| return (token, tokens[index + 1]) | ||
| } | ||
| } | ||
| return ("", "") | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -54,6 +54,7 @@ struct PluginMetadataSnapshot: Sendable { | |
| var supportsAddIndex: Bool = true | ||
| var supportsDropIndex: Bool = true | ||
| var supportsModifyPrimaryKey: Bool = true | ||
| var supportsTriggers: Bool = false | ||
| var defaultSSLMode: SSLMode = .disabled | ||
| var supportsOpportunisticTLS: Bool = true | ||
| var supportsCloudflareTunnel: Bool = true | ||
|
|
@@ -449,6 +450,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { | |
| requiresReconnectForDatabaseSwitch: false, | ||
| supportsDropDatabase: true, | ||
| supportsRenameColumn: true, | ||
| supportsTriggers: true, | ||
| defaultSSLMode: .preferred | ||
| ), | ||
| schema: PluginMetadataSnapshot.SchemaInfo( | ||
|
|
@@ -498,6 +500,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { | |
| requiresReconnectForDatabaseSwitch: false, | ||
| supportsDropDatabase: true, | ||
| supportsRenameColumn: true, | ||
| supportsTriggers: true, | ||
| defaultSSLMode: .preferred | ||
| ), | ||
| schema: PluginMetadataSnapshot.SchemaInfo( | ||
|
|
@@ -548,6 +551,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { | |
| requiresReconnectForDatabaseSwitch: true, | ||
| supportsDropDatabase: true, | ||
| supportsRenameColumn: true, | ||
| supportsTriggers: true, | ||
| defaultSSLMode: .preferred | ||
| ), | ||
| schema: PluginMetadataSnapshot.SchemaInfo( | ||
|
|
@@ -709,6 +713,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { | |
| supportsModifyColumn: false, | ||
| supportsRenameColumn: true, | ||
| supportsModifyPrimaryKey: false, | ||
| supportsTriggers: true, | ||
| supportsCloudflareTunnel: false | ||
| ), | ||
| schema: PluginMetadataSnapshot.SchemaInfo( | ||
|
|
@@ -894,6 +899,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { | |
| supportsAddIndex: driverType.supportsAddIndex, | ||
| supportsDropIndex: driverType.supportsDropIndex, | ||
| supportsModifyPrimaryKey: driverType.supportsModifyPrimaryKey, | ||
| supportsTriggers: driverType.supportsTriggers, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For third-party drivers compiled against the previous PluginKit, this new Useful? React with 👍 / 👎. |
||
| defaultSSLMode: existingSnapshot?.capabilities.defaultSSLMode ?? .disabled, | ||
| supportsOpportunisticTLS: existingSnapshot?.capabilities.supportsOpportunisticTLS ?? true, | ||
| supportsCloudflareTunnel: driverType.supportsSSH, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -196,6 +196,10 @@ extension DatabaseType { | |
| PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.supportsForeignKeys ?? true | ||
| } | ||
|
|
||
| var supportsTriggers: Bool { | ||
| PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsTriggers ?? false | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the connection type is an alias such as Redshift or CockroachDB, Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| var supportsSchemaEditing: Bool { | ||
| PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.supportsSchemaEditing ?? true | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For MySQL/MariaDB triggers created with an explicit
DEFINER(common for audit/security triggers),ACTION_STATEMENTis only the trigger body and this synthesizedCREATE TRIGGERomits the definer, so the new Copy/Open in Editor actions produce DDL that recreates the trigger under the current user and can change privileges or fail. Fetch the real statement viaSHOW CREATE TRIGGERor include the definer instead of reconstructing it from these fields.Useful? React with 👍 / 👎.