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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +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)
- 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)
- Traditional Chinese (繁體中文) language in Settings > General with full UI translation

### Fixed
Expand Down
1 change: 1 addition & 0 deletions Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ final class CloudflareD1Plugin: NSObject, TableProPlugin, DriverPlugin {
static let isDownloadable = true
static let supportsImport = false
static let supportsSchemaEditing = true
static let supportsTriggers = true
static let databaseGroupingStrategy: GroupingStrategy = .flat
static let brandColorHex = "#F6821F"
static let urlSchemes: [String] = ["d1"]
Expand Down
21 changes: 21 additions & 0 deletions Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,27 @@ final class CloudflareD1PluginDriver: 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)'
AND name NOT GLOB '_cf_*'
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
1 change: 1 addition & 0 deletions Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ final class LibSQLPlugin: NSObject, TableProPlugin, DriverPlugin {
static let isDownloadable = true
static let supportsImport = false
static let supportsSchemaEditing = true
static let supportsTriggers = true
static let supportsDropDatabase = false
static let supportsDatabaseSwitching = false
static let databaseGroupingStrategy: GroupingStrategy = .flat
Expand Down
20 changes: 20 additions & 0 deletions Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,26 @@ final class LibSQLPluginDriver: 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
1 change: 1 addition & 0 deletions Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin {
)

static let supportsDropDatabase = true
static let supportsTriggers = true

func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver {
MSSQLPluginDriver(config: config)
Expand Down
42 changes: 42 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 @@ -201,6 +201,48 @@
}
}

func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] {
let esc = (schema ?? _currentSchema).replacingOccurrences(of: "]", with: "]]")
let bracketedTable = table.replacingOccurrences(of: "]", with: "]]")
let bracketedFull = "[\(esc)].[\(bracketedTable)]"
let sql = """
SELECT t.name, t.is_disabled, t.is_instead_of_trigger,
OBJECT_DEFINITION(t.object_id) AS definition,
te.type_desc AS event
FROM sys.triggers t
JOIN sys.trigger_events te ON t.object_id = te.object_id
WHERE t.parent_id = OBJECT_ID('\(bracketedFull)')

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 Escape quotes before calling OBJECT_ID

For SQL Server tables or schemas whose identifier contains a single quote (valid when bracket-quoted, e.g. [O'Brien]), bracketedFull only escapes ] and is then interpolated into a quoted OBJECT_ID('...') literal. Opening the Triggers tab for such a table will at least fail with malformed SQL, and a malicious identifier can terminate the string and append extra statements; escape ' in the full object-name literal before interpolation.

Useful? React with 👍 / 👎.

ORDER BY t.name, te.type_desc
"""
let result = try await execute(query: sql)

var order: [String] = []
var byName: [String: (timing: String, definition: String, enabled: Bool, events: [String])] = [:]
for row in result.rows {
guard let name = row[safe: 0]?.asText else { continue }
let event = row[safe: 4]?.asText ?? ""
if byName[name] == nil {
order.append(name)
let timing = (row[safe: 2]?.asText == "1") ? "INSTEAD OF" : "AFTER"
let enabled = (row[safe: 1]?.asText != "1")
byName[name] = (timing: timing, definition: row[safe: 3]?.asText ?? "", enabled: enabled, events: [])
}
if !event.isEmpty {
byName[name]?.events.append(event)
}
}
return order.compactMap { name in
guard let info = byName[name] else { return nil }
return PluginTriggerInfo(
name: name,
timing: info.timing,
event: info.events.joined(separator: " OR "),
statement: info.definition,
enabled: info.enabled
)
}
}

func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] {
let esc = effectiveSchemaEscaped(schema)
let sql = """
Expand Down
45 changes: 45 additions & 0 deletions Plugins/OracleDriverPlugin/OraclePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin, PluginDiagnost
// MARK: - UI/Capability Metadata

static let isDownloadable = true
static let supportsTriggers = true
static let pathFieldRole: PathFieldRole = .serviceName
static let supportsForeignKeyDisable = false
static let supportsSchemaSwitching = true
Expand Down Expand Up @@ -467,6 +468,50 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}
}

func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] {
let escapedTable = table.replacingOccurrences(of: "'", with: "''")
let escaped = effectiveSchemaEscaped(schema)
let sql = """
SELECT TRIGGER_NAME, TRIGGER_TYPE, TRIGGERING_EVENT, STATUS, WHEN_CLAUSE
FROM ALL_TRIGGERS
WHERE TABLE_OWNER = '\(escaped)'
AND TABLE_NAME = '\(escapedTable)'
ORDER BY TRIGGER_NAME
"""
let result = try await execute(query: sql)
return result.rows.compactMap { row -> PluginTriggerInfo? in
guard let name = row[safe: 0]?.asText else { return nil }
let triggerType = (row[safe: 1]?.asText ?? "").uppercased()
let event = row[safe: 2]?.asText ?? ""
let timing: String
if triggerType.contains("INSTEAD OF") {
timing = "INSTEAD OF"
} else if triggerType.hasPrefix("BEFORE") {
timing = "BEFORE"
} else {
timing = "AFTER"
}
let isRowLevel = triggerType.contains("EACH ROW")
let enabled = (row[safe: 3]?.asText ?? "").uppercased() == "ENABLED"
let whenClause = row[safe: 4]?.asText
let quotedName = "\"\(name.replacingOccurrences(of: "\"", with: "\"\""))\""
let quotedTable = "\"\(table.replacingOccurrences(of: "\"", with: "\"\""))\""
let forEach = isRowLevel ? " FOR EACH ROW" : ""
let whenLine = (whenClause?.isEmpty == false) ? "\n WHEN (\(whenClause ?? ""))" : ""
let statement = """
CREATE OR REPLACE TRIGGER \(quotedName)
\(timing) \(event) ON \(quotedTable)\(forEach)\(whenLine)
"""
Comment on lines +501 to +504

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 Include Oracle trigger bodies in the displayed DDL

For Oracle connections with any table trigger, supportsTriggers now exposes this result in TriggerDetailView, which renders/copies PluginTriggerInfo.statement; however this statement is synthesized from only the trigger header and never selects or appends ALL_TRIGGERS.TRIGGER_BODY (or the WHEN clause). The details, Open in Editor, and Copy actions therefore show DDL that cannot recreate the trigger and omits the actual PL/SQL logic, so please fetch the body or use DBMS_METADATA before returning the statement.

Useful? React with 👍 / 👎.

return PluginTriggerInfo(
name: name,
timing: timing,
event: event,
statement: statement,
enabled: enabled
)
}
}

func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] {
let escaped = effectiveSchemaEscaped(schema)
let sql = """
Expand Down
8 changes: 5 additions & 3 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
WHEN (t.tgtype & 16) != 0 THEN 'DELETE'
WHEN (t.tgtype & 32) != 0 THEN 'TRUNCATE'
ELSE '' END AS event,
t.tgenabled <> 'D' AS enabled,
pg_get_triggerdef(t.oid) AS definition
FROM pg_catalog.pg_trigger t
JOIN pg_catalog.pg_class c ON c.oid = t.tgrelid
Expand All @@ -318,17 +319,18 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
"""
let result = try await execute(query: query)
let triggers: [PluginTriggerInfo] = result.rows.compactMap { row -> PluginTriggerInfo? in
guard row.count >= 4,
guard row.count >= 5,
let name = row[0].asText,
let timing = row[1].asText,
let event = row[2].asText,
let definition = row[3].asText
let definition = row[4].asText
else { return nil }
return PluginTriggerInfo(
name: name,
timing: timing,
event: event,
statement: definition
statement: definition,
enabled: row[3].asText == "t"
)
}
Self.logger.info("[trigger] postgres fetchTriggers schema=\(resolvedSchema, privacy: .public) table=\(table, privacy: .public) rows=\(result.rows.count) parsed=\(triggers.count)")
Expand Down
5 changes: 4 additions & 1 deletion Plugins/TableProPluginKit/PluginTriggerInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,19 @@ public struct PluginTriggerInfo: Codable, Sendable {
public let timing: String
public let event: String
public let statement: String
public let enabled: Bool?

public init(
name: String,
timing: String,
event: String,
statement: String
statement: String,
enabled: Bool? = nil
Comment on lines +21 to +22

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 the four-argument trigger initializer

Adding enabled by changing the existing public initializer removes the old PluginTriggerInfo(name:timing:event:statement:) ABI symbol. Source callers still compile because of the default argument, but already-built plugins that construct trigger info against the previous PluginKit can fail to load or call this initializer; follow the existing PluginKit pattern and add a separate @_disfavoredOverload four-argument initializer that forwards with enabled: nil.

Useful? React with 👍 / 👎.

) {
self.name = name
self.timing = timing
self.event = event
self.statement = statement
self.enabled = enabled
}
}
3 changes: 2 additions & 1 deletion TablePro/Core/Plugins/PluginDriverAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,8 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable {
name: trigger.name,
timing: trigger.timing,
event: trigger.event,
statement: trigger.statement
statement: trigger.statement,
enabled: trigger.enabled
)
}
}
Expand Down
5 changes: 4 additions & 1 deletion TablePro/Models/Query/QueryResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -223,17 +223,20 @@ struct TriggerInfo: Identifiable, Hashable {
let timing: String
let event: String
let statement: String
let enabled: Bool?

init(
name: String,
timing: String,
event: String,
statement: String
statement: String,
enabled: Bool? = nil
) {
self.name = name
self.timing = timing
self.event = event
self.statement = statement
self.enabled = enabled
}
}

Expand Down
52 changes: 52 additions & 0 deletions TablePro/Views/Components/AutosavingVSplitView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
//
// AutosavingVSplitView.swift
// TablePro
//
// A vertically stacked split view whose divider position persists via NSSplitView.autosaveName.
//

import AppKit
import SwiftUI

struct AutosavingVSplitView<Top: View, Bottom: View>: NSViewControllerRepresentable {
let autosaveName: String
let topMinimumHeight: CGFloat
let bottomMinimumHeight: CGFloat
@ViewBuilder let top: () -> Top
@ViewBuilder let bottom: () -> Bottom

func makeCoordinator() -> Coordinator { Coordinator() }

func makeNSViewController(context: Context) -> NSSplitViewController {
let controller = NSSplitViewController()
controller.splitView.isVertical = false
controller.splitView.dividerStyle = .thin

let topController = NSHostingController(rootView: top())
let bottomController = NSHostingController(rootView: bottom())
context.coordinator.topController = topController
context.coordinator.bottomController = bottomController

let topItem = NSSplitViewItem(viewController: topController)
topItem.minimumThickness = topMinimumHeight
topItem.canCollapse = false
let bottomItem = NSSplitViewItem(viewController: bottomController)
bottomItem.minimumThickness = bottomMinimumHeight
bottomItem.canCollapse = false

controller.addSplitViewItem(topItem)
controller.addSplitViewItem(bottomItem)
controller.splitView.autosaveName = NSSplitView.AutosaveName(autosaveName)
return controller
}

func updateNSViewController(_ controller: NSSplitViewController, context: Context) {
context.coordinator.topController?.rootView = top()
context.coordinator.bottomController?.rootView = bottom()
}

final class Coordinator {
var topController: NSHostingController<Top>?
var bottomController: NSHostingController<Bottom>?
}
}
2 changes: 1 addition & 1 deletion TablePro/Views/Editor/ExplainResultView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ struct ExplainResultView: View {
let executionTime: TimeInterval?
let plan: QueryPlan?

@State private var fontSize: CGFloat = 13
@State private var fontSize: Double = 13
@State private var showCopyConfirmation = false
@State private var copyResetTask: Task<Void, Never>?
@State private var viewMode: ExplainViewMode = .diagram
Expand Down
8 changes: 4 additions & 4 deletions TablePro/Views/Structure/DDLTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import TableProPluginKit
/// Read-only DDL display with syntax highlighting powered by CodeEditSourceEditor
struct DDLTextView: View {
let ddl: String
@Binding var fontSize: CGFloat
@Binding var fontSize: Double
var databaseType: DatabaseType?

@State private var text: String
Expand All @@ -22,7 +22,7 @@ struct DDLTextView: View {
@Environment(\.colorScheme) private var colorScheme

/// Primary initializer accepting DDL as a value (read-only display)
init(ddl: String, fontSize: Binding<CGFloat>, databaseType: DatabaseType? = nil) {
init(ddl: String, fontSize: Binding<Double>, databaseType: DatabaseType? = nil) {
self.ddl = ddl
self._text = State(wrappedValue: ddl)
self._fontSize = fontSize
Expand Down Expand Up @@ -59,8 +59,8 @@ struct DDLTextView: View {
return .sql
}

private static func makeConfiguration(fontSize: CGFloat) -> SourceEditorConfiguration {
let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
private static func makeConfiguration(fontSize: Double) -> SourceEditorConfiguration {
let font = NSFont.monospacedSystemFont(ofSize: CGFloat(fontSize), weight: .regular)
return SourceEditorConfiguration(
appearance: .init(
theme: TableProEditorTheme.make(),
Expand Down
5 changes: 1 addition & 4 deletions TablePro/Views/Structure/TableStructureView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,8 @@ struct TableStructureView: View {
@State var indexes: [IndexInfo] = []
@State var foreignKeys: [ForeignKeyInfo] = []
@State var triggers: [TriggerInfo] = []
@State private var selectedTriggerID: TriggerInfo.ID?
@State var ddlStatement: String = ""
@State var ddlFontSize: CGFloat = 13
@AppStorage("structureCodeFontSize") var ddlFontSize: Double = 13
@State var showCopyConfirmation = false
@State var copyResetTask: Task<Void, Never>?
@State var isLoading = true
Expand Down Expand Up @@ -295,8 +294,6 @@ struct TableStructureView: View {
case .triggers:
TriggerDetailView(
triggers: triggers,
selectedTriggerID: $selectedTriggerID,
fontSize: $ddlFontSize,
databaseType: connection.type,
isLoading: !loadedTabs.contains(.triggers),
onOpenInEditor: openTriggerInEditor
Expand Down
Loading
Loading