From 35f341762a904bbcf480a7a5b273fb406a991e33 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 17 Jun 2026 15:36:08 +0700 Subject: [PATCH 1/3] feat(structure): triggers for SQL Server, Oracle, libSQL, Cloudflare D1 plus metadata columns, filter/sort, and persisted layout --- CHANGELOG.md | 2 +- .../CloudflareD1Plugin.swift | 1 + .../CloudflareD1PluginDriver.swift | 21 ++++++ Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift | 1 + .../LibSQLPluginDriver.swift | 20 +++++ Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 1 + .../MSSQLPluginDriver+Schema.swift | 42 +++++++++++ Plugins/OracleDriverPlugin/OraclePlugin.swift | 46 ++++++++++++ .../PostgreSQLPluginDriver.swift | 12 ++- .../TableProPluginKit/PluginTriggerInfo.swift | 21 +++++- .../Core/Plugins/PluginDriverAdapter.swift | 5 +- TablePro/Models/Query/QueryResult.swift | 11 ++- .../Components/AutosavingVSplitView.swift | 52 +++++++++++++ TablePro/Views/Editor/ExplainResultView.swift | 2 +- TablePro/Views/Structure/DDLTextView.swift | 8 +- .../Views/Structure/TableStructureView.swift | 2 +- .../Views/Structure/TriggerDetailView.swift | 75 +++++++++++++++++-- .../Database/TriggerInfoMappingTests.swift | 26 ++++++- docs/features/table-structure.mdx | 9 ++- 19 files changed, 332 insertions(+), 25 deletions(-) create mode 100644 TablePro/Views/Components/AutosavingVSplitView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 298960470..d8426cf26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, event, and (where the engine reports them) orientation, enabled state, and condition, 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 diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift index b6014ece5..0c079a056 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift @@ -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"] diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift index 92cf379bb..0c0572c3c 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift @@ -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 = """ diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift b/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift index ee16d1e80..a8b424a46 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift @@ -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 diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift index 62f1e62e2..4db194ec3 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift @@ -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 = """ diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 468243764..29ff08a19 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -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) diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift index e2b27121d..c98ffb6a0 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift @@ -201,6 +201,48 @@ extension MSSQLPluginDriver { } } + 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)') + ORDER BY t.name + """ + 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 ?? "").replacingOccurrences(of: "_", with: " ") + 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 = """ diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index c43e1d371..b232c0120 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -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 @@ -467,6 +468,51 @@ 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 orientation = triggerType.contains("EACH ROW") ? "ROW" : "STATEMENT" + 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 = orientation == "ROW" ? " FOR EACH ROW" : "" + let statement = """ + CREATE OR REPLACE TRIGGER \(quotedName) + \(timing) \(event) ON \(quotedTable)\(forEach) + """ + return PluginTriggerInfo( + name: name, + timing: timing, + event: event, + statement: statement, + enabled: enabled, + orientation: orientation, + whenClause: whenClause + ) + } + } + func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { let escaped = effectiveSchemaEscaped(schema) let sql = """ diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 7bd9ccfcd..7ef304ddf 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -307,6 +307,9 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { WHEN (t.tgtype & 16) != 0 THEN 'DELETE' WHEN (t.tgtype & 32) != 0 THEN 'TRUNCATE' ELSE '' END AS event, + CASE WHEN (t.tgtype & 1) = 1 THEN 'ROW' ELSE 'STATEMENT' END AS orientation, + t.tgenabled <> 'D' AS enabled, + 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 @@ -318,17 +321,20 @@ 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 >= 7, let name = row[0].asText, let timing = row[1].asText, let event = row[2].asText, - let definition = row[3].asText + let definition = row[6].asText else { return nil } return PluginTriggerInfo( name: name, timing: timing, event: event, - statement: definition + statement: definition, + enabled: row[4].asText == "t", + orientation: row[3].asText, + whenClause: row[5].asText ) } Self.logger.info("[trigger] postgres fetchTriggers schema=\(resolvedSchema, privacy: .public) table=\(table, privacy: .public) rows=\(result.rows.count) parsed=\(triggers.count)") diff --git a/Plugins/TableProPluginKit/PluginTriggerInfo.swift b/Plugins/TableProPluginKit/PluginTriggerInfo.swift index a990731f8..f4bd7aaea 100644 --- a/Plugins/TableProPluginKit/PluginTriggerInfo.swift +++ b/Plugins/TableProPluginKit/PluginTriggerInfo.swift @@ -12,16 +12,35 @@ public struct PluginTriggerInfo: Codable, Sendable { public let timing: String public let event: String public let statement: String + public let enabled: Bool? + public let orientation: String? + public let whenClause: String? public init( name: String, timing: String, event: String, - statement: String + statement: String, + enabled: Bool? = nil, + orientation: String? = nil, + whenClause: String? = nil ) { self.name = name self.timing = timing self.event = event self.statement = statement + self.enabled = enabled + self.orientation = orientation + self.whenClause = whenClause + } + + @_disfavoredOverload + public init( + name: String, + timing: String, + event: String, + statement: String + ) { + self.init(name: name, timing: timing, event: event, statement: statement, enabled: nil, orientation: nil, whenClause: nil) } } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 1761378dd..be251f4c2 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -258,7 +258,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { name: trigger.name, timing: trigger.timing, event: trigger.event, - statement: trigger.statement + statement: trigger.statement, + enabled: trigger.enabled, + orientation: trigger.orientation, + whenClause: trigger.whenClause ) } } diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index 826b6671e..30ef07d77 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -223,17 +223,26 @@ struct TriggerInfo: Identifiable, Hashable { let timing: String let event: String let statement: String + let enabled: Bool? + let orientation: String? + let whenClause: String? init( name: String, timing: String, event: String, - statement: String + statement: String, + enabled: Bool? = nil, + orientation: String? = nil, + whenClause: String? = nil ) { self.name = name self.timing = timing self.event = event self.statement = statement + self.enabled = enabled + self.orientation = orientation + self.whenClause = whenClause } } diff --git a/TablePro/Views/Components/AutosavingVSplitView.swift b/TablePro/Views/Components/AutosavingVSplitView.swift new file mode 100644 index 000000000..b80380587 --- /dev/null +++ b/TablePro/Views/Components/AutosavingVSplitView.swift @@ -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: 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? + var bottomController: NSHostingController? + } +} diff --git a/TablePro/Views/Editor/ExplainResultView.swift b/TablePro/Views/Editor/ExplainResultView.swift index 16c47889a..2d49aeecd 100644 --- a/TablePro/Views/Editor/ExplainResultView.swift +++ b/TablePro/Views/Editor/ExplainResultView.swift @@ -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? @State private var viewMode: ExplainViewMode = .diagram diff --git a/TablePro/Views/Structure/DDLTextView.swift b/TablePro/Views/Structure/DDLTextView.swift index eef8c7ec4..25a3c24bc 100644 --- a/TablePro/Views/Structure/DDLTextView.swift +++ b/TablePro/Views/Structure/DDLTextView.swift @@ -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 @@ -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, databaseType: DatabaseType? = nil) { + init(ddl: String, fontSize: Binding, databaseType: DatabaseType? = nil) { self.ddl = ddl self._text = State(wrappedValue: ddl) self._fontSize = fontSize @@ -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(), diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 1756fce68..77d844b60 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -30,7 +30,7 @@ struct TableStructureView: View { @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? @State var isLoading = true diff --git a/TablePro/Views/Structure/TriggerDetailView.swift b/TablePro/Views/Structure/TriggerDetailView.swift index 316dc3155..a5d19e26c 100644 --- a/TablePro/Views/Structure/TriggerDetailView.swift +++ b/TablePro/Views/Structure/TriggerDetailView.swift @@ -1,13 +1,23 @@ +// +// TriggerDetailView.swift +// TablePro +// +// Read-only master-detail view of a table's triggers. +// + import SwiftUI struct TriggerDetailView: View { let triggers: [TriggerInfo] @Binding var selectedTriggerID: TriggerInfo.ID? - @Binding var fontSize: CGFloat + @Binding var fontSize: Double let databaseType: DatabaseType let isLoading: Bool let onOpenInEditor: (TriggerInfo) -> Void + @State private var searchText = "" + @State private var sortOrder: [KeyPathComparator] = [KeyPathComparator(\.name)] + var body: some View { if isLoading { ProgressView() @@ -16,11 +26,14 @@ struct TriggerDetailView: View { EmptyStateView.triggers() .frame(maxWidth: .infinity, maxHeight: .infinity) } else { - VSplitView { - triggerTable - .frame(minHeight: 120, idealHeight: 170) + AutosavingVSplitView( + autosaveName: "com.TablePro.triggerSplit", + topMinimumHeight: 120, + bottomMinimumHeight: 180 + ) { + triggerList + } bottom: { detailPane - .frame(minHeight: 180) } .frame(maxWidth: .infinity, maxHeight: .infinity) .onAppear(perform: ensureSelection) @@ -28,6 +41,17 @@ struct TriggerDetailView: View { } } + private var displayedTriggers: [TriggerInfo] { + let filtered = searchText.isEmpty + ? triggers + : triggers.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + return filtered.sorted(using: sortOrder) + } + + private var showEnabled: Bool { triggers.contains { $0.enabled != nil } } + private var showOrientation: Bool { triggers.contains { !($0.orientation ?? "").isEmpty } } + private var showWhen: Bool { triggers.contains { !($0.whenClause ?? "").isEmpty } } + private var selectedTrigger: TriggerInfo? { guard let id = selectedTriggerID, let match = triggers.first(where: { $0.id == id }) else { @@ -44,14 +68,49 @@ struct TriggerDetailView: View { } } + private var triggerList: some View { + VStack(spacing: 0) { + NativeSearchField(text: $searchText, placeholder: String(localized: "Filter")) + .padding(.horizontal, 8) + .padding(.vertical, 6) + Divider() + triggerTable + } + } + private var triggerTable: some View { - Table(triggers, selection: $selectedTriggerID) { + Table(displayedTriggers, selection: $selectedTriggerID, sortOrder: $sortOrder) { TableColumn(String(localized: "Name"), value: \.name) - .width(min: 160, ideal: 240) + .width(min: 140, ideal: 220) TableColumn(String(localized: "Timing"), value: \.timing) .width(min: 70, ideal: 90) TableColumn(String(localized: "Event"), value: \.event) - .width(min: 90, ideal: 140) + .width(min: 90, ideal: 150) + if showOrientation { + TableColumn(String(localized: "For Each")) { trigger in + Text(trigger.orientation ?? "") + } + .width(min: 70, ideal: 90) + } + if showEnabled { + TableColumn(String(localized: "Enabled")) { trigger in + if let enabled = trigger.enabled { + Image(systemName: enabled ? "checkmark.circle.fill" : "xmark.circle") + .foregroundStyle(enabled ? Color.green : Color.secondary) + .accessibilityLabel(enabled + ? String(localized: "Enabled") + : String(localized: "Disabled")) + } + } + .width(min: 60, ideal: 70) + } + if showWhen { + TableColumn(String(localized: "When")) { trigger in + Text(trigger.whenClause ?? "") + .foregroundStyle(.secondary) + } + .width(min: 100, ideal: 180) + } } } diff --git a/TableProTests/Core/Database/TriggerInfoMappingTests.swift b/TableProTests/Core/Database/TriggerInfoMappingTests.swift index 98efea922..c71587820 100644 --- a/TableProTests/Core/Database/TriggerInfoMappingTests.swift +++ b/TableProTests/Core/Database/TriggerInfoMappingTests.swift @@ -68,7 +68,10 @@ struct TriggerInfoMappingTests { name: "trg_audit", timing: "AFTER", event: "INSERT OR UPDATE", - statement: "CREATE TRIGGER trg_audit ..." + statement: "CREATE TRIGGER trg_audit ...", + enabled: false, + orientation: "ROW", + whenClause: "new.id IS NOT NULL" ) ] let connection = DatabaseConnection(name: "Test", type: .postgresql) @@ -81,6 +84,27 @@ struct TriggerInfoMappingTests { #expect(trigger.timing == "AFTER") #expect(trigger.event == "INSERT OR UPDATE") #expect(trigger.statement == "CREATE TRIGGER trg_audit ...") + #expect(trigger.enabled == false) + #expect(trigger.orientation == "ROW") + #expect(trigger.whenClause == "new.id IS NOT NULL") + } + + @Test("PluginTriggerInfo carries optional metadata through Codable") + func codableRoundTripWithMetadata() throws { + let original = PluginTriggerInfo( + name: "trg_check", + timing: "BEFORE", + event: "UPDATE", + statement: "CREATE TRIGGER trg_check ...", + enabled: true, + orientation: "STATEMENT", + whenClause: "new.amount > 0" + ) + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(PluginTriggerInfo.self, from: data) + #expect(decoded.enabled == true) + #expect(decoded.orientation == "STATEMENT") + #expect(decoded.whenClause == "new.amount > 0") } } diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx index fbc2b7207..2ce520ed6 100644 --- a/docs/features/table-structure.mdx +++ b/docs/features/table-structure.mdx @@ -159,18 +159,21 @@ The DDL view uses tree-sitter syntax highlighting with line numbers. Use the too ## Triggers Tab -The Triggers tab lists the table's triggers and shows the full definition of the selected one. It is read-only. +The Triggers tab lists the table's triggers and shows the full definition of the selected one. It is read-only. Use the filter field to narrow the list, and click a column header to sort. | Property | Description | |----------|-------------| | **Name** | Trigger name | | **Timing** | BEFORE, AFTER, or INSTEAD OF | | **Event** | INSERT, UPDATE, DELETE (PostgreSQL can combine events, such as INSERT OR UPDATE) | +| **For Each** | ROW or STATEMENT (shown when the engine reports it) | +| **Enabled** | Whether the trigger is enabled (PostgreSQL, SQL Server, Oracle) | +| **When** | The trigger's WHEN condition, where present | -Select a trigger to see its full definition in a syntax-highlighted viewer with a **Copy** button. The definition is the `CREATE TRIGGER` statement (MySQL and MariaDB reconstruct it from the trigger body; PostgreSQL uses `pg_get_triggerdef`; SQLite reads the stored statement). +Select a trigger to see its full definition in a syntax-highlighted viewer with **Copy** and **Open in Editor** buttons. The definition is the `CREATE TRIGGER` statement (MySQL and MariaDB reconstruct it from the body; PostgreSQL uses `pg_get_triggerdef`; SQLite, libSQL, and Cloudflare D1 read the stored statement; SQL Server uses `OBJECT_DEFINITION`). -Available for MySQL, MariaDB, PostgreSQL, and SQLite. The tab is hidden for databases that do not expose triggers. +Available for MySQL, MariaDB, PostgreSQL, SQLite, SQL Server, Oracle, libSQL, and Cloudflare D1. The tab is hidden for databases that do not expose triggers. Oracle shows trigger metadata only; the body is not retrieved. ## Creating a New Table From 7c06e514242107e245e566f9d73d74b001a60943 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 17 Jun 2026 15:46:21 +0700 Subject: [PATCH 2/3] refactor(structure): drive trigger inspector via observable model so split panes self-update without rebuilding --- .../MSSQLPluginDriver+Schema.swift | 4 +- .../Views/Structure/TableStructureView.swift | 3 - .../Views/Structure/TriggerDetailView.swift | 160 ++++++++++-------- 3 files changed, 87 insertions(+), 80 deletions(-) diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift index c98ffb6a0..1aae8bf2d 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift @@ -212,7 +212,7 @@ extension MSSQLPluginDriver { FROM sys.triggers t JOIN sys.trigger_events te ON t.object_id = te.object_id WHERE t.parent_id = OBJECT_ID('\(bracketedFull)') - ORDER BY t.name + ORDER BY t.name, te.type_desc """ let result = try await execute(query: sql) @@ -220,7 +220,7 @@ extension MSSQLPluginDriver { 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 ?? "").replacingOccurrences(of: "_", with: " ") + let event = row[safe: 4]?.asText ?? "" if byName[name] == nil { order.append(name) let timing = (row[safe: 2]?.asText == "1") ? "INSTEAD OF" : "AFTER" diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift index 77d844b60..e41b47ed1 100644 --- a/TablePro/Views/Structure/TableStructureView.swift +++ b/TablePro/Views/Structure/TableStructureView.swift @@ -28,7 +28,6 @@ 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 = "" @AppStorage("structureCodeFontSize") var ddlFontSize: Double = 13 @State var showCopyConfirmation = false @@ -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 diff --git a/TablePro/Views/Structure/TriggerDetailView.swift b/TablePro/Views/Structure/TriggerDetailView.swift index a5d19e26c..f77721bf8 100644 --- a/TablePro/Views/Structure/TriggerDetailView.swift +++ b/TablePro/Views/Structure/TriggerDetailView.swift @@ -7,16 +7,41 @@ import SwiftUI +@Observable +final class TriggerInspectorState { + var searchText = "" + var sortOrder: [KeyPathComparator] = [KeyPathComparator(\.name)] + var selectedID: TriggerInfo.ID? + + func displayed(_ triggers: [TriggerInfo]) -> [TriggerInfo] { + let filtered = searchText.isEmpty + ? triggers + : triggers.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + return filtered.sorted(using: sortOrder) + } + + func selectedTrigger(_ triggers: [TriggerInfo]) -> TriggerInfo? { + let list = displayed(triggers) + if let id = selectedID, let match = list.first(where: { $0.id == id }) { + return match + } + return list.first + } + + func ensureSelection(_ triggers: [TriggerInfo]) { + if selectedID == nil || !triggers.contains(where: { $0.id == selectedID }) { + selectedID = triggers.first?.id + } + } +} + struct TriggerDetailView: View { let triggers: [TriggerInfo] - @Binding var selectedTriggerID: TriggerInfo.ID? - @Binding var fontSize: Double let databaseType: DatabaseType let isLoading: Bool let onOpenInEditor: (TriggerInfo) -> Void - @State private var searchText = "" - @State private var sortOrder: [KeyPathComparator] = [KeyPathComparator(\.name)] + @State private var state = TriggerInspectorState() var body: some View { if isLoading { @@ -31,104 +56,89 @@ struct TriggerDetailView: View { topMinimumHeight: 120, bottomMinimumHeight: 180 ) { - triggerList + TriggerListPane(triggers: triggers, state: state) } bottom: { - detailPane + TriggerDetailPane(triggers: triggers, state: state, databaseType: databaseType, onOpenInEditor: onOpenInEditor) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .onAppear(perform: ensureSelection) - .onChange(of: triggers) { _, _ in ensureSelection() } + .onAppear { state.ensureSelection(triggers) } + .onChange(of: triggers) { _, newTriggers in state.ensureSelection(newTriggers) } } } +} - private var displayedTriggers: [TriggerInfo] { - let filtered = searchText.isEmpty - ? triggers - : triggers.filter { $0.name.localizedCaseInsensitiveContains(searchText) } - return filtered.sorted(using: sortOrder) - } +private struct TriggerListPane: View { + let triggers: [TriggerInfo] + @Bindable var state: TriggerInspectorState private var showEnabled: Bool { triggers.contains { $0.enabled != nil } } private var showOrientation: Bool { triggers.contains { !($0.orientation ?? "").isEmpty } } private var showWhen: Bool { triggers.contains { !($0.whenClause ?? "").isEmpty } } - private var selectedTrigger: TriggerInfo? { - guard let id = selectedTriggerID, - let match = triggers.first(where: { $0.id == id }) else { - return triggers.first - } - return match - } - - private func ensureSelection() { - guard let id = selectedTriggerID, - triggers.contains(where: { $0.id == id }) else { - selectedTriggerID = triggers.first?.id - return - } - } - - private var triggerList: some View { + var body: some View { VStack(spacing: 0) { - NativeSearchField(text: $searchText, placeholder: String(localized: "Filter")) + NativeSearchField(text: $state.searchText, placeholder: String(localized: "Filter")) .padding(.horizontal, 8) .padding(.vertical, 6) Divider() - triggerTable - } - } - - private var triggerTable: some View { - Table(displayedTriggers, selection: $selectedTriggerID, sortOrder: $sortOrder) { - TableColumn(String(localized: "Name"), value: \.name) - .width(min: 140, ideal: 220) - TableColumn(String(localized: "Timing"), value: \.timing) - .width(min: 70, ideal: 90) - TableColumn(String(localized: "Event"), value: \.event) - .width(min: 90, ideal: 150) - if showOrientation { - TableColumn(String(localized: "For Each")) { trigger in - Text(trigger.orientation ?? "") + Table(state.displayed(triggers), selection: $state.selectedID, sortOrder: $state.sortOrder) { + TableColumn(String(localized: "Name"), value: \.name) + .width(min: 140, ideal: 220) + TableColumn(String(localized: "Timing"), value: \.timing) + .width(min: 70, ideal: 90) + TableColumn(String(localized: "Event"), value: \.event) + .width(min: 90, ideal: 150) + if showOrientation { + TableColumn(String(localized: "For Each")) { trigger in + Text(trigger.orientation ?? "") + } + .width(min: 70, ideal: 90) } - .width(min: 70, ideal: 90) - } - if showEnabled { - TableColumn(String(localized: "Enabled")) { trigger in - if let enabled = trigger.enabled { - Image(systemName: enabled ? "checkmark.circle.fill" : "xmark.circle") - .foregroundStyle(enabled ? Color.green : Color.secondary) - .accessibilityLabel(enabled - ? String(localized: "Enabled") - : String(localized: "Disabled")) + if showEnabled { + TableColumn(String(localized: "Enabled")) { trigger in + if let enabled = trigger.enabled { + Image(systemName: enabled ? "checkmark.circle.fill" : "xmark.circle") + .foregroundStyle(enabled ? Color.green : Color.secondary) + .accessibilityLabel(enabled + ? String(localized: "Enabled") + : String(localized: "Disabled")) + } } + .width(min: 60, ideal: 70) } - .width(min: 60, ideal: 70) - } - if showWhen { - TableColumn(String(localized: "When")) { trigger in - Text(trigger.whenClause ?? "") - .foregroundStyle(.secondary) + if showWhen { + TableColumn(String(localized: "When")) { trigger in + Text(trigger.whenClause ?? "") + .foregroundStyle(.secondary) + } + .width(min: 100, ideal: 180) } - .width(min: 100, ideal: 180) } } } +} - private var detailPane: some View { - Group { - if let trigger = selectedTrigger { - VStack(spacing: 0) { - detailToolbar(for: trigger) - Divider() - DDLTextView(ddl: trigger.statement, fontSize: $fontSize, databaseType: databaseType) - } - } else { - Color(nsColor: .textBackgroundColor) +private struct TriggerDetailPane: View { + let triggers: [TriggerInfo] + let state: TriggerInspectorState + let databaseType: DatabaseType + let onOpenInEditor: (TriggerInfo) -> Void + + @AppStorage("structureCodeFontSize") private var fontSize: Double = 13 + + var body: some View { + if let trigger = state.selectedTrigger(triggers) { + VStack(spacing: 0) { + toolbar(for: trigger) + Divider() + DDLTextView(ddl: trigger.statement, fontSize: $fontSize, databaseType: databaseType) } + } else { + Color(nsColor: .textBackgroundColor) } } - private func detailToolbar(for trigger: TriggerInfo) -> some View { + private func toolbar(for trigger: TriggerInfo) -> some View { HStack(spacing: 12) { HStack(spacing: 4) { Button { From b1054780fce99366faf656b1a49b9f768a23017c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 17 Jun 2026 15:59:20 +0700 Subject: [PATCH 3/3] fix(structure): replace macOS 14.4 conditional trigger columns with a 14.0-safe Enabled column variant --- CHANGELOG.md | 2 +- Plugins/OracleDriverPlugin/OraclePlugin.swift | 11 ++-- .../PostgreSQLPluginDriver.swift | 10 ++-- .../TableProPluginKit/PluginTriggerInfo.swift | 18 +------ .../Core/Plugins/PluginDriverAdapter.swift | 4 +- TablePro/Models/Query/QueryResult.swift | 8 +-- .../Views/Structure/TriggerDetailView.swift | 54 ++++++++++--------- .../Database/TriggerInfoMappingTests.swift | 16 ++---- docs/features/table-structure.mdx | 4 +- 9 files changed, 45 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8426cf26..6537bb4e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, SQLite, SQL Server, Oracle, libSQL, and Cloudflare D1. It lists each trigger with its timing, event, and (where the engine reports them) orientation, enabled state, and condition, with a filter field and sortable columns. Selecting a trigger shows its 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 diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index b232c0120..0d780171f 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -491,24 +491,23 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } else { timing = "AFTER" } - let orientation = triggerType.contains("EACH ROW") ? "ROW" : "STATEMENT" + 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 = orientation == "ROW" ? " FOR EACH ROW" : "" + 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) + \(timing) \(event) ON \(quotedTable)\(forEach)\(whenLine) """ return PluginTriggerInfo( name: name, timing: timing, event: event, statement: statement, - enabled: enabled, - orientation: orientation, - whenClause: whenClause + enabled: enabled ) } } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 7ef304ddf..22adc504a 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -307,9 +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, - CASE WHEN (t.tgtype & 1) = 1 THEN 'ROW' ELSE 'STATEMENT' END AS orientation, t.tgenabled <> 'D' AS enabled, - 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 @@ -321,20 +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 >= 7, + guard row.count >= 5, let name = row[0].asText, let timing = row[1].asText, let event = row[2].asText, - let definition = row[6].asText + let definition = row[4].asText else { return nil } return PluginTriggerInfo( name: name, timing: timing, event: event, statement: definition, - enabled: row[4].asText == "t", - orientation: row[3].asText, - whenClause: row[5].asText + 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)") diff --git a/Plugins/TableProPluginKit/PluginTriggerInfo.swift b/Plugins/TableProPluginKit/PluginTriggerInfo.swift index f4bd7aaea..4687b1f7d 100644 --- a/Plugins/TableProPluginKit/PluginTriggerInfo.swift +++ b/Plugins/TableProPluginKit/PluginTriggerInfo.swift @@ -13,34 +13,18 @@ public struct PluginTriggerInfo: Codable, Sendable { public let event: String public let statement: String public let enabled: Bool? - public let orientation: String? - public let whenClause: String? public init( name: String, timing: String, event: String, statement: String, - enabled: Bool? = nil, - orientation: String? = nil, - whenClause: String? = nil + enabled: Bool? = nil ) { self.name = name self.timing = timing self.event = event self.statement = statement self.enabled = enabled - self.orientation = orientation - self.whenClause = whenClause - } - - @_disfavoredOverload - public init( - name: String, - timing: String, - event: String, - statement: String - ) { - self.init(name: name, timing: timing, event: event, statement: statement, enabled: nil, orientation: nil, whenClause: nil) } } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index be251f4c2..c21c37aba 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -259,9 +259,7 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { timing: trigger.timing, event: trigger.event, statement: trigger.statement, - enabled: trigger.enabled, - orientation: trigger.orientation, - whenClause: trigger.whenClause + enabled: trigger.enabled ) } } diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index 30ef07d77..57472d715 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -224,25 +224,19 @@ struct TriggerInfo: Identifiable, Hashable { let event: String let statement: String let enabled: Bool? - let orientation: String? - let whenClause: String? init( name: String, timing: String, event: String, statement: String, - enabled: Bool? = nil, - orientation: String? = nil, - whenClause: String? = nil + enabled: Bool? = nil ) { self.name = name self.timing = timing self.event = event self.statement = statement self.enabled = enabled - self.orientation = orientation - self.whenClause = whenClause } } diff --git a/TablePro/Views/Structure/TriggerDetailView.swift b/TablePro/Views/Structure/TriggerDetailView.swift index f77721bf8..b61a16e91 100644 --- a/TablePro/Views/Structure/TriggerDetailView.swift +++ b/TablePro/Views/Structure/TriggerDetailView.swift @@ -72,8 +72,6 @@ private struct TriggerListPane: View { @Bindable var state: TriggerInspectorState private var showEnabled: Bool { triggers.contains { $0.enabled != nil } } - private var showOrientation: Bool { triggers.contains { !($0.orientation ?? "").isEmpty } } - private var showWhen: Bool { triggers.contains { !($0.whenClause ?? "").isEmpty } } var body: some View { VStack(spacing: 0) { @@ -81,6 +79,13 @@ private struct TriggerListPane: View { .padding(.horizontal, 8) .padding(.vertical, 6) Divider() + table + } + } + + @ViewBuilder + private var table: some View { + if showEnabled { Table(state.displayed(triggers), selection: $state.selectedID, sortOrder: $state.sortOrder) { TableColumn(String(localized: "Name"), value: \.name) .width(min: 140, ideal: 220) @@ -88,34 +93,31 @@ private struct TriggerListPane: View { .width(min: 70, ideal: 90) TableColumn(String(localized: "Event"), value: \.event) .width(min: 90, ideal: 150) - if showOrientation { - TableColumn(String(localized: "For Each")) { trigger in - Text(trigger.orientation ?? "") - } - .width(min: 70, ideal: 90) - } - if showEnabled { - TableColumn(String(localized: "Enabled")) { trigger in - if let enabled = trigger.enabled { - Image(systemName: enabled ? "checkmark.circle.fill" : "xmark.circle") - .foregroundStyle(enabled ? Color.green : Color.secondary) - .accessibilityLabel(enabled - ? String(localized: "Enabled") - : String(localized: "Disabled")) - } - } - .width(min: 60, ideal: 70) - } - if showWhen { - TableColumn(String(localized: "When")) { trigger in - Text(trigger.whenClause ?? "") - .foregroundStyle(.secondary) - } - .width(min: 100, ideal: 180) + TableColumn(String(localized: "Enabled")) { trigger in + enabledIndicator(trigger) } + .width(min: 60, ideal: 70) + } + } else { + Table(state.displayed(triggers), selection: $state.selectedID, sortOrder: $state.sortOrder) { + TableColumn(String(localized: "Name"), value: \.name) + .width(min: 140, ideal: 240) + TableColumn(String(localized: "Timing"), value: \.timing) + .width(min: 70, ideal: 90) + TableColumn(String(localized: "Event"), value: \.event) + .width(min: 90, ideal: 160) } } } + + @ViewBuilder + private func enabledIndicator(_ trigger: TriggerInfo) -> some View { + if let enabled = trigger.enabled { + Image(systemName: enabled ? "checkmark.circle.fill" : "xmark.circle") + .foregroundStyle(enabled ? Color.green : Color.secondary) + .accessibilityLabel(enabled ? String(localized: "Enabled") : String(localized: "Disabled")) + } + } } private struct TriggerDetailPane: View { diff --git a/TableProTests/Core/Database/TriggerInfoMappingTests.swift b/TableProTests/Core/Database/TriggerInfoMappingTests.swift index c71587820..ed3abbc4c 100644 --- a/TableProTests/Core/Database/TriggerInfoMappingTests.swift +++ b/TableProTests/Core/Database/TriggerInfoMappingTests.swift @@ -69,9 +69,7 @@ struct TriggerInfoMappingTests { timing: "AFTER", event: "INSERT OR UPDATE", statement: "CREATE TRIGGER trg_audit ...", - enabled: false, - orientation: "ROW", - whenClause: "new.id IS NOT NULL" + enabled: false ) ] let connection = DatabaseConnection(name: "Test", type: .postgresql) @@ -85,26 +83,20 @@ struct TriggerInfoMappingTests { #expect(trigger.event == "INSERT OR UPDATE") #expect(trigger.statement == "CREATE TRIGGER trg_audit ...") #expect(trigger.enabled == false) - #expect(trigger.orientation == "ROW") - #expect(trigger.whenClause == "new.id IS NOT NULL") } - @Test("PluginTriggerInfo carries optional metadata through Codable") - func codableRoundTripWithMetadata() throws { + @Test("PluginTriggerInfo carries enabled state through Codable") + func codableRoundTripWithEnabled() throws { let original = PluginTriggerInfo( name: "trg_check", timing: "BEFORE", event: "UPDATE", statement: "CREATE TRIGGER trg_check ...", - enabled: true, - orientation: "STATEMENT", - whenClause: "new.amount > 0" + enabled: true ) let data = try JSONEncoder().encode(original) let decoded = try JSONDecoder().decode(PluginTriggerInfo.self, from: data) #expect(decoded.enabled == true) - #expect(decoded.orientation == "STATEMENT") - #expect(decoded.whenClause == "new.amount > 0") } } diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx index 2ce520ed6..0888420aa 100644 --- a/docs/features/table-structure.mdx +++ b/docs/features/table-structure.mdx @@ -166,11 +166,9 @@ The Triggers tab lists the table's triggers and shows the full definition of the | **Name** | Trigger name | | **Timing** | BEFORE, AFTER, or INSTEAD OF | | **Event** | INSERT, UPDATE, DELETE (PostgreSQL can combine events, such as INSERT OR UPDATE) | -| **For Each** | ROW or STATEMENT (shown when the engine reports it) | | **Enabled** | Whether the trigger is enabled (PostgreSQL, SQL Server, Oracle) | -| **When** | The trigger's WHEN condition, where present | -Select a trigger to see its full definition in a syntax-highlighted viewer with **Copy** and **Open in Editor** buttons. The definition is the `CREATE TRIGGER` statement (MySQL and MariaDB reconstruct it from the body; PostgreSQL uses `pg_get_triggerdef`; SQLite, libSQL, and Cloudflare D1 read the stored statement; SQL Server uses `OBJECT_DEFINITION`). +Select a trigger to see its full definition in a syntax-highlighted viewer with **Copy** and **Open in Editor** buttons. The definition is the `CREATE TRIGGER` statement, which includes the orientation (FOR EACH ROW/STATEMENT) and any WHEN condition. MySQL and MariaDB reconstruct it from the body; PostgreSQL uses `pg_get_triggerdef`; SQLite, libSQL, and Cloudflare D1 read the stored statement; SQL Server uses `OBJECT_DEFINITION`. Available for MySQL, MariaDB, PostgreSQL, SQLite, SQL Server, Oracle, libSQL, and Cloudflare D1. The tab is hidden for databases that do not expose triggers. Oracle shows trigger metadata only; the body is not retrieved.