Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,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, and SQLite. It lists each trigger with its timing and event and shows the full definition in a read-only syntax-highlighted viewer. (#1695)
- Traditional Chinese (繁體中文) language in Settings > General with full UI translation

## [0.51.1] - 2026-06-16
Expand Down
1 change: 1 addition & 0 deletions Plugins/MySQLDriverPlugin/MySQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin {
)

static let supportsDropDatabase = true
static let supportsTriggers = true

func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver {
MySQLPluginDriver(config: config)
Expand Down
39 changes: 39 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,45 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
return foreignKeys
}

func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] {
let dbName = _activeDatabase
let escapedDb = dbName.replacingOccurrences(of: "'", with: "''")
let escapedTable = table.replacingOccurrences(of: "'", with: "''")

let query = """
SELECT TRIGGER_NAME, ACTION_TIMING, EVENT_MANIPULATION, ACTION_STATEMENT
FROM information_schema.TRIGGERS
WHERE EVENT_OBJECT_SCHEMA = '\(escapedDb)'
AND EVENT_OBJECT_TABLE = '\(escapedTable)'
ORDER BY TRIGGER_NAME
"""

let result = try await execute(query: query)

let triggers: [PluginTriggerInfo] = result.rows.compactMap { row in
guard let name = row[safe: 0]?.asText,
let timing = row[safe: 1]?.asText,
let event = row[safe: 2]?.asText,
let body = row[safe: 3]?.asText
else { return nil }

let statement = """
CREATE TRIGGER \(quoteIdentifier(name)) \(timing) \(event)
ON \(quoteIdentifier(table)) FOR EACH ROW
\(body)
Comment on lines +408 to +410

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve MySQL trigger definers in copied DDL

For MySQL/MariaDB triggers created with an explicit DEFINER (common for audit/security triggers), ACTION_STATEMENT is only the trigger body and this synthesized CREATE TRIGGER omits 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 via SHOW CREATE TRIGGER or include the definer instead of reconstructing it from these fields.

Useful? React with 👍 / 👎.

"""

return PluginTriggerInfo(
name: name,
timing: timing,
event: event,
statement: statement
)
}
Self.logger.info("[trigger] mysql fetchTriggers db=\(dbName, privacy: .public) table=\(table, privacy: .public) rows=\(result.rows.count) parsed=\(triggers.count)")
return triggers
}

func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] {
let dbName = _activeDatabase
let escapedDb = dbName.replacingOccurrences(of: "'", with: "''")
Expand Down
1 change: 1 addition & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin {
static let requiresReconnectForDatabaseSwitch = true
static let parameterStyle: ParameterStyle = .dollar
static let supportsDropDatabase = true
static let supportsTriggers = true

static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor(
identifierQuote: "\"",
Expand Down
48 changes: 48 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Decode PostgreSQL UPDATE/DELETE trigger bits correctly

PostgreSQL's tgtype uses bit 8 for DELETE and bit 16 for UPDATE, but these branches label them the other way around. Any single-event UPDATE trigger will be shown as DELETE, and DELETE triggers as UPDATE, with the same mix-up in the multi-event labels above.

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 = """
Expand Down
27 changes: 27 additions & 0 deletions Plugins/SQLiteDriverPlugin/SQLitePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ 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 supportsTriggers = true
static let databaseGroupingStrategy: GroupingStrategy = .flat
static let columnTypesByCategory: [String: [String]] = [
"Integer": ["INTEGER", "INT", "TINYINT", "SMALLINT", "MEDIUMINT", "BIGINT"],
Expand Down Expand Up @@ -824,6 +825,32 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}
}

func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] {
let safeTable = escapeStringLiteral(table)
let query = """
SELECT name, sql FROM sqlite_master
WHERE type = 'trigger' AND tbl_name = '\(safeTable)'
ORDER BY name
"""
let result = try await execute(query: query)

return result.rows.compactMap { row -> PluginTriggerInfo? in
guard row.count >= 2,
let name = row[0].asText,
let sql = row[1].asText else {
return nil
}

let (timing, event) = TriggerSQLParser.timingAndEvent(from: sql)
return PluginTriggerInfo(
name: name,
timing: timing,
event: event,
statement: sql
)
}
}

func fetchTableDDL(table: String, schema: String?) async throws -> String {
let safeTable = escapeStringLiteral(table)
let query = """
Expand Down
2 changes: 2 additions & 0 deletions Plugins/TableProPluginKit/DriverPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public protocol DriverPlugin: TableProPlugin {
static var queryLanguageName: String { get }
static var editorLanguage: EditorLanguage { get }
static var supportsForeignKeys: Bool { get }
static var supportsTriggers: Bool { get }
static var supportsSchemaEditing: Bool { get }
static var supportsDatabaseSwitching: Bool { get }
static var supportsSchemaSwitching: Bool { get }
Expand Down Expand Up @@ -82,6 +83,7 @@ public extension DriverPlugin {
static var queryLanguageName: String { "SQL" }
static var editorLanguage: EditorLanguage { .sql }
static var supportsForeignKeys: Bool { true }
static var supportsTriggers: Bool { false }
static var supportsSchemaEditing: Bool { true }
static var supportsDatabaseSwitching: Bool { true }
static var supportsSchemaSwitching: Bool { false }
Expand Down
3 changes: 3 additions & 0 deletions Plugins/TableProPluginKit/PluginDatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable {
func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo]
func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo]
func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo]
func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo]
func fetchTableDDL(table: String, schema: String?) async throws -> String
func fetchViewDefinition(view: String, schema: String?) async throws -> String
func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata
Expand Down Expand Up @@ -197,6 +198,8 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable {
public extension PluginDatabaseDriver {
var capabilities: PluginCapabilities { [] }

func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] { [] }

var supportsSchemas: Bool { false }

func fetchSchemas() async throws -> [String] { [] }
Expand Down
27 changes: 27 additions & 0 deletions Plugins/TableProPluginKit/PluginTriggerInfo.swift
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
}
}
33 changes: 33 additions & 0 deletions Plugins/TableProPluginKit/TriggerSQLParser.swift
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 ("", "")
}
}
5 changes: 5 additions & 0 deletions TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,9 @@ protocol DatabaseDriver: AnyObject {
/// Fetch foreign keys for a specific table
func fetchForeignKeys(table: String) async throws -> [ForeignKeyInfo]

/// Fetch triggers for a specific table
func fetchTriggers(table: String) async throws -> [TriggerInfo]

/// 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]]
Expand Down Expand Up @@ -244,6 +247,8 @@ extension DatabaseDriver {
try await fetchColumns(table: table)
}

func fetchTriggers(table: String) async throws -> [TriggerInfo] { [] }

func testConnection() async throws -> Bool {
try await connect()
disconnect()
Expand Down
12 changes: 12 additions & 0 deletions TablePro/Core/Plugins/PluginDriverAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,18 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable {
}
}

func fetchTriggers(table: String) async throws -> [TriggerInfo] {
let pluginTriggers = try await pluginDriver.fetchTriggers(table: table, schema: pluginDriver.currentSchema)
return pluginTriggers.map { trigger in
TriggerInfo(
name: trigger.name,
timing: trigger.timing,
event: trigger.event,
statement: trigger.statement
)
}
}

func fetchApproximateRowCount(table: String) async throws -> Int? {
try await pluginDriver.fetchApproximateRowCount(table: table, schema: pluginDriver.currentSchema)
}
Expand Down
6 changes: 6 additions & 0 deletions TablePro/Core/Plugins/PluginMetadataRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -449,6 +450,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
requiresReconnectForDatabaseSwitch: false,
supportsDropDatabase: true,
supportsRenameColumn: true,
supportsTriggers: true,
defaultSSLMode: .preferred
),
schema: PluginMetadataSnapshot.SchemaInfo(
Expand Down Expand Up @@ -498,6 +500,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
requiresReconnectForDatabaseSwitch: false,
supportsDropDatabase: true,
supportsRenameColumn: true,
supportsTriggers: true,
defaultSSLMode: .preferred
),
schema: PluginMetadataSnapshot.SchemaInfo(
Expand Down Expand Up @@ -548,6 +551,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
requiresReconnectForDatabaseSwitch: true,
supportsDropDatabase: true,
supportsRenameColumn: true,
supportsTriggers: true,
defaultSSLMode: .preferred
),
schema: PluginMetadataSnapshot.SchemaInfo(
Expand Down Expand Up @@ -709,6 +713,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
supportsModifyColumn: false,
supportsRenameColumn: true,
supportsModifyPrimaryKey: false,
supportsTriggers: true,
supportsCloudflareTunnel: false
),
schema: PluginMetadataSnapshot.SchemaInfo(
Expand Down Expand Up @@ -894,6 +899,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
supportsAddIndex: driverType.supportsAddIndex,
supportsDropIndex: driverType.supportsDropIndex,
supportsModifyPrimaryKey: driverType.supportsModifyPrimaryKey,
supportsTriggers: driverType.supportsTriggers,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid reading new DriverPlugin witnesses from stale plugins

For third-party drivers compiled against the previous PluginKit, this new DriverPlugin requirement has no witness-table entry; this method already avoids supportsColumnReorder for exactly that reason. Accessing driverType.supportsTriggers while registering such a plugin can crash or reject the plugin before it can fall back to the default false, so existing installed drivers are not actually compatible with this additive change.

Useful? React with 👍 / 👎.

defaultSSLMode: existingSnapshot?.capabilities.defaultSSLMode ?? .disabled,
supportsOpportunisticTLS: existingSnapshot?.capabilities.supportsOpportunisticTLS ?? true,
supportsCloudflareTunnel: driverType.supportsSSH,
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Core/SchemaTracking/StructureChangeManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ final class StructureChangeManager: ChangeManaging {
case .foreignKeys:
guard row < workingForeignKeys.count else { return }
key = .foreignKey(workingForeignKeys[row].id)
case .ddl, .parts:
case .ddl, .parts, .triggers:
return
}
guard pendingChanges[key]?.isDelete == true else { return }
Expand Down Expand Up @@ -789,7 +789,7 @@ final class StructureChangeManager: ChangeManaging {
let isDeleted = change?.isDelete ?? false
let isInserted = !currentForeignKeys.contains(where: { $0.id == fk.id })
return (isDeleted, isInserted)
case .ddl, .parts:
case .ddl, .parts, .triggers:
return (false, false)
}
}
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Models/Connection/DatabaseConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,10 @@ extension DatabaseType {
PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.supportsForeignKeys ?? true
}

var supportsTriggers: Bool {
PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsTriggers ?? false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep trigger support type-specific

When the connection type is an alias such as Redshift or CockroachDB, pluginTypeId resolves to PostgreSQL, so this reads PostgreSQL's supportsTriggers = true instead of the alias snapshot. I checked the variant drivers returned from PostgreSQLPlugin.createDriver; RedshiftPluginDriver and CockroachPluginDriver do not implement fetchTriggers, so those connections now expose a Triggers tab that can only show the default empty result rather than being hidden. Please read the type-specific snapshot or preserve per-variant support here.

Useful? React with 👍 / 👎.

}

var supportsSchemaEditing: Bool {
PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.supportsSchemaEditing ?? true
}
Expand Down
Loading
Loading