Skip to content
Open
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
42 changes: 42 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,48 @@ 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, ACTION_ORIENTATION
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 orientation = row[safe: 4]?.asText ?? "ROW"
let statement = """
CREATE TRIGGER \(quoteIdentifier(name)) \(timing) \(event)
ON \(quoteIdentifier(table)) FOR EACH ROW
\(body)
"""

return PluginTriggerInfo(
name: name,
timing: timing,
event: event,
forEachRow: orientation == "ROW",
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
54 changes: 54 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,60 @@ 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,
(t.tgtype & 1) = 1 AS for_each_row,
pg_get_expr(t.tgqual, t.tgrelid) AS when_clause,
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 >= 6,
let name = row[0].asText,
let timing = row[1].asText,
let event = row[2].asText,
let definition = row[5].asText
else { return nil }
let forEachRow = row[3].asText == "t"
let whenClause = row[4].asText
return PluginTriggerInfo(
name: name,
timing: timing,
event: event,
forEachRow: forEachRow,
whenClause: whenClause,
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
56 changes: 56 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,61 @@ 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) = Self.parseTimingAndEvent(from: sql)
return PluginTriggerInfo(
name: name,
timing: timing,
event: event,
statement: sql
)
}
}

private static func parseTimingAndEvent(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)

let eventIndex = tokens.lastIndex(where: { $0 == "INSERT" || $0 == "UPDATE" || $0 == "DELETE" })
let event = eventIndex.map { tokens[$0] } ?? ""

let timingSearchEnd = eventIndex ?? tokens.endIndex
var timing = "AFTER"
for token in tokens[tokens.startIndex..<timingSearchEnd].reversed() {
if token == "INSTEAD" {
timing = "INSTEAD OF"
break
}
if token == "BEFORE" {
timing = "BEFORE"
break
}
if token == "AFTER" {
timing = "AFTER"
break
}
}
return (timing, event)
}

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
26 changes: 26 additions & 0 deletions Plugins/TableProPluginKit/PluginTriggerInfo.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import Foundation

public struct PluginTriggerInfo: Codable, Sendable {
public let name: String
public let timing: String
public let event: String
public let forEachRow: Bool
public let whenClause: String?
public let statement: String

public init(
name: String,
timing: String,
event: String,
forEachRow: Bool = true,
whenClause: String? = nil,
statement: String
) {
self.name = name
self.timing = timing
self.event = event
self.forEachRow = forEachRow
self.whenClause = whenClause
self.statement = statement
}
}
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
14 changes: 14 additions & 0 deletions TablePro/Core/Plugins/PluginDriverAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,20 @@ 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,
forEachRow: trigger.forEachRow,
whenClause: trigger.whenClause,
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