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 @@ -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.

Expand Down
1 change: 1 addition & 0 deletions Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
14 changes: 14 additions & 0 deletions Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = """
Expand Down
1 change: 1 addition & 0 deletions Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,22 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}
}

var supportsTransactionalDDL: Bool { true }

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 Avoid forcing transactions for remote libSQL triggers

On libSQL/Turso connections using the default remote mode, this flag makes TriggerApplyStrategy choose the transactional path for both trigger creates and SQLite-style edits, so TriggerEditing.runInTransaction calls beginTransaction() before running the DDL. I checked LibSQLPlugin.swift and libsqlMode defaults to remote, while executeTransactionStatement throws unless the backend is .local, so New/Edit Trigger always fails on the main remote/Turso path before the trigger SQL is executed; make this capability backend-dependent or avoid the transaction wrapper for remote libSQL.

Useful? React with 👍 / 👎.


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 = """
Expand Down
1 change: 1 addition & 0 deletions Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
35 changes: 35 additions & 0 deletions Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@
extra: isIdentity ? "IDENTITY" : nil
)
}
identityCacheLock.lock()

Check warning on line 107 in Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

instance method 'lock' is unavailable from asynchronous contexts; Use async-safe scoped locking instead; this is an error in the Swift 6 language mode
identityColumnsByTable[table] = identityColumns
identityCacheLock.unlock()

Check warning on line 109 in Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift

View workflow job for this annotation

GitHub Actions / macOS App Tests

instance method 'unlock' is unavailable from asynchronous contexts; Use async-safe scoped locking instead; this is an error in the Swift 6 language mode
return columns
}

Expand Down Expand Up @@ -243,6 +243,41 @@
}
}

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"))

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 Avoid CREATE OR ALTER on older SQL Server

The new SQL Server trigger template unconditionally uses CREATE OR ALTER TRIGGER, and fetched definitions are rewritten to the same syntax, but older SQL Server versions that the driver can connect to reject CREATE OR ALTER. The existing view template already checks MSSQLCapabilities before using this syntax, so trigger editing should do the same or fall back to drop/create; otherwise New/Edit Trigger fails on those servers even though the UI is enabled.

Useful? React with 👍 / 👎.

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 = """
Expand Down
1 change: 1 addition & 0 deletions Plugins/MySQLDriverPlugin/MySQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin {

static let supportsDropDatabase = true
static let supportsTriggers = true
static let supportsTriggerEditing = true

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 Fetch full MySQL trigger DDL before enabling edits

Enabling editing here makes MySQL/MariaDB edits use TriggerDetailView.editTrigger's fallback because MySQLPluginDriver does not implement fetchTriggerDefinition; that fallback TriggerInfo.statement is reconstructed from information_schema.TRIGGERS.ACTION_STATEMENT and omits full SHOW CREATE TRIGGER metadata such as the original DEFINER. Since the apply path drops and recreates on edit, even a no-op save can change the trigger to run under the current user (and rollback restores the same lossy definition), so fetch the full trigger DDL before advertising editing.

Useful? React with 👍 / 👎.


func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver {
MySQLPluginDriver(config: config)
Expand Down
14 changes: 14 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: "''")
Expand Down
20 changes: 20 additions & 0 deletions Plugins/OracleDriverPlugin/OraclePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin, PluginDiagnost

static let isDownloadable = true
static let supportsTriggers = true
static let supportsTriggerEditing = true

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 Don't enable Oracle editing without full trigger DDL

For Oracle connections, enabling the edit UI here exposes Edit/Save for existing triggers, but OraclePluginDriver does not override fetchTriggerDefinition and its fetchTriggers query builds statement from only TRIGGER_NAME, type, event, status, and WHEN clause, omitting the trigger body. As a result, editing an existing Oracle trigger opens and applies an incomplete CREATE OR REPLACE TRIGGER header rather than the real definition, so saves either fail or replace the trigger incorrectly; keep this disabled until the driver returns full trigger DDL.

Useful? React with 👍 / 👎.

static let pathFieldRole: PathFieldRole = .serviceName
static let supportsForeignKeyDisable = false
static let supportsSchemaSwitching = true
Expand Down Expand Up @@ -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 = """
Expand Down
1 change: 1 addition & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

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 Do not enable trigger editing for PostgreSQL aliases

Because this plugin also advertises additionalDatabaseTypeIds for Redshift and CockroachDB, and DatabaseConnection.supportsTriggerEditing resolves aliases through the PostgreSQL plugin type, this single flag exposes the New/Edit/Delete trigger UI for those variants too. The Redshift and Cockroach driver classes do not implement the trigger create/fetch/drop hooks, so those connections fall back to generic trigger DDL (or empty trigger lists) and the new actions can only fail or run inappropriate PostgreSQL-oriented SQL; gate this capability per variant instead of enabling it on the shared plugin descriptor.

Useful? React with 👍 / 👎.


static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor(
identifierQuote: "\"",
Expand Down
64 changes: 64 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,70 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
return triggers
}

var triggerEditUsesReplace: Bool { true }

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 Gate PostgreSQL replace syntax by server version

This advertises replace-based trigger editing for every PostgreSQL server, and the editor later emits CREATE OR REPLACE TRIGGER; PostgreSQL 13 and older do not accept that syntax, while this driver already supports/version-gates much older server versions via serverVersionNumber. On those connections the new Create/Edit Trigger UI will consistently fail instead of using a drop-and-create fallback or hiding editing.

Useful? React with 👍 / 👎.


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 = """
Expand Down
17 changes: 17 additions & 0 deletions Plugins/SQLiteDriverPlugin/SQLitePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -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 = """
Expand Down
2 changes: 2 additions & 0 deletions Plugins/TableProPluginKit/DriverPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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 }
Expand Down
13 changes: 13 additions & 0 deletions Plugins/TableProPluginKit/PluginDatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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] { [] }
Expand Down
13 changes: 13 additions & 0 deletions TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down Expand Up @@ -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")
}
Expand Down
Loading
Loading