diff --git a/CHANGELOG.md b/CHANGELOG.md index e96868c4f..781cc2864 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Triggers as a sidebar section, listed per database and schema alongside Procedures and Functions. (#2383) +- Read-only source viewer for procedures, functions and triggers, with Copy, Export and Open in Editor. (#2383) +- Procedures, functions and triggers on MSSQL, Oracle, SQLite, ClickHouse, DuckDB, Snowflake, BigQuery, Cassandra, LibSQL, Cloudflare D1, Teradata and Dameng. (#2383) +- Procedures, functions and triggers in the quick switcher. +- Argument signatures on routine rows, shown when two routines in a section share a name. +- Schema-wide `list_triggers` for MCP clients, and `return_type` and `language` on `list_routines`. + ### Changed - The data grid draws its cells instead of building a view for each one, so a result with hundreds of columns opens at once and holds a fraction of the memory. (#2381) @@ -22,6 +31,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tab out of a row's last cell and Shift+Tab out of its first doing nothing. - Size All Columns to Fit leaving the far columns of a wide result unreachable. - A table with 500 columns pinning a core for 20 seconds and taking a gigabyte to open. (#2381) +- One of two PostgreSQL function overloads missing from the sidebar, and Show DDL opening an arbitrary one. (#2383) +- MySQL Show DDL reading the session database instead of the one being browsed. (#2383) +- Routine tooltips, VoiceOver labels and Copy with Signature showing a return type in place of the argument list. (#2383) +- Duplicate routine rows in the flat sidebar taking the selection back to the first of them. (#2383) +- MySQL triggers losing their definer, `WHEN` clause and ordering in the Structure tab. +- Oracle triggers showing a header with no body in the Structure tab. ## [0.67.1] - 2026-08-22 diff --git a/Packages/TableProCore/Sources/TableProTeradataCore/TeradataObjectQueries.swift b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataObjectQueries.swift new file mode 100644 index 000000000..1f9e76e6d --- /dev/null +++ b/Packages/TableProCore/Sources/TableProTeradataCore/TeradataObjectQueries.swift @@ -0,0 +1,92 @@ +// +// TeradataObjectQueries.swift +// TableProTeradataCore +// +// Catalog SQL for routines and triggers. Pure, so it is testable without a server. +// + +import Foundation + +public enum TeradataObjectQueries { + /// DBC.TablesV.TableKind is a single character. These are the ones that are routines or + /// triggers rather than tables, views or indexes. + public enum TableKind { + public static let storedProcedure = "P" + public static let externalProcedure = "E" + public static let standardFunction = "F" + public static let aggregateFunction = "A" + public static let combinedFunction = "B" + public static let tableFunction = "R" + public static let orderedAnalyticFunction = "S" + public static let macro = "M" + public static let trigger = "G" + + public static let procedures = [storedProcedure, externalProcedure] + public static let functions = [ + standardFunction, aggregateFunction, combinedFunction, + tableFunction, orderedAnalyticFunction, macro, + ] + } + + public static func routineList(database: String) -> String { + let kinds = (TableKind.procedures + TableKind.functions) + .map { TeradataSchemaQueries.quoteLiteral($0) } + .joined(separator: ", ") + return """ + SELECT TableName, TableKind, DatabaseName, RequestText, CreatorName + FROM DBC.TablesV + WHERE DatabaseName = \(TeradataSchemaQueries.quoteLiteral(database)) + AND TableKind IN (\(kinds)) + ORDER BY TableKind, TableName + """ + } + + public static func triggerList(database: String, table: String?) -> String { + var query = """ + SELECT TriggerName, SubjectTableDataBaseName, TableName, ActionTime, Event, Kind, \ + EnabledFlag, RequestText, OrderNumber + FROM DBC.TriggersV + WHERE DatabaseName = \(TeradataSchemaQueries.quoteLiteral(database)) + """ + if let table { + query += "\nAND TableName = \(TeradataSchemaQueries.quoteLiteral(table))" + } + return query + "\nORDER BY TableName, TriggerName" + } + + /// SHOW PROCEDURE only returns text when the procedure was created with SPL retention on, so + /// DBC.TablesV.RequestText is tried first and this is the fallback. + public static func routineDefinition(kind: String, database: String, name: String) -> String { + let qualified = TeradataSchemaQueries.qualifiedName(database: database, table: name) + return TableKind.procedures.contains(kind) + ? "SHOW PROCEDURE \(qualified)" + : "SHOW FUNCTION \(qualified)" + } + + public static func isProcedure(kind: String?) -> Bool { + guard let kind = kind?.trimmingCharacters(in: .whitespaces) else { return false } + return TableKind.procedures.contains(kind) + } + + public static func timing(fromActionTime actionTime: String?) -> String { + switch actionTime?.trimmingCharacters(in: .whitespaces).uppercased() { + case "B": return "BEFORE" + case "A": return "AFTER" + case "I": return "INSTEAD OF" + default: return actionTime?.trimmingCharacters(in: .whitespaces) ?? "" + } + } + + public static func event(fromEventCode event: String?) -> String { + switch event?.trimmingCharacters(in: .whitespaces).uppercased() { + case "I": return "INSERT" + case "U": return "UPDATE" + case "D": return "DELETE" + default: return event?.trimmingCharacters(in: .whitespaces) ?? "" + } + } + + public static func orientation(fromKind kind: String?) -> String { + kind?.trimmingCharacters(in: .whitespaces).uppercased() == "R" ? "ROW" : "STATEMENT" + } +} diff --git a/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift b/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift index f0ebaf75a..45ec3ccb9 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift @@ -32,6 +32,7 @@ final class BigQueryPlugin: NSObject, TableProPlugin, DriverPlugin { static let queryLanguageName = "SQL" static let editorLanguage: EditorLanguage = .sql static let supportsForeignKeys = false + static let supportsRoutines = true static let supportsSchemaEditing = false static let supportsDatabaseSwitching = false static let supportsSchemaSwitching = true diff --git a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver+Routines.swift b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver+Routines.swift new file mode 100644 index 000000000..500ab20f5 --- /dev/null +++ b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver+Routines.swift @@ -0,0 +1,64 @@ +// +// BigQueryPluginDriver+Routines.swift +// BigQueryDriverPlugin +// + +import Foundation +import TableProPluginKit + +/// BigQuery has procedures and functions and no triggers. +public enum BigQueryObjectQueries { + public static func escapeLiteral(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + } + + /// The ddl column holds the whole CREATE statement, so the list and the source are one read. + public static func routineList(project: String, dataset: String) -> String { + """ + SELECT routine_name, routine_schema, routine_type, data_type, language, ddl + FROM `\(project).\(dataset).INFORMATION_SCHEMA.ROUTINES` + ORDER BY routine_type, routine_name + """ + } +} + +extension BigQueryPluginDriver { + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { + guard let conn = connection else { throw BigQueryError.notConnected } + let dataset = schema ?? currentSchema ?? "" + guard !dataset.isEmpty else { return [] } + let sql = BigQueryObjectQueries.routineList(project: conn.projectId, dataset: dataset) + let result = try await conn.executeQuery(sql, defaultDataset: dataset) + return (result.queryResponse.rows ?? []).compactMap { row -> PluginRoutineInfo? in + let cells = row.f ?? [] + func text(_ index: Int) -> String? { + guard index < cells.count, case .string(let value) = cells[index].v else { return nil } + return value + } + guard let name = text(0) else { return nil } + let isProcedure = (text(2) ?? "").uppercased() == "PROCEDURE" + return PluginRoutineInfo( + name: name, + kind: isProcedure ? .procedure : .function, + schema: text(1) ?? dataset, + returnType: text(3), + language: text(4), + argumentSignature: nil, + identity: nil, + definition: text(5), + attributes: [] + ) + } + } + + func fetchRoutineDDL(_ routine: PluginRoutineInfo) async throws -> String { + if let ddl = routine.definition, !ddl.isEmpty { return ddl } + let listed = try await fetchRoutines(schema: routine.schema) + guard let ddl = listed.first(where: { $0.name == routine.name })?.definition, !ddl.isEmpty else { + throw PluginObjectSourceError.notFound(routine.name) + } + return ddl + } +} diff --git a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift index 23c946115..511ae9f5f 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift @@ -27,7 +27,7 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send private var _columnTypeCache: [String: [String]] = [:] private var _queryTimeoutSeconds: Int = 300 - private var connection: BigQueryConnection? { + var connection: BigQueryConnection? { lock.withLock { _connection } } diff --git a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift index 9c1bbf3fc..c8cde388a 100644 --- a/Plugins/CassandraDriverPlugin/CassandraPlugin.swift +++ b/Plugins/CassandraDriverPlugin/CassandraPlugin.swift @@ -33,6 +33,8 @@ internal final class CassandraPlugin: NSObject, TableProPlugin, DriverPlugin { static let urlSchemes: [String] = ["cassandra", "cql", "scylladb", "scylla"] static let requiresAuthentication = false static let supportsForeignKeys = false + static let supportsRoutines = true + static let supportsDatabaseTriggerBrowse = true static let brandColorHex = "#26A0D8" static let queryLanguageName = "CQL" static let supportsDatabaseSwitching = true @@ -564,7 +566,7 @@ internal final class CassandraPluginDriver: PluginDatabaseDriver, @unchecked Sen // MARK: - Private Helpers - private func resolveKeyspace(_ schema: String?) -> String { + func resolveKeyspace(_ schema: String?) -> String { if let schema, !schema.isEmpty { return schema } stateLock.lock() defer { stateLock.unlock() } diff --git a/Plugins/CassandraDriverPlugin/CassandraPluginDriver+Routines.swift b/Plugins/CassandraDriverPlugin/CassandraPluginDriver+Routines.swift new file mode 100644 index 000000000..9823a1ab2 --- /dev/null +++ b/Plugins/CassandraDriverPlugin/CassandraPluginDriver+Routines.swift @@ -0,0 +1,201 @@ +// +// CassandraPluginDriver+Routines.swift +// CassandraDriverPlugin +// + +import Foundation +import TableProPluginKit + +/// Cassandra has user-defined functions and aggregates, and triggers that are a Java class name +/// rather than a body. +public enum CassandraObjectQueries { + public static func escapeLiteral(_ value: String) -> String { + value.replacingOccurrences(of: "'", with: "''") + } + + public static func functionList(keyspace: String) -> String { + """ + SELECT function_name, argument_names, argument_types, return_type, language, body, called_on_null_input + FROM system_schema.functions + WHERE keyspace_name = '\(escapeLiteral(keyspace))' + """ + } + + public static func aggregateList(keyspace: String) -> String { + """ + SELECT aggregate_name, argument_types, return_type, state_func, state_type, final_func + FROM system_schema.aggregates + WHERE keyspace_name = '\(escapeLiteral(keyspace))' + """ + } + + public static func triggerList(keyspace: String, table: String?) -> String { + let tablePredicate = table.map { " AND table_name = '\(escapeLiteral($0))'" } ?? "" + return """ + SELECT trigger_name, table_name, keyspace_name, options + FROM system_schema.triggers + WHERE keyspace_name = '\(escapeLiteral(keyspace))'\(tablePredicate) + ALLOW FILTERING + """ + } + + public static func signature(argumentNames: String?, argumentTypes: String?) -> String { + let names = list(from: argumentNames) + let types = list(from: argumentTypes) + guard !types.isEmpty else { return "()" } + let parts = types.enumerated().map { index, type -> String in + guard index < names.count, !names[index].isEmpty else { return type } + return "\(names[index]) \(type)" + } + return "(\(parts.joined(separator: ", ")))" + } + + /// The driver renders a CQL list as `['a', 'b']`, so the brackets and quotes come off before + /// the elements can be paired up with each other. + public static func list(from value: String?) -> [String] { + guard let value else { return [] } + let trimmed = value.trimmingCharacters(in: CharacterSet(charactersIn: "[] ")) + guard !trimmed.isEmpty else { return [] } + return trimmed.split(separator: ",").map { + $0.trimmingCharacters(in: CharacterSet(charactersIn: " '\"")) + } + } + + public static func functionDefinition( + keyspace: String, + name: String, + signature: String, + returnType: String?, + language: String?, + body: String?, + calledOnNullInput: Bool + ) -> String { + let nullBehaviour = calledOnNullInput ? "CALLED ON NULL INPUT" : "RETURNS NULL ON NULL INPUT" + return """ + CREATE OR REPLACE FUNCTION \(keyspace).\(name)\(signature) + \(nullBehaviour) + RETURNS \(returnType ?? "text") + LANGUAGE \(language ?? "java") + AS $$\(body ?? "")$$; + """ + } +} + +extension CassandraPluginDriver { + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { + let keyspace = resolveKeyspace(schema) + async let functions = fetchCassandraFunctions(keyspace: keyspace) + async let aggregates = fetchCassandraAggregates(keyspace: keyspace) + return try await functions + aggregates + } + + func fetchRoutineDDL(_ routine: PluginRoutineInfo) async throws -> String { + guard let definition = routine.definition, !definition.isEmpty else { + throw PluginObjectSourceError.notFound(routine.name) + } + return definition + } + + func fetchAllTriggers(schema: String?) async throws -> [PluginTriggerInfo] { + try await cassandraTriggerList(keyspace: resolveKeyspace(schema), table: nil) + } + + /// A Cassandra trigger is a pointer to a Java class the server loads, so there is no body to + /// show. The class name is presented as what it is rather than as an empty source pane. + func fetchTriggerDDL(_ trigger: PluginTriggerInfo) async throws -> String { + if let definition = trigger.definition, !definition.isEmpty { return definition } + throw PluginObjectSourceError.unsupported(trigger.name) + } + + private func fetchCassandraFunctions(keyspace: String) async throws -> [PluginRoutineInfo] { + let result = try await execute(query: CassandraObjectQueries.functionList(keyspace: keyspace)) + return result.rows.compactMap { row -> PluginRoutineInfo? in + guard let name = row[safe: 0]?.asText else { return nil } + let signature = CassandraObjectQueries.signature( + argumentNames: row[safe: 1]?.asText, + argumentTypes: row[safe: 2]?.asText + ) + let language = row[safe: 4]?.asText + let definition = CassandraObjectQueries.functionDefinition( + keyspace: keyspace, + name: name, + signature: signature, + returnType: row[safe: 3]?.asText, + language: language, + body: row[safe: 5]?.asText, + calledOnNullInput: row[safe: 6]?.asText == "true" + ) + return PluginRoutineInfo( + name: name, + kind: .function, + schema: keyspace, + returnType: row[safe: 3]?.asText, + language: language, + argumentSignature: signature, + definition: definition, + attributes: [] + ) + } + } + + private func fetchCassandraAggregates(keyspace: String) async throws -> [PluginRoutineInfo] { + let result = try await execute(query: CassandraObjectQueries.aggregateList(keyspace: keyspace)) + return result.rows.compactMap { row -> PluginRoutineInfo? in + guard let name = row[safe: 0]?.asText else { return nil } + let signature = CassandraObjectQueries.signature( + argumentNames: nil, + argumentTypes: row[safe: 1]?.asText + ) + var attributes: [PluginObjectAttribute] = [PluginObjectAttribute(label: "Kind", value: "AGGREGATE")] + if let stateFunction = row[safe: 3]?.asText, !stateFunction.isEmpty { + attributes.append(PluginObjectAttribute(label: "State Function", value: stateFunction)) + } + if let stateType = row[safe: 4]?.asText, !stateType.isEmpty { + attributes.append(PluginObjectAttribute(label: "State Type", value: stateType)) + } + if let finalFunction = row[safe: 5]?.asText, !finalFunction.isEmpty { + attributes.append(PluginObjectAttribute(label: "Final Function", value: finalFunction)) + } + let definition = """ + CREATE OR REPLACE AGGREGATE \(keyspace).\(name)\(signature) + SFUNC \(row[safe: 3]?.asText ?? "") + STYPE \(row[safe: 4]?.asText ?? "")\(row[safe: 5]?.asText.map { "\n FINALFUNC \($0)" } ?? ""); + """ + return PluginRoutineInfo( + name: name, + kind: .function, + schema: keyspace, + returnType: row[safe: 2]?.asText, + language: nil, + argumentSignature: signature, + definition: definition, + attributes: attributes + ) + } + } + + func cassandraTriggerList(keyspace: String, table: String?) async throws -> [PluginTriggerInfo] { + let result = try await execute(query: CassandraObjectQueries.triggerList(keyspace: keyspace, table: table)) + return result.rows.compactMap { row -> PluginTriggerInfo? in + guard let name = row[safe: 0]?.asText else { return nil } + let options = row[safe: 3]?.asText ?? "" + let owningTable = row[safe: 1]?.asText + let definition = """ + CREATE TRIGGER \(name) ON \(keyspace).\(owningTable ?? "") + USING \(options); + """ + return PluginTriggerInfo( + name: name, + table: owningTable, + schema: row[safe: 2]?.asText ?? keyspace, + timing: "", + event: "", + orientation: nil, + statement: options, + definition: definition, + enabled: nil, + attributes: [PluginObjectAttribute(label: "Class", value: options)] + ) + } + } +} diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift index f38394408..616f3223c 100644 --- a/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift @@ -32,6 +32,7 @@ final class ClickHousePlugin: NSObject, TableProPlugin, DriverPlugin { static let brandColorHex = "#FFD100" static let postConnectActions: [PostConnectAction] = [.selectDatabaseFromLastSession] static let supportsForeignKeys = false + static let supportsRoutines = true static let systemDatabaseNames: [String] = ["information_schema", "INFORMATION_SCHEMA", "system"] static let columnTypesByCategory: [String: [String]] = [ "Integer": [ diff --git a/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Routines.swift b/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Routines.swift new file mode 100644 index 000000000..cd3e745c2 --- /dev/null +++ b/Plugins/ClickHouseDriverPlugin/ClickHousePluginDriver+Routines.swift @@ -0,0 +1,72 @@ +// +// ClickHousePluginDriver+Routines.swift +// ClickHouseDriverPlugin +// + +import Foundation +import TableProPluginKit + +/// ClickHouse has user-defined functions and no procedures or triggers, so only the Functions +/// section appears. +public enum ClickHouseObjectQueries { + public static func escapeLiteral(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + } + + public static func quoteIdentifier(_ value: String) -> String { + "`\(value.replacingOccurrences(of: "`", with: "``"))`" + } + + /// `origin` separates the server's own catalogue of built-ins from what a user created. Without + /// it the list is every ClickHouse function that exists, which is thousands of rows. + public static let functionList = """ + SELECT name, origin, is_aggregate, create_query + FROM system.functions + WHERE origin != 'System' + ORDER BY name + """ + + /// system.functions.create_query is documented as obsolete and is empty on several versions, + /// so it is the fallback rather than the source. + public static func functionDefinition(name: String) -> String { + "SHOW CREATE FUNCTION \(quoteIdentifier(name))" + } +} + +extension ClickHousePluginDriver { + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { + let result = try await execute(query: ClickHouseObjectQueries.functionList) + return result.rows.compactMap { row -> PluginRoutineInfo? in + guard let name = row[safe: 0]?.asText, !name.isEmpty else { return nil } + var attributes: [PluginObjectAttribute] = [] + if let origin = row[safe: 1]?.asText, !origin.isEmpty { + attributes.append(PluginObjectAttribute(label: "Origin", value: origin)) + } + if row[safe: 2]?.asText == "1" { + attributes.append(PluginObjectAttribute(label: "Aggregate", value: "YES")) + } + return PluginRoutineInfo( + name: name, + kind: .function, + schema: nil, + returnType: nil, + language: "SQL", + argumentSignature: nil, + identity: nil, + definition: row[safe: 3]?.asText, + attributes: attributes + ) + } + } + + func fetchRoutineDDL(_ routine: PluginRoutineInfo) async throws -> String { + if let cached = routine.definition, !cached.isEmpty { return cached } + let result = try await execute(query: ClickHouseObjectQueries.functionDefinition(name: routine.name)) + guard let ddl = result.rows.first?[safe: 0]?.asText, !ddl.isEmpty else { + throw PluginObjectSourceError.notFound(routine.name) + } + return ddl + } +} diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift index d7cba31dd..348e886db 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1Plugin.swift @@ -27,6 +27,7 @@ final class CloudflareD1Plugin: NSObject, TableProPlugin, DriverPlugin { static let supportsImport = false static let supportsSchemaEditing = true static let supportsTriggers = true + static let supportsDatabaseTriggerBrowse = true static let supportsTriggerEditing = true static let databaseGroupingStrategy: GroupingStrategy = .flat static let brandColorHex = "#F6821F" diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver+Triggers.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver+Triggers.swift new file mode 100644 index 000000000..4e75643c9 --- /dev/null +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver+Triggers.swift @@ -0,0 +1,33 @@ +// +// CloudflareD1PluginDriver+Triggers.swift +// CloudflareD1DriverPlugin +// + +import Foundation +import TableProPluginKit + +extension CloudflareD1PluginDriver { + func fetchAllTriggers(schema: String?) async throws -> [PluginTriggerInfo] { + try await sqliteTriggerList(table: nil) + } + + func fetchTriggerDDL(_ trigger: PluginTriggerInfo) async throws -> String { + if let definition = trigger.definition, !definition.isEmpty { return definition } + let listed = try await sqliteTriggerList(table: trigger.table) + guard let definition = listed.first(where: { $0.name == trigger.name })?.definition, + !definition.isEmpty + else { + throw PluginObjectSourceError.notFound(trigger.name) + } + return definition + } + + func sqliteTriggerList(table: String?) async throws -> [PluginTriggerInfo] { + let query = SQLiteMasterQueries.triggerList(table: table, excludeNameGlob: "_cf_*") + let result = try await execute(query: query) + return result.rows.compactMap { row -> PluginTriggerInfo? in + guard row.count >= 3, let name = row[0].asText, let sql = row[2].asText else { return nil } + return SQLiteMasterQueries.trigger(name: name, table: row[1].asText, sql: sql) + } + } +} diff --git a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift index 2de705f2b..b6be1d1af 100644 --- a/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift +++ b/Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift @@ -430,24 +430,7 @@ 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) - } + try await sqliteTriggerList(table: table) } func createTriggerTemplate(table: String, schema: String?) -> String? { diff --git a/Plugins/DamengDriverPlugin/DamengPlugin.swift b/Plugins/DamengDriverPlugin/DamengPlugin.swift index 3096b0f70..c9f62954d 100644 --- a/Plugins/DamengDriverPlugin/DamengPlugin.swift +++ b/Plugins/DamengDriverPlugin/DamengPlugin.swift @@ -17,6 +17,8 @@ final class DamengPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsSSL = false static let supportsDatabaseSwitching = false static let supportsSchemaSwitching = true + static let supportsRoutines = true + static let supportsDatabaseTriggerBrowse = true static let defaultSchemaName = "" static let containerEntityName = "Schema" static let postConnectActions: [PostConnectAction] = [.selectSchemaFromLastSession] diff --git a/Plugins/DamengDriverPlugin/DamengPluginDriver+Routines.swift b/Plugins/DamengDriverPlugin/DamengPluginDriver+Routines.swift new file mode 100644 index 000000000..44d1411f7 --- /dev/null +++ b/Plugins/DamengDriverPlugin/DamengPluginDriver+Routines.swift @@ -0,0 +1,127 @@ +// +// DamengPluginDriver+Routines.swift +// DamengDriverPlugin +// + +import Foundation +import TableProPluginKit + +/// DM8 publishes Oracle-compatible data dictionary views, so the shapes below match the Oracle +/// driver's. Every value goes through executeParameterized rather than into the SQL text. +extension DamengPluginDriver { + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { + let owner = effectiveSchema(schema) + let result = try await executeParameterized( + query: """ + SELECT OBJECT_NAME, OWNER, OBJECT_TYPE, STATUS + FROM ALL_OBJECTS + WHERE OWNER = ? + AND OBJECT_TYPE IN ('PROCEDURE', 'FUNCTION') + ORDER BY OBJECT_TYPE, OBJECT_NAME + """, + parameters: [.text(owner)] + ) + return result.rows.compactMap { row -> PluginRoutineInfo? in + guard let name = row[safe: 0]?.asText else { return nil } + let objectType = (row[safe: 2]?.asText ?? "").uppercased() + var attributes: [PluginObjectAttribute] = [] + if let status = row[safe: 3]?.asText, !status.isEmpty { + attributes.append(PluginObjectAttribute(label: "Status", value: status)) + } + return PluginRoutineInfo( + name: name, + kind: objectType == "PROCEDURE" ? .procedure : .function, + schema: row[safe: 1]?.asText ?? owner, + returnType: nil, + language: "DMSQL", + argumentSignature: nil, + identity: objectType, + attributes: attributes + ) + } + } + + func fetchRoutineDDL(_ routine: PluginRoutineInfo) async throws -> String { + let owner = effectiveSchema(routine.schema) + let type = routine.kind == .procedure ? "PROCEDURE" : "FUNCTION" + let result = try await executeParameterized( + query: """ + SELECT TEXT + FROM ALL_SOURCE + WHERE OWNER = ? AND NAME = ? AND TYPE = ? + ORDER BY LINE + """, + parameters: [.text(owner), .text(routine.name), .text(type)] + ) + /// ALL_SOURCE returns no rows at all when the caller cannot see the object, which is a + /// privilege answer rather than a missing one. + let body = result.rows + .compactMap { $0[safe: 0]?.asText } + .joined() + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !body.isEmpty else { + throw PluginObjectSourceError.insufficientPrivilege(routine.name) + } + return body.uppercased().hasPrefix("CREATE") ? body : "CREATE OR REPLACE \(body)" + } + + func fetchAllTriggers(schema: String?) async throws -> [PluginTriggerInfo] { + try await damengTriggerList(schema: schema, table: nil) + } + + func fetchTriggerDDL(_ trigger: PluginTriggerInfo) async throws -> String { + if let definition = trigger.definition, !definition.isEmpty { return definition } + let listed = try await damengTriggerList(schema: trigger.schema, table: trigger.table) + guard let definition = listed.first(where: { $0.name == trigger.name })?.definition, + !definition.isEmpty + else { + throw PluginObjectSourceError.insufficientPrivilege(trigger.name) + } + return definition + } + + func damengTriggerList(schema: String?, table: String?) async throws -> [PluginTriggerInfo] { + let owner = effectiveSchema(schema) + /// A schema browse asks for the triggers this schema owns, which is OWNER. A per-table + /// fetch asks for the triggers on that table, which is TABLE_OWNER plus TABLE_NAME. + let scope = table == nil ? "OWNER = ?" : "TABLE_OWNER = ? AND TABLE_NAME = ?" + var parameters: [PluginCellValue] = [.text(owner)] + if let table { parameters.append(.text(table)) } + let result = try await executeParameterized( + query: """ + SELECT TRIGGER_NAME, TABLE_NAME, OWNER, TRIGGER_TYPE, TRIGGERING_EVENT, + STATUS, WHEN_CLAUSE, DESCRIPTION, TRIGGER_BODY + FROM ALL_TRIGGERS + WHERE \(scope) + ORDER BY TABLE_NAME, TRIGGER_NAME + """, + parameters: parameters + ) + return result.rows.compactMap { row -> PluginTriggerInfo? in + guard let name = row[safe: 0]?.asText else { return nil } + let triggerType = (row[safe: 3]?.asText ?? "").uppercased() + let header = row[safe: 7]?.asText?.trimmingCharacters(in: .whitespacesAndNewlines) + let body = row[safe: 8]?.asText?.trimmingCharacters(in: .whitespacesAndNewlines) + let definition = [header.map { "CREATE OR REPLACE TRIGGER \($0)" }, body] + .compactMap { $0?.isEmpty == false ? $0 : nil } + .joined(separator: "\n") + var attributes: [PluginObjectAttribute] = [] + if let whenClause = row[safe: 6]?.asText, !whenClause.isEmpty { + attributes.append(PluginObjectAttribute(label: "When", value: whenClause)) + } + return PluginTriggerInfo( + name: name, + table: row[safe: 1]?.asText, + schema: row[safe: 2]?.asText ?? owner, + timing: triggerType.contains("INSTEAD OF") ? "INSTEAD OF" + : (triggerType.hasPrefix("BEFORE") ? "BEFORE" : "AFTER"), + event: row[safe: 4]?.asText ?? "", + orientation: triggerType.contains("EACH ROW") ? "ROW" : "STATEMENT", + statement: body ?? definition, + definition: definition, + enabled: (row[safe: 5]?.asText ?? "").uppercased() == "ENABLED", + attributes: attributes + ) + } + } +} diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift index 6c1ae3ff8..66e2b22ae 100644 --- a/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift +++ b/Plugins/DuckDBDriverPlugin/DuckDBPlugin.swift @@ -87,6 +87,7 @@ final class DuckDBPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsDatabaseSwitching = true static let supportsSchemaSwitching = true + static let supportsRoutines = true static let databaseGroupingStrategy: GroupingStrategy = .bySchema static let defaultSchemaName = "main" static let systemDatabaseNames: [String] = ["system", "temp"] diff --git a/Plugins/DuckDBDriverPlugin/DuckDBPluginDriver+Routines.swift b/Plugins/DuckDBDriverPlugin/DuckDBPluginDriver+Routines.swift new file mode 100644 index 000000000..acc8aa651 --- /dev/null +++ b/Plugins/DuckDBDriverPlugin/DuckDBPluginDriver+Routines.swift @@ -0,0 +1,65 @@ +// +// DuckDBPluginDriver+Routines.swift +// DuckDBDriverPlugin +// + +import Foundation +import TableProPluginKit + +/// DuckDB has macros where other engines have functions, and no procedures or triggers. +public enum DuckDBObjectQueries { + public static func escapeLiteral(_ value: String) -> String { + value.replacingOccurrences(of: "'", with: "''") + } + + /// duckdb_functions() also lists every built-in, so the list is restricted to macros, which are + /// the only routines a user can define. + public static func macroList(schema: String?) -> String { + let schemaPredicate = schema.map { "WHERE schema_name = '\(escapeLiteral($0))'" } + ?? "WHERE schema_name NOT IN ('system', 'pg_catalog', 'information_schema')" + return """ + SELECT schema_name, function_name, macro_definition, parameters, function_type, internal + FROM duckdb_functions() + \(schemaPredicate) AND function_type IN ('macro', 'table_macro') AND NOT internal + ORDER BY schema_name, function_name + """ + } +} + +extension DuckDBPluginDriver { + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { + let result = try await execute(query: DuckDBObjectQueries.macroList(schema: schema)) + return result.rows.compactMap { row -> PluginRoutineInfo? in + guard let name = row[safe: 1]?.asText, !name.isEmpty else { return nil } + let parameters = row[safe: 3]?.asText ?? "" + var attributes: [PluginObjectAttribute] = [] + if let type = row[safe: 4]?.asText, !type.isEmpty { + attributes.append(PluginObjectAttribute(label: "Kind", value: type)) + } + return PluginRoutineInfo( + name: name, + kind: .function, + schema: row[safe: 0]?.asText, + returnType: nil, + language: "SQL", + argumentSignature: parameters.isEmpty ? nil : "(\(parameters.trimmingCharacters(in: CharacterSet(charactersIn: "[]"))))", + identity: nil, + definition: row[safe: 2]?.asText, + attributes: attributes + ) + } + } + + /// DuckDB keeps the parsed expression, not the CREATE MACRO text the user typed, so the + /// definition is reconstructed and labelled as such rather than presented as original source. + func fetchRoutineDDL(_ routine: PluginRoutineInfo) async throws -> String { + guard let body = routine.definition, !body.isEmpty else { + throw PluginObjectSourceError.notFound(routine.name) + } + let qualified = [routine.schema, routine.name] + .compactMap { $0?.isEmpty == false ? $0 : nil } + .joined(separator: ".") + let signature = routine.argumentSignature ?? "()" + return "CREATE OR REPLACE MACRO \(qualified)\(signature) AS \(body);" + } +} diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift b/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift index 3658ce525..c560d48f8 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPlugin.swift @@ -29,6 +29,7 @@ final class LibSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsImport = false static let supportsSchemaEditing = true static let supportsTriggers = true + static let supportsDatabaseTriggerBrowse = true static let supportsTriggerEditing = true static let supportsDropDatabase = false static let supportsDatabaseSwitching = false diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver+Triggers.swift b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver+Triggers.swift new file mode 100644 index 000000000..fc921c963 --- /dev/null +++ b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver+Triggers.swift @@ -0,0 +1,33 @@ +// +// LibSQLPluginDriver+Triggers.swift +// LibSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension LibSQLPluginDriver { + func fetchAllTriggers(schema: String?) async throws -> [PluginTriggerInfo] { + try await sqliteTriggerList(table: nil) + } + + func fetchTriggerDDL(_ trigger: PluginTriggerInfo) async throws -> String { + if let definition = trigger.definition, !definition.isEmpty { return definition } + let listed = try await sqliteTriggerList(table: trigger.table) + guard let definition = listed.first(where: { $0.name == trigger.name })?.definition, + !definition.isEmpty + else { + throw PluginObjectSourceError.notFound(trigger.name) + } + return definition + } + + func sqliteTriggerList(table: String?) async throws -> [PluginTriggerInfo] { + let query = SQLiteMasterQueries.triggerList(table: table, excludeNameGlob: nil) + let result = try await execute(query: query) + return result.rows.compactMap { row -> PluginTriggerInfo? in + guard row.count >= 3, let name = row[0].asText, let sql = row[2].asText else { return nil } + return SQLiteMasterQueries.trigger(name: name, table: row[1].asText, sql: sql) + } + } +} diff --git a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift index 7e37780c2..6f0add357 100644 --- a/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift +++ b/Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift @@ -504,23 +504,7 @@ 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) - } + try await sqliteTriggerList(table: table) } var supportsTransactionalDDL: Bool { true } diff --git a/Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift b/Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift new file mode 100644 index 000000000..6296a1343 --- /dev/null +++ b/Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift @@ -0,0 +1,90 @@ +// +// MSSQLObjectQueries.swift +// MSSQLDriverPlugin +// +// Catalog SQL for routines and triggers. Pure, so it is testable without a server. +// + +import Foundation + +public enum MSSQLObjectQueries { + public static func escapeLiteral(_ value: String) -> String { + value.replacingOccurrences(of: "'", with: "''") + } + + /// Reads sys.sql_modules, never INFORMATION_SCHEMA.ROUTINES.ROUTINE_DEFINITION. That column is + /// nvarchar(4000) and silently returns the first 4000 characters of a longer body, which looks + /// like a routine that ends mid-statement. + public static func routineList(schema: String) -> String { + let schemaLiteral = escapeLiteral(schema) + return """ + SELECT + o.name, + s.name AS schema_name, + o.type, + m.definition, + CASE WHEN m.definition IS NULL THEN 1 ELSE 0 END AS is_encrypted, + ( + SELECT STUFF(( + SELECT ', ' + p.name + ' ' + TYPE_NAME(p.user_type_id) + FROM sys.parameters p + WHERE p.object_id = o.object_id AND p.parameter_id > 0 + ORDER BY p.parameter_id + FOR XML PATH(''), TYPE + ).value('.', 'nvarchar(max)'), 1, 2, '') + ) AS parameter_list, + ( + SELECT TOP 1 TYPE_NAME(r.user_type_id) + FROM sys.parameters r + WHERE r.object_id = o.object_id AND r.is_output = 1 AND r.parameter_id = 0 + ) AS return_type + FROM sys.objects o + JOIN sys.schemas s ON s.schema_id = o.schema_id + LEFT JOIN sys.sql_modules m ON m.object_id = o.object_id + WHERE s.name = '\(schemaLiteral)' + AND o.type IN ('P', 'FN', 'IF', 'TF') + AND o.is_ms_shipped = 0 + ORDER BY o.type, o.name + """ + } + + public static func routineDefinition(schema: String, name: String) -> String { + """ + SELECT m.definition + FROM sys.sql_modules m + JOIN sys.objects o ON o.object_id = m.object_id + JOIN sys.schemas s ON s.schema_id = o.schema_id + WHERE s.name = '\(escapeLiteral(schema))' AND o.name = '\(escapeLiteral(name))' + """ + } + + /// One row per trigger per event, so the caller folds the events back together. Filtering to + /// one table is one more predicate on the same query, so the per-table list and the + /// schema-wide list cannot disagree. + public static func triggerList(schema: String, table: String?) -> String { + let schemaLiteral = escapeLiteral(schema) + let tablePredicate = table.map { "AND parent.name = '\(escapeLiteral($0))'" } ?? "" + return """ + SELECT + t.name, + parent.name AS table_name, + s.name AS schema_name, + t.is_instead_of_trigger, + te.type_desc AS event, + t.is_disabled, + OBJECT_DEFINITION(t.object_id) AS definition + FROM sys.triggers t + JOIN sys.objects parent ON parent.object_id = t.parent_id + JOIN sys.schemas s ON s.schema_id = parent.schema_id + JOIN sys.trigger_events te ON te.object_id = t.object_id + WHERE t.parent_class = 1 + AND s.name = '\(schemaLiteral)' + \(tablePredicate) + ORDER BY parent.name, t.name, te.type_desc + """ + } + + public static func routineKind(forObjectType type: String) -> String { + type.trimmingCharacters(in: .whitespaces).uppercased() == "P" ? "PROCEDURE" : "FUNCTION" + } +} diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index a3c32c5c8..79dc7274a 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -192,6 +192,8 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsDropDatabase = true static let supportsDropSchema = true static let supportsTriggers = true + static let supportsRoutines = true + static let supportsDatabaseTriggerBrowse = true static let supportsTriggerEditing = true func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Routines.swift b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Routines.swift new file mode 100644 index 000000000..d84cea2a1 --- /dev/null +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Routines.swift @@ -0,0 +1,117 @@ +// +// MSSQLPluginDriver+Routines.swift +// MSSQLDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension MSSQLPluginDriver { + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { + let resolvedSchema = effectiveSchema(schema) + let result = try await execute(query: MSSQLObjectQueries.routineList(schema: resolvedSchema)) + return result.rows.compactMap { row -> PluginRoutineInfo? in + guard let name = row[safe: 0]?.asText else { return nil } + let objectType = row[safe: 2]?.asText ?? "" + let isProcedure = MSSQLObjectQueries.routineKind(forObjectType: objectType) == "PROCEDURE" + var attributes: [PluginObjectAttribute] = [] + attributes.append(PluginObjectAttribute(label: "Object Type", value: objectType.trimmingCharacters(in: .whitespaces))) + if row[safe: 4]?.asText == "1" { + attributes.append(PluginObjectAttribute(label: "Encrypted", value: "YES")) + } + let parameters = row[safe: 5]?.asText ?? "" + return PluginRoutineInfo( + name: name, + kind: isProcedure ? .procedure : .function, + schema: row[safe: 1]?.asText ?? resolvedSchema, + returnType: isProcedure ? nil : row[safe: 6]?.asText, + language: "T-SQL", + argumentSignature: "(\(parameters))", + identity: nil, + attributes: attributes + ) + } + } + + func fetchRoutineDDL(_ routine: PluginRoutineInfo) async throws -> String { + let resolvedSchema = effectiveSchema(routine.schema) + let query = MSSQLObjectQueries.routineDefinition(schema: resolvedSchema, name: routine.name) + let result = try await execute(query: query) + guard let row = result.rows.first else { + throw PluginObjectSourceError.notFound(routine.name) + } + /// sys.sql_modules.definition is NULL for WITH ENCRYPTION, and for a caller without + /// VIEW DEFINITION. Neither means the routine is gone. + guard let definition = row[safe: 0]?.asText, !definition.isEmpty else { + throw PluginObjectSourceError.insufficientPrivilege(routine.name) + } + return definition + } + + func fetchAllTriggers(schema: String?) async throws -> [PluginTriggerInfo] { + try await triggerList(schema: effectiveSchema(schema), table: nil) + } + + func fetchTriggerDDL(_ trigger: PluginTriggerInfo) async throws -> String { + if let definition = trigger.definition, !definition.isEmpty { return definition } + let listed = try await triggerList(schema: effectiveSchema(trigger.schema), table: trigger.table) + guard let definition = listed.first(where: { $0.name == trigger.name })?.definition, + !definition.isEmpty + else { + throw PluginObjectSourceError.insufficientPrivilege(trigger.name) + } + return definition + } + + /// sys.trigger_events has one row per event, so a trigger on INSERT and UPDATE arrives twice + /// and its events are folded back together here in the order the server listed them. + func triggerList(schema: String, table: String?) async throws -> [PluginTriggerInfo] { + let result = try await execute(query: MSSQLObjectQueries.triggerList(schema: schema, table: table)) + var order: [String] = [] + var byKey: [String: (info: PluginTriggerInfo, events: [String])] = [:] + for row in result.rows { + guard let name = row[safe: 0]?.asText else { continue } + let owningTable = row[safe: 1]?.asText + let key = "\(owningTable ?? "")|\(name)" + let event = row[safe: 4]?.asText ?? "" + if byKey[key] == nil { + order.append(key) + let definition = row[safe: 6]?.asText ?? "" + byKey[key] = ( + info: PluginTriggerInfo( + name: name, + table: owningTable, + schema: row[safe: 2]?.asText ?? schema, + timing: row[safe: 3]?.asText == "1" ? "INSTEAD OF" : "AFTER", + event: "", + orientation: "STATEMENT", + statement: definition, + definition: definition, + enabled: row[safe: 5]?.asText != "1", + attributes: [] + ), + events: [] + ) + } + if !event.isEmpty { + byKey[key]?.events.append(event) + } + } + return order.compactMap { key in + guard let entry = byKey[key] else { return nil } + let info = entry.info + return PluginTriggerInfo( + name: info.name, + table: info.table, + schema: info.schema, + timing: info.timing, + event: entry.events.joined(separator: " OR "), + orientation: info.orientation, + statement: info.statement, + definition: info.definition, + enabled: info.enabled, + attributes: info.attributes + ) + } + } +} diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift index b0f7bc89b..4c11dd594 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift @@ -180,47 +180,8 @@ extension MSSQLPluginDriver { } func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] { - let esc = MSSQLSchemaQueries.escapeBracket(effectiveSchema(schema)) - 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, 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 - ) - } + try await triggerList(schema: effectiveSchema(schema), table: table) } - var triggerEditUsesReplace: Bool { true } var supportsTransactionalDDL: Bool { true } diff --git a/Plugins/MySQLDriverPlugin/MySQLObjectQueries.swift b/Plugins/MySQLDriverPlugin/MySQLObjectQueries.swift new file mode 100644 index 000000000..4f55bac62 --- /dev/null +++ b/Plugins/MySQLDriverPlugin/MySQLObjectQueries.swift @@ -0,0 +1,121 @@ +// +// MySQLObjectQueries.swift +// MySQLDriverPlugin +// +// Catalog SQL for routines and triggers. Pure, so it is testable without a server. +// + +import Foundation + +public enum MySQLObjectQueries { + public static func escapeLiteral(_ value: String) -> String { + value + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "''") + } + + public static func quoteIdentifier(_ value: String) -> String { + "`\(value.replacingOccurrences(of: "`", with: "``"))`" + } + + public static func qualifiedIdentifier(schema: String?, name: String) -> String { + guard let schema, !schema.isEmpty else { return quoteIdentifier(name) } + return "\(quoteIdentifier(schema)).\(quoteIdentifier(name))" + } + + /// The parameter list comes from information_schema.PARAMETERS, where ordinal 0 is a function's + /// return value rather than a parameter. + public static func routineList(schema: String) -> String { + let schemaLiteral = escapeLiteral(schema) + return """ + SELECT + r.ROUTINE_NAME, + r.ROUTINE_TYPE, + r.DTD_IDENTIFIER, + r.SQL_DATA_ACCESS, + r.IS_DETERMINISTIC, + r.SECURITY_TYPE, + r.DEFINER, + r.ROUTINE_SCHEMA, + ( + SELECT GROUP_CONCAT( + CONCAT_WS(' ', p.PARAMETER_MODE, p.PARAMETER_NAME, p.DTD_IDENTIFIER) + ORDER BY p.ORDINAL_POSITION SEPARATOR ', ' + ) + FROM information_schema.PARAMETERS p + WHERE p.SPECIFIC_SCHEMA = r.ROUTINE_SCHEMA + AND p.SPECIFIC_NAME = r.ROUTINE_NAME + AND p.ROUTINE_TYPE = r.ROUTINE_TYPE + AND p.ORDINAL_POSITION > 0 + ) AS PARAMETER_LIST + FROM information_schema.ROUTINES r + WHERE r.ROUTINE_SCHEMA = '\(schemaLiteral)' + ORDER BY r.ROUTINE_TYPE, r.ROUTINE_NAME + """ + } + + /// Qualified with the schema. Unqualified, the server resolves the name against the session + /// database instead of the one being browsed, and returns a different routine's body or none. + public static func routineDefinition(kind: String, schema: String?, name: String) -> String { + "SHOW CREATE \(kind) \(qualifiedIdentifier(schema: schema, name: name))" + } + + /// One builder for both scopes: the per-table fetch adds a predicate and nothing else, so the + /// Structure tab and the sidebar cannot disagree about a table's triggers. + public static func triggerList(schema: String, table: String?) -> String { + let schemaLiteral = escapeLiteral(schema) + let tablePredicate = table.map { "AND EVENT_OBJECT_TABLE = '\(escapeLiteral($0))'" } ?? "" + return """ + SELECT + TRIGGER_NAME, + EVENT_OBJECT_TABLE, + EVENT_OBJECT_SCHEMA, + ACTION_TIMING, + EVENT_MANIPULATION, + ACTION_ORIENTATION, + ACTION_STATEMENT, + ACTION_CONDITION, + DEFINER, + ACTION_ORDER + FROM information_schema.TRIGGERS + WHERE EVENT_OBJECT_SCHEMA = '\(schemaLiteral)' + \(tablePredicate) + ORDER BY EVENT_OBJECT_TABLE, TRIGGER_NAME + """ + } + + /// information_schema holds the parts of a trigger but not its text, so the statement is + /// assembled. Dropping DEFINER or the WHEN clause would produce something that looks runnable + /// and is not the trigger the server holds. + public static func triggerStatement( + name: String, + table: String, + schema: String?, + timing: String, + event: String, + orientation: String?, + condition: String?, + definer: String? + ) -> String { + var header = "CREATE" + if let definer, !definer.isEmpty { + header += " DEFINER = \(quotedDefiner(definer))" + } + header += " TRIGGER \(qualifiedIdentifier(schema: schema, name: name))" + header += " \(timing) \(event) ON \(qualifiedIdentifier(schema: schema, name: table))" + header += " FOR EACH \(orientation?.isEmpty == false ? orientation ?? "ROW" : "ROW")" + if let condition, !condition.isEmpty { + header += " WHEN (\(condition))" + } + return header + } + + /// A DEFINER arrives as `user@host` and both halves are identifiers, so quoting the whole + /// string produces a name no server will accept. + public static func quotedDefiner(_ definer: String) -> String { + guard let separator = definer.lastIndex(of: "@") else { return quoteIdentifier(definer) } + let user = String(definer[definer.startIndex ..< separator]) + let host = String(definer[definer.index(after: separator)...]) + return "\(quoteIdentifier(user))@\(quoteIdentifier(host))" + } +} diff --git a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift index e548405e4..901c3f7e0 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift @@ -103,6 +103,8 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsDropDatabase = true static let supportsTriggers = true + static let supportsRoutines = true + static let supportsDatabaseTriggerBrowse = true static let supportsTriggerEditing = true func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver { diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Routines.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Routines.swift index 58d4ad62a..af025757f 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Routines.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver+Routines.swift @@ -6,60 +6,122 @@ import Foundation import TableProPluginKit -extension MySQLPluginDriver: PluginProcedureFunctionSupport { - func fetchProcedures(schema: String?) async throws -> [PluginRoutineInfo] { - try await fetchRoutines(routineType: "PROCEDURE") +extension MySQLPluginDriver { + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { + let resolvedSchema = routineSchema(schema) + let result = try await execute(query: MySQLObjectQueries.routineList(schema: resolvedSchema)) + return result.rows.compactMap { row -> PluginRoutineInfo? in + guard let name = row[safe: 0]?.asText else { return nil } + let isProcedure = row[safe: 1]?.asText?.uppercased() == "PROCEDURE" + let parameters = row[safe: 8]?.asText ?? "" + var attributes: [PluginObjectAttribute] = [] + if let access = row[safe: 3]?.asText, !access.isEmpty { + attributes.append(PluginObjectAttribute(label: "Data Access", value: access)) + } + if let deterministic = row[safe: 4]?.asText, !deterministic.isEmpty { + attributes.append(PluginObjectAttribute(label: "Deterministic", value: deterministic)) + } + if let security = row[safe: 5]?.asText, !security.isEmpty { + attributes.append(PluginObjectAttribute(label: "Security", value: security)) + } + if let definer = row[safe: 6]?.asText, !definer.isEmpty { + attributes.append(PluginObjectAttribute(label: "Definer", value: definer)) + } + return PluginRoutineInfo( + name: name, + kind: isProcedure ? .procedure : .function, + schema: row[safe: 7]?.asText ?? resolvedSchema, + returnType: isProcedure ? nil : row[safe: 2]?.asText, + language: "SQL", + argumentSignature: "(\(parameters))", + identity: nil, + attributes: attributes + ) + } } - func fetchFunctions(schema: String?) async throws -> [PluginRoutineInfo] { - try await fetchRoutines(routineType: "FUNCTION") + func fetchRoutineDDL(_ routine: PluginRoutineInfo) async throws -> String { + let resolvedSchema = routineSchema(routine.schema) + let kind = routine.kind == .procedure ? "PROCEDURE" : "FUNCTION" + let query = MySQLObjectQueries.routineDefinition( + kind: kind, + schema: resolvedSchema, + name: routine.name + ) + let result = try await execute(query: query) + guard let row = result.rows.first else { + throw PluginObjectSourceError.notFound(routine.name) + } + /// The server returns a NULL body rather than an error when the account lacks SHOW_ROUTINE, + /// so reporting this as "not found" would tell the user their routine is gone. + guard let ddl = row[safe: 2]?.asText, !ddl.isEmpty else { + throw PluginObjectSourceError.insufficientPrivilege(routine.name) + } + return ddl } - func fetchProcedureDDL(name: String, schema: String?) async throws -> String { - try await fetchRoutineDDL(name: name, kind: "PROCEDURE") + func fetchAllTriggers(schema: String?) async throws -> [PluginTriggerInfo] { + try await triggerList(schema: routineSchema(schema), table: nil) } - func fetchFunctionDDL(name: String, schema: String?) async throws -> String { - try await fetchRoutineDDL(name: name, kind: "FUNCTION") + func fetchTriggerDDL(_ trigger: PluginTriggerInfo) async throws -> String { + if let definition = trigger.definition, !definition.isEmpty { return definition } + let listed = try await triggerList(schema: routineSchema(trigger.schema), table: trigger.table) + guard let definition = listed.first(where: { $0.name == trigger.name })?.definition, + !definition.isEmpty + else { + throw PluginObjectSourceError.notFound(trigger.name) + } + return definition } - private func fetchRoutines(routineType: String) async throws -> [PluginRoutineInfo] { - let typeLiteral = escapeStringLiteral(routineType) - let query = """ - SELECT routine_name, data_type - FROM information_schema.routines - WHERE routine_schema = DATABASE() - AND routine_type = '\(typeLiteral)' - ORDER BY routine_name - """ - let result = try await execute(query: query) - return result.rows.compactMap { row -> PluginRoutineInfo? in - guard let name = row[safe: 0]?.asText else { return nil } - return PluginRoutineInfo( + func triggerList(schema: String, table: String?) async throws -> [PluginTriggerInfo] { + let result = try await execute(query: MySQLObjectQueries.triggerList(schema: schema, table: table)) + return result.rows.compactMap { row -> PluginTriggerInfo? in + guard let name = row[safe: 0]?.asText, + let owningTable = row[safe: 1]?.asText, + let timing = row[safe: 3]?.asText, + let event = row[safe: 4]?.asText, + let body = row[safe: 6]?.asText + else { return nil } + let resolvedSchema = row[safe: 2]?.asText ?? schema + let orientation = row[safe: 5]?.asText + let condition = row[safe: 7]?.asText + let definer = row[safe: 8]?.asText + let header = MySQLObjectQueries.triggerStatement( + name: name, + table: owningTable, + schema: resolvedSchema, + timing: timing, + event: event, + orientation: orientation, + condition: condition, + definer: definer + ) + var attributes: [PluginObjectAttribute] = [] + if let definer, !definer.isEmpty { + attributes.append(PluginObjectAttribute(label: "Definer", value: definer)) + } + if let order = row[safe: 9]?.asText, !order.isEmpty, order != "0" { + attributes.append(PluginObjectAttribute(label: "Action Order", value: order)) + } + return PluginTriggerInfo( name: name, - returnType: row[safe: 1]?.asText, - language: "SQL" + table: owningTable, + schema: resolvedSchema, + timing: timing, + event: event, + orientation: orientation, + statement: body, + definition: "\(header)\n\(body)", + enabled: nil, + attributes: attributes ) } } - private func fetchRoutineDDL(name: String, kind: String) async throws -> String { - let quoted = quoteIdentifier(name) - let result = try await execute(query: "SHOW CREATE \(kind) \(quoted)") - guard let row = result.rows.first else { - throw NSError( - domain: "MySQLDriverPlugin", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "DDL not found for \(kind.lowercased()) '\(name)'"] - ) - } - if let ddl = row[safe: 2]?.asText, !ddl.isEmpty { - return ddl - } - throw NSError( - domain: "MySQLDriverPlugin", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "DDL body missing for \(kind.lowercased()) '\(name)'"] - ) + private func routineSchema(_ schema: String?) -> String { + guard let schema, !schema.isEmpty else { return activeDatabaseName } + return schema } } diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index 29cc7d5ef..f91e1dd6b 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -15,6 +15,10 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private var _serverVersion: String? private var _activeDatabase: String + /// The database a metadata read is scoped to. MySQL has no schema level, so this is what a + /// caller means by "schema" everywhere in the catalog queries. + var activeDatabaseName: String { _activeDatabase } + internal var cachedPrivilegeCatalog: PluginPrivilegeCatalog? /// Detected server type from version string after connecting @@ -410,42 +414,11 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return foreignKeys } + /// The same builder the schema-wide list uses, with one more predicate. func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] { - let dbName = _activeDatabase - let escapedDb = dbName.replacingOccurrences(of: "'", with: "''") - let escapedTable = table.replacingOccurrences(of: "'", with: "''") - - let query = """ - SELECT TRIGGER_NAME, ACTION_TIMING, EVENT_MANIPULATION, ACTION_STATEMENT - FROM information_schema.TRIGGERS - WHERE EVENT_OBJECT_SCHEMA = '\(escapedDb)' - AND EVENT_OBJECT_TABLE = '\(escapedTable)' - ORDER BY TRIGGER_NAME - """ - - let result = try await execute(query: query) - - let triggers: [PluginTriggerInfo] = result.rows.compactMap { row in - guard let name = row[safe: 0]?.asText, - let timing = row[safe: 1]?.asText, - let event = row[safe: 2]?.asText, - let body = row[safe: 3]?.asText - else { return nil } - - let statement = """ - CREATE TRIGGER \(quoteIdentifier(name)) \(timing) \(event) - ON \(quoteIdentifier(table)) FOR EACH ROW - \(body) - """ - - return PluginTriggerInfo( - name: name, - timing: timing, - event: event, - statement: statement - ) - } - Self.logger.info("[trigger] mysql fetchTriggers db=\(dbName, privacy: .public) table=\(table, privacy: .public) rows=\(result.rows.count) parsed=\(triggers.count)") + let dbName = schema?.isEmpty == false ? (schema ?? _activeDatabase) : _activeDatabase + let triggers = try await triggerList(schema: dbName, table: table) + Self.logger.info("[trigger] mysql fetchTriggers db=\(dbName, privacy: .public) table=\(table, privacy: .public) parsed=\(triggers.count)") return triggers } diff --git a/Plugins/OracleDriverPlugin/OracleObjectQueries.swift b/Plugins/OracleDriverPlugin/OracleObjectQueries.swift new file mode 100644 index 000000000..f5b2bc170 --- /dev/null +++ b/Plugins/OracleDriverPlugin/OracleObjectQueries.swift @@ -0,0 +1,112 @@ +// +// OracleObjectQueries.swift +// OracleDriverPlugin +// +// Catalog SQL for routines and triggers. Pure, so it is testable without a server. +// + +import Foundation + +public enum OracleObjectQueries { + public static func escapeLiteral(_ value: String) -> String { + value.replacingOccurrences(of: "'", with: "''") + } + + public static func quoteIdentifier(_ value: String) -> String { + "\"\(value.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + + /// Standalone procedures and functions only. A packaged routine is addressed through its + /// package, which is a different object with a different DDL call, so listing it here would + /// produce rows whose source cannot be fetched. + /// Standalone procedures and functions only. A packaged routine is addressed through its + /// package, which is a different object with a different DDL call, so listing it here would + /// produce rows whose source cannot be fetched. + /// + /// No argument list is built. Oracle only allows overloading inside a package, so a standalone + /// routine is identified by its name alone, and the LISTAGG that would assemble a signature + /// raises ORA-01489 past 4000 bytes, which fails the whole listing over one wide signature. + public static func routineList(schema: String) -> String { + let schemaLiteral = escapeLiteral(schema) + return """ + SELECT + o.OBJECT_NAME, + o.OWNER, + o.OBJECT_TYPE, + o.STATUS + FROM ALL_OBJECTS o + WHERE o.OWNER = '\(schemaLiteral)' + AND o.OBJECT_TYPE IN ('PROCEDURE', 'FUNCTION') + ORDER BY o.OBJECT_TYPE, o.OBJECT_NAME + """ + } + + /// ALL_SOURCE stores one row per line, so the body has to be reassembled in LINE order. + /// DBMS_METADATA.GET_DDL is nicer but returns nothing rather than raising when the caller + /// lacks SELECT_CATALOG_ROLE for another schema, which reads as a routine that vanished. + public static func routineSource(schema: String, name: String, type: String) -> String { + """ + SELECT TEXT + FROM ALL_SOURCE + WHERE OWNER = '\(escapeLiteral(schema))' + AND NAME = '\(escapeLiteral(name))' + AND TYPE = '\(escapeLiteral(type))' + ORDER BY LINE + """ + } + + /// TRIGGER_BODY is the part the previous query never selected, which left the viewer showing a + /// CREATE OR REPLACE header with no body under it. + public static func triggerList(schema: String, table: String?) -> String { + let schemaLiteral = escapeLiteral(schema) + /// A schema browse asks for the triggers this schema owns, which is OWNER. A per-table + /// fetch asks for the triggers on that table, which is TABLE_OWNER plus TABLE_NAME. They + /// differ for a trigger one schema owns on another schema's table. + let scope = table.map { + "TABLE_OWNER = '\(schemaLiteral)' AND TABLE_NAME = '\(escapeLiteral($0))'" + } ?? "OWNER = '\(schemaLiteral)'" + return """ + SELECT + TRIGGER_NAME, + TABLE_NAME, + OWNER, + TRIGGER_TYPE, + TRIGGERING_EVENT, + STATUS, + WHEN_CLAUSE, + DESCRIPTION, + TRIGGER_BODY + FROM ALL_TRIGGERS + WHERE \(scope) + ORDER BY TABLE_NAME, TRIGGER_NAME + """ + } + + public static func timing(fromTriggerType triggerType: String) -> String { + let upper = triggerType.uppercased() + if upper.contains("INSTEAD OF") { return "INSTEAD OF" } + if upper.hasPrefix("BEFORE") { return "BEFORE" } + return "AFTER" + } + + public static func orientation(fromTriggerType triggerType: String) -> String { + triggerType.uppercased().contains("EACH ROW") ? "ROW" : "STATEMENT" + } + + /// ALL_TRIGGERS.DESCRIPTION holds the trigger's name, its timing, its events, the table and + /// the WHEN clause, exactly as they would follow CREATE OR REPLACE TRIGGER. Assembling that + /// header ourselves from the separate columns is how the old code lost the WHEN clause. + public static func triggerDefinition(description: String?, body: String?, name: String) -> String { + let header = description?.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedBody = body?.trimmingCharacters(in: .whitespacesAndNewlines) + let prefix = "CREATE OR REPLACE TRIGGER " + guard let header, !header.isEmpty else { + guard let trimmedBody, !trimmedBody.isEmpty else { return "" } + return "\(prefix)\(quoteIdentifier(name))\n\(trimmedBody)" + } + guard let trimmedBody, !trimmedBody.isEmpty else { + return "\(prefix)\(header)" + } + return "\(prefix)\(header)\n\(trimmedBody)" + } +} diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index 8219c4211..6ae4d3ec7 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -77,6 +77,8 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin, PluginDiagnost static let isDownloadable = true static let supportsTriggers = true + static let supportsRoutines = true + static let supportsDatabaseTriggerBrowse = true static let supportsTriggerEditing = true static let pathFieldRole: PathFieldRole = .serviceName static let supportsForeignKeyDisable = false @@ -519,47 +521,7 @@ 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) - """ - return PluginTriggerInfo( - name: name, - timing: timing, - event: event, - statement: statement, - enabled: enabled - ) - } + try await triggerList(schema: effectiveSchema(schema), table: table) } var triggerEditUsesReplace: Bool { true } @@ -1316,7 +1278,7 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) } - private func effectiveSchema(_ schema: String?) -> String { + func effectiveSchema(_ schema: String?) -> String { schema ?? _currentSchema ?? config.username.uppercased() } diff --git a/Plugins/OracleDriverPlugin/OraclePluginDriver+Routines.swift b/Plugins/OracleDriverPlugin/OraclePluginDriver+Routines.swift new file mode 100644 index 000000000..54452901c --- /dev/null +++ b/Plugins/OracleDriverPlugin/OraclePluginDriver+Routines.swift @@ -0,0 +1,104 @@ +// +// OraclePluginDriver+Routines.swift +// OracleDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension OraclePluginDriver { + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { + let resolvedSchema = routineSchema(schema) + let result = try await execute(query: OracleObjectQueries.routineList(schema: resolvedSchema)) + return result.rows.compactMap { row -> PluginRoutineInfo? in + guard let name = row[safe: 0]?.asText else { return nil } + let objectType = (row[safe: 2]?.asText ?? "").uppercased() + var attributes: [PluginObjectAttribute] = [] + if let status = row[safe: 3]?.asText, !status.isEmpty { + attributes.append(PluginObjectAttribute(label: "Status", value: status)) + } + return PluginRoutineInfo( + name: name, + kind: objectType == "PROCEDURE" ? .procedure : .function, + schema: row[safe: 1]?.asText ?? resolvedSchema, + returnType: nil, + language: "PL/SQL", + argumentSignature: nil, + identity: objectType, + attributes: attributes + ) + } + } + + func fetchRoutineDDL(_ routine: PluginRoutineInfo) async throws -> String { + let resolvedSchema = routineSchema(routine.schema) + let type = routine.kind == .procedure ? "PROCEDURE" : "FUNCTION" + let query = OracleObjectQueries.routineSource( + schema: resolvedSchema, + name: routine.name, + type: type + ) + let result = try await execute(query: query) + /// ALL_SOURCE holds one line per row and returns no rows at all when the caller cannot see + /// the object, which is a privilege answer rather than a missing one. + guard !result.rows.isEmpty else { + throw PluginObjectSourceError.insufficientPrivilege(routine.name) + } + let body = result.rows + .compactMap { $0[safe: 0]?.asText } + .joined() + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !body.isEmpty else { + throw PluginObjectSourceError.insufficientPrivilege(routine.name) + } + return body.uppercased().hasPrefix("CREATE") ? body : "CREATE OR REPLACE \(body)" + } + + func fetchAllTriggers(schema: String?) async throws -> [PluginTriggerInfo] { + try await triggerList(schema: routineSchema(schema), table: nil) + } + + func fetchTriggerDDL(_ trigger: PluginTriggerInfo) async throws -> String { + if let definition = trigger.definition, !definition.isEmpty { return definition } + let listed = try await triggerList(schema: routineSchema(trigger.schema), table: trigger.table) + guard let definition = listed.first(where: { $0.name == trigger.name })?.definition, + !definition.isEmpty + else { + throw PluginObjectSourceError.insufficientPrivilege(trigger.name) + } + return definition + } + + func triggerList(schema: String, table: String?) async throws -> [PluginTriggerInfo] { + let result = try await execute(query: OracleObjectQueries.triggerList(schema: schema, table: table)) + return result.rows.compactMap { row -> PluginTriggerInfo? in + guard let name = row[safe: 0]?.asText else { return nil } + let triggerType = row[safe: 3]?.asText ?? "" + let definition = OracleObjectQueries.triggerDefinition( + description: row[safe: 7]?.asText, + body: row[safe: 8]?.asText, + name: name + ) + var attributes: [PluginObjectAttribute] = [] + if let whenClause = row[safe: 6]?.asText, !whenClause.isEmpty { + attributes.append(PluginObjectAttribute(label: "When", value: whenClause)) + } + return PluginTriggerInfo( + name: name, + table: row[safe: 1]?.asText, + schema: row[safe: 2]?.asText ?? schema, + timing: OracleObjectQueries.timing(fromTriggerType: triggerType), + event: row[safe: 4]?.asText ?? "", + orientation: OracleObjectQueries.orientation(fromTriggerType: triggerType), + statement: row[safe: 8]?.asText ?? definition, + definition: definition, + enabled: (row[safe: 5]?.asText ?? "").uppercased() == "ENABLED", + attributes: attributes + ) + } + } + + private func routineSchema(_ schema: String?) -> String { + effectiveSchema(schema?.isEmpty == false ? schema : nil) + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift new file mode 100644 index 000000000..c44b598e2 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift @@ -0,0 +1,124 @@ +// +// PostgreSQLObjectQueries.swift +// PostgreSQLDriverPlugin +// +// Catalog SQL for routines and triggers. Pure, so it is testable without a server. +// + +import Foundation + +public enum PostgreSQLObjectQueries { + public static func escapeLiteral(_ value: String) -> String { + value.replacingOccurrences(of: "'", with: "''") + } + + /// `prokind` arrived in PostgreSQL 11, which is also the first release with procedures. + public static let prokindMinimumServerVersion: Int32 = 110_000 + + /// libpq answers 0 for a handle it has not connected, so an unknown version has to read as + /// modern. Reading it as ancient emits `proisagg`, a column PostgreSQL 11 removed, and the + /// whole routine list fails on every current server. + public static func usesProkind(serverVersionNumber: Int32) -> Bool { + serverVersionNumber <= 0 || serverVersionNumber >= prokindMinimumServerVersion + } + + /// Reads pg_proc rather than information_schema.routines. information_schema shows only what + /// the current user has a privilege on, and its routine_name repeats across overloads with no + /// column that separates them; pg_proc has one row per routine and an oid that does. + /// + /// Aggregates (prokind 'a') and window functions ('w') are excluded because + /// pg_get_functiondef raises on them, which would fail the whole listing over one object the + /// viewer could not have shown anyway. + public static func routineList(schema: String, serverVersionNumber: Int32) -> String { + let schemaLiteral = escapeLiteral(schema) + let modern = usesProkind(serverVersionNumber: serverVersionNumber) + let kindColumn = modern + ? "p.prokind" + : "CASE WHEN p.proisagg THEN 'a' WHEN p.proiswindow THEN 'w' ELSE 'f' END" + let kindFilter = modern + ? "p.prokind IN ('f', 'p')" + : "NOT p.proisagg AND NOT p.proiswindow" + return """ + SELECT + p.oid::text AS identity, + p.proname AS name, + n.nspname AS schema, + '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')' AS arguments, + pg_catalog.pg_get_function_result(p.oid) AS result, + l.lanname AS language, + \(kindColumn) AS kind, + CASE p.provolatile WHEN 'i' THEN 'IMMUTABLE' WHEN 's' THEN 'STABLE' ELSE 'VOLATILE' END AS volatility, + CASE WHEN p.prosecdef THEN 'DEFINER' ELSE 'INVOKER' END AS security, + pg_catalog.pg_get_userbyid(p.proowner) AS owner + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + JOIN pg_catalog.pg_language l ON l.oid = p.prolang + WHERE n.nspname = '\(schemaLiteral)' + AND \(kindFilter) + AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_depend d + WHERE d.objid = p.oid AND d.deptype = 'e' + ) + ORDER BY p.proname, arguments + """ + } + + /// Addressed by oid, so an overloaded name resolves to the exact routine the reader clicked + /// instead of whichever row the planner happened to return first. + public static func routineDefinition(identity: String) -> String { + """ + SELECT pg_catalog.pg_get_functiondef('\(escapeLiteral(identity))'::oid) + """ + } + + public static func routineDefinitionByName(name: String, schema: String, arguments: String?) -> String { + let nameLiteral = escapeLiteral(name) + let schemaLiteral = escapeLiteral(schema) + let argumentsPredicate = arguments.map { + "AND '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')' = '\(escapeLiteral($0))'" + } ?? "" + return """ + SELECT pg_catalog.pg_get_functiondef(p.oid) + FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = '\(schemaLiteral)' + AND p.proname = '\(nameLiteral)' + \(argumentsPredicate) + ORDER BY p.oid + LIMIT 1 + """ + } + + /// One query for the whole schema. The per-table fetch is the same SELECT with one more + /// predicate, so the two lists cannot disagree about a table they both cover. + public static func triggerList(schema: String, table: String?) -> String { + let schemaLiteral = escapeLiteral(schema) + let tablePredicate = table.map { "AND c.relname = '\(escapeLiteral($0))'" } ?? "" + return """ + SELECT + t.tgname AS name, + c.relname AS table_name, + n.nspname AS schema, + CASE WHEN (t.tgtype & 64) != 0 THEN 'INSTEAD OF' + WHEN (t.tgtype & 2) != 0 THEN 'BEFORE' + ELSE 'AFTER' END AS timing, + array_to_string(array_remove(ARRAY[ + CASE WHEN (t.tgtype & 4) != 0 THEN 'INSERT' END, + CASE WHEN (t.tgtype & 8) != 0 THEN 'DELETE' END, + CASE WHEN (t.tgtype & 16) != 0 THEN 'UPDATE' END, + CASE WHEN (t.tgtype & 32) != 0 THEN 'TRUNCATE' END + ], NULL), ' OR ') AS event, + CASE WHEN (t.tgtype & 1) != 0 THEN 'ROW' ELSE 'STATEMENT' END AS orientation, + t.tgenabled <> 'D' AS enabled, + pg_catalog.pg_get_triggerdef(t.oid) AS definition, + pg_catalog.pg_get_userbyid(c.relowner) AS owner + 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 n.nspname = '\(schemaLiteral)' + AND NOT t.tgisinternal + \(tablePredicate) + ORDER BY c.relname, t.tgname + """ + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift index d7c759765..38bae8f0c 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift @@ -85,6 +85,8 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin { static let supportsDropDatabase = true static let supportsDropSchema = true static let supportsTriggers = true + static let supportsRoutines = true + static let supportsDatabaseTriggerBrowse = true static let supportsTriggerEditing = true static let sqlDialect: SQLDialectDescriptor? = PostgreSQLDialect.descriptor diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Routines.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Routines.swift index f24984b90..3e7c7d702 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Routines.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Routines.swift @@ -6,73 +6,103 @@ import Foundation import TableProPluginKit -extension PostgreSQLPluginDriver: PluginProcedureFunctionSupport { - func fetchProcedures(schema: String?) async throws -> [PluginRoutineInfo] { - try await fetchRoutines(schema: schema, routineType: "PROCEDURE") +extension PostgreSQLPluginDriver { + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { + let resolvedSchema = schema ?? currentSchema ?? "public" + let query = PostgreSQLObjectQueries.routineList( + schema: resolvedSchema, + serverVersionNumber: serverVersionNumber + ) + let result = try await execute(query: query) + return result.rows.compactMap { row -> PluginRoutineInfo? in + guard let name = row[safe: 1]?.asText else { return nil } + let kind: PluginRoutineKind = row[safe: 6]?.asText == "p" ? .procedure : .function + return PluginRoutineInfo( + name: name, + kind: kind, + schema: row[safe: 2]?.asText ?? resolvedSchema, + returnType: row[safe: 4]?.asText, + language: row[safe: 5]?.asText, + argumentSignature: row[safe: 3]?.asText, + identity: row[safe: 0]?.asText, + attributes: Self.routineAttributes(row) + ) + } } - func fetchFunctions(schema: String?) async throws -> [PluginRoutineInfo] { - try await fetchRoutines(schema: schema, routineType: "FUNCTION") + func fetchRoutineDDL(_ routine: PluginRoutineInfo) async throws -> String { + let resolvedSchema = routine.schema ?? currentSchema ?? "public" + let query: String + if let identity = routine.identity, !identity.isEmpty, Int(identity) != nil { + query = PostgreSQLObjectQueries.routineDefinition(identity: identity) + } else { + query = PostgreSQLObjectQueries.routineDefinitionByName( + name: routine.name, + schema: resolvedSchema, + arguments: routine.argumentSignature + ) + } + let result = try await execute(query: query) + guard let ddl = result.rows.first?[safe: 0]?.asText, !ddl.isEmpty else { + throw PluginObjectSourceError.notFound(routine.name) + } + return ddl } - func fetchProcedureDDL(name: String, schema: String?) async throws -> String { - try await fetchRoutineDDL(name: name, schema: schema, routineType: "PROCEDURE") + func fetchAllTriggers(schema: String?) async throws -> [PluginTriggerInfo] { + let resolvedSchema = schema ?? currentSchema ?? "public" + let query = PostgreSQLObjectQueries.triggerList(schema: resolvedSchema, table: nil) + let result = try await execute(query: query) + return result.rows.compactMap { Self.trigger(from: $0, fallbackSchema: resolvedSchema) } } - func fetchFunctionDDL(name: String, schema: String?) async throws -> String { - try await fetchRoutineDDL(name: name, schema: schema, routineType: "FUNCTION") + func fetchTriggerDDL(_ trigger: PluginTriggerInfo) async throws -> String { + if let definition = trigger.definition, !definition.isEmpty { return definition } + let resolvedSchema = trigger.schema ?? currentSchema ?? "public" + let query = PostgreSQLObjectQueries.triggerList(schema: resolvedSchema, table: trigger.table) + let result = try await execute(query: query) + let match = result.rows + .compactMap { Self.trigger(from: $0, fallbackSchema: resolvedSchema) } + .first { $0.name == trigger.name } + guard let definition = match?.definition, !definition.isEmpty else { + throw PluginObjectSourceError.notFound(trigger.name) + } + return definition } - private func fetchRoutines(schema: String?, routineType: String) async throws -> [PluginRoutineInfo] { - let schemaLiteral = escapeStringLiteral(schema ?? currentSchema ?? "public") - let typeLiteral = escapeStringLiteral(routineType) - let query = """ - SELECT r.routine_name, r.data_type, r.external_language - FROM information_schema.routines r - JOIN pg_proc p ON p.proname = r.routine_name - JOIN pg_namespace n ON n.oid = p.pronamespace AND n.nspname = r.routine_schema - WHERE r.routine_schema = '\(schemaLiteral)' - AND r.routine_type = '\(typeLiteral)' - AND NOT EXISTS ( - SELECT 1 FROM pg_depend d - WHERE d.objid = p.oid AND d.deptype = 'e' - ) - ORDER BY r.routine_name - """ - let result = try await execute(query: query) - return result.rows.compactMap { row -> PluginRoutineInfo? in - guard let name = row[safe: 0]?.asText else { return nil } - return PluginRoutineInfo( - name: name, - returnType: row[safe: 1]?.asText, - language: row[safe: 2]?.asText - ) + static func trigger(from row: [PluginCellValue], fallbackSchema: String) -> PluginTriggerInfo? { + guard let name = row[safe: 0]?.asText, + let definition = row[safe: 7]?.asText + else { return nil } + var attributes: [PluginObjectAttribute] = [] + if let owner = row[safe: 8]?.asText, !owner.isEmpty { + attributes.append(PluginObjectAttribute(label: "Owner", value: owner)) } + return PluginTriggerInfo( + name: name, + table: row[safe: 1]?.asText, + schema: row[safe: 2]?.asText ?? fallbackSchema, + timing: row[safe: 3]?.asText ?? "", + event: row[safe: 4]?.asText ?? "", + orientation: row[safe: 5]?.asText, + statement: definition, + definition: definition, + enabled: row[safe: 6]?.asText == "t", + attributes: attributes + ) } - private func fetchRoutineDDL(name: String, schema: String?, routineType: String) async throws -> String { - let schemaLiteral = escapeStringLiteral(schema ?? currentSchema ?? "public") - let nameLiteral = escapeStringLiteral(name) - let typeLiteral = escapeStringLiteral(routineType) - let query = """ - SELECT pg_get_functiondef(p.oid) - FROM pg_proc p - JOIN pg_namespace n ON n.oid = p.pronamespace - JOIN information_schema.routines r - ON r.specific_name = p.proname || '_' || p.oid - WHERE n.nspname = '\(schemaLiteral)' - AND p.proname = '\(nameLiteral)' - AND r.routine_type = '\(typeLiteral)' - LIMIT 1 - """ - let result = try await execute(query: query) - guard let ddl = result.rows.first?[safe: 0]?.asText else { - throw NSError( - domain: "PostgreSQLDriverPlugin", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "DDL not found for \(routineType.lowercased()) '\(name)'"] - ) + private static func routineAttributes(_ row: [PluginCellValue]) -> [PluginObjectAttribute] { + var attributes: [PluginObjectAttribute] = [] + if let volatility = row[safe: 7]?.asText, !volatility.isEmpty { + attributes.append(PluginObjectAttribute(label: "Volatility", value: volatility)) } - return ddl + if let security = row[safe: 8]?.asText, !security.isEmpty { + attributes.append(PluginObjectAttribute(label: "Security", value: security)) + } + if let owner = row[safe: 9]?.asText, !owner.isEmpty { + attributes.append(PluginObjectAttribute(label: "Owner", value: owner)) + } + return attributes } } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index b12d9d7e6..50f782155 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -317,51 +317,14 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { return foreignKeys } + /// The same builder the schema-wide list uses, with one more predicate. Two hand-written + /// queries over pg_trigger would be two chances to disagree about one table's triggers. 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' - 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 - 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 query = PostgreSQLObjectQueries.triggerList(schema: resolvedSchema, table: table) let result = try await execute(query: query) - let triggers: [PluginTriggerInfo] = result.rows.compactMap { row -> PluginTriggerInfo? in - guard row.count >= 5, - let name = row[0].asText, - let timing = row[1].asText, - let event = row[2].asText, - let definition = row[4].asText - else { return nil } - return PluginTriggerInfo( - name: name, - timing: timing, - event: event, - statement: definition, - enabled: row[3].asText == "t" - ) + let triggers = result.rows.compactMap { + Self.trigger(from: $0, fallbackSchema: resolvedSchema) } Self.logger.info("[trigger] postgres fetchTriggers schema=\(resolvedSchema, privacy: .public) table=\(table, privacy: .public) rows=\(result.rows.count) parsed=\(triggers.count)") return triggers diff --git a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift index ffa84fa8f..ff5ed0e7f 100644 --- a/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift +++ b/Plugins/SQLiteDriverPlugin/SQLitePlugin.swift @@ -38,6 +38,7 @@ final class SQLitePlugin: NSObject, TableProPlugin, DriverPlugin { static let brandColorHex = "#003B57" static let supportsDatabaseSwitching = false static let supportsTriggers = true + static let supportsDatabaseTriggerBrowse = true static let supportsTriggerEditing = true static let databaseGroupingStrategy: GroupingStrategy = .flat static let columnTypesByCategory: [String: [String]] = [ @@ -935,29 +936,7 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] { - let safeTable = escapeStringLiteral(table) - let query = """ - SELECT name, sql FROM sqlite_master - WHERE type = 'trigger' AND tbl_name = '\(safeTable)' - ORDER BY name - """ - let result = try await execute(query: query) - - return result.rows.compactMap { row -> PluginTriggerInfo? in - guard row.count >= 2, - let name = row[0].asText, - let sql = row[1].asText else { - return nil - } - - let (timing, event) = TriggerSQLParser.timingAndEvent(from: sql) - return PluginTriggerInfo( - name: name, - timing: timing, - event: event, - statement: sql - ) - } + try await sqliteTriggerList(table: table) } var supportsTransactionalDDL: Bool { true } diff --git a/Plugins/SQLiteDriverPlugin/SQLitePluginDriver+Triggers.swift b/Plugins/SQLiteDriverPlugin/SQLitePluginDriver+Triggers.swift new file mode 100644 index 000000000..7a7564b6f --- /dev/null +++ b/Plugins/SQLiteDriverPlugin/SQLitePluginDriver+Triggers.swift @@ -0,0 +1,33 @@ +// +// SQLitePluginDriver+Triggers.swift +// SQLiteDriverPlugin +// + +import Foundation +import TableProPluginKit + +extension SQLitePluginDriver { + func fetchAllTriggers(schema: String?) async throws -> [PluginTriggerInfo] { + try await sqliteTriggerList(table: nil) + } + + func fetchTriggerDDL(_ trigger: PluginTriggerInfo) async throws -> String { + if let definition = trigger.definition, !definition.isEmpty { return definition } + let listed = try await sqliteTriggerList(table: trigger.table) + guard let definition = listed.first(where: { $0.name == trigger.name })?.definition, + !definition.isEmpty + else { + throw PluginObjectSourceError.notFound(trigger.name) + } + return definition + } + + func sqliteTriggerList(table: String?) async throws -> [PluginTriggerInfo] { + let query = SQLiteMasterQueries.triggerList(table: table, excludeNameGlob: nil) + let result = try await execute(query: query) + return result.rows.compactMap { row -> PluginTriggerInfo? in + guard row.count >= 3, let name = row[0].asText, let sql = row[2].asText else { return nil } + return SQLiteMasterQueries.trigger(name: name, table: row[1].asText, sql: sql) + } + } +} diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift index e371c5aff..3c7bf5fad 100644 --- a/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift @@ -35,6 +35,7 @@ final class SnowflakePlugin: NSObject, TableProPlugin, DriverPlugin { static let queryLanguageName = "SQL" static let editorLanguage: EditorLanguage = .sql static let supportsForeignKeys = true + static let supportsRoutines = true static let supportsSchemaEditing = true static let supportsAddColumn = true static let supportsModifyColumn = true diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver+Routines.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver+Routines.swift new file mode 100644 index 000000000..f5ca4e0e9 --- /dev/null +++ b/Plugins/SnowflakeDriverPlugin/SnowflakePluginDriver+Routines.swift @@ -0,0 +1,91 @@ +// +// SnowflakePluginDriver+Routines.swift +// SnowflakeDriverPlugin +// + +import Foundation +import TableProPluginKit + +/// Snowflake has procedures and functions and no triggers. +public enum SnowflakeObjectQueries { + public static func escapeLiteral(_ value: String) -> String { + value.replacingOccurrences(of: "'", with: "''") + } + + public static func routineList(schema: String) -> String { + let schemaLiteral = escapeLiteral(schema) + return """ + SELECT PROCEDURE_NAME AS NAME, PROCEDURE_SCHEMA AS SCHEMA_NAME, ARGUMENT_SIGNATURE, + DATA_TYPE, PROCEDURE_LANGUAGE AS LANGUAGE, 'PROCEDURE' AS ROUTINE_KIND + FROM INFORMATION_SCHEMA.PROCEDURES + WHERE PROCEDURE_SCHEMA = '\(schemaLiteral)' + UNION ALL + SELECT FUNCTION_NAME AS NAME, FUNCTION_SCHEMA AS SCHEMA_NAME, ARGUMENT_SIGNATURE, + DATA_TYPE, FUNCTION_LANGUAGE AS LANGUAGE, 'FUNCTION' AS ROUTINE_KIND + FROM INFORMATION_SCHEMA.FUNCTIONS + WHERE FUNCTION_SCHEMA = '\(schemaLiteral)' + ORDER BY ROUTINE_KIND, NAME + """ + } + + /// ARGUMENT_SIGNATURE names its parameters, `(A NUMBER, B VARCHAR)`, and GET_DDL accepts types + /// alone, `(NUMBER, VARCHAR)`. Passing the signature through unchanged is an error, not a + /// missing routine, so the names are dropped here. + public static func argumentTypes(fromSignature signature: String?) -> String { + guard let signature else { return "()" } + let trimmed = signature.trimmingCharacters(in: .whitespaces) + let inner = trimmed.hasPrefix("(") && trimmed.hasSuffix(")") + ? String(trimmed.dropFirst().dropLast()) + : trimmed + guard !inner.trimmingCharacters(in: .whitespaces).isEmpty else { return "()" } + let types = inner.split(separator: ",").map { part -> String in + let tokens = part.split(whereSeparator: { $0.isWhitespace }) + guard tokens.count > 1 else { return tokens.joined() } + return tokens.dropFirst().joined(separator: " ") + } + return "(\(types.joined(separator: ", ")))" + } + + /// GET_DDL needs the argument types inside the name, so a routine cannot be addressed without + /// the signature the listing captured. + public static func routineDefinition(kind: String, schema: String?, name: String, signature: String?) -> String { + let arguments = argumentTypes(fromSignature: signature) + let qualified = [schema, name].compactMap { $0?.isEmpty == false ? $0 : nil }.joined(separator: ".") + return "SELECT GET_DDL('\(escapeLiteral(kind))', '\(escapeLiteral(qualified + arguments))')" + } +} + +extension SnowflakePluginDriver { + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { + let resolvedSchema = schema ?? currentSchema ?? "PUBLIC" + let result = try await execute(query: SnowflakeObjectQueries.routineList(schema: resolvedSchema)) + return result.rows.compactMap { row -> PluginRoutineInfo? in + guard let name = row[safe: 0]?.asText else { return nil } + let isProcedure = row[safe: 5]?.asText?.uppercased() == "PROCEDURE" + return PluginRoutineInfo( + name: name, + kind: isProcedure ? .procedure : .function, + schema: row[safe: 1]?.asText ?? resolvedSchema, + returnType: row[safe: 3]?.asText, + language: row[safe: 4]?.asText, + argumentSignature: row[safe: 2]?.asText, + identity: row[safe: 2]?.asText, + attributes: [] + ) + } + } + + func fetchRoutineDDL(_ routine: PluginRoutineInfo) async throws -> String { + let query = SnowflakeObjectQueries.routineDefinition( + kind: routine.kind == .procedure ? "PROCEDURE" : "FUNCTION", + schema: routine.schema ?? currentSchema, + name: routine.name, + signature: routine.identity ?? routine.argumentSignature + ) + let result = try await execute(query: query) + guard let ddl = result.rows.first?[safe: 0]?.asText, !ddl.isEmpty else { + throw PluginObjectSourceError.insufficientPrivilege(routine.name) + } + return ddl + } +} diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift index a6b703581..2944dc8e1 100644 --- a/Plugins/TableProPluginKit/DriverPlugin.swift +++ b/Plugins/TableProPluginKit/DriverPlugin.swift @@ -25,6 +25,8 @@ public protocol DriverPlugin: TableProPlugin { static var supportsForeignKeys: Bool { get } static var supportsTriggers: Bool { get } static var supportsTriggerEditing: Bool { get } + static var supportsRoutines: Bool { get } + static var supportsDatabaseTriggerBrowse: Bool { get } static var supportsSchemaEditing: Bool { get } static var supportsDatabaseSwitching: Bool { get } static var supportsSchemaSwitching: Bool { get } @@ -88,6 +90,12 @@ public extension DriverPlugin { static var supportsForeignKeys: Bool { true } static var supportsTriggers: Bool { false } static var supportsTriggerEditing: Bool { false } + + /// These say what the ENGINE has, so the app knows not to run a query that can only fail on + /// Redis or DynamoDB. They never gate whether returned objects are shown: a driver that + /// declares nothing and returns routines anyway still gets its section. + static var supportsRoutines: Bool { false } + static var supportsDatabaseTriggerBrowse: Bool { supportsTriggers } static var supportsSchemaEditing: Bool { true } static var supportsDatabaseSwitching: Bool { true } static var supportsSchemaSwitching: Bool { false } diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index f156fccc9..0eba7beaf 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -88,6 +88,10 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { 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 fetchAllTriggers(schema: String?) async throws -> [PluginTriggerInfo] + func fetchTriggerDDL(_ trigger: PluginTriggerInfo) async throws -> String + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] + func fetchRoutineDDL(_ routine: PluginRoutineInfo) async throws -> String 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 @@ -226,6 +230,46 @@ public extension PluginDatabaseDriver { func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] { [] } + func fetchAllTriggers(schema: String?) async throws -> [PluginTriggerInfo] { [] } + + func fetchTriggerDDL(_ trigger: PluginTriggerInfo) async throws -> String { + if let definition = trigger.definition, !definition.isEmpty { return definition } + guard let table = trigger.table else { + throw PluginObjectSourceError.unsupported(trigger.name) + } + guard let existing = try await fetchTriggerDefinition( + name: trigger.name, + table: table, + schema: trigger.schema + ) else { + throw PluginObjectSourceError.unsupported(trigger.name) + } + return existing + } + + /// A driver written against `PluginProcedureFunctionSupport` keeps working untouched: the + /// runtime fills this requirement from here, and here adopts that conformance. The app only + /// ever calls this one, so nothing above PluginKit has to know the older protocol exists. + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { + guard let legacy = self as? PluginProcedureFunctionSupport else { return [] } + let procedures = try await legacy.fetchProcedures(schema: schema) + let functions = try await legacy.fetchFunctions(schema: schema) + return procedures.map { $0.adopting(kind: .procedure, schema: schema) } + + functions.map { $0.adopting(kind: .function, schema: schema) } + } + + func fetchRoutineDDL(_ routine: PluginRoutineInfo) async throws -> String { + guard let legacy = self as? PluginProcedureFunctionSupport else { + throw PluginObjectSourceError.unsupported(routine.name) + } + switch routine.kind { + case .procedure: + return try await legacy.fetchProcedureDDL(name: routine.name, schema: routine.schema) + case .function: + return try await legacy.fetchFunctionDDL(name: routine.name, schema: routine.schema) + } + } + /// Engines whose partitions are metadata on one table object, rather than /// separate relations, have nothing to nest and keep the empty default. func fetchPartitions(table: String, schema: String?) async throws -> [PluginTableInfo] { [] } diff --git a/Plugins/TableProPluginKit/PluginObjectAttribute.swift b/Plugins/TableProPluginKit/PluginObjectAttribute.swift new file mode 100644 index 000000000..fb114e978 --- /dev/null +++ b/Plugins/TableProPluginKit/PluginObjectAttribute.swift @@ -0,0 +1,20 @@ +// +// PluginObjectAttribute.swift +// TableProPluginKit +// +// One labelled property of a database object, supplied by the driver and rendered verbatim. +// + +import Foundation + +/// A driver names and orders these itself, so per-engine vocabulary (volatility, security, +/// determinism, parallel safety, trigger orientation) never has to be modelled in the app. +public struct PluginObjectAttribute: Codable, Sendable, Hashable { + public let label: String + public let value: String + + public init(label: String, value: String) { + self.label = label + self.value = value + } +} diff --git a/Plugins/TableProPluginKit/PluginObjectSourceError.swift b/Plugins/TableProPluginKit/PluginObjectSourceError.swift new file mode 100644 index 000000000..b2687b39f --- /dev/null +++ b/Plugins/TableProPluginKit/PluginObjectSourceError.swift @@ -0,0 +1,32 @@ +// +// PluginObjectSourceError.swift +// TableProPluginKit +// +// Why a driver could not produce an object's source. +// + +import Foundation + +/// The three answers are genuinely different to a reader and must not collapse into one message. +/// MySQL returns a NULL body rather than an error when the account lacks SHOW_ROUTINE, and Oracle +/// returns nothing rather than raising when the account lacks SELECT_CATALOG_ROLE, so a driver +/// that reports either as "not found" tells the user their routine is gone when it is not. +public enum PluginObjectSourceError: Error, LocalizedError, Sendable { + case unsupported(String) + case notFound(String) + case insufficientPrivilege(String) + + public var errorDescription: String? { + switch self { + case .unsupported(let object): + return String(format: String(localized: "This database cannot show the source of %@"), object) + case .notFound(let object): + return String(format: String(localized: "%@ no longer exists"), object) + case .insufficientPrivilege(let object): + return String( + format: String(localized: "Your account is not allowed to read the source of %@"), + object + ) + } + } +} diff --git a/Plugins/TableProPluginKit/PluginProcedureFunctionSupport.swift b/Plugins/TableProPluginKit/PluginProcedureFunctionSupport.swift index 3cc8807be..a1e5c5274 100644 --- a/Plugins/TableProPluginKit/PluginProcedureFunctionSupport.swift +++ b/Plugins/TableProPluginKit/PluginProcedureFunctionSupport.swift @@ -1,5 +1,10 @@ import Foundation +/// Superseded by `PluginDatabaseDriver.fetchRoutines(schema:)` and `fetchRoutineDDL(_:)`, which a +/// driver gets defaults for. It stays declared because removing a published protocol deletes its +/// descriptor symbol and every already-built plugin that referenced it fails to load; the default +/// implementation of `fetchRoutines(schema:)` adopts a conformer of this protocol so one keeps +/// working unchanged. public protocol PluginProcedureFunctionSupport { func fetchProcedures(schema: String?) async throws -> [PluginRoutineInfo] func fetchFunctions(schema: String?) async throws -> [PluginRoutineInfo] @@ -7,14 +12,80 @@ public protocol PluginProcedureFunctionSupport { func fetchFunctionDDL(name: String, schema: String?) async throws -> String } +public enum PluginRoutineKind: String, Codable, Sendable { + case procedure + case function +} + public struct PluginRoutineInfo: Codable, Sendable { public let name: String public let returnType: String? public let language: String? + public let schema: String? + public let kind: PluginRoutineKind + + /// What the engine calls this routine's parameter list, spelled the way the engine spells it, + /// including the parentheses: `(date)`, `(geometry, integer)`. Nil when the engine has no + /// overloading and offers no parameter list. + public let argumentSignature: String? + + /// Whatever the driver needs to address this exact routine again when asked for its DDL: a + /// PostgreSQL oid, a Snowflake argument-type list, an Oracle overload position. Opaque to the + /// app, which only ever hands it back. + public let identity: String? + /// The source, when the same read that listed the routine already returned it. Never part of + /// the routine's identity: a definition that changes must not change which routine this is. + public let definition: String? + + public let attributes: [PluginObjectAttribute] + + public init( + name: String, + kind: PluginRoutineKind, + schema: String? = nil, + returnType: String? = nil, + language: String? = nil, + argumentSignature: String? = nil, + identity: String? = nil, + definition: String? = nil, + attributes: [PluginObjectAttribute] = [] + ) { + self.name = name + self.kind = kind + self.schema = schema + self.returnType = returnType + self.language = language + self.argumentSignature = argumentSignature + self.identity = identity + self.definition = definition + self.attributes = attributes + } + + @_disfavoredOverload public init(name: String, returnType: String? = nil, language: String? = nil) { self.name = name + self.kind = .function + self.schema = nil self.returnType = returnType self.language = language + self.argumentSignature = nil + self.identity = nil + self.definition = nil + self.attributes = [] + } + + public func adopting(kind: PluginRoutineKind, schema: String?) -> PluginRoutineInfo { + PluginRoutineInfo( + name: name, + kind: kind, + schema: self.schema ?? schema, + returnType: returnType, + language: language, + argumentSignature: argumentSignature, + identity: identity, + definition: definition, + attributes: attributes + ) } } diff --git a/Plugins/TableProPluginKit/PluginTriggerInfo.swift b/Plugins/TableProPluginKit/PluginTriggerInfo.swift index 4687b1f7d..268b87012 100644 --- a/Plugins/TableProPluginKit/PluginTriggerInfo.swift +++ b/Plugins/TableProPluginKit/PluginTriggerInfo.swift @@ -14,6 +14,45 @@ public struct PluginTriggerInfo: Codable, Sendable { public let statement: String public let enabled: Bool? + /// The table the trigger fires for. A per-table fetch already knows it, but a schema-wide list + /// cannot be grouped, labelled or navigated back to its table without it. + public let table: String? + public let schema: String? + + /// ROW or STATEMENT, spelled the way the engine spells it. + public let orientation: String? + + /// The whole CREATE TRIGGER text when the engine can produce one. `statement` is only the + /// action body, which is not a runnable definition on its own. + public let definition: String? + + public let attributes: [PluginObjectAttribute] + + public init( + name: String, + table: String?, + schema: String? = nil, + timing: String, + event: String, + orientation: String? = nil, + statement: String, + definition: String? = nil, + enabled: Bool? = nil, + attributes: [PluginObjectAttribute] = [] + ) { + self.name = name + self.table = table + self.schema = schema + self.timing = timing + self.event = event + self.orientation = orientation + self.statement = statement + self.definition = definition + self.enabled = enabled + self.attributes = attributes + } + + @_disfavoredOverload public init( name: String, timing: String, @@ -26,5 +65,25 @@ public struct PluginTriggerInfo: Codable, Sendable { self.event = event self.statement = statement self.enabled = enabled + self.table = nil + self.schema = nil + self.orientation = nil + self.definition = nil + self.attributes = [] + } + + public func adopting(table: String?, schema: String?) -> PluginTriggerInfo { + PluginTriggerInfo( + name: name, + table: self.table ?? table, + schema: self.schema ?? schema, + timing: timing, + event: event, + orientation: orientation, + statement: statement, + definition: definition, + enabled: enabled, + attributes: attributes + ) } } diff --git a/Plugins/TableProPluginKit/SQLiteMasterQueries.swift b/Plugins/TableProPluginKit/SQLiteMasterQueries.swift new file mode 100644 index 000000000..420916638 --- /dev/null +++ b/Plugins/TableProPluginKit/SQLiteMasterQueries.swift @@ -0,0 +1,46 @@ +// +// SQLiteMasterQueries.swift +// TableProPluginKit +// +// sqlite_master reads shared by every SQLite-compatible driver. +// + +import Foundation + +/// SQLite, LibSQL and Cloudflare D1 are three bundles reading one catalog. Three copies of this +/// query would be three chances for the per-table list and the schema-wide list to drift apart. +public enum SQLiteMasterQueries { + public static func escapeLiteral(_ value: String) -> String { + value.replacingOccurrences(of: "'", with: "''") + } + + /// `excludeNameGlob` hides a host's own bookkeeping triggers, which Cloudflare D1 prefixes + /// `_cf_` and which the user did not create and cannot edit. + public static func triggerList(table: String? = nil, excludeNameGlob: String? = nil) -> String { + let tablePredicate = table.map { "AND tbl_name = '\(escapeLiteral($0))'" } ?? "" + let excludePredicate = excludeNameGlob.map { "AND name NOT GLOB '\(escapeLiteral($0))'" } ?? "" + return """ + SELECT name, tbl_name, sql FROM sqlite_master + WHERE type = 'trigger' \(tablePredicate) \(excludePredicate) + ORDER BY tbl_name, name + """ + } + + /// SQLite stores the CREATE TRIGGER text and nothing else about the trigger, so timing, event + /// and orientation are read back out of that text rather than from columns. + public static func trigger(name: String, table: String?, sql: String) -> PluginTriggerInfo { + let parsed = TriggerSQLParser.timingAndEvent(from: sql) + return PluginTriggerInfo( + name: name, + table: table, + schema: nil, + timing: parsed.timing, + event: parsed.event, + orientation: "ROW", + statement: sql, + definition: sql, + enabled: nil, + attributes: [] + ) + } +} diff --git a/Plugins/TeradataDriverPlugin/TeradataPlugin.swift b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift index 704cd8711..e809de191 100644 --- a/Plugins/TeradataDriverPlugin/TeradataPlugin.swift +++ b/Plugins/TeradataDriverPlugin/TeradataPlugin.swift @@ -53,6 +53,8 @@ final class TeradataPlugin: NSObject, TableProPlugin, DriverPlugin { static let databaseGroupingStrategy: GroupingStrategy = .byDatabase static let containerEntityName = "Database" static let supportsForeignKeys = true + static let supportsRoutines = true + static let supportsDatabaseTriggerBrowse = true static let supportsSchemaEditing = true static let supportsSSL = true static let systemDatabaseNames: [String] = [ diff --git a/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Routines.swift b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Routines.swift new file mode 100644 index 000000000..a899b11ca --- /dev/null +++ b/Plugins/TeradataDriverPlugin/TeradataPluginDriver+Routines.swift @@ -0,0 +1,118 @@ +// +// TeradataPluginDriver+Routines.swift +// TeradataDriverPlugin +// + +import Foundation +import TableProPluginKit +import TableProTeradataCore + +extension TeradataPluginDriver { + func fetchRoutines(schema: String?) async throws -> [PluginRoutineInfo] { + guard let database = routineDatabase(schema) else { return [] } + let result = try await execute(query: TeradataObjectQueries.routineList(database: database)) + return result.rows.compactMap { row -> PluginRoutineInfo? in + guard let name = cellText(row, 0)?.trimmingCharacters(in: .whitespaces), !name.isEmpty else { + return nil + } + let kind = cellText(row, 1)?.trimmingCharacters(in: .whitespaces) + var attributes: [PluginObjectAttribute] = [] + if let kind, !kind.isEmpty { + attributes.append(PluginObjectAttribute(label: "Table Kind", value: kind)) + } + if let creator = cellText(row, 4)?.trimmingCharacters(in: .whitespaces), !creator.isEmpty { + attributes.append(PluginObjectAttribute(label: "Creator", value: creator)) + } + return PluginRoutineInfo( + name: name, + kind: TeradataObjectQueries.isProcedure(kind: kind) ? .procedure : .function, + schema: cellText(row, 2)?.trimmingCharacters(in: .whitespaces) ?? database, + returnType: nil, + language: "SQL", + argumentSignature: nil, + identity: nil, + definition: cellText(row, 3), + attributes: attributes + ) + } + } + + func fetchRoutineDDL(_ routine: PluginRoutineInfo) async throws -> String { + if let requestText = routine.definition?.trimmingCharacters(in: .whitespacesAndNewlines), + !requestText.isEmpty { + return requestText + } + guard let database = routineDatabase(routine.schema) else { + throw PluginObjectSourceError.notFound(routine.name) + } + let query = TeradataObjectQueries.routineDefinition( + kind: routine.kind == .procedure ? TeradataObjectQueries.TableKind.storedProcedure : "F", + database: database, + name: routine.name + ) + let result = try await execute(query: query) + let text = result.rows + .compactMap { cellText($0, 0) } + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + /// A procedure created without SPL retention has no stored text, which the server reports + /// as an empty answer rather than an error. + guard !text.isEmpty else { + throw PluginObjectSourceError.insufficientPrivilege(routine.name) + } + return text + } + + func fetchAllTriggers(schema: String?) async throws -> [PluginTriggerInfo] { + try await teradataTriggerList(schema: schema, table: nil) + } + + func fetchTriggerDDL(_ trigger: PluginTriggerInfo) async throws -> String { + if let definition = trigger.definition, !definition.isEmpty { return definition } + let listed = try await teradataTriggerList(schema: trigger.schema, table: trigger.table) + guard let definition = listed.first(where: { $0.name == trigger.name })?.definition, + !definition.isEmpty + else { + throw PluginObjectSourceError.notFound(trigger.name) + } + return definition + } + + func teradataTriggerList(schema: String?, table: String?) async throws -> [PluginTriggerInfo] { + guard let database = routineDatabase(schema) else { return [] } + let query = TeradataObjectQueries.triggerList(database: database, table: table) + let result = try await execute(query: query) + return result.rows.compactMap { row -> PluginTriggerInfo? in + guard let name = cellText(row, 0)?.trimmingCharacters(in: .whitespaces), !name.isEmpty else { + return nil + } + let definition = cellText(row, 7)?.trimmingCharacters(in: .whitespacesAndNewlines) + var attributes: [PluginObjectAttribute] = [] + if let order = cellText(row, 8)?.trimmingCharacters(in: .whitespaces), !order.isEmpty { + attributes.append(PluginObjectAttribute(label: "Order", value: order)) + } + return PluginTriggerInfo( + name: name, + table: cellText(row, 2)?.trimmingCharacters(in: .whitespaces), + schema: cellText(row, 1)?.trimmingCharacters(in: .whitespaces) ?? database, + timing: TeradataObjectQueries.timing(fromActionTime: cellText(row, 3)), + event: TeradataObjectQueries.event(fromEventCode: cellText(row, 4)), + orientation: TeradataObjectQueries.orientation(fromKind: cellText(row, 5)), + statement: definition ?? "", + definition: definition, + enabled: cellText(row, 6)?.trimmingCharacters(in: .whitespaces).uppercased() == "Y", + attributes: attributes + ) + } + } + + private func routineDatabase(_ schema: String?) -> String? { + if let schema, !schema.isEmpty { return schema } + return currentDatabaseName + } + + private func cellText(_ row: [PluginCellValue], _ index: Int) -> String? { + guard index < row.count, case .text(let value) = row[index] else { return nil } + return value + } +} diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 5c56315c7..80f59a28c 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -157,13 +157,20 @@ protocol DatabaseDriver: AnyObject, Sendable { /// Default implementation returns an empty set; drivers that support them override. func fetchExternalSchemaNames() async throws -> Set - /// Fetch stored procedures for the given schema (or current schema if nil). - /// Default implementation returns an empty list; drivers that support routines override. - func fetchProcedures(schema: String?) async throws -> [RoutineInfo] + /// Fetch every stored procedure and function in the given schema (or the current schema if + /// nil), in one round trip. Callers that want one kind filter the result rather than asking + /// twice, so an engine is never queried twice for what a single catalog read answers. + func fetchRoutines(schema: String?) async throws -> [RoutineInfo] - /// Fetch user-defined functions for the given schema (or current schema if nil). - /// Default implementation returns an empty list; drivers that support routines override. - func fetchFunctions(schema: String?) async throws -> [RoutineInfo] + /// Fetch the source of one routine. The routine must be one this driver listed, because its + /// `identity` is the driver's own key for finding it again. + func fetchRoutineDDL(_ routine: RoutineInfo) async throws -> String + + /// Fetch every trigger in the given schema, across all its tables. + func fetchAllTriggers(schema: String?) async throws -> [TriggerInfo] + + /// Fetch the source of one trigger. + func fetchTriggerDDL(_ trigger: TriggerInfo) async throws -> String /// Fetch metadata for a specific database (table count, size, etc.) func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata @@ -482,9 +489,18 @@ extension DatabaseDriver { try await fetchTables() } - func fetchProcedures(schema: String?) async throws -> [RoutineInfo] { [] } + func fetchRoutines(schema: String?) async throws -> [RoutineInfo] { [] } + + func fetchRoutineDDL(_ routine: RoutineInfo) async throws -> String { + throw PluginObjectSourceError.unsupported(routine.name) + } + + func fetchAllTriggers(schema: String?) async throws -> [TriggerInfo] { [] } - func fetchFunctions(schema: String?) async throws -> [RoutineInfo] { [] } + func fetchTriggerDDL(_ trigger: TriggerInfo) async throws -> String { + if let definition = trigger.definition, !definition.isEmpty { return definition } + throw PluginObjectSourceError.unsupported(trigger.name) + } var supportsTransactions: Bool { true } diff --git a/TablePro/Core/MCP/MCPConnectionBridge+Schema.swift b/TablePro/Core/MCP/MCPConnectionBridge+Schema.swift index c065a3998..1708a69ef 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge+Schema.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge+Schema.swift @@ -165,13 +165,19 @@ extension MCPConnectionBridge { ]) } - func listTriggers(scope: DatabaseScope, table: String) async throws -> JsonValue { + /// A nil table asks for the whole schema, which is what the sidebar's Triggers section shows. + /// Every entry carries its own table either way, so one reader can handle both answers. + func listTriggers(scope: DatabaseScope, table: String?) async throws -> JsonValue { try await ensureConnected(scope.connectionId) + let schema = scope.schema let triggers = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in - try await driver.fetchTriggers(table: table) + if let table { + return try await driver.fetchTriggers(table: table) + } + return try await driver.fetchAllTriggers(schema: schema) } let payload = triggers - .sorted { $0.name < $1.name } + .sorted { ($0.table ?? "", $0.name) < ($1.table ?? "", $1.name) } .map { trigger -> JsonValue in var fields: [String: JsonValue] = [ "name": .string(trigger.name), @@ -179,12 +185,28 @@ extension MCPConnectionBridge { "event": .string(trigger.event), "statement": .string(trigger.statement) ] + if let owningTable = trigger.table ?? table { + fields["table"] = .string(owningTable) + } + if let triggerSchema = trigger.schema { + fields["schema"] = .string(triggerSchema) + } + if let orientation = trigger.orientation { + fields["orientation"] = .string(orientation) + } + if let definition = trigger.definition { + fields["definition"] = .string(definition) + } if let enabled = trigger.enabled { fields["is_enabled"] = .bool(enabled) } return .object(fields) } - return .object(["table": .string(table), "triggers": .array(payload)]) + var result: [String: JsonValue] = ["triggers": .array(payload)] + if let table { + result["table"] = .string(table) + } + return .object(result) } func getViewDefinition(scope: DatabaseScope, view: String) async throws -> JsonValue { @@ -203,14 +225,9 @@ extension MCPConnectionBridge { try await ensureConnected(scope.connectionId) let schema = scope.schema let routines = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in - var collected: [RoutineInfo] = [] - if kind == nil || kind == "procedure" { - collected += try await driver.fetchProcedures(schema: schema) - } - if kind == nil || kind == "function" { - collected += try await driver.fetchFunctions(schema: schema) - } - return collected + let all = try await driver.fetchRoutines(schema: schema) + guard let kind else { return all } + return all.filter { $0.kind.rawValue.lowercased() == kind.lowercased() } } let payload = routines .sorted { $0.qualifiedName < $1.qualifiedName } @@ -223,9 +240,15 @@ extension MCPConnectionBridge { if let schema = routine.schema { fields["schema"] = .string(schema) } - if let signature = routine.signature { + if let signature = routine.argumentSignature { fields["signature"] = .string(signature) } + if let returnType = routine.returnType { + fields["return_type"] = .string(returnType) + } + if let language = routine.language { + fields["language"] = .string(language) + } return .object(fields) } return .object(["routines": .array(payload)]) diff --git a/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift b/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift index eeeafad95..8a9dbdeb8 100644 --- a/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift +++ b/TablePro/Core/MCP/Protocol/Tools/MCPTabSnapshotProvider.swift @@ -137,6 +137,7 @@ private extension TabType { case .serverDashboard: "serverDashboard" case .insights: "insights" case .usersRoles: "usersRoles" + case .objectSource: "objectSource" } } } diff --git a/TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift b/TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift index b5817b72a..c2d82bc1b 100644 --- a/TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift +++ b/TablePro/Core/MCP/Protocol/Tools/SchemaObjectTools.swift @@ -150,27 +150,31 @@ public struct ListTriggersTool: MCPToolImplementation { "database": MCPToolSchema.database, "schema": MCPToolSchema.schema ], - required: ["connection_id", "table"] + required: ["connection_id"] ) public static let outputSchema: JsonValue? = MCPToolSchema.object( properties: [ - "table": MCPToolSchema.string(String(localized: "Table the triggers belong to")), + "table": MCPToolSchema.string(String(localized: "Table the triggers belong to, when one was named")), "triggers": MCPToolSchema.array( - String(localized: "Triggers, sorted by name"), + String(localized: "Triggers, sorted by table then name"), of: MCPToolSchema.object( properties: [ "name": MCPToolSchema.string(String(localized: "Trigger name")), + "table": MCPToolSchema.string(String(localized: "Table the trigger fires for")), + "schema": MCPToolSchema.string(String(localized: "Schema the trigger belongs to")), "timing": MCPToolSchema.string(String(localized: "BEFORE, AFTER, or INSTEAD OF")), "event": MCPToolSchema.string(String(localized: "INSERT, UPDATE, or DELETE")), + "orientation": MCPToolSchema.string(String(localized: "ROW or STATEMENT")), "statement": MCPToolSchema.string(String(localized: "Trigger body")), + "definition": MCPToolSchema.string(String(localized: "Full CREATE TRIGGER statement")), "is_enabled": MCPToolSchema.boolean(String(localized: "Whether the trigger is enabled")) ], required: ["name", "timing", "event", "statement"] ) ) ], - required: ["table", "triggers"] + required: ["triggers"] ) public init() {} @@ -181,7 +185,7 @@ public struct ListTriggersTool: MCPToolImplementation { services: MCPToolServices ) async throws -> MCPToolCallResult { try MCPArgumentDecoder.rejectUnknownKeys(arguments, allowed: MCPScopeArguments.keys.union(["table"])) - let table = try MCPArgumentDecoder.requireNonEmptyString(arguments, key: "table") + let table = try MCPArgumentDecoder.optionalString(arguments, key: "table") let scope = try await MCPScopeArguments.resolve(arguments, services: services) let payload = try await services.connectionBridge.listTriggers(scope: scope, table: table) return .structured(payload) @@ -273,7 +277,13 @@ public struct ListRoutinesTool: MCPToolImplementation { "kind": MCPToolSchema.string(String(localized: "PROCEDURE or FUNCTION")), "schema": MCPToolSchema.string(String(localized: "Schema the routine lives in")), "qualified_name": MCPToolSchema.string(String(localized: "Schema-qualified name")), - "signature": MCPToolSchema.string(String(localized: "Argument signature, when reported")) + "signature": MCPToolSchema.string( + String(localized: "Argument list the engine reports, such as (date), when it reports one") + ), + "return_type": MCPToolSchema.string( + String(localized: "What a function returns, absent for a procedure") + ), + "language": MCPToolSchema.string(String(localized: "Language the routine is written in")) ], required: ["name", "kind", "qualified_name"] ) diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 7f04ac509..84accf11d 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -287,16 +287,19 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor } func fetchTriggers(table: String) async throws -> [TriggerInfo] { - let pluginTriggers = try await pluginDriver.fetchTriggers(table: table, schema: pluginDriver.currentSchema) - return pluginTriggers.map { trigger in - TriggerInfo( - name: trigger.name, - timing: trigger.timing, - event: trigger.event, - statement: trigger.statement, - enabled: trigger.enabled - ) - } + let schema = pluginDriver.currentSchema + let pluginTriggers = try await pluginDriver.fetchTriggers(table: table, schema: schema) + return pluginTriggers.map { TriggerInfo($0.adopting(table: table, schema: schema)) } + } + + func fetchAllTriggers(schema: String?) async throws -> [TriggerInfo] { + let resolvedSchema = schema ?? pluginDriver.currentSchema + let pluginTriggers = try await pluginDriver.fetchAllTriggers(schema: resolvedSchema) + return pluginTriggers.map { TriggerInfo($0.adopting(table: nil, schema: resolvedSchema)) } + } + + func fetchTriggerDDL(_ trigger: TriggerInfo) async throws -> String { + try await pluginDriver.fetchTriggerDDL(trigger.pluginTrigger) } func createTriggerTemplate(table: String) -> String? { @@ -390,59 +393,20 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor try await pluginDriver.fetchExternalSchemaNames() } - func fetchProcedures(schema: String?) async throws -> [RoutineInfo] { - guard let support = pluginDriver as? PluginProcedureFunctionSupport else { return [] } + func fetchRoutines(schema: String?) async throws -> [RoutineInfo] { let resolvedSchema = schema ?? pluginDriver.currentSchema do { - let pluginRoutines = try await support.fetchProcedures(schema: resolvedSchema) - return pluginRoutines.map { routine in - RoutineInfo( - name: routine.name, - schema: resolvedSchema, - kind: .procedure, - signature: routine.returnType - ) - } + let pluginRoutines = try await pluginDriver.fetchRoutines(schema: resolvedSchema) + return pluginRoutines.map { RoutineInfo($0.adopting(kind: $0.kind, schema: resolvedSchema)) } + .sorted { ($0.kind.rawValue, $0.name) < ($1.kind.rawValue, $1.name) } } catch { - Self.logger.warning("fetchProcedures failed: \(error.localizedDescription, privacy: .public)") + Self.logger.warning("fetchRoutines failed: \(error.localizedDescription, privacy: .public)") throw error } } - func fetchFunctions(schema: String?) async throws -> [RoutineInfo] { - guard let support = pluginDriver as? PluginProcedureFunctionSupport else { return [] } - let resolvedSchema = schema ?? pluginDriver.currentSchema - do { - let pluginRoutines = try await support.fetchFunctions(schema: resolvedSchema) - return pluginRoutines.map { routine in - RoutineInfo( - name: routine.name, - schema: resolvedSchema, - kind: .function, - signature: routine.returnType - ) - } - } catch { - Self.logger.warning("fetchFunctions failed: \(error.localizedDescription, privacy: .public)") - throw error - } - } - - func fetchRoutineDDL(routine: RoutineInfo) async throws -> String { - guard let support = pluginDriver as? PluginProcedureFunctionSupport else { - throw NSError( - domain: "PluginDriverAdapter", - code: -1, - userInfo: [NSLocalizedDescriptionKey: String(localized: "This driver does not expose routine DDL.")] - ) - } - let resolvedSchema = routine.schema ?? pluginDriver.currentSchema - switch routine.kind { - case .procedure: - return try await support.fetchProcedureDDL(name: routine.name, schema: resolvedSchema) - case .function: - return try await support.fetchFunctionDDL(name: routine.name, schema: resolvedSchema) - } + func fetchRoutineDDL(_ routine: RoutineInfo) async throws -> String { + try await pluginDriver.fetchRoutineDDL(routine.pluginRoutine) } func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index cc0ec3757..0069262cc 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -58,6 +58,8 @@ struct PluginMetadataSnapshot: Sendable { var supportsModifyPrimaryKey: Bool = true var supportsTriggers: Bool = false var supportsTriggerEditing: Bool = false + var supportsRoutines: Bool = false + var supportsDatabaseTriggerBrowse: Bool = false var defaultSSLMode: SSLMode = .disabled var supportsOpportunisticTLS: Bool = true var supportsCloudflareTunnel: Bool = true @@ -542,6 +544,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsRenameColumn: true, supportsTriggers: true, supportsTriggerEditing: true, + supportsRoutines: true, + supportsDatabaseTriggerBrowse: true, defaultSSLMode: .preferred ), schema: PluginMetadataSnapshot.SchemaInfo( @@ -597,6 +601,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsRenameColumn: true, supportsTriggers: true, supportsTriggerEditing: true, + supportsRoutines: true, + supportsDatabaseTriggerBrowse: true, defaultSSLMode: .preferred ), schema: PluginMetadataSnapshot.SchemaInfo( @@ -654,6 +660,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsRenameColumn: true, supportsTriggers: true, supportsTriggerEditing: true, + supportsRoutines: true, + supportsDatabaseTriggerBrowse: true, defaultSSLMode: .preferred ), schema: PluginMetadataSnapshot.SchemaInfo( @@ -884,6 +892,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsModifyPrimaryKey: false, supportsTriggers: true, supportsTriggerEditing: true, + supportsDatabaseTriggerBrowse: true, supportsCloudflareTunnel: false ), schema: PluginMetadataSnapshot.SchemaInfo( @@ -1135,6 +1144,8 @@ final class PluginMetadataRegistry: @unchecked Sendable { supportsModifyPrimaryKey: driverType.supportsModifyPrimaryKey, supportsTriggers: driverType.supportsTriggers, supportsTriggerEditing: driverType.supportsTriggerEditing, + supportsRoutines: driverType.supportsRoutines, + supportsDatabaseTriggerBrowse: driverType.supportsDatabaseTriggerBrowse, defaultSSLMode: existingSnapshot?.capabilities.defaultSSLMode ?? .disabled, supportsOpportunisticTLS: existingSnapshot?.capabilities.supportsOpportunisticTLS ?? true, supportsCloudflareTunnel: driverType.supportsSSH, diff --git a/TablePro/Core/Plugins/PluginObjectMapping.swift b/TablePro/Core/Plugins/PluginObjectMapping.swift new file mode 100644 index 000000000..d99d6fd0b --- /dev/null +++ b/TablePro/Core/Plugins/PluginObjectMapping.swift @@ -0,0 +1,103 @@ +// +// PluginObjectMapping.swift +// TablePro +// +// The single crossing between PluginKit's routine and trigger transfer types and the app's. +// + +import Foundation +import TableProPluginKit + +extension ObjectAttribute { + init(_ attribute: PluginObjectAttribute) { + self.init(label: attribute.label, value: attribute.value) + } + + var pluginAttribute: PluginObjectAttribute { + PluginObjectAttribute(label: label, value: value) + } +} + +extension RoutineInfo.Kind { + /// PluginKit ships with Library Evolution, so a plugin built against a later version can hand + /// back a kind this build has no case for. Reading it as a function keeps that routine listed + /// under a heading that exists rather than dropping it. + init(_ kind: PluginRoutineKind) { + switch kind { + case .procedure: self = .procedure + case .function: self = .function + @unknown default: self = .function + } + } + + var pluginKind: PluginRoutineKind { + switch self { + case .procedure: return .procedure + case .function: return .function + } + } +} + +extension RoutineInfo { + init(_ routine: PluginRoutineInfo) { + self.init( + name: routine.name, + kind: Kind(routine.kind), + schema: routine.schema, + argumentSignature: routine.argumentSignature, + returnType: routine.returnType, + language: routine.language, + identity: routine.identity, + definition: routine.definition, + attributes: routine.attributes.map(ObjectAttribute.init) + ) + } + + /// Handed straight back to the driver that produced it, so `identity` survives the round trip + /// and a DDL fetch addresses the exact overload the user clicked. + var pluginRoutine: PluginRoutineInfo { + PluginRoutineInfo( + name: name, + kind: kind.pluginKind, + schema: schema, + returnType: returnType, + language: language, + argumentSignature: argumentSignature, + identity: identity, + definition: definition, + attributes: attributes.map(\.pluginAttribute) + ) + } +} + +extension TriggerInfo { + init(_ trigger: PluginTriggerInfo) { + self.init( + name: trigger.name, + timing: trigger.timing, + event: trigger.event, + statement: trigger.statement, + enabled: trigger.enabled, + table: trigger.table, + schema: trigger.schema, + orientation: trigger.orientation, + definition: trigger.definition, + attributes: trigger.attributes.map(ObjectAttribute.init) + ) + } + + var pluginTrigger: PluginTriggerInfo { + PluginTriggerInfo( + name: name, + table: table, + schema: schema, + timing: timing, + event: event, + orientation: orientation, + statement: statement, + definition: definition, + enabled: enabled, + attributes: attributes.map(\.pluginAttribute) + ) + } +} diff --git a/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift b/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift index 43e51feb1..731c6e701 100644 --- a/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift +++ b/TablePro/Core/Services/Infrastructure/EditorTabOpener.swift @@ -84,6 +84,9 @@ internal enum EditorTabOpener { tabManager.addUsersRolesTab() case .insights: tabManager.addQueryInsightsTab() + case .objectSource: + guard let objectRef = payload.objectRef else { return } + tabManager.addObjectSourceTab(objectRef: objectRef) } } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift index 88e791827..3a8d7e85b 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController.swift @@ -975,7 +975,7 @@ internal final class MainSplitViewController: NSSplitViewController, InspectorVi switch tabType { case .usersRoles: return UsersRolesLayoutMetrics.tabMinimumWidth - case .query, .table, .createTable, .erDiagram, .serverDashboard, .insights: + case .query, .table, .createTable, .erDiagram, .serverDashboard, .insights, .objectSource: return defaultDetailMinThickness } } diff --git a/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift b/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift index 7496aeaa6..d1ddd0d1e 100644 --- a/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift +++ b/TablePro/Core/Services/Infrastructure/WindowTitleResolver.swift @@ -124,6 +124,13 @@ enum WindowTitleResolver { return String(localized: "ER Diagram") case .createTable: return String(localized: "Create Table") + case .objectSource: + /// The tab already carries the object's identity as its title. Falling through would + /// title the window "SQL Query" while its tab reads "Procedure: public.f(date)". + if let explicitTitle, !explicitTitle.isBlank { + return explicitTitle + } + return String(localized: "Source") default: break } diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index d4ea3287e..bf3095a0d 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -34,12 +34,14 @@ final class DatabaseTreeMetadataService { private(set) var schemaList: [DatabaseKey: MetadataLoadState<[String]>] = [:] private(set) var tablesState: [ObjectsKey: MetadataLoadState<[TableInfo]>] = [:] private(set) var routinesState: [ObjectsKey: MetadataLoadState<[RoutineInfo]>] = [:] + private(set) var triggersState: [ObjectsKey: MetadataLoadState<[TriggerInfo]>] = [:] private(set) var partitionsState: [PartitionsKey: MetadataLoadState<[TableInfo]>] = [:] @ObservationIgnored private let databaseDedup = OnceTask() @ObservationIgnored private let schemaDedup = OnceTask() @ObservationIgnored private let tablesDedup = OnceTask() @ObservationIgnored private let routinesDedup = OnceTask() + @ObservationIgnored private let triggersDedup = OnceTask() @ObservationIgnored private let partitionsDedup = OnceTask() @ObservationIgnored nonisolated private static let logger = Logger( @@ -82,6 +84,14 @@ final class DatabaseTreeMetadataService { routinesState[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)]?.value ?? [] } + func triggersLoadState(connectionId: UUID, database: String, schema: String?) -> MetadataLoadState<[TriggerInfo]> { + triggersState[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)] ?? .idle + } + + func triggers(connectionId: UUID, database: String, schema: String?) -> [TriggerInfo] { + triggersState[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)]?.value ?? [] + } + func partitionsLoadState( connectionId: UUID, database: String, schema: String?, table: String ) -> MetadataLoadState<[TableInfo]> { @@ -196,6 +206,26 @@ final class DatabaseTreeMetadataService { } } + func loadTriggers(connectionId: UUID, database: String, schema: String?) async { + guard isConnected(connectionId), browsesTriggers(connectionId) else { return } + let key = Self.objectsKey(connectionId: connectionId, database: database, schema: schema) + switch triggersState[key] ?? .idle { + case .loaded, .loading: return + case .idle, .failed: break + } + triggersState[key] = .loading + do { + triggersState[key] = .loaded(try await fetchTriggerList(key)) + } catch is CancellationError { + if case .loading = triggersState[key] { triggersState[key] = .idle } + } catch { + triggersState[key] = .failed(error.localizedDescription) + Self.logger.warning( + "triggers load failed db=\(database, privacy: .public) schema=\(schema ?? "nil", privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + } + } + private func fetchRoutineList(_ key: ObjectsKey) async throws -> [RoutineInfo] { let schema = key.schema return try await routinesDedup.execute(key: key) { [self] in @@ -205,9 +235,21 @@ final class DatabaseTreeMetadataService { schema: schema, workload: .bulk ) { driver in - let procedures = try await driver.fetchProcedures(schema: schema) - let functions = try await driver.fetchFunctions(schema: schema) - return procedures + functions + try await driver.fetchRoutines(schema: schema) + } + } + } + + private func fetchTriggerList(_ key: ObjectsKey) async throws -> [TriggerInfo] { + let schema = key.schema + return try await triggersDedup.execute(key: key) { [self] in + try await withDriver( + connectionId: key.connectionId, + database: key.database, + schema: schema, + workload: .bulk + ) { driver in + try await driver.fetchAllTriggers(schema: schema) } } } @@ -286,12 +328,13 @@ final class DatabaseTreeMetadataService { func refreshObjects(connectionId: UUID, database: String, schema: String?) async { async let tables: Void = refreshTableObjects(connectionId: connectionId, database: database, schema: schema) async let routines: Void = refreshRoutineObjects(connectionId: connectionId, database: database, schema: schema) - _ = await (tables, routines) + async let triggers: Void = refreshTriggerObjects(connectionId: connectionId, database: database, schema: schema) + _ = await (tables, routines, triggers) } - /// Tables and routines are two separate fetches behind two separate states, so a row that - /// stands for one kind refreshes only the fetch its kind comes from. Partitions ride with the - /// tables, because a partition row is drawn as a child of the table it belongs to. + /// Tables, routines and triggers are three separate fetches behind three separate states, so a + /// row that stands for one kind refreshes only the fetch its kind comes from. Partitions ride + /// with the tables, because a partition row is drawn as a child of the table it belongs to. func refreshTableObjects(connectionId: UUID, database: String, schema: String?) async { let key = Self.objectsKey(connectionId: connectionId, database: database, schema: schema) await tablesDedup.cancel(key: key) @@ -306,6 +349,12 @@ final class DatabaseTreeMetadataService { await refreshRoutines(key) } + func refreshTriggerObjects(connectionId: UUID, database: String, schema: String?) async { + let key = Self.objectsKey(connectionId: connectionId, database: database, schema: schema) + await triggersDedup.cancel(key: key) + await refreshTriggers(key) + } + private func refreshTables(_ key: ObjectsKey) async { guard case .loaded = tablesState[key] ?? .idle else { tablesState.removeValue(forKey: key) @@ -340,6 +389,23 @@ final class DatabaseTreeMetadataService { } } + private func refreshTriggers(_ key: ObjectsKey) async { + guard case .loaded = triggersState[key] ?? .idle else { + triggersState.removeValue(forKey: key) + await loadTriggers(connectionId: key.connectionId, database: key.database, schema: key.schema) + return + } + guard isConnected(key.connectionId) else { return } + do { + triggersState[key] = .loaded(try await fetchTriggerList(key)) + } catch is CancellationError { + } catch { + Self.logger.warning( + "triggers refresh failed db=\(key.database, privacy: .public) schema=\(key.schema ?? "nil", privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + } + } + private func refreshPartitions(under key: ObjectsKey) async { for partitionKey in partitionKeys(matching: key) { guard case .loaded = partitionsState[partitionKey] ?? .idle else { @@ -425,13 +491,17 @@ final class DatabaseTreeMetadataService { SchemaForeignKeyStore.shared.invalidate(connectionId: connectionId) let schemaKeys = schemaList.keys.filter { $0.connectionId == connectionId } let objectKeys = Self.connectionObjectKeys( - tableKeys: tablesState.keys, routineKeys: routinesState.keys, connectionId: connectionId + tableKeys: tablesState.keys, + routineKeys: routinesState.keys, + triggerKeys: triggersState.keys, + connectionId: connectionId ) await databaseDedup.cancel(key: connectionId) for key in schemaKeys { await schemaDedup.cancel(key: key) } for key in objectKeys { await tablesDedup.cancel(key: key) await routinesDedup.cancel(key: key) + await triggersDedup.cancel(key: key) } for key in connectionPartitionKeys(connectionId) { await partitionsDedup.cancel(key: key) @@ -440,6 +510,7 @@ final class DatabaseTreeMetadataService { schemaList = schemaList.filter { $0.key.connectionId != connectionId } tablesState = tablesState.filter { $0.key.connectionId != connectionId } routinesState = routinesState.filter { $0.key.connectionId != connectionId } + triggersState = triggersState.filter { $0.key.connectionId != connectionId } partitionsState = partitionsState.filter { $0.key.connectionId != connectionId } } @@ -448,7 +519,10 @@ final class DatabaseTreeMetadataService { private func resetPending(connectionId: UUID) async { let schemaKeys = schemaList.keys.filter { $0.connectionId == connectionId } let objectKeys = Self.connectionObjectKeys( - tableKeys: tablesState.keys, routineKeys: routinesState.keys, connectionId: connectionId + tableKeys: tablesState.keys, + routineKeys: routinesState.keys, + triggerKeys: triggersState.keys, + connectionId: connectionId ) if isPending(databaseList[connectionId]) { @@ -460,6 +534,7 @@ final class DatabaseTreeMetadataService { for key in objectKeys { if isPending(tablesState[key]) { await tablesDedup.cancel(key: key) } if isPending(routinesState[key]) { await routinesDedup.cancel(key: key) } + if isPending(triggersState[key]) { await triggersDedup.cancel(key: key) } } let partitionKeys = connectionPartitionKeys(connectionId) for key in partitionKeys where isPending(partitionsState[key]) { @@ -471,6 +546,7 @@ final class DatabaseTreeMetadataService { for key in objectKeys { if isPending(tablesState[key]) { tablesState[key] = .idle } if isPending(routinesState[key]) { routinesState[key] = .idle } + if isPending(triggersState[key]) { triggersState[key] = .idle } } for key in partitionKeys where isPending(partitionsState[key]) { partitionsState[key] = .idle } } @@ -486,6 +562,14 @@ final class DatabaseTreeMetadataService { DatabaseManager.shared.session(for: connectionId)?.status == .connected } + /// The capability gates the QUERY, never the display. An engine with no triggers should not + /// pay a catalog read that can only answer empty, and a driver that returns triggers anyway + /// still gets its section: `SidebarObjectKind.visible` lists any kind that has rows. + private func browsesTriggers(_ connectionId: UUID) -> Bool { + DatabaseManager.shared.session(for: connectionId)? + .connection.type.supportsDatabaseTriggerBrowse ?? false + } + /// Always routes through a scoped driver. Reusing the session driver when the target /// looked like the browsed database used to be safe; it is not now that a tab's /// execution moves that driver without writing session state. @@ -533,8 +617,9 @@ final class DatabaseTreeMetadataService { nonisolated static func connectionObjectKeys( tableKeys: some Sequence, routineKeys: some Sequence, + triggerKeys: some Sequence, connectionId: UUID ) -> [ObjectsKey] { - Array(Set(tableKeys).union(routineKeys)).filter { $0.connectionId == connectionId } + Array(Set(tableKeys).union(routineKeys).union(triggerKeys)).filter { $0.connectionId == connectionId } } } diff --git a/TablePro/Core/Services/Query/MetadataLoadState.swift b/TablePro/Core/Services/Query/MetadataLoadState.swift index bb1716924..1f9c5cdb4 100644 --- a/TablePro/Core/Services/Query/MetadataLoadState.swift +++ b/TablePro/Core/Services/Query/MetadataLoadState.swift @@ -15,6 +15,34 @@ enum MetadataLoadState: Sendable { if case .loaded(let value) = self { return value } return nil } + + /// Drops the payload so states over different value types can be compared side by side, which + /// is what a container row needs when several fetches decide one status row between them. + var erased: MetadataLoadPhase { + switch self { + case .idle: return .idle + case .loading: return .loading + case .loaded: return .loaded + case .failed(let message): return .failed(message) + } + } +} + +enum MetadataLoadPhase: Sendable, Equatable { + case idle + case loading + case loaded + case failed(String) + + var isLoaded: Bool { + if case .loaded = self { return true } + return false + } + + var failureMessage: String? { + if case .failed(let message) = self { return message } + return nil + } } extension MetadataLoadState: Equatable where Value: Equatable {} diff --git a/TablePro/Core/Services/Query/SchemaRefreshService.swift b/TablePro/Core/Services/Query/SchemaRefreshService.swift index 5d535eeb9..3143ad769 100644 --- a/TablePro/Core/Services/Query/SchemaRefreshService.swift +++ b/TablePro/Core/Services/Query/SchemaRefreshService.swift @@ -204,15 +204,18 @@ final class SchemaRefreshService { guard let scope = metadataDriverProvider.browseScope(for: connectionId) else { throw DatabaseError.notConnected } + let browsesTriggers = databaseManager?.session(for: connectionId)? + .connection.type.supportsDatabaseTriggerBrowse ?? false let reloaded = try await metadataDriverProvider.withMetadataDriver( scope: scope, workload: .bulk ) { [schemaService] driver in - /// Both run, and neither short circuits the other: a failed procedure fetch must - /// not skip the function fetch that would still have succeeded. - let procedures = await schemaService.reloadProcedures(connectionId: connectionId, driver: driver) - let functions = await schemaService.reloadFunctions(connectionId: connectionId, driver: driver) - return procedures && functions + /// Both run, and neither short circuits the other: a failed routine fetch must + /// not skip the trigger fetch that would still have succeeded. + let routines = await schemaService.reloadRoutines(connectionId: connectionId, driver: driver) + guard browsesTriggers else { return routines } + let triggers = await schemaService.reloadTriggers(connectionId: connectionId, driver: driver) + return routines && triggers } /// Recording the new scope says the loaded routines belong to it. A reload that failed /// left the previous schema's routines in place, so claiming coverage there would pin diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index 72461ba48..6ee94f6bb 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -13,8 +13,8 @@ final class SchemaService { static let shared = SchemaService() private(set) var states: [UUID: SchemaState] = [:] - private(set) var procedures: [UUID: [RoutineInfo]] = [:] - private(set) var functions: [UUID: [RoutineInfo]] = [:] + private(set) var routines: [UUID: [RoutineInfo]] = [:] + private(set) var triggers: [UUID: [TriggerInfo]] = [:] private(set) var schemasInOrder: [UUID: [String]] = [:] private(set) var perSchemaStates: [UUID: [String: SchemaState]] = [:] private(set) var generations: [UUID: Int] = [:] @@ -30,8 +30,8 @@ final class SchemaService { } @ObservationIgnored private let loadDedup = OnceTask() - @ObservationIgnored private let procedureDedup = OnceTask() - @ObservationIgnored private let functionDedup = OnceTask() + @ObservationIgnored private let routinesDedup = OnceTask() + @ObservationIgnored private let triggersDedup = OnceTask() @ObservationIgnored private let schemasDedup = OnceTask() @ObservationIgnored private let perSchemaDedup = OnceTask() @@ -118,16 +118,20 @@ final class SchemaService { return [] } + func routines(for connectionId: UUID) -> [RoutineInfo] { + routines[connectionId] ?? [] + } + func procedures(for connectionId: UUID) -> [RoutineInfo] { - procedures[connectionId] ?? [] + routines(for: connectionId).filter { $0.kind == .procedure } } func functions(for connectionId: UUID) -> [RoutineInfo] { - functions[connectionId] ?? [] + routines(for: connectionId).filter { $0.kind == .function } } - func routines(for connectionId: UUID) -> [RoutineInfo] { - procedures(for: connectionId) + functions(for: connectionId) + func triggers(for connectionId: UUID) -> [TriggerInfo] { + triggers[connectionId] ?? [] } func schemas(for connectionId: UUID) -> [String] { @@ -235,38 +239,38 @@ final class SchemaService { /// Returns false when the stored list is still the one from before the call, so a caller that /// is about to record what its refresh covered can tell a real reload from a swallowed error. @discardableResult - func reloadProcedures(connectionId: UUID, driver: DatabaseDriver) async -> Bool { + func reloadRoutines(connectionId: UUID, driver: DatabaseDriver) async -> Bool { do { - let routines = try await procedureDedup.execute(key: connectionId) { - try await driver.fetchProcedures(schema: nil) + let loaded = try await routinesDedup.execute(key: connectionId) { + try await driver.fetchRoutines(schema: nil) } - procedures[connectionId] = routines + routines[connectionId] = loaded bumpGeneration(connectionId) return true } catch is CancellationError { return false } catch { Self.logger.warning( - "[schema] procedures reload failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + "[schema] routines reload failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" ) return false } } @discardableResult - func reloadFunctions(connectionId: UUID, driver: DatabaseDriver) async -> Bool { + func reloadTriggers(connectionId: UUID, driver: DatabaseDriver) async -> Bool { do { - let routines = try await functionDedup.execute(key: connectionId) { - try await driver.fetchFunctions(schema: nil) + let loaded = try await triggersDedup.execute(key: connectionId) { + try await driver.fetchAllTriggers(schema: nil) } - functions[connectionId] = routines + triggers[connectionId] = loaded bumpGeneration(connectionId) return true } catch is CancellationError { return false } catch { Self.logger.warning( - "[schema] functions reload failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + "[schema] triggers reload failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" ) return false } @@ -280,8 +284,8 @@ final class SchemaService { private func cancelInFlightLoads(connectionId: UUID) async { await loadDedup.cancel { $0.connectionId == connectionId } - await procedureDedup.cancel(key: connectionId) - await functionDedup.cancel(key: connectionId) + await routinesDedup.cancel(key: connectionId) + await triggersDedup.cancel(key: connectionId) await schemasDedup.cancel(key: connectionId) await perSchemaDedup.cancel { $0.connectionId == connectionId } } @@ -291,8 +295,8 @@ final class SchemaService { loadGenerations.removeValue(forKey: connectionId) refreshingConnections.remove(connectionId) states.removeValue(forKey: connectionId) - procedures.removeValue(forKey: connectionId) - functions.removeValue(forKey: connectionId) + routines.removeValue(forKey: connectionId) + triggers.removeValue(forKey: connectionId) schemasInOrder.removeValue(forKey: connectionId) perSchemaStates.removeValue(forKey: connectionId) generations.removeValue(forKey: connectionId) @@ -353,6 +357,7 @@ final class SchemaService { await runHierarchicalLoad( connectionId: connectionId, driver: driver, + browsesTriggers: connection.type.supportsDatabaseTriggerBrowse, generation: generation, scope: scope ) @@ -364,18 +369,21 @@ final class SchemaService { ) { try await driver.fetchTables() } - async let proceduresTask: [RoutineInfo]? = Self.fetchRoutinesSafely( + async let routinesTask: [RoutineInfo]? = Self.fetchObjectsSafely( connectionId: connectionId, - kind: .procedure, - dedup: procedureDedup, - fetch: { try await driver.fetchProcedures(schema: nil) } - ) - async let functionsTask: [RoutineInfo]? = Self.fetchRoutinesSafely( - connectionId: connectionId, - kind: .function, - dedup: functionDedup, - fetch: { try await driver.fetchFunctions(schema: nil) } + label: "routines", + dedup: routinesDedup, + fetch: { try await driver.fetchRoutines(schema: nil) } ) + let browsesTriggers = connection.type.supportsDatabaseTriggerBrowse + async let triggersTask: [TriggerInfo]? = browsesTriggers + ? Self.fetchObjectsSafely( + connectionId: connectionId, + label: "triggers", + dedup: triggersDedup, + fetch: { try await driver.fetchAllTriggers(schema: nil) } + ) + : nil async let schemasTask: [String]? = supportsSchemas ? Self.fetchSchemasSafely( connectionId: connectionId, @@ -391,24 +399,24 @@ final class SchemaService { } states[connectionId] = .loaded(tables) - let loadedProcedures = await proceduresTask - guard isCurrentLoadGeneration(generation, for: connectionId, phase: "procedures-loaded") else { + let loadedRoutines = await routinesTask + guard isCurrentLoadGeneration(generation, for: connectionId, phase: "routines-loaded") else { return } - if let loadedProcedures { - procedures[connectionId] = loadedProcedures + if let loadedRoutines { + routines[connectionId] = loadedRoutines } else if scopeChanged { - procedures.removeValue(forKey: connectionId) + routines.removeValue(forKey: connectionId) } - let loadedFunctions = await functionsTask - guard isCurrentLoadGeneration(generation, for: connectionId, phase: "functions-loaded") else { + let loadedTriggers = await triggersTask + guard isCurrentLoadGeneration(generation, for: connectionId, phase: "triggers-loaded") else { return } - if let loadedFunctions { - functions[connectionId] = loadedFunctions + if let loadedTriggers { + triggers[connectionId] = loadedTriggers } else if scopeChanged { - functions.removeValue(forKey: connectionId) + triggers.removeValue(forKey: connectionId) } if let loadedSchemas = await schemasTask { @@ -440,25 +448,28 @@ final class SchemaService { private func runHierarchicalLoad( connectionId: UUID, driver: DatabaseDriver, + browsesTriggers: Bool, generation: Int, scope: DatabaseScope? ) async { let scopeChanged = scope != nil && loadedScopes[connectionId] != scope - async let proceduresTask: [RoutineInfo]? = Self.fetchRoutinesSafely( - connectionId: connectionId, - kind: .procedure, - dedup: procedureDedup, - fetch: { try await driver.fetchProcedures(schema: nil) } - ) - async let functionsTask: [RoutineInfo]? = Self.fetchRoutinesSafely( + async let routinesTask: [RoutineInfo]? = Self.fetchObjectsSafely( connectionId: connectionId, - kind: .function, - dedup: functionDedup, - fetch: { try await driver.fetchFunctions(schema: nil) } + label: "routines", + dedup: routinesDedup, + fetch: { try await driver.fetchRoutines(schema: nil) } ) + async let triggersTask: [TriggerInfo]? = browsesTriggers + ? Self.fetchObjectsSafely( + connectionId: connectionId, + label: "triggers", + dedup: triggersDedup, + fetch: { try await driver.fetchAllTriggers(schema: nil) } + ) + : nil - let loadedProcedures = await proceduresTask - let loadedFunctions = await functionsTask + let loadedRoutines = await routinesTask + let loadedTriggers = await triggersTask let loadedSchemas: [String] do { @@ -482,15 +493,15 @@ final class SchemaService { return } schemasInOrder[connectionId] = loadedSchemas - if let loadedProcedures { - procedures[connectionId] = loadedProcedures + if let loadedRoutines { + routines[connectionId] = loadedRoutines } else if scopeChanged { - procedures.removeValue(forKey: connectionId) + routines.removeValue(forKey: connectionId) } - if let loadedFunctions { - functions[connectionId] = loadedFunctions + if let loadedTriggers { + triggers[connectionId] = loadedTriggers } else if scopeChanged { - functions.removeValue(forKey: connectionId) + triggers.removeValue(forKey: connectionId) } states[connectionId] = .loaded([]) if let scope { @@ -575,19 +586,19 @@ final class SchemaService { /// database with no routines, and the caller committed it over the loaded one: a single /// dropped connection emptied the sidebar's procedures and functions while the refresh /// reported success, with nothing scheduled to put them back. - private static func fetchRoutinesSafely( + private static func fetchObjectsSafely( connectionId: UUID, - kind: RoutineInfo.Kind, - dedup: OnceTask, - fetch: @Sendable @escaping () async throws -> [RoutineInfo] - ) async -> [RoutineInfo]? { + label: String, + dedup: OnceTask, + fetch: @Sendable @escaping () async throws -> [Value] + ) async -> [Value]? { do { return try await dedup.execute(key: connectionId, work: fetch) } catch is CancellationError { return nil } catch { logger.warning( - "[schema] \(kind.rawValue, privacy: .public) load failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + "[schema] \(label, privacy: .public) load failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)" ) return nil } diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift index f59d34de5..717b60ccc 100644 --- a/TablePro/Models/Connection/DatabaseConnection.swift +++ b/TablePro/Models/Connection/DatabaseConnection.swift @@ -206,6 +206,29 @@ extension DatabaseType { PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsTriggerEditing ?? false } + var supportsRoutines: Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)?.capabilities.supportsRoutines ?? false + } + + var supportsDatabaseTriggerBrowse: Bool { + PluginMetadataRegistry.shared.snapshot(forTypeId: pluginTypeId)? + .capabilities.supportsDatabaseTriggerBrowse ?? false + } + + /// The object kinds the sidebar should offer a section for even before any have been fetched. + /// It never subtracts: a kind whose driver returned rows is listed whatever this says. + var declaredObjectKinds: Set { + var kinds: Set = [] + if supportsRoutines { + kinds.insert(.procedure) + kinds.insert(.function) + } + if supportsDatabaseTriggerBrowse { + kinds.insert(.trigger) + } + return kinds + } + var supportsSchemaEditing: Bool { PluginMetadataRegistry.shared.snapshot(forTypeId: rawValue)?.supportsSchemaEditing ?? true } diff --git a/TablePro/Models/Query/DatabaseObjectRef.swift b/TablePro/Models/Query/DatabaseObjectRef.swift new file mode 100644 index 000000000..60a984628 --- /dev/null +++ b/TablePro/Models/Query/DatabaseObjectRef.swift @@ -0,0 +1,156 @@ +// +// DatabaseObjectRef.swift +// TablePro +// +// Everything needed to find one routine or trigger again and read its source. +// + +import Foundation + +enum DatabaseObjectKind: String, Codable, Sendable, Hashable { + case procedure + case function + case trigger + + var sidebarObjectKind: SidebarObjectKind { + switch self { + case .procedure: return .procedure + case .function: return .function + case .trigger: return .trigger + } + } + + var displayName: String { + sidebarObjectKind.displayName + } + + var iconName: String { + sidebarObjectKind.iconName + } +} + +/// This survives a relaunch, so it carries addressing only and never the source text. A restored +/// viewer refetches, which is also what makes it show the current definition rather than the one +/// that was on screen when the app quit. +struct DatabaseObjectRef: Hashable, Codable, Sendable { + let kind: DatabaseObjectKind + let name: String + let database: String + let schema: String? + + /// The owning table. Triggers only. + let table: String? + + /// The driver's own key for this routine, opaque here. Routines only. + let identity: String? + let argumentSignature: String? + + /// What the sidebar's listing already learned about the object. Carried so the viewer does not + /// re-list an entire schema to recover it, and short enough to persist with the tab. + let attributes: [ObjectAttribute] + + init( + kind: DatabaseObjectKind, + name: String, + database: String, + schema: String? = nil, + table: String? = nil, + identity: String? = nil, + argumentSignature: String? = nil, + attributes: [ObjectAttribute] = [] + ) { + self.kind = kind + self.name = name + self.database = database + self.schema = schema + self.table = table + self.identity = identity + self.argumentSignature = argumentSignature + self.attributes = attributes + } + + init(routine: RoutineInfo, database: String) { + self.init( + kind: routine.kind == .procedure ? .procedure : .function, + name: routine.name, + database: database, + schema: routine.schema, + identity: routine.identity, + argumentSignature: routine.argumentSignature, + attributes: routine.attributes + ) + } + + init(trigger: TriggerInfo, database: String) { + self.init( + kind: .trigger, + name: trigger.name, + database: database, + schema: trigger.schema, + table: trigger.table, + attributes: trigger.attributes + ) + } + + /// What the tab is titled and what the viewer's header shows: enough to tell two overloads + /// apart, and enough to tell two same-named triggers on different tables apart. + var displayIdentity: String { + switch kind { + case .procedure, .function: + guard let argumentSignature, !argumentSignature.isEmpty else { return qualifiedName } + return "\(qualifiedName)\(argumentSignature)" + case .trigger: + guard let table, !table.isEmpty else { return qualifiedName } + return String(format: String(localized: "%1$@ on %2$@"), qualifiedName, table) + } + } + + var qualifiedName: String { + guard let schema, !schema.isEmpty else { return name } + return "\(schema).\(name)" + } + + /// A ref built where no database was selected carries an empty one, and an empty database is + /// server-scoped. Resolving it once keeps the tab-dedup key and the loader's scope agreeing. + func resolvingDatabase(_ fallback: String) -> DatabaseObjectRef { + guard database.isEmpty, !fallback.isEmpty else { return self } + return DatabaseObjectRef( + kind: kind, + name: name, + database: fallback, + schema: schema, + table: table, + identity: identity, + argumentSignature: argumentSignature, + attributes: attributes + ) + } + + var routine: RoutineInfo? { + switch kind { + case .procedure, .function: + return RoutineInfo( + name: name, + kind: kind == .procedure ? .procedure : .function, + schema: schema, + argumentSignature: argumentSignature, + identity: identity + ) + case .trigger: + return nil + } + } + + var trigger: TriggerInfo? { + guard kind == .trigger else { return nil } + return TriggerInfo(name: name, timing: "", event: "", statement: "", table: table, schema: schema) + } + + /// A file name for Export, safe on every filesystem the save panel can reach. + var suggestedFileName: String { + let base = [schema, table, name].compactMap { $0?.isEmpty == false ? $0 : nil }.joined(separator: "_") + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_-.")) + let cleaned = String(base.unicodeScalars.map { allowed.contains($0) ? Character($0) : "_" }) + return "\(cleaned.isEmpty ? "object" : cleaned).sql" + } +} diff --git a/TablePro/Models/Query/EditorTabPayload.swift b/TablePro/Models/Query/EditorTabPayload.swift index a17a9dfe8..d2735d792 100644 --- a/TablePro/Models/Query/EditorTabPayload.swift +++ b/TablePro/Models/Query/EditorTabPayload.swift @@ -52,6 +52,8 @@ internal struct EditorTabPayload: Codable, Hashable { internal let sourceFileURL: URL? /// Schema key for ER diagram tabs internal let erDiagramSchemaKey: String? + /// The routine or trigger a .objectSource tab shows + internal let objectRef: DatabaseObjectRef? /// Tab title (for restoring persisted tabs with their original names) internal let tabTitle: String? /// The intent behind creating this tab @@ -62,7 +64,7 @@ internal struct EditorTabPayload: Codable, Hashable { case initialQuery, isView, showStructure, skipAutoExecute, isPreview case forcesNewTab case tabTitle - case initialFilterState, sourceFileURL, erDiagramSchemaKey, intent + case initialFilterState, sourceFileURL, erDiagramSchemaKey, objectRef, intent // Legacy key for backward decoding only case isNewTab } @@ -83,6 +85,7 @@ internal struct EditorTabPayload: Codable, Hashable { initialFilterState: TabFilterState? = nil, sourceFileURL: URL? = nil, erDiagramSchemaKey: String? = nil, + objectRef: DatabaseObjectRef? = nil, tabTitle: String? = nil, intent: TabIntent = .openContent ) { @@ -101,6 +104,7 @@ internal struct EditorTabPayload: Codable, Hashable { self.initialFilterState = initialFilterState self.sourceFileURL = sourceFileURL self.erDiagramSchemaKey = erDiagramSchemaKey + self.objectRef = objectRef self.tabTitle = tabTitle self.intent = intent } @@ -122,6 +126,7 @@ internal struct EditorTabPayload: Codable, Hashable { initialFilterState = try container.decodeIfPresent(TabFilterState.self, forKey: .initialFilterState) sourceFileURL = try container.decodeIfPresent(URL.self, forKey: .sourceFileURL) erDiagramSchemaKey = try container.decodeIfPresent(String.self, forKey: .erDiagramSchemaKey) + objectRef = try container.decodeIfPresent(DatabaseObjectRef.self, forKey: .objectRef) tabTitle = try container.decodeIfPresent(String.self, forKey: .tabTitle) if let decodedIntent = try container.decodeIfPresent(TabIntent.self, forKey: .intent) { intent = decodedIntent @@ -148,6 +153,7 @@ internal struct EditorTabPayload: Codable, Hashable { try container.encodeIfPresent(initialFilterState, forKey: .initialFilterState) try container.encodeIfPresent(sourceFileURL, forKey: .sourceFileURL) try container.encodeIfPresent(erDiagramSchemaKey, forKey: .erDiagramSchemaKey) + try container.encodeIfPresent(objectRef, forKey: .objectRef) try container.encodeIfPresent(tabTitle, forKey: .tabTitle) try container.encode(intent, forKey: .intent) } @@ -169,6 +175,7 @@ internal struct EditorTabPayload: Codable, Hashable { self.initialFilterState = nil self.sourceFileURL = tab.content.sourceFileURL self.erDiagramSchemaKey = tab.display.erDiagramSchemaKey + self.objectRef = tab.display.objectRef self.tabTitle = tab.title self.intent = .openContent } diff --git a/TablePro/Models/Query/ObjectAttribute.swift b/TablePro/Models/Query/ObjectAttribute.swift new file mode 100644 index 000000000..5e7066eb5 --- /dev/null +++ b/TablePro/Models/Query/ObjectAttribute.swift @@ -0,0 +1,19 @@ +// +// ObjectAttribute.swift +// TablePro +// + +import Foundation + +/// One labelled property of a database object, in the order the driver listed it. The app renders +/// these and never interprets them, so per-engine vocabulary stays in the driver that speaks it. +struct ObjectAttribute: Identifiable, Hashable, Codable, Sendable { + var id: String { label } + let label: String + let value: String + + init(label: String, value: String) { + self.label = label + self.value = value + } +} diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index f5890ced6..fe52e750b 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -237,25 +237,53 @@ struct ForeignKeyInfo: Identifiable, Hashable { } struct TriggerInfo: Identifiable, Hashable { - var id: String { name } let name: String let timing: String let event: String let statement: String let enabled: Bool? + /// A trigger name is unique per table on PostgreSQL and Oracle, not per schema, so a + /// database-wide list keyed on the name alone loses one of any two tables that agree on it. + let table: String? + let schema: String? + let orientation: String? + + /// The runnable CREATE TRIGGER text. `statement` is only the action body. + let definition: String? + let attributes: [ObjectAttribute] + + var id: String { + [schema, table, name].compactMap { $0?.isEmpty == false ? $0 : nil }.joined(separator: ".") + } + init( name: String, timing: String, event: String, statement: String, - enabled: Bool? = nil + enabled: Bool? = nil, + table: String? = nil, + schema: String? = nil, + orientation: String? = nil, + definition: String? = nil, + attributes: [ObjectAttribute] = [] ) { self.name = name self.timing = timing self.event = event self.statement = statement self.enabled = enabled + self.table = table + self.schema = schema + self.orientation = orientation + self.definition = definition + self.attributes = attributes + } + + var qualifiedName: String { + guard let table, !table.isEmpty else { return name } + return "\(table).\(name)" } } diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index 93dac9de1..8ad77375a 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -183,7 +183,10 @@ struct QueryTab: Identifiable, Equatable { isEditable: persisted.tabType == .table && !persisted.isView, isView: persisted.isView ) - self.display = TabDisplayState(erDiagramSchemaKey: persisted.erDiagramSchemaKey) + self.display = TabDisplayState( + erDiagramSchemaKey: persisted.erDiagramSchemaKey, + objectRef: persisted.objectRef + ) self.pendingChanges = TabChangeSnapshot() self.selectedRowIndices = [] self.sortState = SortState() @@ -336,6 +339,7 @@ struct QueryTab: Identifiable, Equatable { schemaName: tableContext.schemaName, sourceFileURL: content.sourceFileURL, erDiagramSchemaKey: display.erDiagramSchemaKey, + objectRef: display.objectRef, queryParameters: content.queryParameters.isEmpty ? nil : content.queryParameters, sortColumns: persistedSort, restoredPage: restoredPage, diff --git a/TablePro/Models/Query/QueryTabManager.swift b/TablePro/Models/Query/QueryTabManager.swift index b51fd42fb..588bb72ab 100644 --- a/TablePro/Models/Query/QueryTabManager.swift +++ b/TablePro/Models/Query/QueryTabManager.swift @@ -375,6 +375,33 @@ final class QueryTabManager { selectedTabId = newTab.id } + /// One tab per object, so opening the same routine twice returns to the tab already showing + /// it, the way opening the same table does. + func addObjectSourceTab(objectRef: DatabaseObjectRef) { + if let existing = tabs.first(where: { $0.tabType == .objectSource && $0.display.objectRef == objectRef }) { + selectedTabId = existing.id + return + } + var newTab = QueryTab(title: Self.objectSourceTitle(for: objectRef), tabType: .objectSource) + newTab.tableContext.isEditable = false + newTab.tableContext.databaseName = objectRef.database + newTab.tableContext.schemaName = objectRef.schema + newTab.display.objectRef = objectRef + newTab.hasUserInteraction = true + tabs.append(newTab) + selectedTabId = newTab.id + } + + static func objectSourceTitle(for objectRef: DatabaseObjectRef) -> String { + let format: String + switch objectRef.kind { + case .procedure: format = String(localized: "Procedure: %@") + case .function: format = String(localized: "Function: %@") + case .trigger: format = String(localized: "Trigger: %@") + } + return String(format: format, objectRef.displayIdentity) + } + func addUsersRolesTab() { if let existing = tabs.first(where: { $0.tabType == .usersRoles }) { selectedTabId = existing.id diff --git a/TablePro/Models/Query/QueryTabState.swift b/TablePro/Models/Query/QueryTabState.swift index acd7c5576..d9f2f82d6 100644 --- a/TablePro/Models/Query/QueryTabState.swift +++ b/TablePro/Models/Query/QueryTabState.swift @@ -20,6 +20,7 @@ enum TabType: Equatable, Codable, Hashable { case serverDashboard case usersRoles case insights + case objectSource } /// Minimal representation of a tab for persistence @@ -34,6 +35,7 @@ struct PersistedTab: Codable { var schemaName: String? var sourceFileURL: URL? var erDiagramSchemaKey: String? + var objectRef: DatabaseObjectRef? var queryParameters: [QueryParameter]? var sortColumns: [PersistedSortColumn]? var restoredPage: Int? @@ -59,6 +61,7 @@ struct PersistedTab: Codable { schemaName: String? = nil, sourceFileURL: URL? = nil, erDiagramSchemaKey: String? = nil, + objectRef: DatabaseObjectRef? = nil, queryParameters: [QueryParameter]? = nil, sortColumns: [PersistedSortColumn]? = nil, restoredPage: Int? = nil, @@ -80,6 +83,7 @@ struct PersistedTab: Codable { self.schemaName = schemaName self.sourceFileURL = sourceFileURL self.erDiagramSchemaKey = erDiagramSchemaKey + self.objectRef = objectRef self.queryParameters = queryParameters self.sortColumns = sortColumns self.restoredPage = restoredPage @@ -94,7 +98,7 @@ struct PersistedTab: Codable { private enum CodingKeys: String, CodingKey { case id, title, query, tabType, tableName, isView, databaseName, schemaName - case sourceFileURL, erDiagramSchemaKey, queryParameters + case sourceFileURL, erDiagramSchemaKey, objectRef, queryParameters case sortColumns, restoredPage, restoredPageSize, cursorOffset, cursorLength, collapsedFoldRanges case columnWidths, columnContentWidths, windowGroupIndex case overflowFileName @@ -112,6 +116,7 @@ struct PersistedTab: Codable { schemaName = try container.decodeIfPresent(String.self, forKey: .schemaName) sourceFileURL = try container.decodeIfPresent(URL.self, forKey: .sourceFileURL) erDiagramSchemaKey = try container.decodeIfPresent(String.self, forKey: .erDiagramSchemaKey) + objectRef = try container.decodeIfPresent(DatabaseObjectRef.self, forKey: .objectRef) queryParameters = try container.decodeIfPresent([QueryParameter].self, forKey: .queryParameters) sortColumns = try container.decodeIfPresent([PersistedSortColumn].self, forKey: .sortColumns) restoredPage = try container.decodeIfPresent(Int.self, forKey: .restoredPage) @@ -612,6 +617,7 @@ struct TabQueryContent: Equatable { struct TabDisplayState: Equatable { var resultsViewMode: ResultsViewMode = .data var erDiagramSchemaKey: String? + var objectRef: DatabaseObjectRef? var isResultsCollapsed: Bool = false var resultSets: [ResultSet] = [] var activeResultSetId: UUID? diff --git a/TablePro/Models/Query/RoutineInfo.swift b/TablePro/Models/Query/RoutineInfo.swift index aaeb878f5..a981c2760 100644 --- a/TablePro/Models/Query/RoutineInfo.swift +++ b/TablePro/Models/Query/RoutineInfo.swift @@ -1,18 +1,25 @@ import Foundation struct RoutineInfo: Identifiable, Hashable, Sendable { - var id: String { - guard let signature, !signature.isEmpty else { - return "\(kind.rawValue)_\(qualifiedName)" - } - return "\(kind.rawValue)_\(qualifiedName)_\(signature)" - } let name: String let schema: String? let kind: Kind - let signature: String? - enum Kind: String, Sendable { + /// The parameter list as the engine spells it, parentheses included: `(date)`. This is what + /// separates two overloads of one name, and it is never the return type. + let argumentSignature: String? + let returnType: String? + let language: String? + + /// The driver's own key for re-addressing this routine when asked for its source. Opaque here. + let identity: String? + + /// The source, when the listing already returned it. Never part of `id`: a definition that + /// changes must not change which routine this is, or a reload stops matching its own object. + let definition: String? + let attributes: [ObjectAttribute] + + enum Kind: String, Sendable, CaseIterable { case procedure = "PROCEDURE" case function = "FUNCTION" @@ -24,6 +31,28 @@ struct RoutineInfo: Identifiable, Hashable, Sendable { } } + init( + name: String, + kind: Kind, + schema: String? = nil, + argumentSignature: String? = nil, + returnType: String? = nil, + language: String? = nil, + identity: String? = nil, + definition: String? = nil, + attributes: [ObjectAttribute] = [] + ) { + self.name = name + self.kind = kind + self.schema = schema + self.argumentSignature = argumentSignature + self.returnType = returnType + self.language = language + self.identity = identity + self.definition = definition + self.attributes = attributes + } + var qualifiedName: String { if let schema, !schema.isEmpty { return "\(schema).\(name)" @@ -31,13 +60,29 @@ struct RoutineInfo: Identifiable, Hashable, Sendable { return name } + /// The engine's own key wins, because it is the only value guaranteed to separate two routines + /// the engine considers distinct. The signature is the readable fallback. + var discriminator: String? { + if let identity, !identity.isEmpty { return identity } + guard let argumentSignature, !argumentSignature.isEmpty else { return nil } + return argumentSignature + } + + var id: String { + guard let discriminator else { + return "\(kind.rawValue)_\(qualifiedName)" + } + return "\(kind.rawValue)_\(qualifiedName)_\(discriminator)" + } + + /// Equality follows `id` alone so a Set, a Dictionary and an outline view can never disagree + /// about how many routines there are. Excluding the discriminator collapsed two overloads into + /// one entry, which is how one of a pair of PostgreSQL overloads became unreachable. static func == (lhs: RoutineInfo, rhs: RoutineInfo) -> Bool { - lhs.kind == rhs.kind && lhs.schema == rhs.schema && lhs.name == rhs.name + lhs.id == rhs.id } func hash(into hasher: inout Hasher) { - hasher.combine(kind) - hasher.combine(schema) - hasher.combine(name) + hasher.combine(id) } } diff --git a/TablePro/Models/Sidebar/RoutineDisplayLabel.swift b/TablePro/Models/Sidebar/RoutineDisplayLabel.swift new file mode 100644 index 000000000..016692e61 --- /dev/null +++ b/TablePro/Models/Sidebar/RoutineDisplayLabel.swift @@ -0,0 +1,41 @@ +// +// RoutineDisplayLabel.swift +// TablePro +// + +import Foundation + +/// A routine row shows its bare name, unless the name repeats inside its own section. Then every +/// row that shares that name shows its argument list too, because two PostgreSQL overloads drawn +/// as two identical rows give the reader no way to tell which one they are about to open. +enum RoutineDisplayLabel { + static func labels(for routines: [RoutineInfo]) -> [RoutineInfo.ID: String] { + var countsByName: [String: Int] = [:] + for routine in routines { + countsByName[routine.name, default: 0] += 1 + } + var labels: [RoutineInfo.ID: String] = [:] + for routine in routines { + labels[routine.id] = label(for: routine, isAmbiguous: countsByName[routine.name, default: 0] > 1) + } + return labels + } + + static func label(for routine: RoutineInfo, isAmbiguous: Bool) -> String { + guard isAmbiguous, + let signature = routine.argumentSignature, + !signature.isEmpty else { + return routine.name + } + return "\(routine.name)\(signature)" + } + + /// What Copy with Signature writes. Always qualified, always parenthesised when the engine + /// gave a parameter list, because the pasteboard has no section to disambiguate it. + static func copyableSignature(for routine: RoutineInfo) -> String { + guard let signature = routine.argumentSignature, !signature.isEmpty else { + return routine.qualifiedName + } + return "\(routine.qualifiedName)\(signature)" + } +} diff --git a/TablePro/Models/Sidebar/SidebarObjectKind.swift b/TablePro/Models/Sidebar/SidebarObjectKind.swift index 228ad85d8..1218fec37 100644 --- a/TablePro/Models/Sidebar/SidebarObjectKind.swift +++ b/TablePro/Models/Sidebar/SidebarObjectKind.swift @@ -6,6 +6,14 @@ struct DatabaseTreeObjectGroup: Hashable, Sendable { let kind: SidebarObjectKind } +/// Which model a kind's rows are drawn from. Every helper that used to ask `isRoutine` and pick +/// one of two buckets asks this instead, so a kind added later cannot silently read the wrong one. +enum SidebarObjectCategory: Sendable, Hashable { + case table + case routine + case trigger +} + enum SidebarObjectKind: String, CaseIterable, Sendable, Hashable { case table case view @@ -13,6 +21,7 @@ enum SidebarObjectKind: String, CaseIterable, Sendable, Hashable { case foreignTable case procedure case function + case trigger var displayName: String { switch self { @@ -22,6 +31,7 @@ enum SidebarObjectKind: String, CaseIterable, Sendable, Hashable { case .foreignTable: return String(localized: "Foreign Table") case .procedure: return String(localized: "Procedure") case .function: return String(localized: "Function") + case .trigger: return String(localized: "Trigger") } } @@ -33,6 +43,19 @@ enum SidebarObjectKind: String, CaseIterable, Sendable, Hashable { case .foreignTable: return String(localized: "Foreign Tables") case .procedure: return String(localized: "Procedures") case .function: return String(localized: "Functions") + case .trigger: return String(localized: "Triggers") + } + } + + var emptyDescription: String { + switch self { + case .table: return String(localized: "No tables") + case .view: return String(localized: "No views") + case .materializedView: return String(localized: "No materialized views") + case .foreignTable: return String(localized: "No foreign tables") + case .procedure: return String(localized: "No procedures") + case .function: return String(localized: "No functions") + case .trigger: return String(localized: "No triggers") } } @@ -52,11 +75,16 @@ enum SidebarObjectKind: String, CaseIterable, Sendable, Hashable { case .foreignTable: return "link" case .procedure: return "curlybraces.square" case .function: return "function" + case .trigger: return "bolt" } } - var isRoutine: Bool { - self == .procedure || self == .function + var category: SidebarObjectCategory { + switch self { + case .table, .view, .materializedView, .foreignTable: return .table + case .procedure, .function: return .routine + case .trigger: return .trigger + } } static func resolve(tableType: TableInfo.TableType) -> SidebarObjectKind { @@ -72,20 +100,26 @@ enum SidebarObjectKind: String, CaseIterable, Sendable, Hashable { self == .table } - /// Which kinds a container lists, in declaration order. The count is the whole rule: a plugin's - /// capability flag says what it declared, not what its driver returned, so gating on one hides - /// objects that came back with no section, no status row and no error. + /// Which kinds a container lists, in declaration order. A kind that returned objects is always + /// listed: a plugin's capability flag says what it declared, not what its driver returned, so + /// gating on one hides objects that came back with no section, no status row and no error. + /// + /// `declaredKinds` only ever adds. It lets an engine that has procedures but currently holds + /// none say so with an empty section, instead of being indistinguishable from an engine whose + /// driver never implemented the fetch. /// /// `includingEmptyTables` is the only thing the two sidebar layouts disagree on. The flat root's /// sections are chrome that exists before their contents do, so it keeps Tables whatever the /// count. A tree container answers for itself with its own status row instead. static func visible( itemCounts: [SidebarObjectKind: Int], + declaredKinds: Set = [], includingEmptyTables: Bool ) -> [SidebarObjectKind] { allCases.filter { kind in if includingEmptyTables, kind == .table { return true } - return itemCounts[kind, default: 0] > 0 + if itemCounts[kind, default: 0] > 0 { return true } + return declaredKinds.contains(kind) } } } diff --git a/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift b/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift index feed66d0d..1ee5366c0 100644 --- a/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift +++ b/TablePro/Models/Sidebar/SidebarObjectListPresentation.swift @@ -22,7 +22,8 @@ internal enum SidebarObjectListPresentation: Equatable { state: SchemaState, hasActiveFilter: Bool, hasAnyMatch: Bool, - hasRoutines: Bool + hasRoutines: Bool, + hasTriggers: Bool ) -> SidebarObjectListPresentation { switch state { case .idle, .loading: @@ -33,7 +34,7 @@ internal enum SidebarObjectListPresentation: Equatable { if hasActiveFilter, !hasAnyMatch { return .noMatch } - if tables.isEmpty, !hasRoutines { + if tables.isEmpty, !hasRoutines, !hasTriggers { return .empty } return .list diff --git a/TablePro/Models/UI/GridSelectionOwner.swift b/TablePro/Models/UI/GridSelectionOwner.swift index 6c85be3d4..03d768b1e 100644 --- a/TablePro/Models/UI/GridSelectionOwner.swift +++ b/TablePro/Models/UI/GridSelectionOwner.swift @@ -22,7 +22,7 @@ internal enum GridSelectionOwner: Equatable { switch tabType { case .table, .query: return .dataGrid - case .createTable, .erDiagram, .serverDashboard, .usersRoles, .insights: + case .createTable, .erDiagram, .serverDashboard, .usersRoles, .insights, .objectSource: return .none } } diff --git a/TablePro/Models/UI/QuickSwitcherItem.swift b/TablePro/Models/UI/QuickSwitcherItem.swift index 19d9fe348..1d371da4c 100644 --- a/TablePro/Models/UI/QuickSwitcherItem.swift +++ b/TablePro/Models/UI/QuickSwitcherItem.swift @@ -15,6 +15,9 @@ internal enum QuickSwitcherItemKind: String, Hashable, Sendable { case systemTable case database case schema + case procedure + case function + case trigger case savedQuery case queryHistory } @@ -107,6 +110,8 @@ internal struct QuickSwitcherItem: Identifiable, Hashable, Sendable { /// with no schema at all: the tab-reuse check compares schemas, so "Switch to Tab" opened a /// duplicate of a table that was already open under an explicit schema. var schemaName: String? + /// Set on a routine or trigger row, which opens its source rather than a table tab. + var objectRef: DatabaseObjectRef? var target: QuickSwitcherTarget? /// The frecency identity of a table, produced identically by the two places that record one: @@ -133,6 +138,9 @@ internal struct QuickSwitcherItem: Identifiable, Hashable, Sendable { case .systemTable: return "gearshape" case .database: return "cylinder" case .schema: return "folder" + case .procedure: return "curlybraces.square" + case .function: return "function" + case .trigger: return "bolt" case .savedQuery: return "star" case .queryHistory: return "clock.arrow.circlepath" } @@ -146,6 +154,9 @@ internal struct QuickSwitcherItem: Identifiable, Hashable, Sendable { case .systemTable: return String(localized: "System Table") case .database: return String(localized: "Database") case .schema: return String(localized: "Schema") + case .procedure: return String(localized: "Procedure") + case .function: return String(localized: "Function") + case .trigger: return String(localized: "Trigger") case .savedQuery: return String(localized: "Saved Query") case .queryHistory: return String(localized: "History") } diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index fd1a2de7b..9130fac29 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -282,6 +282,9 @@ internal final class QuickSwitcherViewModel { } } + items += routineItems(connectionId: connectionId, database: activeDatabase) + items += triggerItems(connectionId: connectionId, database: activeDatabase) + let favorites = await services.sqlFavoriteManager.fetchFavorites(connectionId: connectionId) for favorite in favorites { items.append(QuickSwitcherItem( @@ -942,6 +945,37 @@ internal final class QuickSwitcherViewModel { ) } + /// Reads the sidebar's own cache rather than querying. The switcher opens over a connection + /// whose objects the tree has already loaded, and a fresh catalog read per keystroke session + /// would make opening the panel wait on the server. + private func routineItems(connectionId: UUID, database: String?) -> [QuickSwitcherItem] { + let routines = SchemaService.shared.routines(for: connectionId) + let labels = RoutineDisplayLabel.labels(for: routines) + return routines.map { routine in + QuickSwitcherItem( + id: "routine_\(routine.id)", + name: labels[routine.id] ?? routine.name, + kind: routine.kind == .procedure ? .procedure : .function, + subtitle: routine.schema ?? database ?? "", + schemaName: routine.schema, + objectRef: DatabaseObjectRef(routine: routine, database: database ?? "") + ) + } + } + + private func triggerItems(connectionId: UUID, database: String?) -> [QuickSwitcherItem] { + SchemaService.shared.triggers(for: connectionId).map { trigger in + QuickSwitcherItem( + id: "trigger_\(trigger.id)", + name: trigger.name, + kind: .trigger, + subtitle: trigger.table ?? trigger.schema ?? database ?? "", + schemaName: trigger.schema, + objectRef: DatabaseObjectRef(trigger: trigger, database: database ?? "") + ) + } + } + nonisolated static func databaseDisplayName( _ databaseName: String?, pathFieldRole: PathFieldRole @@ -954,7 +988,8 @@ internal final class QuickSwitcherViewModel { private extension QuickSwitcherItemKind { static let displayOrder: [QuickSwitcherItemKind] = [ - .table, .view, .systemTable, .database, .schema, .savedQuery, .queryHistory + .table, .view, .systemTable, .database, .schema, + .procedure, .function, .trigger, .savedQuery, .queryHistory ] var rankWeight: Double { @@ -964,6 +999,9 @@ private extension QuickSwitcherItemKind { case .systemTable: return 0.85 case .database: return 0.95 case .schema: return 0.93 + case .procedure: return 0.92 + case .function: return 0.92 + case .trigger: return 0.91 case .savedQuery: return 0.9 case .queryHistory: return 0.7 } @@ -976,6 +1014,9 @@ private extension QuickSwitcherItemKind { case .systemTable: return String(localized: "System Tables") case .database: return String(localized: "Databases") case .schema: return String(localized: "Schemas") + case .procedure: return String(localized: "Procedures") + case .function: return String(localized: "Functions") + case .trigger: return String(localized: "Triggers") case .savedQuery: return String(localized: "Saved Queries") case .queryHistory: return String(localized: "Recent Queries") } diff --git a/TablePro/ViewModels/SidebarViewModel.swift b/TablePro/ViewModels/SidebarViewModel.swift index 28a390308..bffc2a15b 100644 --- a/TablePro/ViewModels/SidebarViewModel.swift +++ b/TablePro/ViewModels/SidebarViewModel.swift @@ -359,13 +359,15 @@ final class SidebarViewModel { @ObservationIgnored private var cachedFilteredRoutines: [SidebarObjectKind: [RoutineInfo]] = [:] @ObservationIgnored private var cachedFilteredRoutinesFingerprint: (count: Int, generation: Int, query: String)? + @ObservationIgnored private var cachedFilteredTriggers: [TriggerInfo] = [] + @ObservationIgnored private var cachedFilteredTriggersFingerprint: (count: Int, generation: Int, query: String)? private var schemaGeneration: Int { SchemaService.shared.generationToken(for: connectionId) } func tables(of kind: SidebarObjectKind, from tables: [TableInfo]) -> [TableInfo] { - guard !kind.isRoutine else { return [] } + guard kind.category == .table else { return [] } let fingerprint = (count: tables.count, generation: schemaGeneration) if cachedKindFingerprint?.count != fingerprint.count || cachedKindFingerprint?.generation != fingerprint.generation { @@ -415,6 +417,18 @@ final class SidebarViewModel { return cachedFilteredRoutines[kind] ?? [] } + func filteredTriggers(from triggers: [TriggerInfo]) -> [TriggerInfo] { + let query = filterQuery + let fingerprint = (count: triggers.count, generation: schemaGeneration, query: query) + if cachedFilteredTriggersFingerprint?.count != fingerprint.count + || cachedFilteredTriggersFingerprint?.generation != fingerprint.generation + || cachedFilteredTriggersFingerprint?.query != fingerprint.query { + cachedFilteredTriggers = DatabaseTreeFilter.filteredTriggers(triggers, searchText: query) + cachedFilteredTriggersFingerprint = fingerprint + } + return cachedFilteredTriggers + } + func effectiveExpanded(kind: SidebarObjectKind, hasMatches: Bool) -> Bool { if !filterQuery.isEmpty && hasMatches { return true } return expanded[kind] @@ -424,8 +438,12 @@ final class SidebarViewModel { SidebarNameFilter.ranked(tables, query: query, name: { $0.name }) } + /// Goes through DatabaseTreeFilter so the flat root and the tree share one dedup owner. The + /// flat root used to rank without deduplicating, so a driver that returned one routine twice + /// handed NSOutlineView the same node object at several row indices and selection snapped back + /// to the first of them. private func applyRoutineQuery(_ query: String, to routines: [RoutineInfo]) -> [RoutineInfo] { - SidebarNameFilter.ranked(routines, query: query, name: { $0.name }) + DatabaseTreeFilter.filteredRoutines(routines, searchText: query) } private func rebuildKindBuckets(from tables: [TableInfo]) { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index d321dd2cc..fd0d22451 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -225,12 +225,35 @@ struct MainEditorContentView: View { usersRolesContent(tab: tab) case .insights: queryInsightsContent(tab: tab) + case .objectSource: + objectSourceContent(tab: tab) } } - // MARK: - Query Insights Tab Content + // MARK: - Object Source Tab Content @ViewBuilder + private func objectSourceContent(tab: QueryTab) -> some View { + if let objectRef = tab.display.objectRef { + ObjectSourceTabView( + connectionId: connection.id, + databaseType: connection.type, + objectRef: objectRef, + onOpenInEditor: { source in + coordinator.openObjectSourceInEditor(objectRef, source: source) + } + ) + .id(objectRef) + } else { + ContentUnavailableView( + String(localized: "No Object"), + systemImage: "questionmark.square.dashed" + ) + } + } + + // MARK: - Query Insights Tab Content + private func queryInsightsContent(tab: QueryTab) -> some View { Group { if let vm = queryInsightsViewModels[tab.id] { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index 404bfcd77..06b026341 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -83,6 +83,10 @@ extension MainContentCoordinator { await switchSchema(to: item.name) } + case .procedure, .function, .trigger: + guard let objectRef = item.objectRef else { return } + showObjectSource(objectRef) + case .savedQuery: loadQueryIntoEditor( item.payload ?? item.name, diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 0e9899d96..e78986379 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -758,53 +758,47 @@ final class MainContentCoordinator { pruneStaleSidebarState() } - func refreshProcedures() async { + func refreshRoutines() async { try? await services.databaseManager.withBrowseMetadataDriver(connectionId: connectionId) { [services, connectionId] driver in - _ = await services.schemaService.reloadProcedures(connectionId: connectionId, driver: driver) + _ = await services.schemaService.reloadRoutines(connectionId: connectionId, driver: driver) } } - func refreshFunctions() async { + func refreshTriggers() async { + guard connection.type.supportsDatabaseTriggerBrowse else { return } try? await services.databaseManager.withBrowseMetadataDriver(connectionId: connectionId) { [services, connectionId] driver in - _ = await services.schemaService.reloadFunctions(connectionId: connectionId, driver: driver) + _ = await services.schemaService.reloadTriggers(connectionId: connectionId, driver: driver) } } - func showRoutineDDL(_ routine: RoutineInfo) { - guard let adapter = services.databaseManager.driver(for: connectionId) as? PluginDriverAdapter else { - AlertHelper.showErrorSheet( - title: String(localized: "Cannot Show DDL"), - message: String(localized: "This driver does not expose routine DDL."), - window: nil - ) - return - } - Task { [connectionId = connection.id, routine] in - do { - let ddl = try await adapter.fetchRoutineDDL(routine: routine) - let titleFormat: String = routine.kind == .procedure - ? String(localized: "Procedure: %@") - : String(localized: "Function: %@") - let payload = EditorTabPayload( - connectionId: connectionId, - tabType: .query, - initialQuery: ddl, - skipAutoExecute: true, - tabTitle: String(format: titleFormat, routine.name) - ) - await MainActor.run { - WindowManager.shared.openTab(payload: payload) - } - } catch { - await MainActor.run { - AlertHelper.showErrorSheet( - title: String(localized: "Failed to Fetch DDL"), - message: error.localizedDescription, - window: nil - ) - } - } - } + /// Opens the viewer rather than fetching here. Inspecting an object should not put its source + /// into an editable query buffer, where the next Cmd+Return runs it, and the viewer refetches + /// on its own so a restored tab shows the current definition instead of a stale one. + func showObjectSource(_ objectRef: DatabaseObjectRef) { + let resolved = objectRef.resolvingDatabase(browseDatabaseName) + let payload = EditorTabPayload( + connectionId: connectionId, + tabType: .objectSource, + databaseName: resolved.database, + schemaName: resolved.schema, + objectRef: resolved, + tabTitle: QueryTabManager.objectSourceTitle(for: resolved) + ) + WindowManager.shared.openTab(payload: payload) + } + + func openObjectSourceInEditor(_ objectRef: DatabaseObjectRef, source: String) { + let resolved = objectRef.resolvingDatabase(browseDatabaseName) + let payload = EditorTabPayload( + connectionId: connectionId, + tabType: .query, + databaseName: resolved.database, + schemaName: resolved.schema, + initialQuery: source, + skipAutoExecute: true, + tabTitle: resolved.displayIdentity + ) + WindowManager.shared.openTab(payload: payload) } /// Drop sidebar state for tables that no longer exist. The selection lives in this diff --git a/TablePro/Views/ObjectSource/ObjectSourceTabView.swift b/TablePro/Views/ObjectSource/ObjectSourceTabView.swift new file mode 100644 index 000000000..15435a323 --- /dev/null +++ b/TablePro/Views/ObjectSource/ObjectSourceTabView.swift @@ -0,0 +1,173 @@ +// +// ObjectSourceTabView.swift +// TablePro +// +// Tab showing the source of one stored procedure, function or trigger. +// + +import SwiftUI +import TableProPluginKit + +@MainActor +@Observable +final class ObjectSourceLoader { + enum State { + case loading + case loaded(source: String, attributes: [ObjectAttribute]) + case failed(String) + } + + private(set) var state: State = .loading + + private let connectionId: UUID + private let objectRef: DatabaseObjectRef + + init(connectionId: UUID, objectRef: DatabaseObjectRef) { + self.connectionId = connectionId + self.objectRef = objectRef + } + + var source: String { + if case .loaded(let source, _) = state { return source } + return "" + } + + var attributes: [ObjectAttribute] { + if case .loaded(_, let attributes) = state { return attributes } + return [] + } + + /// A refresh fetches before it commits, so a failed reload leaves the definition the reader is + /// looking at on screen rather than replacing it with an error. + func load(isRefresh: Bool = false) async { + if !isRefresh { state = .loading } + do { + let fetched = try await fetch() + state = .loaded(source: fetched.source, attributes: fetched.attributes) + } catch is CancellationError { + } catch { + guard case .loaded = state, isRefresh else { + state = .failed(error.localizedDescription) + return + } + } + } + + /// One round trip. Re-listing the schema to recover the attributes cost a full catalog scan + /// per open and per reload, for values the sidebar's listing already carried into the ref. + private func fetch() async throws -> (source: String, attributes: [ObjectAttribute]) { + let scope = DatabaseScope( + connectionId: connectionId, + database: objectRef.database, + schema: objectRef.schema + ) + let source = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { [objectRef] driver in + switch objectRef.kind { + case .procedure, .function: + guard let routine = objectRef.routine else { + throw PluginObjectSourceError.unsupported(objectRef.name) + } + return try await driver.fetchRoutineDDL(routine) + case .trigger: + guard let trigger = objectRef.trigger else { + throw PluginObjectSourceError.unsupported(objectRef.name) + } + return try await driver.fetchTriggerDDL(trigger) + } + } + return (source, objectRef.attributes) + } +} + +struct ObjectSourceTabView: View { + let connectionId: UUID + let databaseType: DatabaseType + let objectRef: DatabaseObjectRef + let onOpenInEditor: (String) -> Void + + @State private var loader: ObjectSourceLoader + + init( + connectionId: UUID, + databaseType: DatabaseType, + objectRef: DatabaseObjectRef, + onOpenInEditor: @escaping (String) -> Void + ) { + self.connectionId = connectionId + self.databaseType = databaseType + self.objectRef = objectRef + self.onOpenInEditor = onOpenInEditor + _loader = State(wrappedValue: ObjectSourceLoader(connectionId: connectionId, objectRef: objectRef)) + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + content + } + .task { await loader.load() } + } + + private var header: some View { + HStack(spacing: 8) { + Image(systemName: objectRef.kind.iconName) + .foregroundStyle(Color.accentColor) + Text(objectRef.displayIdentity) + .font(.headline) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + Text(objectRef.kind.displayName) + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color(nsColor: .quaternaryLabelColor), in: Capsule()) + Spacer() + Text("Read Only") + .font(.caption) + .foregroundStyle(.secondary) + Button { + Task { await loader.load(isRefresh: true) } + } label: { + Image(systemName: "arrow.clockwise") + } + .buttonStyle(.borderless) + .help(String(localized: "Reload from the database")) + .accessibilityLabel(String(localized: "Reload")) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(nsColor: .controlBackgroundColor)) + } + + @ViewBuilder + private var content: some View { + switch loader.state { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(nsColor: .textBackgroundColor)) + case .failed(let message): + ContentUnavailableView { + Label("Source Unavailable", systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } actions: { + Button("Try Again") { + Task { await loader.load() } + } + } + .background(Color(nsColor: .textBackgroundColor)) + case .loaded: + ObjectSourceView( + source: loader.source, + databaseType: databaseType, + exportFileName: objectRef.suggestedFileName, + attributes: loader.attributes, + onOpenInEditor: { onOpenInEditor(loader.source) } + ) + } + } +} diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift index b975ab936..315032f5a 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherPanelView.swift @@ -357,6 +357,8 @@ struct QuickSwitcherPanelContent: View { return item.isOpenInTab ? String(localized: "Switch to Tab") : String(localized: "Open") case .database, .schema: return String(localized: "Switch") + case .procedure, .function, .trigger: + return String(localized: "Show DDL") case .savedQuery, .queryHistory: return String(localized: "Load Query") } diff --git a/TablePro/Views/Shared/ObjectSourceView.swift b/TablePro/Views/Shared/ObjectSourceView.swift new file mode 100644 index 000000000..8e29bb904 --- /dev/null +++ b/TablePro/Views/Shared/ObjectSourceView.swift @@ -0,0 +1,140 @@ +// +// ObjectSourceView.swift +// TablePro +// +// Read-only source of one database object, with the toolbar that goes above it. +// + +import AppKit +import SwiftUI + +/// The one place the app draws an object's source. The Structure tab's trigger inspector and the +/// object viewer tab both use it, so the font stepper, Copy, Export and Open in Editor behave the +/// same wherever a definition is shown. +struct ObjectSourceView: View { + let source: String + let databaseType: DatabaseType + let exportFileName: String + var attributes: [ObjectAttribute] = [] + var onOpenInEditor: (() -> Void)? + + @AppStorage("structureCodeFontSize", store: AppStorageEnvironment.shared.defaults) private var fontSize: Double = 13 + @State private var exportError: String? + + var body: some View { + VStack(spacing: 0) { + toolbar + Divider() + if !attributes.isEmpty { + ObjectAttributeStrip(attributes: attributes) + Divider() + } + DDLTextView(ddl: source, fontSize: $fontSize, databaseType: databaseType) + } + .alert( + String(localized: "Export Failed"), + isPresented: Binding(get: { exportError != nil }, set: { if !$0 { exportError = nil } }) + ) { + Button("OK", role: .cancel) { exportError = nil } + } message: { + Text(exportError ?? "") + } + } + + private var hasSource: Bool { !source.isEmpty } + + private var toolbar: some View { + HStack(spacing: 12) { + fontStepper + Spacer() + if let onOpenInEditor { + Button(action: onOpenInEditor) { + Label("Open in Editor", systemImage: "square.and.pencil") + } + .buttonStyle(.bordered) + .disabled(!hasSource) + } + Button { + ClipboardService.shared.writeText(source) + } label: { + Label("Copy", systemImage: "doc.on.doc") + } + .buttonStyle(.bordered) + .disabled(!hasSource) + Button { + Task { await export() } + } label: { + Label("Export…", systemImage: "square.and.arrow.down") + } + .buttonStyle(.bordered) + .disabled(!hasSource) + } + .padding() + .background(Color(nsColor: .controlBackgroundColor)) + } + + private var fontStepper: some View { + HStack(spacing: 4) { + Button { + fontSize = max(10, fontSize - 1) + } label: { + Image(systemName: "textformat.size.smaller") + .frame(width: 24, height: 24) + } + .accessibilityLabel(String(localized: "Decrease font size")) + Text("\(Int(fontSize))") + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 24) + Button { + fontSize = min(24, fontSize + 1) + } label: { + Image(systemName: "textformat.size.larger") + .frame(width: 24, height: 24) + } + .accessibilityLabel(String(localized: "Increase font size")) + } + .buttonStyle(.borderless) + } + + @MainActor + private func export() async { + guard let url = await SQLFileService.showSavePanel(suggestedName: exportFileName) else { return } + do { + try await SQLFileService.writeFile(content: source, to: url) + } catch { + exportError = error.localizedDescription + } + } +} + +/// What the driver said about the object, in the order it said it. The app renders the pairs and +/// never interprets them, so an engine can describe volatility, security or a trigger's Java class +/// without the app learning that vocabulary. +struct ObjectAttributeStrip: View { + let attributes: [ObjectAttribute] + + private let columns = [GridItem(.adaptive(minimum: 180, maximum: 320), alignment: .leading)] + + var body: some View { + LazyVGrid(columns: columns, alignment: .leading, spacing: 6) { + ForEach(attributes) { attribute in + HStack(spacing: 6) { + Text(attribute.label) + .font(.caption) + .foregroundStyle(.secondary) + Text(attribute.value) + .font(.caption.monospaced()) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.tail) + .help(attribute.value) + } + .accessibilityElement(children: .combine) + } + } + .padding(.horizontal) + .padding(.vertical, 8) + .background(Color(nsColor: .controlBackgroundColor)) + } +} diff --git a/TablePro/Views/Sidebar/DatabaseTreeDoubleClickIntent.swift b/TablePro/Views/Sidebar/DatabaseTreeDoubleClickIntent.swift index 8cb0447d2..e3dfdc3e3 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeDoubleClickIntent.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeDoubleClickIntent.swift @@ -14,6 +14,9 @@ import Foundation internal enum DatabaseTreeDoubleClickIntent: Equatable { /// Open the table in a tab the next sidebar click will not replace. case openPermanently(DatabaseTreeTableRef) + /// Open a routine's or trigger's source. Selection alone does not open one, because fetching a + /// definition is a round trip, and arrowing through a section would fire one per row. + case openObjectSource(DatabaseObjectRef) /// Expand or collapse a container row. case toggleDisclosure case ignore @@ -27,6 +30,13 @@ internal enum DatabaseTreeDoubleClickResolver { if let ref = DatabaseTreeSelection.tableRef(of: node) { return .openPermanently(ref) } - return node.isExpandable ? .toggleDisclosure : .ignore + switch node.kind { + case .routine(let ref): + return .openObjectSource(ref.objectRef) + case .trigger(let ref): + return .openObjectSource(ref.objectRef) + default: + return node.isExpandable ? .toggleDisclosure : .ignore + } } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeFilter.swift b/TablePro/Views/Sidebar/DatabaseTreeFilter.swift index c5ecae17a..cb70c9e9a 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeFilter.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeFilter.swift @@ -18,9 +18,10 @@ struct DatabaseTreeContainerKey: Hashable { struct DatabaseTreeObjectBuckets { let tables: [SidebarObjectKind: [TableInfo]] let routines: [SidebarObjectKind: [RoutineInfo]] + let triggers: [TriggerInfo] var isEmpty: Bool { - tables.values.allSatisfy(\.isEmpty) && routines.values.allSatisfy(\.isEmpty) + tables.values.allSatisfy(\.isEmpty) && routines.values.allSatisfy(\.isEmpty) && triggers.isEmpty } var itemCounts: [SidebarObjectKind: Int] { @@ -28,6 +29,7 @@ struct DatabaseTreeObjectBuckets { for (kind, list) in routines { counts[kind, default: 0] += list.count } + counts[.trigger, default: 0] += triggers.count return counts } } @@ -47,9 +49,23 @@ enum DatabaseTreeFilter { return deduplicated(matched, by: \.id) } + /// A trigger is findable by its own name and by the table it fires for, because a reader who + /// knows only the table is exactly the reader the database-level list exists for. + static func filteredTriggers(_ triggers: [TriggerInfo], searchText: String) -> [TriggerInfo] { + let matched = SidebarNameFilter.ranked(triggers, query: searchText, name: { $0.name }) + let byTable = searchText.isEmpty + ? [] + : triggers.filter { trigger in + guard let table = trigger.table, matches(searchText, table) else { return false } + return true + } + return deduplicated(matched + byTable, by: \.id) + } + static func objectBuckets( tables: [TableInfo], routines: [RoutineInfo], + triggers: [TriggerInfo], searchText: String ) -> DatabaseTreeObjectBuckets { var tableBuckets: [SidebarObjectKind: [TableInfo]] = [:] @@ -60,7 +76,11 @@ enum DatabaseTreeFilter { for routine in filteredRoutines(routines, searchText: searchText) { routineBuckets[routine.kind.sidebarObjectKind, default: []].append(routine) } - return DatabaseTreeObjectBuckets(tables: tableBuckets, routines: routineBuckets) + return DatabaseTreeObjectBuckets( + tables: tableBuckets, + routines: routineBuckets, + triggers: filteredTriggers(triggers, searchText: searchText) + ) } /// A schema whose tables have not loaded yet cannot be judged, so it stays visible. Reading an diff --git a/TablePro/Views/Sidebar/DatabaseTreeNode.swift b/TablePro/Views/Sidebar/DatabaseTreeNode.swift index a5617fcab..194827855 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeNode.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeNode.swift @@ -11,10 +11,15 @@ internal enum DatabaseTreeObjectGroupResolver { internal static func groups( database: String, schema: String?, - itemCounts: [SidebarObjectKind: Int] + itemCounts: [SidebarObjectKind: Int], + declaredKinds: Set = [] ) -> [DatabaseTreeObjectGroup] { - SidebarObjectKind.visible(itemCounts: itemCounts, includingEmptyTables: false) - .map { DatabaseTreeObjectGroup(database: database, schema: schema, kind: $0) } + SidebarObjectKind.visible( + itemCounts: itemCounts, + declaredKinds: declaredKinds, + includingEmptyTables: false + ) + .map { DatabaseTreeObjectGroup(database: database, schema: schema, kind: $0) } } } @@ -34,6 +39,7 @@ final class DatabaseTreeNode: SidebarOutlineNode { case schema(database: String, schema: String) case table(DatabaseTreeTableRef) case routine(DatabaseTreeRoutineRef) + case trigger(DatabaseTreeTriggerRef) case status(Status) /// Flat shape: one collapsible section per object kind. @@ -65,7 +71,7 @@ final class DatabaseTreeNode: SidebarOutlineNode { case .redisNode(let node): guard case .namespace = node else { return false } return true - case .recentTable, .routine, .status: + case .recentTable, .routine, .trigger, .status: return false } } @@ -92,7 +98,7 @@ final class DatabaseTreeNode: SidebarOutlineNode { return true case .database, .schema, .containerObjectKindSection, .hierarchicalSchemaSection, .recentTable, .table, - .routine, .status, .redisNode: + .routine, .trigger, .status, .redisNode: return false } } @@ -101,7 +107,7 @@ final class DatabaseTreeNode: SidebarOutlineNode { switch kind { case .database, .schema: return true - case .recentSection, .recentTable, .table, .routine, .status, + case .recentSection, .recentTable, .table, .routine, .trigger, .status, .objectKindSection, .containerObjectKindSection, .hierarchicalSchemaSection, .redisKeysSection, .redisNode: return false @@ -114,7 +120,7 @@ final class DatabaseTreeNode: SidebarOutlineNode { return .database(metadata.name, isSystem: metadata.isSystemDatabase) case .schema(let database, let schema): return .schema(database: database, schema: schema, isSystem: systemSchemas.contains(schema)) - case .recentSection, .recentTable, .table, .routine, .status, + case .recentSection, .recentTable, .table, .routine, .trigger, .status, .objectKindSection, .containerObjectKindSection, .hierarchicalSchemaSection, .redisKeysSection, .redisNode: return nil @@ -127,6 +133,7 @@ final class DatabaseTreeNode: SidebarOutlineNode { static func tableId(_ ref: DatabaseTreeTableRef) -> String { "table\u{1}\(ref.id)" } static func recentTableId(_ ref: DatabaseTreeTableRef) -> String { "recent\u{1}table\u{1}\(ref.id)" } static func routineId(_ ref: DatabaseTreeRoutineRef) -> String { "routine\u{1}\(ref.id)" } + static func triggerId(_ ref: DatabaseTreeTriggerRef) -> String { "trigger\u{1}\(ref.id)" } static func statusId(parentId: String, status: Status) -> String { switch status { case .loading: return "\(parentId)\u{1}status.loading" diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift index 743e24411..f05772c81 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift @@ -106,8 +106,8 @@ extension DatabaseTreeOutlineCoordinator { reloadHierarchicalSchemaTables(schema) case .copyText(let text): ClipboardService.shared.writeText(text) - case .showRoutineDDL(let ref): - mainCoordinator?.showRoutineDDL(ref.routine) + case .showObjectSource(let ref): + mainCoordinator?.showObjectSource(ref) case .copyRedisNamespacePrefix(let prefix): ClipboardService.shared.writeText(prefix) case .copyRedisKey(let key): diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift index d64acf00e..009673edc 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Expansion.swift @@ -111,7 +111,7 @@ extension DatabaseTreeOutlineCoordinator { restoreObjectGroupExpansion(under: node) case .objectKindSection, .containerObjectKindSection, .hierarchicalSchemaSection: restorePartitionExpansion(under: node) - case .recentSection, .recentTable, .database, .table, .routine, .status, + case .recentSection, .recentTable, .database, .table, .routine, .trigger, .status, .redisKeysSection, .redisNode: break } @@ -162,7 +162,7 @@ extension DatabaseTreeOutlineCoordinator { } else { windowState?.expandedTreeTables.remove(key) } - case .recentTable, .routine, .status, .redisNode: + case .recentTable, .routine, .trigger, .status, .redisNode: break } } @@ -184,7 +184,7 @@ extension DatabaseTreeOutlineCoordinator { loadPartitions(ref) case .hierarchicalSchemaSection(let schema): loadHierarchicalSchemaTables(schema) - case .recentSection, .recentTable, .routine, .status, + case .recentSection, .recentTable, .routine, .trigger, .status, .objectKindSection, .containerObjectKindSection, .redisKeysSection, .redisNode: break @@ -234,6 +234,9 @@ extension DatabaseTreeOutlineCoordinator { if isIdle(service.routinesLoadState(connectionId: connectionId, database: database, schema: schema)) { Task { await service.loadRoutines(connectionId: connectionId, database: database, schema: schema) } } + if isIdle(service.triggersLoadState(connectionId: connectionId, database: database, schema: schema)) { + Task { await service.loadTriggers(connectionId: connectionId, database: database, schema: schema) } + } } private func isIdle(_ state: MetadataLoadState) -> Bool { diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift index 675f90454..e1a9c6a68 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Nodes.swift @@ -52,7 +52,7 @@ extension DatabaseTreeOutlineCoordinator { return redisChildren(of: nil) case .redisNode(let redisNode): return redisChildren(of: redisNode) - case .recentTable, .routine, .status: + case .recentTable, .routine, .trigger, .status: return [] } } @@ -142,32 +142,52 @@ extension DatabaseTreeOutlineCoordinator { let itemCounts = SidebarObjectKind.allCases.reduce(into: [SidebarObjectKind: Int]()) { $0[$1] = flatItemCount(for: $1) } - return SidebarObjectKind.visible(itemCounts: itemCounts, includingEmptyTables: true) + return SidebarObjectKind.visible( + itemCounts: itemCounts, + declaredKinds: declaredObjectKinds, + includingEmptyTables: true + ) + } + + /// What the engine says it has, so a database that genuinely holds no procedures still shows a + /// Procedures section saying so, instead of looking like an engine that never implemented the + /// fetch. It only ever adds a section; a kind with rows is listed whatever this returns. + internal var declaredObjectKinds: Set { + databaseType.declaredObjectKinds } internal func flatItemCount(for kind: SidebarObjectKind) -> Int { guard let viewModel else { return 0 } - if kind.isRoutine { + switch kind.category { + case .table: + return viewModel.filteredTables(of: kind, from: schemaService.tables(for: connectionId)).count + case .routine: return viewModel.filteredRoutines(of: kind, from: schemaService.routines(for: connectionId)).count + case .trigger: + return viewModel.filteredTriggers(from: schemaService.triggers(for: connectionId)).count } - return viewModel.filteredTables(of: kind, from: schemaService.tables(for: connectionId)).count } private func flatObjectNodes(for kind: SidebarObjectKind) -> [DatabaseTreeNode] { guard let viewModel else { return [] } let database = browsingDatabase - if kind.isRoutine { - return viewModel.filteredRoutines(of: kind, from: schemaService.routines(for: connectionId)) - .map { routine in - let ref = DatabaseTreeRoutineRef(database: database, schema: routine.schema, routine: routine) - return node(id: DatabaseTreeNode.routineId(ref), kind: .routine(ref)) + switch kind.category { + case .table: + return viewModel.filteredTables(of: kind, from: schemaService.tables(for: connectionId)) + .map { table in + let ref = DatabaseTreeTableRef(database: database, schema: table.schema, table: table) + return node(id: DatabaseTreeNode.tableId(ref), kind: .table(ref)) + } + case .routine: + let routines = viewModel.filteredRoutines(of: kind, from: schemaService.routines(for: connectionId)) + return routineNodes(routines, database: database, schema: { $0.schema }) + case .trigger: + return viewModel.filteredTriggers(from: schemaService.triggers(for: connectionId)) + .map { trigger in + let ref = DatabaseTreeTriggerRef(database: database, schema: trigger.schema, trigger: trigger) + return node(id: DatabaseTreeNode.triggerId(ref), kind: .trigger(ref)) } } - return viewModel.filteredTables(of: kind, from: schemaService.tables(for: connectionId)) - .map { table in - let ref = DatabaseTreeTableRef(database: database, schema: table.schema, table: table) - return node(id: DatabaseTreeNode.tableId(ref), kind: .table(ref)) - } } private func hierarchicalRootNodes() -> [DatabaseTreeNode] { @@ -293,6 +313,7 @@ extension DatabaseTreeOutlineCoordinator { let buckets = DatabaseTreeFilter.objectBuckets( tables: service.tables(connectionId: connectionId, database: database, schema: schema), routines: service.routines(connectionId: connectionId, database: database, schema: schema), + triggers: service.triggers(connectionId: connectionId, database: database, schema: schema), searchText: searchText ) objectBucketsCache[key] = buckets @@ -302,19 +323,24 @@ extension DatabaseTreeOutlineCoordinator { private func loadedObjectNodes(database: String, schema: String?, parentId: String) -> [DatabaseTreeNode] { let buckets = objectBuckets(database: database, schema: schema) let routinesState = service.routinesLoadState(connectionId: connectionId, database: database, schema: schema) + let triggersState = service.triggersLoadState(connectionId: connectionId, database: database, schema: schema) + let sideStates = [routinesState.erased, triggersState.erased] + let sideFailure = sideStates.compactMap(\.failureMessage).first guard !buckets.isEmpty else { - switch routinesState { - case .failed(let message): return [statusNode(parentId: parentId, status: .error(message))] - case .loaded: return [statusNode(parentId: parentId, status: .empty)] - case .idle, .loading: return [statusNode(parentId: parentId, status: .loading)] + if let sideFailure { + return [statusNode(parentId: parentId, status: .error(sideFailure))] } + return sideStates.allSatisfy(\.isLoaded) + ? [statusNode(parentId: parentId, status: .empty)] + : [statusNode(parentId: parentId, status: .loading)] } let groups = DatabaseTreeObjectGroupResolver.groups( database: database, schema: schema, - itemCounts: buckets.itemCounts + itemCounts: buckets.itemCounts, + declaredKinds: declaredObjectKinds ) var nodes = groups.map { group in node( @@ -322,34 +348,56 @@ extension DatabaseTreeOutlineCoordinator { kind: .containerObjectKindSection(group) ) } - if case .failed(let message) = routinesState { - nodes.append(statusNode(parentId: parentId, status: .error(message))) + if let sideFailure { + nodes.append(statusNode(parentId: parentId, status: .error(sideFailure))) } return nodes } private func containerObjectNodes(for group: DatabaseTreeObjectGroup) -> [DatabaseTreeNode] { let buckets = objectBuckets(database: group.database, schema: group.schema) - if group.kind.isRoutine { + let emptyId = DatabaseTreeNode.containerObjectKindSectionId(group) + switch group.kind.category { + case .table: + let tables = buckets.tables[group.kind] ?? [] + guard !tables.isEmpty else { + return [statusNode(parentId: emptyId, status: .empty)] + } + return tables.map { table in + let ref = DatabaseTreeTableRef(database: group.database, schema: group.schema, table: table) + return node(id: DatabaseTreeNode.tableId(ref), kind: .table(ref)) + } + case .routine: let routines = buckets.routines[group.kind] ?? [] guard !routines.isEmpty else { - return [statusNode(parentId: DatabaseTreeNode.containerObjectKindSectionId(group), status: .empty)] + return [statusNode(parentId: emptyId, status: .empty)] } - return routines.map { routine in - let ref = DatabaseTreeRoutineRef( - database: group.database, schema: group.schema, routine: routine + return routineNodes(routines, database: group.database, schema: { _ in group.schema }) + case .trigger: + guard !buckets.triggers.isEmpty else { + return [statusNode(parentId: emptyId, status: .empty)] + } + return buckets.triggers.map { trigger in + let ref = DatabaseTreeTriggerRef( + database: group.database, schema: group.schema, trigger: trigger ) - return node(id: DatabaseTreeNode.routineId(ref), kind: .routine(ref)) + return node(id: DatabaseTreeNode.triggerId(ref), kind: .trigger(ref)) } } + } - let tables = buckets.tables[group.kind] ?? [] - guard !tables.isEmpty else { - return [statusNode(parentId: DatabaseTreeNode.containerObjectKindSectionId(group), status: .empty)] - } - return tables.map { table in - let ref = DatabaseTreeTableRef(database: group.database, schema: group.schema, table: table) - return node(id: DatabaseTreeNode.tableId(ref), kind: .table(ref)) + /// The labels are decided over the whole section at once, because "is this name ambiguous" + /// is a question about the section and not about the routine. + private func routineNodes( + _ routines: [RoutineInfo], + database: String?, + schema: (RoutineInfo) -> String? + ) -> [DatabaseTreeNode] { + let labels = RoutineDisplayLabel.labels(for: routines) + return routines.map { routine in + let ref = DatabaseTreeRoutineRef(database: database, schema: schema(routine), routine: routine) + routineDisplayLabels[ref.id] = labels[routine.id] ?? routine.name + return node(id: DatabaseTreeNode.routineId(ref), kind: .routine(ref)) } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index 8c8bbc33a..75e60563c 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -35,6 +35,9 @@ final class DatabaseTreeOutlineCoordinator: NSObject { internal var nodeCache: [String: DatabaseTreeNode] = [:] internal var childrenCache: [String: [DatabaseTreeNode]] = [:] internal var objectBucketsCache: [DatabaseTreeContainerKey: DatabaseTreeObjectBuckets] = [:] + /// Whether a routine row shows its signature depends on the other rows in its own section, so + /// the label is decided where the section is built and looked up here when the row draws. + internal var routineDisplayLabels: [String: String] = [:] private var cachedRowContext: DatabaseTreeRowContext? private var cachedRowActions: DatabaseTreeRowActions? private var lastSelection: Set = [] @@ -207,16 +210,18 @@ final class DatabaseTreeOutlineCoordinator: NSObject { _ = service.schemaListState(connectionId: connectionId, database: metadata.name) _ = service.tablesLoadState(connectionId: connectionId, database: metadata.name, schema: nil) _ = service.routinesLoadState(connectionId: connectionId, database: metadata.name, schema: nil) + _ = service.triggersLoadState(connectionId: connectionId, database: metadata.name, schema: nil) case .schema(let database, let schema): _ = service.tablesLoadState(connectionId: connectionId, database: database, schema: schema) _ = service.routinesLoadState(connectionId: connectionId, database: database, schema: schema) + _ = service.triggersLoadState(connectionId: connectionId, database: database, schema: schema) case .hierarchicalSchemaSection(let schema): _ = schemaService.schemaState(for: connectionId, schema: schema) case .table(let ref) where ref.table.type == .partitionedTable: _ = service.partitionsLoadState( connectionId: connectionId, database: ref.database ?? "", schema: ref.schema, table: ref.table.name ) - case .recentSection, .recentTable, .table, .routine, .status, + case .recentSection, .recentTable, .table, .routine, .trigger, .status, .objectKindSection, .containerObjectKindSection, .redisKeysSection, .redisNode: break @@ -229,6 +234,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { isReloading = true childrenCache.removeAll() objectBucketsCache.removeAll() + routineDisplayLabels.removeAll() invalidateRowConfiguration() outlineView.reloadData() applyDesiredExpansion() @@ -500,6 +506,9 @@ final class DatabaseTreeOutlineCoordinator: NSObject { }, objectKindTitle: { [databaseType] kind in kind.title(tableEntityName: PluginManager.shared.tableEntityName(for: databaseType)) + }, + routineDisplayLabel: { [weak self] ref in + self?.routineDisplayLabels[ref.id] ?? ref.routine.name } ) } @@ -525,8 +534,8 @@ final class DatabaseTreeOutlineCoordinator: NSObject { internal func refreshObjectKind(_ kind: SidebarObjectKind) { guard let mainCoordinator else { return } switch kind { - case .procedure: Task { await mainCoordinator.refreshProcedures() } - case .function: Task { await mainCoordinator.refreshFunctions() } + case .procedure, .function: Task { await mainCoordinator.refreshRoutines() } + case .trigger: Task { await mainCoordinator.refreshTriggers() } case .table, .view, .materializedView, .foreignTable: Task { await mainCoordinator.refreshTables() } } } @@ -534,14 +543,21 @@ final class DatabaseTreeOutlineCoordinator: NSObject { internal func refreshContainerObjectKind(_ group: DatabaseTreeObjectGroup) { let connectionId = connectionId Task { - if group.kind.isRoutine { + switch group.kind.category { + case .table: + await service.refreshTableObjects( + connectionId: connectionId, + database: group.database, + schema: group.schema + ) + case .routine: await service.refreshRoutineObjects( connectionId: connectionId, database: group.database, schema: group.schema ) - } else { - await service.refreshTableObjects( + case .trigger: + await service.refreshTriggerObjects( connectionId: connectionId, database: group.database, schema: group.schema @@ -588,6 +604,8 @@ final class DatabaseTreeOutlineCoordinator: NSObject { pendingOpenWork?.cancel() pendingOpenWork = nil open(ref, activateGridFocus: true, forceNonPreview: true) + case .openObjectSource(let objectRef): + mainCoordinator?.showObjectSource(objectRef) case .toggleDisclosure: if outlineView.isItemExpanded(node) { outlineView.collapseItem(node) diff --git a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift index 64f014b43..2906761b5 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift @@ -36,6 +36,9 @@ struct DatabaseTreeRowContext { var isExternalSchema: @MainActor (String, String) -> Bool = { _, _ in false } /// The plugin decides what a table is called, so a section header cannot hardcode "Tables". var objectKindTitle: @MainActor (SidebarObjectKind) -> String = { $0.pluralDisplayName } + /// Whether a routine's row shows its bare name or its signature depends on the other rows in + /// its section, which only the node builder can see, so the row asks rather than deciding. + var routineDisplayLabel: @MainActor (DatabaseTreeRoutineRef) -> String = { $0.routine.name } } struct DatabaseTreeRowView: View { @@ -82,7 +85,9 @@ struct DatabaseTreeRowView: View { case .table(let ref): tableRow(ref) case .routine(let ref): - RoutineRowView(routine: ref.routine) + RoutineRowView(routine: ref.routine, displayLabel: context.routineDisplayLabel(ref)) + case .trigger(let ref): + TriggerRowView(trigger: ref.trigger) case .status(let status): statusRow(status) case .objectKindSection(let kind): diff --git a/TablePro/Views/Sidebar/DatabaseTreeSelection.swift b/TablePro/Views/Sidebar/DatabaseTreeSelection.swift index 8539d9df9..842ca68dd 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeSelection.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeSelection.swift @@ -17,7 +17,8 @@ internal enum DatabaseTreeSelection { switch kind { case .status, .recentSection, .objectKindSection, .hierarchicalSchemaSection, .redisKeysSection: return false - case .database, .schema, .table, .routine, .recentTable, .containerObjectKindSection, .redisNode: + case .database, .schema, .table, .routine, .trigger, .recentTable, + .containerObjectKindSection, .redisNode: return true } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeTypeSelect.swift b/TablePro/Views/Sidebar/DatabaseTreeTypeSelect.swift index 7ce9e71ef..445ec96dd 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeTypeSelect.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeTypeSelect.swift @@ -64,6 +64,8 @@ internal enum DatabaseTreeTypeSelect { return schema case .routine(let ref): return ref.routine.name + case .trigger(let ref): + return ref.trigger.name case .hierarchicalSchemaSection(let schema): return schema case .redisNode(let node): diff --git a/TablePro/Views/Sidebar/DatabaseTreeView.swift b/TablePro/Views/Sidebar/DatabaseTreeView.swift index 7f3234684..9a4f18b19 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeView.swift @@ -36,6 +36,24 @@ struct DatabaseTreeRoutineRef: Identifiable, Equatable { var id: String { "\(database ?? "")|\(schema ?? "")|\(routine.id)" } + + var objectRef: DatabaseObjectRef { + DatabaseObjectRef(routine: routine, database: database ?? "") + } +} + +struct DatabaseTreeTriggerRef: Identifiable, Equatable { + let database: String? + let schema: String? + let trigger: TriggerInfo + + var id: String { + "\(database ?? "")|\(schema ?? "")|\(trigger.id)" + } + + var objectRef: DatabaseObjectRef { + DatabaseObjectRef(trigger: trigger, database: database ?? "") + } } struct DatabaseTreeView: View { diff --git a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift index 35318a90c..00c3f9041 100644 --- a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift +++ b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift @@ -76,6 +76,8 @@ internal enum DatabaseTreeMenuSpec { ) case .routine(let ref): return routineItems(ref) + case .trigger(let ref): + return triggerItems(ref) case .objectKindSection(let kind): return objectKindItems(kind, context: context) case .containerObjectKindSection(let group): @@ -165,14 +167,24 @@ internal enum DatabaseTreeMenuSpec { private static func routineItems(_ ref: DatabaseTreeRoutineRef) -> [DatabaseTreeMenuItem] { var items: [DatabaseTreeMenuItem] = [.command(String(localized: "Copy Name"), .copyText(ref.routine.name))] - if let signature = ref.routine.signature, !signature.isEmpty { + if let signature = ref.routine.argumentSignature, !signature.isEmpty { items.append(.command( String(localized: "Copy with Signature"), - .copyText("\(ref.routine.name)\(signature)") + .copyText(RoutineDisplayLabel.copyableSignature(for: ref.routine)) )) } items.append(.separator) - items.append(.command(String(localized: "Show DDL"), .showRoutineDDL(ref))) + items.append(.command(String(localized: "Show DDL"), .showObjectSource(ref.objectRef))) + return items + } + + private static func triggerItems(_ ref: DatabaseTreeTriggerRef) -> [DatabaseTreeMenuItem] { + var items: [DatabaseTreeMenuItem] = [.command(String(localized: "Copy Name"), .copyText(ref.trigger.name))] + if let table = ref.trigger.table, !table.isEmpty { + items.append(.command(String(localized: "Copy Table Name"), .copyText(table))) + } + items.append(.separator) + items.append(.command(String(localized: "Show DDL"), .showObjectSource(ref.objectRef))) return items } diff --git a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift index 9b182cc1b..eecff1851 100644 --- a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift +++ b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift @@ -46,7 +46,7 @@ internal enum SidebarMenuCommand: Equatable { case refreshContainerObjectKind(DatabaseTreeObjectGroup) case refreshHierarchicalSchema(String) case copyText(String) - case showRoutineDDL(DatabaseTreeRoutineRef) + case showObjectSource(DatabaseObjectRef) case copyRedisNamespacePrefix(String) case copyRedisKey(String) case openRedisKey(key: String, keyType: String) diff --git a/TablePro/Views/Sidebar/RoutineRowView.swift b/TablePro/Views/Sidebar/RoutineRowView.swift index cdb66bf4d..bae058ce7 100644 --- a/TablePro/Views/Sidebar/RoutineRowView.swift +++ b/TablePro/Views/Sidebar/RoutineRowView.swift @@ -6,15 +6,13 @@ import SwiftUI enum RoutineRowLogic { - static func accessibilityLabel(for routine: RoutineInfo) -> String { + static func accessibilityLabel(for routine: RoutineInfo, displayLabel: String) -> String { let kindLabel: String = routine.kind == .procedure ? String(localized: "Procedure") : String(localized: "Function") - let baseLabel = "\(kindLabel): \(routine.name)" - if let signature = routine.signature, !signature.isEmpty { - return "\(baseLabel), \(signature)" - } - return baseLabel + let baseLabel = "\(kindLabel): \(displayLabel)" + guard let returnType = routine.returnType, !returnType.isEmpty else { return baseLabel } + return String(format: String(localized: "%1$@, returns %2$@"), baseLabel, returnType) } static func iconName(for kind: RoutineInfo.Kind) -> String { @@ -24,18 +22,27 @@ enum RoutineRowLogic { } } - static func tooltip(for routine: RoutineInfo) -> String? { - guard let signature = routine.signature, !signature.isEmpty else { return nil } - return signature + /// The parts the row could not show: its full identity, then whatever the engine said about it. + static func tooltip(for routine: RoutineInfo) -> String { + var lines = [RoutineDisplayLabel.copyableSignature(for: routine)] + if let returnType = routine.returnType, !returnType.isEmpty { + lines.append(String(format: String(localized: "Returns %@"), returnType)) + } + if let language = routine.language, !language.isEmpty { + lines.append(String(format: String(localized: "Language %@"), language)) + } + lines.append(contentsOf: routine.attributes.map { "\($0.label): \($0.value)" }) + return lines.joined(separator: "\n") } } struct RoutineRowView: View { let routine: RoutineInfo + let displayLabel: String var body: some View { Label { - Text(routine.name) + Text(displayLabel) .lineLimit(1) .truncationMode(.tail) } icon: { @@ -45,7 +52,7 @@ struct RoutineRowView: View { } .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) .accessibilityElement(children: .combine) - .accessibilityLabel(RoutineRowLogic.accessibilityLabel(for: routine)) - .help(RoutineRowLogic.tooltip(for: routine) ?? routine.name) + .accessibilityLabel(RoutineRowLogic.accessibilityLabel(for: routine, displayLabel: displayLabel)) + .help(RoutineRowLogic.tooltip(for: routine)) } } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 1305779fa..2e62cf66f 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -30,6 +30,10 @@ struct SidebarView: View { schemaService.routines(for: connectionId) } + private var triggers: [TriggerInfo] { + schemaService.triggers(for: connectionId) + } + private var hasAnyMatch: Bool { SidebarObjectKind.allCases.contains { kind in countFor(kind: kind) > 0 @@ -228,7 +232,8 @@ struct SidebarView: View { state: schemaService.state(for: connectionId), hasActiveFilter: !viewModel.filterQuery.isEmpty, hasAnyMatch: hasAnyMatch, - hasRoutines: !routines.isEmpty + hasRoutines: !routines.isEmpty, + hasTriggers: !triggers.isEmpty ) { case .loading: loadingState @@ -320,10 +325,11 @@ struct SidebarView: View { } private func countFor(kind: SidebarObjectKind) -> Int { - if kind.isRoutine { - return viewModel.filteredRoutines(of: kind, from: routines).count + switch kind.category { + case .table: return viewModel.filteredTables(of: kind, from: tables).count + case .routine: return viewModel.filteredRoutines(of: kind, from: routines).count + case .trigger: return viewModel.filteredTriggers(from: triggers).count } - return viewModel.filteredTables(of: kind, from: tables).count } } diff --git a/TablePro/Views/Sidebar/TriggerRowView.swift b/TablePro/Views/Sidebar/TriggerRowView.swift new file mode 100644 index 000000000..73ae726a0 --- /dev/null +++ b/TablePro/Views/Sidebar/TriggerRowView.swift @@ -0,0 +1,58 @@ +// +// TriggerRowView.swift +// TablePro +// + +import SwiftUI + +enum TriggerRowLogic { + static func accessibilityLabel(for trigger: TriggerInfo) -> String { + let base = String(format: String(localized: "Trigger: %@"), trigger.name) + guard let table = trigger.table, !table.isEmpty else { return base } + return String(format: String(localized: "%1$@ on %2$@"), base, table) + } + + /// The row shows the name; the table it fires for, its timing and its event go here, because + /// those are what tell two same-named triggers apart in a database-wide list. + static func tooltip(for trigger: TriggerInfo) -> String { + var lines: [String] = [trigger.qualifiedName] + let firing = [trigger.timing, trigger.event, trigger.orientation] + .compactMap { $0?.isEmpty == false ? $0 : nil } + .joined(separator: " ") + if !firing.isEmpty { lines.append(firing) } + if let enabled = trigger.enabled { + lines.append(enabled ? String(localized: "Enabled") : String(localized: "Disabled")) + } + lines.append(contentsOf: trigger.attributes.map { "\($0.label): \($0.value)" }) + return lines.joined(separator: "\n") + } +} + +struct TriggerRowView: View { + let trigger: TriggerInfo + + var body: some View { + Label { + HStack(spacing: 6) { + Text(trigger.name) + .lineLimit(1) + .truncationMode(.tail) + if let table = trigger.table, !table.isEmpty { + Text(table) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.head) + } + } + } icon: { + Image(systemName: SidebarObjectKind.trigger.iconName) + .selectionAwareTint(Color.accentColor) + .frame(width: 16) + } + .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) + .accessibilityElement(children: .combine) + .accessibilityLabel(TriggerRowLogic.accessibilityLabel(for: trigger)) + .help(TriggerRowLogic.tooltip(for: trigger)) + } +} diff --git a/TablePro/Views/Structure/TriggerDetailView.swift b/TablePro/Views/Structure/TriggerDetailView.swift index 9041d271d..50da9415e 100644 --- a/TablePro/Views/Structure/TriggerDetailView.swift +++ b/TablePro/Views/Structure/TriggerDetailView.swift @@ -253,61 +253,21 @@ private struct TriggerDetailPane: View { let databaseType: DatabaseType let onOpenInEditor: (TriggerInfo) -> Void - @AppStorage("structureCodeFontSize", store: AppStorageEnvironment.shared.defaults) 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) - } + ObjectSourceView( + source: trigger.definition ?? trigger.statement, + databaseType: databaseType, + exportFileName: exportFileName(for: trigger), + attributes: trigger.attributes, + onOpenInEditor: { onOpenInEditor(trigger) } + ) } else { Color(nsColor: .textBackgroundColor) } } - private func toolbar(for trigger: TriggerInfo) -> some View { - HStack(spacing: 12) { - HStack(spacing: 4) { - Button { - fontSize = max(10, fontSize - 1) - } label: { - Image(systemName: "textformat.size.smaller") - .frame(width: 24, height: 24) - } - .accessibilityLabel(String(localized: "Decrease font size")) - Text("\(Int(fontSize))") - .font(.caption) - .foregroundStyle(.secondary) - .frame(width: 24) - Button { - fontSize = min(24, fontSize + 1) - } label: { - Image(systemName: "textformat.size.larger") - .frame(width: 24, height: 24) - } - .accessibilityLabel(String(localized: "Increase font size")) - } - .buttonStyle(.borderless) - - Spacer() - - Button { - onOpenInEditor(trigger) - } label: { - Label("Open in Editor", systemImage: "square.and.pencil") - } - .buttonStyle(.bordered) - - Button { - ClipboardService.shared.writeText(trigger.statement) - } label: { - Label("Copy", systemImage: "doc.on.doc") - } - .buttonStyle(.bordered) - } - .padding() - .background(Color(nsColor: .controlBackgroundColor)) + private func exportFileName(for trigger: TriggerInfo) -> String { + DatabaseObjectRef(trigger: trigger, database: "").suggestedFileName } } diff --git a/TableProTests/Core/MCP/Protocol/Tools/RoutineAndTriggerToolSchemaTests.swift b/TableProTests/Core/MCP/Protocol/Tools/RoutineAndTriggerToolSchemaTests.swift new file mode 100644 index 000000000..74a1f8267 --- /dev/null +++ b/TableProTests/Core/MCP/Protocol/Tools/RoutineAndTriggerToolSchemaTests.swift @@ -0,0 +1,64 @@ +// +// RoutineAndTriggerToolSchemaTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +/// The declared output schema is the contract an MCP client reads. A field the bridge emits but the +/// schema never declares is a silent disagreement: the tool answers with keys the client was told +/// would not be there. `list_routines` shipped exactly that, emitting `return_type` and `language` +/// against a schema that declared neither. +@Suite("Routine and trigger tool schemas") +struct RoutineAndTriggerToolSchemaTests { + private func itemProperties(_ schema: JsonValue?, array: String) throws -> Set { + let output = try #require(schema) + let items = try #require(output["properties"]?[array]?["items"]) + let properties = try #require(items["properties"]?.objectValue) + return Set(properties.keys) + } + + @Test("list_routines declares every field the bridge emits") + func routineOutputSchemaIsComplete() throws { + let declared = try itemProperties(ListRoutinesTool.outputSchema, array: "routines") + #expect(declared.isSuperset(of: [ + "name", "kind", "schema", "qualified_name", "signature", "return_type", "language" + ])) + } + + @Test("list_routines requires only a connection") + func routineInputRequiresConnectionOnly() throws { + let required = ListRoutinesTool.inputSchema["required"]?.arrayValue?.compactMap(\.stringValue) + #expect(required == ["connection_id"]) + } + + /// A schema-wide answer spans many tables, so the table cannot stay a required top-level field. + @Test("list_triggers takes an optional table and never requires it") + func triggerInputTableIsOptional() throws { + let schema = ListTriggersTool.inputSchema + #expect(schema["required"]?.arrayValue?.compactMap(\.stringValue) == ["connection_id"]) + #expect(schema["properties"]?["table"] != nil) + } + + @Test("list_triggers declares every field the bridge emits") + func triggerOutputSchemaIsComplete() throws { + let declared = try itemProperties(ListTriggersTool.outputSchema, array: "triggers") + #expect(declared.isSuperset(of: [ + "name", "table", "schema", "timing", "event", "orientation", "statement", + "definition", "is_enabled" + ])) + } + + /// Only the four the driver always fills may be required; the rest depend on the engine. + /// `MCPToolSchema.object` sorts the required list, so compare as a set. + @Test("list_triggers requires only what every engine reports") + func triggerOutputRequiresOnlyUniversalFields() throws { + let output = try #require(ListTriggersTool.outputSchema) + let items = try #require(output["properties"]?["triggers"]?["items"]) + let required = Set(items["required"]?.arrayValue?.compactMap(\.stringValue) ?? []) + #expect(required == ["name", "timing", "event", "statement"]) + #expect(output["required"]?.arrayValue?.compactMap(\.stringValue) == ["triggers"]) + } +} diff --git a/TableProTests/Core/Services/PersistedTabRoundTripTests.swift b/TableProTests/Core/Services/PersistedTabRoundTripTests.swift index d7b3acc78..40049b953 100644 --- a/TableProTests/Core/Services/PersistedTabRoundTripTests.swift +++ b/TableProTests/Core/Services/PersistedTabRoundTripTests.swift @@ -484,4 +484,57 @@ struct PersistedTabRoundTripTests { let decoded = try JSONDecoder().decode(PersistedTab.self, from: Data(json.utf8)) #expect(decoded.title == "auth.users") } + + /// The viewer tab is addressing only: it persists what identifies the object and refetches the + /// source on restore. An overload's identity has to survive, or a restored tab reopens whichever + /// routine shares its name. + @Test("An object source tab round-trips the reference that identifies its object") + func objectSourceTabRoundTripsItsReference() throws { + let objectRef = DatabaseObjectRef( + kind: .function, + name: "transform", + database: "shop", + schema: "public", + identity: "16401", + argumentSignature: "(geometry, integer)", + attributes: [ObjectAttribute(label: "Volatility", value: "IMMUTABLE")] + ) + let tab = PersistedTab( + id: UUID(), + title: "Function: public.transform(geometry, integer)", + query: "", + tabType: .objectSource, + tableName: nil, + databaseName: "shop", + schemaName: "public", + objectRef: objectRef + ) + + let data = try JSONEncoder().encode(tab) + let decoded = try JSONDecoder().decode(PersistedTab.self, from: data) + + #expect(decoded.tabType == .objectSource) + #expect(decoded.objectRef == objectRef) + #expect(decoded.objectRef?.identity == "16401") + #expect(decoded.objectRef?.displayIdentity == "public.transform(geometry, integer)") + } + + /// The source itself is never persisted, so a restored tab shows the definition as it is now + /// rather than the copy that was on screen at quit. + @Test("An object source tab persists no source text") + func objectSourceTabPersistsNoSource() throws { + let tab = PersistedTab( + id: UUID(), + title: "Trigger: audit", + query: "", + tabType: .objectSource, + tableName: nil, + objectRef: DatabaseObjectRef( + kind: .trigger, name: "audit", database: "shop", schema: "public", table: "orders" + ) + ) + let json = String(decoding: try JSONEncoder().encode(tab), as: UTF8.self) + #expect(!json.contains("CREATE")) + #expect(json.contains("orders")) + } } diff --git a/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift b/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift index f6f487767..f6e70da51 100644 --- a/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift +++ b/TableProTests/Core/Services/Query/DatabaseTreeMetadataServiceTests.swift @@ -18,6 +18,7 @@ struct DatabaseTreeMetadataServiceTests { let keys = DatabaseTreeMetadataService.connectionObjectKeys( tableKeys: [tableOnly, shared], routineKeys: [routineOnly, shared], + triggerKeys: [ObjectsKey](), connectionId: connectionId ) @@ -32,12 +33,30 @@ struct DatabaseTreeMetadataServiceTests { let keys = DatabaseTreeMetadataService.connectionObjectKeys( tableKeys: [ObjectsKey](), routineKeys: [routineOnly], + triggerKeys: [ObjectsKey](), connectionId: connectionId ) #expect(keys == [routineOnly]) } + /// A trigger key with no table or routine key beside it is still this connection's, and the + /// teardown that walks these keys has to reach it or its state outlives the connection. + @Test("connectionObjectKeys includes a trigger key with no matching table or routine key") + func includesOrphanTriggerKey() { + let connectionId = UUID() + let triggerOnly = ObjectsKey(connectionId: connectionId, database: "shop", schema: "audit") + + let keys = DatabaseTreeMetadataService.connectionObjectKeys( + tableKeys: [ObjectsKey](), + routineKeys: [ObjectsKey](), + triggerKeys: [triggerOnly], + connectionId: connectionId + ) + + #expect(keys == [triggerOnly]) + } + @Test("connectionObjectKeys excludes keys from other connections") func excludesOtherConnections() { let connectionId = UUID() @@ -48,6 +67,7 @@ struct DatabaseTreeMetadataServiceTests { let keys = DatabaseTreeMetadataService.connectionObjectKeys( tableKeys: [mine, theirs], routineKeys: [theirs], + triggerKeys: [ObjectsKey](), connectionId: connectionId ) diff --git a/TableProTests/Models/RoutineInfoTests.swift b/TableProTests/Models/RoutineInfoTests.swift index 2e0fef25f..ea495ab8a 100644 --- a/TableProTests/Models/RoutineInfoTests.swift +++ b/TableProTests/Models/RoutineInfoTests.swift @@ -10,32 +10,158 @@ import Testing @Suite("RoutineInfo Identity") struct RoutineInfoTests { - @Test("Overloaded functions with different signatures get distinct ids") + @Test("Overloaded functions with different argument signatures get distinct ids") func overloadsAreDistinct() { - let a = RoutineInfo(name: "st_distance", schema: "public", kind: .function, signature: "(geometry, geometry)") - let b = RoutineInfo(name: "st_distance", schema: "public", kind: .function, signature: "(geography, geography)") + let a = RoutineInfo(name: "st_distance", kind: .function, schema: "public", argumentSignature: "(geometry, geometry)") + let b = RoutineInfo(name: "st_distance", kind: .function, schema: "public", argumentSignature: "(geography, geography)") #expect(a.id != b.id) #expect(Set([a.id, b.id]).count == 2) } + /// The exact case that used to lose a routine: two overloads that return the same type. The id + /// discriminator was the return type, so both produced one id and the tree dropped one of them. + @Test("Overloads that return the same type both survive a Set") + func overloadsWithSameReturnTypeBothSurvive() { + let a = RoutineInfo( + name: "f", kind: .function, schema: "public", + argumentSignature: "(integer)", returnType: "integer" + ) + let b = RoutineInfo( + name: "f", kind: .function, schema: "public", + argumentSignature: "(text)", returnType: "integer" + ) + + #expect(a != b) + #expect(Set([a, b]).count == 2) + #expect(Dictionary(grouping: [a, b], by: \.id).count == 2) + } + + /// The driver's own key wins over the readable signature, because two engines can spell one + /// argument list two ways while the oid is the same object either way. + @Test("Driver identity separates routines whose signatures agree") + func identityWinsOverSignature() { + let a = RoutineInfo(name: "f", kind: .function, schema: "public", argumentSignature: "()", identity: "16401") + let b = RoutineInfo(name: "f", kind: .function, schema: "public", argumentSignature: "()", identity: "16402") + #expect(a != b) + #expect(Set([a, b]).count == 2) + } + @Test("Same routine yields a stable id") func sameRoutineStableId() { - let a = RoutineInfo(name: "f", schema: "public", kind: .function, signature: "(int)") - let b = RoutineInfo(name: "f", schema: "public", kind: .function, signature: "(int)") + let a = RoutineInfo(name: "f", kind: .function, schema: "public", argumentSignature: "(int)") + let b = RoutineInfo(name: "f", kind: .function, schema: "public", argumentSignature: "(int)") #expect(a.id == b.id) + #expect(a == b) + #expect(a.hashValue == b.hashValue) } @Test("Procedure and function with the same name get distinct ids") func procedureAndFunctionDistinct() { - let proc = RoutineInfo(name: "sync", schema: "public", kind: .procedure, signature: nil) - let fn = RoutineInfo(name: "sync", schema: "public", kind: .function, signature: nil) + let proc = RoutineInfo(name: "sync", kind: .procedure, schema: "public") + let fn = RoutineInfo(name: "sync", kind: .function, schema: "public") #expect(proc.id != fn.id) } @Test("Signatureless routine falls back to name-based id") func signaturelessFallback() { - let routine = RoutineInfo(name: "do_thing", schema: "app", kind: .procedure, signature: nil) + let routine = RoutineInfo(name: "do_thing", kind: .procedure, schema: "app") #expect(routine.id == "PROCEDURE_app.do_thing") } + + @Test("Return type is never used as the overload discriminator") + func returnTypeIsNotADiscriminator() { + let a = RoutineInfo(name: "f", kind: .function, schema: "public", returnType: "integer") + let b = RoutineInfo(name: "f", kind: .function, schema: "public", returnType: "text") + #expect(a.id == b.id) + } +} + +@Suite("TriggerInfo Identity") +struct TriggerInfoTests { + /// A trigger name is unique per table on PostgreSQL and Oracle, so a database-wide list keyed + /// on the name alone loses one of any two tables that agree on it. + @Test("Same trigger name on two tables stays two triggers") + func sameNameDifferentTables() { + let a = TriggerInfo( + name: "audit", timing: "AFTER", event: "INSERT", statement: "", + table: "orders", schema: "public" + ) + let b = TriggerInfo( + name: "audit", timing: "AFTER", event: "INSERT", statement: "", + table: "customers", schema: "public" + ) + #expect(a.id != b.id) + #expect(Set([a.id, b.id]).count == 2) + } + + @Test("Id is schema-qualified when a schema is known") + func qualifiedId() { + let trigger = TriggerInfo( + name: "audit", timing: "AFTER", event: "INSERT", statement: "", + table: "orders", schema: "public" + ) + #expect(trigger.id == "public.orders.audit") + #expect(trigger.qualifiedName == "orders.audit") + } + + @Test("A trigger with no table falls back to its name") + func tablelessFallback() { + let trigger = TriggerInfo(name: "audit", timing: "AFTER", event: "INSERT", statement: "") + #expect(trigger.id == "audit") + #expect(trigger.qualifiedName == "audit") + } +} + +@Suite("RoutineDisplayLabel") +struct RoutineDisplayLabelTests { + @Test("A unique name shows without its signature") + func uniqueNameIsBare() { + let routines = [ + RoutineInfo(name: "calculate_age", kind: .function, schema: "public", argumentSignature: "(date)"), + RoutineInfo(name: "sync_orders", kind: .procedure, schema: "public", argumentSignature: "()") + ] + let labels = RoutineDisplayLabel.labels(for: routines) + #expect(labels[routines[0].id] == "calculate_age") + #expect(labels[routines[1].id] == "sync_orders") + } + + @Test("A repeated name shows every row with its signature") + func repeatedNameIsQualified() { + let routines = [ + RoutineInfo(name: "transform", kind: .function, schema: "public", argumentSignature: "(geometry, integer)"), + RoutineInfo(name: "transform", kind: .function, schema: "public", argumentSignature: "(geometry, text)"), + RoutineInfo(name: "other", kind: .function, schema: "public", argumentSignature: "(int)") + ] + let labels = RoutineDisplayLabel.labels(for: routines) + #expect(labels[routines[0].id] == "transform(geometry, integer)") + #expect(labels[routines[1].id] == "transform(geometry, text)") + #expect(labels[routines[2].id] == "other") + } + + @Test("A repeated name with no signature still falls back to the bare name") + func repeatedNameWithoutSignature() { + let routines = [ + RoutineInfo(name: "f", kind: .function, schema: "a"), + RoutineInfo(name: "f", kind: .function, schema: "b") + ] + let labels = RoutineDisplayLabel.labels(for: routines) + #expect(labels[routines[0].id] == "f") + #expect(labels[routines[1].id] == "f") + } + + @Test("Copy with Signature qualifies the name and keeps the parentheses") + func copyableSignatureIsQualified() { + let routine = RoutineInfo( + name: "calculate_age", kind: .function, schema: "public", + argumentSignature: "(date)", returnType: "integer" + ) + #expect(RoutineDisplayLabel.copyableSignature(for: routine) == "public.calculate_age(date)") + } + + @Test("Copy with Signature falls back to the qualified name alone") + func copyableSignatureWithoutArguments() { + let routine = RoutineInfo(name: "sync", kind: .procedure, schema: "app") + #expect(RoutineDisplayLabel.copyableSignature(for: routine) == "app.sync") + } } diff --git a/TableProTests/Models/Sidebar/SidebarObjectKindTests.swift b/TableProTests/Models/Sidebar/SidebarObjectKindTests.swift index 8f06ad3dd..a10e33845 100644 --- a/TableProTests/Models/Sidebar/SidebarObjectKindTests.swift +++ b/TableProTests/Models/Sidebar/SidebarObjectKindTests.swift @@ -10,7 +10,8 @@ import Testing @Suite("SidebarObjectKind visibility") struct SidebarObjectKindTests { private let everyKind: [SidebarObjectKind: Int] = [ - .table: 2, .view: 1, .materializedView: 1, .foreignTable: 1, .procedure: 1, .function: 1, + .table: 2, .view: 1, .materializedView: 1, .foreignTable: 1, + .procedure: 1, .function: 1, .trigger: 1, ] /// The bug: a driver can return materialized views, foreign tables, procedures or functions that @@ -23,7 +24,9 @@ struct SidebarObjectKindTests { itemCounts: everyKind, includingEmptyTables: includingEmptyTables ) - #expect(visible == [.table, .view, .materializedView, .foreignTable, .procedure, .function]) + #expect( + visible == [.table, .view, .materializedView, .foreignTable, .procedure, .function, .trigger] + ) } } @@ -67,7 +70,8 @@ struct SidebarObjectKindTests { @Test("A kind counted as zero reads the same as a kind with no count at all") func explicitZeroCounts() { let counted: [SidebarObjectKind: Int] = [ - .table: 0, .view: 0, .materializedView: 0, .foreignTable: 0, .procedure: 0, .function: 1, + .table: 0, .view: 0, .materializedView: 0, .foreignTable: 0, + .procedure: 0, .function: 1, .trigger: 0, ] for includingEmptyTables in [true, false] { @@ -80,4 +84,53 @@ struct SidebarObjectKindTests { } #expect(SidebarObjectKind.visible(itemCounts: counted, includingEmptyTables: false) == [.function]) } + + /// The declared set only ever adds a section. A kind whose driver returned rows is listed + /// whatever the flag says, which is the invariant this type's own doc comment records. + @Test("A declared kind with no items still gets a section") + func declaredKindShowsEmptySection() { + let counts: [SidebarObjectKind: Int] = [.table: 3] + let visible = SidebarObjectKind.visible( + itemCounts: counts, + declaredKinds: [.procedure, .function, .trigger], + includingEmptyTables: false + ) + #expect(visible == [.table, .procedure, .function, .trigger]) + } + + @Test("An undeclared kind that returned rows is never hidden") + func undeclaredKindWithItemsStillRenders() { + let counts: [SidebarObjectKind: Int] = [.procedure: 2, .trigger: 1] + let visible = SidebarObjectKind.visible( + itemCounts: counts, + declaredKinds: [], + includingEmptyTables: false + ) + #expect(visible == [.procedure, .trigger]) + } + + @Test("Declaring nothing leaves the old behaviour unchanged") + func emptyDeclarationMatchesCountOnlyRule() { + let counts: [SidebarObjectKind: Int] = [.view: 1, .function: 2] + for includingEmptyTables in [true, false] { + #expect( + SidebarObjectKind.visible( + itemCounts: counts, declaredKinds: [], includingEmptyTables: includingEmptyTables + ) + == SidebarObjectKind.visible( + itemCounts: counts, includingEmptyTables: includingEmptyTables + ) + ) + } + } + + @Test("Every kind belongs to exactly one category") + func categoriesPartitionTheKinds() { + #expect(SidebarObjectKind.allCases.filter { $0.category == .routine } == [.procedure, .function]) + #expect(SidebarObjectKind.allCases.filter { $0.category == .trigger } == [.trigger]) + #expect( + SidebarObjectKind.allCases.filter { $0.category == .table } + == [.table, .view, .materializedView, .foreignTable] + ) + } } diff --git a/TableProTests/Models/SidebarObjectListPresentationTests.swift b/TableProTests/Models/SidebarObjectListPresentationTests.swift index dbdbc685f..70a4cab49 100644 --- a/TableProTests/Models/SidebarObjectListPresentationTests.swift +++ b/TableProTests/Models/SidebarObjectListPresentationTests.swift @@ -13,13 +13,15 @@ struct SidebarObjectListPresentationTests { _ state: SchemaState, hasActiveFilter: Bool = false, hasAnyMatch: Bool = true, - hasRoutines: Bool = false + hasRoutines: Bool = false, + hasTriggers: Bool = false ) -> SidebarObjectListPresentation { SidebarObjectListPresentation.resolve( state: state, hasActiveFilter: hasActiveFilter, hasAnyMatch: hasAnyMatch, - hasRoutines: hasRoutines + hasRoutines: hasRoutines, + hasTriggers: hasTriggers ) } @@ -41,6 +43,8 @@ struct SidebarObjectListPresentationTests { @Test("A database with only routines is not empty") func routinesAloneAreNotEmpty() { #expect(resolve(.loaded([]), hasRoutines: true) == .list) + /// A schema whose only objects are triggers is not an empty schema. + #expect(resolve(.loaded([]), hasTriggers: true) == .list) } @Test("Loaded objects render the list") diff --git a/TableProTests/Plugins/ObjectCatalogQueryTests.swift b/TableProTests/Plugins/ObjectCatalogQueryTests.swift new file mode 100644 index 000000000..f6ca78bdd --- /dev/null +++ b/TableProTests/Plugins/ObjectCatalogQueryTests.swift @@ -0,0 +1,289 @@ +// +// ObjectCatalogQueryTests.swift +// TableProTests +// +// The catalog SQL each engine uses to list routines and triggers and to read their source. +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("PostgreSQL Object Catalog Queries") +struct PostgreSQLObjectQueryTests { + /// information_schema.routines shows only what the caller has a privilege on and repeats a + /// name once per overload, which is what produced duplicate rows and an arbitrary definition. + @Test("Routine listing reads pg_proc, never information_schema") + func routineListReadsPgProc() { + let sql = PostgreSQLObjectQueries.routineList(schema: "public", serverVersionNumber: 160_000) + #expect(sql.contains("pg_catalog.pg_proc")) + #expect(!sql.contains("information_schema")) + #expect(sql.contains("p.oid::text")) + #expect(sql.contains("pg_get_function_identity_arguments")) + } + + /// `PQserverVersion` is 0 for a handle that has not connected. Reading that as pre-11 emitted + /// `proisagg`, which PostgreSQL 11 dropped, and failed the listing on every current server. + @Test("An unknown server version reads as modern, not ancient") + func unknownVersionIsModern() { + #expect(PostgreSQLObjectQueries.usesProkind(serverVersionNumber: 0)) + #expect(PostgreSQLObjectQueries.usesProkind(serverVersionNumber: 170_000)) + #expect(!PostgreSQLObjectQueries.usesProkind(serverVersionNumber: 100_000)) + #expect(!PostgreSQLObjectQueries.routineList(schema: "public", serverVersionNumber: 0) + .contains("proisagg")) + } + + @Test("Aggregates and window functions are excluded because pg_get_functiondef raises on them") + func aggregatesExcluded() { + let modern = PostgreSQLObjectQueries.routineList(schema: "public", serverVersionNumber: 160_000) + #expect(modern.contains("p.prokind IN ('f', 'p')")) + + let legacy = PostgreSQLObjectQueries.routineList(schema: "public", serverVersionNumber: 100_000) + #expect(legacy.contains("NOT p.proisagg AND NOT p.proiswindow")) + #expect(!legacy.contains("prokind IN")) + } + + @Test("The DDL fetch addresses one oid, so an overload cannot resolve to a sibling") + func routineDefinitionIsByOid() { + let sql = PostgreSQLObjectQueries.routineDefinition(identity: "16401") + #expect(sql.contains("'16401'::oid")) + #expect(!sql.contains("LIMIT 1")) + } + + @Test("A name-addressed fallback still predicates on the argument list") + func routineDefinitionByNameUsesArguments() { + let sql = PostgreSQLObjectQueries.routineDefinitionByName( + name: "transform", schema: "public", arguments: "(geometry, integer)" + ) + #expect(sql.contains("p.proname = 'transform'")) + #expect(sql.contains("= '(geometry, integer)'")) + } + + @Test("Both trigger scopes come from one builder") + func triggerScopesShareOneQuery() { + let all = PostgreSQLObjectQueries.triggerList(schema: "public", table: nil) + let one = PostgreSQLObjectQueries.triggerList(schema: "public", table: "orders") + #expect(all.contains("pg_catalog.pg_trigger")) + #expect(!all.contains("c.relname =")) + #expect(one.contains("c.relname = 'orders'")) + #expect(one.contains("pg_catalog.pg_get_triggerdef")) + } + + @Test("A quote in a name or schema is escaped in every query") + func literalsAreEscaped() { + let list = PostgreSQLObjectQueries.routineList(schema: "it's", serverVersionNumber: 160_000) + #expect(list.contains("'it''s'")) + + let byName = PostgreSQLObjectQueries.routineDefinitionByName( + name: "x'; DROP TABLE t; --", schema: "public", arguments: nil + ) + #expect(byName.contains("'x''; DROP TABLE t; --'")) + + let triggers = PostgreSQLObjectQueries.triggerList(schema: "public", table: "o'brien") + #expect(triggers.contains("'o''brien'")) + } +} + +@Suite("MySQL Object Catalog Queries") +struct MySQLObjectQueryTests { + @Test("The DDL statement is schema-qualified") + func routineDefinitionIsQualified() { + let sql = MySQLObjectQueries.routineDefinition(kind: "PROCEDURE", schema: "analytics", name: "cleanup") + #expect(sql == "SHOW CREATE PROCEDURE `analytics`.`cleanup`") + } + + /// Unqualified, the server resolves the name against the session database, so browsing one + /// database and opening another's routine returned a different routine's body. + @Test("A nil schema is the only case that falls back to an unqualified name") + func unqualifiedOnlyWithoutSchema() { + let sql = MySQLObjectQueries.routineDefinition(kind: "FUNCTION", schema: nil, name: "f") + #expect(sql == "SHOW CREATE FUNCTION `f`") + } + + @Test("The parameter list excludes a function's return row") + func parameterListSkipsOrdinalZero() { + let sql = MySQLObjectQueries.routineList(schema: "app") + #expect(sql.contains("p.ORDINAL_POSITION > 0")) + #expect(sql.contains("information_schema.PARAMETERS")) + } + + @Test("Both trigger scopes come from one builder") + func triggerScopesShareOneQuery() { + let all = MySQLObjectQueries.triggerList(schema: "app", table: nil) + let one = MySQLObjectQueries.triggerList(schema: "app", table: "orders") + #expect(!all.contains("EVENT_OBJECT_TABLE =")) + #expect(one.contains("EVENT_OBJECT_TABLE = 'orders'")) + #expect(all.contains("ACTION_CONDITION")) + #expect(all.contains("DEFINER")) + } + + /// Dropping DEFINER or the WHEN clause produces something that looks runnable and is not the + /// trigger the server holds. + @Test("The assembled statement keeps the definer and the when clause") + func triggerStatementKeepsDefinerAndCondition() { + let statement = MySQLObjectQueries.triggerStatement( + name: "audit", table: "orders", schema: "app", + timing: "BEFORE", event: "INSERT", orientation: "ROW", + condition: "NEW.total > 0", definer: "root@localhost" + ) + #expect(statement.contains("DEFINER = `root`@`localhost`")) + #expect(statement.contains("WHEN (NEW.total > 0)")) + #expect(statement.contains("`app`.`audit`")) + #expect(statement.contains("ON `app`.`orders`")) + #expect(statement.contains("FOR EACH ROW")) + } + + @Test("A definer is quoted as two identifiers, not one") + func definerIsQuotedInTwoParts() { + #expect(MySQLObjectQueries.quotedDefiner("root@localhost") == "`root`@`localhost`") + #expect(MySQLObjectQueries.quotedDefiner("a@b@c") == "`a@b`@`c`") + #expect(MySQLObjectQueries.quotedDefiner("plain") == "`plain`") + } + + @Test("A quote in a schema or table is escaped") + func literalsAreEscaped() { + #expect(MySQLObjectQueries.routineList(schema: "it's").contains("'it''s'")) + #expect(MySQLObjectQueries.triggerList(schema: "app", table: "o'brien").contains("'o''brien'")) + } + + @Test("A backtick in an identifier is doubled") + func identifiersAreQuoted() { + #expect(MySQLObjectQueries.quoteIdentifier("we`ird") == "`we``ird`") + } +} + +@Suite("MSSQL Object Catalog Queries") +struct MSSQLObjectQueryTests { + /// INFORMATION_SCHEMA.ROUTINES.ROUTINE_DEFINITION is nvarchar(4000) and silently truncates, + /// which looks like a procedure that ends mid-statement. + @Test("Routine source comes from sys.sql_modules, never ROUTINE_DEFINITION") + func routineSourceAvoidsInformationSchema() { + let list = MSSQLObjectQueries.routineList(schema: "dbo") + let definition = MSSQLObjectQueries.routineDefinition(schema: "dbo", name: "p") + #expect(list.contains("sys.sql_modules")) + #expect(definition.contains("sys.sql_modules")) + #expect(!list.contains("ROUTINE_DEFINITION")) + #expect(!definition.contains("ROUTINE_DEFINITION")) + } + + @Test("Both trigger scopes come from one builder") + func triggerScopesShareOneQuery() { + let all = MSSQLObjectQueries.triggerList(schema: "dbo", table: nil) + let one = MSSQLObjectQueries.triggerList(schema: "dbo", table: "Orders") + #expect(!all.contains("parent.name =")) + #expect(one.contains("parent.name = 'Orders'")) + #expect(all.contains("sys.trigger_events")) + } + + @Test("Object types map to the two routine kinds") + func objectTypeMapping() { + #expect(MSSQLObjectQueries.routineKind(forObjectType: "P ") == "PROCEDURE") + #expect(MSSQLObjectQueries.routineKind(forObjectType: "FN") == "FUNCTION") + #expect(MSSQLObjectQueries.routineKind(forObjectType: "IF") == "FUNCTION") + #expect(MSSQLObjectQueries.routineKind(forObjectType: "TF") == "FUNCTION") + } + + @Test("A quote in a schema or table is escaped") + func literalsAreEscaped() { + #expect(MSSQLObjectQueries.routineList(schema: "it's").contains("'it''s'")) + #expect(MSSQLObjectQueries.triggerList(schema: "dbo", table: "o'brien").contains("'o''brien'")) + } +} + +@Suite("Oracle Object Catalog Queries") +struct OracleObjectQueryTests { + @Test("The trigger list selects the body the old query never asked for") + func triggerListSelectsBody() { + let sql = OracleObjectQueries.triggerList(schema: "HR", table: nil) + #expect(sql.contains("TRIGGER_BODY")) + #expect(sql.contains("DESCRIPTION")) + #expect(!sql.contains("TABLE_NAME = ")) + } + + @Test("Both trigger scopes come from one builder") + func triggerScopesShareOneQuery() { + let one = OracleObjectQueries.triggerList(schema: "HR", table: "EMPLOYEES") + #expect(one.contains("TABLE_NAME = 'EMPLOYEES'")) + } + + /// DESCRIPTION already holds the name, timing, events, table and WHEN clause. Assembling that + /// header from the separate columns is how the old code lost the WHEN clause. + @Test("A trigger definition is the description plus the body") + func triggerDefinitionUsesDescription() { + let definition = OracleObjectQueries.triggerDefinition( + description: "\"AUDIT_EMP\"\nBEFORE INSERT ON \"HR\".\"EMPLOYEES\"\nFOR EACH ROW\nWHEN (NEW.SALARY > 0)", + body: "BEGIN NULL; END;", + name: "AUDIT_EMP" + ) + #expect(definition.hasPrefix("CREATE OR REPLACE TRIGGER ")) + #expect(definition.contains("WHEN (NEW.SALARY > 0)")) + #expect(definition.hasSuffix("BEGIN NULL; END;")) + } + + @Test("A missing description still produces a runnable header") + func triggerDefinitionFallsBackToName() { + let definition = OracleObjectQueries.triggerDefinition( + description: nil, body: "BEGIN NULL; END;", name: "AUDIT_EMP" + ) + #expect(definition.hasPrefix("CREATE OR REPLACE TRIGGER \"AUDIT_EMP\"")) + } + + @Test("Timing and orientation are read out of the trigger type") + func timingAndOrientation() { + #expect(OracleObjectQueries.timing(fromTriggerType: "BEFORE EACH ROW") == "BEFORE") + #expect(OracleObjectQueries.timing(fromTriggerType: "AFTER STATEMENT") == "AFTER") + #expect(OracleObjectQueries.timing(fromTriggerType: "INSTEAD OF") == "INSTEAD OF") + #expect(OracleObjectQueries.orientation(fromTriggerType: "BEFORE EACH ROW") == "ROW") + #expect(OracleObjectQueries.orientation(fromTriggerType: "AFTER STATEMENT") == "STATEMENT") + } + + /// A packaged routine is an OBJECT_TYPE of PACKAGE, so listing only PROCEDURE and FUNCTION + /// keeps it out. It is addressed through its package and has a different DDL call, so a row + /// for it here would be a row whose source cannot be fetched. + @Test("Packaged routines stay out of the standalone list") + func packagedRoutinesExcluded() { + let sql = OracleObjectQueries.routineList(schema: "HR") + #expect(sql.contains("OBJECT_TYPE IN ('PROCEDURE', 'FUNCTION')")) + #expect(sql.contains("ALL_OBJECTS")) + } + + /// LISTAGG caps at 4000 bytes and raises ORA-01489 past it, which fails the whole SELECT and + /// loses every routine in the schema over one wide signature. Oracle only overloads inside a + /// package, so a standalone routine needs no argument list to be identified. + @Test("The routine list builds no argument signature") + func routineListAvoidsListagg() { + let sql = OracleObjectQueries.routineList(schema: "HR") + #expect(!sql.contains("LISTAGG")) + #expect(!sql.contains("ALL_ARGUMENTS")) + } + + /// A schema browse asks for the triggers this schema owns; a per-table fetch asks for the + /// triggers on that table. The two columns differ for a cross-schema trigger. + @Test("Schema scope reads OWNER and table scope reads TABLE_OWNER") + func triggerScopeColumns() { + let schemaWide = OracleObjectQueries.triggerList(schema: "HR", table: nil) + #expect(schemaWide.contains("WHERE OWNER = 'HR'")) + #expect(!schemaWide.contains("TABLE_OWNER = 'HR'")) + + let perTable = OracleObjectQueries.triggerList(schema: "HR", table: "EMPLOYEES") + #expect(perTable.contains("TABLE_OWNER = 'HR'")) + #expect(perTable.contains("TABLE_NAME = 'EMPLOYEES'")) + } + + @Test("ALL_SOURCE is read in line order") + func routineSourceOrdersByLine() { + let sql = OracleObjectQueries.routineSource(schema: "HR", name: "P", type: "PROCEDURE") + #expect(sql.contains("ORDER BY LINE")) + #expect(sql.contains("ALL_SOURCE")) + } + + @Test("A quote in a schema or name is escaped") + func literalsAreEscaped() { + #expect(OracleObjectQueries.routineList(schema: "IT'S").contains("'IT''S'")) + #expect( + OracleObjectQueries.routineSource(schema: "HR", name: "X'; DROP", type: "PROCEDURE") + .contains("'X''; DROP'") + ) + } +} diff --git a/TableProTests/Services/SchemaServiceRoutinesTests.swift b/TableProTests/Services/SchemaServiceRoutinesTests.swift index 2f5bebd71..1d0d09b51 100644 --- a/TableProTests/Services/SchemaServiceRoutinesTests.swift +++ b/TableProTests/Services/SchemaServiceRoutinesTests.swift @@ -18,6 +18,7 @@ private final class RoutineMockDriver: DatabaseDriver, @unchecked Sendable { var proceduresToReturn: [RoutineInfo] = [] var functionsToReturn: [RoutineInfo] = [] + var triggersToReturn: [TriggerInfo] = [] var proceduresCallCount = 0 var functionsCallCount = 0 var tablesCallCount = 0 @@ -76,14 +77,16 @@ private final class RoutineMockDriver: DatabaseDriver, @unchecked Sendable { func commitTransaction() async throws {} func rollbackTransaction() async throws {} - func fetchProcedures(schema: String?) async throws -> [RoutineInfo] { + /// One catalog read answers both kinds, so both counters move together. They stay separate so + /// a test can still assert which kinds came back. + func fetchRoutines(schema: String?) async throws -> [RoutineInfo] { proceduresCallCount += 1 - return proceduresToReturn + functionsCallCount += 1 + return proceduresToReturn + functionsToReturn } - func fetchFunctions(schema: String?) async throws -> [RoutineInfo] { - functionsCallCount += 1 - return functionsToReturn + func fetchAllTriggers(schema: String?) async throws -> [TriggerInfo] { + triggersToReturn } } @@ -145,11 +148,7 @@ private final class FailingRoutineDriver: DatabaseDriver, @unchecked Sendable { func commitTransaction() async throws {} func rollbackTransaction() async throws {} - func fetchProcedures(schema: String?) async throws -> [RoutineInfo] { - throw NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "boom"]) - } - - func fetchFunctions(schema: String?) async throws -> [RoutineInfo] { + func fetchRoutines(schema: String?) async throws -> [RoutineInfo] { throw NSError(domain: "test", code: 1, userInfo: [NSLocalizedDescriptionKey: "boom"]) } } @@ -253,14 +252,9 @@ private final class BlockingAuxiliaryDriver: DatabaseDriver, @unchecked Sendable func commitTransaction() async throws {} func rollbackTransaction() async throws {} - func fetchProcedures(schema: String?) async throws -> [RoutineInfo] { + func fetchRoutines(schema: String?) async throws -> [RoutineInfo] { await routinesGate.wait() - return proceduresToReturn - } - - func fetchFunctions(schema: String?) async throws -> [RoutineInfo] { - await routinesGate.wait() - return functionsToReturn + return proceduresToReturn + functionsToReturn } } @@ -275,10 +269,10 @@ struct SchemaServiceRoutinesTests { let driver = RoutineMockDriver(connection: connection) driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "users")] driver.proceduresToReturn = [ - RoutineInfo(name: "add_user", schema: "public", kind: .procedure, signature: nil) + RoutineInfo(name: "add_user", kind: .procedure, schema: "public") ] driver.functionsToReturn = [ - RoutineInfo(name: "user_count", schema: "public", kind: .function, signature: "int") + RoutineInfo(name: "user_count", kind: .function, schema: "public", argumentSignature: "int") ] await service.load(connectionId: connectionId, driver: driver, connection: connection) @@ -298,10 +292,10 @@ struct SchemaServiceRoutinesTests { let connection = TestFixtures.makeConnection(id: connectionId, type: .postgresql) let driver = RoutineMockDriver(connection: connection) driver.proceduresToReturn = [ - RoutineInfo(name: "p1", schema: nil, kind: .procedure, signature: nil) + RoutineInfo(name: "p1", kind: .procedure) ] driver.functionsToReturn = [ - RoutineInfo(name: "f1", schema: nil, kind: .function, signature: nil) + RoutineInfo(name: "f1", kind: .function) ] await service.load(connectionId: connectionId, driver: driver, connection: connection) @@ -342,10 +336,10 @@ struct SchemaServiceRoutinesTests { let driver = RoutineMockDriver(connection: connection) driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "users")] driver.proceduresToReturn = [ - RoutineInfo(name: "p1", schema: nil, kind: .procedure, signature: nil) + RoutineInfo(name: "p1", kind: .procedure) ] driver.functionsToReturn = [ - RoutineInfo(name: "f1", schema: nil, kind: .function, signature: nil) + RoutineInfo(name: "f1", kind: .function) ] await service.load(connectionId: connectionId, driver: driver, connection: connection) #expect(service.procedures(for: connectionId).map(\.name) == ["p1"]) @@ -367,7 +361,7 @@ struct SchemaServiceRoutinesTests { let driver = RoutineMockDriver(connection: connection) driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "t")] driver.proceduresToReturn = [ - RoutineInfo(name: "p", schema: nil, kind: .procedure, signature: nil) + RoutineInfo(name: "p", kind: .procedure) ] await service.load(connectionId: connectionId, driver: driver, connection: connection) @@ -388,10 +382,10 @@ struct SchemaServiceRoutinesTests { let driver = BlockingAuxiliaryDriver(connection: connection) driver.tablesToReturn = [TestFixtures.makeTableInfo(name: "users")] driver.proceduresToReturn = [ - RoutineInfo(name: "add_user", schema: "public", kind: .procedure, signature: nil) + RoutineInfo(name: "add_user", kind: .procedure, schema: "public") ] driver.functionsToReturn = [ - RoutineInfo(name: "user_count", schema: "public", kind: .function, signature: "int") + RoutineInfo(name: "user_count", kind: .function, schema: "public", argumentSignature: "int") ] driver.schemasToReturn = ["public"] @@ -425,27 +419,45 @@ struct SchemaServiceRoutinesTests { } } - @Test("reloadProcedures refreshes only procedures") - func reloadProceduresOnly() async { + /// One catalog read answers both kinds. Asking twice was two round trips per schema for an + /// answer one query already held. + @Test("reloadRoutines refreshes both kinds in one catalog read") + func reloadRoutinesIssuesOneRead() async { let service = SchemaService() let connectionId = UUID() let connection = TestFixtures.makeConnection(id: connectionId, type: .postgresql) let driver = RoutineMockDriver(connection: connection) - driver.proceduresToReturn = [ - RoutineInfo(name: "p1", schema: nil, kind: .procedure, signature: nil) - ] + driver.proceduresToReturn = [RoutineInfo(name: "p1", kind: .procedure)] + driver.functionsToReturn = [RoutineInfo(name: "f1", kind: .function)] await service.load(connectionId: connectionId, driver: driver, connection: connection) - let firstProcCount = driver.proceduresCallCount - let firstFuncCount = driver.functionsCallCount + let firstCount = driver.proceduresCallCount driver.proceduresToReturn = [ - RoutineInfo(name: "p1", schema: nil, kind: .procedure, signature: nil), - RoutineInfo(name: "p2", schema: nil, kind: .procedure, signature: nil) + RoutineInfo(name: "p1", kind: .procedure), + RoutineInfo(name: "p2", kind: .procedure) ] - await service.reloadProcedures(connectionId: connectionId, driver: driver) + driver.functionsToReturn = [RoutineInfo(name: "f2", kind: .function)] + await service.reloadRoutines(connectionId: connectionId, driver: driver) - #expect(driver.proceduresCallCount == firstProcCount + 1) - #expect(driver.functionsCallCount == firstFuncCount) + #expect(driver.proceduresCallCount == firstCount + 1) #expect(service.procedures(for: connectionId).map(\.name) == ["p1", "p2"]) + #expect(service.functions(for: connectionId).map(\.name) == ["f2"]) + } + + /// A trigger list is its own fetch behind its own state, so a driver that returns none is not + /// the same as one that never answered. + @Test("reloadTriggers caches the database-wide trigger list") + func reloadTriggersCaches() async { + let service = SchemaService() + let connectionId = UUID() + let connection = TestFixtures.makeConnection(id: connectionId, type: .postgresql) + let driver = RoutineMockDriver(connection: connection) + driver.triggersToReturn = [ + TriggerInfo(name: "audit", timing: "BEFORE", event: "INSERT", statement: "", table: "orders") + ] + await service.load(connectionId: connectionId, driver: driver, connection: connection) + + #expect(service.triggers(for: connectionId).map(\.name) == ["audit"]) + #expect(service.triggers(for: connectionId).first?.table == "orders") } } diff --git a/TableProTests/ViewModels/SidebarViewModelTests.swift b/TableProTests/ViewModels/SidebarViewModelTests.swift index 2ade1f12c..2f4772442 100644 --- a/TableProTests/ViewModels/SidebarViewModelTests.swift +++ b/TableProTests/ViewModels/SidebarViewModelTests.swift @@ -342,8 +342,8 @@ struct SidebarViewModelMultiSectionTests { @MainActor func filteredRoutinesByKind() { let vm = makeViewModel() - let getUser = RoutineInfo(name: "get_user_by_id", schema: "public", kind: .procedure, signature: nil) - let calcAge = RoutineInfo(name: "calculate_age", schema: "public", kind: .function, signature: nil) + let getUser = RoutineInfo(name: "get_user_by_id", kind: .procedure, schema: "public") + let calcAge = RoutineInfo(name: "calculate_age", kind: .function, schema: "public") let mixed = [getUser, calcAge] let procs = vm.filteredRoutines(of: .procedure, from: mixed) @@ -384,8 +384,8 @@ struct SidebarViewModelMultiSectionTests { @MainActor func filteredRoutinesSearch() { let vm = makeViewModel() - let getUser = RoutineInfo(name: "GET_USER_BY_ID", schema: nil, kind: .procedure, signature: nil) - let other = RoutineInfo(name: "log_event", schema: nil, kind: .procedure, signature: nil) + let getUser = RoutineInfo(name: "GET_USER_BY_ID", kind: .procedure) + let other = RoutineInfo(name: "log_event", kind: .procedure) vm.searchText = "user" let procs = vm.filteredRoutines(of: .procedure, from: [getUser, other]) diff --git a/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift index 80aa77f7a..233305bfa 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeFilterTests.swift @@ -10,7 +10,7 @@ struct DatabaseTreeFilterTests { } private func routine(_ name: String) -> RoutineInfo { - RoutineInfo(name: name, schema: "public", kind: .function, signature: nil) + RoutineInfo(name: name, kind: .function, schema: "public") } @Test("filteredTables returns every table and deduplicates when search is empty") @@ -145,17 +145,28 @@ struct DatabaseTreeFilterTests { table("users") ] let routines = [ - RoutineInfo(name: "order_audit", schema: "public", kind: .procedure, signature: nil), + RoutineInfo(name: "order_audit", kind: .procedure, schema: "public"), routine("calc_total") ] - let buckets = DatabaseTreeFilter.objectBuckets(tables: tables, routines: routines, searchText: "ord") + let triggers = [ + TriggerInfo(name: "order_guard", timing: "BEFORE", event: "INSERT", statement: "", table: "orders"), + TriggerInfo(name: "unrelated", timing: "AFTER", event: "DELETE", statement: "", table: "users") + ] + let buckets = DatabaseTreeFilter.objectBuckets( + tables: tables, routines: routines, triggers: triggers, searchText: "ord" + ) #expect(buckets.tables[.table]?.map(\.name) == ["orders"]) #expect(buckets.tables[.view]?.map(\.name) == ["order_totals"]) #expect(buckets.routines[.procedure]?.map(\.name) == ["order_audit"]) #expect(buckets.routines[.function] == nil) - #expect(buckets.itemCounts == [.table: 1, .view: 1, .procedure: 1]) + #expect(buckets.triggers.map(\.name) == ["order_guard"]) + #expect(buckets.itemCounts == [.table: 1, .view: 1, .procedure: 1, .trigger: 1]) #expect(!buckets.isEmpty) - #expect(DatabaseTreeFilter.objectBuckets(tables: tables, routines: routines, searchText: "zzz").isEmpty) + #expect( + DatabaseTreeFilter.objectBuckets( + tables: tables, routines: routines, triggers: triggers, searchText: "zzz" + ).isEmpty + ) } } diff --git a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift index 6f1dcceec..949284db9 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift @@ -400,7 +400,7 @@ struct DatabaseTreeMenuSpecTests { .table(tableRef("orders")), .routine(DatabaseTreeRoutineRef( database: "app", schema: "public", - routine: RoutineInfo(name: "do_thing", schema: "public", kind: .function, signature: nil) + routine: RoutineInfo(name: "do_thing", kind: .function, schema: "public") )), .status(.loading), .recentSection, diff --git a/TableProTests/Views/Sidebar/DatabaseTreeSelectionPolicyTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeSelectionPolicyTests.swift index cfae8c982..6f43a91fc 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeSelectionPolicyTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeSelectionPolicyTests.swift @@ -23,7 +23,7 @@ struct DatabaseTreeSelectionPolicyTests { DatabaseTreeRoutineRef( database: "app", schema: "public", - routine: RoutineInfo(name: name, schema: "public", kind: .function, signature: nil) + routine: RoutineInfo(name: name, kind: .function, schema: "public") ) } diff --git a/TableProTests/Views/Sidebar/DatabaseTreeSelectionProjectionTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeSelectionProjectionTests.swift index 25e9fa448..339650891 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeSelectionProjectionTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeSelectionProjectionTests.swift @@ -25,7 +25,7 @@ struct DatabaseTreeSelectionProjectionTests { DatabaseTreeRoutineRef( database: "app", schema: "public", - routine: RoutineInfo(name: name, schema: "public", kind: .function, signature: nil) + routine: RoutineInfo(name: name, kind: .function, schema: "public") ) } diff --git a/docs/docs.json b/docs/docs.json index 779015e95..199c324fb 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -201,6 +201,7 @@ "icon": "sitemap", "pages": [ "features/table-structure", + "features/routines-triggers", "features/table-operations", "features/er-diagram", "features/explain-visualization", diff --git a/docs/external-api/mcp-tools.mdx b/docs/external-api/mcp-tools.mdx index a13ada732..fdeb9be44 100644 --- a/docs/external-api/mcp-tools.mdx +++ b/docs/external-api/mcp-tools.mdx @@ -70,9 +70,9 @@ The two `switch_` tools move what the user sees in TablePro. To run one statemen | `search_schema` | `connection_id`, `term` (`limit`, `database`, `schema`) | `term`, `matches[]` (`kind` is `table` or `column`, plus `name`, `table`, `schema`, `object_type`, `data_type`), `is_truncated`. Table matches first | | `list_indexes` | `connection_id` (`table`, `database`, `schema`) | `database`, `schema`, `tables[]` of `{ table, indexes[] }`. Tables with no index are left out | | `list_foreign_keys` | `connection_id` (`table`, `database`, `schema`) | `database`, `schema`, `tables[]` of `{ table, foreign_keys[] }` | -| `list_triggers` | `connection_id`, `table` (`database`, `schema`) | `table`, `triggers[]` (`name`, `timing`, `event`, `statement`, `is_enabled`), sorted by name | +| `list_triggers` | `connection_id` (`table`, `database`, `schema`) | `triggers[]` (`name`, `table`, `schema`, `timing`, `event`, `orientation`, `statement`, `definition`, `is_enabled`), sorted by table then name, plus `table` when one was named | | `get_view_definition` | `connection_id`, `view` (`database`, `schema`) | `view`, `schema`, `definition` | -| `list_routines` | `connection_id` (`kind`, `database`, `schema`) | `routines[]` (`name`, `kind`, `schema`, `qualified_name`, `signature`) | +| `list_routines` | `connection_id` (`kind`, `database`, `schema`) | `routines[]` (`name`, `kind`, `schema`, `qualified_name`, `signature`, `return_type`, `language`) | | `list_partitions` | `connection_id`, `table` (`database`, `schema`) | `table`, `partitions[]`, shaped like `list_tables` entries | | `get_table_statistics` | `connection_id`, `table` (`database`, `schema`) | `table` plus whatever the engine records: `data_size_bytes`, `index_size_bytes`, `total_size_bytes`, `average_row_length`, `row_count`, `comment`, `engine`, `collation`, `created_at`, `updated_at` | | `get_database_statistics` | `connection_id` (`database`) | `databases[]` (`name`, `table_count`, `size_bytes`, `is_system_database`), sorted by name | @@ -81,7 +81,7 @@ The two `switch_` tools move what the user sees in TablePro. To run one statemen `include_row_counts` defaults to `false`. Counts come from engine statistics rather than `COUNT(*)`, and are fetched one table at a time, so `list_tables` skips them when the schema holds more than 200 objects. -`search_schema` locates a column without describing every table; its `limit` runs 1 to 500, default 50. `list_routines` takes `kind` as `procedure` or `function`, omitted for both. +`search_schema` locates a column without describing every table; its `limit` runs 1 to 500, default 50. `list_routines` takes `kind` as `procedure` or `function`, omitted for both, and its `signature` is the argument list, not the return type. `list_triggers` takes `table` to scope to one table, omitted for every trigger in the schema. ## Reading data diff --git a/docs/features/routines-triggers.mdx b/docs/features/routines-triggers.mdx new file mode 100644 index 000000000..9e4d215d1 --- /dev/null +++ b/docs/features/routines-triggers.mdx @@ -0,0 +1,69 @@ +--- +title: Procedures, Functions, and Triggers +description: Browse stored procedures, functions, and triggers per schema and read their source +--- + +Expand a schema and **Procedures**, **Functions**, and **Triggers** sit beside **Tables**. Select one, press Return, and its source opens in a read-only tab. + + + Sidebar tree with Procedures, Functions, and Triggers expanded, and a function's source in a tab beside it + Sidebar tree with Procedures, Functions, and Triggers expanded, and a function's source in a tab beside it + + +## Engine support + +| Engine | Procedures | Functions | Triggers | +|---|---|---|---| +| PostgreSQL | Yes | Yes | Yes | +| MySQL, MariaDB | Yes | Yes | Yes | +| MSSQL | Yes | Yes | Yes | +| Oracle | Yes | Yes | Yes | +| Dameng | Yes | Yes | Yes | +| Teradata | Yes | Yes | Yes | +| Snowflake | Yes | Yes | No | +| BigQuery | Yes | Yes | No | +| Cassandra | No | Yes, and aggregates | Yes | +| ClickHouse | No | Yes | No | +| DuckDB | No | Macros | No | +| SQLite, LibSQL, Cloudflare D1 | No | No | Yes | +| Redshift, CockroachDB, PGlite | No | No | No | +| MongoDB, Redis, Elasticsearch, DynamoDB, etcd, SurrealDB, Trino, Beancount | No | No | No | + +A section appears only where the engine has that kind of object. An engine that has them and currently holds none keeps the section and shows **No procedures** inside it. + +## Finding a routine or trigger + +A trigger row carries the table it fires for beside its name. The sidebar filter matches a trigger by its own name or by that table; type-select matches the name only. Triggers also appear on their table's **Structure** tab. + +`Cmd+P` indexes all three kinds alongside tables and views. Choosing one opens its source. + +### Overloaded names + +PostgreSQL lets several functions share a name with different argument lists. Where a name repeats inside a section, every row in that group gains its arguments: `transform(geometry, integer)` and `transform(geometry, text)`. A unique name stays bare. Each row opens its own definition. + +## Reading the source + +Return or a double-click opens the selected row. Right-click offers **Show DDL** for the same thing, plus **Copy Name** and **Copy with Signature**. + +| Action | What it does | +|---|---| +| **Copy** | Puts the whole definition on the pasteboard | +| **Export…** | Saves it as a `.sql` file named after the object | +| **Open in Editor** | Opens the same text in a query tab, editable and runnable | +| **Reload** | Fetches the current definition from the server | + +Beside the source sit the properties the engine reports: language, return type, volatility, security, and owner for a routine; timing, events, orientation, and enabled state for a trigger. The labels are the engine's own, so they differ between engines. + +A viewer tab survives a relaunch and refetches when it reopens. + + +A DuckDB macro shows the expression DuckDB stored, not the `CREATE MACRO` text as typed. + + +## When the source is missing + +| Message | What it means | What to do | +|---|---|---| +| Your account is not allowed to read the source of … | MySQL without `SHOW_ROUTINE`, MSSQL with `WITH ENCRYPTION` or without `VIEW DEFINITION`, Oracle without `SELECT_CATALOG_ROLE`, or a Teradata procedure created without SPL retention | Grant the privilege, or ask an account that holds it | +| … no longer exists | The object was dropped after the section was listed | Right-click the section and choose **Refresh** | +| This database cannot show the source of … | A Cassandra trigger is a Java class name with no body | Read the class name from the properties beside the empty source | diff --git a/docs/images/routines-triggers-sidebar-dark.png b/docs/images/routines-triggers-sidebar-dark.png new file mode 100644 index 000000000..ae569d1ba Binary files /dev/null and b/docs/images/routines-triggers-sidebar-dark.png differ diff --git a/docs/images/routines-triggers-sidebar.png b/docs/images/routines-triggers-sidebar.png new file mode 100644 index 000000000..5f3009068 Binary files /dev/null and b/docs/images/routines-triggers-sidebar.png differ diff --git a/project.yml b/project.yml index c9222d846..a513c8ea5 100644 --- a/project.yml +++ b/project.yml @@ -368,6 +368,7 @@ targets: - Plugins/JSONImportPlugin/JSONImportOptionsView.swift - Plugins/JSONImportPlugin/JSONImportParsing.swift - Plugins/JSONImportPlugin/JSONImportPlugin.swift + - Plugins/MSSQLDriverPlugin/MSSQLObjectQueries.swift - Plugins/MSSQLDriverPlugin/MSSQLLoginParameters.swift - Plugins/MSSQLDriverPlugin/MSSQLSSLMapping.swift - Plugins/MQLExportPlugin/MQLExportHelpers.swift @@ -385,8 +386,10 @@ targets: - Plugins/MongoDBDriverPlugin/MongoDBStatementGenerator.swift - Plugins/MongoDBDriverPlugin/MongoDBTimeoutPolicy.swift - Plugins/MongoDBDriverPlugin/MongoStreamProjection.swift + - Plugins/OracleDriverPlugin/OracleObjectQueries.swift - Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift - Plugins/MySQLDriverPlugin/MySQLGeneratedColumnClassification.swift + - Plugins/MySQLDriverPlugin/MySQLObjectQueries.swift - Plugins/MySQLDriverPlugin/MySQLQueryTimeoutStatement.swift - Plugins/MySQLDriverPlugin/MySQLSocketTimeout.swift - Plugins/MySQLDriverPlugin/MySQLStatementClassification.swift @@ -396,6 +399,7 @@ targets: - Plugins/PostgreSQLDriverPlugin/LibPQSSLMapping.swift - Plugins/PostgreSQLDriverPlugin/PostGISSpatialRewrite.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogPresence.swift + - Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLSystemDatabases.swift - Plugins/PostgreSQLDriverPlugin/PostgreSQLTableListingLadder.swift diff --git a/scripts/check-postgres-object-queries.sh b/scripts/check-postgres-object-queries.sh new file mode 100755 index 000000000..f0f2b873d --- /dev/null +++ b/scripts/check-postgres-object-queries.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# +# Run the PostgreSQL routine and trigger catalog queries against a live server and check the +# answers, so a hand-written query cannot drift from what the server actually returns. +# +# The queries in Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift are hand-written and +# nothing at runtime checks them. Three of the things they get right are only visible with real +# overloads in the catalog: +# - one row per routine, not one per pairing of a name with itself +# - a distinct oid per overload, which is what makes the DDL fetch address the right one +# - aggregates excluded, because pg_get_functiondef raises on them and would fail the whole list +# +# Usage: +# scripts/check-postgres-object-queries.sh [database] [schema] +# +# Defaults to the `postgres` database and a scratch schema it creates and drops. + +set -euo pipefail + +DATABASE="${1:-postgres}" +SCHEMA="${2:-tablepro_object_query_check}" + +if ! command -v psql > /dev/null 2>&1; then + echo "psql not found" >&2 + exit 2 +fi + +if ! psql -d "$DATABASE" -Atc 'SELECT 1' > /dev/null 2>&1; then + echo "cannot connect to database '$DATABASE'" >&2 + exit 2 +fi + +cleanup() { + psql -d "$DATABASE" -q -c "DROP SCHEMA IF EXISTS $SCHEMA CASCADE" > /dev/null 2>&1 || true +} +trap cleanup EXIT + +failures=0 + +fail() { + echo "FAIL: $1" >&2 + failures=$((failures + 1)) +} + +psql -d "$DATABASE" -q -v ON_ERROR_STOP=1 > /dev/null << SQL +DROP SCHEMA IF EXISTS $SCHEMA CASCADE; +CREATE SCHEMA $SCHEMA; +CREATE FUNCTION $SCHEMA.transform(a integer) RETURNS integer LANGUAGE sql IMMUTABLE AS \$\$ SELECT \$1 \$\$; +CREATE FUNCTION $SCHEMA.transform(a text) RETURNS integer LANGUAGE sql AS \$\$ SELECT 2 \$\$; +CREATE FUNCTION $SCHEMA.transform(a date, b int) RETURNS integer LANGUAGE sql AS \$\$ SELECT 3 \$\$; +CREATE PROCEDURE $SCHEMA.sync_orders() LANGUAGE plpgsql AS \$\$ BEGIN NULL; END \$\$; +CREATE AGGREGATE $SCHEMA.my_sum(int) (SFUNC = int4pl, STYPE = int); +CREATE TABLE $SCHEMA.orders(id int primary key, total numeric); +CREATE TABLE $SCHEMA.customers(id int primary key); +CREATE FUNCTION $SCHEMA.audit_fn() RETURNS trigger LANGUAGE plpgsql AS \$\$ BEGIN RETURN NEW; END \$\$; +CREATE TRIGGER audit BEFORE INSERT OR UPDATE ON $SCHEMA.orders + FOR EACH ROW WHEN (NEW.total > 0) EXECUTE FUNCTION $SCHEMA.audit_fn(); +CREATE TRIGGER audit AFTER DELETE ON $SCHEMA.customers + FOR EACH STATEMENT EXECUTE FUNCTION $SCHEMA.audit_fn(); +SQL + +ROUTINE_LIST=" +SELECT p.oid::text, p.proname, '(' || pg_catalog.pg_get_function_identity_arguments(p.oid) || ')', p.prokind +FROM pg_catalog.pg_proc p +JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace +JOIN pg_catalog.pg_language l ON l.oid = p.prolang +WHERE n.nspname = '$SCHEMA' + AND p.prokind IN ('f', 'p') + AND NOT EXISTS (SELECT 1 FROM pg_catalog.pg_depend d WHERE d.objid = p.oid AND d.deptype = 'e') +" + +rows=$(psql -d "$DATABASE" -Atc "$ROUTINE_LIST" | wc -l | tr -d ' ') +[ "$rows" = "5" ] || fail "routine list returned $rows rows, expected 5 (3 overloads, 1 procedure, 1 trigger function)" + +transform_rows=$(psql -d "$DATABASE" -Atc "$ROUTINE_LIST AND p.proname = 'transform'" | wc -l | tr -d ' ') +[ "$transform_rows" = "3" ] || fail "three overloads returned $transform_rows rows, expected 3" + +distinct_oids=$(psql -d "$DATABASE" -Atc "SELECT count(DISTINCT p.oid) FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = '$SCHEMA' AND p.proname = 'transform'") +[ "$distinct_oids" = "3" ] || fail "three overloads share $distinct_oids oids, expected 3 distinct" + +distinct_args=$(psql -d "$DATABASE" -Atc "SELECT count(DISTINCT pg_catalog.pg_get_function_identity_arguments(p.oid)) + FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = '$SCHEMA' AND p.proname = 'transform'") +[ "$distinct_args" = "3" ] || fail "three overloads share $distinct_args argument signatures, expected 3 distinct" + +aggregates=$(psql -d "$DATABASE" -Atc "$ROUTINE_LIST AND p.proname = 'my_sum'" | wc -l | tr -d ' ') +[ "$aggregates" = "0" ] || fail "an aggregate reached the routine list; pg_get_functiondef raises on it" + +if psql -d "$DATABASE" -Atc "SELECT pg_catalog.pg_get_functiondef(p.oid) FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = '$SCHEMA' AND p.proname = 'my_sum'" > /dev/null 2>&1; then + echo "note: pg_get_functiondef no longer raises on an aggregate on this server version" +fi + +for oid in $(psql -d "$DATABASE" -Atc "SELECT p.oid FROM pg_catalog.pg_proc p + JOIN pg_catalog.pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = '$SCHEMA' AND p.proname = 'transform' ORDER BY p.oid"); do + args=$(psql -d "$DATABASE" -Atc "SELECT pg_catalog.pg_get_function_identity_arguments($oid)") + definition=$(psql -d "$DATABASE" -Atc "SELECT pg_catalog.pg_get_functiondef($oid::oid)" | head -1) + case "$definition" in + *"($args)"*) ;; + *) fail "pg_get_functiondef($oid) returned a definition for a different overload: $definition" ;; + esac +done + +TRIGGER_LIST=" +SELECT t.tgname, c.relname, + CASE WHEN (t.tgtype & 64) != 0 THEN 'INSTEAD OF' + WHEN (t.tgtype & 2) != 0 THEN 'BEFORE' ELSE 'AFTER' END, + array_to_string(array_remove(ARRAY[ + CASE WHEN (t.tgtype & 4) != 0 THEN 'INSERT' END, + CASE WHEN (t.tgtype & 8) != 0 THEN 'DELETE' END, + CASE WHEN (t.tgtype & 16) != 0 THEN 'UPDATE' END, + CASE WHEN (t.tgtype & 32) != 0 THEN 'TRUNCATE' END], NULL), ' OR '), + CASE WHEN (t.tgtype & 1) != 0 THEN 'ROW' ELSE 'STATEMENT' END +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 n.nspname = '$SCHEMA' AND NOT t.tgisinternal +ORDER BY c.relname, t.tgname +" + +trigger_rows=$(psql -d "$DATABASE" -Atc "$TRIGGER_LIST") +expected=$'audit|customers|AFTER|DELETE|STATEMENT\naudit|orders|BEFORE|INSERT OR UPDATE|ROW' +if [ "$trigger_rows" != "$expected" ]; then + fail "trigger list disagreed" + echo "expected:" >&2 + echo "$expected" >&2 + echo "got:" >&2 + echo "$trigger_rows" >&2 +fi + +definition=$(psql -d "$DATABASE" -Atc "SELECT pg_catalog.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 n.nspname = '$SCHEMA' AND c.relname = 'orders' AND t.tgname = 'audit'") +case "$definition" in + *"WHEN"*) ;; + *) fail "pg_get_triggerdef dropped the WHEN clause: $definition" ;; +esac + +if [ "$failures" -gt 0 ]; then + echo "$failures check(s) failed" >&2 + exit 1 +fi + +echo "PostgreSQL object catalog queries agree with $(psql -d "$DATABASE" -Atc 'SHOW server_version')"