From c4484ebdff6d89c18243180d56ae76775a804242 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 17:16:33 +0700 Subject: [PATCH 1/2] feat(shortcuts): add Run Query, Open Connection and Add Rows actions to the Mac app --- apple/TablePro/AppDelegate.swift | 3 + .../ChangeTracking/DataChangeManager.swift | 27 +- .../Core/DataWrite/DataWritePlan.swift | 32 ++ .../Commands/ScriptRunQueryCommand.swift | 2 +- .../Core/Scripting/ScriptQueryRunner.swift | 89 +++-- .../Execution/ExecutionAuditLog.swift | 1 + .../Execution/ExternalStatementGate.swift | 18 +- .../Services/Execution/OperationCaller.swift | 1 + .../Providers/OperationConfirming.swift | 2 + .../Core/Shortcuts/AddRowIntents.swift | 116 ++++++ .../Core/Shortcuts/ConnectionEntity.swift | 76 ++++ .../Core/Shortcuts/DatabaseEntity.swift | 48 +++ .../Core/Shortcuts/OpenConnectionIntent.swift | 33 ++ .../Core/Shortcuts/QueryResultFiles.swift | 75 ++++ .../Core/Shortcuts/QueryResultFormat.swift | 22 ++ .../Core/Shortcuts/RunQueryIntent.swift | 109 ++++++ .../Shortcuts/Shared}/IntentDataError.swift | 0 .../Core/Shortcuts/Shared}/PayloadRow.swift | 0 .../Shortcuts/Shared/PayloadRowPlanner.swift | 37 ++ .../Core/Shortcuts/Shared}/RowPayload.swift | 9 +- .../Core/Shortcuts/ShortcutsActionError.swift | 31 ++ .../Core/Shortcuts/ShortcutsCatalog.swift | 115 ++++++ .../Core/Shortcuts/ShortcutsRowWriter.swift | 237 ++++++++++++ .../TablePro/Core/Shortcuts/TableEntity.swift | 53 +++ .../Core/Shortcuts/TableProShortcuts.swift | 64 ++++ .../Models/Query/QueryHistorySource.swift | 17 +- .../TablePro/Resources/Localizable.xcstrings | 192 +++++++++- .../Panes/OptionsPaneView.swift | 4 +- .../TableProMobile/Helpers/RowInserter.swift | 27 +- apple/TableProMobile/project.yml | 2 + .../OperationConfirmationPromptTests.swift | 1 + .../Shortcuts/QueryResultFilesTests.swift | 81 ++++ .../Core/Shortcuts/ShortcutsActionTests.swift | 357 ++++++++++++++++++ .../Shortcuts/ShortcutsPlanningTests.swift | 168 +++++++++ 34 files changed, 1960 insertions(+), 89 deletions(-) create mode 100644 apple/TablePro/Core/Shortcuts/AddRowIntents.swift create mode 100644 apple/TablePro/Core/Shortcuts/ConnectionEntity.swift create mode 100644 apple/TablePro/Core/Shortcuts/DatabaseEntity.swift create mode 100644 apple/TablePro/Core/Shortcuts/OpenConnectionIntent.swift create mode 100644 apple/TablePro/Core/Shortcuts/QueryResultFiles.swift create mode 100644 apple/TablePro/Core/Shortcuts/QueryResultFormat.swift create mode 100644 apple/TablePro/Core/Shortcuts/RunQueryIntent.swift rename apple/{TableProMobile/TableProMobile/Intents => TablePro/Core/Shortcuts/Shared}/IntentDataError.swift (100%) rename apple/{TableProMobile/TableProMobile/Helpers => TablePro/Core/Shortcuts/Shared}/PayloadRow.swift (100%) create mode 100644 apple/TablePro/Core/Shortcuts/Shared/PayloadRowPlanner.swift rename apple/{TableProMobile/TableProMobile/Intents => TablePro/Core/Shortcuts/Shared}/RowPayload.swift (94%) create mode 100644 apple/TablePro/Core/Shortcuts/ShortcutsActionError.swift create mode 100644 apple/TablePro/Core/Shortcuts/ShortcutsCatalog.swift create mode 100644 apple/TablePro/Core/Shortcuts/ShortcutsRowWriter.swift create mode 100644 apple/TablePro/Core/Shortcuts/TableEntity.swift create mode 100644 apple/TablePro/Core/Shortcuts/TableProShortcuts.swift create mode 100644 apple/TableProTests/Core/Shortcuts/QueryResultFilesTests.swift create mode 100644 apple/TableProTests/Core/Shortcuts/ShortcutsActionTests.swift create mode 100644 apple/TableProTests/Core/Shortcuts/ShortcutsPlanningTests.swift diff --git a/apple/TablePro/AppDelegate.swift b/apple/TablePro/AppDelegate.swift index c31516e378..1778ce05e7 100644 --- a/apple/TablePro/AppDelegate.swift +++ b/apple/TablePro/AppDelegate.swift @@ -119,6 +119,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { ) RecentConnectionsRecorder.shared.start() + if #available(macOS 14.0, *) { + ShortcutsParameterRefresher.shared.start() + } LaunchTracer.shared.mark(.didFinishLaunchingEnded) AppLaunchCoordinator.shared.didFinishLaunching() diff --git a/apple/TablePro/Core/ChangeTracking/DataChangeManager.swift b/apple/TablePro/Core/ChangeTracking/DataChangeManager.swift index f1b0a30faa..92a95e3749 100644 --- a/apple/TablePro/Core/ChangeTracking/DataChangeManager.swift +++ b/apple/TablePro/Core/ChangeTracking/DataChangeManager.swift @@ -737,34 +737,13 @@ final class DataChangeManager: ObservableObject, ChangeManaging { insertedRowIDs: Set = [], factory: RowChangeStatementFactory? = nil ) throws -> [DataWriteStep] { - switch try (factory ?? statementFactory()).rowWriteStatements( + let statements = try (factory ?? statementFactory()).rowWriteStatements( for: changes, insertedRowData: insertedRowData, deletedRowIDs: deletedRowIDs, insertedRowIDs: insertedRowIDs - ) { - case .counted(let attributed): - return attributed.map { - DataWriteStep( - kind: .rowWrite, - statement: $0.statement, - expectedRowCount: $0.rowCount, - tableName: tableName, - matchesRowsWithoutKey: primaryKeyColumns.isEmpty && $0.kind != .insert, - refusesSharedKey: $0.refusesSharedKey - ) - } - case .driverWritten(let statements): - return statements.map { - DataWriteStep( - kind: .rowWrite, - statement: $0.statement, - expectedRowCount: $0.keylessRowCount, - tableName: tableName, - matchesRowsWithoutKey: $0.keylessRowCount != nil - ) - } - } + ) + return DataWriteStep.rowWrites(statements, tableName: tableName, primaryKeyColumns: primaryKeyColumns) } func statementFactory() throws -> RowChangeStatementFactory { diff --git a/apple/TablePro/Core/DataWrite/DataWritePlan.swift b/apple/TablePro/Core/DataWrite/DataWritePlan.swift index 5f403569a1..073b0cf0c6 100644 --- a/apple/TablePro/Core/DataWrite/DataWritePlan.swift +++ b/apple/TablePro/Core/DataWrite/DataWritePlan.swift @@ -123,6 +123,38 @@ struct DataWriteStep: Sendable { } } +extension DataWriteStep { + static func rowWrites( + _ statements: RowWriteStatements, + tableName: String, + primaryKeyColumns: [String] + ) -> [DataWriteStep] { + switch statements { + case .counted(let attributed): + return attributed.map { + DataWriteStep( + kind: .rowWrite, + statement: $0.statement, + expectedRowCount: $0.rowCount, + tableName: tableName, + matchesRowsWithoutKey: primaryKeyColumns.isEmpty && $0.kind != .insert, + refusesSharedKey: $0.refusesSharedKey + ) + } + case .driverWritten(let statements): + return statements.map { + DataWriteStep( + kind: .rowWrite, + statement: $0.statement, + expectedRowCount: $0.keylessRowCount, + tableName: tableName, + matchesRowsWithoutKey: $0.keylessRowCount != nil + ) + } + } + } +} + /// The statements a change set produces, beside the record of the rows behind them. struct RowWriteBuild: Sendable { let steps: [DataWriteStep] diff --git a/apple/TablePro/Core/Scripting/Commands/ScriptRunQueryCommand.swift b/apple/TablePro/Core/Scripting/Commands/ScriptRunQueryCommand.swift index d1c5ff4313..8858810e28 100644 --- a/apple/TablePro/Core/Scripting/Commands/ScriptRunQueryCommand.swift +++ b/apple/TablePro/Core/Scripting/Commands/ScriptRunQueryCommand.swift @@ -25,7 +25,7 @@ internal final class ScriptRunQueryCommand: ScriptCommand { schema: optionalString(ScriptingKeys.Parameter.schema), rowLimit: optionalInt(ScriptingKeys.Parameter.rowLimit), timeoutSeconds: optionalInt(ScriptingKeys.Parameter.timeout), - client: sendingApplication + channel: .appleScript(client: sendingApplication) ) return ScriptResultEncoder.encode(try await ScriptQueryRunner.run(request, bridge: bridge)) diff --git a/apple/TablePro/Core/Scripting/ScriptQueryRunner.swift b/apple/TablePro/Core/Scripting/ScriptQueryRunner.swift index 366c581867..ceccd71d74 100644 --- a/apple/TablePro/Core/Scripting/ScriptQueryRunner.swift +++ b/apple/TablePro/Core/Scripting/ScriptQueryRunner.swift @@ -5,16 +5,8 @@ import Foundation -/// Runs one statement for a script, or on SQL Server a whole script, with every gate a script has to clear. -/// -/// A script is an external caller with no token, so the gates it clears are the connection's own: -/// **External Clients** decides whether it may write at all, and Safe Mode decides whether a person -/// has to see the statement first. Both are applied by `ExternalStatementGate`, which is the same -/// code the MCP tools run, so the two surfaces cannot drift apart on what a connection allows. -/// -/// The confirmation names the app that sent the Apple event, taken from the sender pid the kernel -/// stamps on it. A person approving a `DELETE` deserves to know whether it came from a Shortcut they -/// just ran or from something they forgot was open. +// One statement, or on SQL Server a whole script, from AppleScript or a Shortcuts action. Both clear the +// connection's own gates through `ExternalStatementGate`, the code MCP runs: External Clients, then Safe Mode. internal enum ScriptQueryRunner { /// Deliberately not the MCP settings. That row cap and that timeout belong to the MCP server's /// own configuration, and reading them here would make a script's behaviour change when the user @@ -24,6 +16,45 @@ internal enum ScriptQueryRunner { internal static let defaultTimeoutSeconds = 30 internal static let maximumTimeoutSeconds = 600 + internal enum Channel: Sendable, Equatable { + case appleScript(client: String?) + case shortcuts + + internal var caller: OperationCaller { + switch self { + case .appleScript(let client): .appleScript(client: client) + case .shortcuts: .shortcuts + } + } + + internal var historySource: QueryHistorySource { + switch self { + case .appleScript: .script + case .shortcuts: .shortcuts + } + } + + // AppleScript cannot address a Blocked connection, so it hears that none exists. A shortcut picked the + // connection from a list before it was blocked, so it hears why it stopped. + internal func refusal(for access: ExternalAccessLevel?) -> ScriptingError { + switch (self, access) { + case (.shortcuts, .some): + return .refused(String(localized: "External access is disabled for this connection")) + case (.shortcuts, nil): + return .noSuchObject(String(localized: "That connection no longer exists in TablePro.")) + case (.appleScript, _): + return .noSuchObject(String(localized: "No saved connection has that id.")) + } + } + + fileprivate var clientName: String? { + switch self { + case .appleScript(let client): client + case .shortcuts: String(localized: "Shortcuts") + } + } + } + internal struct Request: Sendable { internal let sql: String internal let connectionId: UUID @@ -31,7 +62,7 @@ internal enum ScriptQueryRunner { internal let schema: String? internal let rowLimit: Int? internal let timeoutSeconds: Int? - internal let client: String? + internal let channel: Channel } /// What a script may ask of the execution gate: to write, and to drop once a person confirms it. It is never @@ -44,17 +75,11 @@ internal enum ScriptQueryRunner { internal static func run( _ request: Request, bridge: DatabaseAccessBridge, - history: QueryHistoryRecording = QueryHistoryManager.shared + history: QueryHistoryRecording = QueryHistoryManager.shared, + gate: any ExecutionGate = ExecutionGateProvider.shared ) async throws -> DatabaseAccessBridge.ScriptOutcome { let snapshot = try await MainActor.run { () throws -> ExternalConnectionPolicySnapshot in - /// Asked here as well as at the object model, so the rule holds even if some later - /// command hands this a connection id it did not resolve through `connections()`. - guard ScriptingSnapshot.isVisibleToScripts(connectionId: request.connectionId) else { - throw ScriptingError.noSuchObject( - String(localized: "No saved connection has that id.") - ) - } - return try ExternalConnectionPolicySnapshot.resolve(connectionId: request.connectionId) + try visibleSnapshot(connectionId: request.connectionId, channel: request.channel) } let capabilities = Self.capabilities(on: snapshot.databaseType) @@ -86,9 +111,13 @@ internal enum ScriptQueryRunner { sql: request.sql, connectionId: request.connectionId, databaseType: snapshot.databaseType, - caller: .appleScript(client: request.client), + caller: request.channel.caller, capabilities: capabilities, - operationDescription: confirmationTitle(client: request.client, connection: snapshot.connectionName) + operationDescription: confirmationTitle( + client: request.channel.clientName, + connection: snapshot.connectionName + ), + gate: gate ) let rowLimit = (request.rowLimit ?? defaultRowLimit).clamped(to: 1...maximumRowLimit) @@ -146,6 +175,20 @@ internal enum ScriptQueryRunner { } } + // Asked here as well as at each caller's own listing, so the rule holds for a connection id that did not come + // from one. + @MainActor + internal static func visibleSnapshot( + connectionId: UUID, + channel: Channel + ) throws -> ExternalConnectionPolicySnapshot { + guard ScriptingSnapshot.isVisibleToScripts(connectionId: connectionId) else { + let stored = ConnectionStorage.shared.loadConnections().first { $0.id == connectionId } + throw channel.refusal(for: stored?.externalAccess) + } + return try ExternalConnectionPolicySnapshot.resolve(connectionId: connectionId) + } + private static func confirmationTitle(client: String?, connection: String) -> String { guard let client, !client.isEmpty else { return String(format: String(localized: "A script wants to run a query on \"%@\""), connection) @@ -175,7 +218,7 @@ internal enum ScriptQueryRunner { databaseName: scope.database, databaseType: databaseType, schemaName: scope.schema, - source: .script, + source: request.channel.historySource, executionTime: elapsed, rowCount: rowCount, wasSuccessful: error == nil, diff --git a/apple/TablePro/Core/Services/Execution/ExecutionAuditLog.swift b/apple/TablePro/Core/Services/Execution/ExecutionAuditLog.swift index f5707f3d87..15a906ccee 100644 --- a/apple/TablePro/Core/Services/Execution/ExecutionAuditLog.swift +++ b/apple/TablePro/Core/Services/Execution/ExecutionAuditLog.swift @@ -81,6 +81,7 @@ internal actor ExecutionAuditLog: ExecutionAuditLogging { case .mcpClient: "mcpClient" case .aiAssistant: "aiAssistant" case .appleScript: "appleScript" + case .shortcuts: "shortcuts" case .importPipeline: "importPipeline" case .backgroundMaintenance: "backgroundMaintenance" } diff --git a/apple/TablePro/Core/Services/Execution/ExternalStatementGate.swift b/apple/TablePro/Core/Services/Execution/ExternalStatementGate.swift index f620fdbc9f..f2e3d40a3a 100644 --- a/apple/TablePro/Core/Services/Execution/ExternalStatementGate.swift +++ b/apple/TablePro/Core/Services/Execution/ExternalStatementGate.swift @@ -94,10 +94,8 @@ internal enum ExternalStatementGate { ) } - if classification.tier != .safe, statement.externalAccess != .readWrite { - throw ExternalStatementGateError.denied( - String(localized: "This connection is read only for external clients.") - ) + if classification.tier != .safe { + try refuseWrite(externalAccess: statement.externalAccess) } if classification.tier == .destructive, !statement.allowsDestructive { @@ -110,6 +108,15 @@ internal enum ExternalStatementGate { return classification } + // For a caller that writes by construction, such as adding rows, whose statements a classifier may not read as + // writes on every engine. + internal static func refuseWrite(externalAccess: ExternalAccessLevel) throws { + guard externalAccess != .readWrite else { return } + throw ExternalStatementGateError.denied( + String(localized: "This connection is read only for external clients.") + ) + } + /// Whether a caller that takes scripts may send several statements in one call to this engine. /// /// Only where a script is cut into batches at `GO` lines, as on SQL Server. There the server runs whatever one @@ -156,6 +163,7 @@ internal enum ExternalStatementGate { caller: OperationCaller, capabilities: CallerCapabilities, operationDescription: String, + kind: OperationKind? = nil, gate: any ExecutionGate = ExecutionGateProvider.shared ) async throws { let decision = await gate.authorize( @@ -163,7 +171,7 @@ internal enum ExternalStatementGate { connectionId: connectionId, databaseType: databaseType, sql: sql, - kind: OperationKind.from(QueryClassifier.classifyTier(sql, databaseType: databaseType)), + kind: kind ?? OperationKind.from(QueryClassifier.classifyTier(sql, databaseType: databaseType)), caller: caller, capabilities: capabilities, operationDescription: operationDescription diff --git a/apple/TablePro/Core/Services/Execution/OperationCaller.swift b/apple/TablePro/Core/Services/Execution/OperationCaller.swift index f604f36940..012700f2ec 100644 --- a/apple/TablePro/Core/Services/Execution/OperationCaller.swift +++ b/apple/TablePro/Core/Services/Execution/OperationCaller.swift @@ -12,6 +12,7 @@ internal enum OperationCaller: Sendable, Equatable { /// An Apple event from another app. The name is the sending application's, taken from the pid /// the kernel stamps on the event rather than from anything the script says about itself. case appleScript(client: String?) + case shortcuts case importPipeline case backgroundMaintenance } diff --git a/apple/TablePro/Core/Services/Execution/Providers/OperationConfirming.swift b/apple/TablePro/Core/Services/Execution/Providers/OperationConfirming.swift index 49a008322b..a9b20f2aa9 100644 --- a/apple/TablePro/Core/Services/Execution/Providers/OperationConfirming.swift +++ b/apple/TablePro/Core/Services/Execution/Providers/OperationConfirming.swift @@ -90,6 +90,8 @@ internal enum OperationConfirmationPrompt { return String(localized: "The AI assistant") case .appleScript(let client): return client ?? String(localized: "Another app") + case .shortcuts: + return String(localized: "Shortcuts") } } } diff --git a/apple/TablePro/Core/Shortcuts/AddRowIntents.swift b/apple/TablePro/Core/Shortcuts/AddRowIntents.swift new file mode 100644 index 0000000000..9e61d2ba98 --- /dev/null +++ b/apple/TablePro/Core/Shortcuts/AddRowIntents.swift @@ -0,0 +1,116 @@ +// +// AddRowIntents.swift +// TablePro +// + +import AppIntents +import Foundation + +@available(macOS 14.0, *) +internal protocol RowInsertingIntent: AppIntent { + var connection: ConnectionEntity { get } + var database: DatabaseEntity? { get } + var table: TableEntity { get } +} + +@available(macOS 14.0, *) +extension RowInsertingIntent { + func insert(rows: [PayloadRow]) async throws -> Int { + try await ShortcutsRowWriter.insert( + ShortcutsRowWriter.Request( + rows: rows, + table: table.name, + connectionId: connection.id, + namespace: database?.id + ) + ) + } + + func resultDialog(affectedRows: Int) -> IntentDialog { + if affectedRows == 1 { + return "Added one row to \(table.name)." + } + return "Added \(affectedRows) rows to \(table.name)." + } +} + +@available(macOS 14.0, *) +internal struct AddRowToTableIntent: RowInsertingIntent { + static let title: LocalizedStringResource = "Add Row to Table" + static let description = IntentDescription( + "Add one row to a table on a saved connection. Provide the row as a JSON object or a CSV row.", + categoryName: "Database", + searchKeywords: ["TablePro", "database", "SQL", "insert", "row", "table"] + ) + static let openAppWhenRun = false + static let authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication + + @Parameter(title: "Connection") + var connection: ConnectionEntity + + @Parameter(title: "Database or Schema") + var database: DatabaseEntity? + + @Parameter(title: "Table") + var table: TableEntity + + @Parameter(title: "Row (JSON or CSV)") + var data: String + + static var parameterSummary: some ParameterSummary { + Summary("Add a row to \(\.$table)") { + \.$connection + \.$database + \.$data + } + } + + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + let rows = try await RowPayload.parseSingle(data: data, file: nil) + let count = try await insert(rows: rows) + return .result(value: count, dialog: resultDialog(affectedRows: count)) + } +} + +@available(macOS 14.0, *) +internal struct AddRowsToTableIntent: RowInsertingIntent { + static let title: LocalizedStringResource = "Add Rows to Table" + static let description = IntentDescription( + "Add multiple rows to a table on a saved connection. Provide the rows as a JSON array, CSV text, or a file.", + categoryName: "Database", + searchKeywords: ["TablePro", "database", "SQL", "insert", "rows", "table", "import"] + ) + static let openAppWhenRun = false + static let authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication + + @Parameter(title: "Connection") + var connection: ConnectionEntity + + @Parameter(title: "Database or Schema") + var database: DatabaseEntity? + + @Parameter(title: "Table") + var table: TableEntity + + @Parameter(title: "Rows (JSON or CSV)") + var data: String? + + // A file type filter needs macOS 15; the payload is read as text whatever the file says it is. + @Parameter(title: "File") + var file: IntentFile? + + static var parameterSummary: some ParameterSummary { + Summary("Add rows to \(\.$table)") { + \.$connection + \.$database + \.$data + \.$file + } + } + + func perform() async throws -> some IntentResult & ReturnsValue & ProvidesDialog { + let rows = try await RowPayload.parse(data: data, file: file) + let count = try await insert(rows: rows) + return .result(value: count, dialog: resultDialog(affectedRows: count)) + } +} diff --git a/apple/TablePro/Core/Shortcuts/ConnectionEntity.swift b/apple/TablePro/Core/Shortcuts/ConnectionEntity.swift new file mode 100644 index 0000000000..0e0cbc67df --- /dev/null +++ b/apple/TablePro/Core/Shortcuts/ConnectionEntity.swift @@ -0,0 +1,76 @@ +// +// ConnectionEntity.swift +// TablePro +// + +import AppIntents +import Foundation + +@available(macOS 14.0, *) +internal struct ConnectionEntity: AppEntity { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Connection") + static let defaultQuery = ConnectionEntityQuery() + + var id: UUID + var name: String + var host: String + var databaseType: String + + var displayRepresentation: DisplayRepresentation { + guard !databaseType.isEmpty else { return DisplayRepresentation(title: "\(name)") } + guard !host.isEmpty else { return DisplayRepresentation(title: "\(name)", subtitle: "\(databaseType)") } + return DisplayRepresentation(title: "\(name)", subtitle: "\(databaseType), \(host)") + } + + init(id: UUID, name: String, host: String, databaseType: String) { + self.id = id + self.name = name + self.host = host + self.databaseType = databaseType + } + + init(listing: ExternalConnectionListing) { + self.init( + id: listing.id, + name: listing.name.isEmpty ? listing.host : listing.name, + host: listing.host, + databaseType: listing.databaseType + ) + } +} + +@available(macOS 14.0, *) +internal struct ConnectionEntityQuery: EntityQuery { + func entities(for identifiers: [UUID]) async throws -> [ConnectionEntity] { + await MainActor.run { + Self.entities( + for: identifiers, + listings: ExternalConnectionDirectory.listings(), + stored: ConnectionStorage.shared.loadConnections() + ) + } + } + + func suggestedEntities() async throws -> [ConnectionEntity] { + await MainActor.run { ExternalConnectionDirectory.listings().map(ConnectionEntity.init(listing:)) } + } + + // A connection blocked after a shortcut picked it still resolves, by name only, so running the shortcut says why + // it stopped instead of asking for a connection again. + static func entities( + for identifiers: [UUID], + listings: [ExternalConnectionListing], + stored: [DatabaseConnection] + ) -> [ConnectionEntity] { + let listed = Dictionary(listings.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + let saved = Dictionary(stored.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + return identifiers.compactMap { id in + if let listing = listed[id] { + return ConnectionEntity(listing: listing) + } + guard let connection = saved[id] else { return nil } + let name = connection.name.isEmpty ? connection.host : connection.name + return ConnectionEntity(id: id, name: name, host: "", databaseType: "") + } + } +} diff --git a/apple/TablePro/Core/Shortcuts/DatabaseEntity.swift b/apple/TablePro/Core/Shortcuts/DatabaseEntity.swift new file mode 100644 index 0000000000..09af6a0c74 --- /dev/null +++ b/apple/TablePro/Core/Shortcuts/DatabaseEntity.swift @@ -0,0 +1,48 @@ +// +// DatabaseEntity.swift +// TablePro +// + +import AppIntents +import Foundation + +@available(macOS 14.0, *) +internal struct DatabaseEntity: AppEntity { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Database or Schema") + static let defaultQuery = DatabaseEntityQuery() + + var id: String + var name: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } +} + +@available(macOS 14.0, *) +internal struct DatabaseEntityQuery: EntityQuery { + @IntentParameterDependency(\.$connection) + var runQuery + + @IntentParameterDependency(\.$connection) + var addRow + + @IntentParameterDependency(\.$connection) + var addRows + + func entities(for identifiers: [String]) async throws -> [DatabaseEntity] { + identifiers.map { DatabaseEntity(id: $0, name: $0) } + } + + // Throws rather than listing nothing: the picker shows the error, so a connection that cannot open does not read + // as a server with no databases. + func suggestedEntities() async throws -> [DatabaseEntity] { + guard let connection = runQuery?.connection ?? addRow?.connection ?? addRows?.connection else { return [] } + do { + return try await ShortcutsCatalog.namespaces(connectionId: connection.id) + .map { DatabaseEntity(id: $0, name: $0) } + } catch { + throw ShortcutsActionError.wrapping(error) + } + } +} diff --git a/apple/TablePro/Core/Shortcuts/OpenConnectionIntent.swift b/apple/TablePro/Core/Shortcuts/OpenConnectionIntent.swift new file mode 100644 index 0000000000..5592a30713 --- /dev/null +++ b/apple/TablePro/Core/Shortcuts/OpenConnectionIntent.swift @@ -0,0 +1,33 @@ +// +// OpenConnectionIntent.swift +// TablePro +// + +import AppIntents +import Foundation + +@available(macOS 14.0, *) +internal struct OpenConnectionIntent: AppIntent { + static let title: LocalizedStringResource = "Open Connection" + static let description = IntentDescription( + "Opens a database connection in TablePro", + categoryName: "Database", + searchKeywords: ["TablePro", "database", "connection", "SQL", "open"] + ) + static let openAppWhenRun = true + + @Parameter(title: "Connection") + var connection: ConnectionEntity + + @MainActor + func perform() async throws -> some IntentResult { + do { + _ = try ScriptQueryRunner.visibleSnapshot(connectionId: connection.id, channel: .shortcuts) + try await TabRouter.shared.route(.openConnection(connection.id)) + } catch { + throw ShortcutsActionError.wrapping(error) + } + AppActivationPolicyController.shared.activate(ignoringOtherApps: true) + return .result() + } +} diff --git a/apple/TablePro/Core/Shortcuts/QueryResultFiles.swift b/apple/TablePro/Core/Shortcuts/QueryResultFiles.swift new file mode 100644 index 0000000000..9758348017 --- /dev/null +++ b/apple/TablePro/Core/Shortcuts/QueryResultFiles.swift @@ -0,0 +1,75 @@ +// +// QueryResultFiles.swift +// TablePro +// + +import AppIntents +import Foundation +import TableProPluginKit +import TableProTabularIO +import UniformTypeIdentifiers + +// Shortcuts reads a JSON file as a Dictionary, so a row handed over as one becomes a dictionary keyed by column in +// the next action. App Intents has no dictionary value of its own. +internal enum QueryResultFiles { + internal static func files(columns: [String], rows: [[PluginCellValue]], format: QueryResultFormat) -> [IntentFile] { + switch format { + case .rows: + return rowObjects(columns: columns, rows: rows).enumerated().map { index, object in + IntentFile(data: Data(object.utf8), filename: "row-\(index + 1).json", type: .json) + } + case .firstValue: + guard let value = firstValue(rows: rows) else { return [] } + return [IntentFile(data: Data(value.utf8), filename: "value.txt", type: .utf8PlainText)] + case .csv: + return [IntentFile(data: Data(csv(columns: columns, rows: rows).utf8), filename: "result.csv", type: .commaSeparatedText)] + case .json: + return [IntentFile(data: Data(jsonArray(columns: columns, rows: rows).utf8), filename: "result.json", type: .json)] + } + } + + internal static func rowObjects(columns: [String], rows: [[PluginCellValue]]) -> [String] { + let keys = keys(for: columns).map(jsonString) + return rows.map { row in + let members = keys.enumerated().map { index, key in + "\(key):\(index < row.count ? jsonValue(row[index]) : "null")" + } + return "{\(members.joined(separator: ","))}" + } + } + + internal static func jsonArray(columns: [String], rows: [[PluginCellValue]]) -> String { + "[\(rowObjects(columns: columns, rows: rows).joined(separator: ","))]" + } + + internal static func csv(columns: [String], rows: [[PluginCellValue]]) -> String { + let writer = DelimitedWriter(dialect: DelimitedDialect(), source: nil) + let lines = [columns] + rows.map { row in row.map(ScriptResultEncoder.text(of:)) } + return lines.map { fields in fields.map(writer.quoted).joined(separator: ",") }.joined(separator: "\n") + "\n" + } + + internal static func firstValue(rows: [[PluginCellValue]]) -> String? { + guard let cell = rows.first?.first else { return nil } + if case .null = cell { return nil } + return ScriptResultEncoder.text(of: cell) + } + + // Two columns of one name would collapse into one key, so a repeat takes a numbered name. + internal static func keys(for columns: [String]) -> [String] { + ImportFieldNaming.uniqueNames(for: columns) { "column \($0 + 1)" } + } + + private static func jsonValue(_ cell: PluginCellValue) -> String { + switch cell { + case .null: "null" + case .text, .bytes: jsonString(ScriptResultEncoder.text(of: cell)) + } + } + + private static func jsonString(_ text: String) -> String { + guard let data = try? JSONSerialization.data(withJSONObject: text, options: [.fragmentsAllowed, .withoutEscapingSlashes]), + let encoded = String(data: data, encoding: .utf8) + else { return "\"\"" } + return encoded + } +} diff --git a/apple/TablePro/Core/Shortcuts/QueryResultFormat.swift b/apple/TablePro/Core/Shortcuts/QueryResultFormat.swift new file mode 100644 index 0000000000..616c4bf65c --- /dev/null +++ b/apple/TablePro/Core/Shortcuts/QueryResultFormat.swift @@ -0,0 +1,22 @@ +// +// QueryResultFormat.swift +// TablePro +// + +import AppIntents +import Foundation + +internal enum QueryResultFormat: String, AppEnum { + case rows + case firstValue + case csv + case json + + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Result Format") + static let caseDisplayRepresentations: [QueryResultFormat: DisplayRepresentation] = [ + .rows: DisplayRepresentation(title: "Rows", subtitle: "A dictionary for each row, keyed by column"), + .firstValue: DisplayRepresentation(title: "First Value", subtitle: "The first column of the first row"), + .csv: DisplayRepresentation(title: "CSV"), + .json: DisplayRepresentation(title: "JSON") + ] +} diff --git a/apple/TablePro/Core/Shortcuts/RunQueryIntent.swift b/apple/TablePro/Core/Shortcuts/RunQueryIntent.swift new file mode 100644 index 0000000000..f267c49af5 --- /dev/null +++ b/apple/TablePro/Core/Shortcuts/RunQueryIntent.swift @@ -0,0 +1,109 @@ +// +// RunQueryIntent.swift +// TablePro +// + +import AppIntents +import Foundation + +@available(macOS 14.0, *) +internal struct RunQueryIntent: AppIntent { + static let title: LocalizedStringResource = "Run Query" + static let description = IntentDescription( + "Run a SQL statement on a saved connection and pass the result to the next action.", + categoryName: "Database", + searchKeywords: ["TablePro", "database", "SQL", "query", "select"] + ) + static let openAppWhenRun = false + static let authenticationPolicy: IntentAuthenticationPolicy = .requiresAuthentication + + @Parameter( + title: "Query", + inputOptions: String.IntentInputOptions( + capitalizationType: .none, + multiline: true, + autocorrect: false, + smartQuotes: false, + smartDashes: false + ) + ) + var query: String + + @Parameter(title: "Connection") + var connection: ConnectionEntity + + @Parameter(title: "Database or Schema") + var database: DatabaseEntity? + + @Parameter(title: "Result", default: .rows) + var format: QueryResultFormat + + @Parameter( + title: "Row Limit", + default: 500, + controlStyle: .field, + inclusiveRange: (1, 10_000) + ) + var rowLimit: Int + + static var parameterSummary: some ParameterSummary { + Summary("Run \(\.$query) on \(\.$connection)") { + \.$database + \.$format + \.$rowLimit + } + } + + func perform() async throws -> some IntentResult & ReturnsValue<[IntentFile]> { + let outcome = try await ShortcutsQuery.run( + sql: query, + connectionId: connection.id, + namespace: database?.id, + rowLimit: rowLimit + ) + return .result( + value: QueryResultFiles.files( + columns: outcome.primary.columns, + rows: outcome.primary.rows, + format: format + ) + ) + } +} + +internal enum ShortcutsQuery { + internal static func run( + sql: String, + connectionId: UUID, + namespace: String?, + rowLimit: Int?, + bridge: DatabaseAccessBridge = DatabaseAccessBridge(), + history: QueryHistoryRecording = QueryHistoryManager.shared, + gate: any ExecutionGate = ExecutionGateProvider.shared + ) async throws -> DatabaseAccessBridge.ScriptOutcome { + let placement = await MainActor.run { () -> ShortcutsCatalog.Placement in + guard let stored = ConnectionStorage.shared.loadConnections().first(where: { $0.id == connectionId }) else { + return ShortcutsCatalog.Placement(database: nil, schema: nil) + } + return ShortcutsCatalog.placement(of: namespace, databaseType: stored.type) + } + do { + return try await ScriptQueryRunner.run( + ScriptQueryRunner.Request( + sql: sql, + connectionId: connectionId, + database: placement.database, + schema: placement.schema, + rowLimit: rowLimit, + timeoutSeconds: nil, + channel: .shortcuts + ), + bridge: bridge, + history: history, + gate: gate + ) + } catch { + throw ShortcutsActionError.wrapping(error) + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Intents/IntentDataError.swift b/apple/TablePro/Core/Shortcuts/Shared/IntentDataError.swift similarity index 100% rename from apple/TableProMobile/TableProMobile/Intents/IntentDataError.swift rename to apple/TablePro/Core/Shortcuts/Shared/IntentDataError.swift diff --git a/apple/TableProMobile/TableProMobile/Helpers/PayloadRow.swift b/apple/TablePro/Core/Shortcuts/Shared/PayloadRow.swift similarity index 100% rename from apple/TableProMobile/TableProMobile/Helpers/PayloadRow.swift rename to apple/TablePro/Core/Shortcuts/Shared/PayloadRow.swift diff --git a/apple/TablePro/Core/Shortcuts/Shared/PayloadRowPlanner.swift b/apple/TablePro/Core/Shortcuts/Shared/PayloadRowPlanner.swift new file mode 100644 index 0000000000..47d4e2da17 --- /dev/null +++ b/apple/TablePro/Core/Shortcuts/Shared/PayloadRowPlanner.swift @@ -0,0 +1,37 @@ +import Foundation + +nonisolated struct PayloadColumn: Equatable, Sendable { + let name: String + let isPrimaryKey: Bool + let isServerOwned: Bool +} + +nonisolated enum PayloadRowPlanner { + // The defaults suit a Shortcuts payload, which cannot say "leave this column out" except by leaving it empty. + static func values( + table: String, + columns: [PayloadColumn], + rows: [PayloadRow], + allowAllDefaults: Bool = false, + dropsEmptyPrimaryKey: Bool = true + ) throws -> [[(column: String, value: String?)]] { + guard !columns.isEmpty else { throw IntentDataError.noColumns(table) } + let columnNames = Set(columns.map(\.name)) + let primaryKeys = Set(columns.filter(\.isPrimaryKey).map(\.name)) + + return try rows.compactMap { row in + let unknown = row.keys.filter { !columnNames.contains($0) } + guard unknown.isEmpty else { throw IntentDataError.unknownColumns(unknown.sorted(), table) } + + var values: [(column: String, value: String?)] = [] + for column in columns { + guard !column.isServerOwned else { continue } + guard let value = row.value(for: column.name) else { continue } + if dropsEmptyPrimaryKey, primaryKeys.contains(column.name), value.isEmptyOrNull { continue } + values.append((column: column.name, value: value.sqlValue)) + } + guard !values.isEmpty || allowAllDefaults else { return nil } + return values + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Intents/RowPayload.swift b/apple/TablePro/Core/Shortcuts/Shared/RowPayload.swift similarity index 94% rename from apple/TableProMobile/TableProMobile/Intents/RowPayload.swift rename to apple/TablePro/Core/Shortcuts/Shared/RowPayload.swift index 4472cc9bfc..2ff528de24 100644 --- a/apple/TableProMobile/TableProMobile/Intents/RowPayload.swift +++ b/apple/TablePro/Core/Shortcuts/Shared/RowPayload.swift @@ -28,7 +28,7 @@ nonisolated enum RowPayload { private static func rawContent(data: String?, file: IntentFile?) async throws -> String { if let file { - return try text(of: try await file.data(contentType: .data)) + return try text(of: try await contents(of: file)) } if let data, !data.isEmpty { return data @@ -36,6 +36,13 @@ nonisolated enum RowPayload { throw IntentDataError.emptyPayload } + private static func contents(of file: IntentFile) async throws -> Data { + #if os(macOS) + guard #available(macOS 15.0, *) else { return file.data } + #endif + return try await file.data(contentType: .data) + } + static func text(of fileData: Data) throws -> String { let sniff = TabularEncodingDetector.sniff(fileData) guard sniff.encoding != .utf8 else { diff --git a/apple/TablePro/Core/Shortcuts/ShortcutsActionError.swift b/apple/TablePro/Core/Shortcuts/ShortcutsActionError.swift new file mode 100644 index 0000000000..b86ea6754f --- /dev/null +++ b/apple/TablePro/Core/Shortcuts/ShortcutsActionError.swift @@ -0,0 +1,31 @@ +// +// ShortcutsActionError.swift +// TablePro +// + +import AppIntents +import Foundation + +// The reason a Shortcuts action stopped, already localized: a refusal keeps the gate's own wording, and a driver's +// error is redacted the way a script's is. +internal struct ShortcutsActionError: LocalizedError, Equatable, CustomLocalizedStringResourceConvertible { + internal let message: String + + internal var errorDescription: String? { message } + + internal var localizedStringResource: LocalizedStringResource { + LocalizedStringResource(String.LocalizationValue(message)) + } + + internal static func wrapping(_ error: Error, secrets: [String] = []) -> Error { + switch error { + case is ShortcutsActionError, is IntentDataError: + return error + default: + let scripting = ScriptingError.from(error, secrets: secrets) + return ShortcutsActionError( + message: scripting.errorDescription ?? String(localized: "The operation failed.") + ) + } + } +} diff --git a/apple/TablePro/Core/Shortcuts/ShortcutsCatalog.swift b/apple/TablePro/Core/Shortcuts/ShortcutsCatalog.swift new file mode 100644 index 0000000000..473c3ceab6 --- /dev/null +++ b/apple/TablePro/Core/Shortcuts/ShortcutsCatalog.swift @@ -0,0 +1,115 @@ +// +// ShortcutsCatalog.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +// What the Shortcuts pickers list, read through the gates a statement from a shortcut clears. +internal enum ShortcutsCatalog { + internal enum NamespaceKind: Equatable, Sendable { + case schema + case database + case neither + } + + internal struct Placement: Equatable, Sendable { + internal let database: String? + internal let schema: String? + } + + // "Database or Schema" lists schemas where the engine groups tables by schema, databases where it can switch + // database, and nothing on an engine with neither. + @MainActor + internal static func namespaceKind(of databaseType: DatabaseType) -> NamespaceKind { + switch PluginManager.shared.databaseGroupingStrategy(for: databaseType) { + case .bySchema, .hierarchicalSchema: + return .schema + default: + return PluginManager.shared.supportsDatabaseSwitching(for: databaseType) ? .database : .neither + } + } + + internal static func placement(of namespace: String?, kind: NamespaceKind) -> Placement { + guard let namespace, !namespace.isEmpty else { return Placement(database: nil, schema: nil) } + switch kind { + case .schema: return Placement(database: nil, schema: namespace) + case .database: return Placement(database: namespace, schema: nil) + case .neither: return Placement(database: nil, schema: nil) + } + } + + @MainActor + internal static func placement(of namespace: String?, databaseType: DatabaseType) -> Placement { + placement(of: namespace, kind: namespaceKind(of: databaseType)) + } + + internal static func namespaces( + connectionId: UUID, + bridge: DatabaseAccessBridge = DatabaseAccessBridge() + ) async throws -> [String] { + try await whileConnected(connectionId, bridge: bridge) { databaseType in + switch await MainActor.run(body: { namespaceKind(of: databaseType) }) { + case .neither: + return [] + case .schema: + let scope = try await bridge.resolveScope(connectionId: connectionId, database: nil, schema: nil) + let schemas = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in + try await driver.fetchSchemas() + } + return schemas.sorted() + case .database: + let (driver, _) = try await bridge.resolveDriver(connectionId) + let databases = try await DatabaseManager.shared.trackOperation(sessionId: connectionId) { + try await driver.fetchDatabases() + } + return databases.sorted() + } + } + } + + internal static func tables( + connectionId: UUID, + namespace: String?, + bridge: DatabaseAccessBridge = DatabaseAccessBridge() + ) async throws -> [String] { + try await whileConnected(connectionId, bridge: bridge) { databaseType in + let target = await MainActor.run { placement(of: namespace, databaseType: databaseType) } + let scope = try await bridge.resolveScope( + connectionId: connectionId, + database: target.database, + schema: target.schema + ) + let tables = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in + try await driver.fetchTables(schema: scope.schema) + } + return insertableTableNames(tables) + } + } + + internal static func insertableTableNames(_ tables: [TableInfo]) -> [String] { + let skipped: Set = [.materializedView, .systemTable, .sequence] + let names = tables.filter { !skipped.contains($0.type) }.map(\.name) + return Array(Set(names)).sorted { $0.localizedStandardCompare($1) == .orderedAscending } + } + + // Connects the way a statement from a shortcut does, and keeps the connection's host, user and database out of + // any error the picker shows. + private static func whileConnected( + _ connectionId: UUID, + bridge: DatabaseAccessBridge, + _ body: (DatabaseType) async throws -> Value + ) async throws -> Value { + let snapshot = try await MainActor.run { + try ScriptQueryRunner.visibleSnapshot(connectionId: connectionId, channel: .shortcuts) + } + do { + try await ScriptConnectGate.authorizeConnect(connectionId: connectionId) + let databaseType = try await bridge.ensureConnected(connectionId) + return try await body(databaseType) + } catch { + throw ShortcutsActionError.wrapping(error, secrets: snapshot.redactionSecrets) + } + } +} diff --git a/apple/TablePro/Core/Shortcuts/ShortcutsRowWriter.swift b/apple/TablePro/Core/Shortcuts/ShortcutsRowWriter.swift new file mode 100644 index 0000000000..076621721c --- /dev/null +++ b/apple/TablePro/Core/Shortcuts/ShortcutsRowWriter.swift @@ -0,0 +1,237 @@ +// +// ShortcutsRowWriter.swift +// TablePro +// + +import Combine +import Foundation +import TableProPluginKit + +// Add Row and Add Rows clear the gates a statement from a shortcut clears, then write through the grid's own insert +// path, so a driver that writes its own rows writes these too, in one transaction of the app's own. +internal enum ShortcutsRowWriter { + internal struct Request: Sendable { + internal let rows: [PayloadRow] + internal let table: String + internal let connectionId: UUID + internal let namespace: String? + } + + internal static func insert( + _ request: Request, + bridge: DatabaseAccessBridge = DatabaseAccessBridge(), + history: QueryHistoryRecording = QueryHistoryManager.shared, + gate: any ExecutionGate = ExecutionGateProvider.shared + ) async throws -> Int { + let snapshot = try await MainActor.run { + try ScriptQueryRunner.visibleSnapshot(connectionId: request.connectionId, channel: .shortcuts) + } + do { + return try await insert(request, snapshot: snapshot, bridge: bridge, history: history, gate: gate) + } catch { + throw ShortcutsActionError.wrapping(error, secrets: snapshot.redactionSecrets) + } + } + + private static func insert( + _ request: Request, + snapshot: ExternalConnectionPolicySnapshot, + bridge: DatabaseAccessBridge, + history: QueryHistoryRecording, + gate: any ExecutionGate + ) async throws -> Int { + let databaseType = snapshot.databaseType + // Before anything connects or asks for a password. + try ExternalStatementGate.refuseWrite(externalAccess: snapshot.externalAccess) + + try await ScriptConnectGate.authorizeConnect(connectionId: request.connectionId) + try await bridge.ensureConnected(request.connectionId) + let target = await MainActor.run { + ShortcutsCatalog.placement(of: request.namespace, databaseType: databaseType) + } + let scope = try await bridge.resolveScope( + connectionId: request.connectionId, + database: target.database, + schema: target.schema + ) + + // Built on the driver that read the columns: a driver that types values from what it read about the table, + // as MongoDB's does, keeps that reading on the connection it read it on. + let (plan, rowCount) = try await DatabaseManager.shared.withMetadataDriver(scope: scope) { driver in + let columns = try await driver.fetchColumns(table: request.table, schema: scope.schema) + let pluginDriver = driver.queryBuildingPluginDriver + return try await MainActor.run { + try writePlan( + for: request.rows, + table: request.table, + scope: scope, + columns: columns, + databaseType: databaseType, + pluginDriver: pluginDriver + ) + } + } + guard !plan.steps.isEmpty else { throw IntentDataError.noInsertableValues(request.table) } + + // The person approving the write reads every row with its values, as a grid save shows them. + try await ExternalStatementGate.authorizeExecution( + sql: plan.displaySQL, + connectionId: request.connectionId, + databaseType: databaseType, + caller: .shortcuts, + capabilities: [.mayWrite, .mayRunMultiStatement], + operationDescription: confirmationTitle(rows: rowCount, table: request.table), + kind: .writeQuery, + gate: gate + ) + + let route = await MainActor.run { DatabaseManager.shared.schemaChangeRoute(for: scope) } + let ownsConnection = route.isPooled + let started = Date() + let recorded = historyText(of: plan) + func record(rowCount: Int, error: String?) async { + await history.record( + QueryHistoryRecordRequest( + query: recorded, + connectionId: request.connectionId, + databaseName: scope.database, + databaseType: databaseType, + schemaName: scope.schema, + source: .shortcuts, + executionTime: Date().timeIntervalSince(started), + rowCount: rowCount, + wasSuccessful: error == nil, + errorMessage: error + ) + ) + } + + do { + _ = try await DatabaseManager.shared.withScopedDriver( + scope: scope, + route: route, + cancellation: .protectedWrite + ) { driver in + try await refuseHeldTransaction(on: driver, ownsConnection: ownsConnection) + return try await DataWriteExecutor.run(plan, on: driver) + } + } catch let partial as DataWritePartialCommitError { + let reason = ScriptingError.from(partial.underlying, secrets: snapshot.redactionSecrets) + .errorDescription ?? String(localized: "The operation failed.") + await record(rowCount: partial.committed.count, error: reason) + await announceRowsChanged(in: request.table, scope: scope) + throw ShortcutsActionError(message: "\(partial.partialCommitMessage) \(reason)") + } catch { + await record(rowCount: 0, error: ScriptingError.from(error, secrets: snapshot.redactionSecrets).errorDescription) + throw error + } + + await record(rowCount: rowCount, error: nil) + await announceRowsChanged(in: request.table, scope: scope) + return rowCount + } + + // The change set the grid saves for rows a person added, so a driver with its own row writes (an upsert engine + // adding IF NOT EXISTS, a document store) writes these rows its own way. + @MainActor + internal static func writePlan( + for rows: [PayloadRow], + table: String, + scope: DatabaseScope, + columns: [ColumnInfo], + databaseType: DatabaseType, + pluginDriver: (any PluginDatabaseDriver)? + ) throws -> (plan: DataWritePlan, rowCount: Int) { + let serverOwned = Set(columns.filter { $0.isGenerated || $0.identityKind == .always }.map(\.name)) + let planned = try PayloadRowPlanner.values( + table: table, + columns: columns.map { + PayloadColumn(name: $0.name, isPrimaryKey: $0.isPrimaryKey, isServerOwned: serverOwned.contains($0.name)) + }, + rows: rows + ) + guard !planned.isEmpty else { throw IntentDataError.noInsertableValues(table) } + + let names = columns.map(\.name) + let primaryKeys = columns.filter(\.isPrimaryKey).map(\.name) + var changes: [RowChange] = [] + var insertedRowData: [RowID: [PluginCellValue]] = [:] + for (index, values) in planned.enumerated() { + let rowID = RowID.inserted(UUID()) + let byColumn = Dictionary(values.map { ($0.column, $0.value) }, uniquingKeysWith: { first, _ in first }) + insertedRowData[rowID] = names.map { name in + guard let value = byColumn[name] else { return .text(PluginCellValue.defaultMarkerText) } + return value.map(PluginCellValue.text) ?? .null + } + changes.append(RowChange(rowID: rowID, type: .insert, sequence: index)) + } + + let factory = RowChangeStatementFactory( + tableName: table, + schemaName: scope.schema, + columns: names, + primaryKeyColumns: primaryKeys, + generatedColumns: serverOwned, + columnTypeNames: Dictionary(columns.map { ($0.name, $0.dataType) }, uniquingKeysWith: { first, _ in first }), + databaseType: databaseType, + pluginDriver: pluginDriver + ) + let statements: RowWriteStatements + do { + statements = try factory.rowWriteStatements( + for: changes, + insertedRowData: insertedRowData, + insertedRowIDs: Set(insertedRowData.keys) + ) + } catch DataWriteError.statementGenerationUnavailable(_) { + throw IntentDataError.unsupportedDatabaseType(databaseType.rawValue) + } catch is SQLDialectError { + throw IntentDataError.unsupportedDatabaseType(databaseType.rawValue) + } + let steps = DataWriteStep.rowWrites(statements, tableName: table, primaryKeyColumns: primaryKeys) + return (DataWritePlan(scope: scope, databaseType: databaseType, steps: steps), planned.count) + } + + // A transaction already open is never the shortcut's to join: on the shared session it is the user's, and on a + // pooled connection a startup command left it. Rows written into it could still be rolled back after the + // shortcut reported them added. + private static func refuseHeldTransaction(on driver: DatabaseDriver, ownsConnection: Bool) async throws { + let owner = WriteTransactionOwner.resolve( + supportsTransactions: driver.supportsTransactions, + sessionState: await driver.heldSessionTransactionState() + ) + guard owner == .session else { return } + guard ownsConnection else { + throw ShortcutsActionError( + message: String( + localized: "A transaction is open on this connection. Commit or roll it back in TablePro, then run the shortcut again." + ) + ) + } + throw ShortcutsActionError( + message: String( + localized: "This connection's startup commands leave a transaction open, so rows added from Shortcuts could not be committed." + ) + ) + } + + @MainActor + private static func announceRowsChanged(in table: String, scope: DatabaseScope) { + AppCommands.shared.objectChanged.send( + DatabaseObjectChange(connectionId: scope.connectionId, scope: scope, name: table, kind: .rows) + ) + } + + private static func confirmationTitle(rows: Int, table: String) -> String { + guard rows != 1 else { + return String(format: String(localized: "Shortcuts wants to add a row to \"%@\""), table) + } + return String(format: String(localized: "Shortcuts wants to add %1$lld rows to \"%2$@\""), rows, table) + } + + // One entry per action: each statement shape once, since every row of a payload shares one or a few. + private static func historyText(of plan: DataWritePlan) -> String { + var seen = Set() + return plan.steps.map(\.statement.sql).filter { seen.insert($0).inserted }.joined(separator: ";\n") + } +} diff --git a/apple/TablePro/Core/Shortcuts/TableEntity.swift b/apple/TablePro/Core/Shortcuts/TableEntity.swift new file mode 100644 index 0000000000..e1804aeb4a --- /dev/null +++ b/apple/TablePro/Core/Shortcuts/TableEntity.swift @@ -0,0 +1,53 @@ +// +// TableEntity.swift +// TablePro +// + +import AppIntents +import Foundation + +@available(macOS 14.0, *) +internal struct TableEntity: AppEntity { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Table") + static let defaultQuery = TableEntityQuery() + + var id: String + var name: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(name)") + } +} + +// A dependency hands back every parameter it lists unwrapped, so one that lists the optional database stays nil +// until a database is picked. SQLite never offers one, hence the connection-only dependencies too. +@available(macOS 14.0, *) +internal struct TableEntityQuery: EntityQuery { + @IntentParameterDependency(\.$connection) + var addRow + + @IntentParameterDependency(\.$connection, \.$database) + var addRowInDatabase + + @IntentParameterDependency(\.$connection) + var addRows + + @IntentParameterDependency(\.$connection, \.$database) + var addRowsInDatabase + + func entities(for identifiers: [String]) async throws -> [TableEntity] { + identifiers.map { TableEntity(id: $0, name: $0) } + } + + func suggestedEntities() async throws -> [TableEntity] { + let scoped = addRowInDatabase.map { (connection: $0.connection, database: $0.database) } + ?? addRowsInDatabase.map { (connection: $0.connection, database: $0.database) } + guard let connection = scoped?.connection ?? addRow?.connection ?? addRows?.connection else { return [] } + do { + return try await ShortcutsCatalog.tables(connectionId: connection.id, namespace: scoped?.database.id) + .map { TableEntity(id: $0, name: $0) } + } catch { + throw ShortcutsActionError.wrapping(error) + } + } +} diff --git a/apple/TablePro/Core/Shortcuts/TableProShortcuts.swift b/apple/TablePro/Core/Shortcuts/TableProShortcuts.swift new file mode 100644 index 0000000000..873820dbaa --- /dev/null +++ b/apple/TablePro/Core/Shortcuts/TableProShortcuts.swift @@ -0,0 +1,64 @@ +// +// TableProShortcuts.swift +// TablePro +// + +import AppIntents +import Combine +import Foundation + +@available(macOS 14.0, *) +internal struct TableProShortcuts: AppShortcutsProvider { + static var appShortcuts: [AppShortcut] { + AppShortcut( + intent: OpenConnectionIntent(), + phrases: [ + "Open \(\.$connection) in \(.applicationName)", + "Connect to \(\.$connection) in \(.applicationName)" + ], + shortTitle: "Open Connection", + systemImageName: "server.rack" + ) + AppShortcut( + intent: RunQueryIntent(), + phrases: [ + "Run a query in \(.applicationName)" + ], + shortTitle: "Run Query", + systemImageName: "play.rectangle" + ) + AppShortcut( + intent: AddRowToTableIntent(), + phrases: [ + "Add a row in \(.applicationName)" + ], + shortTitle: "Add Row to Table", + systemImageName: "plus.rectangle.on.folder" + ) + AppShortcut( + intent: AddRowsToTableIntent(), + phrases: [ + "Add rows in \(.applicationName)" + ], + shortTitle: "Add Rows to Table", + systemImageName: "rectangle.stack.badge.plus" + ) + } +} + +// Siri's "Open " phrases read the connection names, so they follow every change to the list. +@available(macOS 14.0, *) +@MainActor +internal final class ShortcutsParameterRefresher { + internal static let shared = ShortcutsParameterRefresher() + + private var cancellable: AnyCancellable? + + internal func start(appEvents: AppEvents = .shared) { + guard cancellable == nil else { return } + TableProShortcuts.updateAppShortcutParameters() + cancellable = appEvents.connectionUpdated + .debounce(for: .seconds(1), scheduler: RunLoop.main) + .sink { _ in TableProShortcuts.updateAppShortcutParameters() } + } +} diff --git a/apple/TablePro/Models/Query/QueryHistorySource.swift b/apple/TablePro/Models/Query/QueryHistorySource.swift index 1ee82d9cbd..babe66469f 100644 --- a/apple/TablePro/Models/Query/QueryHistorySource.swift +++ b/apple/TablePro/Models/Query/QueryHistorySource.swift @@ -9,6 +9,7 @@ enum QueryHistorySource: String, Codable, CaseIterable, Sendable, Identifiable { case dataImport = "import" case mcp case script + case shortcuts var id: String { rawValue } @@ -22,6 +23,7 @@ enum QueryHistorySource: String, Codable, CaseIterable, Sendable, Identifiable { case .dataImport: return String(localized: "Imports") case .mcp: return String(localized: "AI and MCP") case .script: return String(localized: "AppleScript") + case .shortcuts: return String(localized: "Shortcuts") } } @@ -35,22 +37,19 @@ enum QueryHistorySource: String, Codable, CaseIterable, Sendable, Identifiable { case .dataImport: return "square.and.arrow.down" case .mcp: return "sparkles" case .script: return "applescript" + case .shortcuts: return "square.2.layers.3d" } } static let userAuthored: Set = [.editor, .explain] - /// The full set as it stood before `script` was added. - /// - /// A stored filter holding exactly these was the user choosing **Everything**, and decoding it - /// verbatim would silently drop the new source: their filter would start hiding scripted queries - /// and the toolbar would change from "Everything" to a count. Widening only this exact set leaves - /// a genuinely custom selection alone. - private static let allBeforeScript: Set = [ - .editor, .explain, .tableBrowse, .rowEdit, .structureDDL, .dataImport, .mcp + // A stored filter equal to an earlier full set was "Everything" and widens to every source added since. + private static let earlierFullSets: [Set] = [ + [.editor, .explain, .tableBrowse, .rowEdit, .structureDDL, .dataImport, .mcp], + [.editor, .explain, .tableBrowse, .rowEdit, .structureDDL, .dataImport, .mcp, .script] ] static func migratingStoredSelection(_ stored: Set) -> Set { - stored == allBeforeScript ? Set(allCases) : stored + earlierFullSets.contains(stored) ? Set(allCases) : stored } } diff --git a/apple/TablePro/Resources/Localizable.xcstrings b/apple/TablePro/Resources/Localizable.xcstrings index d1d10ab57d..060fd16d82 100644 --- a/apple/TablePro/Resources/Localizable.xcstrings +++ b/apple/TablePro/Resources/Localizable.xcstrings @@ -2166,6 +2166,9 @@ } } } + }, + "%@" : { + }, "%@\nPreview tab. Double-click to keep it open." : { "localizations" : { @@ -3057,6 +3060,9 @@ } } } + }, + "%@ connections do not support adding rows from Shortcuts." : { + }, "%@ connections open a server, not a database file, so there is nothing to fetch over SSH." : { "localizations" : { @@ -3432,6 +3438,16 @@ } } }, + "%@ has no column named %@." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ has no column named %2$@." + } + } + } + }, "%@ has no column order of its own to change." : { "localizations" : { "fr" : { @@ -3924,6 +3940,9 @@ } } } + }, + "%@ is read-only, so rows cannot be added." : { + }, "%@ is required" : { "localizations" : { @@ -15168,6 +15187,9 @@ } } } + }, + "A dictionary for each row, keyed by column" : { + }, "A different file already exists at %@." : { "extractionState" : "stale", @@ -16853,6 +16875,9 @@ }, "A transaction is open on this connection, and its uncommitted rows aren't in these counts." : { + }, + "A transaction is open on this connection. Commit or roll it back in TablePro, then run the shortcut again." : { + }, "A tunnel forwards one address, and the node Sentinel names is on the server's own network, so Sentinel mode cannot run through one. Connect to a data node directly, or turn the tunnel off." : { "extractionState" : "manual", @@ -18762,6 +18787,9 @@ } } } + }, + "Add a row to ${table}" : { + }, "Add an AI provider in Settings > AI first." : { "localizations" : { @@ -19678,6 +19706,12 @@ } } } + }, + "Add multiple rows to a table on a saved connection. Provide the rows as a JSON array, CSV text, or a file." : { + + }, + "Add one row to a table on a saved connection. Provide the row as a JSON object or a CSV row." : { + }, "Add Provider" : { "extractionState" : "stale", @@ -19809,6 +19843,15 @@ } } } + }, + "Add Row to Table" : { + + }, + "Add Row to Table expects one row. Use Add Rows to Table for multiple rows." : { + + }, + "Add rows to ${table}" : { + }, "Add rows to it" : { "localizations" : { @@ -19819,6 +19862,9 @@ } } } + }, + "Add Rows to Table" : { + }, "Add Rule" : { "localizations" : { @@ -20093,6 +20139,19 @@ } } } + }, + "Added %lld rows to %@." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Added %1$lld rows to %2$@." + } + } + } + }, + "Added one row to %@." : { + }, "Adding check constraint %@ scans the table and fails if any existing row violates it." : { "localizations" : { @@ -21210,6 +21269,9 @@ } } } + }, + "AI Policy controls in-app AI agents. External Clients controls Raycast, Cursor, Claude Desktop, other MCP clients, AppleScript, and Shortcuts. Effective scope is the minimum of the requesting token's scope and the External Clients level." : { + }, "AI Provider" : { "extractionState" : "stale", @@ -46674,6 +46736,9 @@ } } } + }, + "connection" : { + }, "Connection" : { "localizations" : { @@ -48719,6 +48784,9 @@ } } } + }, + "Controls how external clients (Raycast, Cursor, Claude Desktop, AppleScript, Shortcuts) access this connection. Tokens cannot exceed this level even with full-access scope." : { + }, "Controls how external clients (Raycast, Cursor, Claude Desktop, AppleScript) access this connection. Tokens cannot exceed this level even with full-access scope." : { "localizations" : { @@ -51598,6 +51666,9 @@ } } } + }, + "Could not connect to the database: %@" : { + }, "Could not create %@." : { "localizations" : { @@ -52742,6 +52813,9 @@ } } } + }, + "Could not read the columns of %@." : { + }, "Could not read the file. It may have been deleted or moved." : { "localizations" : { @@ -56998,6 +57072,9 @@ } } } + }, + "database" : { + }, "Database" : { "extractionState" : "manual", @@ -57995,6 +58072,9 @@ } } } + }, + "Database or Schema" : { + }, "Database Schema" : { "extractionState" : "manual", @@ -82178,6 +82258,9 @@ } } } + }, + "First Value" : { + }, "Fish" : { "extractionState" : "manual", @@ -89461,6 +89544,9 @@ } } } + }, + "import" : { + }, "Import" : { "extractionState" : "manual", @@ -92652,6 +92738,9 @@ } } } + }, + "insert" : { + }, "Insert" : { "extractionState" : "manual", @@ -100605,6 +100694,16 @@ } } }, + "Line %lld of the file is not valid %@ text." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "Line %1$lld of the file is not valid %2$@ text." + } + } + } + }, "Line break" : { "extractionState" : "manual", "generatesSymbol" : false, @@ -105363,6 +105462,9 @@ } } } + }, + "Match what I type" : { + }, "Match what I type, otherwise lowercase" : { "extractionState" : "manual", @@ -105375,9 +105477,6 @@ } } } - }, - "Match what I type" : { - }, "Match what I type, otherwise UPPERCASE" : { "extractionState" : "manual", @@ -114279,6 +114378,9 @@ } } } + }, + "No data was provided to add." : { + }, "No database connection" : { "localizations" : { @@ -124230,6 +124332,9 @@ } } } + }, + "open" : { + }, "Open" : { "localizations" : { @@ -126362,6 +126467,9 @@ } } } + }, + "Opens a database connection in TablePro" : { + }, "Opens an AWS Systems Manager port forwarding session through a target instance." : { "localizations" : { @@ -136340,6 +136448,9 @@ } } } + }, + "Provide a JSON object or an array of objects." : { + }, "Providers" : { "localizations" : { @@ -137296,6 +137407,9 @@ } } } + }, + "query" : { + }, "Query" : { "localizations" : { @@ -148160,6 +148274,9 @@ } } } + }, + "Result Format" : { + }, "Result Options" : { "localizations" : { @@ -149740,6 +149857,9 @@ } } } + }, + "row" : { + }, "Row" : { "localizations" : { @@ -149780,6 +149900,9 @@ } } } + }, + "Row (JSON or CSV)" : { + }, "Row %@ already exists. Edit that row, or give the new one another key." : { "extractionState" : "manual", @@ -150384,6 +150507,9 @@ } } } + }, + "Row Limit" : { + }, "Row no longer exists" : { "localizations" : { @@ -150598,6 +150724,9 @@ } } } + }, + "rows" : { + }, "Rows" : { "localizations" : { @@ -150638,6 +150767,9 @@ } } } + }, + "Rows (JSON or CSV)" : { + }, "Rows %lld-%lld" : { "localizations" : { @@ -151453,6 +151585,9 @@ } } } + }, + "Run ${query} on ${connection}" : { + }, "Run a maintenance operation the engine supports, on one table or on the whole database. TablePro generates the statements; the user approves them. Needs tools:write." : { "localizations" : { @@ -151493,6 +151628,9 @@ } } } + }, + "Run a SQL statement on a saved connection and pass the result to the next action." : { + }, "Run All Statements" : { "localizations" : { @@ -151825,6 +151963,9 @@ } } } + }, + "Run Query" : { + }, "Run Script" : { "localizations" : { @@ -157413,6 +157554,9 @@ } } } + }, + "select" : { + }, "Select" : { "localizations" : { @@ -161451,6 +161595,15 @@ } } } + }, + "Shortcuts" : { + + }, + "Shortcuts wants to add %1$lld rows to \"%2$@\"" : { + + }, + "Shortcuts wants to add a row to \"%@\"" : { + }, "Show" : { "localizations" : { @@ -176645,9 +176798,6 @@ } } } - }, - "Table, column and schema names take this case in autocomplete and in Copy as INSERT and UPDATE when the server reads the new spelling as the same name. Other names are quoted as stored." : { - }, "Table-level CHECK constraints" : { "localizations" : { @@ -176658,6 +176808,9 @@ } } } + }, + "Table, column and schema names take this case in autocomplete and in Copy as INSERT and UPDATE when the server reads the new spelling as the same name. Other names are quoted as stored." : { + }, "TablePro" : { "extractionState" : "stale", @@ -179840,6 +179993,9 @@ } } } + }, + "That connection no longer exists in TablePro." : { + }, "That destination is not valid for this engine." : { "extractionState" : "stale", @@ -182813,6 +182969,9 @@ } } } + }, + "The CSV data has no header row." : { + }, "The Dameng connection is closed." : { "extractionState" : "manual", @@ -182877,6 +183036,12 @@ "The data changed after it was read." : { "extractionState" : "manual", "generatesSymbol" : false + }, + "The data could not be read: %@" : { + + }, + "The data has no values to insert into %@." : { + }, "The database “%@” has an open transaction. Closing rolls it back and discards its uncommitted changes." : { "localizations" : { @@ -183954,6 +184119,9 @@ } } } + }, + "The first column of the first row" : { + }, "The first sheet has no header row to map columns from." : { "extractionState" : "manual", @@ -184288,6 +184456,9 @@ } } } + }, + "The JSON array must contain only objects." : { + }, "The JSON is nested more deeply than DynamoDB allows" : { "extractionState" : "manual", @@ -189746,6 +189917,9 @@ } } } + }, + "The text encoding is invalid." : { + }, "The text is not valid JSON." : { "extractionState" : "manual", @@ -192066,6 +192240,9 @@ } } } + }, + "This connection's startup commands leave a transaction open, so rows added from Shortcuts could not be committed." : { + }, "This conversation is too long to continue. Start a new conversation and send your message there." : { "localizations" : { @@ -198724,6 +198901,9 @@ } } } + }, + "Too many rows. Add up to %lld rows at a time." : { + }, "Tool" : { "localizations" : { diff --git a/apple/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift b/apple/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift index 0bd1a5deae..265cca1745 100644 --- a/apple/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift +++ b/apple/TablePro/Views/ConnectionForm/Panes/OptionsPaneView.swift @@ -337,10 +337,10 @@ struct OptionsPaneView: View { } if aiIsEnabled { // swiftlint:disable:next line_length - Text(String(localized: "AI Policy controls in-app AI agents. External Clients controls Raycast, Cursor, Claude Desktop, other MCP clients, and AppleScript. Effective scope is the minimum of the requesting token's scope and the External Clients level.")) + Text(String(localized: "AI Policy controls in-app AI agents. External Clients controls Raycast, Cursor, Claude Desktop, other MCP clients, AppleScript, and Shortcuts. Effective scope is the minimum of the requesting token's scope and the External Clients level.")) } else { // swiftlint:disable:next line_length - Text(String(localized: "Controls how external clients (Raycast, Cursor, Claude Desktop, AppleScript) access this connection. Tokens cannot exceed this level even with full-access scope.")) + Text(String(localized: "Controls how external clients (Raycast, Cursor, Claude Desktop, AppleScript, Shortcuts) access this connection. Tokens cannot exceed this level even with full-access scope.")) } } .font(.caption) diff --git a/apple/TableProMobile/TableProMobile/Helpers/RowInserter.swift b/apple/TableProMobile/TableProMobile/Helpers/RowInserter.swift index d8d4cd74fc..51e30f4422 100644 --- a/apple/TableProMobile/TableProMobile/Helpers/RowInserter.swift +++ b/apple/TableProMobile/TableProMobile/Helpers/RowInserter.swift @@ -62,24 +62,15 @@ nonisolated enum RowInsertPlanner { allowAllDefaults: Bool, dropsEmptyPrimaryKey: Bool ) throws -> [[(column: String, value: String?)]] { - guard !columns.isEmpty else { throw IntentDataError.noColumns(table) } - let columnNames = Set(columns.map(\.name)) - let primaryKeys = Set(columns.filter(\.isPrimaryKey).map(\.name)) - - return try rows.compactMap { row in - let unknown = row.keys.filter { !columnNames.contains($0) } - guard unknown.isEmpty else { throw IntentDataError.unknownColumns(unknown.sorted(), table) } - - var values: [(column: String, value: String?)] = [] - for column in columns { - guard !column.isServerOwned else { continue } - guard let value = row.value(for: column.name) else { continue } - if dropsEmptyPrimaryKey, primaryKeys.contains(column.name), value.isEmptyOrNull { continue } - values.append((column: column.name, value: value.sqlValue)) - } - guard !values.isEmpty || allowAllDefaults else { return nil } - return values - } + try PayloadRowPlanner.values( + table: table, + columns: columns.map { + PayloadColumn(name: $0.name, isPrimaryKey: $0.isPrimaryKey, isServerOwned: $0.isServerOwned) + }, + rows: rows, + allowAllDefaults: allowAllDefaults, + dropsEmptyPrimaryKey: dropsEmptyPrimaryKey + ) } } diff --git a/apple/TableProMobile/project.yml b/apple/TableProMobile/project.yml index 71f79ff7e3..99a3f90c7a 100644 --- a/apple/TableProMobile/project.yml +++ b/apple/TableProMobile/project.yml @@ -102,6 +102,8 @@ targets: - ../TablePro/Core/SSH/LibSSH2ForwardChannel.swift - ../TablePro/Core/SSH/LibSSH2JumpRelay.swift - ../TablePro/Core/SSH/SSHKeepAliveResult.swift + # Shortcuts payload parsing and row planning, shared with the macOS app's App Intents. + - ../TablePro/Core/Shortcuts/Shared # License texts behind the Acknowledgements screen, copied whole so each keeps the # texts/ path Acknowledgements.json names it by. - path: ../TablePro/Resources/ThirdPartyLicenses/texts diff --git a/apple/TableProTests/Core/Services/Execution/OperationConfirmationPromptTests.swift b/apple/TableProTests/Core/Services/Execution/OperationConfirmationPromptTests.swift index 9af1240f9f..5366547a8d 100644 --- a/apple/TableProTests/Core/Services/Execution/OperationConfirmationPromptTests.swift +++ b/apple/TableProTests/Core/Services/Execution/OperationConfirmationPromptTests.swift @@ -84,6 +84,7 @@ struct OperationConfirmationPromptTests { let subtitles = [ OperationCaller.mcpClient(label: "Claude"), .appleScript(client: "Raycast"), + .shortcuts, .aiAssistant(sessionId: nil), .userInterface ].map { caller in diff --git a/apple/TableProTests/Core/Shortcuts/QueryResultFilesTests.swift b/apple/TableProTests/Core/Shortcuts/QueryResultFilesTests.swift new file mode 100644 index 0000000000..9f1fc8ea1f --- /dev/null +++ b/apple/TableProTests/Core/Shortcuts/QueryResultFilesTests.swift @@ -0,0 +1,81 @@ +// +// QueryResultFilesTests.swift +// TableProTests +// + +import AppIntents +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing +import UniformTypeIdentifiers + +struct QueryResultFilesTests { + @Test("Each row becomes a JSON object keyed by column, with NULL as null and bytes as Base64") + func rowsAreObjectsKeyedByColumn() throws { + let objects = QueryResultFiles.rowObjects( + columns: ["id", "note", "blob"], + rows: [[.text("1"), .null, .bytes(Data([0x01, 0x02]))], [.text("2"), .text("say \"hi\""), .null]] + ) + + #expect(objects == [ + #"{"id":"1","note":null,"blob":"AQI="}"#, + #"{"id":"2","note":"say \"hi\"","blob":null}"# + ]) + let decoded = try JSONSerialization.jsonObject(with: Data(objects[1].utf8)) as? [String: Any] + #expect(decoded?["note"] as? String == "say \"hi\"") + } + + @Test("A repeated column name gets a numbered key instead of overwriting the first") + func repeatedColumnsKeepBothValues() { + let objects = QueryResultFiles.rowObjects(columns: ["id", "id"], rows: [[.text("1"), .text("7")]]) + + #expect(objects == [#"{"id":"1","id 2":"7"}"#]) + } + + @Test("The first value is the first column of the first row, and nothing when there is none") + func firstValue() { + #expect(QueryResultFiles.firstValue(rows: [[.text("42"), .text("x")], [.text("9")]]) == "42") + #expect(QueryResultFiles.firstValue(rows: []) == nil) + #expect(QueryResultFiles.firstValue(rows: [[.null]]) == nil) + } + + @Test("CSV has a header row and quotes a field holding a comma, a quote or a line break") + func csvQuotesWhatNeedsIt() { + let csv = QueryResultFiles.csv( + columns: ["name", "note"], + rows: [[.text("Ada"), .text("a,b")], [.text("Grace"), .text("line\nbreak")], [.null, .text("say \"hi\"")]] + ) + + #expect(csv == "name,note\nAda,\"a,b\"\nGrace,\"line\nbreak\"\n,\"say \"\"hi\"\"\"\n") + } + + @Test("CSV text reads back through the payload parser the insert actions use") + func csvRoundTripsThroughThePayloadParser() throws { + let csv = QueryResultFiles.csv(columns: ["name", "note"], rows: [[.text("Ada"), .text("a,b")]]) + + let rows = try RowPayload.parseCSV(csv) + + #expect(rows == [PayloadRow(values: ["name": .text("Ada"), "note": .text("a,b")])]) + } + + @Test("Each format hands Shortcuts the files it reads as that kind of value") + func filesPerFormat() { + let columns = ["count"] + let rows: [[PluginCellValue]] = [[.text("42")]] + + let rowFiles = QueryResultFiles.files(columns: columns, rows: rows, format: .rows) + #expect(rowFiles.map(\.filename) == ["row-1.json"]) + #expect(rowFiles.first?.type == .json) + #expect(rowFiles.first?.data == Data(#"{"count":"42"}"#.utf8)) + + let value = QueryResultFiles.files(columns: columns, rows: rows, format: .firstValue) + #expect(value.first?.data == Data("42".utf8)) + #expect(value.first?.type == .utf8PlainText) + + #expect(QueryResultFiles.files(columns: columns, rows: rows, format: .csv).first?.type == .commaSeparatedText) + #expect(QueryResultFiles.files(columns: columns, rows: rows, format: .json).first?.data == Data(#"[{"count":"42"}]"#.utf8)) + #expect(QueryResultFiles.files(columns: columns, rows: [], format: .rows).isEmpty) + #expect(QueryResultFiles.files(columns: columns, rows: [], format: .json).first?.data == Data("[]".utf8)) + } +} diff --git a/apple/TableProTests/Core/Shortcuts/ShortcutsActionTests.swift b/apple/TableProTests/Core/Shortcuts/ShortcutsActionTests.swift new file mode 100644 index 0000000000..ba25d58077 --- /dev/null +++ b/apple/TableProTests/Core/Shortcuts/ShortcutsActionTests.swift @@ -0,0 +1,357 @@ +// +// ShortcutsActionTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private actor RecordingGate: ExecutionGate { + private(set) var requests: [OperationRequest] = [] + + func authorize(_ request: OperationRequest) async -> OperationDecision { + requests.append(request) + return .authorized( + OperationReceipt( + connectionId: request.connectionId, + kind: request.kind, + effectiveWrite: true, + grantedAt: Date(), + token: UUID() + ) + ) + } +} + +private actor RecordingHistory: QueryHistoryRecording { + private(set) var requests: [QueryHistoryRecordRequest] = [] + + func record(_ request: QueryHistoryRecordRequest) async -> Bool { + requests.append(request) + return true + } +} + +private final class InsertRecordingDriver: PluginDriverStub, @unchecked Sendable { + private let lock = NSLock() + private var log: [String] = [] + let columns: [PluginColumnInfo] + let transactionState: PluginSessionTransactionState + let supportsTransactions: Bool + private let failingInsert: Int? + private var inserts = 0 + + init( + columns: [PluginColumnInfo], + transactionState: PluginSessionTransactionState = .idle, + supportsTransactions: Bool = true, + failingInsert: Int? = nil + ) { + self.columns = columns + self.transactionState = transactionState + self.supportsTransactions = supportsTransactions + self.failingInsert = failingInsert + } + + func sessionTransactionState() async -> PluginSessionTransactionState { transactionState } + + var received: [String] { + lock.withLock { log } + } + + private func record(_ query: String) -> PluginQueryResult { + lock.withLock { log.append(query) } + return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 1, executionTime: 0) + } + + func execute(query: String) async throws -> PluginQueryResult { record(query) } + + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { + let attempt = lock.withLock { () -> Int in + inserts += 1 + return inserts + } + if attempt == failingInsert { + throw DatabaseError.queryFailed("duplicate key") + } + return record(query) + } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { columns } + + func switchDatabase(to database: String) async throws {} +} + +extension GlobalStateSuites { + @MainActor + struct ShortcutsActionTests { + private static func save(_ connection: DatabaseConnection) -> [DatabaseConnection] { + let saved = ConnectionStorage.shared.loadConnections() + ConnectionStorage.shared.saveConnections(saved + [connection]) + return saved + } + + private static func restore(_ saved: [DatabaseConnection], removing connection: DatabaseConnection) { + DatabaseManager.shared.removeSession(for: connection.id) + MetadataConnectionPool.shared.closeAll(connectionId: connection.id) + ConnectionStorage.shared.saveConnections(saved) + } + + private static func connection(_ access: ExternalAccessLevel, type: DatabaseType) -> DatabaseConnection { + var connection = DatabaseConnection(name: "Shop", database: "shop", type: type) + connection.externalAccess = access + return connection + } + + private static func injectQueryDriver(for connection: DatabaseConnection) -> ScriptAnsweringDriver { + let driver = ScriptAnsweringDriver(connection: connection, sendsBatchesWhole: false) + var session = ConnectionSession(connection: connection) + session.driver = driver + DatabaseManager.shared.injectSession(session, for: connection.id) + return driver + } + + @Test("Run Query hands back the rows and files the statement under Shortcuts, asked as Shortcuts") + func runQueryOnAListedConnection() async throws { + let connection = Self.connection(.readOnly, type: .postgresql) + let saved = Self.save(connection) + defer { Self.restore(saved, removing: connection) } + let driver = Self.injectQueryDriver(for: connection) + let gate = RecordingGate() + let history = RecordingHistory() + + let outcome = try await ShortcutsQuery.run( + sql: "SELECT count(*) FROM orders", + connectionId: connection.id, + namespace: nil, + rowLimit: nil, + history: history, + gate: gate + ) + + #expect(driver.sentStatements.count == 1) + #expect(QueryResultFiles.firstValue(rows: outcome.primary.rows) == "1") + #expect(await gate.requests.map(\.caller) == [.shortcuts]) + #expect(await history.requests.map(\.source) == [.shortcuts]) + #expect(await history.requests.map(\.query) == ["SELECT count(*) FROM orders"]) + } + + @Test("Run Query on a Blocked connection fails with the reason and reaches nothing") + func runQueryOnABlockedConnection() async throws { + let connection = Self.connection(.blocked, type: .postgresql) + let saved = Self.save(connection) + defer { Self.restore(saved, removing: connection) } + let driver = Self.injectQueryDriver(for: connection) + let gate = RecordingGate() + let history = RecordingHistory() + + await #expect(throws: ShortcutsActionError(message: "External access is disabled for this connection")) { + try await ShortcutsQuery.run( + sql: "SELECT count(*) FROM orders", + connectionId: connection.id, + namespace: nil, + rowLimit: nil, + history: history, + gate: gate + ) + } + #expect(driver.sentStatements.isEmpty) + #expect(await gate.requests.isEmpty) + #expect(await history.requests.isEmpty) + } + + @Test("Run Query on a deleted connection says it no longer exists") + func runQueryOnADeletedConnection() async throws { + await #expect(throws: ShortcutsActionError(message: "That connection no longer exists in TablePro.")) { + try await ShortcutsQuery.run( + sql: "SELECT 1", + connectionId: UUID(), + namespace: nil, + rowLimit: nil, + history: RecordingHistory(), + gate: RecordingGate() + ) + } + } + + @Test("A write from Run Query on a Read Only connection is refused before the gate is asked") + func runQueryWriteOnReadOnly() async throws { + let connection = Self.connection(.readOnly, type: .postgresql) + let saved = Self.save(connection) + defer { Self.restore(saved, removing: connection) } + let driver = Self.injectQueryDriver(for: connection) + let gate = RecordingGate() + + await #expect(throws: ShortcutsActionError(message: "This connection is read only for external clients.")) { + try await ShortcutsQuery.run( + sql: "DELETE FROM orders WHERE id = 1", + connectionId: connection.id, + namespace: nil, + rowLimit: nil, + history: RecordingHistory(), + gate: gate + ) + } + #expect(driver.sentStatements.isEmpty) + #expect(await gate.requests.isEmpty) + } + + @Test("Add Rows on a Read Only connection is refused before anything connects") + func addRowsOnReadOnly() async throws { + let connection = Self.connection(.readOnly, type: .mysql) + let saved = Self.save(connection) + defer { Self.restore(saved, removing: connection) } + let gate = RecordingGate() + + await #expect(throws: ShortcutsActionError(message: "This connection is read only for external clients.")) { + try await ShortcutsRowWriter.insert( + ShortcutsRowWriter.Request( + rows: [PayloadRow(values: ["name": .text("Ada")])], + table: "orders", + connectionId: connection.id, + namespace: nil + ), + history: RecordingHistory(), + gate: gate + ) + } + #expect(DatabaseManager.shared.activeSessions[connection.id] == nil) + #expect(await gate.requests.isEmpty) + } + + private static let orderColumns = [ + PluginColumnInfo(name: "id", dataType: "int", isNullable: false, isPrimaryKey: true, extra: "auto_increment"), + PluginColumnInfo(name: "name", dataType: "varchar(64)") + ] + + // The session driver sees nothing in these cases; the pooled one stands for the connection of the action's own. + private static func injectPooled( + _ pooled: InsertRecordingDriver, + for connection: DatabaseConnection + ) async throws -> InsertRecordingDriver { + let sessionDriver = InsertRecordingDriver(columns: orderColumns) + var session = ConnectionSession( + connection: connection, + driver: PluginDriverAdapter(connection: connection, pluginDriver: sessionDriver) + ) + session.browseDatabase = "shop" + DatabaseManager.shared.injectSession(session, for: connection.id) + let adapter = PluginDriverAdapter(connection: connection, pluginDriver: pooled) + try await adapter.connect() + MetadataConnectionPool.shared.injectEntry( + adapter, + scope: DatabaseScope(connectionId: connection.id, database: "shop", schema: nil) + ) + return sessionDriver + } + + private static func addTwoRows(to connection: DatabaseConnection) async throws -> Int { + try await ShortcutsRowWriter.insert( + ShortcutsRowWriter.Request( + rows: try RowPayload.parseJSON(#"[{"name":"Ada"},{"name":"Grace"}]"#), + table: "orders", + connectionId: connection.id, + namespace: nil + ), + history: RecordingHistory(), + gate: RecordingGate() + ) + } + + @Test("Add Rows inserts every row in one transaction of its own and files it under Shortcuts") + func addRowsInOneTransaction() async throws { + let connection = Self.connection(.readWrite, type: .mysql) + let saved = Self.save(connection) + defer { Self.restore(saved, removing: connection) } + let pooled = InsertRecordingDriver(columns: Self.orderColumns) + let sessionDriver = try await Self.injectPooled(pooled, for: connection) + let gate = RecordingGate() + let history = RecordingHistory() + let rows = try RowPayload.parseJSON(#"[{"id":"","name":"Ada"},{"name":"Grace"}]"#) + + let inserted = try await ShortcutsRowWriter.insert( + ShortcutsRowWriter.Request(rows: rows, table: "orders", connectionId: connection.id, namespace: nil), + history: history, + gate: gate + ) + + let insert = "INSERT INTO `orders` (`name`) VALUES (?)" + #expect(inserted == 2) + #expect(pooled.received == ["BEGIN", insert, insert, "COMMIT"]) + #expect(sessionDriver.received.isEmpty) + #expect(await gate.requests.map(\.caller) == [.shortcuts]) + let approved = try #require(await gate.requests.first?.sql) + #expect(approved.contains("'Ada'")) + #expect(approved.contains("'Grace'")) + #expect(await history.requests.map(\.source) == [.shortcuts]) + #expect(await history.requests.map(\.query) == [insert]) + #expect(await history.requests.map(\.rowCount) == [2]) + } + + @Test("Add Rows will not nest inside a transaction a startup command left open on its own connection") + func addRowsRefusesAStartupTransaction() async throws { + let connection = Self.connection(.readWrite, type: .mysql) + let saved = Self.save(connection) + defer { Self.restore(saved, removing: connection) } + let pooled = InsertRecordingDriver(columns: Self.orderColumns, transactionState: .inTransaction) + _ = try await Self.injectPooled(pooled, for: connection) + + await #expect(throws: ShortcutsActionError( + message: "This connection's startup commands leave a transaction open, so rows added from Shortcuts could not be committed." + )) { + try await Self.addTwoRows(to: connection) + } + #expect(pooled.received.isEmpty) + } + + @Test("An engine without transactions says how many rows were already written when a later one fails") + func addRowsReportsWhatAnEngineWithoutTransactionsKept() async throws { + let connection = Self.connection(.readWrite, type: .mysql) + let saved = Self.save(connection) + defer { Self.restore(saved, removing: connection) } + let pooled = InsertRecordingDriver(columns: Self.orderColumns, supportsTransactions: false, failingInsert: 2) + _ = try await Self.injectPooled(pooled, for: connection) + + await #expect(throws: ShortcutsActionError( + message: "1 of 2 statements were already written, and MySQL cannot roll them back. duplicate key" + )) { + try await Self.addTwoRows(to: connection) + } + #expect(pooled.received == ["INSERT INTO `orders` (`name`) VALUES (?)"]) + } + + @Test("Add Rows will not join a transaction the user left open on the one connection an engine allows") + func addRowsRefusesTheUsersOpenTransaction() async throws { + let connection = Self.connection(.readWrite, type: .duckdb) + let saved = Self.save(connection) + defer { Self.restore(saved, removing: connection) } + let driver = InsertRecordingDriver( + columns: [PluginColumnInfo(name: "name", dataType: "VARCHAR")], + transactionState: .inTransaction + ) + let session = ConnectionSession( + connection: connection, + driver: PluginDriverAdapter(connection: connection, pluginDriver: driver) + ) + DatabaseManager.shared.injectSession(session, for: connection.id) + + await #expect(throws: ShortcutsActionError( + message: "A transaction is open on this connection. Commit or roll it back in TablePro, then run the shortcut again." + )) { + try await ShortcutsRowWriter.insert( + ShortcutsRowWriter.Request( + rows: [PayloadRow(values: ["name": .text("Ada")])], + table: "orders", + connectionId: connection.id, + namespace: nil + ), + history: RecordingHistory(), + gate: RecordingGate() + ) + } + #expect(driver.received.isEmpty) + } + } +} diff --git a/apple/TableProTests/Core/Shortcuts/ShortcutsPlanningTests.swift b/apple/TableProTests/Core/Shortcuts/ShortcutsPlanningTests.swift new file mode 100644 index 0000000000..247b1572b3 --- /dev/null +++ b/apple/TableProTests/Core/Shortcuts/ShortcutsPlanningTests.swift @@ -0,0 +1,168 @@ +// +// ShortcutsPlanningTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +struct ShortcutsPlanningTests { + @Test("Database or Schema names a schema, a database or nothing, by what the engine has") + func placementFollowsTheNamespaceKind() { + #expect(ShortcutsCatalog.placement(of: "sales", kind: .schema) == .init(database: nil, schema: "sales")) + #expect(ShortcutsCatalog.placement(of: "shop", kind: .database) == .init(database: "shop", schema: nil)) + #expect(ShortcutsCatalog.placement(of: "main", kind: .neither) == .init(database: nil, schema: nil)) + #expect(ShortcutsCatalog.placement(of: nil, kind: .database) == .init(database: nil, schema: nil)) + #expect(ShortcutsCatalog.placement(of: "", kind: .schema) == .init(database: nil, schema: nil)) + } + + @Test("Engines that group by schema list schemas, and one that switches databases lists databases") + @MainActor + func namespaceKindFollowsTheEngine() { + #expect(ShortcutsCatalog.namespaceKind(of: .postgresql) == .schema) + #expect(ShortcutsCatalog.namespaceKind(of: .mysql) == .database) + } + + @Test("The table picker lists what can take a row, once each") + func tablePickerSkipsWhatTakesNoRows() { + let tables = [ + TableInfo(name: "orders", type: .table, rowCount: nil), + TableInfo(name: "active_orders", type: .view, rowCount: nil), + TableInfo(name: "order_totals", type: .materializedView, rowCount: nil), + TableInfo(name: "order_seq", type: .sequence, rowCount: nil), + TableInfo(name: "sqlite_master", type: .systemTable, rowCount: nil), + TableInfo(name: "orders", type: .table, rowCount: nil) + ] + + #expect(ShortcutsCatalog.insertableTableNames(tables) == ["active_orders", "orders"]) + } + + @Test("A Shortcuts channel calls itself Shortcuts, files its history under Shortcuts and says why a connection is refused") + func shortcutsChannel() { + let channel = ScriptQueryRunner.Channel.shortcuts + + #expect(channel.caller == .shortcuts) + #expect(channel.historySource == .shortcuts) + #expect(channel.refusal(for: .blocked).errorDescription == "External access is disabled for this connection") + #expect(channel.refusal(for: nil).errorDescription == "That connection no longer exists in TablePro.") + } + + @Test("An AppleScript channel keeps its source and still cannot tell a Blocked connection from a missing one") + func appleScriptChannel() { + let channel = ScriptQueryRunner.Channel.appleScript(client: "Script Editor") + + #expect(channel.caller == .appleScript(client: "Script Editor")) + #expect(channel.historySource == .script) + #expect(channel.refusal(for: .blocked).errorDescription == "No saved connection has that id.") + #expect(channel.refusal(for: nil).errorDescription == "No saved connection has that id.") + } + + private static let scope = DatabaseScope(connectionId: UUID(), database: "shop", schema: nil) + + @MainActor + private static func writePlan( + _ rows: [PayloadRow], + columns: [ColumnInfo], + type: DatabaseType = .postgresql + ) throws -> (plan: DataWritePlan, rowCount: Int) { + try ShortcutsRowWriter.writePlan( + for: rows, table: "orders", scope: scope, columns: columns, databaseType: type, pluginDriver: nil + ) + } + + @Test("Rows become the grid's parameterized INSERTs, leaving out generated, always-identity and empty key columns") + @MainActor + func insertStatementsSkipServerOwnedColumns() throws { + let columns = [ + ColumnInfo(name: "id", dataType: "integer", isNullable: false, isPrimaryKey: true, identityKind: .byDefault), + ColumnInfo(name: "serial", dataType: "integer", isNullable: false, isPrimaryKey: false, identityKind: .always), + ColumnInfo(name: "total", dataType: "numeric", isNullable: true, isPrimaryKey: false, isGenerated: true), + ColumnInfo(name: "note", dataType: "text", isNullable: true, isPrimaryKey: false) + ] + let rows = [ + PayloadRow(values: ["id": .text(""), "serial": .text("9"), "total": .text("1"), "note": .text("Ada")]), + PayloadRow(values: ["id": .text("7"), "note": .null]) + ] + + let (plan, rowCount) = try Self.writePlan(rows, columns: columns) + + #expect(rowCount == 2) + #expect(plan.steps.map(\.statement.sql) == [ + #"INSERT INTO "orders" ("note") VALUES ($1)"#, + #"INSERT INTO "orders" ("id", "note") VALUES ($1, $2)"# + ]) + #expect(plan.steps.map(\.expectedRowCount) == [1, 1]) + #expect(plan.steps[0].statement.parameters.map { $0 as? String } == ["Ada"]) + #expect(plan.steps[1].statement.parameters.count == 2) + #expect(plan.steps[1].statement.parameters[0] as? String == "7") + #expect(plan.steps[1].statement.parameters[1] == nil) + } + + @Test("A key that names no column refuses the whole payload") + @MainActor + func unknownColumnRefuses() { + let columns = [ColumnInfo(name: "note", dataType: "text", isNullable: true, isPrimaryKey: false)] + + #expect(throws: IntentDataError.unknownColumns(["nope"], "orders")) { + try Self.writePlan([PayloadRow(values: ["nope": .text("x")])], columns: columns) + } + } + + @Test("A table whose columns cannot be read refuses the payload") + @MainActor + func noColumnsRefuses() { + #expect(throws: IntentDataError.noColumns("orders")) { + try Self.writePlan([PayloadRow(values: ["note": .text("x")])], columns: []) + } + } + + @Test("A payload with nothing to write refuses instead of inserting an empty row") + @MainActor + func emptyPayloadRefuses() { + let columns = [ColumnInfo(name: "id", dataType: "integer", isNullable: false, isPrimaryKey: true)] + + #expect(throws: IntentDataError.noInsertableValues("orders")) { + try Self.writePlan([PayloadRow(values: ["id": .text("")])], columns: columns) + } + } + + @Test("Every full source set the app shipped widens to the current one") + func earlierFullHistorySetsWiden() { + let beforeShortcuts: Set = [ + .editor, .explain, .tableBrowse, .rowEdit, .structureDDL, .dataImport, .mcp, .script + ] + + #expect(QueryHistorySource.migratingStoredSelection(beforeShortcuts) == Set(QueryHistorySource.allCases)) + #expect(QueryHistorySource.allCases.contains(.shortcuts)) + #expect(QueryHistorySource.shortcuts.rawValue == "shortcuts") + } +} + +struct ConnectionEntityQueryTests { + @Test("A listed connection resolves whole, a Blocked one by name only, and a deleted one not at all") + func resolvesByIdentifier() { + guard #available(macOS 14.0, *) else { return } + var listed = DatabaseConnection(name: "Prod", host: "db.example.com", database: "shop", type: .postgresql) + listed.externalAccess = .readOnly + var blocked = DatabaseConnection(name: "Payroll", host: "hr.example.com", database: "hr", type: .postgresql) + blocked.externalAccess = .blocked + let listings = ExternalConnectionDirectory.listings( + connections: [listed, blocked], groups: [], tags: [], live: [:], defaultPolicy: .askEachTime + ) + + let entities = ConnectionEntityQuery.entities( + for: [listed.id, blocked.id, UUID()], + listings: listings, + stored: [listed, blocked] + ) + + #expect(entities.map(\.id) == [listed.id, blocked.id]) + #expect(entities.first?.host == "db.example.com") + #expect(entities.last?.name == "Payroll") + #expect(entities.last?.host.isEmpty == true) + #expect(entities.last?.databaseType.isEmpty == true) + #expect(listings.map(\.id) == [listed.id]) + } +} From a17bb482fae6f39ac00b41ef50fa4f193743b518 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 21 Oct 2026 17:17:16 +0700 Subject: [PATCH 2/2] docs(changelog): add the Mac Shortcuts actions fragment --- apple/changelog.d/3753.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 apple/changelog.d/3753.added.md diff --git a/apple/changelog.d/3753.added.md b/apple/changelog.d/3753.added.md new file mode 100644 index 0000000000..8826a41fdb --- /dev/null +++ b/apple/changelog.d/3753.added.md @@ -0,0 +1 @@ +Shortcuts actions on the Mac to open a connection, run a query and add rows to a table.